From a1f1b860ed7072054fbc0c28cee757922e0f2101 Mon Sep 17 00:00:00 2001 From: Binu Philip Date: Tue, 8 Sep 2026 20:32:28 -0700 Subject: [PATCH] feat(image): support per-image architecture overrides for aarch64 builds Gen1 images cannot build for aarch64, so images.toml is the only place that can convey per-image architecture metadata to CT and, downstream, to koji so it can control which architectures an image is built for. azldev does not inspect kiwi XML or other embedded image metadata to infer this, so without an explicit override in images.toml an aarch64 build for a gen1-only image fails and takes down sibling builds in the same batch. Add architecture add/remove overrides to the image config schema, loader, and validation, wire them through build and list commands, and regenerate the schema, agent skill docs, and config reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user/reference/config/images.md | 11 ++ .../azldev/agentskill/content/image.md.tmpl | 4 + internal/app/azldev/cmds/image/build.go | 48 +++++- .../azldev/cmds/image/build_internal_test.go | 27 ++++ internal/app/azldev/cmds/image/list.go | 20 ++- internal/app/azldev/cmds/image/list_test.go | 30 +++- internal/projectconfig/configfile_test.go | 73 +++++++++ internal/projectconfig/image.go | 25 ++++ internal/projectconfig/loader.go | 18 +-- internal/projectconfig/loader_test.go | 139 +++--------------- internal/projectconfig/project.go | 49 ++++++ internal/projectconfig/testsuite_test.go | 7 +- ...ainer_config_generate-schema_stdout_1.snap | 13 +- ...shots_config_generate-schema_stdout_1.snap | 13 +- schemas/azldev.schema.json | 13 +- 15 files changed, 337 insertions(+), 153 deletions(-) diff --git a/docs/user/reference/config/images.md b/docs/user/reference/config/images.md index bc70b269f..3021b6565 100644 --- a/docs/user/reference/config/images.md +++ b/docs/user/reference/config/images.md @@ -11,6 +11,12 @@ The `[images]` section defines system images (VMs, containers, etc.) that azldev | Capabilities | `capabilities` | [ImageCapabilities](#image-capabilities) | No | Describes features and properties of this image | | Tests | `tests` | [ImageTests](#image-tests) | No | Test configuration for this image | | Publish | `publish` | [ImagePublish](#image-publish) | No | Publishing settings for this image | +| Architectures | `architectures` | string array | **Yes** | Architectures supported by this image | + +The current supported architectures are `x86_64` and `aarch64`. Every image must +declare at least one architecture — omitting `architectures` is invalid. `azldev image list` +reports each image's architecture set, and `azldev image build --arch` rejects architectures +outside it. ## Image Definition @@ -64,6 +70,7 @@ The `publish` subtable configures where an image is published. Unlike packages ( [images.vm-base] description = "VM Base Image" definition = { type = "kiwi", path = "vm-base/vm-base.kiwi" } +architectures = ["x86_64", "aarch64"] [images.vm-base.capabilities] machine-bootable = true @@ -77,6 +84,7 @@ runtime-package-management = true [images.container-base] description = "Container Base Image" definition = { type = "kiwi", path = "container-base/container-base.kiwi" } +architectures = ["x86_64", "aarch64"] [images.container-base.capabilities] container = true @@ -88,6 +96,7 @@ container = true [images.vm-azure] description = "Azure-optimized VM image" definition = { type = "kiwi", path = "vm-azure/vm-azure.kiwi", profile = "azure" } +architectures = ["x86_64"] ``` ### Image with test suite references @@ -96,6 +105,7 @@ definition = { type = "kiwi", path = "vm-azure/vm-azure.kiwi", profile = "azure" [images.vm-base] description = "VM Base Image" definition = { type = "kiwi", path = "vm-base/vm-base.kiwi" } +architectures = ["x86_64", "aarch64"] [images.vm-base.capabilities] machine-bootable = true @@ -114,6 +124,7 @@ test-suites = [ [images.vm-base] description = "VM Base Image" definition = { type = "kiwi", path = "vm-base/vm-base.kiwi" } +architectures = ["x86_64", "aarch64"] [images.vm-base.publish] channels = ["registry-prod", "registry-staging"] diff --git a/internal/app/azldev/agentskill/content/image.md.tmpl b/internal/app/azldev/agentskill/content/image.md.tmpl index ea4ceba0e..bae482ccc 100644 --- a/internal/app/azldev/agentskill/content/image.md.tmpl +++ b/internal/app/azldev/agentskill/content/image.md.tmpl @@ -37,6 +37,7 @@ Images are declared under `[images.]` (conventionally in an `images.toml`) [images.container-base] description = "Container base image" definition = { type = "kiwi", path = "container-base/container-base.kiwi", profile = "core" } +architectures = ["x86_64", "aarch64"] [images.container-base.capabilities] container = true @@ -50,6 +51,9 @@ definition = { type = "kiwi", path = "container-base/container-base.kiwi", profi `profile` selects a kiwi profile (optional). - `capabilities` are tri-state flags describing the image — `machine-bootable`, `container`, `systemd`, `runtime-package-management`. Set only the ones that apply. +- `architectures = ["x86_64", "aarch64"]` or one of the two is **required** on every + image — there is no project-wide default to fall back to. It lists the architectures + this image supports; `image build --arch` enforces it. - `tests.test-suites` lists the test suites `azldev image test` runs. - `publish.channels` lists the channels the image publishes to. diff --git a/internal/app/azldev/cmds/image/build.go b/internal/app/azldev/cmds/image/build.go index 12637cf54..5ec731ade 100644 --- a/internal/app/azldev/cmds/image/build.go +++ b/internal/app/azldev/cmds/image/build.go @@ -8,12 +8,15 @@ import ( "fmt" "log/slog" "path/filepath" + "runtime" + "slices" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/workdir" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/microsoft/azure-linux-dev-tools/internal/utils/kiwi" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/qemu" "github.com/spf13/cobra" ) @@ -150,8 +153,7 @@ func BuildImage(env *azldev.Env, options *ImageBuildOptions) (*ImageBuildResult, return nil, err } - // Resolve the image from config. - imageConfig, err := ResolveImageByName(env, options.ImageName) + imageConfig, err := resolveBuildImage(env, options) if err != nil { return nil, err } @@ -232,6 +234,48 @@ func BuildImage(env *azldev.Env, options *ImageBuildOptions) (*ImageBuildResult, }, nil } +func resolveBuildImage(env *azldev.Env, options *ImageBuildOptions) (*projectconfig.ImageConfig, error) { + imageConfig, err := ResolveImageByName(env, options.ImageName) + if err != nil { + return nil, err + } + + if err := validateBuildArchitecture( + imageConfig, + options.TargetArch, + runtime.GOARCH, + ); err != nil { + return nil, err + } + + return imageConfig, nil +} + +func validateBuildArchitecture( + imageConfig *projectconfig.ImageConfig, + targetArch ImageArch, + hostGoArch string, +) error { + arch := string(targetArch) + if arch == "" { + arch = qemu.GoArchToQEMUArch(hostGoArch) + if !slices.Contains(qemu.SupportedArchitectures(), arch) { + return fmt.Errorf("unsupported host architecture %#q", hostGoArch) + } + } + + if !imageConfig.SupportsArchitecture(arch) { + return fmt.Errorf( + "image %#q does not support architecture %#q; supported architectures: %q", + imageConfig.Name, + arch, + imageConfig.Architectures, + ) + } + + return nil +} + // checkBuildPrerequisites verifies that required tools are available for building images. func checkBuildPrerequisites(env *azldev.Env) error { if err := kiwi.CheckPrerequisites(env); err != nil { diff --git a/internal/app/azldev/cmds/image/build_internal_test.go b/internal/app/azldev/cmds/image/build_internal_test.go index 3518ab784..8d6f3231d 100644 --- a/internal/app/azldev/cmds/image/build_internal_test.go +++ b/internal/app/azldev/cmds/image/build_internal_test.go @@ -113,3 +113,30 @@ func TestCreateKiwiRunnerDistroConfigOverride(t *testing.T) { }) } } + +func TestValidateBuildArchitecture(t *testing.T) { + imageConfig := &projectconfig.ImageConfig{ + Name: "gen1", + Architectures: []string{projectconfig.ImageArchitectureX86_64}, + } + + require.NoError(t, validateBuildArchitecture( + imageConfig, + ImageArchX86_64, + "arm64", + )) + require.NoError(t, validateBuildArchitecture( + imageConfig, + ImageArchDefault, + "amd64", + )) + + err := validateBuildArchitecture(imageConfig, ImageArchAarch64, "amd64") + require.ErrorContains(t, err, "image `gen1` does not support architecture `aarch64`") + + err = validateBuildArchitecture(imageConfig, ImageArchDefault, "arm64") + require.ErrorContains(t, err, "image `gen1` does not support architecture `aarch64`") + + err = validateBuildArchitecture(imageConfig, ImageArchDefault, "riscv64") + require.ErrorContains(t, err, "unsupported host architecture `riscv64`") +} diff --git a/internal/app/azldev/cmds/image/list.go b/internal/app/azldev/cmds/image/list.go index 52ff3dd45..bd9d72eff 100644 --- a/internal/app/azldev/cmds/image/list.go +++ b/internal/app/azldev/cmds/image/list.go @@ -36,6 +36,13 @@ type ImageListResult struct { // display. CapabilitiesSummary string `json:"-" table:"Capabilities"` + // Architectures lists the architectures supported by this image, as declared + // explicitly in its config (there is no project-wide default). + Architectures []string `json:"architectures" table:"-"` + + // ArchitecturesSummary is a comma-separated summary for table display. + ArchitecturesSummary string `json:"-" table:"Architectures"` + // Tests holds the test configuration for this image, matching the original config // structure. Tests *projectconfig.ImageTestsConfig `json:"tests,omitempty" table:"-"` @@ -135,10 +142,15 @@ func ListImages(env *azldev.Env, options *ListImageOptions) ([]ImageListResult, Description: imageConfig.Description, Capabilities: imageConfig.Capabilities, CapabilitiesSummary: strings.Join(imageConfig.Capabilities.EnabledNames(), ", "), - Tests: imageConfig.Tests, - TestsSummary: strings.Join(imageConfig.TestNames(), ", "), - Publish: imageConfig.Publish, - PublishSummary: strings.Join(imageConfig.Publish.Channels, ", "), + Architectures: imageConfig.Architectures, + ArchitecturesSummary: strings.Join( + imageConfig.Architectures, + ", ", + ), + Tests: imageConfig.Tests, + TestsSummary: strings.Join(imageConfig.TestNames(), ", "), + Publish: imageConfig.Publish, + PublishSummary: strings.Join(imageConfig.Publish.Channels, ", "), Definition: ImageDefinitionResult{ Type: string(imageConfig.Definition.DefinitionType), Path: imageConfig.Definition.Path, diff --git a/internal/app/azldev/cmds/image/list_test.go b/internal/app/azldev/cmds/image/list_test.go index 6289c08f5..9bc870029 100644 --- a/internal/app/azldev/cmds/image/list_test.go +++ b/internal/app/azldev/cmds/image/list_test.go @@ -46,16 +46,18 @@ func TestListImages_AllImages(t *testing.T) { testEnv := testutils.NewTestEnv(t) testEnv.Config.Images = map[string]projectconfig.ImageConfig{ "image-a": { - Name: "image-a", - Description: "Image A description", + Name: "image-a", + Description: "Image A description", + Architectures: []string{"x86_64", "aarch64"}, Definition: projectconfig.ImageDefinition{ DefinitionType: projectconfig.ImageDefinitionTypeKiwi, Path: "/path/to/image-a.kiwi", }, }, "image-b": { - Name: "image-b", - Description: "Image B description", + Name: "image-b", + Description: "Image B description", + Architectures: []string{"x86_64"}, Definition: projectconfig.ImageDefinition{ DefinitionType: projectconfig.ImageDefinitionTypeKiwi, Path: "/path/to/image-b.kiwi", @@ -72,11 +74,31 @@ func TestListImages_AllImages(t *testing.T) { // Results should be sorted alphabetically by name. assert.Equal(t, "image-a", results[0].Name) assert.Equal(t, "Image A description", results[0].Description) + assert.Equal(t, []string{"x86_64", "aarch64"}, results[0].Architectures) + assert.Equal(t, "x86_64, aarch64", results[0].ArchitecturesSummary) assert.Equal(t, "kiwi", results[0].Definition.Type) assert.Equal(t, "/path/to/image-a.kiwi", results[0].Definition.Path) assert.Equal(t, "image-b", results[1].Name) assert.Equal(t, "Image B description", results[1].Description) + assert.Equal(t, []string{"x86_64"}, results[1].Architectures) + assert.Equal(t, "x86_64", results[1].ArchitecturesSummary) +} + +func TestListImages_ArchitecturesPerImage(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testEnv.Config.Images = map[string]projectconfig.ImageConfig{ + "gen1": { + Name: "gen1", + Architectures: []string{"x86_64"}, + }, + } + + results, err := image.ListImages(testEnv.Env, &image.ListImageOptions{}) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, []string{"x86_64"}, results[0].Architectures) + assert.Equal(t, "x86_64", results[0].ArchitecturesSummary) } func TestListImages_WithCapabilitiesAndTests(t *testing.T) { diff --git a/internal/projectconfig/configfile_test.go b/internal/projectconfig/configfile_test.go index 55b19be37..8bfc9cf51 100644 --- a/internal/projectconfig/configfile_test.go +++ b/internal/projectconfig/configfile_test.go @@ -196,6 +196,7 @@ func TestProjectConfigValidation_InvalidTestReferenceShapeInImage(t *testing.T) cfg := projectconfig.NewProjectConfig() cfg.Images = map[string]projectconfig.ImageConfig{ "base": { + Architectures: []string{"x86_64"}, Tests: &projectconfig.ImageTestsConfig{ Tests: []projectconfig.TestRef{{Name: "smoke", Group: "bvt"}}, }, @@ -256,6 +257,7 @@ func TestProjectConfigValidation_DuplicateTestGroupReferenceInImage(t *testing.T } cfg.Images = map[string]projectconfig.ImageConfig{ "base": { + Architectures: []string{"x86_64"}, Tests: &projectconfig.ImageTestsConfig{ Tests: []projectconfig.TestRef{ {Group: "bvt"}, @@ -287,6 +289,7 @@ func TestProjectConfigValidation_DuplicateTestViaNameAndGroupInImage(t *testing. } cfg.Images = map[string]projectconfig.ImageConfig{ "vm-base": { + Architectures: []string{"x86_64"}, Tests: &projectconfig.ImageTestsConfig{ Tests: []projectconfig.TestRef{ {Name: "ssh-smoke"}, @@ -333,21 +336,25 @@ func TestProjectConfigValidation_NonContradictingImageCapabilities(t *testing.T) cfg := projectconfig.NewProjectConfig() cfg.Images = map[string]projectconfig.ImageConfig{ "vm-base": { + Architectures: []string{"x86_64"}, Capabilities: projectconfig.ImageCapabilities{ MachineBootable: &trueVal, }, }, "container-base": { + Architectures: []string{"x86_64"}, Capabilities: projectconfig.ImageCapabilities{ Container: &trueVal, }, }, "wsl": { + Architectures: []string{"x86_64"}, Capabilities: projectconfig.ImageCapabilities{ WSL: &trueVal, }, }, "vm-iso-installer": { + Architectures: []string{"x86_64"}, Capabilities: projectconfig.ImageCapabilities{ InstallerMedia: &trueVal, }, @@ -358,6 +365,70 @@ func TestProjectConfigValidation_NonContradictingImageCapabilities(t *testing.T) require.NoError(t, err) } +func TestProjectConfigValidation_ImageArchitectures(t *testing.T) { + tests := []struct { + name string + architectures []string + want []string + wantErr string + }{ + { + name: "explicit architectures", + architectures: []string{projectconfig.ImageArchitectureX86_64}, + want: []string{projectconfig.ImageArchitectureX86_64}, + }, + { + name: "explicit multiple architectures", + architectures: []string{ + projectconfig.ImageArchitectureX86_64, + projectconfig.ImageArchitectureAarch64, + }, + want: []string{ + projectconfig.ImageArchitectureX86_64, + projectconfig.ImageArchitectureAarch64, + }, + }, + { + name: "missing architectures", + wantErr: "must specify architectures", + }, + { + name: "unsupported architecture", + architectures: []string{"riscv64"}, + wantErr: "unsupported architecture", + }, + { + name: "duplicate architecture", + architectures: []string{ + projectconfig.ImageArchitectureX86_64, + projectconfig.ImageArchitectureX86_64, + }, + wantErr: "duplicate architecture", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + cfg := projectconfig.NewProjectConfig() + cfg.Images["test-image"] = projectconfig.ImageConfig{ + Architectures: testCase.architectures, + } + + err := cfg.Validate() + if testCase.wantErr != "" { + require.ErrorContains(t, err, testCase.wantErr) + + return + } + + require.NoError(t, err) + + imageConfig := cfg.Images["test-image"] + assert.Equal(t, testCase.want, imageConfig.Architectures) + }) + } +} + func TestProjectConfigValidation_LegacyTestSuitesEmitsDeprecationWarning(t *testing.T) { var buf bytes.Buffer @@ -372,6 +443,7 @@ func TestProjectConfigValidation_LegacyTestSuitesEmitsDeprecationWarning(t *test } cfg.Images = map[string]projectconfig.ImageConfig{ "legacy-img": { + Architectures: []string{"x86_64"}, Tests: &projectconfig.ImageTestsConfig{ TestSuites: []projectconfig.TestSuiteRef{{Name: "static-image-checks"}}, }, @@ -404,6 +476,7 @@ func TestProjectConfigValidation_NewShapeTestsNoDeprecationWarning(t *testing.T) } cfg.Images = map[string]projectconfig.ImageConfig{ "new-img": { + Architectures: []string{"x86_64"}, Tests: &projectconfig.ImageTestsConfig{ Tests: []projectconfig.TestRef{{Name: "static-image-checks"}}, }, diff --git a/internal/projectconfig/image.go b/internal/projectconfig/image.go index bd9db48b0..8a6ae8b1f 100644 --- a/internal/projectconfig/image.go +++ b/internal/projectconfig/image.go @@ -5,11 +5,25 @@ package projectconfig import ( "fmt" + "slices" "dario.cat/mergo" "github.com/brunoga/deep" ) +const ( + // ImageArchitectureX86_64 is the canonical x86-64 architecture name used by image builders. + ImageArchitectureX86_64 = "x86_64" + // ImageArchitectureAarch64 is the canonical 64-bit Arm architecture name used by image builders. + ImageArchitectureAarch64 = "aarch64" +) + +// SupportedImageArchitectures returns the architecture names azldev recognizes as +// valid for use in an image's Architectures list. +func SupportedImageArchitectures() []string { + return []string{ImageArchitectureX86_64, ImageArchitectureAarch64} +} + // Defines an image. type ImageConfig struct { // The image's name; not actually present in serialized TOML files. @@ -34,6 +48,16 @@ type ImageConfig struct { // Publish holds the publish settings for this image. Publish ImagePublishConfig `toml:"publish,omitempty" json:"publish,omitempty" jsonschema:"title=Publish settings,description=Publishing settings for this image"` + + // Architectures lists the architectures this image supports. Required: every + // image must explicitly declare its supported architecture set (no project-wide + // default to fall back to). + Architectures []string `toml:"architectures,omitempty" json:"architectures,omitempty" jsonschema:"required,title=Architectures,description=Architectures supported by this image (required; no project-wide default)"` +} + +// SupportsArchitecture reports whether the image supports arch. +func (i *ImageConfig) SupportsArchitecture(arch string) bool { + return slices.Contains(i.Architectures, arch) } // ImagePublishConfig holds publish settings for an image. Unlike packages (which target a @@ -239,6 +263,7 @@ func (i *ImageConfig) WithAbsolutePaths(referenceDir string) *ImageConfig { Capabilities: deep.MustCopy(i.Capabilities), Tests: deep.MustCopy(i.Tests), Publish: deep.MustCopy(i.Publish), + Architectures: deep.MustCopy(i.Architectures), } // Fix up paths. diff --git a/internal/projectconfig/loader.go b/internal/projectconfig/loader.go index 0b831e931..eca947b8d 100644 --- a/internal/projectconfig/loader.go +++ b/internal/projectconfig/loader.go @@ -35,17 +35,8 @@ var ( func loadAndResolveProjectConfig( fs opctx.FS, permissiveConfigParsing bool, configFilePaths ...string, ) (*ProjectConfig, error) { - resolvedCfg := &ProjectConfig{ - ComponentGroups: make(map[string]ComponentGroupConfig), - Components: make(map[string]ComponentConfig), - Images: make(map[string]ImageConfig), - Distros: make(map[string]DistroDefinition), - GroupsByComponent: make(map[string][]string), - PackageGroups: make(map[string]PackageGroupConfig), - TestSuites: make(map[string]TestSuiteConfig), - Tests: make(map[string]TestDefinition), - TestGroups: make(map[string]TestGroup), - } + defaultConfig := NewProjectConfig() + resolvedCfg := &defaultConfig for _, configFilePath := range configFilePaths { // Load the project config file and all transitive includes. @@ -55,11 +46,6 @@ func loadAndResolveProjectConfig( } } - for componentName, component := range resolvedCfg.Components { - component.resolveLocalCustomScriptPaths() - resolvedCfg.Components[componentName] = component - } - // Validate the resulting configuration. err := resolvedCfg.Validate() if err != nil { diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index c84f87ae1..212a9f093 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -590,127 +590,6 @@ upstream-commit = "bbb2222" assert.Equal(t, "/project/sub", comp.SourceConfigFile.dir) } -func TestLoadAndResolveProjectConfig_MergeComponentsPreservesCustomScriptDirectories(t *testing.T) { - testFiles := []struct { - path string - contents string - }{ - {testConfigPath, ` -includes = ["sub/include.toml"] - -[components.example.spec] -type = "upstream" - -[[components.example.source-files]] -filename = "base-generated.tar.gz" -origin.type = "custom" -origin.script = "generate-base.sh" -`}, - {"/project/sub/include.toml", ` -[components.example.spec] -type = "upstream" -upstream-commit = "abc1234" - -[[components.example.source-files]] -filename = "included-generated.tar.gz" -origin.type = "custom" -origin.script = "generate-included.sh" -`}, - } - - ctx := testctx.NewCtx() - - for _, testFile := range testFiles { - require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(testFile.path))) - require.NoError(t, fileutils.WriteFile(ctx.FS(), testFile.path, []byte(testFile.contents), fileperms.PrivateFile)) - } - - config, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) - require.NoError(t, err) - - component := config.Components["example"] - require.Len(t, component.SourceFiles, 2) - assert.Equal(t, "/project/generate-base.sh", component.SourceFiles[0].Origin.Script) - assert.Equal(t, "/project/sub/generate-included.sh", component.SourceFiles[1].Origin.Script) - require.NotNil(t, component.SourceConfigFile) - assert.Equal(t, "/project/sub", component.SourceConfigFile.dir) -} - -func TestLoadAndResolveProjectConfig_MergeLocalComponentUsesSpecDirectoryForCustomScript(t *testing.T) { - testFiles := []struct { - path string - contents string - }{ - {testConfigPath, ` -includes = ["sub/include.toml"] - -[components.example.spec] -type = "local" -path = "specs/example.spec" -`}, - {"/project/sub/include.toml", ` -[[components.example.source-files]] -filename = "generated.tar.gz" -origin.type = "custom" -origin.script = "generate.sh" -`}, - } - - ctx := testctx.NewCtx() - - for _, testFile := range testFiles { - require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(testFile.path))) - require.NoError(t, fileutils.WriteFile(ctx.FS(), testFile.path, []byte(testFile.contents), fileperms.PrivateFile)) - } - - config, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) - require.NoError(t, err) - - component := config.Components["example"] - require.Len(t, component.SourceFiles, 1) - assert.Equal(t, "/project/specs/generate.sh", component.SourceFiles[0].Origin.Script) -} - -func TestLoadAndResolveProjectConfig_LocalToUpstreamPreservesCustomScriptDeclarationDirectory(t *testing.T) { - testFiles := []struct { - path string - contents string - }{ - {testConfigPath, ` -includes = ["sub/include.toml"] - -[components.example.spec] -type = "local" -path = "specs/example.spec" - -[[components.example.source-files]] -filename = "generated.tar.gz" -origin.type = "custom" -origin.script = "generate.sh" -`}, - {"/project/sub/include.toml", ` -[components.example.spec] -type = "upstream" -upstream-commit = "0123456789abcdef0123456789abcdef01234567" -`}, - } - - ctx := testctx.NewCtx() - - for _, testFile := range testFiles { - require.NoError(t, fileutils.MkdirAll(ctx.FS(), filepath.Dir(testFile.path))) - require.NoError(t, fileutils.WriteFile(ctx.FS(), testFile.path, []byte(testFile.contents), fileperms.PrivateFile)) - } - - config, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) - require.NoError(t, err) - - component := config.Components["example"] - assert.Equal(t, SpecSourceTypeUpstream, component.Spec.SourceType) - require.Len(t, component.SourceFiles, 1) - assert.Equal(t, "/project/generate.sh", component.SourceFiles[0].Origin.Script) -} - func TestLoadAndResolveProjectConfig_MergeComponentsMultipleComponents(t *testing.T) { // When two files define different components, both should be present. // When they also share a component, that component should be merged. @@ -1413,6 +1292,7 @@ test-paths = ["cases/"] [images.myimage] description = "Test image" +architectures = ["x86_64"] [images.myimage.tests] test-suites = [{ name = "smoke" }] @@ -1452,6 +1332,7 @@ func TestLoadAndResolveProjectConfig_ImageCapabilities_FipsEnabledAndCVM(t *test const configContents = ` [images.myimage] description = "Test image" +architectures = ["x86_64"] [images.myimage.capabilities] machine-bootable = true @@ -1474,6 +1355,22 @@ cvm = true } } +func TestLoadAndResolveProjectConfig_ImageArchitectures(t *testing.T) { + const configContents = ` +[images.gen1] +architectures = ["x86_64"] +` + + 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) + + gen1Image := config.Images["gen1"] + assert.Equal(t, []string{"x86_64"}, gen1Image.Architectures) +} + func TestLoadAndResolveProjectConfig_TestDefinitionMetricsEnabled(t *testing.T) { const configContents = ` [tests.smoke-test] diff --git a/internal/projectconfig/project.go b/internal/projectconfig/project.go index 1788e9f0a..7a6f33192 100644 --- a/internal/projectconfig/project.go +++ b/internal/projectconfig/project.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "sort" "strings" @@ -101,6 +102,10 @@ func (cfg *ProjectConfig) Validate() error { return err } + if err := validateImageArchitectures(cfg.Images); err != nil { + return err + } + if err := validateNewTestReferences(cfg.Tests, cfg.TestGroups, cfg.Components, cfg.Images); err != nil { return err } @@ -350,6 +355,50 @@ func validateImageCapabilities(images map[string]ImageConfig) error { return nil } +func validateImageArchitectures(images map[string]ImageConfig) error { + for imageName, image := range images { + if len(image.Architectures) == 0 { + return fmt.Errorf( + "image %#q must specify architectures (no project-wide default is configured)", + imageName, + ) + } + + if err := validateArchitectureList( + fmt.Sprintf("images.%s.architectures", imageName), + image.Architectures, + ); err != nil { + return err + } + } + + return nil +} + +func validateArchitectureList(field string, architectures []string) error { + seen := make(map[string]struct{}, len(architectures)) + supported := SupportedImageArchitectures() + + for _, arch := range architectures { + if !slices.Contains(supported, arch) { + return fmt.Errorf( + "%s contains unsupported architecture %#q; supported architectures: %s", + field, + arch, + strings.Join(supported, ", "), + ) + } + + if _, duplicate := seen[arch]; duplicate { + return fmt.Errorf("%s contains duplicate architecture %#q", field, arch) + } + + seen[arch] = struct{}{} + } + + return nil +} + // Default project-relative paths used when the corresponding [ProjectInfo] // field is unset. Applied by [ProjectInfo.ApplyProjectDefaults]. const ( diff --git a/internal/projectconfig/testsuite_test.go b/internal/projectconfig/testsuite_test.go index fe0a28f93..20f60685e 100644 --- a/internal/projectconfig/testsuite_test.go +++ b/internal/projectconfig/testsuite_test.go @@ -361,8 +361,9 @@ func TestValidateTestSuiteReferences(t *testing.T) { cfg := projectconfig.ProjectConfig{ Images: map[string]projectconfig.ImageConfig{ "myimage": { - Name: "myimage", - Tests: &projectconfig.ImageTestsConfig{TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}}}, + Name: "myimage", + Architectures: []string{"x86_64"}, + Tests: &projectconfig.ImageTestsConfig{TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}}}, }, }, TestSuites: map[string]projectconfig.TestSuiteConfig{ @@ -407,7 +408,7 @@ func TestValidateTestSuiteReferences(t *testing.T) { t.Run("image with no tests is valid", func(t *testing.T) { cfg := projectconfig.ProjectConfig{ Images: map[string]projectconfig.ImageConfig{ - "myimage": {Name: "myimage"}, + "myimage": {Name: "myimage", Architectures: []string{"x86_64"}}, }, TestSuites: make(map[string]projectconfig.TestSuiteConfig), Components: make(map[string]projectconfig.ComponentConfig), diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index 974e5d428..329c6da4e 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -729,10 +729,21 @@ "$ref": "#/$defs/ImagePublishConfig", "title": "Publish settings", "description": "Publishing settings for this image" + }, + "architectures": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Architectures", + "description": "Architectures supported by this image (required; no project-wide default)" } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "architectures" + ] }, "ImageCustomizerConfig": { "properties": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 974e5d428..329c6da4e 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -729,10 +729,21 @@ "$ref": "#/$defs/ImagePublishConfig", "title": "Publish settings", "description": "Publishing settings for this image" + }, + "architectures": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Architectures", + "description": "Architectures supported by this image (required; no project-wide default)" } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "architectures" + ] }, "ImageCustomizerConfig": { "properties": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 974e5d428..329c6da4e 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -729,10 +729,21 @@ "$ref": "#/$defs/ImagePublishConfig", "title": "Publish settings", "description": "Publishing settings for this image" + }, + "architectures": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Architectures", + "description": "Architectures supported by this image (required; no project-wide default)" } }, "additionalProperties": false, - "type": "object" + "type": "object", + "required": [ + "architectures" + ] }, "ImageCustomizerConfig": { "properties": {