diff --git a/alpha/declcfg/write.go b/alpha/declcfg/write.go index d03e1315f..63d1c59c0 100644 --- a/alpha/declcfg/write.go +++ b/alpha/declcfg/write.go @@ -3,13 +3,94 @@ package declcfg import ( "bytes" "encoding/json" + "fmt" "io" "sort" + "strings" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/yaml" ) +// 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 +// +// 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. +// +// 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 +// end +// +func WriteMermaidChannels(cfg DeclarativeConfig, out io.Writer) 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 + } + 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)) + } + 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") + } + + out.Write([]byte("\n")) + out.Write([]byte("graph LR\n")) + pkgNames := []string{} + for pname, _ := range pkgs { + pkgNames = append(pkgNames, pname) + } + sort.Slice(pkgNames, func(i, j int) bool { + return pkgNames[i] < pkgNames[j] + }) + for _, pkgName := range pkgNames { + out.Write([]byte(fmt.Sprintf(" %%%% package %q\n", pkgName))) + out.Write([]byte(fmt.Sprintf(" subgraph %q\n", pkgName))) + out.Write([]byte(pkgs[pkgName].String())) + out.Write([]byte(" end\n")) + } + out.Write([]byte("\n")) + + return 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 d8c909d8f..06e394f8b 100644 --- a/alpha/declcfg/write_test.go +++ b/alpha/declcfg/write_test.go @@ -469,3 +469,56 @@ func removeJSONWhitespace(cfg *DeclarativeConfig) { cfg.Others[io].Blob = buf.Bytes() } } + +func TestWriteMermaidChannels(t *testing.T) { + type spec struct { + name string + cfg DeclarativeConfig + expected string + } + specs := []spec{ + { + name: "Success", + cfg: buildValidDeclarativeConfig(true), + expected: ` +graph LR + %% package "anakin" + subgraph "anakin" + %% channel "dark" + subgraph anakin-dark["dark"] + anakin-dark-anakin.v0.0.1["anakin.v0.0.1"] + anakin-dark-anakin.v0.1.0["anakin.v0.1.0"] + anakin-dark-anakin.v0.1.0["anakin.v0.1.0"]-- replaces --> anakin-dark-anakin.v0.0.1["anakin.v0.0.1"] + anakin-dark-anakin.v0.1.1["anakin.v0.1.1"] + anakin-dark-anakin.v0.1.1["anakin.v0.1.1"]-- replaces --> anakin-dark-anakin.v0.0.1["anakin.v0.0.1"] + anakin-dark-anakin.v0.1.1["anakin.v0.1.1"]-- skips --> anakin-dark-anakin.v0.1.0["anakin.v0.1.0"] + end + %% channel "light" + subgraph anakin-light["light"] + anakin-light-anakin.v0.0.1["anakin.v0.0.1"] + anakin-light-anakin.v0.1.0["anakin.v0.1.0"] + anakin-light-anakin.v0.1.0["anakin.v0.1.0"]-- replaces --> anakin-light-anakin.v0.0.1["anakin.v0.0.1"] + end + end + %% package "boba-fett" + subgraph "boba-fett" + %% channel "mando" + subgraph boba-fett-mando["mando"] + boba-fett-mando-boba-fett.v1.0.0["boba-fett.v1.0.0"] + boba-fett-mando-boba-fett.v2.0.0["boba-fett.v2.0.0"] + boba-fett-mando-boba-fett.v2.0.0["boba-fett.v2.0.0"]-- replaces --> boba-fett-mando-boba-fett.v1.0.0["boba-fett.v1.0.0"] + end + end + +`, + }, + } + for _, s := range specs { + t.Run(s.name, func(t *testing.T) { + var buf bytes.Buffer + err := WriteMermaidChannels(s.cfg, &buf) + require.NoError(t, err) + require.Equal(t, s.expected, buf.String()) + }) + } +} diff --git a/alpha/veneer/semver/semver.go b/alpha/veneer/semver/semver.go index 242aa46b2..4fbe4716a 100644 --- a/alpha/veneer/semver/semver.go +++ b/alpha/veneer/semver/semver.go @@ -1,10 +1,8 @@ package semver import ( - "bytes" "context" "fmt" - "io" "io/ioutil" "sort" @@ -421,31 +419,6 @@ func getMinorVersion(v semver.Version) semver.Version { } } -func MermaidChannelWriter(cfg declcfg.DeclarativeConfig, out io.Writer) error { - for _, c := range cfg.Channels { - var buf bytes.Buffer - - buf.WriteString(fmt.Sprintf("<-- Channel %q --> \n", c.Name)) - buf.WriteString("graph LR\n") - - for _, ce := range c.Entries { - - // no support for SkipRange yet - buf.WriteString(fmt.Sprintf("%s\n", ce.Name)) - if len(ce.Replaces) > 0 { - buf.WriteString(fmt.Sprintf("%s-- %s --> %s\n", ce.Name, "replaces", ce.Replaces)) - } - if len(ce.Skips) > 0 { - for _, s := range ce.Skips { - buf.WriteString(fmt.Sprintf("%s-- %s --> %s\n", ce.Name, "skips", s)) - } - } - } - out.Write(buf.Bytes()) - } - return nil -} - func withoutBuildMetadataConflict(versions *map[string]semver.Version) error { errs := []error{} diff --git a/cmd/opm/alpha/veneer/semver.go b/cmd/opm/alpha/veneer/semver.go index cd2c61eb4..74c9de614 100644 --- a/cmd/opm/alpha/veneer/semver.go +++ b/cmd/opm/alpha/veneer/semver.go @@ -37,7 +37,7 @@ func newSemverCmd() *cobra.Command { case "yaml": write = declcfg.WriteYAML case "mermaid": - write = semver.MermaidChannelWriter + write = declcfg.WriteMermaidChannels default: return fmt.Errorf("invalid output format %q", output) } diff --git a/cmd/opm/render/cmd.go b/cmd/opm/render/cmd.go index 228deab5a..52cae5538 100644 --- a/cmd/opm/render/cmd.go +++ b/cmd/opm/render/cmd.go @@ -37,8 +37,10 @@ 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)", output) + log.Fatalf("invalid --output value %q, expected (json|yaml|mermaid)", output) } // The bundle loading impl is somewhat verbose, even on the happy path, @@ -79,7 +81,7 @@ func NewCmd() *cobra.Command { } }, } - cmd.Flags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml)") + cmd.Flags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml|mermaid)") cmd.Flags().Bool("skip-tls-verify", false, "disable TLS verification") cmd.Flags().Bool("use-http", false, "use plain HTTP") return cmd