diff --git a/toolkit/tools/imagecustomizerapi/config.go b/toolkit/tools/imagecustomizerapi/config.go index 545d27764..564833eaf 100644 --- a/toolkit/tools/imagecustomizerapi/config.go +++ b/toolkit/tools/imagecustomizerapi/config.go @@ -19,6 +19,7 @@ type Config struct { Scripts Scripts `yaml:"scripts" json:"scripts,omitempty"` PreviewFeatures []PreviewFeature `yaml:"previewFeatures" json:"previewFeatures,omitempty"` Output Output `yaml:"output" json:"output,omitempty"` + BaseConfigs []BaseConfig `yaml:"baseConfigs" json:"baseConfigs,omitempty"` } func (c *Config) IsValid() (err error) { @@ -123,6 +124,18 @@ func (c *Config) IsValid() (err error) { return fmt.Errorf("the 'reinitialize-verity' preview feature must be enabled to use 'storage.reinitializeVerity'") } + if c.BaseConfigs != nil { + if !sliceutils.ContainsValue(c.PreviewFeatures, PreviewFeatureBaseConfigs) { + return fmt.Errorf("the '%s' preview feature must be enabled to use 'baseConfigs'", PreviewFeatureBaseConfigs) + } + + for i, base := range c.BaseConfigs { + if err := base.IsValid(); err != nil { + return fmt.Errorf("invalid baseConfig item at index %d:\n%w", i, err) + } + } + } + return nil } diff --git a/toolkit/tools/imagecustomizerapi/previewfeaturetype.go b/toolkit/tools/imagecustomizerapi/previewfeaturetype.go index b7ceb5736..ade32f4d6 100644 --- a/toolkit/tools/imagecustomizerapi/previewfeaturetype.go +++ b/toolkit/tools/imagecustomizerapi/previewfeaturetype.go @@ -28,12 +28,15 @@ const ( // PreviewFeatureFedora42 enables support for Fedora 42 images. PreviewFeatureFedora42 PreviewFeature = "fedora-42" + + // PreviewFeatureBaseConfigs enables support for base configuration. + PreviewFeatureBaseConfigs PreviewFeature = "base-configs" ) func (pf PreviewFeature) IsValid() error { switch pf { case PreviewFeatureUki, PreviewFeatureOutputArtifacts, PreviewFeatureInjectFiles, PreviewFeaturePackageSnapshotTime, - PreviewFeatureKdumpBootFiles, PreviewFeatureFedora42: + PreviewFeatureKdumpBootFiles, PreviewFeatureFedora42, PreviewFeatureBaseConfigs: return nil default: return fmt.Errorf("invalid preview feature: %s", pf) diff --git a/toolkit/tools/imagecustomizerapi/schema.json b/toolkit/tools/imagecustomizerapi/schema.json index b036ff5b2..f72af09e3 100644 --- a/toolkit/tools/imagecustomizerapi/schema.json +++ b/toolkit/tools/imagecustomizerapi/schema.json @@ -45,6 +45,15 @@ "additionalProperties": false, "type": "object" }, + "BaseConfig": { + "properties": { + "path": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, "BootLoader": { "properties": { "resetType": { @@ -82,6 +91,12 @@ }, "output": { "$ref": "#/$defs/Output" + }, + "baseConfigs": { + "items": { + "$ref": "#/$defs/BaseConfig" + }, + "type": "array" } }, "additionalProperties": false, diff --git a/toolkit/tools/pkg/imagecustomizerlib/baseconfigs.go b/toolkit/tools/pkg/imagecustomizerlib/baseconfigs.go new file mode 100644 index 000000000..a8cfefc71 --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/baseconfigs.go @@ -0,0 +1,127 @@ +package imagecustomizerlib + +import ( + "fmt" + + "path/filepath" + + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagecustomizerapi" +) + +func resolveBaseConfigs(cfg *imagecustomizerapi.Config, baseDir string) (*ResolvedConfig, error) { + visited := make(map[string]bool) + pathStack := []string{} + + configChain, err := BuildInheritanceChain(cfg, baseDir, visited, pathStack) + if err != nil { + return nil, err + } + + resolved := NewResolvedConfig(configChain) + + return resolved, nil +} + +func BuildInheritanceChain(cfg *imagecustomizerapi.Config, baseDir string, visited map[string]bool, pathStack []string) ([]*imagecustomizerapi.Config, error) { + if cfg.BaseConfigs != nil { + for i, base := range cfg.BaseConfigs { + if err := base.IsValid(); err != nil { + return nil, fmt.Errorf("invalid baseConfig item at index %d:\n%w", i, err) + } + } + } + + var chain []*imagecustomizerapi.Config + + for _, base := range cfg.BaseConfigs { + basePath := filepath.Join(baseDir, base.Path) + absPath, err := filepath.Abs(basePath) + if err != nil { + return nil, err + } + + if visited[absPath] { + return nil, fmt.Errorf("cycle detected in baseConfigs: %v -> %s", pathStack, absPath) + } + + visited[absPath] = true + pathStack = append(pathStack, absPath) + + // Load base file into struct + var baseCfg imagecustomizerapi.Config + if err := imagecustomizerapi.UnmarshalYamlFile(absPath, &baseCfg); err != nil { + return nil, fmt.Errorf("failed to load base config %s: %w", absPath, err) + } + + // Recurse into base config + subChain, err := BuildInheritanceChain(&baseCfg, filepath.Dir(absPath), visited, pathStack) + if err != nil { + return nil, err + } + + chain = append(chain, subChain...) + } + + // Add the current config at the end + chain = append(chain, cfg) + + return chain, nil +} + +func resolveOverrideFields(chain []*imagecustomizerapi.Config, target *ResolvedConfig) { + for _, config := range chain { + if config.Input.Image != (imagecustomizerapi.InputImage{}) && config.Input.Image.Path != "" { + // .input.image.path + target.InputImagePath = config.Input.Image.Path + } + + if config.Output != (imagecustomizerapi.Output{}) { + // .output.image.path + if config.Output.Image != (imagecustomizerapi.OutputImage{}) && config.Output.Image.Path != "" { + target.OutputImagePath = config.Output.Image.Path + } + // .output.image.format + if config.Output.Image != (imagecustomizerapi.OutputImage{}) && config.Output.Image.Format != "" { + target.OutputImageFormat = config.Output.Image.Format + } + // .output.image.artifacts.path + if config.Output.Artifacts != nil && config.Output.Artifacts.Path != "" { + target.OutputArtifactsPath = config.Output.Artifacts.Path + } + } + } +} + +func resolveMergeFields(chain []*imagecustomizerapi.Config, target *ResolvedConfig) { + for _, cfg := range chain { + // .output.artifacts.items + if cfg.Output != (imagecustomizerapi.Output{}) && cfg.Output.Artifacts != nil && len(cfg.Output.Artifacts.Items) > 0 { + target.OutputArtifactsItems = mergeOutputArtifactTypes( + target.OutputArtifactsItems, cfg.Output.Artifacts.Items, + ) + } + } +} + +func mergeOutputArtifactTypes(base, current []imagecustomizerapi.OutputArtifactsItemType) []imagecustomizerapi.OutputArtifactsItemType { + seen := make(map[imagecustomizerapi.OutputArtifactsItemType]bool) + var merged []imagecustomizerapi.OutputArtifactsItemType + + // Add base items first + for _, item := range base { + if !seen[item] { + merged = append(merged, item) + seen[item] = true + } + } + + // Add current items + for _, item := range current { + if !seen[item] { + merged = append(merged, item) + seen[item] = true + } + } + + return merged +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/baseconfigs_test.go b/toolkit/tools/pkg/imagecustomizerlib/baseconfigs_test.go new file mode 100644 index 000000000..ab0e7e859 --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/baseconfigs_test.go @@ -0,0 +1,98 @@ +package imagecustomizerlib_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagecustomizerapi" + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/pkg/imagecustomizerlib" +) + +func TestBaseConfigsInput(t *testing.T) { + base := &imagecustomizerapi.Config{ + Input: imagecustomizerapi.Input{ + Image: imagecustomizerapi.InputImage{ + Path: "base-image-1.vhdx", + }, + }, + } + + current := &imagecustomizerapi.Config{ + Input: imagecustomizerapi.Input{ + Image: imagecustomizerapi.InputImage{ + Path: "base-image-2.vhdx", + }, + }, + } + + chain := []*imagecustomizerapi.Config{base, current} + + resolved := imagecustomizerlib.NewResolvedConfig(chain) + + if resolved.InputImagePath != "base-image-2.vhdx" { + t.Errorf("expected input image path is base-image-2.vhdx, got %s", resolved.InputImagePath) + } +} + +func TestBaseConfigsOutput(t *testing.T) { + base := &imagecustomizerapi.Config{ + Output: imagecustomizerapi.Output{ + Image: imagecustomizerapi.OutputImage{ + Path: "output-image-1.vhdx", + }, + Artifacts: &imagecustomizerapi.Artifacts{ + Path: "./artifacts-1", + Items: []imagecustomizerapi.OutputArtifactsItemType{ + imagecustomizerapi.OutputArtifactsItemUkis, + }, + }, + }, + } + + current := &imagecustomizerapi.Config{ + Output: imagecustomizerapi.Output{ + Image: imagecustomizerapi.OutputImage{ + Path: "output-image-2.vhdx", + }, + Artifacts: &imagecustomizerapi.Artifacts{ + Path: "./artifacts-2", + Items: []imagecustomizerapi.OutputArtifactsItemType{ + imagecustomizerapi.OutputArtifactsItemShim, + }, + }, + }, + } + + chain := []*imagecustomizerapi.Config{base, current} + + resolved := imagecustomizerlib.NewResolvedConfig(chain) + + if resolved.OutputImagePath != "output-image-2.vhdx" { + t.Errorf("expected output path is output-image-2.vhdx, got %s", resolved.OutputImagePath) + } + + if resolved.OutputArtifactsPath != "./artifacts-2" { + t.Errorf("expected artifacts path is ./artifacts-2, got %s", resolved.OutputArtifactsPath) + } + + expectedItems := []imagecustomizerapi.OutputArtifactsItemType{ + imagecustomizerapi.OutputArtifactsItemUkis, + imagecustomizerapi.OutputArtifactsItemShim, + } + got := resolved.OutputArtifactsItems + if len(got) != len(expectedItems) { + t.Errorf("expected output artifacts length is %d, got %d", len(expectedItems), len(got)) + } + + for _, item := range expectedItems { + found := false + for _, g := range got { + if g == item { + found = true + break + } + } + if !found { + t.Errorf("expected artifact item %q not found in result: %v", item, got) + } + } +} diff --git a/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go b/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go index 304d1f220..ef9ed1a9d 100644 --- a/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go +++ b/toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go @@ -144,6 +144,9 @@ type ImageCustomizerParameters struct { osPackages []OsPackage cosiBootMetadata *CosiBootloader targetOS targetos.TargetOs + + // For future migration: fields to be read from resolvedConfig instead of config + resolvedConfig *ResolvedConfig } type verityDeviceMetadata struct { @@ -165,6 +168,18 @@ func createImageCustomizerParameters(ctx context.Context, configPath string, con ic := &ImageCustomizerParameters{} + if len(ic.config.BaseConfigs) > 0 { + resolvedConfig, err := resolveBaseConfigs(ic.config, ic.configPath) + if err != nil { + return nil, fmt.Errorf("failed to load base config:\n%w", err) + } + ic.config.Input.Image.Path = resolvedConfig.InputImagePath + ic.config.Output.Image.Path = resolvedConfig.OutputImagePath + ic.config.Output.Image.Format = resolvedConfig.OutputImageFormat + ic.config.Output.Artifacts.Path = resolvedConfig.OutputArtifactsPath + ic.config.Output.Artifacts.Items = resolvedConfig.OutputArtifactsItems + } + // working directories buildDirAbs, err := filepath.Abs(options.BuildDir) if err != nil { diff --git a/toolkit/tools/pkg/imagecustomizerlib/resolvedconfig.go b/toolkit/tools/pkg/imagecustomizerlib/resolvedconfig.go new file mode 100644 index 000000000..b63aa105a --- /dev/null +++ b/toolkit/tools/pkg/imagecustomizerlib/resolvedconfig.go @@ -0,0 +1,27 @@ +package imagecustomizerlib + +import ( + "github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagecustomizerapi" +) + +// ResolvedConfig represents resolved fields and a chain of base configs. +type ResolvedConfig struct { + InputImagePath string + OutputImagePath string + OutputImageFormat imagecustomizerapi.ImageFormatType + OutputArtifactsPath string + OutputArtifactsItems []imagecustomizerapi.OutputArtifactsItemType + + InheritanceChain []*imagecustomizerapi.Config +} + +func NewResolvedConfig(chain []*imagecustomizerapi.Config) *ResolvedConfig { + resolved := &ResolvedConfig{ + InheritanceChain: chain, + } + + resolveOverrideFields(chain, resolved) + resolveMergeFields(chain, resolved) + + return resolved +}