diff --git a/alpha/template/semver/semver.go b/alpha/template/semver/semver.go index fb235089b..2b36d66f5 100644 --- a/alpha/template/semver/semver.go +++ b/alpha/template/semver/semver.go @@ -3,6 +3,7 @@ package semver import ( "cmp" "context" + "encoding/json" "fmt" "io" "slices" @@ -16,6 +17,7 @@ import ( "github.com/operator-framework/operator-registry/alpha/declcfg" "github.com/operator-framework/operator-registry/alpha/property" "github.com/operator-framework/operator-registry/alpha/template/api" + "github.com/operator-framework/operator-registry/pkg/registry" ) // Schema @@ -115,6 +117,8 @@ func (t *semverTemplate) Render(ctx context.Context, reader io.Reader) (*declcfg out.Channels = channels out.Packages[0].DefaultChannel = sv.defaultChannel + sv.populatePackageMetadata(&out) + return &out, nil } @@ -428,6 +432,53 @@ func (sv *SemverTemplateData) generateChannels(semverChannels *bundleVersions) [ return outChannels } +// populatePackageMetadata extracts icon and description from the head bundle of the default channel -- if present -- +// and sets them on the package object +// this assumes that all schema have been validated as part of the declarative config aggregation +func (sv *SemverTemplateData) populatePackageMetadata(cfg *declcfg.DeclarativeConfig) { + if len(cfg.Packages) == 0 { + return + } + + // Find the default channel + channelIdx := slices.IndexFunc(cfg.Channels, func(ch declcfg.Channel) bool { + return ch.Name == sv.defaultChannel + }) + if channelIdx == -1 || len(cfg.Channels[channelIdx].Entries) == 0 { + return + } + + // Find the head bundle (the bundle with the highest version, which is the last entry in the channel) + // Since bundles are processed in ascending version order, the last entry is the head + headBundleName := cfg.Channels[channelIdx].Entries[len(cfg.Channels[channelIdx].Entries)-1].Name + + bundleIdx := slices.IndexFunc(cfg.Bundles, func(b declcfg.Bundle) bool { + return b.Name == headBundleName + }) + if bundleIdx == -1 || cfg.Bundles[bundleIdx].CsvJSON == "" { + return + } + + // Parse CSV JSON to extract metadata + var csv registry.ClusterServiceVersion + if err := json.Unmarshal([]byte(cfg.Bundles[bundleIdx].CsvJSON), &csv); err != nil { + return + } + + // Extract and set description + if desc, err := csv.GetDescription(); err == nil && desc != "" { + cfg.Packages[0].Description = desc + } + + // Extract and set icon + if icons, err := csv.GetIcons(); err == nil && len(icons) > 0 { + cfg.Packages[0].Icon = &declcfg.Icon{ + Data: icons[0].Base64data, + MediaType: icons[0].MediaType, + } + } +} + func (sv *SemverTemplateData) linkChannels(unlinkedChannels map[string]*declcfg.Channel, entries []entryTuple) []declcfg.Channel { channels := []declcfg.Channel{} diff --git a/alpha/template/semver/semver_test.go b/alpha/template/semver/semver_test.go index 79b17196b..4f3f30712 100644 --- a/alpha/template/semver/semver_test.go +++ b/alpha/template/semver/semver_test.go @@ -611,6 +611,380 @@ func TestGetVersionsFromStandardChannel(t *testing.T) { } } +func TestPopulatePackageMetadata(t *testing.T) { + tests := []struct { + name string + cfg declcfg.DeclarativeConfig + sv SemverTemplateData + expectIcon bool + expectDesc bool + expectedDesc string + expectedIcon *declcfg.Icon + }{ + { + name: "successfully populate icon and description from head bundle", + sv: SemverTemplateData{ + defaultChannel: "stable-v1", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "test-operator", + }, + }, + Channels: []declcfg.Channel{ + { + Name: "stable-v1", + Package: "test-operator", + Entries: []declcfg.ChannelEntry{ + {Name: "test-operator-v1.0.0"}, + {Name: "test-operator-v1.1.0"}, + }, + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "test-operator-v1.0.0", + Image: "quay.io/test/operator:v1.0.0", + CsvJSON: `{ + "apiVersion": "operators.coreos.com/v1alpha1", + "kind": "ClusterServiceVersion", + "metadata": {"name": "test-operator.v1.0.0"}, + "spec": { + "description": "Old description", + "icon": [{"base64data": "b2xkZGF0YQ==", "mediatype": "image/png"}] + } + }`, + }, + { + Name: "test-operator-v1.1.0", + Image: "quay.io/test/operator:v1.1.0", + CsvJSON: `{ + "apiVersion": "operators.coreos.com/v1alpha1", + "kind": "ClusterServiceVersion", + "metadata": {"name": "test-operator.v1.1.0"}, + "spec": { + "description": "Test operator provides awesome functionality", + "icon": [{"base64data": "aGVsbG93b3JsZA==", "mediatype": "image/svg+xml"}] + } + }`, + }, + }, + }, + expectIcon: true, + expectDesc: true, + expectedDesc: "Test operator provides awesome functionality", + expectedIcon: &declcfg.Icon{ + Data: []byte("helloworld"), + MediaType: "image/svg+xml", + }, + }, + { + name: "no packages in config", + sv: SemverTemplateData{ + defaultChannel: "stable", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{}, + }, + expectIcon: false, + expectDesc: false, + }, + { + name: "default channel not found", + sv: SemverTemplateData{ + defaultChannel: "nonexistent", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + {Schema: "olm.package", Name: "test-operator"}, + }, + Channels: []declcfg.Channel{ + {Name: "stable", Package: "test-operator"}, + }, + }, + expectIcon: false, + expectDesc: false, + }, + { + name: "empty channel entries", + sv: SemverTemplateData{ + defaultChannel: "stable", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + {Schema: "olm.package", Name: "test-operator"}, + }, + Channels: []declcfg.Channel{ + {Name: "stable", Package: "test-operator", Entries: []declcfg.ChannelEntry{}}, + }, + }, + expectIcon: false, + expectDesc: false, + }, + { + name: "head bundle not found in bundles", + sv: SemverTemplateData{ + defaultChannel: "stable", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + {Schema: "olm.package", Name: "test-operator"}, + }, + Channels: []declcfg.Channel{ + { + Name: "stable", + Package: "test-operator", + Entries: []declcfg.ChannelEntry{{Name: "missing-bundle"}}, + }, + }, + Bundles: []declcfg.Bundle{}, + }, + expectIcon: false, + expectDesc: false, + }, + { + name: "bundle has no CSV JSON", + sv: SemverTemplateData{ + defaultChannel: "stable", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + {Schema: "olm.package", Name: "test-operator"}, + }, + Channels: []declcfg.Channel{ + { + Name: "stable", + Package: "test-operator", + Entries: []declcfg.ChannelEntry{{Name: "test-bundle"}}, + }, + }, + Bundles: []declcfg.Bundle{ + {Name: "test-bundle", Image: "quay.io/test:v1", CsvJSON: ""}, + }, + }, + expectIcon: false, + expectDesc: false, + }, + { + name: "CSV has no description", + sv: SemverTemplateData{ + defaultChannel: "stable", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + {Schema: "olm.package", Name: "test-operator"}, + }, + Channels: []declcfg.Channel{ + { + Name: "stable", + Package: "test-operator", + Entries: []declcfg.ChannelEntry{{Name: "test-bundle"}}, + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "test-bundle", + Image: "quay.io/test:v1", + CsvJSON: `{ + "apiVersion": "operators.coreos.com/v1alpha1", + "kind": "ClusterServiceVersion", + "metadata": {"name": "test.v1.0.0"}, + "spec": { + "icon": [{"base64data": "aGVsbG8=", "mediatype": "image/png"}] + } + }`, + }, + }, + }, + expectIcon: true, + expectDesc: false, + expectedIcon: &declcfg.Icon{ + Data: []byte("hello"), + MediaType: "image/png", + }, + }, + { + name: "CSV has no icon", + sv: SemverTemplateData{ + defaultChannel: "stable", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + {Schema: "olm.package", Name: "test-operator"}, + }, + Channels: []declcfg.Channel{ + { + Name: "stable", + Package: "test-operator", + Entries: []declcfg.ChannelEntry{{Name: "test-bundle"}}, + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "test-bundle", + Image: "quay.io/test:v1", + CsvJSON: `{ + "apiVersion": "operators.coreos.com/v1alpha1", + "kind": "ClusterServiceVersion", + "metadata": {"name": "test.v1.0.0"}, + "spec": { + "description": "Test description" + } + }`, + }, + }, + }, + expectIcon: false, + expectDesc: true, + expectedDesc: "Test description", + }, + { + name: "CSV has empty description", + sv: SemverTemplateData{ + defaultChannel: "stable", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + {Schema: "olm.package", Name: "test-operator"}, + }, + Channels: []declcfg.Channel{ + { + Name: "stable", + Package: "test-operator", + Entries: []declcfg.ChannelEntry{{Name: "test-bundle"}}, + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "test-bundle", + Image: "quay.io/test:v1", + CsvJSON: `{ + "apiVersion": "operators.coreos.com/v1alpha1", + "kind": "ClusterServiceVersion", + "metadata": {"name": "test.v1.0.0"}, + "spec": { + "description": "", + "icon": [{"base64data": "aGVsbG8=", "mediatype": "image/png"}] + } + }`, + }, + }, + }, + expectIcon: true, + expectDesc: false, + expectedIcon: &declcfg.Icon{ + Data: []byte("hello"), + MediaType: "image/png", + }, + }, + { + name: "CSV has empty icons array", + sv: SemverTemplateData{ + defaultChannel: "stable", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + {Schema: "olm.package", Name: "test-operator"}, + }, + Channels: []declcfg.Channel{ + { + Name: "stable", + Package: "test-operator", + Entries: []declcfg.ChannelEntry{{Name: "test-bundle"}}, + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "test-bundle", + Image: "quay.io/test:v1", + CsvJSON: `{ + "apiVersion": "operators.coreos.com/v1alpha1", + "kind": "ClusterServiceVersion", + "metadata": {"name": "test.v1.0.0"}, + "spec": { + "description": "Test description", + "icon": [] + } + }`, + }, + }, + }, + expectIcon: false, + expectDesc: true, + expectedDesc: "Test description", + }, + { + name: "CSV has multiple icons - uses first one", + sv: SemverTemplateData{ + defaultChannel: "stable", + }, + cfg: declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + {Schema: "olm.package", Name: "test-operator"}, + }, + Channels: []declcfg.Channel{ + { + Name: "stable", + Package: "test-operator", + Entries: []declcfg.ChannelEntry{{Name: "test-bundle"}}, + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "test-bundle", + Image: "quay.io/test:v1", + CsvJSON: `{ + "apiVersion": "operators.coreos.com/v1alpha1", + "kind": "ClusterServiceVersion", + "metadata": {"name": "test.v1.0.0"}, + "spec": { + "description": "Test description", + "icon": [ + {"base64data": "Zmlyc3Q=", "mediatype": "image/png"}, + {"base64data": "c2Vjb25k", "mediatype": "image/svg+xml"} + ] + } + }`, + }, + }, + }, + expectIcon: true, + expectDesc: true, + expectedDesc: "Test description", + expectedIcon: &declcfg.Icon{ + Data: []byte("first"), + MediaType: "image/png", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.sv.populatePackageMetadata(&tt.cfg) + + if len(tt.cfg.Packages) == 0 { + return + } + + if tt.expectDesc { + require.Equal(t, tt.expectedDesc, tt.cfg.Packages[0].Description) + } else { + require.Empty(t, tt.cfg.Packages[0].Description) + } + + if tt.expectIcon { + require.NotNil(t, tt.cfg.Packages[0].Icon) + require.Equal(t, tt.expectedIcon.Data, tt.cfg.Packages[0].Icon.Data) + require.Equal(t, tt.expectedIcon.MediaType, tt.cfg.Packages[0].Icon.MediaType) + } else { + require.Nil(t, tt.cfg.Packages[0].Icon) + } + }) + } +} + func TestBailOnVersionBuildMetadata(t *testing.T) { sv := SemverTemplateData{ Stable: channelBundles{