diff --git a/docs/user/reference/config/components.md b/docs/user/reference/config/components.md index 2e70793..c9aef9d 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -12,6 +12,8 @@ A component definition tells azldev where to find the spec file, how to customiz | Overlays | `overlays` | array of [Overlay](overlays.md) | No | Modifications to apply to the spec and/or source files | | Build config | `build` | [BuildConfig](#build-configuration) | No | Build-time options (macros, conditionals, check config) | | Source files | `source-files` | array of [SourceFileReference](#source-file-references) | No | Additional source files to download for this component | +| Default package config | `default-package-config` | [PackageConfig](package-groups.md#package-config) | No | Default configuration applied to all binary packages produced by this component; overrides project defaults and package-group defaults | +| Package overrides | `packages` | map of string → [PackageConfig](package-groups.md#package-config) | No | Exact per-package configuration overrides; highest priority in the resolution order | ### Bare Components @@ -190,6 +192,58 @@ The `hints` field provides non-essential metadata about how or when to build a c hints = { expensive = true } ``` +## Package Configuration + +Components can customize the 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" +``` + +### Per-Package Overrides + +The `[components..packages.]` map lets you override config for a **specific** binary package by its exact name. This is the highest-priority layer and overrides all inherited defaults: + +```toml +# Override just one subpackage +[components.curl.packages.curl-devel.publish] +channel = "rpm-devel" +``` + +### Resolution Order + +For each binary package produced by a component, the effective config is assembled in this order (later layers win): + +1. Project `default-package-config` +2. Package group containing this package name (if any) +3. Component `default-package-config` +4. Component `packages.` (highest priority) + +See [Package Groups](package-groups.md) for the full field reference and a complete example. + +### Example + +```toml +[components.curl] + +# Route all curl packages to "base" by default ... +[components.curl.default-package-config.publish] +channel = "rpm-base" + +# ... but put curl-devel in the "devel" channel +[components.curl.packages.libcurl-devel.publish] +channel = "rpm-devel" + +# Signal to downstream tooling that this package should not be published +[components.curl.packages.libcurl-minimal.publish] +channel = "none" +``` + ## Source File References The `[[components..source-files]]` array defines additional source files that azldev should download before building. These are files not available in the dist-git repository or lookaside cache — typically binaries, pre-built artifacts, or files from custom hosting. @@ -313,5 +367,6 @@ lines = ["cp -vf %{shimdirx64}/$(basename %{shimefix64}) %{shimefix64} ||:"] - [Config File Structure](config-file.md) — top-level config file layout - [Distros](distros.md) — distro definitions and `default-component-config` inheritance - [Component Groups](component-groups.md) — grouping components with shared defaults +- [Package Groups](package-groups.md) — project-level package groups and full resolution order - [Configuration System](../../explanation/config-system.md) — inheritance and merge behavior - [JSON Schema](../../../../schemas/azldev.schema.json) — machine-readable schema diff --git a/docs/user/reference/config/package-groups.md b/docs/user/reference/config/package-groups.md new file mode 100644 index 0000000..33cf231 --- /dev/null +++ b/docs/user/reference/config/package-groups.md @@ -0,0 +1,92 @@ +# Package Groups + +Package groups let you apply shared configuration to named sets of binary packages. They are defined under `[package-groups.]` in the TOML configuration. + +Package groups are evaluated at build time, after the binary RPMs are produced. They are analogous to [component groups](component-groups.md), which apply shared configuration to sets of components. + +## Field Reference + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Description | `description` | string | No | Human-readable description of this group | +| Packages | `packages` | string array | No | Explicit list of binary package names that belong to this group | +| Default package config | `default-package-config` | [PackageConfig](#package-config) | No | Configuration inherited by all packages listed in this group | + +## Packages + +The `packages` field is an explicit list of binary package names (as they appear in the RPM `Name` tag) that belong to this group. Membership is determined by exact name match — no glob patterns or wildcards are supported. + +```toml +[package-groups.devel-packages] +description = "Development subpackages" +packages = ["libcurl-devel", "curl-static", "wget2-devel"] + +[package-groups.debug-packages] +description = "Debug info and source packages" +packages = ["curl-debuginfo", "curl-debugsource", "wget2-debuginfo"] +``` + +> **Note:** A package name may appear in at most one group. Listing the same name in two groups produces a validation error. + +## Package Config + +The `[package-groups..default-package-config]` section defines the configuration applied to all packages matching this group. + +### PackageConfig Fields + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| + +| Publish settings | `publish` | [PublishConfig](#publish-config) | No | Publishing settings for matched packages | + +### Publish Config + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Channel | `channel` | string | No | Publish channel for this package. Use `"none"` to signal to downstream tooling that this package should not be published. | + +## Resolution Order + +When determining the effective config for a binary package, azldev applies config layers in this order — later layers override earlier ones: + +1. **Project `default-package-config`** — lowest priority; applies to all packages in the project +2. **Package 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 + +> **Note:** Each package name may appear in at most one group. Listing the same name in two groups produces a validation error. + +## Example + +```toml +# Set a project-wide default channel +[default-package-config.publish] +channel = "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" + +[package-groups.debug-packages] +description = "Debug info and source" +packages = [ + "libcurl-debuginfo", + "libcurl-minimal-debuginfo", + "curl-debugsource", + "wget2-debuginfo", + "wget2-debugsource", + "wget2-libs-debuginfo" +] + +[package-groups.debug-packages.default-package-config.publish] +channel = "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 +- [Configuration System](../../explanation/config-system.md) — inheritance and merge behavior diff --git a/docs/user/reference/config/project.md b/docs/user/reference/config/project.md index 7b7061f..ff1d301 100644 --- a/docs/user/reference/config/project.md +++ b/docs/user/reference/config/project.md @@ -11,6 +11,8 @@ The `[project]` section defines metadata and directory layout for an azldev proj | Work directory | `work-dir` | string | No | Path to the temporary working directory for build artifacts (relative to this config file) | | Output directory | `output-dir` | string | No | Path to the directory where final build outputs (RPMs, SRPMs) are placed (relative to this config file) | | Default distro | `default-distro` | [DistroReference](distros.md#distro-references) | No | The default distro and version to use when building components | +| Default package config | `default-package-config` | [PackageConfig](package-groups.md#package-config) | No | Project-wide default applied to every binary package before group and component overrides | +| Package groups | `package-groups` | map of string → [PackageGroupConfig](package-groups.md) | No | Named groups of binary packages with shared configuration | ## Directory Paths @@ -33,6 +35,27 @@ default-distro = { name = "azurelinux", version = "4.0" } Components inherit their spec source and build environment from the default distro's configuration unless they override it explicitly. See [Configuration Inheritance](../../explanation/config-system.md#configuration-inheritance) for details. +## Default Package Config + +The `[default-package-config]` section defines the lowest-priority configuration layer applied to every binary package produced by any component in the project. It is overridden by [package groups](package-groups.md), [component-level defaults](components.md#package-configuration), and explicit per-package overrides. + +The most common use is to set a project-wide default publish channel: + +```toml +[default-package-config.publish] +channel = "rpm-base" +``` + +See [Package Groups](package-groups.md#resolution-order) for the full resolution order. + +## Package Groups + +The `[package-groups.]` section defines named groups of binary packages. Each group lists its members explicitly in the `packages` field and provides a `default-package-config` that is applied to all listed packages. + +This is currently used to route different types of packages (e.g., `-devel`, `-debuginfo`) to different publish channels, though groups can also carry other future configuration. + +See [Package Groups](package-groups.md) for the full field reference. + ## Example ```toml @@ -42,10 +65,22 @@ log-dir = "build/logs" work-dir = "build/work" output-dir = "out" default-distro = { name = "azurelinux", version = "4.0" } + +[default-package-config.publish] +channel = "base" + +[package-groups.devel-packages] +description = "Development subpackages" +packages = ["curl-devel", "curl-static", "wget2-devel"] + +[package-groups.devel-packages.default-package-config.publish] +channel = "devel" ``` ## Related Resources - [Config File Structure](config-file.md) — top-level config file layout - [Distros](distros.md) — distro definitions referenced by `default-distro` +- [Package Groups](package-groups.md) — full reference for `package-groups` and package config resolution +- [Components](components.md) — per-component package config overrides - [Configuration System](../../explanation/config-system.md) — how project config merges with other files diff --git a/internal/app/azldev/cmds/component/build.go b/internal/app/azldev/cmds/component/build.go index d472687..e5a3059 100644 --- a/internal/app/azldev/cmds/component/build.go +++ b/internal/app/azldev/cmds/component/build.go @@ -8,12 +8,15 @@ import ( "fmt" "path/filepath" + rpmlib "github.com/cavaliergopher/rpm" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/componentbuilder" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/workdir" "github.com/microsoft/azure-linux-dev-tools/internal/buildenv" + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" "github.com/microsoft/azure-linux-dev-tools/internal/utils/defers" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" @@ -37,6 +40,20 @@ type ComponentBuildOptions struct { MockConfigOpts map[string]string } +// RPMResult encapsulates a single binary RPM produced by a component build, +// together with the resolved publish channel for that package. +type RPMResult struct { + // Path is the absolute path to the RPM file. + Path string `json:"path" table:"Path"` + + // PackageName is the binary package name extracted from the RPM header tag (e.g., "libcurl-devel"). + PackageName string `json:"packageName" table:"Package"` + + // Channel is the resolved publish channel from project config. + // Empty when no channel is configured for this package. + Channel string `json:"channel" table:"Channel"` +} + // ComponentBuildResults summarizes the results of building a single component. type ComponentBuildResults struct { // Names of the component that was built. @@ -47,6 +64,13 @@ type ComponentBuildResults struct { // Absolute paths to any RPMs built by the operation. RPMPaths []string `json:"rpmPaths" table:"RPM Paths"` + + // RPMChannels holds the resolved publish channel for each RPM, parallel to [RPMPaths]. + // Empty string means no channel was configured for that package. + RPMChannels []string `json:"rpmChannels" table:"Channels"` + + // RPMs contains enriched per-RPM information including the resolved publish channel. + RPMs []RPMResult `json:"rpms" table:"-"` } func buildOnAppInit(_ *azldev.App, parent *cobra.Command) { @@ -288,6 +312,18 @@ func buildComponentUsingBuilder( return results, fmt.Errorf("failed to build RPM for %q: %w", component.GetName(), err) } + // Enrich each RPM with its binary package name and resolved publish channel. + results.RPMs, err = resolveRPMResults(env.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) + } + + // Populate the parallel Channels slice for table display. + results.RPMChannels = make([]string, len(results.RPMs)) + for rpmIdx, rpm := range results.RPMs { + results.RPMChannels[rpmIdx] = rpm.Channel + } + // Publish built RPMs to local repo with publish enabled. if localRepoWithPublishPath != "" && len(results.RPMPaths) > 0 { publishErr := publishToLocalRepo(env, results.RPMPaths, localRepoWithPublishPath) @@ -352,6 +388,59 @@ func checkLocalRepoPathOverlap(localRepoPaths []string, localRepoWithPublishPath return nil } +// resolveRPMResults builds an [RPMResult] for each RPM path, extracting the binary package +// name from the RPM headers and resolving its publish channel from the project config (if available). +// When no project config is loaded, the Channel field is left empty. +func resolveRPMResults( + fs opctx.FS, rpmPaths []string, proj *projectconfig.ProjectConfig, compConfig *projectconfig.ComponentConfig, +) ([]RPMResult, error) { + rpmResults := make([]RPMResult, 0, len(rpmPaths)) + + for _, rpmPath := range rpmPaths { + pkgName, err := packageNameFromRPM(fs, rpmPath) + if err != nil { + return nil, fmt.Errorf("failed to determine package name:\n%w", err) + } + + rpmResult := RPMResult{ + Path: rpmPath, + PackageName: pkgName, + } + + if proj != nil { + pkgConfig, err := projectconfig.ResolvePackageConfig(pkgName, compConfig, proj) + if err != nil { + return nil, fmt.Errorf("failed to resolve package config for %#q:\n%w", pkgName, err) + } + + rpmResult.Channel = pkgConfig.Publish.Channel + } + + rpmResults = append(rpmResults, rpmResult) + } + + return rpmResults, nil +} + +// packageNameFromRPM extracts the binary package name from an RPM file by reading +// its headers. Reading the Name tag directly from the RPM metadata is authoritative and +// handles all valid package names regardless of naming conventions. +func packageNameFromRPM(fs opctx.FS, rpmPath string) (string, error) { + rpmFile, err := fs.Open(rpmPath) + if err != nil { + return "", fmt.Errorf("failed to open RPM %#q:\n%w", rpmPath, err) + } + + defer rpmFile.Close() + + pkg, err := rpmlib.Read(rpmFile) + if err != nil { + return "", fmt.Errorf("failed to read RPM headers from %#q:\n%w", rpmPath, err) + } + + return pkg.Name(), nil +} + // publishToLocalRepo publishes the given RPMs to the specified local repo. func publishToLocalRepo(env *azldev.Env, rpmPaths []string, repoPath string) error { publisher, err := localrepo.NewPublisher(env, repoPath, false) diff --git a/internal/projectconfig/component.go b/internal/projectconfig/component.go index 6910021..189887d 100644 --- a/internal/projectconfig/component.go +++ b/internal/projectconfig/component.go @@ -128,6 +128,14 @@ type ComponentConfig struct { // Source file references for this component. SourceFiles []SourceFileReference `toml:"source-files,omitempty" json:"sourceFiles,omitempty" table:"-" jsonschema:"title=Source files,description=Source files to download for this component"` + + // Default configuration applied to all binary packages produced by this component. + // Takes precedence over package-group defaults; overridden by explicit Packages entries. + DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" table:"-" jsonschema:"title=Default package config,description=Default configuration applied to all binary packages produced by this component"` + + // Per-package configuration overrides, keyed by exact binary package name. + // Takes precedence over DefaultPackageConfig and package-group defaults. + Packages map[string]PackageConfig `toml:"packages,omitempty" json:"packages,omitempty" table:"-" jsonschema:"title=Package overrides,description=Per-package configuration overrides keyed by exact binary package name"` } // Mutates the component config, updating it with overrides present in other. @@ -147,11 +155,13 @@ func (c *ComponentConfig) WithAbsolutePaths(referenceDir string) *ComponentConfi // the SourceConfigFile, as we *do* want to alias that pointer, sharing it across // all configs that came from that source config file. result := &ComponentConfig{ - Name: c.Name, - SourceConfigFile: c.SourceConfigFile, - Spec: deep.MustCopy(c.Spec), - Build: deep.MustCopy(c.Build), - SourceFiles: deep.MustCopy(c.SourceFiles), + Name: c.Name, + SourceConfigFile: c.SourceConfigFile, + Spec: deep.MustCopy(c.Spec), + Build: deep.MustCopy(c.Build), + SourceFiles: deep.MustCopy(c.SourceFiles), + DefaultPackageConfig: deep.MustCopy(c.DefaultPackageConfig), + Packages: deep.MustCopy(c.Packages), } // Fix up paths. diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index 9274472..23e5527 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -43,6 +43,14 @@ type ConfigFile struct { // Configuration for tools used by azldev. Tools *ToolsConfig `toml:"tools,omitempty" jsonschema:"title=Tools configuration,description=Configuration for tools used by azldev"` + // DefaultPackageConfig is the project-wide default package configuration applied before any + // package-group or component-level config is considered. + DefaultPackageConfig *PackageConfig `toml:"default-package-config,omitempty" jsonschema:"title=Default package config,description=Project-wide default applied to all binary packages before group and component overrides"` + + // Definitions of package groups. Groups allow shared configuration + // to be applied to sets of binary packages. + PackageGroups map[string]PackageGroupConfig `toml:"package-groups,omitempty" jsonschema:"title=Package groups,description=Definitions of package groups for shared binary package configuration"` + // Internal fields used to track the origin of the config file; `dir` is the directory // that the config file's relative paths are based from. sourcePath string `toml:"-"` @@ -56,6 +64,13 @@ func (f ConfigFile) Validate() error { return fmt.Errorf("config file error:\n%w", err) } + // Validate package group configurations. + for groupName, group := range f.PackageGroups { + if err := group.Validate(); err != nil { + return fmt.Errorf("invalid package group %#q:\n%w", groupName, err) + } + } + // Validate overlay configurations for each component. for componentName, component := range f.Components { for i, overlay := range component.Overlays { diff --git a/internal/projectconfig/loader.go b/internal/projectconfig/loader.go index b006345..270d091 100644 --- a/internal/projectconfig/loader.go +++ b/internal/projectconfig/loader.go @@ -25,6 +25,8 @@ var ( ErrDuplicateComponentGroups = errors.New("duplicate component group") // ErrDuplicateImages is returned when duplicate conflicting image definitions are found. ErrDuplicateImages = errors.New("duplicate image") + // ErrDuplicatePackageGroups is returned when duplicate conflicting package group definitions are found. + ErrDuplicatePackageGroups = errors.New("duplicate package group") ) // Loads and resolves the project configuration files located at the given path. Referenced include files @@ -39,6 +41,7 @@ func loadAndResolveProjectConfig( Images: make(map[string]ImageConfig), Distros: make(map[string]DistroDefinition), GroupsByComponent: make(map[string][]string), + PackageGroups: make(map[string]PackageGroupConfig), } for _, configFilePath := range configFilePaths { @@ -116,6 +119,14 @@ func mergeConfigFile(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return err } + if err := mergeDefaultPackageConfig(resolvedCfg, loadedCfg); err != nil { + return err + } + + if err := mergePackageGroups(resolvedCfg, loadedCfg); err != nil { + return err + } + return nil } @@ -213,6 +224,32 @@ func mergeTools(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return nil } +// mergeDefaultPackageConfig merges the project-level default package config from a loaded +// config file into the resolved config. +func mergeDefaultPackageConfig(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + if loadedCfg.DefaultPackageConfig != nil { + if err := resolvedCfg.DefaultPackageConfig.MergeUpdatesFrom(loadedCfg.DefaultPackageConfig); err != nil { + return fmt.Errorf("failed to merge project default package config:\n%w", err) + } + } + + return nil +} + +// mergePackageGroups merges package group definitions from a loaded config file into +// the resolved config. Duplicate package group names are not allowed. +func mergePackageGroups(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + for groupName, group := range loadedCfg.PackageGroups { + if _, ok := resolvedCfg.PackageGroups[groupName]; ok { + return fmt.Errorf("%w: %#q", ErrDuplicatePackageGroups, groupName) + } + + resolvedCfg.PackageGroups[groupName] = group + } + + return nil +} + func loadProjectConfigWithIncludes( fs opctx.FS, filePath string, permissiveConfigParsing bool, ) ([]*ConfigFile, error) { diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index 2f91863..1f1deb3 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -561,3 +561,240 @@ includes = ["*non-existent*.toml"] _, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) require.NoError(t, err) } + +func TestLoadAndResolveProjectConfig_DefaultPackageConfig(t *testing.T) { + const configContents = ` +[default-package-config.publish] +channel = "base" +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.NoError(t, err) + + assert.Equal(t, "base", config.DefaultPackageConfig.Publish.Channel) +} + +func TestLoadAndResolveProjectConfig_DefaultPackageConfig_MergedAcrossFiles(t *testing.T) { + // First file sets a channel; second file overrides it. + testFiles := []struct { + path string + contents string + }{ + {testConfigPath, ` +includes = ["extra.toml"] + +[default-package-config.publish] +channel = "base" +`}, + {"/project/extra.toml", ` +[default-package-config.publish] +channel = "stable" +`}, + } + + ctx := testctx.NewCtx() + for _, f := range testFiles { + require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(f.path))) + require.NoError(t, fileutils.WriteFile(ctx.FS(), f.path, []byte(f.contents), fileperms.PrivateFile)) + } + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) + require.NoError(t, err) + + // The later-loaded file wins. + assert.Equal(t, "stable", config.DefaultPackageConfig.Publish.Channel) +} + +func TestLoadAndResolveProjectConfig_DefaultPackageConfig_MergedAcrossTopLevelFiles(t *testing.T) { + // Two separate top-level config files; the second one overrides the first. + const ( + configContents1 = ` +[default-package-config.publish] +channel = "first" +` + configContents2 = ` +[default-package-config.publish] +channel = "second" +` + ) + + configPath1 := testConfigPath + configPath2 := filepath.Join("/project", "extra.toml") + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), configPath1, []byte(configContents1), fileperms.PrivateFile)) + require.NoError(t, fileutils.WriteFile(ctx.FS(), configPath2, []byte(configContents2), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, configPath1, configPath2) + require.NoError(t, err) + + assert.Equal(t, "second", config.DefaultPackageConfig.Publish.Channel) +} + +func TestLoadAndResolveProjectConfig_PackageGroups(t *testing.T) { + const configContents = ` +[package-groups.devel-packages] +description = "Development subpackages" +packages = ["curl-devel", "wget2-devel"] + +[package-groups.devel-packages.default-package-config.publish] +channel = "devel" + +[package-groups.debug-packages] +description = "Debug info packages" +packages = ["curl-debuginfo", "curl-debugsource"] + +[package-groups.debug-packages.default-package-config.publish] +channel = "none" +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.NoError(t, err) + + require.Len(t, config.PackageGroups, 2) + + if assert.Contains(t, config.PackageGroups, "devel-packages") { + g := config.PackageGroups["devel-packages"] + assert.Equal(t, "Development subpackages", g.Description) + assert.Equal(t, []string{"curl-devel", "wget2-devel"}, g.Packages) + assert.Equal(t, "devel", g.DefaultPackageConfig.Publish.Channel) + } + + if assert.Contains(t, config.PackageGroups, "debug-packages") { + g := config.PackageGroups["debug-packages"] + assert.Equal(t, "Debug info packages", g.Description) + assert.Equal(t, []string{"curl-debuginfo", "curl-debugsource"}, g.Packages) + assert.Equal(t, "none", g.DefaultPackageConfig.Publish.Channel) + } +} + +func TestLoadAndResolveProjectConfig_DuplicatePackageGroups(t *testing.T) { + testFiles := []struct { + path string + contents string + }{ + {testConfigPath, ` +includes = ["extra.toml"] + +[package-groups.devel-packages] +packages = ["curl-devel"] +`}, + {"/project/extra.toml", ` +[package-groups.devel-packages] +packages = ["wget2-devel"] +`}, + } + + ctx := testctx.NewCtx() + for _, f := range testFiles { + require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(f.path))) + require.NoError(t, fileutils.WriteFile(ctx.FS(), f.path, []byte(f.contents), fileperms.PrivateFile)) + } + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) + require.ErrorIs(t, err, ErrDuplicatePackageGroups) +} + +func TestLoadAndResolveProjectConfig_DuplicatePackageGroupsAcrossTopLevelFiles(t *testing.T) { + const ( + configContents1 = ` +[package-groups.devel-packages] +packages = ["curl-devel"] +` + configContents2 = ` +[package-groups.devel-packages] +packages = ["wget2-devel"] +` + ) + + configPath1 := testConfigPath + configPath2 := filepath.Join("/project", "extra.toml") + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), configPath1, []byte(configContents1), fileperms.PrivateFile)) + require.NoError(t, fileutils.WriteFile(ctx.FS(), configPath2, []byte(configContents2), fileperms.PrivateFile)) + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, configPath1, configPath2) + require.ErrorIs(t, err, ErrDuplicatePackageGroups) +} + +func TestLoadAndResolveProjectConfig_PackageGroups_EmptyPackageName(t *testing.T) { + const configContents = ` +[package-groups.bad-group] +packages = ["curl-devel", ""] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "packages[1]") + assert.Contains(t, err.Error(), "must not be empty") +} + +func TestLoadAndResolveProjectConfig_PackageGroups_DuplicatePackageWithinGroup(t *testing.T) { + const configContents = ` +[package-groups.my-group] +packages = ["curl-devel", "wget2-devel", "curl-devel"] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "curl-devel") + assert.Contains(t, err.Error(), "more than once") +} + +func TestLoadAndResolveProjectConfig_PackageGroups_DuplicatePackageAcrossGroups(t *testing.T) { + const configContents = ` +[package-groups.group-a] +packages = ["curl-devel", "wget2-devel"] + +[package-groups.group-b] +packages = ["wget2-devel", "bash-devel"] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "wget2-devel") + assert.Contains(t, err.Error(), "may only belong to one group") +} + +func TestLoadAndResolveProjectConfig_ComponentDefaultPackageConfig(t *testing.T) { + const configContents = ` +[components.curl] + +[components.curl.default-package-config.publish] +channel = "base" + +[components.curl.packages.curl-devel.publish] +channel = "devel" +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.NoError(t, err) + + if assert.Contains(t, config.Components, "curl") { + comp := config.Components["curl"] + assert.Equal(t, "base", comp.DefaultPackageConfig.Publish.Channel) + + if assert.Contains(t, comp.Packages, "curl-devel") { + assert.Equal(t, "devel", comp.Packages["curl-devel"].Publish.Channel) + } + } +} diff --git a/internal/projectconfig/package.go b/internal/projectconfig/package.go new file mode 100644 index 0000000..25756fb --- /dev/null +++ b/internal/projectconfig/package.go @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig + +import ( + "fmt" + "slices" + + "dario.cat/mergo" +) + +// PackagePublishConfig holds publish settings for a single binary package. +// The zero value means the channel is inherited from a higher-priority config layer. +type PackagePublishConfig struct { + // Channel identifies the publish channel for this package. + // The special value "none" is a convention meaning the package should not be published; + // azldev records this value in build results but enforcement is left to downstream tooling. + // When empty, the value is inherited from the next layer in the resolution order. + Channel string `toml:"channel,omitempty" json:"channel,omitempty" jsonschema:"title=Channel,description=Publish channel for this package; use 'none' to signal to downstream tooling that this package should not be published"` +} + +// PackageConfig holds all configuration applied to a single binary package. +// Currently only publish settings are supported; additional fields may be added in the future. +type PackageConfig struct { + // Publish holds the publish settings for this package. + Publish PackagePublishConfig `toml:"publish,omitempty" json:"publish,omitempty" jsonschema:"title=Publish settings,description=Publishing settings for this binary package"` +} + +// MergeUpdatesFrom updates the package config with non-zero values from other. +func (p *PackageConfig) MergeUpdatesFrom(other *PackageConfig) error { + err := mergo.Merge(p, other, mergo.WithOverride) + if err != nil { + return fmt.Errorf("failed to merge package config:\n%w", err) + } + + return nil +} + +// PackageGroupConfig defines a named group of binary packages with shared configuration. +// It is analogous to [ComponentGroupConfig] for components. +// +// If a binary package name appears in a group's [PackageGroupConfig.Packages] list, that group's +// [PackageGroupConfig.DefaultPackageConfig] is applied when resolving the package's [PackageConfig]. +// A package may belong to at most one group. +type PackageGroupConfig struct { + // Description is an optional human-readable description of this group. + Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Human-readable description of this package group"` + + // Packages is an explicit list of binary package names that belong to this group. + Packages []string `toml:"packages,omitempty" json:"packages,omitempty" jsonschema:"title=Packages,description=Explicit list of binary package names that are members of this group"` + + // DefaultPackageConfig is the configuration applied to all packages listed in Packages. + DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" jsonschema:"title=Default package config,description=Configuration inherited by all packages in this group"` +} + +// Validate checks that all package names in the group are non-empty and unique within the group. +func (g *PackageGroupConfig) Validate() error { + seen := make(map[string]struct{}, len(g.Packages)) + + for i, pkg := range g.Packages { + if pkg == "" { + return fmt.Errorf("packages[%d] must not be empty", i) + } + + if _, duplicate := seen[pkg]; duplicate { + return fmt.Errorf("package %#q appears more than once in the packages list", pkg) + } + + seen[pkg] = struct{}{} + } + + return nil +} + +// ResolvePackageConfig returns the effective [PackageConfig] for a binary package produced +// by a component, merging contributions from all applicable config layers. +// +// Resolution order (each layer overrides the previous — later wins): +// 1. The project's DefaultPackageConfig (lowest priority) +// 2. The [PackageGroupConfig] whose Packages list contains pkgName, if any +// 3. The component's DefaultPackageConfig +// 4. The component's explicit Packages entry for the exact package name (highest priority) +func ResolvePackageConfig(pkgName string, comp *ComponentConfig, proj *ProjectConfig) (PackageConfig, error) { + // 1. Start from the project-level default (lowest priority). + result := proj.DefaultPackageConfig + + // 2. Apply the package group that contains this package, if any. + // A package belongs to at most one group, so we stop at the first match. + for groupName, group := range proj.PackageGroups { + if slices.Contains(group.Packages, pkgName) { + if err := result.MergeUpdatesFrom(&group.DefaultPackageConfig); err != nil { + return PackageConfig{}, fmt.Errorf( + "failed to apply defaults from package group %#q to package %#q:\n%w", + groupName, pkgName, err, + ) + } + + break + } + } + + // 3. Apply the component-level default (overrides group defaults). + if err := result.MergeUpdatesFrom(&comp.DefaultPackageConfig); err != nil { + return PackageConfig{}, fmt.Errorf( + "failed to apply component defaults to package %#q:\n%w", pkgName, err, + ) + } + + // 4. Apply the explicit per-package override (exact name, highest priority). + if pkgConfig, ok := comp.Packages[pkgName]; ok { + if err := result.MergeUpdatesFrom(&pkgConfig); err != nil { + return PackageConfig{}, fmt.Errorf( + "failed to apply package override for %#q:\n%w", pkgName, err, + ) + } + } + + return result, nil +} diff --git a/internal/projectconfig/package_test.go b/internal/projectconfig/package_test.go new file mode 100644 index 0000000..f5b972e --- /dev/null +++ b/internal/projectconfig/package_test.go @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageGroupConfig_Validate(t *testing.T) { + t.Run("empty group is valid", func(t *testing.T) { + group := projectconfig.PackageGroupConfig{} + assert.NoError(t, group.Validate()) + }) + + t.Run("group with packages is valid", func(t *testing.T) { + group := projectconfig.PackageGroupConfig{ + Description: "development packages", + Packages: []string{"curl-devel", "python3-requests", "curl"}, + } + assert.NoError(t, group.Validate()) + }) + + t.Run("empty package name is invalid", func(t *testing.T) { + group := projectconfig.PackageGroupConfig{ + Packages: []string{"curl-devel", ""}, + } + err := group.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "packages[1]") + assert.Contains(t, err.Error(), "must not be empty") + }) + + t.Run("duplicate package name within group is invalid", func(t *testing.T) { + group := projectconfig.PackageGroupConfig{ + Packages: []string{"curl-devel", "wget2-devel", "curl-devel"}, + } + err := group.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "curl-devel") + assert.Contains(t, err.Error(), "more than once") + }) +} + +func TestPackageConfig_MergeUpdatesFrom(t *testing.T) { + t.Run("non-zero other overrides zero base", func(t *testing.T) { + base := projectconfig.PackageConfig{} + other := projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + } + require.NoError(t, base.MergeUpdatesFrom(&other)) + assert.Equal(t, "build", base.Publish.Channel) + }) + + t.Run("non-zero other overrides non-zero base", func(t *testing.T) { + base := projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + } + other := projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + require.NoError(t, base.MergeUpdatesFrom(&other)) + assert.Equal(t, "base", base.Publish.Channel) + }) + + t.Run("zero other does not override non-zero base", func(t *testing.T) { + base := projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + } + other := projectconfig.PackageConfig{} + require.NoError(t, base.MergeUpdatesFrom(&other)) + assert.Equal(t, "build", base.Publish.Channel) + }) +} + +func TestResolvePackageConfig(t *testing.T) { + makeProj := func(groups map[string]projectconfig.PackageGroupConfig) *projectconfig.ProjectConfig { + proj := projectconfig.NewProjectConfig() + proj.PackageGroups = groups + + return &proj + } + + baseProj := makeProj(map[string]projectconfig.PackageGroupConfig{ + "debug-packages": { + Packages: []string{"gcc-debuginfo", "curl-debuginfo", "curl-debugsource"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + }, + "build-time-deps": { + Packages: []string{"curl-devel", "curl-static", "gcc-devel"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + }, + }, + }) + + testCases := []struct { + name string + pkgName string + compDefault projectconfig.PackageConfig + compPackages map[string]projectconfig.PackageConfig + expectedChannel string + }{ + { + name: "package not in any group returns zero channel", + pkgName: "curl", + expectedChannel: "", + }, + { + name: "package listed in build-time-deps group gets build channel", + pkgName: "curl-devel", + expectedChannel: "build", + }, + { + name: "package listed in debug-packages group gets none channel", + pkgName: "gcc-debuginfo", + expectedChannel: "none", + }, + { + name: "component default overrides group default", + pkgName: "gcc-devel", + compDefault: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + }, + expectedChannel: "base", + }, + { + name: "component default applies when no group 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", + pkgName: "curl-devel", + compDefault: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + compPackages: map[string]projectconfig.PackageConfig{ + "curl-devel": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + }, + expectedChannel: "base", + }, + { + name: "exact package override takes priority over group with no component default", + pkgName: "curl-devel", + compPackages: map[string]projectconfig.PackageConfig{ + "curl-devel": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + }, + expectedChannel: "base", + }, + { + name: "unrelated exact package entry does not affect result", + pkgName: "curl-devel", + compPackages: map[string]projectconfig.PackageConfig{ + "curl": {Publish: projectconfig.PackagePublishConfig{Channel: "base"}}, + }, + expectedChannel: "build", // from group + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + comp := &projectconfig.ComponentConfig{ + Name: "test-component", + DefaultPackageConfig: testCase.compDefault, + Packages: testCase.compPackages, + } + + got, err := projectconfig.ResolvePackageConfig(testCase.pkgName, comp, baseProj) + require.NoError(t, err) + assert.Equal(t, testCase.expectedChannel, got.Publish.Channel) + }) + } + + t.Run("package group default-package-config is applied", func(t *testing.T) { + proj := makeProj(map[string]projectconfig.PackageGroupConfig{ + "my-group": { + Packages: []string{"curl-devel"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "build"}, + }, + }, + }) + + comp := &projectconfig.ComponentConfig{Name: "curl"} + got, err := projectconfig.ResolvePackageConfig("curl-devel", comp, proj) + require.NoError(t, err) + assert.Equal(t, "build", got.Publish.Channel) + }) + + t.Run("empty project config returns zero-value PackageConfig", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + comp := &projectconfig.ComponentConfig{Name: "curl"} + + got, err := projectconfig.ResolvePackageConfig("curl", comp, &proj) + require.NoError(t, err) + assert.Empty(t, got.Publish.Channel) + }) + + t.Run("project default applies when no other config matches", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + proj.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + + comp := &projectconfig.ComponentConfig{Name: "curl"} + got, err := projectconfig.ResolvePackageConfig("curl", comp, &proj) + require.NoError(t, err) + assert.Equal(t, "base", got.Publish.Channel) + }) + + t.Run("package group overrides project default", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + proj.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + proj.PackageGroups = map[string]projectconfig.PackageGroupConfig{ + "debug-packages": { + Packages: []string{"gcc-debuginfo"}, + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + }, + } + + comp := &projectconfig.ComponentConfig{Name: "gcc"} + got, err := projectconfig.ResolvePackageConfig("gcc-debuginfo", comp, &proj) + require.NoError(t, err) + assert.Equal(t, "none", got.Publish.Channel) + }) + + t.Run("component default overrides project default", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + proj.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + + comp := &projectconfig.ComponentConfig{ + Name: "build-id-helper", + DefaultPackageConfig: projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "none"}, + }, + } + + got, err := projectconfig.ResolvePackageConfig("build-id-helper-tool", comp, &proj) + require.NoError(t, err) + assert.Equal(t, "none", got.Publish.Channel) + }) + + t.Run("per-package override takes priority over project default", func(t *testing.T) { + proj := projectconfig.NewProjectConfig() + proj.DefaultPackageConfig = projectconfig.PackageConfig{ + Publish: projectconfig.PackagePublishConfig{Channel: "base"}, + } + + comp := &projectconfig.ComponentConfig{ + Name: "curl", + Packages: map[string]projectconfig.PackageConfig{ + "curl-devel": {Publish: projectconfig.PackagePublishConfig{Channel: "none"}}, + }, + } + + got, err := projectconfig.ResolvePackageConfig("curl-devel", comp, &proj) + require.NoError(t, err) + assert.Equal(t, "none", got.Publish.Channel) + }) +} diff --git a/internal/projectconfig/project.go b/internal/projectconfig/project.go index cd8e84c..1172cfb 100644 --- a/internal/projectconfig/project.go +++ b/internal/projectconfig/project.go @@ -26,6 +26,14 @@ type ProjectConfig struct { // Configuration for tools used by azldev. Tools ToolsConfig `toml:"tools,omitempty" json:"tools,omitempty" jsonschema:"title=Tools configuration,description=Configuration for tools used by azldev"` + // DefaultPackageConfig is the project-wide default applied to every binary package before any + // package-group or component-level config is considered. It is the lowest-priority layer in the + // package config resolution order. + DefaultPackageConfig PackageConfig `toml:"default-package-config,omitempty" json:"defaultPackageConfig,omitempty" jsonschema:"title=Default package config,description=Project-wide default applied to all binary packages before group and component overrides"` + + // Definitions of package groups with shared configuration. + PackageGroups map[string]PackageGroupConfig `toml:"package-groups,omitempty" json:"packageGroups,omitempty" jsonschema:"title=Package groups,description=Mapping of package group names to configurations for publish-time routing"` + // Root config file path; not serialized. RootConfigFilePath string `toml:"-" json:"-"` // Map from component names to groups they belong to; not serialized. @@ -41,6 +49,7 @@ func NewProjectConfig() ProjectConfig { Images: make(map[string]ImageConfig), Distros: make(map[string]DistroDefinition), GroupsByComponent: make(map[string][]string), + PackageGroups: make(map[string]PackageGroupConfig), } } @@ -51,6 +60,33 @@ func (cfg *ProjectConfig) Validate() error { return fmt.Errorf("config error:\n%w", err) } + if err := validatePackageGroupMembership(cfg.PackageGroups); err != nil { + return err + } + + return nil +} + +// validatePackageGroupMembership checks that no binary package name appears in more than one +// package group. A packagemay belong to at most one group to keep routing unambiguous, but it +// may also be left ungrouped. +func validatePackageGroupMembership(groups map[string]PackageGroupConfig) error { + // Track which group each package name was first seen in. + seenIn := make(map[string]string, len(groups)) + + for groupName, group := range groups { + for _, pkg := range group.Packages { + if firstGroup, already := seenIn[pkg]; already && firstGroup != groupName { + return fmt.Errorf( + "package %#q appears in both package-group %#q and %#q; a package may only belong to one group", + pkg, firstGroup, groupName, + ) + } + + seenIn[pkg] = groupName + } + } + return nil } diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 0548cef..87965e9 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -126,6 +126,19 @@ "type": "array", "title": "Source files", "description": "Source files to download for this component" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Default configuration applied to all binary packages produced by this component" + }, + "packages": { + "additionalProperties": { + "$ref": "#/$defs/PackageConfig" + }, + "type": "object", + "title": "Package overrides", + "description": "Per-package configuration overrides keyed by exact binary package name" } }, "additionalProperties": false, @@ -315,6 +328,19 @@ "$ref": "#/$defs/ToolsConfig", "title": "Tools configuration", "description": "Configuration for tools used by azldev" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Project-wide default applied to all binary packages before group and component overrides" + }, + "package-groups": { + "additionalProperties": { + "$ref": "#/$defs/PackageGroupConfig" + }, + "type": "object", + "title": "Package groups", + "description": "Definitions of package groups for shared binary package configuration" } }, "additionalProperties": false, @@ -511,6 +537,52 @@ "type" ] }, + "PackageConfig": { + "properties": { + "publish": { + "$ref": "#/$defs/PackagePublishConfig", + "title": "Publish settings", + "description": "Publishing settings for this binary package" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackageGroupConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable description of this package group" + }, + "packages": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Packages", + "description": "Explicit list of binary package names that are members of this group" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Configuration inherited by all packages in this group" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackagePublishConfig": { + "properties": { + "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" + } + }, + "additionalProperties": false, + "type": "object" + }, "PackageRepository": { "properties": { "base-uri": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 0548cef..87965e9 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -126,6 +126,19 @@ "type": "array", "title": "Source files", "description": "Source files to download for this component" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Default configuration applied to all binary packages produced by this component" + }, + "packages": { + "additionalProperties": { + "$ref": "#/$defs/PackageConfig" + }, + "type": "object", + "title": "Package overrides", + "description": "Per-package configuration overrides keyed by exact binary package name" } }, "additionalProperties": false, @@ -315,6 +328,19 @@ "$ref": "#/$defs/ToolsConfig", "title": "Tools configuration", "description": "Configuration for tools used by azldev" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Project-wide default applied to all binary packages before group and component overrides" + }, + "package-groups": { + "additionalProperties": { + "$ref": "#/$defs/PackageGroupConfig" + }, + "type": "object", + "title": "Package groups", + "description": "Definitions of package groups for shared binary package configuration" } }, "additionalProperties": false, @@ -511,6 +537,52 @@ "type" ] }, + "PackageConfig": { + "properties": { + "publish": { + "$ref": "#/$defs/PackagePublishConfig", + "title": "Publish settings", + "description": "Publishing settings for this binary package" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackageGroupConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable description of this package group" + }, + "packages": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Packages", + "description": "Explicit list of binary package names that are members of this group" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Configuration inherited by all packages in this group" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackagePublishConfig": { + "properties": { + "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" + } + }, + "additionalProperties": false, + "type": "object" + }, "PackageRepository": { "properties": { "base-uri": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 0548cef..87965e9 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -126,6 +126,19 @@ "type": "array", "title": "Source files", "description": "Source files to download for this component" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Default configuration applied to all binary packages produced by this component" + }, + "packages": { + "additionalProperties": { + "$ref": "#/$defs/PackageConfig" + }, + "type": "object", + "title": "Package overrides", + "description": "Per-package configuration overrides keyed by exact binary package name" } }, "additionalProperties": false, @@ -315,6 +328,19 @@ "$ref": "#/$defs/ToolsConfig", "title": "Tools configuration", "description": "Configuration for tools used by azldev" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Project-wide default applied to all binary packages before group and component overrides" + }, + "package-groups": { + "additionalProperties": { + "$ref": "#/$defs/PackageGroupConfig" + }, + "type": "object", + "title": "Package groups", + "description": "Definitions of package groups for shared binary package configuration" } }, "additionalProperties": false, @@ -511,6 +537,52 @@ "type" ] }, + "PackageConfig": { + "properties": { + "publish": { + "$ref": "#/$defs/PackagePublishConfig", + "title": "Publish settings", + "description": "Publishing settings for this binary package" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackageGroupConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Human-readable description of this package group" + }, + "packages": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Packages", + "description": "Explicit list of binary package names that are members of this group" + }, + "default-package-config": { + "$ref": "#/$defs/PackageConfig", + "title": "Default package config", + "description": "Configuration inherited by all packages in this group" + } + }, + "additionalProperties": false, + "type": "object" + }, + "PackagePublishConfig": { + "properties": { + "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" + } + }, + "additionalProperties": false, + "type": "object" + }, "PackageRepository": { "properties": { "base-uri": {