Repository: patrickhoefler/dockerfilegraph
Pull request #264 (2023-01-27 19:17:18)

Improve the edge styles
## With `--legend`

![Dockerfile-legend](https://user-images.githubusercontent.com/547220/215192032-e5553646-2095-4884-826d-64034e613395.png)

## With `--layers`

![Dockerfile-layers](https://user-images.githubusercontent.com/547220/215192059-ae3bf14f-432d-4fa0-b7d9-382e5777e862.png)


Patch:
diff --git a/README.md b/README.md
index cd9773f..a1fea8e 100644
--- a/README.md
+++ b/README.md
@@ -7,31 +7,36 @@
 
 `dockerfilegraph` visualizes your multi-stage Dockerfiles.
 
-It creates a visual graph representation of the build process. The graph contains the following nodes:
+It creates a visual graph representation of the build process.
+The graph contains the following nodes:
 
-- All _build stages_
-- The _default build target_ highlighted in grey
-- _External images_ with dashed borders
+- All build stages
+- The default build target (highlighted in grey)
+- External images (with dashed borders)
 
 The edges of the graph represent:
 
-- _FROM_ dependencies with a full arrow head
-- _COPY --from=..._ dependencies with an empty arrow head
-- _RUN --mount=type=cache,from=..._ dependencies with an empty diamond arrow head
+- `FROM ...` dependencies
+  (with a solid line and a full arrow head)
+- `COPY --from=...` dependencies
+  (with a dashed line and an empty arrow head)
+- `RUN --mount=type=cache,from=...` dependencies
+  (with a dotted line and an empty diamond arrow head)
 
-You can add an optional legend to the graph and change the output format and resolution. For all the details, see the [options](#more-options) below.
+You can add an optional legend to the graph and change the output format and resolution.
+For all the details, see the [options](#more-options) below.
 
 ## Example Output
 
 ### Including `--legend`
 
-![Example output including a legend](https://user-images.githubusercontent.com/547220/169665156-09cb79a9-8441-48a7-b2af-4e010eec4b13.png)
+![Example output including a legend](https://user-images.githubusercontent.com/547220/215192032-e5553646-2095-4884-826d-64034e613395.png)
 
 ---
 
 ### Including `--layers`
 
-![Example output including layers](https://user-images.githubusercontent.com/547220/169665172-0a083ae4-6b9c-4900-ad91-aa9085e21b52.png)
+![Example output including layers](https://user-images.githubusercontent.com/547220/215192059-ae3bf14f-432d-4fa0-b7d9-382e5777e862.png)
 
 ## Getting Started
 
@@ -41,7 +46,8 @@ You can add an optional legend to the graph and change the output format and res
 
 ### Installation and Usage
 
-Running `dockerfilegraph` without any arguments will create a `Dockerfile.pdf` in your current working directory. This PDF contains a visual graph representation of your multi-stage Dockerfile.
+Running `dockerfilegraph` without any arguments will create a `Dockerfile.pdf` in your current working directory.
+This PDF contains a visual graph representation of your multi-stage Dockerfile.
 
 #### docker / [nerdctl](https://github.com/containerd/nerdctl)
 
@@ -104,6 +110,7 @@ Usage:
 
 Flags:
   -d, --dpi int           dots per inch of the PNG export (default 96)
+  -e, --edgestyle         style of the graph edges, one of: default, solid (default default)
   -f, --filename string   name of the Dockerfile (default "Dockerfile")
   -h, --help              help for dockerfilegraph
       --layers            display all layers (default false)
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 151f3ca..cb9e5ea 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -14,12 +14,13 @@ import (
 )
 
 var (
-	dpiFlag      int
-	filenameFlag string
-	layersFlag   bool
-	legendFlag   bool
-	outputFlag   enum
-	versionFlag  bool
+	dpiFlag       int
+	edgeStyleFlag enum
+	filenameFlag  string
+	layersFlag    bool
+	legendFlag    bool
+	outputFlag    enum
+	versionFlag   bool
 )
 
 // dfgWriter is a writer that prints to stdout. When testing, we
@@ -55,7 +56,9 @@ It outputs a graph representation of the build process.`,
 			}
 			defer os.Remove(dotFile.Name())
 
-			dotFileContent := dockerfile2dot.BuildDotFile(dockerfile, legendFlag, layersFlag)
+			dotFileContent := dockerfile2dot.BuildDotFile(
+				dockerfile, legendFlag, layersFlag, edgeStyleFlag.String(),
+			)
 
 			_, err = dotFile.Write([]byte(dotFileContent))
 			if err != nil {
@@ -109,6 +112,14 @@ It outputs a graph representation of the build process.`,
 		"dots per inch of the PNG export",
 	)
 
+	edgeStyleFlag = newEnum("default", "solid")
+	rootCmd.Flags().VarP(
+		&edgeStyleFlag,
+		"edgestyle",
+		"e",
+		"style of the graph edges, one of: "+strings.Join(edgeStyleFlag.AllowedValues(), ", "),
+	)
+
 	rootCmd.Flags().StringVarP(
 		&filenameFlag,
 		"filename",
diff --git a/internal/dockerfile2dot/build.go b/internal/dockerfile2dot/build.go
index db5a1f4..563eb14 100644
--- a/internal/dockerfile2dot/build.go
+++ b/internal/dockerfile2dot/build.go
@@ -11,6 +11,7 @@ import (
 // BuildDotFile builds a GraphViz .dot file from a simplified Dockerfile
 func BuildDotFile(
 	simplifiedDockerfile SimplifiedDockerfile, legend bool, layers bool,
+	edgeStyle string,
 ) string {
 	// Create a new graph
 	graph := gographviz.NewEscape()
@@ -22,7 +23,7 @@ func BuildDotFile(
 
 	// Add the legend if requested
 	if legend {
-		addLegend(graph)
+		addLegend(graph, edgeStyle)
 	}
 
 	// Add the external images
@@ -106,7 +107,9 @@ func BuildDotFile(
 		}
 
 		// Add the egdes for this build stage
-		addEdgesForStage(stageIndex, stage, graph, simplifiedDockerfile, layers)
+		addEdgesForStage(
+			stageIndex, stage, graph, simplifiedDockerfile, layers, edgeStyle,
+		)
 	}
 
 	// Add the ARGS that appear before the first stage, if layers are requested
@@ -137,7 +140,7 @@ func BuildDotFile(
 
 func addEdgesForStage(
 	stageIndex int, stage Stage, graph *gographviz.Escape,
-	simplifiedDockerfile SimplifiedDockerfile, layers bool,
+	simplifiedDockerfile SimplifiedDockerfile, layers bool, edgeStyle string,
 ) {
 	for layerIndex, layer := range stage.Layers {
 		if layer.WaitFor.Name == "" {
@@ -147,8 +150,14 @@ func addEdgesForStage(
 		edgeAttrs := map[string]string{}
 		if layer.WaitFor.Type == waitForType(copy) {
 			edgeAttrs["arrowhead"] = "empty"
+			if edgeStyle == "default" {
+				edgeAttrs["style"] = "dashed"
+			}
 		} else if layer.WaitFor.Type == waitForType(runMountTypeCache) {
 			edgeAttrs["arrowhead"] = "ediamond"
+			if edgeStyle == "default" {
+				edgeAttrs["style"] = "dotted"
+			}
 		}
 
 		sourceNodeID, additionalEdgeAttrs := getWaitForNodeID(
@@ -167,7 +176,7 @@ func addEdgesForStage(
 	}
 }
 
-func addLegend(graph *gographviz.Escape) {
+func addLegend(graph *gographviz.Escape, edgeStyle string) {
 	_ = graph.AddSubGraph("G", "cluster_legend", nil)
 
 	_ = graph.AddNode("cluster_legend", "key",
@@ -196,13 +205,23 @@ func addLegend(graph *gographviz.Escape) {
 	)
 
 	_ = graph.AddPortEdge("key", "i0:e", "key2", "i0:w", true, nil)
+
+	copyEdgeAttrs := map[string]string{"arrowhead": "empty"}
+	if edgeStyle == "default" {
+		copyEdgeAttrs["style"] = "dashed"
+	}
 	_ = graph.AddPortEdge(
 		"key", "i1:e", "key2", "i1:w", true,
-		map[string]string{"arrowhead": "empty"},
+		copyEdgeAttrs,
 	)
+
+	cacheEdgeAttrs := map[string]string{"arrowhead": "ediamond"}
+	if edgeStyle == "default" {
+		cacheEdgeAttrs["style"] = "dotted"
+	}
 	_ = graph.AddPortEdge(
 		"key", "i2:e", "key2", "i2:w", true,
-		map[string]string{"arrowhead": "ediamond"},
+		cacheEdgeAttrs,
 	)
 }
 


Test patch:
diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go
index e1afe95..3d97287 100644
--- a/internal/cmd/root_test.go
+++ b/internal/cmd/root_test.go
@@ -27,6 +27,7 @@ var usage = `Usage:
 
 Flags:
   -d, --dpi int           dots per inch of the PNG export (default 96)
+  -e, --edgestyle         style of the graph edges, one of: default, solid (default default)
   -f, --filename string   name of the Dockerfile (default "Dockerfile")
   -h, --help              help for dockerfilegraph
       --layers            display all layers (default false)
@@ -96,63 +97,6 @@ It outputs a graph representation of the build process.
 			wantErr:           true,
 			wantOut:           "Error: file with no instructions\n" + usage + "\n",
 		},
-		{
-			name:        "output flag canon",
-			cliArgs:     []string{"--output", "canon"},
-			wantOut:     "Successfully created Dockerfile.canon\n",
-			wantOutFile: "Dockerfile.canon",
-			wantOutFileContent: `digraph G {
-	graph [compound=true,
-		nodesep=1,
-		rankdir=LR
-	];
-	node [label="\N"];
-	external_image_0	[color=grey20,
-		fontcolor=grey20,
-		label="ubuntu:latest",
-		shape=box,
-		style="dashed,rounded",
-		width=2];
-	stage_0	[label=ubuntu,
-		shape=box,
-		style=rounded,
-		width=2];
-	external_image_0 -> stage_0;
-	stage_2	[fillcolor=grey90,
-		label=release,
-		shape=box,
-		style="filled,rounded",
-		width=2];
-	stage_0 -> stage_2	[arrowhead=empty];
-	external_image_1	[color=grey20,
-		fontcolor=grey20,
-		label="golang:1.19",
-		shape=box,
-		style="dashed,rounded",
-		width=2];
-	stage_1	[label=build,
-		shape=box,
-		style=rounded,
-		width=2];
-	external_image_1 -> stage_1;
-	stage_1 -> stage_2	[arrowhead=empty];
-	external_image_2	[color=grey20,
-		fontcolor=grey20,
-		label=buildcache,
-		shape=box,
-		style="dashed,rounded",
-		width=2];
-	external_image_2 -> stage_1	[arrowhead=ediamond];
-	external_image_3	[color=grey20,
-		fontcolor=grey20,
-		label=scratch,
-		shape=box,
-		style="dashed,rounded",
-		width=2];
-	external_image_3 -> stage_2;
-}
-`,
-		},
 		{
 			name:        "output flag dot",
 			cliArgs:     []string{"--output", "dot"},
@@ -276,6 +220,125 @@ It outputs a graph representation of the build process.
 			width=2];
 		stage_2_layer_2 -> stage_2_layer_3;
 	}
+	stage_0_layer_1 -> stage_2_layer_1	[arrowhead=empty,
+		ltail=cluster_stage_0,
+		style=dashed];
+	external_image_0	[color=grey20,
+		fontcolor=grey20,
+		label="ubuntu:latest",
+		shape=box,
+		style="dashed,rounded",
+		width=2];
+	external_image_0 -> stage_0_layer_0;
+	stage_1_layer_1 -> stage_2_layer_2	[arrowhead=empty,
+		ltail=cluster_stage_1,
+		style=dashed];
+	external_image_1	[color=grey20,
+		fontcolor=grey20,
+		label="golang:1.19",
+		shape=box,
+		style="dashed,rounded",
+		width=2];
+	external_image_1 -> stage_1_layer_0;
+	external_image_2	[color=grey20,
+		fontcolor=grey20,
+		label=buildcache,
+		shape=box,
+		style="dashed,rounded",
+		width=2];
+	external_image_2 -> stage_1_layer_1	[arrowhead=ediamond,
+		style=dotted];
+	external_image_3	[color=grey20,
+		fontcolor=grey20,
+		label=scratch,
+		shape=box,
+		style="dashed,rounded",
+		width=2];
+	external_image_3 -> stage_2_layer_0;
+}
+`,
+		},
+		{
+			name:        "layers flag with solid edges",
+			cliArgs:     []string{"--layers", "-o", "canon", "-e", "solid"},
+			wantOut:     "Successfully created Dockerfile.canon\n",
+			wantOutFile: "Dockerfile.canon",
+			wantOutFileContent: `digraph G {
+	graph [compound=true,
+		nodesep=1,
+		rankdir=LR
+	];
+	node [label="\N"];
+	subgraph cluster_stage_0 {
+		graph [label=ubuntu,
+			margin=16
+		];
+		stage_0_layer_0	[fillcolor=white,
+			label="FROM ubuntu:lates...",
+			penwidth=0.5,
+			shape=box,
+			style="filled,rounded",
+			width=2];
+		stage_0_layer_1	[fillcolor=white,
+			label="RUN   apt-get upd...",
+			penwidth=0.5,
+			shape=box,
+			style="filled,rounded",
+			width=2];
+		stage_0_layer_0 -> stage_0_layer_1;
+	}
+	subgraph cluster_stage_1 {
+		graph [label=build,
+			margin=16
+		];
+		stage_1_layer_0	[fillcolor=white,
+			label="FROM golang:1.19 ...",
+			penwidth=0.5,
+			shape=box,
+			style="filled,rounded",
+			width=2];
+		stage_1_layer_1	[fillcolor=white,
+			label="RUN --mount=type=...",
+			penwidth=0.5,
+			shape=box,
+			style="filled,rounded",
+			width=2];
+		stage_1_layer_0 -> stage_1_layer_1;
+	}
+	subgraph cluster_stage_2 {
+		graph [fillcolor=grey90,
+			label=release,
+			margin=16,
+			style=filled
+		];
+		stage_2_layer_0	[fillcolor=white,
+			label="FROM scratch AS r...",
+			penwidth=0.5,
+			shape=box,
+			style="filled,rounded",
+			width=2];
+		stage_2_layer_1	[fillcolor=white,
+			label="COPY --from=ubunt...",
+			penwidth=0.5,
+			shape=box,
+			style="filled,rounded",
+			width=2];
+		stage_2_layer_0 -> stage_2_layer_1;
+		stage_2_layer_2	[fillcolor=white,
+			label="COPY --from=build...",
+			penwidth=0.5,
+			shape=box,
+			style="filled,rounded",
+			width=2];
+		stage_2_layer_1 -> stage_2_layer_2;
+		stage_2_layer_3	[fillcolor=white,
+			label="ENTRYPOINT ['/exa...",
+			penwidth=0.5,
+			shape=box,
+			style="filled,rounded",
+			width=2];
+		stage_2_layer_2 -> stage_2_layer_3;
+	}
 	stage_0_layer_1 -> stage_2_layer_1	[arrowhead=empty,
 		ltail=cluster_stage_0];
 	external_image_0	[color=grey20,
@@ -337,6 +400,89 @@ It outputs a graph representation of the build process.
 	<tr><td port="i0">&nbsp;</td></tr>
 	<tr><td port="i1">&nbsp;</td></tr>
 	<tr><td port="i2">&nbsp;</td></tr>
+</table>>,
+			shape=plaintext];
+		key:i0:e -> key2:i0:w;
+		key:i1:e -> key2:i1:w	[arrowhead=empty,
+			style=dashed];
+		key:i2:e -> key2:i2:w	[arrowhead=ediamond,
+			style=dotted];
+	}
+	external_image_0	[color=grey20,
+		fontcolor=grey20,
+		label="ubuntu:latest",
+		shape=box,
+		style="dashed,rounded",
+		width=2];
+	stage_0	[label=ubuntu,
+		shape=box,
+		style=rounded,
+		width=2];
+	external_image_0 -> stage_0;
+	stage_2	[fillcolor=grey90,
+		label=release,
+		shape=box,
+		style="filled,rounded",
+		width=2];
+	stage_0 -> stage_2	[arrowhead=empty,
+		style=dashed];
+	external_image_1	[color=grey20,
+		fontcolor=grey20,
+		label="golang:1.19",
+		shape=box,
+		style="dashed,rounded",
+		width=2];
+	stage_1	[label=build,
+		shape=box,
+		style=rounded,
+		width=2];
+	external_image_1 -> stage_1;
+	stage_1 -> stage_2	[arrowhead=empty,
+		style=dashed];
+	external_image_2	[color=grey20,
+		fontcolor=grey20,
+		label=buildcache,
+		shape=box,
+		style="dashed,rounded",
+		width=2];
+	external_image_2 -> stage_1	[arrowhead=ediamond,
+		style=dotted];
+	external_image_3	[color=grey20,
+		fontcolor=grey20,
+		label=scratch,
+		shape=box,
+		style="dashed,rounded",
+		width=2];
+	external_image_3 -> stage_2;
+}
+`,
+		},
+		{
+			name:        "legend flag with solid edges",
+			cliArgs:     []string{"--legend", "-o", "canon", "-e", "solid"},
+			wantOut:     "Successfully created Dockerfile.canon\n",
+			wantOutFile: "Dockerfile.canon",
+			wantOutFileContent: `digraph G {
+	graph [compound=true,
+		nodesep=1,
+		rankdir=LR
+	];
+	node [label="\N"];
+	subgraph cluster_legend {
+		key	[fontname=monospace,
+			fontsize=10,
+			label=<<table border="0" cellpadding="2" cellspacing="0" cellborder="0">
+	<tr><td align="right" port="i0">FROM&nbsp;...&nbsp;</td></tr>
+	<tr><td align="right" port="i1">COPY --from=...&nbsp;</td></tr>
+	<tr><td align="right" port="i2">RUN --mount=type=cache,from=...&nbsp;</td></tr>
+</table>>,
+			shape=plaintext];
+		key2	[fontname=monospace,
+			fontsize=10,
+			label=<<table border="0" cellpadding="2" cellspacing="0" cellborder="0">
+	<tr><td port="i0">&nbsp;</td></tr>
+	<tr><td port="i1">&nbsp;</td></tr>
+	<tr><td port="i2">&nbsp;</td></tr>
 </table>>,
 			shape=plaintext];
 		key:i0:e -> key2:i0:w;
diff --git a/internal/dockerfile2dot/build_test.go b/internal/dockerfile2dot/build_test.go
index f0b2e28..a7cd861 100644
--- a/internal/dockerfile2dot/build_test.go
+++ b/internal/dockerfile2dot/build_test.go
@@ -7,9 +7,10 @@ import (
 
 func TestBuildDotFile(t *testing.T) {
 	type args struct {
-		simplifiedDockerfile SimplifiedDockerfile
-		legend               bool
+		edgeStyle            string
 		layers               bool
+		legend               bool
+		simplifiedDockerfile SimplifiedDockerfile
 	}
 	tests := []struct {
 		name         string
@@ -19,6 +20,8 @@ func TestBuildDotFile(t *testing.T) {
 		{
 			name: "legend",
 			args: args{
+				edgeStyle: "default",
+				legend:    true,
 				simplifiedDockerfile: SimplifiedDockerfile{
 					BeforeFirstStage: []Layer{
 						{
@@ -43,13 +46,14 @@ func TestBuildDotFile(t *testing.T) {
 						},
 					},
 				},
-				legend: true,
 			},
 			wantContains: "release",
 		},
 		{
 			name: "layers",
 			args: args{
+				edgeStyle: "default",
+				layers:    true,
 				simplifiedDockerfile: SimplifiedDockerfile{
 					BeforeFirstStage: []Layer{
 						{
@@ -74,7 +78,6 @@ func TestBuildDotFile(t *testing.T) {
 						},
 					},
 				},
-				layers: true,
 			},
 			wantContains: "release",
 		},
@@ -83,8 +86,11 @@ func TestBuildDotFile(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			if got := BuildDotFile(
 				tt.args.simplifiedDockerfile, tt.args.legend, tt.args.layers,
+				tt.args.edgeStyle,
 			); !strings.Contains(got, tt.wantContains) {
-				t.Errorf("BuildDotFile() = %v, did not contain %v", got, tt.wantContains)
+				t.Errorf(
+					"BuildDotFile() = %v, did not contain %v", got, tt.wantContains,
+				)
 			}
 		})
 	}