diff --git a/cmd/minikube/cmd/image.go b/cmd/minikube/cmd/image.go index de3e80e389fa..a379d6151109 100644 --- a/cmd/minikube/cmd/image.go +++ b/cmd/minikube/cmd/image.go @@ -33,9 +33,8 @@ import ( // imageCmd represents the image command var imageCmd = &cobra.Command{ - Use: "image", - Short: "Load a local image into minikube", - Long: "Load a local image into minikube", + Use: "image COMMAND", + Short: "Manage images", } var ( @@ -125,8 +124,30 @@ var loadImageCmd = &cobra.Command{ }, } +var removeImageCmd = &cobra.Command{ + Use: "rm IMAGE [IMAGE...]", + Short: "Remove one or more images", + Example: ` +$ minikube image rm image busybox + +$ minikube image unload image busybox +`, + Args: cobra.MinimumNArgs(1), + Aliases: []string{"unload"}, + Run: func(cmd *cobra.Command, args []string) { + profile, err := config.LoadProfile(viper.GetString(config.ProfileName)) + if err != nil { + exit.Error(reason.Usage, "loading profile", err) + } + if err := machine.RemoveImages(args, profile); err != nil { + exit.Error(reason.GuestImageRemove, "Failed to remove image", err) + } + }, +} + func init() { imageCmd.AddCommand(loadImageCmd) + imageCmd.AddCommand(removeImageCmd) loadImageCmd.Flags().BoolVar(&imgDaemon, "daemon", false, "Cache image from docker daemon") loadImageCmd.Flags().BoolVar(&imgRemote, "remote", false, "Cache image from remote registry") } diff --git a/pkg/generate/docs.go b/pkg/generate/docs.go index 17a2c2cad1b4..b8cbcded797e 100644 --- a/pkg/generate/docs.go +++ b/pkg/generate/docs.go @@ -96,6 +96,11 @@ func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) buf.WriteString(fmt.Sprintf("```shell\n%s\n```\n\n", cmd.UseLine())) } + if len(cmd.Aliases) > 0 { + buf.WriteString("### Aliases\n\n") + buf.WriteString(fmt.Sprintf("%s\n\n", cmd.Aliases)) + } + if len(cmd.Example) > 0 { buf.WriteString("### Examples\n\n") buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.Example)) diff --git a/pkg/minikube/cruntime/containerd.go b/pkg/minikube/cruntime/containerd.go index dee2a1dfd950..ca937cca9162 100644 --- a/pkg/minikube/cruntime/containerd.go +++ b/pkg/minikube/cruntime/containerd.go @@ -249,6 +249,11 @@ func (r *Containerd) LoadImage(path string) error { return nil } +// RemoveImage removes a image +func (r *Containerd) RemoveImage(name string) error { + return removeCRIImage(r.Runner, name) +} + // CGroupDriver returns cgroup driver ("cgroupfs" or "systemd") func (r *Containerd) CGroupDriver() (string, error) { info, err := getCRIInfo(r.Runner) diff --git a/pkg/minikube/cruntime/cri.go b/pkg/minikube/cruntime/cri.go index 1a9b46a0c7df..5ad274928707 100644 --- a/pkg/minikube/cruntime/cri.go +++ b/pkg/minikube/cruntime/cri.go @@ -187,6 +187,19 @@ func killCRIContainers(cr CommandRunner, ids []string) error { return nil } +// removeCRIImage remove image using crictl +func removeCRIImage(cr CommandRunner, name string) error { + klog.Infof("Removing image: %s", name) + + crictl := getCrictlPath(cr) + args := append([]string{crictl, "rmi"}, name) + c := exec.Command("sudo", args...) + if _, err := cr.RunCmd(c); err != nil { + return errors.Wrap(err, "crictl") + } + return nil +} + // stopCRIContainers stops containers using crictl func stopCRIContainers(cr CommandRunner, ids []string) error { if len(ids) == 0 { diff --git a/pkg/minikube/cruntime/crio.go b/pkg/minikube/cruntime/crio.go index 8483752d8682..63d961aed3a9 100644 --- a/pkg/minikube/cruntime/crio.go +++ b/pkg/minikube/cruntime/crio.go @@ -177,6 +177,11 @@ func (r *CRIO) LoadImage(path string) error { return nil } +// RemoveImage removes a image +func (r *CRIO) RemoveImage(name string) error { + return removeCRIImage(r.Runner, name) +} + // 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 f7111e91aff7..dd60a432a9e9 100644 --- a/pkg/minikube/cruntime/cruntime.go +++ b/pkg/minikube/cruntime/cruntime.go @@ -99,6 +99,9 @@ type Manager interface { // ImageExists takes image name and image sha checks if an it exists ImageExists(string, string) bool + // RemoveImage remove image based on name + RemoveImage(string) error + // ListContainers returns a list of managed by this container runtime ListContainers(ListOptions) ([]string, error) // KillContainers removes containers based on ID diff --git a/pkg/minikube/cruntime/cruntime_test.go b/pkg/minikube/cruntime/cruntime_test.go index 461809e56894..64cdf0af4169 100644 --- a/pkg/minikube/cruntime/cruntime_test.go +++ b/pkg/minikube/cruntime/cruntime_test.go @@ -63,14 +63,19 @@ func TestImageExists(t *testing.T) { sha string want bool }{ - {"docker", "missing", "0000000000000000000000000000000000000000000000000000000000000000", false}, - {"docker", "image", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", true}, - {"crio", "missing", "0000000000000000000000000000000000000000000000000000000000000000", false}, - {"crio", "image", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", true}, + {"docker", "missing-image", "0000000000000000000000000000000000000000000000000000000000000000", false}, + {"docker", "available-image", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", true}, + {"crio", "missing-image", "0000000000000000000000000000000000000000000000000000000000000000", false}, + {"crio", "available-image", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", true}, } for _, tc := range tests { + runner := NewFakeRunner(t) + runner.images = map[string]string{ + "available-image": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + } t.Run(tc.runtime, func(t *testing.T) { - r, err := New(Config{Type: tc.runtime, Runner: NewFakeRunner(t)}) + + r, err := New(Config{Type: tc.runtime, Runner: runner}) if err != nil { t.Fatalf("New(%s): %v", tc.runtime, err) } @@ -157,6 +162,7 @@ type FakeRunner struct { cmds []string services map[string]serviceState containers map[string]string + images map[string]string t *testing.T } @@ -167,6 +173,7 @@ func NewFakeRunner(t *testing.T) *FakeRunner { cmds: []string{}, t: t, containers: map[string]string{}, + images: map[string]string{}, } } @@ -277,10 +284,23 @@ func (f *FakeRunner) dockerRm(args []string) (string, error) { func (f *FakeRunner) dockerInspect(args []string) (string, error) { if args[1] == "--format" && args[2] == "{{.Id}}" { - if args[3] == "missing" { + image, ok := f.images[args[3]] + if !ok { return "", &exec.ExitError{Stderr: []byte("Error: No such object: missing")} } - return "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", nil + return "sha256:" + image, nil + } + return "", nil +} + +func (f *FakeRunner) dockerRmi(args []string) (string, error) { + // Skip "-f" argument + for _, id := range args[1:] { + f.t.Logf("fake docker: Removing id %q", id) + if f.images[id] == "" { + return "", fmt.Errorf("no such image") + } + delete(f.images, id) } return "", nil } @@ -308,6 +328,9 @@ func (f *FakeRunner) docker(args []string, _ bool) (string, error) { return f.dockerInspect(args[1:]) } + case "rmi": + return f.dockerRmi(args) + case "inspect": return f.dockerInspect(args) @@ -417,7 +440,14 @@ func (f *FakeRunner) crictl(args []string, _ bool) (string, error) { delete(f.containers, id) } - + case "rmi": + for _, id := range args[1:] { + f.t.Logf("fake crictl: Removing id %q", id) + if f.images[id] == "" { + return "", fmt.Errorf("no such image") + } + delete(f.images, id) + } } return "", nil } @@ -660,6 +690,9 @@ func TestContainerFunctions(t *testing.T) { "fgh1": prefix + "coredns", "xyz2": prefix + "storage", } + runner.images = map[string]string{ + "image1": "latest", + } cr, err := New(Config{Type: tc.runtime, Runner: runner}) if err != nil { t.Fatalf("New(%s): %v", tc.runtime, err) @@ -709,6 +742,14 @@ func TestContainerFunctions(t *testing.T) { if len(got) > 0 { t.Errorf("ListContainers(apiserver) = %v, want 0 items", got) } + + // Remove a image + if err := cr.RemoveImage("image1"); err != nil { + t.Fatalf("RemoveImage: %v", err) + } + if len(runner.images) > 0 { + t.Errorf("RemoveImage = %v, want 0 items", len(runner.images)) + } }) } } diff --git a/pkg/minikube/cruntime/docker.go b/pkg/minikube/cruntime/docker.go index 6ce546e9fc6f..84a6a59b7565 100644 --- a/pkg/minikube/cruntime/docker.go +++ b/pkg/minikube/cruntime/docker.go @@ -172,6 +172,19 @@ func (r *Docker) LoadImage(path string) error { return nil } +// RemoveImage removes a image +func (r *Docker) RemoveImage(name string) error { + klog.Infof("Removing image: %s", name) + if r.UseCRI { + return removeCRIImage(r.Runner, name) + } + c := exec.Command("docker", "rmi", name) + if _, err := r.Runner.RunCmd(c); err != nil { + return errors.Wrap(err, "remove image 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/cache_images.go b/pkg/minikube/machine/cache_images.go index 304d9a6b7085..bfdb975cf2dc 100644 --- a/pkg/minikube/machine/cache_images.go +++ b/pkg/minikube/machine/cache_images.go @@ -292,3 +292,83 @@ func transferAndLoadImage(cr command.Runner, k8s config.KubernetesConfig, src st klog.Infof("Transferred and loaded %s from cache", src) return nil } + +// removeImages removes images from the container run time +func removeImages(cruntime cruntime.Manager, images []string) error { + klog.Infof("RemovingImages start: %s", images) + start := time.Now() + + defer func() { + klog.Infof("RemovingImages completed in %s", time.Since(start)) + }() + + var g errgroup.Group + + for _, image := range images { + image := image + g.Go(func() error { + return cruntime.RemoveImage(image) + }) + } + if err := g.Wait(); err != nil { + return errors.Wrap(err, "error removing images") + } + klog.Infoln("Successfully removed images") + return nil +} + +func RemoveImages(images []string, profile *config.Profile) error { + api, err := NewAPIClient() + if err != nil { + return errors.Wrap(err, "error creating api client") + } + defer api.Close() + + succeeded := []string{} + failed := []string{} + + pName := profile.Name + + c, err := config.Load(pName) + if err != nil { + klog.Errorf("Failed to load profile %q: %v", pName, err) + return errors.Wrapf(err, "error loading config for profile :%v", pName) + } + + 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) + continue + } + + if status == state.Running.String() { + h, err := api.Load(m) + if err != nil { + klog.Warningf("Failed to load machine %q: %v", m, err) + continue + } + runner, err := CommandRunner(h) + if err != nil { + return err + } + cruntime, err := cruntime.New(cruntime.Config{Type: c.KubernetesConfig.ContainerRuntime, Runner: runner}) + if err != nil { + return errors.Wrap(err, "error creating container runtime") + } + err = removeImages(cruntime, images) + if err != nil { + failed = append(failed, m) + klog.Warningf("Failed to remove images for profile %s %v", pName, err.Error()) + continue + } + succeeded = append(succeeded, m) + } + } + + klog.Infof("succeeded removing from: %s", strings.Join(succeeded, " ")) + klog.Infof("failed removing from: %s", strings.Join(failed, " ")) + return nil +} diff --git a/pkg/minikube/reason/reason.go b/pkg/minikube/reason/reason.go index 854547b14f05..1bd8ebb20610 100644 --- a/pkg/minikube/reason/reason.go +++ b/pkg/minikube/reason/reason.go @@ -246,6 +246,7 @@ var ( GuestCpConfig = Kind{ID: "GUEST_CP_CONFIG", ExitCode: ExGuestConfig} GuestDeletion = Kind{ID: "GUEST_DELETION", ExitCode: ExGuestError} GuestImageLoad = Kind{ID: "GUEST_IMAGE_LOAD", ExitCode: ExGuestError} + GuestImageRemove = Kind{ID: "GUEST_IMAGE_REMOVE", 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 af13106b244d..d4a019adfefa 100644 --- a/site/content/en/docs/commands/image.md +++ b/site/content/en/docs/commands/image.md @@ -1,17 +1,17 @@ --- title: "image" description: > - Load a local image into minikube + Manage images --- ## minikube image -Load a local image into minikube +Manage images ### Synopsis -Load a local image into minikube +Manage images ### Options inherited from parent commands @@ -118,3 +118,51 @@ minikube image load image.tar --vmodule moduleSpec comma-separated list of pattern=N settings for file-filtered logging ``` +## minikube image rm + +Remove one or more images + +### Synopsis + +Remove one or more images + +```shell +minikube image rm IMAGE [IMAGE...] [flags] +``` + +### Aliases + +[unload] + +### Examples + +``` + +$ minikube image rm image busybox + +$ minikube image unload image busybox + +``` + +### 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 +``` + diff --git a/test/integration/functional_test.go b/test/integration/functional_test.go index aa7432e96231..4e921a73f9a1 100644 --- a/test/integration/functional_test.go +++ b/test/integration/functional_test.go @@ -133,6 +133,7 @@ func TestFunctional(t *testing.T) { {"DockerEnv", validateDockerEnv}, {"NodeLabels", validateNodeLabels}, {"LoadImage", validateLoadImage}, + {"RemoveImage", validateRemoveImage}, } for _, tc := range tests { tc := tc @@ -219,27 +220,80 @@ func validateLoadImage(ctx context.Context, t *testing.T, profile string) { } // make sure the image was correctly loaded + rr, err = inspectImage(ctx, t, profile, newImage) + if err != nil { + t.Fatalf("listing images: %v\n%s", err, rr.Output()) + } + if !strings.Contains(rr.Output(), newImage) { + t.Fatalf("expected %s to be loaded into minikube but the image is not there", newImage) + } + +} + +// validateRemoveImage makes sures that `minikube rm image` works as expected +func validateRemoveImage(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) + + // pull busybox + busyboxImage := "busybox:latest" + rr, err := Run(t, exec.CommandContext(ctx, "docker", "pull", busyboxImage)) + if err != nil { + t.Fatalf("failed to setup test (pull image): %v\n%s", err, rr.Output()) + } + + // try to load the image into minikube + rr, err = Run(t, exec.CommandContext(ctx, Target(), "-p", profile, "image", "load", busyboxImage)) + if err != nil { + t.Fatalf("loading image into minikube: %v\n%s", err, rr.Output()) + } + + // try to remove the image from minikube + rr, err = Run(t, exec.CommandContext(ctx, Target(), "-p", profile, "image", "rm", busyboxImage)) + if err != nil { + 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", "image", "inspect", newImage) - } else if ContainerRuntime() == "containerd" { - // crictl inspecti busybox:test-example - cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "sudo", "crictl", "inspecti", newImage) + cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "docker", "images") } else { - // crio adds localhost prefix - // crictl inspecti localhost/busybox:test-example - cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "sudo", "crictl", "inspecti", "localhost/"+newImage) + cmd = exec.CommandContext(ctx, Target(), "ssh", "-p", profile, "--", "sudo", "crictl", "images") } rr, err = Run(t, cmd) if err != nil { t.Fatalf("listing images: %v\n%s", err, rr.Output()) } - if !strings.Contains(rr.Output(), newImage) { - t.Fatalf("expected %s to be loaded into minikube but the image is not there", newImage) + if strings.Contains(rr.Output(), busyboxImage) { + t.Fatalf("expected %s to be removed from minikube but the image is there", busyboxImage) } } +func inspectImage(ctx context.Context, t *testing.T, profile string, image string) (*RunResult, error) { + 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) + } + rr, err := Run(t, cmd) + if err != nil { + return rr, err + } + return rr, nil +} + // 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) {