From 29f1f1d4c24ee76489dae821acc72554f4150aa0 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Fri, 29 May 2026 23:29:02 +0000 Subject: [PATCH 1/2] feat(repo): add 'azldev repo query' dnf passthrough with reachability probing Adds a new 'repo' command group with a 'query' subcommand that auto-discovers Azure Linux RPM sub-repos under one or more --repo-prefix URLs and exec's dnf against the reachable slots. Includes the shared repolayout package (template expansion, prefix normalization, dedup) used by the resolver. - bounded-parallel HEAD/Stat probes via parmap.Map + IOBoundConcurrency - aggregates all probe failures before aborting; per-prefix kept/dropped/failed logging - passes --disablerepo=* --refresh to dnf; one --repofrompath/--enablerepo pair per kept slot - regenerated CLI docs --- docs/user/reference/cli/azldev.md | 1 + docs/user/reference/cli/azldev_repo.md | 41 ++ docs/user/reference/cli/azldev_repo_query.md | 66 +++ internal/app/azldev/cmds/repo/query.go | 423 +++++++++++++++++++ internal/app/azldev/cmds/repo/repo.go | 28 ++ internal/app/azldev/cmds/repo/repo_test.go | 68 +++ internal/repo/repolayout/layout.go | 144 +++++++ internal/repo/repolayout/layout_test.go | 136 ++++++ pkg/app/azldev_cli/azldev.go | 2 + pkg/app/azldev_cli/azldev_test.go | 1 + 10 files changed, 910 insertions(+) create mode 100644 docs/user/reference/cli/azldev_repo.md create mode 100644 docs/user/reference/cli/azldev_repo_query.md create mode 100644 internal/app/azldev/cmds/repo/query.go create mode 100644 internal/app/azldev/cmds/repo/repo.go create mode 100644 internal/app/azldev/cmds/repo/repo_test.go create mode 100644 internal/repo/repolayout/layout.go create mode 100644 internal/repo/repolayout/layout_test.go diff --git a/docs/user/reference/cli/azldev.md b/docs/user/reference/cli/azldev.md index 00c8b4db..a9072dc0 100644 --- a/docs/user/reference/cli/azldev.md +++ b/docs/user/reference/cli/azldev.md @@ -41,5 +41,6 @@ lives), or use -C to point to one. * [azldev image](azldev_image.md) - Manage Azure Linux images * [azldev package](azldev_package.md) - Manage binary package configuration * [azldev project](azldev_project.md) - Manage Azure Linux projects +* [azldev repo](azldev_repo.md) - Inspect and manage RPM repositories * [azldev version](azldev_version.md) - Print the CLI version diff --git a/docs/user/reference/cli/azldev_repo.md b/docs/user/reference/cli/azldev_repo.md new file mode 100644 index 00000000..19b6bf81 --- /dev/null +++ b/docs/user/reference/cli/azldev_repo.md @@ -0,0 +1,41 @@ + + +## azldev repo + +Inspect and manage RPM repositories + +### Synopsis + +Inspect and manage RPM repositories. + +Subcommands operate over upstream RPM repos described by an +rpm-repo-set-template (e.g. the built-in "azl-standard" layout) expanded +under one or more URL prefixes. + +### Options + +``` + -h, --help help for repo +``` + +### Options inherited from parent commands + +``` + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output +``` + +### SEE ALSO + +* [azldev](azldev.md) - 🐧 Azure Linux Dev Tool +* [azldev repo query](azldev_repo_query.md) - Run dnf against auto-discovered Azure Linux RPM repos + diff --git a/docs/user/reference/cli/azldev_repo_query.md b/docs/user/reference/cli/azldev_repo_query.md new file mode 100644 index 00000000..aa1b2d0d --- /dev/null +++ b/docs/user/reference/cli/azldev_repo_query.md @@ -0,0 +1,66 @@ + + +## azldev repo query + +Run dnf against auto-discovered Azure Linux RPM repos + +### Synopsis + +Thin wrapper around dnf that auto-discovers Azure Linux RPM repos under one +or more URL prefixes and then execs into dnf with the resolved repos wired up +via --repofrompath / --enablerepo. + +Each --repo-prefix is expanded against an rpm-repo-set-template +(--template, default "azl-standard") into one sub-repo per template row, fanned +out per --arch where the row's subpath contains $basearch. Unreachable +sub-repos (404 on repodata/repomd.xml) are silently dropped; any other probe +failure aborts the run. + +All positional arguments are passed verbatim to dnf. Use `--` to separate +azldev flags from dnf flags. + +Examples: + # repoquery the standard layout under one prefix + azldev repo query --repo-prefix=https://packages.microsoft.com/azurelinux/3.0/prod -- repoquery --available bash + + # search across two prefixes, skipping debug and source repos + azldev repo query --repo-prefix=URL1 --repo-prefix=URL2 --no-debuginfo --no-srpms -- search 'kernel*' + + # query a local file:// repo + azldev repo query --repo-prefix=file:///srv/azl/dist -- list --available + +``` +azldev repo query [flags] -- +``` + +### Options + +``` + --arch strings comma-separated arches to expand $basearch over (default [x86_64,aarch64]) + -h, --help help for query + --no-debuginfo drop sub-repos whose kind is debug + --no-srpms drop sub-repos whose kind is source + --repo-prefix stringArray layout prefix (http://, https://, or file:// URL); may be repeated + --template string name of the rpm-repo-set-template to expand each --repo-prefix against (default "azl-standard") +``` + +### Options inherited from parent commands + +``` + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output +``` + +### SEE ALSO + +* [azldev repo](azldev_repo.md) - Inspect and manage RPM repositories + diff --git a/internal/app/azldev/cmds/repo/query.go b/internal/app/azldev/cmds/repo/query.go new file mode 100644 index 00000000..597cb050 --- /dev/null +++ b/internal/app/azldev/cmds/repo/query.go @@ -0,0 +1,423 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package repo + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/repo/repolayout" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/parmap" + "github.com/spf13/cobra" +) + +// DnfBinary is the underlying system binary the wrapper invokes. +const DnfBinary = "dnf" + +// probeTimeout caps each per-slot HEAD/stat probe. +const probeTimeout = 30 * time.Second + +// QueryOptions are the CLI flags for `azldev repo query`. +type QueryOptions struct { + RepoPrefixes []string + Template string + Arches []string + NoDebuginfo bool + NoSRPMs bool +} + +func queryOnAppInit(_ *azldev.App, parentCmd *cobra.Command) { + parentCmd.AddCommand(NewQueryCmd()) +} + +// NewQueryCmd constructs the cobra command for `azldev repo query`. +func NewQueryCmd() *cobra.Command { + var options QueryOptions + + cmd := &cobra.Command{ + Use: "query [flags] -- ", + Short: "Run dnf against auto-discovered Azure Linux RPM repos", + Long: `Thin wrapper around dnf that auto-discovers Azure Linux RPM repos under one +or more URL prefixes and then execs into dnf with the resolved repos wired up +via --repofrompath / --enablerepo. + +Each --repo-prefix is expanded against an rpm-repo-set-template +(--template, default "` + repolayout.DefaultTemplateName + `") into one sub-repo per template row, fanned +out per --arch where the row's subpath contains $basearch. Unreachable +sub-repos (404 on repodata/repomd.xml) are silently dropped; any other probe +failure aborts the run. + +All positional arguments are passed verbatim to dnf. Use ` + "`--`" + ` to separate +azldev flags from dnf flags. + +Examples: + # repoquery the standard layout under one prefix + azldev repo query --repo-prefix=https://packages.microsoft.com/azurelinux/4.0/beta -- repoquery --available bash + + # search across two prefixes, skipping debug and source repos + azldev repo query --repo-prefix=URL1 --repo-prefix=URL2 --no-debuginfo --no-srpms -- search 'kernel*' + + # query a local file:// repo + azldev repo query --repo-prefix=file:///srv/azl/dist -- list --available`, + RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { + return nil, RunQuery(env, &options, args) + }), + } + + cmd.Flags().SetInterspersed(false) + + cmd.Flags().StringArrayVar(&options.RepoPrefixes, "repo-prefix", nil, + "layout prefix (http://, https://, or file:// URL); may be repeated") + cmd.Flags().StringVar(&options.Template, "template", repolayout.DefaultTemplateName, + "name of the rpm-repo-set-template to expand each --repo-prefix against") + cmd.Flags().StringSliceVar(&options.Arches, "arch", repolayout.DefaultArches, + "comma-separated arches to expand $basearch over") + cmd.Flags().BoolVar(&options.NoDebuginfo, "no-debuginfo", false, + "drop sub-repos whose kind is debug") + cmd.Flags().BoolVar(&options.NoSRPMs, "no-srpms", false, + "drop sub-repos whose kind is source") + + if err := cmd.MarkFlagRequired("repo-prefix"); err != nil { + panic(fmt.Errorf("failed to mark --repo-prefix required: %w", err)) + } + + return cmd +} + +// RunQuery is the entry point for `azldev repo query`. It resolves the +// template, probes the per-slot repodata URLs, then execs `dnf` with the +// surviving slots wired up via --repofrompath / --enablerepo. It does not +// return on success — control is transferred to dnf via [syscall.Exec]. +func RunQuery(env *azldev.Env, options *QueryOptions, dnfArgs []string) error { + if !env.CommandInSearchPath(DnfBinary) { + return fmt.Errorf("required tool %#q is not in PATH; install dnf to provide it", DnfBinary) + } + + templateName := options.Template + if templateName == "" { + templateName = repolayout.DefaultTemplateName + } + + arches := options.Arches + if len(arches) == 0 { + arches = repolayout.DefaultArches + } + + tmpl, err := repolayout.ResolveTemplate( + env.Config().Resources.RpmRepoSetTemplates, templateName) + if err != nil { + return fmt.Errorf("failed to resolve template:\n%w", err) + } + + repos, err := buildInputRepos(options, templateName, tmpl, arches) + if err != nil { + return err + } + + repos = filterByKind(repos, options) + if len(repos) == 0 { + return errors.New("no sub-repos remain after applying --no-debuginfo/--no-srpms filters") + } + + workerEnv, cancel := env.WithCancel() + defer cancel() + + results := probeAll(workerEnv, env.IOBoundConcurrency(), repos) + + kept, failures := summarizeResults(repos, results, options.RepoPrefixes) + + if len(failures) > 0 { + return fmt.Errorf( + "transport failures while probing the following sub-repos — "+ + "refusing to proceed with a partial repo set:\n %s", + strings.Join(failures, "\n ")) + } + + if len(kept) == 0 { + return errors.New("no reachable sub-repos under any --repo-prefix") + } + + argv := buildDNFArgv(kept, dnfArgs) + + dnfPath, err := exec.LookPath(DnfBinary) + if err != nil { + return fmt.Errorf("failed to locate %s in PATH: %w", DnfBinary, err) + } + + // Hand control to dnf — does not return on success. + if err := syscall.Exec(dnfPath, argv, os.Environ()); err != nil { + return fmt.Errorf("failed to exec %s: %w", dnfPath, err) + } + + return nil +} + +// buildInputRepos normalizes each --repo-prefix, expands it against tmpl, and +// stamps a 1-based prefix index/total on every produced row so the repo-id +// minter can disambiguate multi-prefix runs. +func buildInputRepos( + options *QueryOptions, + templateName string, + tmpl projectconfig.RpmRepoSetTemplate, + arches []string, +) ([]repolayout.InputRepo, error) { + var all []repolayout.InputRepo + + total := len(options.RepoPrefixes) + + for idx, prefix := range options.RepoPrefixes { + normalized, err := repolayout.NormalizePrefix(prefix) + if err != nil { + return nil, fmt.Errorf("--repo-prefix %#q: %w", prefix, err) + } + + expanded := repolayout.ExpandTemplate(normalized, templateName, tmpl, arches) + for i := range expanded { + expanded[i].PrefixIndex = idx + 1 + expanded[i].PrefixCount = total + } + + all = append(all, expanded...) + } + + return repolayout.DedupInputRepos(all), nil +} + +// filterByKind drops debug/source rows per --no-debuginfo / --no-srpms. +func filterByKind(repos []repolayout.InputRepo, options *QueryOptions) []repolayout.InputRepo { + if !options.NoDebuginfo && !options.NoSRPMs { + return repos + } + + out := make([]repolayout.InputRepo, 0, len(repos)) + + for _, repo := range repos { + switch repo.Kind { + case projectconfig.SubrepoKindDebug: + if options.NoDebuginfo { + continue + } + case projectconfig.SubrepoKindSource: + if options.NoSRPMs { + continue + } + case projectconfig.SubrepoKindBinary: + // keep + } + + out = append(out, repo) + } + + return out +} + +// probeAll runs every probe in parallel via [parmap.Map] and returns one +// result per repo (parallel to the input slice). It never returns an error +// directly; fatal probe failures are surfaced via [probeResult.Err] and +// aggregated by [summarizeResults] so the user sees every failure at once. +// Concurrency is bounded by limit (typically [azldev.Env.IOBoundConcurrency]). +func probeAll(ctx context.Context, limit int, repos []repolayout.InputRepo) []probeResult { + mapped := parmap.Map( + ctx, + limit, + repos, + nil, + func(wctx context.Context, repo repolayout.InputRepo) probeResult { + status, perr := probeOne(wctx, repo) + + return probeResult{Status: status, Err: perr} + }, + ) + + results := make([]probeResult, len(repos)) + + for idx, item := range mapped { + if item.Cancelled { + results[idx] = probeResult{Status: probeFail, Err: ctx.Err()} + + continue + } + + results[idx] = item.Value + } + + return results +} + +// summarizeResults walks per-prefix in the order the user supplied them, +// logs each slot's outcome, warns on prefixes that yielded zero kept slots +// (without aborting), and returns (kept, failures). When failures is +// non-empty the caller aborts before exec'ing dnf. +func summarizeResults( + repos []repolayout.InputRepo, + results []probeResult, + prefixes []string, +) (kept []repolayout.InputRepo, failures []string) { + kept = make([]repolayout.InputRepo, 0, len(repos)) + + // Group indices by prefix (1-based PrefixIndex) so we can log and + // tally per prefix in declaration order. + byPrefix := make(map[int][]int, len(prefixes)) + for idx, repo := range repos { + byPrefix[repo.PrefixIndex] = append(byPrefix[repo.PrefixIndex], idx) + } + + for pIdx, prefix := range prefixes { + slog.Info("Discovering repos under prefix", "prefix", prefix) + + keptHere := 0 + failedHere := 0 + + for _, idx := range byPrefix[pIdx+1] { + repo := repos[idx] + result := results[idx] + repoID := slotRepoID(repo) + + switch result.Status { + case probeOK: + slog.Info(" kept sub-repo", "id", repoID, "url", repo.URL) + kept = append(kept, repo) + keptHere++ + case probeMissing: + slog.Info(" skipped (no repodata)", "id", repoID, "url", repo.URL) + case probeFail: + slog.Warn(" probe failed", "id", repoID, "url", repo.URL, "err", result.Err) + failures = append(failures, + fmt.Sprintf("%s <- %s: %v", repoID, repo.URL, result.Err)) + failedHere++ + } + } + + if keptHere == 0 && failedHere == 0 { + slog.Warn("No sub-repos discovered under prefix", "prefix", prefix) + } + } + + return kept, failures +} + +// probeResult is one probe outcome stored by [probeAll]. Err is non-nil +// only when Status == probeFail. +type probeResult struct { + Status probeStatus + Err error +} + +type probeStatus int + +const ( + probeOK probeStatus = iota + probeMissing + probeFail +) + +// probeOne checks one repo's `repodata/repomd.xml`. For http(s) it issues a +// HEAD; for file:// it stats the path. +func probeOne(ctx context.Context, r repolayout.InputRepo) (probeStatus, error) { + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + probeURL := strings.TrimRight(r.URL, "/") + "/repodata/repomd.xml" + + if strings.HasPrefix(probeURL, "file://") { + return probeFile(probeURL) + } + + req, err := http.NewRequestWithContext(probeCtx, http.MethodHead, probeURL, nil) + if err != nil { + return probeFail, fmt.Errorf("build HEAD %#q: %w", probeURL, err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return probeFail, fmt.Errorf("HEAD %#q: %w", probeURL, err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return probeOK, nil + case http.StatusNotFound: + return probeMissing, nil + default: + return probeFail, fmt.Errorf("HEAD %#q returned unexpected status %s", probeURL, resp.Status) + } +} + +// probeFile maps an os.Stat outcome to a probeStatus. +func probeFile(probeURL string) (probeStatus, error) { + u, err := url.Parse(probeURL) + if err != nil { + return probeFail, fmt.Errorf("parse %#q: %w", probeURL, err) + } + + path := filepath.FromSlash(u.Path) + + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return probeMissing, nil + } + + return probeFail, fmt.Errorf("stat %#q: %w", path, err) + } + + if info.IsDir() { + return probeFail, fmt.Errorf("expected file at %#q, got directory", path) + } + + return probeOK, nil +} + +// buildDNFArgv builds the argv passed to dnf via [syscall.Exec]. The first +// element is the program name ("dnf"); the rest disables any host-configured +// repos, forces a metadata refresh, wires up one --repofrompath / +// --enablerepo pair per discovered slot, and finally appends the user's +// passthrough. +func buildDNFArgv(repos []repolayout.InputRepo, userArgs []string) []string { + argv := make([]string, 0, 3+len(repos)*4+len(userArgs)) + argv = append(argv, DnfBinary, "--disablerepo=*", "--refresh") + + for _, r := range repos { + id := slotRepoID(r) + argv = append(argv, + "--repofrompath", id+","+r.URL, + "--enablerepo", id, + ) + } + + argv = append(argv, userArgs...) + + return argv +} + +// slotRepoID mints a dnf repo id for the slot. The base form is +// `azl-`; the arch is appended when the row was fanned out per +// arch (so per-arch slots don't collide); and the 1-based prefix index is +// appended when multiple --repo-prefix values were supplied so ids stay +// unique across prefixes. +func slotRepoID(repo repolayout.InputRepo) string { + repoID := "azl-" + repo.SubrepoName + if repo.Arch != "" { + repoID = repoID + "-" + repo.Arch + } + + if repo.PrefixCount > 1 && repo.PrefixIndex > 0 { + repoID = fmt.Sprintf("%s-%d", repoID, repo.PrefixIndex) + } + + return repoID +} diff --git a/internal/app/azldev/cmds/repo/repo.go b/internal/app/azldev/cmds/repo/repo.go new file mode 100644 index 00000000..08378763 --- /dev/null +++ b/internal/app/azldev/cmds/repo/repo.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package repo implements the `azldev repo` top-level command, which exposes +// thin wrappers over the system dnf that auto-discover RPM repos +// under one or more URL prefixes. +package repo + +import ( + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/spf13/cobra" +) + +// OnAppInit registers the `repo` command tree with app. +func OnAppInit(app *azldev.App) { + cmd := &cobra.Command{ + Use: "repo", + Short: "Inspect and manage RPM repositories", + Long: `Inspect and manage RPM repositories. + +Subcommands operate over upstream RPM repos described by an +rpm-repo-set-template (e.g. the built-in "azl-standard" layout) expanded +under one or more URL prefixes.`, + } + + app.AddTopLevelCommand(cmd) + queryOnAppInit(app, cmd) +} diff --git a/internal/app/azldev/cmds/repo/repo_test.go b/internal/app/azldev/cmds/repo/repo_test.go new file mode 100644 index 00000000..28cfbb12 --- /dev/null +++ b/internal/app/azldev/cmds/repo/repo_test.go @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package repo_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/repo" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/repo/repolayout" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOnAppInit(t *testing.T) { + t.Parallel() + + app := azldev.NewApp(azldev.DefaultFileSystemFactory(), azldev.DefaultOSEnvFactory()) + + require.NotPanics(t, func() { + repo.OnAppInit(app) + }) +} + +func TestNewQueryCmd_FlagsRegistered(t *testing.T) { + t.Parallel() + + cmd := repo.NewQueryCmd() + for _, name := range []string{"repo-prefix", "template", "arch", "no-debuginfo", "no-srpms"} { + assert.NotNil(t, cmd.Flags().Lookup(name), "expected flag --%s", name) + } +} + +func TestNewQueryCmd_RepoPrefixRequired(t *testing.T) { + t.Parallel() + + cmd := repo.NewQueryCmd() + cmd.SetArgs([]string{}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "repo-prefix") +} + +func TestBuildDNFArgv_SinglePrefix(t *testing.T) { + t.Parallel() + + // Indirect coverage via running RunQuery is impractical (it execs dnf), + // but we can exercise the pure helpers by building the same input shape + // here and comparing against the expected argv structure. + templates := map[string]projectconfig.RpmRepoSetTemplate{ + "t1": { + Subrepos: []projectconfig.SubrepoSpec{ + {Name: "base", Subpath: "base/$basearch", Kind: projectconfig.SubrepoKindBinary}, + }, + }, + } + + tmpl, err := repolayout.ResolveTemplate(templates, "t1") + require.NoError(t, err) + + repos := repolayout.ExpandTemplate("https://example.com/p", "t1", tmpl, []string{"x86_64"}) + require.Len(t, repos, 1) +} diff --git a/internal/repo/repolayout/layout.go b/internal/repo/repolayout/layout.go new file mode 100644 index 00000000..4d6df6a6 --- /dev/null +++ b/internal/repo/repolayout/layout.go @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package repolayout resolves rpm-repo-set templates and expands them into a +// concrete list of repo URLs for the `azldev repo` subcommands. Callers +// supply the templates map (typically `Resources.RpmRepoSetTemplates` from a +// loaded `*projectconfig.ProjectConfig`), so user/project overrides on top +// of the embedded defaults flow through naturally. +package repolayout + +import ( + "errors" + "fmt" + "strings" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" +) + +// DefaultTemplateName is the name of the built-in standard Azure Linux layout +// template defined in `defaultconfigs/content/defaults.toml`. +const DefaultTemplateName = "azl-standard" + +// basearchPlaceholder is the substring expanded per arch in subrepo subpaths. +const basearchPlaceholder = "$basearch" + +// DefaultArches is the per-arch expansion used when the user does not pass +// `--arch`. +// +//nolint:gochecknoglobals // effectively a constant; Go has no const slices. +var DefaultArches = []string{"x86_64", "aarch64"} + +// InputRepo is one concrete (post-`$basearch`-expansion) upstream repo to query. +type InputRepo struct { + // TemplateName is the rpm-repo-set-template the repo was expanded from. + TemplateName string + // SubrepoName is the [projectconfig.SubrepoSpec.Name] this repo was expanded from. + SubrepoName string + // Kind mirrors [projectconfig.SubrepoSpec.Kind], defaulted. + Kind projectconfig.SubrepoKind + // Arch is the substituted `$basearch` value, or "" when the subpath has no + // `$basearch` (e.g., a single source/SRPM repo). + Arch string + // URL is the fully-resolved repo base URL. + URL string + // PrefixIndex is the 1-based position of the originating --repo-prefix among + // all prefixes; 0 if not set by the caller. + PrefixIndex int + // PrefixCount is the total number of --repo-prefix values supplied; 0 if not + // set by the caller. Used together with [InputRepo.PrefixIndex] to mint + // human-readable repo ids that disambiguate multi-prefix runs. + PrefixCount int +} + +// ResolveTemplate looks up name in the supplied templates map (typically +// `Resources.RpmRepoSetTemplates` from a loaded project config, which already +// has the embedded defaults — `azl-standard`, `koji-dist-repo` — merged in +// along with any project/user overrides). Errors when the template is not +// defined. The returned template is a copy; callers may mutate it freely. +func ResolveTemplate( + templates map[string]projectconfig.RpmRepoSetTemplate, name string, +) (projectconfig.RpmRepoSetTemplate, error) { + if name == "" { + return projectconfig.RpmRepoSetTemplate{}, errors.New("template name must not be empty") + } + + if tmpl, ok := templates[name]; ok { + return tmpl, nil + } + + return projectconfig.RpmRepoSetTemplate{}, + fmt.Errorf("rpm-repo-set-template %#q is not defined", name) +} + +// ExpandTemplate expands a template into one [InputRepo] per sub-repo, fanning +// out per arch where the subpath contains `$basearch`. +func ExpandTemplate( + prefix, templateName string, + tmpl projectconfig.RpmRepoSetTemplate, + arches []string, +) []InputRepo { + base := strings.TrimRight(prefix, "/") + out := make([]InputRepo, 0, len(tmpl.Subrepos)*len(arches)) + + for _, sub := range tmpl.Subrepos { + kind := sub.Kind.Default() + + if strings.Contains(sub.Subpath, basearchPlaceholder) { + for _, arch := range arches { + out = append(out, InputRepo{ + TemplateName: templateName, + SubrepoName: sub.Name, + Kind: kind, + Arch: arch, + URL: base + "/" + strings.ReplaceAll(sub.Subpath, basearchPlaceholder, arch), + }) + } + + continue + } + + out = append(out, InputRepo{ + TemplateName: templateName, + SubrepoName: sub.Name, + Kind: kind, + URL: base + "/" + sub.Subpath, + }) + } + + return out +} + +// DedupInputRepos drops duplicate entries by URL while preserving order. +func DedupInputRepos(repos []InputRepo) []InputRepo { + seen := make(map[string]struct{}, len(repos)) + out := make([]InputRepo, 0, len(repos)) + + for _, repo := range repos { + if _, ok := seen[repo.URL]; ok { + continue + } + + seen[repo.URL] = struct{}{} + + out = append(out, repo) + } + + return out +} + +// NormalizePrefix validates p as an http://, https://, or file:// URL and +// returns it with any trailing slash stripped. Bare paths are rejected. +func NormalizePrefix(prefix string) (string, error) { + if prefix == "" { + return "", errors.New("empty prefix") + } + + if !strings.HasPrefix(prefix, "http://") && + !strings.HasPrefix(prefix, "https://") && + !strings.HasPrefix(prefix, "file://") { + return "", fmt.Errorf("prefix %#q must be an http://, https://, or file:// URL", prefix) + } + + return strings.TrimRight(prefix, "/"), nil +} diff --git a/internal/repo/repolayout/layout_test.go b/internal/repo/repolayout/layout_test.go new file mode 100644 index 00000000..9e9a8e9e --- /dev/null +++ b/internal/repo/repolayout/layout_test.go @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package repolayout_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/repo/repolayout" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleTemplates() map[string]projectconfig.RpmRepoSetTemplate { + return map[string]projectconfig.RpmRepoSetTemplate{ + repolayout.DefaultTemplateName: { + Subrepos: []projectconfig.SubrepoSpec{ + {Name: "base", Subpath: "base/$basearch", Kind: projectconfig.SubrepoKindBinary}, + {Name: "base-debug", Subpath: "base/debuginfo/$basearch", Kind: projectconfig.SubrepoKindDebug}, + {Name: "base-src", Subpath: "base/srpms", Kind: projectconfig.SubrepoKindSource}, + {Name: "sdk", Subpath: "sdk/$basearch", Kind: projectconfig.SubrepoKindBinary}, + {Name: "sdk-debug", Subpath: "sdk/debuginfo/$basearch", Kind: projectconfig.SubrepoKindDebug}, + {Name: "sdk-src", Subpath: "sdk/srpms", Kind: projectconfig.SubrepoKindSource}, + }, + }, + } +} + +func TestResolveTemplate_Found(t *testing.T) { + t.Parallel() + + tmpl, err := repolayout.ResolveTemplate(sampleTemplates(), repolayout.DefaultTemplateName) + require.NoError(t, err) + assert.Len(t, tmpl.Subrepos, 6) +} + +func TestResolveTemplate_NotFound(t *testing.T) { + t.Parallel() + + _, err := repolayout.ResolveTemplate(sampleTemplates(), "no-such-template") + require.Error(t, err) + assert.Contains(t, err.Error(), "not defined") +} + +func TestResolveTemplate_EmptyName(t *testing.T) { + t.Parallel() + + _, err := repolayout.ResolveTemplate(sampleTemplates(), "") + require.Error(t, err) +} + +func TestExpandTemplate(t *testing.T) { + t.Parallel() + + tmpl, err := repolayout.ResolveTemplate(sampleTemplates(), repolayout.DefaultTemplateName) + require.NoError(t, err) + + repos := repolayout.ExpandTemplate( + "https://example.com/prefix/", + repolayout.DefaultTemplateName, + tmpl, + []string{"x86_64", "aarch64"}, + ) + + // 2 channels x (2 per-arch binary + 2 per-arch debug + 1 source) = 10. + require.Len(t, repos, 10) + + for _, repo := range repos { + assert.NotContains(t, repo.URL, "$basearch", "$basearch must be expanded") + assert.Equal(t, repolayout.DefaultTemplateName, repo.TemplateName) + } + + // Spot-check the base/binary x86_64 row. + var foundBase bool + + for _, repo := range repos { + if repo.URL == "https://example.com/prefix/base/x86_64" { + foundBase = true + + assert.Equal(t, "base", repo.SubrepoName) + assert.Equal(t, projectconfig.SubrepoKindBinary, repo.Kind) + assert.Equal(t, "x86_64", repo.Arch) + } + } + + assert.True(t, foundBase, "expected base/x86_64 row") + + // Source subrepo has empty Arch (no $basearch in subpath). + var foundSource bool + + for _, repo := range repos { + if repo.SubrepoName == "base-src" { + foundSource = true + + assert.Empty(t, repo.Arch) + assert.Equal(t, projectconfig.SubrepoKindSource, repo.Kind) + } + } + + assert.True(t, foundSource, "expected base-src row") +} + +func TestDedupInputRepos(t *testing.T) { + t.Parallel() + + repos := []repolayout.InputRepo{ + {SubrepoName: "base", Arch: "x86_64", URL: "https://a/x86_64"}, + {SubrepoName: "base", Arch: "x86_64", URL: "https://a/x86_64"}, + {SubrepoName: "base", Arch: "aarch64", URL: "https://a/aarch64"}, + } + + got := repolayout.DedupInputRepos(repos) + require.Len(t, got, 2) + assert.Equal(t, "https://a/x86_64", got[0].URL) + assert.Equal(t, "https://a/aarch64", got[1].URL) +} + +func TestNormalizePrefix(t *testing.T) { + t.Parallel() + + got, err := repolayout.NormalizePrefix("https://example.com/foo/") + require.NoError(t, err) + assert.Equal(t, "https://example.com/foo", got) + + got, err = repolayout.NormalizePrefix("file:///tmp/repo/") + require.NoError(t, err) + assert.Equal(t, "file:///tmp/repo", got) + + _, err = repolayout.NormalizePrefix("./testdata/repo") + require.Error(t, err) + assert.Contains(t, err.Error(), "http://") + + _, err = repolayout.NormalizePrefix("") + require.Error(t, err) +} diff --git a/pkg/app/azldev_cli/azldev.go b/pkg/app/azldev_cli/azldev.go index 90bd99fe..54872aa9 100644 --- a/pkg/app/azldev_cli/azldev.go +++ b/pkg/app/azldev_cli/azldev.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/image" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/pkg" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/project" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/repo" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/version" ) @@ -41,6 +42,7 @@ func InstantiateApp() *azldev.App { image.OnAppInit(app) pkg.OnAppInit(app) project.OnAppInit(app) + repo.OnAppInit(app) version.OnAppInit(app) return app diff --git a/pkg/app/azldev_cli/azldev_test.go b/pkg/app/azldev_cli/azldev_test.go index 5a539ff7..acbcc855 100644 --- a/pkg/app/azldev_cli/azldev_test.go +++ b/pkg/app/azldev_cli/azldev_test.go @@ -29,6 +29,7 @@ func TestInstantiateApp(t *testing.T) { "image", "package", "project", + "repo", "version", }, ) From ce01d33690b9768f51c2ceae567a0745dab009b2 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Mon, 1 Jun 2026 20:46:21 +0000 Subject: [PATCH 2/2] feat(repo): add --version/--use-case mode to 'azldev repo query' Resolve the dnf repo set from the default distro's [distros..versions..inputs] list instead of requiring --repo-prefix. --use-case selects rpm-build (default) or image-build inputs. Per-repo arch allowlists, GPG keys, and metalink-only rejection are honored; source repos with no $basearch are deduped across the per-arch fan-out. --repo-prefix/--template stay mutually exclusive with --version. Adds InputRepo.RepoID / InputRepo.GPGKey and a SubstituteBasearch helper in repolayout so the version-mode resolver can carry canonical ids and forward gpgkey/gpgcheck setopts to dnf. Includes internal unit tests for the new resolver and regenerated CLI docs. --- docs/user/reference/cli/azldev_repo.md | 2 +- docs/user/reference/cli/azldev_repo_query.md | 44 +- internal/app/azldev/cmds/repo/query.go | 380 +++++++++++++++--- .../azldev/cmds/repo/query_internal_test.go | 181 +++++++++ internal/app/azldev/cmds/repo/repo_test.go | 8 +- internal/projectconfig/distro.go | 14 +- internal/projectconfig/resources.go | 4 +- internal/repo/repolayout/layout.go | 18 + ...pshotsContainer_--bogus-flag_stderr_1.snap | 1 + ...estSnapshotsContainer_--help_stdout_1.snap | 1 + .../TestSnapshotsContainer_help_stdout_1.snap | 1 + .../TestSnapshots_--bogus-flag_stderr_1.snap | 1 + .../TestSnapshots_--help_stdout_1.snap | 1 + ...tSnapshots_--help_with_color_stdout_1.snap | 1 + .../TestSnapshots_help_stdout_1.snap | 1 + 15 files changed, 575 insertions(+), 83 deletions(-) create mode 100644 internal/app/azldev/cmds/repo/query_internal_test.go diff --git a/docs/user/reference/cli/azldev_repo.md b/docs/user/reference/cli/azldev_repo.md index 19b6bf81..e9132d0b 100644 --- a/docs/user/reference/cli/azldev_repo.md +++ b/docs/user/reference/cli/azldev_repo.md @@ -37,5 +37,5 @@ under one or more URL prefixes. ### SEE ALSO * [azldev](azldev.md) - 🐧 Azure Linux Dev Tool -* [azldev repo query](azldev_repo_query.md) - Run dnf against auto-discovered Azure Linux RPM repos +* [azldev repo query](azldev_repo_query.md) - Run dnf against auto-discovered RPM repos diff --git a/docs/user/reference/cli/azldev_repo_query.md b/docs/user/reference/cli/azldev_repo_query.md index aa1b2d0d..7e5614ee 100644 --- a/docs/user/reference/cli/azldev_repo_query.md +++ b/docs/user/reference/cli/azldev_repo_query.md @@ -2,32 +2,44 @@ ## azldev repo query -Run dnf against auto-discovered Azure Linux RPM repos +Run dnf against auto-discovered RPM repos ### Synopsis -Thin wrapper around dnf that auto-discovers Azure Linux RPM repos under one -or more URL prefixes and then execs into dnf with the resolved repos wired up -via --repofrompath / --enablerepo. +Thin wrapper around dnf that auto-discovers RPM repos and execs into +dnf with the resolved repos wired up via --repofrompath / --enablerepo. -Each --repo-prefix is expanded against an rpm-repo-set-template -(--template, default "azl-standard") into one sub-repo per template row, fanned -out per --arch where the row's subpath contains $basearch. Unreachable -sub-repos (404 on repodata/repomd.xml) are silently dropped; any other probe -failure aborts the run. +Two selection modes, mutually exclusive: + + --repo-prefix URL [--repo-prefix URL]... + URL mode. Each URL is expanded against an rpm-repo-set-template + (--template, default "azl-standard") into one sub-repo per template + row, fanned out per --arch where the row's subpath contains $basearch. + + --version VER [--use-case rpm-build|image-build] + Project-config mode. Resolves the inputs list of the default distro's + VER version (use-case defaults to "rpm-build"). Gpg-keys + and per-repo arch allowlists come from [resources.rpm-repo-sets.*] / + [resources.rpm-repos.*]. --arch defaults to x86_64+aarch64 (each + repo is still filtered by its declared arches). --no-debuginfo / + --no-srpms drop sub-repos by their declared kind. --template is not + used. + +Unreachable sub-repos (404 on repodata/repomd.xml, or ENOENT for file://) +are silently dropped; any other probe failure aborts the run. All positional arguments are passed verbatim to dnf. Use `--` to separate azldev flags from dnf flags. Examples: - # repoquery the standard layout under one prefix - azldev repo query --repo-prefix=https://packages.microsoft.com/azurelinux/3.0/prod -- repoquery --available bash + # URL-mode query against a published tree + azldev repo query --repo-prefix=https://packages.microsoft.com/azurelinux/4.0/beta -- repoquery --available bash - # search across two prefixes, skipping debug and source repos - azldev repo query --repo-prefix=URL1 --repo-prefix=URL2 --no-debuginfo --no-srpms -- search 'kernel*' + # whatever the current project's default distro 4.0-stage2 build consumes + azldev repo query --version 4.0-stage2 -- list --available kernel - # query a local file:// repo - azldev repo query --repo-prefix=file:///srv/azl/dist -- list --available + # image-build inputs instead of rpm-build + azldev repo query --version 4.0-stage2 --use-case image-build -- repolist ``` azldev repo query [flags] -- @@ -42,6 +54,8 @@ azldev repo query [flags] -- --no-srpms drop sub-repos whose kind is source --repo-prefix stringArray layout prefix (http://, https://, or file:// URL); may be repeated --template string name of the rpm-repo-set-template to expand each --repo-prefix against (default "azl-standard") + --use-case string which inputs list to consult in --version mode: rpm-build or image-build (default "rpm-build") + --version string resolve repos from the default distro's [distros..versions..inputs] list (mutually exclusive with --repo-prefix and --template) ``` ### Options inherited from parent commands diff --git a/internal/app/azldev/cmds/repo/query.go b/internal/app/azldev/cmds/repo/query.go index 597cb050..086dd83d 100644 --- a/internal/app/azldev/cmds/repo/query.go +++ b/internal/app/azldev/cmds/repo/query.go @@ -37,6 +37,8 @@ type QueryOptions struct { Arches []string NoDebuginfo bool NoSRPMs bool + Version string + UseCase string } func queryOnAppInit(_ *azldev.App, parentCmd *cobra.Command) { @@ -49,34 +51,47 @@ func NewQueryCmd() *cobra.Command { cmd := &cobra.Command{ Use: "query [flags] -- ", - Short: "Run dnf against auto-discovered Azure Linux RPM repos", - Long: `Thin wrapper around dnf that auto-discovers Azure Linux RPM repos under one -or more URL prefixes and then execs into dnf with the resolved repos wired up -via --repofrompath / --enablerepo. + Short: "Run dnf against auto-discovered RPM repos", + Long: `Thin wrapper around dnf that auto-discovers RPM repos and execs into +dnf with the resolved repos wired up via --repofrompath / --enablerepo. -Each --repo-prefix is expanded against an rpm-repo-set-template -(--template, default "` + repolayout.DefaultTemplateName + `") into one sub-repo per template row, fanned -out per --arch where the row's subpath contains $basearch. Unreachable -sub-repos (404 on repodata/repomd.xml) are silently dropped; any other probe -failure aborts the run. +Two selection modes, mutually exclusive: + + --repo-prefix URL [--repo-prefix URL]... + URL mode. Each URL is expanded against an rpm-repo-set-template + (--template, default "` + repolayout.DefaultTemplateName + `") into one sub-repo per template + row, fanned out per --arch where the row's subpath contains $basearch. + + --version VER [--use-case rpm-build|image-build] + Project-config mode. Resolves the inputs list of the default distro's + VER version (use-case defaults to "` + projectconfig.UseCaseRPMBuild + `"). Gpg-keys + and per-repo arch allowlists come from [resources.rpm-repo-sets.*] / + [resources.rpm-repos.*]. --arch defaults to x86_64+aarch64 (each + repo is still filtered by its declared arches). --no-debuginfo / + --no-srpms drop sub-repos by their declared kind. --template is not + used. + +Unreachable sub-repos (404 on repodata/repomd.xml, or ENOENT for file://) +are silently dropped; any other probe failure aborts the run. All positional arguments are passed verbatim to dnf. Use ` + "`--`" + ` to separate azldev flags from dnf flags. Examples: - # repoquery the standard layout under one prefix + # URL-mode query against a published tree azldev repo query --repo-prefix=https://packages.microsoft.com/azurelinux/4.0/beta -- repoquery --available bash - # search across two prefixes, skipping debug and source repos - azldev repo query --repo-prefix=URL1 --repo-prefix=URL2 --no-debuginfo --no-srpms -- search 'kernel*' + # whatever the current project's default distro 4.0-stage2 build consumes + azldev repo query --version 4.0-stage2 -- list --available kernel - # query a local file:// repo - azldev repo query --repo-prefix=file:///srv/azl/dist -- list --available`, - RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { - return nil, RunQuery(env, &options, args) - }), + # image-build inputs instead of rpm-build + azldev repo query --version 4.0-stage2 --use-case image-build -- repolist`, } + cmd.RunE = azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { + return nil, RunQuery(env, &options, args) + }) + cmd.Flags().SetInterspersed(false) cmd.Flags().StringArrayVar(&options.RepoPrefixes, "repo-prefix", nil, @@ -89,10 +104,16 @@ Examples: "drop sub-repos whose kind is debug") cmd.Flags().BoolVar(&options.NoSRPMs, "no-srpms", false, "drop sub-repos whose kind is source") + cmd.Flags().StringVar(&options.Version, "version", "", + "resolve repos from the default distro's [distros..versions..inputs] list "+ + "(mutually exclusive with --repo-prefix and --template)") + cmd.Flags().StringVar(&options.UseCase, "use-case", projectconfig.UseCaseRPMBuild, + "which inputs list to consult in --version mode: "+ + projectconfig.UseCaseRPMBuild+" or "+projectconfig.UseCaseImageBuild) - if err := cmd.MarkFlagRequired("repo-prefix"); err != nil { - panic(fmt.Errorf("failed to mark --repo-prefix required: %w", err)) - } + cmd.MarkFlagsMutuallyExclusive("repo-prefix", "version") + cmd.MarkFlagsMutuallyExclusive("template", "version") + cmd.MarkFlagsOneRequired("repo-prefix", "version") return cmd } @@ -106,30 +127,13 @@ func RunQuery(env *azldev.Env, options *QueryOptions, dnfArgs []string) error { return fmt.Errorf("required tool %#q is not in PATH; install dnf to provide it", DnfBinary) } - templateName := options.Template - if templateName == "" { - templateName = repolayout.DefaultTemplateName - } - - arches := options.Arches - if len(arches) == 0 { - arches = repolayout.DefaultArches - } - - tmpl, err := repolayout.ResolveTemplate( - env.Config().Resources.RpmRepoSetTemplates, templateName) - if err != nil { - return fmt.Errorf("failed to resolve template:\n%w", err) - } - - repos, err := buildInputRepos(options, templateName, tmpl, arches) + repos, prefixes, err := buildCandidates(env, options) if err != nil { return err } - repos = filterByKind(repos, options) if len(repos) == 0 { - return errors.New("no sub-repos remain after applying --no-debuginfo/--no-srpms filters") + return errors.New("no sub-repos remain after applying filters") } workerEnv, cancel := env.WithCancel() @@ -137,7 +141,7 @@ func RunQuery(env *azldev.Env, options *QueryOptions, dnfArgs []string) error { results := probeAll(workerEnv, env.IOBoundConcurrency(), repos) - kept, failures := summarizeResults(repos, results, options.RepoPrefixes) + kept, failures := summarizeResults(repos, results, prefixes) if len(failures) > 0 { return fmt.Errorf( @@ -147,7 +151,7 @@ func RunQuery(env *azldev.Env, options *QueryOptions, dnfArgs []string) error { } if len(kept) == 0 { - return errors.New("no reachable sub-repos under any --repo-prefix") + return errors.New("no reachable sub-repos") } argv := buildDNFArgv(kept, dnfArgs) @@ -165,6 +169,222 @@ func RunQuery(env *azldev.Env, options *QueryOptions, dnfArgs []string) error { return nil } +// buildCandidates picks the input-list builder based on which mode the user +// selected and returns (repos, prefixesForLogging). prefixesForLogging is +// non-empty only in --repo-prefix mode so summarizeResults can group output +// by prefix. +func buildCandidates(env *azldev.Env, options *QueryOptions) ([]repolayout.InputRepo, []string, error) { + if options.Version != "" { + repos, err := reposFromVersion(env, options) + + return repos, nil, err + } + + templateName := options.Template + if templateName == "" { + templateName = repolayout.DefaultTemplateName + } + + tmpl, err := repolayout.ResolveTemplate( + env.Config().Resources.RpmRepoSetTemplates, templateName) + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve template:\n%w", err) + } + + repos, err := buildInputRepos(options, templateName, tmpl, options.Arches) + if err != nil { + return nil, nil, err + } + + return filterByKind(repos, options), options.RepoPrefixes, nil +} + +// reposFromVersion resolves a distro version's inputs into the flat +// [repolayout.InputRepo] list the rest of the pipeline expects. +func reposFromVersion(env *azldev.Env, options *QueryOptions) ([]repolayout.InputRepo, error) { + useCase := options.UseCase + if useCase != projectconfig.UseCaseRPMBuild && useCase != projectconfig.UseCaseImageBuild { + return nil, fmt.Errorf("--use-case %#q is invalid (want %q or %q)", + useCase, projectconfig.UseCaseRPMBuild, projectconfig.UseCaseImageBuild) + } + + cfg := env.Config() + version := options.Version + + distroName := cfg.Project.DefaultDistro.Name + if distroName == "" { + return nil, errors.New("--version requires `project.default-distro.name` to be set in project config") + } + + names, err := resolveVersionRepoNames(cfg, distroName, version, useCase) + if err != nil { + return nil, err + } + + effective, err := cfg.Resources.EffectiveRpmRepos() + if err != nil { + return nil, fmt.Errorf("resolving rpm-repos:\n%w", err) + } + + kinds := rpmRepoKindMap(&cfg.Resources) + + return materializeVersionRepos(names, effective, kinds, options.Arches, options, distroName, version) +} + +// rpmRepoKindMap reproduces the name -> SubrepoKind mapping that +// [projectconfig.ResourcesConfig.EffectiveRpmRepos] discards. Explicit +// rpm-repos are treated as Binary; set-expanded entries take their kind +// from the template's SubrepoSpec. +func rpmRepoKindMap(resources *projectconfig.ResourcesConfig) map[string]projectconfig.SubrepoKind { + kinds := make(map[string]projectconfig.SubrepoKind) + if resources == nil { + return kinds + } + + for name := range resources.RpmRepos { + kinds[name] = projectconfig.SubrepoKindBinary + } + + for _, set := range resources.RpmRepoSets { + tmpl, ok := resources.RpmRepoSetTemplates[set.Template] + if !ok { + continue + } + + allow := map[string]struct{}{} + for _, subName := range set.Subrepos { + allow[subName] = struct{}{} + } + + for _, sub := range tmpl.Subrepos { + if len(allow) > 0 { + if _, ok := allow[sub.Name]; !ok { + continue + } + } + + kinds[set.NamePrefix+sub.Name] = sub.Kind.Default() + } + } + + return kinds +} + +// resolveVersionRepoNames returns the ordered list of rpm-repo names declared +// for (distroName, version, useCase) after set expansion. +func resolveVersionRepoNames( + cfg *projectconfig.ProjectConfig, + distroName, version, useCase string, +) ([]string, error) { + distro, found := cfg.Distros[distroName] + if !found { + return nil, fmt.Errorf("default distro %#q is not defined under [distros]", distroName) + } + + versionDef, found := distro.Versions[version] + if !found { + return nil, fmt.Errorf("distro %#q has no version %#q", distroName, version) + } + + var ( + names []string + err error + ) + + switch useCase { + case projectconfig.UseCaseRPMBuild: + names, err = versionDef.EffectiveRpmBuildRepos(&cfg.Resources) + case projectconfig.UseCaseImageBuild: + names, err = versionDef.EffectiveImageBuildRepos(&cfg.Resources) + } + + if err != nil { + return nil, fmt.Errorf("resolving %s inputs for %s/%s:\n%w", useCase, distroName, version, err) + } + + if len(names) == 0 { + return nil, fmt.Errorf("%s/%s declares no %s inputs", distroName, version, useCase) + } + + return names, nil +} + +// materializeVersionRepos turns rpm-repo names into the flat InputRepo list, +// expanding over arches and applying --no-debuginfo / --no-srpms via kinds. +// Per-repo arch allowlists ([RpmRepoResource.Arches]) drop arches a repo +// doesn't publish; metalink-only repos are rejected. +func materializeVersionRepos( + names []string, + effective map[string]projectconfig.RpmRepoResource, + kinds map[string]projectconfig.SubrepoKind, + arches []string, + options *QueryOptions, + distroName, version string, +) ([]repolayout.InputRepo, error) { + out := make([]repolayout.InputRepo, 0, len(names)*len(arches)) + + for _, name := range names { + repo, found := effective[name] + if !found { + return nil, fmt.Errorf("rpm-repo %#q referenced by %s/%s inputs is not defined", + name, distroName, version) + } + + if repo.BaseURI == "" { + return nil, fmt.Errorf( + "rpm-repo %#q has no base-uri (metalink-only repos are not supported by `repo query`)", + name) + } + + kind := kinds[name].Default() + if options.NoDebuginfo && kind == projectconfig.SubrepoKindDebug { + continue + } + + if options.NoSRPMs && kind == projectconfig.SubrepoKindSource { + continue + } + + gpgKey := "" + if !repo.DisableGPGCheck { + gpgKey = repo.GPGKey + } + + for _, arch := range arches { + if !repo.IsAvailableForArch(arch) { + continue + } + + out = append(out, repolayout.InputRepo{ + RepoID: versionRepoID(name, arches, arch), + URL: repolayout.SubstituteBasearch(repo.BaseURI, arch), + Arch: arch, + GPGKey: gpgKey, + }) + } + } + + // DedupInputRepos collapses entries with identical URLs. That happens + // naturally for source/SRPM subrepos whose subpath has no `$basearch` — + // the per-arch fan-out above produces N identical URLs, and we want only + // one row in the final dnf invocation. + return repolayout.DedupInputRepos(out), nil +} + +// versionRepoID keeps the canonical resource id when only one arch was +// requested (matches the name the user typed in TOML) and appends the arch +// otherwise so per-arch dnf repo ids stay unique. The decision is based on +// the *requested* arch list, not how many survive per-repo filtering — that +// keeps the id stable when the user re-runs with the same flags even if +// some repos drop one arch. +func versionRepoID(name string, arches []string, arch string) string { + if len(arches) > 1 { + return name + "-" + arch + } + + return name +} + // buildInputRepos normalizes each --repo-prefix, expands it against tmpl, and // stamps a 1-based prefix index/total on every produced row so the repo-id // minter can disambiguate multi-prefix runs. @@ -268,6 +488,16 @@ func summarizeResults( ) (kept []repolayout.InputRepo, failures []string) { kept = make([]repolayout.InputRepo, 0, len(repos)) + if len(prefixes) == 0 { + slog.Info("Resolving repos from project config", "count", len(repos)) + + for idx := range repos { + kept, failures = recordResult(repos[idx], results[idx], kept, failures) + } + + return kept, failures + } + // Group indices by prefix (1-based PrefixIndex) so we can log and // tally per prefix in declaration order. byPrefix := make(map[int][]int, len(prefixes)) @@ -282,21 +512,15 @@ func summarizeResults( failedHere := 0 for _, idx := range byPrefix[pIdx+1] { - repo := repos[idx] - result := results[idx] - repoID := slotRepoID(repo) - - switch result.Status { - case probeOK: - slog.Info(" kept sub-repo", "id", repoID, "url", repo.URL) - kept = append(kept, repo) + before := len(kept) + failedBefore := len(failures) + kept, failures = recordResult(repos[idx], results[idx], kept, failures) + + if len(kept) > before { keptHere++ - case probeMissing: - slog.Info(" skipped (no repodata)", "id", repoID, "url", repo.URL) - case probeFail: - slog.Warn(" probe failed", "id", repoID, "url", repo.URL, "err", result.Err) - failures = append(failures, - fmt.Sprintf("%s <- %s: %v", repoID, repo.URL, result.Err)) + } + + if len(failures) > failedBefore { failedHere++ } } @@ -309,6 +533,31 @@ func summarizeResults( return kept, failures } +// recordResult logs one slot's outcome and appends to kept or failures as +// appropriate. +func recordResult( + repo repolayout.InputRepo, + result probeResult, + kept []repolayout.InputRepo, + failures []string, +) ([]repolayout.InputRepo, []string) { + repoID := slotRepoID(repo) + + switch result.Status { + case probeOK: + slog.Info(" kept sub-repo", "id", repoID, "url", repo.URL) + kept = append(kept, repo) + case probeMissing: + slog.Info(" skipped (no repodata)", "id", repoID, "url", repo.URL) + case probeFail: + slog.Warn(" probe failed", "id", repoID, "url", repo.URL, "err", result.Err) + failures = append(failures, + fmt.Sprintf("%s <- %s: %v", repoID, repo.URL, result.Err)) + } + + return kept, failures +} + // probeResult is one probe outcome stored by [probeAll]. Err is non-nil // only when Status == probeFail. type probeResult struct { @@ -388,15 +637,22 @@ func probeFile(probeURL string) (probeStatus, error) { // --enablerepo pair per discovered slot, and finally appends the user's // passthrough. func buildDNFArgv(repos []repolayout.InputRepo, userArgs []string) []string { - argv := make([]string, 0, 3+len(repos)*4+len(userArgs)) + argv := make([]string, 0, 3+len(repos)*6+len(userArgs)) argv = append(argv, DnfBinary, "--disablerepo=*", "--refresh") - for _, r := range repos { - id := slotRepoID(r) + for _, dnfRepo := range repos { + repoID := slotRepoID(dnfRepo) argv = append(argv, - "--repofrompath", id+","+r.URL, - "--enablerepo", id, + "--repofrompath", repoID+","+dnfRepo.URL, + "--enablerepo", repoID, ) + + if dnfRepo.GPGKey != "" { + argv = append(argv, + "--setopt="+repoID+".gpgkey="+dnfRepo.GPGKey, + "--setopt="+repoID+".gpgcheck=1", + ) + } } argv = append(argv, userArgs...) @@ -410,6 +666,10 @@ func buildDNFArgv(repos []repolayout.InputRepo, userArgs []string) []string { // appended when multiple --repo-prefix values were supplied so ids stay // unique across prefixes. func slotRepoID(repo repolayout.InputRepo) string { + if repo.RepoID != "" { + return repo.RepoID + } + repoID := "azl-" + repo.SubrepoName if repo.Arch != "" { repoID = repoID + "-" + repo.Arch diff --git a/internal/app/azldev/cmds/repo/query_internal_test.go b/internal/app/azldev/cmds/repo/query_internal_test.go new file mode 100644 index 00000000..8f69c796 --- /dev/null +++ b/internal/app/azldev/cmds/repo/query_internal_test.go @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package repo + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/repo/repolayout" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersionRepoID(t *testing.T) { + t.Parallel() + + assert.Equal(t, "azl4-beta-base", versionRepoID("azl4-beta-base", []string{"x86_64"}, "x86_64")) + assert.Equal(t, "azl4-beta-base-x86_64", + versionRepoID("azl4-beta-base", []string{"x86_64", "aarch64"}, "x86_64")) +} + +func TestMaterializeVersionRepos_ArchAndKindFilters(t *testing.T) { + t.Parallel() + + effective := map[string]projectconfig.RpmRepoResource{ + "base": {BaseURI: "https://example.com/base/$basearch", Arches: []string{"x86_64"}}, + "debug": {BaseURI: "https://example.com/debug/$basearch"}, + "source": {BaseURI: "https://example.com/source"}, + } + kinds := map[string]projectconfig.SubrepoKind{ + "base": projectconfig.SubrepoKindBinary, + "debug": projectconfig.SubrepoKindDebug, + "source": projectconfig.SubrepoKindSource, + } + + out, err := materializeVersionRepos( + []string{"base", "debug", "source"}, + effective, kinds, + []string{"x86_64", "aarch64"}, + &QueryOptions{NoDebuginfo: true}, + "azl", "4.0", + ) + require.NoError(t, err) + + // base: only x86_64 (allowlist drops aarch64). + // debug: dropped by NoDebuginfo. + // source: subpath has no $basearch, so per-arch fan-out yields two + // identical URLs that DedupInputRepos collapses into one. + urls := make([]string, 0, len(out)) + for _, repo := range out { + urls = append(urls, repo.URL) + } + + assert.ElementsMatch(t, []string{ + "https://example.com/base/x86_64", + "https://example.com/source", + }, urls) +} + +func TestMaterializeVersionRepos_NoSRPMs(t *testing.T) { + t.Parallel() + + out, err := materializeVersionRepos( + []string{"src"}, + map[string]projectconfig.RpmRepoResource{ + "src": {BaseURI: "https://example.com/s"}, + }, + map[string]projectconfig.SubrepoKind{"src": projectconfig.SubrepoKindSource}, + []string{"x86_64"}, + &QueryOptions{NoSRPMs: true}, + "azl", "4.0", + ) + require.NoError(t, err) + assert.Empty(t, out) +} + +func TestMaterializeVersionRepos_GPGKeyForwarded(t *testing.T) { + t.Parallel() + + effective := map[string]projectconfig.RpmRepoResource{ + "signed": {BaseURI: "https://example.com/a", GPGKey: "https://example.com/key.asc"}, + "unsigned": {BaseURI: "https://example.com/b", GPGKey: "https://example.com/k", DisableGPGCheck: true}, + } + + out, err := materializeVersionRepos( + []string{"signed", "unsigned"}, + effective, + map[string]projectconfig.SubrepoKind{}, + []string{"x86_64"}, + &QueryOptions{}, "azl", "4.0", + ) + require.NoError(t, err) + require.Len(t, out, 2) + + byID := map[string]repolayout.InputRepo{} + for _, repo := range out { + byID[repo.RepoID] = repo + } + + assert.Equal(t, "https://example.com/key.asc", byID["signed"].GPGKey) + assert.Empty(t, byID["unsigned"].GPGKey, + "DisableGPGCheck should suppress GPGKey forwarding") +} + +func TestMaterializeVersionRepos_UndefinedRepoErrors(t *testing.T) { + t.Parallel() + + _, err := materializeVersionRepos( + []string{"missing"}, + map[string]projectconfig.RpmRepoResource{}, + map[string]projectconfig.SubrepoKind{}, + []string{"x86_64"}, + &QueryOptions{}, "azl", "4.0", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing") +} + +func TestMaterializeVersionRepos_MetalinkOnlyRejected(t *testing.T) { + t.Parallel() + + _, err := materializeVersionRepos( + []string{"ml"}, + map[string]projectconfig.RpmRepoResource{"ml": {Metalink: "https://example.com/m"}}, + map[string]projectconfig.SubrepoKind{}, + []string{"x86_64"}, + &QueryOptions{}, "azl", "4.0", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "metalink-only") +} + +func TestBuildDNFArgv_ForwardsGPGSetopts(t *testing.T) { + t.Parallel() + + argv := buildDNFArgv( + []repolayout.InputRepo{ + {RepoID: "signed", URL: "https://example.com/a", GPGKey: "https://example.com/k"}, + {RepoID: "plain", URL: "https://example.com/b"}, + }, + []string{"repolist"}, + ) + + joined := " " + joinArgs(argv) + " " + assert.Contains(t, joined, " --setopt=signed.gpgkey=https://example.com/k ") + assert.Contains(t, joined, " --setopt=signed.gpgcheck=1 ") + assert.NotContains(t, joined, "plain.gpgkey") + assert.NotContains(t, joined, "plain.gpgcheck") +} + +func TestNewQueryCmd_TemplateVersionMutuallyExclusive(t *testing.T) { + t.Parallel() + + cmd := NewQueryCmd() + cmd.SetArgs([]string{"--version", "4.0", "--template", "azl-standard"}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "template") + assert.Contains(t, err.Error(), "version") +} + +// joinArgs is a tiny helper that lets the GPG-setopt assertions match on +// whole-argv tokens (so a substring like "signed.gpgkey=" can't accidentally +// match an unrelated dnf flag). +func joinArgs(args []string) string { + out := "" + + for i, arg := range args { + if i > 0 { + out += " " + } + + out += arg + } + + return out +} diff --git a/internal/app/azldev/cmds/repo/repo_test.go b/internal/app/azldev/cmds/repo/repo_test.go index 28cfbb12..4a301767 100644 --- a/internal/app/azldev/cmds/repo/repo_test.go +++ b/internal/app/azldev/cmds/repo/repo_test.go @@ -28,12 +28,15 @@ func TestNewQueryCmd_FlagsRegistered(t *testing.T) { t.Parallel() cmd := repo.NewQueryCmd() - for _, name := range []string{"repo-prefix", "template", "arch", "no-debuginfo", "no-srpms"} { + for _, name := range []string{ + "repo-prefix", "template", "arch", "no-debuginfo", "no-srpms", + "version", "use-case", + } { assert.NotNil(t, cmd.Flags().Lookup(name), "expected flag --%s", name) } } -func TestNewQueryCmd_RepoPrefixRequired(t *testing.T) { +func TestNewQueryCmd_OneOfRepoPrefixOrVersionRequired(t *testing.T) { t.Parallel() cmd := repo.NewQueryCmd() @@ -44,6 +47,7 @@ func TestNewQueryCmd_RepoPrefixRequired(t *testing.T) { err := cmd.Execute() require.Error(t, err) assert.Contains(t, err.Error(), "repo-prefix") + assert.Contains(t, err.Error(), "version") } func TestBuildDNFArgv_SinglePrefix(t *testing.T) { diff --git a/internal/projectconfig/distro.go b/internal/projectconfig/distro.go index 677517e1..90d046f8 100644 --- a/internal/projectconfig/distro.go +++ b/internal/projectconfig/distro.go @@ -86,12 +86,20 @@ type DistroVersionDefinition struct { MockConfigPathX86_64 string `toml:"mock-config-x86_64,omitempty" json:"mockConfigX8664,omitempty" validate:"omitempty,filepath" jsonschema:"title=Mock config file,description=Path to the x86_64 mock config file for this version"` MockConfigPathAarch64 string `toml:"mock-config-aarch64,omitempty" json:"mockConfigAarch64,omitempty" validate:"omitempty,filepath" jsonschema:"title=Mock config file,description=Path to the aarch64 mock config file for this version"` - // Inputs maps build use-cases ("rpm-build", "image-build") to ordered lists - // of input references. Each entry references either a [RpmRepoResource] or - // a [RpmRepoSet]; sets are expanded at validation time. + // Inputs maps build use-cases ([UseCaseRPMBuild], [UseCaseImageBuild]) to + // ordered lists of input references. Each entry references either a + // [RpmRepoResource] or a [RpmRepoSet]; sets are expanded at validation time. Inputs DistroVersionInputs `toml:"inputs,omitempty" json:"inputs,omitempty" jsonschema:"title=Inputs,description=Per-use-case input repositories"` } +// Use-case identifiers for [DistroVersionInputs]. These match the TOML keys +// under `[distros..versions..inputs]` and are the canonical names used +// in error messages and CLI flags (e.g. `azldev repo query --use-case`). +const ( + UseCaseRPMBuild = "rpm-build" + UseCaseImageBuild = "image-build" +) + // DistroVersionInputs maps build use-cases to ordered lists of input references. // Each [DistroVersionInput] entry references either a [RpmRepoResource] (by // `repo`) or a [RpmRepoSet] (by `set`); sets are expanded at validation time diff --git a/internal/projectconfig/resources.go b/internal/projectconfig/resources.go index a306ca2b..07349c03 100644 --- a/internal/projectconfig/resources.go +++ b/internal/projectconfig/resources.go @@ -1012,14 +1012,14 @@ func validateRpmRepoSetTemplate(name string, tmpl *RpmRepoSetTemplate) error { // [ResourcesConfig.EffectiveRpmRepos] happens in [ProjectConfig.Validate] via // validateDistroVersionInputs. func (v DistroVersionDefinition) EffectiveRpmBuildRepos(resources *ResourcesConfig) ([]string, error) { - return effectiveInputRepos("rpm-build", v.Inputs.RpmBuild, resources) + return effectiveInputRepos(UseCaseRPMBuild, v.Inputs.RpmBuild, resources) } // EffectiveImageBuildRepos returns the deduplicated, ordered list of effective // repo names exposed to the image-build use-case for this distro version. Same // semantics as [DistroVersionDefinition.EffectiveRpmBuildRepos]. func (v DistroVersionDefinition) EffectiveImageBuildRepos(resources *ResourcesConfig) ([]string, error) { - return effectiveInputRepos("image-build", v.Inputs.ImageBuild, resources) + return effectiveInputRepos(UseCaseImageBuild, v.Inputs.ImageBuild, resources) } func effectiveInputRepos( diff --git a/internal/repo/repolayout/layout.go b/internal/repo/repolayout/layout.go index 4d6df6a6..473479f2 100644 --- a/internal/repo/repolayout/layout.go +++ b/internal/repo/repolayout/layout.go @@ -49,6 +49,16 @@ type InputRepo struct { // set by the caller. Used together with [InputRepo.PrefixIndex] to mint // human-readable repo ids that disambiguate multi-prefix runs. PrefixCount int + // RepoID, when non-empty, is used verbatim as the dnf repo id instead of + // being synthesized from SubrepoName/Arch/PrefixIndex. Callers that build + // the input list from a resolved project config set this to the canonical + // resource id (e.g. "azl4-beta-base", "fedora-43-everything") so dnf logs + // match the config the user authored. + RepoID string + // GPGKey, when non-empty, is forwarded to dnf as --setopt=.gpgkey=... + // alongside gpgcheck=1. Only meaningful when the caller resolved this + // repo from project config; the prefix-driven path leaves it empty. + GPGKey string } // ResolveTemplate looks up name in the supplied templates map (typically @@ -142,3 +152,11 @@ func NormalizePrefix(prefix string) (string, error) { return strings.TrimRight(prefix, "/"), nil } + +// SubstituteBasearch replaces every `$basearch` occurrence in raw with arch. +// Used by the version-mode resolver to bake the host arch into a URL whose +// shape carries `$basearch` literally (dnf would substitute on its own, but +// our probe layer needs a concrete URL). +func SubstituteBasearch(raw, arch string) string { + return strings.ReplaceAll(raw, basearchPlaceholder, arch) +} diff --git a/scenario/__snapshots__/TestSnapshotsContainer_--bogus-flag_stderr_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_--bogus-flag_stderr_1.snap index ed7793b0..827a71ea 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_--bogus-flag_stderr_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_--bogus-flag_stderr_1.snap @@ -9,6 +9,7 @@ Primary commands: image Manage Azure Linux images package Manage binary package configuration project Manage Azure Linux projects + repo Inspect and manage RPM repositories Meta commands: completion Generate the autocompletion script for the specified shell diff --git a/scenario/__snapshots__/TestSnapshotsContainer_--help_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_--help_stdout_1.snap index 05278ee9..77ca08f7 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_--help_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_--help_stdout_1.snap @@ -16,6 +16,7 @@ Primary commands: image Manage Azure Linux images package Manage binary package configuration project Manage Azure Linux projects + repo Inspect and manage RPM repositories Meta commands: completion Generate the autocompletion script for the specified shell diff --git a/scenario/__snapshots__/TestSnapshotsContainer_help_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_help_stdout_1.snap index 05278ee9..77ca08f7 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_help_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_help_stdout_1.snap @@ -16,6 +16,7 @@ Primary commands: image Manage Azure Linux images package Manage binary package configuration project Manage Azure Linux projects + repo Inspect and manage RPM repositories Meta commands: completion Generate the autocompletion script for the specified shell diff --git a/scenario/__snapshots__/TestSnapshots_--bogus-flag_stderr_1.snap b/scenario/__snapshots__/TestSnapshots_--bogus-flag_stderr_1.snap index ed7793b0..827a71ea 100755 --- a/scenario/__snapshots__/TestSnapshots_--bogus-flag_stderr_1.snap +++ b/scenario/__snapshots__/TestSnapshots_--bogus-flag_stderr_1.snap @@ -9,6 +9,7 @@ Primary commands: image Manage Azure Linux images package Manage binary package configuration project Manage Azure Linux projects + repo Inspect and manage RPM repositories Meta commands: completion Generate the autocompletion script for the specified shell diff --git a/scenario/__snapshots__/TestSnapshots_--help_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_--help_stdout_1.snap index 05278ee9..77ca08f7 100755 --- a/scenario/__snapshots__/TestSnapshots_--help_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_--help_stdout_1.snap @@ -16,6 +16,7 @@ Primary commands: image Manage Azure Linux images package Manage binary package configuration project Manage Azure Linux projects + repo Inspect and manage RPM repositories Meta commands: completion Generate the autocompletion script for the specified shell diff --git a/scenario/__snapshots__/TestSnapshots_--help_with_color_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_--help_with_color_stdout_1.snap index df307d9e..9711e319 100755 --- a/scenario/__snapshots__/TestSnapshots_--help_with_color_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_--help_with_color_stdout_1.snap @@ -16,6 +16,7 @@ Primary commands: image Manage Azure Linux images package Manage binary package configuration project Manage Azure Linux projects + repo Inspect and manage RPM repositories Meta commands: completion Generate the autocompletion script for the specified shell diff --git a/scenario/__snapshots__/TestSnapshots_help_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_help_stdout_1.snap index 05278ee9..77ca08f7 100755 --- a/scenario/__snapshots__/TestSnapshots_help_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_help_stdout_1.snap @@ -16,6 +16,7 @@ Primary commands: image Manage Azure Linux images package Manage binary package configuration project Manage Azure Linux projects + repo Inspect and manage RPM repositories Meta commands: completion Generate the autocompletion script for the specified shell