Skip to content
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ dockerfilegraph
- `--legend` - Add a legend explaining the notation
- `--layers` - Show all Docker layers
- `--separate ubuntu,alpine` - Display selected external images as separate nodes per usage, reducing edge clutter
- `--target release,app` - Only show stages required to build the given target(s), eliding everything else

**All Available Options:**

Expand All @@ -133,6 +134,7 @@ Flags:
-r, --ranksep float minimum separation between ranks (default 0.5)
--scratch how to handle scratch images, one of: collapsed, hidden, separated (default collapsed)
--separate strings external images to display as separate nodes per usage (e.g. --separate ubuntu,alpine)
--target strings only show stages required to build the given target(s) (e.g. --target release,app)
-u, --unflatten uint stagger length of leaf edges between [1,u] (default 0)
--version display the version of dockerfilegraph
```
Expand Down
48 changes: 23 additions & 25 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type cliFlags struct {
ranksep float64
scratch enum
separate []string
target []string
unflatten uint
version bool
}
Expand Down Expand Up @@ -67,16 +68,16 @@ It creates a visual graph representation of the build process.`,
return
}

// Determine scratch mode from flag
scratchMode := scratchModeFromString(f.scratch.String())

// Load and parse the Dockerfile.
dockerfile, err := dockerfile2dot.LoadAndParseDockerfile(
inputFS,
f.filename,
int(f.maxLabelLength),
scratchMode,
f.separate,
dockerfile2dot.ParseOptions{
MaxLabelLength: int(f.maxLabelLength),
ScratchMode: dockerfile2dot.ScratchModeFromString(f.scratch.String()),
SeparateImages: f.separate,
Targets: f.target,
},
)
if err != nil {
return
Expand All @@ -90,13 +91,15 @@ It creates a visual graph representation of the build process.`,

dotFileContent, err := dockerfile2dot.BuildDotFile(
dockerfile,
f.concentrate,
f.edgestyle.String(),
f.layers,
f.legend,
int(f.maxLabelLength),
f.nodesep,
f.ranksep,
dockerfile2dot.BuildOptions{
Concentrate: f.concentrate,
EdgeStyle: f.edgestyle.String(),
Layers: f.layers,
Legend: f.legend,
MaxLabelLength: int(f.maxLabelLength),
NodeSep: f.nodesep,
RankSep: f.ranksep,
},
)
if err != nil {
return
Expand Down Expand Up @@ -257,6 +260,13 @@ It creates a visual graph representation of the build process.`,
"external images to display as separate nodes per usage (e.g. --separate ubuntu,alpine)",
)

rootCmd.Flags().StringSliceVar(
&f.target,
"target",
nil,
"only show stages required to build the given target(s) (e.g. --target release,app)",
)

rootCmd.Flags().UintVarP(
&f.unflatten,
"unflatten",
Expand Down Expand Up @@ -338,15 +348,3 @@ func checkFlags(maxLabelLength uint) error {
}
return nil
}

// scratchModeFromString converts a validated enum string to a ScratchMode constant.
func scratchModeFromString(s string) dockerfile2dot.ScratchMode {
switch s {
case "separated":
return dockerfile2dot.ScratchSeparated
case "hidden":
return dockerfile2dot.ScratchHidden
default:
return dockerfile2dot.ScratchCollapsed
}
}
46 changes: 46 additions & 0 deletions internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Flags:
-r, --ranksep float minimum separation between ranks (default 0.5)
--scratch how to handle scratch images, one of: collapsed, hidden, separated (default collapsed)
--separate strings external images to display as separate nodes per usage (e.g. --separate ubuntu,alpine)
--target strings only show stages required to build the given target(s) (e.g. --target release,app)
-u, --unflatten uint stagger length of leaf edges between [1,u] (default 0)
--version display the version of dockerfilegraph
`
Expand Down Expand Up @@ -587,6 +588,51 @@ It creates a visual graph representation of the build process.
}
`,
},
{
name: "target flag single target",
cliArgs: []string{"--target", "ubuntu", "-o", "raw"},
wantOut: "Successfully created Dockerfile.raw\n",
wantOutFile: "Dockerfile.raw",
wantOutFileContent: `digraph G {
compound=true;
nodesep=1.00;
rankdir=LR;
ranksep=0.50;
external_image_0->stage_0;
external_image_0 [ color=grey20, fontcolor=grey20, label="ubuntu:latest", shape=box, style="dashed,rounded", width=2 ];
stage_0 [ fillcolor=grey90, label="ubuntu", shape=box, style="filled,rounded", width=2 ];

}
`,
},
{
name: "target flag two targets",
cliArgs: []string{"--target", "ubuntu,build-tool-dependencies", "-o", "raw"},
wantOut: "Successfully created Dockerfile.raw\n",
wantOutFile: "Dockerfile.raw",
wantOutFileContent: `digraph G {
compound=true;
nodesep=1.00;
rankdir=LR;
ranksep=0.50;
external_image_0->stage_0;
external_image_1->stage_1;
external_image_2->stage_1[ arrowhead=ediamond, style=dotted ];
external_image_0 [ color=grey20, fontcolor=grey20, label="ubuntu:latest", shape=box, style="dashed,rounded", width=2 ];
external_image_1 [ color=grey20, fontcolor=grey20, label="golang:1.19", shape=box, style="dashed,rounded", width=2 ];
external_image_2 [ color=grey20, fontcolor=grey20, label="buildcache", shape=box, style="dashed,rounded", width=2 ];
stage_0 [ label="ubuntu", shape=box, style=rounded, width=2 ];
stage_1 [ fillcolor=grey90, label="build-tool-depend...", shape=box, style="filled,rounded", width=2 ];

}
`,
},
{
name: "target flag invalid target",
cliArgs: []string{"--target", "nonexistent", "-o", "raw"},
wantErr: true,
wantOutRegex: `Error: target "nonexistent" not found in Dockerfile`,
},
{
name: "separate flag multiple images",
cliArgs: []string{"--separate", "ubuntu:latest,alpine", "-o", "raw"},
Expand Down
70 changes: 29 additions & 41 deletions internal/dockerfile2dot/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,7 @@ import (
// BuildDotFile builds a GraphViz .dot file from a simplified Dockerfile
func BuildDotFile(
simplifiedDockerfile SimplifiedDockerfile,
concentrate bool,
edgestyle string,
layers bool,
legend bool,
maxLabelLength int,
nodesep float64,
ranksep float64,
opts BuildOptions,
) (string, error) {
// Create a new graph
graph := gographviz.NewEscape()
Expand All @@ -33,10 +27,10 @@ func BuildDotFile(
set(graph.SetName("G"))
set(graph.SetDir(true))
set(graph.AddAttr("G", "compound", "true")) // allow edges between clusters
set(graph.AddAttr("G", "nodesep", fmt.Sprintf("%.2f", nodesep)))
set(graph.AddAttr("G", "nodesep", fmt.Sprintf("%.2f", opts.NodeSep)))
set(graph.AddAttr("G", "rankdir", "LR"))
set(graph.AddAttr("G", "ranksep", fmt.Sprintf("%.2f", ranksep)))
if concentrate {
set(graph.AddAttr("G", "ranksep", fmt.Sprintf("%.2f", opts.RankSep)))
if opts.Concentrate {
set(graph.AddAttr("G", "concentrate", "true"))
}

Expand All @@ -45,22 +39,22 @@ func BuildDotFile(
}

// Add the legend if requested
if legend {
if err := addLegend(graph, edgestyle); err != nil {
if opts.Legend {
if err := addLegend(graph, opts.EdgeStyle); err != nil {
return "", err
}
}

if err := addExternalImagesToGraph(graph, simplifiedDockerfile, maxLabelLength); err != nil {
if err := addExternalImagesToGraph(graph, simplifiedDockerfile, opts.MaxLabelLength); err != nil {
return "", err
}

if err := addStages(graph, simplifiedDockerfile, maxLabelLength, layers, edgestyle); err != nil {
if err := addStages(graph, simplifiedDockerfile, opts.MaxLabelLength, opts.Layers, opts.EdgeStyle); err != nil {
return "", err
}

// Add the ARGS that appear before the first stage, if layers are requested
if layers {
if opts.Layers {
if err := addBeforeFirstStage(graph, simplifiedDockerfile); err != nil {
return "", err
}
Expand Down Expand Up @@ -369,42 +363,20 @@ func getWaitForNodeID(
) (string, map[string]string, error) {
attrs := map[string]string{}

// If it can be converted to an integer, it's a stage ID
// If it can be converted to an integer, it's a numeric stage reference
if stageIndex, convertErr := strconv.Atoi(nameOrID); convertErr == nil {
if stageIndex < 0 || stageIndex >= len(simplifiedDockerfile.Stages) {
return "", nil, fmt.Errorf(
"stage index %d out of range (have %d stages)",
stageIndex, len(simplifiedDockerfile.Stages),
)
}
if layers {
if len(simplifiedDockerfile.Stages[stageIndex].Layers) == 0 {
return "", nil, fmt.Errorf("stage %d has no layers", stageIndex)
}
// Return the last layer of the stage
return fmt.Sprintf(
"stage_%d_layer_%d",
stageIndex, len(simplifiedDockerfile.Stages[stageIndex].Layers)-1,
), map[string]string{"ltail": fmt.Sprintf("cluster_stage_%d", stageIndex)}, nil
}
return fmt.Sprintf("stage_%d", stageIndex), attrs, nil
return stageNodeID(simplifiedDockerfile, stageIndex, nameOrID, layers)
}

// Check if it's a stage name
for stageIndex, stage := range simplifiedDockerfile.Stages {
if nameOrID == stage.Name {
if layers {
if len(simplifiedDockerfile.Stages[stageIndex].Layers) == 0 {
return "", nil, fmt.Errorf("stage %q has no layers", nameOrID)
}
// Return the last layer of the stage
return fmt.Sprintf(
"stage_%d_layer_%d",
stageIndex, len(simplifiedDockerfile.Stages[stageIndex].Layers)-1,
), map[string]string{"ltail": fmt.Sprintf("cluster_stage_%d", stageIndex)}, nil
}
return fmt.Sprintf("stage_%d", stageIndex), attrs, nil
}
if stageIndex, found := findStageIndex(simplifiedDockerfile.Stages, nameOrID); found {
return stageNodeID(simplifiedDockerfile, stageIndex, nameOrID, layers)
}
Comment thread
patrickhoefler marked this conversation as resolved.

// Check if it's an external image ID
Expand All @@ -419,3 +391,19 @@ func getWaitForNodeID(
nameOrID,
)
}

// stageNodeID returns the graph node ID for a stage, handling the layers case.
func stageNodeID(
sdf SimplifiedDockerfile, stageIndex int, nameOrID string, layers bool,
) (string, map[string]string, error) {
if layers {
if len(sdf.Stages[stageIndex].Layers) == 0 {
return "", nil, fmt.Errorf("stage %q has no layers", nameOrID)
}
lastLayer := len(sdf.Stages[stageIndex].Layers) - 1
return fmt.Sprintf("stage_%d_layer_%d", stageIndex, lastLayer),
map[string]string{"ltail": fmt.Sprintf("cluster_stage_%d", stageIndex)},
nil
}
return fmt.Sprintf("stage_%d", stageIndex), map[string]string{}, nil
}
19 changes: 11 additions & 8 deletions internal/dockerfile2dot/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ func TestBuildDotFileErrors(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := BuildDotFile(
tt.simplifiedDockerfile, false, "default", tt.layers, false, 20, 0.5, 0.5,
tt.simplifiedDockerfile,
BuildOptions{EdgeStyle: "default", MaxLabelLength: 20, NodeSep: 0.5, RankSep: 0.5, Layers: tt.layers},
)
if err == nil {
t.Error("BuildDotFile() expected an error, got nil")
Expand Down Expand Up @@ -205,13 +206,15 @@ func TestBuildDotFile(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
got, err := BuildDotFile(
tt.args.simplifiedDockerfile,
tt.args.concentrate,
tt.args.edgestyle,
tt.args.layers,
tt.args.legend,
tt.args.maxLabelLength,
tt.args.nodesep,
tt.args.ranksep,
BuildOptions{
Concentrate: tt.args.concentrate,
EdgeStyle: tt.args.edgestyle,
Layers: tt.args.layers,
Legend: tt.args.legend,
MaxLabelLength: tt.args.maxLabelLength,
NodeSep: tt.args.nodesep,
RankSep: tt.args.ranksep,
},
)
if err != nil {
t.Fatalf("BuildDotFile() error = %v", err)
Expand Down
Loading
Loading