diff --git a/alpha/declcfg/declcfg_to_model.go b/alpha/declcfg/declcfg_to_model.go index 342cab403..d2c5a79bb 100644 --- a/alpha/declcfg/declcfg_to_model.go +++ b/alpha/declcfg/declcfg_to_model.go @@ -135,6 +135,18 @@ 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) + } + if relver.Major != 0 || relver.Minor != 0 || relver.Patch != 0 || len(relver.Build) != 0 { + return nil, fmt.Errorf("bundle %q release version %q must only contain prerelease", b.Name, props.Packages[0].Release) + } + } + channelDefinedEntries[b.Package] = channelDefinedEntries[b.Package].Delete(b.Name) found := false for _, mch := range mpkg.Channels { @@ -147,6 +159,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 de8639c1b..40b45009e 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..5457fe595 100644 --- a/alpha/model/model.go +++ b/alpha/model/model.go @@ -3,6 +3,7 @@ package model import ( "errors" "fmt" + "slices" "sort" "strings" @@ -103,24 +104,24 @@ func (m *Package) Validate() error { } func (m *Package) validateUniqueBundleVersions() error { - versionsMap := map[string]semver.Version{} + versionsMap := map[string]string{} bundlesWithVersion := map[string]sets.Set[string]{} for _, ch := range m.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,41 @@ 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) + } + return b.Release.Compare(other.Release) } func (b *Bundle) Validate() error { @@ -339,6 +375,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 +418,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..a125d7978 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-hotfix.0.0.1": {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-hotfix.0.0.1": {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 a34d7541d..4eab720f4 100644 --- a/alpha/template/basic/basic.go +++ b/alpha/template/basic/basic.go @@ -9,40 +9,29 @@ import ( "k8s.io/apimachinery/pkg/util/yaml" "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/template" ) const schema string = "olm.template.basic" -type Template struct { - RenderBundle func(context.Context, string) (*declcfg.DeclarativeConfig, error) -} - type BasicTemplate struct { - Schema string `json:"schema"` - Entries []*declcfg.Meta `json:"entries"` + renderBundle template.BundleRenderer } -func parseSpec(reader io.Reader) (*BasicTemplate, error) { - bt := &BasicTemplate{} - btDoc := json.RawMessage{} - btDecoder := yaml.NewYAMLOrJSONDecoder(reader, 4096) - err := btDecoder.Decode(&btDoc) - if err != nil { - return nil, fmt.Errorf("decoding template schema: %v", err) - } - err = json.Unmarshal(btDoc, bt) - if err != nil { - return nil, fmt.Errorf("unmarshalling template: %v", err) - } - - if bt.Schema != schema { - return nil, fmt.Errorf("template has unknown schema (%q), should be %q", bt.Schema, schema) +// NewTemplate creates a new basic template instance +func NewTemplate(renderBundle template.BundleRenderer) template.Template { + return &BasicTemplate{ + renderBundle: renderBundle, } +} - return bt, nil +// RenderBundle implements the template.Template interface +func (t *BasicTemplate) RenderBundle(ctx context.Context, image string) (*declcfg.DeclarativeConfig, error) { + return t.renderBundle(ctx, image) } -func (t Template) Render(ctx context.Context, reader io.Reader) (*declcfg.DeclarativeConfig, error) { +// Render implements the template.Template interface +func (t *BasicTemplate) Render(ctx context.Context, reader io.Reader) (*declcfg.DeclarativeConfig, error) { bt, err := parseSpec(reader) if err != nil { return nil, err @@ -68,14 +57,57 @@ func (t Template) Render(ctx context.Context, reader io.Reader) (*declcfg.Declar return cfg, nil } +// Schema implements the template.Template interface +func (t *BasicTemplate) Schema() string { + return schema +} + +// Factory implements the template.TemplateFactory interface +type Factory struct{} + +// CreateTemplate implements the template.TemplateFactory interface +func (f *Factory) CreateTemplate(renderBundle template.BundleRenderer) template.Template { + return NewTemplate(renderBundle) +} + +// Schema implements the template.TemplateFactory interface +func (f *Factory) Schema() string { + return schema +} + +type BasicTemplateData struct { + Schema string `json:"schema"` + Entries []*declcfg.Meta `json:"entries"` +} + +func parseSpec(reader io.Reader) (*BasicTemplateData, error) { + bt := &BasicTemplateData{} + btDoc := json.RawMessage{} + btDecoder := yaml.NewYAMLOrJSONDecoder(reader, 4096) + err := btDecoder.Decode(&btDoc) + if err != nil { + return nil, fmt.Errorf("decoding template schema: %v", err) + } + err = json.Unmarshal(btDoc, bt) + if err != nil { + return nil, fmt.Errorf("unmarshalling template: %v", err) + } + + if bt.Schema != schema { + return nil, fmt.Errorf("template has unknown schema (%q), should be %q", bt.Schema, schema) + } + + return bt, nil +} + // isBundleTemplate identifies a Bundle template source as having a Schema and Image defined // but no Properties, RelatedImages or Package defined func isBundleTemplate(b *declcfg.Bundle) bool { return b.Schema != "" && b.Image != "" && b.Package == "" && len(b.Properties) == 0 && len(b.RelatedImages) == 0 } -// FromReader reads FBC from a reader and generates a BasicTemplate from it -func FromReader(r io.Reader) (*BasicTemplate, error) { +// FromReader reads FBC from a reader and generates a BasicTemplateData from it +func FromReader(r io.Reader) (*BasicTemplateData, error) { var entries []*declcfg.Meta if err := declcfg.WalkMetasReader(r, func(meta *declcfg.Meta, err error) error { if err != nil { @@ -101,7 +133,7 @@ func FromReader(r io.Reader) (*BasicTemplate, error) { return nil, err } - bt := &BasicTemplate{ + bt := &BasicTemplateData{ Schema: schema, Entries: entries, } diff --git a/alpha/template/schema.go b/alpha/template/schema.go new file mode 100644 index 000000000..28eb97914 --- /dev/null +++ b/alpha/template/schema.go @@ -0,0 +1,35 @@ +package template + +import ( + "encoding/json" + "fmt" + "io" + + "k8s.io/apimachinery/pkg/util/yaml" +) + +// detectSchema reads the input and extracts the schema field +func detectSchema(reader io.Reader) (string, error) { + // Read the input into a raw message + rawDoc := json.RawMessage{} + decoder := yaml.NewYAMLOrJSONDecoder(reader, 4096) + err := decoder.Decode(&rawDoc) + if err != nil { + return "", fmt.Errorf("decoding template input: %v", err) + } + + // Parse the raw message to extract schema + var schemaDoc struct { + Schema string `json:"schema"` + } + err = json.Unmarshal(rawDoc, &schemaDoc) + if err != nil { + return "", fmt.Errorf("unmarshalling template schema: %v", err) + } + + if schemaDoc.Schema == "" { + return "", fmt.Errorf("template input missing required 'schema' field") + } + + return schemaDoc.Schema, nil +} diff --git a/alpha/template/semver/semver.go b/alpha/template/semver/semver.go index 8a4b837b4..9e02b6d9f 100644 --- a/alpha/template/semver/semver.go +++ b/alpha/template/semver/semver.go @@ -15,12 +15,55 @@ import ( "github.com/operator-framework/operator-registry/alpha/declcfg" "github.com/operator-framework/operator-registry/alpha/property" + "github.com/operator-framework/operator-registry/alpha/template" ) -func (t Template) Render(ctx context.Context) (*declcfg.DeclarativeConfig, error) { +// IO structs -- BEGIN +type semverTemplateBundleEntry struct { + Image string `json:"image,omitempty"` +} + +type semverTemplateChannelBundles struct { + Bundles []semverTemplateBundleEntry `json:"bundles,omitempty"` +} + +type SemverTemplateData struct { + Schema string `json:"schema"` + GenerateMajorChannels bool `json:"generateMajorChannels,omitempty"` + GenerateMinorChannels bool `json:"generateMinorChannels,omitempty"` + DefaultChannelTypePreference streamType `json:"defaultChannelTypePreference,omitempty"` + Candidate semverTemplateChannelBundles `json:"candidate,omitempty"` + Fast semverTemplateChannelBundles `json:"fast,omitempty"` + Stable semverTemplateChannelBundles `json:"stable,omitempty"` + + pkg string `json:"-"` // the derived package name + defaultChannel string `json:"-"` // detected "most stable" channel head +} + +// IO structs -- END + +// SemverTemplate implements the common template interface +type SemverTemplate struct { + renderBundle template.BundleRenderer +} + +// NewTemplate creates a new semver template instance +func NewTemplate(renderBundle template.BundleRenderer) template.Template { + return &SemverTemplate{ + renderBundle: renderBundle, + } +} + +// RenderBundle implements the template.Template interface +func (t *SemverTemplate) RenderBundle(ctx context.Context, image string) (*declcfg.DeclarativeConfig, error) { + return t.renderBundle(ctx, image) +} + +// Render implements the template.Template interface +func (t *SemverTemplate) Render(ctx context.Context, reader io.Reader) (*declcfg.DeclarativeConfig, error) { var out declcfg.DeclarativeConfig - sv, err := readFile(t.Data) + sv, err := readFile(reader) if err != nil { return nil, fmt.Errorf("render: unable to read file: %v", err) } @@ -58,7 +101,79 @@ func (t Template) Render(ctx context.Context) (*declcfg.DeclarativeConfig, error return &out, nil } -func buildBundleList(t semverTemplate) map[string]string { +// Schema implements the template.Template interface +func (t *SemverTemplate) Schema() string { + return schema +} + +// Factory implements the template.TemplateFactory interface +type Factory struct{} + +// CreateTemplate implements the template.TemplateFactory interface +func (f *Factory) CreateTemplate(renderBundle template.BundleRenderer) template.Template { + return NewTemplate(renderBundle) +} + +// Schema implements the template.TemplateFactory interface +func (f *Factory) Schema() string { + return schema +} + +const schema string = "olm.semver" + +// channel "archetypes", restricted in this iteration to just these +type channelArchetype string + +const ( + candidateChannelArchetype channelArchetype = "candidate" + fastChannelArchetype channelArchetype = "fast" + stableChannelArchetype channelArchetype = "stable" +) + +// mapping channel name --> stability, where higher values indicate greater stability +var channelPriorities = map[channelArchetype]int{candidateChannelArchetype: 0, fastChannelArchetype: 1, stableChannelArchetype: 2} + +// sorting capability for a slice according to the assigned channelPriorities +type byChannelPriority []channelArchetype + +func (b byChannelPriority) Len() int { return len(b) } +func (b byChannelPriority) Less(i, j int) bool { + return channelPriorities[b[i]] < channelPriorities[b[j]] +} +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} + +// 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 + +// the "high-water channel" struct functions as a freely-rising indicator of the "most stable" channel head, so we can use that +// later as the package's defaultChannel attribute +type highwaterChannel struct { + archetype channelArchetype + kind streamType + version semver.Version + name string +} + +// entryTuple represents a channel entry with its associated metadata +type entryTuple struct { + arch channelArchetype + kind streamType + parent string + name string + version semver.Version + index int +} + +func buildBundleList(t SemverTemplateData) map[string]string { dict := make(map[string]string) for _, bl := range []semverTemplateChannelBundles{t.Candidate, t.Fast, t.Stable} { for _, b := range bl.Bundles { @@ -70,13 +185,13 @@ func buildBundleList(t semverTemplate) map[string]string { return dict } -func readFile(reader io.Reader) (*semverTemplate, error) { +func readFile(reader io.Reader) (*SemverTemplateData, error) { data, err := io.ReadAll(reader) if err != nil { return nil, err } - sv := semverTemplate{} + sv := SemverTemplateData{} if err := yaml.UnmarshalStrict(data, &sv); err != nil { return nil, err } @@ -115,7 +230,7 @@ func readFile(reader io.Reader) (*semverTemplate, error) { return &sv, nil } -func (sv *semverTemplate) getVersionsFromStandardChannels(cfg *declcfg.DeclarativeConfig, bundleDict map[string]string) (*bundleVersions, error) { +func (sv *SemverTemplateData) getVersionsFromStandardChannels(cfg *declcfg.DeclarativeConfig, bundleDict map[string]string) (*bundleVersions, error) { versions := bundleVersions{} bdm, err := sv.getVersionsFromChannel(sv.Candidate.Bundles, bundleDict, cfg) @@ -148,7 +263,7 @@ func (sv *semverTemplate) getVersionsFromStandardChannels(cfg *declcfg.Declarati return &versions, nil } -func (sv *semverTemplate) getVersionsFromChannel(semverBundles []semverTemplateBundleEntry, bundleDict map[string]string, cfg *declcfg.DeclarativeConfig) (map[string]semver.Version, error) { +func (sv *SemverTemplateData) getVersionsFromChannel(semverBundles []semverTemplateBundleEntry, bundleDict map[string]string, cfg *declcfg.DeclarativeConfig) (map[string]semver.Version, error) { entries := make(map[string]semver.Version) // we iterate over the channel bundles from the template, to: @@ -210,7 +325,7 @@ func (sv *semverTemplate) getVersionsFromChannel(semverBundles []semverTemplateB // - within the same minor version (Y-stream), the head of the channel should have a 'skips' encompassing all lesser Y.Z versions of the bundle enumerated in the template. // along the way, uses a highwaterChannel marker to identify the "most stable" channel head to be used as the default channel for the generated package -func (sv *semverTemplate) generateChannels(semverChannels *bundleVersions) []declcfg.Channel { +func (sv *SemverTemplateData) generateChannels(semverChannels *bundleVersions) []declcfg.Channel { outChannels := []declcfg.Channel{} // sort the channel archetypes in ascending order so we can traverse the bundles in order of @@ -287,7 +402,7 @@ func (sv *semverTemplate) generateChannels(semverChannels *bundleVersions) []dec return outChannels } -func (sv *semverTemplate) linkChannels(unlinkedChannels map[string]*declcfg.Channel, entries []entryTuple) []declcfg.Channel { +func (sv *SemverTemplateData) linkChannels(unlinkedChannels map[string]*declcfg.Channel, entries []entryTuple) []declcfg.Channel { channels := []declcfg.Channel{} // sort to force partitioning by archetype --> kind --> semver diff --git a/alpha/template/semver/semver_test.go b/alpha/template/semver/semver_test.go index d85522ba4..430996af1 100644 --- a/alpha/template/semver/semver_test.go +++ b/alpha/template/semver/semver_test.go @@ -306,7 +306,7 @@ func TestLinkChannels(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sv := &semverTemplate{pkg: "a", GenerateMajorChannels: tt.generateMajorChannels, GenerateMinorChannels: tt.generateMinorChannels} + sv := &SemverTemplateData{pkg: "a", GenerateMajorChannels: tt.generateMajorChannels, GenerateMinorChannels: tt.generateMinorChannels} diff := gocmp.Diff(tt.out, sv.linkChannels(tt.unlinkedChannels, tt.channelEntries)) if diff != "" { t.Errorf("unexpected channel diff (-expected +received):\n%s", diff) @@ -527,11 +527,9 @@ func TestGenerateChannels(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sv := &semverTemplate{GenerateMajorChannels: tt.generateMajorChannels, GenerateMinorChannels: tt.generateMinorChannels, pkg: "a", DefaultChannelTypePreference: tt.channelTypePreference} - diff := gocmp.Diff(tt.out, sv.generateChannels(&channelOperatorVersions)) - if diff != "" { - t.Errorf("unexpected channel diff (-expected +received):\n%s", diff) - } + sv := &SemverTemplateData{GenerateMajorChannels: tt.generateMajorChannels, GenerateMinorChannels: tt.generateMinorChannels, pkg: "a", DefaultChannelTypePreference: tt.channelTypePreference} + out := sv.generateChannels(&channelOperatorVersions) + require.ElementsMatch(t, tt.out, out) require.Equal(t, tt.defaultChannel, sv.defaultChannel) }) } @@ -540,13 +538,13 @@ func TestGenerateChannels(t *testing.T) { func TestGetVersionsFromStandardChannel(t *testing.T) { tests := []struct { name string - sv semverTemplate + sv SemverTemplateData outVersions bundleVersions dc declcfg.DeclarativeConfig }{ { name: "sunny day case", - sv: semverTemplate{ + sv: SemverTemplateData{ Stable: semverTemplateChannelBundles{ []semverTemplateBundleEntry{ {Image: "repo/origin/a-v0.1.0"}, @@ -612,7 +610,7 @@ func TestGetVersionsFromStandardChannel(t *testing.T) { } func TestBailOnVersionBuildMetadata(t *testing.T) { - sv := semverTemplate{ + sv := SemverTemplateData{ Stable: semverTemplateChannelBundles{ []semverTemplateBundleEntry{ {Image: "repo/origin/a-v0.1.0"}, @@ -694,13 +692,13 @@ stable: type testCase struct { name string input string - assertions func(*testing.T, *semverTemplate, error) + assertions func(*testing.T, *SemverTemplateData, error) } testCases := []testCase{ { name: "valid", input: fmt.Sprintf(templateFstr, "true", "true", "minor"), - assertions: func(t *testing.T, template *semverTemplate, err error) { + assertions: func(t *testing.T, template *SemverTemplateData, err error) { require.NotNil(t, template) require.NoError(t, err) }, @@ -738,7 +736,7 @@ invalid: bundles: - image: quay.io/foo/olm:testoperator.v1.0.1 `, - assertions: func(t *testing.T, template *semverTemplate, err error) { + assertions: func(t *testing.T, template *SemverTemplateData, err error) { require.Nil(t, template) require.EqualError(t, err, `error unmarshaling JSON: while decoding JSON: json: unknown field "invalid"`) }, @@ -746,7 +744,7 @@ invalid: { name: "generate/default mismatch, minor/major", input: fmt.Sprintf(templateFstr, "true", "false", "minor"), - assertions: func(t *testing.T, template *semverTemplate, err error) { + assertions: func(t *testing.T, template *SemverTemplateData, err error) { require.Nil(t, template) require.ErrorContains(t, err, "schema attribute mismatch") }, @@ -754,7 +752,7 @@ invalid: { name: "generate/default mismatch, major/minor", input: fmt.Sprintf(templateFstr, "false", "true", "major"), - assertions: func(t *testing.T, template *semverTemplate, err error) { + assertions: func(t *testing.T, template *SemverTemplateData, err error) { require.Nil(t, template) require.ErrorContains(t, err, "schema attribute mismatch") }, @@ -762,7 +760,7 @@ invalid: { name: "unknown defaultchanneltypepreference", input: fmt.Sprintf(templateFstr, "false", "true", "foo"), - assertions: func(t *testing.T, template *semverTemplate, err error) { + assertions: func(t *testing.T, template *SemverTemplateData, err error) { require.Nil(t, template) require.ErrorContains(t, err, "unknown DefaultChannelTypePreference") }, diff --git a/alpha/template/semver/types.go b/alpha/template/semver/types.go deleted file mode 100644 index fda01139a..000000000 --- a/alpha/template/semver/types.go +++ /dev/null @@ -1,93 +0,0 @@ -package semver - -import ( - "context" - "io" - - "github.com/blang/semver/v4" - - "github.com/operator-framework/operator-registry/alpha/declcfg" -) - -// data passed into this module externally -type Template struct { - Data io.Reader - RenderBundle func(context.Context, string) (*declcfg.DeclarativeConfig, error) -} - -// IO structs -- BEGIN -type semverTemplateBundleEntry struct { - Image string `json:"image,omitempty"` -} - -type semverTemplateChannelBundles struct { - Bundles []semverTemplateBundleEntry `json:"bundles,omitempty"` -} - -type semverTemplate struct { - Schema string `json:"schema"` - GenerateMajorChannels bool `json:"generateMajorChannels,omitempty"` - GenerateMinorChannels bool `json:"generateMinorChannels,omitempty"` - DefaultChannelTypePreference streamType `json:"defaultChannelTypePreference,omitempty"` - Candidate semverTemplateChannelBundles `json:"candidate,omitempty"` - Fast semverTemplateChannelBundles `json:"fast,omitempty"` - Stable semverTemplateChannelBundles `json:"stable,omitempty"` - - pkg string `json:"-"` // the derived package name - defaultChannel string `json:"-"` // detected "most stable" channel head -} - -// IO structs -- END - -const schema string = "olm.semver" - -// channel "archetypes", restricted in this iteration to just these -type channelArchetype string - -const ( - candidateChannelArchetype channelArchetype = "candidate" - fastChannelArchetype channelArchetype = "fast" - stableChannelArchetype channelArchetype = "stable" -) - -// mapping channel name --> stability, where higher values indicate greater stability -var channelPriorities = map[channelArchetype]int{candidateChannelArchetype: 0, fastChannelArchetype: 1, stableChannelArchetype: 2} - -// sorting capability for a slice according to the assigned channelPriorities -type byChannelPriority []channelArchetype - -func (b byChannelPriority) Len() int { return len(b) } -func (b byChannelPriority) Less(i, j int) bool { - return channelPriorities[b[i]] < channelPriorities[b[j]] -} -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} - -// 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 - -// the "high-water channel" struct functions as a freely-rising indicator of the "most stable" channel head, so we can use that -// later as the package's defaultChannel attribute -type highwaterChannel struct { - archetype channelArchetype - kind streamType - version semver.Version - name string -} - -type entryTuple struct { - arch channelArchetype - kind streamType - name string - parent string - index int - version semver.Version -} diff --git a/alpha/template/substitutes/substitutes.go b/alpha/template/substitutes/substitutes.go new file mode 100644 index 000000000..e42340fe8 --- /dev/null +++ b/alpha/template/substitutes/substitutes.go @@ -0,0 +1,192 @@ +package substitutes + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/template" +) + +const substitutesSchema string = "olm.template.substitutes" + +type SubstitutesTemplate struct { + renderBundle template.BundleRenderer +} + +// NewTemplate creates a new substitutes template instance +func NewTemplate(renderBundle template.BundleRenderer) template.Template { + return &SubstitutesTemplate{ + renderBundle: renderBundle, + } +} + +// RenderBundle implements the template.Template interface +func (t *SubstitutesTemplate) RenderBundle(ctx context.Context, image string) (*declcfg.DeclarativeConfig, error) { + return t.renderBundle(ctx, image) +} + +// Render implements the template.Template interface +func (t *SubstitutesTemplate) 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 +} + +// Schema implements the template.Template interface +func (t *SubstitutesTemplate) Schema() string { + return substitutesSchema +} + +// Factory implements the template.TemplateFactory interface +type Factory struct{} + +// CreateTemplate implements the template.TemplateFactory interface +func (f *Factory) CreateTemplate(renderBundle template.BundleRenderer) template.Template { + return NewTemplate(renderBundle) +} + +// Schema implements the template.TemplateFactory interface +func (f *Factory) Schema() string { + return substitutesSchema +} + +type Substitute struct { + Name string `json:"name"` // the bundle image pullspec to substitute + Base string `json:"base"` // the bundle name to substitute for +} + +type SubstitutesTemplateData struct { + Schema string `json:"schema"` + Entries []*declcfg.Meta `json:"entries"` + Substitutions []Substitute `json:"substitutions"` +} + +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 != substitutesSchema { + return nil, fmt.Errorf("template has unknown schema (%q), should be %q", st.Schema, substitutesSchema) + } + + return st, nil +} + +// processSubstitution handles the complex logic for processing a single substitution +func (t *SubstitutesTemplate) processSubstitution(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") + } + + substituteCfg, err := t.RenderBundle(ctx, substitution.Name) + if err != nil { + return fmt.Errorf("failed to render bundle image reference %q: %v", substitution.Name, err) + } + + 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) + } + } + } + + // 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 +} diff --git a/alpha/template/substitutes/substitutes_test.go b/alpha/template/substitutes/substitutes_test.go new file mode 100644 index 000000000..45505a045 --- /dev/null +++ b/alpha/template/substitutes/substitutes_test.go @@ -0,0 +1,1232 @@ +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" + "github.com/operator-framework/operator-registry/alpha/template" +) + +// Helper function to create a mock template for testing +func createMockTemplate() *SubstitutesTemplate { + return &SubstitutesTemplate{ + renderBundle: template.BundleRenderer(func(ctx context.Context, imageRef string) (*declcfg.DeclarativeConfig, error) { + // Extract package and version from image reference (simplified for testing) + packageName := "testoperator" + version := "1.2.0" + if strings.Contains(imageRef, "test-bundle") { + packageName = "test" + version = "1.0.0" + } + // Extract version from image reference if it contains a version + if strings.Contains(imageRef, ":v") { + parts := strings.Split(imageRef, ":v") + if len(parts) == 2 { + version = parts[1] + } + } + + // Create bundle name based on version for predictable naming + bundleName := packageName + "-v" + version + "-alpha" + + 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: []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"}`)), + }, + 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-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, + {Name: "testoperator-v1.2.0-alpha", Replaces: "testoperator-v1.1.0-alpha", Skips: []string{"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", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.1.0", "alpha"), + }, + }, + { + Schema: "olm.bundle", + Name: "testoperator-v1.2.0-alpha", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.2.0", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.2.0", "alpha"), + }, + }, + }, + } +} + +// 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.Equal(t, len(tt.expected.Entries), len(result.Entries)) + require.Equal(t, len(tt.expected.Substitutions), len(result.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-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, // Base bundle must be in channel entries + }), + createValidTestBundleMeta("testoperator-v1.0.0-alpha", "testoperator", "1.0.0", "alpha"), + createValidTestBundleMeta("testoperator-v1.1.0-alpha", "testoperator", "1.1.0", "alpha"), // Base bundle must be defined as bundle + // Substitute.name bundle (testoperator.v1.2.0) must NOT be in template entries + }, + substitutions: []Substitute{ + {Name: "quay.io/test/testoperator-bundle:v1.2.0", Base: "testoperator-v1.1.0-alpha"}, // 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.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/render with multiple substitutions", + entries: []*declcfg.Meta{ + createValidTestPackageMeta("testoperator", "stable"), + createValidTestChannelMeta("stable", "testoperator", []declcfg.ChannelEntry{ + {Name: "testoperator-v1.0.0-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, + // Don't include substitution bundles in channel entries initially - they will be added by the substitution process + }), + createValidTestBundleMeta("testoperator-v1.0.0-alpha", "testoperator", "1.0.0", "alpha"), + createValidTestBundleMeta("testoperator-v1.1.0-alpha", "testoperator", "1.1.0", "alpha"), + // 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", Base: "testoperator-v1.1.0-alpha"}, + {Name: "quay.io/test/testoperator-bundle:v1.3.0", 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.Equal(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-alpha", 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-alpha"}, + }), + createValidTestBundleMeta("testoperator-v1.0.0-alpha", "testoperator", "1.0.0", "alpha"), + }, + 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-alpha", 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-alpha"}, + }), + createValidTestBundleMeta("testoperator-v1.0.0-alpha", "testoperator", "1.0.0", "alpha"), + }, + substitutions: []Substitute{ + {Name: "quay.io/test/testoperator-bundle:v1.2.0", Base: "nonexistent-v1.0.0-alpha"}, + }, + 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-alpha", 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-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, + }), + createValidTestBundleMeta("testoperator-v1.0.0-alpha", "testoperator", "1.0.0", "alpha"), + createValidTestBundleMeta("testoperator-v1.1.0-alpha", "testoperator", "1.1.0", "alpha"), + }, + substitutions: []Substitute{ + {Name: "", Base: "testoperator-v1.1.0-alpha"}, // 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-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, + }), + createValidTestBundleMeta("testoperator-v1.0.0-alpha", "testoperator", "1.0.0", "alpha"), + createValidTestBundleMeta("testoperator-v1.1.0-alpha", "testoperator", "1.1.0", "alpha"), + }, + substitutions: []Substitute{ + {Name: "testoperator-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-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, + }), + createValidTestBundleMeta("testoperator-v1.0.0-alpha", "testoperator", "1.0.0", "alpha"), + createValidTestBundleMeta("testoperator-v1.1.0-alpha", "testoperator", "1.1.0", "alpha"), + }, + substitutions: []Substitute{ + {Name: "testoperator-v1.1.0-alpha", Base: "testoperator-v1.1.0-alpha"}, // 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 := &SubstitutesTemplate{ + renderBundle: template.BundleRenderer(func(ctx context.Context, imageRef string) (*declcfg.DeclarativeConfig, error) { + // Mock implementation that creates a bundle from the image reference + // Extract version from image reference (simplified for testing) + version := "1.2.0" + if strings.Contains(imageRef, ":v") { + parts := strings.Split(imageRef, ":v") + if len(parts) == 2 { + version = parts[1] + } + } + + // Create bundle name based on version for predictable naming + bundleName := "testoperator-v" + version + "-alpha" + + return &declcfg.DeclarativeConfig{ + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: bundleName, + Package: "testoperator", + Image: imageRef, + Properties: []property.Property{ + property.MustBuildPackage("testoperator", 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"}`)), + }, + 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", + 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"}, + }, + 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: "testoperator-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", + 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"}, + }, + 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: "testoperator-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-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"), + }, + }, + }, + }, + substitution: Substitute{Name: "quay.io/test/testoperator-bundle:v1.2.0", 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", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.1.0", "alpha"), + }, + }, + }, + }, + substitution: Substitute{Name: "quay.io/test/testoperator-bundle:v1.2.0", 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-alpha"}, + {Name: "testoperator-v1.1.0-alpha", Replaces: "testoperator-v1.0.0-alpha"}, + {Name: "testoperator-v1.2.0-alpha", Replaces: "testoperator-v1.1.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", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.1.0", "alpha"), + }, + }, + { + Schema: "olm.bundle", + Name: "testoperator-v1.2.0-alpha", + Package: "testoperator", + Image: "quay.io/test/testoperator-bundle:v1.2.0", + Properties: []property.Property{ + property.MustBuildPackageRelease("testoperator", "1.2.0", "alpha"), + }, + }, + }, + }, + substitution: Substitute{Name: "quay.io/test/testoperator-bundle:v1.1.5", Base: "testoperator-v1.1.0-alpha"}, + 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-alpha + var updatedEntry *declcfg.ChannelEntry + for i := range channel.Entries { + if channel.Entries[i].Name == "testoperator-v1.2.0-alpha" { + updatedEntry = &channel.Entries[i] + break + } + } + require.NotNil(t, updatedEntry) + require.Equal(t, "testoperator-v1.1.5-alpha", updatedEntry.Replaces) // Should now reference the substitute + require.Contains(t, updatedEntry.Skips, "testoperator-v1.1.0-alpha") // 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{} + substitution := Substitute{Name: "quay.io/test/test-bundle:v1.0.0", 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{}, + } + substitution := Substitute{Name: "quay.io/test/test-bundle:v1.0.0", 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{}, + }, + }, + } + substitution := Substitute{Name: "quay.io/test/test-bundle:v1.0.0", 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", 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", Base: "quay.io/test/testoperator-bundle: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 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"), + } + + template := SubstitutesTemplateData{ + Schema: "olm.template.substitutes", + Entries: entries, + Substitutions: []Substitute{}, + } + + templateJSON, err := json.Marshal(template) + require.NoError(t, err) + + reader := strings.NewReader(string(templateJSON)) + templateInstance := &SubstitutesTemplate{} + + 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 that references a non-existent package + cfg := &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "nonexistent", + DefaultChannel: "stable", + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "testoperator.v1.1.0", // This is the substitution name we're testing + Package: "nonexistent", // This package exists but bundle name doesn't match + Properties: []property.Property{ + { + Type: property.TypePackage, + Value: json.RawMessage(`{"packageName":"nonexistent","version":"1.1.0"}`), + }, + }, + }, + }, + } + substitution := Substitute{Name: "quay.io/test/testoperator-bundle:v1.1.0", 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 with a bundle that has no olm.package property + cfg := &declcfg.DeclarativeConfig{ + Packages: []declcfg.Package{ + { + Schema: "olm.package", + Name: "testoperator", + DefaultChannel: "stable", + }, + }, + Bundles: []declcfg.Bundle{ + { + Name: "testoperator.v1.1.0", // This is the substitution name we're testing + Package: "testoperator", + Properties: []property.Property{}, // No olm.package property + }, + }, + } + substitution := Substitute{Name: "quay.io/test/testoperator-bundle:v1.1.0", 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") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.testFunc(t) + }) + } +} diff --git a/alpha/template/template.go b/alpha/template/template.go new file mode 100644 index 000000000..3a4fec5c5 --- /dev/null +++ b/alpha/template/template.go @@ -0,0 +1,79 @@ +package template + +import ( + "context" + "io" + + "github.com/operator-framework/operator-registry/alpha/declcfg" +) + +// BundleRenderer defines the function signature for rendering bundle images +type BundleRenderer func(context.Context, string) (*declcfg.DeclarativeConfig, error) + +// Template defines the common interface for all template types +type Template interface { + // RenderBundle renders a bundle image reference into a DeclarativeConfig + RenderBundle(ctx context.Context, image string) (*declcfg.DeclarativeConfig, error) + // Render processes the template input and returns a DeclarativeConfig + Render(ctx context.Context, reader io.Reader) (*declcfg.DeclarativeConfig, error) + // Schema returns the schema identifier for this template type + Schema() string +} + +// TemplateFactory creates template instances based on schema +type TemplateFactory interface { + // CreateTemplate creates a new template instance with the given RenderBundle function + CreateTemplate(renderBundle BundleRenderer) Template + // Schema returns the schema identifier this factory handles + Schema() string +} + +// Registry maintains a mapping of schema identifiers to template factories +type Registry struct { + factories map[string]TemplateFactory +} + +// NewRegistry creates a new template registry +func NewRegistry() *Registry { + return &Registry{ + factories: make(map[string]TemplateFactory), + } +} + +// Register adds a template factory to the registry +func (r *Registry) Register(factory TemplateFactory) { + r.factories[factory.Schema()] = factory +} + +// CreateTemplate creates a template instance based on the schema found in the input +func (r *Registry) CreateTemplate(reader io.Reader, renderBundle BundleRenderer) (Template, error) { + schema, err := detectSchema(reader) + if err != nil { + return nil, err + } + + factory, exists := r.factories[schema] + if !exists { + return nil, &UnknownSchemaError{Schema: schema} + } + + return factory.CreateTemplate(renderBundle), nil +} + +// GetSupportedSchemas returns all supported schema identifiers +func (r *Registry) GetSupportedSchemas() []string { + schemas := make([]string, 0, len(r.factories)) + for schema := range r.factories { + schemas = append(schemas, schema) + } + return schemas +} + +// UnknownSchemaError is returned when a schema is not recognized +type UnknownSchemaError struct { + Schema string +} + +func (e *UnknownSchemaError) Error() string { + return "unknown template schema: " + e.Schema +} diff --git a/cmd/opm/alpha/template/basic.go b/cmd/opm/alpha/template/basic.go deleted file mode 100644 index de6aed367..000000000 --- a/cmd/opm/alpha/template/basic.go +++ /dev/null @@ -1,103 +0,0 @@ -package template - -import ( - "context" - "io" - "log" - "os" - - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "github.com/operator-framework/operator-registry/alpha/action" - "github.com/operator-framework/operator-registry/alpha/action/migrations" - "github.com/operator-framework/operator-registry/alpha/declcfg" - "github.com/operator-framework/operator-registry/alpha/template/basic" - "github.com/operator-framework/operator-registry/cmd/opm/internal/util" -) - -func newBasicTemplateCmd() *cobra.Command { - var ( - template basic.Template - migrateLevel string - ) - cmd := &cobra.Command{ - Use: "basic basic-template-file", - Short: `Generate a file-based catalog from a single 'basic template' file -When FILE is '-' or not provided, the template is read from standard input`, - Long: `Generate a file-based catalog from a single 'basic template' file -When FILE is '-' or not provided, the template is read from standard input`, - Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - // Handle different input argument types - // When no arguments or "-" is passed to the command, - // assume input is coming from stdin - // Otherwise open the file passed to the command - data, source, err := util.OpenFileOrStdin(cmd, args) - if err != nil { - log.Fatalf("unable to open %q: %v", source, err) - } - defer data.Close() - - var write func(declcfg.DeclarativeConfig, io.Writer) error - output, err := cmd.Flags().GetString("output") - if err != nil { - log.Fatalf("unable to determine output format") - } - switch output { - case "yaml": - write = declcfg.WriteYAML - case "json": - write = declcfg.WriteJSON - default: - log.Fatalf("invalid --output value %q, expected (json|yaml)", output) - } - - // The bundle loading impl is somewhat verbose, even on the happy path, - // so discard all logrus default logger logs. Any important failures will be - // returned from template.Render and logged as fatal errors. - logrus.SetOutput(io.Discard) - - reg, err := util.CreateCLIRegistry(cmd) - if err != nil { - log.Fatalf("creating containerd registry: %v", err) - } - defer func() { - _ = reg.Destroy() - }() - - var m *migrations.Migrations - if migrateLevel != "" { - m, err = migrations.NewMigrations(migrateLevel) - if err != nil { - log.Fatal(err) - } - } - - template.RenderBundle = func(ctx context.Context, image string) (*declcfg.DeclarativeConfig, error) { - // populate registry, incl any flags from CLI, and enforce only rendering bundle images - r := action.Render{ - Refs: []string{image}, - Registry: reg, - AllowedRefMask: action.RefBundleImage, - Migrations: m, - } - return r.Run(ctx) - } - - // only taking first file argument - cfg, err := template.Render(cmd.Context(), data) - if err != nil { - log.Fatal(err) - } - - if err := write(*cfg, os.Stdout); err != nil { - log.Fatal(err) - } - }, - } - - cmd.Flags().StringVar(&migrateLevel, "migrate-level", "", "Name of the last migration to run (default: none)\n"+migrations.HelpText()) - - return cmd -} diff --git a/cmd/opm/alpha/template/cmd.go b/cmd/opm/alpha/template/cmd.go index 55ac55187..ae52952fe 100644 --- a/cmd/opm/alpha/template/cmd.go +++ b/cmd/opm/alpha/template/cmd.go @@ -2,26 +2,37 @@ package template import ( "github.com/spf13/cobra" + + "github.com/operator-framework/operator-registry/alpha/action/migrations" ) func NewCmd() *cobra.Command { - var output string + var output, migrateLevel string runCmd := &cobra.Command{ - Use: "render-template", - Short: "Render a catalog template type", - Args: cobra.NoArgs, - } + Use: "render-template [TYPE] [FILE]", + Short: "Render a catalog template (auto-detects type from schema if TYPE not specified)", + Long: `Render a catalog template with optional type specification. - bc := newBasicTemplateCmd() - // bc.Hidden = true - runCmd.AddCommand(bc) +If TYPE is specified, it must be one of: basic, semver, substitutes +If TYPE is not specified, the template type will be auto-detected from the schema field in the input file. - sc := newSemverTemplateCmd() - // sc.Hidden = true - runCmd.AddCommand(sc) +When FILE is '-' or not provided, the template is read from standard input. + +Examples: + opm alpha render-template basic template.yaml + opm alpha render-template semver template.yaml + opm alpha render-template substitutes template.yaml + opm alpha render-template template.yaml # auto-detect type + opm alpha render-template < template.yaml # auto-detect from stdin`, + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + return runRenderTemplate(cmd, args) + }, + } - runCmd.PersistentFlags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml)") + runCmd.PersistentFlags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml|mermaid)") + runCmd.PersistentFlags().StringVar(&migrateLevel, "migrate-level", "", "Name of the last migration to run (default: none)\n"+migrations.HelpText()) return runCmd } diff --git a/cmd/opm/alpha/template/render.go b/cmd/opm/alpha/template/render.go new file mode 100644 index 000000000..5a087682d --- /dev/null +++ b/cmd/opm/alpha/template/render.go @@ -0,0 +1,187 @@ +package template + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/operator-framework/operator-registry/alpha/action" + "github.com/operator-framework/operator-registry/alpha/action/migrations" + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/template" + "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" + "github.com/operator-framework/operator-registry/cmd/opm/internal/util" +) + +// runRenderTemplate handles the unified template rendering logic +func runRenderTemplate(cmd *cobra.Command, args []string) error { + var templateType, filePath string + + // Parse arguments based on number provided + switch len(args) { + case 0: + // No arguments - read from stdin, auto-detect type + filePath = "-" + case 1: + // One argument - could be type or file + if isValidTemplateType(args[0]) { + // It's a template type, read from stdin + templateType = args[0] + filePath = "-" + } else { + // It's a file path, auto-detect type + filePath = args[0] + } + case 2: + // Two arguments - type and file + templateType = args[0] + filePath = args[1] + if !isValidTemplateType(templateType) { + return fmt.Errorf("invalid template type %q, must be one of: basic, semver, substitutes", templateType) + } + } + + // Handle different input argument types + data, source, err := util.OpenFileOrStdin(cmd, []string{filePath}) + if err != nil { + return fmt.Errorf("unable to open %q: %v", source, err) + } + defer data.Close() + + // Determine output format + var write func(declcfg.DeclarativeConfig, io.Writer) error + output, err := cmd.Flags().GetString("output") + if err != nil { + return fmt.Errorf("unable to determine output format") + } + switch output { + case "yaml": + write = declcfg.WriteYAML + case "json": + write = declcfg.WriteJSON + case "mermaid": + write = func(cfg declcfg.DeclarativeConfig, writer io.Writer) error { + mermaidWriter := declcfg.NewMermaidWriter() + return mermaidWriter.WriteChannels(cfg, writer) + } + default: + return fmt.Errorf("invalid --output value %q, expected (json|yaml|mermaid)", output) + } + + // The bundle loading impl is somewhat verbose, even on the happy path, + // so discard all logrus default logger logs. Any important failures will be + // returned from template.Render and logged as fatal errors. + logrus.SetOutput(io.Discard) + + // Create registry and registry client + reg, err := util.CreateCLIRegistry(cmd) + if err != nil { + return fmt.Errorf("creating containerd registry: %v", err) + } + defer func() { + _ = reg.Destroy() + }() + + // Handle migrations + var m *migrations.Migrations + migrateLevel, err := cmd.Flags().GetString("migrate-level") + if err == nil && migrateLevel != "" { + m, err = migrations.NewMigrations(migrateLevel) + if err != nil { + return err + } + } + + // Create render bundle function + renderBundle := template.BundleRenderer(func(ctx context.Context, image string) (*declcfg.DeclarativeConfig, error) { + renderer := action.Render{ + Refs: []string{image}, + Registry: reg, + AllowedRefMask: action.RefBundleImage, + Migrations: m, + } + return renderer.Run(ctx) + }) + + // Create template registry and register factories + registry := template.NewRegistry() + registry.Register(&basic.Factory{}) + registry.Register(&semver.Factory{}) + registry.Register(&substitutes.Factory{}) + + var tmpl template.Template + + // nolint:nestif + if templateType != "" { + // Use specified template type + tmpl, err = createTemplateByType(templateType, renderBundle) + if err != nil { + return err + } + } else { + // Auto-detect template type from schema + // We need to re-open the file since schema detection consumes the reader + data.Close() + data, source, err = util.OpenFileOrStdin(cmd, []string{filePath}) + if err != nil { + return fmt.Errorf("unable to reopen %q: %v", source, err) + } + defer data.Close() + + tmpl, err = registry.CreateTemplate(data, renderBundle) + if err != nil { + return fmt.Errorf("auto-detecting template type: %v", err) + } + + // Re-open again for rendering + data.Close() + data, source, err = util.OpenFileOrStdin(cmd, []string{filePath}) + if err != nil { + return fmt.Errorf("unable to reopen %q for rendering: %v", source, err) + } + defer data.Close() + } + + // Render the template + cfg, err := tmpl.Render(cmd.Context(), data) + if err != nil { + return fmt.Errorf("rendering template: %v", err) + } + + // Write output + if err := write(*cfg, os.Stdout); err != nil { + return fmt.Errorf("writing output: %v", err) + } + + return nil +} + +// isValidTemplateType checks if the provided string is a valid template type +func isValidTemplateType(templateType string) bool { + switch templateType { + case "basic", "semver", "substitutes": + return true + default: + return false + } +} + +// createTemplateByType creates a template instance of the specified type +func createTemplateByType(templateType string, renderBundle template.BundleRenderer) (template.Template, error) { + switch templateType { + case "basic": + return basic.NewTemplate(renderBundle), nil + case "semver": + return semver.NewTemplate(renderBundle), nil + case "substitutes": + return substitutes.NewTemplate(renderBundle), nil + default: + return nil, fmt.Errorf("unknown template type: %s", templateType) + } +} diff --git a/cmd/opm/alpha/template/semver.go b/cmd/opm/alpha/template/semver.go deleted file mode 100644 index eb07ab568..000000000 --- a/cmd/opm/alpha/template/semver.go +++ /dev/null @@ -1,114 +0,0 @@ -package template - -import ( - "context" - "fmt" - "io" - "log" - "os" - - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "github.com/operator-framework/operator-registry/alpha/action" - "github.com/operator-framework/operator-registry/alpha/action/migrations" - "github.com/operator-framework/operator-registry/alpha/declcfg" - "github.com/operator-framework/operator-registry/alpha/template/semver" - "github.com/operator-framework/operator-registry/cmd/opm/internal/util" -) - -func newSemverTemplateCmd() *cobra.Command { - var ( - migrateLevel string - ) - - cmd := &cobra.Command{ - Use: "semver [FILE]", - Short: `Generate a file-based catalog from a single 'semver template' file -When FILE is '-' or not provided, the template is read from standard input`, - Long: `Generate a file-based catalog from a single 'semver template' file -When FILE is '-' or not provided, the template is read from standard input`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - // Handle different input argument types - // When no arguments or "-" is passed to the command, - // assume input is coming from stdin - // Otherwise open the file passed to the command - data, source, err := util.OpenFileOrStdin(cmd, args) - if err != nil { - return err - } - defer data.Close() - - var write func(declcfg.DeclarativeConfig, io.Writer) error - output, err := cmd.Flags().GetString("output") - if err != nil { - log.Fatalf("unable to determine output format") - } - switch output { - case "json": - write = declcfg.WriteJSON - case "yaml": - write = declcfg.WriteYAML - case "mermaid": - write = func(cfg declcfg.DeclarativeConfig, writer io.Writer) error { - mermaidWriter := declcfg.NewMermaidWriter() - return mermaidWriter.WriteChannels(cfg, writer) - } - default: - return fmt.Errorf("invalid output format %q", output) - } - - // The bundle loading impl is somewhat verbose, even on the happy path, - // so discard all logrus default logger logs. Any important failures will be - // returned from template.Render and logged as fatal errors. - logrus.SetOutput(io.Discard) - - reg, err := util.CreateCLIRegistry(cmd) - if err != nil { - log.Fatalf("creating containerd registry: %v", err) - } - defer func() { - _ = reg.Destroy() - }() - - var m *migrations.Migrations - if migrateLevel != "" { - m, err = migrations.NewMigrations(migrateLevel) - if err != nil { - log.Fatal(err) - } - } - - template := semver.Template{ - Data: data, - RenderBundle: func(ctx context.Context, ref string) (*declcfg.DeclarativeConfig, error) { - renderer := action.Render{ - Refs: []string{ref}, - Registry: reg, - AllowedRefMask: action.RefBundleImage, - Migrations: m, - } - return renderer.Run(ctx) - }, - } - - out, err := template.Render(cmd.Context()) - if err != nil { - log.Fatalf("semver %q: %v", source, err) - } - - if out != nil { - if err := write(*out, os.Stdout); err != nil { - log.Fatal(err) - } - } - - return nil - }, - } - - cmd.Flags().StringVar(&migrateLevel, "migrate-level", "", "Name of the last migration to run (default: none)\n"+migrations.HelpText()) - - return cmd -}