diff --git a/docs/user/reference/config/config-file.md b/docs/user/reference/config/config-file.md index 1f254e8..f00e37c 100644 --- a/docs/user/reference/config/config-file.md +++ b/docs/user/reference/config/config-file.md @@ -14,7 +14,10 @@ All config files share the same schema — there is no distinction between a "ro | `components` | map of objects | Component (package) definitions | [Components](components.md) | | `component-groups` | map of objects | Named groups of components with shared defaults | [Component Groups](component-groups.md) | | `images` | map of objects | Image definitions (VMs, containers) | [Images](images.md) | +| `test-suites` | map of objects | Named test suite definitions referenced by images | [Test Suites](test-suites.md) | | `tools` | object | Configuration for external tools used by azldev | [Tools](tools.md) | +| `default-package-config` | object | Project-wide default applied to all binary packages | [Package Groups — Resolution Order](package-groups.md#resolution-order) | +| `package-groups` | map of objects | Named groups of binary packages with shared config | [Package Groups](package-groups.md) | ## Includes diff --git a/docs/user/reference/config/images.md b/docs/user/reference/config/images.md index e03f7ad..ebbd5e5 100644 --- a/docs/user/reference/config/images.md +++ b/docs/user/reference/config/images.md @@ -8,6 +8,9 @@ The `[images]` section defines system images (VMs, containers, etc.) that azldev |-------|----------|------|----------|-------------| | Description | `description` | string | No | Human-readable description of the image | | Definition | `definition` | [ImageDefinition](#image-definition) | No | Specifies the image definition format, file path, and optional profile | +| 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 | ## Image Definition @@ -19,24 +22,59 @@ The `definition` field tells azldev where to find the image definition file and | Path | `path` | string | No | Path to the image definition file, relative to the config file | | Profile | `profile` | string | No | Build profile to use when building the image (format-specific) | +## Image Capabilities + +The `capabilities` subtable describes what the image supports. All fields are optional booleans using tri-state semantics: `true` (explicitly enabled), `false` (explicitly disabled), or omitted (unspecified / inherit from defaults). + +| Field | TOML Key | Type | Default | Description | +|-------|----------|------|---------|-------------| +| Machine Bootable | `machine-bootable` | bool | unset | Whether the image can be booted on a machine (bare metal or VM) | +| Container | `container` | bool | unset | Whether the image can be run on an OCI container host | +| Systemd | `systemd` | bool | unset | Whether the image runs systemd as its init system | +| Runtime Package Management | `runtime-package-management` | bool | unset | Whether the image supports installing/removing packages at runtime (e.g., via dnf/tdnf) | + +## Image Tests + +The `tests` subtable links an image to one or more test suites defined in the top-level [`[test-suites]`](test-suites.md) section. + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Test Suites | `test-suites` | array of inline tables | No | List of test suite references. Each entry must have a `name` field matching a key in `[test-suites]`. | + +## Image Publish + +The `publish` subtable configures where an image is published. Unlike packages (which target a single channel), images may be published to multiple channels simultaneously. + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Channels | `channels` | string array | No | List of publish channels for this image | + > **Note:** Each image name must be unique across all config files. Defining the same image name in two files produces an error. ## Examples -### VM image using Kiwi +### VM image with capabilities ```toml [images.vm-base] description = "VM Base Image" definition = { type = "kiwi", path = "vm-base/vm-base.kiwi" } + +[images.vm-base.capabilities] +machine-bootable = true +systemd = true +runtime-package-management = true ``` -### Container image +### Container image with capabilities ```toml [images.container-base] description = "Container Base Image" definition = { type = "kiwi", path = "container-base/container-base.kiwi" } + +[images.container-base.capabilities] +container = true ``` ### Image with a build profile @@ -47,7 +85,37 @@ description = "Azure-optimized VM image" definition = { type = "kiwi", path = "vm-azure/vm-azure.kiwi", profile = "azure" } ``` +### Image with test suite references + +```toml +[images.vm-base] +description = "VM Base Image" +definition = { type = "kiwi", path = "vm-base/vm-base.kiwi" } + +[images.vm-base.capabilities] +machine-bootable = true +systemd = true + +[images.vm-base.tests] +test-suites = [ + { name = "smoke" }, + { name = "integration" }, +] +``` + +### Image with publish channels + +```toml +[images.vm-base] +description = "VM Base Image" +definition = { type = "kiwi", path = "vm-base/vm-base.kiwi" } + +[images.vm-base.publish] +channels = ["registry-prod", "registry-staging"] +``` + ## Related Resources - [Config File Structure](config-file.md) — top-level config file layout +- [Test Suites](test-suites.md) — test suite definitions - [Tools](tools.md) — Image Customizer tool configuration diff --git a/docs/user/reference/config/test-suites.md b/docs/user/reference/config/test-suites.md new file mode 100644 index 0000000..93ea0f9 --- /dev/null +++ b/docs/user/reference/config/test-suites.md @@ -0,0 +1,43 @@ +# Test Suites + +The `[test-suites]` section defines named test suites that can be referenced by images. Each test suite is defined under `[test-suites.]`. + +## Test Suite Config + +| Field | TOML Key | Type | Required | Description | +|-------|----------|------|----------|-------------| +| Description | `description` | string | No | Human-readable description of the test suite | + +Test suites are referenced by images through the [`[images..tests]`](images.md#image-tests) subtable. Each image can reference one or more test suites by name. + +> **Note:** Each test suite name must be unique across all config files. Defining the same test suite name in two files produces an error. + +## Examples + +### Basic test suite definitions + +```toml +[test-suites.smoke] +description = "Smoke tests for basic image validation" + +[test-suites.integration] +description = "Integration tests for live VM validation" +``` + +### Referencing test suites from an image + +```toml +[test-suites.smoke] +description = "Smoke tests" + +[images.vm-base] +description = "VM Base Image" + +[images.vm-base.tests] +test-suites = [{ name = "smoke" }] +``` + +## Related Resources + +- [Images](images.md) — image configuration including test references +- [Config File Structure](config-file.md) — top-level config file layout diff --git a/internal/app/azldev/cmds/image/list.go b/internal/app/azldev/cmds/image/list.go index ebfe468..d0d6bc7 100644 --- a/internal/app/azldev/cmds/image/list.go +++ b/internal/app/azldev/cmds/image/list.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" "github.com/samber/lo" "github.com/spf13/cobra" ) @@ -28,6 +29,26 @@ type ImageListResult struct { // Description of the image. Description string `json:"description"` + // Capabilities describes the features and properties of this image. + Capabilities projectconfig.ImageCapabilities `json:"capabilities" table:"-"` + + // CapabilitiesSummary is a comma-separated summary of enabled capabilities for table + // display. + CapabilitiesSummary string `json:"-" table:"Capabilities"` + + // Tests holds the test configuration for this image, matching the original config + // structure. + Tests projectconfig.ImageTestsConfig `json:"tests" table:"-"` + + // TestsSummary is a comma-separated summary of test suite names for table display. + TestsSummary string `json:"-" table:"Tests"` + + // Publish holds the publish settings for this image. + Publish projectconfig.ImagePublishConfig `json:"publish" table:"-"` + + // PublishSummary is a comma-separated summary of publish channels for table display. + PublishSummary string `json:"-" table:"Publish"` + // Definition contains the image definition details (hidden from table output). Definition ImageDefinitionResult `json:"definition" table:"-"` } @@ -108,9 +129,16 @@ func ListImages(env *azldev.Env, options *ListImageOptions) ([]ImageListResult, } imageConfig := cfg.Images[name] + results = append(results, ImageListResult{ - Name: name, - Description: imageConfig.Description, + Name: name, + 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, ", "), 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 c1a0f75..6c572ac 100644 --- a/internal/app/azldev/cmds/image/list_test.go +++ b/internal/app/azldev/cmds/image/list_test.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/image" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -78,6 +79,91 @@ func TestListImages_AllImages(t *testing.T) { assert.Equal(t, "Image B description", results[1].Description) } +func TestListImages_WithCapabilitiesAndTests(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + testEnv.Config.Images = map[string]projectconfig.ImageConfig{ + "vm-base": { + Name: "vm-base", + Description: "VM Base Image", + Capabilities: projectconfig.ImageCapabilities{ + MachineBootable: lo.ToPtr(true), + Systemd: lo.ToPtr(true), + }, + Tests: projectconfig.ImageTestsConfig{ + TestSuites: []projectconfig.TestSuiteRef{ + {Name: "smoke"}, + {Name: "integration"}, + }, + }, + Publish: projectconfig.ImagePublishConfig{ + Channels: []string{"registry-prod", "registry-staging"}, + }, + }, + "container-base": { + Name: "container-base", + Description: "Container Base Image", + Capabilities: projectconfig.ImageCapabilities{ + Container: lo.ToPtr(true), + }, + Tests: projectconfig.ImageTestsConfig{ + TestSuites: []projectconfig.TestSuiteRef{ + {Name: "smoke"}, + }, + }, + Publish: projectconfig.ImagePublishConfig{ + Channels: []string{"registry-prod"}, + }, + }, + "minimal": { + Name: "minimal", + Description: "Minimal image with no capabilities or tests", + }, + } + + options := &image.ListImageOptions{} + + results, err := image.ListImages(testEnv.Env, options) + require.NoError(t, err) + require.Len(t, results, 3) + + // Results sorted alphabetically. + assert.Equal(t, "container-base", results[0].Name) + assert.Equal(t, lo.ToPtr(true), results[0].Capabilities.Container) + assert.Nil(t, results[0].Capabilities.MachineBootable) + assert.Equal(t, "container", results[0].CapabilitiesSummary) + assert.Equal(t, projectconfig.ImageTestsConfig{ + TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}}, + }, results[0].Tests) + assert.Equal(t, "smoke", results[0].TestsSummary) + assert.Equal(t, projectconfig.ImagePublishConfig{ + Channels: []string{"registry-prod"}, + }, results[0].Publish) + assert.Equal(t, "registry-prod", results[0].PublishSummary) + + assert.Equal(t, "minimal", results[1].Name) + assert.Nil(t, results[1].Capabilities.MachineBootable) + assert.Nil(t, results[1].Capabilities.Container) + assert.Empty(t, results[1].CapabilitiesSummary) + assert.Empty(t, results[1].Tests.TestSuites) + assert.Empty(t, results[1].TestsSummary) + assert.Empty(t, results[1].Publish.Channels) + assert.Empty(t, results[1].PublishSummary) + + assert.Equal(t, "vm-base", results[2].Name) + assert.Equal(t, lo.ToPtr(true), results[2].Capabilities.MachineBootable) + assert.Equal(t, lo.ToPtr(true), results[2].Capabilities.Systemd) + assert.Nil(t, results[2].Capabilities.Container) + assert.Equal(t, "machine-bootable, systemd", results[2].CapabilitiesSummary) + assert.Equal(t, projectconfig.ImageTestsConfig{ + TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}, {Name: "integration"}}, + }, results[2].Tests) + assert.Equal(t, "smoke, integration", results[2].TestsSummary) + assert.Equal(t, projectconfig.ImagePublishConfig{ + Channels: []string{"registry-prod", "registry-staging"}, + }, results[2].Publish) + assert.Equal(t, "registry-prod, registry-staging", results[2].PublishSummary) +} + func TestListImages_ExactMatch(t *testing.T) { testEnv := testutils.NewTestEnv(t) testEnv.Config.Images = map[string]projectconfig.ImageConfig{ diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index 8d0d985..e4bb480 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -54,6 +54,9 @@ type ConfigFile struct { // to be applied to sets of binary packages. PackageGroups map[string]PackageGroupConfig `toml:"package-groups,omitempty" validate:"dive" jsonschema:"title=Package groups,description=Definitions of package groups for shared binary package configuration"` + // Definitions of test suites. + TestSuites map[string]TestSuiteConfig `toml:"test-suites,omitempty" validate:"dive" jsonschema:"title=Test Suites,description=Definitions of test suites for this project"` + // Internal fields used to track the origin of the config file; `dir` is the directory // that the config file's relative paths are based from. sourcePath string `toml:"-"` diff --git a/internal/projectconfig/image.go b/internal/projectconfig/image.go index f965997..f200085 100644 --- a/internal/projectconfig/image.go +++ b/internal/projectconfig/image.go @@ -24,6 +24,114 @@ type ImageConfig struct { // Where to find its definition. Definition ImageDefinition `toml:"definition,omitempty" json:"definition,omitempty" jsonschema:"title=Definition,description=Identifies where to find the definition for this image"` + + // Capabilities describes the features and properties of this image. + Capabilities ImageCapabilities `toml:"capabilities,omitempty" json:"capabilities,omitempty" jsonschema:"title=Capabilities,description=Features and properties of this image"` + + // Tests holds the test configuration for this image, including which test suites + // apply to it. + Tests ImageTestsConfig `toml:"tests,omitempty" json:"tests,omitempty" jsonschema:"title=Tests,description=Test configuration for this image"` + + // 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"` +} + +// ImagePublishConfig holds publish settings for an image. Unlike packages (which target a +// single channel), images may be published to multiple channels simultaneously. +type ImagePublishConfig struct { + // Channels lists the publish channels for this image. + Channels []string `toml:"channels,omitempty" json:"channels,omitempty" validate:"dive,required,ne=.,ne=..,excludesall=/\\" jsonschema:"title=Channels,description=List of publish channels for this image"` +} + +// ImageCapabilities describes the features and properties of an image. Boolean fields +// use *bool to distinguish "explicitly true", "explicitly false", and "unspecified" +// (nil). This tristate enables correct merge semantics (unspecified inherits, false +// overrides) and detection of underspecification. +type ImageCapabilities struct { + // MachineBootable indicates whether the image can be booted on a machine (bare metal, + // VM, etc.). Images that lack a kernel are not machine-bootable. + MachineBootable *bool `toml:"machine-bootable,omitempty" json:"machineBootable,omitempty" jsonschema:"title=Machine bootable,description=Whether the image can be booted on a machine (bare metal or VM)"` + + // Container indicates whether the image can be run on an OCI container host. + Container *bool `toml:"container,omitempty" json:"container,omitempty" jsonschema:"title=Container,description=Whether the image can be run on an OCI container host"` + + // Systemd indicates whether the image runs systemd as its init system. + Systemd *bool `toml:"systemd,omitempty" json:"systemd,omitempty" jsonschema:"title=Systemd,description=Whether the image runs systemd as its init system"` + + // RuntimePackageManagement indicates whether the image supports installing or + // removing packages at runtime (e.g., via dnf/tdnf). + RuntimePackageManagement *bool `toml:"runtime-package-management,omitempty" json:"runtimePackageManagement,omitempty" jsonschema:"title=Runtime package management,description=Whether the image supports installing or removing packages at runtime"` +} + +// IsMachineBootable returns true if the image is explicitly marked as machine-bootable. +func (c *ImageCapabilities) IsMachineBootable() bool { + return c.MachineBootable != nil && *c.MachineBootable +} + +// IsContainer returns true if the image is explicitly marked as runnable on +// an OCI container host. +func (c *ImageCapabilities) IsContainer() bool { + return c.Container != nil && *c.Container +} + +// IsSystemd returns true if the image explicitly runs systemd. +func (c *ImageCapabilities) IsSystemd() bool { + return c.Systemd != nil && *c.Systemd +} + +// IsRuntimePackageManagement returns true if the image explicitly supports runtime +// package management. +func (c *ImageCapabilities) IsRuntimePackageManagement() bool { + return c.RuntimePackageManagement != nil && *c.RuntimePackageManagement +} + +// EnabledNames returns the TOML field names of capabilities that are explicitly set to +// true, in a stable order matching the struct field declaration order. +func (c *ImageCapabilities) EnabledNames() []string { + var names []string + + if c.IsMachineBootable() { + names = append(names, "machine-bootable") + } + + if c.IsContainer() { + names = append(names, "container") + } + + if c.IsSystemd() { + names = append(names, "systemd") + } + + if c.IsRuntimePackageManagement() { + names = append(names, "runtime-package-management") + } + + return names +} + +// ImageTestsConfig holds the test-related configuration for an image. +type ImageTestsConfig struct { + // TestSuites is the list of test suite references that apply to this image. Each + // reference identifies a test suite defined in the top-level [test-suites] section + // and may carry per-test metadata in the future (e.g., required vs optional). + TestSuites []TestSuiteRef `toml:"test-suites,omitempty" json:"testSuites,omitempty" jsonschema:"title=Test Suites,description=List of test suite references that apply to this image"` +} + +// TestSuiteRef is a reference to a named test suite. Using a structured type (rather than +// a bare string) allows per-test metadata to be added later without a breaking config change. +type TestSuiteRef struct { + // Name is the key into the top-level [test-suites] map. + Name string `toml:"name" json:"name" jsonschema:"required,title=Name,description=Name of the test suite (must match a key in [test-suites])"` +} + +// TestNames returns the test suite names referenced by this image. +func (i *ImageConfig) TestNames() []string { + names := make([]string, len(i.Tests.TestSuites)) + for idx, ref := range i.Tests.TestSuites { + names[idx] = ref.Name + } + + return names } // Defines where to find an image definition. @@ -69,6 +177,9 @@ func (i *ImageConfig) WithAbsolutePaths(referenceDir string) *ImageConfig { Description: i.Description, SourceConfigFile: i.SourceConfigFile, Definition: deep.MustCopy(i.Definition), + Capabilities: deep.MustCopy(i.Capabilities), + Tests: deep.MustCopy(i.Tests), + Publish: deep.MustCopy(i.Publish), } // Fix up paths. diff --git a/internal/projectconfig/loader.go b/internal/projectconfig/loader.go index 270d091..4655b12 100644 --- a/internal/projectconfig/loader.go +++ b/internal/projectconfig/loader.go @@ -42,6 +42,7 @@ func loadAndResolveProjectConfig( Distros: make(map[string]DistroDefinition), GroupsByComponent: make(map[string][]string), PackageGroups: make(map[string]PackageGroupConfig), + TestSuites: make(map[string]TestSuiteConfig), } for _, configFilePath := range configFilePaths { @@ -127,6 +128,10 @@ func mergeConfigFile(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { return err } + if err := mergeTestSuites(resolvedCfg, loadedCfg); err != nil { + return err + } + return nil } @@ -250,6 +255,24 @@ func mergePackageGroups(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error return nil } +// mergeTestSuites merges test suite definitions from a loaded config file into the +// resolved config. Duplicate test suite names are not allowed. +func mergeTestSuites(resolvedCfg *ProjectConfig, loadedCfg *ConfigFile) error { + for testName, test := range loadedCfg.TestSuites { + if _, ok := resolvedCfg.TestSuites[testName]; ok { + return fmt.Errorf("%w: test suite %#q", ErrDuplicateTestSuites, testName) + } + + // Fill out fields not explicitly serialized. + test.Name = testName + test.SourceConfigFile = loadedCfg + + resolvedCfg.TestSuites[testName] = test + } + + return nil +} + func loadProjectConfigWithIncludes( fs opctx.FS, filePath string, permissiveConfigParsing bool, ) ([]*ConfigFile, error) { diff --git a/internal/projectconfig/loader_test.go b/internal/projectconfig/loader_test.go index 1f1deb3..b0d5825 100644 --- a/internal/projectconfig/loader_test.go +++ b/internal/projectconfig/loader_test.go @@ -798,3 +798,102 @@ channel = "devel" } } } + +func TestLoadAndResolveProjectConfig_TestSuite(t *testing.T) { + const configContents = ` +[test-suites.smoke] +description = "Smoke tests for images" + +[test-suites.integration] +description = "Integration tests" +` + + 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) + + require.Len(t, config.TestSuites, 2) + + if assert.Contains(t, config.TestSuites, "smoke") { + smokeTest := config.TestSuites["smoke"] + assert.Equal(t, "smoke", smokeTest.Name) + assert.Equal(t, "Smoke tests for images", smokeTest.Description) + } + + if assert.Contains(t, config.TestSuites, "integration") { + integrationTest := config.TestSuites["integration"] + assert.Equal(t, "integration", integrationTest.Name) + assert.Equal(t, "Integration tests", integrationTest.Description) + } +} + +func TestLoadAndResolveProjectConfig_DuplicateTests(t *testing.T) { + testFiles := []struct { + path string + contents string + }{ + {testConfigPath, ` +includes = ["include.toml"] + +[test-suites.smoke] +description = "Smoke tests" +`}, + {"/project/include.toml", ` +[test-suites.smoke] +description = "Other smoke tests" +`}, + } + + 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)) + } + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testFiles[0].path) + require.ErrorIs(t, err, ErrDuplicateTestSuites) +} + +func TestLoadAndResolveProjectConfig_ImageWithValidTestRef(t *testing.T) { + const configContents = ` +[test-suites.smoke] +description = "Smoke tests" + +[images.myimage] +description = "Test image" + +[images.myimage.tests] +test-suites = [{ name = "smoke" }] +` + + 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) + + if assert.Contains(t, config.Images, "myimage") { + assert.Equal(t, []TestSuiteRef{{Name: "smoke"}}, config.Images["myimage"].Tests.TestSuites) + } +} + +func TestLoadAndResolveProjectConfig_ImageWithInvalidTestRef(t *testing.T) { + const configContents = ` +[images.myimage] +description = "Test image" + +[images.myimage.tests] +test-suites = [{ name = "nonexistent" }] +` + + ctx := testctx.NewCtx() + require.NoError(t, fileutils.WriteFile(ctx.FS(), testConfigPath, []byte(configContents), fileperms.PrivateFile)) + + _, err := loadAndResolveProjectConfig(ctx.FS(), false, testConfigPath) + require.Error(t, err) + require.ErrorIs(t, err, ErrUndefinedTestSuite) + assert.Contains(t, err.Error(), "nonexistent") +} diff --git a/internal/projectconfig/project.go b/internal/projectconfig/project.go index 7f781ec..276c418 100644 --- a/internal/projectconfig/project.go +++ b/internal/projectconfig/project.go @@ -34,6 +34,9 @@ type ProjectConfig struct { // Definitions of package groups with shared configuration. PackageGroups map[string]PackageGroupConfig `toml:"package-groups,omitempty" json:"packageGroups,omitempty" jsonschema:"title=Package groups,description=Mapping of package group names to configurations for publish-time routing"` + // Definitions of test suites. + TestSuites map[string]TestSuiteConfig `toml:"test-suites,omitempty" json:"testSuites,omitempty" jsonschema:"title=Test Suites,description=Mapping of test suite names to configurations"` + // Root config file path; not serialized. RootConfigFilePath string `toml:"-" json:"-"` // Map from component names to groups they belong to; not serialized. @@ -50,6 +53,7 @@ func NewProjectConfig() ProjectConfig { Distros: make(map[string]DistroDefinition), GroupsByComponent: make(map[string][]string), PackageGroups: make(map[string]PackageGroupConfig), + TestSuites: make(map[string]TestSuiteConfig), } } @@ -64,6 +68,10 @@ func (cfg *ProjectConfig) Validate() error { return err } + if err := validateImageTestReferences(cfg.Images, cfg.TestSuites); err != nil { + return err + } + return nil } @@ -90,6 +98,24 @@ func validatePackageGroupMembership(groups map[string]PackageGroupConfig) error return nil } +// validateImageTestReferences checks that every test suite name in an image's +// [ImageConfig.Tests.TestSuites] list corresponds to a defined entry in the top-level +// TestSuites map. +func validateImageTestReferences(images map[string]ImageConfig, tests map[string]TestSuiteConfig) error { + for imageName, image := range images { + for _, testName := range image.TestNames() { + if _, ok := tests[testName]; !ok { + return fmt.Errorf( + "%w: image %#q references test suite %#q, which is not defined in [test-suites]", + ErrUndefinedTestSuite, imageName, testName, + ) + } + } + } + + return nil +} + // Basic information regarding a project. type ProjectInfo struct { // Human-readable description of this project. diff --git a/internal/projectconfig/testsuite.go b/internal/projectconfig/testsuite.go new file mode 100644 index 0000000..44aa91c --- /dev/null +++ b/internal/projectconfig/testsuite.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig + +import "errors" + +var ( + // ErrDuplicateTestSuites is returned when duplicate conflicting test suite definitions are found. + ErrDuplicateTestSuites = errors.New("duplicate test suite") + // ErrUndefinedTestSuite is returned when an image references a test suite name that is not defined. + ErrUndefinedTestSuite = errors.New("undefined test suite reference") +) + +// TestSuiteConfig defines a named test suite. +type TestSuiteConfig struct { + // The test suite's name; not present in serialized TOML files (populated from the map key). + Name string `toml:"-" json:"name" table:",sortkey"` + + // Description of the test suite. + Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Description of this test suite"` + + // Reference to the source config file that this definition came from; not present + // in serialized files. + SourceConfigFile *ConfigFile `toml:"-" json:"-" table:"-"` +} diff --git a/internal/projectconfig/testsuite_test.go b/internal/projectconfig/testsuite_test.go new file mode 100644 index 0000000..28286ab --- /dev/null +++ b/internal/projectconfig/testsuite_test.go @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package projectconfig_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImageCapabilities_EnabledNames(t *testing.T) { + t.Run("all enabled", func(t *testing.T) { + caps := projectconfig.ImageCapabilities{ + MachineBootable: lo.ToPtr(true), + Container: lo.ToPtr(true), + Systemd: lo.ToPtr(true), + RuntimePackageManagement: lo.ToPtr(true), + } + assert.Equal(t, []string{ + "machine-bootable", "container", "systemd", "runtime-package-management", + }, caps.EnabledNames()) + }) + + t.Run("partial enabled", func(t *testing.T) { + caps := projectconfig.ImageCapabilities{ + Container: lo.ToPtr(true), + Systemd: lo.ToPtr(true), + } + assert.Equal(t, []string{"container", "systemd"}, caps.EnabledNames()) + }) + + t.Run("explicitly false excluded", func(t *testing.T) { + caps := projectconfig.ImageCapabilities{ + MachineBootable: lo.ToPtr(false), + Container: lo.ToPtr(true), + } + assert.Equal(t, []string{"container"}, caps.EnabledNames()) + }) + + t.Run("all nil returns nil", func(t *testing.T) { + caps := projectconfig.ImageCapabilities{} + assert.Nil(t, caps.EnabledNames()) + }) +} + +func TestImageConfig_TestNames(t *testing.T) { + t.Run("with tests", func(t *testing.T) { + img := projectconfig.ImageConfig{ + Tests: projectconfig.ImageTestsConfig{ + TestSuites: []projectconfig.TestSuiteRef{ + {Name: "smoke"}, + {Name: "integration"}, + }, + }, + } + assert.Equal(t, []string{"smoke", "integration"}, img.TestNames()) + }) + + t.Run("no tests returns empty", func(t *testing.T) { + img := projectconfig.ImageConfig{} + assert.Empty(t, img.TestNames()) + }) +} + +func TestValidateTestSuiteReferences(t *testing.T) { + t.Run("valid references", func(t *testing.T) { + cfg := projectconfig.ProjectConfig{ + Images: map[string]projectconfig.ImageConfig{ + "myimage": { + Name: "myimage", + Tests: projectconfig.ImageTestsConfig{TestSuites: []projectconfig.TestSuiteRef{{Name: "smoke"}}}, + }, + }, + TestSuites: map[string]projectconfig.TestSuiteConfig{ + "smoke": { + Name: "smoke", + }, + }, + Components: make(map[string]projectconfig.ComponentConfig), + ComponentGroups: make(map[string]projectconfig.ComponentGroupConfig), + Distros: make(map[string]projectconfig.DistroDefinition), + GroupsByComponent: make(map[string][]string), + PackageGroups: make(map[string]projectconfig.PackageGroupConfig), + } + assert.NoError(t, cfg.Validate()) + }) + + t.Run("undefined test reference", func(t *testing.T) { + cfg := projectconfig.ProjectConfig{ + Images: map[string]projectconfig.ImageConfig{ + "myimage": { + Name: "myimage", + Tests: projectconfig.ImageTestsConfig{TestSuites: []projectconfig.TestSuiteRef{{Name: "nonexistent"}}}, + }, + }, + TestSuites: make(map[string]projectconfig.TestSuiteConfig), + Components: make(map[string]projectconfig.ComponentConfig), + ComponentGroups: make(map[string]projectconfig.ComponentGroupConfig), + Distros: make(map[string]projectconfig.DistroDefinition), + GroupsByComponent: make(map[string][]string), + PackageGroups: make(map[string]projectconfig.PackageGroupConfig), + } + err := cfg.Validate() + require.Error(t, err) + require.ErrorIs(t, err, projectconfig.ErrUndefinedTestSuite) + assert.Contains(t, err.Error(), "nonexistent") + }) + + t.Run("image with no tests is valid", func(t *testing.T) { + cfg := projectconfig.ProjectConfig{ + Images: map[string]projectconfig.ImageConfig{ + "myimage": {Name: "myimage"}, + }, + TestSuites: make(map[string]projectconfig.TestSuiteConfig), + Components: make(map[string]projectconfig.ComponentConfig), + ComponentGroups: make(map[string]projectconfig.ComponentGroupConfig), + Distros: make(map[string]projectconfig.DistroDefinition), + GroupsByComponent: make(map[string][]string), + PackageGroups: make(map[string]projectconfig.PackageGroupConfig), + } + assert.NoError(t, cfg.Validate()) + }) +} diff --git a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap index b0406be..ebe3526 100755 --- a/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshotsContainer_config_generate-schema_stdout_1.snap @@ -342,6 +342,14 @@ "type": "object", "title": "Package groups", "description": "Definitions of package groups for shared binary package configuration" + }, + "test-suites": { + "additionalProperties": { + "$ref": "#/$defs/TestSuiteConfig" + }, + "type": "object", + "title": "Test Suites", + "description": "Definitions of test suites for this project" } }, "additionalProperties": false, @@ -465,6 +473,32 @@ "release-ver" ] }, + "ImageCapabilities": { + "properties": { + "machine-bootable": { + "type": "boolean", + "title": "Machine bootable", + "description": "Whether the image can be booted on a machine (bare metal or VM)" + }, + "container": { + "type": "boolean", + "title": "Container", + "description": "Whether the image can be run on an OCI container host" + }, + "systemd": { + "type": "boolean", + "title": "Systemd", + "description": "Whether the image runs systemd as its init system" + }, + "runtime-package-management": { + "type": "boolean", + "title": "Runtime package management", + "description": "Whether the image supports installing or removing packages at runtime" + } + }, + "additionalProperties": false, + "type": "object" + }, "ImageConfig": { "properties": { "description": { @@ -476,6 +510,21 @@ "$ref": "#/$defs/ImageDefinition", "title": "Definition", "description": "Identifies where to find the definition for this image" + }, + "capabilities": { + "$ref": "#/$defs/ImageCapabilities", + "title": "Capabilities", + "description": "Features and properties of this image" + }, + "tests": { + "$ref": "#/$defs/ImageTestsConfig", + "title": "Tests", + "description": "Test configuration for this image" + }, + "publish": { + "$ref": "#/$defs/ImagePublishConfig", + "title": "Publish settings", + "description": "Publishing settings for this image" } }, "additionalProperties": false, @@ -513,6 +562,34 @@ "additionalProperties": false, "type": "object" }, + "ImagePublishConfig": { + "properties": { + "channels": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Channels", + "description": "List of publish channels for this image" + } + }, + "additionalProperties": false, + "type": "object" + }, + "ImageTestsConfig": { + "properties": { + "test-suites": { + "items": { + "$ref": "#/$defs/TestSuiteRef" + }, + "type": "array", + "title": "Test Suites", + "description": "List of test suite references that apply to this image" + } + }, + "additionalProperties": false, + "type": "object" + }, "Origin": { "properties": { "type": { @@ -729,6 +806,31 @@ "type" ] }, + "TestSuiteConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Description of this test suite" + } + }, + "additionalProperties": false, + "type": "object" + }, + "TestSuiteRef": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the test suite (must match a key in [test-suites])" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ] + }, "ToolsConfig": { "properties": { "imageCustomizer": { diff --git a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap index b0406be..ebe3526 100755 --- a/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap +++ b/scenario/__snapshots__/TestSnapshots_config_generate-schema_stdout_1.snap @@ -342,6 +342,14 @@ "type": "object", "title": "Package groups", "description": "Definitions of package groups for shared binary package configuration" + }, + "test-suites": { + "additionalProperties": { + "$ref": "#/$defs/TestSuiteConfig" + }, + "type": "object", + "title": "Test Suites", + "description": "Definitions of test suites for this project" } }, "additionalProperties": false, @@ -465,6 +473,32 @@ "release-ver" ] }, + "ImageCapabilities": { + "properties": { + "machine-bootable": { + "type": "boolean", + "title": "Machine bootable", + "description": "Whether the image can be booted on a machine (bare metal or VM)" + }, + "container": { + "type": "boolean", + "title": "Container", + "description": "Whether the image can be run on an OCI container host" + }, + "systemd": { + "type": "boolean", + "title": "Systemd", + "description": "Whether the image runs systemd as its init system" + }, + "runtime-package-management": { + "type": "boolean", + "title": "Runtime package management", + "description": "Whether the image supports installing or removing packages at runtime" + } + }, + "additionalProperties": false, + "type": "object" + }, "ImageConfig": { "properties": { "description": { @@ -476,6 +510,21 @@ "$ref": "#/$defs/ImageDefinition", "title": "Definition", "description": "Identifies where to find the definition for this image" + }, + "capabilities": { + "$ref": "#/$defs/ImageCapabilities", + "title": "Capabilities", + "description": "Features and properties of this image" + }, + "tests": { + "$ref": "#/$defs/ImageTestsConfig", + "title": "Tests", + "description": "Test configuration for this image" + }, + "publish": { + "$ref": "#/$defs/ImagePublishConfig", + "title": "Publish settings", + "description": "Publishing settings for this image" } }, "additionalProperties": false, @@ -513,6 +562,34 @@ "additionalProperties": false, "type": "object" }, + "ImagePublishConfig": { + "properties": { + "channels": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Channels", + "description": "List of publish channels for this image" + } + }, + "additionalProperties": false, + "type": "object" + }, + "ImageTestsConfig": { + "properties": { + "test-suites": { + "items": { + "$ref": "#/$defs/TestSuiteRef" + }, + "type": "array", + "title": "Test Suites", + "description": "List of test suite references that apply to this image" + } + }, + "additionalProperties": false, + "type": "object" + }, "Origin": { "properties": { "type": { @@ -729,6 +806,31 @@ "type" ] }, + "TestSuiteConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Description of this test suite" + } + }, + "additionalProperties": false, + "type": "object" + }, + "TestSuiteRef": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the test suite (must match a key in [test-suites])" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ] + }, "ToolsConfig": { "properties": { "imageCustomizer": { diff --git a/schemas/azldev.schema.json b/schemas/azldev.schema.json index b0406be..ebe3526 100644 --- a/schemas/azldev.schema.json +++ b/schemas/azldev.schema.json @@ -342,6 +342,14 @@ "type": "object", "title": "Package groups", "description": "Definitions of package groups for shared binary package configuration" + }, + "test-suites": { + "additionalProperties": { + "$ref": "#/$defs/TestSuiteConfig" + }, + "type": "object", + "title": "Test Suites", + "description": "Definitions of test suites for this project" } }, "additionalProperties": false, @@ -465,6 +473,32 @@ "release-ver" ] }, + "ImageCapabilities": { + "properties": { + "machine-bootable": { + "type": "boolean", + "title": "Machine bootable", + "description": "Whether the image can be booted on a machine (bare metal or VM)" + }, + "container": { + "type": "boolean", + "title": "Container", + "description": "Whether the image can be run on an OCI container host" + }, + "systemd": { + "type": "boolean", + "title": "Systemd", + "description": "Whether the image runs systemd as its init system" + }, + "runtime-package-management": { + "type": "boolean", + "title": "Runtime package management", + "description": "Whether the image supports installing or removing packages at runtime" + } + }, + "additionalProperties": false, + "type": "object" + }, "ImageConfig": { "properties": { "description": { @@ -476,6 +510,21 @@ "$ref": "#/$defs/ImageDefinition", "title": "Definition", "description": "Identifies where to find the definition for this image" + }, + "capabilities": { + "$ref": "#/$defs/ImageCapabilities", + "title": "Capabilities", + "description": "Features and properties of this image" + }, + "tests": { + "$ref": "#/$defs/ImageTestsConfig", + "title": "Tests", + "description": "Test configuration for this image" + }, + "publish": { + "$ref": "#/$defs/ImagePublishConfig", + "title": "Publish settings", + "description": "Publishing settings for this image" } }, "additionalProperties": false, @@ -513,6 +562,34 @@ "additionalProperties": false, "type": "object" }, + "ImagePublishConfig": { + "properties": { + "channels": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Channels", + "description": "List of publish channels for this image" + } + }, + "additionalProperties": false, + "type": "object" + }, + "ImageTestsConfig": { + "properties": { + "test-suites": { + "items": { + "$ref": "#/$defs/TestSuiteRef" + }, + "type": "array", + "title": "Test Suites", + "description": "List of test suite references that apply to this image" + } + }, + "additionalProperties": false, + "type": "object" + }, "Origin": { "properties": { "type": { @@ -729,6 +806,31 @@ "type" ] }, + "TestSuiteConfig": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "description": "Description of this test suite" + } + }, + "additionalProperties": false, + "type": "object" + }, + "TestSuiteRef": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the test suite (must match a key in [test-suites])" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ] + }, "ToolsConfig": { "properties": { "imageCustomizer": {