From 6ef6b8f4547bf6742c30ce8880b3674594ad8552 Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Thu, 23 Apr 2026 22:02:57 +0000 Subject: [PATCH 1/2] feat(projectconfig): add component-level publish channel configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ComponentPublishConfig to ComponentConfig for routing RPM, SRPM, and debuginfo packages to different publish channels. Key changes: - Add ComponentPublishConfig with rpm-channel, srpm-channel, debuginfo-channel fields - Add project-level DefaultComponentConfig (lowest-priority config layer) - Updated ResolveComponentConfig: project defaults → distro defaults → group defaults (sorted) → component explicit config - Implement ResolvePackagePublishChannel: selects channel from resolved component config, with package-group and per-package overrides - Add IsDebugInfoPackage helper using hyphen-delimited segment matching - Expose publish channel in 'azldev package list' output - Update docs: project.md (new default-component-config section), components.md, component-groups.md, package-groups.md, config-system.md, and how-to guides --- docs/user/explanation/config-system.md | 7 +- docs/user/how-to/inspect-package-config.md | 5 +- .../user/reference/cli/azldev_package_list.md | 9 +- .../user/reference/config/component-groups.md | 7 +- docs/user/reference/config/components.md | 41 +- docs/user/reference/config/package-groups.md | 14 +- docs/user/reference/config/project.md | 29 +- internal/app/azldev/cmds/component/build.go | 56 +- internal/app/azldev/cmds/pkg/list.go | 126 +++- internal/app/azldev/cmds/pkg/list_test.go | 79 ++- .../app/azldev/core/components/resolver.go | 1 + .../azldev/core/components/resolver_test.go | 83 +++ internal/projectconfig/component.go | 141 ++++- internal/projectconfig/component_test.go | 592 +++++++++++++++++- internal/projectconfig/configfile.go | 4 + internal/projectconfig/fingerprint_test.go | 3 + internal/projectconfig/loader.go | 17 + internal/projectconfig/loader_test.go | 34 +- internal/projectconfig/package.go | 31 +- internal/projectconfig/package_test.go | 144 +++-- internal/projectconfig/project.go | 5 + ...ainer_config_generate-schema_stdout_1.snap | 51 +- ...shots_config_generate-schema_stdout_1.snap | 51 +- schemas/azldev.schema.json | 51 +- 24 files changed, 1273 insertions(+), 308 deletions(-) diff --git a/docs/user/explanation/config-system.md b/docs/user/explanation/config-system.md index 39716850..052a7def 100644 --- a/docs/user/explanation/config-system.md +++ b/docs/user/explanation/config-system.md @@ -95,9 +95,10 @@ These are simple structs (not maps). Later files' non-empty fields override earl Component configuration supports a layered inheritance model. When azldev resolves the effective configuration for a component, it assembles it from multiple sources in this order (later layers override earlier ones): -1. **Distro version defaults** — the `default-component-config` defined in the distro version (e.g., `[distros.azurelinux.versions.'4.0'.default-component-config]`) -2. **Component group defaults** — the `default-component-config` from any component groups the component belongs to (applied in alphabetical order by group name) -3. **Component-specific config** — the component's own explicit configuration +1. **Project-level defaults** — the `default-component-config` defined at the project root +2. **Distro version defaults** — the `default-component-config` defined in the distro version (e.g., `[distros.azurelinux.versions.'4.0'.default-component-config]`) +3. **Component group defaults** — the `default-component-config` from any component groups the component belongs to (applied in alphabetical order by group name) +4. **Component-specific config** — the component's own explicit configuration This inheritance is applied lazily at resolution time, not at config load time. diff --git a/docs/user/how-to/inspect-package-config.md b/docs/user/how-to/inspect-package-config.md index 6d15ad40..31d73616 100644 --- a/docs/user/how-to/inspect-package-config.md +++ b/docs/user/how-to/inspect-package-config.md @@ -5,13 +5,12 @@ binary-package configuration for your project without running a build. ## Background -Binary package configuration in azldev is assembled from up to four layers +Binary package configuration in azldev is assembled from up to three layers (see [Package Groups](../reference/config/package-groups.md) for details): 1. Project `default-package-config` 2. Package group `default-package-config` -3. Component `default-package-config` -4. Component `packages.` override (highest priority) +3. Component `packages.` override (highest priority) `azldev package list` resolves all of these layers and prints the effective configuration for each package you ask about. diff --git a/docs/user/reference/cli/azldev_package_list.md b/docs/user/reference/cli/azldev_package_list.md index 250d1da3..b2496a6d 100644 --- a/docs/user/reference/cli/azldev_package_list.md +++ b/docs/user/reference/cli/azldev_package_list.md @@ -14,10 +14,11 @@ to look up one or more specific packages by exact name — including packages that are not explicitly configured (they resolve using only project defaults). Resolution order (lowest to highest priority): - 1. Project default-package-config - 2. Package group default-package-config - 3. Component default-package-config - 4. Component packages. override + 1. Project default-component-config publish settings + 2. Component-group default-component-config publish settings + 3. Component publish settings + 4. Package-group default-package-config + 5. Component packages. override ``` azldev package list [package-name...] [flags] diff --git a/docs/user/reference/config/component-groups.md b/docs/user/reference/config/component-groups.md index 9c66ec37..90283c97 100644 --- a/docs/user/reference/config/component-groups.md +++ b/docs/user/reference/config/component-groups.md @@ -56,9 +56,10 @@ defines = { azure = "1" } When a component belongs to one or more groups, the effective configuration is assembled in this order (later layers override earlier ones): -1. Distro version `default-component-config` -2. Component group `default-component-config` (in alphabetical order by group name if multiple groups apply) -3. Component's own explicit configuration +1. Project-level `default-component-config` +2. Distro version `default-component-config` +3. Component group `default-component-config` (in alphabetical order by group name if multiple groups apply) +4. Component's own explicit configuration See [Configuration Inheritance](../../explanation/config-system.md#configuration-inheritance) for the full details. diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index d61315b4..ffcc6605 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -14,7 +14,6 @@ A component definition tells azldev where to find the spec file, how to customiz | Build config | `build` | [BuildConfig](#build-configuration) | No | Build-time options (macros, conditionals, check config) | | Render config | `render` | [RenderConfig](#render-configuration) | No | Options controlling spec rendering behavior | | 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 @@ -232,16 +231,7 @@ hints = { expensive = true } ## Package Configuration -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 - -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 = "rpm-base" -``` +Components can customize the configuration for the binary packages they produce using the `packages` map. ### Per-Package Overrides @@ -250,7 +240,7 @@ The `[components..packages.]` map lets you override config for a ```toml # Override just one subpackage [components.curl.packages.curl-devel.publish] -channel = "rpm-devel" +rpm-channel = "rpm-devel" ``` ### Resolution Order @@ -259,27 +249,40 @@ For each binary package produced by a component, the effective config is assembl 1. Project `default-package-config` 2. Package group containing this package name (if any) -3. Component `default-package-config` -4. Component `packages.` (highest priority) +3. Component `packages.` (highest priority) + +The component's `[publish]` section provides default channels for all packages that don't have explicit overrides. See [Publish Settings](#publish-settings) for details. See [Package Groups](package-groups.md) for the full field reference and a complete example. +### Publish Settings + +The `[components..publish]` section sets default publish channels for all packages produced by this component. These channels are inherited by every binary package unless overridden by a package-group or per-package setting. + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| RPM Channel | `rpm-channel` | string | No | Default publish channel for binary (non-debuginfo) packages | +| SRPM Channel | `srpm-channel` | string | No | Publish channel for the SRPM | +| Debuginfo Channel | `debuginfo-channel` | string | No | Publish channel for debuginfo and debugsource packages | + ### Example ```toml [components.curl] -# Route all curl packages to "base" by default ... -[components.curl.default-package-config.publish] -channel = "rpm-base" +# Set component-level default channels via publish +[components.curl.publish] +rpm-channel = "rpm-base" +srpm-channel = "rpm-base-srpm" +debuginfo-channel = "rpm-base-debuginfo" # ... but put curl-devel in the "devel" channel [components.curl.packages.libcurl-devel.publish] -channel = "rpm-devel" +rpm-channel = "rpm-devel" # Signal to downstream tooling that this package should not be published [components.curl.packages.libcurl-minimal.publish] -channel = "none" +rpm-channel = "none" ``` ## Source File References diff --git a/docs/user/reference/config/package-groups.md b/docs/user/reference/config/package-groups.md index 33cf231a..b7f52bfb 100644 --- a/docs/user/reference/config/package-groups.md +++ b/docs/user/reference/config/package-groups.md @@ -43,7 +43,8 @@ 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 signal to downstream tooling that this package should not be published. | +| RPM Channel | `rpm-channel` | string | No | Publish channel for binary packages. Use `"none"` to signal to downstream tooling that this package should not be published. | +| Debuginfo Channel | `debuginfo-channel` | string | No | Publish channel for debuginfo and debugsource packages. | ## Resolution Order @@ -51,8 +52,7 @@ When determining the effective config for a binary package, azldev applies confi 1. **Project `default-package-config`** — lowest priority; applies to all packages in the project 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 +3. **Component `packages.`** — highest priority; exact per-package override > **Note:** Each package name may appear in at most one group. Listing the same name in two groups produces a validation error. @@ -61,14 +61,14 @@ When determining the effective config for a binary package, azldev applies confi ```toml # Set a project-wide default channel [default-package-config.publish] -channel = "rpm-base" +rpm-channel = "rpm-base" [package-groups.devel-packages] description = "Development subpackages" packages = ["libcurl-devel", "curl-static", "wget2-devel"] [package-groups.devel-packages.default-package-config.publish] -channel = "rpm-build-only" +rpm-channel = "rpm-build-only" [package-groups.debug-packages] description = "Debug info and source" @@ -82,11 +82,11 @@ packages = [ ] [package-groups.debug-packages.default-package-config.publish] -channel = "rpm-debug" +debuginfo-channel = "rpm-debug" ``` ## 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 +- [Components](components.md) — per-component `packages` overrides and `publish` settings - [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 eaea0992..32347af6 100644 --- a/docs/user/reference/config/project.md +++ b/docs/user/reference/config/project.md @@ -15,7 +15,7 @@ The following fields are nested under the `[project]` TOML section: | Rendered specs directory | `rendered-specs-dir` | string | No | Output directory for `component render` (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 | -> **Note:** `[default-package-config]` and `[package-groups]` are **top-level** TOML sections — they are not nested under `[project]`. They are documented in the sections below. +> **Note:** `[default-component-config]`, `[default-package-config]`, and `[package-groups]` are **top-level** TOML sections — they are not nested under `[project]`. They are documented in the sections below. ## Directory Paths @@ -39,6 +39,21 @@ 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 Component Config + +The `[default-component-config]` section is a **top-level** TOML section (not nested under `[project]`). It defines the lowest-priority configuration layer applied to every component in the project before any component-group or component-level config is considered. + +The most common use is to set project-wide default publish channels for all components: + +```toml +[default-component-config.publish] +rpm-channel = "rpms-base" +srpm-channel = "rpms-base-srpm" +debuginfo-channel = "rpms-base-debuginfo" +``` + +Individual components or [component groups](component-groups.md) can override these defaults. See [Components — Publish Settings](components.md#publish-settings) for the full field reference and [Package Groups](package-groups.md#resolution-order) for the complete resolution order. + ## Default Package Config The `[default-package-config]` section is a **top-level** TOML section (not nested under `[project]`). It 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. @@ -47,7 +62,7 @@ The most common use is to set a project-wide default publish channel: ```toml [default-package-config.publish] -channel = "rpm-base" +rpm-channel = "rpm-base" ``` See [Package Groups](package-groups.md#resolution-order) for the full resolution order. @@ -70,21 +85,27 @@ work-dir = "build/work" output-dir = "out" default-distro = { name = "azurelinux", version = "4.0" } +[default-component-config.publish] +rpm-channel = "rpms-base" +srpm-channel = "rpms-base-srpm" +debuginfo-channel = "rpms-base-debuginfo" + [default-package-config.publish] -channel = "base" +rpm-channel = "base" [package-groups.devel-packages] description = "Development subpackages" packages = ["curl-devel", "curl-static", "wget2-devel"] [package-groups.devel-packages.default-package-config.publish] -channel = "devel" +rpm-channel = "devel" ``` ## Related Resources - [Config File Structure](config-file.md) — top-level config file layout - [Distros](distros.md) — distro definitions referenced by `default-distro` +- [Component Groups](component-groups.md) — group-level component config overrides - [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 f24f5c49..bad7ab5b 100644 --- a/internal/app/azldev/cmds/component/build.go +++ b/internal/app/azldev/cmds/component/build.go @@ -63,6 +63,10 @@ type ComponentBuildResults struct { // Absolute paths to any source RPMs built by the operation. SRPMPaths []string `json:"srpmPaths" table:"SRPM Paths"` + // SRPMChannel is the resolved publish channel for the SRPM. + // Empty when no channel is configured. + SRPMChannel string `json:"srpmPublishChannel,omitempty" table:"SRPM Publish Channel"` + // Absolute paths to any RPMs built by the operation. RPMPaths []string `json:"rpmPaths" table:"RPM Paths"` @@ -329,6 +333,9 @@ func buildComponentUsingBuilder( results.ComponentName = component.GetName() results.SRPMPaths = []string{outputSourcePackagePath} + // The component is already resolved — its Publish field reflects all inherited defaults. + results.SRPMChannel = component.GetConfig().Publish.SRPMChannel + // Short circuit if we were asked only to build the SRPM. if sourcePackageOnly { return results, nil @@ -345,38 +352,51 @@ func buildComponentUsingBuilder( return results, fmt.Errorf("failed to build RPM for %#q:\n%w", component.GetName(), err) } - // Enrich each RPM with its binary package name and resolved publish channel. + // Enrich each RPM with its binary package name and resolved publish channel, + // move RPMs into channel subdirectories, and populate the parallel result slices. + if err = resolveAndPlaceRPMs(env, component, &results, rpmsDir); err != nil { + return results, err + } + + // Publish built RPMs to local repo with publish enabled. + if localRepoWithPublishPath != "" && len(results.RPMPaths) > 0 { + publishErr := publishToLocalRepo(env, results.RPMPaths, localRepoWithPublishPath) + if publishErr != nil { + return results, fmt.Errorf("failed to publish RPMs for %#q:\n%w", component.GetName(), publishErr) + } + } + + return results, nil +} + +// resolveAndPlaceRPMs enriches build results with per-RPM publish channels, moves RPMs into +// channel subdirectories, and populates the parallel RPMPaths/RPMChannels slices. +func resolveAndPlaceRPMs( + env *azldev.Env, component components.Component, + results *ComponentBuildResults, rpmsDir string, +) error { + var err error + 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) + return fmt.Errorf("failed to resolve publish channels for %#q:\n%w", component.GetName(), err) } - // Move RPMs with a channel into out/rpms//, leaving unconfigured ones in out/rpms/. if err = PlaceRPMsByChannel(env, results.RPMs, rpmsDir); err != nil { - return results, fmt.Errorf("failed to place RPMs by channel for %#q:\n%w", component.GetName(), err) + return fmt.Errorf("failed to place RPMs by channel for %#q:\n%w", component.GetName(), err) } - // Sync RPMPaths to the final (possibly moved) locations. results.RPMPaths = make([]string, len(results.RPMs)) for rpmIdx, rpm := range results.RPMs { results.RPMPaths[rpmIdx] = rpm.Path } - // 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) - if publishErr != nil { - return results, fmt.Errorf("failed to publish RPMs for %q:\n%w", component.GetName(), publishErr) - } - } - - return results, nil + return nil } // PlaceRPMsByChannel moves each RPM with a configured channel from its initial location in @@ -480,12 +500,12 @@ func resolveRPMResults( } if proj != nil { - pkgConfig, err := projectconfig.ResolvePackageConfig(pkgName, compConfig, proj) + channel, err := projectconfig.ResolvePackagePublishChannel(pkgName, compConfig, proj) if err != nil { - return nil, fmt.Errorf("failed to resolve package config for %#q:\n%w", pkgName, err) + return nil, fmt.Errorf("failed to resolve publish channel for %#q:\n%w", pkgName, err) } - rpmResult.Channel = pkgConfig.Publish.Channel + rpmResult.Channel = channel } rpmResults = append(rpmResults, rpmResult) diff --git a/internal/app/azldev/cmds/pkg/list.go b/internal/app/azldev/cmds/pkg/list.go index 43e0db61..38a55141 100644 --- a/internal/app/azldev/cmds/pkg/list.go +++ b/internal/app/azldev/cmds/pkg/list.go @@ -52,10 +52,11 @@ to look up one or more specific packages by exact name — including packages that are not explicitly configured (they resolve using only project defaults). Resolution order (lowest to highest priority): - 1. Project default-package-config - 2. Package group default-package-config - 3. Component default-package-config - 4. Component packages. override`, + 1. Project default-component-config publish settings + 2. Component-group default-component-config publish settings + 3. Component publish settings + 4. Package-group default-package-config + 5. Component packages. override`, Example: ` # List all explicitly-configured packages azldev package list -a @@ -174,28 +175,12 @@ func ListPackages(env *azldev.Env, options *ListPackageOptions) ([]PackageListRe results := make([]PackageListResult, 0, len(toResolve)) for pkgName := range toResolve { - // Resolve using the component's config if one is known; otherwise use an empty config - // so that only the project default and group layers are applied. - compName := compOf[pkgName] - compConfig := &projectconfig.ComponentConfig{} - - if compName != "" { - if c, ok := proj.Components[compName]; ok { - compConfig = &c - } - } - - pkgConfig, err := projectconfig.ResolvePackageConfig(pkgName, compConfig, proj) + result, err := resolvePackageListResult(pkgName, compOf, groupOf, proj) if err != nil { - return nil, fmt.Errorf("failed to resolve config for package %#q:\n%w", pkgName, err) + return nil, err } - results = append(results, PackageListResult{ - PackageName: pkgName, - Group: groupOf[pkgName], - Component: compName, - Channel: pkgConfig.Publish.Channel, - }) + results = append(results, result) } if options.SynthesizeDebugPackages { @@ -216,6 +201,85 @@ func ListPackages(env *azldev.Env, options *ListPackageOptions) ([]PackageListRe return results, nil } +// resolvePackageListResult resolves the publish channel and component membership for a single +// package and returns a [PackageListResult]. +func resolvePackageListResult( + pkgName string, + compOf map[string]string, + groupOf map[string]string, + proj *projectconfig.ProjectConfig, +) (PackageListResult, error) { + compName := resolveComponentName(pkgName, compOf, proj) + compConfig := resolveComponentConfig(compName, proj) + + // Apply inherited defaults (project → groups → component) so that + // compConfig.Publish reflects the fully-merged publish channels. + // No distro context is available here (pkg list operates on the raw project config), + // so distro defaults are omitted. + resolved, err := projectconfig.ResolveComponentConfig( + compConfig, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, // no distro context at this call site + proj.ComponentGroups, + proj.GroupsByComponent[compName], + ) + if err != nil { + return PackageListResult{}, fmt.Errorf("failed to resolve defaults for component %#q:\n%w", compName, err) + } + + channel, err := projectconfig.ResolvePackagePublishChannel(pkgName, &resolved, proj) + if err != nil { + return PackageListResult{}, fmt.Errorf("failed to resolve publish channel for package %#q:\n%w", pkgName, err) + } + + return PackageListResult{ + PackageName: pkgName, + Group: groupOf[pkgName], + Component: compName, + Channel: channel, + }, nil +} + +// resolveComponentName returns the component name for a binary package. It first +// checks for an explicit per-package override in compOf, then falls back to +// treating the package name itself as a component name when a matching component +// or component-group member exists. +func resolveComponentName( + pkgName string, + compOf map[string]string, + proj *projectconfig.ProjectConfig, +) string { + if name := compOf[pkgName]; name != "" { + return name + } + + if _, ok := proj.Components[pkgName]; ok { + return pkgName + } + + if _, ok := proj.GroupsByComponent[pkgName]; ok { + return pkgName + } + + return "" +} + +// resolveComponentConfig returns the [projectconfig.ComponentConfig] for a named +// component. If the component has an explicit entry in [projectconfig.ProjectConfig.Components], +// that entry is returned; otherwise a minimal config with just the Name set is returned so that +// [projectconfig.ResolveComponentConfig] can still look up group membership. +func resolveComponentConfig(compName string, proj *projectconfig.ProjectConfig) projectconfig.ComponentConfig { + if compName == "" { + return projectconfig.ComponentConfig{} + } + + if c, ok := proj.Components[compName]; ok { + return c + } + + return projectconfig.ComponentConfig{Name: compName} +} + // synthesizeDebugPackages augments results with synthetic '-debuginfo' packages (one per // already-resolved package, using a parallel publish channel derived from the original // package's publish channel by appending '-debuginfo', except when the original channel is @@ -268,7 +332,7 @@ func synthesizeDebugPackages( // standard package-resolution chain for '-debugsource' so the entry // reflects any explicit package override, package-group defaults, component // settings, or project defaults instead of an implicit empty channel. - for compName, comp := range proj.Components { + for compName := range proj.Components { if isDebugPackageName(compName) { continue } @@ -280,18 +344,14 @@ func synthesizeDebugPackages( existing[name] = struct{}{} - compCopy := comp - - pkgConfig, err := projectconfig.ResolvePackageConfig(name, &compCopy, proj) + // compOf pins the synthesized package to its owning component; groupOf is nil + // because -debugsource entries are never members of a package group. + result, err := resolvePackageListResult(name, map[string]string{name: compName}, nil, proj) if err != nil { - return nil, fmt.Errorf("failed to resolve config for synthesized package %#q:\n%w", name, err) + return nil, err } - results = append(results, PackageListResult{ - PackageName: name, - Component: compName, - Channel: debugChannelName(pkgConfig.Publish.Channel), - }) + results = append(results, result) } return results, nil diff --git a/internal/app/azldev/cmds/pkg/list_test.go b/internal/app/azldev/cmds/pkg/list_test.go index 21ec4fdb..af05b01b 100644 --- a/internal/app/azldev/cmds/pkg/list_test.go +++ b/internal/app/azldev/cmds/pkg/list_test.go @@ -48,7 +48,7 @@ func TestListPackages_FromPackageGroup(t *testing.T) { "devel-packages": { Packages: []string{"curl-devel", "wget2-devel"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "devel"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "devel"}, }, }, } @@ -75,7 +75,7 @@ func TestListPackages_FromComponentPackageOverride(t *testing.T) { Name: "curl", Packages: map[string]projectconfig.PackageConfig{ "curl-minimal": { - Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "none"}, }, }, } @@ -96,7 +96,7 @@ func TestListPackages_ComponentOverrideWinsOverGroup(t *testing.T) { "base-packages": { Packages: []string{"curl-devel"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "base"}, }, }, } @@ -104,7 +104,7 @@ func TestListPackages_ComponentOverrideWinsOverGroup(t *testing.T) { Name: "curl", Packages: map[string]projectconfig.PackageConfig{ "curl-devel": { - Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "none"}, }, }, } @@ -143,7 +143,7 @@ func TestListPackages_ByName_InExplicitConfig(t *testing.T) { "devel-packages": { Packages: []string{"curl-devel", "wget2-devel"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "devel"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "devel"}, }, }, } @@ -162,10 +162,13 @@ func TestListPackages_ByName_InExplicitConfig(t *testing.T) { func TestListPackages_ByName_NotInExplicitConfig(t *testing.T) { testEnv := testutils.NewTestEnv(t) testEnv.Config.DefaultPackageConfig = projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "default-channel"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "default-channel"}, } - // Look up a package that has no explicit config; it still resolves via project defaults. + // Look up a package that has no component publish config or package group. + // DefaultPackageConfig does NOT affect publish channel resolution (it is intentionally + // excluded — otherwise it would override the already-resolved component publish channel). + // The channel is therefore empty. results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{PackageNames: []string{"unknown-pkg"}}) require.NoError(t, err) @@ -173,7 +176,7 @@ func TestListPackages_ByName_NotInExplicitConfig(t *testing.T) { assert.Equal(t, "unknown-pkg", results[0].PackageName) assert.Empty(t, results[0].Group) assert.Empty(t, results[0].Component) - assert.Equal(t, "default-channel", results[0].Channel) + assert.Empty(t, results[0].Channel) } func TestListPackages_ByName_MultipleNames(t *testing.T) { @@ -195,13 +198,13 @@ func TestListPackages_DuplicatePackageAcrossComponents_ReturnsError(t *testing.T testEnv.Config.Components["curl"] = projectconfig.ComponentConfig{ Name: "curl", Packages: map[string]projectconfig.PackageConfig{ - "shared-pkg": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + "shared-pkg": {Publish: projectconfig.PackagePublishConfig{RPMChannel: "base"}}, }, } testEnv.Config.Components["other"] = projectconfig.ComponentConfig{ Name: "other", Packages: map[string]projectconfig.PackageConfig{ - "shared-pkg": {Publish: projectconfig.PackagePublishConfig{Channel: "none"}}, + "shared-pkg": {Publish: projectconfig.PackagePublishConfig{RPMChannel: "none"}}, }, } @@ -216,24 +219,19 @@ func TestListPackages_DuplicatePackageAcrossComponents_ReturnsError(t *testing.T func TestListPackages_SynthesizeDebugPackages(t *testing.T) { testEnv := testutils.NewTestEnv(t) - testEnv.Config.DefaultPackageConfig = projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "default-channel"}, + testEnv.Config.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{DebugInfoChannel: "default-channel-debuginfo"}, } testEnv.Config.PackageGroups = map[string]projectconfig.PackageGroupConfig{ "devel-packages": { Packages: []string{"curl-devel"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "devel"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "devel"}, }, }, } testEnv.Config.Components["curl"] = projectconfig.ComponentConfig{Name: "curl"} - testEnv.Config.Components["wget"] = projectconfig.ComponentConfig{ - Name: "wget", - DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "wget-default"}, - }, - } + testEnv.Config.Components["wget"] = projectconfig.ComponentConfig{Name: "wget"} results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{ All: true, @@ -264,7 +262,7 @@ func TestListPackages_SynthesizeDebugPackages(t *testing.T) { assert.Empty(t, byName["curl-debugsource"].Group) require.Contains(t, byName, "wget-debugsource") - assert.Equal(t, "wget-default-debuginfo", byName["wget-debugsource"].Channel) + assert.Equal(t, "default-channel-debuginfo", byName["wget-debugsource"].Channel) assert.Equal(t, "wget", byName["wget-debugsource"].Component) } @@ -274,14 +272,14 @@ func TestListPackages_SynthesizeDebugPackages_SkipsExisting(t *testing.T) { "g": { Packages: []string{"curl", "curl-debuginfo"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "real"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "real", DebugInfoChannel: "real"}, }, }, } testEnv.Config.Components["curl"] = projectconfig.ComponentConfig{ Name: "curl", Packages: map[string]projectconfig.PackageConfig{ - "curl-debugsource": {Publish: projectconfig.PackagePublishConfig{Channel: "explicit"}}, + "curl-debugsource": {Publish: projectconfig.PackagePublishConfig{DebugInfoChannel: "explicit"}}, }, } @@ -318,14 +316,14 @@ func TestListPackages_SynthesizeDebugPackages_SkipsExisting(t *testing.T) { func TestListPackages_SynthesizeDebugPackages_ByName(t *testing.T) { testEnv := testutils.NewTestEnv(t) - testEnv.Config.DefaultPackageConfig = projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "default-channel"}, + testEnv.Config.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{DebugInfoChannel: "default-channel-debuginfo"}, } testEnv.Config.PackageGroups = map[string]projectconfig.PackageGroupConfig{ "devel-packages": { Packages: []string{"curl-devel"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "devel"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "devel"}, }, }, } @@ -363,7 +361,7 @@ func TestListPackages_SynthesizeDebugPackages_ChannelSuffixRules(t *testing.T) { "none-grp": { Packages: []string{"pkg-none"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "none"}, }, }, "empty-grp": { @@ -373,7 +371,7 @@ func TestListPackages_SynthesizeDebugPackages_ChannelSuffixRules(t *testing.T) { "already-grp": { Packages: []string{"pkg-already"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "ms-debuginfo"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "ms-debuginfo"}, }, }, } @@ -397,3 +395,30 @@ func TestListPackages_SynthesizeDebugPackages_ChannelSuffixRules(t *testing.T) { // Already-suffixed channels are not doubled. assert.Equal(t, "ms-debuginfo", byName["pkg-already-debuginfo"].Channel) } + +func TestListPackages_ComponentGroupPublishChannel(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + // Project-wide default — lowest priority. + testEnv.Config.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{RPMChannel: "rpm-sdk"}, + } + + // Component group with a higher-priority publish channel. + testEnv.Config.ComponentGroups["base-published"] = projectconfig.ComponentGroupConfig{ + Components: []string{"jq"}, + DefaultComponentConfig: projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{RPMChannel: "rpm-base"}, + }, + } + testEnv.Config.GroupsByComponent["jq"] = []string{"base-published"} + + // No explicit [components.jq] entry — the component is defined only via group membership. + results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{PackageNames: []string{"jq"}}) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "jq", results[0].PackageName) + // The group's rpm-base channel must win over the project default rpm-sdk. + assert.Equal(t, "rpm-base", results[0].Channel) +} diff --git a/internal/app/azldev/core/components/resolver.go b/internal/app/azldev/core/components/resolver.go index 124608da..7d290b16 100644 --- a/internal/app/azldev/core/components/resolver.go +++ b/internal/app/azldev/core/components/resolver.go @@ -483,6 +483,7 @@ func applyInheritedDefaultsToComponent( resolved, err := projectconfig.ResolveComponentConfig( component, + env.Config().DefaultComponentConfig, distroVer.DefaultComponentConfig, env.Config().ComponentGroups, groupNames, diff --git a/internal/app/azldev/core/components/resolver_test.go b/internal/app/azldev/core/components/resolver_test.go index 29c8f3e3..3ea4d081 100644 --- a/internal/app/azldev/core/components/resolver_test.go +++ b/internal/app/azldev/core/components/resolver_test.go @@ -566,6 +566,89 @@ func TestApplyInheritedDefaults_NoGroupMembership(t *testing.T) { assert.Contains(t, resolved.Build.With, "my-feature") } +func TestApplyInheritedDefaults_ProjectDefault(t *testing.T) { + // The project-level DefaultComponentConfig should be applied as the + // lowest-priority layer, before distro and group defaults. + env := testutils.NewTestEnv(t) + + // Set project-level defaults. + env.Config.DefaultComponentConfig = projectconfig.ComponentConfig{ + Build: projectconfig.ComponentBuildConfig{ + Without: []string{"docs"}, + }, + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + SRPMChannel: "rpms-sdk-srpm", + }, + } + + component := projectconfig.ComponentConfig{ + Name: "my-comp", + Build: projectconfig.ComponentBuildConfig{ + With: []string{"feature-x"}, + }, + } + env.Config.Components[component.Name] = component + + filter := &components.ComponentFilter{IncludeAllComponents: true} + + result, err := components.NewResolver(env.Env).FindComponents(filter) + require.NoError(t, err) + require.Len(t, result.Components(), 1) + + resolved := result.Components()[0].GetConfig() + + // Component's own With should be present. + assert.Contains(t, resolved.Build.With, "feature-x") + // Project default Without should be inherited. + assert.Contains(t, resolved.Build.Without, "docs") + // Project default publish config should be inherited. + assert.Equal(t, "rpms-sdk", resolved.Publish.RPMChannel) + assert.Equal(t, "rpms-sdk-srpm", resolved.Publish.SRPMChannel) +} + +func TestApplyInheritedDefaults_ProjectDefaultOverriddenByGroup(t *testing.T) { + // Group defaults should override project defaults, and the component's own + // config should override everything. + env := testutils.NewTestEnv(t) + + env.Config.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + SRPMChannel: "rpms-sdk-srpm", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + } + + env.Config.ComponentGroups["base-group"] = projectconfig.ComponentGroupConfig{ + Components: []string{"my-comp"}, + DefaultComponentConfig: projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-base", + SRPMChannel: "rpms-base-srpm", + }, + }, + } + env.Config.GroupsByComponent["my-comp"] = []string{"base-group"} + + component := projectconfig.ComponentConfig{Name: "my-comp"} + env.Config.Components[component.Name] = component + + filter := &components.ComponentFilter{IncludeAllComponents: true} + + result, err := components.NewResolver(env.Env).FindComponents(filter) + require.NoError(t, err) + require.Len(t, result.Components(), 1) + + resolved := result.Components()[0].GetConfig() + + // Group overrides project default for RPM and SRPM channels. + assert.Equal(t, "rpms-base", resolved.Publish.RPMChannel) + assert.Equal(t, "rpms-base-srpm", resolved.Publish.SRPMChannel) + // Project default is inherited for DebugInfoChannel (not overridden by group). + assert.Equal(t, "rpms-sdk-debuginfo", resolved.Publish.DebugInfoChannel) +} + func TestFindAllSpecPaths_Nothing(t *testing.T) { env := testutils.NewTestEnv(t) diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index 4d59035c..eb5d228f 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -7,6 +7,7 @@ import ( "fmt" "slices" "sort" + "strings" "dario.cat/mergo" "github.com/brunoga/deep" @@ -66,6 +67,24 @@ type SourceFileReference struct { Origin Origin `toml:"origin,omitempty" json:"origin,omitempty" fingerprint:"-"` } +// ComponentPublishConfig holds publish channel settings for a component's packages. +// The zero value means all channels are inherited from a higher-priority config layer. +type ComponentPublishConfig struct { + // RPMChannel identifies the publish channel for binary (non-debuginfo) packages + // produced by this component. When empty, the value is inherited from the next layer + // in the resolution order. + RPMChannel string `toml:"rpm-channel,omitempty" json:"rpmChannel,omitempty" validate:"omitempty,ne=.,ne=..,excludesall=/\\" jsonschema:"title=RPM channel,description=Publish channel for binary packages produced by this component"` + + // SRPMChannel identifies the publish channel for the SRPM produced by this component. + // When empty, the value is inherited from the next layer in the resolution order. + SRPMChannel string `toml:"srpm-channel,omitempty" json:"srpmChannel,omitempty" validate:"omitempty,ne=.,ne=..,excludesall=/\\" jsonschema:"title=SRPM channel,description=Publish channel for the SRPM produced by this component"` + + // DebugInfoChannel identifies the publish channel for debuginfo packages produced + // by this component. When empty, the value is inherited from the next layer in the + // resolution order. + DebugInfoChannel string `toml:"debuginfo-channel,omitempty" json:"debuginfoChannel,omitempty" validate:"omitempty,ne=.,ne=..,excludesall=/\\" jsonschema:"title=Debuginfo channel,description=Publish channel for debuginfo packages produced by this component"` +} + // Defines a component group. Component groups are logical groupings of components (see [ComponentConfig]). // A component group is useful because it allows for succinctly naming/identifying a curated set of components, // say in a command line interface. Note that a component group does not uniquely "own" its components; a @@ -162,13 +181,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. + // Takes precedence over package-group defaults. Packages map[string]PackageConfig `toml:"packages,omitempty" json:"packages,omitempty" table:"-" validate:"dive" jsonschema:"title=Package overrides,description=Per-package configuration overrides keyed by exact binary package name"` + + // Publish holds the component-level publish settings. These provide default channels for + // all packages produced by this component. Overridden by package-group and per-package settings + // for binary and debuginfo channels. + Publish ComponentPublishConfig `toml:"publish,omitempty" json:"publish,omitempty" table:"-" jsonschema:"title=Publish settings,description=Component-level publish channel settings" fingerprint:"-"` } // AllowedSourceFilesHashTypes defines the set of hash types that are supported @@ -191,17 +211,22 @@ func (c *ComponentConfig) MergeUpdatesFrom(other *ComponentConfig) error { return nil } -// ResolveComponentConfig applies the full config inheritance chain for a single -// component: distro defaults → group defaults (sorted) → component explicit config. +// ResolveComponentConfig applies the full config inheritance chain for a single component: +// project-level defaults → distro defaults → group defaults (sorted) → component explicit config. // Returns a fully resolved copy; the inputs are not modified. // On error the returned config is undefined and must not be used. func ResolveComponentConfig( comp ComponentConfig, + projectDefaults ComponentConfig, distroDefaults ComponentConfig, groups map[string]ComponentGroupConfig, groupMembership []string, ) (ComponentConfig, error) { - merged := deep.MustCopy(distroDefaults) + merged := deep.MustCopy(projectDefaults) + + if err := merged.MergeUpdatesFrom(&distroDefaults); err != nil { + return ComponentConfig{}, fmt.Errorf("failed to apply distro defaults:\n%w", err) + } // Apply group defaults in sorted order for determinism. sortedGroups := slices.Clone(groupMembership) @@ -234,16 +259,16 @@ 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, - RenderedSpecDir: c.RenderedSpecDir, - Release: c.Release, - Spec: deep.MustCopy(c.Spec), - Build: deep.MustCopy(c.Build), - Render: c.Render, - SourceFiles: deep.MustCopy(c.SourceFiles), - DefaultPackageConfig: deep.MustCopy(c.DefaultPackageConfig), - Packages: deep.MustCopy(c.Packages), + Name: c.Name, + SourceConfigFile: c.SourceConfigFile, + RenderedSpecDir: c.RenderedSpecDir, + Release: c.Release, + Spec: deep.MustCopy(c.Spec), + Build: deep.MustCopy(c.Build), + Render: c.Render, + SourceFiles: deep.MustCopy(c.SourceFiles), + Packages: deep.MustCopy(c.Packages), + Publish: deep.MustCopy(c.Publish), } // Fix up paths. @@ -260,3 +285,83 @@ func (c *ComponentConfig) WithAbsolutePaths(referenceDir string) *ComponentConfi return result } + +// IsDebugInfoPackage reports whether pkgName is a debuginfo or debugsource package. +// It matches "-debuginfo" or "-debugsource" as hyphen-delimited segments so that +// names like "kernel-debuginfo-common-x86_64" are correctly identified while +// unrelated packages like "elfutils-debuginfod" are not. +func IsDebugInfoPackage(pkgName string) bool { + return containsRPMSegment(pkgName, "-debuginfo") || containsRPMSegment(pkgName, "-debugsource") +} + +// containsRPMSegment reports whether pkgName contains segment as a hyphen-delimited +// segment — i.e. segment is followed by end-of-string or '-'. This prevents +// "-debuginfo" from matching "-debuginfod". +func containsRPMSegment(pkgName, segment string) bool { + searchStart := 0 + + for { + idx := strings.Index(pkgName[searchStart:], segment) + if idx < 0 { + return false + } + + idx += searchStart + end := idx + len(segment) + + if end == len(pkgName) || pkgName[end] == '-' { + return true + } + + searchStart = idx + 1 + } +} + +// ResolvePackagePublishChannel returns the publish channel for a binary package produced by a +// component. The caller must pass a resolved [ComponentConfig] (one whose Publish field already +// reflects project-level, distro, and component-group defaults). +// +// Resolution order (later wins): +// 1. The resolved component-level publish channel ([ComponentPublishConfig.RPMChannel] or +// [ComponentPublishConfig.DebugInfoChannel], depending on the package name). +// 2. The matching package-group's publish channel, if the package belongs to one. +// 3. The component's explicit per-package publish channel override, if set. +// +// Note: [ProjectConfig.DefaultPackageConfig] is intentionally excluded from publish channel +// resolution here. It would override the already-resolved component channel, which would make +// component-level publish defaults ineffective whenever a project default is set. +func ResolvePackagePublishChannel(pkgName string, comp *ComponentConfig, proj *ProjectConfig) (string, error) { + isDebugInfo := IsDebugInfoPackage(pkgName) + + // Start with the already-resolved component-level channel for the package type. + var channel string + if isDebugInfo { + channel = comp.Publish.DebugInfoChannel + } else { + channel = comp.Publish.RPMChannel + } + + // Apply package-group override if this package belongs to one. + for _, group := range proj.PackageGroups { + if slices.Contains(group.Packages, pkgName) { + if isDebugInfo && group.DefaultPackageConfig.Publish.DebugInfoChannel != "" { + channel = group.DefaultPackageConfig.Publish.DebugInfoChannel + } else if !isDebugInfo && group.DefaultPackageConfig.Publish.RPMChannel != "" { + channel = group.DefaultPackageConfig.Publish.RPMChannel + } + + break + } + } + + // Apply the explicit per-package override (highest priority). + if pkgConfig, ok := comp.Packages[pkgName]; ok { + if isDebugInfo && pkgConfig.Publish.DebugInfoChannel != "" { + channel = pkgConfig.Publish.DebugInfoChannel + } else if !isDebugInfo && pkgConfig.Publish.RPMChannel != "" { + channel = pkgConfig.Publish.RPMChannel + } + } + + return channel, nil +} diff --git a/internal/projectconfig/component_test.go b/internal/projectconfig/component_test.go index 84dc8070..d0aafc96 100644 --- a/internal/projectconfig/component_test.go +++ b/internal/projectconfig/component_test.go @@ -245,7 +245,7 @@ func TestResolveComponentConfig(t *testing.T) { comp := projectconfig.ComponentConfig{Name: "curl"} resolved, err := projectconfig.ResolveComponentConfig( - comp, distroDefaults, nil, nil, + comp, projectconfig.ComponentConfig{}, distroDefaults, nil, nil, ) require.NoError(t, err) assert.Equal(t, "curl", resolved.Name) @@ -265,7 +265,7 @@ func TestResolveComponentConfig(t *testing.T) { comp := projectconfig.ComponentConfig{Name: "curl"} resolved, err := projectconfig.ResolveComponentConfig( - comp, distroDefaults, groups, []string{"core"}, + comp, projectconfig.ComponentConfig{}, distroDefaults, groups, []string{"core"}, ) require.NoError(t, err) assert.Equal(t, "group-commit", resolved.Spec.UpstreamCommit) @@ -289,7 +289,7 @@ func TestResolveComponentConfig(t *testing.T) { // Groups applied in sorted order: alpha then beta. beta wins. resolved, err := projectconfig.ResolveComponentConfig( - comp, distroDefaults, groups, []string{"beta", "alpha"}, + comp, projectconfig.ComponentConfig{}, distroDefaults, groups, []string{"beta", "alpha"}, ) require.NoError(t, err) assert.Equal(t, "beta-commit", resolved.Spec.UpstreamCommit) @@ -309,7 +309,7 @@ func TestResolveComponentConfig(t *testing.T) { } resolved, err := projectconfig.ResolveComponentConfig( - comp, distroDefaults, groups, []string{"core"}, + comp, projectconfig.ComponentConfig{}, distroDefaults, groups, []string{"core"}, ) require.NoError(t, err) assert.Equal(t, "comp-commit", resolved.Spec.UpstreamCommit) @@ -319,7 +319,7 @@ func TestResolveComponentConfig(t *testing.T) { comp := projectconfig.ComponentConfig{Name: "curl"} _, err := projectconfig.ResolveComponentConfig( - comp, distroDefaults, nil, []string{"nonexistent"}, + comp, projectconfig.ComponentConfig{}, distroDefaults, nil, []string{"nonexistent"}, ) require.Error(t, err) assert.Contains(t, err.Error(), "component group not found") @@ -337,10 +337,590 @@ func TestResolveComponentConfig(t *testing.T) { originalDefaults := distroDefaults _, err := projectconfig.ResolveComponentConfig( - comp, distroDefaults, groups, []string{"core"}, + comp, projectconfig.ComponentConfig{}, distroDefaults, groups, []string{"core"}, ) require.NoError(t, err) assert.Equal(t, originalDefaults, distroDefaults, "distro defaults should not be mutated") assert.Empty(t, comp.Spec.UpstreamCommit, "component config should not be mutated") }) } + +func TestComponentPublishConfig_Validate(t *testing.T) { + t.Parallel() + + validCases := []struct { + name string + channel string + }{ + {name: "empty channel is valid (means inherit)", channel: ""}, + {name: "simple channel name", channel: "rpms-base"}, + {name: "channel with hyphens", channel: "rpms-sdk-srpm"}, + {name: "channel with underscores", channel: "rpms_base"}, + {name: "reserved none value", channel: "none"}, + } + + for _, testCase := range validCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + cfg := projectconfig.ComponentPublishConfig{ + RPMChannel: testCase.channel, + SRPMChannel: testCase.channel, + DebugInfoChannel: testCase.channel, + } + assert.NoError(t, validator.New().Struct(&cfg)) + }) + } + + invalidCases := []struct { + name string + channel string + errContains string + }{ + {name: "absolute path", channel: "/etc/passwd", errContains: "excludesall"}, + {name: "traversal with slash", channel: "../secret", errContains: "excludesall"}, + {name: "backslash separator", channel: `foo\bar`, errContains: "excludesall"}, + {name: "dot traversal", channel: "..", errContains: "'ne'"}, + {name: "single dot", channel: ".", errContains: "'ne'"}, + } + + for _, testCase := range invalidCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + cfg := projectconfig.ComponentPublishConfig{RPMChannel: testCase.channel} + err := validator.New().Struct(&cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), testCase.errContains) + }) + } +} + +func TestIsDebugInfoPackage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pkgName string + expected bool + }{ + {"simple debuginfo suffix", "curl-debuginfo", true}, + {"simple debugsource suffix", "curl-debugsource", true}, + {"debuginfo with arch suffix", "kernel-debuginfo-common-x86_64", true}, + {"debugsource with extra segments", "glibc-debugsource-common", true}, + {"debuginfod is not debuginfo", "elfutils-debuginfod", false}, + {"debuginfod client is not debuginfo", "elfutils-debuginfod-client", false}, + {"debuginfod followed by debuginfo", "foo-debuginfod-debuginfo", true}, + {"plain binary package", "curl", false}, + {"package ending in info", "texinfo", false}, + {"package with debug prefix", "debug-tools", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, projectconfig.IsDebugInfoPackage(tt.pkgName)) + }) + } +} + +func TestResolveComponentConfig_ProjectDefaults(t *testing.T) { + t.Parallel() + + t.Run("empty project and component returns zero-value config", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + comp := projectconfig.ComponentConfig{Name: "curl"} + + got, err := projectconfig.ResolveComponentConfig( + comp, proj.DefaultComponentConfig, projectconfig.ComponentConfig{}, + proj.ComponentGroups, proj.GroupsByComponent[comp.Name], + ) + require.NoError(t, err) + assert.Empty(t, got.Publish.RPMChannel) + assert.Empty(t, got.Publish.SRPMChannel) + assert.Empty(t, got.Publish.DebugInfoChannel) + }) + + t.Run("project default applies when no other config matches", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + SRPMChannel: "rpms-sdk-srpm", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + } + comp := projectconfig.ComponentConfig{Name: "curl"} + + got, err := projectconfig.ResolveComponentConfig( + comp, proj.DefaultComponentConfig, projectconfig.ComponentConfig{}, + proj.ComponentGroups, proj.GroupsByComponent[comp.Name], + ) + require.NoError(t, err) + assert.Equal(t, "rpms-sdk", got.Publish.RPMChannel) + assert.Equal(t, "rpms-sdk-srpm", got.Publish.SRPMChannel) + assert.Equal(t, "rpms-sdk-debuginfo", got.Publish.DebugInfoChannel) + }) + + t.Run("component group overrides project default", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + SRPMChannel: "rpms-sdk-srpm", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + } + proj.ComponentGroups = map[string]projectconfig.ComponentGroupConfig{ + "base-published": { + Components: []string{"systemd", "bash"}, + DefaultComponentConfig: projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-base", + SRPMChannel: "rpms-base-srpm", + DebugInfoChannel: "rpms-base-debuginfo", + }, + }, + }, + } + proj.GroupsByComponent = map[string][]string{ + "systemd": {"base-published"}, + "bash": {"base-published"}, + } + + comp := projectconfig.ComponentConfig{Name: "systemd"} + got, err := projectconfig.ResolveComponentConfig( + comp, proj.DefaultComponentConfig, projectconfig.ComponentConfig{}, + proj.ComponentGroups, proj.GroupsByComponent[comp.Name], + ) + require.NoError(t, err) + assert.Equal(t, "rpms-base", got.Publish.RPMChannel) + assert.Equal(t, "rpms-base-srpm", got.Publish.SRPMChannel) + assert.Equal(t, "rpms-base-debuginfo", got.Publish.DebugInfoChannel) + }) + + t.Run("component not in group inherits project default", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + SRPMChannel: "rpms-sdk-srpm", + }, + } + proj.ComponentGroups = map[string]projectconfig.ComponentGroupConfig{ + "base-published": { + Components: []string{"systemd"}, + DefaultComponentConfig: projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-base", + }, + }, + }, + } + proj.GroupsByComponent = map[string][]string{ + "systemd": {"base-published"}, + } + + comp := projectconfig.ComponentConfig{Name: "curl"} + got, err := projectconfig.ResolveComponentConfig( + comp, proj.DefaultComponentConfig, projectconfig.ComponentConfig{}, + proj.ComponentGroups, proj.GroupsByComponent[comp.Name], + ) + require.NoError(t, err) + assert.Equal(t, "rpms-sdk", got.Publish.RPMChannel) + assert.Equal(t, "rpms-sdk-srpm", got.Publish.SRPMChannel) + }) + + t.Run("component own publish config overrides all defaults", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + SRPMChannel: "rpms-sdk-srpm", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + } + comp := projectconfig.ComponentConfig{ + Name: "special-comp", + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-custom", + }, + } + + got, err := projectconfig.ResolveComponentConfig( + comp, proj.DefaultComponentConfig, projectconfig.ComponentConfig{}, + proj.ComponentGroups, proj.GroupsByComponent[comp.Name], + ) + require.NoError(t, err) + assert.Equal(t, "rpms-custom", got.Publish.RPMChannel) + assert.Equal(t, "rpms-sdk-srpm", got.Publish.SRPMChannel) + assert.Equal(t, "rpms-sdk-debuginfo", got.Publish.DebugInfoChannel) + }) + + t.Run("partial group override preserves other fields from project default", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + SRPMChannel: "rpms-sdk-srpm", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + } + proj.ComponentGroups = map[string]projectconfig.ComponentGroupConfig{ + "base-published": { + Components: []string{"bash"}, + DefaultComponentConfig: projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-base", + // SRPMChannel and DebugInfoChannel not set — should inherit. + }, + }, + }, + } + proj.GroupsByComponent = map[string][]string{ + "bash": {"base-published"}, + } + + comp := projectconfig.ComponentConfig{Name: "bash"} + got, err := projectconfig.ResolveComponentConfig( + comp, proj.DefaultComponentConfig, projectconfig.ComponentConfig{}, + proj.ComponentGroups, proj.GroupsByComponent[comp.Name], + ) + require.NoError(t, err) + assert.Equal(t, "rpms-base", got.Publish.RPMChannel) + assert.Equal(t, "rpms-sdk-srpm", got.Publish.SRPMChannel) + assert.Equal(t, "rpms-sdk-debuginfo", got.Publish.DebugInfoChannel) + }) + + t.Run("build config is also inherited from defaults", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Build: projectconfig.ComponentBuildConfig{ + Without: []string{"docs"}, + }, + } + + comp := projectconfig.ComponentConfig{Name: "curl"} + got, err := projectconfig.ResolveComponentConfig( + comp, proj.DefaultComponentConfig, projectconfig.ComponentConfig{}, + proj.ComponentGroups, proj.GroupsByComponent[comp.Name], + ) + require.NoError(t, err) + assert.Equal(t, []string{"docs"}, got.Build.Without) + }) +} + +func TestResolvePackagePublishChannel(t *testing.T) { + t.Parallel() + + t.Run("binary package gets binary channel from resolved component", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + } + + resolved, err := projectconfig.ResolveComponentConfig( + projectconfig.ComponentConfig{Name: "curl"}, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, + proj.ComponentGroups, + proj.GroupsByComponent["curl"], + ) + require.NoError(t, err) + + channel, err := projectconfig.ResolvePackagePublishChannel("curl", &resolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-sdk", channel) + }) + + t.Run("debuginfo package gets debuginfo channel from resolved component", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + } + + resolved, err := projectconfig.ResolveComponentConfig( + projectconfig.ComponentConfig{Name: "curl"}, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, + proj.ComponentGroups, + proj.GroupsByComponent["curl"], + ) + require.NoError(t, err) + + channel, err := projectconfig.ResolvePackagePublishChannel("curl-debuginfo", &resolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-sdk-debuginfo", channel) + }) + + t.Run("no config returns empty channel", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + + resolved, err := projectconfig.ResolveComponentConfig( + projectconfig.ComponentConfig{Name: "curl"}, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, + proj.ComponentGroups, + proj.GroupsByComponent["curl"], + ) + require.NoError(t, err) + + channel, err := projectconfig.ResolvePackagePublishChannel("curl", &resolved, &proj) + require.NoError(t, err) + assert.Empty(t, channel) + }) + + t.Run("debuginfo with arch suffix uses debuginfo channel", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-base", + DebugInfoChannel: "rpms-base-debuginfo", + }, + } + + resolved, err := projectconfig.ResolveComponentConfig( + projectconfig.ComponentConfig{Name: "kernel"}, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, + proj.ComponentGroups, + proj.GroupsByComponent["kernel"], + ) + require.NoError(t, err) + + // kernel-debuginfo-common-x86_64 has "-debuginfo" as a middle segment, not a suffix. + channel, err := projectconfig.ResolvePackagePublishChannel( + "kernel-debuginfo-common-x86_64", &resolved, &proj, + ) + require.NoError(t, err) + assert.Equal(t, "rpms-base-debuginfo", channel) + }) +} + +func TestResolvePackagePublishChannel_PackageGroupOverrides(t *testing.T) { + t.Parallel() + + t.Run("package group overrides component binary channel", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-base", + SRPMChannel: "rpms-base-srpm", + DebugInfoChannel: "rpms-base-debuginfo", + }, + } + proj.ComponentGroups = map[string]projectconfig.ComponentGroupConfig{ + "base-published": { + Components: []string{"cmake"}, + DefaultComponentConfig: projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-base", + DebugInfoChannel: "rpms-base-debuginfo", + }, + }, + }, + } + proj.GroupsByComponent = map[string][]string{ + "cmake": {"base-published"}, + } + proj.PackageGroups = map[string]projectconfig.PackageGroupConfig{ + "sdk-published": { + Packages: []string{"cmake-gui"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{ + RPMChannel: "rpms-sdk", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + }, + }, + } + + resolved, err := projectconfig.ResolveComponentConfig( + projectconfig.ComponentConfig{Name: "cmake"}, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, + proj.ComponentGroups, + proj.GroupsByComponent["cmake"], + ) + require.NoError(t, err) + + // cmake-gui is in sdk-published package group — should get sdk channels. + channel, err := projectconfig.ResolvePackagePublishChannel("cmake-gui", &resolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-sdk", channel) + + // cmake (not in package group) should get base channels from component group. + channel, err = projectconfig.ResolvePackagePublishChannel("cmake", &resolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-base", channel) + }) + + t.Run("package group overrides component debuginfo channel", func(t *testing.T) { + t.Parallel() + + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + DebugInfoChannel: "rpms-base-debuginfo", + }, + } + proj.PackageGroups = map[string]projectconfig.PackageGroupConfig{ + "sdk-debug": { + Packages: []string{"cmake-gui-debuginfo"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{ + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + }, + }, + } + + resolved, err := projectconfig.ResolveComponentConfig( + projectconfig.ComponentConfig{Name: "cmake"}, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, + proj.ComponentGroups, + proj.GroupsByComponent["cmake"], + ) + require.NoError(t, err) + + channel, err := projectconfig.ResolvePackagePublishChannel("cmake-gui-debuginfo", &resolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-sdk-debuginfo", channel) + }) +} + +func TestResolvePackagePublishChannel_FullScenario(t *testing.T) { + t.Parallel() + + // Reproduce the user's TOML config: + // Project default: everything goes to SDK channels + // Component group "base-published": upgrades some components to base channels + // Package group "sdk-published": overrides specific sub-packages back to SDK + proj := projectconfig.NewProjectConfig() + proj.DefaultComponentConfig = projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-sdk", + SRPMChannel: "rpms-sdk-srpm", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + } + proj.ComponentGroups = map[string]projectconfig.ComponentGroupConfig{ + "base-published": { + Components: []string{"systemd", "bash", "cmake"}, + DefaultComponentConfig: projectconfig.ComponentConfig{ + Publish: projectconfig.ComponentPublishConfig{ + RPMChannel: "rpms-base", + SRPMChannel: "rpms-base-srpm", + DebugInfoChannel: "rpms-base-debuginfo", + }, + }, + }, + } + proj.GroupsByComponent = map[string][]string{ + "systemd": {"base-published"}, + "bash": {"base-published"}, + "cmake": {"base-published"}, + } + proj.PackageGroups = map[string]projectconfig.PackageGroupConfig{ + "sdk-published": { + Packages: []string{"cmake-gui"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{ + RPMChannel: "rpms-sdk", + DebugInfoChannel: "rpms-sdk-debuginfo", + }, + }, + }, + } + + // systemd is in base-published → SRPM channel comes from resolved Publish. + systemdResolved, err := projectconfig.ResolveComponentConfig( + projectconfig.ComponentConfig{Name: "systemd"}, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, + proj.ComponentGroups, + proj.GroupsByComponent["systemd"], + ) + require.NoError(t, err) + assert.Equal(t, "rpms-base-srpm", systemdResolved.Publish.SRPMChannel) + + // systemd binary → rpms-base (from component group). + channel, err := projectconfig.ResolvePackagePublishChannel("systemd", &systemdResolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-base", channel) + + // systemd-debuginfo → rpms-base-debuginfo (from component group). + channel, err = projectconfig.ResolvePackagePublishChannel("systemd-debuginfo", &systemdResolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-base-debuginfo", channel) + + // cmake-gui (in sdk-published package group) → rpms-sdk (overrides base). + cmakeResolved, err := projectconfig.ResolveComponentConfig( + projectconfig.ComponentConfig{Name: "cmake"}, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, + proj.ComponentGroups, + proj.GroupsByComponent["cmake"], + ) + require.NoError(t, err) + + channel, err = projectconfig.ResolvePackagePublishChannel("cmake-gui", &cmakeResolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-sdk", channel) + + // cmake SRPM → rpms-base-srpm (from component group). + assert.Equal(t, "rpms-base-srpm", cmakeResolved.Publish.SRPMChannel) + + // cmake (main binary, not in package group) → rpms-base. + channel, err = projectconfig.ResolvePackagePublishChannel("cmake", &cmakeResolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-base", channel) + + // A component NOT in any group (e.g., "python3") → SDK defaults. + pythonResolved, err := projectconfig.ResolveComponentConfig( + projectconfig.ComponentConfig{Name: "python3"}, + proj.DefaultComponentConfig, + projectconfig.ComponentConfig{}, + proj.ComponentGroups, + proj.GroupsByComponent["python3"], + ) + require.NoError(t, err) + + channel, err = projectconfig.ResolvePackagePublishChannel("python3", &pythonResolved, &proj) + require.NoError(t, err) + assert.Equal(t, "rpms-sdk", channel) + + assert.Equal(t, "rpms-sdk-srpm", pythonResolved.Publish.SRPMChannel) +} diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index e4bb4806..4a614c10 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -46,6 +46,10 @@ 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"` + // DefaultComponentConfig is the project-wide default component configuration applied before any + // component-group or component-level config is considered. + DefaultComponentConfig *ComponentConfig `toml:"default-component-config,omitempty" jsonschema:"title=Default component config,description=Project-wide default applied to all components before group and component overrides"` + // 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"` diff --git a/internal/projectconfig/fingerprint_test.go b/internal/projectconfig/fingerprint_test.go index 32b32bdd..b5168ea4 100644 --- a/internal/projectconfig/fingerprint_test.go +++ b/internal/projectconfig/fingerprint_test.go @@ -57,6 +57,9 @@ func TestAllFingerprintedFieldsHaveDecision(t *testing.T) { // PackageConfig.Publish — post-build routing (where to publish), not a build input. "PackageConfig.Publish": true, + // ComponentConfig.Publish — post-build routing (where to publish), not a build input. + "ComponentConfig.Publish": true, + // ComponentOverlay.Description — human-readable documentation for the overlay. "ComponentOverlay.Description": true, diff --git a/internal/projectconfig/loader.go b/internal/projectconfig/loader.go index 4655b121..693d1aca 100644 --- a/internal/projectconfig/loader.go +++ b/internal/projectconfig/loader.go @@ -120,6 +120,10 @@ func mergeConfigFile(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return err } + if err := mergeDefaultComponentConfig(resolvedCfg, loadedCfg); err != nil { + return err + } + if err := mergeDefaultPackageConfig(resolvedCfg, loadedCfg); err != nil { return err } @@ -241,6 +245,19 @@ func mergeDefaultPackageConfig(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile return nil } +// mergeDefaultComponentConfig merges the project-level default component config from a loaded +// config file into the resolved config. +func mergeDefaultComponentConfig(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + if loadedCfg.DefaultComponentConfig != nil { + absConfig := loadedCfg.DefaultComponentConfig.WithAbsolutePaths(loadedCfg.dir) + if err := resolvedCfg.DefaultComponentConfig.MergeUpdatesFrom(absConfig); err != nil { + return fmt.Errorf("failed to merge project default component 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 { diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index b0d58259..ef25495a 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -565,7 +565,7 @@ includes = ["*non-existent*.toml"] func TestLoadAndResolveProjectConfig_DefaultPackageConfig(t *testing.T) { const configContents = ` [default-package-config.publish] -channel = "base" +rpm-channel = "base" ` ctx := testctx.NewCtx() @@ -574,7 +574,7 @@ channel = "base" config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) require.NoError(t, err) - assert.Equal(t, "base", config.DefaultPackageConfig.Publish.Channel) + assert.Equal(t, "base", config.DefaultPackageConfig.Publish.RPMChannel) } func TestLoadAndResolveProjectConfig_DefaultPackageConfig_MergedAcrossFiles(t *testing.T) { @@ -587,11 +587,11 @@ func TestLoadAndResolveProjectConfig_DefaultPackageConfig_MergedAcrossFiles(t *t includes = ["extra.toml"] [default-package-config.publish] -channel = "base" +rpm-channel = "base" `}, {"/project/extra.toml", ` [default-package-config.publish] -channel = "stable" +rpm-channel = "stable" `}, } @@ -605,7 +605,7 @@ channel = "stable" require.NoError(t, err) // The later-loaded file wins. - assert.Equal(t, "stable", config.DefaultPackageConfig.Publish.Channel) + assert.Equal(t, "stable", config.DefaultPackageConfig.Publish.RPMChannel) } func TestLoadAndResolveProjectConfig_DefaultPackageConfig_MergedAcrossTopLevelFiles(t *testing.T) { @@ -613,11 +613,11 @@ func TestLoadAndResolveProjectConfig_DefaultPackageConfig_MergedAcrossTopLevelFi const ( configContents1 = ` [default-package-config.publish] -channel = "first" +rpm-channel = "first" ` configContents2 = ` [default-package-config.publish] -channel = "second" +rpm-channel = "second" ` ) @@ -631,7 +631,7 @@ channel = "second" config, err := loadAndResolveProjectConfig(ctx.FS(), false, configPath1, configPath2) require.NoError(t, err) - assert.Equal(t, "second", config.DefaultPackageConfig.Publish.Channel) + assert.Equal(t, "second", config.DefaultPackageConfig.Publish.RPMChannel) } func TestLoadAndResolveProjectConfig_PackageGroups(t *testing.T) { @@ -641,14 +641,14 @@ description = "Development subpackages" packages = ["curl-devel", "wget2-devel"] [package-groups.devel-packages.default-package-config.publish] -channel = "devel" +rpm-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" +rpm-channel = "none" ` ctx := testctx.NewCtx() @@ -663,14 +663,14 @@ channel = "none" 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) + assert.Equal(t, "devel", g.DefaultPackageConfig.Publish.RPMChannel) } 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) + assert.Equal(t, "none", g.DefaultPackageConfig.Publish.RPMChannel) } } @@ -772,15 +772,12 @@ packages = ["wget2-devel", "bash-devel"] assert.Contains(t, err.Error(), "may only belong to one group") } -func TestLoadAndResolveProjectConfig_ComponentDefaultPackageConfig(t *testing.T) { +func TestLoadAndResolveProjectConfig_ComponentPackageOverrides(t *testing.T) { const configContents = ` [components.curl] -[components.curl.default-package-config.publish] -channel = "base" - [components.curl.packages.curl-devel.publish] -channel = "devel" +rpm-channel = "devel" ` ctx := testctx.NewCtx() @@ -791,10 +788,9 @@ channel = "devel" 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) + assert.Equal(t, "devel", comp.Packages["curl-devel"].Publish.RPMChannel) } } } diff --git a/internal/projectconfig/package.go b/internal/projectconfig/package.go index 3575e1dd..bce85a5e 100644 --- a/internal/projectconfig/package.go +++ b/internal/projectconfig/package.go @@ -11,13 +11,20 @@ import ( ) // PackagePublishConfig holds publish settings for a single binary package. -// The zero value means the channel is inherited from a higher-priority config layer. +// The zero value means all channels are inherited from a higher-priority config layer. type PackagePublishConfig struct { - // Channel identifies the publish channel for this package. - // 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" validate:"omitempty,ne=.,ne=..,excludesall=/\\" jsonschema:"title=Channel,description=Publish channel for this package; use 'none' to signal to downstream tooling that this package should not be published"` + // RPMChannel identifies the publish channel specifically for binary (non-debuginfo) + // packages. When set at the package level, it overrides the component-level + // [ComponentPublishConfig.RPMChannel]. When empty, the value is inherited. The + // reserved value `"none"` keeps RPMs in the base directory and means they should + // not be published. + RPMChannel string `toml:"rpm-channel,omitempty" json:"rpmChannel,omitempty" validate:"omitempty,ne=.,ne=..,excludesall=/\\" jsonschema:"title=RPM channel,description=Publish channel for binary packages; overrides the component-level rpm-channel; use 'none' to keep RPMs in the base directory and skip publishing"` + // DebugInfoChannel identifies the publish channel specifically for debuginfo packages. + // When set at the package level, it overrides the component-level + // [ComponentPublishConfig.DebugInfoChannel]. When empty, the value is inherited. + // The reserved value `"none"` keeps RPMs in the base directory and means they + // should not be published. + DebugInfoChannel string `toml:"debuginfo-channel,omitempty" json:"debuginfoChannel,omitempty" validate:"omitempty,ne=.,ne=..,excludesall=/\\" jsonschema:"title=Debuginfo channel,description=Publish channel for debuginfo packages; overrides the component-level debuginfo-channel; use 'none' to keep RPMs in the base directory and skip publishing"` } // PackageConfig holds all configuration applied to a single binary package. @@ -79,8 +86,7 @@ func (g *PackageGroupConfig) Validate() error { // Resolution order (each layer overrides the previous — later wins): // 1. The project's DefaultPackageConfig (lowest priority) // 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) +// 3. 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 @@ -100,14 +106,7 @@ func ResolvePackageConfig(pkgName string, comp *ComponentConfig, proj *ProjectCo } } - // 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). + // 3. 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( diff --git a/internal/projectconfig/package_test.go b/internal/projectconfig/package_test.go index 303d4bff..c6d129a2 100644 --- a/internal/projectconfig/package_test.go +++ b/internal/projectconfig/package_test.go @@ -29,10 +29,17 @@ func TestPackagePublishConfig_Validate(t *testing.T) { } for _, testCase := range validCases { - t.Run(testCase.name, func(t *testing.T) { + t.Run("RPMChannel/"+testCase.name, func(t *testing.T) { t.Parallel() - cfg := projectconfig.PackagePublishConfig{Channel: testCase.channel} + cfg := projectconfig.PackagePublishConfig{RPMChannel: testCase.channel} + assert.NoError(t, validator.New().Struct(&cfg)) + }) + + t.Run("DebugInfoChannel/"+testCase.name, func(t *testing.T) { + t.Parallel() + + cfg := projectconfig.PackagePublishConfig{DebugInfoChannel: testCase.channel} assert.NoError(t, validator.New().Struct(&cfg)) }) } @@ -51,10 +58,19 @@ func TestPackagePublishConfig_Validate(t *testing.T) { } for _, testCase := range invalidCases { - t.Run(testCase.name, func(t *testing.T) { + t.Run("RPMChannel/"+testCase.name, func(t *testing.T) { + t.Parallel() + + cfg := projectconfig.PackagePublishConfig{RPMChannel: testCase.channel} + err := validator.New().Struct(&cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), testCase.errContains) + }) + + t.Run("DebugInfoChannel/"+testCase.name, func(t *testing.T) { t.Parallel() - cfg := projectconfig.PackagePublishConfig{Channel: testCase.channel} + cfg := projectconfig.PackagePublishConfig{DebugInfoChannel: testCase.channel} err := validator.New().Struct(&cfg) require.Error(t, err) assert.Contains(t, err.Error(), testCase.errContains) @@ -73,14 +89,33 @@ func TestPackageGroupConfig_Validate_InvalidChannel(t *testing.T) { "test-group": { Packages: []string{"curl"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "../escape"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "../escape"}, }, }, }, } err := file.Validate() require.Error(t, err) - assert.Contains(t, err.Error(), "Channel") + assert.Contains(t, err.Error(), "RPMChannel") + assert.Contains(t, err.Error(), "excludesall") + }) + + t.Run("traversal debuginfo channel in group default config is rejected", func(t *testing.T) { + t.Parallel() + + file := projectconfig.ConfigFile{ + PackageGroups: map[string]projectconfig.PackageGroupConfig{ + "test-group": { + Packages: []string{"curl-debuginfo"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{DebugInfoChannel: "../escape"}, + }, + }, + }, + } + err := file.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "DebugInfoChannel") assert.Contains(t, err.Error(), "excludesall") }) } @@ -124,30 +159,30 @@ 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"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "build"}, } require.NoError(t, base.MergeUpdatesFrom(&other)) - assert.Equal(t, "build", base.Publish.Channel) + assert.Equal(t, "build", base.Publish.RPMChannel) }) t.Run("non-zero other overrides non-zero base", func(t *testing.T) { base := projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "build"}, } other := projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "base"}, } require.NoError(t, base.MergeUpdatesFrom(&other)) - assert.Equal(t, "base", base.Publish.Channel) + assert.Equal(t, "base", base.Publish.RPMChannel) }) t.Run("zero other does not override non-zero base", func(t *testing.T) { base := projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "build"}, } other := projectconfig.PackageConfig{} require.NoError(t, base.MergeUpdatesFrom(&other)) - assert.Equal(t, "build", base.Publish.Channel) + assert.Equal(t, "build", base.Publish.RPMChannel) }) } @@ -163,13 +198,13 @@ func TestResolvePackageConfig(t *testing.T) { "debug-packages": { Packages: []string{"gcc-debuginfo", "curl-debuginfo", "curl-debugsource"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "none"}, }, }, "build-time-deps": { Packages: []string{"curl-devel", "curl-static", "gcc-devel"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "build"}, }, }, }) @@ -177,7 +212,6 @@ func TestResolvePackageConfig(t *testing.T) { testCases := []struct { name string pkgName string - compDefault projectconfig.PackageConfig compPackages map[string]projectconfig.PackageConfig expectedChannel string }{ @@ -197,37 +231,18 @@ func TestResolvePackageConfig(t *testing.T) { 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 contains the package", - pkgName: "curl", - compDefault: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "none"}, - }, - expectedChannel: "none", - }, - { - name: "exact package override takes priority over group and component default", + name: "exact package override takes priority over group", pkgName: "curl-devel", - compDefault: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "none"}, - }, compPackages: map[string]projectconfig.PackageConfig{ - "curl-devel": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + "curl-devel": {Publish: projectconfig.PackagePublishConfig{RPMChannel: "base"}}, }, expectedChannel: "base", }, { - name: "exact package override takes priority over group with no component default", - pkgName: "curl-devel", + name: "exact package override with no group match", + pkgName: "curl", compPackages: map[string]projectconfig.PackageConfig{ - "curl-devel": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + "curl": {Publish: projectconfig.PackagePublishConfig{RPMChannel: "base"}}, }, expectedChannel: "base", }, @@ -235,7 +250,7 @@ func TestResolvePackageConfig(t *testing.T) { name: "unrelated exact package entry does not affect result", pkgName: "curl-devel", compPackages: map[string]projectconfig.PackageConfig{ - "curl": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + "curl": {Publish: projectconfig.PackagePublishConfig{RPMChannel: "base"}}, }, expectedChannel: "build", // from group }, @@ -244,14 +259,13 @@ func TestResolvePackageConfig(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { comp := &projectconfig.ComponentConfig{ - Name: "test-component", - DefaultPackageConfig: testCase.compDefault, - Packages: testCase.compPackages, + Name: "test-component", + Packages: testCase.compPackages, } got, err := projectconfig.ResolvePackageConfig(testCase.pkgName, comp, baseProj) require.NoError(t, err) - assert.Equal(t, testCase.expectedChannel, got.Publish.Channel) + assert.Equal(t, testCase.expectedChannel, got.Publish.RPMChannel) }) } @@ -260,7 +274,7 @@ func TestResolvePackageConfig(t *testing.T) { "my-group": { Packages: []string{"curl-devel"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "build"}, }, }, }) @@ -268,7 +282,7 @@ func TestResolvePackageConfig(t *testing.T) { comp := &projectconfig.ComponentConfig{Name: "curl"} got, err := projectconfig.ResolvePackageConfig("curl-devel", comp, proj) require.NoError(t, err) - assert.Equal(t, "build", got.Publish.Channel) + assert.Equal(t, "build", got.Publish.RPMChannel) }) t.Run("empty project config returns zero-value PackageConfig", func(t *testing.T) { @@ -277,31 +291,31 @@ func TestResolvePackageConfig(t *testing.T) { got, err := projectconfig.ResolvePackageConfig("curl", comp, &proj) require.NoError(t, err) - assert.Empty(t, got.Publish.Channel) + assert.Empty(t, got.Publish.RPMChannel) }) 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"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "base"}, } comp := &projectconfig.ComponentConfig{Name: "curl"} got, err := projectconfig.ResolvePackageConfig("curl", comp, &proj) require.NoError(t, err) - assert.Equal(t, "base", got.Publish.Channel) + assert.Equal(t, "base", got.Publish.RPMChannel) }) t.Run("package group overrides project default", func(t *testing.T) { proj := projectconfig.NewProjectConfig() proj.DefaultPackageConfig = projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "base"}, } proj.PackageGroups = map[string]projectconfig.PackageGroupConfig{ "debug-packages": { Packages: []string{"gcc-debuginfo"}, DefaultPackageConfig: projectconfig.PackageConfig{ - Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "none"}, }, }, } @@ -309,42 +323,24 @@ func TestResolvePackageConfig(t *testing.T) { 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) + assert.Equal(t, "none", got.Publish.RPMChannel) }) 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"}, + Publish: projectconfig.PackagePublishConfig{RPMChannel: "base"}, } comp := &projectconfig.ComponentConfig{ Name: "curl", Packages: map[string]projectconfig.PackageConfig{ - "curl-devel": {Publish: projectconfig.PackagePublishConfig{Channel: "none"}}, + "curl-devel": {Publish: projectconfig.PackagePublishConfig{RPMChannel: "none"}}, }, } got, err := projectconfig.ResolvePackageConfig("curl-devel", comp, &proj) require.NoError(t, err) - assert.Equal(t, "none", got.Publish.Channel) + assert.Equal(t, "none", got.Publish.RPMChannel) }) } diff --git a/internal/projectconfig/project.go b/internal/projectconfig/project.go index 502bcd24..68b5759f 100644 --- a/internal/projectconfig/project.go +++ b/internal/projectconfig/project.go @@ -26,6 +26,11 @@ 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"` + // DefaultComponentConfig is the project-wide default applied to every component before any + // component-group or component-level config is considered. It is the lowest-priority layer in + // the component publish config resolution order. + DefaultComponentConfig ComponentConfig `toml:"default-component-config,omitempty" json:"defaultComponentConfig,omitempty" jsonschema:"title=Default component config,description=Project-wide default applied to all components before group and component overrides"` + // 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. diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index f9dd59a2..3e2635ed 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -124,11 +124,6 @@ "title": "Build configuration", "description": "Configuration for building the component" }, - "render": { - "$ref": "#/$defs/ComponentRenderConfig", - "title": "Render configuration", - "description": "Configuration for rendering the component" - }, "source-files": { "items": { "$ref": "#/$defs/SourceFileReference" @@ -137,11 +132,6 @@ "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" @@ -149,6 +139,11 @@ "type": "object", "title": "Package overrides", "description": "Per-package configuration overrides keyed by exact binary package name" + }, + "publish": { + "$ref": "#/$defs/ComponentPublishConfig", + "title": "Publish settings", + "description": "Component-level publish channel settings" } }, "additionalProperties": false, @@ -285,12 +280,22 @@ "type" ] }, - "ComponentRenderConfig": { + "ComponentPublishConfig": { "properties": { - "skip-file-filter": { - "type": "boolean", - "title": "Skip file filter", - "description": "Disable post-render file filtering for specs with unexpandable macros in Source/Patch tags" + "rpm-channel": { + "type": "string", + "title": "RPM channel", + "description": "Publish channel for binary packages produced by this component" + }, + "srpm-channel": { + "type": "string", + "title": "SRPM channel", + "description": "Publish channel for the SRPM produced by this component" + }, + "debuginfo-channel": { + "type": "string", + "title": "Debuginfo channel", + "description": "Publish channel for debuginfo packages produced by this component" } }, "additionalProperties": false, @@ -351,6 +356,11 @@ "title": "Tools configuration", "description": "Configuration for tools used by azldev" }, + "default-component-config": { + "$ref": "#/$defs/ComponentConfig", + "title": "Default component config", + "description": "Project-wide default applied to all components before group and component overrides" + }, "default-package-config": { "$ref": "#/$defs/PackageConfig", "title": "Default package config", @@ -673,10 +683,15 @@ }, "PackagePublishConfig": { "properties": { - "channel": { + "rpm-channel": { + "type": "string", + "title": "RPM channel", + "description": "Publish channel for binary packages; overrides the component-level rpm-channel; use 'none' to keep RPMs in the base directory and skip publishing" + }, + "debuginfo-channel": { "type": "string", - "title": "Channel", - "description": "Publish channel for this package; use 'none' to signal to downstream tooling that this package should not be published" + "title": "Debuginfo channel", + "description": "Publish channel for debuginfo packages; overrides the component-level debuginfo-channel; use 'none' to keep RPMs in the base directory and skip publishing" } }, "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 f9dd59a2..3e2635ed 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -124,11 +124,6 @@ "title": "Build configuration", "description": "Configuration for building the component" }, - "render": { - "$ref": "#/$defs/ComponentRenderConfig", - "title": "Render configuration", - "description": "Configuration for rendering the component" - }, "source-files": { "items": { "$ref": "#/$defs/SourceFileReference" @@ -137,11 +132,6 @@ "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" @@ -149,6 +139,11 @@ "type": "object", "title": "Package overrides", "description": "Per-package configuration overrides keyed by exact binary package name" + }, + "publish": { + "$ref": "#/$defs/ComponentPublishConfig", + "title": "Publish settings", + "description": "Component-level publish channel settings" } }, "additionalProperties": false, @@ -285,12 +280,22 @@ "type" ] }, - "ComponentRenderConfig": { + "ComponentPublishConfig": { "properties": { - "skip-file-filter": { - "type": "boolean", - "title": "Skip file filter", - "description": "Disable post-render file filtering for specs with unexpandable macros in Source/Patch tags" + "rpm-channel": { + "type": "string", + "title": "RPM channel", + "description": "Publish channel for binary packages produced by this component" + }, + "srpm-channel": { + "type": "string", + "title": "SRPM channel", + "description": "Publish channel for the SRPM produced by this component" + }, + "debuginfo-channel": { + "type": "string", + "title": "Debuginfo channel", + "description": "Publish channel for debuginfo packages produced by this component" } }, "additionalProperties": false, @@ -351,6 +356,11 @@ "title": "Tools configuration", "description": "Configuration for tools used by azldev" }, + "default-component-config": { + "$ref": "#/$defs/ComponentConfig", + "title": "Default component config", + "description": "Project-wide default applied to all components before group and component overrides" + }, "default-package-config": { "$ref": "#/$defs/PackageConfig", "title": "Default package config", @@ -673,10 +683,15 @@ }, "PackagePublishConfig": { "properties": { - "channel": { + "rpm-channel": { + "type": "string", + "title": "RPM channel", + "description": "Publish channel for binary packages; overrides the component-level rpm-channel; use 'none' to keep RPMs in the base directory and skip publishing" + }, + "debuginfo-channel": { "type": "string", - "title": "Channel", - "description": "Publish channel for this package; use 'none' to signal to downstream tooling that this package should not be published" + "title": "Debuginfo channel", + "description": "Publish channel for debuginfo packages; overrides the component-level debuginfo-channel; use 'none' to keep RPMs in the base directory and skip publishing" } }, "additionalProperties": false, diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index f9dd59a2..3e2635ed 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -124,11 +124,6 @@ "title": "Build configuration", "description": "Configuration for building the component" }, - "render": { - "$ref": "#/$defs/ComponentRenderConfig", - "title": "Render configuration", - "description": "Configuration for rendering the component" - }, "source-files": { "items": { "$ref": "#/$defs/SourceFileReference" @@ -137,11 +132,6 @@ "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" @@ -149,6 +139,11 @@ "type": "object", "title": "Package overrides", "description": "Per-package configuration overrides keyed by exact binary package name" + }, + "publish": { + "$ref": "#/$defs/ComponentPublishConfig", + "title": "Publish settings", + "description": "Component-level publish channel settings" } }, "additionalProperties": false, @@ -285,12 +280,22 @@ "type" ] }, - "ComponentRenderConfig": { + "ComponentPublishConfig": { "properties": { - "skip-file-filter": { - "type": "boolean", - "title": "Skip file filter", - "description": "Disable post-render file filtering for specs with unexpandable macros in Source/Patch tags" + "rpm-channel": { + "type": "string", + "title": "RPM channel", + "description": "Publish channel for binary packages produced by this component" + }, + "srpm-channel": { + "type": "string", + "title": "SRPM channel", + "description": "Publish channel for the SRPM produced by this component" + }, + "debuginfo-channel": { + "type": "string", + "title": "Debuginfo channel", + "description": "Publish channel for debuginfo packages produced by this component" } }, "additionalProperties": false, @@ -351,6 +356,11 @@ "title": "Tools configuration", "description": "Configuration for tools used by azldev" }, + "default-component-config": { + "$ref": "#/$defs/ComponentConfig", + "title": "Default component config", + "description": "Project-wide default applied to all components before group and component overrides" + }, "default-package-config": { "$ref": "#/$defs/PackageConfig", "title": "Default package config", @@ -673,10 +683,15 @@ }, "PackagePublishConfig": { "properties": { - "channel": { + "rpm-channel": { + "type": "string", + "title": "RPM channel", + "description": "Publish channel for binary packages; overrides the component-level rpm-channel; use 'none' to keep RPMs in the base directory and skip publishing" + }, + "debuginfo-channel": { "type": "string", - "title": "Channel", - "description": "Publish channel for this package; use 'none' to signal to downstream tooling that this package should not be published" + "title": "Debuginfo channel", + "description": "Publish channel for debuginfo packages; overrides the component-level debuginfo-channel; use 'none' to keep RPMs in the base directory and skip publishing" } }, "additionalProperties": false, From ddab0fded4fb9965a5f50942db311d65534eec2b Mon Sep 17 00:00:00 2001 From: Nan Liu Date: Fri, 24 Apr 2026 21:30:06 +0000 Subject: [PATCH 2/2] refactor(projectconfig): replace channel migration with resolver fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of migrating the deprecated 'publish.channel' TOML field to 'publish.rpm-channel' at load time, preserve the raw value and resolve it lazily via PackagePublishConfig.EffectiveRPMChannel(). This removes the migrateDeprecatedFields infrastructure entirely and keeps the backwards-compat logic close to where it is used. - Add EffectiveRPMChannel() value method: returns RPMChannel if set, falls back to DeprecatedChannel - Update packagePublishChannel() and synthesizeDebugPackages to call EffectiveRPMChannel() instead of reading RPMChannel directly - Remove migrateDeprecatedFields, migrateDefaultPackagePublishConfig, migratePackagePublishConfig functions and their log/slog import - Remove migrateDeprecatedFields() call from loader.go - Update four loader tests to assert that DeprecatedChannel is preserved as-is and EffectiveRPMChannel() returns the expected value - Fix DeprecatedChannel jsonschema description (remove stale 'Migrated automatically' wording) - Update docs: resolution order is distro → project → group → component - Regenerate schema snapshots --- docs/user/explanation/config-system.md | 4 +- docs/user/how-to/inspect-package-config.md | 15 +-- .../user/reference/config/component-groups.md | 4 +- docs/user/reference/config/project.md | 2 +- internal/app/azldev/cmds/pkg/list.go | 16 +-- internal/app/azldev/cmds/pkg/list_test.go | 15 ++- internal/projectconfig/component.go | 54 ++++++---- internal/projectconfig/loader_test.go | 98 +++++++++++++++++++ internal/projectconfig/package.go | 16 +++ ...ainer_config_generate-schema_stdout_1.snap | 20 ++++ ...shots_config_generate-schema_stdout_1.snap | 20 ++++ schemas/azldev.schema.json | 20 ++++ 12 files changed, 239 insertions(+), 45 deletions(-) diff --git a/docs/user/explanation/config-system.md b/docs/user/explanation/config-system.md index 052a7def..24d6e6e2 100644 --- a/docs/user/explanation/config-system.md +++ b/docs/user/explanation/config-system.md @@ -95,8 +95,8 @@ These are simple structs (not maps). Later files' non-empty fields override earl Component configuration supports a layered inheritance model. When azldev resolves the effective configuration for a component, it assembles it from multiple sources in this order (later layers override earlier ones): -1. **Project-level defaults** — the `default-component-config` defined at the project root -2. **Distro version defaults** — the `default-component-config` defined in the distro version (e.g., `[distros.azurelinux.versions.'4.0'.default-component-config]`) +1. **Distro version defaults** — the `default-component-config` defined in the distro version (e.g., `[distros.azurelinux.versions.'4.0'.default-component-config]`) +2. **Project-level defaults** — the `default-component-config` defined at the project root 3. **Component group defaults** — the `default-component-config` from any component groups the component belongs to (applied in alphabetical order by group name) 4. **Component-specific config** — the component's own explicit configuration diff --git a/docs/user/how-to/inspect-package-config.md b/docs/user/how-to/inspect-package-config.md index 31d73616..3468a6e4 100644 --- a/docs/user/how-to/inspect-package-config.md +++ b/docs/user/how-to/inspect-package-config.md @@ -5,12 +5,15 @@ binary-package configuration for your project without running a build. ## Background -Binary package configuration in azldev is assembled from up to three layers -(see [Package Groups](../reference/config/package-groups.md) for details): - -1. Project `default-package-config` -2. Package group `default-package-config` -3. Component `packages.` override (highest priority) +Binary package configuration in azldev is assembled from up to four layers +(see [Package Groups](../reference/config/package-groups.md) and +[Config System](../explanation/config-system.md) for details): + +1. Project `default-package-config` (lowest priority) +2. Component `publish` channel settings (`publish.rpm-channel`, `publish.debuginfo-channel`) — + themselves resolved from distro defaults → project defaults → component-group defaults → component config +3. Package group `default-package-config` +4. Component `packages.` override (highest priority) `azldev package list` resolves all of these layers and prints the effective configuration for each package you ask about. diff --git a/docs/user/reference/config/component-groups.md b/docs/user/reference/config/component-groups.md index 90283c97..76974b1e 100644 --- a/docs/user/reference/config/component-groups.md +++ b/docs/user/reference/config/component-groups.md @@ -56,8 +56,8 @@ defines = { azure = "1" } When a component belongs to one or more groups, the effective configuration is assembled in this order (later layers override earlier ones): -1. Project-level `default-component-config` -2. Distro version `default-component-config` +1. Distro version `default-component-config` +2. Project-level `default-component-config` 3. Component group `default-component-config` (in alphabetical order by group name if multiple groups apply) 4. Component's own explicit configuration diff --git a/docs/user/reference/config/project.md b/docs/user/reference/config/project.md index 32347af6..42f35f39 100644 --- a/docs/user/reference/config/project.md +++ b/docs/user/reference/config/project.md @@ -56,7 +56,7 @@ Individual components or [component groups](component-groups.md) can override th ## Default Package Config -The `[default-package-config]` section is a **top-level** TOML section (not nested under `[project]`). It 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 `[default-package-config]` section is a **top-level** TOML section (not nested under `[project]`). It 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) `default-package-config` settings and explicit per-package overrides. The most common use is to set a project-wide default publish channel: diff --git a/internal/app/azldev/cmds/pkg/list.go b/internal/app/azldev/cmds/pkg/list.go index 38a55141..7065d1bd 100644 --- a/internal/app/azldev/cmds/pkg/list.go +++ b/internal/app/azldev/cmds/pkg/list.go @@ -332,7 +332,7 @@ func synthesizeDebugPackages( // standard package-resolution chain for '-debugsource' so the entry // reflects any explicit package override, package-group defaults, component // settings, or project defaults instead of an implicit empty channel. - for compName := range proj.Components { + for compName, comp := range proj.Components { if isDebugPackageName(compName) { continue } @@ -344,14 +344,18 @@ func synthesizeDebugPackages( existing[name] = struct{}{} - // compOf pins the synthesized package to its owning component; groupOf is nil - // because -debugsource entries are never members of a package group. - result, err := resolvePackageListResult(name, map[string]string{name: compName}, nil, proj) + compCopy := comp + + pkgConfig, err := projectconfig.ResolvePackageConfig(name, &compCopy, proj) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to resolve config for synthesized package %#q:\n%w", name, err) } - results = append(results, result) + results = append(results, PackageListResult{ + PackageName: name, + Component: compName, + Channel: debugChannelName(pkgConfig.Publish.EffectiveRPMChannel()), + }) } return results, nil diff --git a/internal/app/azldev/cmds/pkg/list_test.go b/internal/app/azldev/cmds/pkg/list_test.go index af05b01b..02abb338 100644 --- a/internal/app/azldev/cmds/pkg/list_test.go +++ b/internal/app/azldev/cmds/pkg/list_test.go @@ -166,9 +166,8 @@ func TestListPackages_ByName_NotInExplicitConfig(t *testing.T) { } // Look up a package that has no component publish config or package group. - // DefaultPackageConfig does NOT affect publish channel resolution (it is intentionally - // excluded — otherwise it would override the already-resolved component publish channel). - // The channel is therefore empty. + // DefaultPackageConfig acts as the lowest-priority fallback, so the channel resolves + // to the project default when no higher-priority source provides one. results, err := pkgcmds.ListPackages(testEnv.Env, &pkgcmds.ListPackageOptions{PackageNames: []string{"unknown-pkg"}}) require.NoError(t, err) @@ -176,7 +175,7 @@ func TestListPackages_ByName_NotInExplicitConfig(t *testing.T) { assert.Equal(t, "unknown-pkg", results[0].PackageName) assert.Empty(t, results[0].Group) assert.Empty(t, results[0].Component) - assert.Empty(t, results[0].Channel) + assert.Equal(t, "default-channel", results[0].Channel) } func TestListPackages_ByName_MultipleNames(t *testing.T) { @@ -219,8 +218,8 @@ func TestListPackages_DuplicatePackageAcrossComponents_ReturnsError(t *testing.T func TestListPackages_SynthesizeDebugPackages(t *testing.T) { testEnv := testutils.NewTestEnv(t) - testEnv.Config.DefaultComponentConfig = projectconfig.ComponentConfig{ - Publish: projectconfig.ComponentPublishConfig{DebugInfoChannel: "default-channel-debuginfo"}, + testEnv.Config.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{RPMChannel: "default-channel"}, } testEnv.Config.PackageGroups = map[string]projectconfig.PackageGroupConfig{ "devel-packages": { @@ -316,8 +315,8 @@ func TestListPackages_SynthesizeDebugPackages_SkipsExisting(t *testing.T) { func TestListPackages_SynthesizeDebugPackages_ByName(t *testing.T) { testEnv := testutils.NewTestEnv(t) - testEnv.Config.DefaultComponentConfig = projectconfig.ComponentConfig{ - Publish: projectconfig.ComponentPublishConfig{DebugInfoChannel: "default-channel-debuginfo"}, + testEnv.Config.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{RPMChannel: "default-channel"}, } testEnv.Config.PackageGroups = map[string]projectconfig.PackageGroupConfig{ "devel-packages": { diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index eb5d228f..86429a00 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -212,7 +212,7 @@ func (c *ComponentConfig) MergeUpdatesFrom(other *ComponentConfig) error { } // ResolveComponentConfig applies the full config inheritance chain for a single component: -// project-level defaults → distro defaults → group defaults (sorted) → component explicit config. +// distro defaults → project-level defaults → group defaults (sorted) → component explicit config. // Returns a fully resolved copy; the inputs are not modified. // On error the returned config is undefined and must not be used. func ResolveComponentConfig( @@ -222,10 +222,10 @@ func ResolveComponentConfig( groups map[string]ComponentGroupConfig, groupMembership []string, ) (ComponentConfig, error) { - merged := deep.MustCopy(projectDefaults) + merged := deep.MustCopy(distroDefaults) - if err := merged.MergeUpdatesFrom(&distroDefaults); err != nil { - return ComponentConfig{}, fmt.Errorf("failed to apply distro defaults:\n%w", err) + if err := merged.MergeUpdatesFrom(&projectDefaults); err != nil { + return ComponentConfig{}, fmt.Errorf("failed to apply project defaults:\n%w", err) } // Apply group defaults in sorted order for determinism. @@ -322,32 +322,37 @@ func containsRPMSegment(pkgName, segment string) bool { // reflects project-level, distro, and component-group defaults). // // Resolution order (later wins): +// 0. The project-level default package config ([ProjectConfig.DefaultPackageConfig]), used +// only as a fallback when all higher-priority sources produce an empty channel. // 1. The resolved component-level publish channel ([ComponentPublishConfig.RPMChannel] or // [ComponentPublishConfig.DebugInfoChannel], depending on the package name). // 2. The matching package-group's publish channel, if the package belongs to one. // 3. The component's explicit per-package publish channel override, if set. -// -// Note: [ProjectConfig.DefaultPackageConfig] is intentionally excluded from publish channel -// resolution here. It would override the already-resolved component channel, which would make -// component-level publish defaults ineffective whenever a project default is set. func ResolvePackagePublishChannel(pkgName string, comp *ComponentConfig, proj *ProjectConfig) (string, error) { isDebugInfo := IsDebugInfoPackage(pkgName) - // Start with the already-resolved component-level channel for the package type. - var channel string + // Lowest priority: project-level default package config acts as a fallback for + // packages not covered by any higher-priority source. This matches the old + // single-field 'publish.channel' behaviour where the default applied to all packages. + channel := packagePublishChannel(&proj.DefaultPackageConfig.Publish, isDebugInfo) + + // Component-level channel overrides the project default. + var compChannel string if isDebugInfo { - channel = comp.Publish.DebugInfoChannel + compChannel = comp.Publish.DebugInfoChannel } else { - channel = comp.Publish.RPMChannel + compChannel = comp.Publish.RPMChannel + } + + if compChannel != "" { + channel = compChannel } // Apply package-group override if this package belongs to one. for _, group := range proj.PackageGroups { if slices.Contains(group.Packages, pkgName) { - if isDebugInfo && group.DefaultPackageConfig.Publish.DebugInfoChannel != "" { - channel = group.DefaultPackageConfig.Publish.DebugInfoChannel - } else if !isDebugInfo && group.DefaultPackageConfig.Publish.RPMChannel != "" { - channel = group.DefaultPackageConfig.Publish.RPMChannel + if groupChannel := packagePublishChannel(&group.DefaultPackageConfig.Publish, isDebugInfo); groupChannel != "" { + channel = groupChannel } break @@ -356,12 +361,21 @@ func ResolvePackagePublishChannel(pkgName string, comp *ComponentConfig, proj *P // Apply the explicit per-package override (highest priority). if pkgConfig, ok := comp.Packages[pkgName]; ok { - if isDebugInfo && pkgConfig.Publish.DebugInfoChannel != "" { - channel = pkgConfig.Publish.DebugInfoChannel - } else if !isDebugInfo && pkgConfig.Publish.RPMChannel != "" { - channel = pkgConfig.Publish.RPMChannel + if pkgChannel := packagePublishChannel(&pkgConfig.Publish, isDebugInfo); pkgChannel != "" { + channel = pkgChannel } } return channel, nil } + +// packagePublishChannel returns the rpm-channel or debuginfo-channel from publish config +// depending on whether the package is a debuginfo package. For non-debuginfo packages, +// it falls back to the deprecated 'channel' field for backwards compatibility. +func packagePublishChannel(publish *PackagePublishConfig, isDebugInfo bool) string { + if isDebugInfo { + return publish.DebugInfoChannel + } + + return publish.EffectiveRPMChannel() +} diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index ef25495a..c86e7b57 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -893,3 +893,101 @@ test-suites = [{ name = "nonexistent" }] require.ErrorIs(t, err, ErrUndefinedTestSuite) assert.Contains(t, err.Error(), "nonexistent") } + +// TestLoadAndResolveProjectConfig_DeprecatedChannelField_DefaultPackageConfig verifies that the +// deprecated 'publish.channel' field is preserved as-is after loading and used as a fallback +// by the channel resolver via [PackagePublishConfig.EffectiveRPMChannel]. +func TestLoadAndResolveProjectConfig_DeprecatedChannelField_DefaultPackageConfig(t *testing.T) { + const configContents = ` +[default-package-config.publish] +channel = "rpm-base" +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), true, testConfigPath) + require.NoError(t, err) + + // The deprecated field is preserved; no migration happens at load time. + assert.Empty(t, config.DefaultPackageConfig.Publish.RPMChannel) + assert.Equal(t, "rpm-base", config.DefaultPackageConfig.Publish.DeprecatedChannel) + // The resolver falls back to the deprecated field when rpm-channel is unset. + assert.Equal(t, "rpm-base", config.DefaultPackageConfig.Publish.EffectiveRPMChannel()) +} + +// TestLoadAndResolveProjectConfig_DeprecatedChannelField_PackageGroup verifies that the deprecated +// 'publish.channel' field is preserved and used as a fallback by the channel resolver in +// package-group default-package-config. +func TestLoadAndResolveProjectConfig_DeprecatedChannelField_PackageGroup(t *testing.T) { + const configContents = ` +[package-groups.my-group] +packages = ["curl-devel"] + +[package-groups.my-group.default-package-config.publish] +channel = "rpm-sdk" +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), true, testConfigPath) + require.NoError(t, err) + + require.Contains(t, config.PackageGroups, "my-group") + g := config.PackageGroups["my-group"] + // The deprecated field is preserved; the resolver falls back to it via EffectiveRPMChannel. + assert.Empty(t, g.DefaultPackageConfig.Publish.RPMChannel) + assert.Equal(t, "rpm-sdk", g.DefaultPackageConfig.Publish.DeprecatedChannel) + assert.Equal(t, "rpm-sdk", g.DefaultPackageConfig.Publish.EffectiveRPMChannel()) +} + +// TestLoadAndResolveProjectConfig_DeprecatedChannelField_ComponentPackage verifies that the deprecated +// 'publish.channel' field is preserved and used as a fallback by the channel resolver in +// per-package component overrides. +func TestLoadAndResolveProjectConfig_DeprecatedChannelField_ComponentPackage(t *testing.T) { + const configContents = ` +[components.curl] + +[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(), true, testConfigPath) + require.NoError(t, err) + + require.Contains(t, config.Components, "curl") + comp := config.Components["curl"] + require.Contains(t, comp.Packages, "curl-devel") + // The deprecated field is preserved; the resolver falls back to it via EffectiveRPMChannel. + assert.Empty(t, comp.Packages["curl-devel"].Publish.RPMChannel) + assert.Equal(t, "devel", comp.Packages["curl-devel"].Publish.DeprecatedChannel) + assert.Equal(t, "devel", comp.Packages["curl-devel"].Publish.EffectiveRPMChannel()) +} + +// TestLoadAndResolveProjectConfig_DeprecatedChannelField_NotOverriddenByNewField verifies that when +// both 'publish.channel' (deprecated) and 'publish.rpm-channel' are set, rpm-channel takes precedence +// via [PackagePublishConfig.EffectiveRPMChannel]. +func TestLoadAndResolveProjectConfig_DeprecatedChannelField_NotOverriddenByNewField(t *testing.T) { + const configContents = ` +[default-package-config.publish] +channel = "old-channel" +rpm-channel = "new-channel" +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), true, testConfigPath) + require.NoError(t, err) + + // Both fields are preserved as loaded. + assert.Equal(t, "new-channel", config.DefaultPackageConfig.Publish.RPMChannel) + assert.Equal(t, "old-channel", config.DefaultPackageConfig.Publish.DeprecatedChannel) + // rpm-channel takes precedence over the deprecated field. + assert.Equal(t, "new-channel", config.DefaultPackageConfig.Publish.EffectiveRPMChannel(), + "rpm-channel should take precedence over the deprecated channel field") +} diff --git a/internal/projectconfig/package.go b/internal/projectconfig/package.go index bce85a5e..c504c978 100644 --- a/internal/projectconfig/package.go +++ b/internal/projectconfig/package.go @@ -25,6 +25,22 @@ type PackagePublishConfig struct { // The reserved value `"none"` keeps RPMs in the base directory and means they // should not be published. DebugInfoChannel string `toml:"debuginfo-channel,omitempty" json:"debuginfoChannel,omitempty" validate:"omitempty,ne=.,ne=..,excludesall=/\\" jsonschema:"title=Debuginfo channel,description=Publish channel for debuginfo packages; overrides the component-level debuginfo-channel; use 'none' to keep RPMs in the base directory and skip publishing"` + + // Deprecated: use 'rpm-channel' instead. When set, the value is used as a fallback + // for [PackagePublishConfig.RPMChannel] during channel resolution if 'rpm-channel' is not + // already set. Kept for backwards compatibility with older config files. + DeprecatedChannel string `toml:"channel,omitempty" json:"-" validate:"omitempty,ne=.,ne=..,excludesall=/\\" jsonschema:"deprecated=true,description=Deprecated: use 'rpm-channel' instead. Kept for backwards compatibility; falls back to this value when 'rpm-channel' is not set."` +} + +// EffectiveRPMChannel returns the configured RPM channel, falling back to the deprecated +// 'channel' field for backwards compatibility with older config files that predate +// the 'rpm-channel' field. +func (p PackagePublishConfig) EffectiveRPMChannel() string { + if p.RPMChannel != "" { + return p.RPMChannel + } + + return p.DeprecatedChannel } // PackageConfig holds all configuration applied to a single binary package. diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 3e2635ed..86a1ca20 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -124,6 +124,11 @@ "title": "Build configuration", "description": "Configuration for building the component" }, + "render": { + "$ref": "#/$defs/ComponentRenderConfig", + "title": "Render configuration", + "description": "Configuration for rendering the component" + }, "source-files": { "items": { "$ref": "#/$defs/SourceFileReference" @@ -301,6 +306,17 @@ "additionalProperties": false, "type": "object" }, + "ComponentRenderConfig": { + "properties": { + "skip-file-filter": { + "type": "boolean", + "title": "Skip file filter", + "description": "Disable post-render file filtering for specs with unexpandable macros in Source/Patch tags" + } + }, + "additionalProperties": false, + "type": "object" + }, "ConfigFile": { "properties": { "$schema": { @@ -692,6 +708,10 @@ "type": "string", "title": "Debuginfo channel", "description": "Publish channel for debuginfo packages; overrides the component-level debuginfo-channel; use 'none' to keep RPMs in the base directory and skip publishing" + }, + "channel": { + "type": "string", + "description": "Deprecated: use 'rpm-channel' instead. Kept for backwards compatibility; falls back to this value when 'rpm-channel' is not set." } }, "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 3e2635ed..86a1ca20 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -124,6 +124,11 @@ "title": "Build configuration", "description": "Configuration for building the component" }, + "render": { + "$ref": "#/$defs/ComponentRenderConfig", + "title": "Render configuration", + "description": "Configuration for rendering the component" + }, "source-files": { "items": { "$ref": "#/$defs/SourceFileReference" @@ -301,6 +306,17 @@ "additionalProperties": false, "type": "object" }, + "ComponentRenderConfig": { + "properties": { + "skip-file-filter": { + "type": "boolean", + "title": "Skip file filter", + "description": "Disable post-render file filtering for specs with unexpandable macros in Source/Patch tags" + } + }, + "additionalProperties": false, + "type": "object" + }, "ConfigFile": { "properties": { "$schema": { @@ -692,6 +708,10 @@ "type": "string", "title": "Debuginfo channel", "description": "Publish channel for debuginfo packages; overrides the component-level debuginfo-channel; use 'none' to keep RPMs in the base directory and skip publishing" + }, + "channel": { + "type": "string", + "description": "Deprecated: use 'rpm-channel' instead. Kept for backwards compatibility; falls back to this value when 'rpm-channel' is not set." } }, "additionalProperties": false, diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 3e2635ed..86a1ca20 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -124,6 +124,11 @@ "title": "Build configuration", "description": "Configuration for building the component" }, + "render": { + "$ref": "#/$defs/ComponentRenderConfig", + "title": "Render configuration", + "description": "Configuration for rendering the component" + }, "source-files": { "items": { "$ref": "#/$defs/SourceFileReference" @@ -301,6 +306,17 @@ "additionalProperties": false, "type": "object" }, + "ComponentRenderConfig": { + "properties": { + "skip-file-filter": { + "type": "boolean", + "title": "Skip file filter", + "description": "Disable post-render file filtering for specs with unexpandable macros in Source/Patch tags" + } + }, + "additionalProperties": false, + "type": "object" + }, "ConfigFile": { "properties": { "$schema": { @@ -692,6 +708,10 @@ "type": "string", "title": "Debuginfo channel", "description": "Publish channel for debuginfo packages; overrides the component-level debuginfo-channel; use 'none' to keep RPMs in the base directory and skip publishing" + }, + "channel": { + "type": "string", + "description": "Deprecated: use 'rpm-channel' instead. Kept for backwards compatibility; falls back to this value when 'rpm-channel' is not set." } }, "additionalProperties": false,