From 83eab7f0bb98e799b89042b585041783456fe077 Mon Sep 17 00:00:00 2001 From: Jiri Tomasek Date: Mon, 25 May 2026 12:35:58 +0200 Subject: [PATCH] Add lifecycle metadata to product/solution and core release manifests - Adds an optional lifecycle section to product/solution and core release manifests - Bumps release manifest schema version to v1, defaults to v0 if not provided - Removed metadata.creationDate from release manifests, replaced by 'lifecycle.availabilityDate' - Updated tests - Updated example manifests - Updated docs --- docs/release-manifest.md | 24 ++- .../multi-node/suse-solution-manifest.yaml | 6 +- .../single-node/suse-solution-manifest.yaml | 6 +- internal/cli/action/release_info.go | 57 ++++-- internal/cli/action/release_info_test.go | 8 +- pkg/manifest/api/core/manifest.go | 8 +- pkg/manifest/api/core/manifest_test.go | 164 +++++++++++++++++- pkg/manifest/api/shared.go | 25 ++- pkg/manifest/api/shared_test.go | 15 +- pkg/manifest/api/solution/manifest.go | 8 +- pkg/manifest/api/solution/manifest_test.go | 131 +++++++++++++- pkg/manifest/api/validator.go | 2 + pkg/manifest/resolver/resolver_test.go | 12 +- .../testdata/full_core_release_manifest.yaml | 7 +- .../full_solution_release_manifest.yaml | 7 +- 15 files changed, 432 insertions(+), 48 deletions(-) diff --git a/docs/release-manifest.md b/docs/release-manifest.md index ef18d938..46270179 100644 --- a/docs/release-manifest.md +++ b/docs/release-manifest.md @@ -7,6 +7,10 @@ Ultimately, there are two types of release manifests: * [Solution Release Manifest](#solution-release-manifest) * [Core Platform Release Manifest](#core-platform-release-manifest) +## Schema versions + +Manifests may declare a schema version with a top-level `schema` field. Both `v0` and `v1` are accepted; when the field is omitted, the manifest is treated as `v0`. The `lifecycle` section is optional; when supplied, `availabilityDate` is required and all other lifecycle fields remain optional. + ## Solution Release Manifest > **NOTE:** Elemental is in active development and the Solution manifest API may change over time. @@ -20,10 +24,14 @@ Enables consumers to extend a specific `Core Platform` release with additional c Consumers who wish to create a release manifest for their solution should refer to the below API reference for information. ```yaml +schema: v1 metadata: name: "SUSE Solution" version: "4.2.0" - creationDate: "2025-07-10" +lifecycle: + availabilityDate: "2025-07-10" + fullSupportEndDate: "2026-07-10" + maintenanceSupportEndDate: "2027-07-10" corePlatform: image: "registry.suse.com/uc/release-manifest:0.0.1" components: @@ -54,10 +62,14 @@ components: url: "https://charts.jetstack.io" ``` +* `schema` - Optional; Schema version of this manifest. Accepted values are `v0` and `v1`; defaults to `v0` when omitted. * `metadata` - Optional; General information about the solution version that this manifest describes. * `name` - Required; Name of the solution that this manifest describes. * `version` - Required; Version of the solution release that this manifest describes. - * `creationDate` - Optional; Defines the release date for the specified version. +* `lifecycle` - Optional; Release lifecycle metadata. When supplied, dates must use ISO `YYYY-MM-DD` format. + * `availabilityDate` - Required (when `lifecycle` is present); Date when this release became available. + * `fullSupportEndDate` - Optional; Date when full support for this release ends. + * `maintenanceSupportEndDate` - Optional; Date when maintenance support for this release ends. * `corePlatform` - Required; Defines the `Core Platform` release version that this solution wishes to be based upon and extend. * `image` - Required; Container image pointing to the desired `Core Platform` release manifest. * `components` - Optional; Components with which to extend the `Core Platform`. @@ -102,10 +114,14 @@ Defines the set of components that make up a specific `Core Platform` release ve ```yaml # The values shown in this example are for illustrative purposes only # and should not be used directly +schema: v1 metadata: name: "SUSE Core Platform" version: "0.0.2" - creationDate: "2025-07-14" +lifecycle: + availabilityDate: "2025-07-14" + fullSupportEndDate: "2026-07-14" + maintenanceSupportEndDate: "2027-07-14" components: operatingSystem: image: @@ -126,7 +142,7 @@ components: url: "https://metallb.github.io/metallb" ``` -The manifest's structure is similar to that of the [Solution Release Manifest](#solution-release-manifest-api), with the key difference being the inclusion of components unique to the Core Platform (e.g. `operatingSystem` and `kubernetes`). +The manifest's structure is similar to that of the [Solution Release Manifest](#solution-release-manifest-api), with the key difference being the inclusion of components unique to the Core Platform (e.g. `operatingSystem` and `kubernetes`). The `schema`, `metadata`, and `lifecycle` sections follow the same rules as in the `Solution Release Manifest`. This reference focuses only on the unique to the Core Platform component APIs. Any components not mentioned here share the same description as those in the `Solution Release Manifest`. diff --git a/examples/elemental/customize/multi-node/suse-solution-manifest.yaml b/examples/elemental/customize/multi-node/suse-solution-manifest.yaml index 3001ac44..ce299ae9 100644 --- a/examples/elemental/customize/multi-node/suse-solution-manifest.yaml +++ b/examples/elemental/customize/multi-node/suse-solution-manifest.yaml @@ -1,7 +1,11 @@ +schema: v1 metadata: name: "suse-solution" version: "4.2.0" - creationDate: "2025-12-08" +lifecycle: + availabilityDate: "2025-12-08" + fullSupportEndDate: "2026-12-08" + maintenanceSupportEndDate: "2027-12-08" corePlatform: # Registry path to the release manifest OCI image of the Core Platform that this SUSE Solution extends image: "registry.suse.com/beta/uc/release-manifest:0.6_rke2_1.35" diff --git a/examples/elemental/customize/single-node/suse-solution-manifest.yaml b/examples/elemental/customize/single-node/suse-solution-manifest.yaml index 2264d61d..bf9885a2 100644 --- a/examples/elemental/customize/single-node/suse-solution-manifest.yaml +++ b/examples/elemental/customize/single-node/suse-solution-manifest.yaml @@ -1,8 +1,12 @@ # This configuration represents a fictional SUSE Solution and is meant for example purposes only. +schema: v1 metadata: name: "suse-solution" version: "4.2.0" - creationDate: "2025-12-08" +lifecycle: + availabilityDate: "2025-12-08" + fullSupportEndDate: "2026-12-08" + maintenanceSupportEndDate: "2027-12-08" corePlatform: # Release manifest OCI image of the Core Platform that this SUSE Solution extends image: "registry.suse.com/beta/uc/release-manifest:0.6_rke2_1.35" diff --git a/internal/cli/action/release_info.go b/internal/cli/action/release_info.go index 2521e3f0..bb833faf 100644 --- a/internal/cli/action/release_info.go +++ b/internal/cli/action/release_info.go @@ -51,10 +51,9 @@ const ( var markdown bool type basicInfo struct { - Name string - Version string - CreationDate string - Source string + Name string + Version string + Source string } func ReleaseInfo(_ context.Context, cmd *cli.Command) error { @@ -224,42 +223,62 @@ func newTable(markdown bool, out io.Writer) *tablewriter.Table { // most basic information that shall be printed for all the optional flags func printBasicData(cm *core.ReleaseManifest, sm *solution.ReleaseManifest, arg string, out io.Writer) error { var data [][]string - var cmBasic, smBasic *basicInfo table := newTable(markdown, out) - cmBasic = &basicInfo{ - Name: cm.Metadata.Name, - Version: cm.Metadata.Version, - CreationDate: cm.Metadata.CreationDate, - Source: arg, + cmBasic := &basicInfo{ + Name: cm.Metadata.Name, + Version: cm.Metadata.Version, + Source: arg, } + cmLifecycle := lifecycleRowValues(cm.Lifecycle) + if sm != nil { // we are dealing with a solution manifest - smBasic = &basicInfo{ - Name: sm.Metadata.Name, - Version: sm.Metadata.Version, - CreationDate: sm.Metadata.CreationDate, - Source: arg, + smBasic := &basicInfo{ + Name: sm.Metadata.Name, + Version: sm.Metadata.Version, + Source: arg, } + smLifecycle := lifecycleRowValues(sm.Lifecycle) cmBasic.Source = sm.CorePlatform.Image table.Header([]string{"Attribute", "Core Platform (Base)", "Solution Manifest (Extension)"}) data = append(data, []string{"Name", cmBasic.Name, smBasic.Name}) - data = append(data, []string{versionHdr, cmBasic.Version, sm.Metadata.Version}) - data = append(data, []string{"Release Date", cmBasic.CreationDate, sm.Metadata.CreationDate}) + data = append(data, []string{versionHdr, cmBasic.Version, smBasic.Version}) + data = append(data, []string{"Availability Date", cmLifecycle.availability, smLifecycle.availability}) + data = append(data, []string{"Full Support End", cmLifecycle.fullSupportEnd, smLifecycle.fullSupportEnd}) + data = append(data, []string{"Maintenance Support End", cmLifecycle.maintenanceSupportEnd, smLifecycle.maintenanceSupportEnd}) data = append(data, []string{sourceHdr, cmBasic.Source, smBasic.Source}) } else { // we are dealing with a core manifest table.Header([]string{"Attribute", "Core Platform (Base)"}) data = append(data, []string{"Name", cmBasic.Name}) data = append(data, []string{versionHdr, cmBasic.Version}) - data = append(data, []string{"Release Data", cmBasic.CreationDate}) + data = append(data, []string{"Availability Date", cmLifecycle.availability}) + data = append(data, []string{"Full Support End", cmLifecycle.fullSupportEnd}) + data = append(data, []string{"Maintenance Support End", cmLifecycle.maintenanceSupportEnd}) data = append(data, []string{sourceHdr, cmBasic.Source}) - } return printAndClearData(table, data, out) } +type lifecycleRow struct { + availability string + fullSupportEnd string + maintenanceSupportEnd string +} + +func lifecycleRowValues(l *api.Lifecycle) lifecycleRow { + if l == nil { + return lifecycleRow{} + } + return lifecycleRow{ + availability: l.AvailabilityDate, + fullSupportEnd: l.FullSupportEndDate, + maintenanceSupportEnd: l.MaintenanceSupportEndDate, + } +} + func printInfraData(cm *core.ReleaseManifest, out io.Writer) error { var data [][]string table := newTable(markdown, out) diff --git a/internal/cli/action/release_info_test.go b/internal/cli/action/release_info_test.go index 884857d2..0d502f31 100644 --- a/internal/cli/action/release_info_test.go +++ b/internal/cli/action/release_info_test.go @@ -23,10 +23,14 @@ var _ = Describe("Release info tests", Label("release-info"), func() { var cliCmd *cli.Command var buffer *bytes.Buffer var ctx context.Context - var manifest = `metadata: + var manifest = `schema: v1 +metadata: name: suse-core-test version: 0.6-rc.20260317 - creationDate: '2026-03-17' +lifecycle: + availabilityDate: '2026-03-17' + fullSupportEndDate: '2027-03-17' + maintenanceSupportEndDate: '2028-03-17' components: operatingSystem: image: diff --git a/pkg/manifest/api/core/manifest.go b/pkg/manifest/api/core/manifest.go index ca857675..cb855ca0 100644 --- a/pkg/manifest/api/core/manifest.go +++ b/pkg/manifest/api/core/manifest.go @@ -31,6 +31,7 @@ import ( type ReleaseManifest struct { Schema api.SchemaVersion `yaml:"schema,omitempty"` Metadata *api.Metadata `yaml:"metadata,omitempty"` + Lifecycle *api.Lifecycle `yaml:"lifecycle,omitempty"` Components Components `yaml:"components" validate:"required"` } @@ -56,7 +57,8 @@ type Image struct { } func Parse(data []byte) (*ReleaseManifest, error) { - if _, err := api.LoadSchemaVersion(data); err != nil { + schema, err := api.LoadSchemaVersion(data) + if err != nil { return nil, fmt.Errorf("parsing 'core' release manifest: %w", err) } @@ -77,5 +79,9 @@ func Parse(data []byte) (*ReleaseManifest, error) { return nil, fmt.Errorf("validating 'core' release manifest: %w", err) } + if err := api.ValidateMetadata(schema, rm.Metadata); err != nil { + return nil, fmt.Errorf("validating 'core' release manifest: %w", err) + } + return rm, nil } diff --git a/pkg/manifest/api/core/manifest_test.go b/pkg/manifest/api/core/manifest_test.go index 221c3abf..9ca802a0 100644 --- a/pkg/manifest/api/core/manifest_test.go +++ b/pkg/manifest/api/core/manifest_test.go @@ -29,21 +29,28 @@ import ( ) const invalidManifest = ` +schema: v1 metadata: name: "suse-core" version: "0.0.1" - upgradePathsFrom: - - "0.0.1-rc" - creationDate: "2000-01-01" +lifecycle: + availabilityDate: "2000-01-01" + fullSupportEndDate: "2001-01-01" + maintenanceSupportEndDate: "2002-01-01" corePlatform: name: "suse-edge" version: "0.0.0" ` const brokenManifest = ` +schema: v1 metadata: name: "suse-edge" version: "3.2.0" +lifecycle: + availabilityDate: "2000-01-01" + fullSupportEndDate: "2001-01-01" + maintenanceSupportEndDate: "2002-01-01" components: operatingSystem: image: @@ -60,6 +67,34 @@ components: type: "broken" ` +const missingLifecycleManifest = ` +schema: v1 +metadata: + name: "suse-core" + version: "1.0" +components: + operatingSystem: + image: + base: "registry.com/foo/bar/os-base:6.2" + iso: "registry.com/foo/bar/installer-iso:6.2" +` + +const badDateManifest = ` +schema: v1 +metadata: + name: "suse-core" + version: "1.0" +lifecycle: + availabilityDate: "not-a-date" + fullSupportEndDate: "2001-01-01" + maintenanceSupportEndDate: "2002-01-01" +components: + operatingSystem: + image: + base: "registry.com/foo/bar/os-base:6.2" + iso: "registry.com/foo/bar/installer-iso:6.2" +` + func TestCoreManifestSuite(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Core Release Manifest API test suite") @@ -74,12 +109,16 @@ var _ = Describe("ReleaseManifest", Label("release-manifest"), func() { Expect(err).NotTo(HaveOccurred()) Expect(rm).ToNot(BeNil()) - Expect(rm.Schema).To(BeEquivalentTo("v0")) + Expect(rm.Schema).To(BeEquivalentTo("v1")) Expect(rm.Metadata).ToNot(BeNil()) Expect(rm.Metadata.Name).To(Equal("suse-core")) Expect(rm.Metadata.Version).To(Equal("1.0")) - Expect(rm.Metadata.CreationDate).To(Equal("2000-01-01")) + + Expect(rm.Lifecycle).ToNot(BeNil()) + Expect(rm.Lifecycle.AvailabilityDate).To(Equal("2000-01-01")) + Expect(rm.Lifecycle.FullSupportEndDate).To(Equal("2001-01-01")) + Expect(rm.Lifecycle.MaintenanceSupportEndDate).To(Equal("2002-01-01")) Expect(rm.Components).ToNot(BeNil()) Expect(rm.Components.OperatingSystem).ToNot(BeNil()) @@ -133,6 +172,7 @@ components: rm, err := core.Parse(data) Expect(err).NotTo(HaveOccurred()) Expect(rm).ToNot(BeNil()) + Expect(rm.Schema).To(BeEquivalentTo("")) }) It("succeeds with explicit schema v0", func() { @@ -150,6 +190,62 @@ components: Expect(rm.Schema).To(BeEquivalentTo("v0")) }) + It("allows metadata.creationDate under schema v0", func() { + data := []byte(` +schema: v0 +metadata: + name: "suse-core" + version: "1.0" + creationDate: "2000-01-01" +components: + operatingSystem: + image: + base: "registry.com/foo/bar/os-base:6.2" + iso: "registry.com/foo/bar/installer-iso:6.2" +`) + rm, err := core.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(rm.Metadata.CreationDate).To(Equal("2000-01-01")) + }) + + It("allows metadata.creationDate when schema is omitted (default v0)", func() { + data := []byte(` +metadata: + name: "suse-core" + version: "1.0" + creationDate: "2000-01-01" +components: + operatingSystem: + image: + base: "registry.com/foo/bar/os-base:6.2" + iso: "registry.com/foo/bar/installer-iso:6.2" +`) + rm, err := core.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(rm.Metadata.CreationDate).To(Equal("2000-01-01")) + }) + + It("rejects metadata.creationDate under schema v1", func() { + data := []byte(` +schema: v1 +metadata: + name: "suse-core" + version: "1.0" + creationDate: "2000-01-01" +lifecycle: + availabilityDate: "2000-01-01" +components: + operatingSystem: + image: + base: "registry.com/foo/bar/os-base:6.2" + iso: "registry.com/foo/bar/installer-iso:6.2" +`) + rm, err := core.Parse(data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`field "metadata.creationDate" is not allowed in schema "v1"`)) + Expect(rm).To(BeNil()) + }) + It("fails with unknown schema version", func() { data := []byte(` schema: v99 @@ -165,6 +261,64 @@ components: Expect(rm).To(BeNil()) }) + It("succeeds when the lifecycle section is omitted", func() { + data := []byte(missingLifecycleManifest) + rm, err := core.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(rm).ToNot(BeNil()) + Expect(rm.Lifecycle).To(BeNil()) + }) + + It("fails when a lifecycle date is malformed", func() { + data := []byte(badDateManifest) + rm, err := core.Parse(data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`field "ReleaseManifest.lifecycle.availabilityDate" must be a date in YYYY-MM-DD format, but got "not-a-date"`)) + Expect(rm).To(BeNil()) + }) + + It("fails when lifecycle is present but availabilityDate is missing", func() { + data := []byte(` +schema: v1 +metadata: + name: "suse-core" + version: "1.0" +lifecycle: + fullSupportEndDate: "2001-01-01" +components: + operatingSystem: + image: + base: "registry.com/foo/bar/os-base:6.2" + iso: "registry.com/foo/bar/installer-iso:6.2" +`) + rm, err := core.Parse(data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`field "ReleaseManifest.lifecycle.availabilityDate" is required`)) + Expect(rm).To(BeNil()) + }) + + It("accepts a lifecycle block with only availabilityDate", func() { + data := []byte(` +schema: v1 +metadata: + name: "suse-core" + version: "1.0" +lifecycle: + availabilityDate: "2000-01-01" +components: + operatingSystem: + image: + base: "registry.com/foo/bar/os-base:6.2" + iso: "registry.com/foo/bar/installer-iso:6.2" +`) + rm, err := core.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(rm).ToNot(BeNil()) + Expect(rm.Lifecycle).ToNot(BeNil()) + Expect(rm.Lifecycle.AvailabilityDate).To(Equal("2000-01-01")) + Expect(rm.Lifecycle.FullSupportEndDate).To(BeEmpty()) + }) + It("fails when manifest is broken", func() { expErrors := []string{ "field \"ReleaseManifest.components.operatingSystem.image.iso\" is required", diff --git a/pkg/manifest/api/shared.go b/pkg/manifest/api/shared.go index 61d60d68..f07e014d 100644 --- a/pkg/manifest/api/shared.go +++ b/pkg/manifest/api/shared.go @@ -29,7 +29,10 @@ import ( type SchemaVersion string -const SchemaV0 SchemaVersion = "v0" +const ( + SchemaV0 SchemaVersion = "v0" + SchemaV1 SchemaVersion = "v1" +) type schemaHeader struct { SchemaVersion SchemaVersion `yaml:"schema"` @@ -51,6 +54,8 @@ func LoadSchemaVersion(data []byte) (SchemaVersion, error) { } switch header.SchemaVersion { + case SchemaV1: + return SchemaV1, nil case SchemaV0: return SchemaV0, nil default: @@ -71,6 +76,24 @@ type Metadata struct { CreationDate string `yaml:"creationDate,omitempty"` } +// ValidateMetadata enforces schema-version-specific rules on metadata that +// cannot be expressed via struct tags. +func ValidateMetadata(schema SchemaVersion, m *Metadata) error { + if m == nil { + return nil + } + if schema == SchemaV1 && m.CreationDate != "" { + return fmt.Errorf(`field "metadata.creationDate" is not allowed in schema %q; use "lifecycle.availabilityDate" instead`, SchemaV1) + } + return nil +} + +type Lifecycle struct { + AvailabilityDate string `yaml:"availabilityDate" validate:"required,datetime=2006-01-02"` + FullSupportEndDate string `yaml:"fullSupportEndDate,omitempty" validate:"omitempty,datetime=2006-01-02"` + MaintenanceSupportEndDate string `yaml:"maintenanceSupportEndDate,omitempty" validate:"omitempty,datetime=2006-01-02"` +} + type Helm struct { Charts []*HelmChart `yaml:"charts" validate:"dive"` Repositories []*HelmRepository `yaml:"repositories" validate:"dive"` diff --git a/pkg/manifest/api/shared_test.go b/pkg/manifest/api/shared_test.go index d73fb410..211577cf 100644 --- a/pkg/manifest/api/shared_test.go +++ b/pkg/manifest/api/shared_test.go @@ -25,14 +25,15 @@ import ( ) var _ = Describe("LoadSchemaVersion", Label("release-manifest"), func() { - It("defaults to v0 when schema field is missing", func() { + It("returns v1 when schema is explicitly set to v1", func() { data := []byte(` +schema: v1 metadata: name: "test" `) version, err := api.LoadSchemaVersion(data) Expect(err).NotTo(HaveOccurred()) - Expect(version).To(Equal(api.SchemaV0)) + Expect(version).To(Equal(api.SchemaV1)) }) It("returns v0 when schema is explicitly set to v0", func() { @@ -46,6 +47,16 @@ metadata: Expect(version).To(Equal(api.SchemaV0)) }) + It("defaults to v0 when schema field is missing", func() { + data := []byte(` +metadata: + name: "test" +`) + version, err := api.LoadSchemaVersion(data) + Expect(err).NotTo(HaveOccurred()) + Expect(version).To(Equal(api.SchemaV0)) + }) + It("fails with an unsupported schema version", func() { data := []byte(` schema: v99 diff --git a/pkg/manifest/api/solution/manifest.go b/pkg/manifest/api/solution/manifest.go index 31d22db5..f9970145 100644 --- a/pkg/manifest/api/solution/manifest.go +++ b/pkg/manifest/api/solution/manifest.go @@ -31,6 +31,7 @@ import ( type ReleaseManifest struct { Schema api.SchemaVersion `yaml:"schema,omitempty"` Metadata *api.Metadata `yaml:"metadata,omitempty"` + Lifecycle *api.Lifecycle `yaml:"lifecycle,omitempty"` CorePlatform *CorePlatform `yaml:"corePlatform" validate:"required"` Components Components `yaml:"components,omitempty"` } @@ -45,7 +46,8 @@ type Components struct { } func Parse(data []byte) (*ReleaseManifest, error) { - if _, err := api.LoadSchemaVersion(data); err != nil { + schema, err := api.LoadSchemaVersion(data) + if err != nil { return nil, fmt.Errorf("parsing 'solution' release manifest: %w", err) } @@ -66,5 +68,9 @@ func Parse(data []byte) (*ReleaseManifest, error) { return nil, fmt.Errorf("validating 'solution' release manifest: %w", err) } + if err := api.ValidateMetadata(schema, rm.Metadata); err != nil { + return nil, fmt.Errorf("validating 'solution' release manifest: %w", err) + } + return rm, nil } diff --git a/pkg/manifest/api/solution/manifest_test.go b/pkg/manifest/api/solution/manifest_test.go index 466a85d2..72bacc53 100644 --- a/pkg/manifest/api/solution/manifest_test.go +++ b/pkg/manifest/api/solution/manifest_test.go @@ -29,12 +29,14 @@ import ( ) const unknownFieldManifest = ` +schema: v1 metadata: name: "suse-edge" version: "3.2.0" - upgradePathsFrom: - - "3.1.2" - creationDate: "2025-01-20" +lifecycle: + availabilityDate: "2025-01-20" + fullSupportEndDate: "2026-01-20" + maintenanceSupportEndDate: "2027-01-20" components: operatingSystem: version: "6.2" @@ -42,9 +44,14 @@ components: ` const brokenManifest = ` +schema: v1 metadata: name: "suse-edge" version: "3.2.0" +lifecycle: + availabilityDate: "2025-01-20" + fullSupportEndDate: "2026-01-20" + maintenanceSupportEndDate: "2027-01-20" components: systemd: extensions: @@ -58,6 +65,22 @@ components: type: "broken" ` +const missingLifecycleManifest = ` +schema: v1 +corePlatform: + image: "foo.example.com/bar/release-manifest:1.0" +` + +const badDateManifest = ` +schema: v1 +lifecycle: + availabilityDate: "not-a-date" + fullSupportEndDate: "2026-01-20" + maintenanceSupportEndDate: "2027-01-20" +corePlatform: + image: "foo.example.com/bar/release-manifest:1.0" +` + func TestSolutionManifestSuite(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Solution Release Manifest API test suite") @@ -72,12 +95,16 @@ var _ = Describe("ReleaseManifest", Label("release-manifest"), func() { Expect(err).NotTo(HaveOccurred()) Expect(rm).ToNot(BeNil()) - Expect(rm.Schema).To(BeEquivalentTo("v0")) + Expect(rm.Schema).To(BeEquivalentTo("v1")) Expect(rm.Metadata).ToNot(BeNil()) Expect(rm.Metadata.Name).To(Equal("suse-edge")) Expect(rm.Metadata.Version).To(Equal("3.2.0")) - Expect(rm.Metadata.CreationDate).To(Equal("2025-01-20")) + + Expect(rm.Lifecycle).ToNot(BeNil()) + Expect(rm.Lifecycle.AvailabilityDate).To(Equal("2025-01-20")) + Expect(rm.Lifecycle.FullSupportEndDate).To(Equal("2026-01-20")) + Expect(rm.Lifecycle.MaintenanceSupportEndDate).To(Equal("2027-01-20")) Expect(rm.CorePlatform).ToNot(BeNil()) Expect(rm.CorePlatform.Image).To(Equal("foo.example.com/bar/release-manifest:1.0")) @@ -115,6 +142,7 @@ corePlatform: rm, err := solution.Parse(data) Expect(err).NotTo(HaveOccurred()) Expect(rm).ToNot(BeNil()) + Expect(rm.Schema).To(BeEquivalentTo("")) }) It("succeeds with explicit schema v0", func() { @@ -141,6 +169,99 @@ corePlatform: Expect(rm).To(BeNil()) }) + It("allows metadata.creationDate under schema v0", func() { + data := []byte(` +schema: v0 +metadata: + name: "suse-edge" + version: "3.2.0" + creationDate: "2025-01-20" +corePlatform: + image: "foo.example.com/bar/release-manifest:1.0" +`) + rm, err := solution.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(rm.Metadata.CreationDate).To(Equal("2025-01-20")) + }) + + It("allows metadata.creationDate when schema is omitted (default v0)", func() { + data := []byte(` +metadata: + name: "suse-edge" + version: "3.2.0" + creationDate: "2025-01-20" +corePlatform: + image: "foo.example.com/bar/release-manifest:1.0" +`) + rm, err := solution.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(rm.Metadata.CreationDate).To(Equal("2025-01-20")) + }) + + It("rejects metadata.creationDate under schema v1", func() { + data := []byte(` +schema: v1 +metadata: + name: "suse-edge" + version: "3.2.0" + creationDate: "2025-01-20" +lifecycle: + availabilityDate: "2025-01-20" +corePlatform: + image: "foo.example.com/bar/release-manifest:1.0" +`) + rm, err := solution.Parse(data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`field "metadata.creationDate" is not allowed in schema "v1"`)) + Expect(rm).To(BeNil()) + }) + + It("succeeds when the lifecycle section is omitted", func() { + data := []byte(missingLifecycleManifest) + rm, err := solution.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(rm).ToNot(BeNil()) + Expect(rm.Lifecycle).To(BeNil()) + }) + + It("fails when a lifecycle date is malformed", func() { + data := []byte(badDateManifest) + rm, err := solution.Parse(data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`field "ReleaseManifest.lifecycle.availabilityDate" must be a date in YYYY-MM-DD format, but got "not-a-date"`)) + Expect(rm).To(BeNil()) + }) + + It("fails when lifecycle is present but availabilityDate is missing", func() { + data := []byte(` +schema: v1 +lifecycle: + fullSupportEndDate: "2026-01-20" +corePlatform: + image: "foo.example.com/bar/release-manifest:1.0" +`) + rm, err := solution.Parse(data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(`field "ReleaseManifest.lifecycle.availabilityDate" is required`)) + Expect(rm).To(BeNil()) + }) + + It("accepts a lifecycle block with only availabilityDate", func() { + data := []byte(` +schema: v1 +lifecycle: + availabilityDate: "2025-01-20" +corePlatform: + image: "foo.example.com/bar/release-manifest:1.0" +`) + rm, err := solution.Parse(data) + Expect(err).NotTo(HaveOccurred()) + Expect(rm).ToNot(BeNil()) + Expect(rm.Lifecycle).ToNot(BeNil()) + Expect(rm.Lifecycle.AvailabilityDate).To(Equal("2025-01-20")) + Expect(rm.Lifecycle.FullSupportEndDate).To(BeEmpty()) + }) + It("fails when unknown field is introduced", func() { expErrMsg := "field operatingSystem not found in type solution.Components" data := []byte(unknownFieldManifest) diff --git a/pkg/manifest/api/validator.go b/pkg/manifest/api/validator.go index bfce0ed9..7fd0a924 100644 --- a/pkg/manifest/api/validator.go +++ b/pkg/manifest/api/validator.go @@ -56,6 +56,8 @@ func FormatErrors(errs validator.ValidationErrors) error { messages = append(messages, fmt.Sprintf("field %q must be one of [%s], but got %q", err.Namespace(), err.Param(), err.Value())) case "url": messages = append(messages, fmt.Sprintf("field %q must be a valid URL, but got %q", err.Namespace(), err.Value())) + case "datetime": + messages = append(messages, fmt.Sprintf("field %q must be a date in YYYY-MM-DD format, but got %q", err.Namespace(), err.Value())) default: messages = append(messages, fmt.Sprintf("field %q failed validation on tag %q", err.Namespace(), err.Tag())) } diff --git a/pkg/manifest/resolver/resolver_test.go b/pkg/manifest/resolver/resolver_test.go index 591da54a..4fb1d2c4 100644 --- a/pkg/manifest/resolver/resolver_test.go +++ b/pkg/manifest/resolver/resolver_test.go @@ -148,7 +148,11 @@ func validateResolvedManifest(rm *resolver.ResolvedManifest, coreOnly bool) { Expect(rm.CorePlatform.Metadata).ToNot(BeNil()) Expect(rm.CorePlatform.Metadata.Name).To(Equal("suse-core")) Expect(rm.CorePlatform.Metadata.Version).To(Equal("1.0")) - Expect(rm.CorePlatform.Metadata.CreationDate).To(Equal("2000-01-01")) + + Expect(rm.CorePlatform.Lifecycle).ToNot(BeNil()) + Expect(rm.CorePlatform.Lifecycle.AvailabilityDate).To(Equal("2000-01-01")) + Expect(rm.CorePlatform.Lifecycle.FullSupportEndDate).To(Equal("2001-01-01")) + Expect(rm.CorePlatform.Lifecycle.MaintenanceSupportEndDate).To(Equal("2002-01-01")) Expect(rm.CorePlatform.Components).ToNot(BeNil()) Expect(rm.CorePlatform.Components.OperatingSystem).ToNot(BeNil()) @@ -187,7 +191,11 @@ func validateResolvedManifest(rm *resolver.ResolvedManifest, coreOnly bool) { Expect(rm.SolutionExtension.Metadata).ToNot(BeNil()) Expect(rm.SolutionExtension.Metadata.Name).To(Equal("suse-edge")) Expect(rm.SolutionExtension.Metadata.Version).To(Equal("3.2.0")) - Expect(rm.SolutionExtension.Metadata.CreationDate).To(Equal("2025-01-20")) + + Expect(rm.SolutionExtension.Lifecycle).ToNot(BeNil()) + Expect(rm.SolutionExtension.Lifecycle.AvailabilityDate).To(Equal("2025-01-20")) + Expect(rm.SolutionExtension.Lifecycle.FullSupportEndDate).To(Equal("2026-01-20")) + Expect(rm.SolutionExtension.Lifecycle.MaintenanceSupportEndDate).To(Equal("2027-01-20")) Expect(rm.SolutionExtension.CorePlatform).ToNot(BeNil()) Expect(rm.SolutionExtension.CorePlatform.Image).To(Equal("foo.example.com/bar/release-manifest:1.0")) diff --git a/pkg/manifest/testdata/full_core_release_manifest.yaml b/pkg/manifest/testdata/full_core_release_manifest.yaml index 23dcdd83..9b1764cc 100644 --- a/pkg/manifest/testdata/full_core_release_manifest.yaml +++ b/pkg/manifest/testdata/full_core_release_manifest.yaml @@ -13,11 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -schema: v0 +schema: v1 metadata: name: "suse-core" version: "1.0" - creationDate: "2000-01-01" +lifecycle: + availabilityDate: "2000-01-01" + fullSupportEndDate: "2001-01-01" + maintenanceSupportEndDate: "2002-01-01" components: operatingSystem: image: diff --git a/pkg/manifest/testdata/full_solution_release_manifest.yaml b/pkg/manifest/testdata/full_solution_release_manifest.yaml index 9da98951..b6f3c880 100644 --- a/pkg/manifest/testdata/full_solution_release_manifest.yaml +++ b/pkg/manifest/testdata/full_solution_release_manifest.yaml @@ -13,11 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -schema: v0 +schema: v1 metadata: name: "suse-edge" version: "3.2.0" - creationDate: "2025-01-20" +lifecycle: + availabilityDate: "2025-01-20" + fullSupportEndDate: "2026-01-20" + maintenanceSupportEndDate: "2027-01-20" corePlatform: image: "foo.example.com/bar/release-manifest:1.0" components: