-
Notifications
You must be signed in to change notification settings - Fork 10
Base Configs Support - Phase 1 #455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
0eb8f89
17d28a8
4c2f544
a6e3ccd
e90f0a7
a80847b
2e8bbdd
2ba2a8f
3e0aad7
e10c7d1
15e0bd6
7a61336
3017e68
ed28023
efc0bf2
deb63e5
6ea0f26
89eaa04
4070cdb
9737ab1
f9dc60b
a46a4b6
b1a4166
7d8022e
344f754
b8895b7
a6ea660
fcbee70
2ba46bf
2bff675
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| 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 { | ||
elainezhao1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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) { | ||
elainezhao1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if cfg.BaseConfigs != nil { | ||
| for i, base := range cfg.BaseConfigs { | ||
| err := base.IsValid() | ||
elainezhao1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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) | ||
elainezhao1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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) | ||
elainezhao1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // Validate base config content | ||
| if err := baseCfg.IsValid(); err != nil { | ||
| return nil, fmt.Errorf("%w at %s:\n%v", ErrInvalidImageConfig, absPath, err) | ||
elainezhao1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
elainezhao1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // 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 != "" { | ||
elainezhao1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| target.Config.Input.Image.Path = config.Input.Image.Path | ||
elainezhao1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // .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 | ||
| } | ||
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See the You can pass in the base image to the test using the |
||
| 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()) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.