From 9aa0651944881143506fd7d4abb2f0039ca5a896 Mon Sep 17 00:00:00 2001 From: Rishikesh Nair <42700059+rishinair11@users.noreply.github.com> Date: Sat, 3 Aug 2024 15:14:27 +0530 Subject: [PATCH] View the SVG using a web server instead of PNG on the local FS (#4) This requires zero setup for the user, because everyone has a browser ;) Added 3 flags: ```console $ flux-graph --help Processes a Flux Kustomization tree and generates a graph Usage: flux-graph [flags] Flags: -d, --direction string Specify direction of graph (TB,BT,LR,RL) (default "TB") -f, --file string Specify input file -h, --help help for flux-graph -n, --no-serve Don't serve the graph on a web server -o, --output string Specify output file (default "graph.svg") -p, --port string Specify web server port (default "9000") ``` Also refactored the code by making it a bit more modular and testable (still not there yet) --- main.go | 64 +++++++------------ pkg/graph/graph.go | 4 +- pkg/resource/resource.go | 20 ++++++ pkg/resource/resource_test.go | 43 +++++++++++++ pkg/resource/testdata/test.yaml | 20 ++++++ pkg/serve/serve.go | 37 +++++++++++ pkg/serve/serve_test.go | 32 ++++++++++ pkg/serve/static/graph.html | 105 ++++++++++++++++++++++++++++++++ pkg/util/util.go | 42 +++++++++++++ pkg/util/util_test.go | 59 ++++++++++++++++++ 10 files changed, 381 insertions(+), 45 deletions(-) create mode 100644 pkg/resource/testdata/test.yaml create mode 100644 pkg/serve/serve.go create mode 100644 pkg/serve/serve_test.go create mode 100644 pkg/serve/static/graph.html create mode 100644 pkg/util/util.go create mode 100644 pkg/util/util_test.go diff --git a/main.go b/main.go index a35522f..a15b450 100644 --- a/main.go +++ b/main.go @@ -1,80 +1,58 @@ package main import ( - "io" "log" - "os" - graphviz "github.com/goccy/go-graphviz" "github.com/rishinair11/flux-ks-graph/pkg/graph" "github.com/rishinair11/flux-ks-graph/pkg/resource" + "github.com/rishinair11/flux-ks-graph/pkg/serve" + "github.com/rishinair11/flux-ks-graph/pkg/util" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ) func main() { - var inputFile string - var outputFile string + var ( + inputFile string + outputFile string + serverPort string + graphDirection string + noServe bool + ) rootCmd := &cobra.Command{ Use: "flux-graph", Short: "Processes a Flux Kustomization tree and generates a graph", Run: func(cmd *cobra.Command, _ []string) { - var yamlBytes []byte - var err error - - // Read YAML input - yamlBytes, err = readInput(inputFile) + rt, err := resource.NewResourceTree(inputFile) if err != nil { - log.Fatalf("Failed to read YAML: %v", err) - } - - // Unmarshal YAML into ResourceTree - t := &resource.ResourceTree{} - if err := yaml.Unmarshal(yamlBytes, t); err != nil { - log.Fatalf("Failed to unmarshal YAML: %v", err) + log.Fatalf("Failed to initialize ResourceTree: %v", err) } // Process the graph - graph, err := graph.ProcessGraph(t) + graph, err := graph.ProcessGraph(rt, graphDirection) if err != nil { log.Fatalf("Failed to construct graph: %v", err) } - gvGraph, err := graphviz.ParseBytes([]byte(graph.String())) - if err != nil { - log.Fatalf("Failed to parse graph dot string: %v", err) + if err := util.GenerateGraphSVG(graph, outputFile); err != nil { + log.Fatalf("Failed to generate graph SVG: %v", err) } - defer gvGraph.Close() - f, err := os.Create(outputFile) - if err != nil { - log.Fatalf("Failed to create output file: %v", err) - } - defer f.Close() + log.Println("Generated graph:", outputFile) - if err := graphviz.New().RenderFilename(gvGraph, graphviz.PNG, f.Name()); err != nil { - log.Fatalf("Failed to write output graph image: %v", err) + if !noServe { + serve.ServeAssets(outputFile, serverPort) } - - log.Println("Generated graph:", outputFile) }, } rootCmd.Flags().StringVarP(&inputFile, "file", "f", "", "Specify input file") - rootCmd.Flags().StringVarP(&outputFile, "output", "o", "graph.png", "Specify output file") + rootCmd.Flags().StringVarP(&graphDirection, "direction", "d", "TB", "Specify direction of graph (https://graphviz.gitlab.io/docs/attrs/rankdir)") + rootCmd.Flags().StringVarP(&outputFile, "output", "o", "graph.svg", "Specify output file") + rootCmd.Flags().StringVarP(&serverPort, "port", "p", "9000", "Specify web server port") + rootCmd.Flags().BoolVarP(&noServe, "no-serve", "n", false, "Don't serve the graph on a web server") if err := rootCmd.Execute(); err != nil { log.Fatal(err) } } - -// readInput reads the YAML input from a file or stdin -func readInput(inputFile string) ([]byte, error) { - if inputFile != "" { - log.Println("Reading from file:", inputFile) - return os.ReadFile(inputFile) - } - log.Println("Reading from STDIN...") - return io.ReadAll(os.Stdin) -} diff --git a/pkg/graph/graph.go b/pkg/graph/graph.go index bfc48ab..502eaef 100644 --- a/pkg/graph/graph.go +++ b/pkg/graph/graph.go @@ -10,14 +10,14 @@ import ( ) // ProcessGraph creates a Graphviz graph from the given ResourceTree -func ProcessGraph(t *resource.ResourceTree) (*gographviz.Graph, error) { +func ProcessGraph(t *resource.ResourceTree, rankDir string) (*gographviz.Graph, error) { log.Println("Processing tree...") graphName := "fluxgraph" g := gographviz.NewGraph() g.SetDir(true) //nolint errcheck 'always returns nil' g.SetName(graphName) //nolint errcheck 'always returns nil' - if err := g.Attrs.Add(string(gographviz.RankDir), "LR"); err != nil { + if err := g.Attrs.Add(string(gographviz.RankDir), rankDir); err != nil { log.Fatal(err) } if err := g.Attrs.Add(string(gographviz.RankSep), "5.0"); err != nil { diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 78f07df..fc2636e 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -1,10 +1,30 @@ package resource +import ( + "github.com/rishinair11/flux-ks-graph/pkg/util" + "gopkg.in/yaml.v3" +) + type ResourceTree struct { Resource Resource `yaml:"resource"` Resources []ResourceTree `yaml:"resources"` } +// NewResourceTree parses a Flux 'tree' YAML file and returns a ResourceTree +func NewResourceTree(fileName string) (*ResourceTree, error) { + yamlBytes, err := util.ReadInput(fileName) + if err != nil { + return nil, err + } + + rt := &ResourceTree{} + if err := yaml.Unmarshal(yamlBytes, rt); err != nil { + return nil, err + } + + return rt, nil +} + type Resource struct { GroupKind GroupKind `yaml:"GroupKind"` Name string `yaml:"Name"` diff --git a/pkg/resource/resource_test.go b/pkg/resource/resource_test.go index 495f505..b124417 100644 --- a/pkg/resource/resource_test.go +++ b/pkg/resource/resource_test.go @@ -33,3 +33,46 @@ func TestResource_GetKind(t *testing.T) { }) } } + +func TestNewResourceTree(t *testing.T) { + testPath := "testdata/test.yaml" + + want := &ResourceTree{ + Resource: Resource{ + GroupKind: GroupKind{ + Group: "parent-group", + Kind: "parent-kind", + }, + Name: "parent-name", + Namespace: "parent-namespace", + }, + Resources: []ResourceTree{ + { + Resource: Resource{ + GroupKind: GroupKind{ + Group: "child-group", + Kind: "child-kind", + }, + Name: "child-name", + Namespace: "child-namespace", + }, + Resources: []ResourceTree{ + { + Resource: Resource{ + GroupKind: GroupKind{ + Group: "grandchild-group", + Kind: "grandchild-kind", + }, + Name: "grandchild-name", + Namespace: "grandchild-namespace", + }, + }, + }, + }, + }, + } + + got, err := NewResourceTree(testPath) + assert.NoError(t, err) + assert.Equal(t, want, got) +} diff --git a/pkg/resource/testdata/test.yaml b/pkg/resource/testdata/test.yaml new file mode 100644 index 0000000..8c577ff --- /dev/null +++ b/pkg/resource/testdata/test.yaml @@ -0,0 +1,20 @@ +resource: + GroupKind: + Group: parent-group + Kind: parent-kind + Name: parent-name + Namespace: parent-namespace +resources: +- resource: + GroupKind: + Group: child-group + Kind: child-kind + Name: child-name + Namespace: child-namespace + resources: + - resource: + GroupKind: + Group: grandchild-group + Kind: grandchild-kind + Name: grandchild-name + Namespace: grandchild-namespace diff --git a/pkg/serve/serve.go b/pkg/serve/serve.go new file mode 100644 index 0000000..8c18e7a --- /dev/null +++ b/pkg/serve/serve.go @@ -0,0 +1,37 @@ +package serve + +import ( + "embed" + "log" + "net/http" + "path/filepath" +) + +//go:embed static/*.html +var staticFS embed.FS + +// ServeAssets starts a web server to serve the generated graph.html containing the SVG +func ServeAssets(filePath string, port string) { + http.HandleFunc("/", handleGraphSVG) + + http.HandleFunc("/svg", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, filePath) + }) + + log.Println("Serving your graph at http://localhost:" + port) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} + +// handleGraphSVG serves graph.html, which contains the graph SVG, for "/" path. +func handleGraphSVG(w http.ResponseWriter, r *http.Request) { + data, err := staticFS.ReadFile(filepath.Join("static", "graph.html")) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/html") + w.Write(data) //nolint:errcheck +} diff --git a/pkg/serve/serve_test.go b/pkg/serve/serve_test.go new file mode 100644 index 0000000..4792505 --- /dev/null +++ b/pkg/serve/serve_test.go @@ -0,0 +1,32 @@ +package serve + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHandleGraphSVG(t *testing.T) { + // test server + req := httptest.NewRequest(http.MethodGet, "/graph.html", nil) + w := httptest.NewRecorder() + + handleGraphSVG(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // response body should match the static file + expectedBody, err := os.ReadFile(filepath.Join("static", "graph.html")) + assert.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + + assert.Equal(t, string(expectedBody), string(body)) +} diff --git a/pkg/serve/static/graph.html b/pkg/serve/static/graph.html new file mode 100644 index 0000000..6d2d888 --- /dev/null +++ b/pkg/serve/static/graph.html @@ -0,0 +1,105 @@ + + + + + + Flux Graph + + + + +
+
+ +
+
+ + + +
+
+ + + + + + + + diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 0000000..1050e1b --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,42 @@ +package util + +import ( + "io" + "log" + "os" + + "github.com/awalterschulze/gographviz" + "github.com/goccy/go-graphviz" +) + +// readInput reads the YAML input from a file or stdin +func ReadInput(inputFile string) ([]byte, error) { + if inputFile != "" { + log.Println("Reading from file:", inputFile) + return os.ReadFile(inputFile) + } + log.Println("Reading from STDIN...") + return io.ReadAll(os.Stdin) +} + +// GenerateGraphSVG takes a gographviz Graph object, gets the DOT bytes, +// converts it into SVG bytes using the goccy/graphviz library and +// writes it to an .svg file +func GenerateGraphSVG(graph *gographviz.Graph, outputFile string) error { + // TODO: maybe fix this double parsing by generating the graph using goccy/graphviz? + // convert gographviz graph to DOT bytes + gvGraph, err := graphviz.ParseBytes([]byte(graph.String())) + if err != nil { + log.Fatalf("Failed to parse graph dot string: %v", err) + } + defer gvGraph.Close() + + f, err := os.Create(outputFile) + if err != nil { + log.Fatalf("Failed to create output file: %v", err) + } + defer f.Close() + + // convert DOT bytes to SVG file + return graphviz.New().RenderFilename(gvGraph, graphviz.SVG, f.Name()) +} diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go new file mode 100644 index 0000000..31fd654 --- /dev/null +++ b/pkg/util/util_test.go @@ -0,0 +1,59 @@ +package util + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadInput_FromFile(t *testing.T) { + // create a temporary file with test content + tmpFile, err := os.CreateTemp("", "testfile-*.yaml") + assert.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + testContent := []byte("test: content") + n, err := tmpFile.Write(testContent) + assert.NoError(t, err) + assert.Greater(t, n, 0) + + tmpFile.Close() + + // call with temp file path + content, err := ReadInput(tmpFile.Name()) + assert.NoError(t, err) + + assert.Equal(t, string(testContent), string(content)) +} + +func TestReadInput_FromStdin(t *testing.T) { + // prepare stdin with test content + oldStdin := os.Stdin + r, w, err := os.Pipe() + assert.NoError(t, err) + os.Stdin = r + + testContent := "test: content" + + done := make(chan struct{}) + // stdin writer go routine + go func() { + defer w.Close() + _, err := w.Write([]byte(testContent)) + assert.NoError(t, err) + close(done) + }() + + // call with no file path + content, err := ReadInput("") + assert.NoError(t, err) + + assert.Equal(t, string(testContent), string(content)) + + // wait for stdin writer goroutine to complete + <-done + + // restore original stdin + os.Stdin = oldStdin +}