diff --git a/pkg/dockerfile/dockerfile.go b/pkg/dockerfile/dockerfile.go index 51519dc30..8e3f1b6e1 100644 --- a/pkg/dockerfile/dockerfile.go +++ b/pkg/dockerfile/dockerfile.go @@ -22,9 +22,9 @@ import ( "io/ioutil" "net/http" "regexp" - "strconv" "strings" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/sirupsen/logrus" "github.com/GoogleContainerTools/kaniko/pkg/config" @@ -34,60 +34,7 @@ import ( "github.com/pkg/errors" ) -// Stages parses a Dockerfile and returns an array of KanikoStage -func Stages(opts *config.KanikoOptions) ([]config.KanikoStage, error) { - var err error - var d []uint8 - match, _ := regexp.MatchString("^https?://", opts.DockerfilePath) - if match { - response, e := http.Get(opts.DockerfilePath) - if e != nil { - return nil, e - } - d, err = ioutil.ReadAll(response.Body) - } else { - d, err = ioutil.ReadFile(opts.DockerfilePath) - } - - if err != nil { - return nil, errors.Wrap(err, fmt.Sprintf("reading dockerfile at path %s", opts.DockerfilePath)) - } - - stages, metaArgs, err := Parse(d) - if err != nil { - return nil, errors.Wrap(err, "parsing dockerfile") - } - targetStage, err := targetStage(stages, opts.Target) - if err != nil { - return nil, err - } - resolveStages(stages) - var kanikoStages []config.KanikoStage - for index, stage := range stages { - resolvedBaseName, err := util.ResolveEnvironmentReplacement(stage.BaseName, opts.BuildArgs, false) - if err != nil { - return nil, errors.Wrap(err, "resolving base name") - } - stage.Name = resolvedBaseName - logrus.Infof("Resolved base name %s to %s", stage.BaseName, stage.Name) - kanikoStages = append(kanikoStages, config.KanikoStage{ - Stage: stage, - BaseImageIndex: baseImageIndex(index, stages), - BaseImageStoredLocally: (baseImageIndex(index, stages) != -1), - SaveStage: saveStage(index, stages), - Final: index == targetStage, - MetaArgs: metaArgs, - Index: index, - }) - if index == targetStage { - break - } - } - - return kanikoStages, nil -} - -func ParseInstructions(opts *config.KanikoOptions) ([]instructions.Stage, []instructions.ArgCommand, error) { +func ParseStages(opts *config.KanikoOptions) ([]instructions.Stage, []instructions.ArgCommand, error) { var err error var d []uint8 match, _ := regexp.MatchString("^https?://", opts.DockerfilePath) @@ -113,49 +60,6 @@ func ParseInstructions(opts *config.KanikoOptions) ([]instructions.Stage, []inst return stages, metaArgs, nil } -func ResolveCrossStageInstructions(stages []instructions.Stage) map[string]string { - nameToIndex := make(map[string]string) - for i, stage := range stages { - index := strconv.Itoa(i) - if stage.Name != "" { - nameToIndex[stage.Name] = index - } - ResolveCommands(stage.Commands, nameToIndex) - } - - logrus.Debugf("Built stage name to index map: %v", nameToIndex) - return nameToIndex -} - -func MakeKanikoStages(opts *config.KanikoOptions, stages []instructions.Stage, metaArgs []instructions.ArgCommand) ([]config.KanikoStage, error) { - targetStage, err := targetStage(stages, opts.Target) - if err != nil { - return nil, errors.Wrap(err, "Error finding target stage") - } - var kanikoStages []config.KanikoStage - for index, stage := range stages { - resolvedBaseName, err := util.ResolveEnvironmentReplacement(stage.BaseName, opts.BuildArgs, false) - if err != nil { - return nil, errors.Wrap(err, "resolving base name") - } - stage.Name = resolvedBaseName - logrus.Infof("Resolved base name %s to %s", stage.BaseName, stage.Name) - kanikoStages = append(kanikoStages, config.KanikoStage{ - Stage: stage, - BaseImageIndex: baseImageIndex(index, stages), - BaseImageStoredLocally: (baseImageIndex(index, stages) != -1), - SaveStage: saveStage(index, stages), - Final: index == targetStage, - MetaArgs: metaArgs, - Index: index, - }) - if index == targetStage { - break - } - } - return kanikoStages, nil -} - // baseImageIndex returns the index of the stage the current stage is built off // returns -1 if the current stage isn't built off a previous stage func baseImageIndex(currentStage int, stages []instructions.Stage) int { @@ -273,29 +177,6 @@ func targetStage(stages []instructions.Stage, target string) (int, error) { return -1, fmt.Errorf("%s is not a valid target build stage", target) } -// resolveStages resolves any calls to previous stages with names to indices -// Ex. --from=second_stage should be --from=1 for easier processing later on -// As third party library lowers stage name in FROM instruction, this function resolves stage case insensitively. -func resolveStages(stages []instructions.Stage) { - nameToIndex := make(map[string]string) - for i, stage := range stages { - index := strconv.Itoa(i) - if stage.Name != index { - nameToIndex[stage.Name] = index - } - for _, cmd := range stage.Commands { - switch c := cmd.(type) { - case *instructions.CopyCommand: - if c.From != "" { - if val, ok := nameToIndex[strings.ToLower(c.From)]; ok { - c.From = val - } - } - } - } - } -} - // ParseCommands parses an array of commands into an array of instructions.Command; used for onbuild func ParseCommands(cmdArray []string) ([]instructions.Command, error) { var cmds []instructions.Command @@ -333,8 +214,10 @@ func saveStage(index int, stages []instructions.Stage) bool { return false } -// TODO move it to private method or put elsewhere -func ResolveCommands(cmds []instructions.Command, stageNameToIdx map[string]string) () { +// ResolveCrossStageCommands resolves any calls to previous stages with names to indices +// Ex. --from=secondStage should be --from=1 for easier processing later on +// As third party library lowers stage name in FROM instruction, this function resolves stage case insensitively. +func ResolveCrossStageCommands(cmds []instructions.Command, stageNameToIdx map[string]string) { for _, cmd := range cmds { switch c := cmd.(type) { case *instructions.CopyCommand: @@ -345,5 +228,48 @@ func ResolveCommands(cmds []instructions.Command, stageNameToIdx map[string]stri } } } +} -} \ No newline at end of file +func MakeKanikoStages(opts *config.KanikoOptions, stages []instructions.Stage, metaArgs []instructions.ArgCommand) ([]config.KanikoStage, error) { + targetStage, err := targetStage(stages, opts.Target) + if err != nil { + return nil, errors.Wrap(err, "Error finding target stage") + } + var kanikoStages []config.KanikoStage + for index, stage := range stages { + resolvedBaseName, err := util.ResolveEnvironmentReplacement(stage.BaseName, opts.BuildArgs, false) + if err != nil { + return nil, errors.Wrap(err, "resolving base name") + } + stage.Name = resolvedBaseName + logrus.Infof("Resolved base name %s to %s", stage.BaseName, stage.Name) + kanikoStages = append(kanikoStages, config.KanikoStage{ + Stage: stage, + BaseImageIndex: baseImageIndex(index, stages), + BaseImageStoredLocally: (baseImageIndex(index, stages) != -1), + SaveStage: saveStage(index, stages), + Final: index == targetStage, + MetaArgs: metaArgs, + Index: index, + }) + if index == targetStage { + break + } + } + return kanikoStages, nil +} + +func GetOnBuildInstructions(config *v1.Config, stageNameToIdx map[string]string) ([]instructions.Command, error) { + if config.OnBuild == nil || len(config.OnBuild) == 0 { + return nil, nil + } + + cmds, err := ParseCommands(config.OnBuild) + if err != nil { + return nil, err + } + + // Iterate over commands and replace references to other stages with their index + ResolveCrossStageCommands(cmds, stageNameToIdx) + return cmds, nil +} diff --git a/pkg/dockerfile/dockerfile_test.go b/pkg/dockerfile/dockerfile_test.go index 256cd9dc4..334b8008b 100644 --- a/pkg/dockerfile/dockerfile_test.go +++ b/pkg/dockerfile/dockerfile_test.go @@ -19,18 +19,19 @@ package dockerfile import ( "io/ioutil" "os" - "strconv" + "reflect" "testing" "github.com/GoogleContainerTools/kaniko/pkg/config" "github.com/GoogleContainerTools/kaniko/testutil" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/moby/buildkit/frontend/dockerfile/instructions" - "github.com/sirupsen/logrus" ) -func Test_Stages_ArgValueWithQuotes(t *testing.T) { +func Test_ParseStages_ArgValueWithQuotes(t *testing.T) { dockerfile := ` ARG IMAGE="ubuntu:16.04" + ARG FOO=bar FROM ${IMAGE} RUN echo hi > /hi @@ -54,25 +55,23 @@ func Test_Stages_ArgValueWithQuotes(t *testing.T) { t.Fatal(err) } - stages, err := Stages(&config.KanikoOptions{DockerfilePath: tmpfile.Name()}) + stages, metaArgs, err := ParseStages(&config.KanikoOptions{DockerfilePath: tmpfile.Name()}) if err != nil { t.Fatal(err) } if len(stages) == 0 { t.Fatal("length of stages expected to be greater than zero, but was zero") - } - if len(stages[0].MetaArgs) == 0 { - t.Fatal("length of stage[0] meta args expected to be greater than zero, but was zero") + if len(metaArgs) != 2 { + t.Fatalf("length of stage meta args expected to be 2, but was %d", len(metaArgs)) } - expectedVal := "ubuntu:16.04" - - arg := stages[0].MetaArgs[0] - if arg.ValueString() != expectedVal { - t.Fatalf("expected stages[0].MetaArgs[0] val to be %s but was %s", expectedVal, arg.ValueString()) + for i, expectedVal := range []string{"ubuntu:16.04", "bar"} { + if metaArgs[i].ValueString() != expectedVal { + t.Fatalf("expected metaArg %d val to be %s but was %s", i, expectedVal, metaArgs[i].ValueString()) + } } } @@ -190,40 +189,103 @@ func Test_stripEnclosingQuotes(t *testing.T) { } } -func Test_resolveStages(t *testing.T) { - dockerfile := ` - FROM scratch - RUN echo hi > /hi - - FROM scratch AS second - COPY --from=0 /hi /hi2 - - FROM scratch AS tHiRd - COPY --from=second /hi2 /hi3 - COPY --from=1 /hi2 /hi3 - - FROM scratch - COPY --from=thIrD /hi3 /hi4 - COPY --from=third /hi3 /hi4 - COPY --from=2 /hi3 /hi4 - ` - stages, _, err := Parse([]byte(dockerfile)) - if err != nil { - t.Fatal(err) +func Test_ResolveCrossStageCommands(t *testing.T) { + type testCase struct { + name string + cmd instructions.CopyCommand + stageToIdx map[string]string + expFrom string } - resolveStages(stages) - for index, stage := range stages { - if index == 0 { - continue - } - expectedStage := strconv.Itoa(index - 1) - for _, command := range stage.Commands { - copyCmd := command.(*instructions.CopyCommand) - if copyCmd.From != expectedStage { - t.Fatalf("unexpected copy command: %s resolved to stage %s, expected %s", copyCmd.String(), copyCmd.From, expectedStage) - } - } + tests := []testCase{ + { + name: "resolve copy command", + cmd: instructions.CopyCommand{From: "builder"}, + stageToIdx: map[string]string{"builder": "0"}, + expFrom: "0", + }, + { + name: "resolve upper case FROM", + cmd: instructions.CopyCommand{From: "BuIlDeR"}, + stageToIdx: map[string]string{"builder": "0"}, + expFrom: "0", + }, + { + name: "nothing to resolve", + cmd: instructions.CopyCommand{From: "downloader"}, + stageToIdx: map[string]string{"builder": "0"}, + expFrom: "downloader", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmds := []instructions.Command{&test.cmd} + ResolveCrossStageCommands(cmds, test.stageToIdx) + if test.cmd.From != test.expFrom { + t.Fatalf("Failed to resolve command: expected from %s, resolved to %s", test.expFrom, test.cmd.From) + } + }) + } +} + +func Test_GetOnBuildInstructions(t *testing.T) { + type testCase struct { + name string + cfg *v1.Config + stageToIdx map[string]string + expCommands []instructions.Command + } + + tests := []testCase{ + {name: "no on-build on config", + cfg: &v1.Config{}, + stageToIdx: map[string]string{"builder": "0"}, + expCommands: nil, + }, + {name: "onBuild on config, nothing to resolve", + cfg: &v1.Config{OnBuild: []string{"WORKDIR /app"}}, + stageToIdx: map[string]string{"builder": "0", "temp": "1"}, + expCommands: []instructions.Command{&instructions.WorkdirCommand{Path: "/app"}}, + }, + {name: "onBuild on config, resolve multiple stages", + cfg: &v1.Config{OnBuild: []string{"COPY --from=builder a.txt b.txt", "COPY --from=temp /app /app"}}, + stageToIdx: map[string]string{"builder": "0", "temp": "1"}, + expCommands: []instructions.Command{ + &instructions.CopyCommand{ + SourcesAndDest: []string{"a.txt b.txt"}, + From: "0", + }, + &instructions.CopyCommand{ + SourcesAndDest: []string{"/app /app"}, + From: "1", + }, + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmds, err := GetOnBuildInstructions(test.cfg, test.stageToIdx) + if err != nil { + t.Fatalf("Failed to parse config for on-build instructions") + } + if len(cmds) != len(test.expCommands) { + t.Fatalf("Expected %d commands, got %d", len(test.expCommands), len(cmds)) + } + + for i, cmd := range cmds { + if reflect.TypeOf(cmd) != reflect.TypeOf(test.expCommands[i]) { + t.Fatalf("Got command %s, expected %s", cmd, test.expCommands[i]) + } + switch c := cmd.(type) { + case *instructions.CopyCommand: + { + exp := test.expCommands[i].(*instructions.CopyCommand) + testutil.CheckDeepEqual(t, exp.From, c.From) + } + } + } + }) } } @@ -365,30 +427,3 @@ func Test_baseImageIndex(t *testing.T) { }) } } - - - -func Test_ResolveStages(t *testing.T) { - in := &instructions.CopyCommand{ - SourcesAndDest: []string{ - "/var/bo", "foo.txt", - }, - From: "boo", - Chown: "", - } - ibn := &instructions.CopyCommand{ - SourcesAndDest: []string{ - "/var/bo", "foo.txt", - }, - From: "poo", - Chown: "", - } - - foo := []instructions.Command{in, ibn} - stageMap := map[string]string{"boo": "1"} - logrus.Infof("%#v", foo) - ResolveCommands(foo, stageMap) - logrus.Infof("%#v", foo) - logrus.Infof("%#v", foo[0]) - -} \ No newline at end of file diff --git a/pkg/executor/build.go b/pkg/executor/build.go index e4c3a2797..015466bf7 100644 --- a/pkg/executor/build.go +++ b/pkg/executor/build.go @@ -52,6 +52,11 @@ import ( // This is the size of an empty tar in Go const emptyTarSize = 1024 +// for testing +var ( + initializeConfig = initConfig +) + type cachePusher func(*config.KanikoOptions, string, string, string) error type snapShotter interface { Init() error @@ -136,7 +141,7 @@ func newStageBuilder(opts *config.KanikoOptions, stage config.KanikoStage, cross return s, nil } -func initializeConfig(img partial.WithConfigFile, opts *config.KanikoOptions) (*v1.ConfigFile, error) { +func initConfig(img partial.WithConfigFile, opts *config.KanikoOptions) (*v1.ConfigFile, error) { imageConfig, err := img.ConfigFile() if err != nil { return nil, err @@ -510,10 +515,10 @@ func CalculateDependencies(stages []config.KanikoStage, opts *config.KanikoOptio return nil, err } - cmds, err := getOnBuildInstructions(&cfg.Config, stageNameToIdx) - stageCmds := append(cmds, s.Commands...) + cmds, err := dockerfile.GetOnBuildInstructions(&cfg.Config, stageNameToIdx) + cmds = append(cmds, s.Commands...) - for _, c := range stageCmds { + for _, c := range cmds { switch cmd := c.(type) { case *instructions.CopyCommand: if cmd.From != "" { @@ -554,25 +559,13 @@ func DoBuild(opts *config.KanikoOptions) (v1.Image, error) { digestToCacheKey := make(map[string]string) stageIdxToDigest := make(map[string]string) - //* dani plan - // 1. parse opts - to []instruction.Stages - // 2. resolve environment - get map stageidx-->name - // 3. convert []instruction.Stages to []config.KanikoStages - // 4. pass to newStageBuilder() the map to resolve on build - //*/ - - // Parse dockerfile - //stages, err := dockerfile.Stages(opts) - //if err != nil { - // return nil, err - //} - instrct, metaArgs, err := dockerfile.ParseInstructions(opts) + stages, metaArgs, err := dockerfile.ParseStages(opts) if err != nil { return nil, err } - stageNameToIdx := dockerfile.ResolveCrossStageInstructions(instrct) + stageNameToIdx := ResolveCrossStageInstructions(stages) - stages, err := dockerfile.MakeKanikoStages(opts, instrct, metaArgs) + kanikoStages, err := dockerfile.MakeKanikoStages(opts, stages, metaArgs) if err != nil { return nil, err } @@ -582,16 +575,16 @@ func DoBuild(opts *config.KanikoOptions) (v1.Image, error) { } // Some stages may refer to other random images, not previous stages - if err := fetchExtraStages(stages, opts); err != nil { + if err := fetchExtraStages(kanikoStages, opts); err != nil { return nil, err } - crossStageDependencies, err := CalculateDependencies(stages, opts, stageNameToIdx) + crossStageDependencies, err := CalculateDependencies(kanikoStages, opts, stageNameToIdx) if err != nil { return nil, err } logrus.Infof("Built cross stage deps: %v", crossStageDependencies) - for index, stage := range stages { + for index, stage := range kanikoStages { sb, err := newStageBuilder(opts, stage, crossStageDependencies, digestToCacheKey, stageIdxToDigest, stageNameToIdx) if err != nil { return nil, err @@ -792,23 +785,8 @@ func getHasher(snapshotMode string) (func(string) (string, error), error) { return nil, fmt.Errorf("%s is not a valid snapshot mode", snapshotMode) } -func getOnBuildInstructions(config *v1.Config, stageNameToIdx map[string]string) ([]instructions.Command, error) { - if config.OnBuild == nil || len(config.OnBuild) == 0 { - return nil, nil - } - - cmds, err := dockerfile.ParseCommands(config.OnBuild) - if err != nil { - return nil, err - } - - // Iterate over commands and replace references to other stages with their index - dockerfile.ResolveCommands(cmds, stageNameToIdx) - return cmds, nil -} - func resolveOnBuild(stage *config.KanikoStage, config *v1.Config, stageNameToIdx map[string]string) error { - cmds, err := getOnBuildInstructions(config, stageNameToIdx) + cmds, err := dockerfile.GetOnBuildInstructions(config, stageNameToIdx) if err != nil { return err } @@ -841,3 +819,19 @@ func reviewConfig(stage config.KanikoStage, config *v1.Config) { config.Cmd = nil } } + +// iterates over a list of stages and resolves instructions referring to earlier stages +// returns a mapping of stage name to stage id, f.e - ["first": "0", "second": "1", "target": "2"] +func ResolveCrossStageInstructions(stages []instructions.Stage) map[string]string { + nameToIndex := make(map[string]string) + for i, stage := range stages { + index := strconv.Itoa(i) + if stage.Name != "" { + nameToIndex[stage.Name] = index + } + dockerfile.ResolveCrossStageCommands(stage.Commands, nameToIndex) + } + + logrus.Debugf("Built stage name to index map: %v", nameToIndex) + return nameToIndex +} diff --git a/pkg/executor/build_test.go b/pkg/executor/build_test.go index 344e2611b..ff97aa03e 100644 --- a/pkg/executor/build_test.go +++ b/pkg/executor/build_test.go @@ -25,6 +25,7 @@ import ( "path/filepath" "reflect" "sort" + "strconv" "testing" "github.com/GoogleContainerTools/kaniko/pkg/commands" @@ -35,6 +36,7 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/partial" "github.com/moby/buildkit/frontend/dockerfile/instructions" ) @@ -194,7 +196,8 @@ func Test_stageBuilder_shouldTakeSnapshot(t *testing.T) { func TestCalculateDependencies(t *testing.T) { type args struct { - dockerfile string + dockerfile string + mockInitConfig func(partial.WithConfigFile, *config.KanikoOptions) (*v1.ConfigFile, error) } tests := []struct { name string @@ -314,19 +317,58 @@ COPY --from=stage2 /bar /bat 1: {"/bar"}, }, }, + { + name: "one image has onbuild config", + args: args{ + mockInitConfig: func(img partial.WithConfigFile, opts *config.KanikoOptions) (*v1.ConfigFile, error) { + cfg, err := img.ConfigFile() + // if image is "alpine" then add ONBUILD to its config + if cfg != nil && cfg.Architecture != "" { + cfg.Config.OnBuild = []string{"COPY --from=builder /app /app"} + } + return cfg, err + }, + dockerfile: ` +FROM scratch as builder +RUN foo +FROM alpine as second +# This image has an ONBUILD command so it will be executed +COPY --from=builder /foo /bar +FROM scratch as target +COPY --from=second /bar /bat +`, + }, + want: map[int][]string{ + 0: {"/app", "/foo"}, + 1: {"/bar"}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.args.mockInitConfig != nil { + original := initializeConfig + defer func() { initializeConfig = original }() + initializeConfig = tt.args.mockInitConfig + } + f, _ := ioutil.TempFile("", "") ioutil.WriteFile(f.Name(), []byte(tt.args.dockerfile), 0755) opts := &config.KanikoOptions{ DockerfilePath: f.Name(), } - testStages, err := dockerfile.Stages(opts) + testStages, metaArgs, err := dockerfile.ParseStages(opts) if err != nil { t.Errorf("Failed to parse test dockerfile to stages: %s", err) } - got, err := CalculateDependencies(testStages, opts) + + stageNameToIdx := ResolveCrossStageInstructions(testStages) + kanikoStages, err := dockerfile.MakeKanikoStages(opts, testStages, metaArgs) + if err != nil { + t.Errorf("Failed to parse stages to Kaniko Stages: %s", err) + } + + got, err := CalculateDependencies(kanikoStages, opts, stageNameToIdx) if err != nil { t.Errorf("got error: %s,", err) } @@ -870,12 +912,16 @@ COPY %s bar.txt DockerfilePath: f.Name(), } - stages, err := dockerfile.Stages(opts) + testStages, metaArgs, err := dockerfile.ParseStages(opts) if err != nil { - t.Errorf("could not parse test dockerfile") + t.Errorf("Failed to parse test dockerfile to stages: %s", err) } - - stage := stages[0] + _ = ResolveCrossStageInstructions(testStages) + kanikoStages, err := dockerfile.MakeKanikoStages(opts, testStages, metaArgs) + if err != nil { + t.Errorf("Failed to parse stages to Kaniko Stages: %s", err) + } + stage := kanikoStages[0] cmds := stage.Commands return testcase{ @@ -941,12 +987,17 @@ COPY %s bar.txt DockerfilePath: f.Name(), } - stages, err := dockerfile.Stages(opts) + testStages, metaArgs, err := dockerfile.ParseStages(opts) if err != nil { - t.Errorf("could not parse test dockerfile") + t.Errorf("Failed to parse test dockerfile to stages: %s", err) + } + _ = ResolveCrossStageInstructions(testStages) + kanikoStages, err := dockerfile.MakeKanikoStages(opts, testStages, metaArgs) + if err != nil { + t.Errorf("Failed to parse stages to Kaniko Stages: %s", err) } - stage := stages[0] + stage := kanikoStages[0] cmds := stage.Commands return testcase{ @@ -1247,3 +1298,42 @@ func hashCompositeKeys(t *testing.T, ck1 CompositeCache, ck2 CompositeCache) (st } return key1, key2 } + +func Test_ResolveCrossStageInstructions(t *testing.T) { + df := ` + FROM scratch + RUN echo hi > /hi + + FROM scratch AS second + COPY --from=0 /hi /hi2 + + FROM scratch AS tHiRd + COPY --from=second /hi2 /hi3 + COPY --from=1 /hi2 /hi3 + + FROM scratch + COPY --from=thIrD /hi3 /hi4 + COPY --from=third /hi3 /hi4 + COPY --from=2 /hi3 /hi4 + ` + stages, _, err := dockerfile.Parse([]byte(df)) + if err != nil { + t.Fatal(err) + } + stageToIdx := ResolveCrossStageInstructions(stages) + for index, stage := range stages { + if index == 0 { + continue + } + expectedStage := strconv.Itoa(index - 1) + for _, command := range stage.Commands { + copyCmd := command.(*instructions.CopyCommand) + if copyCmd.From != expectedStage { + t.Fatalf("unexpected copy command: %s resolved to stage %s, expected %s", copyCmd.String(), copyCmd.From, expectedStage) + } + } + + expectedMap := map[string]string{"second": "1", "third": "2"} + testutil.CheckDeepEqual(t, expectedMap, stageToIdx) + } +}