diff --git a/alpha/action/render.go b/alpha/action/render.go index 465f54db9..5dbb980cf 100644 --- a/alpha/action/render.go +++ b/alpha/action/render.go @@ -12,6 +12,7 @@ import ( "sort" "strings" "sync" + "text/template" "github.com/h2non/filetype" "github.com/h2non/filetype/matchers" @@ -39,6 +40,7 @@ const ( RefSqliteFile RefDCImage RefDCDir + RefBundleDir RefAll = 0 ) @@ -50,10 +52,11 @@ func (r RefType) Allowed(refType RefType) bool { var ErrNotAllowed = errors.New("not allowed") type Render struct { - Refs []string - Registry image.Registry - AllowedRefMask RefType - Migrate bool + Refs []string + Registry image.Registry + AllowedRefMask RefType + Migrate bool + ImageRefTemplate *template.Template skipSqliteDeprecationLog bool } @@ -125,25 +128,44 @@ func (r Render) createRegistry() (*containerdregistry.Registry, error) { } func (r Render) renderReference(ctx context.Context, ref string) (*declcfg.DeclarativeConfig, error) { - if stat, serr := os.Stat(ref); serr == nil { - if stat.IsDir() { - if !r.AllowedRefMask.Allowed(RefDCDir) { - return nil, fmt.Errorf("cannot render declarative config directory: %w", ErrNotAllowed) - } - return declcfg.LoadFS(ctx, os.DirFS(ref)) - } else { - // The only supported file type is an sqlite DB file, - // since declarative configs will be in a directory. - if err := checkDBFile(ref); err != nil { - return nil, err - } - if !r.AllowedRefMask.Allowed(RefSqliteFile) { - return nil, fmt.Errorf("cannot render sqlite file: %w", ErrNotAllowed) + stat, err := os.Stat(ref) + if err != nil { + return r.imageToDeclcfg(ctx, ref) + } + if stat.IsDir() { + dirEntries, err := os.ReadDir(ref) + if err != nil { + return nil, err + } + if isBundle(dirEntries) { + // Looks like a bundle directory + if !r.AllowedRefMask.Allowed(RefBundleDir) { + return nil, fmt.Errorf("cannot render bundle directory %q: %w", ref, ErrNotAllowed) } - return sqliteToDeclcfg(ctx, ref) + return r.renderBundleDirectory(ref) } + + // Otherwise, assume it is a declarative config root directory. + if !r.AllowedRefMask.Allowed(RefDCDir) { + return nil, fmt.Errorf("cannot render declarative config directory: %w", ErrNotAllowed) + } + return declcfg.LoadFS(ctx, os.DirFS(ref)) + } + // The only supported file type is an sqlite DB file, + // since declarative configs will be in a directory. + if err := checkDBFile(ref); err != nil { + return nil, err + } + if !r.AllowedRefMask.Allowed(RefSqliteFile) { + return nil, fmt.Errorf("cannot render sqlite file: %w", ErrNotAllowed) + } + + db, err := sqlite.Open(ref) + if err != nil { + return nil, err } - return r.imageToDeclcfg(ctx, ref) + defer db.Close() + return sqliteToDeclcfg(ctx, db) } func (r Render) imageToDeclcfg(ctx context.Context, imageRef string) (*declcfg.DeclarativeConfig, error) { @@ -169,7 +191,12 @@ func (r Render) imageToDeclcfg(ctx context.Context, imageRef string) (*declcfg.D if !r.AllowedRefMask.Allowed(RefSqliteImage) { return nil, fmt.Errorf("cannot render sqlite image: %w", ErrNotAllowed) } - cfg, err = sqliteToDeclcfg(ctx, filepath.Join(tmpDir, dbFile)) + db, err := sqlite.Open(filepath.Join(tmpDir, dbFile)) + if err != nil { + return nil, err + } + defer db.Close() + cfg, err = sqliteToDeclcfg(ctx, db) if err != nil { return nil, err } @@ -190,10 +217,11 @@ func (r Render) imageToDeclcfg(ctx context.Context, imageRef string) (*declcfg.D return nil, err } - cfg, err = bundleToDeclcfg(img.Bundle) + bundle, err := bundleToDeclcfg(img.Bundle) if err != nil { return nil, err } + cfg = &declcfg.DeclarativeConfig{Bundles: []declcfg.Bundle{*bundle}} } else { labelKeys := sets.StringKeySet(labels) labelVals := []string{} @@ -221,17 +249,11 @@ func checkDBFile(ref string) error { return nil } -func sqliteToDeclcfg(ctx context.Context, dbFile string) (*declcfg.DeclarativeConfig, error) { +func sqliteToDeclcfg(ctx context.Context, db *sql.DB) (*declcfg.DeclarativeConfig, error) { logDeprecationMessage.Do(func() { sqlite.LogSqliteDeprecation() }) - db, err := sqlite.Open(dbFile) - if err != nil { - return nil, err - } - defer db.Close() - migrator, err := sqlite.NewSQLLiteMigrator(db) if err != nil { return nil, err @@ -303,7 +325,7 @@ func populateDBRelatedImages(ctx context.Context, cfg *declcfg.DeclarativeConfig return nil } -func bundleToDeclcfg(bundle *registry.Bundle) (*declcfg.DeclarativeConfig, error) { +func bundleToDeclcfg(bundle *registry.Bundle) (*declcfg.Bundle, error) { objs, props, err := registry.ObjectsAndPropertiesFromBundle(bundle) if err != nil { return nil, fmt.Errorf("get properties for bundle %q: %v", bundle.Name, err) @@ -323,7 +345,7 @@ func bundleToDeclcfg(bundle *registry.Bundle) (*declcfg.DeclarativeConfig, error } } - dBundle := declcfg.Bundle{ + return &declcfg.Bundle{ Schema: "olm.bundle", Name: bundle.Name, Package: bundle.Package, @@ -332,9 +354,7 @@ func bundleToDeclcfg(bundle *registry.Bundle) (*declcfg.DeclarativeConfig, error RelatedImages: relatedImages, Objects: objs, CsvJSON: string(csvJson), - } - - return &declcfg.DeclarativeConfig{Bundles: []declcfg.Bundle{dBundle}}, nil + }, nil } func getRelatedImages(b *registry.Bundle) ([]declcfg.RelatedImage, error) { @@ -363,7 +383,7 @@ func getRelatedImages(b *registry.Bundle) ([]declcfg.RelatedImage, error) { allImages = allImages.Insert(ri.Image) } - if !allImages.Has(b.BundleImage) { + if b.BundleImage != "" && !allImages.Has(b.BundleImage) { relatedImages = append(relatedImages, declcfg.RelatedImage{ Image: b.BundleImage, }) @@ -454,3 +474,72 @@ func combineConfigs(cfgs []declcfg.DeclarativeConfig) *declcfg.DeclarativeConfig } return out } + +func isBundle(entries []os.DirEntry) bool { + foundManifests := false + foundMetadata := false + for _, e := range entries { + if e.IsDir() { + switch e.Name() { + case "manifests": + foundManifests = true + case "metadata": + foundMetadata = true + } + } + if foundMetadata && foundManifests { + return true + } + } + return false +} + +type imageReferenceTemplateData struct { + Package string + Name string + Version string +} + +func (r *Render) renderBundleDirectory(ref string) (*declcfg.DeclarativeConfig, error) { + img, err := registry.NewImageInput(image.SimpleReference(""), ref) + if err != nil { + return nil, err + } + if err := r.templateBundleImageRef(img.Bundle); err != nil { + return nil, fmt.Errorf("failed templating image reference from bundle for %q: %v", ref, err) + } + fbcBundle, err := bundleToDeclcfg(img.Bundle) + if err != nil { + return nil, err + } + return &declcfg.DeclarativeConfig{Bundles: []declcfg.Bundle{*fbcBundle}}, nil +} + +func (r *Render) templateBundleImageRef(bundle *registry.Bundle) error { + if r.ImageRefTemplate == nil { + return nil + } + + var pkgProp property.Package + for _, p := range bundle.Properties { + if p.Type != property.TypePackage { + continue + } + if err := json.Unmarshal(p.Value, &pkgProp); err != nil { + return err + } + break + } + + var buf strings.Builder + tmplInput := imageReferenceTemplateData{ + Package: bundle.Package, + Name: bundle.Name, + Version: pkgProp.Version, + } + if err := r.ImageRefTemplate.Execute(&buf, tmplInput); err != nil { + return err + } + bundle.BundleImage = buf.String() + return nil +} diff --git a/alpha/action/render_test.go b/alpha/action/render_test.go index 5c2043825..b1ee6840f 100644 --- a/alpha/action/render_test.go +++ b/alpha/action/render_test.go @@ -12,6 +12,7 @@ import ( "path/filepath" "testing" "testing/fstest" + "text/template" "github.com/operator-framework/api/pkg/operators/v1alpha1" "github.com/stretchr/testify/assert" @@ -736,6 +737,99 @@ func TestRender(t *testing.T) { }, assertion: require.NoError, }, + { + name: "Success/BundleDirectoryWithImageRefTemplate", + render: action.Render{ + Refs: []string{"testdata/foo-bundle-v0.2.0"}, + ImageRefTemplate: template.Must(template.New("imageRef").Parse("test.registry/{{.Package}}-operator/{{.Package}}:v{{.Version}}")), + Registry: reg, + }, + expectCfg: &declcfg.DeclarativeConfig{ + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "foo.v0.2.0", + Package: "foo", + Image: "test.registry/foo-operator/foo:v0.2.0", + Properties: []property.Property{ + property.MustBuildGVK("test.foo", "v1", "Foo"), + property.MustBuildGVKRequired("test.bar", "v1alpha1", "Bar"), + property.MustBuildPackage("foo", "0.2.0"), + property.MustBuildPackageRequired("bar", "<0.1.0"), + mustBuildCSVMetadata(bytes.NewReader(foov2csv)), + }, + Objects: []string{string(foov2csv), string(foov2crd)}, + CsvJSON: string(foov2csv), + RelatedImages: []declcfg.RelatedImage{ + { + Image: "test.registry/foo-operator/foo-2:v0.2.0", + }, + { + Image: "test.registry/foo-operator/foo-init-2:v0.2.0", + }, + { + Image: "test.registry/foo-operator/foo-init:v0.2.0", + }, + { + Name: "other", + Image: "test.registry/foo-operator/foo-other:v0.2.0", + }, + { + Name: "operator", + Image: "test.registry/foo-operator/foo:v0.2.0", + }, + }, + }, + }, + }, + assertion: require.NoError, + }, + { + name: "Success/BundleDirectory", + render: action.Render{ + Refs: []string{"testdata/foo-bundle-v0.2.0"}, + Registry: reg, + }, + expectCfg: &declcfg.DeclarativeConfig{ + Bundles: []declcfg.Bundle{ + { + Schema: "olm.bundle", + Name: "foo.v0.2.0", + Package: "foo", + Properties: []property.Property{ + property.MustBuildGVK("test.foo", "v1", "Foo"), + property.MustBuildGVKRequired("test.bar", "v1alpha1", "Bar"), + property.MustBuildPackage("foo", "0.2.0"), + property.MustBuildPackageRequired("bar", "<0.1.0"), + property.MustBuildBundleObject(foov2crd), + property.MustBuildBundleObject(foov2csv), + }, + Objects: []string{string(foov2csv), string(foov2crd)}, + CsvJSON: string(foov2csv), + RelatedImages: []declcfg.RelatedImage{ + { + Image: "test.registry/foo-operator/foo-2:v0.2.0", + }, + { + Image: "test.registry/foo-operator/foo-init-2:v0.2.0", + }, + { + Image: "test.registry/foo-operator/foo-init:v0.2.0", + }, + { + Name: "other", + Image: "test.registry/foo-operator/foo-other:v0.2.0", + }, + { + Name: "operator", + Image: "test.registry/foo-operator/foo:v0.2.0", + }, + }, + }, + }, + }, + assertion: require.NoError, + }, } for _, s := range specs { @@ -790,7 +884,7 @@ func TestAllowRefMask(t *testing.T) { render: action.Render{ Refs: []string{"test.registry/foo-operator/foo-index-sqlite:v0.2.0"}, Registry: reg, - AllowedRefMask: action.RefDCImage | action.RefDCDir | action.RefSqliteFile | action.RefBundleImage, + AllowedRefMask: action.RefDCImage | action.RefDCDir | action.RefSqliteFile | action.RefBundleImage | action.RefBundleDir, }, expectErr: action.ErrNotAllowed, }, @@ -808,7 +902,7 @@ func TestAllowRefMask(t *testing.T) { render: action.Render{ Refs: []string{dbFile}, Registry: reg, - AllowedRefMask: action.RefDCImage | action.RefDCDir | action.RefSqliteImage | action.RefBundleImage, + AllowedRefMask: action.RefDCImage | action.RefDCDir | action.RefSqliteImage | action.RefBundleImage | action.RefBundleDir, }, expectErr: action.ErrNotAllowed, }, @@ -826,7 +920,7 @@ func TestAllowRefMask(t *testing.T) { render: action.Render{ Refs: []string{"test.registry/foo-operator/foo-index-declcfg:v0.2.0"}, Registry: reg, - AllowedRefMask: action.RefDCDir | action.RefSqliteImage | action.RefSqliteFile | action.RefBundleImage, + AllowedRefMask: action.RefDCDir | action.RefSqliteImage | action.RefSqliteFile | action.RefBundleImage | action.RefBundleDir, }, expectErr: action.ErrNotAllowed, }, @@ -844,7 +938,7 @@ func TestAllowRefMask(t *testing.T) { render: action.Render{ Refs: []string{"testdata/foo-index-v0.2.0-declcfg"}, Registry: reg, - AllowedRefMask: action.RefDCImage | action.RefSqliteImage | action.RefSqliteFile | action.RefBundleImage, + AllowedRefMask: action.RefDCImage | action.RefSqliteImage | action.RefSqliteFile | action.RefBundleImage | action.RefBundleDir, }, expectErr: action.ErrNotAllowed, }, @@ -862,7 +956,25 @@ func TestAllowRefMask(t *testing.T) { render: action.Render{ Refs: []string{"test.registry/foo-operator/foo-bundle:v0.2.0"}, Registry: reg, - AllowedRefMask: action.RefDCImage | action.RefDCDir | action.RefSqliteImage | action.RefSqliteFile, + AllowedRefMask: action.RefDCImage | action.RefDCDir | action.RefSqliteImage | action.RefSqliteFile | action.RefBundleDir, + }, + expectErr: action.ErrNotAllowed, + }, + { + name: "BundleDir/Allowed", + render: action.Render{ + Refs: []string{"testdata/foo-bundle-v0.2.0"}, + Registry: reg, + AllowedRefMask: action.RefBundleDir, + }, + expectErr: nil, + }, + { + name: "BundleDir/NotAllowed", + render: action.Render{ + Refs: []string{"testdata/foo-bundle-v0.2.0"}, + Registry: reg, + AllowedRefMask: action.RefDCImage | action.RefDCDir | action.RefSqliteImage | action.RefSqliteFile | action.RefBundleImage, }, expectErr: action.ErrNotAllowed, }, @@ -875,6 +987,7 @@ func TestAllowRefMask(t *testing.T) { "test.registry/foo-operator/foo-index-declcfg:v0.2.0", "testdata/foo-index-v0.2.0-declcfg", "test.registry/foo-operator/foo-bundle:v0.2.0", + "testdata/foo-bundle-v0.2.0", }, Registry: reg, }, @@ -908,6 +1021,7 @@ func TestAllowRefMaskAllowed(t *testing.T) { action.RefSqliteImage, action.RefSqliteFile, action.RefBundleImage, + action.RefBundleDir, }, fail: []action.RefType{}, }, diff --git a/cmd/opm/render/cmd.go b/cmd/opm/render/cmd.go index e624ab197..51c1cfb0e 100644 --- a/cmd/opm/render/cmd.go +++ b/cmd/opm/render/cmd.go @@ -4,6 +4,7 @@ import ( "io" "log" "os" + "text/template" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -16,16 +17,26 @@ import ( func NewCmd() *cobra.Command { var ( - render action.Render - output string + render action.Render + output string + imageRefTemplate string ) cmd := &cobra.Command{ - Use: "render [index-image | bundle-image | sqlite-file]...", + Use: "render [catalog-image | catalog-directory | bundle-image | bundle-directory | sqlite-file]...", Short: "Generate a stream of file-based catalog objects from catalogs and bundles", Long: `Generate a stream of file-based catalog objects to stdout from the provided catalog images, file-based catalog directories, bundle images, and sqlite database files. +If rendering sources that do not carry bundle image reference information +(e.g. bundle directories), the --image-ref-template flag can be used to +generate image references for the rendered file-based catalog objects. +This is useful when generating a catalog with image references prior to +those images actually existing. Available template variables are: + - {{.Package}} : the package name the bundle belongs to + - {{.Name}} : the name of the bundle (for registry+v1 bundles, this is the CSV name) + - {{.Version}} : the version of the bundle + ` + sqlite.DeprecationMessage, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { @@ -54,6 +65,14 @@ database files. render.Registry = reg + if imageRefTemplate != "" { + tmpl, err := template.New("image-ref-template").Parse(imageRefTemplate) + if err != nil { + log.Fatalf("invalid image reference template: %v", err) + } + render.ImageRefTemplate = tmpl + } + cfg, err := render.Run(cmd.Context()) if err != nil { log.Fatal(err) @@ -66,6 +85,7 @@ database files. } cmd.Flags().StringVarP(&output, "output", "o", "json", "Output format of the streamed file-based catalog objects (json|yaml)") cmd.Flags().BoolVar(&render.Migrate, "migrate", false, "Perform migrations on the rendered FBC") + cmd.Flags().StringVar(&imageRefTemplate, "image-ref-template", "", "When bundle image reference information is unavailable, populate it with this template") return cmd }