From 89f6ca79d9cfe561700ae2db3b1ddd5ecdd6fe19 Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Fri, 6 Jun 2025 03:37:53 +0530 Subject: [PATCH 01/13] add flag to main --- main.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 8017845..3958d44 100644 --- a/main.go +++ b/main.go @@ -53,6 +53,7 @@ func cli() *cobra.Command { var mappingsFile string var updateFlag bool var noBuiltInFlag bool + var multistageFlag bool // Default log level is info var level = slag.Level(slog.LevelInfo) @@ -124,10 +125,11 @@ func cli() *cobra.Command { // Setup conversion options opts := dfc.Options{ - Organization: org, - Registry: registry, - Update: updateFlag, - NoBuiltIn: noBuiltInFlag, + Organization: org, + Registry: registry, + Update: updateFlag, + NoBuiltIn: noBuiltInFlag, + ConvertToMultistage: multistageFlag, } // If custom mappings file is provided, load it as ExtraMappings @@ -214,6 +216,7 @@ func cli() *cobra.Command { cmd.Flags().StringVarP(&mappingsFile, "mappings", "m", "", "path to a custom package mappings YAML file (instead of the default)") cmd.Flags().BoolVar(&updateFlag, "update", false, "check for and apply available updates") cmd.Flags().BoolVar(&noBuiltInFlag, "no-builtin", false, "skip built-in package/image mappings, still apply default conversion logic") + cmd.Flags().BoolVar(&multistageFlag, "multistage", false, "convert single-stage Dockerfiles to secure multistage builds") cmd.Flags().Var(&level, "log-level", "log level (e.g. debug, info, warn, error)") return cmd From 2735fae45d929e206dd6055865b673db7c31e3cc Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Fri, 6 Jun 2025 03:38:11 +0530 Subject: [PATCH 02/13] add multi stage --- pkg/dfc/dfc.go | 175 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 163 insertions(+), 12 deletions(-) diff --git a/pkg/dfc/dfc.go b/pkg/dfc/dfc.go index 703e541..ff61038 100644 --- a/pkg/dfc/dfc.go +++ b/pkg/dfc/dfc.go @@ -467,13 +467,14 @@ type RunLineConverter func(run *RunDetails, converted string, stage int) (string // Options defines the configuration options for the conversion type Options struct { - Organization string - Registry string - ExtraMappings MappingsConfig - Update bool // When true, update cached mappings before conversion - NoBuiltIn bool // When true, don't use built-in mappings, only ExtraMappings - FromLineConverter FromLineConverter // Optional custom converter for FROM lines - RunLineConverter RunLineConverter // Optional custom converter for RUN lines + Organization string + Registry string + ExtraMappings MappingsConfig + Update bool // When true, update cached mappings before conversion + NoBuiltIn bool // When true, don't use built-in mappings, only ExtraMappings + FromLineConverter FromLineConverter // Optional custom converter for FROM lines + RunLineConverter RunLineConverter // Optional custom converter for RUN lines + ConvertToMultistage bool // When true, convert single-stage builds to multistage for security } // MappingsConfig represents the structure of builtin-mappings.yaml @@ -496,6 +497,15 @@ func parseImageReference(imageRef string) (base, tag string) { // Convert applies the conversion to the Dockerfile and returns a new converted Dockerfile func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, error) { + var dockerfileToConvert *Dockerfile = d + if opts.ConvertToMultistage && shouldConvertToMultistage(d.Lines) { + converted, err := convertSingleStageToMultistage(d, opts) + if err != nil { + return nil, fmt.Errorf("converting to multistage: %w", err) + } + dockerfileToConvert = converted + } + // Initialize mappings var mappings MappingsConfig @@ -529,7 +539,7 @@ func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, er // Create a new Dockerfile for the converted content converted := &Dockerfile{ - Lines: make([]*DockerfileLine, len(d.Lines)), + Lines: make([]*DockerfileLine, len(dockerfileToConvert.Lines)), } // Track packages installed per stage @@ -540,13 +550,13 @@ func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, er argsUsedAsBase := make(map[string]bool) // Track stages with RUN commands for determining if we need -dev suffix - stagesWithRunCommands := detectStagesWithRunCommands(d.Lines) + stagesWithRunCommands := detectStagesWithRunCommands(dockerfileToConvert.Lines) // First pass: collect all ARG definitions and identify which ones are used as base images - identifyArgsUsedAsBaseImages(d.Lines, argNameToDockerfileLine, argsUsedAsBase) + identifyArgsUsedAsBaseImages(dockerfileToConvert.Lines, argNameToDockerfileLine, argsUsedAsBase) // Convert each line - for i, line := range d.Lines { + for i, line := range dockerfileToConvert.Lines { // Create a deep copy of the line newLine := &DockerfileLine{ Raw: line.Raw, @@ -581,7 +591,7 @@ func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, er FromLineConverter: opts.FromLineConverter, RunLineConverter: opts.RunLineConverter, } - argLine, argDetails := convertArgLine(line.Arg, d.Lines, stagesWithRunCommands, optsWithMappings) + argLine, argDetails := convertArgLine(line.Arg, dockerfileToConvert.Lines, stagesWithRunCommands, optsWithMappings) newLine.Converted = argLine newLine.Arg = argDetails } @@ -1564,3 +1574,144 @@ func createApkPackageSpec(name string, spec PackageSpec) string { return pkg } + +// shouldConvertToMultistage determines if a single-stage Dockerfile should be converted to multistage +func shouldConvertToMultistage(lines []*DockerfileLine) bool { + stageCount := 0 + hasPackageInstallCommands := false + + for _, line := range lines { + if line.From != nil { + stageCount++ + } + + // Check for RUN commands that contain package manager commands + if line.Run != nil && line.Run.Shell != nil && line.Run.Shell.Before != nil { + for _, part := range line.Run.Shell.Before.Parts { + switch part.Command { + case "apt-get", "apt", "yum", "dnf", "microdnf", "apk", "pip", "pip3": + for _, arg := range part.Args { + if arg == "install" || arg == "add" { + hasPackageInstallCommands = true + break + } + } + } + if hasPackageInstallCommands { + break + } + } + } + + if hasPackageInstallCommands { + break + } + } + + // Convert to multistage if: + // 1. It's a single-stage build (only one FROM) + // 2. It has RUN commands that install packages + return stageCount == 1 && hasPackageInstallCommands +} + +// convertSingleStageToMultistage converts a single-stage Dockerfile to a secure multistage build +func convertSingleStageToMultistage(d *Dockerfile, opts Options) (*Dockerfile, error) { + if len(d.Lines) == 0 { + return d, nil + } + + // Find the FROM line and split the Dockerfile into build and runtime sections + var fromLineIndex int = -1 + var buildLines []*DockerfileLine + var runtimeLines []*DockerfileLine + var copyLines []*DockerfileLine + + inBuildSection := true + + for i, line := range d.Lines { + if line.From != nil { + fromLineIndex = i + buildFromLine := &DockerfileLine{ + Raw: line.Raw, + Extra: line.Extra, + Stage: 1, + From: &FromDetails{ + Base: line.From.Base, + Tag: line.From.Tag, + Digest: line.From.Digest, + Alias: "builder", + Parent: line.From.Parent, + BaseDynamic: line.From.BaseDynamic, + TagDynamic: line.From.TagDynamic, + Orig: line.From.Orig, + }, + } + buildLines = append(buildLines, buildFromLine) + continue + } + + // Categorize lines based on their purpose + if inBuildSection { + if line.Run != nil && line.Run.Manager != "" { + buildLines = append(buildLines, line) + } else if strings.Contains(strings.ToUpper(line.Raw), "COPY") && + !strings.Contains(strings.ToUpper(line.Raw), "--FROM=") { + inBuildSection = false + copyLines = append(copyLines, line) + } else if strings.Contains(strings.ToUpper(line.Raw), "WORKDIR") || + strings.Contains(strings.ToUpper(line.Raw), "ENV") || + strings.Contains(strings.ToUpper(line.Raw), "ARG") { + buildLines = append(buildLines, line) + } else { + buildLines = append(buildLines, line) + } + } else { + runtimeLines = append(runtimeLines, line) + } + } + + // Create the new multistage Dockerfile + var newLines []*DockerfileLine + + newLines = append(newLines, buildLines...) + + if fromLineIndex >= 0 { + originalFrom := d.Lines[fromLineIndex] + + runtimeFromLine := &DockerfileLine{ + Raw: "", + Extra: "\n# Runtime stage with minimal image\n", + Stage: 2, + From: &FromDetails{ + Base: originalFrom.From.Base, + Tag: originalFrom.From.Tag, + Digest: originalFrom.From.Digest, + Alias: "", + Parent: 0, + BaseDynamic: originalFrom.From.BaseDynamic, + TagDynamic: originalFrom.From.TagDynamic, + Orig: originalFrom.From.Orig, + }, + } + newLines = append(newLines, runtimeFromLine) + + // Add COPY --from=builder for built artifacts + if len(copyLines) > 0 { + for _, copyLine := range copyLines { + newCopyLine := &DockerfileLine{ + Raw: strings.Replace(copyLine.Raw, "COPY", "COPY --from=builder", 1), + Extra: copyLine.Extra, + Stage: 2, + } + newLines = append(newLines, newCopyLine) + } + } + + for _, line := range runtimeLines { + line.Stage = 2 + newLines = append(newLines, line) + } + } + + return &Dockerfile{Lines: newLines}, nil +} From 5a65c51079af1df80705b0f77c84b5e6798fb18d Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Fri, 6 Jun 2025 03:38:16 +0530 Subject: [PATCH 03/13] add multi stage test --- pkg/dfc/dfc_test.go | 115 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/pkg/dfc/dfc_test.go b/pkg/dfc/dfc_test.go index b839d70..f88e557 100644 --- a/pkg/dfc/dfc_test.go +++ b/pkg/dfc/dfc_test.go @@ -2218,3 +2218,118 @@ func TestCreateApkPackageSpec(t *testing.T) { }) } } + +func TestConvertToMultistage(t *testing.T) { + tests := []struct { + name string + raw string + convertToMultistage bool + expectedStages int + expectBuilderAlias bool + expectCopyFromBuilder bool + }{ + { + name: "single-stage with package installation converts to multistage", + raw: `FROM python:3.9 +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . +CMD ["python", "app.py"]`, + convertToMultistage: true, + expectedStages: 2, + expectBuilderAlias: true, + expectCopyFromBuilder: true, + }, + { + name: "single-stage without package installation remains single-stage", + raw: `FROM python:3.9 +WORKDIR /app +COPY . . +CMD ["python", "app.py"]`, + convertToMultistage: true, + expectedStages: 1, + expectBuilderAlias: false, + expectCopyFromBuilder: false, + }, + { + name: "multistage conversion disabled keeps original structure", + raw: `FROM python:3.9 +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . +CMD ["python", "app.py"]`, + convertToMultistage: false, + expectedStages: 1, + expectBuilderAlias: false, + expectCopyFromBuilder: false, + }, + { + name: "dockerfile with apt-get converts to multistage", + raw: `FROM ubuntu:20.04 +RUN apt-get update && apt-get install -y python3 +COPY app.py /app/ +CMD ["python3", "/app/app.py"]`, + convertToMultistage: true, + expectedStages: 2, + expectBuilderAlias: true, + expectCopyFromBuilder: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + parsed, err := ParseDockerfile(ctx, []byte(tt.raw)) + if err != nil { + t.Fatalf("Failed to parse Dockerfile: %v", err) + } + + converted, err := parsed.Convert(ctx, Options{ + ConvertToMultistage: tt.convertToMultistage, + ExtraMappings: MappingsConfig{ + Images: map[string]string{ + "python": "python", + "ubuntu": "chainguard-base", + }, + Packages: PackageMap{}, + }, + NoBuiltIn: true, + }) + if err != nil { + t.Fatalf("Failed to convert Dockerfile: %v", err) + } + + // Count stages and check for builder alias and COPY --from=builder + stageCount := 0 + hasBuilderAlias := false + hasCopyFromBuilder := false + + for _, line := range converted.Lines { + if line.From != nil { + stageCount++ + if line.From.Alias == "builder" { + hasBuilderAlias = true + } + } + if strings.Contains(line.Raw, "COPY --from=builder") || strings.Contains(line.Converted, "COPY --from=builder") { + hasCopyFromBuilder = true + } + } + + if stageCount != tt.expectedStages { + t.Errorf("Expected %d stages, got %d", tt.expectedStages, stageCount) + } + + if hasBuilderAlias != tt.expectBuilderAlias { + t.Errorf("Expected builder alias: %v, got: %v", tt.expectBuilderAlias, hasBuilderAlias) + } + + if hasCopyFromBuilder != tt.expectCopyFromBuilder { + t.Errorf("Expected COPY --from=builder: %v, got: %v", tt.expectCopyFromBuilder, hasCopyFromBuilder) + } + }) + } +} From 62e2fd5a962753d64cfa695df502cbc6cb912958 Mon Sep 17 00:00:00 2001 From: Rajiv Singh Date: Fri, 6 Jun 2025 03:54:36 +0530 Subject: [PATCH 04/13] fix lint Signed-off-by: Rajiv Singh --- pkg/dfc/dfc.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/dfc/dfc.go b/pkg/dfc/dfc.go index 3666ce6..064ce65 100644 --- a/pkg/dfc/dfc.go +++ b/pkg/dfc/dfc.go @@ -498,7 +498,7 @@ type Options struct { FromLineConverter FromLineConverter // Optional custom converter for FROM lines RunLineConverter RunLineConverter // Optional custom converter for RUN lines ConvertToMultistage bool // When true, convert single-stage builds to multistage for security - Strict bool // When true, fail if any package is unknown + Strict bool // When true, fail if any package is unknown } // MappingsConfig represents the structure of builtin-mappings.yaml @@ -521,7 +521,7 @@ func parseImageReference(imageRef string) (base, tag string) { // Convert applies the conversion to the Dockerfile and returns a new converted Dockerfile func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, error) { - var dockerfileToConvert *Dockerfile = d + dockerfileToConvert := d if opts.ConvertToMultistage && shouldConvertToMultistage(d.Lines) { converted, err := convertSingleStageToMultistage(d, opts) if err != nil { @@ -1666,7 +1666,7 @@ func convertSingleStageToMultistage(d *Dockerfile, opts Options) (*Dockerfile, e } // Find the FROM line and split the Dockerfile into build and runtime sections - var fromLineIndex int = -1 + fromLineIndex := -1 var buildLines []*DockerfileLine var runtimeLines []*DockerfileLine var copyLines []*DockerfileLine From d3471fe396bc858c7beaa257aa968952f042498c Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Tue, 10 Jun 2025 05:48:07 +0530 Subject: [PATCH 05/13] add test for multi stage --- pkg/dfc/dfc_test.go | 53 +++++++++++++++++++- testdata/multistage-alpine.after.Dockerfile | 15 ++++++ testdata/multistage-alpine.before.Dockerfile | 15 ++++++ testdata/multistage-single.after.Dockerfile | 16 ++++++ testdata/multistage-single.before.Dockerfile | 17 +++++++ 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 testdata/multistage-alpine.after.Dockerfile create mode 100644 testdata/multistage-alpine.before.Dockerfile create mode 100644 testdata/multistage-single.after.Dockerfile create mode 100644 testdata/multistage-single.before.Dockerfile diff --git a/pkg/dfc/dfc_test.go b/pkg/dfc/dfc_test.go index 77bcf81..bd7d10a 100644 --- a/pkg/dfc/dfc_test.go +++ b/pkg/dfc/dfc_test.go @@ -1197,8 +1197,15 @@ func TestFullFileConversion(t *testing.T) { t.Fatalf("Failed to find test files: %v", err) } + var filteredFiles []string + for _, file := range beforeFiles { + if !strings.Contains(filepath.Base(file), "multistage-") { + filteredFiles = append(filteredFiles, file) + } + } + // Test each file - for _, beforeFile := range beforeFiles { + for _, beforeFile := range filteredFiles { name := strings.Split(filepath.Base(beforeFile), ".")[0] t.Run(name, func(t *testing.T) { ctx := context.Background() @@ -2495,3 +2502,47 @@ CMD ["python3", "/app/app.py"]`, }) } } + +// TestMultistageFileConversion tests full file conversion with multistage option enabled +func TestMultistageFileConversion(t *testing.T) { + beforeFiles, err := filepath.Glob("../../testdata/multistage-*.before.Dockerfile") + if err != nil { + t.Fatalf("Failed to find multistage test files: %v", err) + } + + for _, beforeFile := range beforeFiles { + name := strings.Split(filepath.Base(beforeFile), ".")[0] + t.Run(name, func(t *testing.T) { + ctx := context.Background() + + before, err := os.ReadFile(beforeFile) + if err != nil { + t.Fatalf("Failed to read input file: %v", err) + } + + afterFile := strings.Replace(beforeFile, ".before.", ".after.", 1) + after, err := os.ReadFile(afterFile) + if err != nil { + t.Fatalf("Failed to read expected output file: %v", err) + } + + orig, err := ParseDockerfile(ctx, before) + if err != nil { + t.Fatalf("Failed to parse Dockerfile: %v", err) + } + converted, err := orig.Convert(ctx, Options{ + ConvertToMultistage: true, + }) + if err != nil { + t.Fatalf("Failed to convert Dockerfile: %v", err) + } + + got := converted.String() + want := string(after) + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("multistage conversion not as expected (-want, +got):\n%s", diff) + } + }) + } +} diff --git a/testdata/multistage-alpine.after.Dockerfile b/testdata/multistage-alpine.after.Dockerfile new file mode 100644 index 0000000..a9b4a0b --- /dev/null +++ b/testdata/multistage-alpine.after.Dockerfile @@ -0,0 +1,15 @@ +FROM cgr.dev/ORG/chainguard-base:latest AS builder +USER root + +RUN apk add --no-cache git nodejs npm + +FROM cgr.dev/ORG/chainguard-base:latest + +COPY --from=builder package.json package-lock.json ./ +RUN npm ci --only=production + +COPY src/ ./src/ +COPY public/ ./public/ + +EXPOSE 3000 +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/testdata/multistage-alpine.before.Dockerfile b/testdata/multistage-alpine.before.Dockerfile new file mode 100644 index 0000000..57cba10 --- /dev/null +++ b/testdata/multistage-alpine.before.Dockerfile @@ -0,0 +1,15 @@ +FROM alpine:3.18 + +RUN apk add --no-cache \ + nodejs \ + npm \ + git + +COPY package.json package-lock.json ./ +RUN npm ci --only=production + +COPY src/ ./src/ +COPY public/ ./public/ + +EXPOSE 3000 +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/testdata/multistage-single.after.Dockerfile b/testdata/multistage-single.after.Dockerfile new file mode 100644 index 0000000..eef8c19 --- /dev/null +++ b/testdata/multistage-single.after.Dockerfile @@ -0,0 +1,16 @@ +FROM cgr.dev/ORG/chainguard-base:latest AS builder +USER root + +RUN apk add --no-cache curl git py3-pip python-3 + +FROM cgr.dev/ORG/chainguard-base:latest + +COPY --from=builder requirements.txt /app/requirements.txt +COPY app.py /app/app.py +COPY static/ /app/static/ + +WORKDIR /app +RUN pip3 install -r requirements.txt + +EXPOSE 8000 +CMD ["python3", "app.py"] \ No newline at end of file diff --git a/testdata/multistage-single.before.Dockerfile b/testdata/multistage-single.before.Dockerfile new file mode 100644 index 0000000..252e89c --- /dev/null +++ b/testdata/multistage-single.before.Dockerfile @@ -0,0 +1,17 @@ +FROM ubuntu:20.04 + +RUN apt-get update && apt-get install -y \ + python3 \ + python3-pip \ + curl \ + git + +COPY requirements.txt /app/requirements.txt +COPY app.py /app/app.py +COPY static/ /app/static/ + +WORKDIR /app +RUN pip3 install -r requirements.txt + +EXPOSE 8000 +CMD ["python3", "app.py"] \ No newline at end of file From 8b74b5096acd642652ae333240deee8105418dcc Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Fri, 13 Jun 2025 02:25:00 +0530 Subject: [PATCH 06/13] add mission npm --- testdata/multistage-alpine.after.Dockerfile | 2 ++ testdata/multistage-alpine.before.Dockerfile | 14 ++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/testdata/multistage-alpine.after.Dockerfile b/testdata/multistage-alpine.after.Dockerfile index a9b4a0b..18658df 100644 --- a/testdata/multistage-alpine.after.Dockerfile +++ b/testdata/multistage-alpine.after.Dockerfile @@ -5,6 +5,8 @@ RUN apk add --no-cache git nodejs npm FROM cgr.dev/ORG/chainguard-base:latest +RUN apk add --no-cache nodejs npm + COPY --from=builder package.json package-lock.json ./ RUN npm ci --only=production diff --git a/testdata/multistage-alpine.before.Dockerfile b/testdata/multistage-alpine.before.Dockerfile index 57cba10..18658df 100644 --- a/testdata/multistage-alpine.before.Dockerfile +++ b/testdata/multistage-alpine.before.Dockerfile @@ -1,11 +1,13 @@ -FROM alpine:3.18 +FROM cgr.dev/ORG/chainguard-base:latest AS builder +USER root -RUN apk add --no-cache \ - nodejs \ - npm \ - git +RUN apk add --no-cache git nodejs npm -COPY package.json package-lock.json ./ +FROM cgr.dev/ORG/chainguard-base:latest + +RUN apk add --no-cache nodejs npm + +COPY --from=builder package.json package-lock.json ./ RUN npm ci --only=production COPY src/ ./src/ From 8816363148f4984719a35b070ef95af762b7b218 Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Fri, 13 Jun 2025 02:28:54 +0530 Subject: [PATCH 07/13] add generic struct --- pkg/dfc/dfc.go | 59 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/pkg/dfc/dfc.go b/pkg/dfc/dfc.go index 064ce65..3a1f14e 100644 --- a/pkg/dfc/dfc.go +++ b/pkg/dfc/dfc.go @@ -523,7 +523,12 @@ func parseImageReference(imageRef string) (base, tag string) { func (d *Dockerfile) Convert(ctx context.Context, opts Options) (*Dockerfile, error) { dockerfileToConvert := d if opts.ConvertToMultistage && shouldConvertToMultistage(d.Lines) { - converted, err := convertSingleStageToMultistage(d, opts) + converted, err := convertSingleStageToMultistageGeneric(d, MultistageOptions{ + BuildAlias: "builder", + RuntimeAlias: "", + PreserveAliases: true, + CopyStrategy: DefaultCopyStrategy, + }) if err != nil { return nil, fmt.Errorf("converting to multistage: %w", err) } @@ -1659,23 +1664,51 @@ func shouldConvertToMultistage(lines []*DockerfileLine) bool { return stageCount == 1 && hasPackageInstallCommands } -// convertSingleStageToMultistage converts a single-stage Dockerfile to a secure multistage build -func convertSingleStageToMultistage(d *Dockerfile, opts Options) (*Dockerfile, error) { +// MultistageOptions allows customization of multistage conversion +type MultistageOptions struct { + BuildAlias string + RuntimeAlias string + PreserveAliases bool + CopyStrategy func(line string, buildAlias string) string +} + +// DefaultCopyStrategy adds --from=buildAlias if not present +func DefaultCopyStrategy(line string, buildAlias string) string { + if strings.Contains(line, "--from=") { + return line + } + if strings.HasPrefix(strings.TrimSpace(line), "COPY ") { + return strings.Replace(line, "COPY ", "COPY --from="+buildAlias+" ", 1) + } + return line +} + +func convertSingleStageToMultistageGeneric( + d *Dockerfile, + opts MultistageOptions, +) (*Dockerfile, error) { if len(d.Lines) == 0 { return d, nil } - // Find the FROM line and split the Dockerfile into build and runtime sections fromLineIndex := -1 var buildLines []*DockerfileLine var runtimeLines []*DockerfileLine var copyLines []*DockerfileLine inBuildSection := true + buildAlias := opts.BuildAlias + if buildAlias == "" { + buildAlias = "builder" + } for i, line := range d.Lines { if line.From != nil { fromLineIndex = i + alias := buildAlias + if opts.PreserveAliases && line.From.Alias != "" { + alias = line.From.Alias + } buildFromLine := &DockerfileLine{ Raw: line.Raw, Extra: line.Extra, @@ -1684,18 +1717,18 @@ func convertSingleStageToMultistage(d *Dockerfile, opts Options) (*Dockerfile, e Base: line.From.Base, Tag: line.From.Tag, Digest: line.From.Digest, - Alias: "builder", + Alias: alias, Parent: line.From.Parent, BaseDynamic: line.From.BaseDynamic, TagDynamic: line.From.TagDynamic, Orig: line.From.Orig, + Platform: line.From.Platform, }, } buildLines = append(buildLines, buildFromLine) continue } - // Categorize lines based on their purpose if inBuildSection { if line.Run != nil && line.Run.Manager != "" { buildLines = append(buildLines, line) @@ -1715,36 +1748,36 @@ func convertSingleStageToMultistage(d *Dockerfile, opts Options) (*Dockerfile, e } } - // Create the new multistage Dockerfile var newLines []*DockerfileLine - newLines = append(newLines, buildLines...) if fromLineIndex >= 0 { originalFrom := d.Lines[fromLineIndex] - runtimeFromLine := &DockerfileLine{ Raw: "", - Extra: "\n# Runtime stage with minimal image\n", Stage: 2, From: &FromDetails{ Base: originalFrom.From.Base, Tag: originalFrom.From.Tag, Digest: originalFrom.From.Digest, - Alias: "", + Alias: opts.RuntimeAlias, Parent: 0, BaseDynamic: originalFrom.From.BaseDynamic, TagDynamic: originalFrom.From.TagDynamic, Orig: originalFrom.From.Orig, + Platform: originalFrom.From.Platform, }, } newLines = append(newLines, runtimeFromLine) - // Add COPY --from=builder for built artifacts if len(copyLines) > 0 { for _, copyLine := range copyLines { + newCopyRaw := copyLine.Raw + if opts.CopyStrategy != nil { + newCopyRaw = opts.CopyStrategy(copyLine.Raw, buildAlias) + } newCopyLine := &DockerfileLine{ - Raw: strings.Replace(copyLine.Raw, "COPY", "COPY --from=builder", 1), + Raw: newCopyRaw, Extra: copyLine.Extra, Stage: 2, } From 339199be2f7ad2430f1cd80ac0c54910c61e4a9c Mon Sep 17 00:00:00 2001 From: iamrajiv Date: Fri, 13 Jun 2025 02:29:18 +0530 Subject: [PATCH 08/13] add generic struct tests --- testdata/multistage-alpine.after.Dockerfile | 6 ++++-- testdata/multistage-single.after.Dockerfile | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/testdata/multistage-alpine.after.Dockerfile b/testdata/multistage-alpine.after.Dockerfile index 18658df..9517030 100644 --- a/testdata/multistage-alpine.after.Dockerfile +++ b/testdata/multistage-alpine.after.Dockerfile @@ -3,14 +3,16 @@ USER root RUN apk add --no-cache git nodejs npm -FROM cgr.dev/ORG/chainguard-base:latest +FROM cgr.dev/ORG/chainguard-base:latest AS builder RUN apk add --no-cache nodejs npm COPY --from=builder package.json package-lock.json ./ RUN npm ci --only=production +FROM cgr.dev/ORG/chainguard-base:latest +USER root -COPY src/ ./src/ +COPY --from=builder src/ ./src/ COPY public/ ./public/ EXPOSE 3000 diff --git a/testdata/multistage-single.after.Dockerfile b/testdata/multistage-single.after.Dockerfile index eef8c19..91e6b08 100644 --- a/testdata/multistage-single.after.Dockerfile +++ b/testdata/multistage-single.after.Dockerfile @@ -2,7 +2,6 @@ FROM cgr.dev/ORG/chainguard-base:latest AS builder USER root RUN apk add --no-cache curl git py3-pip python-3 - FROM cgr.dev/ORG/chainguard-base:latest COPY --from=builder requirements.txt /app/requirements.txt From 9ca032996c87ea68b2e4bd3cfa451b92308a4266 Mon Sep 17 00:00:00 2001 From: Rajiv Singh Date: Fri, 13 Jun 2025 02:32:47 +0530 Subject: [PATCH 09/13] Update dfc.go Signed-off-by: Rajiv Singh --- pkg/dfc/dfc.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/dfc/dfc.go b/pkg/dfc/dfc.go index a3076ab..d6ff8a2 100644 --- a/pkg/dfc/dfc.go +++ b/pkg/dfc/dfc.go @@ -1690,6 +1690,7 @@ func DefaultCopyStrategy(line string, buildAlias string) string { return line } +// convertSingleStageToMultistageGeneric conveerts a single-stage Dockerfile to a multistage build func convertSingleStageToMultistageGeneric( d *Dockerfile, opts MultistageOptions, From 93c78372de22907c75f6ba3bfa9151868b397c77 Mon Sep 17 00:00:00 2001 From: Rajiv Singh Date: Fri, 13 Jun 2025 02:33:10 +0530 Subject: [PATCH 10/13] fix typo Signed-off-by: Rajiv Singh --- pkg/dfc/dfc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/dfc/dfc.go b/pkg/dfc/dfc.go index d6ff8a2..f1f9d4c 100644 --- a/pkg/dfc/dfc.go +++ b/pkg/dfc/dfc.go @@ -1690,7 +1690,7 @@ func DefaultCopyStrategy(line string, buildAlias string) string { return line } -// convertSingleStageToMultistageGeneric conveerts a single-stage Dockerfile to a multistage build +// convertSingleStageToMultistageGeneric converts a single-stage Dockerfile to a multistage build func convertSingleStageToMultistageGeneric( d *Dockerfile, opts MultistageOptions, From 2d9ff18d538e4e62c3ee4235d02e1dcd046c7f45 Mon Sep 17 00:00:00 2001 From: Rajiv Singh Date: Fri, 4 Jul 2025 11:32:04 +0530 Subject: [PATCH 11/13] Update multistage-single.after.Dockerfile Signed-off-by: Rajiv Singh --- testdata/multistage-single.after.Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/testdata/multistage-single.after.Dockerfile b/testdata/multistage-single.after.Dockerfile index 91e6b08..4d539e7 100644 --- a/testdata/multistage-single.after.Dockerfile +++ b/testdata/multistage-single.after.Dockerfile @@ -1,7 +1,7 @@ FROM cgr.dev/ORG/chainguard-base:latest AS builder USER root -RUN apk add --no-cache curl git py3-pip python-3 +RUN apk add --no-cache curl git py3-pip python-3 python3-venv FROM cgr.dev/ORG/chainguard-base:latest COPY --from=builder requirements.txt /app/requirements.txt @@ -9,7 +9,9 @@ COPY app.py /app/app.py COPY static/ /app/static/ WORKDIR /app +RUN python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" RUN pip3 install -r requirements.txt EXPOSE 8000 -CMD ["python3", "app.py"] \ No newline at end of file +CMD ["python3", "app.py"] From 59fea819221e4a9b992dcfcd0f191764a6596cf1 Mon Sep 17 00:00:00 2001 From: Rajiv Singh Date: Fri, 4 Jul 2025 11:32:16 +0530 Subject: [PATCH 12/13] Update multistage-single.before.Dockerfile Signed-off-by: Rajiv Singh --- testdata/multistage-single.before.Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/testdata/multistage-single.before.Dockerfile b/testdata/multistage-single.before.Dockerfile index 252e89c..523f96f 100644 --- a/testdata/multistage-single.before.Dockerfile +++ b/testdata/multistage-single.before.Dockerfile @@ -3,6 +3,7 @@ FROM ubuntu:20.04 RUN apt-get update && apt-get install -y \ python3 \ python3-pip \ + python3-venv \ curl \ git @@ -11,7 +12,9 @@ COPY app.py /app/app.py COPY static/ /app/static/ WORKDIR /app +RUN python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" RUN pip3 install -r requirements.txt EXPOSE 8000 -CMD ["python3", "app.py"] \ No newline at end of file +CMD ["python3", "app.py"] From 04dbe1d512ebeef56d74e3808098e52b7dfdeda1 Mon Sep 17 00:00:00 2001 From: Rajiv Singh Date: Fri, 4 Jul 2025 11:34:24 +0530 Subject: [PATCH 13/13] add testdata Signed-off-by: Rajiv Singh --- testdata/multistage-gcc-static.after.Dockerfile | 14 ++++++++++++++ .../multistage-gcc-static.before.Dockerfile | 17 +++++++++++++++++ testdata/multistage-go-static.after.Dockerfile | 14 ++++++++++++++ testdata/multistage-go-static.before.Dockerfile | 15 +++++++++++++++ .../multistage-node-distroless.after.Dockerfile | 14 ++++++++++++++ ...multistage-node-distroless.before.Dockerfile | 17 +++++++++++++++++ 6 files changed, 91 insertions(+) create mode 100644 testdata/multistage-gcc-static.after.Dockerfile create mode 100644 testdata/multistage-gcc-static.before.Dockerfile create mode 100644 testdata/multistage-go-static.after.Dockerfile create mode 100644 testdata/multistage-go-static.before.Dockerfile create mode 100644 testdata/multistage-node-distroless.after.Dockerfile create mode 100644 testdata/multistage-node-distroless.before.Dockerfile diff --git a/testdata/multistage-gcc-static.after.Dockerfile b/testdata/multistage-gcc-static.after.Dockerfile new file mode 100644 index 0000000..02c496d --- /dev/null +++ b/testdata/multistage-gcc-static.after.Dockerfile @@ -0,0 +1,14 @@ +FROM cgr.dev/ORG/chainguard-base:latest AS builder +USER root + +RUN apk add --no-cache curl gcc git glibc-dev make +FROM cgr.dev/ORG/chainguard-base:latest + +COPY --from=builder hello.c /app/hello.c +COPY Makefile /app/Makefile + +WORKDIR /app +RUN gcc -static -o hello hello.c + +EXPOSE 8080 +CMD ["./hello"] \ No newline at end of file diff --git a/testdata/multistage-gcc-static.before.Dockerfile b/testdata/multistage-gcc-static.before.Dockerfile new file mode 100644 index 0000000..68f309a --- /dev/null +++ b/testdata/multistage-gcc-static.before.Dockerfile @@ -0,0 +1,17 @@ +FROM ubuntu:20.04 + +RUN apt-get update && apt-get install -y \ + gcc \ + libc6-dev \ + make \ + curl \ + git + +COPY hello.c /app/hello.c +COPY Makefile /app/Makefile + +WORKDIR /app +RUN gcc -static -o hello hello.c + +EXPOSE 8080 +CMD ["./hello"] \ No newline at end of file diff --git a/testdata/multistage-go-static.after.Dockerfile b/testdata/multistage-go-static.after.Dockerfile new file mode 100644 index 0000000..a23dd04 --- /dev/null +++ b/testdata/multistage-go-static.after.Dockerfile @@ -0,0 +1,14 @@ +FROM cgr.dev/ORG/go:1.21-dev AS builder +USER root + +RUN apk add --no-cache ca-certificates curl git +FROM cgr.dev/ORG/go:1.21-dev + +COPY --from=builder go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + +EXPOSE 8080 +CMD ["./main"] \ No newline at end of file diff --git a/testdata/multistage-go-static.before.Dockerfile b/testdata/multistage-go-static.before.Dockerfile new file mode 100644 index 0000000..359aaaf --- /dev/null +++ b/testdata/multistage-go-static.before.Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.21 + +RUN apt-get update && apt-get install -y \ + git \ + ca-certificates \ + curl + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + +EXPOSE 8080 +CMD ["./main"] \ No newline at end of file diff --git a/testdata/multistage-node-distroless.after.Dockerfile b/testdata/multistage-node-distroless.after.Dockerfile new file mode 100644 index 0000000..0034f2c --- /dev/null +++ b/testdata/multistage-node-distroless.after.Dockerfile @@ -0,0 +1,14 @@ +FROM cgr.dev/ORG/node:18-dev AS builder +USER root + +RUN apk add --no-cache curl gcc git make python-3 +FROM cgr.dev/ORG/node:18-dev + +COPY --from=builder package*.json ./ +RUN npm ci --only=production + +COPY src/ ./src/ +COPY public/ ./public/ + +EXPOSE 3000 +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/testdata/multistage-node-distroless.before.Dockerfile b/testdata/multistage-node-distroless.before.Dockerfile new file mode 100644 index 0000000..d88a8b6 --- /dev/null +++ b/testdata/multistage-node-distroless.before.Dockerfile @@ -0,0 +1,17 @@ +FROM node:18 + +RUN apt-get update && apt-get install -y \ + python3 \ + make \ + g++ \ + git \ + curl + +COPY package*.json ./ +RUN npm ci --only=production + +COPY src/ ./src/ +COPY public/ ./public/ + +EXPOSE 3000 +CMD ["node", "src/index.js"] \ No newline at end of file