diff --git a/alpha/template/basic/basic.go b/alpha/template/basic/basic.go index a34d7541d..f835aaafc 100644 --- a/alpha/template/basic/basic.go +++ b/alpha/template/basic/basic.go @@ -9,40 +9,33 @@ 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) +func init() { + template.GetTemplateRegistry().Register(&Factory{}) } 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 +61,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 +137,7 @@ func FromReader(r io.Reader) (*BasicTemplate, error) { return nil, err } - bt := &BasicTemplate{ + bt := &BasicTemplateData{ Schema: schema, Entries: entries, } diff --git a/alpha/template/registry.go b/alpha/template/registry.go new file mode 100644 index 000000000..18ef33d93 --- /dev/null +++ b/alpha/template/registry.go @@ -0,0 +1,26 @@ +package template + +import ( + "fmt" + "strings" + "text/tabwriter" +) + +var tr = NewTemplateRegistry() + +// GetTemplateRegistry returns the global template registry +func GetTemplateRegistry() *TemplateRegistry { + return tr +} + +func (r *TemplateRegistry) HelpText() string { + var help strings.Builder + supportedTypes := r.GetSupportedTypes() + help.WriteString("\n") + tabber := tabwriter.NewWriter(&help, 0, 0, 1, ' ', 0) + for _, item := range supportedTypes { + fmt.Fprintf(tabber, " - %s\n", item) + } + tabber.Flush() + return help.String() +} diff --git a/alpha/template/schema.go b/alpha/template/schema.go new file mode 100644 index 000000000..d96b346da --- /dev/null +++ b/alpha/template/schema.go @@ -0,0 +1,47 @@ +package template + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + + "k8s.io/apimachinery/pkg/util/yaml" +) + +// detectSchema reads the input, extracts the schema field, and returns a reader +// that includes the consumed data followed by the remaining stream data. +// This works when the input is stdin or a file (since stdin cannot be closed and reopened) +// and complies with the requirement that each supplied schema has a defined "schema" field, +// without attempting to load all input into memory. +func detectSchema(reader io.Reader) (string, io.Reader, error) { + // Capture what's read during schema detection + var capturedData bytes.Buffer + teeReader := io.TeeReader(reader, &capturedData) + + // Read the input into a raw message + rawDoc := json.RawMessage{} + decoder := yaml.NewYAMLOrJSONDecoder(teeReader, 4096) + err := decoder.Decode(&rawDoc) + if err != nil { + return "", nil, 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 "", nil, fmt.Errorf("unmarshalling template schema: %v", err) + } + + if schemaDoc.Schema == "" { + return "", nil, fmt.Errorf("template input missing required 'schema' field") + } + + // Create a reader that combines the captured data with the remaining stream + replayReader := io.MultiReader(&capturedData, reader) + + return schemaDoc.Schema, replayReader, nil +} diff --git a/alpha/template/semver/semver.go b/alpha/template/semver/semver.go index 8a4b837b4..9e8a5f3ff 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,83 @@ 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" + +func init() { + template.GetTemplateRegistry().Register(&Factory{}) +} + +// 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 +189,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 +234,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 +267,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 +329,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 +406,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..3d5c2e9c0 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,10 +527,10 @@ 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) + if diff := gocmp.Diff(tt.out, out); diff != "" { + t.Errorf("unexpected generated channels (-expected +received):\n%s", diff) } require.Equal(t, tt.defaultChannel, sv.defaultChannel) }) @@ -540,13 +540,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 +612,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 +694,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 +738,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 +746,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 +754,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 +762,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/template.go b/alpha/template/template.go new file mode 100644 index 000000000..258d13866 --- /dev/null +++ b/alpha/template/template.go @@ -0,0 +1,115 @@ +package template + +import ( + "context" + "io" + "slices" + "strings" + + "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 +} + +// TemplateRegistry maintains a mapping of schema identifiers to template factories +type TemplateRegistry struct { + factories map[string]TemplateFactory +} + +// NewTemplateRegistry creates a new template registry +func NewTemplateRegistry() *TemplateRegistry { + return &TemplateRegistry{ + factories: make(map[string]TemplateFactory), + } +} + +// Register adds a template factory to the registry +func (r *TemplateRegistry) Register(factory TemplateFactory) { + r.factories[factory.Schema()] = factory +} + +// CreateTemplateBySchema creates a template instance based on the schema found in the input +// and returns a reader that can be used to render the template. The returned reader includes +// both the data consumed during schema detection and the remaining unconsumed data. +func (r *TemplateRegistry) CreateTemplateBySchema(reader io.Reader, renderBundle BundleRenderer) (Template, io.Reader, error) { + schema, replayReader, err := detectSchema(reader) + if err != nil { + return nil, nil, err + } + + factory, exists := r.factories[schema] + if !exists { + return nil, nil, &UnknownSchemaError{Schema: schema} + } + + return factory.CreateTemplate(renderBundle), replayReader, nil +} + +func (r *TemplateRegistry) CreateTemplateByType(templateType string, renderBundle BundleRenderer) (Template, error) { + factory, exists := r.factories[templateType] + if !exists { + return nil, &UnknownSchemaError{Schema: templateType} + } + + return factory.CreateTemplate(renderBundle), nil +} + +// GetSupportedSchemas returns all supported schema identifiers +func (r *TemplateRegistry) GetSupportedSchemas() []string { + schemas := make([]string, 0, len(r.factories)) + for schema := range r.factories { + schemas = append(schemas, schema) + } + slices.Sort(schemas) + return schemas +} + +// GetSupportedTypes returns all supported template types +// TODO: in future, might store the type separately from the schema +// right now it's just the last part of the schema string +func (r *TemplateRegistry) GetSupportedTypes() []string { + types := make([]string, 0, len(r.factories)) + for schema := range r.factories { + types = append(types, schema[strings.LastIndex(schema, ".")+1:]) + } + slices.Sort(types) + return types +} + +func (r *TemplateRegistry) HasSchema(schema string) bool { + _, exists := r.factories[schema] + return exists +} + +func (r *TemplateRegistry) HasType(templateType string) bool { + types := r.GetSupportedTypes() + return slices.Contains(types, templateType) +} + +// 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/alpha/template/template_test.go b/alpha/template/template_test.go new file mode 100644 index 000000000..d15d156d9 --- /dev/null +++ b/alpha/template/template_test.go @@ -0,0 +1,474 @@ +package template + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/operator-framework/operator-registry/alpha/declcfg" +) + +// mockTemplate is a test implementation of the Template interface +type mockTemplate struct { + schema string + renderBundle BundleRenderer +} + +func (m *mockTemplate) RenderBundle(ctx context.Context, image string) (*declcfg.DeclarativeConfig, error) { + if m.renderBundle != nil { + return m.renderBundle(ctx, image) + } + return &declcfg.DeclarativeConfig{}, nil +} + +func (m *mockTemplate) Render(ctx context.Context, reader io.Reader) (*declcfg.DeclarativeConfig, error) { + return &declcfg.DeclarativeConfig{}, nil +} + +func (m *mockTemplate) Schema() string { + return m.schema +} + +// mockFactory is a test implementation of the TemplateFactory interface +type mockFactory struct { + schema string +} + +func (f *mockFactory) CreateTemplate(renderBundle BundleRenderer) Template { + return &mockTemplate{ + schema: f.schema, + renderBundle: renderBundle, + } +} + +func (f *mockFactory) Schema() string { + return f.schema +} + +func TestNewTemplateRegistry(t *testing.T) { + registry := NewTemplateRegistry() + + require.NotNil(t, registry) + require.NotNil(t, registry.factories) + require.Empty(t, registry.factories) +} + +func TestTemplateRegistry_Register(t *testing.T) { + tests := []struct { + name string + factories []TemplateFactory + expected []string + }{ + { + name: "register single factory", + factories: []TemplateFactory{&mockFactory{schema: "olm.semver"}}, + expected: []string{"olm.semver"}, + }, + { + name: "register multiple factories", + factories: []TemplateFactory{ + &mockFactory{schema: "olm.semver"}, + &mockFactory{schema: "olm.basic"}, + &mockFactory{schema: "olm.composite"}, + }, + expected: []string{"olm.basic", "olm.composite", "olm.semver"}, + }, + { + name: "register duplicate schema overwrites", + factories: []TemplateFactory{ + &mockFactory{schema: "olm.semver"}, + &mockFactory{schema: "olm.semver"}, + }, + expected: []string{"olm.semver"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := NewTemplateRegistry() + for _, factory := range tt.factories { + registry.Register(factory) + } + + schemas := registry.GetSupportedSchemas() + require.Equal(t, tt.expected, schemas) + }) + } +} + +func TestTemplateRegistry_CreateTemplateByType(t *testing.T) { + tests := []struct { + name string + setupSchemas []string + requestType string + expectError bool + expectedErr string + }{ + { + name: "create template for registered type", + setupSchemas: []string{"olm.semver"}, + requestType: "olm.semver", + expectError: false, + }, + { + name: "create template for multiple registered types", + setupSchemas: []string{"olm.semver", "olm.basic", "olm.composite"}, + requestType: "olm.basic", + expectError: false, + }, + { + name: "error on unregistered type", + setupSchemas: []string{"olm.semver"}, + requestType: "olm.unknown", + expectError: true, + expectedErr: "unknown template schema: olm.unknown", + }, + { + name: "error on empty registry", + setupSchemas: []string{}, + requestType: "olm.semver", + expectError: true, + expectedErr: "unknown template schema: olm.semver", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := NewTemplateRegistry() + for _, schema := range tt.setupSchemas { + registry.Register(&mockFactory{schema: schema}) + } + + template, err := registry.CreateTemplateByType(tt.requestType, nil) + + if tt.expectError { + require.Error(t, err) + require.Nil(t, template) + require.Contains(t, err.Error(), tt.expectedErr) + + var unknownSchemaErr *UnknownSchemaError + require.ErrorAs(t, err, &unknownSchemaErr) + require.Equal(t, tt.requestType, unknownSchemaErr.Schema) + } else { + require.NoError(t, err) + require.NotNil(t, template) + require.Equal(t, tt.requestType, template.Schema()) + } + }) + } +} + +func TestTemplateRegistry_CreateTemplateBySchema(t *testing.T) { + tests := []struct { + name string + setupSchemas []string + input string + expectError bool + expectedErr string + expectedSchema string + }{ + { + name: "create template from valid YAML input", + setupSchemas: []string{"olm.semver"}, + input: `schema: olm.semver +bundles: + - image: example.com/bundle:v1.0.0`, + expectError: false, + expectedSchema: "olm.semver", + }, + { + name: "create template from valid JSON input", + setupSchemas: []string{"olm.semver"}, + input: `{"schema": "olm.semver", "bundles": [{"image": "example.com/bundle:v1.0.0"}]}`, + expectError: false, + expectedSchema: "olm.semver", + }, + { + name: "error on unregistered schema", + setupSchemas: []string{"olm.semver"}, + input: `schema: olm.unknown`, + expectError: true, + expectedErr: "unknown template schema: olm.unknown", + }, + { + name: "error on missing schema field", + setupSchemas: []string{"olm.semver"}, + input: `bundles: []`, + expectError: true, + expectedErr: "missing required 'schema' field", + }, + { + name: "error on invalid YAML", + setupSchemas: []string{"olm.semver"}, + input: `schema: olm.semver\n\tinvalid: [unclosed`, + expectError: true, + expectedErr: "decoding template input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := NewTemplateRegistry() + for _, schema := range tt.setupSchemas { + registry.Register(&mockFactory{schema: schema}) + } + + reader := strings.NewReader(tt.input) + template, replayReader, err := registry.CreateTemplateBySchema(reader, nil) + + if tt.expectError { + require.Error(t, err) + require.Nil(t, template) + require.Contains(t, err.Error(), tt.expectedErr) + } else { + require.NoError(t, err) + require.NotNil(t, template) + require.NotNil(t, replayReader) + require.Equal(t, tt.expectedSchema, template.Schema()) + + // Verify replay reader contains original input + replayedData, err := io.ReadAll(replayReader) + require.NoError(t, err) + require.Equal(t, tt.input, string(replayedData)) + } + }) + } +} + +func TestTemplateRegistry_GetSupportedSchemas(t *testing.T) { + tests := []struct { + name string + schemas []string + expected []string + }{ + { + name: "empty registry", + schemas: []string{}, + expected: []string{}, + }, + { + name: "single schema", + schemas: []string{"olm.semver"}, + expected: []string{"olm.semver"}, + }, + { + name: "multiple schemas sorted alphabetically", + schemas: []string{"olm.semver", "olm.basic", "olm.composite"}, + expected: []string{"olm.basic", "olm.composite", "olm.semver"}, + }, + { + name: "schemas with different formats", + schemas: []string{"olm.template.semver", "olm.template.basic", "custom.schema"}, + expected: []string{"custom.schema", "olm.template.basic", "olm.template.semver"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := NewTemplateRegistry() + for _, schema := range tt.schemas { + registry.Register(&mockFactory{schema: schema}) + } + + schemas := registry.GetSupportedSchemas() + require.Equal(t, tt.expected, schemas) + }) + } +} + +func TestTemplateRegistry_GetSupportedTypes(t *testing.T) { + tests := []struct { + name string + schemas []string + expected []string + }{ + { + name: "empty registry", + schemas: []string{}, + expected: []string{}, + }, + { + name: "single schema extracts type", + schemas: []string{"olm.semver"}, + expected: []string{"semver"}, + }, + { + name: "multiple schemas extract types sorted", + schemas: []string{"olm.semver", "olm.basic", "olm.composite"}, + expected: []string{"basic", "composite", "semver"}, + }, + { + name: "schema without dots returns entire string", + schemas: []string{"simple"}, + expected: []string{"simple"}, + }, + { + name: "nested schema extracts last part", + schemas: []string{"olm.template.semver", "olm.template.basic"}, + expected: []string{"basic", "semver"}, + }, + { + name: "duplicate types from different schemas", + schemas: []string{"olm.semver", "custom.semver"}, + expected: []string{"semver", "semver"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := NewTemplateRegistry() + for _, schema := range tt.schemas { + registry.Register(&mockFactory{schema: schema}) + } + + types := registry.GetSupportedTypes() + require.Equal(t, tt.expected, types) + }) + } +} + +func TestTemplateRegistry_HasSchema(t *testing.T) { + tests := []struct { + name string + registeredSchemas []string + checkSchema string + expected bool + }{ + { + name: "schema exists", + registeredSchemas: []string{"olm.semver"}, + checkSchema: "olm.semver", + expected: true, + }, + { + name: "schema does not exist", + registeredSchemas: []string{"olm.semver"}, + checkSchema: "olm.basic", + expected: false, + }, + { + name: "empty registry", + registeredSchemas: []string{}, + checkSchema: "olm.semver", + expected: false, + }, + { + name: "multiple schemas, check existing", + registeredSchemas: []string{"olm.semver", "olm.basic", "olm.composite"}, + checkSchema: "olm.basic", + expected: true, + }, + { + name: "multiple schemas, check non-existing", + registeredSchemas: []string{"olm.semver", "olm.basic"}, + checkSchema: "olm.unknown", + expected: false, + }, + { + name: "case sensitive check", + registeredSchemas: []string{"olm.semver"}, + checkSchema: "olm.Semver", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := NewTemplateRegistry() + for _, schema := range tt.registeredSchemas { + registry.Register(&mockFactory{schema: schema}) + } + + result := registry.HasSchema(tt.checkSchema) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestUnknownSchemaError_Error(t *testing.T) { + tests := []struct { + name string + schema string + expected string + }{ + { + name: "simple schema name", + schema: "olm.semver", + expected: "unknown template schema: olm.semver", + }, + { + name: "complex schema name", + schema: "custom.template.v1.0", + expected: "unknown template schema: custom.template.v1.0", + }, + { + name: "empty schema name", + schema: "", + expected: "unknown template schema: ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := &UnknownSchemaError{Schema: tt.schema} + require.Equal(t, tt.expected, err.Error()) + }) + } +} + +// capturingRenderBundle creates a BundleRenderer that captures the image parameter +func capturingRenderBundle(captured *string) BundleRenderer { + return func(_ context.Context, image string) (*declcfg.DeclarativeConfig, error) { + *captured = image + return &declcfg.DeclarativeConfig{}, nil + } +} + +func TestTemplateRegistry_RenderBundlePropagation(t *testing.T) { + expectedImage := "example.com/bundle:v1.0.0" + var capturedImage string + + mockRenderBundle := capturingRenderBundle(&capturedImage) + + tests := []struct { + name string + method string + }{ + { + name: "CreateTemplateByType propagates RenderBundle", + method: "byType", + }, + { + name: "CreateTemplateBySchema propagates RenderBundle", + method: "bySchema", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := NewTemplateRegistry() + registry.Register(&mockFactory{schema: "olm.semver"}) + + var template Template + var err error + + if tt.method == "byType" { + template, err = registry.CreateTemplateByType("olm.semver", mockRenderBundle) + require.NoError(t, err) + } else { + input := `schema: olm.semver` + reader := strings.NewReader(input) + template, _, err = registry.CreateTemplateBySchema(reader, mockRenderBundle) + require.NoError(t, err) + } + + ctx := context.Background() + _, err = template.RenderBundle(ctx, expectedImage) + require.NoError(t, err) + require.Equal(t, expectedImage, capturedImage) + }) + } +} 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..58ba180a0 100644 --- a/cmd/opm/alpha/template/cmd.go +++ b/cmd/opm/alpha/template/cmd.go @@ -2,26 +2,38 @@ package template import ( "github.com/spf13/cobra" + + "github.com/operator-framework/operator-registry/alpha/action/migrations" + "github.com/operator-framework/operator-registry/alpha/template" ) 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: ` + template.GetTemplateRegistry().HelpText() + ` +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. +FILE must not match a supported TYPE name when TYPE is not provided, to avoid ambiguity. + +Examples: + opm alpha render-template basic template.yaml + opm alpha render-template semver 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..bf0d77369 --- /dev/null +++ b/cmd/opm/alpha/template/render.go @@ -0,0 +1,144 @@ +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/cmd/opm/internal/util" +) + +// runRenderTemplate handles the unified template rendering logic +func runRenderTemplate(cmd *cobra.Command, args []string) error { + tr := template.GetTemplateRegistry() + 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 tr.HasType(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 !tr.HasType(templateType) { + return fmt.Errorf("invalid template type %q, must be one of: %s", templateType, tr.GetSupportedTypes()) + } + } + + // 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. + 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) + }) + + var tmpl template.Template + // a reader for the schema data. in the simple case, this is just 'data'. + // in the case where we auto-detect the schema, this is a reader that + // includes the consumed schema data plus any remainder. + var renderReader io.Reader + + if templateType != "" { + // Use specified template type + tmpl, err = tr.CreateTemplateByType(templateType, renderBundle) + if err != nil { + return fmt.Errorf("creating template by type: %v", err) + } + renderReader = data + } else { + // Auto-detect template type from schema + tmpl, renderReader, err = tr.CreateTemplateBySchema(data, renderBundle) + if err != nil { + return fmt.Errorf("auto-detecting template type: %v", err) + } + } + + // Render the template + cfg, err := tmpl.Render(cmd.Context(), renderReader) + 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 +} 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 -}