diff --git a/alpha/declcfg/write.go b/alpha/declcfg/write.go index 293d9363b..52b2a67f3 100644 --- a/alpha/declcfg/write.go +++ b/alpha/declcfg/write.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "slices" "sort" "strings" @@ -20,6 +21,7 @@ import ( type MermaidWriter struct { MinEdgeName string SpecifiedPackageName string + DrawV0Semantics bool } type MermaidOption func(*MermaidWriter) @@ -32,6 +34,7 @@ func NewMermaidWriter(opts ...MermaidOption) *MermaidWriter { m := &MermaidWriter{ MinEdgeName: minEdgeName, SpecifiedPackageName: specifiedPackageName, + DrawV0Semantics: true, } for _, opt := range opts { @@ -52,6 +55,12 @@ func WithSpecifiedPackageName(specifiedPackageName string) MermaidOption { } } +func WithV0Semantics(drawV0Semantics bool) MermaidOption { + return func(o *MermaidWriter) { + o.DrawV0Semantics = drawV0Semantics + } +} + // 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 @@ -124,7 +133,10 @@ func (writer *MermaidWriter) WriteChannels(cfg DeclarativeConfig, out io.Writer) } var deprecatedPackage string - deprecatedChannels := []string{} + deprecatedChannelIDs := []string{} + decoratedBundleIDs := map[string][]string{"deprecated": {}, "skipped": {}, "deprecatedskipped": {}} + linkID := 0 + skippedLinkIDs := []string{} for _, c := range cfg.Channels { filteredChannel := writer.filterChannel(&c, versionMap, minVersion, minEdgePackage) @@ -137,58 +149,102 @@ func (writer *MermaidWriter) WriteChannels(cfg DeclarativeConfig, out io.Writer) } 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)) + fmt.Fprintf(pkgBuilder, " %%%% channel %q\n", filteredChannel.Name) + fmt.Fprintf(pkgBuilder, " subgraph %s[%q]\n", channelID, filteredChannel.Name) if depByPackage.Has(filteredChannel.Package) { deprecatedPackage = filteredChannel.Package } if depByChannel.Has(filteredChannel.Name) { - deprecatedChannels = append(deprecatedChannels, channelID) + deprecatedChannelIDs = append(deprecatedChannelIDs, channelID) } - for _, ce := range filteredChannel.Entries { - if versionMap[ce.Name].GE(minVersion) { - bundleDeprecation := "" - if depByBundle.Has(ce.Name) { - bundleDeprecation = ":::deprecated" + // sort edges by decreasing version + sortedEntries := make([]*ChannelEntry, 0, len(filteredChannel.Entries)) + for i := range filteredChannel.Entries { + sortedEntries = append(sortedEntries, &filteredChannel.Entries[i]) + } + sort.Slice(sortedEntries, func(i, j int) bool { + // Sort by decreasing version: greater version comes first + return versionMap[sortedEntries[i].Name].GT(versionMap[sortedEntries[j].Name]) + }) + + skippedEntities := sets.Set[string]{} + + const ( + captureNewEntry = true + processExisting = false + ) + handleSemantics := func(edge string, linkID int, captureNew bool) { + if writer.DrawV0Semantics { + if captureNew { + if skippedEntities.Has(edge) { + skippedLinkIDs = append(skippedLinkIDs, fmt.Sprintf("%d", linkID)) + } else { + skippedEntities.Insert(edge) + } + } else { + if skippedEntities.Has(edge) { + skippedLinkIDs = append(skippedLinkIDs, fmt.Sprintf("%d", linkID)) + } } + } + } - entryID := fmt.Sprintf("%s-%s", channelID, ce.Name) - pkgBuilder.WriteString(fmt.Sprintf(" %s[%q]%s\n", entryID, ce.Name, bundleDeprecation)) + for _, ce := range sortedEntries { + entryID := fmt.Sprintf("%s-%s", channelID, ce.Name) + fmt.Fprintf(pkgBuilder, " %s[%q]\n", entryID, ce.Name) + + // mermaid allows specification of only a single decoration class, so any combinations must be independently represented + switch { + case depByBundle.Has(ce.Name) && skippedEntities.Has(ce.Name): + decoratedBundleIDs["deprecatedskipped"] = append(decoratedBundleIDs["deprecatedskipped"], entryID) + case depByBundle.Has(ce.Name): + decoratedBundleIDs["deprecated"] = append(decoratedBundleIDs["deprecated"], entryID) + case skippedEntities.Has(ce.Name): + decoratedBundleIDs["skipped"] = append(decoratedBundleIDs["skipped"], entryID) + } - if len(ce.Replaces) > 0 { - replacesID := fmt.Sprintf("%s-%s", channelID, ce.Replaces) - pkgBuilder.WriteString(fmt.Sprintf(" %s[%q]-- %s --> %s[%q]\n", replacesID, ce.Replaces, "replace", entryID, ce.Name)) - } - 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", skipsID, s, "skip", entryID, ce.Name)) - } + if len(ce.Skips) > 0 { + for _, s := range ce.Skips { + skipsID := fmt.Sprintf("%s-%s", channelID, s) + fmt.Fprintf(pkgBuilder, " %s[%q]-- %s --> %s[%q]\n", skipsID, s, "skip", entryID, ce.Name) + handleSemantics(s, linkID, captureNewEntry) + linkID++ } - if len(ce.SkipRange) > 0 { - skipRange, err := semver.ParseRange(ce.SkipRange) - if err == nil { - for _, edgeName := range filteredChannel.Entries { - if skipRange(versionMap[edgeName.Name]) { - skipRangeID := fmt.Sprintf("%s-%s", channelID, edgeName.Name) - pkgBuilder.WriteString(fmt.Sprintf(" %s[%q]-- \"%s(%s)\" --> %s[%q]\n", skipRangeID, edgeName.Name, "skipRange", ce.SkipRange, entryID, ce.Name)) - } + } + if len(ce.SkipRange) > 0 { + skipRange, err := semver.ParseRange(ce.SkipRange) + if err == nil { + for _, edgeName := range filteredChannel.Entries { + if skipRange(versionMap[edgeName.Name]) { + skipRangeID := fmt.Sprintf("%s-%s", channelID, edgeName.Name) + fmt.Fprintf(pkgBuilder, " %s[%q]-- \"%s(%s)\" --> %s[%q]\n", skipRangeID, edgeName.Name, "skipRange", ce.SkipRange, entryID, ce.Name) + handleSemantics(ce.Name, linkID, processExisting) + linkID++ } - } else { - fmt.Fprintf(os.Stderr, "warning: ignoring invalid SkipRange for package/edge %q/%q: %v\n", c.Package, ce.Name, err) } + } else { + fmt.Fprintf(os.Stderr, "warning: ignoring invalid SkipRange for package/edge %q/%q: %v\n", c.Package, ce.Name, err) } } + // have to process replaces last, because applicablity can be impacted by skips + if len(ce.Replaces) > 0 { + replacesID := fmt.Sprintf("%s-%s", channelID, ce.Replaces) + fmt.Fprintf(pkgBuilder, " %s[%q]-- %s --> %s[%q]\n", replacesID, ce.Replaces, "replace", entryID, ce.Name) + handleSemantics(ce.Name, linkID, processExisting) + linkID++ + } } - pkgBuilder.WriteString(" end\n") + fmt.Fprintf(pkgBuilder, " end\n") } } _, _ = out.Write([]byte("graph LR\n")) _, _ = out.Write([]byte(" classDef deprecated fill:#E8960F\n")) + _, _ = out.Write([]byte(" classDef skipped stroke:#FF0000,stroke-width:4px\n")) + _, _ = out.Write([]byte(" classDef deprecatedskipped fill:#E8960F,stroke:#FF0000,stroke-width:4px\n")) pkgNames := []string{} for pname := range pkgs { pkgNames = append(pkgNames, pname) @@ -197,22 +253,35 @@ func (writer *MermaidWriter) WriteChannels(cfg DeclarativeConfig, out io.Writer) 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))) + _, _ = fmt.Fprintf(out, " %%%% package %q\n", pkgName) + _, _ = fmt.Fprintf(out, " subgraph %q\n", pkgName) _, _ = out.Write([]byte(pkgs[pkgName].String())) _, _ = out.Write([]byte(" end\n")) } if deprecatedPackage != "" { - _, _ = out.Write([]byte(fmt.Sprintf("style %s fill:#989695\n", deprecatedPackage))) + _, _ = fmt.Fprintf(out, "style %s fill:#989695\n", deprecatedPackage) + } + + if len(deprecatedChannelIDs) > 0 { + for _, deprecatedChannel := range deprecatedChannelIDs { + _, _ = fmt.Fprintf(out, "style %s fill:#DCD0FF\n", deprecatedChannel) + } } - if len(deprecatedChannels) > 0 { - for _, deprecatedChannel := range deprecatedChannels { - _, _ = out.Write([]byte(fmt.Sprintf("style %s fill:#DCD0FF\n", deprecatedChannel))) + // express the decoration classes + for key := range decoratedBundleIDs { + if len(decoratedBundleIDs[key]) > 0 { + b := slices.Clone(decoratedBundleIDs[key]) + slices.Sort(b) + _, _ = fmt.Fprintf(out, "class %s %s\n", strings.Join(b, ","), key) } } + if len(skippedLinkIDs) > 0 { + _, _ = fmt.Fprintf(out, "linkStyle %s %s\n", strings.Join(skippedLinkIDs, ","), "stroke:#FF0000,stroke-width:3px,stroke-dasharray:5;") + } + return nil } diff --git a/alpha/declcfg/write_test.go b/alpha/declcfg/write_test.go index eca428768..fd3c9fb20 100644 --- a/alpha/declcfg/write_test.go +++ b/alpha/declcfg/write_test.go @@ -527,35 +527,40 @@ func TestWriteMermaidChannels(t *testing.T) { packageFilter: "", expected: `graph LR classDef deprecated fill:#E8960F + classDef skipped stroke:#FF0000,stroke-width:4px + classDef deprecatedskipped fill:#E8960F,stroke:#FF0000,stroke-width:4px %% package "anakin" subgraph "anakin" %% channel "dark" subgraph anakin-dark["dark"] - anakin-dark-anakin.v0.0.1["anakin.v0.0.1"]:::deprecated - anakin-dark-anakin.v0.1.0["anakin.v0.1.0"] - anakin-dark-anakin.v0.0.1["anakin.v0.0.1"]-- replace --> anakin-dark-anakin.v0.1.0["anakin.v0.1.0"] anakin-dark-anakin.v0.1.1["anakin.v0.1.1"] - anakin-dark-anakin.v0.0.1["anakin.v0.0.1"]-- replace --> anakin-dark-anakin.v0.1.1["anakin.v0.1.1"] anakin-dark-anakin.v0.1.0["anakin.v0.1.0"]-- skip --> anakin-dark-anakin.v0.1.1["anakin.v0.1.1"] + anakin-dark-anakin.v0.0.1["anakin.v0.0.1"]-- replace --> anakin-dark-anakin.v0.1.1["anakin.v0.1.1"] + anakin-dark-anakin.v0.1.0["anakin.v0.1.0"] + anakin-dark-anakin.v0.0.1["anakin.v0.0.1"]-- replace --> anakin-dark-anakin.v0.1.0["anakin.v0.1.0"] + anakin-dark-anakin.v0.0.1["anakin.v0.0.1"] end %% channel "light" subgraph anakin-light["light"] - anakin-light-anakin.v0.0.1["anakin.v0.0.1"]:::deprecated anakin-light-anakin.v0.1.0["anakin.v0.1.0"] anakin-light-anakin.v0.0.1["anakin.v0.0.1"]-- replace --> anakin-light-anakin.v0.1.0["anakin.v0.1.0"] + 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.v1.0.0["boba-fett.v1.0.0"]-- replace --> boba-fett-mando-boba-fett.v2.0.0["boba-fett.v2.0.0"] + boba-fett-mando-boba-fett.v1.0.0["boba-fett.v1.0.0"] end end style anakin fill:#989695 style anakin-light fill:#DCD0FF +class anakin-dark-anakin.v0.0.1,anakin-light-anakin.v0.0.1 deprecated +class anakin-dark-anakin.v0.1.0 skipped +linkStyle 2 stroke:#FF0000,stroke-width:3px,stroke-dasharray:5; `, }, { @@ -565,13 +570,15 @@ style anakin-light fill:#DCD0FF packageFilter: "", expected: `graph LR classDef deprecated fill:#E8960F + classDef skipped stroke:#FF0000,stroke-width:4px + classDef deprecatedskipped fill:#E8960F,stroke:#FF0000,stroke-width:4px %% package "anakin" subgraph "anakin" %% channel "dark" subgraph anakin-dark["dark"] - anakin-dark-anakin.v0.1.0["anakin.v0.1.0"] anakin-dark-anakin.v0.1.1["anakin.v0.1.1"] anakin-dark-anakin.v0.1.0["anakin.v0.1.0"]-- skip --> anakin-dark-anakin.v0.1.1["anakin.v0.1.1"] + anakin-dark-anakin.v0.1.0["anakin.v0.1.0"] end %% channel "light" subgraph anakin-light["light"] @@ -580,6 +587,7 @@ style anakin-light fill:#DCD0FF end style anakin fill:#989695 style anakin-light fill:#DCD0FF +class anakin-dark-anakin.v0.1.0 skipped `, }, { @@ -589,13 +597,15 @@ style anakin-light fill:#DCD0FF packageFilter: "boba-fett", expected: `graph LR classDef deprecated fill:#E8960F + classDef skipped stroke:#FF0000,stroke-width:4px + classDef deprecatedskipped fill:#E8960F,stroke:#FF0000,stroke-width:4px %% 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.v1.0.0["boba-fett.v1.0.0"]-- replace --> boba-fett-mando-boba-fett.v2.0.0["boba-fett.v2.0.0"] + boba-fett-mando-boba-fett.v1.0.0["boba-fett.v1.0.0"] end end `, diff --git a/cmd/opm/alpha/render-graph/cmd.go b/cmd/opm/alpha/render-graph/cmd.go index 29d36eef9..a8a35ae5a 100644 --- a/cmd/opm/alpha/render-graph/cmd.go +++ b/cmd/opm/alpha/render-graph/cmd.go @@ -18,6 +18,7 @@ func NewCmd() *cobra.Command { render action.Render minEdge string specifiedPackageName string + drawV0Semantics bool ) cmd := &cobra.Command{ Use: "render-graph [index-image | fbc-dir]", @@ -67,7 +68,10 @@ $ opm alpha render-graph quay.io/operatorhubio/catalog:latest | \ log.Fatal(err) } - writer := declcfg.NewMermaidWriter(declcfg.WithMinEdgeName(minEdge), declcfg.WithSpecifiedPackageName(specifiedPackageName)) + writer := declcfg.NewMermaidWriter( + declcfg.WithMinEdgeName(minEdge), + declcfg.WithSpecifiedPackageName(specifiedPackageName), + declcfg.WithV0Semantics(drawV0Semantics)) if err := writer.WriteChannels(*cfg, os.Stdout); err != nil { log.Fatal(err) } @@ -75,5 +79,6 @@ $ opm alpha render-graph quay.io/operatorhubio/catalog:latest | \ } 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; default is to include all edges") cmd.Flags().StringVarP(&specifiedPackageName, "package-name", "p", "", "a specific package name to filter output; default is to include all packages in reference") + cmd.Flags().BoolVar(&drawV0Semantics, "draw-v0-semantics", false, "whether to indicate OLMv0 semantics in the output; default is to simply represent the upgrade graph") return cmd }