From 58fcdc08576f89548df8e7b85f76f204b3c64ced Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Fri, 20 Mar 2026 20:25:54 +0000 Subject: [PATCH 1/7] feat(projectconfig): add package publish channel annotations to TOML schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a layered package-config resolution system that annotates each built binary RPM with a publish channel derived from project config. Schema additions: - PackagePublishConfig { channel, description } - PackageConfig { description, publish } - PackageGroupConfig { description, package-patterns, default-package-config } - ProjectConfig.DefaultPackageConfig — project-wide baseline (lowest priority) - ProjectConfig.PackageGroups — named groups matched by binary package name globs - ComponentConfig.DefaultPackageConfig, ComponentConfig.Packages Build output enrichment: - RPMResult struct with Path, PackageName, Channel - ComponentBuildResults.RPMs and RPMChannels populated post-build --- internal/app/azldev/cmds/component/build.go | 91 +++++++ internal/projectconfig/component.go | 20 +- internal/projectconfig/configfile.go | 14 + internal/projectconfig/loader.go | 37 +++ internal/projectconfig/package.go | 146 ++++++++++ internal/projectconfig/package_test.go | 282 ++++++++++++++++++++ internal/projectconfig/project.go | 9 + 7 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 internal/projectconfig/package.go create mode 100644 internal/projectconfig/package_test.go diff --git a/internal/app/azldev/cmds/component/build.go b/internal/app/azldev/cmds/component/build.go index d472687..739e52f 100644 --- a/internal/app/azldev/cmds/component/build.go +++ b/internal/app/azldev/cmds/component/build.go @@ -6,14 +6,17 @@ package component import ( "errors" "fmt" + "os" "path/filepath" + rpmlib "github.com/cavaliergopher/rpm" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/componentbuilder" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/workdir" "github.com/microsoft/azure-linux-dev-tools/internal/buildenv" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" "github.com/microsoft/azure-linux-dev-tools/internal/utils/defers" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" @@ -37,6 +40,20 @@ type ComponentBuildOptions struct { MockConfigOpts map[string]string } +// RPMResult encapsulates a single binary RPM produced by a component build, +// together with the resolved publish channel for that package. +type RPMResult struct { + // Path is the absolute path to the RPM file. + Path string `json:"path" table:"Path"` + + // PackageName is the binary package name extracted from the RPM filename (e.g., "curl-devel"). + PackageName string `json:"packageName" table:"Package"` + + // Channel is the resolved publish channel from project config. + // Empty when no channel is configured for this package. + Channel string `json:"channel" table:"Channel"` +} + // ComponentBuildResults summarizes the results of building a single component. type ComponentBuildResults struct { // Names of the component that was built. @@ -47,6 +64,13 @@ type ComponentBuildResults struct { // Absolute paths to any RPMs built by the operation. RPMPaths []string `json:"rpmPaths" table:"RPM Paths"` + + // RPMChannels holds the resolved publish channel for each RPM, parallel to [RPMPaths]. + // Empty string means no channel was configured for that package. + RPMChannels []string `json:"rpmChannels" table:"Channels"` + + // RPMs contains enriched per-RPM information including the resolved publish channel. + RPMs []RPMResult `json:"rpms" table:"-"` } func buildOnAppInit(_ *azldev.App, parent *cobra.Command) { @@ -288,6 +312,18 @@ func buildComponentUsingBuilder( return results, fmt.Errorf("failed to build RPM for %q: %w", component.GetName(), err) } + // Enrich each RPM with its binary package name and resolved publish channel. + results.RPMs, err = resolveRPMResults(env, results.RPMPaths, component.GetConfig()) + if err != nil { + return results, fmt.Errorf("failed to resolve publish channels for %q:\n%w", component.GetName(), err) + } + + // Populate the parallel Channels slice for table display. + results.RPMChannels = make([]string, len(results.RPMs)) + for rpmIdx, rpm := range results.RPMs { + results.RPMChannels[rpmIdx] = rpm.Channel + } + // Publish built RPMs to local repo with publish enabled. if localRepoWithPublishPath != "" && len(results.RPMPaths) > 0 { publishErr := publishToLocalRepo(env, results.RPMPaths, localRepoWithPublishPath) @@ -352,6 +388,61 @@ func checkLocalRepoPathOverlap(localRepoPaths []string, localRepoWithPublishPath return nil } +// resolveRPMResults builds an [RPMResult] for each RPM path, extracting the binary package +// name from the filename and resolving its publish channel from the project config (if available). +// When no project config is loaded, the Channel field is left empty. +func resolveRPMResults( + env *azldev.Env, rpmPaths []string, compConfig *projectconfig.ComponentConfig, +) ([]RPMResult, error) { + proj := env.Config() + + rpmResults := make([]RPMResult, 0, len(rpmPaths)) + + for _, rpmPath := range rpmPaths { + pkgName, err := packageNameFromRPMFilename(rpmPath) + if err != nil { + return nil, fmt.Errorf("failed to determine package name:\n%w", err) + } + + rpmResult := RPMResult{ + Path: rpmPath, + PackageName: pkgName, + } + + if proj != nil { + pkgConfig, err := projectconfig.ResolvePackageConfig(pkgName, compConfig, proj) + if err != nil { + return nil, fmt.Errorf("failed to resolve package config for %#q:\n%w", pkgName, err) + } + + rpmResult.Channel = pkgConfig.Publish.Channel + } + + rpmResults = append(rpmResults, rpmResult) + } + + return rpmResults, nil +} + +// packageNameFromRPMFilename extracts the binary package name from an RPM file by reading +// its headers. Reading the Name tag directly from the RPM metadata is authoritative and +// handles all valid package names regardless of naming conventions. +func packageNameFromRPMFilename(rpmPath string) (pkgName string, err error) { + rpmFile, err := os.Open(rpmPath) + if err != nil { + return "", fmt.Errorf("failed to open RPM %#q:\n%w", rpmPath, err) + } + + defer rpmFile.Close() + + pkg, err := rpmlib.Read(rpmFile) + if err != nil { + return "", fmt.Errorf("failed to read RPM headers from %#q:\n%w", rpmPath, err) + } + + return pkg.Name(), nil +} + // publishToLocalRepo publishes the given RPMs to the specified local repo. func publishToLocalRepo(env *azldev.Env, rpmPaths []string, repoPath string) error { publisher, err := localrepo.NewPublisher(env, repoPath, false) diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index 6910021..189887d 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -128,6 +128,14 @@ type ComponentConfig struct { // Source file references for this component. SourceFiles []SourceFileReference `toml:"source-files,omitempty" json:"sourceFiles,omitempty" table:"-" jsonschema:"title=Source files,description=Source files to download for this component"` + + // Default configuration applied to all binary packages produced by this component. + // Takes precedence over package-group defaults; overridden by explicit Packages entries. + DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" table:"-" jsonschema:"title=Default package config,description=Default configuration applied to all binary packages produced by this component"` + + // Per-package configuration overrides, keyed by exact binary package name. + // Takes precedence over DefaultPackageConfig and package-group defaults. + Packages map[string]PackageConfig `toml:"packages,omitempty" json:"packages,omitempty" table:"-" jsonschema:"title=Package overrides,description=Per-package configuration overrides keyed by exact binary package name"` } // Mutates the component config, updating it with overrides present in other. @@ -147,11 +155,13 @@ func (c *ComponentConfig) WithAbsolutePaths(referenceDir string) *ComponentConfi // the SourceConfigFile, as we *do* want to alias that pointer, sharing it across // all configs that came from that source config file. result := &ComponentConfig{ - Name: c.Name, - SourceConfigFile: c.SourceConfigFile, - Spec: deep.MustCopy(c.Spec), - Build: deep.MustCopy(c.Build), - SourceFiles: deep.MustCopy(c.SourceFiles), + Name: c.Name, + SourceConfigFile: c.SourceConfigFile, + Spec: deep.MustCopy(c.Spec), + Build: deep.MustCopy(c.Build), + SourceFiles: deep.MustCopy(c.SourceFiles), + DefaultPackageConfig: deep.MustCopy(c.DefaultPackageConfig), + Packages: deep.MustCopy(c.Packages), } // Fix up paths. diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index 9274472..f89108d 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -43,6 +43,13 @@ type ConfigFile struct { // Configuration for tools used by azldev. Tools *ToolsConfig `toml:"tools,omitempty" jsonschema:"title=Tools configuration,description=Configuration for tools used by azldev"` + // DefaultPackageConfig is the project-wide default package configuration applied before any + // package-group or component-level config is considered. + DefaultPackageConfig *PackageConfig `toml:"default-package-config,omitempty" jsonschema:"title=Default package config,description=Project-wide default applied to all binary packages before group and component overrides"` + + // Definitions of package groups for publish-time routing of binary packages. + PackageGroups map[string]PackageGroupConfig `toml:"package-groups,omitempty" jsonschema:"title=Package groups,description=Definitions of package groups for publish-time routing"` + // Internal fields used to track the origin of the config file; `dir` is the directory // that the config file's relative paths are based from. sourcePath string `toml:"-"` @@ -56,6 +63,13 @@ func (f ConfigFile) Validate() error { return fmt.Errorf("config file error:\n%w", err) } + // Validate package group configurations. + for groupName, group := range f.PackageGroups { + if err := group.Validate(); err != nil { + return fmt.Errorf("invalid package group %#q:\n%w", groupName, err) + } + } + // Validate overlay configurations for each component. for componentName, component := range f.Components { for i, overlay := range component.Overlays { diff --git a/internal/projectconfig/loader.go b/internal/projectconfig/loader.go index b006345..ec001e1 100644 --- a/internal/projectconfig/loader.go +++ b/internal/projectconfig/loader.go @@ -25,6 +25,8 @@ var ( ErrDuplicateComponentGroups = errors.New("duplicate component group") // ErrDuplicateImages is returned when duplicate conflicting image definitions are found. ErrDuplicateImages = errors.New("duplicate image") + // ErrDuplicatePackageGroups is returned when duplicate conflicting package group definitions are found. + ErrDuplicatePackageGroups = errors.New("duplicate package group") ) // Loads and resolves the project configuration files located at the given path. Referenced include files @@ -39,6 +41,7 @@ func loadAndResolveProjectConfig( Images: make(map[string]ImageConfig), Distros: make(map[string]DistroDefinition), GroupsByComponent: make(map[string][]string), + PackageGroups: make(map[string]PackageGroupConfig), } for _, configFilePath := range configFilePaths { @@ -116,6 +119,14 @@ func mergeConfigFile(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return err } + if err := mergeDefaultPackageConfig(resolvedCfg, loadedCfg); err != nil { + return err + } + + if err := mergePackageGroups(resolvedCfg, loadedCfg); err != nil { + return err + } + return nil } @@ -213,6 +224,32 @@ func mergeTools(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return nil } +// mergeDefaultPackageConfig merges the project-level default package config from a loaded +// config file into the resolved config. +func mergeDefaultPackageConfig(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + if loadedCfg.DefaultPackageConfig != nil { + if err := resolvedCfg.DefaultPackageConfig.MergeUpdatesFrom(loadedCfg.DefaultPackageConfig); err != nil { + return fmt.Errorf("failed to merge project default package config:\n%w", err) + } + } + + return nil +} + +// mergePackageGroups merges package group definitions from a loaded config file into +// the resolved config. Duplicate package group names are not allowed. +func mergePackageGroups(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + for groupName, group := range loadedCfg.PackageGroups { + if _, ok := resolvedCfg.PackageGroups[groupName]; ok { + return fmt.Errorf("%w: %s", ErrDuplicatePackageGroups, groupName) + } + + resolvedCfg.PackageGroups[groupName] = group + } + + return nil +} + func loadProjectConfigWithIncludes( fs opctx.FS, filePath string, permissiveConfigParsing bool, ) ([]*ConfigFile, error) { diff --git a/internal/projectconfig/package.go b/internal/projectconfig/package.go new file mode 100644 index 0000000..75fb253 --- /dev/null +++ b/internal/projectconfig/package.go @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig + +import ( + "fmt" + "path" + "sort" + + "dario.cat/mergo" +) + +// PackagePublishConfig holds publish settings for a single binary package. +// The zero value means the channel is inherited from a higher-priority config layer. +type PackagePublishConfig struct { + // Channel identifies the publish channel for this package. + // The special value "none" means the package should not be published. + // When empty, the value is inherited from the next layer in the resolution order. + Channel string `toml:"channel,omitempty" json:"channel,omitempty" jsonschema:"title=Channel,description=Publish channel for this package; 'none' skips publishing entirely"` +} + +// PackageConfig holds all configuration applied to a single binary package. +// Currently only publish settings are supported; additional fields may be added in the future. +type PackageConfig struct { + // Description is an optional human-readable note about this package's configuration + // (e.g., "user-facing API — ships in base repo"). + Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Human-readable note about this package's configuration"` + + // Publish holds the publish settings for this package. + Publish PackagePublishConfig `toml:"publish,omitempty" json:"publish,omitempty" jsonschema:"title=Publish settings,description=Publishing settings for this binary package"` +} + +// MergeUpdatesFrom updates the package config with non-zero values from other. +func (p *PackageConfig) MergeUpdatesFrom(other *PackageConfig) error { + err := mergo.Merge(p, other, mergo.WithOverride) + if err != nil { + return fmt.Errorf("failed to merge package config:\n%w", err) + } + + return nil +} + +// PackageGroupConfig defines a named group of binary packages matched by name globs. +// It is analogous to [ComponentGroupConfig] for components, but operates at publish time +// rather than config-load time. +// +// All package-groups whose [PackageGroupConfig.PackagePatterns] match a given binary package +// name contribute their [PackageGroupConfig.DefaultPackageConfig] to the resolved [PackageConfig] +// for that package. Groups are applied in alphabetical name order; later-named groups override +// earlier-named ones. +type PackageGroupConfig struct { + // Description is an optional human-readable description of this group. + Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Human-readable description of this package group"` + + // PackagePatterns is a list of binary package name globs. + // A package belongs to this group if its name matches any one of these patterns. + // Pattern syntax follows [path.Match] rules (e.g., "*-devel", "python3-*", "curl"). + PackagePatterns []string `toml:"package-patterns,omitempty" json:"packagePatterns,omitempty" jsonschema:"title=Package patterns,description=Glob patterns matched against binary package names to determine group membership"` + + // DefaultPackageConfig is the configuration applied to all packages whose name matches + // any pattern in PackagePatterns. + DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" jsonschema:"title=Default package config,description=Configuration inherited by all packages matched by this group"` +} + +// Validate checks that all package patterns in the group are non-empty and well-formed globs. +func (g *PackageGroupConfig) Validate() error { + for patternIdx, pattern := range g.PackagePatterns { + if pattern == "" { + return fmt.Errorf("package-patterns[%d] must not be empty", patternIdx) + } + + // Verify the pattern is a valid glob by doing a trial match. + // path.Match returns ErrBadPattern for malformed globs. + if _, err := path.Match(pattern, ""); err != nil { + return fmt.Errorf("package-patterns[%d] %#q is not a valid glob:\n%w", patternIdx, pattern, err) + } + } + + return nil +} + +// ResolvePackageConfig returns the effective [PackageConfig] for a binary package produced +// by a component, merging contributions from all applicable config layers. +// +// Resolution order (each layer overrides the previous — later wins): +// 1. The project's DefaultPackageConfig (lowest priority) +// 2. All [PackageGroupConfig] whose patterns match pkgName, applied in alphabetical group name order +// 3. The component's DefaultPackageConfig +// 4. The component's explicit Packages entry for the exact package name (highest priority) +func ResolvePackageConfig(pkgName string, comp *ComponentConfig, proj *ProjectConfig) (PackageConfig, error) { + // 1. Start from the project-level default (lowest priority). + result := proj.DefaultPackageConfig + + // 2. Apply all matching package-groups in sorted name order for deterministic behavior. + groupNames := make([]string, 0, len(proj.PackageGroups)) + for name := range proj.PackageGroups { + groupNames = append(groupNames, name) + } + + sort.Strings(groupNames) + + for _, groupName := range groupNames { + group := proj.PackageGroups[groupName] + for _, pattern := range group.PackagePatterns { + if matchGlob(pattern, pkgName) { + if err := result.MergeUpdatesFrom(&group.DefaultPackageConfig); err != nil { + return PackageConfig{}, fmt.Errorf( + "failed to apply defaults from package group %#q to package %#q:\n%w", + groupName, pkgName, err, + ) + } + + break // one pattern match per group is sufficient + } + } + } + + // 3. Apply the component-level default (overrides group defaults). + if err := result.MergeUpdatesFrom(&comp.DefaultPackageConfig); err != nil { + return PackageConfig{}, fmt.Errorf( + "failed to apply component defaults to package %#q:\n%w", pkgName, err, + ) + } + + // 4. Apply the explicit per-package override (exact name, highest priority). + if pkgConfig, ok := comp.Packages[pkgName]; ok { + if err := result.MergeUpdatesFrom(&pkgConfig); err != nil { + return PackageConfig{}, fmt.Errorf( + "failed to apply package override for %#q:\n%w", pkgName, err, + ) + } + } + + return result, nil +} + +// matchGlob reports whether pkgName matches the given glob pattern. +// Pattern syntax follows [path.Match] rules. A malformed pattern is treated as a non-match +// to avoid panicking at resolution time; patterns should be validated at config-load time +// via [PackageGroupConfig.Validate]. +func matchGlob(pattern, pkgName string) bool { + matched, err := path.Match(pattern, pkgName) + + return err == nil && matched +} diff --git a/internal/projectconfig/package_test.go b/internal/projectconfig/package_test.go new file mode 100644 index 0000000..5dd45e3 --- /dev/null +++ b/internal/projectconfig/package_test.go @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageGroupConfig_Validate(t *testing.T) { + t.Run("empty group is valid", func(t *testing.T) { + group := projectconfig.PackageGroupConfig{} + assert.NoError(t, group.Validate()) + }) + + t.Run("group with valid patterns is valid", func(t *testing.T) { + group := projectconfig.PackageGroupConfig{ + Description: "development packages", + PackagePatterns: []string{"*-devel", "python3-*", "curl"}, + } + assert.NoError(t, group.Validate()) + }) + + t.Run("empty pattern string is invalid", func(t *testing.T) { + group := projectconfig.PackageGroupConfig{ + PackagePatterns: []string{"*-devel", ""}, + } + err := group.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "package-patterns[1]") + assert.Contains(t, err.Error(), "must not be empty") + }) + + t.Run("malformed glob is invalid", func(t *testing.T) { + group := projectconfig.PackageGroupConfig{ + PackagePatterns: []string{"[invalid"}, + } + err := group.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "not a valid glob") + }) +} + +func TestPackageConfig_MergeUpdatesFrom(t *testing.T) { + t.Run("non-zero other overrides zero base", func(t *testing.T) { + base := projectconfig.PackageConfig{} + other := projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + } + require.NoError(t, base.MergeUpdatesFrom(&other)) + assert.Equal(t, "build", base.Publish.Channel) + }) + + t.Run("non-zero other overrides non-zero base", func(t *testing.T) { + base := projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + } + other := projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + require.NoError(t, base.MergeUpdatesFrom(&other)) + assert.Equal(t, "base", base.Publish.Channel) + }) + + t.Run("zero other does not override non-zero base", func(t *testing.T) { + base := projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + } + other := projectconfig.PackageConfig{} + require.NoError(t, base.MergeUpdatesFrom(&other)) + assert.Equal(t, "build", base.Publish.Channel) + }) +} + +func TestResolvePackageConfig(t *testing.T) { + makeProj := func(groups map[string]projectconfig.PackageGroupConfig) *projectconfig.ProjectConfig { + proj := projectconfig.NewProjectConfig() + proj.PackageGroups = groups + + return &proj + } + + baseProj := makeProj(map[string]projectconfig.PackageGroupConfig{ + "debug-packages": { + PackagePatterns: []string{"*-debuginfo", "*-debugsource"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + }, + "build-time-deps": { + PackagePatterns: []string{"*-devel", "*-static"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + }, + }, + }) + + testCases := []struct { + name string + pkgName string + compDefault projectconfig.PackageConfig + compPackages map[string]projectconfig.PackageConfig + expectedChannel string + }{ + { + name: "unmatched package returns zero channel", + pkgName: "curl", + expectedChannel: "", + }, + { + name: "devel package matched by group pattern", + pkgName: "curl-devel", + expectedChannel: "build", + }, + { + name: "debuginfo package matched by group pattern", + pkgName: "gcc-debuginfo", + expectedChannel: "none", + }, + { + name: "component default overrides group default", + pkgName: "gcc-devel", + compDefault: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + }, + expectedChannel: "base", + }, + { + name: "component default applies when no group matches", + pkgName: "curl", + compDefault: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + expectedChannel: "none", + }, + { + name: "exact package override takes priority over group and component default", + pkgName: "curl-devel", + compDefault: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + compPackages: map[string]projectconfig.PackageConfig{ + "curl-devel": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + }, + expectedChannel: "base", + }, + { + name: "exact package override takes priority over group with no component default", + pkgName: "curl-devel", + compPackages: map[string]projectconfig.PackageConfig{ + "curl-devel": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + }, + expectedChannel: "base", + }, + { + name: "non-matching exact package entry does not affect result", + pkgName: "curl-devel", + compPackages: map[string]projectconfig.PackageConfig{ + "curl": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + }, + expectedChannel: "build", // from group + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + comp := &projectconfig.ComponentConfig{ + Name: "test-component", + DefaultPackageConfig: testCase.compDefault, + Packages: testCase.compPackages, + } + + got, err := projectconfig.ResolvePackageConfig(testCase.pkgName, comp, baseProj) + require.NoError(t, err) + assert.Equal(t, testCase.expectedChannel, got.Publish.Channel) + }) + } + + t.Run("groups applied in alphabetical order - later-named overrides earlier-named", func(t *testing.T) { + // "zzz-group" is alphabetically later than "aaa-group", so its channel wins. + proj := makeProj(map[string]projectconfig.PackageGroupConfig{ + "aaa-group": { + PackagePatterns: []string{"*-devel"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + }, + }, + "zzz-group": { + PackagePatterns: []string{"curl-*"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + }, + }, + }) + + comp := &projectconfig.ComponentConfig{Name: "curl"} + got, err := projectconfig.ResolvePackageConfig("curl-devel", comp, proj) + require.NoError(t, err) + assert.Equal(t, "base", got.Publish.Channel) + }) + + t.Run("empty project config returns zero-value PackageConfig", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + comp := &projectconfig.ComponentConfig{Name: "curl"} + + got, err := projectconfig.ResolvePackageConfig("curl", comp, &proj) + require.NoError(t, err) + assert.Empty(t, got.Publish.Channel) + }) + + t.Run("project default applies when no other config matches", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + proj.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + + comp := &projectconfig.ComponentConfig{Name: "curl"} + got, err := projectconfig.ResolvePackageConfig("curl", comp, &proj) + require.NoError(t, err) + assert.Equal(t, "base", got.Publish.Channel) + }) + + t.Run("package group overrides project default", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + proj.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + proj.PackageGroups = map[string]projectconfig.PackageGroupConfig{ + "debug-packages": { + PackagePatterns: []string{"*-debuginfo"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + }, + } + + comp := &projectconfig.ComponentConfig{Name: "gcc"} + got, err := projectconfig.ResolvePackageConfig("gcc-debuginfo", comp, &proj) + require.NoError(t, err) + assert.Equal(t, "none", got.Publish.Channel) + }) + + t.Run("component default overrides project default", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + proj.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + + comp := &projectconfig.ComponentConfig{ + Name: "build-id-helper", + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + } + + got, err := projectconfig.ResolvePackageConfig("build-id-helper-tool", comp, &proj) + require.NoError(t, err) + assert.Equal(t, "none", got.Publish.Channel) + }) + + t.Run("per-package override takes priority over project default", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + proj.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + + comp := &projectconfig.ComponentConfig{ + Name: "curl", + Packages: map[string]projectconfig.PackageConfig{ + "curl-devel": {Publish: projectconfig.PackagePublishConfig{Channel: "none"}}, + }, + } + + got, err := projectconfig.ResolvePackageConfig("curl-devel", comp, &proj) + require.NoError(t, err) + assert.Equal(t, "none", got.Publish.Channel) + }) +} diff --git a/internal/projectconfig/project.go b/internal/projectconfig/project.go index cd8e84c..10b11f9 100644 --- a/internal/projectconfig/project.go +++ b/internal/projectconfig/project.go @@ -26,6 +26,14 @@ type ProjectConfig struct { // Configuration for tools used by azldev. Tools ToolsConfig `toml:"tools,omitempty" json:"tools,omitempty" jsonschema:"title=Tools configuration,description=Configuration for tools used by azldev"` + // DefaultPackageConfig is the project-wide default applied to every binary package before any + // package-group or component-level config is considered. It is the lowest-priority layer in the + // package config resolution order. + DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" jsonschema:"title=Default package config,description=Project-wide default applied to all binary packages before group and component overrides"` + + // Definitions of package groups for publish-time routing of binary packages. + PackageGroups map[string]PackageGroupConfig `toml:"package-groups,omitempty" json:"packageGroups,omitempty" jsonschema:"title=Package groups,description=Mapping of package group names to configurations for publish-time routing"` + // Root config file path; not serialized. RootConfigFilePath string `toml:"-" json:"-"` // Map from component names to groups they belong to; not serialized. @@ -41,6 +49,7 @@ func NewProjectConfig() ProjectConfig { Images: make(map[string]ImageConfig), Distros: make(map[string]DistroDefinition), GroupsByComponent: make(map[string][]string), + PackageGroups: make(map[string]PackageGroupConfig), } } From 3de812a16cb1853d6978e9b179dcd7d06db92b3e Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Fri, 20 Mar 2026 21:17:52 +0000 Subject: [PATCH 2/7] chore: regenerate schema and update snapshots for package config keys Regenerate schemas/azldev.schema.json and scenario snapshots to include the new default-package-config and package-groups config keys introduced by the new package.go types. --- ...ainer_config_generate-schema_stdout_1.snap | 77 +++++++++++++++++++ ...shots_config_generate-schema_stdout_1.snap | 77 +++++++++++++++++++ schemas/azldev.schema.json | 77 +++++++++++++++++++ 3 files changed, 231 insertions(+) diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 0548cef..2601556 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -126,6 +126,19 @@ "type": "array", "title": "Source files", "description": "Source files to download for this component" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Default configuration applied to all binary packages produced by this component" + }, + "packages": { + "additionalProperties": { + "$ref": "#/$defs/PackageConfig" + }, + "type": "object", + "title": "Package overrides", + "description": "Per-package configuration overrides keyed by exact binary package name" } }, "additionalProperties": false, @@ -315,6 +328,19 @@ "$ref": "#/$defs/ToolsConfig", "title": "Tools configuration", "description": "Configuration for tools used by azldev" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Project-wide default applied to all binary packages before group and component overrides" + }, + "package-groups": { + "additionalProperties": { + "$ref": "#/$defs/PackageGroupConfig" + }, + "type": "object", + "title": "Package groups", + "description": "Definitions of package groups for publish-time routing" } }, "additionalProperties": false, @@ -511,6 +537,57 @@ "type" ] }, + "PackageConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable note about this package's configuration" + }, + "publish": { + "$ref": "#/$defs/PackagePublishConfig", + "title": "Publish settings", + "description": "Publishing settings for this binary package" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackageGroupConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable description of this package group" + }, + "package-patterns": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Package patterns", + "description": "Glob patterns matched against binary package names to determine group membership" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Configuration inherited by all packages matched by this group" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackagePublishConfig": { + "properties": { + "channel": { + "type": "string", + "title": "Channel", + "description": "Publish channel for this package; 'none' skips publishing entirely" + } + }, + "additionalProperties": false, + "type": "object" + }, "PackageRepository": { "properties": { "base-uri": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 0548cef..2601556 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -126,6 +126,19 @@ "type": "array", "title": "Source files", "description": "Source files to download for this component" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Default configuration applied to all binary packages produced by this component" + }, + "packages": { + "additionalProperties": { + "$ref": "#/$defs/PackageConfig" + }, + "type": "object", + "title": "Package overrides", + "description": "Per-package configuration overrides keyed by exact binary package name" } }, "additionalProperties": false, @@ -315,6 +328,19 @@ "$ref": "#/$defs/ToolsConfig", "title": "Tools configuration", "description": "Configuration for tools used by azldev" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Project-wide default applied to all binary packages before group and component overrides" + }, + "package-groups": { + "additionalProperties": { + "$ref": "#/$defs/PackageGroupConfig" + }, + "type": "object", + "title": "Package groups", + "description": "Definitions of package groups for publish-time routing" } }, "additionalProperties": false, @@ -511,6 +537,57 @@ "type" ] }, + "PackageConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable note about this package's configuration" + }, + "publish": { + "$ref": "#/$defs/PackagePublishConfig", + "title": "Publish settings", + "description": "Publishing settings for this binary package" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackageGroupConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable description of this package group" + }, + "package-patterns": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Package patterns", + "description": "Glob patterns matched against binary package names to determine group membership" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Configuration inherited by all packages matched by this group" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackagePublishConfig": { + "properties": { + "channel": { + "type": "string", + "title": "Channel", + "description": "Publish channel for this package; 'none' skips publishing entirely" + } + }, + "additionalProperties": false, + "type": "object" + }, "PackageRepository": { "properties": { "base-uri": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 0548cef..2601556 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -126,6 +126,19 @@ "type": "array", "title": "Source files", "description": "Source files to download for this component" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Default configuration applied to all binary packages produced by this component" + }, + "packages": { + "additionalProperties": { + "$ref": "#/$defs/PackageConfig" + }, + "type": "object", + "title": "Package overrides", + "description": "Per-package configuration overrides keyed by exact binary package name" } }, "additionalProperties": false, @@ -315,6 +328,19 @@ "$ref": "#/$defs/ToolsConfig", "title": "Tools configuration", "description": "Configuration for tools used by azldev" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Project-wide default applied to all binary packages before group and component overrides" + }, + "package-groups": { + "additionalProperties": { + "$ref": "#/$defs/PackageGroupConfig" + }, + "type": "object", + "title": "Package groups", + "description": "Definitions of package groups for publish-time routing" } }, "additionalProperties": false, @@ -511,6 +537,57 @@ "type" ] }, + "PackageConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable note about this package's configuration" + }, + "publish": { + "$ref": "#/$defs/PackagePublishConfig", + "title": "Publish settings", + "description": "Publishing settings for this binary package" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackageGroupConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable description of this package group" + }, + "package-patterns": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Package patterns", + "description": "Glob patterns matched against binary package names to determine group membership" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Configuration inherited by all packages matched by this group" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackagePublishConfig": { + "properties": { + "channel": { + "type": "string", + "title": "Channel", + "description": "Publish channel for this package; 'none' skips publishing entirely" + } + }, + "additionalProperties": false, + "type": "object" + }, "PackageRepository": { "properties": { "base-uri": { From 88bb6d6f38b3664cc8b69e9ff9485a968621dadf Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Fri, 20 Mar 2026 21:38:31 +0000 Subject: [PATCH 3/7] refactor: use env.FS() for RPM file access and add package config docs - Use env.FS() in packageNameFromRPM instead of os.Open to stay consistent with the repo's filesystem abstraction and improve unit testability - Add docs/user/reference/config/package-groups.md with full reference for package-groups, PackageConfig, and the 4-layer resolution order - Update docs/user/reference/config/project.md to document default-package-config and package-groups top-level fields - Update docs/user/reference/config/components.md to document per-component default-package-config and packages overrides --- docs/user/reference/config/components.md | 55 ++++++++++++ docs/user/reference/config/package-groups.md | 91 ++++++++++++++++++++ docs/user/reference/config/project.md | 51 +++++++++++ internal/app/azldev/cmds/component/build.go | 18 ++-- internal/projectconfig/loader.go | 2 +- 5 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 docs/user/reference/config/package-groups.md diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index 2e70793..b02a061 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -12,6 +12,8 @@ A component definition tells azldev where to find the spec file, how to customiz | Overlays | `overlays` | array of [Overlay](overlays.md) | No | Modifications to apply to the spec and/or source files | | Build config | `build` | [BuildConfig](#build-configuration) | No | Build-time options (macros, conditionals, check config) | | Source files | `source-files` | array of [SourceFileReference](#source-file-references) | No | Additional source files to download for this component | +| Default package config | `default-package-config` | [PackageConfig](package-groups.md#package-config) | No | Default configuration applied to all binary packages produced by this component; overrides project defaults and package-group defaults | +| Package overrides | `packages` | map of string → [PackageConfig](package-groups.md#package-config) | No | Exact per-package configuration overrides; highest priority in the resolution order | ### Bare Components @@ -190,6 +192,58 @@ The `hints` field provides non-essential metadata about how or when to build a c hints = { expensive = true } ``` +## Package Configuration + +Components can customize the publish configuration for the binary packages they produce. There are two fields for this, applied at different levels of specificity. + +### Default Package Config + +The `default-package-config` field provides a component-level default that applies to **all** binary packages produced by this component. It overrides any matching [package groups](package-groups.md) but is itself overridden by the `packages` map. + +```toml +[components.curl.default-package-config.publish] +channel = "base" +``` + +### Per-Package Overrides + +The `[components..packages.]` map lets you override config for a **specific** binary package by its exact name. This is the highest-priority layer and overrides all inherited defaults: + +```toml +# Override just one subpackage +[components.curl.packages.curl-devel.publish] +channel = "devel" +``` + +### Resolution Order + +For each binary package produced by a component, the effective config is assembled in this order (later layers win): + +1. Project `default-package-config` +2. Matching `package-groups`, in alphabetical group name order +3. Component `default-package-config` +4. Component `packages.` (highest priority) + +See [Package Groups](package-groups.md#resolution-order) for the full details. + +### Example + +```toml +[components.curl] + +# Route all curl packages to "base" by default ... +[components.curl.default-package-config.publish] +channel = "base" + +# ... but put curl-devel in the "devel" channel +[components.curl.packages.curl-devel.publish] +channel = "devel" + +# Don't publish the minimal build at all +[components.curl.packages.curl-minimal.publish] +channel = "none" +``` + ## Source File References The `[[components..source-files]]` array defines additional source files that azldev should download before building. These are files not available in the dist-git repository or lookaside cache — typically binaries, pre-built artifacts, or files from custom hosting. @@ -313,5 +367,6 @@ lines = ["cp -vf %{shimdirx64}/$(basename %{shimefix64}) %{shimefix64} ||:"] - [Config File Structure](config-file.md) — top-level config file layout - [Distros](distros.md) — distro definitions and `default-component-config` inheritance - [Component Groups](component-groups.md) — grouping components with shared defaults +- [Package Groups](package-groups.md) — project-level package groups and full resolution order - [Configuration System](../../explanation/config-system.md) — inheritance and merge behavior - [JSON Schema](../../../../schemas/azldev.schema.json) — machine-readable schema diff --git a/docs/user/reference/config/package-groups.md b/docs/user/reference/config/package-groups.md new file mode 100644 index 0000000..e8e7543 --- /dev/null +++ b/docs/user/reference/config/package-groups.md @@ -0,0 +1,91 @@ +# Package Groups + +Package groups let you apply shared publish configuration to sets of binary packages matched by name glob patterns. They are defined under `[package-groups.]` in the TOML configuration. + +Package groups are evaluated at build time, after the binary RPMs are produced, to determine the publish channel for each package. This is analogous to how [component groups](component-groups.md) apply shared configuration to components at config-load time. + +## Field Reference + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Description | `description` | string | No | Human-readable description of this group | +| Package patterns | `package-patterns` | string array | No | Glob patterns matched against binary package names to determine group membership | +| Default package config | `default-package-config` | [PackageConfig](#package-config) | No | Configuration inherited by all packages whose name matches any pattern in this group | + +## Package Patterns + +The `package-patterns` field accepts glob patterns following [`path.Match`](https://pkg.go.dev/path#Match) syntax: + +- `*` matches any sequence of non-separator characters +- `?` matches any single non-separator character +- `[abc]` matches any character in the set + +A binary package is a member of a group if its name matches **any** pattern in `package-patterns`. + +```toml +[package-groups.devel-packages] +description = "All -devel subpackages" +package-patterns = ["*-devel"] + +[package-groups.python-packages] +description = "Python 3 packages" +package-patterns = ["python3-*", "python3"] +``` + +## Package Config + +The `[package-groups..default-package-config]` section defines the configuration applied to all packages matching this group. + +### PackageConfig Fields + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Description | `description` | string | No | Human-readable note about this package's configuration | +| Publish settings | `publish` | [PublishConfig](#publish-config) | No | Publishing settings for matched packages | + +### PublishConfig Fields + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Channel | `channel` | string | No | Publish channel for this package. Use `"none"` to skip publishing entirely. | + +## Resolution Order + +When determining the effective config for a binary package, azldev applies config layers in this order — later layers override earlier ones: + +1. **Project `default-package-config`** — lowest priority; applies to all packages in the project +2. **Package groups** — all groups whose `package-patterns` match the package name, applied in **alphabetical order by group name** (later-named groups win) +3. **Component `default-package-config`** — applies to all packages produced by that component +4. **Component `packages.`** — highest priority; exact per-package override + +> **Tip:** Group names beginning with letters near the end of the alphabet take precedence over earlier names. Prefix group names with a priority hint (e.g., `10-base`, `20-security`) if you need explicit ordering. + +## Example + +```toml +# Set a project-wide default channel +[default-package-config.publish] +channel = "base" + +# Route all -devel packages to the "devel" channel +[package-groups.devel-packages] +description = "Development subpackages" +package-patterns = ["*-devel", "*-static", "*-headers"] + +[package-groups.devel-packages.default-package-config.publish] +channel = "devel" + +# Exclude debug packages from publishing entirely +[package-groups.debug-packages] +description = "Debug info packages — not published" +package-patterns = ["*-debuginfo", "*-debugsource"] + +[package-groups.debug-packages.default-package-config.publish] +channel = "none" +``` + +## Related Resources + +- [Project Configuration](project.md) — top-level `default-package-config` and `package-groups` fields +- [Components](components.md) — per-component `default-package-config` and `packages` overrides +- [Configuration System](../../explanation/config-system.md) — inheritance and merge behavior diff --git a/docs/user/reference/config/project.md b/docs/user/reference/config/project.md index 7b7061f..48b7c59 100644 --- a/docs/user/reference/config/project.md +++ b/docs/user/reference/config/project.md @@ -11,6 +11,8 @@ The `[project]` section defines metadata and directory layout for an azldev proj | Work directory | `work-dir` | string | No | Path to the temporary working directory for build artifacts (relative to this config file) | | Output directory | `output-dir` | string | No | Path to the directory where final build outputs (RPMs, SRPMs) are placed (relative to this config file) | | Default distro | `default-distro` | [DistroReference](distros.md#distro-references) | No | The default distro and version to use when building components | +| Default package config | `default-package-config` | [PackageConfig](package-groups.md#package-config) | No | Project-wide default applied to every binary package before group and component overrides | +| Package groups | `package-groups` | map of string → [PackageGroupConfig](package-groups.md) | No | Named groups of binary packages with shared publish configuration | ## Directory Paths @@ -33,6 +35,43 @@ default-distro = { name = "azurelinux", version = "4.0" } Components inherit their spec source and build environment from the default distro's configuration unless they override it explicitly. See [Configuration Inheritance](../../explanation/config-system.md#configuration-inheritance) for details. +## Default Package Config + +The `[default-package-config]` section defines the lowest-priority configuration layer applied to every binary package produced by any component in the project. It is overridden by [package groups](package-groups.md), [component-level defaults](components.md#package-configuration), and explicit per-package overrides. + +The most common use is to set a project-wide default publish channel: + +```toml +[default-package-config.publish] +channel = "base" +``` + +See [Package Groups](package-groups.md#resolution-order) for the full resolution order. + +## Package Groups + +The `[package-groups.]` section defines named groups of binary packages matched by name glob patterns. Each group can supply a `default-package-config` that is applied to all packages whose name matches any of its patterns. + +This is commonly used to route different types of packages (e.g., `-devel`, `-debuginfo`) to different publish channels: + +```toml +[package-groups.devel-packages] +description = "Development subpackages" +package-patterns = ["*-devel", "*-static"] + +[package-groups.devel-packages.default-package-config.publish] +channel = "devel" + +[package-groups.debug-packages] +description = "Debug packages — not published" +package-patterns = ["*-debuginfo", "*-debugsource"] + +[package-groups.debug-packages.default-package-config.publish] +channel = "none" +``` + +See [Package Groups](package-groups.md) for the full field reference. + ## Example ```toml @@ -42,10 +81,22 @@ log-dir = "build/logs" work-dir = "build/work" output-dir = "out" default-distro = { name = "azurelinux", version = "4.0" } + +[default-package-config.publish] +channel = "base" + +[package-groups.devel-packages] +description = "Development subpackages" +package-patterns = ["*-devel", "*-static", "*-headers"] + +[package-groups.devel-packages.default-package-config.publish] +channel = "devel" ``` ## Related Resources - [Config File Structure](config-file.md) — top-level config file layout - [Distros](distros.md) — distro definitions referenced by `default-distro` +- [Package Groups](package-groups.md) — full reference for `package-groups` and package config resolution +- [Components](components.md) — per-component package config overrides - [Configuration System](../../explanation/config-system.md) — how project config merges with other files diff --git a/internal/app/azldev/cmds/component/build.go b/internal/app/azldev/cmds/component/build.go index 739e52f..137ccea 100644 --- a/internal/app/azldev/cmds/component/build.go +++ b/internal/app/azldev/cmds/component/build.go @@ -6,7 +6,6 @@ package component import ( "errors" "fmt" - "os" "path/filepath" rpmlib "github.com/cavaliergopher/rpm" @@ -16,6 +15,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/workdir" "github.com/microsoft/azure-linux-dev-tools/internal/buildenv" + "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/providers/sourceproviders" "github.com/microsoft/azure-linux-dev-tools/internal/utils/defers" @@ -313,7 +313,7 @@ func buildComponentUsingBuilder( } // Enrich each RPM with its binary package name and resolved publish channel. - results.RPMs, err = resolveRPMResults(env, results.RPMPaths, component.GetConfig()) + results.RPMs, err = resolveRPMResults(env.FS(), results.RPMPaths, env.Config(), component.GetConfig()) if err != nil { return results, fmt.Errorf("failed to resolve publish channels for %q:\n%w", component.GetName(), err) } @@ -389,17 +389,15 @@ func checkLocalRepoPathOverlap(localRepoPaths []string, localRepoWithPublishPath } // resolveRPMResults builds an [RPMResult] for each RPM path, extracting the binary package -// name from the filename and resolving its publish channel from the project config (if available). +// name from the RPM headers and resolving its publish channel from the project config (if available). // When no project config is loaded, the Channel field is left empty. func resolveRPMResults( - env *azldev.Env, rpmPaths []string, compConfig *projectconfig.ComponentConfig, + fs opctx.FS, rpmPaths []string, proj *projectconfig.ProjectConfig, compConfig *projectconfig.ComponentConfig, ) ([]RPMResult, error) { - proj := env.Config() - rpmResults := make([]RPMResult, 0, len(rpmPaths)) for _, rpmPath := range rpmPaths { - pkgName, err := packageNameFromRPMFilename(rpmPath) + pkgName, err := packageNameFromRPM(fs, rpmPath) if err != nil { return nil, fmt.Errorf("failed to determine package name:\n%w", err) } @@ -424,11 +422,11 @@ func resolveRPMResults( return rpmResults, nil } -// packageNameFromRPMFilename extracts the binary package name from an RPM file by reading +// packageNameFromRPM extracts the binary package name from an RPM file by reading // its headers. Reading the Name tag directly from the RPM metadata is authoritative and // handles all valid package names regardless of naming conventions. -func packageNameFromRPMFilename(rpmPath string) (pkgName string, err error) { - rpmFile, err := os.Open(rpmPath) +func packageNameFromRPM(fs opctx.FS, rpmPath string) (pkgName string, err error) { + rpmFile, err := fs.Open(rpmPath) if err != nil { return "", fmt.Errorf("failed to open RPM %#q:\n%w", rpmPath, err) } diff --git a/internal/projectconfig/loader.go b/internal/projectconfig/loader.go index ec001e1..270d091 100644 --- a/internal/projectconfig/loader.go +++ b/internal/projectconfig/loader.go @@ -241,7 +241,7 @@ func mergeDefaultPackageConfig(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile func mergePackageGroups(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { for groupName, group := range loadedCfg.PackageGroups { if _, ok := resolvedCfg.PackageGroups[groupName]; ok { - return fmt.Errorf("%w: %s", ErrDuplicatePackageGroups, groupName) + return fmt.Errorf("%w: %#q", ErrDuplicatePackageGroups, groupName) } resolvedCfg.PackageGroups[groupName] = group From 3fd9797bf6efbff1c45a736a3ad3475c7be7cf9e Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Fri, 20 Mar 2026 21:41:48 +0000 Subject: [PATCH 4/7] doc: update package-groups.md --- docs/user/reference/config/package-groups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/reference/config/package-groups.md b/docs/user/reference/config/package-groups.md index e8e7543..8e871fa 100644 --- a/docs/user/reference/config/package-groups.md +++ b/docs/user/reference/config/package-groups.md @@ -43,7 +43,7 @@ The `[package-groups..default-package-config]` section defines the configu | Description | `description` | string | No | Human-readable note about this package's configuration | | Publish settings | `publish` | [PublishConfig](#publish-config) | No | Publishing settings for matched packages | -### PublishConfig Fields +### Publish Config | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| From e420ee6758e9abcb8db26c81df2337e630ccfd6c Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Mon, 23 Mar 2026 23:34:02 +0000 Subject: [PATCH 5/7] refactor(config): add package groups and per-package config - Replace PackageGroupConfig.PackagePatterns (glob-based) with Packages (explicit list); membership is now exact-name match only - Remove PackageConfig.Description field (redundant with TOML comments) - Enforce one-group-per-package: ProjectConfig.Validate now calls validatePackageGroupMembership, returning an error if any name appears in more than one group - Simplify ResolvePackageConfig step 2 from a sorted multi-group loop to a single-group lookup with early break --- docs/user/reference/config/components.md | 18 +- docs/user/reference/config/package-groups.md | 59 ++--- docs/user/reference/config/project.md | 26 +- internal/app/azldev/cmds/component/build.go | 4 +- .../azldev/cmds/component/build_rpm_test.go | 181 ++++++++++++++ internal/projectconfig/configfile.go | 5 +- internal/projectconfig/loader_test.go | 222 ++++++++++++++++++ internal/projectconfig/package.go | 87 ++----- internal/projectconfig/package_test.go | 52 ++-- internal/projectconfig/project.go | 28 ++- ...ainer_config_generate-schema_stdout_1.snap | 15 +- ...shots_config_generate-schema_stdout_1.snap | 15 +- schemas/azldev.schema.json | 15 +- 13 files changed, 538 insertions(+), 189 deletions(-) create mode 100644 internal/app/azldev/cmds/component/build_rpm_test.go diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index b02a061..04eab0f 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -194,7 +194,7 @@ hints = { expensive = true } ## Package Configuration -Components can customize the publish configuration for the binary packages they produce. There are two fields for this, applied at different levels of specificity. +Components can customize the configuration for the binary packages they produce. There are two fields for this, applied at different levels of specificity. ### Default Package Config @@ -202,7 +202,7 @@ The `default-package-config` field provides a component-level default that appli ```toml [components.curl.default-package-config.publish] -channel = "base" +channel = "rpm-base" ``` ### Per-Package Overrides @@ -212,7 +212,7 @@ The `[components..packages.]` map lets you override config for a ```toml # Override just one subpackage [components.curl.packages.curl-devel.publish] -channel = "devel" +channel = "rpm-devel" ``` ### Resolution Order @@ -220,11 +220,11 @@ channel = "devel" For each binary package produced by a component, the effective config is assembled in this order (later layers win): 1. Project `default-package-config` -2. Matching `package-groups`, in alphabetical group name order +2. Package group containing this package name (if any) 3. Component `default-package-config` 4. Component `packages.` (highest priority) -See [Package Groups](package-groups.md#resolution-order) for the full details. +See [Package Groups](package-groups.md) for the full field reference and a complete example. ### Example @@ -233,14 +233,14 @@ See [Package Groups](package-groups.md#resolution-order) for the full details. # Route all curl packages to "base" by default ... [components.curl.default-package-config.publish] -channel = "base" +channel = "rpm-base" # ... but put curl-devel in the "devel" channel -[components.curl.packages.curl-devel.publish] -channel = "devel" +[components.curl.packages.libcurl-devel.publish] +channel = "rpm-devel" # Don't publish the minimal build at all -[components.curl.packages.curl-minimal.publish] +[components.curl.packages.libcurl-minimal.publish] channel = "none" ``` diff --git a/docs/user/reference/config/package-groups.md b/docs/user/reference/config/package-groups.md index 8e871fa..dbdd542 100644 --- a/docs/user/reference/config/package-groups.md +++ b/docs/user/reference/config/package-groups.md @@ -1,37 +1,33 @@ # Package Groups -Package groups let you apply shared publish configuration to sets of binary packages matched by name glob patterns. They are defined under `[package-groups.]` in the TOML configuration. +Package groups let you apply shared configuration to named sets of binary packages. They are defined under `[package-groups.]` in the TOML configuration. -Package groups are evaluated at build time, after the binary RPMs are produced, to determine the publish channel for each package. This is analogous to how [component groups](component-groups.md) apply shared configuration to components at config-load time. +Package groups are evaluated at build time, after the binary RPMs are produced. They are analogous to [component groups](component-groups.md), which apply shared configuration to sets of components. ## Field Reference | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| | Description | `description` | string | No | Human-readable description of this group | -| Package patterns | `package-patterns` | string array | No | Glob patterns matched against binary package names to determine group membership | -| Default package config | `default-package-config` | [PackageConfig](#package-config) | No | Configuration inherited by all packages whose name matches any pattern in this group | +| Packages | `packages` | string array | No | Explicit list of binary package names that belong to this group | +| Default package config | `default-package-config` | [PackageConfig](#package-config) | No | Configuration inherited by all packages listed in this group | -## Package Patterns +## Packages -The `package-patterns` field accepts glob patterns following [`path.Match`](https://pkg.go.dev/path#Match) syntax: - -- `*` matches any sequence of non-separator characters -- `?` matches any single non-separator character -- `[abc]` matches any character in the set - -A binary package is a member of a group if its name matches **any** pattern in `package-patterns`. +The `packages` field is an explicit list of binary package names (as they appear in the RPM `Name` tag) that belong to this group. Membership is determined by exact name match — no glob patterns or wildcards are supported. ```toml [package-groups.devel-packages] -description = "All -devel subpackages" -package-patterns = ["*-devel"] +description = "Development subpackages" +packages = ["libcurl-devel", "curl-static", "wget2-devel"] -[package-groups.python-packages] -description = "Python 3 packages" -package-patterns = ["python3-*", "python3"] +[package-groups.debug-packages] +description = "Debug info and source packages" +packages = ["curl-debuginfo", "curl-debugsource", "wget2-debuginfo"] ``` +> **Note:** A package name may appear in at most one group. Listing the same name in two groups produces a validation error. + ## Package Config The `[package-groups..default-package-config]` section defines the configuration applied to all packages matching this group. @@ -40,48 +36,53 @@ The `[package-groups..default-package-config]` section defines the configu | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| -| Description | `description` | string | No | Human-readable note about this package's configuration | + | Publish settings | `publish` | [PublishConfig](#publish-config) | No | Publishing settings for matched packages | ### Publish Config | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| -| Channel | `channel` | string | No | Publish channel for this package. Use `"none"` to skip publishing entirely. | +| Channel | `channel` | string | No | Publish channel for this package. **Use `"none"` to skip publishing entirely.** | ## Resolution Order When determining the effective config for a binary package, azldev applies config layers in this order — later layers override earlier ones: 1. **Project `default-package-config`** — lowest priority; applies to all packages in the project -2. **Package groups** — all groups whose `package-patterns` match the package name, applied in **alphabetical order by group name** (later-named groups win) +2. **Package group** — the group (if any) whose `packages` list contains the package name 3. **Component `default-package-config`** — applies to all packages produced by that component 4. **Component `packages.`** — highest priority; exact per-package override -> **Tip:** Group names beginning with letters near the end of the alphabet take precedence over earlier names. Prefix group names with a priority hint (e.g., `10-base`, `20-security`) if you need explicit ordering. +> **Note:** Each package name may appear in at most one group. Listing the same name in two groups produces a validation error. ## Example ```toml # Set a project-wide default channel [default-package-config.publish] -channel = "base" +channel = "rpm-base" -# Route all -devel packages to the "devel" channel [package-groups.devel-packages] description = "Development subpackages" -package-patterns = ["*-devel", "*-static", "*-headers"] +packages = ["libcurl-devel", "curl-static", "wget2-devel"] [package-groups.devel-packages.default-package-config.publish] -channel = "devel" +channel = "rpm-build-only" -# Exclude debug packages from publishing entirely [package-groups.debug-packages] -description = "Debug info packages — not published" -package-patterns = ["*-debuginfo", "*-debugsource"] +description = "Debug info and source" +packages = [ + "libcurl-debuginfo", + "libcurl-minimal-debuginfo", + "curl-debugsource", + "wget2-debuginfo", + "wget2-debugsource", + "wget2-libs-debuginfo" +] [package-groups.debug-packages.default-package-config.publish] -channel = "none" +channel = "rpm-debug" ``` ## Related Resources diff --git a/docs/user/reference/config/project.md b/docs/user/reference/config/project.md index 48b7c59..ff1d301 100644 --- a/docs/user/reference/config/project.md +++ b/docs/user/reference/config/project.md @@ -12,7 +12,7 @@ The `[project]` section defines metadata and directory layout for an azldev proj | Output directory | `output-dir` | string | No | Path to the directory where final build outputs (RPMs, SRPMs) are placed (relative to this config file) | | Default distro | `default-distro` | [DistroReference](distros.md#distro-references) | No | The default distro and version to use when building components | | Default package config | `default-package-config` | [PackageConfig](package-groups.md#package-config) | No | Project-wide default applied to every binary package before group and component overrides | -| Package groups | `package-groups` | map of string → [PackageGroupConfig](package-groups.md) | No | Named groups of binary packages with shared publish configuration | +| Package groups | `package-groups` | map of string → [PackageGroupConfig](package-groups.md) | No | Named groups of binary packages with shared configuration | ## Directory Paths @@ -43,32 +43,16 @@ The most common use is to set a project-wide default publish channel: ```toml [default-package-config.publish] -channel = "base" +channel = "rpm-base" ``` See [Package Groups](package-groups.md#resolution-order) for the full resolution order. ## Package Groups -The `[package-groups.]` section defines named groups of binary packages matched by name glob patterns. Each group can supply a `default-package-config` that is applied to all packages whose name matches any of its patterns. - -This is commonly used to route different types of packages (e.g., `-devel`, `-debuginfo`) to different publish channels: - -```toml -[package-groups.devel-packages] -description = "Development subpackages" -package-patterns = ["*-devel", "*-static"] +The `[package-groups.]` section defines named groups of binary packages. Each group lists its members explicitly in the `packages` field and provides a `default-package-config` that is applied to all listed packages. -[package-groups.devel-packages.default-package-config.publish] -channel = "devel" - -[package-groups.debug-packages] -description = "Debug packages — not published" -package-patterns = ["*-debuginfo", "*-debugsource"] - -[package-groups.debug-packages.default-package-config.publish] -channel = "none" -``` +This is currently used to route different types of packages (e.g., `-devel`, `-debuginfo`) to different publish channels, though groups can also carry other future configuration. See [Package Groups](package-groups.md) for the full field reference. @@ -87,7 +71,7 @@ channel = "base" [package-groups.devel-packages] description = "Development subpackages" -package-patterns = ["*-devel", "*-static", "*-headers"] +packages = ["curl-devel", "curl-static", "wget2-devel"] [package-groups.devel-packages.default-package-config.publish] channel = "devel" diff --git a/internal/app/azldev/cmds/component/build.go b/internal/app/azldev/cmds/component/build.go index 137ccea..e5a3059 100644 --- a/internal/app/azldev/cmds/component/build.go +++ b/internal/app/azldev/cmds/component/build.go @@ -46,7 +46,7 @@ type RPMResult struct { // Path is the absolute path to the RPM file. Path string `json:"path" table:"Path"` - // PackageName is the binary package name extracted from the RPM filename (e.g., "curl-devel"). + // PackageName is the binary package name extracted from the RPM header tag (e.g., "libcurl-devel"). PackageName string `json:"packageName" table:"Package"` // Channel is the resolved publish channel from project config. @@ -425,7 +425,7 @@ func resolveRPMResults( // packageNameFromRPM extracts the binary package name from an RPM file by reading // its headers. Reading the Name tag directly from the RPM metadata is authoritative and // handles all valid package names regardless of naming conventions. -func packageNameFromRPM(fs opctx.FS, rpmPath string) (pkgName string, err error) { +func packageNameFromRPM(fs opctx.FS, rpmPath string) (string, error) { rpmFile, err := fs.Open(rpmPath) if err != nil { return "", fmt.Errorf("failed to open RPM %#q:\n%w", rpmPath, err) diff --git a/internal/app/azldev/cmds/component/build_rpm_test.go b/internal/app/azldev/cmds/component/build_rpm_test.go new file mode 100644 index 0000000..7f665ec --- /dev/null +++ b/internal/app/azldev/cmds/component/build_rpm_test.go @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// White-box tests for unexported helpers in this package. +// +//nolint:testpackage // Intentional: tests access unexported packageNameFromRPM and resolveRPMResults. +package component + +import ( + "os" + "path/filepath" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rpmTestdataPath returns the absolute path to the testdata directory. +func rpmTestdataPath(t *testing.T) string { + t.Helper() + + // runtime.Caller is not available here so we resolve relative to the test binary's + // working directory, which Go sets to the package directory. + abs, err := filepath.Abs("testdata") + require.NoError(t, err) + + return abs +} + +// loadTestRPMIntoMemFS reads the epel-release testdata RPM from the real FS and writes it into +// an in-memory filesystem, returning both the FS and the in-memory path. +func loadTestRPMIntoMemFS(t *testing.T) (afero.Fs, string) { + t.Helper() + + realPath := filepath.Join(rpmTestdataPath(t), "epel-release-7-5.noarch.rpm") + data, err := os.ReadFile(realPath) + require.NoError(t, err, "failed to read testdata RPM %q", realPath) + + memFS := afero.NewMemMapFs() + + const inMemPath = "/rpm/test.rpm" + + require.NoError(t, memFS.MkdirAll("/rpm", 0o755)) + require.NoError(t, afero.WriteFile(memFS, inMemPath, data, 0o644)) + + return memFS, inMemPath +} + +// TestPackageNameFromRPM_Success verifies that a valid RPM's Name tag is extracted correctly. +func TestPackageNameFromRPM_Success(t *testing.T) { + memFS, rpmPath := loadTestRPMIntoMemFS(t) + + name, err := packageNameFromRPM(memFS, rpmPath) + + require.NoError(t, err) + assert.Equal(t, "epel-release", name) +} + +// TestPackageNameFromRPM_FileNotFound verifies that a missing RPM returns a clear error. +func TestPackageNameFromRPM_FileNotFound(t *testing.T) { + memFS := afero.NewMemMapFs() // empty — no files + + _, err := packageNameFromRPM(memFS, "/nonexistent/path/package.rpm") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to open RPM") +} + +// TestPackageNameFromRPM_CorruptFile verifies that a file with invalid RPM content returns +// a clear error rather than panicking. +func TestPackageNameFromRPM_CorruptFile(t *testing.T) { + memFS := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(memFS, "/bad.rpm", []byte("this is not an RPM"), 0o644)) + + _, err := packageNameFromRPM(memFS, "/bad.rpm") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read RPM headers") +} + +// TestResolveRPMResults_NoProjectConfig verifies that channels are left empty when no +// project config is loaded. +func TestResolveRPMResults_NoProjectConfig(t *testing.T) { + memFS, rpmPath := loadTestRPMIntoMemFS(t) + + results, err := resolveRPMResults(memFS, []string{rpmPath}, nil, &projectconfig.ComponentConfig{}) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "epel-release", results[0].PackageName) + assert.Equal(t, rpmPath, results[0].Path) + assert.Empty(t, results[0].Channel, "channel should be empty when no project config is present") +} + +// TestResolveRPMResults_ProjectDefaultChannel verifies that the project-level default +// package config channel is propagated to the result. +func TestResolveRPMResults_ProjectDefaultChannel(t *testing.T) { + memFS, rpmPath := loadTestRPMIntoMemFS(t) + + proj := &projectconfig.ProjectConfig{ + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "stable"}, + }, + PackageGroups: make(map[string]projectconfig.PackageGroupConfig), + } + compConfig := &projectconfig.ComponentConfig{} + + results, err := resolveRPMResults(memFS, []string{rpmPath}, proj, compConfig) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "epel-release", results[0].PackageName) + assert.Equal(t, "stable", results[0].Channel) +} + +// TestResolveRPMResults_PerPackageOverride verifies that an explicit per-package entry in +// the component config takes precedence over the project default channel. +func TestResolveRPMResults_PerPackageOverride(t *testing.T) { + memFS, rpmPath := loadTestRPMIntoMemFS(t) + + proj := &projectconfig.ProjectConfig{ + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "stable"}, + }, + PackageGroups: make(map[string]projectconfig.PackageGroupConfig), + } + compConfig := &projectconfig.ComponentConfig{ + Packages: map[string]projectconfig.PackageConfig{ + "epel-release": { + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + }, + } + + results, err := resolveRPMResults(memFS, []string{rpmPath}, proj, compConfig) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "none", results[0].Channel, "per-package override should win over project default") +} + +// TestResolveRPMResults_PackageGroupChannel verifies that a matching package-group channel +// overrides the project default but is itself overridden by the component default. +func TestResolveRPMResults_PackageGroupChannel(t *testing.T) { + memFS, rpmPath := loadTestRPMIntoMemFS(t) + + proj := &projectconfig.ProjectConfig{ + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + }, + PackageGroups: map[string]projectconfig.PackageGroupConfig{ + "epel-group": { + Packages: []string{"epel-release"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "extras"}, + }, + }, + }, + } + compConfig := &projectconfig.ComponentConfig{} + + results, err := resolveRPMResults(memFS, []string{rpmPath}, proj, compConfig) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "extras", results[0].Channel, "package-group channel should override project default") +} + +// TestResolveRPMResults_CorruptRPM verifies that an unreadable RPM surfaces an error +// rather than silently producing a result with an empty package name. +func TestResolveRPMResults_CorruptRPM(t *testing.T) { + memFS := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(memFS, "/bad.rpm", []byte("garbage"), 0o644)) + + _, err := resolveRPMResults(memFS, []string{"/bad.rpm"}, nil, &projectconfig.ComponentConfig{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to determine package name") +} diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index f89108d..23e5527 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -47,8 +47,9 @@ type ConfigFile struct { // package-group or component-level config is considered. DefaultPackageConfig *PackageConfig `toml:"default-package-config,omitempty" jsonschema:"title=Default package config,description=Project-wide default applied to all binary packages before group and component overrides"` - // Definitions of package groups for publish-time routing of binary packages. - PackageGroups map[string]PackageGroupConfig `toml:"package-groups,omitempty" jsonschema:"title=Package groups,description=Definitions of package groups for publish-time routing"` + // Definitions of package groups. Groups allow shared configuration + // to be applied to sets of binary packages. + PackageGroups map[string]PackageGroupConfig `toml:"package-groups,omitempty" jsonschema:"title=Package groups,description=Definitions of package groups for shared binary package configuration"` // Internal fields used to track the origin of the config file; `dir` is the directory // that the config file's relative paths are based from. diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index 2f91863..55c1be5 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -561,3 +561,225 @@ includes = ["*non-existent*.toml"] _, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) require.NoError(t, err) } + +func TestLoadAndResolveProjectConfig_DefaultPackageConfig(t *testing.T) { + const configContents = ` +[default-package-config.publish] +channel = "base" +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.NoError(t, err) + + assert.Equal(t, "base", config.DefaultPackageConfig.Publish.Channel) +} + +func TestLoadAndResolveProjectConfig_DefaultPackageConfig_MergedAcrossFiles(t *testing.T) { + // First file sets a channel; second file overrides it. + testFiles := []struct { + path string + contents string + }{ + {testConfigPath, ` +includes = ["extra.toml"] + +[default-package-config.publish] +channel = "base" +`}, + {"/project/extra.toml", ` +[default-package-config.publish] +channel = "stable" +`}, + } + + ctx := testctx.NewCtx() + for _, f := range testFiles { + require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(f.path))) + require.NoError(t, fileutils.WriteFile(ctx.FS(), f.path, []byte(f.contents), fileperms.PrivateFile)) + } + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) + require.NoError(t, err) + + // The later-loaded file wins. + assert.Equal(t, "stable", config.DefaultPackageConfig.Publish.Channel) +} + +func TestLoadAndResolveProjectConfig_DefaultPackageConfig_MergedAcrossTopLevelFiles(t *testing.T) { + // Two separate top-level config files; the second one overrides the first. + const ( + configContents1 = ` +[default-package-config.publish] +channel = "first" +` + configContents2 = ` +[default-package-config.publish] +channel = "second" +` + ) + + configPath1 := testConfigPath + configPath2 := filepath.Join("/project", "extra.toml") + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), configPath1, []byte(configContents1), fileperms.PrivateFile)) + require.NoError(t, fileutils.WriteFile(ctx.FS(), configPath2, []byte(configContents2), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, configPath1, configPath2) + require.NoError(t, err) + + assert.Equal(t, "second", config.DefaultPackageConfig.Publish.Channel) +} + +func TestLoadAndResolveProjectConfig_PackageGroups(t *testing.T) { + const configContents = ` +[package-groups.devel-packages] +description = "Development subpackages" +packages = ["curl-devel", "wget2-devel"] + +[package-groups.devel-packages.default-package-config.publish] +channel = "devel" + +[package-groups.debug-packages] +description = "Debug info packages" +packages = ["curl-debuginfo", "curl-debugsource"] + +[package-groups.debug-packages.default-package-config.publish] +channel = "none" +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.NoError(t, err) + + require.Len(t, config.PackageGroups, 2) + + if assert.Contains(t, config.PackageGroups, "devel-packages") { + g := config.PackageGroups["devel-packages"] + assert.Equal(t, "Development subpackages", g.Description) + assert.Equal(t, []string{"curl-devel", "wget2-devel"}, g.Packages) + assert.Equal(t, "devel", g.DefaultPackageConfig.Publish.Channel) + } + + if assert.Contains(t, config.PackageGroups, "debug-packages") { + g := config.PackageGroups["debug-packages"] + assert.Equal(t, "Debug info packages", g.Description) + assert.Equal(t, []string{"curl-debuginfo", "curl-debugsource"}, g.Packages) + assert.Equal(t, "none", g.DefaultPackageConfig.Publish.Channel) + } +} + +func TestLoadAndResolveProjectConfig_DuplicatePackageGroups(t *testing.T) { + testFiles := []struct { + path string + contents string + }{ + {testConfigPath, ` +includes = ["extra.toml"] + +[package-groups.devel-packages] +packages = ["curl-devel"] +`}, + {"/project/extra.toml", ` +[package-groups.devel-packages] +packages = ["wget2-devel"] +`}, + } + + ctx := testctx.NewCtx() + for _, f := range testFiles { + require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(f.path))) + require.NoError(t, fileutils.WriteFile(ctx.FS(), f.path, []byte(f.contents), fileperms.PrivateFile)) + } + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) + require.ErrorIs(t, err, ErrDuplicatePackageGroups) +} + +func TestLoadAndResolveProjectConfig_DuplicatePackageGroupsAcrossTopLevelFiles(t *testing.T) { + const ( + configContents1 = ` +[package-groups.devel-packages] +packages = ["curl-devel"] +` + configContents2 = ` +[package-groups.devel-packages] +packages = ["wget2-devel"] +` + ) + + configPath1 := testConfigPath + configPath2 := filepath.Join("/project", "extra.toml") + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), configPath1, []byte(configContents1), fileperms.PrivateFile)) + require.NoError(t, fileutils.WriteFile(ctx.FS(), configPath2, []byte(configContents2), fileperms.PrivateFile)) + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, configPath1, configPath2) + require.ErrorIs(t, err, ErrDuplicatePackageGroups) +} + +func TestLoadAndResolveProjectConfig_PackageGroups_EmptyPackageName(t *testing.T) { + const configContents = ` +[package-groups.bad-group] +packages = ["curl-devel", ""] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "packages[1]") + assert.Contains(t, err.Error(), "must not be empty") +} + +func TestLoadAndResolveProjectConfig_PackageGroups_DuplicatePackageAcrossGroups(t *testing.T) { + const configContents = ` +[package-groups.group-a] +packages = ["curl-devel", "wget2-devel"] + +[package-groups.group-b] +packages = ["wget2-devel", "bash-devel"] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "wget2-devel") + assert.Contains(t, err.Error(), "may only belong to one group") +} + +func TestLoadAndResolveProjectConfig_ComponentDefaultPackageConfig(t *testing.T) { + const configContents = ` +[components.curl] + +[components.curl.default-package-config.publish] +channel = "base" + +[components.curl.packages.curl-devel.publish] +channel = "devel" +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.NoError(t, err) + + if assert.Contains(t, config.Components, "curl") { + comp := config.Components["curl"] + assert.Equal(t, "base", comp.DefaultPackageConfig.Publish.Channel) + + if assert.Contains(t, comp.Packages, "curl-devel") { + assert.Equal(t, "devel", comp.Packages["curl-devel"].Publish.Channel) + } + } +} diff --git a/internal/projectconfig/package.go b/internal/projectconfig/package.go index 75fb253..0b151aa 100644 --- a/internal/projectconfig/package.go +++ b/internal/projectconfig/package.go @@ -5,8 +5,7 @@ package projectconfig import ( "fmt" - "path" - "sort" + "slices" "dario.cat/mergo" ) @@ -23,10 +22,6 @@ type PackagePublishConfig struct { // PackageConfig holds all configuration applied to a single binary package. // Currently only publish settings are supported; additional fields may be added in the future. type PackageConfig struct { - // Description is an optional human-readable note about this package's configuration - // (e.g., "user-facing API — ships in base repo"). - Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Human-readable note about this package's configuration"` - // Publish holds the publish settings for this package. Publish PackagePublishConfig `toml:"publish,omitempty" json:"publish,omitempty" jsonschema:"title=Publish settings,description=Publishing settings for this binary package"` } @@ -41,39 +36,28 @@ func (p *PackageConfig) MergeUpdatesFrom(other *PackageConfig) error { return nil } -// PackageGroupConfig defines a named group of binary packages matched by name globs. -// It is analogous to [ComponentGroupConfig] for components, but operates at publish time -// rather than config-load time. +// PackageGroupConfig defines a named group of binary packages with shared configuration. +// It is analogous to [ComponentGroupConfig] for components. // -// All package-groups whose [PackageGroupConfig.PackagePatterns] match a given binary package -// name contribute their [PackageGroupConfig.DefaultPackageConfig] to the resolved [PackageConfig] -// for that package. Groups are applied in alphabetical name order; later-named groups override -// earlier-named ones. +// If a binary package name appears in a group's [PackageGroupConfig.Packages] list, that group's +// [PackageGroupConfig.DefaultPackageConfig] is applied when resolving the package's [PackageConfig]. +// A package may belong to at most one group. type PackageGroupConfig struct { // Description is an optional human-readable description of this group. Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Human-readable description of this package group"` - // PackagePatterns is a list of binary package name globs. - // A package belongs to this group if its name matches any one of these patterns. - // Pattern syntax follows [path.Match] rules (e.g., "*-devel", "python3-*", "curl"). - PackagePatterns []string `toml:"package-patterns,omitempty" json:"packagePatterns,omitempty" jsonschema:"title=Package patterns,description=Glob patterns matched against binary package names to determine group membership"` + // Packages is an explicit list of binary package names that belong to this group. + Packages []string `toml:"packages,omitempty" json:"packages,omitempty" jsonschema:"title=Packages,description=Explicit list of binary package names that are members of this group"` - // DefaultPackageConfig is the configuration applied to all packages whose name matches - // any pattern in PackagePatterns. - DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" jsonschema:"title=Default package config,description=Configuration inherited by all packages matched by this group"` + // DefaultPackageConfig is the configuration applied to all packages listed in Packages. + DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" jsonschema:"title=Default package config,description=Configuration inherited by all packages in this group"` } -// Validate checks that all package patterns in the group are non-empty and well-formed globs. +// Validate checks that all package names in the group are non-empty. func (g *PackageGroupConfig) Validate() error { - for patternIdx, pattern := range g.PackagePatterns { - if pattern == "" { - return fmt.Errorf("package-patterns[%d] must not be empty", patternIdx) - } - - // Verify the pattern is a valid glob by doing a trial match. - // path.Match returns ErrBadPattern for malformed globs. - if _, err := path.Match(pattern, ""); err != nil { - return fmt.Errorf("package-patterns[%d] %#q is not a valid glob:\n%w", patternIdx, pattern, err) + for i, pkg := range g.Packages { + if pkg == "" { + return fmt.Errorf("packages[%d] must not be empty", i) } } @@ -85,34 +69,25 @@ func (g *PackageGroupConfig) Validate() error { // // Resolution order (each layer overrides the previous — later wins): // 1. The project's DefaultPackageConfig (lowest priority) -// 2. All [PackageGroupConfig] whose patterns match pkgName, applied in alphabetical group name order +// 2. The [PackageGroupConfig] whose Packages list contains pkgName, if any // 3. The component's DefaultPackageConfig // 4. The component's explicit Packages entry for the exact package name (highest priority) func ResolvePackageConfig(pkgName string, comp *ComponentConfig, proj *ProjectConfig) (PackageConfig, error) { // 1. Start from the project-level default (lowest priority). result := proj.DefaultPackageConfig - // 2. Apply all matching package-groups in sorted name order for deterministic behavior. - groupNames := make([]string, 0, len(proj.PackageGroups)) - for name := range proj.PackageGroups { - groupNames = append(groupNames, name) - } - - sort.Strings(groupNames) - - for _, groupName := range groupNames { - group := proj.PackageGroups[groupName] - for _, pattern := range group.PackagePatterns { - if matchGlob(pattern, pkgName) { - if err := result.MergeUpdatesFrom(&group.DefaultPackageConfig); err != nil { - return PackageConfig{}, fmt.Errorf( - "failed to apply defaults from package group %#q to package %#q:\n%w", - groupName, pkgName, err, - ) - } - - break // one pattern match per group is sufficient + // 2. Apply the package group that contains this package, if any. + // A package belongs to at most one group, so we stop at the first match. + for groupName, group := range proj.PackageGroups { + if slices.Contains(group.Packages, pkgName) { + if err := result.MergeUpdatesFrom(&group.DefaultPackageConfig); err != nil { + return PackageConfig{}, fmt.Errorf( + "failed to apply defaults from package group %#q to package %#q:\n%w", + groupName, pkgName, err, + ) } + + break } } @@ -134,13 +109,3 @@ func ResolvePackageConfig(pkgName string, comp *ComponentConfig, proj *ProjectCo return result, nil } - -// matchGlob reports whether pkgName matches the given glob pattern. -// Pattern syntax follows [path.Match] rules. A malformed pattern is treated as a non-match -// to avoid panicking at resolution time; patterns should be validated at config-load time -// via [PackageGroupConfig.Validate]. -func matchGlob(pattern, pkgName string) bool { - matched, err := path.Match(pattern, pkgName) - - return err == nil && matched -} diff --git a/internal/projectconfig/package_test.go b/internal/projectconfig/package_test.go index 5dd45e3..805deb2 100644 --- a/internal/projectconfig/package_test.go +++ b/internal/projectconfig/package_test.go @@ -17,32 +17,23 @@ func TestPackageGroupConfig_Validate(t *testing.T) { assert.NoError(t, group.Validate()) }) - t.Run("group with valid patterns is valid", func(t *testing.T) { + t.Run("group with packages is valid", func(t *testing.T) { group := projectconfig.PackageGroupConfig{ - Description: "development packages", - PackagePatterns: []string{"*-devel", "python3-*", "curl"}, + Description: "development packages", + Packages: []string{"curl-devel", "python3-requests", "curl"}, } assert.NoError(t, group.Validate()) }) - t.Run("empty pattern string is invalid", func(t *testing.T) { + t.Run("empty package name is invalid", func(t *testing.T) { group := projectconfig.PackageGroupConfig{ - PackagePatterns: []string{"*-devel", ""}, + Packages: []string{"curl-devel", ""}, } err := group.Validate() require.Error(t, err) - assert.Contains(t, err.Error(), "package-patterns[1]") + assert.Contains(t, err.Error(), "packages[1]") assert.Contains(t, err.Error(), "must not be empty") }) - - t.Run("malformed glob is invalid", func(t *testing.T) { - group := projectconfig.PackageGroupConfig{ - PackagePatterns: []string{"[invalid"}, - } - err := group.Validate() - require.Error(t, err) - assert.Contains(t, err.Error(), "not a valid glob") - }) } func TestPackageConfig_MergeUpdatesFrom(t *testing.T) { @@ -86,13 +77,13 @@ func TestResolvePackageConfig(t *testing.T) { baseProj := makeProj(map[string]projectconfig.PackageGroupConfig{ "debug-packages": { - PackagePatterns: []string{"*-debuginfo", "*-debugsource"}, + Packages: []string{"gcc-debuginfo", "curl-debuginfo", "curl-debugsource"}, DefaultPackageConfig: projectconfig.PackageConfig{ Publish: projectconfig.PackagePublishConfig{Channel: "none"}, }, }, "build-time-deps": { - PackagePatterns: []string{"*-devel", "*-static"}, + Packages: []string{"curl-devel", "curl-static", "gcc-devel"}, DefaultPackageConfig: projectconfig.PackageConfig{ Publish: projectconfig.PackagePublishConfig{Channel: "build"}, }, @@ -107,17 +98,17 @@ func TestResolvePackageConfig(t *testing.T) { expectedChannel string }{ { - name: "unmatched package returns zero channel", + name: "package not in any group returns zero channel", pkgName: "curl", expectedChannel: "", }, { - name: "devel package matched by group pattern", + name: "package listed in build-time-deps group gets build channel", pkgName: "curl-devel", expectedChannel: "build", }, { - name: "debuginfo package matched by group pattern", + name: "package listed in debug-packages group gets none channel", pkgName: "gcc-debuginfo", expectedChannel: "none", }, @@ -130,7 +121,7 @@ func TestResolvePackageConfig(t *testing.T) { expectedChannel: "base", }, { - name: "component default applies when no group matches", + name: "component default applies when no group contains the package", pkgName: "curl", compDefault: projectconfig.PackageConfig{ Publish: projectconfig.PackagePublishConfig{Channel: "none"}, @@ -157,7 +148,7 @@ func TestResolvePackageConfig(t *testing.T) { expectedChannel: "base", }, { - name: "non-matching exact package entry does not affect result", + name: "unrelated exact package entry does not affect result", pkgName: "curl-devel", compPackages: map[string]projectconfig.PackageConfig{ "curl": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, @@ -180,27 +171,20 @@ func TestResolvePackageConfig(t *testing.T) { }) } - t.Run("groups applied in alphabetical order - later-named overrides earlier-named", func(t *testing.T) { - // "zzz-group" is alphabetically later than "aaa-group", so its channel wins. + t.Run("package group default-package-config is applied", func(t *testing.T) { proj := makeProj(map[string]projectconfig.PackageGroupConfig{ - "aaa-group": { - PackagePatterns: []string{"*-devel"}, + "my-group": { + Packages: []string{"curl-devel"}, DefaultPackageConfig: projectconfig.PackageConfig{ Publish: projectconfig.PackagePublishConfig{Channel: "build"}, }, }, - "zzz-group": { - PackagePatterns: []string{"curl-*"}, - DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "base"}, - }, - }, }) comp := &projectconfig.ComponentConfig{Name: "curl"} got, err := projectconfig.ResolvePackageConfig("curl-devel", comp, proj) require.NoError(t, err) - assert.Equal(t, "base", got.Publish.Channel) + assert.Equal(t, "build", got.Publish.Channel) }) t.Run("empty project config returns zero-value PackageConfig", func(t *testing.T) { @@ -231,7 +215,7 @@ func TestResolvePackageConfig(t *testing.T) { } proj.PackageGroups = map[string]projectconfig.PackageGroupConfig{ "debug-packages": { - PackagePatterns: []string{"*-debuginfo"}, + Packages: []string{"gcc-debuginfo"}, DefaultPackageConfig: projectconfig.PackageConfig{ Publish: projectconfig.PackagePublishConfig{Channel: "none"}, }, diff --git a/internal/projectconfig/project.go b/internal/projectconfig/project.go index 10b11f9..bdbbd80 100644 --- a/internal/projectconfig/project.go +++ b/internal/projectconfig/project.go @@ -31,7 +31,7 @@ type ProjectConfig struct { // package config resolution order. DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" jsonschema:"title=Default package config,description=Project-wide default applied to all binary packages before group and component overrides"` - // Definitions of package groups for publish-time routing of binary packages. + // Definitions of package groups with shared configuration. PackageGroups map[string]PackageGroupConfig `toml:"package-groups,omitempty" json:"packageGroups,omitempty" jsonschema:"title=Package groups,description=Mapping of package group names to configurations for publish-time routing"` // Root config file path; not serialized. @@ -60,6 +60,32 @@ func (cfg *ProjectConfig) Validate() error { return fmt.Errorf("config error:\n%w", err) } + if err := validatePackageGroupMembership(cfg.PackageGroups); err != nil { + return err + } + + return nil +} + +// validatePackageGroupMembership checks that no binary package name appears in more than one +// package group. A package should belong to exactly one group to keep routing unambiguous. +func validatePackageGroupMembership(groups map[string]PackageGroupConfig) error { + // Track which group each package name was first seen in. + seenIn := make(map[string]string, len(groups)) + + for groupName, group := range groups { + for _, pkg := range group.Packages { + if firstGroup, already := seenIn[pkg]; already { + return fmt.Errorf( + "package %#q appears in both package-group %#q and %#q; a package may only belong to one group", + pkg, firstGroup, groupName, + ) + } + + seenIn[pkg] = groupName + } + } + return nil } diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 2601556..7a86763 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -340,7 +340,7 @@ }, "type": "object", "title": "Package groups", - "description": "Definitions of package groups for publish-time routing" + "description": "Definitions of package groups for shared binary package configuration" } }, "additionalProperties": false, @@ -539,11 +539,6 @@ }, "PackageConfig": { "properties": { - "description": { - "type": "string", - "title": "Description", - "description": "Human-readable note about this package's configuration" - }, "publish": { "$ref": "#/$defs/PackagePublishConfig", "title": "Publish settings", @@ -560,18 +555,18 @@ "title": "Description", "description": "Human-readable description of this package group" }, - "package-patterns": { + "packages": { "items": { "type": "string" }, "type": "array", - "title": "Package patterns", - "description": "Glob patterns matched against binary package names to determine group membership" + "title": "Packages", + "description": "Explicit list of binary package names that are members of this group" }, "default-package-config": { "$ref": "#/$defs/PackageConfig", "title": "Default package config", - "description": "Configuration inherited by all packages matched by this group" + "description": "Configuration inherited by all packages in this group" } }, "additionalProperties": false, diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 2601556..7a86763 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -340,7 +340,7 @@ }, "type": "object", "title": "Package groups", - "description": "Definitions of package groups for publish-time routing" + "description": "Definitions of package groups for shared binary package configuration" } }, "additionalProperties": false, @@ -539,11 +539,6 @@ }, "PackageConfig": { "properties": { - "description": { - "type": "string", - "title": "Description", - "description": "Human-readable note about this package's configuration" - }, "publish": { "$ref": "#/$defs/PackagePublishConfig", "title": "Publish settings", @@ -560,18 +555,18 @@ "title": "Description", "description": "Human-readable description of this package group" }, - "package-patterns": { + "packages": { "items": { "type": "string" }, "type": "array", - "title": "Package patterns", - "description": "Glob patterns matched against binary package names to determine group membership" + "title": "Packages", + "description": "Explicit list of binary package names that are members of this group" }, "default-package-config": { "$ref": "#/$defs/PackageConfig", "title": "Default package config", - "description": "Configuration inherited by all packages matched by this group" + "description": "Configuration inherited by all packages in this group" } }, "additionalProperties": false, diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 2601556..7a86763 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -340,7 +340,7 @@ }, "type": "object", "title": "Package groups", - "description": "Definitions of package groups for publish-time routing" + "description": "Definitions of package groups for shared binary package configuration" } }, "additionalProperties": false, @@ -539,11 +539,6 @@ }, "PackageConfig": { "properties": { - "description": { - "type": "string", - "title": "Description", - "description": "Human-readable note about this package's configuration" - }, "publish": { "$ref": "#/$defs/PackagePublishConfig", "title": "Publish settings", @@ -560,18 +555,18 @@ "title": "Description", "description": "Human-readable description of this package group" }, - "package-patterns": { + "packages": { "items": { "type": "string" }, "type": "array", - "title": "Package patterns", - "description": "Glob patterns matched against binary package names to determine group membership" + "title": "Packages", + "description": "Explicit list of binary package names that are members of this group" }, "default-package-config": { "$ref": "#/$defs/PackageConfig", "title": "Default package config", - "description": "Configuration inherited by all packages matched by this group" + "description": "Configuration inherited by all packages in this group" } }, "additionalProperties": false, From 201f05bc2e454d49fef518fb20a4785ea094c880 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Mon, 23 Mar 2026 23:49:57 +0000 Subject: [PATCH 6/7] fix: remove build_rpm_test.go --- .../azldev/cmds/component/build_rpm_test.go | 181 ------------------ 1 file changed, 181 deletions(-) delete mode 100644 internal/app/azldev/cmds/component/build_rpm_test.go diff --git a/internal/app/azldev/cmds/component/build_rpm_test.go b/internal/app/azldev/cmds/component/build_rpm_test.go deleted file mode 100644 index 7f665ec..0000000 --- a/internal/app/azldev/cmds/component/build_rpm_test.go +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// White-box tests for unexported helpers in this package. -// -//nolint:testpackage // Intentional: tests access unexported packageNameFromRPM and resolveRPMResults. -package component - -import ( - "os" - "path/filepath" - "testing" - - "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// rpmTestdataPath returns the absolute path to the testdata directory. -func rpmTestdataPath(t *testing.T) string { - t.Helper() - - // runtime.Caller is not available here so we resolve relative to the test binary's - // working directory, which Go sets to the package directory. - abs, err := filepath.Abs("testdata") - require.NoError(t, err) - - return abs -} - -// loadTestRPMIntoMemFS reads the epel-release testdata RPM from the real FS and writes it into -// an in-memory filesystem, returning both the FS and the in-memory path. -func loadTestRPMIntoMemFS(t *testing.T) (afero.Fs, string) { - t.Helper() - - realPath := filepath.Join(rpmTestdataPath(t), "epel-release-7-5.noarch.rpm") - data, err := os.ReadFile(realPath) - require.NoError(t, err, "failed to read testdata RPM %q", realPath) - - memFS := afero.NewMemMapFs() - - const inMemPath = "/rpm/test.rpm" - - require.NoError(t, memFS.MkdirAll("/rpm", 0o755)) - require.NoError(t, afero.WriteFile(memFS, inMemPath, data, 0o644)) - - return memFS, inMemPath -} - -// TestPackageNameFromRPM_Success verifies that a valid RPM's Name tag is extracted correctly. -func TestPackageNameFromRPM_Success(t *testing.T) { - memFS, rpmPath := loadTestRPMIntoMemFS(t) - - name, err := packageNameFromRPM(memFS, rpmPath) - - require.NoError(t, err) - assert.Equal(t, "epel-release", name) -} - -// TestPackageNameFromRPM_FileNotFound verifies that a missing RPM returns a clear error. -func TestPackageNameFromRPM_FileNotFound(t *testing.T) { - memFS := afero.NewMemMapFs() // empty — no files - - _, err := packageNameFromRPM(memFS, "/nonexistent/path/package.rpm") - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to open RPM") -} - -// TestPackageNameFromRPM_CorruptFile verifies that a file with invalid RPM content returns -// a clear error rather than panicking. -func TestPackageNameFromRPM_CorruptFile(t *testing.T) { - memFS := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(memFS, "/bad.rpm", []byte("this is not an RPM"), 0o644)) - - _, err := packageNameFromRPM(memFS, "/bad.rpm") - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to read RPM headers") -} - -// TestResolveRPMResults_NoProjectConfig verifies that channels are left empty when no -// project config is loaded. -func TestResolveRPMResults_NoProjectConfig(t *testing.T) { - memFS, rpmPath := loadTestRPMIntoMemFS(t) - - results, err := resolveRPMResults(memFS, []string{rpmPath}, nil, &projectconfig.ComponentConfig{}) - - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, "epel-release", results[0].PackageName) - assert.Equal(t, rpmPath, results[0].Path) - assert.Empty(t, results[0].Channel, "channel should be empty when no project config is present") -} - -// TestResolveRPMResults_ProjectDefaultChannel verifies that the project-level default -// package config channel is propagated to the result. -func TestResolveRPMResults_ProjectDefaultChannel(t *testing.T) { - memFS, rpmPath := loadTestRPMIntoMemFS(t) - - proj := &projectconfig.ProjectConfig{ - DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "stable"}, - }, - PackageGroups: make(map[string]projectconfig.PackageGroupConfig), - } - compConfig := &projectconfig.ComponentConfig{} - - results, err := resolveRPMResults(memFS, []string{rpmPath}, proj, compConfig) - - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, "epel-release", results[0].PackageName) - assert.Equal(t, "stable", results[0].Channel) -} - -// TestResolveRPMResults_PerPackageOverride verifies that an explicit per-package entry in -// the component config takes precedence over the project default channel. -func TestResolveRPMResults_PerPackageOverride(t *testing.T) { - memFS, rpmPath := loadTestRPMIntoMemFS(t) - - proj := &projectconfig.ProjectConfig{ - DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "stable"}, - }, - PackageGroups: make(map[string]projectconfig.PackageGroupConfig), - } - compConfig := &projectconfig.ComponentConfig{ - Packages: map[string]projectconfig.PackageConfig{ - "epel-release": { - Publish: projectconfig.PackagePublishConfig{Channel: "none"}, - }, - }, - } - - results, err := resolveRPMResults(memFS, []string{rpmPath}, proj, compConfig) - - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, "none", results[0].Channel, "per-package override should win over project default") -} - -// TestResolveRPMResults_PackageGroupChannel verifies that a matching package-group channel -// overrides the project default but is itself overridden by the component default. -func TestResolveRPMResults_PackageGroupChannel(t *testing.T) { - memFS, rpmPath := loadTestRPMIntoMemFS(t) - - proj := &projectconfig.ProjectConfig{ - DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "base"}, - }, - PackageGroups: map[string]projectconfig.PackageGroupConfig{ - "epel-group": { - Packages: []string{"epel-release"}, - DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "extras"}, - }, - }, - }, - } - compConfig := &projectconfig.ComponentConfig{} - - results, err := resolveRPMResults(memFS, []string{rpmPath}, proj, compConfig) - - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, "extras", results[0].Channel, "package-group channel should override project default") -} - -// TestResolveRPMResults_CorruptRPM verifies that an unreadable RPM surfaces an error -// rather than silently producing a result with an empty package name. -func TestResolveRPMResults_CorruptRPM(t *testing.T) { - memFS := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(memFS, "/bad.rpm", []byte("garbage"), 0o644)) - - _, err := resolveRPMResults(memFS, []string{"/bad.rpm"}, nil, &projectconfig.ComponentConfig{}) - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to determine package name") -} From cb541001a37d66b38f02bcefb07acc78878510d8 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Tue, 24 Mar 2026 00:22:52 +0000 Subject: [PATCH 7/7] doc: update package-group docs and comments --- docs/user/reference/config/components.md | 2 +- docs/user/reference/config/package-groups.md | 2 +- internal/projectconfig/loader_test.go | 15 +++++++++++++++ internal/projectconfig/package.go | 15 ++++++++++++--- internal/projectconfig/package_test.go | 10 ++++++++++ internal/projectconfig/project.go | 5 +++-- ...Container_config_generate-schema_stdout_1.snap | 2 +- ...Snapshots_config_generate-schema_stdout_1.snap | 2 +- schemas/azldev.schema.json | 2 +- 9 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index 04eab0f..c9aef9d 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -239,7 +239,7 @@ channel = "rpm-base" [components.curl.packages.libcurl-devel.publish] channel = "rpm-devel" -# Don't publish the minimal build at all +# Signal to downstream tooling that this package should not be published [components.curl.packages.libcurl-minimal.publish] channel = "none" ``` diff --git a/docs/user/reference/config/package-groups.md b/docs/user/reference/config/package-groups.md index dbdd542..33cf231 100644 --- a/docs/user/reference/config/package-groups.md +++ b/docs/user/reference/config/package-groups.md @@ -43,7 +43,7 @@ The `[package-groups..default-package-config]` section defines the configu | Field | TOML Key | Type | Required | Description | |-------|----------|------|----------|-------------| -| Channel | `channel` | string | No | Publish channel for this package. **Use `"none"` to skip publishing entirely.** | +| Channel | `channel` | string | No | Publish channel for this package. Use `"none"` to signal to downstream tooling that this package should not be published. | ## Resolution Order diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index 55c1be5..1f1deb3 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -739,6 +739,21 @@ packages = ["curl-devel", ""] assert.Contains(t, err.Error(), "must not be empty") } +func TestLoadAndResolveProjectConfig_PackageGroups_DuplicatePackageWithinGroup(t *testing.T) { + const configContents = ` +[package-groups.my-group] +packages = ["curl-devel", "wget2-devel", "curl-devel"] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "curl-devel") + assert.Contains(t, err.Error(), "more than once") +} + func TestLoadAndResolveProjectConfig_PackageGroups_DuplicatePackageAcrossGroups(t *testing.T) { const configContents = ` [package-groups.group-a] diff --git a/internal/projectconfig/package.go b/internal/projectconfig/package.go index 0b151aa..25756fb 100644 --- a/internal/projectconfig/package.go +++ b/internal/projectconfig/package.go @@ -14,9 +14,10 @@ import ( // The zero value means the channel is inherited from a higher-priority config layer. type PackagePublishConfig struct { // Channel identifies the publish channel for this package. - // The special value "none" means the package should not be published. + // The special value "none" is a convention meaning the package should not be published; + // azldev records this value in build results but enforcement is left to downstream tooling. // When empty, the value is inherited from the next layer in the resolution order. - Channel string `toml:"channel,omitempty" json:"channel,omitempty" jsonschema:"title=Channel,description=Publish channel for this package; 'none' skips publishing entirely"` + Channel string `toml:"channel,omitempty" json:"channel,omitempty" jsonschema:"title=Channel,description=Publish channel for this package; use 'none' to signal to downstream tooling that this package should not be published"` } // PackageConfig holds all configuration applied to a single binary package. @@ -53,12 +54,20 @@ type PackageGroupConfig struct { DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" jsonschema:"title=Default package config,description=Configuration inherited by all packages in this group"` } -// Validate checks that all package names in the group are non-empty. +// Validate checks that all package names in the group are non-empty and unique within the group. func (g *PackageGroupConfig) Validate() error { + seen := make(map[string]struct{}, len(g.Packages)) + for i, pkg := range g.Packages { if pkg == "" { return fmt.Errorf("packages[%d] must not be empty", i) } + + if _, duplicate := seen[pkg]; duplicate { + return fmt.Errorf("package %#q appears more than once in the packages list", pkg) + } + + seen[pkg] = struct{}{} } return nil diff --git a/internal/projectconfig/package_test.go b/internal/projectconfig/package_test.go index 805deb2..f5b972e 100644 --- a/internal/projectconfig/package_test.go +++ b/internal/projectconfig/package_test.go @@ -34,6 +34,16 @@ func TestPackageGroupConfig_Validate(t *testing.T) { assert.Contains(t, err.Error(), "packages[1]") assert.Contains(t, err.Error(), "must not be empty") }) + + t.Run("duplicate package name within group is invalid", func(t *testing.T) { + group := projectconfig.PackageGroupConfig{ + Packages: []string{"curl-devel", "wget2-devel", "curl-devel"}, + } + err := group.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "curl-devel") + assert.Contains(t, err.Error(), "more than once") + }) } func TestPackageConfig_MergeUpdatesFrom(t *testing.T) { diff --git a/internal/projectconfig/project.go b/internal/projectconfig/project.go index bdbbd80..1172cfb 100644 --- a/internal/projectconfig/project.go +++ b/internal/projectconfig/project.go @@ -68,14 +68,15 @@ func (cfg *ProjectConfig) Validate() error { } // validatePackageGroupMembership checks that no binary package name appears in more than one -// package group. A package should belong to exactly one group to keep routing unambiguous. +// package group. A packagemay belong to at most one group to keep routing unambiguous, but it +// may also be left ungrouped. func validatePackageGroupMembership(groups map[string]PackageGroupConfig) error { // Track which group each package name was first seen in. seenIn := make(map[string]string, len(groups)) for groupName, group := range groups { for _, pkg := range group.Packages { - if firstGroup, already := seenIn[pkg]; already { + if firstGroup, already := seenIn[pkg]; already && firstGroup != groupName { return fmt.Errorf( "package %#q appears in both package-group %#q and %#q; a package may only belong to one group", pkg, firstGroup, groupName, diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 7a86763..87965e9 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -577,7 +577,7 @@ "channel": { "type": "string", "title": "Channel", - "description": "Publish channel for this package; 'none' skips publishing entirely" + "description": "Publish channel for this package; use 'none' to signal to downstream tooling that this package should not be published" } }, "additionalProperties": false, diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 7a86763..87965e9 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -577,7 +577,7 @@ "channel": { "type": "string", "title": "Channel", - "description": "Publish channel for this package; 'none' skips publishing entirely" + "description": "Publish channel for this package; use 'none' to signal to downstream tooling that this package should not be published" } }, "additionalProperties": false, diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 7a86763..87965e9 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -577,7 +577,7 @@ "channel": { "type": "string", "title": "Channel", - "description": "Publish channel for this package; 'none' skips publishing entirely" + "description": "Publish channel for this package; use 'none' to signal to downstream tooling that this package should not be published" } }, "additionalProperties": false,