diff --git a/docs/user/reference/config/images.md b/docs/user/reference/config/images.md index bc70b269..f2b5e170 100644 --- a/docs/user/reference/config/images.md +++ b/docs/user/reference/config/images.md @@ -11,6 +11,13 @@ 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 | No | Architectures supported by this image | + +The current supported architectures are `x86_64` and `aarch64`. `architectures` is +optional; omitting it (or leaving it empty) means the image is unrestricted and +supports all recognized architectures, which keeps images.toml files written before +this field existed valid. `azldev image list` reports each image's architecture set, +and `azldev image build --arch` rejects architectures outside a declared set. ## Image Definition @@ -64,6 +71,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 +85,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 +97,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 +106,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 +125,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/agentskill_test.go b/internal/app/azldev/agentskill/agentskill_test.go index 42172f89..0e3c81a3 100644 --- a/internal/app/azldev/agentskill/agentskill_test.go +++ b/internal/app/azldev/agentskill/agentskill_test.go @@ -126,6 +126,14 @@ func TestImageSkillDocumentsRuntimeConfigOverride(t *testing.T) { assert.Contains(t, doc, "`kiwi-config-override`") } +func TestImageSkillDocumentsArchitecturesField(t *testing.T) { + doc, err := agentskill.SkillDocument("azldev-image", testParams()) + require.NoError(t, err) + + assert.Contains(t, doc, "architectures = ") + assert.Contains(t, doc, "unrestricted") +} + func TestSkillFrontmatterInvariants(t *testing.T) { layout := agentskill.DefaultLayout() diff --git a/internal/app/azldev/agentskill/content/image.md.tmpl b/internal/app/azldev/agentskill/content/image.md.tmpl index ea4ceba0..04be94b6 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"]` is optional; when unset or empty, the + image is treated as unrestricted (all recognized architectures). Set it to + restrict which architectures `image build --arch` allows for the image. - `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..8fdf405c 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,60 @@ 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) + } + } + + // SupportsArchitecture rejects both architectures the image doesn't declare + // support for and architectures azldev doesn't recognize at all (relevant for + // an unrestricted image with no declared Architectures, and for an explicit + // --arch value that bypassed ImageArch.Set's validation). + if !imageConfig.SupportsArchitecture(arch) { + supportedArchitectures := imageConfig.Architectures + if len(supportedArchitectures) == 0 { + // An unrestricted image supports every recognized architecture; report + // that set instead of the empty declared list, which would otherwise + // misleadingly suggest the image supports none. + supportedArchitectures = projectconfig.SupportedImageArchitectures() + } + + return fmt.Errorf( + "image %#q does not support architecture %#q; supported architectures: %q", + imageConfig.Name, + arch, + supportedArchitectures, + ) + } + + 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..dd5ae223 100644 --- a/internal/app/azldev/cmds/image/build_internal_test.go +++ b/internal/app/azldev/cmds/image/build_internal_test.go @@ -113,3 +113,54 @@ 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`") +} + +func TestValidateBuildArchitecture_UnrestrictedWhenUnset(t *testing.T) { + // An image with no declared Architectures (e.g. from an images.toml written + // before this field existed) must remain unrestricted. + imageConfig := &projectconfig.ImageConfig{Name: "legacy"} + + require.NoError(t, validateBuildArchitecture(imageConfig, ImageArchX86_64, "amd64")) + require.NoError(t, validateBuildArchitecture(imageConfig, ImageArchAarch64, "amd64")) +} + +func TestValidateBuildArchitecture_RejectsUnsupportedExplicitTarget(t *testing.T) { + // An unrestricted image (no declared Architectures) must still reject an + // explicit --arch value that bypasses ImageArch.Set (e.g. set directly rather + // than via flag parsing), instead of silently accepting any string. + // SupportsArchitecture rejects it because "riscv64" isn't a recognized + // architecture at all, regardless of the image's declared support. The error + // must report the recognized architecture set, not the image's empty + // declared list (which would misleadingly suggest it supports none). + imageConfig := &projectconfig.ImageConfig{Name: "legacy"} + + err := validateBuildArchitecture(imageConfig, ImageArch("riscv64"), "amd64") + require.ErrorContains(t, err, "image `legacy` does not support architecture `riscv64`") + require.ErrorContains(t, err, `supported architectures: ["x86_64" "aarch64"]`) +} diff --git a/internal/app/azldev/cmds/image/list.go b/internal/app/azldev/cmds/image/list.go index 52ff3dd4..afd218aa 100644 --- a/internal/app/azldev/cmds/image/list.go +++ b/internal/app/azldev/cmds/image/list.go @@ -36,6 +36,14 @@ type ImageListResult struct { // display. CapabilitiesSummary string `json:"-" table:"Capabilities"` + // Architectures lists the architectures supported by this image, as declared in + // its config. An empty list means the image is unrestricted (all recognized + // architectures), since the field is optional. + 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 +143,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..68cc5ab2 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,116 @@ 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 is unrestricted", + want: nil, + }, + { + 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 TestImageConfig_SupportsArchitecture(t *testing.T) { + tests := []struct { + name string + image projectconfig.ImageConfig + arch string + want bool + }{ + { + name: "unrestricted image supports recognized architecture", + image: projectconfig.ImageConfig{}, + arch: projectconfig.ImageArchitectureAarch64, + want: true, + }, + { + name: "unrestricted image rejects unrecognized architecture", + image: projectconfig.ImageConfig{}, + arch: "riscv64", + want: false, + }, + { + name: "restricted image supports declared architecture", + image: projectconfig.ImageConfig{Architectures: []string{projectconfig.ImageArchitectureX86_64}}, + arch: projectconfig.ImageArchitectureX86_64, + want: true, + }, + { + name: "restricted image rejects undeclared architecture", + image: projectconfig.ImageConfig{Architectures: []string{projectconfig.ImageArchitectureX86_64}}, + arch: projectconfig.ImageArchitectureAarch64, + want: false, + }, + { + name: "restricted image rejects unrecognized architecture even if declared", + image: projectconfig.ImageConfig{Architectures: []string{"riscv64"}}, + arch: "riscv64", + want: false, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + assert.Equal(t, testCase.want, testCase.image.SupportsArchitecture(testCase.arch)) + }) + } +} + func TestProjectConfigValidation_LegacyTestSuitesEmitsDeprecationWarning(t *testing.T) { var buf bytes.Buffer @@ -372,6 +489,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 +522,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..7c48b519 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,29 @@ 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. Optional: an + // unset or empty list means the image is unrestricted (supports all + // architectures azldev recognizes), preserving compatibility with + // images.toml files written before this field existed. + Architectures []string `toml:"architectures,omitempty" json:"architectures,omitempty" jsonschema:"title=Architectures,description=Architectures supported by this image (optional; unset means unrestricted),enum=x86_64,enum=aarch64"` +} + +// SupportsArchitecture reports whether the image supports arch. An image with no +// declared Architectures is treated as unrestricted, for compatibility with +// images.toml files that predate this field, but arch must still be one of the +// architectures azldev recognizes (see SupportedImageArchitectures); an +// unrecognized architecture is never supported, restricted or not. +func (i *ImageConfig) SupportsArchitecture(arch string) bool { + if !slices.Contains(SupportedImageArchitectures(), arch) { + return false + } + + if len(i.Architectures) == 0 { + return true + } + + return slices.Contains(i.Architectures, arch) } // ImagePublishConfig holds publish settings for an image. Unlike packages (which target a @@ -239,6 +276,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..0a8ac007 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. diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index c84f87ae..a9691dd3 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -1413,6 +1413,7 @@ test-paths = ["cases/"] [images.myimage] description = "Test image" +architectures = ["x86_64"] [images.myimage.tests] test-suites = [{ name = "smoke" }] @@ -1452,6 +1453,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 +1476,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..1b808b3e 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,49 @@ func validateImageCapabilities(images map[string]ImageConfig) error { return nil } +func validateImageArchitectures(images map[string]ImageConfig) error { + for imageName, image := range images { + // Architectures is optional: an unset list means the image is + // unrestricted, so images.toml files predating this field remain valid. + if len(image.Architectures) == 0 { + continue + } + + 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..bad976c4 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -729,6 +729,18 @@ "$ref": "#/$defs/ImagePublishConfig", "title": "Publish settings", "description": "Publishing settings for this image" + }, + "architectures": { + "items": { + "type": "string", + "enum": [ + "x86_64", + "aarch64" + ] + }, + "type": "array", + "title": "Architectures", + "description": "Architectures supported by this image (optional; unset means unrestricted)" } }, "additionalProperties": false, diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index 974e5d42..bad976c4 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -729,6 +729,18 @@ "$ref": "#/$defs/ImagePublishConfig", "title": "Publish settings", "description": "Publishing settings for this image" + }, + "architectures": { + "items": { + "type": "string", + "enum": [ + "x86_64", + "aarch64" + ] + }, + "type": "array", + "title": "Architectures", + "description": "Architectures supported by this image (optional; unset means unrestricted)" } }, "additionalProperties": false, diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index 974e5d42..bad976c4 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -729,6 +729,18 @@ "$ref": "#/$defs/ImagePublishConfig", "title": "Publish settings", "description": "Publishing settings for this image" + }, + "architectures": { + "items": { + "type": "string", + "enum": [ + "x86_64", + "aarch64" + ] + }, + "type": "array", + "title": "Architectures", + "description": "Architectures supported by this image (optional; unset means unrestricted)" } }, "additionalProperties": false,