Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions toolkit/tools/imagecustomizerapi/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down
5 changes: 4 additions & 1 deletion toolkit/tools/imagecustomizerapi/previewfeaturetype.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions toolkit/tools/imagecustomizerapi/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@
"additionalProperties": false,
"type": "object"
},
"BaseConfig": {
"properties": {
"path": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object"
},
"BootLoader": {
"properties": {
"resetType": {
Expand Down Expand Up @@ -82,6 +91,12 @@
},
"output": {
"$ref": "#/$defs/Output"
},
"baseConfigs": {
"items": {
"$ref": "#/$defs/BaseConfig"
},
"type": "array"
}
},
"additionalProperties": false,
Expand Down
127 changes: 127 additions & 0 deletions toolkit/tools/pkg/imagecustomizerlib/baseconfigs.go
Original file line number Diff line number Diff line change
@@ -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
}
98 changes: 98 additions & 0 deletions toolkit/tools/pkg/imagecustomizerlib/baseconfigs_test.go
Original file line number Diff line number Diff line change
@@ -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{
Copy link
Contributor

@himaja-kesari himaja-kesari Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try creating some configs without the artifacts path field the test would fail with null pointer exception at ResolveOverrideFields_

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)
}
}
}
15 changes: 15 additions & 0 deletions toolkit/tools/pkg/imagecustomizerlib/imagecustomizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions toolkit/tools/pkg/imagecustomizerlib/resolvedconfig.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading