diff --git a/alpha/declcfg/declcfg_to_model.go b/alpha/declcfg/declcfg_to_model.go index 342cab403..be94f9056 100644 --- a/alpha/declcfg/declcfg_to_model.go +++ b/alpha/declcfg/declcfg_to_model.go @@ -147,6 +147,7 @@ func ConvertToModel(cfg DeclarativeConfig) (model.Model, error) { mb.Objects = b.Objects mb.PropertiesP = props mb.Version = ver + mb.Release = props.Packages[0].Release } } if !found { diff --git a/alpha/declcfg/helpers_test.go b/alpha/declcfg/helpers_test.go index 1d55f9e2a..928b5c853 100644 --- a/alpha/declcfg/helpers_test.go +++ b/alpha/declcfg/helpers_test.go @@ -145,6 +145,16 @@ func withNoBundleData() func(*Bundle) { } } +func withReleaseVersion(packageName, version string, rel property.Release) func(*Bundle) { + return func(b *Bundle) { + for i, p := range b.Properties { + if p.Type == property.TypePackage { + b.Properties[i] = property.MustBuildPackageRelease(packageName, version, rel.Label, rel.Version.String()) + } + } + } +} + func newTestBundle(packageName, version string, opts ...bundleOpt) Bundle { csvJSON := fmt.Sprintf(`{"kind": "ClusterServiceVersion", "apiVersion": "operators.coreos.com/v1alpha1", "metadata":{"name":%q}}`, testBundleName(packageName, version)) b := Bundle{ diff --git a/alpha/model/model.go b/alpha/model/model.go index 9b4e3ae85..415ed2bd1 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, ", "))) } @@ -327,6 +328,46 @@ type Bundle struct { // These fields are used to compare bundles in a diff. PropertiesP *property.Properties Version semver.Version + Release property.Release +} + +func (b *Bundle) VersionString() string { + if b.Release.Label != "" || (b.Release.Version.Major != 0 || b.Release.Version.Minor != 0 || b.Release.Version.Patch != 0) { + return strings.Join([]string{b.Version.String(), b.Release.String()}, "-") + } else { + 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 b.Release.Label != "" || (b.Release.Version.Major != 0 || b.Release.Version.Minor != 0 || b.Release.Version.Patch != 0) { + return strings.Join([]string{b.Package.Name, "v" + b.Version.String(), b.Release.String()}, "-") + } else { + return b.Name + } +} + +// order by version, then +// release, if present +// - label first, if present +// - then version, 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) + } + if b.Release.Label != other.Release.Label { + return strings.Compare(b.Release.Label, other.Release.Label) + } + if b.Release.Version.NE(other.Release.Version) { + return b.Release.Version.Compare(other.Release.Version) + } + return 0 } func (b *Bundle) Validate() error { @@ -335,6 +376,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")) } diff --git a/alpha/model/model_test.go b/alpha/model/model_test.go index 11391b74c..c2bb7e6a5 100644 --- a/alpha/model/model_test.go +++ b/alpha/model/model_test.go @@ -280,6 +280,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-alpha-0.0.1": {Name: "anakin.v0.0.1", Version: semver.MustParse("0.0.1"), Release: property.Release{Label: "alpha", Version: semver.MustParse("0.0.1")}, Package: pkg}, + "anakin-v0.0.2-alpha-0.0.1": {Name: "anakin.v0.0.2", Version: semver.MustParse("0.0.1"), Release: property.Release{Label: "alpha", Version: semver.MustParse("0.0.1")}, Package: pkg}, + }, + }, + }, + }, + assertion: hasError(`duplicate versions found in bundles: [{0.0.1: [anakin.v0.0.1, anakin.v0.0.2]} {0.0.1-alpha-0.0.1: [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.alpha.0.0.1": {Name: "anakin.v0.0.1.alpha.0.0.1", Version: semver.MustParse("0.0.1"), Release: property.Release{Label: "alpha", Version: semver.MustParse("0.0.1")}, Package: pkg}, + }, + }, + }, + }, + assertion: hasError(`name "anakin.v0.0.1.alpha.0.0.1" does not match normalized name "anakin-v0.0.1-alpha-0.0.1"`), + }, { name: "Package/Error/NoDefaultChannel", v: &Package{ diff --git a/alpha/property/property.go b/alpha/property/property.go index 6fb792dda..ce8dc139a 100644 --- a/alpha/property/property.go +++ b/alpha/property/property.go @@ -6,9 +6,11 @@ import ( "errors" "fmt" "reflect" + "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/blang/semver/v4" "github.com/operator-framework/api/pkg/operators/v1alpha1" ) @@ -35,9 +37,15 @@ func (p Property) String() string { return fmt.Sprintf("type: %q, value: %q", p.Type, p.Value) } +type Release struct { + Label string `json:"label"` + Version semver.Version `json:"version"` +} + type Package struct { - PackageName string `json:"packageName"` - Version string `json:"version"` + PackageName string `json:"packageName"` + Version string `json:"version"` + Release Release `json:"release,omitzero"` } // NOTICE: The Channel properties are for internal use only. @@ -247,6 +255,9 @@ func jsonMarshal(p interface{}) ([]byte, error) { func MustBuildPackage(name, version string) Property { return MustBuild(&Package{PackageName: name, Version: version}) } +func MustBuildPackageRelease(name, version, relLabel, relVersion string) Property { + return MustBuild(&Package{PackageName: name, Version: version, Release: Release{Label: relLabel, Version: semver.MustParse(relVersion)}}) +} func MustBuildPackageRequired(name, versionRange string) Property { return MustBuild(&PackageRequired{name, versionRange}) } @@ -286,3 +297,14 @@ func MustBuildCSVMetadata(csv v1alpha1.ClusterServiceVersion) Property { func MustBuildChannelPriority(name string, priority int) Property { return MustBuild(&Channel{ChannelName: name, Priority: priority}) } + +func (r *Release) String() string { + segments := []string{} + if r.Label != "" { + segments = append(segments, r.Label) + } + if r.Version.Major != 0 || r.Version.Minor != 0 || r.Version.Patch != 0 { + segments = append(segments, r.Version.String()) + } + return strings.Join(segments, "-") +} diff --git a/alpha/property/property_test.go b/alpha/property/property_test.go index 171cec7a0..31e68e095 100644 --- a/alpha/property/property_test.go +++ b/alpha/property/property_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "testing" + "github.com/blang/semver/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -132,12 +133,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 +207,16 @@ 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-ReleaseVersion", + input: &Package{PackageName: "name", Version: "0.1.0", Release: Release{Label: "alpha-whatsit", Version: semver.MustParse("1.1.0-bluefoot")}}, + assertion: require.NoError, + expectedProperty: propPtr(MustBuildPackageRelease("name", "0.1.0", "alpha-whatsit", "1.1.0-bluefoot")), + }, { name: "Success/PackageRequired", input: &PackageRequired{"name", ">=0.1.0"}, diff --git a/alpha/template/substitutes/substitutes.go b/alpha/template/substitutes/substitutes.go new file mode 100644 index 000000000..c41286011 --- /dev/null +++ b/alpha/template/substitutes/substitutes.go @@ -0,0 +1,60 @@ +package substitutes + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/operator-framework/operator-registry/alpha/declcfg" +) + +type Template struct { + RenderBundle func(context.Context, string) (*declcfg.DeclarativeConfig, error) +} + +type Substitute struct { + Name string `json:"name"` + Base string `json:"base"` +} + +type SubstitutesForTemplate struct { + Schema string `json:"schema"` + Entries []*declcfg.Meta `json:"entries"` + Substitutions []Substitute `json:"substitutions"` +} + +const schema string = "olm.template.substitutes" + +func parseSpec(reader io.Reader) (*SubstitutesForTemplate, error) { + st := &SubstitutesForTemplate{} + stDoc := json.RawMessage{} + stDecoder := yaml.NewYAMLOrJSONDecoder(reader, 4096) + err := stDecoder.Decode(&stDoc) + if err != nil { + return nil, fmt.Errorf("decoding template schema: %v", err) + } + err = json.Unmarshal(stDoc, st) + if err != nil { + return nil, fmt.Errorf("unmarshalling template: %v", err) + } + + if st.Schema != schema { + return nil, fmt.Errorf("template has unknown schema (%q), should be %q", st.Schema, schema) + } + + return st, nil +} + +func (t Template) Render(ctx context.Context, reader io.Reader) (*declcfg.DeclarativeConfig, error) { + st, err := parseSpec(reader) + if err != nil { + return nil, fmt.Errorf("render: unable to parse template: %v", err) + } + + // TODO: Implement the actual rendering logic using st.Entries and st.Substitutes + _ = st + return nil, nil +} diff --git a/cmd/opm/alpha/template/cmd.go b/cmd/opm/alpha/template/cmd.go index 55ac55187..2e963ebd8 100644 --- a/cmd/opm/alpha/template/cmd.go +++ b/cmd/opm/alpha/template/cmd.go @@ -21,6 +21,10 @@ func NewCmd() *cobra.Command { // sc.Hidden = true runCmd.AddCommand(sc) + subs := newSubstitutesForTemplateCmd() + // subs.Hidden = true + runCmd.AddCommand(subs) + runCmd.PersistentFlags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml)") return runCmd diff --git a/cmd/opm/alpha/template/substitutes.go b/cmd/opm/alpha/template/substitutes.go new file mode 100644 index 000000000..db1e502d5 --- /dev/null +++ b/cmd/opm/alpha/template/substitutes.go @@ -0,0 +1,113 @@ +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/substitutes" + "github.com/operator-framework/operator-registry/cmd/opm/internal/util" +) + +func newSubstitutesForTemplateCmd() *cobra.Command { + var ( + migrateLevel string + ) + + cmd := &cobra.Command{ + Use: "substitutes [FILE]", + Short: `Generate a file-based catalog from a single 'substitutes template' file +When FILE is '-' or not provided, the template is read from standard input`, + Long: `Generate a file-based catalog from a single 'substitutes 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 := substitutes.Template{ + 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(), data) + if err != nil { + log.Fatalf("substitutes %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 +}