From df2ec23769ba74d961d115620470e573177b708e Mon Sep 17 00:00:00 2001 From: Jordan Keister Date: Wed, 24 Aug 2022 07:20:31 -0500 Subject: [PATCH] move the mermaid-format graph output to its own command since this fits a different use-case than "generate valid file-based-catalog of X" provide the ability to specify a minimum-edge-name to filter out edges below the version of interest Signed-off-by: Jordan Keister --- alpha/declcfg/write.go | 172 ++++++++++++++++++++++++------ alpha/declcfg/write_test.go | 3 +- cmd/opm/alpha/cmd.go | 2 + cmd/opm/alpha/render-graph/cmd.go | 52 +++++++++ cmd/opm/alpha/veneer/semver.go | 5 +- cmd/opm/render/cmd.go | 6 +- 6 files changed, 199 insertions(+), 41 deletions(-) create mode 100644 cmd/opm/alpha/render-graph/cmd.go diff --git a/alpha/declcfg/write.go b/alpha/declcfg/write.go index 63d1c59c0..b721d3145 100644 --- a/alpha/declcfg/write.go +++ b/alpha/declcfg/write.go @@ -8,6 +8,8 @@ import ( "sort" "strings" + "github.com/blang/semver/v4" + "github.com/operator-framework/operator-registry/alpha/property" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/yaml" ) @@ -15,60 +17,81 @@ import ( // writes out the channel edges of the declarative config graph in a mermaid format capable of being pasted into // mermaid renderers like github, mermaid.live, etc. // output is sorted lexicographically by package name, and then by channel name +// if provided, minEdgeName will be used as the lower bound for edges in the output graph // // NB: Output has wrapper comments stating the skipRange edge caveat in HTML comment format, which cannot be parsed by mermaid renderers. -// This is deliberate, and intended as an explicit acknowledgement of the limitations, instead of requiring the user to notice the missing edges upon inspection. +// +// This is deliberate, and intended as an explicit acknowledgement of the limitations, instead of requiring the user to notice the missing edges upon inspection. // // Example output: // // graph LR -// %% package "neuvector-certified-operator-rhmp" -// subgraph "neuvector-certified-operator-rhmp" -// %% channel "beta" -// subgraph neuvector-certified-operator-rhmp-beta["beta"] -// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.2.8["neuvector-operator.v1.2.8"] -// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.2.9["neuvector-operator.v1.2.9"] -// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.3.0["neuvector-operator.v1.3.0"] -// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.3.0["neuvector-operator.v1.3.0"]-- replaces --> neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.2.8["neuvector-operator.v1.2.8"] -// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.3.0["neuvector-operator.v1.3.0"]-- skips --> neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.2.9["neuvector-operator.v1.2.9"] -// end -// end +// +// %% package "neuvector-certified-operator-rhmp" +// subgraph "neuvector-certified-operator-rhmp" +// %% channel "beta" +// subgraph neuvector-certified-operator-rhmp-beta["beta"] +// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.2.8["neuvector-operator.v1.2.8"] +// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.2.9["neuvector-operator.v1.2.9"] +// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.3.0["neuvector-operator.v1.3.0"] +// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.3.0["neuvector-operator.v1.3.0"]-- replaces --> neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.2.8["neuvector-operator.v1.2.8"] +// neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.3.0["neuvector-operator.v1.3.0"]-- skips --> neuvector-certified-operator-rhmp-beta-neuvector-operator.v1.2.9["neuvector-operator.v1.2.9"] +// end +// end +// // end // -func WriteMermaidChannels(cfg DeclarativeConfig, out io.Writer) error { +func WriteMermaidChannels(cfg DeclarativeConfig, out io.Writer, minEdgeName string) error { pkgs := map[string]*strings.Builder{} sort.Slice(cfg.Channels, func(i, j int) bool { return cfg.Channels[i].Name < cfg.Channels[j].Name }) - for _, c := range cfg.Channels { - pkgBuilder, ok := pkgs[c.Package] - if !ok { - pkgBuilder = &strings.Builder{} - pkgs[c.Package] = pkgBuilder + versionMap, err := getBundleVersions(&cfg) + if err != nil { + return err + } + + if _, ok := versionMap[minEdgeName]; !ok { + if minEdgeName != "" { + return fmt.Errorf("unknown minimum edge name: %q", minEdgeName) } - channelID := fmt.Sprintf("%s-%s", c.Package, c.Name) - pkgBuilder.WriteString(fmt.Sprintf(" %%%% channel %q\n", c.Name)) - pkgBuilder.WriteString(fmt.Sprintf(" subgraph %s[%q]\n", channelID, c.Name)) - - for _, ce := range c.Entries { - entryId := fmt.Sprintf("%s-%s", channelID, ce.Name) - pkgBuilder.WriteString(fmt.Sprintf(" %s[%q]\n", entryId, ce.Name)) - - // no support for SkipRange yet - if len(ce.Replaces) > 0 { - replacesId := fmt.Sprintf("%s-%s", channelID, ce.Replaces) - pkgBuilder.WriteString(fmt.Sprintf(" %s[%q]-- %s --> %s[%q]\n", entryId, ce.Name, "replaces", replacesId, ce.Replaces)) + } + + for _, c := range cfg.Channels { + filteredChannel := filterChannel(&c, versionMap, minEdgeName) + if filteredChannel != nil { + pkgBuilder, ok := pkgs[c.Package] + if !ok { + pkgBuilder = &strings.Builder{} + pkgs[c.Package] = pkgBuilder } - if len(ce.Skips) > 0 { - for _, s := range ce.Skips { - skipsId := fmt.Sprintf("%s-%s", channelID, s) - pkgBuilder.WriteString(fmt.Sprintf(" %s[%q]-- %s --> %s[%q]\n", entryId, ce.Name, "skips", skipsId, s)) + + channelID := fmt.Sprintf("%s-%s", filteredChannel.Package, filteredChannel.Name) + pkgBuilder.WriteString(fmt.Sprintf(" %%%% channel %q\n", filteredChannel.Name)) + pkgBuilder.WriteString(fmt.Sprintf(" subgraph %s[%q]\n", channelID, filteredChannel.Name)) + + for _, ce := range filteredChannel.Entries { + if versionMap[ce.Name].GE(versionMap[minEdgeName]) { + entryId := fmt.Sprintf("%s-%s", channelID, ce.Name) + pkgBuilder.WriteString(fmt.Sprintf(" %s[%q]\n", entryId, ce.Name)) + + // no support for SkipRange yet + if len(ce.Replaces) > 0 { + replacesId := fmt.Sprintf("%s-%s", channelID, ce.Replaces) + pkgBuilder.WriteString(fmt.Sprintf(" %s[%q]-- %s --> %s[%q]\n", entryId, ce.Name, "replaces", replacesId, ce.Replaces)) + } + if len(ce.Skips) > 0 { + for _, s := range ce.Skips { + skipsId := fmt.Sprintf("%s-%s", channelID, s) + pkgBuilder.WriteString(fmt.Sprintf(" %s[%q]-- %s --> %s[%q]\n", entryId, ce.Name, "skips", skipsId, s)) + } + } } } + pkgBuilder.WriteString(" end\n") } - pkgBuilder.WriteString(" end\n") } out.Write([]byte("\n")) @@ -91,6 +114,85 @@ func WriteMermaidChannels(cfg DeclarativeConfig, out io.Writer) error { return nil } +// filters the channel edges to include only those which are greater-than-or-equal to the edge named by startVersion +// returns a nil channel if all edges are filtered out +func filterChannel(c *Channel, versionMap map[string]semver.Version, minEdgeName string) *Channel { + // short-circuit if no specified startVersion + if minEdgeName == "" { + return c + } + // convert the edge name to the version so we don't have to duplicate the lookup + minVersion := versionMap[minEdgeName] + + out := &Channel{Name: c.Name, Package: c.Package, Properties: c.Properties, Entries: []ChannelEntry{}} + for _, ce := range c.Entries { + filteredCe := ChannelEntry{Name: ce.Name} + // short-circuit to take the edge name (but no references to earlier versions) + if ce.Name == minEdgeName { + out.Entries = append(out.Entries, filteredCe) + continue + } + // if len(ce.SkipRange) > 0 { + // } + if len(ce.Replaces) > 0 { + if versionMap[ce.Replaces].GTE(minVersion) { + filteredCe.Replaces = ce.Replaces + } + } + if len(ce.Skips) > 0 { + filteredSkips := []string{} + for _, s := range ce.Skips { + if versionMap[s].GTE(minVersion) { + filteredSkips = append(filteredSkips, s) + } + } + if len(filteredSkips) > 0 { + filteredCe.Skips = filteredSkips + } + } + if len(filteredCe.Replaces) > 0 || len(filteredCe.Skips) > 0 { + out.Entries = append(out.Entries, filteredCe) + } + } + + if len(out.Entries) > 0 { + return out + } else { + return nil + } +} + +func parseVersionProperty(b *Bundle) (*semver.Version, error) { + props, err := property.Parse(b.Properties) + if err != nil { + return nil, fmt.Errorf("parse properties for bundle %q: %v", b.Name, err) + } + if len(props.Packages) != 1 { + return nil, fmt.Errorf("bundle %q has multiple %q properties, expected exactly 1", b.Name, property.TypePackage) + } + v, err := semver.Parse(props.Packages[0].Version) + if err != nil { + return nil, fmt.Errorf("bundle %q has invalid version %q: %v", b.Name, props.Packages[0].Version, err) + } + + return &v, nil +} + +func getBundleVersions(cfg *DeclarativeConfig) (map[string]semver.Version, error) { + entries := make(map[string]semver.Version) + for index := range cfg.Bundles { + if _, ok := entries[cfg.Bundles[index].Name]; !ok { + ver, err := parseVersionProperty(&cfg.Bundles[index]) + if err != nil { + return entries, err + } + entries[cfg.Bundles[index].Name] = *ver + } + } + + return entries, nil +} + func WriteJSON(cfg DeclarativeConfig, w io.Writer) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") diff --git a/alpha/declcfg/write_test.go b/alpha/declcfg/write_test.go index 06e394f8b..96eb47095 100644 --- a/alpha/declcfg/write_test.go +++ b/alpha/declcfg/write_test.go @@ -513,10 +513,11 @@ graph LR `, }, } + startVersion := "" for _, s := range specs { t.Run(s.name, func(t *testing.T) { var buf bytes.Buffer - err := WriteMermaidChannels(s.cfg, &buf) + err := WriteMermaidChannels(s.cfg, &buf, startVersion) require.NoError(t, err) require.Equal(t, s.expected, buf.String()) }) diff --git a/cmd/opm/alpha/cmd.go b/cmd/opm/alpha/cmd.go index 6651b3b88..74e88edbe 100644 --- a/cmd/opm/alpha/cmd.go +++ b/cmd/opm/alpha/cmd.go @@ -5,6 +5,7 @@ import ( "github.com/operator-framework/operator-registry/cmd/opm/alpha/bundle" "github.com/operator-framework/operator-registry/cmd/opm/alpha/list" + rendergraph "github.com/operator-framework/operator-registry/cmd/opm/alpha/render-graph" "github.com/operator-framework/operator-registry/cmd/opm/alpha/veneer" ) @@ -19,6 +20,7 @@ func NewCmd() *cobra.Command { runCmd.AddCommand( bundle.NewCmd(), list.NewCmd(), + rendergraph.NewCmd(), veneer.NewCmd(), ) return runCmd diff --git a/cmd/opm/alpha/render-graph/cmd.go b/cmd/opm/alpha/render-graph/cmd.go new file mode 100644 index 000000000..72a7666c8 --- /dev/null +++ b/cmd/opm/alpha/render-graph/cmd.go @@ -0,0 +1,52 @@ +package rendergraph + +import ( + "io" + "log" + "os" + + "github.com/operator-framework/operator-registry/alpha/action" + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/cmd/opm/internal/util" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + var ( + render action.Render + minEdge string + ) + cmd := &cobra.Command{ + Use: "render-graph [index-image | fbc-dir | bundle-image]", + Short: "Generate mermaid-formatted view of upgrade graph of operators in an index", + Long: `Generate mermaid-formatted view of upgrade graphs of operators in an index`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // 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 render.Run and logged as fatal errors. + logrus.SetOutput(io.Discard) + + registry, err := util.CreateCLIRegistry(cmd) + if err != nil { + log.Fatal(err) + } + + render.Refs = args + render.AllowedRefMask = action.RefBundleImage | action.RefDCImage | action.RefDCDir // all non-sqlite + render.Registry = registry + + cfg, err := render.Run(cmd.Context()) + if err != nil { + log.Fatal(err) + } + + if err := declcfg.WriteMermaidChannels(*cfg, os.Stdout, minEdge); err != nil { + log.Fatal(err) + } + }, + } + cmd.Flags().StringVar(&minEdge, "minimum-edge", "", "the channel edge to be used as the lower bound of the set of edges composing the upgrade graph") + return cmd +} diff --git a/cmd/opm/alpha/veneer/semver.go b/cmd/opm/alpha/veneer/semver.go index 6a2486c99..6b3783059 100644 --- a/cmd/opm/alpha/veneer/semver.go +++ b/cmd/opm/alpha/veneer/semver.go @@ -39,7 +39,10 @@ func newSemverCmd() *cobra.Command { case "yaml": write = declcfg.WriteYAML case "mermaid": - write = declcfg.WriteMermaidChannels + write = func(cfg declcfg.DeclarativeConfig, writer io.Writer) error { + startVersion := "" + return declcfg.WriteMermaidChannels(cfg, writer, startVersion) + } default: return fmt.Errorf("invalid output format %q", output) } diff --git a/cmd/opm/render/cmd.go b/cmd/opm/render/cmd.go index faf03abc0..448097a73 100644 --- a/cmd/opm/render/cmd.go +++ b/cmd/opm/render/cmd.go @@ -36,10 +36,8 @@ func NewCmd() *cobra.Command { write = declcfg.WriteYAML case "json": write = declcfg.WriteJSON - case "mermaid": - write = declcfg.WriteMermaidChannels default: - log.Fatalf("invalid --output value %q, expected (json|yaml|mermaid)", output) + log.Fatalf("invalid --output value %q, expected (json|yaml)", output) } // The bundle loading impl is somewhat verbose, even on the happy path, @@ -65,7 +63,7 @@ func NewCmd() *cobra.Command { } }, } - cmd.Flags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml|mermaid)") + cmd.Flags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml)") return cmd }