diff --git a/cmd/minikube/cmd/image.go b/cmd/minikube/cmd/image.go index 62c50b44f5..fe9d364fe7 100644 --- a/cmd/minikube/cmd/image.go +++ b/cmd/minikube/cmd/image.go @@ -19,7 +19,10 @@ package cmd import ( "io" "io/ioutil" + "net/url" "os" + "path/filepath" + "runtime" "strings" "github.com/spf13/cobra" @@ -29,6 +32,7 @@ import ( "k8s.io/minikube/pkg/minikube/image" "k8s.io/minikube/pkg/minikube/machine" "k8s.io/minikube/pkg/minikube/reason" + docker "k8s.io/minikube/third_party/go-dockerclient" ) // imageCmd represents the image command @@ -38,9 +42,14 @@ var imageCmd = &cobra.Command{ } var ( - pull bool - imgDaemon bool - imgRemote bool + pull bool + imgDaemon bool + imgRemote bool + tag string + push bool + dockerFile string + buildEnv []string + buildOpt []string ) func saveFile(r io.Reader) (string, error) { @@ -69,7 +78,7 @@ var loadImageCmd = &cobra.Command{ if len(args) == 0 { exit.Message(reason.Usage, "Please provide an image in your local daemon to load into minikube via ") } - // Cache and load images into docker daemon + // Cache and load images into container runtime profile, err := config.LoadProfile(viper.GetString(config.ProfileName)) if err != nil { exit.Error(reason.Usage, "loading profile", err) @@ -155,6 +164,67 @@ $ minikube image unload image busybox }, } +func createTar(dir string) (string, error) { + tar, err := docker.CreateTarStream(dir, dockerFile) + if err != nil { + return "", err + } + return saveFile(tar) +} + +// buildImageCmd represents the image build command +var buildImageCmd = &cobra.Command{ + Use: "build PATH | URL | -", + Short: "Build a container image in minikube", + Long: "Build a container image, using the container runtime.", + Example: `minikube image build .`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 1 { + exit.Message(reason.Usage, "Please provide a path or url to build") + } + // Build images into container runtime + profile, err := config.LoadProfile(viper.GetString(config.ProfileName)) + if err != nil { + exit.Error(reason.Usage, "loading profile", err) + } + + img := args[0] + var tmp string + if img == "-" { + tmp, err = saveFile(os.Stdin) + if err != nil { + exit.Error(reason.GuestImageBuild, "Failed to save stdin", err) + } + img = tmp + } else { + // If it is an URL, pass it as-is + u, err := url.Parse(img) + local := err == nil && u.Scheme == "" && u.Host == "" + if runtime.GOOS == "windows" && filepath.VolumeName(img) != "" { + local = true + } + if local { + // If it's a directory, tar it + info, err := os.Stat(img) + if err == nil && info.IsDir() { + tmp, err := createTar(img) + if err != nil { + exit.Error(reason.GuestImageBuild, "Failed to save dir", err) + } + img = tmp + } + // Otherwise, assume it's a tar + } + } + if err := machine.BuildImage(img, dockerFile, tag, push, buildEnv, buildOpt, []*config.Profile{profile}); err != nil { + exit.Error(reason.GuestImageBuild, "Failed to build image", err) + } + if tmp != "" { + os.Remove(tmp) + } + }, +} + var listImageCmd = &cobra.Command{ Use: "list", Short: "List images", @@ -167,6 +237,7 @@ $ minikube image list if err != nil { exit.Error(reason.Usage, "loading profile", err) } + if err := machine.ListImages(profile); err != nil { exit.Error(reason.GuestImageList, "Failed to list images", err) } @@ -174,10 +245,16 @@ $ minikube image list } func init() { - imageCmd.AddCommand(loadImageCmd) - imageCmd.AddCommand(removeImageCmd) loadImageCmd.Flags().BoolVarP(&pull, "pull", "", false, "Pull the remote image (no caching)") loadImageCmd.Flags().BoolVar(&imgDaemon, "daemon", false, "Cache image from docker daemon") loadImageCmd.Flags().BoolVar(&imgRemote, "remote", false, "Cache image from remote registry") + imageCmd.AddCommand(loadImageCmd) + imageCmd.AddCommand(removeImageCmd) + buildImageCmd.Flags().StringVarP(&tag, "tag", "t", "", "Tag to apply to the new image (optional)") + buildImageCmd.Flags().BoolVarP(&push, "push", "", false, "Push the new image (requires tag)") + buildImageCmd.Flags().StringVarP(&dockerFile, "file", "f", "", "Path to the Dockerfile to use (optional)") + buildImageCmd.Flags().StringArrayVar(&buildEnv, "build-env", nil, "Environment variables to pass to the build. (format: key=value)") + buildImageCmd.Flags().StringArrayVar(&buildOpt, "build-opt", nil, "Specify arbitrary flags to pass to the build. (format: key=value)") + imageCmd.AddCommand(buildImageCmd) imageCmd.AddCommand(listImageCmd) } diff --git a/go.mod b/go.mod index a740ba6e43..ad6b5f45d7 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/cloudfoundry-attic/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 // indirect github.com/docker/cli v0.0.0-20200303162255-7d407207c304 // indirect - github.com/docker/docker v17.12.0-ce-rc1.0.20200916142827-bd33bbf0497b+incompatible + github.com/docker/docker v17.12.0-ce-rc1.0.20210128214336-420b1d36250f+incompatible github.com/docker/go-units v0.4.0 github.com/docker/machine v0.16.2 github.com/elazarl/goproxy v0.0.0-20190421051319-9d40249d3c2f @@ -102,6 +102,7 @@ require ( replace ( git.apache.org/thrift.git => github.com/apache/thrift v0.0.0-20180902110319-2566ecd5d999 github.com/briandowns/spinner => github.com/alonyb/spinner v1.12.7 + github.com/docker/docker => github.com/afbjorklund/moby v0.0.0-20210308214533-2fa72faf0e8b github.com/docker/machine => github.com/machine-drivers/machine v0.7.1-0.20210306082426-fcb2ad5bcb17 github.com/google/go-containerregistry => github.com/afbjorklund/go-containerregistry v0.4.1-0.20210321165649-761f6f9626b1 github.com/samalba/dockerclient => github.com/sayboras/dockerclient v1.0.0 diff --git a/go.sum b/go.sum index df9b043fc4..41e1d3b96e 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/VividCortex/godaemon v0.0.0-20201030160542-15e3f4925a21 h1:Pgxfz/g+Xy github.com/VividCortex/godaemon v0.0.0-20201030160542-15e3f4925a21/go.mod h1:Y8CJ3IwPIAkMhv/rRUWIlczaeqd9ty9yrl+nc2AbaL4= github.com/afbjorklund/go-containerregistry v0.4.1-0.20210321165649-761f6f9626b1 h1:AI8EIk8occ3pruhaTpkaQxQGlC1dHx3J9hAtg7t+FLI= github.com/afbjorklund/go-containerregistry v0.4.1-0.20210321165649-761f6f9626b1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0= +github.com/afbjorklund/moby v0.0.0-20210308214533-2fa72faf0e8b h1:wmyy8gOOzYzMD6SfMs44yCPoOWAAHcjxCio/zQjOlDU= +github.com/afbjorklund/moby v0.0.0-20210308214533-2fa72faf0e8b/go.mod h1:qXUBi22bjTfxOV8XyOI/W1PklPSinepyWoJ6eYSLwwo= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -292,10 +294,8 @@ github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TT github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker v17.12.0-ce-rc1.0.20181225093023-5ddb1d410a8b+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker v17.12.0-ce-rc1.0.20200916142827-bd33bbf0497b+incompatible h1:SiUATuP//KecDjpOK2tvZJgeScYAklvyjfK8JZlU6fo= -github.com/docker/docker v17.12.0-ce-rc1.0.20200916142827-bd33bbf0497b+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v17.12.0-ce-rc1.0.20210128214336-420b1d36250f+incompatible h1:nhVo1udYfMj0Jsw0lnqrTjjf33aLpdgW9Wve9fHVzhQ= +github.com/docker/docker v17.12.0-ce-rc1.0.20210128214336-420b1d36250f+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ= github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= diff --git a/pkg/minikube/cruntime/containerd.go b/pkg/minikube/cruntime/containerd.go index 11bcaf9fef..78738be5c9 100644 --- a/pkg/minikube/cruntime/containerd.go +++ b/pkg/minikube/cruntime/containerd.go @@ -21,6 +21,8 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/url" + "os" "os/exec" "path" "strings" @@ -277,6 +279,109 @@ func (r *Containerd) RemoveImage(name string) error { return removeCRIImage(r.Runner, name) } +func gitClone(cr CommandRunner, src string) (string, error) { + // clone to a temporary directory + rr, err := cr.RunCmd(exec.Command("mktemp", "-d")) + if err != nil { + return "", err + } + tmp := strings.TrimSpace(rr.Stdout.String()) + cmd := exec.Command("git", "clone", src, tmp) + if _, err := cr.RunCmd(cmd); err != nil { + return "", err + } + return tmp, nil +} + +func downloadRemote(cr CommandRunner, src string) (string, error) { + u, err := url.Parse(src) + if err != nil { + return "", err + } + if u.Scheme == "" && u.Host == "" { // regular file, return + return src, nil + } + if u.Scheme == "git" { + return gitClone(cr, src) + } + + // download to a temporary file + rr, err := cr.RunCmd(exec.Command("mktemp")) + if err != nil { + return "", err + } + dst := strings.TrimSpace(rr.Stdout.String()) + cmd := exec.Command("curl", "-L", "-o", dst, src) + if _, err := cr.RunCmd(cmd); err != nil { + return "", err + } + + // extract to a temporary directory + rr, err = cr.RunCmd(exec.Command("mktemp", "-d")) + if err != nil { + return "", err + } + tmp := strings.TrimSpace(rr.Stdout.String()) + cmd = exec.Command("tar", "-C", tmp, "-xf", dst) + if _, err := cr.RunCmd(cmd); err != nil { + return "", err + } + + return tmp, nil +} + +// BuildImage builds an image into this runtime +func (r *Containerd) BuildImage(src string, file string, tag string, push bool, env []string, opts []string) error { + // download url if not already present + dir, err := downloadRemote(r.Runner, src) + if err != nil { + return err + } + if file != "" { + if dir != src { + file = path.Join(dir, file) + } + // copy to standard path for Dockerfile + df := path.Join(dir, "Dockerfile") + if file != df { + cmd := exec.Command("sudo", "cp", "-f", file, df) + if _, err := r.Runner.RunCmd(cmd); err != nil { + return err + } + } + } + klog.Infof("Building image: %s", dir) + extra := "" + if tag != "" { + // add default tag if missing + if !strings.Contains(tag, ":") { + tag += ":latest" + } + extra = fmt.Sprintf(",name=%s", tag) + if push { + extra += ",push=true" + } + } + args := []string{"buildctl", "build", + "--frontend", "dockerfile.v0", + "--local", fmt.Sprintf("context=%s", dir), + "--local", fmt.Sprintf("dockerfile=%s", dir), + "--output", fmt.Sprintf("type=image%s", extra)} + for _, opt := range opts { + args = append(args, "--"+opt) + } + c := exec.Command("sudo", args...) + e := os.Environ() + e = append(e, env...) + c.Env = e + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if _, err := r.Runner.RunCmd(c); err != nil { + return errors.Wrap(err, "buildctl build.") + } + return nil +} + // CGroupDriver returns cgroup driver ("cgroupfs" or "systemd") func (r *Containerd) CGroupDriver() (string, error) { info, err := getCRIInfo(r.Runner) diff --git a/pkg/minikube/cruntime/crio.go b/pkg/minikube/cruntime/crio.go index 210a2927b7..faea475faf 100644 --- a/pkg/minikube/cruntime/crio.go +++ b/pkg/minikube/cruntime/crio.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "net" + "os" "os/exec" "path" "strings" @@ -197,6 +198,40 @@ func (r *CRIO) RemoveImage(name string) error { return removeCRIImage(r.Runner, name) } +// BuildImage builds an image into this runtime +func (r *CRIO) BuildImage(src string, file string, tag string, push bool, env []string, opts []string) error { + klog.Infof("Building image: %s", src) + args := []string{"podman", "build"} + if file != "" { + args = append(args, "-f", file) + } + if tag != "" { + args = append(args, "-t", tag) + } + args = append(args, src) + for _, opt := range opts { + args = append(args, "--"+opt) + } + c := exec.Command("sudo", args...) + e := os.Environ() + e = append(e, env...) + c.Env = e + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if _, err := r.Runner.RunCmd(c); err != nil { + return errors.Wrap(err, "crio build image") + } + if tag != "" && push { + c := exec.Command("sudo", "podman", "push", tag) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if _, err := r.Runner.RunCmd(c); err != nil { + return errors.Wrap(err, "crio push image") + } + } + return nil +} + // CGroupDriver returns cgroup driver ("cgroupfs" or "systemd") func (r *CRIO) CGroupDriver() (string, error) { c := exec.Command("crio", "config") diff --git a/pkg/minikube/cruntime/cruntime.go b/pkg/minikube/cruntime/cruntime.go index 1a5d0a8ae7..04a49783f6 100644 --- a/pkg/minikube/cruntime/cruntime.go +++ b/pkg/minikube/cruntime/cruntime.go @@ -97,6 +97,8 @@ type Manager interface { LoadImage(string) error // Pull an image to the runtime from the container registry PullImage(string) error + // Build an image idempotently into the runtime on a host + BuildImage(string, string, string, bool, []string, []string) error // ImageExists takes image name and image sha checks if an it exists ImageExists(string, string) bool diff --git a/pkg/minikube/cruntime/docker.go b/pkg/minikube/cruntime/docker.go index 4298e75c1b..d351fedd30 100644 --- a/pkg/minikube/cruntime/docker.go +++ b/pkg/minikube/cruntime/docker.go @@ -18,6 +18,7 @@ package cruntime import ( "fmt" + "os" "os/exec" "path" "strings" @@ -217,6 +218,40 @@ func (r *Docker) RemoveImage(name string) error { return nil } +// BuildImage builds an image into this runtime +func (r *Docker) BuildImage(src string, file string, tag string, push bool, env []string, opts []string) error { + klog.Infof("Building image: %s", src) + args := []string{"build"} + if file != "" { + args = append(args, "-f", file) + } + if tag != "" { + args = append(args, "-t", tag) + } + args = append(args, src) + for _, opt := range opts { + args = append(args, "--"+opt) + } + c := exec.Command("docker", args...) + e := os.Environ() + e = append(e, env...) + c.Env = e + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if _, err := r.Runner.RunCmd(c); err != nil { + return errors.Wrap(err, "buildimage docker.") + } + if tag != "" && push { + c := exec.Command("docker", "push", tag) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if _, err := r.Runner.RunCmd(c); err != nil { + return errors.Wrap(err, "pushimage docker.") + } + } + return nil +} + // CGroupDriver returns cgroup driver ("cgroupfs" or "systemd") func (r *Docker) CGroupDriver() (string, error) { // Note: the server daemon has to be running, for this call to return successfully diff --git a/pkg/minikube/machine/build_images.go b/pkg/minikube/machine/build_images.go new file mode 100644 index 0000000000..ed39b21779 --- /dev/null +++ b/pkg/minikube/machine/build_images.go @@ -0,0 +1,189 @@ +/* +Copyright 2021 The Kubernetes Authors All rights reserved. + +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 machine + +import ( + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" + + "github.com/docker/machine/libmachine/state" + "github.com/pkg/errors" + "k8s.io/klog/v2" + "k8s.io/minikube/pkg/minikube/assets" + "k8s.io/minikube/pkg/minikube/command" + "k8s.io/minikube/pkg/minikube/config" + "k8s.io/minikube/pkg/minikube/cruntime" + "k8s.io/minikube/pkg/minikube/localpath" + "k8s.io/minikube/pkg/minikube/vmpath" +) + +// buildRoot is where images should be built from within the guest VM +var buildRoot = path.Join(vmpath.GuestPersistentDir, "build") + +// BuildImage builds image to all profiles +func BuildImage(path string, file string, tag string, push bool, env []string, opt []string, profiles []*config.Profile) error { + api, err := NewAPIClient() + if err != nil { + return errors.Wrap(err, "api") + } + defer api.Close() + + succeeded := []string{} + failed := []string{} + + u, err := url.Parse(path) + if err == nil && u.Scheme == "file" { + path = u.Path + } + remote := err == nil && u.Scheme != "" + if runtime.GOOS == "windows" && filepath.VolumeName(path) != "" { + remote = false + } + + for _, p := range profiles { // building images to all running profiles + pName := p.Name // capture the loop variable + + c, err := config.Load(pName) + if err != nil { + // Non-fatal because it may race with profile deletion + klog.Errorf("Failed to load profile %q: %v", pName, err) + failed = append(failed, pName) + continue + } + + for _, n := range c.Nodes { + m := config.MachineName(*c, n) + + status, err := Status(api, m) + if err != nil { + klog.Warningf("error getting status for %s: %v", m, err) + failed = append(failed, m) + continue + } + + if status == state.Running.String() { + h, err := api.Load(m) + if err != nil { + klog.Warningf("Failed to load machine %q: %v", m, err) + failed = append(failed, m) + continue + } + cr, err := CommandRunner(h) + if err != nil { + return err + } + if remote { + err = buildImage(cr, c.KubernetesConfig, path, file, tag, push, env, opt) + } else { + err = transferAndBuildImage(cr, c.KubernetesConfig, path, file, tag, push, env, opt) + } + if err != nil { + failed = append(failed, m) + klog.Warningf("Failed to build image for profile %s. make sure the profile is running. %v", pName, err) + continue + } + succeeded = append(succeeded, m) + } + } + } + + klog.Infof("succeeded building to: %s", strings.Join(succeeded, " ")) + klog.Infof("failed building to: %s", strings.Join(failed, " ")) + return nil +} + +// buildImage builds a single image +func buildImage(cr command.Runner, k8s config.KubernetesConfig, src string, file string, tag string, push bool, env []string, opt []string) error { + r, err := cruntime.New(cruntime.Config{Type: k8s.ContainerRuntime, Runner: cr}) + if err != nil { + return errors.Wrap(err, "runtime") + } + klog.Infof("Building image from url: %s", src) + + err = r.BuildImage(src, file, tag, push, env, opt) + if err != nil { + return errors.Wrapf(err, "%s build %s", r.Name(), src) + } + + klog.Infof("Built %s from %s", tag, src) + return nil +} + +// transferAndBuildImage transfers and builds a single image +func transferAndBuildImage(cr command.Runner, k8s config.KubernetesConfig, src string, file string, tag string, push bool, env []string, opt []string) error { + r, err := cruntime.New(cruntime.Config{Type: k8s.ContainerRuntime, Runner: cr}) + if err != nil { + return errors.Wrap(err, "runtime") + } + klog.Infof("Building image from path: %s", src) + + filename := filepath.Base(src) + filename = localpath.SanitizeCacheDir(filename) + + if _, err := os.Stat(src); err != nil { + return err + } + + args := append([]string{"mkdir", "-p"}, buildRoot) + if _, err := cr.RunCmd(exec.Command("sudo", args...)); err != nil { + return err + } + + dst := path.Join(buildRoot, filename) + f, err := assets.NewFileAsset(src, buildRoot, filename, "0644") + if err != nil { + return errors.Wrapf(err, "creating copyable file asset: %s", filename) + } + if err := cr.Copy(f); err != nil { + return errors.Wrap(err, "transferring cached image") + } + + context := path.Join(buildRoot, ".", strings.TrimSuffix(filename, filepath.Ext(filename))) + args = append([]string{"mkdir", "-p"}, context) + if _, err := cr.RunCmd(exec.Command("sudo", args...)); err != nil { + return err + } + args = append([]string{"tar", "-C", context, "-xf"}, dst) + if _, err := cr.RunCmd(exec.Command("sudo", args...)); err != nil { + return err + } + + if file != "" && !path.IsAbs(file) { + file = path.Join(context, file) + } + err = r.BuildImage(context, file, tag, push, env, opt) + if err != nil { + return errors.Wrapf(err, "%s build %s", r.Name(), dst) + } + + args = append([]string{"rm", "-rf"}, context) + if _, err := cr.RunCmd(exec.Command("sudo", args...)); err != nil { + return err + } + args = append([]string{"rm", "-f"}, dst) + if _, err := cr.RunCmd(exec.Command("sudo", args...)); err != nil { + return err + } + + klog.Infof("Built %s from %s", tag, src) + return nil +} diff --git a/pkg/minikube/reason/reason.go b/pkg/minikube/reason/reason.go index 0aa22685b5..fd5edb095f 100644 --- a/pkg/minikube/reason/reason.go +++ b/pkg/minikube/reason/reason.go @@ -250,6 +250,7 @@ var ( GuestImageList = Kind{ID: "GUEST_IMAGE_LIST", ExitCode: ExGuestError} GuestImageLoad = Kind{ID: "GUEST_IMAGE_LOAD", ExitCode: ExGuestError} GuestImageRemove = Kind{ID: "GUEST_IMAGE_REMOVE", ExitCode: ExGuestError} + GuestImageBuild = Kind{ID: "GUEST_IMAGE_BUILD", ExitCode: ExGuestError} GuestLoadHost = Kind{ID: "GUEST_LOAD_HOST", ExitCode: ExGuestError} GuestMount = Kind{ID: "GUEST_MOUNT", ExitCode: ExGuestError} GuestMountConflict = Kind{ID: "GUEST_MOUNT_CONFLICT", ExitCode: ExGuestConflict} diff --git a/site/content/en/docs/commands/image.md b/site/content/en/docs/commands/image.md index a1457499fa..49a0317602 100644 --- a/site/content/en/docs/commands/image.md +++ b/site/content/en/docs/commands/image.md @@ -35,6 +35,56 @@ Manage images --vmodule moduleSpec comma-separated list of pattern=N settings for file-filtered logging ``` +## minikube image build + +Build a container image in minikube + +### Synopsis + +Build a container image, using the container runtime. + +```shell +minikube image build PATH | URL | - [flags] +``` + +### Examples + +``` +minikube image build . +``` + +### Options + +``` + --build-env stringArray Environment variables to pass to the build. (format: key=value) + --build-opt stringArray Specify arbitrary flags to pass to the build. (format: key=value) + -f, --file string Path to the Dockerfile to use (optional) + --push Push the new image (requires tag) + -t, --tag string Tag to apply to the new image (optional) +``` + +### Options inherited from parent commands + +``` + --add_dir_header If true, adds the file directory to the header of the log messages + --alsologtostderr log to standard error as well as files + -b, --bootstrapper string The name of the cluster bootstrapper that will set up the Kubernetes cluster. (default "kubeadm") + -h, --help + --log_backtrace_at traceLocation when logging hits line file:N, emit a stack trace (default :0) + --log_dir string If non-empty, write log files in this directory + --log_file string If non-empty, use this log file + --log_file_max_size uint Defines the maximum size a log file can grow to. Unit is megabytes. If the value is 0, the maximum file size is unlimited. (default 1800) + --logtostderr log to standard error instead of files + --one_output If true, only write logs to their native severity level (vs also writing to each lower severity level) + -p, --profile string The name of the minikube VM being used. This can be set to allow having multiple instances of minikube independently. (default "minikube") + --skip_headers If true, avoid header prefixes in the log messages + --skip_log_headers If true, avoid headers when opening log files + --stderrthreshold severity logs at or above this threshold go to stderr (default 2) + --user string Specifies the user executing the operation. Useful for auditing operations executed by 3rd party tools. Defaults to the operating system username. + -v, --v Level number for the log level verbosity + --vmodule moduleSpec comma-separated list of pattern=N settings for file-filtered logging +``` + ## minikube image help Help about any command diff --git a/test/integration/functional_test.go b/test/integration/functional_test.go index d032315570..d63b545db1 100644 --- a/test/integration/functional_test.go +++ b/test/integration/functional_test.go @@ -134,6 +134,7 @@ func TestFunctional(t *testing.T) { {"NodeLabels", validateNodeLabels}, {"LoadImage", validateLoadImage}, {"RemoveImage", validateRemoveImage}, + {"BuildImage", validateBuildImage}, } for _, tc := range tests { tc := tc @@ -156,12 +157,19 @@ func cleanupUnwantedImages(ctx context.Context, t *testing.T, profile string) { t.Skipf("docker is not installed, cannot delete docker images") } else { t.Run("delete busybox image", func(t *testing.T) { - newImage := fmt.Sprintf("busybox:%s", profile) + newImage := fmt.Sprintf("docker.io/library/busybox:%s", profile) rr, err := Run(t, exec.CommandContext(ctx, "docker", "rmi", "-f", newImage)) if err != nil { t.Logf("failed to remove image busybox from docker images. args %q: %v", rr.Command(), err) } }) + t.Run("delete my-image image", func(t *testing.T) { + newImage := fmt.Sprintf("localhost/my-image:%s", profile) + rr, err := Run(t, exec.CommandContext(ctx, "docker", "rmi", "-f", newImage)) + if err != nil { + t.Logf("failed to remove image my-image from docker images. args %q: %v", rr.Command(), err) + } + }) t.Run("delete minikube cached images", func(t *testing.T) { img := "minikube-local-cache-test:" + profile @@ -207,7 +215,7 @@ func validateLoadImage(ctx context.Context, t *testing.T, profile string) { } // tag busybox - newImage := fmt.Sprintf("busybox:%s", profile) + newImage := fmt.Sprintf("docker.io/library/busybox:%s", profile) rr, err = Run(t, exec.CommandContext(ctx, "docker", "tag", busybox, newImage)) if err != nil { t.Fatalf("failed to setup test (tag image) : %v\n%s", err, rr.Output()) @@ -224,7 +232,7 @@ func validateLoadImage(ctx context.Context, t *testing.T, profile string) { if err != nil { t.Fatalf("listing images: %v\n%s", err, rr.Output()) } - if !strings.Contains(rr.Output(), newImage) { + if !strings.Contains(rr.Output(), fmt.Sprintf("busybox:%s", profile)) { t.Fatalf("expected %s to be loaded into minikube but the image is not there", newImage) } @@ -259,13 +267,7 @@ func validateRemoveImage(ctx context.Context, t *testing.T, profile string) { t.Fatalf("removing image from minikube: %v\n%s", err, rr.Output()) } // make sure the image was removed - var cmd *exec.Cmd - if ContainerRuntime() == "docker" { - cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "docker", "images") - } else { - cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "sudo", "crictl", "images") - } - rr, err = Run(t, cmd) + rr, err = listImages(ctx, t, profile) if err != nil { t.Fatalf("listing images: %v\n%s", err, rr.Output()) } @@ -279,13 +281,8 @@ func inspectImage(ctx context.Context, t *testing.T, profile string, image strin var cmd *exec.Cmd if ContainerRuntime() == "docker" { cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "docker", "image", "inspect", image) - } else if ContainerRuntime() == "containerd" { - // crictl inspecti busybox:test-example - cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "sudo", "crictl", "inspecti", image) } else { - // crio adds localhost prefix - // crictl inspecti localhost/busybox:test-example - cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "sudo", "crictl", "inspecti", "localhost/"+image) + cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "sudo", "crictl", "inspecti", image) } rr, err := Run(t, cmd) if err != nil { @@ -294,6 +291,70 @@ func inspectImage(ctx context.Context, t *testing.T, profile string, image strin return rr, nil } +func listImages(ctx context.Context, t *testing.T, profile string) (*RunResult, error) { + var cmd *exec.Cmd + if ContainerRuntime() == "docker" { + cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "docker", "images") + } else { + cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "sudo", "crictl", "images") + } + rr, err := Run(t, cmd) + if err != nil { + return rr, err + } + return rr, nil +} + +// validateBuildImage makes sures that `minikube image build` works as expected +func validateBuildImage(ctx context.Context, t *testing.T, profile string) { + if NoneDriver() { + t.Skip("load image not available on none driver") + } + if GithubActionRunner() && runtime.GOOS == "darwin" { + t.Skip("skipping on github actions and darwin, as this test requires a running docker daemon") + } + defer PostMortemLogs(t, profile) + + newImage := fmt.Sprintf("localhost/my-image:%s", profile) + if ContainerRuntime() == "containerd" { + startBuildkit(ctx, t, profile) + // unix:///run/buildkit/buildkitd.sock + } + + // try to build the new image with minikube + rr, err := Run(t, exec.CommandContext(ctx, Target(), "-p", profile, "image", "build", "-t", newImage, filepath.Join(*testdataDir, "build"))) + if err != nil { + t.Fatalf("building image with minikube: %v\n%s", err, rr.Output()) + } + if rr.Stdout.Len() > 0 { + t.Logf("(dbg) Stdout: %s:\n%s", rr.Command(), rr.Stdout) + } + if rr.Stderr.Len() > 0 { + t.Logf("(dbg) Stderr: %s:\n%s", rr.Command(), rr.Stderr) + } + + // make sure the image was correctly built + rr, err = inspectImage(ctx, t, profile, newImage) + if err != nil { + ll, _ := listImages(ctx, t, profile) + t.Logf("(dbg) images: %s", ll.Output()) + t.Fatalf("listing images: %v\n%s", err, rr.Output()) + } + if !strings.Contains(rr.Output(), newImage) { + t.Fatalf("expected %s to be built with minikube but the image is not there", newImage) + } +} + +func startBuildkit(ctx context.Context, t *testing.T, profile string) { + // sudo systemctl start buildkit.socket + cmd := exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "nohup", + "sudo", "-b", "buildkitd", "--oci-worker=false", + "--containerd-worker=true", "--containerd-worker-namespace=k8s.io") + if rr, err := Run(t, cmd); err != nil { + t.Fatalf("%s failed: %v", rr.Command(), err) + } +} + // check functionality of minikube after evaling docker-env // TODO: Add validatePodmanEnv for crio runtime: #10231 func validateDockerEnv(ctx context.Context, t *testing.T, profile string) { diff --git a/test/integration/testdata/build/Dockerfile b/test/integration/testdata/build/Dockerfile new file mode 100644 index 0000000000..dfe60cebfa --- /dev/null +++ b/test/integration/testdata/build/Dockerfile @@ -0,0 +1,3 @@ +FROM busybox +RUN true +ADD content.txt / diff --git a/test/integration/testdata/build/content.txt b/test/integration/testdata/build/content.txt new file mode 100644 index 0000000000..63817ba73c --- /dev/null +++ b/test/integration/testdata/build/content.txt @@ -0,0 +1 @@ +Content for image build diff --git a/third_party/go-dockerclient/LICENSE b/third_party/go-dockerclient/LICENSE new file mode 100644 index 0000000000..707a0ed49b --- /dev/null +++ b/third_party/go-dockerclient/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2013-2021, go-dockerclient authors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/go-dockerclient/tar.go b/third_party/go-dockerclient/tar.go new file mode 100644 index 0000000000..bb6eee087f --- /dev/null +++ b/third_party/go-dockerclient/tar.go @@ -0,0 +1,122 @@ +// Copyright 2014 go-dockerclient authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package docker + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "path" + "path/filepath" + "strings" + + "github.com/docker/docker/pkg/archive" + "github.com/docker/docker/pkg/fileutils" +) + +func CreateTarStream(srcPath, dockerfilePath string) (io.ReadCloser, error) { + srcPath, err := filepath.Abs(srcPath) + if err != nil { + return nil, err + } + + excludes, err := parseDockerignore(srcPath) + if err != nil { + return nil, err + } + + includes := []string{"."} + + // If .dockerignore mentions .dockerignore or the Dockerfile + // then make sure we send both files over to the daemon + // because Dockerfile is, obviously, needed no matter what, and + // .dockerignore is needed to know if either one needs to be + // removed. The deamon will remove them for us, if needed, after it + // parses the Dockerfile. + // + // https://github.com/docker/docker/issues/8330 + // + forceIncludeFiles := []string{".dockerignore", dockerfilePath} + + for _, includeFile := range forceIncludeFiles { + if includeFile == "" { + continue + } + keepThem, err := fileutils.Matches(includeFile, excludes) + if err != nil { + return nil, fmt.Errorf("cannot match .dockerfileignore: '%s', error: %w", includeFile, err) + } + if keepThem { + includes = append(includes, includeFile) + } + } + + if err := validateContextDirectory(srcPath, excludes); err != nil { + return nil, err + } + tarOpts := &archive.TarOptions{ + ExcludePatterns: excludes, + IncludeFiles: includes, + Compression: archive.Uncompressed, + NoLchown: true, + } + return archive.TarWithOptions(srcPath, tarOpts) +} + +// validateContextDirectory checks if all the contents of the directory +// can be read and returns an error if some files can't be read. +// Symlinks which point to non-existing files don't trigger an error +func validateContextDirectory(srcPath string, excludes []string) error { + return filepath.Walk(filepath.Join(srcPath, "."), func(filePath string, f os.FileInfo, err error) error { + // skip this directory/file if it's not in the path, it won't get added to the context + if relFilePath, relErr := filepath.Rel(srcPath, filePath); relErr != nil { + return relErr + } else if skip, matchErr := fileutils.Matches(relFilePath, excludes); matchErr != nil { + return matchErr + } else if skip { + if f.IsDir() { + return filepath.SkipDir + } + return nil + } + + if err != nil { + if os.IsPermission(err) { + return fmt.Errorf("cannot stat %q: %w", filePath, err) + } + if os.IsNotExist(err) { + return nil + } + return err + } + + // skip checking if symlinks point to non-existing files, such symlinks can be useful + // also skip named pipes, because they hanging on open + if f.Mode()&(os.ModeSymlink|os.ModeNamedPipe) != 0 { + return nil + } + + if !f.IsDir() { + currentFile, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("cannot open %q for reading: %w", filePath, err) + } + currentFile.Close() + } + return nil + }) +} + +func parseDockerignore(root string) ([]string, error) { + var excludes []string + ignore, err := ioutil.ReadFile(path.Join(root, ".dockerignore")) + if err != nil && !os.IsNotExist(err) { + return excludes, fmt.Errorf("error reading .dockerignore: %w", err) + } + excludes = strings.Split(string(ignore), "\n") + + return excludes, nil +}