From bf77dbe421de428034d33113eac24153cbe2a5a3 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Thu, 30 Apr 2026 18:54:50 +0000 Subject: [PATCH 1/3] feat(pkg): add --rpm-file flag to package list command Add '--rpm-file ' flag to 'azldev package list' that accepts a JSON RPM source map file (array of {packageName, sourcePackageName} records) and lists all SRPMs and their binary RPMs with their resolved publish channels. - Add RPMFile string field to ListPackageOptions - Add listPackagesFromRPMFile helper to handle the --rpm-file path with early return - Add resolveFromSRPMFile to resolve each SRPM (via SRPMChannel) and each binary RPM (via full publish-channel stack) from the JSON file - Results include a 'type' column ('srpm' or 'rpm') to distinguish entry kinds - '--rpm-file' is mutually exclusive with '-a', '-p', and '--synthesize-debug-packages' - Update docs/user/how-to/inspect-package-config.md: add Type column to output table, document --rpm-file usage and mutual exclusivity, add JSON example - Update auto-generated CLI reference docs (azldev_package_list.md) --- docs/user/how-to/inspect-package-config.md | 44 ++- .../user/reference/cli/azldev_package_list.md | 21 +- internal/app/azldev/cmds/pkg/list.go | 263 +++++++++++++++++- internal/app/azldev/cmds/pkg/list_test.go | 89 +++++- internal/app/azldev/core/mcp/mcpserver.go | 6 +- .../TestMCPServerMode_1.snap.json | 33 +-- 6 files changed, 397 insertions(+), 59 deletions(-) diff --git a/docs/user/how-to/inspect-package-config.md b/docs/user/how-to/inspect-package-config.md index 3468a6e4..74bb91f4 100644 --- a/docs/user/how-to/inspect-package-config.md +++ b/docs/user/how-to/inspect-package-config.md @@ -30,21 +30,22 @@ azldev package list -a Example output: ``` -╭──────────────────┬────────────────┬───────────┬─────────────────╮ -│ PACKAGE │ GROUP │ COMPONENT │ PUBLISH CHANNEL │ -├──────────────────┼────────────────┼───────────┼─────────────────┤ -│ curl-debugsource │ debug-packages │ │ rpm-debug │ -│ libcurl │ base-packages │ │ rpm-base │ -│ libcurl-devel │ devel-packages │ curl │ rpm-base │ -│ wget2-wget │ │ wget2 │ rpm-base │ -╰──────────────────┴────────────────┴───────────┴─────────────────╯ +╭──────────────────┬──────┬────────────────┬───────────┬─────────────────╮ +│ PACKAGE │ TYPE │ GROUP │ COMPONENT │ PUBLISH CHANNEL │ +├──────────────────┼──────┼────────────────┼───────────┼─────────────────┤ +│ curl-debugsource │ rpm │ debug-packages │ │ rpm-debug │ +│ libcurl │ rpm │ base-packages │ │ rpm-base │ +│ libcurl-devel │ rpm │ devel-packages │ curl │ rpm-base │ +│ wget2-wget │ rpm │ │ wget2 │ rpm-base │ +╰──────────────────┴──────┴────────────────┴───────────┴─────────────────╯ ``` ### Column meanings | Column | Meaning | -|--------|---------| -| **Package** | Binary package name (RPM `Name` tag) | +|--------|----------| +| **Package** | Binary or source package name (RPM `Name` tag) | +| **Type** | `rpm` for binary packages, `srpm` for source packages (set when using `--rpm-file`) | | **Group** | Package-group whose `packages` list contains this package, if any | | **Component** | Component that has an explicit `packages.` override for this package, if any | | **Publish Channel** | Effective publish channel after all config layers are applied | @@ -72,6 +73,22 @@ azldev package list libcurl libcurl-devel curl-debugsource You can combine `-a` and `-p` — the results are the union of both selections. +## List Packages From an RPM Source Map File + +Use `--rpm-file` to enumerate all source packages (SRPMs) and their binary RPMs from a +JSON RPM source map file. Each entry in the file maps a binary package name to the source +package (SRPM) name that produced it: + +```bash +azldev package list --rpm-file rpm_source_map.json +``` + +The output includes a `type` column to distinguish SRPMs (`srpm`) from binary RPMs (`rpm`). +SRPM entries use the component's `srpm-channel`; binary RPM entries use the full +publish-channel resolution stack. + +> **Note:** `--rpm-file` is mutually exclusive with `-a`, `-p`, and `--synthesize-debug-packages`. + ## Machine-Readable Output Pass `-q -O json` to get JSON output suitable for scripting: @@ -84,6 +101,7 @@ azldev package list -a -q -O json [ { "packageName": "libcurl", + "type": "rpm", "group": "base-packages", "component": "", "publishChannel": "rpm-base" @@ -92,6 +110,12 @@ azldev package list -a -q -O json ] ``` +For an RPM source map file: + +```bash +azldev package list --rpm-file rpm_source_map.json -q -O json +``` + ## Alias `pkg` is an alias for the `package` subcommand: diff --git a/docs/user/reference/cli/azldev_package_list.md b/docs/user/reference/cli/azldev_package_list.md index b2496a6d..4c096cc4 100644 --- a/docs/user/reference/cli/azldev_package_list.md +++ b/docs/user/reference/cli/azldev_package_list.md @@ -8,10 +8,18 @@ List resolved configuration for binary packages List resolved configuration for binary packages. -Use -a to enumerate all packages that have explicit configuration (via -package-groups or component package overrides). Use -p (or positional args) -to look up one or more specific packages by exact name — including packages -that are not explicitly configured (they resolve using only project defaults). +Use -a to enumerate all packages that have explicit configuration (via package-groups +or component package overrides). + +Use --rpm-file to enumerate all source packages (SRPMs) and their binary RPMs +from a JSON RPM source map file (an array of {"packageName":"bash","sourcePackageName":"bash"} records). +Each SRPM is resolved against the component with the same name; each binary RPM is +resolved using the full publish-channel stack. Results include a 'type' column +("srpm" or "rpm") to distinguish the two. + +Use -p (or positional args) to look up one or more specific packages by exact name — +including packages that are not explicitly configured (they resolve using only project +defaults). Resolution order (lowest to highest priority): 1. Project default-component-config publish settings @@ -30,6 +38,9 @@ azldev package list [package-name...] [flags] # List all explicitly-configured packages azldev package list -a + # List all packages from an RPM source map file + azldev package list --rpm-file rpm_source_map.json + # Look up a specific package azldev package list -p curl @@ -38,6 +49,7 @@ azldev package list [package-name...] [flags] # Output as JSON for scripting azldev package list -a -q -O json + azldev package list --rpm-file rpm_source_map.json -q -O json ``` ### Options @@ -46,6 +58,7 @@ azldev package list [package-name...] [flags] -a, --all-packages List all explicitly-configured binary packages -h, --help help for list -p, --package stringArray Package name to look up (repeatable) + --rpm-file string Path to a JSON RPM source map file (lists all SRPMs and their binary RPMs) --synthesize-debug-packages Also synthesize '-debuginfo' packages (per reported package) and '-debugsource' packages (per component) ``` diff --git a/internal/app/azldev/cmds/pkg/list.go b/internal/app/azldev/cmds/pkg/list.go index 7065d1bd..6ade26b1 100644 --- a/internal/app/azldev/cmds/pkg/list.go +++ b/internal/app/azldev/cmds/pkg/list.go @@ -4,13 +4,17 @@ package pkg import ( + "encoding/json" + "errors" "fmt" "log/slog" "sort" "strings" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/spf13/cobra" ) @@ -19,6 +23,10 @@ type ListPackageOptions struct { // All selects all packages that appear in any package-group or component package override. All bool + // RPMFile is the path to a JSON RPM source map file (array of {packageName, sourcePackageName} + // records). When set, all source packages (SRPMs) and their binary RPMs are resolved and listed. + RPMFile string + // PackageNames contains specific binary package names to look up. // If a package is not in any explicit config it is still resolved using project defaults. PackageNames []string @@ -46,10 +54,18 @@ func NewPackageListCommand() *cobra.Command { Short: "List resolved configuration for binary packages", Long: `List resolved configuration for binary packages. -Use -a to enumerate all packages that have explicit configuration (via -package-groups or component package overrides). Use -p (or positional args) -to look up one or more specific packages by exact name — including packages -that are not explicitly configured (they resolve using only project defaults). +Use -a to enumerate all packages that have explicit configuration (via package-groups +or component package overrides). + +Use --rpm-file to enumerate all source packages (SRPMs) and their binary RPMs +from a JSON RPM source map file (an array of {"packageName":"bash","sourcePackageName":"bash"} records). +Each SRPM is resolved against the component with the same name; each binary RPM is +resolved using the full publish-channel stack. Results include a 'type' column +("srpm" or "rpm") to distinguish the two. + +Use -p (or positional args) to look up one or more specific packages by exact name — +including packages that are not explicitly configured (they resolve using only project +defaults). Resolution order (lowest to highest priority): 1. Project default-component-config publish settings @@ -60,6 +76,9 @@ Resolution order (lowest to highest priority): Example: ` # List all explicitly-configured packages azldev package list -a + # List all packages from an RPM source map file + azldev package list --rpm-file rpm_source_map.json + # Look up a specific package azldev package list -p curl @@ -67,7 +86,8 @@ Resolution order (lowest to highest priority): azldev package list -p curl -p wget # Output as JSON for scripting - azldev package list -a -q -O json`, + azldev package list -a -q -O json + azldev package list --rpm-file rpm_source_map.json -q -O json`, RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { options.PackageNames = append(args, options.PackageNames...) @@ -76,6 +96,8 @@ Resolution order (lowest to highest priority): } cmd.Flags().BoolVarP(&options.All, "all-packages", "a", false, "List all explicitly-configured binary packages") + cmd.Flags().StringVar(&options.RPMFile, "rpm-file", "", + "Path to a JSON RPM source map file (lists all SRPMs and their binary RPMs)") cmd.Flags().StringArrayVarP(&options.PackageNames, "package", "p", []string{}, "Package name to look up (repeatable)") cmd.Flags().BoolVar(&options.SynthesizeDebugPackages, "synthesize-debug-packages", false, "Also synthesize '-debuginfo' packages (per reported package) and '-debugsource' packages (per component)") @@ -85,16 +107,22 @@ Resolution order (lowest to highest priority): return cmd } -// PackageListResult holds the resolved configuration for a single binary package. +// PackageListResult holds the resolved configuration for a single binary package or source package. type PackageListResult struct { - // PackageName is the binary package name (RPM Name tag). + // PackageName is the RPM Name tag (binary or source package name). PackageName string `json:"packageName" table:"Package"` + // Type is the package type: [PackageTypeSRPM] for source packages, [PackageTypeRPM] for binary + // packages. Always [PackageTypeRPM] for '-a' and '-p' lookups; either value for '--rpm-file'. + Type string `json:"type" table:"Type"` + // Group is the package-group this package belongs to, or empty if it is not in any group. Group string `json:"group" table:"Group"` - // Component is the component that has an explicit per-package override for this package, - // or empty if the package is only configured via a group or project default. + // Component is the resolved component name for this package. + // When using '-a' or '-p', it is the component with an explicit [projectconfig.ComponentConfig.Packages] + // override for this package, or the component whose name matches the package name, or empty if + // no component association can be determined. Component string `json:"component" table:"Component"` // Channel is the resolved publish channel after applying all config layers. @@ -102,6 +130,22 @@ type PackageListResult struct { Channel string `json:"publishChannel" table:"Publish Channel"` } +const ( + // PackageTypeSRPM is the [PackageListResult.Type] value for source packages (SRPMs). + PackageTypeSRPM = "srpm" + + // PackageTypeRPM is the [PackageListResult.Type] value for binary packages (RPMs). + PackageTypeRPM = "rpm" +) + +// rpmSourceEntry is one record in the RPM source map JSON file. +// The file is a JSON array of these objects, each mapping a binary package name to the +// source package (SRPM) name that produced it. +type rpmSourceEntry struct { + PackageName string `json:"packageName"` + SourcePackageName string `json:"sourcePackageName"` +} + // buildComponentPackageIndex builds a map from binary package name to the component // that declares an explicit override for it in its [projectconfig.ComponentConfig.Packages] map. // Returns an error if the same package name appears in more than one component, which would @@ -125,16 +169,64 @@ func buildComponentPackageIndex(components map[string]projectconfig.ComponentCon return compOf, nil } +// listPackagesFromRPMFile implements the '--rpm-file' path of [ListPackages]. +// It validates that '-a', '-p', and '--synthesize-debug-packages' are not also set, +// resolves all SRPMs and binary RPMs from the JSON file, and returns a sorted result list. +func listPackagesFromRPMFile( + env *azldev.Env, + options *ListPackageOptions, + groupOf map[string]string, + proj *projectconfig.ProjectConfig, +) ([]PackageListResult, error) { + if options.All || len(options.PackageNames) > 0 { + return nil, errors.New("'--rpm-file' cannot be used with '-a' or package name arguments ('-p' / positional args)") + } + + if options.SynthesizeDebugPackages { + return nil, errors.New("'--rpm-file' cannot be used together with '--synthesize-debug-packages'") + } + + results, err := resolveFromSRPMFile(env.FS(), options.RPMFile, groupOf, proj) + if err != nil { + return nil, err + } + + // Sort for deterministic output. [resolveFromSRPMFile] iterates over a map, + // so the order of results is non-deterministic without this sort. Use + // additional tie-breakers so entries that share the same package name (for + // example, an SRPM and an RPM from '--rpm-file') also have a stable order. + sort.Slice(results, func(left, right int) bool { + if results[left].PackageName != results[right].PackageName { + return results[left].PackageName < results[right].PackageName + } + + if results[left].Type != results[right].Type { + return results[left].Type < results[right].Type + } + + return results[left].Component < results[right].Component + }) + + return results, nil +} + // ListPackages returns the resolved [PackageListResult] for the packages selected by options. // // If [ListPackageOptions.All] is true, all packages with explicit configuration (via // package groups or component [projectconfig.ComponentConfig.Packages] maps) are included. -// Specific package names in [ListPackageOptions.PackageNames] are always included regardless +// +// If [ListPackageOptions.RPMFile] is set, all source packages and their binary RPMs from +// the JSON file are resolved. Each SRPM uses [projectconfig.ComponentPublishConfig.SRPMChannel] +// from its matching component; each RPM uses the full package-level publish-channel stack with +// the JSON-derived component association. +// +// Specific package names in [ListPackageOptions.PackageNames] are always resolved regardless // of whether they have explicit configuration. func ListPackages(env *azldev.Env, options *ListPackageOptions) ([]PackageListResult, error) { proj := env.Config() // Build an index: pkgName → groupName for packages in any package-group. + // Used by '-a', '--rpm-file', and '-p' modes for group attribution and channel resolution. groupOf := make(map[string]string) for groupName, group := range proj.PackageGroups { @@ -143,13 +235,20 @@ func ListPackages(env *azldev.Env, options *ListPackageOptions) ([]PackageListRe } } + results := make([]PackageListResult, 0) + + if options.RPMFile != "" { + return listPackagesFromRPMFile(env, options, groupOf, proj) + } + // Build an index: pkgName → componentName for packages with explicit component overrides. + // Needed for '-a' and '-p' lookups. compOf, err := buildComponentPackageIndex(proj.Components) if err != nil { return nil, err } - // Collect the set of package names to resolve. + // Collect the set of package names to resolve for '-a' and '-p' modes. toResolve := make(map[string]struct{}) if options.All { @@ -172,8 +271,6 @@ func ListPackages(env *azldev.Env, options *ListPackageOptions) ([]PackageListRe return nil, nil } - results := make([]PackageListResult, 0, len(toResolve)) - for pkgName := range toResolve { result, err := resolvePackageListResult(pkgName, compOf, groupOf, proj) if err != nil { @@ -234,6 +331,7 @@ func resolvePackageListResult( return PackageListResult{ PackageName: pkgName, + Type: PackageTypeRPM, Group: groupOf[pkgName], Component: compName, Channel: channel, @@ -280,6 +378,143 @@ func resolveComponentConfig(compName string, proj *projectconfig.ProjectConfig) return projectconfig.ComponentConfig{Name: compName} } +// resolveSourcePackageListResult resolves the publish channel for a source package (SRPM). +// The SRPM name is always equal to the component name, so no compOf lookup is needed. +// It reads [projectconfig.ComponentPublishConfig.SRPMChannel] from the fully-merged component +// config, which already reflects the full inheritance chain: +// project default → component group → component. +func resolveSourcePackageListResult( + srpmName string, + groupOf map[string]string, + proj *projectconfig.ProjectConfig, +) (PackageListResult, error) { + // The SRPM name is the component name by definition. + compName := srpmName + compConfig := resolveComponentConfig(compName, proj) + + resolved, err := projectconfig.ResolveComponentConfig( + compConfig, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, // no distro context at this call site + proj.ComponentGroups, + proj.GroupsByComponent[compName], + ) + if err != nil { + return PackageListResult{}, fmt.Errorf("failed to resolve defaults for component %#q:\n%w", compName, err) + } + + return PackageListResult{ + PackageName: srpmName, + Type: PackageTypeSRPM, + Group: groupOf[srpmName], + Component: compName, + Channel: resolved.Publish.SRPMChannel, + }, nil +} + +// resolveFromSRPMFile loads a source map file and resolves all SRPMs and their RPMs. +// Returns a flat list of SRPM and RPM entries derived from the file. The returned slice is +// not ordered by contract; callers may sort or otherwise reorder the flattened results. The +// JSON file is parsed once by [loadSRPMFile] to produce both the SRPM → RPMs map and the +// authoritative RPM → component index. +func resolveFromSRPMFile( + fs opctx.FS, + path string, + groupOf map[string]string, + proj *projectconfig.ProjectConfig, +) ([]PackageListResult, error) { + srpmMap, rpmCompOf, err := loadSRPMFile(fs, path) + if err != nil { + return nil, err + } + + results := make([]PackageListResult, 0, len(srpmMap)) + + for srpmName, rpmNames := range srpmMap { + srpmResult, resolveErr := resolveSourcePackageListResult(srpmName, groupOf, proj) + if resolveErr != nil { + return nil, resolveErr + } + + results = append(results, srpmResult) + + for _, rpmName := range rpmNames { + rpmResult, resolveErr := resolvePackageListResult(rpmName, rpmCompOf, groupOf, proj) + if resolveErr != nil { + return nil, resolveErr + } + + results = append(results, rpmResult) + } + } + + return results, nil +} + +// loadSRPMFile reads and parses a JSON RPM source map from path on fs. +// The file is a JSON array of [rpmSourceEntry] records. +// Returns: +// - srpmMap: source package name → ordered list of binary RPM names it produces +// - rpmCompOf: binary RPM name → source package (component) name +// +// Both maps are built in a single pass over the JSON entries. +func loadSRPMFile(fs opctx.FS, path string) (srpmMap map[string][]string, rpmCompOf map[string]string, err error) { + data, readErr := fileutils.ReadFile(fs, path) + if readErr != nil { + return nil, nil, fmt.Errorf("reading RPM source map %#q:\n%w", path, readErr) + } + + var entries []rpmSourceEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, nil, fmt.Errorf("parsing RPM source map %#q:\n%w", path, err) + } + + srpmMap = make(map[string][]string) + rpmCompOf = make(map[string]string, len(entries)) + + for idx, e := range entries { + packageName := strings.TrimSpace(e.PackageName) + sourcePackageName := strings.TrimSpace(e.SourcePackageName) + + if packageName == "" { + return nil, nil, fmt.Errorf( + "invalid RPM source map %#q entry %d:\nmissing non-empty 'packageName'", + path, + idx, + ) + } + + if sourcePackageName == "" { + return nil, nil, fmt.Errorf( + "invalid RPM source map %#q entry %d for package %#q:\nmissing non-empty 'sourcePackageName'", + path, + idx, + packageName, + ) + } + + if existingSourcePackageName, exists := rpmCompOf[packageName]; exists { + if existingSourcePackageName != sourcePackageName { + return nil, nil, fmt.Errorf( + "invalid RPM source map %#q entry %d for package %#q:\nconflicting source package names %#q and %#q", + path, + idx, + packageName, + existingSourcePackageName, + sourcePackageName, + ) + } + // Ignore repeated identical mappings so [srpmMap] does not accumulate duplicates. + continue + } + + srpmMap[sourcePackageName] = append(srpmMap[sourcePackageName], packageName) + rpmCompOf[packageName] = sourcePackageName + } + + return srpmMap, rpmCompOf, nil +} + // synthesizeDebugPackages augments results with synthetic '-debuginfo' packages (one per // already-resolved package, using a parallel publish channel derived from the original // package's publish channel by appending '-debuginfo', except when the original channel is @@ -320,6 +555,7 @@ func synthesizeDebugPackages( existing[name] = struct{}{} debugInfoEntries = append(debugInfoEntries, PackageListResult{ PackageName: name, + Type: PackageTypeRPM, Group: result.Group, Component: result.Component, Channel: debugChannelName(result.Channel), @@ -353,6 +589,7 @@ func synthesizeDebugPackages( results = append(results, PackageListResult{ PackageName: name, + Type: PackageTypeRPM, Component: compName, Channel: debugChannelName(pkgConfig.Publish.EffectiveRPMChannel()), }) diff --git a/internal/app/azldev/cmds/pkg/list_test.go b/internal/app/azldev/cmds/pkg/list_test.go index 02abb338..356b0f34 100644 --- a/internal/app/azldev/cmds/pkg/list_test.go +++ b/internal/app/azldev/cmds/pkg/list_test.go @@ -4,11 +4,14 @@ package pkg_test import ( + "encoding/json" "testing" pkgcmds "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/pkg" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,7 +39,7 @@ func TestListPackages_NoCriteria(t *testing.T) { func TestListPackages_Empty(t *testing.T) { testEnv := testutils.NewTestEnv(t) - results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{All: true}) + results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{}) require.NoError(t, err) assert.Empty(t, results) @@ -53,7 +56,9 @@ func TestListPackages_FromPackageGroup(t *testing.T) { }, } - results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{All: true}) + results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{ + PackageNames: []string{"curl-devel", "wget2-devel"}, + }) require.NoError(t, err) require.Len(t, results, 2) @@ -80,7 +85,7 @@ func TestListPackages_FromComponentPackageOverride(t *testing.T) { }, } - results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{All: true}) + results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{PackageNames: []string{"curl-minimal"}}) require.NoError(t, err) require.Len(t, results, 1) @@ -109,7 +114,7 @@ func TestListPackages_ComponentOverrideWinsOverGroup(t *testing.T) { }, } - results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{All: true}) + results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{PackageNames: []string{"curl-devel"}}) require.NoError(t, err) require.Len(t, results, 1) @@ -128,7 +133,9 @@ func TestListPackages_SortedByName(t *testing.T) { }, } - results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{All: true}) + results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{ + PackageNames: []string{"zzz-pkg", "aaa-pkg", "mmm-pkg"}, + }) require.NoError(t, err) require.Len(t, results, 3) @@ -207,7 +214,7 @@ func TestListPackages_DuplicatePackageAcrossComponents_ReturnsError(t *testing.T }, } - _, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{All: true}) + _, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{PackageNames: []string{"shared-pkg"}}) require.Error(t, err) assert.Contains(t, err.Error(), "shared-pkg") @@ -233,7 +240,7 @@ func TestListPackages_SynthesizeDebugPackages(t *testing.T) { testEnv.Config.Components["wget"] = projectconfig.ComponentConfig{Name: "wget"} results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{ - All: true, + PackageNames: []string{"curl-devel"}, SynthesizeDebugPackages: true, }) @@ -283,7 +290,7 @@ func TestListPackages_SynthesizeDebugPackages_SkipsExisting(t *testing.T) { } results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{ - All: true, + PackageNames: []string{"curl", "curl-debuginfo", "curl-debugsource"}, SynthesizeDebugPackages: true, }) @@ -376,7 +383,7 @@ func TestListPackages_SynthesizeDebugPackages_ChannelSuffixRules(t *testing.T) { } results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{ - All: true, + PackageNames: []string{"pkg-none", "pkg-empty", "pkg-already"}, SynthesizeDebugPackages: true, }) @@ -421,3 +428,67 @@ func TestListPackages_ComponentGroupPublishChannel(t *testing.T) { // The group's rpm-base channel must win over the project default rpm-sdk. assert.Equal(t, "rpm-base", results[0].Channel) } + +func TestListPackages_SRPMFile_UsesSRPMChannel(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + // Project default component config sets both RPM and SRPM channels. + testEnv.Config.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpm-sdk", + SRPMChannel: "rpm-sdk-srpm", + }, + } + + // Component with a higher-priority SRPM channel override. + testEnv.Config.Components["curl"] = projectconfig.ComponentConfig{ + Name: "curl", + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpm-base", + SRPMChannel: "rpm-base-srpm", + }, + } + + const srpmMapPath = "/test-srpm-map.json" + + entries := []map[string]string{ + // 389-ds-base has no component entry — resolves from project default. + {"packageName": "389-ds-base", "sourcePackageName": "389-ds-base"}, + {"packageName": "389-ds-base-devel", "sourcePackageName": "389-ds-base"}, + // curl has an explicit component entry — resolves from component config. + {"packageName": "curl", "sourcePackageName": "curl"}, + {"packageName": "curl-devel", "sourcePackageName": "curl"}, + } + + data, jsonErr := json.Marshal(entries) + require.NoError(t, jsonErr) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, srpmMapPath, data, fileperms.PublicFile)) + + results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{RPMFile: srpmMapPath}) + require.NoError(t, err) + + byName := make(map[string]pkgcmds.PackageListResult, len(results)) + for _, r := range results { + byName[r.PackageName+"#"+r.Type] = r + } + + // SRPMs must use SRPMChannel, not RPMChannel. + srpm389 := byName["389-ds-base#"+pkgcmds.PackageTypeSRPM] + assert.Equal(t, pkgcmds.PackageTypeSRPM, srpm389.Type) + assert.Equal(t, "rpm-sdk-srpm", srpm389.Channel, "SRPM should use SRPMChannel from project default") + + srpmCurl := byName["curl#"+pkgcmds.PackageTypeSRPM] + assert.Equal(t, pkgcmds.PackageTypeSRPM, srpmCurl.Type) + assert.Equal(t, "rpm-base-srpm", srpmCurl.Channel, "SRPM should use SRPMChannel from component config") + + // RPMs must use RPMChannel, not SRPMChannel. + rpm389 := byName["389-ds-base#"+pkgcmds.PackageTypeRPM] + assert.Equal(t, pkgcmds.PackageTypeRPM, rpm389.Type) + assert.Equal(t, "rpm-sdk", rpm389.Channel, "binary RPM should use RPMChannel from project default") + + rpmCurlDevel := byName["curl-devel#"+pkgcmds.PackageTypeRPM] + assert.Equal(t, pkgcmds.PackageTypeRPM, rpmCurlDevel.Type) + // curl-devel's component is resolved from the JSON (sourcePackageName = "curl"), + // so it correctly inherits the curl component's RPMChannel. + assert.Equal(t, "rpm-base", rpmCurlDevel.Channel, "binary RPM should use RPMChannel from its SRPM's component") +} diff --git a/internal/app/azldev/core/mcp/mcpserver.go b/internal/app/azldev/core/mcp/mcpserver.go index da717518..51948319 100644 --- a/internal/app/azldev/core/mcp/mcpserver.go +++ b/internal/app/azldev/core/mcp/mcpserver.go @@ -88,8 +88,10 @@ func addToolForCmd(env *azldev.Env, srv *server.MCPServer, leaf *cobra.Command) continue } - // Assume a flag with no default value is required. - if flag.DefValue == "" { + // Mirror cobra's required-flag annotation (set by cmd.MarkFlagRequired) into the MCP + // tool schema. Flags without this annotation are exposed as optional, even if their + // default is the type's zero value. + if _, required := flag.Annotations[cobra.BashCompOneRequiredFlag]; required { propOptions = append(propOptions, mcp.Required()) } diff --git a/scenario/__snapshots__/TestMCPServerMode_1.snap.json b/scenario/__snapshots__/TestMCPServerMode_1.snap.json index cec130da..b1fe5a00 100755 --- a/scenario/__snapshots__/TestMCPServerMode_1.snap.json +++ b/scenario/__snapshots__/TestMCPServerMode_1.snap.json @@ -93,10 +93,7 @@ "type": "boolean" } }, - "required": [ - "output-file", - "project" - ], + "required": [], "type": "object" }, "name": "component-diff-sources" @@ -186,9 +183,7 @@ "type": "boolean" } }, - "required": [ - "project" - ], + "required": [], "type": "object" }, "name": "component-list" @@ -260,9 +255,7 @@ "type": "boolean" } }, - "required": [ - "project" - ], + "required": [], "type": "object" }, "name": "config-dump" @@ -330,9 +323,7 @@ "type": "boolean" } }, - "required": [ - "project" - ], + "required": [], "type": "object" }, "name": "config-generate-schema" @@ -416,8 +407,7 @@ } }, "required": [ - "output-dir", - "project" + "output-dir" ], "type": "object" }, @@ -486,9 +476,7 @@ "type": "boolean" } }, - "required": [ - "project" - ], + "required": [], "type": "object" }, "name": "image-list" @@ -559,6 +547,11 @@ "description": "only enable minimal output", "type": "boolean" }, + "rpm-file": { + "default": "", + "description": "Path to a JSON RPM source map file (lists all SRPMs and their binary RPMs)", + "type": "string" + }, "synthesize-debug-packages": { "default": false, "description": "Also synthesize '-debuginfo' packages (per reported package) and '-debugsource' packages (per component)", @@ -570,9 +563,7 @@ "type": "boolean" } }, - "required": [ - "project" - ], + "required": [], "type": "object" }, "name": "package-list" From c384305687ccf4bbb42011aef1e07c5cc14b1d5b Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Fri, 1 May 2026 17:42:06 +0000 Subject: [PATCH 2/3] refactor(pkg): address PR feedback on --rpm-file flag - Use cobra's MarkFlagsMutuallyExclusive for '--rpm-file' vs '-a', '-p', and '--synthesize-debug-packages' instead of runtime checks - Add Args validator to reject positional args when '--rpm-file' is set - Rename loadSRPMFile -> loadRPMFile and resolveFromSRPMFile -> resolveFromRPMFile to match the '--rpm-file' flag name (the file is a JSON RPM source map) - Drop strings.TrimSpace on JSON-parsed names --- internal/app/azldev/cmds/pkg/list.go | 46 ++++++++++++++++------------ 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/internal/app/azldev/cmds/pkg/list.go b/internal/app/azldev/cmds/pkg/list.go index 6ade26b1..9e575083 100644 --- a/internal/app/azldev/cmds/pkg/list.go +++ b/internal/app/azldev/cmds/pkg/list.go @@ -88,6 +88,15 @@ Resolution order (lowest to highest priority): # Output as JSON for scripting azldev package list -a -q -O json azldev package list --rpm-file rpm_source_map.json -q -O json`, + Args: func(cmd *cobra.Command, args []string) error { + // Positional package names aren't flags, so they can't participate in cobra's + // flag-group machinery; enforce the '--rpm-file' incompatibility here. + if cmd.Flags().Changed("rpm-file") && len(args) > 0 { + return errors.New("'--rpm-file' cannot be used with positional package name arguments") + } + + return nil + }, RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { options.PackageNames = append(args, options.PackageNames...) @@ -102,6 +111,11 @@ Resolution order (lowest to highest priority): cmd.Flags().BoolVar(&options.SynthesizeDebugPackages, "synthesize-debug-packages", false, "Also synthesize '-debuginfo' packages (per reported package) and '-debugsource' packages (per component)") + // '--rpm-file' is mutually exclusive with the other selection / augmentation flags. + cmd.MarkFlagsMutuallyExclusive("rpm-file", "all-packages") + cmd.MarkFlagsMutuallyExclusive("rpm-file", "package") + cmd.MarkFlagsMutuallyExclusive("rpm-file", "synthesize-debug-packages") + azldev.ExportAsMCPTool(cmd) return cmd @@ -170,28 +184,20 @@ func buildComponentPackageIndex(components map[string]projectconfig.ComponentCon } // listPackagesFromRPMFile implements the '--rpm-file' path of [ListPackages]. -// It validates that '-a', '-p', and '--synthesize-debug-packages' are not also set, -// resolves all SRPMs and binary RPMs from the JSON file, and returns a sorted result list. +// Mutual exclusivity with '-a', '-p', '--synthesize-debug-packages', and positional +// package name arguments is enforced by cobra (see [NewPackageListCommand]). func listPackagesFromRPMFile( env *azldev.Env, options *ListPackageOptions, groupOf map[string]string, proj *projectconfig.ProjectConfig, ) ([]PackageListResult, error) { - if options.All || len(options.PackageNames) > 0 { - return nil, errors.New("'--rpm-file' cannot be used with '-a' or package name arguments ('-p' / positional args)") - } - - if options.SynthesizeDebugPackages { - return nil, errors.New("'--rpm-file' cannot be used together with '--synthesize-debug-packages'") - } - - results, err := resolveFromSRPMFile(env.FS(), options.RPMFile, groupOf, proj) + results, err := resolveFromRPMFile(env.FS(), options.RPMFile, groupOf, proj) if err != nil { return nil, err } - // Sort for deterministic output. [resolveFromSRPMFile] iterates over a map, + // Sort for deterministic output. [resolveFromRPMFile] iterates over a map, // so the order of results is non-deterministic without this sort. Use // additional tie-breakers so entries that share the same package name (for // example, an SRPM and an RPM from '--rpm-file') also have a stable order. @@ -412,18 +418,18 @@ func resolveSourcePackageListResult( }, nil } -// resolveFromSRPMFile loads a source map file and resolves all SRPMs and their RPMs. +// resolveFromRPMFile loads a source map file and resolves all SRPMs and their RPMs. // Returns a flat list of SRPM and RPM entries derived from the file. The returned slice is // not ordered by contract; callers may sort or otherwise reorder the flattened results. The -// JSON file is parsed once by [loadSRPMFile] to produce both the SRPM → RPMs map and the +// JSON file is parsed once by [loadRPMFile] to produce both the SRPM → RPMs map and the // authoritative RPM → component index. -func resolveFromSRPMFile( +func resolveFromRPMFile( fs opctx.FS, path string, groupOf map[string]string, proj *projectconfig.ProjectConfig, ) ([]PackageListResult, error) { - srpmMap, rpmCompOf, err := loadSRPMFile(fs, path) + srpmMap, rpmCompOf, err := loadRPMFile(fs, path) if err != nil { return nil, err } @@ -451,14 +457,14 @@ func resolveFromSRPMFile( return results, nil } -// loadSRPMFile reads and parses a JSON RPM source map from path on fs. +// loadRPMFile reads and parses a JSON RPM source map from path on fs. // The file is a JSON array of [rpmSourceEntry] records. // Returns: // - srpmMap: source package name → ordered list of binary RPM names it produces // - rpmCompOf: binary RPM name → source package (component) name // // Both maps are built in a single pass over the JSON entries. -func loadSRPMFile(fs opctx.FS, path string) (srpmMap map[string][]string, rpmCompOf map[string]string, err error) { +func loadRPMFile(fs opctx.FS, path string) (srpmMap map[string][]string, rpmCompOf map[string]string, err error) { data, readErr := fileutils.ReadFile(fs, path) if readErr != nil { return nil, nil, fmt.Errorf("reading RPM source map %#q:\n%w", path, readErr) @@ -473,8 +479,8 @@ func loadSRPMFile(fs opctx.FS, path string) (srpmMap map[string][]string, rpmCom rpmCompOf = make(map[string]string, len(entries)) for idx, e := range entries { - packageName := strings.TrimSpace(e.PackageName) - sourcePackageName := strings.TrimSpace(e.SourcePackageName) + packageName := e.PackageName + sourcePackageName := e.SourcePackageName if packageName == "" { return nil, nil, fmt.Errorf( From b9920cedfab7c5507bb462b5314af0462f9b151e Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Fri, 1 May 2026 18:51:55 +0000 Subject: [PATCH 3/3] refactor(pkg): cover loadRPMFile validation paths Add table-driven tests for invalid JSON, empty packageName, empty sourcePackageName, conflicting source package names, and the silent dedup of identical duplicate mappings. Update docs and snapshot --- docs/user/reference/cli/azldev_package.md | 2 +- .../user/reference/cli/azldev_package_list.md | 4 +- internal/app/azldev/cmds/pkg/list.go | 12 ++-- internal/app/azldev/cmds/pkg/list_test.go | 68 +++++++++++++++++++ .../TestMCPServerMode_1.snap.json | 2 +- 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/docs/user/reference/cli/azldev_package.md b/docs/user/reference/cli/azldev_package.md index 28a583c8..d3fefb25 100644 --- a/docs/user/reference/cli/azldev_package.md +++ b/docs/user/reference/cli/azldev_package.md @@ -37,5 +37,5 @@ publish channel assignments derived from package groups and component overrides. ### SEE ALSO * [azldev](azldev.md) - 🐧 Azure Linux Dev Tool -* [azldev package list](azldev_package_list.md) - List resolved configuration for binary packages +* [azldev package list](azldev_package_list.md) - List resolved configuration for packages (RPMs and SRPMs) diff --git a/docs/user/reference/cli/azldev_package_list.md b/docs/user/reference/cli/azldev_package_list.md index 4c096cc4..3b5747c2 100644 --- a/docs/user/reference/cli/azldev_package_list.md +++ b/docs/user/reference/cli/azldev_package_list.md @@ -2,11 +2,11 @@ ## azldev package list -List resolved configuration for binary packages +List resolved configuration for packages (RPMs and SRPMs) ### Synopsis -List resolved configuration for binary packages. +List resolved configuration for packages (RPMs and SRPMs). Use -a to enumerate all packages that have explicit configuration (via package-groups or component package overrides). diff --git a/internal/app/azldev/cmds/pkg/list.go b/internal/app/azldev/cmds/pkg/list.go index 9e575083..f645ecd3 100644 --- a/internal/app/azldev/cmds/pkg/list.go +++ b/internal/app/azldev/cmds/pkg/list.go @@ -51,8 +51,8 @@ func NewPackageListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list [package-name...]", - Short: "List resolved configuration for binary packages", - Long: `List resolved configuration for binary packages. + Short: "List resolved configuration for packages (RPMs and SRPMs)", + Long: `List resolved configuration for packages (RPMs and SRPMs). Use -a to enumerate all packages that have explicit configuration (via package-groups or component package overrides). @@ -116,6 +116,9 @@ Resolution order (lowest to highest priority): cmd.MarkFlagsMutuallyExclusive("rpm-file", "package") cmd.MarkFlagsMutuallyExclusive("rpm-file", "synthesize-debug-packages") + // Help shells complete '--rpm-file' with .json paths. + _ = cmd.MarkFlagFilename("rpm-file", "json") + azldev.ExportAsMCPTool(cmd) return cmd @@ -391,7 +394,6 @@ func resolveComponentConfig(compName string, proj *projectconfig.ProjectConfig) // project default → component group → component. func resolveSourcePackageListResult( srpmName string, - groupOf map[string]string, proj *projectconfig.ProjectConfig, ) (PackageListResult, error) { // The SRPM name is the component name by definition. @@ -412,7 +414,7 @@ func resolveSourcePackageListResult( return PackageListResult{ PackageName: srpmName, Type: PackageTypeSRPM, - Group: groupOf[srpmName], + Group: "", // SRPMs have no package-group membership in the config model Component: compName, Channel: resolved.Publish.SRPMChannel, }, nil @@ -437,7 +439,7 @@ func resolveFromRPMFile( results := make([]PackageListResult, 0, len(srpmMap)) for srpmName, rpmNames := range srpmMap { - srpmResult, resolveErr := resolveSourcePackageListResult(srpmName, groupOf, proj) + srpmResult, resolveErr := resolveSourcePackageListResult(srpmName, proj) if resolveErr != nil { return nil, resolveErr } diff --git a/internal/app/azldev/cmds/pkg/list_test.go b/internal/app/azldev/cmds/pkg/list_test.go index 356b0f34..a976b8cc 100644 --- a/internal/app/azldev/cmds/pkg/list_test.go +++ b/internal/app/azldev/cmds/pkg/list_test.go @@ -492,3 +492,71 @@ func TestListPackages_SRPMFile_UsesSRPMChannel(t *testing.T) { // so it correctly inherits the curl component's RPMChannel. assert.Equal(t, "rpm-base", rpmCurlDevel.Channel, "binary RPM should use RPMChannel from its SRPM's component") } + +// TestListPackages_RPMFile_Validation exercises the JSON parsing and validation +// error paths in 'loadRPMFile' (invalid JSON, missing fields, conflicting +// mappings) and the silent dedup path for repeated identical mappings. +func TestListPackages_RPMFile_Validation(t *testing.T) { + const path = "/test-rpm-map.json" + + cases := []struct { + name string + body string // raw file contents (not necessarily valid JSON) + wantErrSub string // expected substring in the returned error; empty means success + wantResults int // expected number of results on success + }{ + { + name: "invalid json", + body: "not json", + wantErrSub: "parsing RPM source map", + }, + { + name: "empty packageName", + body: `[{"packageName":"","sourcePackageName":"bash"}]`, + wantErrSub: "missing non-empty 'packageName'", + }, + { + name: "empty sourcePackageName", + body: `[{"packageName":"bash","sourcePackageName":""}]`, + wantErrSub: "missing non-empty 'sourcePackageName'", + }, + { + name: "conflicting source package names", + body: `[ + {"packageName":"bash","sourcePackageName":"bash"}, + {"packageName":"bash","sourcePackageName":"other"} + ]`, + wantErrSub: "conflicting source package names", + }, + { + // Identical duplicate mappings must not produce duplicate entries: + // one SRPM result + one RPM result, not one SRPM + two RPMs. + name: "duplicate identical mappings dedup", + body: `[ + {"packageName":"bash","sourcePackageName":"bash"}, + {"packageName":"bash","sourcePackageName":"bash"} + ]`, + wantResults: 2, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + require.NoError(t, fileutils.WriteFile(testEnv.TestFS, path, []byte(testCase.body), fileperms.PublicFile)) + + results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{RPMFile: path}) + + if testCase.wantErrSub != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), testCase.wantErrSub) + assert.Nil(t, results) + + return + } + + require.NoError(t, err) + assert.Len(t, results, testCase.wantResults) + }) + } +} diff --git a/scenario/__snapshots__/TestMCPServerMode_1.snap.json b/scenario/__snapshots__/TestMCPServerMode_1.snap.json index b1fe5a00..e0c0823a 100755 --- a/scenario/__snapshots__/TestMCPServerMode_1.snap.json +++ b/scenario/__snapshots__/TestMCPServerMode_1.snap.json @@ -488,7 +488,7 @@ "openWorldHint": true, "readOnlyHint": false }, - "description": "List resolved configuration for binary packages", + "description": "List resolved configuration for packages (RPMs and SRPMs)", "inputSchema": { "properties": { "accept-all": {