diff --git a/README.md b/README.md index 2c9cb75..7f06c73 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ dockerfilegraph - `--output svg|png|pdf` - Choose your output format - `--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 **All Available Options:** @@ -131,6 +132,7 @@ Flags: -o, --output output file format, one of: canon, dot, pdf, png, raw, svg (default pdf) -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) -u, --unflatten uint stagger length of leaf edges between [1,u] (default 0) --version display the version of dockerfilegraph ``` diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 52d9c9a..0a90821 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -27,6 +27,7 @@ type cliFlags struct { output enum ranksep float64 scratch enum + separate []string unflatten uint version bool } @@ -67,7 +68,7 @@ It creates a visual graph representation of the build process.`, } // Determine scratch mode from flag - scratchMode := f.scratch.String() + scratchMode := scratchModeFromString(f.scratch.String()) // Load and parse the Dockerfile. dockerfile, err := dockerfile2dot.LoadAndParseDockerfile( @@ -75,6 +76,7 @@ It creates a visual graph representation of the build process.`, f.filename, int(f.maxLabelLength), scratchMode, + f.separate, ) if err != nil { return @@ -93,8 +95,8 @@ It creates a visual graph representation of the build process.`, f.layers, f.legend, int(f.maxLabelLength), - fmt.Sprintf("%.2f", f.nodesep), - fmt.Sprintf("%.2f", f.ranksep), + f.nodesep, + f.ranksep, ) if err != nil { return @@ -248,6 +250,13 @@ It creates a visual graph representation of the build process.`, "how to handle scratch images, one of: "+strings.Join(f.scratch.AllowedValues(), ", "), ) + rootCmd.Flags().StringSliceVar( + &f.separate, + "separate", + nil, + "external images to display as separate nodes per usage (e.g. --separate ubuntu,alpine)", + ) + rootCmd.Flags().UintVarP( &f.unflatten, "unflatten", @@ -329,3 +338,15 @@ 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 + } +} diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index ca1ab81..ba4c88c 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -39,6 +39,7 @@ Flags: -o, --output output file format, one of: canon, dot, pdf, png, raw, svg (default pdf) -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) -u, --unflatten uint stagger length of leaf edges between [1,u] (default 0) --version display the version of dockerfilegraph ` @@ -558,6 +559,65 @@ It creates a visual graph representation of the build process. wantErr: true, wantOutRegex: `invalid argument "invalid" for "--scratch" flag: invalid value: invalid`, }, + { + name: "separate flag single image", + cliArgs: []string{"--separate", "ubuntu:latest", "-o", "raw"}, + dockerfileContent: "FROM ubuntu:latest AS base\nRUN echo base\n\n" + + "FROM ubuntu:latest AS other\nRUN echo other\n\n" + + "FROM alpine AS final\nCOPY --from=base . .\nCOPY --from=other . .\n", + 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_2; + stage_0->stage_2[ arrowhead=empty, style=dashed ]; + stage_1->stage_2[ arrowhead=empty, style=dashed ]; + 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="ubuntu:latest", shape=box, style="dashed,rounded", width=2 ]; + external_image_2 [ color=grey20, fontcolor=grey20, label="alpine", shape=box, style="dashed,rounded", width=2 ]; + stage_0 [ label="base", shape=box, style=rounded, width=2 ]; + stage_1 [ label="other", shape=box, style=rounded, width=2 ]; + stage_2 [ fillcolor=grey90, label="final", shape=box, style="filled,rounded", width=2 ]; + +} +`, + }, + { + name: "separate flag multiple images", + cliArgs: []string{"--separate", "ubuntu:latest,alpine", "-o", "raw"}, + dockerfileContent: "FROM ubuntu:latest AS base\nRUN echo base\n\n" + + "FROM alpine AS mid\nRUN echo mid\n\n" + + "FROM ubuntu:latest AS other\nRUN echo other\n\n" + + "FROM alpine AS final\nCOPY --from=base . .\n", + 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_2; + external_image_3->stage_3; + stage_0->stage_3[ arrowhead=empty, style=dashed ]; + 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="alpine", shape=box, style="dashed,rounded", width=2 ]; + external_image_2 [ color=grey20, fontcolor=grey20, label="ubuntu:latest", shape=box, style="dashed,rounded", width=2 ]; + external_image_3 [ color=grey20, fontcolor=grey20, label="alpine", shape=box, style="dashed,rounded", width=2 ]; + stage_0 [ label="base", shape=box, style=rounded, width=2 ]; + stage_1 [ label="mid", shape=box, style=rounded, width=2 ]; + stage_2 [ label="other", shape=box, style=rounded, width=2 ]; + stage_3 [ fillcolor=grey90, label="final", shape=box, style="filled,rounded", width=2 ]; + +} +`, + }, } for _, tt := range tests { diff --git a/internal/dockerfile2dot/build.go b/internal/dockerfile2dot/build.go index d4c1468..6a0a499 100644 --- a/internal/dockerfile2dot/build.go +++ b/internal/dockerfile2dot/build.go @@ -17,8 +17,8 @@ func BuildDotFile( layers bool, legend bool, maxLabelLength int, - nodesep string, - ranksep string, + nodesep float64, + ranksep float64, ) (string, error) { // Create a new graph graph := gographviz.NewEscape() @@ -33,9 +33,9 @@ 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", nodesep)) + set(graph.AddAttr("G", "nodesep", fmt.Sprintf("%.2f", nodesep))) set(graph.AddAttr("G", "rankdir", "LR")) - set(graph.AddAttr("G", "ranksep", ranksep)) + set(graph.AddAttr("G", "ranksep", fmt.Sprintf("%.2f", ranksep))) if concentrate { set(graph.AddAttr("G", "concentrate", "true")) } diff --git a/internal/dockerfile2dot/build_test.go b/internal/dockerfile2dot/build_test.go index 0291303..0356640 100644 --- a/internal/dockerfile2dot/build_test.go +++ b/internal/dockerfile2dot/build_test.go @@ -58,7 +58,7 @@ 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, false, "default", tt.layers, false, 20, 0.5, 0.5, ) if err == nil { t.Error("BuildDotFile() expected an error, got nil") @@ -75,8 +75,8 @@ func TestBuildDotFile(t *testing.T) { layers bool legend bool maxLabelLength int - nodesep string - ranksep string + nodesep float64 + ranksep float64 } tests := []struct { name string @@ -113,8 +113,8 @@ func TestBuildDotFile(t *testing.T) { edgestyle: "default", legend: true, maxLabelLength: 20, - nodesep: "0.5", - ranksep: "0.5", + nodesep: 0.5, + ranksep: 0.5, }, wantContains: "release", }, @@ -148,8 +148,8 @@ func TestBuildDotFile(t *testing.T) { edgestyle: "default", layers: true, maxLabelLength: 20, - nodesep: "0.5", - ranksep: "0.5", + nodesep: 0.5, + ranksep: 0.5, }, wantContains: "release", }, @@ -193,8 +193,8 @@ func TestBuildDotFile(t *testing.T) { layers: false, legend: false, maxLabelLength: 20, - nodesep: "0.5", - ranksep: "0.5", + nodesep: 0.5, + ranksep: 0.5, }, wantContains: `external_image_0 [ color=grey20, fontcolor=grey20, label="scratch", shape=box, ` + `style="dashed,rounded", width=2 ]; diff --git a/internal/dockerfile2dot/convert.go b/internal/dockerfile2dot/convert.go index 75593b3..dfb397b 100644 --- a/internal/dockerfile2dot/convert.go +++ b/internal/dockerfile2dot/convert.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "regexp" + "strconv" "strings" "github.com/aquilax/truncate" @@ -57,7 +58,8 @@ func newLayer( func dockerfileToSimplifiedDockerfile( content []byte, maxLabelLength int, - scratchMode string, + scratchMode ScratchMode, + separateImages []string, ) (simplifiedDockerfile SimplifiedDockerfile, err error) { result, err := parser.Parse(bytes.NewReader(content)) if err != nil { @@ -68,7 +70,6 @@ func dockerfileToSimplifiedDockerfile( stages := make(map[string]struct{}) stageIndex := -1 - layerIndex := -1 argReplacements := make([]ArgReplacement, 0) @@ -81,15 +82,12 @@ func dockerfileToSimplifiedDockerfile( simplifiedDockerfile.Stages = append(simplifiedDockerfile.Stages, stage) // Add a new layer - layerIndex = 0 simplifiedDockerfile.Stages[stageIndex].Layers = append( simplifiedDockerfile.Stages[stageIndex].Layers, layer, ) case instructionCopy: - // Add a new layer - layerIndex++ layer := processCopyInstruction(node, argReplacements, maxLabelLength, scratchMode) simplifiedDockerfile.Stages[stageIndex].Layers = append( simplifiedDockerfile.Stages[stageIndex].Layers, @@ -97,8 +95,6 @@ func dockerfileToSimplifiedDockerfile( ) case instructionRun: - // Add a new layer - layerIndex++ layer := processRunInstruction(node, argReplacements, maxLabelLength, scratchMode) simplifiedDockerfile.Stages[stageIndex].Layers = append( simplifiedDockerfile.Stages[stageIndex].Layers, @@ -106,9 +102,6 @@ func dockerfileToSimplifiedDockerfile( ) default: - // Add a new layer - layerIndex++ - if stageIndex == -1 { layer := processBeforeFirstStage(node, &argReplacements, maxLabelLength) simplifiedDockerfile.BeforeFirstStage = append( @@ -126,14 +119,14 @@ func dockerfileToSimplifiedDockerfile( } } - addExternalImages(&simplifiedDockerfile, stages, scratchMode) + addExternalImages(&simplifiedDockerfile, stages, scratchMode, separateImages) return } // shouldSkipScratchWaitFor returns true if scratch WaitFors should be skipped in hidden mode -func shouldSkipScratchWaitFor(scratchMode, waitForID string) bool { - return scratchMode == "hidden" && waitForID == "scratch" +func shouldSkipScratchWaitFor(scratchMode ScratchMode, waitForID string) bool { + return scratchMode == ScratchHidden && waitForID == "scratch" } // processFromInstruction handles FROM instruction parsing @@ -141,7 +134,7 @@ func processFromInstruction( node *parser.Node, argReplacements []ArgReplacement, maxLabelLength int, - scratchMode string, + scratchMode ScratchMode, stages map[string]struct{}, ) (Stage, Layer) { stage := Stage{} @@ -171,7 +164,7 @@ func processCopyInstruction( node *parser.Node, argReplacements []ArgReplacement, maxLabelLength int, - scratchMode string, + scratchMode ScratchMode, ) Layer { layer := newLayer(node, argReplacements, maxLabelLength) @@ -197,7 +190,7 @@ func processRunInstruction( node *parser.Node, argReplacements []ArgReplacement, maxLabelLength int, - scratchMode string, + scratchMode ScratchMode, ) Layer { layer := newLayer(node, argReplacements, maxLabelLength) @@ -244,64 +237,96 @@ func processBeforeFirstStage( func addExternalImages( simplifiedDockerfile *SimplifiedDockerfile, stages map[string]struct{}, - scratchMode string, + scratchMode ScratchMode, + separateImages []string, ) { - // Counter to generate unique IDs for separate scratch instances - scratchCounter := 0 + // Build a set of images that should be separated, normalizing whitespace + separateSet := make(map[string]struct{}, len(separateImages)) + for _, img := range separateImages { + img = strings.TrimSpace(img) + if img == "" { + continue + } + separateSet[img] = struct{}{} + } + + // Per-image counters for generating unique IDs when separating + separateCounters := make(map[string]int) + + // Track already-seen image IDs to avoid duplicates + seen := make(map[string]struct{}) + + numStages := len(simplifiedDockerfile.Stages) // Iterate through all stages and layers to find external image dependencies for stageIndex, stage := range simplifiedDockerfile.Stages { for layerIndex, layer := range stage.Layers { - // Process WaitFors for external image collection for waitForIndex, waitFor := range layer.WaitFors { - // Skip if this references an internal stage (not an external image) - if _, ok := stages[waitFor.ID]; ok { + imageID, skip := resolveExternalImageID( + waitFor.ID, stages, numStages, scratchMode, separateSet, separateCounters, + ) + if skip { continue } - - imageID := waitFor.ID - originalName := waitFor.ID - - // Handle scratch image modes - if originalName == "scratch" { - switch scratchMode { - case "separated": - // Generate unique IDs while preserving display name - imageID = fmt.Sprintf("scratch-%d", scratchCounter) - scratchCounter++ - // Update the layer's waitFor reference to use the unique ID for graph connections - simplifiedDockerfile.Stages[stageIndex].Layers[layerIndex].WaitFors[waitForIndex].ID = imageID - case "hidden": - // Skip adding to external images entirely - continue - case "collapsed": - // Default behavior - use original ID (no changes needed) - default: - // Default to collapsed for unknown modes - } + if imageID != waitFor.ID { + simplifiedDockerfile.Stages[stageIndex].Layers[layerIndex].WaitFors[waitForIndex].ID = imageID } // Add to external images if not already present - addExternalImageIfNotExists(&simplifiedDockerfile.ExternalImages, imageID, originalName) + if _, exists := seen[imageID]; !exists { + seen[imageID] = struct{}{} + simplifiedDockerfile.ExternalImages = append( + simplifiedDockerfile.ExternalImages, + ExternalImage{ID: imageID, Name: waitFor.ID}, + ) + } } } } } -// addExternalImageIfNotExists adds an external image if it doesn't already exist -func addExternalImageIfNotExists(externalImages *[]ExternalImage, imageID, originalName string) { - // Avoid duplicate external image entries (based on unique ID) - for _, externalImage := range *externalImages { - if externalImage.ID == imageID { - return // Already exists +// resolveExternalImageID determines the graph node ID for a WaitFor dependency. +// It returns (imageID, skip=true) when the dependency should be omitted entirely +// (internal stage reference or scratch in hidden mode). +func resolveExternalImageID( + rawID string, + stages map[string]struct{}, + numStages int, + scratchMode ScratchMode, + separateSet map[string]struct{}, + separateCounters map[string]int, +) (imageID string, skip bool) { + // Skip internal stage references by name + if _, ok := stages[rawID]; ok { + return "", true + } + + // Skip internal stage references by numeric index + if idx, err := strconv.Atoi(rawID); err == nil && idx >= 0 && idx < numStages { + return "", true + } + + // Handle scratch modes + if rawID == "scratch" { + switch scratchMode { + case ScratchHidden: + return "", true + case ScratchSeparated: + id := fmt.Sprintf("scratch-%d", separateCounters["scratch"]) + separateCounters["scratch"]++ + return id, false } + return rawID, false + } + + // Separate other explicitly listed images + if _, shouldSeparate := separateSet[rawID]; shouldSeparate { + id := fmt.Sprintf("%s-%d", rawID, separateCounters[rawID]) + separateCounters[rawID]++ + return id, false } - // Add the external image with proper ID/Name separation - *externalImages = append(*externalImages, ExternalImage{ - ID: imageID, // Unique identifier for graph connections - Name: originalName, // Display name for graph labels - }) + return rawID, false } // appendAndResolveArgReplacement appends a new ARG and resolves its value using already-resolved previous ARGs. diff --git a/internal/dockerfile2dot/convert_test.go b/internal/dockerfile2dot/convert_test.go index ffa0e29..c10e6f0 100644 --- a/internal/dockerfile2dot/convert_test.go +++ b/internal/dockerfile2dot/convert_test.go @@ -10,7 +10,8 @@ func Test_dockerfileToSimplifiedDockerfile(t *testing.T) { type args struct { content []byte maxLabelLength int - scratchMode string + scratchMode ScratchMode + separateImages []string } tests := []struct { name string @@ -22,7 +23,7 @@ func Test_dockerfileToSimplifiedDockerfile(t *testing.T) { args: args{ content: []byte("FROM scratch"), maxLabelLength: 20, - scratchMode: "collapsed", + scratchMode: ScratchCollapsed, }, want: SimplifiedDockerfile{ ExternalImages: []ExternalImage{ @@ -51,7 +52,7 @@ COPY --from=base . . RUN --mount=type=cache,from=buildcache,source=/go/pkg/mod/cache/,target=/go/pkg/mod/cache/ go build `), maxLabelLength: 20, - scratchMode: "collapsed", + scratchMode: ScratchCollapsed, }, want: SimplifiedDockerfile{ ExternalImages: []ExternalImage{ @@ -99,7 +100,7 @@ RUN \ --mount=from=artifacts,source=/artifacts/embeddata,target=/artifacts/embeddata go build `), maxLabelLength: 20, - scratchMode: "collapsed", + scratchMode: ScratchCollapsed, }, want: SimplifiedDockerfile{ ExternalImages: []ExternalImage{ @@ -419,7 +420,7 @@ FROM scratch AS app2 COPY app2.txt /app2.txt `), maxLabelLength: 20, - scratchMode: "separated", + scratchMode: ScratchSeparated, }, want: SimplifiedDockerfile{ ExternalImages: []ExternalImage{ @@ -456,7 +457,7 @@ COPY app2.txt /app2.txt content: []byte(`FROM scratch AS app COPY app.txt /app.txt`), maxLabelLength: 20, - scratchMode: "separated", + scratchMode: ScratchSeparated, }, want: SimplifiedDockerfile{ ExternalImages: []ExternalImage{ @@ -485,7 +486,7 @@ COPY app.txt /app.txt FROM alpine AS final COPY --from=base /app.txt /final.txt`), maxLabelLength: 20, - scratchMode: "separated", + scratchMode: ScratchSeparated, }, want: SimplifiedDockerfile{ ExternalImages: []ExternalImage{ @@ -528,7 +529,7 @@ COPY app1.txt /app1.txt FROM scratch AS app2 COPY app2.txt /app2.txt`), maxLabelLength: 20, - scratchMode: "collapsed", + scratchMode: ScratchCollapsed, }, want: SimplifiedDockerfile{ ExternalImages: []ExternalImage{ @@ -567,7 +568,7 @@ COPY app1.txt /app1.txt FROM scratch AS app2 COPY app2.txt /app2.txt`), maxLabelLength: 20, - scratchMode: "hidden", + scratchMode: ScratchHidden, }, want: SimplifiedDockerfile{ ExternalImages: nil, @@ -595,6 +596,256 @@ COPY app2.txt /app2.txt`), }, }, }, + { + name: "Separate flag - single image used in multiple stages", + args: args{ + content: []byte(`FROM ubuntu AS base +RUN echo base + +FROM ubuntu AS other +RUN echo other + +FROM alpine AS final +COPY --from=base . .`), + maxLabelLength: 20, + scratchMode: ScratchCollapsed, + separateImages: []string{"ubuntu"}, + }, + want: SimplifiedDockerfile{ + ExternalImages: []ExternalImage{ + {ID: "ubuntu-0", Name: "ubuntu"}, + {ID: "ubuntu-1", Name: "ubuntu"}, + {ID: "alpine", Name: "alpine"}, + }, + Stages: []Stage{ + { + Name: "base", + Layers: []Layer{ + { + Label: "FROM ubuntu AS base", + WaitFors: []WaitFor{{ID: "ubuntu-0", Type: waitForType(waitForFrom)}}, + }, + {Label: "RUN echo base"}, + }, + }, + { + Name: "other", + Layers: []Layer{ + { + Label: "FROM ubuntu AS other", + WaitFors: []WaitFor{{ID: "ubuntu-1", Type: waitForType(waitForFrom)}}, + }, + {Label: "RUN echo other"}, + }, + }, + { + Name: "final", + Layers: []Layer{ + { + Label: "FROM alpine AS final", + WaitFors: []WaitFor{{ID: "alpine", Type: waitForType(waitForFrom)}}, + }, + { + Label: "COPY --from=base . .", + WaitFors: []WaitFor{{ID: "base", Type: waitForType(waitForCopy)}}, + }, + }, + }, + }, + }, + }, + { + name: "Separate flag - image not in Dockerfile is a no-op", + args: args{ + content: []byte(`FROM ubuntu AS base +RUN echo base + +FROM ubuntu AS other +RUN echo other`), + maxLabelLength: 20, + scratchMode: ScratchCollapsed, + separateImages: []string{"nonexistent"}, + }, + want: SimplifiedDockerfile{ + ExternalImages: []ExternalImage{ + {ID: "ubuntu", Name: "ubuntu"}, + }, + Stages: []Stage{ + { + Name: "base", + Layers: []Layer{ + { + Label: "FROM ubuntu AS base", + WaitFors: []WaitFor{{ID: "ubuntu", Type: waitForType(waitForFrom)}}, + }, + {Label: "RUN echo base"}, + }, + }, + { + Name: "other", + Layers: []Layer{ + { + Label: "FROM ubuntu AS other", + WaitFors: []WaitFor{{ID: "ubuntu", Type: waitForType(waitForFrom)}}, + }, + {Label: "RUN echo other"}, + }, + }, + }, + }, + }, + { + name: "Separate flag - whitespace in image names is normalized", + args: args{ + content: []byte(`FROM ubuntu AS base +RUN echo base + +FROM ubuntu AS other +RUN echo other`), + maxLabelLength: 20, + scratchMode: ScratchCollapsed, + separateImages: []string{"ubuntu", " ubuntu", "ubuntu "}, + }, + want: SimplifiedDockerfile{ + ExternalImages: []ExternalImage{ + {ID: "ubuntu-0", Name: "ubuntu"}, + {ID: "ubuntu-1", Name: "ubuntu"}, + }, + Stages: []Stage{ + { + Name: "base", + Layers: []Layer{ + { + Label: "FROM ubuntu AS base", + WaitFors: []WaitFor{{ID: "ubuntu-0", Type: waitForType(waitForFrom)}}, + }, + {Label: "RUN echo base"}, + }, + }, + { + Name: "other", + Layers: []Layer{ + { + Label: "FROM ubuntu AS other", + WaitFors: []WaitFor{{ID: "ubuntu-1", Type: waitForType(waitForFrom)}}, + }, + {Label: "RUN echo other"}, + }, + }, + }, + }, + }, + { + name: "Numeric stage reference is not treated as an external image", + args: args{ + content: []byte(`FROM ubuntu AS base +RUN echo base + +FROM alpine +COPY --from=0 /app /app`), + maxLabelLength: 20, + scratchMode: ScratchCollapsed, + }, + want: SimplifiedDockerfile{ + ExternalImages: []ExternalImage{ + {ID: "ubuntu", Name: "ubuntu"}, + {ID: "alpine", Name: "alpine"}, + }, + Stages: []Stage{ + { + Name: "base", + Layers: []Layer{ + { + Label: "FROM ubuntu AS base", + WaitFors: []WaitFor{{ID: "ubuntu", Type: waitForType(waitForFrom)}}, + }, + {Label: "RUN echo base"}, + }, + }, + { + Layers: []Layer{ + { + Label: "FROM alpine", + WaitFors: []WaitFor{{ID: "alpine", Type: waitForType(waitForFrom)}}, + }, + { + Label: "COPY --from=0 /ap...", + WaitFors: []WaitFor{{ID: "0", Type: waitForType(waitForCopy)}}, + }, + }, + }, + }, + }, + }, + { + name: "Separate flag combined with scratch separated", + args: args{ + content: []byte(`FROM scratch AS app1 +COPY app1.txt /app1.txt + +FROM ubuntu AS base +RUN echo base + +FROM scratch AS app2 +COPY app2.txt /app2.txt + +FROM ubuntu AS other +RUN echo other`), + maxLabelLength: 20, + scratchMode: ScratchSeparated, + separateImages: []string{"ubuntu"}, + }, + want: SimplifiedDockerfile{ + ExternalImages: []ExternalImage{ + {ID: "scratch-0", Name: "scratch"}, + {ID: "ubuntu-0", Name: "ubuntu"}, + {ID: "scratch-1", Name: "scratch"}, + {ID: "ubuntu-1", Name: "ubuntu"}, + }, + Stages: []Stage{ + { + Name: "app1", + Layers: []Layer{ + { + Label: "FROM scratch AS app1", + WaitFors: []WaitFor{{ID: "scratch-0", Type: waitForType(waitForFrom)}}, + }, + {Label: "COPY app1.txt /ap..."}, + }, + }, + { + Name: "base", + Layers: []Layer{ + { + Label: "FROM ubuntu AS base", + WaitFors: []WaitFor{{ID: "ubuntu-0", Type: waitForType(waitForFrom)}}, + }, + {Label: "RUN echo base"}, + }, + }, + { + Name: "app2", + Layers: []Layer{ + { + Label: "FROM scratch AS app2", + WaitFors: []WaitFor{{ID: "scratch-1", Type: waitForType(waitForFrom)}}, + }, + {Label: "COPY app2.txt /ap..."}, + }, + }, + { + Name: "other", + Layers: []Layer{ + { + Label: "FROM ubuntu AS other", + WaitFors: []WaitFor{{ID: "ubuntu-1", Type: waitForType(waitForFrom)}}, + }, + {Label: "RUN echo other"}, + }, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -602,6 +853,7 @@ COPY app2.txt /app2.txt`), tt.args.content, tt.args.maxLabelLength, tt.args.scratchMode, + tt.args.separateImages, ) if err != nil { t.Errorf("dockerfileToSimplifiedDockerfile() error = %v", err) diff --git a/internal/dockerfile2dot/load.go b/internal/dockerfile2dot/load.go index 974ddaa..6cd2c6d 100644 --- a/internal/dockerfile2dot/load.go +++ b/internal/dockerfile2dot/load.go @@ -17,7 +17,8 @@ func LoadAndParseDockerfile( inputFS afero.Fs, filename string, maxLabelLength int, - scratchMode string, + scratchMode ScratchMode, + separateImages []string, ) (SimplifiedDockerfile, error) { content, err := afero.ReadFile(inputFS, filename) if err != nil { @@ -30,5 +31,5 @@ func LoadAndParseDockerfile( } return SimplifiedDockerfile{}, err } - return dockerfileToSimplifiedDockerfile(content, maxLabelLength, scratchMode) + return dockerfileToSimplifiedDockerfile(content, maxLabelLength, scratchMode, separateImages) } diff --git a/internal/dockerfile2dot/load_test.go b/internal/dockerfile2dot/load_test.go index 60a968e..2955e71 100644 --- a/internal/dockerfile2dot/load_test.go +++ b/internal/dockerfile2dot/load_test.go @@ -54,8 +54,9 @@ func TestLoadAndParseDockerfile(t *testing.T) { _, err := LoadAndParseDockerfile( tt.args.inputFS, tt.args.filename, - 20, // Default maxLabelLength - "collapsed", // Default scratchMode + 20, // Default maxLabelLength + ScratchCollapsed, // Default scratchMode + nil, // No separate images ) if (err != nil) != tt.wantErr { t.Errorf("LoadAndParseDockerfile() error = %v, wantErr %v", err, tt.wantErr) diff --git a/internal/dockerfile2dot/structs.go b/internal/dockerfile2dot/structs.go index 1df7641..e08f3ae 100644 --- a/internal/dockerfile2dot/structs.go +++ b/internal/dockerfile2dot/structs.go @@ -32,6 +32,16 @@ type ExternalImage struct { Name string // The original name of the external image } +// ScratchMode controls how scratch base images are rendered in the graph. +type ScratchMode int + +// ScratchMode values control how scratch base images appear in the graph. +const ( + ScratchCollapsed ScratchMode = iota // All scratch references share a single node + ScratchSeparated // Each scratch reference gets its own node + ScratchHidden // Scratch references are omitted from the graph +) + // waitForType represents the type of dependency between stages or images. type waitForType int