diff --git a/docs/user/reference/config/images.md b/docs/user/reference/config/images.md index bc70b269..3021b656 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 ea4ceba0..bae482cc 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 12637cf5..5ec731ad 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 3518ab78..8d6f3231 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 52ff3dd4..bd9d72ef 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 6289c08f..9bc87002 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 55b19be3..8bfc9cf5 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 bd9db48b..8a6ae8b1 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 0b831e93..eca947b8 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 c84f87ae..212a9f09 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 1788e9f0..7a6f3319 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 fe0a28f9..20f60685 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 974e5d42..329c6da4 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 974e5d42..329c6da4 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 974e5d42..329c6da4 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": {