diff --git a/alpha/declcfg/declcfg.go b/alpha/declcfg/declcfg.go index 9e4f752ee..bacf4e0aa 100644 --- a/alpha/declcfg/declcfg.go +++ b/alpha/declcfg/declcfg.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" + "github.com/blang/semver/v4" "golang.org/x/text/cases" utilerrors "k8s.io/apimachinery/pkg/util/errors" "k8s.io/apimachinery/pkg/util/sets" @@ -206,3 +207,71 @@ func (destination *DeclarativeConfig) Merge(src *DeclarativeConfig) { destination.Others = append(destination.Others, src.Others...) destination.Deprecations = append(destination.Deprecations, src.Deprecations...) } + +type CompositeVersion struct { + Version semver.Version + Release semver.Version +} + +func (cv *CompositeVersion) Compare(other *CompositeVersion) int { + if cv.Version.NE(other.Version) { + return cv.Version.Compare(other.Version) + } + hasrelease := len(cv.Release.Pre) > 0 + otherhasrelease := len(other.Release.Pre) > 0 + if hasrelease && !otherhasrelease { + return 1 + } + if !hasrelease && otherhasrelease { + return -1 + } + return cv.Release.Compare(other.Release) +} + +// order by version, then +// release, if present +func (b *Bundle) Compare(other *Bundle) int { + if b.Name == other.Name { + return 0 + } + acv, err := b.CompositeVersion() + if err != nil { + return 0 + } + otherCv, err := other.CompositeVersion() + if err != nil { + return 0 + } + return acv.Compare(otherCv) +} + +func (b *Bundle) CompositeVersion() (*CompositeVersion, error) { + props, err := property.Parse(b.Properties) + if err != nil { + return nil, fmt.Errorf("parse properties for bundle %q: %v", b.Name, err) + } + if len(props.Packages) != 1 { + return nil, fmt.Errorf("bundle %q must have exactly 1 \"olm.package\" property, found %v", b.Name, len(props.Packages)) + } + v, err := semver.Parse(props.Packages[0].Version) + if err != nil { + return nil, fmt.Errorf("bundle %q has invalid version %q: %v", b.Name, props.Packages[0].Version, err) + } + + var r semver.Version + if props.Packages[0].Release != "" { + r, err = semver.Parse(fmt.Sprintf("0.0.0-%s", props.Packages[0].Release)) + if err != nil { + return nil, fmt.Errorf("error parsing bundle %q release version %q: %v", b.Name, props.Packages[0].Release, err) + } + // only need to check for build metadata since we are using explicit zero major, minor, and patch versions above + if len(r.Build) != 0 { + return nil, fmt.Errorf("bundle %q release version %q cannot contain build metadata", b.Name, props.Packages[0].Release) + } + } + + return &CompositeVersion{ + Version: v, + Release: r, + }, nil +} diff --git a/alpha/declcfg/declcfg_to_model.go b/alpha/declcfg/declcfg_to_model.go index 342cab403..62dfd13ca 100644 --- a/alpha/declcfg/declcfg_to_model.go +++ b/alpha/declcfg/declcfg_to_model.go @@ -135,6 +135,19 @@ func ConvertToModel(cfg DeclarativeConfig) (model.Model, error) { return nil, fmt.Errorf("error parsing bundle %q version %q: %v", b.Name, rawVersion, err) } + // Parse release version from the package property. + var relver semver.Version + if props.Packages[0].Release != "" { + relver, err = semver.Parse(fmt.Sprintf("0.0.0-%s", props.Packages[0].Release)) + if err != nil { + return nil, fmt.Errorf("error parsing bundle %q release version %q: %v", b.Name, props.Packages[0].Release, err) + } + // only need to check for build metadata since we are using explicit zero major, minor, and patch versions above + if len(relver.Build) != 0 { + return nil, fmt.Errorf("bundle %q release version %q cannot contain build metadata", b.Name, props.Packages[0].Release) + } + } + channelDefinedEntries[b.Package] = channelDefinedEntries[b.Package].Delete(b.Name) found := false for _, mch := range mpkg.Channels { @@ -147,6 +160,7 @@ func ConvertToModel(cfg DeclarativeConfig) (model.Model, error) { mb.Objects = b.Objects mb.PropertiesP = props mb.Version = ver + mb.Release = relver } } if !found { diff --git a/alpha/declcfg/declcfg_to_model_test.go b/alpha/declcfg/declcfg_to_model_test.go index bf848a8c3..7a5b36377 100644 --- a/alpha/declcfg/declcfg_to_model_test.go +++ b/alpha/declcfg/declcfg_to_model_test.go @@ -442,6 +442,70 @@ func TestConvertToModel(t *testing.T) { }, }, }, + { + name: "Error/InvalidReleaseVersion", + assertion: hasError(`error parsing bundle "foo.v0.1.0" release version "!!!": Invalid character(s) found in prerelease "!!!"`), + cfg: DeclarativeConfig{ + Packages: []Package{newTestPackage("foo", "alpha", svgSmallCircle)}, + Channels: []Channel{newTestChannel("foo", "alpha", ChannelEntry{Name: testBundleName("foo", "0.1.0")})}, + Bundles: []Bundle{newTestBundle("foo", "0.1.0", func(b *Bundle) { + b.Properties = []property.Property{ + property.MustBuildPackageRelease("foo", "0.1.0", "!!!"), + } + })}, + }, + }, + { + name: "Error/InvalidBundleNormalizedName", + assertion: hasError(`invalid index: +└── invalid package "foo": + └── invalid channel "alpha": + └── invalid bundle "foo.v0.1.0-alpha.1.0.0": + └── name "foo.v0.1.0-alpha.1.0.0" does not match normalized name "foo-v0.1.0-alpha.1.0.0"`), + cfg: DeclarativeConfig{ + Packages: []Package{newTestPackage("foo", "alpha", svgSmallCircle)}, + Channels: []Channel{newTestChannel("foo", "alpha", ChannelEntry{Name: "foo.v0.1.0-alpha.1.0.0"})}, + Bundles: []Bundle{newTestBundle("foo", "0.1.0", func(b *Bundle) { + b.Properties = []property.Property{ + property.MustBuildPackageRelease("foo", "0.1.0", "alpha.1.0.0"), + } + b.Name = "foo.v0.1.0-alpha.1.0.0" + })}, + }, + }, + { + name: "Success/ValidBundleReleaseVersion", + assertion: require.NoError, + cfg: DeclarativeConfig{ + Packages: []Package{newTestPackage("foo", "alpha", svgSmallCircle)}, + Channels: []Channel{newTestChannel("foo", "alpha", ChannelEntry{Name: "foo-v0.1.0-alpha.1.0.0"})}, + Bundles: []Bundle{newTestBundle("foo", "0.1.0", func(b *Bundle) { + b.Properties = []property.Property{ + property.MustBuildPackageRelease("foo", "0.1.0", "alpha.1.0.0"), + } + b.Name = "foo-v0.1.0-alpha.1.0.0" + })}, + }, + }, + { + name: "Error/BundleReleaseWithBuildMetadata", + assertion: hasError(`invalid index: +└── invalid package "foo": + └── invalid channel "alpha": + └── invalid bundle "foo.v0.1.0+alpha.1.0.0-0.0.1": + ├── name "foo.v0.1.0+alpha.1.0.0-0.0.1" does not match normalized name "foo-v0.1.0+alpha.1.0.0-0.0.1" + └── cannot use build metadata in version with a release version`), + cfg: DeclarativeConfig{ + Packages: []Package{newTestPackage("foo", "alpha", svgSmallCircle)}, + Channels: []Channel{newTestChannel("foo", "alpha", ChannelEntry{Name: "foo.v0.1.0+alpha.1.0.0-0.0.1"})}, + Bundles: []Bundle{newTestBundle("foo", "0.1.0", func(b *Bundle) { + b.Properties = []property.Property{ + property.MustBuildPackageRelease("foo", "0.1.0+alpha.1.0.0", "0.0.1"), + } + b.Name = "foo.v0.1.0+alpha.1.0.0-0.0.1" + })}, + }, + }, } for _, s := range specs { diff --git a/alpha/model/model.go b/alpha/model/model.go index af6c391e6..3149ae503 100644 --- a/alpha/model/model.go +++ b/alpha/model/model.go @@ -3,6 +3,7 @@ package model import ( "errors" "fmt" + "slices" "sort" "strings" @@ -52,75 +53,75 @@ type Package struct { Deprecation *Deprecation } -func (m *Package) Validate() error { - result := newValidationError(fmt.Sprintf("invalid package %q", m.Name)) +func (p *Package) Validate() error { + result := newValidationError(fmt.Sprintf("invalid package %q", p.Name)) - if m.Name == "" { + if p.Name == "" { result.subErrors = append(result.subErrors, errors.New("package name must not be empty")) } - if err := m.Icon.Validate(); err != nil { + if err := p.Icon.Validate(); err != nil { result.subErrors = append(result.subErrors, err) } - if m.DefaultChannel == nil { + if p.DefaultChannel == nil { result.subErrors = append(result.subErrors, fmt.Errorf("default channel must be set")) } - if len(m.Channels) == 0 { + if len(p.Channels) == 0 { result.subErrors = append(result.subErrors, fmt.Errorf("package must contain at least one channel")) } foundDefault := false - for name, ch := range m.Channels { + for name, ch := range p.Channels { if name != ch.Name { result.subErrors = append(result.subErrors, fmt.Errorf("channel key %q does not match channel name %q", name, ch.Name)) } if err := ch.Validate(); err != nil { result.subErrors = append(result.subErrors, err) } - if ch == m.DefaultChannel { + if ch == p.DefaultChannel { foundDefault = true } - if ch.Package != m { + if ch.Package != p { result.subErrors = append(result.subErrors, fmt.Errorf("channel %q not correctly linked to parent package", ch.Name)) } } - if err := m.validateUniqueBundleVersions(); err != nil { + if err := p.validateUniqueBundleVersions(); err != nil { result.subErrors = append(result.subErrors, err) } - if m.DefaultChannel != nil && !foundDefault { - result.subErrors = append(result.subErrors, fmt.Errorf("default channel %q not found in channels list", m.DefaultChannel.Name)) + if p.DefaultChannel != nil && !foundDefault { + result.subErrors = append(result.subErrors, fmt.Errorf("default channel %q not found in channels list", p.DefaultChannel.Name)) } - if err := m.Deprecation.Validate(); err != nil { + if err := p.Deprecation.Validate(); err != nil { result.subErrors = append(result.subErrors, fmt.Errorf("invalid deprecation: %v", err)) } return result.orNil() } -func (m *Package) validateUniqueBundleVersions() error { - versionsMap := map[string]semver.Version{} +func (p *Package) validateUniqueBundleVersions() error { + versionsMap := map[string]string{} bundlesWithVersion := map[string]sets.Set[string]{} - for _, ch := range m.Channels { + for _, ch := range p.Channels { for _, b := range ch.Bundles { - versionsMap[b.Version.String()] = b.Version - if bundlesWithVersion[b.Version.String()] == nil { - bundlesWithVersion[b.Version.String()] = sets.New[string]() + versionsMap[b.VersionString()] = b.VersionString() + if bundlesWithVersion[b.VersionString()] == nil { + bundlesWithVersion[b.VersionString()] = sets.New[string]() } - bundlesWithVersion[b.Version.String()].Insert(b.Name) + bundlesWithVersion[b.VersionString()].Insert(b.Name) } } versionsSlice := maps.Values(versionsMap) - semver.Sort(versionsSlice) + slices.Sort(versionsSlice) var errs []error for _, v := range versionsSlice { - bundles := sets.List(bundlesWithVersion[v.String()]) + bundles := sets.List(bundlesWithVersion[v]) if len(bundles) > 1 { errs = append(errs, fmt.Errorf("{%s: [%s]}", v, strings.Join(bundles, ", "))) } @@ -331,6 +332,49 @@ type Bundle struct { // These fields are used to compare bundles in a diff. PropertiesP *property.Properties Version semver.Version + Release semver.Version +} + +func (b *Bundle) VersionString() string { + if len(b.Release.Pre) > 0 { + pres := []string{} + for _, pre := range b.Release.Pre { + pres = append(pres, pre.String()) + } + relString := strings.Join(pres, ".") + return strings.Join([]string{b.Version.String(), relString}, "-") + } + return b.Version.String() +} + +func (b *Bundle) normalizeName() string { + // if the bundle has release versioning, then the name must include this in standard form: + // -v- + // if no release versioning exists, then just return the bundle name + if len(b.Release.Pre) > 0 { + return strings.Join([]string{b.Package.Name, "v" + b.VersionString()}, "-") + } + return b.Name +} + +// order by version, then +// release, if present +func (b *Bundle) Compare(other *Bundle) int { + if b.Name == other.Name { + return 0 + } + if b.Version.NE(other.Version) { + return b.Version.Compare(other.Version) + } + bhasrelease := len(b.Release.Pre) > 0 + otherhasrelease := len(other.Release.Pre) > 0 + if bhasrelease && !otherhasrelease { + return 1 + } + if !bhasrelease && otherhasrelease { + return -1 + } + return b.Release.Compare(other.Release) } func (b *Bundle) Validate() error { @@ -339,6 +383,9 @@ func (b *Bundle) Validate() error { if b.Name == "" { result.subErrors = append(result.subErrors, errors.New("name must be set")) } + if b.Name != b.normalizeName() { + result.subErrors = append(result.subErrors, fmt.Errorf("name %q does not match normalized name %q", b.Name, b.normalizeName())) + } if b.Channel == nil { result.subErrors = append(result.subErrors, errors.New("channel must be set")) } @@ -379,6 +426,10 @@ func (b *Bundle) Validate() error { result.subErrors = append(result.subErrors, fmt.Errorf("invalid deprecation: %v", err)) } + if len(b.Version.Build) > 0 && len(b.Release.Pre) > 0 { + result.subErrors = append(result.subErrors, fmt.Errorf("cannot use build metadata in version with a release version")) + } + return result.orNil() } diff --git a/alpha/model/model_test.go b/alpha/model/model_test.go index 248de9c85..898ec3c49 100644 --- a/alpha/model/model_test.go +++ b/alpha/model/model_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "testing" "github.com/blang/semver/v4" @@ -288,6 +289,41 @@ func TestValidators(t *testing.T) { }, assertion: hasError(`duplicate versions found in bundles: [{0.0.1: [anakin.v0.0.1, anakin.v0.0.2]} {1.0.1: [anakin.v1.0.1, anakin.v1.0.2]}]`), }, + { + name: "Package/Error/DuplicateBundleVersionsReleases", + v: &Package{ + Name: "anakin", + Channels: map[string]*Channel{ + "light": { + Package: pkg, + Name: "light", + Bundles: map[string]*Bundle{ + "anakin.v0.0.1": {Name: "anakin.v0.0.1", Version: semver.MustParse("0.0.1")}, + "anakin.v0.0.2": {Name: "anakin.v0.0.2", Version: semver.MustParse("0.0.1")}, + "anakin-v0.0.1-100": {Name: "anakin.v0.0.1", Version: semver.MustParse("0.0.1"), Release: semver.MustParse(fmt.Sprintf("0.0.0-%s", "100")), Package: pkg}, + "anakin-v0.0.2-100": {Name: "anakin.v0.0.2", Version: semver.MustParse("0.0.1"), Release: semver.MustParse(fmt.Sprintf("0.0.0-%s", "100")), Package: pkg}, + }, + }, + }, + }, + assertion: hasError(`duplicate versions found in bundles: [{0.0.1: [anakin.v0.0.1, anakin.v0.0.2]} {0.0.1-100: [anakin.v0.0.1, anakin.v0.0.2]}]`), + }, + { + name: "Package/Error/BundleReleaseNormalizedName", + v: &Package{ + Name: "anakin", + Channels: map[string]*Channel{ + "light": { + Package: pkg, + Name: "light", + Bundles: map[string]*Bundle{ + "anakin.v0.0.1.alpha1": {Name: "anakin.v0.0.1.alpha1", Version: semver.MustParse("0.0.1"), Release: semver.MustParse(fmt.Sprintf("0.0.0-%s", "alpha1")), Package: pkg}, + }, + }, + }, + }, + assertion: hasError(`name "anakin.v0.0.1.alpha1" does not match normalized name "anakin-v0.0.1-alpha1"`), + }, { name: "Package/Error/NoDefaultChannel", v: &Package{ diff --git a/alpha/property/property.go b/alpha/property/property.go index 6fb792dda..dcca33ce5 100644 --- a/alpha/property/property.go +++ b/alpha/property/property.go @@ -38,6 +38,7 @@ func (p Property) String() string { type Package struct { PackageName string `json:"packageName"` Version string `json:"version"` + Release string `json:"release,omitzero"` } // NOTICE: The Channel properties are for internal use only. @@ -247,6 +248,9 @@ func jsonMarshal(p interface{}) ([]byte, error) { func MustBuildPackage(name, version string) Property { return MustBuild(&Package{PackageName: name, Version: version}) } +func MustBuildPackageRelease(name, version, relVersion string) Property { + return MustBuild(&Package{PackageName: name, Version: version, Release: relVersion}) +} func MustBuildPackageRequired(name, versionRange string) Property { return MustBuild(&PackageRequired{name, versionRange}) } diff --git a/alpha/property/property_test.go b/alpha/property/property_test.go index 171cec7a0..bb67d5264 100644 --- a/alpha/property/property_test.go +++ b/alpha/property/property_test.go @@ -132,12 +132,12 @@ func TestParse(t *testing.T) { }, expectProps: &Properties{ Packages: []Package{ - {"package1", "0.1.0"}, - {"package2", "0.2.0"}, + {PackageName: "package1", Version: "0.1.0"}, + {PackageName: "package2", Version: "0.2.0"}, }, PackagesRequired: []PackageRequired{ - {"package3", ">=1.0.0 <2.0.0-0"}, - {"package4", ">=2.0.0 <3.0.0-0"}, + {PackageName: "package3", VersionRange: ">=1.0.0 <2.0.0-0"}, + {PackageName: "package4", VersionRange: ">=2.0.0 <3.0.0-0"}, }, GVKs: []GVK{ {"group", "Kind1", "v1"}, @@ -206,10 +206,28 @@ func TestBuild(t *testing.T) { specs := []spec{ { name: "Success/Package", - input: &Package{"name", "0.1.0"}, + input: &Package{PackageName: "name", Version: "0.1.0"}, assertion: require.NoError, expectedProperty: propPtr(MustBuildPackage("name", "0.1.0")), }, + { + name: "Success/Package-ReleaseVersionNumber", + input: &Package{PackageName: "name", Version: "0.1.0", Release: "1"}, + assertion: require.NoError, + expectedProperty: propPtr(MustBuildPackageRelease("name", "0.1.0", "1")), + }, + { + name: "Success/Package-ReleaseVersionAlpha", + input: &Package{PackageName: "name", Version: "0.1.0", Release: "gamma"}, + assertion: require.NoError, + expectedProperty: propPtr(MustBuildPackageRelease("name", "0.1.0", "gamma")), + }, + { + name: "Success/Package-ReleaseVersionMixed", + input: &Package{PackageName: "name", Version: "0.1.0", Release: "gamma1"}, + assertion: require.NoError, + expectedProperty: propPtr(MustBuildPackageRelease("name", "0.1.0", "gamma1")), + }, { name: "Success/PackageRequired", input: &PackageRequired{"name", ">=0.1.0"}, diff --git a/alpha/template/basic/basic.go b/alpha/template/basic/basic.go index 853c124c1..7bb97955b 100644 --- a/alpha/template/basic/basic.go +++ b/alpha/template/basic/basic.go @@ -12,8 +12,16 @@ import ( "github.com/operator-framework/operator-registry/alpha/template/api" ) +// Schema const schema string = "olm.template.basic" +// Template types + +type BasicTemplateData struct { + Schema string `json:"schema"` + Entries []*declcfg.Meta `json:"entries"` +} + type basicTemplate struct { renderBundle api.BundleRenderer } @@ -25,6 +33,8 @@ func new(renderBundle api.BundleRenderer) api.Template { } } +// Template functions + // RenderBundle expands the bundle image reference into a DeclarativeConfig fragment. func (t *basicTemplate) RenderBundle(ctx context.Context, image string) (*declcfg.DeclarativeConfig, error) { return t.renderBundle(ctx, image) @@ -63,23 +73,7 @@ func (t *basicTemplate) Schema() string { return schema } -// Factory represents the basic template factory -type Factory struct{} - -// CreateTemplate creates a new template instance with the given RenderBundle function -func (f *Factory) CreateTemplate(renderBundle api.BundleRenderer) api.Template { - return new(renderBundle) -} - -// Schema returns the schema supported by this factory -func (f *Factory) Schema() string { - return schema -} - -type BasicTemplateData struct { - Schema string `json:"schema"` - Entries []*declcfg.Meta `json:"entries"` -} +// Helper functions func parseSpec(reader io.Reader) (*BasicTemplateData, error) { bt := &BasicTemplateData{} @@ -141,3 +135,20 @@ func FromReader(r io.Reader) (*BasicTemplateData, error) { return bt, nil } + +// Factory types + +// Factory represents the basic template factory +type Factory struct{} + +// Factory functions + +// CreateTemplate creates a new template instance with the given RenderBundle function +func (f *Factory) CreateTemplate(renderBundle api.BundleRenderer) api.Template { + return new(renderBundle) +} + +// Schema returns the schema supported by this factory +func (f *Factory) Schema() string { + return schema +} diff --git a/alpha/template/converter/converter.go b/alpha/template/converter/converter.go index 03e3e0a97..8a5de92e1 100644 --- a/alpha/template/converter/converter.go +++ b/alpha/template/converter/converter.go @@ -9,22 +9,46 @@ import ( "sigs.k8s.io/yaml" "github.com/operator-framework/operator-registry/alpha/template/basic" + "github.com/operator-framework/operator-registry/alpha/template/substitutes" "github.com/operator-framework/operator-registry/pkg/image" ) type Converter struct { - FbcReader io.Reader - OutputFormat string - Registry image.Registry + FbcReader io.Reader + OutputFormat string + Registry image.Registry + DestinationTemplateType string // TODO: when we have a template factory, we can pass it here } func (c *Converter) Convert() error { - bt, err := basic.FromReader(c.FbcReader) - if err != nil { - return err + var b []byte + var err error + switch c.DestinationTemplateType { + case "basic": + var bt *basic.BasicTemplateData + bt, err = basic.FromReader(c.FbcReader) + if err != nil { + return err + } + b, err = json.MarshalIndent(bt, "", " ") + if err != nil { + return err + } + case "substitutes": + var st *substitutes.SubstitutesTemplateData + st, err = substitutes.FromReader(c.FbcReader) + if err != nil { + return err + } + b, err = json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + default: + // usage pattern prevents us from getting here, so if we do it's a programmer failure and we should panic + panic(fmt.Sprintf("unknown template type %q", c.DestinationTemplateType)) } - b, _ := json.MarshalIndent(bt, "", " ") if c.OutputFormat == "json" { fmt.Fprintln(os.Stdout, string(b)) } else { diff --git a/alpha/template/registry.go b/alpha/template/registry.go index 86cfdbfd7..1ece2e4a6 100644 --- a/alpha/template/registry.go +++ b/alpha/template/registry.go @@ -11,6 +11,7 @@ import ( "github.com/operator-framework/operator-registry/alpha/template/api" "github.com/operator-framework/operator-registry/alpha/template/basic" "github.com/operator-framework/operator-registry/alpha/template/semver" + "github.com/operator-framework/operator-registry/alpha/template/substitutes" ) // Re-export api types for backward compatibility @@ -44,6 +45,7 @@ func NewRegistry() Registry { } r.Register(&basic.Factory{}) r.Register(&semver.Factory{}) + r.Register(&substitutes.Factory{}) return r } diff --git a/alpha/template/semver/semver.go b/alpha/template/semver/semver.go index 4c757a421..22ca94e8d 100644 --- a/alpha/template/semver/semver.go +++ b/alpha/template/semver/semver.go @@ -18,9 +18,23 @@ import ( "github.com/operator-framework/operator-registry/alpha/template/api" ) +// Schema const schema string = "olm.semver" -// IO structs -- BEGIN +// Template types + +// StreamType represents the type of version stream for channel generation +type StreamType string + +const ( + // DefaultStreamType represents an unspecified stream type + DefaultStreamType StreamType = "" + // MinorStreamType represents minor version channels (e.g., stable-v1.2) + MinorStreamType StreamType = "minor" + // MajorStreamType represents major version channels (e.g., stable-v1) + MajorStreamType StreamType = "major" +) + type bundleEntry struct { Image string `json:"image,omitempty"` } @@ -33,7 +47,7 @@ type SemverTemplateData struct { Schema string `json:"schema"` GenerateMajorChannels bool `json:"generateMajorChannels,omitempty"` GenerateMinorChannels bool `json:"generateMinorChannels,omitempty"` - DefaultChannelTypePreference streamType `json:"defaultChannelTypePreference,omitempty"` + DefaultChannelTypePreference StreamType `json:"defaultChannelTypePreference,omitempty"` Candidate channelBundles `json:"candidate,omitempty"` Fast channelBundles `json:"fast,omitempty"` Stable channelBundles `json:"stable,omitempty"` @@ -42,8 +56,6 @@ type SemverTemplateData struct { defaultChannel string `json:"-"` // detected "most stable" channel head } -// IO structs -- END - // semverTemplate implements the common template interface type semverTemplate struct { renderBundle api.BundleRenderer @@ -56,6 +68,8 @@ func new(renderBundle api.BundleRenderer) api.Template { } } +// Template functions + // RenderBundle expands the bundle image reference into a DeclarativeConfig fragment. func (t *semverTemplate) RenderBundle(ctx context.Context, image string) (*declcfg.DeclarativeConfig, error) { return t.renderBundle(ctx, image) @@ -109,18 +123,7 @@ func (t *semverTemplate) Schema() string { return schema } -// Factory represents the semver template factory -type Factory struct{} - -// CreateTemplate creates a new template instance with the given RenderBundle function -func (f *Factory) CreateTemplate(renderBundle api.BundleRenderer) api.Template { - return new(renderBundle) -} - -// Schema returns the schema supported by this factory -func (f *Factory) Schema() string { - return schema -} +// Helper functions // channel "archetypes", restricted in this iteration to just these type channelArchetype string @@ -143,14 +146,8 @@ func (b byChannelPriority) Less(i, j int) bool { } func (b byChannelPriority) Swap(i, j int) { b[i], b[j] = b[j], b[i] } -type streamType string - -const defaultStreamType streamType = "" -const minorStreamType streamType = "minor" -const majorStreamType streamType = "major" - // general preference for minor channels -var streamTypePriorities = map[streamType]int{minorStreamType: 2, majorStreamType: 1, defaultStreamType: 0} +var streamTypePriorities = map[StreamType]int{MinorStreamType: 2, MajorStreamType: 1, DefaultStreamType: 0} // map of archetypes --> bundles --> bundle-version from the input file type bundleVersions map[channelArchetype]map[string]semver.Version // e.g. srcv["stable"]["example-operator.v1.0.0"] = 1.0.0 @@ -159,15 +156,39 @@ type bundleVersions map[channelArchetype]map[string]semver.Version // e.g. srcv[ // later as the package's defaultChannel attribute type highwaterChannel struct { archetype channelArchetype - kind streamType + kind StreamType version semver.Version name string } +// prefer (in descending order of preference): +// - higher-rank archetype, +// - semver version, +// - a channel type matching the set preference, or +// - a 'better' (higher value) channel type +func (h *highwaterChannel) gt(ih *highwaterChannel, pref StreamType) bool { + if channelPriorities[h.archetype] != channelPriorities[ih.archetype] { + return channelPriorities[h.archetype] > channelPriorities[ih.archetype] + } + if h.version.NE(ih.version) { + return h.version.GT(ih.version) + } + if h.kind != ih.kind { + if h.kind == pref { + return true + } + if ih.kind == pref { + return false + } + return h.kind.gt((*ih).kind) + } + return false +} + // entryTuple represents a channel entry with its associated metadata type entryTuple struct { arch channelArchetype - kind streamType + kind StreamType parent string name string version semver.Version @@ -210,17 +231,17 @@ func readFile(reader io.Reader) (*SemverTemplateData, error) { // if un-set, default to align to the selected generate option // if set, error out if we mismatch the two switch sv.DefaultChannelTypePreference { - case defaultStreamType: + case DefaultStreamType: if sv.GenerateMinorChannels { - sv.DefaultChannelTypePreference = minorStreamType + sv.DefaultChannelTypePreference = MinorStreamType } else if sv.GenerateMajorChannels { - sv.DefaultChannelTypePreference = majorStreamType + sv.DefaultChannelTypePreference = MajorStreamType } - case minorStreamType: + case MinorStreamType: if !sv.GenerateMinorChannels { return nil, fmt.Errorf("schema attribute mismatch: DefaultChannelTypePreference set to 'minor' doesn't make sense if not generating minor-version channels") } - case majorStreamType: + case MajorStreamType: if !sv.GenerateMajorChannels { return nil, fmt.Errorf("schema attribute mismatch: DefaultChannelTypePreference set to 'major' doesn't make sense if not generating major-version channels") } @@ -368,12 +389,12 @@ func (sv *SemverTemplateData) generateChannels(semverChannels *bundleVersions) [ for _, bundleName := range bundleNamesByVersion { // a dodge to avoid duplicating channel processing body; accumulate a map of the channels which need creating from the bundle // we need to associate by kind so we can partition the resulting entries - channelNameKeys := make(map[streamType]string) + channelNameKeys := make(map[StreamType]string) if sv.GenerateMajorChannels { - channelNameKeys[majorStreamType] = channelNameFromMajor(archetype, bundles[bundleName]) + channelNameKeys[MajorStreamType] = channelNameFromMajor(archetype, bundles[bundleName]) } if sv.GenerateMinorChannels { - channelNameKeys[minorStreamType] = channelNameFromMinor(archetype, bundles[bundleName]) + channelNameKeys[MinorStreamType] = channelNameFromMinor(archetype, bundles[bundleName]) } for cKey, cName := range channelNameKeys { @@ -580,30 +601,23 @@ func stripBuildMetadata(v semver.Version) string { return v.String() } -// prefer (in descending order of preference): -// - higher-rank archetype, -// - semver version, -// - a channel type matching the set preference, or -// - a 'better' (higher value) channel type -func (h *highwaterChannel) gt(ih *highwaterChannel, pref streamType) bool { - if channelPriorities[h.archetype] != channelPriorities[ih.archetype] { - return channelPriorities[h.archetype] > channelPriorities[ih.archetype] - } - if h.version.NE(ih.version) { - return h.version.GT(ih.version) - } - if h.kind != ih.kind { - if h.kind == pref { - return true - } - if ih.kind == pref { - return false - } - return h.kind.gt((*ih).kind) - } - return false +func (t StreamType) gt(in StreamType) bool { + return streamTypePriorities[t] > streamTypePriorities[in] } -func (t streamType) gt(in streamType) bool { - return streamTypePriorities[t] > streamTypePriorities[in] +// Factory types + +// Factory represents the semver template factory +type Factory struct{} + +// Factory functions + +// CreateTemplate creates a new template instance with the given RenderBundle function +func (f *Factory) CreateTemplate(renderBundle api.BundleRenderer) api.Template { + return new(renderBundle) +} + +// Schema returns the schema supported by this factory +func (f *Factory) Schema() string { + return schema } diff --git a/alpha/template/semver/semver_test.go b/alpha/template/semver/semver_test.go index 17001e8af..79b17196b 100644 --- a/alpha/template/semver/semver_test.go +++ b/alpha/template/semver/semver_test.go @@ -26,38 +26,38 @@ func TestLinkChannels(t *testing.T) { // } minimumChannelEntries := []entryTuple{ - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v0.1.0", parent: "stable-v0.1", index: 0, version: semver.MustParse("0.1.0")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v0.1.0", parent: "stable-v0.1", index: 0, version: semver.MustParse("0.1.0")}, } majorChannelEntries := []entryTuple{ - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v0.1.0", parent: "stable-v0", index: 0, version: semver.MustParse("0.1.0")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v0.1.1", parent: "stable-v0", index: 1, version: semver.MustParse("0.1.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v1.1.0", parent: "stable-v1", index: 0, version: semver.MustParse("1.1.0")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v1.2.1", parent: "stable-v1", index: 1, version: semver.MustParse("1.2.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v1.3.1", parent: "stable-v1", index: 2, version: semver.MustParse("1.3.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v2.1.0", parent: "stable-v2", index: 0, version: semver.MustParse("2.1.0")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v2.1.1", parent: "stable-v2", index: 1, version: semver.MustParse("2.1.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v2.3.1", parent: "stable-v2", index: 2, version: semver.MustParse("2.3.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v2.3.2", parent: "stable-v2", index: 3, version: semver.MustParse("2.3.2")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v0.1.0", parent: "stable-v0", index: 0, version: semver.MustParse("0.1.0")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v0.1.1", parent: "stable-v0", index: 1, version: semver.MustParse("0.1.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v1.1.0", parent: "stable-v1", index: 0, version: semver.MustParse("1.1.0")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v1.2.1", parent: "stable-v1", index: 1, version: semver.MustParse("1.2.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v1.3.1", parent: "stable-v1", index: 2, version: semver.MustParse("1.3.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v2.1.0", parent: "stable-v2", index: 0, version: semver.MustParse("2.1.0")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v2.1.1", parent: "stable-v2", index: 1, version: semver.MustParse("2.1.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v2.3.1", parent: "stable-v2", index: 2, version: semver.MustParse("2.3.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v2.3.2", parent: "stable-v2", index: 3, version: semver.MustParse("2.3.2")}, } majorChannelEntriesLastXChange := []entryTuple{ - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v0.1.0", parent: "stable-v0", index: 0, version: semver.MustParse("0.1.0")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v0.1.1", parent: "stable-v0", index: 1, version: semver.MustParse("0.1.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v1.1.0", parent: "stable-v1", index: 0, version: semver.MustParse("1.1.0")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v1.2.1", parent: "stable-v1", index: 1, version: semver.MustParse("1.2.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v1.3.1", parent: "stable-v1", index: 2, version: semver.MustParse("1.3.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v2.1.0", parent: "stable-v2", index: 0, version: semver.MustParse("2.1.0")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v0.1.0", parent: "stable-v0", index: 0, version: semver.MustParse("0.1.0")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v0.1.1", parent: "stable-v0", index: 1, version: semver.MustParse("0.1.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v1.1.0", parent: "stable-v1", index: 0, version: semver.MustParse("1.1.0")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v1.2.1", parent: "stable-v1", index: 1, version: semver.MustParse("1.2.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v1.3.1", parent: "stable-v1", index: 2, version: semver.MustParse("1.3.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v2.1.0", parent: "stable-v2", index: 0, version: semver.MustParse("2.1.0")}, } majorChannelEntriesLastArchChange := []entryTuple{ - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v1.1.0", parent: "stable-v1", index: 0, version: semver.MustParse("1.1.0")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v1.2.1", parent: "stable-v1", index: 1, version: semver.MustParse("1.2.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v1.3.1", parent: "stable-v1", index: 2, version: semver.MustParse("1.3.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v2.1.0", parent: "stable-v2", index: 0, version: semver.MustParse("2.1.0")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v2.1.1", parent: "stable-v2", index: 1, version: semver.MustParse("2.1.1")}, - {arch: stableChannelArchetype, kind: majorStreamType, name: "a-v2.3.1", parent: "stable-v2", index: 2, version: semver.MustParse("2.3.1")}, - {arch: candidateChannelArchetype, kind: majorStreamType, name: "a-v2.3.2", parent: "candidate-v2", index: 0, version: semver.MustParse("2.3.2")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v1.1.0", parent: "stable-v1", index: 0, version: semver.MustParse("1.1.0")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v1.2.1", parent: "stable-v1", index: 1, version: semver.MustParse("1.2.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v1.3.1", parent: "stable-v1", index: 2, version: semver.MustParse("1.3.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v2.1.0", parent: "stable-v2", index: 0, version: semver.MustParse("2.1.0")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v2.1.1", parent: "stable-v2", index: 1, version: semver.MustParse("2.1.1")}, + {arch: stableChannelArchetype, kind: MajorStreamType, name: "a-v2.3.1", parent: "stable-v2", index: 2, version: semver.MustParse("2.3.1")}, + {arch: candidateChannelArchetype, kind: MajorStreamType, name: "a-v2.3.2", parent: "candidate-v2", index: 0, version: semver.MustParse("2.3.2")}, } majorGeneratedUnlinkedChannels := map[string]*declcfg.Channel{ @@ -472,7 +472,7 @@ func TestGenerateChannels(t *testing.T) { generateMinorChannels bool generateMajorChannels bool defaultChannel string - channelTypePreference streamType + channelTypePreference StreamType out []declcfg.Channel }{ { @@ -480,7 +480,7 @@ func TestGenerateChannels(t *testing.T) { generateMinorChannels: true, generateMajorChannels: false, defaultChannel: "stable-v3.1", - channelTypePreference: minorStreamType, + channelTypePreference: MinorStreamType, out: minorLinkedChannels, }, { @@ -488,7 +488,7 @@ func TestGenerateChannels(t *testing.T) { generateMinorChannels: false, generateMajorChannels: true, defaultChannel: "stable-v3", - channelTypePreference: majorStreamType, + channelTypePreference: MajorStreamType, out: majorLinkedChannels, }, { @@ -496,7 +496,7 @@ func TestGenerateChannels(t *testing.T) { generateMinorChannels: true, generateMajorChannels: true, defaultChannel: "stable-v3.1", - channelTypePreference: minorStreamType, + channelTypePreference: MinorStreamType, out: combinedLinkedChannels, }, { @@ -504,7 +504,7 @@ func TestGenerateChannels(t *testing.T) { generateMinorChannels: true, generateMajorChannels: true, defaultChannel: "stable-v3", - channelTypePreference: majorStreamType, + channelTypePreference: MajorStreamType, out: combinedLinkedChannels, }, { @@ -512,7 +512,7 @@ func TestGenerateChannels(t *testing.T) { generateMinorChannels: true, generateMajorChannels: false, defaultChannel: "stable-v3.1", - channelTypePreference: majorStreamType, + channelTypePreference: MajorStreamType, out: minorLinkedChannels, }, { @@ -520,7 +520,7 @@ func TestGenerateChannels(t *testing.T) { generateMinorChannels: false, generateMajorChannels: true, defaultChannel: "stable-v3", - channelTypePreference: minorStreamType, + channelTypePreference: MinorStreamType, out: majorLinkedChannels, }, } diff --git a/alpha/template/substitutes/substitutes.go b/alpha/template/substitutes/substitutes.go new file mode 100644 index 000000000..d2ed36e96 --- /dev/null +++ b/alpha/template/substitutes/substitutes.go @@ -0,0 +1,287 @@ +package substitutes + +import ( + "context" + "encoding/json" + "fmt" + "io" + "slices" + + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/template/api" +) + +// Schema +const schema string = "olm.template.substitutes" + +// Template types + +// Substitute defines a replacement relationship between an existing bundle name and a superceding bundle image pullspec. +// Since registry+v0 graphs are bundle name based, this uses the name instead of a version. +type Substitute struct { + Name string `json:"name"` // the bundle image pullspec to substitute + Base string `json:"base"` // the bundle name to substitute for +} + +// SubstitutesTemplateData represents a template for bundle substitutions. +// It contains the schema identifier, an input declarative config, and substitution mappings +// that define how bundles should be replaced in upgrade graphs. +type SubstitutesTemplateData struct { + Schema string `json:"schema"` + Entries []*declcfg.Meta `json:"entries"` + Substitutions []Substitute `json:"substitutions"` +} + +// template implements a catalog template to make the substitutesFor mechanics less error prone. +// It provides a customizable RenderBundle function that is used to produce declarative config for a supplied bundle. +type template struct { + renderBundle api.BundleRenderer +} + +func new(renderBundle api.BundleRenderer) api.Template { + return &template{ + renderBundle: renderBundle, + } +} + +// Template functions + +func (t *template) Schema() string { + return schema +} + +func (t *template) RenderBundle(ctx context.Context, bundleRef string) (*declcfg.DeclarativeConfig, error) { + return t.renderBundle(ctx, bundleRef) +} + +func (t *template) Render(ctx context.Context, reader io.Reader) (*declcfg.DeclarativeConfig, error) { + st, err := parseSpec(reader) + if err != nil { + return nil, fmt.Errorf("render: unable to parse template: %v", err) + } + + // Create DeclarativeConfig from template entries + cfg, err := declcfg.LoadSlice(st.Entries) + if err != nil { + return nil, fmt.Errorf("render: unable to create declarative config from entries: %v", err) + } + + _, err = declcfg.ConvertToModel(*cfg) + if err != nil { + return nil, fmt.Errorf("render: entries are not valid FBC: %v", err) + } + + // Process each substitution + for _, substitution := range st.Substitutions { + err := t.processSubstitution(ctx, cfg, substitution) + if err != nil { + return nil, fmt.Errorf("render: error processing substitution %s->%s: %v", substitution.Base, substitution.Name, err) + } + } + + return cfg, nil +} + +// Helper functions + +func parseSpec(reader io.Reader) (*SubstitutesTemplateData, error) { + st := &SubstitutesTemplateData{} + stDoc := json.RawMessage{} + stDecoder := yaml.NewYAMLOrJSONDecoder(reader, 4096) + err := stDecoder.Decode(&stDoc) + if err != nil { + return nil, fmt.Errorf("decoding template schema: %v", err) + } + err = json.Unmarshal(stDoc, st) + if err != nil { + return nil, fmt.Errorf("unmarshalling template: %v", err) + } + + if st.Schema != schema { + return nil, fmt.Errorf("template has unknown schema (%q), should be %q", st.Schema, schema) + } + + return st, nil +} + +// validateSubstitution validates the substitution references +func (t *template) validateSubstitution(ctx context.Context, cfg *declcfg.DeclarativeConfig, substitution Substitute) error { + // Validate substitution fields - all are required + if substitution.Name == "" { + return fmt.Errorf("substitution name cannot be empty") + } + if substitution.Base == "" { + return fmt.Errorf("substitution base cannot be empty") + } + if substitution.Name == substitution.Base { + return fmt.Errorf("substitution name and base cannot be the same") + } + + // determine the versions of the base and substitute bundles and ensure that + // the composite version of the substitute bundle is greater than the composite version of the base bundle + + // 1. Render the pullspec represented by substitution.Name + substituteCfg, err := t.renderBundle(ctx, substitution.Name) + if err != nil { + return fmt.Errorf("failed to render bundle image reference %q: %v", substitution.Name, err) + } + if substituteCfg == nil || len(substituteCfg.Bundles) == 0 { + return fmt.Errorf("rendered bundle image reference %q contains no bundles", substitution.Name) + } + substituteBundle := &substituteCfg.Bundles[0] + substituteCv, err := substituteBundle.CompositeVersion() + if err != nil { + return fmt.Errorf("failed to get composite version for substitute bundle %q: %v", substitution.Name, err) + } + + // 2. Examine cfg to find the bundle which has matching name to substitution.Base + var baseBundle *declcfg.Bundle + for i := range cfg.Bundles { + if cfg.Bundles[i].Name == substitution.Base { + baseBundle = &cfg.Bundles[i] + break + } + } + if baseBundle == nil { + return fmt.Errorf("base bundle %q does not exist in catalog", substitution.Base) + } + baseCv, err := baseBundle.CompositeVersion() + if err != nil { + return fmt.Errorf("failed to get composite version for base bundle %q: %v", substitution.Base, err) + } + + // 3. Ensure that the base bundle composite version is less than the substitute bundle composite version + if baseCv.Compare(substituteCv) >= 0 { + return fmt.Errorf("base bundle %q is not less than substitute bundle %q", substitution.Base, substitution.Name) + } + + return nil +} + +// processSubstitution handles the complex logic for processing a single substitution +func (t *template) processSubstitution(ctx context.Context, cfg *declcfg.DeclarativeConfig, substitution Substitute) error { + if err := t.validateSubstitution(ctx, cfg, substitution); err != nil { + return err + } + + substituteCfg, err := t.RenderBundle(ctx, substitution.Name) + if err != nil { + return fmt.Errorf("failed to render bundle image reference %q: %v", substitution.Name, err) + } + + // normally, we'd rely RenderBundle to represent any failure via err, but since this is comes from input, + // we need to perform more validation of the results here before processing them + if substituteCfg == nil || len(substituteCfg.Bundles) == 0 { + return fmt.Errorf("rendered bundle image reference %q contains no bundles", substitution.Name) + } + + substituteBundle := &substituteCfg.Bundles[0] + + // Iterate over all channels + for i := range cfg.Channels { + channel := &cfg.Channels[i] + + // First pass: find entries that have substitution.base as their name + // Only process original entries, not substitution entries (they have empty replaces after clearing) + var entriesToSubstitute []int + for j := range channel.Entries { + entry := &channel.Entries[j] + if entry.Name == substitution.Base { + entriesToSubstitute = append(entriesToSubstitute, j) + } + } + + // Create new entries for each substitution (process in reverse order to avoid index issues) + for i := len(entriesToSubstitute) - 1; i >= 0; i-- { + entryIndex := entriesToSubstitute[i] + // Create a new channel entry for substitution.name + newEntry := declcfg.ChannelEntry{ + Name: substituteBundle.Name, + Replaces: channel.Entries[entryIndex].Replaces, + Skips: channel.Entries[entryIndex].Skips, + SkipRange: channel.Entries[entryIndex].SkipRange, + } + + // Add skip relationship to substitution.base + newEntry.Skips = append(newEntry.Skips, substitution.Base) + + // Add the new entry to the channel + channel.Entries = append(channel.Entries, newEntry) + + // Clear the original entry's replaces/skips/skipRange since they moved to the new entry + channel.Entries[entryIndex].Replaces = "" + channel.Entries[entryIndex].Skips = nil + channel.Entries[entryIndex].SkipRange = "" + } + + // Second pass: update all references to substitution.base to point to substitution.name + // Skip the newly created substitution entries (they are at the end) + originalEntryCount := len(channel.Entries) - len(entriesToSubstitute) + for j := 0; j < originalEntryCount; j++ { + entry := &channel.Entries[j] + + // If this entry replaces substitution.base, update it to replace substitution.name + if entry.Replaces == substitution.Base { + entry.Replaces = substituteBundle.Name + entry.Skips = append(entry.Skips, substitution.Base) + } else if entry.Skips != nil && slices.Contains(entry.Skips, substitution.Base) { + // If this entry skips substitution.base, update it to skip substitution.name + // and remove substitution.base from the skips list + entry.Skips = append(entry.Skips, substituteBundle.Name) + entry.Skips = slices.DeleteFunc(entry.Skips, func(skip string) bool { + return skip == substitution.Base + }) + } + } + } + + // Add the substitute bundle to the config (only once) + cfg.Bundles = append(cfg.Bundles, *substituteBundle) + + // now validate the resulting config + _, err = declcfg.ConvertToModel(*cfg) + if err != nil { + return fmt.Errorf("resulting config is not valid FBC: %v", err) + } + + return nil +} + +// FromReader reads FBC from a reader and generates a SubstitutesForTemplate from it +func FromReader(r io.Reader) (*SubstitutesTemplateData, error) { + var entries []*declcfg.Meta + if err := declcfg.WalkMetasReader(r, func(meta *declcfg.Meta, err error) error { + if err != nil { + return err + } + + entries = append(entries, meta) + return nil + }); err != nil { + return nil, err + } + + bt := &SubstitutesTemplateData{ + Schema: schema, + Entries: entries, + Substitutions: []Substitute{}, + } + + return bt, nil +} + +// Factory types + +type Factory struct{} + +// Factory functions + +func (f *Factory) CreateTemplate(renderBundle api.BundleRenderer) api.Template { + return new(renderBundle) +} + +func (f *Factory) Schema() string { + return schema +} diff --git a/alpha/template/substitutes/substitutes_test.go b/alpha/template/substitutes/substitutes_test.go new file mode 100644 index 000000000..41a018bca --- /dev/null +++ b/alpha/template/substitutes/substitutes_test.go @@ -0,0 +1,1388 @@ +package substitutes + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/property" +) + +// Helper function to create a mock template for testing +func createMockTemplate() template { + return template{ + renderBundle: func(ctx context.Context, imageRef string) (*declcfg.DeclarativeConfig, error) { + // Extract package name from image reference + packageName := "testoperator" + if strings.Contains(imageRef, "test-bundle") { + packageName = "test" + } + + // Extract version from image reference if it contains a version tag + version := "1.0.0" + release := "" + if strings.Contains(imageRef, ":v") { + parts := strings.Split(imageRef, ":v") + if len(parts) == 2 { + versionTag := parts[1] + // Check if version has a prerelease/build metadata (e.g., "1.2.0-alpha") + if strings.Contains(versionTag, "-") { + versionParts := strings.SplitN(versionTag, "-", 2) + version = versionParts[0] + release = versionParts[1] + } else { + version = versionTag + } + } + } + + // Create bundle name based on whether it has a release version + var bundleName string + var properties []property.Property + + if release != "" { + // substitutesFor bundle: package-vversion-release + bundleName = fmt.Sprintf("%s-v%s-%s", packageName, version, release) + properties = []property.Property{ + property.MustBuildPackageRelease(packageName, version, release), + property.MustBuildBundleObject([]byte(fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, imageRef))), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + } + } else { + // normal bundle: package.vversion + bundleName = fmt.Sprintf("%s.v%s", packageName, version) + properties = []property.Property{ + property.MustBuildPackage(packageName, version), + property.MustBuildBundleObject([]byte(fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, imageRef))), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + } + } + + return &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: packageName, + DefaultChannel: "stable", + }, + }, + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: bundleName, + Package: packageName, + Image: imageRef, + Properties: properties, + RelatedImages: []declcfg.RelatedImage{ + {Name: "bundle", Image: imageRef}, + }, + CsvJSON: fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, imageRef), + Objects: []string{ + fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, imageRef), + `{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`, + }, + }, + }, + }, nil + }, + } +} + +// Helper function to create a test DeclarativeConfig +func createTestDeclarativeConfig() *declcfg.DeclarativeConfig { + return &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Channels: []declcfg.Channel{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Entries: []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.1.0", Replaces: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.2.0", Replaces: "testoperator.v1.1.0", Skips: []string{"testoperator.v1.0.0"}}, + }, + }, + }, + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "testoperator.v1.0.0", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.0.0", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.0.0"), + }, + }, + { + Schema: "olm.bundle", + Name: "testoperator.v1.1.0", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.1.0", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.1.0"), + }, + }, + { + Schema: "olm.bundle", + Name: "testoperator.v1.2.0", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.2.0", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.2.0"), + }, + }, + }, + } +} + +// Helper function to create a valid test package Meta entry +// nolint: unparam +func createValidTestPackageMeta(name, defaultChannel string) *declcfg.Meta { + pkg := declcfg.Package{ + Schema: "olm.package", + Name: name, + DefaultChannel: defaultChannel, + Description: fmt.Sprintf("%s operator", name), + } + + blob, err := json.Marshal(pkg) + if err != nil { + panic(err) + } + + return &declcfg.Meta{ + Schema: "olm.package", + Name: name, + Package: name, + Blob: json.RawMessage(blob), + } +} + +// Helper function to create a valid test bundle Meta entry with proper naming convention +// nolint: unparam +func createValidTestBundleMeta(name, packageName, version, release string) *declcfg.Meta { + var bundleName string + var properties []property.Property + + if release != "" { + // Create bundle name following the normalizeName convention: package-vversion-release + bundleName = fmt.Sprintf("%s-v%s-%s", packageName, version, release) + properties = []property.Property{ + property.MustBuildPackageRelease(packageName, version, release), + property.MustBuildBundleObject([]byte(fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, bundleName))), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + } + } else { + // Use simple naming convention for bundles without release version + bundleName = name + properties = []property.Property{ + property.MustBuildPackage(packageName, version), + property.MustBuildBundleObject([]byte(fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, bundleName))), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + } + } + + bundle := declcfg.Bundle{ + Schema: "olm.bundle", + Name: bundleName, + Package: packageName, + Image: fmt.Sprintf("quay.io/test/%s-bundle:v%s", packageName, version), + Properties: properties, + RelatedImages: []declcfg.RelatedImage{ + { + Name: "bundle", + Image: fmt.Sprintf("quay.io/test/%s-bundle:v%s", packageName, version), + }, + }, + CsvJSON: fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, bundleName), + Objects: []string{ + fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, bundleName), + `{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`, + }, + } + + blob, err := json.Marshal(bundle) + if err != nil { + panic(err) + } + + return &declcfg.Meta{ + Schema: "olm.bundle", + Name: bundleName, + Package: packageName, + Blob: json.RawMessage(blob), + } +} + +// Helper function to create a valid test channel Meta entry with proper bundle names +// nolint: unparam +func createValidTestChannelMeta(name, packageName string, entries []declcfg.ChannelEntry) *declcfg.Meta { + channel := declcfg.Channel{ + Schema: "olm.channel", + Name: name, + Package: packageName, + Entries: entries, + } + + blob, err := json.Marshal(channel) + if err != nil { + panic(err) + } + + return &declcfg.Meta{ + Schema: "olm.channel", + Name: name, + Package: packageName, + Blob: json.RawMessage(blob), + } +} + +func TestParseSpec(t *testing.T) { + tests := []struct { + name string + input string + expected *SubstitutesTemplateData + expectError bool + errorMsg string + }{ + { + name: "Success/valid template with substitutions", + input: ` +schema: olm.template.substitutes +entries: + - schema: olm.channel + name: stable + package: testoperator + blob: '{"schema":"olm.channel","name":"stable","package":"testoperator","entries":[{"name":"testoperator.v1.0.0"}]}' +substitutions: + - name: testoperator.v1.1.0 + base: testoperator.v1.0.0 +`, + expected: &SubstitutesTemplateData{ + Schema: "olm.template.substitutes", + Entries: []*declcfg.Meta{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Blob: json.RawMessage(`{"schema":"olm.channel","name":"stable","package":"testoperator","entries":[{"name":"testoperator.v1.0.0"}]}`), + }, + }, + Substitutions: []Substitute{ + {Name: "testoperator.v1.1.0", Base: "testoperator.v1.0.0"}, + }, + }, + expectError: false, + }, + { + name: "Error/invalid schema", + input: ` +schema: olm.template.invalid +entries: [] +substitutions: [] +`, + expectError: true, + errorMsg: "template has unknown schema", + }, + { + name: "Error/missing schema", + input: ` +entries: [] +substitutions: [] +`, + expectError: true, + errorMsg: "template has unknown schema", + }, + { + name: "Error/invalid YAML", + input: ` +schema: olm.template.substitutes +entries: [ +substitutions: [] +`, + expectError: true, + errorMsg: "decoding template schema", + }, + { + name: "Success/empty template", + input: ` +schema: olm.template.substitutes +entries: [] +substitutions: [] +`, + expected: &SubstitutesTemplateData{ + Schema: "olm.template.substitutes", + Entries: []*declcfg.Meta{}, + Substitutions: []Substitute{}, + }, + expectError: false, + }, + { + name: "Success/multiple substitutions", + input: ` +schema: olm.template.substitutes +entries: + - schema: olm.channel + name: stable + package: testoperator + blob: '{"schema":"olm.channel","name":"stable","package":"testoperator","entries":[{"name":"testoperator.v1.0.0"}]}' +substitutions: + - name: testoperator.v1.1.0 + base: testoperator.v1.0.0 + - name: testoperator.v1.2.0 + base: testoperator.v1.1.0 +`, + expected: &SubstitutesTemplateData{ + Schema: "olm.template.substitutes", + Entries: []*declcfg.Meta{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Blob: json.RawMessage(`{"schema":"olm.channel","name":"stable","package":"testoperator","entries":[{"name":"testoperator.v1.0.0"}]}`), + }, + }, + Substitutions: []Substitute{ + {Name: "testoperator.v1.1.0", Base: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.2.0", Base: "testoperator.v1.1.0"}, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := strings.NewReader(tt.input) + result, err := parseSpec(reader) + + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + require.Nil(t, result) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected.Schema, result.Schema) + require.Len(t, result.Entries, len(tt.expected.Entries)) + require.Len(t, result.Substitutions, len(tt.expected.Substitutions)) + + // Check substitutions + for i, expectedSub := range tt.expected.Substitutions { + require.Equal(t, expectedSub.Name, result.Substitutions[i].Name) + require.Equal(t, expectedSub.Base, result.Substitutions[i].Base) + } + } + }) + } +} + +func TestRender(t *testing.T) { + tests := []struct { + name string + entries []*declcfg.Meta + substitutions []Substitute + expectError bool + errorContains string + validate func(t *testing.T, cfg *declcfg.DeclarativeConfig) + }{ + { + name: "Success/render with single substitution", + entries: []*declcfg.Meta{ + createValidTestPackageMeta("testoperator", "stable"), + createValidTestChannelMeta("stable", "testoperator", []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.1.0", Replaces: "testoperator.v1.0.0"}, // Base bundle must be in channel entries + }), + createValidTestBundleMeta("testoperator.v1.0.0", "testoperator", "1.0.0", ""), + createValidTestBundleMeta("testoperator.v1.1.0", "testoperator", "1.1.0", ""), + // Substitute.name bundle (rendered from image ref) must NOT be in template entries + }, + substitutions: []Substitute{ + {Name: "quay.io/test/testoperator-bundle:v1.1.0-alpha", Base: "testoperator.v1.1.0"}, // Use bundle image reference + }, + expectError: false, + validate: func(t *testing.T, cfg *declcfg.DeclarativeConfig) { + require.Len(t, cfg.Channels, 1) + channel := cfg.Channels[0] + require.Len(t, channel.Entries, 3) // Original 2 + 1 new substitution + + // Find the new substitution entry + var substituteEntry *declcfg.ChannelEntry + for i := range channel.Entries { + if channel.Entries[i].Name == "testoperator-v1.1.0-alpha" { + substituteEntry = &channel.Entries[i] + break + } + } + require.NotNil(t, substituteEntry) + require.Equal(t, "testoperator.v1.0.0", substituteEntry.Replaces) + require.Contains(t, substituteEntry.Skips, "testoperator.v1.1.0") + }, + }, + { + name: "Success/render with multiple substitutions", + entries: []*declcfg.Meta{ + createValidTestPackageMeta("testoperator", "stable"), + createValidTestChannelMeta("stable", "testoperator", []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.1.0", Replaces: "testoperator.v1.0.0"}, + // Don't include substitution bundles in channel entries initially - they will be added by the substitution process + }), + createValidTestBundleMeta("testoperator.v1.0.0", "testoperator", "1.0.0", ""), + createValidTestBundleMeta("testoperator.v1.1.0", "testoperator", "1.1.0", ""), + // Don't include substitution bundles in entries - they will be added by the substitution process + }, + substitutions: []Substitute{ + {Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: "testoperator.v1.1.0"}, + {Name: "quay.io/test/testoperator-bundle:v1.3.0-alpha", Base: "testoperator-v1.2.0-alpha"}, + }, + expectError: false, + validate: func(t *testing.T, cfg *declcfg.DeclarativeConfig) { + require.Len(t, cfg.Channels, 1) + channel := cfg.Channels[0] + require.Len(t, channel.Entries, 4) // Original 2 + 2 new substitutions + + // Check first substitution (it gets cleared by the second substitution) + var firstSub *declcfg.ChannelEntry + for i := range channel.Entries { + if channel.Entries[i].Name == "testoperator-v1.2.0-alpha" { + firstSub = &channel.Entries[i] + break + } + } + require.NotNil(t, firstSub) + require.Empty(t, firstSub.Replaces) // Cleared by second substitution + require.Nil(t, firstSub.Skips) // Cleared by second substitution + + // Check second substitution + var secondSub *declcfg.ChannelEntry + for i := range channel.Entries { + if channel.Entries[i].Name == "testoperator-v1.3.0-alpha" { + secondSub = &channel.Entries[i] + break + } + } + require.NotNil(t, secondSub) + require.Equal(t, "testoperator.v1.0.0", secondSub.Replaces) + require.Contains(t, secondSub.Skips, "testoperator-v1.2.0-alpha") + }, + }, + { + name: "Success/render with no substitutions", + entries: []*declcfg.Meta{ + createValidTestPackageMeta("testoperator", "stable"), + createValidTestChannelMeta("stable", "testoperator", []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + }), + createValidTestBundleMeta("testoperator.v1.0.0", "testoperator", "1.0.0", ""), + }, + substitutions: []Substitute{}, + expectError: false, + validate: func(t *testing.T, cfg *declcfg.DeclarativeConfig) { + require.Len(t, cfg.Channels, 1) + channel := cfg.Channels[0] + require.Len(t, channel.Entries, 1) + require.Equal(t, "testoperator.v1.0.0", channel.Entries[0].Name) + }, + }, + { + name: "Error/render with substitution that has no matching base", + entries: []*declcfg.Meta{ + createValidTestPackageMeta("testoperator", "stable"), + createValidTestChannelMeta("stable", "testoperator", []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + }), + createValidTestBundleMeta("testoperator.v1.0.0", "testoperator", "1.0.0", ""), + }, + substitutions: []Substitute{ + {Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: "nonexistent.v1.0.0"}, + }, + expectError: true, + validate: func(t *testing.T, cfg *declcfg.DeclarativeConfig) { + require.Len(t, cfg.Channels, 1) + channel := cfg.Channels[0] + require.Len(t, channel.Entries, 1) // No new entries added + require.Equal(t, "testoperator.v1.0.0", channel.Entries[0].Name) + }, + }, + { + name: "Error/render with invalid substitution (empty name)", + entries: []*declcfg.Meta{ + createValidTestPackageMeta("testoperator", "stable"), + createValidTestChannelMeta("stable", "testoperator", []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.1.0", Replaces: "testoperator.v1.0.0"}, + }), + createValidTestBundleMeta("testoperator.v1.0.0", "testoperator", "1.0.0", ""), + createValidTestBundleMeta("testoperator.v1.1.0", "testoperator", "1.1.0", ""), + }, + substitutions: []Substitute{ + {Name: "", Base: "testoperator.v1.1.0"}, // Invalid: empty name + }, + expectError: true, + errorContains: "substitution name cannot be empty", + }, + { + name: "Error/render with invalid substitution (empty base)", + entries: []*declcfg.Meta{ + createValidTestPackageMeta("testoperator", "stable"), + createValidTestChannelMeta("stable", "testoperator", []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.1.0", Replaces: "testoperator.v1.0.0"}, + }), + createValidTestBundleMeta("testoperator.v1.0.0", "testoperator", "1.0.0", ""), + createValidTestBundleMeta("testoperator.v1.1.0", "testoperator", "1.1.0", ""), + }, + substitutions: []Substitute{ + {Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: ""}, // Invalid: empty base + }, + expectError: true, + errorContains: "substitution base cannot be empty", + }, + { + name: "Error/render with invalid substitution (same name and base)", + entries: []*declcfg.Meta{ + createValidTestPackageMeta("testoperator", "stable"), + createValidTestChannelMeta("stable", "testoperator", []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.1.0", Replaces: "testoperator.v1.0.0"}, + }), + createValidTestBundleMeta("testoperator.v1.0.0", "testoperator", "1.0.0", ""), + createValidTestBundleMeta("testoperator.v1.1.0", "testoperator", "1.1.0", ""), + }, + substitutions: []Substitute{ + {Name: "testoperator.v1.1.0", Base: "testoperator.v1.1.0"}, // Invalid: same name and base + }, + expectError: true, + errorContains: "substitution name and base cannot be the same", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create template with test data + templateData := SubstitutesTemplateData{ + Schema: "olm.template.substitutes", + Entries: tt.entries, + Substitutions: tt.substitutions, + } + + // Convert to JSON and create reader + templateJSON, err := json.Marshal(templateData) + require.NoError(t, err) + + reader := strings.NewReader(string(templateJSON)) + templateInstance := template{ + renderBundle: func(ctx context.Context, imageRef string) (*declcfg.DeclarativeConfig, error) { + // Mock implementation that creates a bundle from the image reference + packageName := "testoperator" + version := "1.0.0" + release := "" + + // Extract version from image reference if it contains a version tag + if strings.Contains(imageRef, ":v") { + parts := strings.Split(imageRef, ":v") + if len(parts) == 2 { + versionTag := parts[1] + // Check if version has a prerelease/build metadata (e.g., "1.2.0-alpha") + if strings.Contains(versionTag, "-") { + versionParts := strings.SplitN(versionTag, "-", 2) + version = versionParts[0] + release = versionParts[1] + } else { + version = versionTag + } + } + } + + // Create bundle name based on whether it has a release version + var bundleName string + var properties []property.Property + + if release != "" { + // substitutesFor bundle: package-vversion-release + bundleName = fmt.Sprintf("%s-v%s-%s", packageName, version, release) + properties = []property.Property{ + property.MustBuildPackageRelease(packageName, version, release), + property.MustBuildBundleObject([]byte(fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, imageRef))), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + } + } else { + // normal bundle: package.vversion + bundleName = fmt.Sprintf("%s.v%s", packageName, version) + properties = []property.Property{ + property.MustBuildPackage(packageName, version), + property.MustBuildBundleObject([]byte(fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, imageRef))), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + } + } + + return &declcfg.DeclarativeConfig{ + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: bundleName, + Package: packageName, + Image: imageRef, + Properties: properties, + RelatedImages: []declcfg.RelatedImage{ + {Name: "bundle", Image: imageRef}, + }, + CsvJSON: fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, imageRef), + Objects: []string{ + fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, imageRef), + `{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`, + }, + }, + }, + }, nil + }, + } + ctx := context.Background() + + result, err := templateInstance.Render(ctx, reader) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + require.Nil(t, result) + } else { + require.NoError(t, err) + require.NotNil(t, result) + if tt.validate != nil { + tt.validate(t, result) + } + } + }) + } +} + +func TestProcessSubstitution(t *testing.T) { + tests := []struct { + name string + cfg *declcfg.DeclarativeConfig + substitution Substitute + validate func(t *testing.T, cfg *declcfg.DeclarativeConfig) + }{ + { + name: "Success/substitution with replaces relationship", + cfg: &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + Description: "testoperator operator", + }, + }, + Channels: []declcfg.Channel{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Entries: []declcfg.ChannelEntry{ + {Name: "testoperator-v1.0.0-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, + }, + }, + }, + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "testoperator-v1.0.0-alpha", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.0.0", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.0.0", "alpha"), + property.MustBuildBundleObject([]byte(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.0.0-alpha"}}`)), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + }, + RelatedImages: []declcfg.RelatedImage{ + {Name: "bundle", Image: "quay.io/test/testoperator-bundle:v1.0.0"}, + }, + CsvJSON: `{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.0.0-alpha"}}`, + Objects: []string{ + `{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.0.0-alpha"}}`, + `{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`, + }, + }, + { + Schema: "olm.bundle", + Name: "testoperator-v1.1.0-alpha", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.1.0-alpha", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.1.0", "alpha"), + property.MustBuildBundleObject([]byte(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.1.0-alpha"}}`)), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + }, + RelatedImages: []declcfg.RelatedImage{ + {Name: "bundle", Image: "quay.io/test/testoperator-bundle:v1.1.0-alpha"}, + }, + CsvJSON: `{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.1.0-alpha"}}`, + Objects: []string{ + `{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.1.0-alpha"}}`, + `{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`, + }, + }, + }, + }, + substitution: Substitute{Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: "testoperator-v1.1.0-alpha"}, + validate: func(t *testing.T, cfg *declcfg.DeclarativeConfig) { + channel := cfg.Channels[0] + require.Len(t, channel.Entries, 3) + + // Find the new substitution entry + var substituteEntry *declcfg.ChannelEntry + for i := range channel.Entries { + if channel.Entries[i].Name == "testoperator-v1.2.0-alpha" { + substituteEntry = &channel.Entries[i] + break + } + } + require.NotNil(t, substituteEntry) + require.Equal(t, "testoperator-v1.0.0-alpha", substituteEntry.Replaces) + require.Contains(t, substituteEntry.Skips, "testoperator-v1.1.0-alpha") + + // Check that original entry was cleared + var originalEntry *declcfg.ChannelEntry + for i := range channel.Entries { + if channel.Entries[i].Name == "testoperator-v1.1.0-alpha" { + originalEntry = &channel.Entries[i] + break + } + } + require.NotNil(t, originalEntry) + require.Empty(t, originalEntry.Replaces) + require.Empty(t, originalEntry.Skips) + require.Empty(t, originalEntry.SkipRange) + }, + }, + { + name: "Success/substitution with skips and skipRange", + cfg: &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + Description: "testoperator operator", + }, + }, + Channels: []declcfg.Channel{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Entries: []declcfg.ChannelEntry{ + {Name: "testoperator-v1.0.0-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha", Skips: []string{"testoperator-v0.9.0-alpha"}, SkipRange: ">=0.9.0 <1.1.0"}, + }, + }, + }, + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "testoperator-v1.0.0-alpha", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.0.0", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.0.0", "alpha"), + property.MustBuildBundleObject([]byte(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.0.0-alpha"}}`)), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + }, + RelatedImages: []declcfg.RelatedImage{ + {Name: "bundle", Image: "quay.io/test/testoperator-bundle:v1.0.0"}, + }, + CsvJSON: `{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.0.0-alpha"}}`, + Objects: []string{ + `{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.0.0-alpha"}}`, + `{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`, + }, + }, + { + Schema: "olm.bundle", + Name: "testoperator-v1.1.0-alpha", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.1.0-alpha", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.1.0", "alpha"), + property.MustBuildBundleObject([]byte(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.1.0-alpha"}}`)), + property.MustBuildBundleObject([]byte(`{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`)), + }, + RelatedImages: []declcfg.RelatedImage{ + {Name: "bundle", Image: "quay.io/test/testoperator-bundle:v1.1.0-alpha"}, + }, + CsvJSON: `{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.1.0-alpha"}}`, + Objects: []string{ + `{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":"testoperator-v1.1.0-alpha"}}`, + `{"kind": "CustomResourceDefinition", "apiVersion": "apiextensions.k8s.io/v1"}`, + }, + }, + }, + }, + substitution: Substitute{Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: "testoperator-v1.1.0-alpha"}, + validate: func(t *testing.T, cfg *declcfg.DeclarativeConfig) { + channel := cfg.Channels[0] + require.Len(t, channel.Entries, 3) + + // Find the new substitution entry + var substituteEntry *declcfg.ChannelEntry + for i := range channel.Entries { + if channel.Entries[i].Name == "testoperator-v1.2.0-alpha" { + substituteEntry = &channel.Entries[i] + break + } + } + require.NotNil(t, substituteEntry) + require.Equal(t, "testoperator-v1.0.0-alpha", substituteEntry.Replaces) + require.Contains(t, substituteEntry.Skips, "testoperator-v0.9.0-alpha") + require.Contains(t, substituteEntry.Skips, "testoperator-v1.1.0-alpha") + require.Equal(t, ">=0.9.0 <1.1.0", substituteEntry.SkipRange) + }, + }, + { + name: "Error/substitution with no matching base", + cfg: &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Channels: []declcfg.Channel{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Entries: []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + }, + }, + }, + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "testoperator.v1.0.0", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.0.0", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.0.0"), + }, + }, + }, + }, + substitution: Substitute{Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: "nonexistent.v1.0.0"}, + validate: func(t *testing.T, cfg *declcfg.DeclarativeConfig) { + // This test should fail, so this validation should not be called + t.Fatal("This test should have failed") + }, + }, + { + name: "Success/substitution with multiple channels", + cfg: &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Channels: []declcfg.Channel{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Entries: []declcfg.ChannelEntry{ + {Name: "testoperator-v1.0.0-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, + }, + }, + { + Schema: "olm.channel", + Name: "beta", + Package: "testoperator", + Entries: []declcfg.ChannelEntry{ + {Name: "testoperator-v1.0.0-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, + }, + }, + }, + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "testoperator-v1.0.0-alpha", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.0.0", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.0.0", "alpha"), + }, + }, + { + Schema: "olm.bundle", + Name: "testoperator-v1.1.0-alpha", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.1.0-alpha", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.1.0", "alpha"), + }, + }, + }, + }, + substitution: Substitute{Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: "testoperator-v1.1.0-alpha"}, + validate: func(t *testing.T, cfg *declcfg.DeclarativeConfig) { + require.Len(t, cfg.Channels, 2) + + // Check stable channel + stableChannel := cfg.Channels[0] + require.Len(t, stableChannel.Entries, 3) + + // Check beta channel + betaChannel := cfg.Channels[1] + require.Len(t, betaChannel.Entries, 3) + + // Both channels should have the substitution + for _, channel := range cfg.Channels { + var substituteEntry *declcfg.ChannelEntry + for i := range channel.Entries { + if channel.Entries[i].Name == "testoperator-v1.2.0-alpha" { + substituteEntry = &channel.Entries[i] + break + } + } + require.NotNil(t, substituteEntry) + require.Equal(t, "testoperator-v1.0.0-alpha", substituteEntry.Replaces) + require.Contains(t, substituteEntry.Skips, "testoperator-v1.1.0-alpha") + } + }, + }, + { + name: "Success/substitution updates existing references", + cfg: &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Channels: []declcfg.Channel{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Entries: []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.1.0", Replaces: "testoperator.v1.0.0"}, + {Name: "testoperator.v1.2.0", Replaces: "testoperator.v1.1.0"}, + }, + }, + }, + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "testoperator.v1.0.0", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.0.0", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.0.0"), + }, + }, + { + Schema: "olm.bundle", + Name: "testoperator.v1.1.0", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.1.0", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.1.0"), + }, + }, + { + Schema: "olm.bundle", + Name: "testoperator.v1.2.0", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.2.0", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.2.0"), + }, + }, + }, + }, + substitution: Substitute{Name: "quay.io/test/testoperator-bundle:v1.1.0-alpha", Base: "testoperator.v1.1.0"}, + validate: func(t *testing.T, cfg *declcfg.DeclarativeConfig) { + channel := cfg.Channels[0] + require.Len(t, channel.Entries, 4) // Original 3 + 1 new substitution + + // Find the entry that originally replaced testoperator.v1.1.0 + var updatedEntry *declcfg.ChannelEntry + for i := range channel.Entries { + if channel.Entries[i].Name == "testoperator.v1.2.0" { + updatedEntry = &channel.Entries[i] + break + } + } + require.NotNil(t, updatedEntry) + require.Equal(t, "testoperator-v1.1.0-alpha", updatedEntry.Replaces) // Should now reference the substitute + require.Contains(t, updatedEntry.Skips, "testoperator.v1.1.0") // Should skip the original base + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, tt.cfg, tt.substitution) + if strings.Contains(tt.name, "Error/") { + require.Error(t, err) + } else { + require.NoError(t, err) + tt.validate(t, tt.cfg) + } + }) + } +} + +func TestBoundaryCases(t *testing.T) { + tests := []struct { + name string + testFunc func(t *testing.T) + }{ + { + name: "Error/empty DeclarativeConfig", + testFunc: func(t *testing.T) { + cfg := &declcfg.DeclarativeConfig{ + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "test.v0.9.0", + Package: "test", + Image: "quay.io/test/test-bundle:v0.9.0", + Properties: []property.Property{ + property.MustBuildPackage("test", "0.9.0"), + }, + }, + }, + } + substitution := Substitute{Name: "quay.io/test/test-bundle:v1.0.0-alpha", Base: "test.v0.9.0"} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown package") + }, + }, + { + name: "Error/DeclarativeConfig with empty channels", + testFunc: func(t *testing.T) { + cfg := &declcfg.DeclarativeConfig{ + Channels: []declcfg.Channel{}, + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "test.v0.9.0", + Package: "test", + Image: "quay.io/test/test-bundle:v0.9.0", + Properties: []property.Property{ + property.MustBuildPackage("test", "0.9.0"), + }, + }, + }, + } + substitution := Substitute{Name: "quay.io/test/test-bundle:v1.0.0-alpha", Base: "test.v0.9.0"} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown package") + }, + }, + { + name: "Error/channel with empty entries", + testFunc: func(t *testing.T) { + cfg := &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Channels: []declcfg.Channel{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Entries: []declcfg.ChannelEntry{}, + }, + }, + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "test.v0.9.0", + Package: "test", + Image: "quay.io/test/test-bundle:v0.9.0", + Properties: []property.Property{ + property.MustBuildPackage("test", "0.9.0"), + }, + }, + }, + } + substitution := Substitute{Name: "quay.io/test/test-bundle:v1.0.0-alpha", Base: "test.v0.9.0"} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown package") + }, + }, + { + name: "Error/substitution with empty name", + testFunc: func(t *testing.T) { + cfg := createTestDeclarativeConfig() + substitution := Substitute{Name: "", Base: "testoperator.v1.1.0"} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "substitution name cannot be empty") + // Should not create any new entries with empty name + require.Len(t, cfg.Channels[0].Entries, 3) // Original entries unchanged + }, + }, + { + name: "Error/substitution with empty base", + testFunc: func(t *testing.T) { + cfg := createTestDeclarativeConfig() + substitution := Substitute{Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: ""} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "substitution base cannot be empty") + // Should not create any new entries with empty base + require.Len(t, cfg.Channels[0].Entries, 3) // Original entries unchanged + }, + }, + { + name: "Error/substitution with same name and base", + testFunc: func(t *testing.T) { + cfg := createTestDeclarativeConfig() + substitution := Substitute{Name: "quay.io/test/testoperator-bundle:v1.1.0-alpha", Base: "quay.io/test/testoperator-bundle:v1.1.0-alpha"} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "substitution name and base cannot be the same") + // Should not create any new entries when name equals base + require.Len(t, cfg.Channels[0].Entries, 3) // Original entries unchanged + }, + }, + { + name: "Error/template with malformed JSON in blob", + testFunc: func(t *testing.T) { + // Create a template with invalid JSON in the blob + invalidMeta := &declcfg.Meta{ + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Blob: json.RawMessage(`{"invalid": json, "missing": quote}`), + } + + template := SubstitutesTemplateData{ + Schema: "olm.template.substitutes", + Entries: []*declcfg.Meta{invalidMeta}, + Substitutions: []Substitute{}, + } + + _, err := json.Marshal(template) + // The malformed JSON should cause an error during marshaling + require.Error(t, err) + require.Contains(t, err.Error(), "invalid character") + }, + }, + { + name: "Success/template with nil context", + testFunc: func(t *testing.T) { + entries := []*declcfg.Meta{ + createValidTestPackageMeta("testoperator", "stable"), + createValidTestChannelMeta("stable", "testoperator", []declcfg.ChannelEntry{ + {Name: "testoperator-v1.0.0-alpha"}, + }), + createValidTestBundleMeta("testoperator-v1.0.0-alpha", "testoperator", "1.0.0", "alpha"), + } + + templateData := SubstitutesTemplateData{ + Schema: "olm.template.substitutes", + Entries: entries, + Substitutions: []Substitute{}, + } + + templateJSON, err := json.Marshal(templateData) + require.NoError(t, err) + + reader := strings.NewReader(string(templateJSON)) + templateInstance := template{} + + result, err := templateInstance.Render(context.TODO(), reader) + require.NoError(t, err) // Context is not used in current implementation + require.NotNil(t, result) + }, + }, + { + name: "Error/substitution with invalid declarative config - missing package", + testFunc: func(t *testing.T) { + // Create a config with a bundle in the catalog that is not referenced in any channels + cfg := &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Channels: []declcfg.Channel{ + { + Schema: "olm.channel", + Name: "stable", + Package: "testoperator", + Entries: []declcfg.ChannelEntry{ + {Name: "testoperator.v1.0.0"}, + }, + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "testoperator.v1.0.0", // Base bundle for substitution + Package: "testoperator", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.0.0"), + }, + }, + { + Name: "testoperator.v1.1.0", // Extra bundle not in any channel + Package: "testoperator", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.1.0"), + }, + }, + }, + } + substitution := Substitute{Name: "quay.io/test/testoperator-bundle:v1.1.0-alpha", Base: "testoperator.v1.0.0"} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "not found in any channel entries") + }, + }, + { + name: "Error/substitution with invalid declarative config - bundle missing olm.package property", + testFunc: func(t *testing.T) { + // Create a config where the base bundle has no olm.package property + cfg := &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "testoperator.v1.0.0", // Base bundle for substitution, missing olm.package property + Package: "testoperator", + Properties: []property.Property{}, // No olm.package property + }, + }, + } + substitution := Substitute{Name: "quay.io/test/testoperator-bundle:v1.1.0-alpha", Base: "testoperator.v1.0.0"} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "must have exactly 1 \"olm.package\" property") + }, + }, + { + name: "Error/base bundle does not exist in catalog", + testFunc: func(t *testing.T) { + cfg := &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "testoperator.v1.1.0", + Package: "testoperator", + Properties: []property.Property{ + property.MustBuildPackage("testoperator", "1.1.0"), + }, + }, + }, + } + substitution := Substitute{Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: "testoperator.v1.0.0"} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist in catalog") + }, + }, + { + name: "Error/substitute bundle version not greater than base bundle", + testFunc: func(t *testing.T) { + cfg := &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "testoperator-v1.2.0-beta", + Package: "testoperator", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.2.0", "beta"), + }, + }, + }, + } + // Trying to substitute with an alpha release (lower than beta) + substitution := Substitute{Name: "quay.io/test/testoperator-bundle:v1.2.0-alpha", Base: "testoperator-v1.2.0-beta"} + template := createMockTemplate() + ctx := context.Background() + err := template.processSubstitution(ctx, cfg, substitution) + require.Error(t, err) + require.Contains(t, err.Error(), "is not less than") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.testFunc(t) + }) + } +} diff --git a/cmd/opm/alpha/convert-template/convert.go b/cmd/opm/alpha/convert-template/convert.go index a5587c004..4b5567e9c 100644 --- a/cmd/opm/alpha/convert-template/convert.go +++ b/cmd/opm/alpha/convert-template/convert.go @@ -17,6 +17,7 @@ func NewCmd() *cobra.Command { } cmd.AddCommand( newBasicConvertCmd(), + newSubstitutesConvertCmd(), ) return cmd } @@ -49,6 +50,49 @@ If no argument is specified or is '-' input is assumed from STDIN. } converter.FbcReader = reader + converter.DestinationTemplateType = "basic" + err = converter.Convert() + if err != nil { + return fmt.Errorf("converting: %v", err) + } + + return nil + }, + } + cmd.Flags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml)") + + return cmd +} + +func newSubstitutesConvertCmd() *cobra.Command { + var ( + converter converter.Converter + output string + ) + cmd := &cobra.Command{ + Use: "substitutes [ | -]", + Args: cobra.MaximumNArgs(1), + Short: "Generate a substitutes template from existing FBC", + Long: `Generate a substitutes template from existing FBC. + +This command outputs a substitutes catalog template to STDOUT from input FBC. +If no argument is specified or is '-' input is assumed from STDIN. +`, + RunE: func(c *cobra.Command, args []string) error { + switch output { + case "yaml", "json": + converter.OutputFormat = output + default: + log.Fatalf("invalid --output value %q, expected (json|yaml)", output) + } + + reader, name, err := util.OpenFileOrStdin(c, args) + if err != nil { + return fmt.Errorf("unable to open input: %q", name) + } + + converter.FbcReader = reader + converter.DestinationTemplateType = "substitutes" err = converter.Convert() if err != nil { return fmt.Errorf("converting: %v", err) diff --git a/pkg/lib/validation/bundle.go b/pkg/lib/validation/bundle.go index a88b7b630..ce990cd56 100644 --- a/pkg/lib/validation/bundle.go +++ b/pkg/lib/validation/bundle.go @@ -39,6 +39,18 @@ func validateBundle(bundle *registry.Bundle) errors.ManifestResult { result.Add(errors.ErrInvalidParse("error getting bundle CSV version", err)) return result } + release, err := csv.GetRelease() + if err != nil { + result.Add(errors.ErrInvalidParse("error getting bundle CSV release version", err)) + return result + } + substitutesFor := csv.GetSubstitutesFor() + if release != "" && substitutesFor != "" { + result.Add(errors.ErrInvalidBundle( + fmt.Sprintf("bundle %q cannot have both a release version (%q) and olm.substitutesFor annotation (%q)", bundle.Name, release, substitutesFor), + registry.DefinitionKey{}, + )) + } return result } diff --git a/pkg/registry/bundle.go b/pkg/registry/bundle.go index 8b3be74b0..792f9f0dd 100644 --- a/pkg/registry/bundle.go +++ b/pkg/registry/bundle.go @@ -42,6 +42,7 @@ type Bundle struct { Channels []string BundleImage string version string + release string csv *ClusterServiceVersion v1beta1crds []*apiextensionsv1beta1.CustomResourceDefinition v1crds []*apiextensionsv1.CustomResourceDefinition @@ -130,6 +131,23 @@ func (b *Bundle) Version() (string, error) { return b.version, err } +func (b *Bundle) Release() (string, error) { + if b.release != "" { + return b.release, nil + } + + var err error + if err = b.cache(); err != nil { + return "", err + } + + if b.csv != nil { + b.release, err = b.csv.GetRelease() + } + + return b.release, err +} + func (b *Bundle) SkipRange() (string, error) { if err := b.cache(); err != nil { return "", err diff --git a/pkg/registry/csv.go b/pkg/registry/csv.go index d11e5a8cb..fec3d2907 100644 --- a/pkg/registry/csv.go +++ b/pkg/registry/csv.go @@ -58,6 +58,9 @@ const ( // expected to be semver and parseable by blang/semver/v4 version = "version" + // The yaml attribute that specifies the release version of the ClusterServiceVersion + release = "release" + // The yaml attribute that specifies the related images of the ClusterServiceVersion relatedImages = "relatedImages" @@ -181,6 +184,28 @@ func (csv *ClusterServiceVersion) GetVersion() (string, error) { return v, nil } +// GetRelease returns the release of the CSV +// +// If not defined, the function returns an empty string. +func (csv *ClusterServiceVersion) GetRelease() (string, error) { + var objmap map[string]*json.RawMessage + if err := json.Unmarshal(csv.Spec, &objmap); err != nil { + return "", err + } + + rawValue, ok := objmap[release] + if !ok || rawValue == nil { + return "", nil + } + + var r string + if err := json.Unmarshal(*rawValue, &r); err != nil { + return "", err + } + + return r, nil +} + // GetSkipRange returns the skiprange of the CSV // // If not defined, the function returns an empty string. diff --git a/pkg/registry/parse.go b/pkg/registry/parse.go index ae82fe441..2f5a91355 100644 --- a/pkg/registry/parse.go +++ b/pkg/registry/parse.go @@ -197,22 +197,35 @@ func (b *bundleParser) derivedProperties(bundle *Bundle) ([]Property, error) { } } + // nolint:nestif + // existing code triggering nested complexity, but at least will not make worse with release processing if bundle.Annotations != nil && bundle.Annotations.PackageName != "" { pkg := bundle.Annotations.PackageName version, err := bundle.Version() if err != nil { return nil, err } + release, err := bundle.Release() + if err != nil { + return nil, err + } + if release == "" && csv.GetSubstitutesFor() != "" { + version, release, err = extractReleaseVersionFromBuildMetadata(version) + if err != nil { + return nil, fmt.Errorf("bundle %q error: %v", bundle.Name, err) + } + } value, err := json.Marshal(PackageProperty{ PackageName: pkg, Version: version, + Release: release, }) if err != nil { return nil, fmt.Errorf("failed to marshal package property: %s", err) } - // Annotations file takes precedent over CSV annotations + // Annotations file takes precedence over CSV annotations derived = append([]Property{{Type: PackageType, Value: value}}, derived...) } @@ -253,3 +266,21 @@ func propertySet(properties []Property) []Property { return set } + +func extractReleaseVersionFromBuildMetadata(substitutesFor string) (string, string, error) { + var version, release string + // if the bundle expresses no release version, but + // includes the substitutesFor annotation, then we + // interpret any build metadata in the version as + // the release version. + // failure to parse build metadata under these conditions is fatal, + // though validation is later + parts := strings.SplitN(substitutesFor, "+", 2) + if len(parts) == 2 { + version = parts[0] + release = parts[1] + } else { + return "", "", fmt.Errorf("no release version expressed as build metadata: %q", substitutesFor) + } + return version, release, nil +} diff --git a/pkg/registry/registry_to_model.go b/pkg/registry/registry_to_model.go index 947814751..d0491f6df 100644 --- a/pkg/registry/registry_to_model.go +++ b/pkg/registry/registry_to_model.go @@ -29,7 +29,7 @@ func ObjectsAndPropertiesFromBundle(b *Bundle) ([]string, []property.Property, e if err := json.Unmarshal(p.Value, &v); err != nil { return nil, nil, property.ParseError{Idx: i, Typ: p.Type, Err: err} } - p := property.MustBuildPackage(v.PackageName, v.Version) + p := property.MustBuildPackageRelease(v.PackageName, v.Version, v.Release) packageProvidedProperty = &p default: otherProps = append(otherProps, property.Property{ @@ -68,6 +68,11 @@ func ObjectsAndPropertiesFromBundle(b *Bundle) ([]string, []property.Property, e return nil, nil, fmt.Errorf("get version: %v", err) } + release, err := b.Release() + if err != nil { + return nil, nil, fmt.Errorf("get release: %v", err) + } + providedApis, err := b.ProvidedAPIs() if err != nil { return nil, nil, fmt.Errorf("get provided apis: %v", err) @@ -105,7 +110,7 @@ func ObjectsAndPropertiesFromBundle(b *Bundle) ([]string, []property.Property, e } if packageProvidedProperty == nil { - p := property.MustBuildPackage(b.Package, version) + p := property.MustBuildPackageRelease(b.Package, version, release) packageProvidedProperty = &p } props = append(props, *packageProvidedProperty) diff --git a/pkg/registry/types.go b/pkg/registry/types.go index 997eacc64..66dc242e6 100644 --- a/pkg/registry/types.go +++ b/pkg/registry/types.go @@ -256,6 +256,9 @@ type PackageProperty struct { // The version of package in semver format Version string `json:"version" yaml:"version"` + + // The release version of the package in semver pre-release format + Release string `json:"release,omitzero" yaml:"release,omitzero"` } type DeprecatedProperty struct {