Skip to content

OLM-1803 channel upgrade graph visualization #377

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Sep 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 137 additions & 35 deletions staging/operator-registry/alpha/declcfg/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,67 +8,90 @@ 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"
)

// 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:
// <!-- PLEASE NOTE: skipRange edges are not currently displayed -->
// 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
// <!-- PLEASE NOTE: skipRange edges are not currently displayed -->
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("<!-- PLEASE NOTE: skipRange edges are not currently displayed -->\n"))
Expand All @@ -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("", " ")
Expand Down
3 changes: 2 additions & 1 deletion staging/operator-registry/alpha/declcfg/write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
Expand Down
2 changes: 2 additions & 0 deletions staging/operator-registry/cmd/opm/alpha/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/operator-framework/operator-registry/cmd/opm/alpha/bundle"
"github.com/operator-framework/operator-registry/cmd/opm/alpha/diff"
"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"
)

Expand All @@ -20,6 +21,7 @@ func NewCmd() *cobra.Command {
runCmd.AddCommand(
bundle.NewCmd(),
list.NewCmd(),
rendergraph.NewCmd(),
diff.NewCmd(),
veneer.NewCmd(),
)
Expand Down
78 changes: 78 additions & 0 deletions staging/operator-registry/cmd/opm/alpha/render-graph/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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]",
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),
Example: `
#
# Output channel graph of a catalog in mermaid format
#
$ opm alpha render-graph quay.io/operatorhubio/catalog:latest

#
# Output channel graph of a catalog and generate a scaled vector graphic (SVG) representation
# Note: this pipeline filters out the comments about lacking skipRange support
#
$ opm alpha render-graph quay.io/operatorhubio/catalog:latest | \
grep -Ev '^<!--.*$' | \
docker run --rm -i -v "$PWD":/data ghcr.io/mermaid-js/mermaid-cli/mermaid-cli -o /data/operatorhubio-catalog.svg

# Note: mermaid has a default maxTextSize of 30 000 characters. To override this, generate a JSON-formatted initialization file for
# mermaid like this (using 300 000 for the limit):
$ cat << EOF > ./mermaid.json
{ "maxTextSize": 300000 }
EOF
# and then pass the file for initialization configuration, via the '-c' option, like:
$ opm alpha render-graph quay.io/operatorhubio/catalog:latest | \
grep -Ev '^<!--.*$' | \
docker run --rm -i -v "$PWD":/data ghcr.io/mermaid-js/mermaid-cli/mermaid-cli -c /data/mermaid.json -o /data/operatorhubio-catalog.svg


`,
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.RefDCImage | action.RefDCDir | action.RefSqliteImage | action.RefSqliteFile
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
}
5 changes: 4 additions & 1 deletion staging/operator-registry/cmd/opm/alpha/veneer/semver.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,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)
}
Expand Down
6 changes: 2 additions & 4 deletions staging/operator-registry/cmd/opm/render/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}

Expand Down
Loading