diff --git a/cmd/executor/cmd/root.go b/cmd/executor/cmd/root.go index 2c8e9b69a..8a03af831 100644 --- a/cmd/executor/cmd/root.go +++ b/cmd/executor/cmd/root.go @@ -23,9 +23,9 @@ import ( "github.com/genuinetools/amicontained/container" - "github.com/GoogleContainerTools/kaniko/pkg/executor" - "github.com/GoogleContainerTools/kaniko/pkg/constants" + "github.com/GoogleContainerTools/kaniko/pkg/executor" + "github.com/GoogleContainerTools/kaniko/pkg/image" "github.com/GoogleContainerTools/kaniko/pkg/util" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -72,7 +72,12 @@ var RootCmd = &cobra.Command{ } logrus.Warn("kaniko is being run outside of a container. This can have dangerous effects on your system") } - if err := executor.DoBuild(dockerfilePath, srcContext, destination, snapshotMode, dockerInsecureSkipTLSVerify); err != nil { + buildImage, err := executor.DoBuild(dockerfilePath, srcContext, snapshotMode) + if err != nil { + logrus.Error(err) + os.Exit(1) + } + if err := image.PushImage(buildImage, destination, dockerInsecureSkipTLSVerify); err != nil { logrus.Error(err) os.Exit(1) } diff --git a/integration_tests/dockerfiles/Dockerfile_test_multistage b/integration_tests/dockerfiles/Dockerfile_test_multistage new file mode 100644 index 000000000..3833e8806 --- /dev/null +++ b/integration_tests/dockerfiles/Dockerfile_test_multistage @@ -0,0 +1,8 @@ +FROM gcr.io/distroless/base:latest +COPY . . + +FROM scratch as second +COPY --from=0 context/foo /foo + +FROM gcr.io/distroless/base:latest +COPY --from=second /foo /foo2 diff --git a/integration_tests/dockerfiles/config_test_multistage.json b/integration_tests/dockerfiles/config_test_multistage.json new file mode 100644 index 000000000..9aa0494cb --- /dev/null +++ b/integration_tests/dockerfiles/config_test_multistage.json @@ -0,0 +1,12 @@ +[ + { + "Image1": "gcr.io/kaniko-test/docker-test-multistage:latest", + "Image2": "gcr.io/kaniko-test/kaniko-test-multistage:latest", + "DiffType": "File", + "Diff": { + "Adds": null, + "Dels": null, + "Mods": null + } + } +] \ No newline at end of file diff --git a/integration_tests/integration_test_yaml.go b/integration_tests/integration_test_yaml.go index fc01d4848..a9f09e3a4 100644 --- a/integration_tests/integration_test_yaml.go +++ b/integration_tests/integration_test_yaml.go @@ -139,6 +139,14 @@ var fileTests = []struct { kanikoContext: buildcontextPath, repo: "test-scratch", }, + { + description: "test multistage", + dockerfilePath: "/workspace/integration_tests/dockerfiles/Dockerfile_test_multistage", + configPath: "/workspace/integration_tests/dockerfiles/config_test_multistage.json", + dockerContext: buildcontextPath, + kanikoContext: buildcontextPath, + repo: "test-multistage", + }, } var structureTests = []struct { diff --git a/pkg/commands/copy.go b/pkg/commands/copy.go index bded4bf99..3d617a3db 100644 --- a/pkg/commands/copy.go +++ b/pkg/commands/copy.go @@ -17,6 +17,7 @@ limitations under the License. package commands import ( + "github.com/GoogleContainerTools/kaniko/pkg/constants" "github.com/GoogleContainerTools/kaniko/pkg/util" "github.com/containers/image/manifest" "github.com/docker/docker/builder/dockerfile/instructions" @@ -39,6 +40,11 @@ func (c *CopyCommand) ExecuteCommand(config *manifest.Schema2Config) error { logrus.Infof("cmd: copy %s", srcs) logrus.Infof("dest: %s", dest) + // Resolve from + if c.cmd.From != "" { + c.buildcontext = filepath.Join(constants.BuildContextDir, c.cmd.From) + } + // First, resolve any environment replacement resolvedEnvs, err := util.ResolveEnvironmentReplacementList(c.cmd.SourcesAndDest, config.Env, true) if err != nil { @@ -57,11 +63,18 @@ func (c *CopyCommand) ExecuteCommand(config *manifest.Schema2Config) error { if err != nil { return err } - destPath, err := util.DestinationFilepath(src, dest, config.WorkingDir) + cwd := config.WorkingDir + if cwd == "" { + cwd = constants.RootDir + } + destPath, err := util.DestinationFilepath(src, dest, cwd) if err != nil { return err } if fi.IsDir() { + if !filepath.IsAbs(dest) { + dest = filepath.Join(cwd, dest) + } if err := util.CopyDir(fullPath, dest); err != nil { return err } diff --git a/pkg/commands/env.go b/pkg/commands/env.go index acb379e23..691315f6d 100644 --- a/pkg/commands/env.go +++ b/pkg/commands/env.go @@ -29,6 +29,12 @@ type EnvCommand struct { cmd *instructions.EnvCommand } +func NewEnvCommand(cmd *instructions.EnvCommand) EnvCommand { + return EnvCommand{ + cmd: cmd, + } +} + func (e *EnvCommand) ExecuteCommand(config *manifest.Schema2Config) error { logrus.Info("cmd: ENV") newEnvs := e.cmd.Env diff --git a/pkg/dockerfile/dockerfile.go b/pkg/dockerfile/dockerfile.go index f755a547e..c5e91f256 100644 --- a/pkg/dockerfile/dockerfile.go +++ b/pkg/dockerfile/dockerfile.go @@ -18,10 +18,15 @@ package dockerfile import ( "bytes" - "strings" - + "github.com/GoogleContainerTools/kaniko/pkg/commands" + "github.com/GoogleContainerTools/kaniko/pkg/constants" + "github.com/GoogleContainerTools/kaniko/pkg/image" + "github.com/GoogleContainerTools/kaniko/pkg/util" "github.com/docker/docker/builder/dockerfile/instructions" "github.com/docker/docker/builder/dockerfile/parser" + "path/filepath" + "strconv" + "strings" ) // Parse parses the contents of a Dockerfile and returns a list of commands @@ -37,6 +42,25 @@ func Parse(b []byte) ([]instructions.Stage, error) { return stages, err } +// ResolveStages resolves any calls to previous stages to the number value of that stage +// Ex. --from=second_stage should be --from=1 for easier processing later on +func ResolveStages(stages []instructions.Stage) { + nameToIndex := make(map[string]string) + for i, stage := range stages { + index := strconv.Itoa(i) + nameToIndex[stage.Name] = index + nameToIndex[index] = index + for _, cmd := range stage.Commands { + switch c := cmd.(type) { + case *instructions.CopyCommand: + if c.From != "" { + c.From = nameToIndex[c.From] + } + } + } + } +} + // 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 @@ -54,3 +78,47 @@ func ParseCommands(cmdArray []string) ([]instructions.Command, error) { } return cmds, nil } + +// Dependencies returns a list of files in this stage that will be needed in later stages +func Dependencies(index int, stages []instructions.Stage) ([]string, error) { + var dependencies []string + for stageIndex, stage := range stages { + if stageIndex <= index { + continue + } + ms, err := image.NewSourceImage(stage.BaseName) + if err != nil { + return nil, err + } + for _, cmd := range stage.Commands { + switch c := cmd.(type) { + case *instructions.EnvCommand: + envCommand := commands.NewEnvCommand(c) + if err := envCommand.ExecuteCommand(ms.Config()); err != nil { + return nil, err + } + case *instructions.CopyCommand: + if c.From != strconv.Itoa(index) { + continue + } + // First, resolve any environment replacement + resolvedEnvs, err := util.ResolveEnvironmentReplacementList(c.SourcesAndDest, ms.Config().Env, true) + if err != nil { + return nil, err + } + // Resolve wildcards and get a list of resolved sources + srcs, err := util.ResolveSources(resolvedEnvs, constants.RootDir) + if err != nil { + return nil, err + } + for index, src := range srcs { + if !filepath.IsAbs(src) { + srcs[index] = filepath.Join(constants.RootDir, src) + } + } + dependencies = append(dependencies, srcs...) + } + } + } + return dependencies, nil +} diff --git a/pkg/dockerfile/dockerfile_test.go b/pkg/dockerfile/dockerfile_test.go new file mode 100644 index 000000000..4c4c7e39c --- /dev/null +++ b/pkg/dockerfile/dockerfile_test.go @@ -0,0 +1,96 @@ +/* +Copyright 2018 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dockerfile + +import ( + "fmt" + "github.com/GoogleContainerTools/kaniko/testutil" + "github.com/docker/docker/builder/dockerfile/instructions" + "io/ioutil" + "os" + "path/filepath" + "strconv" + "testing" +) + +var dockerfile = ` +FROM scratch +RUN echo hi > /hi + +FROM scratch AS second +COPY --from=0 /hi /hi2 + +FROM scratch +COPY --from=second /hi2 /hi3 +` + +func Test_ResolveStages(t *testing.T) { + stages, err := Parse([]byte(dockerfile)) + if err != nil { + t.Fatal(err) + } + ResolveStages(stages) + for index, stage := range stages { + if index == 0 { + continue + } + copyCmd := stage.Commands[0].(*instructions.CopyCommand) + expectedStage := strconv.Itoa(index - 1) + if copyCmd.From != expectedStage { + t.Fatalf("unexpected copy command: %s resolved to stage %s, expected %s", copyCmd.String(), copyCmd.From, expectedStage) + } + } +} + +func Test_Dependencies(t *testing.T) { + testDir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatal(err) + } + helloPath := filepath.Join(testDir, "hello") + if err := os.Mkdir(helloPath, 0755); err != nil { + t.Fatal(err) + } + + dockerfile := fmt.Sprintf(` + FROM scratch + COPY %s %s + + FROM scratch AS second + ENV hienv %s + COPY a b + COPY --from=0 /$hienv %s /hi2/ + `, helloPath, helloPath, helloPath, testDir) + + stages, err := Parse([]byte(dockerfile)) + if err != nil { + t.Fatal(err) + } + + expectedDependencies := [][]string{ + { + helloPath, + testDir, + }, + nil, + } + + for index := range stages { + actualDeps, err := Dependencies(index, stages) + testutil.CheckErrorAndDeepEqual(t, false, err, expectedDependencies[index], actualDeps) + } +} diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index ab9eacd53..3dc14fb55 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -18,9 +18,7 @@ package executor import ( "fmt" - "io/ioutil" - "os" - + img "github.com/GoogleContainerTools/container-diff/pkg/image" "github.com/GoogleContainerTools/kaniko/pkg/commands" "github.com/GoogleContainerTools/kaniko/pkg/constants" "github.com/GoogleContainerTools/kaniko/pkg/dockerfile" @@ -30,72 +28,76 @@ import ( "github.com/containers/image/manifest" "github.com/docker/docker/builder/dockerfile/instructions" "github.com/sirupsen/logrus" + "io/ioutil" + "os" + "path/filepath" + "strconv" ) -func DoBuild(dockerfilePath, srcContext, destination, snapshotMode string, dockerInsecureSkipTLSVerify bool) error { +// DoBuild executes each stage of the build +func DoBuild(dockerfilePath, srcContext, snapshotMode string) (*img.MutableSource, error) { // Parse dockerfile and unpack base image to root d, err := ioutil.ReadFile(dockerfilePath) if err != nil { - return err + return nil, err } stages, err := dockerfile.Parse(d) if err != nil { - return err - } - baseImage := stages[0].BaseName - - // Unpack file system to root - logrus.Infof("Unpacking filesystem of %s...", baseImage) - if err := util.ExtractFileSystemFromImage(baseImage); err != nil { - return err + return nil, err } + dockerfile.ResolveStages(stages) hasher, err := getHasher(snapshotMode) if err != nil { - return err - } - l := snapshot.NewLayeredMap(hasher) - snapshotter := snapshot.NewSnapshotter(l, constants.RootDir) - - // Take initial snapshot - if err := snapshotter.Init(); err != nil { - return err + return nil, err } - // Initialize source image - sourceImage, err := image.NewSourceImage(baseImage) - if err != nil { - return err - } - - // Set environment variables within the image - if err := image.SetEnvVariables(sourceImage); err != nil { - return err - } - - imageConfig := sourceImage.Config() - // Currently only supports single stage builds - for _, stage := range stages { - if err := resolveOnBuild(&stage, imageConfig); err != nil { - return err + for index, stage := range stages { + baseImage := stage.BaseName + finalStage := index == len(stages)-1 + // Unpack file system to root + logrus.Infof("Unpacking filesystem of %s...", baseImage) + if err := util.ExtractFileSystemFromImage(baseImage); err != nil { + return nil, err } + + l := snapshot.NewLayeredMap(hasher) + snapshotter := snapshot.NewSnapshotter(l, constants.RootDir) + + // Take initial snapshot + if err := snapshotter.Init(); err != nil { + return nil, err + } + // Initialize source image + sourceImage, err := image.NewSourceImage(baseImage) + if err != nil { + return nil, err + } + imageConfig := sourceImage.Config() + if err := resolveOnBuild(&stage, imageConfig); err != nil { + return nil, err + } + for _, cmd := range stage.Commands { dockerCommand, err := commands.GetCommand(cmd, srcContext) if err != nil { - return err + return nil, err } if dockerCommand == nil { continue } if err := dockerCommand.ExecuteCommand(imageConfig); err != nil { - return err + return nil, err + } + if !finalStage { + continue } // Now, we get the files to snapshot from this command and take the snapshot snapshotFiles := dockerCommand.FilesToSnapshot() contents, err := snapshotter.TakeSnapshot(snapshotFiles) if err != nil { - return err + return nil, err } util.MoveVolumeWhitelistToWhitelist() if contents == nil { @@ -105,15 +107,58 @@ func DoBuild(dockerfilePath, srcContext, destination, snapshotMode string, docke } // Append the layer to the image if err := sourceImage.AppendLayer(contents, constants.Author); err != nil { + return nil, err + } + } + if finalStage { + return sourceImage, nil + } + if err := saveStageDependencies(index, stages); err != nil { + return nil, err + } + // Delete the filesystem + if err := util.DeleteFilesystem(); err != nil { + return nil, err + } + } + return nil, nil +} + +func saveStageDependencies(index int, stages []instructions.Stage) error { + // First, get the files in this stage later stages will need + dependencies, err := dockerfile.Dependencies(index, stages) + logrus.Infof("saving dependencies %s", dependencies) + if err != nil { + return err + } + // Then, create the directory they will exist in + i := strconv.Itoa(index) + dependencyDir := filepath.Join(constants.BuildContextDir, i) + if err := os.MkdirAll(dependencyDir, 0755); err != nil { + return err + } + // Now, copy over dependencies to this dir + for _, d := range dependencies { + fi, err := os.Lstat(d) + if err != nil { + return err + } + dest := filepath.Join(dependencyDir, d) + if fi.IsDir() { + if err := util.CopyDir(d, dest); err != nil { + return err + } + } else if fi.Mode()&os.ModeSymlink != 0 { + if err := util.CopySymlink(d, dest); err != nil { + return err + } + } else { + if err := util.CopyFile(d, dest); err != nil { return err } } } - // Push the image - if err := setDefaultEnv(); err != nil { - return err - } - return image.PushImage(sourceImage, destination, dockerInsecureSkipTLSVerify) + return nil } func getHasher(snapshotMode string) (func(string) (string, error), error) { @@ -141,18 +186,3 @@ func resolveOnBuild(stage *instructions.Stage, config *manifest.Schema2Config) e logrus.Infof("Executing %v build triggers", len(cmds)) return nil } - -// setDefaultEnv sets default values for HOME and PATH so that -// config.json and docker-credential-gcr can be accessed -func setDefaultEnv() error { - defaultEnvs := map[string]string{ - "HOME": "/root", - "PATH": "/usr/local/bin/", - } - for key, val := range defaultEnvs { - if err := os.Setenv(key, val); err != nil { - return err - } - } - return nil -} diff --git a/pkg/util/command_util.go b/pkg/util/command_util.go index 20efd19a3..c8788a494 100644 --- a/pkg/util/command_util.go +++ b/pkg/util/command_util.go @@ -178,7 +178,7 @@ func IsSrcsValid(srcsAndDest instructions.SourcesAndDest, resolvedSources []stri } if len(resolvedSources) == 1 { - fi, err := os.Stat(filepath.Join(root, resolvedSources[0])) + fi, err := os.Lstat(filepath.Join(root, resolvedSources[0])) if err != nil { return err } diff --git a/pkg/util/fs_util.go b/pkg/util/fs_util.go index f5c96b80b..e32517c9a 100644 --- a/pkg/util/fs_util.go +++ b/pkg/util/fs_util.go @@ -56,6 +56,33 @@ func ExtractFileSystemFromImage(img string) error { return pkgutil.GetFileSystemFromReference(ref, imgSrc, constants.RootDir, whitelist) } +// DeleteFilesystem deletes the extracted image file system +func DeleteFilesystem() error { + logrus.Info("Deleting filesystem...") + err := filepath.Walk(constants.RootDir, func(path string, info os.FileInfo, err error) error { + if PathInWhitelist(path, constants.RootDir) || ChildDirInWhitelist(path, constants.RootDir) { + logrus.Debugf("Not deleting %s, as it's whitelisted", path) + return nil + } + if path == constants.RootDir { + return nil + } + return os.RemoveAll(path) + }) + return err +} + +// ChildDirInWhitelist returns true if there is a child file or directory of the path in the whitelist +func ChildDirInWhitelist(path, directory string) bool { + for _, d := range whitelist { + dirPath := filepath.Join(directory, d) + if pkgutil.HasFilepathPrefix(dirPath, path) { + return true + } + } + return false +} + // PathInWhitelist returns true if the path is whitelisted func PathInWhitelist(path, directory string) bool { for _, c := range constants.KanikoBuildFiles { @@ -135,6 +162,9 @@ func Files(root string) ([]string, error) { var files []string logrus.Debugf("Getting files and contents at root %s", root) err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if PathInWhitelist(path, root) { + return nil + } files = append(files, path) return err }) @@ -219,7 +249,7 @@ func CopyDir(src, dest string) error { } for _, file := range files { fullPath := filepath.Join(src, file) - fi, err := os.Stat(fullPath) + fi, err := os.Lstat(fullPath) if err != nil { return err } diff --git a/test.sh b/test.sh index 1ca29155d..a1b16b244 100755 --- a/test.sh +++ b/test.sh @@ -21,7 +21,7 @@ GREEN='\033[0;32m' RESET='\033[0m' echo "Running go tests..." -go test -cover -v -tags "containers_image_ostree_stub containers_image_openpgp exclude_graphdriver_devicemapper exclude_graphdriver_btrfs" -timeout 60s `go list ./... | grep -v vendor` | sed ''/PASS/s//$(printf "${GREEN}PASS${RESET}")/'' | sed ''/FAIL/s//$(printf "${RED}FAIL${RESET}")/'' +go test -cover -v -tags "containers_image_ostree_stub containers_image_openpgp exclude_graphdriver_devicemapper exclude_graphdriver_btrfs exclude_graphdriver_overlay" -timeout 60s `go list ./... | grep -v vendor` | sed ''/PASS/s//$(printf "${GREEN}PASS${RESET}")/'' | sed ''/FAIL/s//$(printf "${RED}FAIL${RESET}")/'' GO_TEST_EXIT_CODE=${PIPESTATUS[0]} if [[ $GO_TEST_EXIT_CODE -ne 0 ]]; then exit $GO_TEST_EXIT_CODE