diff --git a/docs/user/reference/config/component-templates.md b/docs/user/reference/config/component-templates.md new file mode 100644 index 00000000..e5695329 --- /dev/null +++ b/docs/user/reference/config/component-templates.md @@ -0,0 +1,151 @@ +# Component Templates + +Component templates define a matrix of axes whose cartesian product expands into multiple [component](components.md) definitions. This is useful when you need to build several variants of the same source project — for example, an out-of-tree kernel module compiled against different kernel versions and toolchains. + +Templates are defined under `[component-templates.]` in the TOML configuration. During config loading, each template is expanded into regular components that behave identically to explicitly defined ones. + +## Template Config + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Description | `description` | string | No | Human-friendly description of this template | +| Default component config | `default-component-config` | [ComponentConfig](components.md#component-config) | No | Base configuration applied to every expanded variant before axis overrides | +| Matrix | `matrix` | array of [MatrixAxis](#matrix-axis) | **Yes** | Ordered list of axes whose cartesian product defines the expanded variants | + +## Matrix Axis + +Each matrix axis defines one dimension of the expansion. The cartesian product of all axes determines the set of expanded components. + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Axis name | `axis` | string | **Yes** | Name of this axis (e.g., `"kernel"`, `"toolchain"`) | +| Values | `values` | map of string → [ComponentConfig](components.md#component-config) | **Yes** | Named values for this axis; each value is a partial component config merged into the expanded component | + +### Constraints + +- At least one axis is required per template. +- Each axis must have at least one value. +- Axis names must be unique within a template. +- Value names must be non-empty. + +## Expansion Rules + +### Name Synthesis + +Expanded component names are formed by joining the template name with each selected value name, separated by `-`, in the order the axes appear in the `matrix` array: + +``` +---... +``` + +For example, a template `my-driver` with axes `kernel` (values `6-6`, `6-12`) and `toolchain` (values `gcc13`, `gcc14`) produces: + +- `my-driver-6-6-gcc13` +- `my-driver-6-6-gcc14` +- `my-driver-6-12-gcc13` +- `my-driver-6-12-gcc14` + +### Config Layering + +For each expanded component, configurations are layered in the following order (later layers override earlier ones): + +1. Template's `default-component-config` +2. First axis's selected value config +3. Second axis's selected value config +4. ... (additional axes in definition order) + +This uses the same `MergeUpdatesFrom` mechanism as [component group defaults](component-groups.md), so all standard merge rules apply. + +### Collision Handling + +If an expanded component name collides with an explicitly defined component (or another template's expansion), a validation error is produced at load time. Expanded components are merged **after** regular components, so the error message will identify the template as the source of the collision. + +## Example + +### Basic Two-Axis Template + +```toml +[component-templates.my-driver] +description = "Out-of-tree driver built against multiple kernel versions and toolchains" + +[component-templates.my-driver.default-component-config] +spec = { type = "local", path = "my-driver.spec" } + +[component-templates.my-driver.default-component-config.build] +defines = { base_config = "true" } + +[[component-templates.my-driver.matrix]] +axis = "kernel" +[component-templates.my-driver.matrix.values.6-6.build] +defines = { kernel_version = "6.6.72" } +[component-templates.my-driver.matrix.values.6-12.build] +defines = { kernel_version = "6.12.8" } + +[[component-templates.my-driver.matrix]] +axis = "toolchain" +[component-templates.my-driver.matrix.values.gcc13.build] +defines = { gcc_version = "13" } +[component-templates.my-driver.matrix.values.gcc14.build] +defines = { gcc_version = "14" } +``` + +This produces 4 components. For example, `my-driver-6-6-gcc14` will have: +- `spec` from the default: `{ type = "local", path = "my-driver.spec" }` +- `build.defines` merged from all layers: `{ base_config = "true", kernel_version = "6.6.72", gcc_version = "14" }` + +### Template with Overlays + +Axis values can include any [ComponentConfig](components.md#component-config) fields, including overlays: + +```toml +[component-templates.my-driver] + +[component-templates.my-driver.default-component-config] +spec = { type = "local", path = "my-driver.spec" } + +[[component-templates.my-driver.matrix]] +axis = "kernel" + +[component-templates.my-driver.matrix.values.6-6] + +[[component-templates.my-driver.matrix.values.6-6.overlays]] +type = "spec-set-tag" +description = "Set kernel version to 6.6" +tag = "kernel_version" +value = "6.6.72" + +[component-templates.my-driver.matrix.values.6-12] + +[[component-templates.my-driver.matrix.values.6-12.overlays]] +type = "spec-set-tag" +description = "Set kernel version to 6.12" +tag = "kernel_version" +value = "6.12.8" +``` + +### Mixing Templates with Regular Components + +Templates and regular components coexist in the same config files: + +```toml +# Regular component +[components.curl] + +# Template-expanded components +[component-templates.my-driver] + +[[component-templates.my-driver.matrix]] +axis = "kernel" +[component-templates.my-driver.matrix.values.6-6] +[component-templates.my-driver.matrix.values.6-12] +``` + +This produces 3 components: `curl`, `my-driver-6-6`, and `my-driver-6-12`. + +## Related Resources + +- [Components](components.md) — component configuration reference +- [Component Groups](component-groups.md) — grouping components with shared defaults +- [Config File Structure](config-file.md) — top-level config file layout +- [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/components.md b/docs/user/reference/config/components.md index c9aef9d0..022804e5 100644 --- a/docs/user/reference/config/components.md +++ b/docs/user/reference/config/components.md @@ -367,6 +367,7 @@ 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 +- [Component Templates](component-templates.md) — generating multiple component variants from a matrix - [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/config-file.md b/docs/user/reference/config/config-file.md index 1f254e8e..eff1e1df 100644 --- a/docs/user/reference/config/config-file.md +++ b/docs/user/reference/config/config-file.md @@ -13,6 +13,7 @@ All config files share the same schema — there is no distinction between a "ro | `distros` | map of objects | Distro definitions (build environments, upstream sources) | [Distros](distros.md) | | `components` | map of objects | Component (package) definitions | [Components](components.md) | | `component-groups` | map of objects | Named groups of components with shared defaults | [Component Groups](component-groups.md) | +| `component-templates` | map of objects | Component templates that expand into multiple components via matrix axes | [Component Templates](component-templates.md) | | `images` | map of objects | Image definitions (VMs, containers) | [Images](images.md) | | `tools` | object | Configuration for external tools used by azldev | [Tools](tools.md) | diff --git a/internal/projectconfig/componenttemplate.go b/internal/projectconfig/componenttemplate.go new file mode 100644 index 00000000..5c486bb7 --- /dev/null +++ b/internal/projectconfig/componenttemplate.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig + +import ( + "errors" + "fmt" + + "github.com/brunoga/deep" +) + +// ComponentTemplateConfig defines a component template that produces multiple component variants +// via a matrix of axes. Each axis contributes named values; the template expands into the +// cartesian product of all axis values, yielding one [ComponentConfig] per combination. +type ComponentTemplateConfig struct { + // A human-friendly description of this component template. + Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Description of this component template"` + + // Default configuration applied to every expanded component before axis-specific overrides. + DefaultComponentConfig ComponentConfig `toml:"default-component-config,omitempty" json:"defaultComponentConfig,omitempty" jsonschema:"title=Default component configuration,description=Default component config applied to every expanded variant before axis overrides"` + + // Ordered list of matrix axes. Each axis defines a dimension with named values; + // the cartesian product of all axes determines the set of expanded components. + // Axis configs are applied in array order, so later axes override earlier ones. + Matrix []MatrixAxis `toml:"matrix" json:"matrix" validate:"required,min=1,dive" jsonschema:"required,minItems=1,title=Matrix axes,description=Ordered list of matrix axes whose cartesian product defines the expanded component variants"` + + // Internal: name assigned during loading (not serialized). + name string `toml:"-" json:"-"` + + // Internal: reference to the source config file (not serialized). + sourceConfigFile *ConfigFile `toml:"-" json:"-"` +} + +// MatrixAxis defines a single dimension of a component template's matrix. +// It has a name (the axis identifier) and a map of named values, each containing +// a partial [ComponentConfig] that is merged into the expanded component. +type MatrixAxis struct { + // Name of this axis (e.g., "kernel", "toolchain"). + Axis string `toml:"axis" json:"axis" validate:"required" jsonschema:"required,title=Axis name,description=Name of this matrix axis (e.g. kernel or toolchain)"` + + // Named values for this axis. Each key is a value name that appears in the + // synthesized component name; each value is a partial [ComponentConfig] merged + // into the expanded component. + Values map[string]ComponentConfig `toml:"values" json:"values" validate:"required,min=1,dive" jsonschema:"required,minProperties=1,title=Axis values,description=Named values for this axis; each value is a partial ComponentConfig merged into the expanded component"` +} + +// Validate checks that the component template configuration is internally consistent. +func (t ComponentTemplateConfig) Validate() error { + if len(t.Matrix) == 0 { + return errors.New("component template must have at least one matrix axis") + } + + seenAxes := make(map[string]struct{}, len(t.Matrix)) + + for i, axis := range t.Matrix { + if axis.Axis == "" { + return fmt.Errorf("matrix axis %d has an empty axis name", i+1) + } + + if _, seen := seenAxes[axis.Axis]; seen { + return fmt.Errorf("duplicate matrix axis name %#q", axis.Axis) + } + + seenAxes[axis.Axis] = struct{}{} + + if len(axis.Values) == 0 { + return fmt.Errorf("matrix axis %#q must have at least one value", axis.Axis) + } + + for valueName := range axis.Values { + if valueName == "" { + return fmt.Errorf("matrix axis %#q has an empty value name", axis.Axis) + } + } + } + + return nil +} + +// WithAbsolutePaths returns a copy of the component template config with relative file paths +// converted to absolute file paths (relative to referenceDir). +func (t ComponentTemplateConfig) WithAbsolutePaths(referenceDir string) ComponentTemplateConfig { + result := deep.MustCopy(t) + + result.DefaultComponentConfig = *(result.DefaultComponentConfig.WithAbsolutePaths(referenceDir)) + + for i, axis := range result.Matrix { + for valueName, valueCfg := range axis.Values { + result.Matrix[i].Values[valueName] = *(valueCfg.WithAbsolutePaths(referenceDir)) + } + } + + return result +} diff --git a/internal/projectconfig/componenttemplate_test.go b/internal/projectconfig/componenttemplate_test.go new file mode 100644 index 00000000..26d61b3a --- /dev/null +++ b/internal/projectconfig/componenttemplate_test.go @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//nolint:testpackage // Allow to test private functions +package projectconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestComponentTemplateConfig_Validate_Valid(t *testing.T) { + tmpl := ComponentTemplateConfig{ + Matrix: []MatrixAxis{ + { + Axis: "kernel", + Values: map[string]ComponentConfig{ + "6-6": {}, + }, + }, + }, + } + + err := tmpl.Validate() + assert.NoError(t, err) +} + +func TestComponentTemplateConfig_Validate_MultiAxis(t *testing.T) { + tmpl := ComponentTemplateConfig{ + Matrix: []MatrixAxis{ + { + Axis: "kernel", + Values: map[string]ComponentConfig{ + "6-6": {}, + "6-12": {}, + }, + }, + { + Axis: "toolchain", + Values: map[string]ComponentConfig{ + "gcc13": {}, + "gcc14": {}, + }, + }, + }, + } + + err := tmpl.Validate() + assert.NoError(t, err) +} + +func TestComponentTemplateConfig_Validate_EmptyMatrix(t *testing.T) { + tmpl := ComponentTemplateConfig{ + Matrix: []MatrixAxis{}, + } + + err := tmpl.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one matrix axis") +} + +func TestComponentTemplateConfig_Validate_NilMatrix(t *testing.T) { + tmpl := ComponentTemplateConfig{} + + err := tmpl.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one matrix axis") +} + +func TestComponentTemplateConfig_Validate_EmptyAxisName(t *testing.T) { + tmpl := ComponentTemplateConfig{ + Matrix: []MatrixAxis{ + { + Axis: "", + Values: map[string]ComponentConfig{"v1": {}}, + }, + }, + } + + err := tmpl.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "empty axis name") +} + +func TestComponentTemplateConfig_Validate_DuplicateAxisName(t *testing.T) { + tmpl := ComponentTemplateConfig{ + Matrix: []MatrixAxis{ + { + Axis: "kernel", + Values: map[string]ComponentConfig{"6-6": {}}, + }, + { + Axis: "kernel", + Values: map[string]ComponentConfig{"6-12": {}}, + }, + }, + } + + err := tmpl.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate matrix axis name") +} + +func TestComponentTemplateConfig_Validate_EmptyValues(t *testing.T) { + tmpl := ComponentTemplateConfig{ + Matrix: []MatrixAxis{ + { + Axis: "kernel", + Values: map[string]ComponentConfig{}, + }, + }, + } + + err := tmpl.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one value") +} + +func TestComponentTemplateConfig_Validate_EmptyValueName(t *testing.T) { + tmpl := ComponentTemplateConfig{ + Matrix: []MatrixAxis{ + { + Axis: "kernel", + Values: map[string]ComponentConfig{"": {}}, + }, + }, + } + + err := tmpl.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "empty value name") +} + +func TestComponentTemplateConfig_WithAbsolutePaths(t *testing.T) { + tmpl := ComponentTemplateConfig{ + DefaultComponentConfig: ComponentConfig{ + Spec: SpecSource{ + SourceType: SpecSourceTypeLocal, + Path: "my-driver.spec", + }, + }, + Matrix: []MatrixAxis{ + { + Axis: "kernel", + Values: map[string]ComponentConfig{ + "6-6": { + Spec: SpecSource{ + SourceType: SpecSourceTypeLocal, + Path: "override.spec", + }, + }, + }, + }, + }, + } + + result := tmpl.WithAbsolutePaths("/project") + + assert.Equal(t, "/project/my-driver.spec", result.DefaultComponentConfig.Spec.Path) + + if assert.Contains(t, result.Matrix[0].Values, "6-6") { + assert.Equal(t, "/project/override.spec", result.Matrix[0].Values["6-6"].Spec.Path) + } + + // Verify original was not mutated. + assert.Equal(t, "my-driver.spec", tmpl.DefaultComponentConfig.Spec.Path) +} diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index 5afe001e..496b38a0 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -38,6 +38,9 @@ type ConfigFile struct { // Definitions of components. Components map[string]ComponentConfig `toml:"components,omitempty" validate:"dive" jsonschema:"title=Components,description=Definitions of components for this project"` + // Definitions of component templates that expand into multiple components via a matrix. + ComponentTemplates map[string]ComponentTemplateConfig `toml:"component-templates,omitempty" validate:"dive" jsonschema:"title=Component templates,description=Definitions of component templates that expand into multiple components via matrix axes"` + // Definitions of images. Images map[string]ImageConfig `toml:"images,omitempty" validate:"dive" jsonschema:"title=Images,description=Definitions of images for this project"` @@ -83,6 +86,13 @@ func (f ConfigFile) Validate() error { } } + // Validate component template configurations. + for templateName, tmpl := range f.ComponentTemplates { + if err := tmpl.Validate(); err != nil { + return fmt.Errorf("invalid component template %#q:\n%w", templateName, 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 270d0918..af7d4357 100644 --- a/internal/projectconfig/loader.go +++ b/internal/projectconfig/loader.go @@ -10,6 +10,7 @@ import ( "os" "path" "path/filepath" + "sort" "strings" "github.com/bmatcuk/doublestar/v4" @@ -23,6 +24,8 @@ var ( ErrDuplicateComponents = errors.New("duplicate component") // ErrDuplicateComponentGroups is returned when duplicate conflicting component group definitions are found. ErrDuplicateComponentGroups = errors.New("duplicate component group") + // ErrDuplicateComponentTemplates is returned when duplicate conflicting component template definitions are found. + ErrDuplicateComponentTemplates = errors.New("duplicate component template") // 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. @@ -111,6 +114,10 @@ func mergeConfigFile(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return err } + if err := mergeComponentTemplates(resolvedCfg, loadedCfg); err != nil { + return err + } + if err := mergeImages(resolvedCfg, loadedCfg); err != nil { return err } @@ -193,6 +200,53 @@ func mergeComponents(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return nil } +// mergeComponentTemplates expands component template definitions from a loaded config file +// and merges the resulting components into the resolved config. Duplicate template names +// across config files are not allowed. Expanded component names must not collide with +// existing components. +func mergeComponentTemplates(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + // Sort template names for deterministic expansion order. + templateNames := make([]string, 0, len(loadedCfg.ComponentTemplates)) + for name := range loadedCfg.ComponentTemplates { + templateNames = append(templateNames, name) + } + + sort.Strings(templateNames) + + for _, templateName := range templateNames { + tmpl := loadedCfg.ComponentTemplates[templateName] + + // Fill out internal fields. + tmpl.name = templateName + tmpl.sourceConfigFile = loadedCfg + + // Resolve paths. + resolvedTmpl := tmpl.WithAbsolutePaths(loadedCfg.dir) + resolvedTmpl.name = templateName + resolvedTmpl.sourceConfigFile = loadedCfg + + // Expand the template into its cartesian product of components. + expandedComponents, err := expandComponentTemplate(templateName, &resolvedTmpl) + if err != nil { + return fmt.Errorf("failed to expand component template %#q:\n%w", templateName, err) + } + + // Merge expanded components into the resolved config, checking for duplicates. + for componentName, component := range expandedComponents { + if _, ok := resolvedCfg.Components[componentName]; ok { + return fmt.Errorf( + "%w: component template %#q expands to %#q which already exists", + ErrDuplicateComponents, templateName, componentName, + ) + } + + resolvedCfg.Components[componentName] = component + } + } + + return nil +} + // mergeImages merges image definitions from a loaded config file into the // resolved config. Duplicate image names are not allowed. func mergeImages(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { diff --git a/internal/projectconfig/templateexpansion.go b/internal/projectconfig/templateexpansion.go new file mode 100644 index 00000000..85e02e5b --- /dev/null +++ b/internal/projectconfig/templateexpansion.go @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig + +import ( + "fmt" + "sort" + "strings" + + "github.com/brunoga/deep" +) + +// expandComponentTemplate expands a single component template into its cartesian product of +// components. Each expanded component is created by layering the template's default config with +// the selected axis value configs in matrix definition order. +func expandComponentTemplate( + templateName string, tmpl *ComponentTemplateConfig, +) (map[string]ComponentConfig, error) { + // Build the cartesian product of all axis values. + combinations := cartesianProduct(tmpl.Matrix) + + result := make(map[string]ComponentConfig, len(combinations)) + + for _, combo := range combinations { + // Build the synthesized component name. + nameParts := make([]string, 0, len(combo)+1) + nameParts = append(nameParts, templateName) + + for _, selection := range combo { + nameParts = append(nameParts, selection.valueName) + } + + componentName := strings.Join(nameParts, "-") + + // Start from a deep copy of the template's default component config. + expanded := deep.MustCopy(tmpl.DefaultComponentConfig) + + // Layer each axis value config in matrix definition order. + for _, selection := range combo { + valueCfg := deep.MustCopy(selection.valueCfg) + + if err := expanded.MergeUpdatesFrom(&valueCfg); err != nil { + return nil, fmt.Errorf( + "failed to merge axis %#q value %#q into expanded component %#q:\n%w", + selection.axisName, selection.valueName, componentName, err, + ) + } + } + + // Set the component's identity fields. + expanded.Name = componentName + expanded.SourceConfigFile = tmpl.sourceConfigFile + + // Check for duplicate names within the same template expansion. + if _, exists := result[componentName]; exists { + return nil, fmt.Errorf( + "component template %#q produces duplicate expanded component name %#q", + templateName, componentName, + ) + } + + result[componentName] = expanded + } + + return result, nil +} + +// axisSelection represents a single axis value chosen for one slot in the cartesian product. +type axisSelection struct { + axisName string + valueName string + valueCfg ComponentConfig +} + +// cartesianProduct generates all combinations of axis values across the given matrix axes. +// Each returned slice is one combination, with entries in the same order as the input axes. +func cartesianProduct(axes []MatrixAxis) [][]axisSelection { + if len(axes) == 0 { + return nil + } + + // Start with a single empty combination. + combinations := [][]axisSelection{{}} + + for _, axis := range axes { + // Sort value names for deterministic expansion order within each axis. + valueNames := make([]string, 0, len(axis.Values)) + for name := range axis.Values { + valueNames = append(valueNames, name) + } + + sort.Strings(valueNames) + + var newCombinations [][]axisSelection + + for _, combo := range combinations { + for _, valueName := range valueNames { + // Extend the existing combination with this axis value. + extended := make([]axisSelection, len(combo), len(combo)+1) + copy(extended, combo) + + extended = append(extended, axisSelection{ + axisName: axis.Axis, + valueName: valueName, + valueCfg: axis.Values[valueName], + }) + + newCombinations = append(newCombinations, extended) + } + } + + combinations = newCombinations + } + + return combinations +} diff --git a/internal/projectconfig/templateexpansion_test.go b/internal/projectconfig/templateexpansion_test.go new file mode 100644 index 00000000..746d9071 --- /dev/null +++ b/internal/projectconfig/templateexpansion_test.go @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//nolint:testpackage // Allow to test private functions +package projectconfig + +import ( + "path/filepath" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/global/testctx" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExpandComponentTemplate_SingleAxis(t *testing.T) { + tmpl := ComponentTemplateConfig{ + DefaultComponentConfig: ComponentConfig{ + Build: ComponentBuildConfig{ + Defines: map[string]string{"base": "true"}, + }, + }, + Matrix: []MatrixAxis{ + { + Axis: "kernel", + Values: map[string]ComponentConfig{ + "6-6": { + Build: ComponentBuildConfig{ + Defines: map[string]string{"kernel_version": "6.6.72"}, + }, + }, + "6-12": { + Build: ComponentBuildConfig{ + Defines: map[string]string{"kernel_version": "6.12.8"}, + }, + }, + }, + }, + }, + } + + result, err := expandComponentTemplate("my-driver", &tmpl) + require.NoError(t, err) + assert.Len(t, result, 2) + + // Check both components exist with expected names. + assert.Contains(t, result, "my-driver-6-12") + assert.Contains(t, result, "my-driver-6-6") + + // Verify config layering: base + axis value. + comp66 := result["my-driver-6-6"] + assert.Equal(t, "my-driver-6-6", comp66.Name) + assert.Equal(t, "true", comp66.Build.Defines["base"]) + assert.Equal(t, "6.6.72", comp66.Build.Defines["kernel_version"]) + + comp612 := result["my-driver-6-12"] + assert.Equal(t, "my-driver-6-12", comp612.Name) + assert.Equal(t, "true", comp612.Build.Defines["base"]) + assert.Equal(t, "6.12.8", comp612.Build.Defines["kernel_version"]) +} + +func TestExpandComponentTemplate_TwoAxes(t *testing.T) { + tmpl := ComponentTemplateConfig{ + DefaultComponentConfig: ComponentConfig{ + Spec: SpecSource{ + SourceType: SpecSourceTypeLocal, + Path: "/specs/my-driver.spec", + }, + }, + Matrix: []MatrixAxis{ + { + Axis: "kernel", + Values: map[string]ComponentConfig{ + "6-6": { + Build: ComponentBuildConfig{ + Defines: map[string]string{"kernel_version": "6.6.72"}, + }, + }, + "6-12": { + Build: ComponentBuildConfig{ + Defines: map[string]string{"kernel_version": "6.12.8"}, + }, + }, + }, + }, + { + Axis: "toolchain", + Values: map[string]ComponentConfig{ + "gcc13": { + Build: ComponentBuildConfig{ + Defines: map[string]string{"gcc_version": "13"}, + }, + }, + "gcc14": { + Build: ComponentBuildConfig{ + Defines: map[string]string{"gcc_version": "14"}, + }, + }, + }, + }, + }, + } + + result, err := expandComponentTemplate("my-driver", &tmpl) + require.NoError(t, err) + assert.Len(t, result, 4) + + // Verify all 4 cartesian product entries exist. + expectedNames := []string{ + "my-driver-6-12-gcc13", + "my-driver-6-12-gcc14", + "my-driver-6-6-gcc13", + "my-driver-6-6-gcc14", + } + + for _, name := range expectedNames { + assert.Contains(t, result, name) + } + + // Verify one component has all layers applied. + comp := result["my-driver-6-6-gcc14"] + assert.Equal(t, "my-driver-6-6-gcc14", comp.Name) + assert.Equal(t, SpecSourceTypeLocal, comp.Spec.SourceType) + assert.Equal(t, "/specs/my-driver.spec", comp.Spec.Path) + assert.Equal(t, "6.6.72", comp.Build.Defines["kernel_version"]) + assert.Equal(t, "14", comp.Build.Defines["gcc_version"]) +} + +func TestExpandComponentTemplate_ThreeAxes(t *testing.T) { + tmpl := ComponentTemplateConfig{ + Matrix: []MatrixAxis{ + { + Axis: "a", + Values: map[string]ComponentConfig{ + "a1": {}, + "a2": {}, + }, + }, + { + Axis: "b", + Values: map[string]ComponentConfig{ + "b1": {}, + "b2": {}, + }, + }, + { + Axis: "c", + Values: map[string]ComponentConfig{ + "c1": {}, + "c2": {}, + "c3": {}, + }, + }, + }, + } + + result, err := expandComponentTemplate("test", &tmpl) + require.NoError(t, err) + + // 2 * 2 * 3 = 12 combinations. + assert.Len(t, result, 12) + + // Verify a few specific names. + assert.Contains(t, result, "test-a1-b1-c1") + assert.Contains(t, result, "test-a2-b2-c3") +} + +func TestExpandComponentTemplate_LaterAxisOverridesEarlier(t *testing.T) { + tmpl := ComponentTemplateConfig{ + DefaultComponentConfig: ComponentConfig{ + Build: ComponentBuildConfig{ + Defines: map[string]string{"shared": "default"}, + }, + }, + Matrix: []MatrixAxis{ + { + Axis: "first", + Values: map[string]ComponentConfig{ + "f1": { + Build: ComponentBuildConfig{ + Defines: map[string]string{"shared": "from-first"}, + }, + }, + }, + }, + { + Axis: "second", + Values: map[string]ComponentConfig{ + "s1": { + Build: ComponentBuildConfig{ + Defines: map[string]string{"shared": "from-second"}, + }, + }, + }, + }, + }, + } + + result, err := expandComponentTemplate("test", &tmpl) + require.NoError(t, err) + require.Len(t, result, 1) + + comp := result["test-f1-s1"] + // Later axis ("second") should override earlier axis ("first"). + assert.Equal(t, "from-second", comp.Build.Defines["shared"]) +} + +func TestExpandComponentTemplate_SourceConfigFilePropagated(t *testing.T) { + configFile := &ConfigFile{sourcePath: "/project/azldev.toml"} + + tmpl := ComponentTemplateConfig{ + sourceConfigFile: configFile, + Matrix: []MatrixAxis{ + { + Axis: "kernel", + Values: map[string]ComponentConfig{ + "6-6": {}, + }, + }, + }, + } + + result, err := expandComponentTemplate("my-driver", &tmpl) + require.NoError(t, err) + + comp := result["my-driver-6-6"] + assert.Same(t, configFile, comp.SourceConfigFile) +} + +func TestCartesianProduct_Empty(t *testing.T) { + result := cartesianProduct(nil) + assert.Nil(t, result) + + result = cartesianProduct([]MatrixAxis{}) + assert.Nil(t, result) +} + +func TestCartesianProduct_SingleAxisSingleValue(t *testing.T) { + axes := []MatrixAxis{ + { + Axis: "a", + Values: map[string]ComponentConfig{ + "v1": {}, + }, + }, + } + + result := cartesianProduct(axes) + require.Len(t, result, 1) + require.Len(t, result[0], 1) + assert.Equal(t, "a", result[0][0].axisName) + assert.Equal(t, "v1", result[0][0].valueName) +} + +// Test full pipeline integration: template expansion through loadAndResolveProjectConfig. +func TestLoadAndResolveProjectConfig_ComponentTemplate(t *testing.T) { + const configContents = ` +[component-templates.my-driver] +description = "Test template" + +[component-templates.my-driver.default-component-config] +spec = { type = "local", path = "my-driver.spec" } + +[[component-templates.my-driver.matrix]] +axis = "kernel" +[component-templates.my-driver.matrix.values.6-6] +[component-templates.my-driver.matrix.values.6-12] + +[[component-templates.my-driver.matrix]] +axis = "toolchain" +[component-templates.my-driver.matrix.values.gcc13] +[component-templates.my-driver.matrix.values.gcc14] +` + + 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) + + // Should have 4 expanded components. + assert.Len(t, config.Components, 4) + assert.Contains(t, config.Components, "my-driver-6-6-gcc13") + assert.Contains(t, config.Components, "my-driver-6-6-gcc14") + assert.Contains(t, config.Components, "my-driver-6-12-gcc13") + assert.Contains(t, config.Components, "my-driver-6-12-gcc14") + + // Verify spec path is resolved relative to the config dir. + comp := config.Components["my-driver-6-6-gcc13"] + assert.Equal(t, SpecSourceTypeLocal, comp.Spec.SourceType) + assert.Equal(t, filepath.Join(filepath.Dir(testConfigPath), "my-driver.spec"), comp.Spec.Path) +} + +// Test that a template expansion colliding with an explicit component produces an error. +func TestLoadAndResolveProjectConfig_ComponentTemplate_CollisionWithExplicit(t *testing.T) { + const configContents = ` +[components.my-driver-6-6] + +[component-templates.my-driver] + +[[component-templates.my-driver.matrix]] +axis = "kernel" +[component-templates.my-driver.matrix.values.6-6] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.ErrorIs(t, err, ErrDuplicateComponents) + assert.Nil(t, config) +} + +// Test that template with build defines are layered correctly. +func TestLoadAndResolveProjectConfig_ComponentTemplate_WithBuildDefines(t *testing.T) { + const configContents = ` +[component-templates.my-driver] + +[component-templates.my-driver.default-component-config.build] +defines = { base_macro = "base_value" } + +[[component-templates.my-driver.matrix]] +axis = "kernel" +[component-templates.my-driver.matrix.values.6-6.build] +defines = { kernel_version = "6.6.72" } + +[[component-templates.my-driver.matrix]] +axis = "toolchain" +[component-templates.my-driver.matrix.values.gcc14.build] +defines = { gcc_version = "14" } +` + + 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.Contains(t, config.Components, "my-driver-6-6-gcc14") + + comp := config.Components["my-driver-6-6-gcc14"] + assert.Equal(t, "base_value", comp.Build.Defines["base_macro"]) + assert.Equal(t, "6.6.72", comp.Build.Defines["kernel_version"]) + assert.Equal(t, "14", comp.Build.Defines["gcc_version"]) +} + +// Test that a template with an empty matrix fails validation. +func TestLoadAndResolveProjectConfig_ComponentTemplate_EmptyMatrix(t *testing.T) { + const configContents = ` +[component-templates.my-driver] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + config, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.Error(t, err) + assert.Nil(t, config) +} + +// Test that templates and regular components coexist. +func TestLoadAndResolveProjectConfig_ComponentTemplate_MixedWithComponents(t *testing.T) { + const configContents = ` +[components.curl] + +[component-templates.my-driver] + +[[component-templates.my-driver.matrix]] +axis = "kernel" +[component-templates.my-driver.matrix.values.6-6] +[component-templates.my-driver.matrix.values.6-12] +` + + 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) + + // 1 explicit component + 2 expanded from template. + assert.Len(t, config.Components, 3) + assert.Contains(t, config.Components, "curl") + assert.Contains(t, config.Components, "my-driver-6-6") + assert.Contains(t, config.Components, "my-driver-6-12") +} diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 4418dea4..205ecd9f 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -275,6 +275,34 @@ "type" ] }, + "ComponentTemplateConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Description of this component template" + }, + "default-component-config": { + "$ref": "#/$defs/ComponentConfig", + "title": "Default component configuration", + "description": "Default component config applied to every expanded variant before axis overrides" + }, + "matrix": { + "items": { + "$ref": "#/$defs/MatrixAxis" + }, + "type": "array", + "minItems": 1, + "title": "Matrix axes", + "description": "Ordered list of matrix axes whose cartesian product defines the expanded component variants" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "matrix" + ] + }, "ConfigFile": { "properties": { "$schema": { @@ -317,6 +345,14 @@ "title": "Components", "description": "Definitions of components for this project" }, + "component-templates": { + "additionalProperties": { + "$ref": "#/$defs/ComponentTemplateConfig" + }, + "type": "object", + "title": "Component templates", + "description": "Definitions of component templates that expand into multiple components via matrix axes" + }, "images": { "additionalProperties": { "$ref": "#/$defs/ImageConfig" @@ -513,6 +549,29 @@ "additionalProperties": false, "type": "object" }, + "MatrixAxis": { + "properties": { + "axis": { + "type": "string", + "title": "Axis name", + "description": "Name of this matrix axis (e.g. kernel or toolchain)" + }, + "values": { + "additionalProperties": { + "$ref": "#/$defs/ComponentConfig" + }, + "type": "object", + "title": "Axis values", + "description": "Named values for this axis; each value is a partial ComponentConfig merged into the expanded component" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "axis", + "values" + ] + }, "Origin": { "properties": { "type": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 4418dea4..205ecd9f 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -275,6 +275,34 @@ "type" ] }, + "ComponentTemplateConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Description of this component template" + }, + "default-component-config": { + "$ref": "#/$defs/ComponentConfig", + "title": "Default component configuration", + "description": "Default component config applied to every expanded variant before axis overrides" + }, + "matrix": { + "items": { + "$ref": "#/$defs/MatrixAxis" + }, + "type": "array", + "minItems": 1, + "title": "Matrix axes", + "description": "Ordered list of matrix axes whose cartesian product defines the expanded component variants" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "matrix" + ] + }, "ConfigFile": { "properties": { "$schema": { @@ -317,6 +345,14 @@ "title": "Components", "description": "Definitions of components for this project" }, + "component-templates": { + "additionalProperties": { + "$ref": "#/$defs/ComponentTemplateConfig" + }, + "type": "object", + "title": "Component templates", + "description": "Definitions of component templates that expand into multiple components via matrix axes" + }, "images": { "additionalProperties": { "$ref": "#/$defs/ImageConfig" @@ -513,6 +549,29 @@ "additionalProperties": false, "type": "object" }, + "MatrixAxis": { + "properties": { + "axis": { + "type": "string", + "title": "Axis name", + "description": "Name of this matrix axis (e.g. kernel or toolchain)" + }, + "values": { + "additionalProperties": { + "$ref": "#/$defs/ComponentConfig" + }, + "type": "object", + "title": "Axis values", + "description": "Named values for this axis; each value is a partial ComponentConfig merged into the expanded component" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "axis", + "values" + ] + }, "Origin": { "properties": { "type": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 4418dea4..205ecd9f 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -275,6 +275,34 @@ "type" ] }, + "ComponentTemplateConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Description of this component template" + }, + "default-component-config": { + "$ref": "#/$defs/ComponentConfig", + "title": "Default component configuration", + "description": "Default component config applied to every expanded variant before axis overrides" + }, + "matrix": { + "items": { + "$ref": "#/$defs/MatrixAxis" + }, + "type": "array", + "minItems": 1, + "title": "Matrix axes", + "description": "Ordered list of matrix axes whose cartesian product defines the expanded component variants" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "matrix" + ] + }, "ConfigFile": { "properties": { "$schema": { @@ -317,6 +345,14 @@ "title": "Components", "description": "Definitions of components for this project" }, + "component-templates": { + "additionalProperties": { + "$ref": "#/$defs/ComponentTemplateConfig" + }, + "type": "object", + "title": "Component templates", + "description": "Definitions of component templates that expand into multiple components via matrix axes" + }, "images": { "additionalProperties": { "$ref": "#/$defs/ImageConfig" @@ -513,6 +549,29 @@ "additionalProperties": false, "type": "object" }, + "MatrixAxis": { + "properties": { + "axis": { + "type": "string", + "title": "Axis name", + "description": "Name of this matrix axis (e.g. kernel or toolchain)" + }, + "values": { + "additionalProperties": { + "$ref": "#/$defs/ComponentConfig" + }, + "type": "object", + "title": "Axis values", + "description": "Named values for this axis; each value is a partial ComponentConfig merged into the expanded component" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "axis", + "values" + ] + }, "Origin": { "properties": { "type": {