Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0eb8f89
initial
elainezhao1 Sep 30, 2025
17d28a8
updates
elainezhao1 Oct 1, 2025
4c2f544
updates
elainezhao1 Oct 2, 2025
a6e3ccd
address some comments
elainezhao1 Oct 3, 2025
e90f0a7
rebase
elainezhao1 Oct 3, 2025
a80847b
rebase
elainezhao1 Oct 3, 2025
2e8bbdd
update schema
elainezhao1 Oct 3, 2025
2ba2a8f
address more feedback
elainezhao1 Oct 7, 2025
3e0aad7
Merge branch 'main' into user/elaine/poc1
elainezhao1 Oct 7, 2025
e10c7d1
update tests
elainezhao1 Oct 7, 2025
15e0bd6
Merge ValidateConfig and createImageCustomizerParameters.
cwize1 Oct 7, 2025
7a61336
Bug fix
cwize1 Oct 7, 2025
3017e68
Feedback updates
cwize1 Oct 8, 2025
ed28023
Split off ResolvedConfig from ImageCustomizerParameters.
cwize1 Oct 10, 2025
efc0bf2
resolve and rebase
elainezhao1 Oct 13, 2025
deb63e5
another rebase
elainezhao1 Oct 13, 2025
6ea0f26
Currently, the `ValidateConfig` and the `createResolvedConfig`
cwize1 Oct 13, 2025
89eaa04
another rebase
elainezhao1 Oct 14, 2025
4070cdb
another rebase
elainezhao1 Oct 15, 2025
9737ab1
another rebase
elainezhao1 Oct 15, 2025
f9dc60b
address comments
elainezhao1 Oct 15, 2025
a46a4b6
address more comments
elainezhao1 Oct 16, 2025
b1a4166
resolve comments
elainezhao1 Oct 17, 2025
7d8022e
add doc and rename test files
elainezhao1 Oct 17, 2025
344f754
add doc and rename test files
elainezhao1 Oct 17, 2025
b8895b7
update doc
elainezhao1 Oct 17, 2025
a6ea660
line wrap
elainezhao1 Oct 17, 2025
fcbee70
Merge branch 'main' into user/elaine/poc1
elainezhao1 Oct 17, 2025
2ba46bf
resolve more comments
elainezhao1 Oct 20, 2025
2bff675
Merge branch 'user/elaine/poc1' of github.com:microsoft/azure-linux-i…
elainezhao1 Oct 20, 2025
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
20 changes: 20 additions & 0 deletions toolkit/tools/imagecustomizerapi/baseconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package imagecustomizerapi

import (
"fmt"
"strings"
)

type BaseConfig struct {
Path string `yaml:"path" json:"path"`
}

func (b BaseConfig) IsValid() error {
if strings.TrimSpace(b.Path) == "" {
return fmt.Errorf("path must not be empty or whitespace")
}
return nil
}
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
169 changes: 169 additions & 0 deletions toolkit/tools/pkg/imagecustomizerlib/baseconfigs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package imagecustomizerlib

import (
"context"
"fmt"

"path/filepath"

"github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagecustomizerapi"
)

func ResolveBaseConfigs(ctx context.Context, rc *ResolvedConfig) error {
visited := make(map[string]bool)
pathStack := []string{}

configChain, err := BuildInheritanceChain(ctx, rc.Config, rc.BaseConfigPath, visited, pathStack)
if err != nil {
return err
}

merged := &ResolvedConfig{}

resolveOverrideFields(configChain, merged)
resolveMergeFields(configChain, merged)

if merged.Config.Input != (imagecustomizerapi.Input{}) {
rc.Config.Input = merged.Config.Input
}
if merged.Config.Output != (imagecustomizerapi.Output{}) {
rc.Config.Output = merged.Config.Output
}

return nil
}

func BuildInheritanceChain(ctx context.Context, cfg *imagecustomizerapi.Config, baseDir string, visited map[string]bool, pathStack []string) ([]*imagecustomizerapi.Config, error) {
if cfg.BaseConfigs != nil {
for i, base := range cfg.BaseConfigs {
err := base.IsValid()
if 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)
}

// Validate base config content
if err := baseCfg.IsValid(); err != nil {
return nil, fmt.Errorf("%w at %s:\n%v", ErrInvalidImageConfig, absPath, err)
}

// Recurse into base config
subChain, err := BuildInheritanceChain(ctx, &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) {
// Defensive initialization to avoid nil dereference
if target.Config == nil {
target.Config = &imagecustomizerapi.Config{}
}
if target.Config.Input.Image == (imagecustomizerapi.InputImage{}) {
target.Config.Input = imagecustomizerapi.Input{}
}
if target.Config.Output == (imagecustomizerapi.Output{}) {
target.Config.Output = imagecustomizerapi.Output{}
}

for _, config := range chain {
// .input.image.path
if config.Input.Image.Path != "" {
target.Config.Input.Image.Path = config.Input.Image.Path
}

// .output.image.path
if config.Output.Image.Path != "" {
target.Config.Output.Image.Path = config.Output.Image.Path
}

// .output.image.format
if config.Output.Image.Format != "" {
target.Config.Output.Image.Format = config.Output.Image.Format
}

// .output.artifacts.path
if config.Output.Artifacts != nil {
if target.Config.Output.Artifacts == nil {
target.Config.Output.Artifacts = &imagecustomizerapi.Artifacts{}
}
if config.Output.Artifacts.Path != "" {
target.Config.Output.Artifacts.Path = config.Output.Artifacts.Path
}
}

}
}

func resolveMergeFields(chain []*imagecustomizerapi.Config, target *ResolvedConfig) {
// Defensive initialization
if target.Config.Output == (imagecustomizerapi.Output{}) {
target.Config.Output = imagecustomizerapi.Output{}
}

for _, config := range chain {
if config.Output.Artifacts != nil {
if target.Config.Output.Artifacts == nil {
target.Config.Output.Artifacts = &imagecustomizerapi.Artifacts{}
}
target.Config.Output.Artifacts.Items = mergeOutputArtifactTypes(
target.Config.Output.Artifacts.Items,
config.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
}
87 changes: 87 additions & 0 deletions toolkit/tools/pkg/imagecustomizerlib/baseconfigs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package imagecustomizerlib

import (
"os"
"path/filepath"
"testing"

"github.com/microsoft/azure-linux-image-tools/toolkit/tools/imagecustomizerapi"
"github.com/stretchr/testify/assert"
)

func TestBaseConfigsInputAndOutput(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be good to have at least one test that actually runs through an actual image customization.

Copy link
Contributor Author

@elainezhao1 elainezhao1 Oct 18, 2025

Choose a reason for hiding this comment

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

to add a full run functional test, any suggestions on how I can obtain a test image from which I can extract 2 artifacts (to test merged artifact items) ? I think current empty.vhdx would not work. if it is not ok to check in a non empty vhdx, can I exclude the artifacts item in the full run?

Copy link
Contributor

Choose a reason for hiding this comment

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

See the checkSkipForCustomizeDefaultImage function. It is used quite extensively in the functional tests.

You can pass in the base image to the test using the --base-image-core-efi-azl3 CLI option when running go test.

testTempDir := filepath.Join(tmpDir, "TestBaseConfigsInputAndOutput")
defer os.RemoveAll(testTempDir)

buildDir := filepath.Join(testTempDir, "build")
currentConfigFile := filepath.Join(testDir, "current-config.yaml")

options := ImageCustomizerOptions{
BuildDir: buildDir,
InputImageFile: currentConfigFile,
}

var config imagecustomizerapi.Config
err := imagecustomizerapi.UnmarshalYamlFile(currentConfigFile, &config)
assert.NoError(t, err)

baseConfigPath, _ := filepath.Split(currentConfigFile)
absBaseConfigPath, err := filepath.Abs(baseConfigPath)
assert.NoError(t, err)

rc := &ResolvedConfig{
BaseConfigPath: absBaseConfigPath,
Config: &config,
Options: options,
}

err = ResolveBaseConfigs(t.Context(), rc)
assert.NoError(t, err)

assert.Equal(t, ".testimages/input-image-2.vhdx", rc.Config.Input.Image.Path)
assert.Equal(t, "./out/output-image-2.vhdx", rc.Config.Output.Image.Path)
assert.Equal(t, "./artifacts-2", rc.Config.Output.Artifacts.Path)
assert.Equal(t, "testname", rc.Config.OS.Hostname)

expectedItems := []imagecustomizerapi.OutputArtifactsItemType{
imagecustomizerapi.OutputArtifactsItemUkis,
imagecustomizerapi.OutputArtifactsItemShim,
}
actual := rc.Config.Output.Artifacts.Items
assert.Equal(t, len(expectedItems), len(actual))

for _, item := range expectedItems {
assert.Containsf(t, actual, item, "expected output artifact item %q not found in resolved config: %v", item, actual)
}
}

func TestBaseConfigsMalformed(t *testing.T) {
testTempDir := filepath.Join(tmpDir, "TestBaseConfigsMalformed")
defer os.RemoveAll(testTempDir)

buildDir := filepath.Join(testTempDir, "build")
currentConfigFile := filepath.Join(testDir, "current-config-malformed.yaml")

options := ImageCustomizerOptions{
BuildDir: buildDir,
InputImageFile: currentConfigFile,
}

var config imagecustomizerapi.Config
err := imagecustomizerapi.UnmarshalYamlFile(currentConfigFile, &config)
assert.NoError(t, err)

baseConfigPath, _ := filepath.Split(currentConfigFile)
absBaseConfigPath, err := filepath.Abs(baseConfigPath)
assert.NoError(t, err)

rc := &ResolvedConfig{
BaseConfigPath: absBaseConfigPath,
Config: &config,
Options: options,
}

err = ResolveBaseConfigs(t.Context(), rc)

assert.ErrorContains(t, err, ErrInvalidImageConfig.Error())
}
2 changes: 2 additions & 0 deletions toolkit/tools/pkg/imagecustomizerlib/configvalidation.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ func ValidateConfig(ctx context.Context, baseConfigPath string, config *imagecus
Options: options,
}

ResolveBaseConfigs(ctx, rc)

err := options.IsValid()
if err != nil {
return nil, err
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
previewFeatures:
- output-artifacts

output:
artifacts:
items:
- ukis
15 changes: 15 additions & 0 deletions toolkit/tools/pkg/imagecustomizerlib/testdata/base-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
previewFeatures:
- output-artifacts

input:
image:
path: .testimages/input-image-1.vhdx

output:
image:
path: ./out/output-image-1.vhdx
format: vhdx
artifacts:
items:
- ukis
path: ./artifacts-1
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
previewFeatures:
- base-configs

baseConfigs:
- path: base-config-malformed.yaml
Loading
Loading