diff --git a/cmd/minikube/cmd/image.go b/cmd/minikube/cmd/image.go index ec2a349e6f..82e7658280 100644 --- a/cmd/minikube/cmd/image.go +++ b/cmd/minikube/cmd/image.go @@ -54,6 +54,7 @@ var ( dockerFile string buildEnv []string buildOpt []string + format string ) func saveFile(r io.Reader) (string, error) { @@ -331,7 +332,7 @@ $ minikube image ls exit.Error(reason.Usage, "loading profile", err) } - if err := machine.ListImages(profile); err != nil { + if err := machine.ListImages(profile, format); err != nil { exit.Error(reason.GuestImageList, "Failed to list images", err) } }, @@ -396,6 +397,7 @@ func init() { saveImageCmd.Flags().BoolVar(&imgDaemon, "daemon", false, "Cache image to docker daemon") saveImageCmd.Flags().BoolVar(&imgRemote, "remote", false, "Cache image to remote registry") imageCmd.AddCommand(saveImageCmd) + listImageCmd.Flags().StringVar(&format, "format", "short", "Format output. One of: short|table|json|yaml") imageCmd.AddCommand(listImageCmd) imageCmd.AddCommand(tagImageCmd) imageCmd.AddCommand(pushImageCmd) diff --git a/pkg/minikube/cruntime/containerd.go b/pkg/minikube/cruntime/containerd.go index 8dd1ab510b..9cf6947669 100644 --- a/pkg/minikube/cruntime/containerd.go +++ b/pkg/minikube/cruntime/containerd.go @@ -283,21 +283,8 @@ func (r *Containerd) ImageExists(name string, sha string) bool { } // ListImages lists images managed by this container runtime -func (r *Containerd) ListImages(ListImagesOptions) ([]string, error) { - c := exec.Command("sudo", "ctr", "-n=k8s.io", "images", "list", "--quiet") - rr, err := r.Runner.RunCmd(c) - if err != nil { - return nil, errors.Wrapf(err, "ctr images list") - } - all := strings.Split(rr.Stdout.String(), "\n") - imgs := []string{} - for _, img := range all { - if img == "" || strings.Contains(img, "sha256:") { - continue - } - imgs = append(imgs, img) - } - return imgs, nil +func (r *Containerd) ListImages(ListImagesOptions) ([]ListImage, error) { + return listCRIImages(r.Runner) } // LoadImage loads an image into this runtime @@ -593,16 +580,6 @@ func containerdImagesPreloaded(runner command.Runner, images []string) bool { if err != nil { return false } - type crictlImages struct { - Images []struct { - ID string `json:"id"` - RepoTags []string `json:"repoTags"` - RepoDigests []string `json:"repoDigests"` - Size string `json:"size"` - UID interface{} `json:"uid"` - Username string `json:"username"` - } `json:"images"` - } var jsonImages crictlImages err = json.Unmarshal(rr.Stdout.Bytes(), &jsonImages) diff --git a/pkg/minikube/cruntime/cri.go b/pkg/minikube/cruntime/cri.go index 0d88580da2..ae9be08243 100644 --- a/pkg/minikube/cruntime/cri.go +++ b/pkg/minikube/cruntime/cri.go @@ -36,6 +36,17 @@ type container struct { Status string } +type crictlImages struct { + Images []struct { + ID string `json:"id"` + RepoTags []string `json:"repoTags"` + RepoDigests []string `json:"repoDigests"` + Size string `json:"size"` + UID interface{} `json:"uid"` + Username string `json:"username"` + } `json:"images"` +} + // crictlList returns the output of 'crictl ps' in an efficient manner func crictlList(cr CommandRunner, root string, o ListContainersOptions) (*command.RunResult, error) { klog.Infof("listing CRI containers in root %s: %+v", root, o) @@ -267,6 +278,33 @@ func getCRIInfo(cr CommandRunner) (map[string]interface{}, error) { return jsonMap, nil } +// listCRIImages lists images using crictl +func listCRIImages(cr CommandRunner) ([]ListImage, error) { + c := exec.Command("sudo", "crictl", "images", "--output", "json") + rr, err := cr.RunCmd(c) + if err != nil { + return nil, errors.Wrapf(err, "crictl images") + } + + var jsonImages crictlImages + err = json.Unmarshal(rr.Stdout.Bytes(), &jsonImages) + if err != nil { + klog.Errorf("failed to unmarshal images, will assume images are not preloaded") + return nil, err + } + + images := []ListImage{} + for _, img := range jsonImages.Images { + images = append(images, ListImage{ + ID: img.ID, + RepoDigests: img.RepoDigests, + RepoTags: img.RepoTags, + Size: img.Size, + }) + } + return images, nil +} + // criContainerLogCmd returns the command to retrieve the log for a container based on ID func criContainerLogCmd(cr CommandRunner, id string, len int, follow bool) string { crictl := getCrictlPath(cr) diff --git a/pkg/minikube/cruntime/crio.go b/pkg/minikube/cruntime/crio.go index 1d36e76be4..85ea766b21 100644 --- a/pkg/minikube/cruntime/crio.go +++ b/pkg/minikube/cruntime/crio.go @@ -240,13 +240,8 @@ func (r *CRIO) ImageExists(name string, sha string) bool { } // ListImages returns a list of images managed by this container runtime -func (r *CRIO) ListImages(ListImagesOptions) ([]string, error) { - c := exec.Command("sudo", "podman", "images", "--format", "{{.Repository}}:{{.Tag}}") - rr, err := r.Runner.RunCmd(c) - if err != nil { - return nil, errors.Wrapf(err, "podman images") - } - return strings.Split(strings.TrimSpace(rr.Stdout.String()), "\n"), nil +func (r *CRIO) ListImages(ListImagesOptions) ([]ListImage, error) { + return listCRIImages(r.Runner) } // LoadImage loads an image into this runtime @@ -465,16 +460,6 @@ func crioImagesPreloaded(runner command.Runner, images []string) bool { if err != nil { return false } - type crictlImages struct { - Images []struct { - ID string `json:"id"` - RepoTags []string `json:"repoTags"` - RepoDigests []string `json:"repoDigests"` - Size string `json:"size"` - UID interface{} `json:"uid"` - Username string `json:"username"` - } `json:"images"` - } var jsonImages crictlImages err = json.Unmarshal(rr.Stdout.Bytes(), &jsonImages) diff --git a/pkg/minikube/cruntime/cruntime.go b/pkg/minikube/cruntime/cruntime.go index 576e9bcf42..5fb49e93bb 100644 --- a/pkg/minikube/cruntime/cruntime.go +++ b/pkg/minikube/cruntime/cruntime.go @@ -113,7 +113,7 @@ type Manager interface { // ImageExists takes image name and optionally image sha to check if an image exists ImageExists(string, string) bool // ListImages returns a list of images managed by this container runtime - ListImages(ListImagesOptions) ([]string, error) + ListImages(ListImagesOptions) ([]ListImage, error) // RemoveImage remove image based on name RemoveImage(string) error @@ -168,6 +168,13 @@ type ListContainersOptions struct { type ListImagesOptions struct { } +type ListImage struct { + ID string `json:"id" yaml:"id"` + RepoDigests []string `json:"repoDigests" yaml:"repoDigests"` + RepoTags []string `json:"repoTags" yaml:"repoTags"` + Size string `json:"size" yaml:"size"` +} + // ErrContainerRuntimeNotRunning is thrown when container runtime is not running var ErrContainerRuntimeNotRunning = errors.New("container runtime is not running") diff --git a/pkg/minikube/cruntime/docker.go b/pkg/minikube/cruntime/docker.go index 3077265812..3ddcc449ae 100644 --- a/pkg/minikube/cruntime/docker.go +++ b/pkg/minikube/cruntime/docker.go @@ -17,6 +17,7 @@ limitations under the License. package cruntime import ( + "encoding/json" "fmt" "os" "os/exec" @@ -25,6 +26,7 @@ import ( "time" "github.com/blang/semver/v4" + units "github.com/docker/go-units" "github.com/pkg/errors" "k8s.io/klog/v2" "k8s.io/minikube/pkg/minikube/assets" @@ -183,22 +185,43 @@ func (r *Docker) ImageExists(name string, sha string) bool { } // ListImages returns a list of images managed by this container runtime -func (r *Docker) ListImages(ListImagesOptions) ([]string, error) { - c := exec.Command("docker", "images", "--format", "{{.Repository}}:{{.Tag}}") +func (r *Docker) ListImages(ListImagesOptions) ([]ListImage, error) { + c := exec.Command("docker", "images", "--no-trunc", "--format", "{{json .}}") rr, err := r.Runner.RunCmd(c) if err != nil { return nil, errors.Wrapf(err, "docker images") } - short := strings.Split(rr.Stdout.String(), "\n") - imgs := []string{} - for _, img := range short { + type dockerImage struct { + ID string `json:"ID"` + Repository string `json:"Repository"` + Tag string `json:"Tag"` + Size string `json:"Size"` + } + images := strings.Split(rr.Stdout.String(), "\n") + result := []ListImage{} + for _, img := range images { if img == "" { continue } - img = addDockerIO(img) - imgs = append(imgs, img) + + var jsonImage dockerImage + if err := json.Unmarshal([]byte(img), &jsonImage); err != nil { + return nil, errors.Wrap(err, "Image convert problem") + } + size, err := units.FromHumanSize(jsonImage.Size) + if err != nil { + return nil, errors.Wrap(err, "Image size convert problem") + } + + repoTag := fmt.Sprintf("%s:%s", jsonImage.Repository, jsonImage.Tag) + result = append(result, ListImage{ + ID: strings.TrimPrefix(jsonImage.ID, "sha256:"), + RepoDigests: []string{}, + RepoTags: []string{addDockerIO(repoTag)}, + Size: fmt.Sprintf("%d", size), + }) } - return imgs, nil + return result, nil } // LoadImage loads an image into this runtime diff --git a/pkg/minikube/machine/cache_images.go b/pkg/minikube/machine/cache_images.go index cedcc62db9..923e78a8f3 100644 --- a/pkg/minikube/machine/cache_images.go +++ b/pkg/minikube/machine/cache_images.go @@ -17,20 +17,25 @@ limitations under the License. package machine import ( + "encoding/json" "fmt" "os" "os/exec" "path" "path/filepath" "sort" + "strconv" "strings" "sync" "time" "github.com/docker/docker/client" + "github.com/docker/go-units" "github.com/docker/machine/libmachine/state" + "github.com/olekukonko/tablewriter" "github.com/pkg/errors" "golang.org/x/sync/errgroup" + "gopkg.in/yaml.v2" "k8s.io/klog/v2" "k8s.io/minikube/pkg/minikube/assets" "k8s.io/minikube/pkg/minikube/bootstrapper" @@ -666,7 +671,7 @@ func RemoveImages(images []string, profile *config.Profile) error { } // ListImages lists images on all nodes in profile -func ListImages(profile *config.Profile) error { +func ListImages(profile *config.Profile, format string) error { api, err := NewAPIClient() if err != nil { return errors.Wrap(err, "error creating api client") @@ -681,6 +686,7 @@ func ListImages(profile *config.Profile) error { return errors.Wrapf(err, "error loading config for profile :%v", pName) } + images := map[string]cruntime.ListImage{} for _, n := range c.Nodes { m := config.MachineName(*c, n) @@ -709,14 +715,100 @@ func ListImages(profile *config.Profile) error { klog.Warningf("Failed to list images for profile %s %v", pName, err.Error()) continue } - sort.Sort(sort.Reverse(sort.StringSlice(list))) - fmt.Printf(strings.Join(list, "\n") + "\n") + + for _, img := range list { + if _, ok := images[img.ID]; !ok { + images[img.ID] = img + } + } } } + uniqueImages := []cruntime.ListImage{} + for _, img := range images { + uniqueImages = append(uniqueImages, img) + } + + switch format { + case "table": + var data [][]string + for _, item := range uniqueImages { + imageSize := humanImageSize(item.Size) + id := parseImageID(item.ID) + for _, img := range item.RepoTags { + imageName, tag := parseRepoTag(img) + if imageName == "" { + continue + } + data = append(data, []string{imageName, tag, id, imageSize}) + } + } + renderImagesTable(data) + case "json": + json, err := json.Marshal(uniqueImages) + if err != nil { + klog.Warningf("Error marshalling images list: %v", err.Error()) + return nil + } + fmt.Printf(string(json) + "\n") + case "yaml": + yaml, err := yaml.Marshal(uniqueImages) + if err != nil { + klog.Warningf("Error marshalling images list: %v", err.Error()) + return nil + } + fmt.Printf(string(yaml) + "\n") + default: + res := []string{} + for _, item := range uniqueImages { + res = append(res, item.RepoTags...) + } + sort.Sort(sort.Reverse(sort.StringSlice(res))) + fmt.Printf(strings.Join(res, "\n") + "\n") + } + return nil } +// parseRepoTag splits input string for two parts: image name and image tag +func parseRepoTag(repoTag string) (string, string) { + idx := strings.LastIndex(repoTag, ":") + if idx == -1 { + return "", "" + } + return repoTag[:idx], repoTag[idx+1:] +} + +// parseImageID truncates image id +func parseImageID(id string) string { + maxImageIDLen := 13 + if len(id) > maxImageIDLen { + return id[:maxImageIDLen] + } + return id +} + +// humanImageSize prints size of image in human readable format +func humanImageSize(imageSize string) string { + f, err := strconv.ParseFloat(imageSize, 32) + if err == nil { + return units.HumanSizeWithPrecision(f, 3) + } + return imageSize +} + +// renderImagesTable renders pretty table for images list +func renderImagesTable(images [][]string) { + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Image", "Tag", "Image ID", "Size"}) + table.SetAutoFormatHeaders(false) + table.SetBorders(tablewriter.Border{Left: true, Top: true, Right: true, Bottom: true}) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("|") + table.AppendBulk(images) + table.Render() +} + // TagImage tags image in all nodes in profile func TagImage(profile *config.Profile, source string, target string) error { api, err := NewAPIClient() diff --git a/site/content/en/docs/commands/image.md b/site/content/en/docs/commands/image.md index ccee3713f9..36893944b9 100644 --- a/site/content/en/docs/commands/image.md +++ b/site/content/en/docs/commands/image.md @@ -196,6 +196,12 @@ $ minikube image ls ``` +### Options + +``` + --format string Format output. One of: short|table|json|yaml (default "short") +``` + ### Options inherited from parent commands ``` diff --git a/test/integration/functional_test.go b/test/integration/functional_test.go index 19dbab8a17..a268c058ff 100644 --- a/test/integration/functional_test.go +++ b/test/integration/functional_test.go @@ -245,22 +245,13 @@ func tagAndLoadImage(ctx context.Context, t *testing.T, profile, taggedImage str checkImageExists(ctx, t, profile, taggedImage) } -// validateImageCommands runs tests on all the `minikube image` commands, ex. `minikube image load`, `minikube image list`, etc. -func validateImageCommands(ctx context.Context, t *testing.T, profile string) { - // docs(skip): Skips on `none` driver as image loading is not supported - if NoneDriver() { - t.Skip("image commands are not available on the none driver") - } - // docs(skip): Skips on GitHub Actions and macOS as this test case requires a running docker daemon - if GithubActionRunner() && runtime.GOOS == "darwin" { - t.Skip("skipping on darwin github action runners, as this test requires a running docker daemon") - } - +// runImageList is a helper function to run 'image ls' command test. +func runImageList(ctx context.Context, t *testing.T, profile, testName, format string, expectedResult []string) { // docs: Make sure image listing works by `minikube image ls` - t.Run("ImageList", func(t *testing.T) { + t.Run(testName, func(t *testing.T) { MaybeParallel(t) - rr, err := Run(t, exec.CommandContext(ctx, Target(), "-p", profile, "image", "ls")) + rr, err := Run(t, exec.CommandContext(ctx, Target(), "-p", profile, "image", "ls", "--format", format)) if err != nil { t.Fatalf("listing image with minikube: %v\n%s", err, rr.Output()) } @@ -272,12 +263,29 @@ func validateImageCommands(ctx context.Context, t *testing.T, profile string) { } list := rr.Output() - for _, theImage := range []string{"k8s.gcr.io/pause", "docker.io/kubernetesui/dashboard"} { + for _, theImage := range expectedResult { if !strings.Contains(list, theImage) { t.Fatalf("expected %s to be listed with minikube but the image is not there", theImage) } } }) +} + +// validateImageCommands runs tests on all the `minikube image` commands, ex. `minikube image load`, `minikube image list`, etc. +func validateImageCommands(ctx context.Context, t *testing.T, profile string) { + // docs(skip): Skips on `none` driver as image loading is not supported + if NoneDriver() { + t.Skip("image commands are not available on the none driver") + } + // docs(skip): Skips on GitHub Actions and macOS as this test case requires a running docker daemon + if GithubActionRunner() && runtime.GOOS == "darwin" { + t.Skip("skipping on darwin github action runners, as this test requires a running docker daemon") + } + + runImageList(ctx, t, profile, "ImageListShort", "short", []string{"k8s.gcr.io/pause", "docker.io/kubernetesui/dashboard"}) + runImageList(ctx, t, profile, "ImageListTable", "table", []string{"| k8s.gcr.io/pause", "| docker.io/kubernetesui/dashboard"}) + runImageList(ctx, t, profile, "ImageListJson", "json", []string{"[\"k8s.gcr.io/pause", "[\"docker.io/kubernetesui/dashboard"}) + runImageList(ctx, t, profile, "ImageListYaml", "yaml", []string{"- k8s.gcr.io/pause", "- docker.io/kubernetesui/dashboard"}) // docs: Make sure image building works by `minikube image build` t.Run("ImageBuild", func(t *testing.T) { diff --git a/translations/de.json b/translations/de.json index fc4d8509f9..a3d5fa3be9 100644 --- a/translations/de.json +++ b/translations/de.json @@ -296,6 +296,7 @@ "For more information, see: {{.url}}": "", "Force environment to be configured for a specified shell: [fish, cmd, powershell, tcsh, bash, zsh], default is auto-detect": "", "Force minikube to perform possibly dangerous operations": "minikube zwingen, möglicherweise gefährliche Operationen durchzuführen", + "Format output. One of: short|table|json|yaml": "", "Format to print stdout in. Options include: [text,json]": "", "Found docker, but the docker service isn't running. Try restarting the docker service.": "", "Found driver(s) but none were healthy. See above for suggestions how to fix installed drivers.": "", diff --git a/translations/es.json b/translations/es.json index 8129b549f3..ce2b398068 100644 --- a/translations/es.json +++ b/translations/es.json @@ -305,6 +305,7 @@ "For more information, see: {{.url}}": "", "Force environment to be configured for a specified shell: [fish, cmd, powershell, tcsh, bash, zsh], default is auto-detect": "", "Force minikube to perform possibly dangerous operations": "Permite forzar minikube para que realice operaciones potencialmente peligrosas", + "Format output. One of: short|table|json|yaml": "", "Format to print stdout in. Options include: [text,json]": "", "Found docker, but the docker service isn't running. Try restarting the docker service.": "", "Found driver(s) but none were healthy. See above for suggestions how to fix installed drivers.": "", diff --git a/translations/fr.json b/translations/fr.json index a662a116c0..9a40ec6e8a 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -285,6 +285,7 @@ "For more information, see: {{.url}}": "Pour plus d'informations, voir : {{.url}}", "Force environment to be configured for a specified shell: [fish, cmd, powershell, tcsh, bash, zsh], default is auto-detect": "Forcer l'environnement à être configuré pour un shell spécifié : [fish, cmd, powershell, tcsh, bash, zsh], la valeur par défaut est la détection automatique", "Force minikube to perform possibly dangerous operations": "Oblige minikube à réaliser des opérations possiblement dangereuses.", + "Format output. One of: short|table|json|yaml": "", "Format to print stdout in. Options include: [text,json]": "Format dans lequel imprimer la sortie standard. Les options incluent : [text,json]", "Found docker, but the docker service isn't running. Try restarting the docker service.": "Docker trouvé, mais le service docker ne fonctionne pas. Essayez de redémarrer le service Docker.", "Found driver(s) but none were healthy. See above for suggestions how to fix installed drivers.": "Pilote(s) trouvé(s) mais aucun n'était en fonctionnement. Voir ci-dessus pour des suggestions sur la façon de réparer les pilotes installés.", diff --git a/translations/ja.json b/translations/ja.json index 57a1a0dfc5..78bbe9d59c 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -293,6 +293,7 @@ "For more information, see: {{.url}}": "", "Force environment to be configured for a specified shell: [fish, cmd, powershell, tcsh, bash, zsh], default is auto-detect": "", "Force minikube to perform possibly dangerous operations": "minikube で危険な可能性のある操作を強制的に実行します", + "Format output. One of: short|table|json|yaml": "", "Format to print stdout in. Options include: [text,json]": "", "Found docker, but the docker service isn't running. Try restarting the docker service.": "", "Found driver(s) but none were healthy. See above for suggestions how to fix installed drivers.": "", diff --git a/translations/ko.json b/translations/ko.json index 36098b572b..e737e90ba3 100644 --- a/translations/ko.json +++ b/translations/ko.json @@ -320,6 +320,7 @@ "For more information, see: {{.url}}": "", "Force environment to be configured for a specified shell: [fish, cmd, powershell, tcsh, bash, zsh], default is auto-detect": "", "Force minikube to perform possibly dangerous operations": "", + "Format output. One of: short|table|json|yaml": "", "Format to print stdout in. Options include: [text,json]": "", "Found docker, but the docker service isn't running. Try restarting the docker service.": "", "Found driver(s) but none were healthy. See above for suggestions how to fix installed drivers.": "", diff --git a/translations/pl.json b/translations/pl.json index 9b503f0813..94b55559fc 100644 --- a/translations/pl.json +++ b/translations/pl.json @@ -306,6 +306,7 @@ "For more information, see: {{.url}}": "", "Force environment to be configured for a specified shell: [fish, cmd, powershell, tcsh, bash, zsh], default is auto-detect": "", "Force minikube to perform possibly dangerous operations": "Wymuś wykonanie potencjalnie niebezpiecznych operacji", + "Format output. One of: short|table|json|yaml": "", "Format to print stdout in. Options include: [text,json]": "", "Found docker, but the docker service isn't running. Try restarting the docker service.": "", "Found driver(s) but none were healthy. See above for suggestions how to fix installed drivers.": "", diff --git a/translations/ru.json b/translations/ru.json index 211372763b..008b6bc424 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -277,6 +277,7 @@ "For more information, see: {{.url}}": "", "Force environment to be configured for a specified shell: [fish, cmd, powershell, tcsh, bash, zsh], default is auto-detect": "", "Force minikube to perform possibly dangerous operations": "", + "Format output. One of: short|table|json|yaml": "", "Format to print stdout in. Options include: [text,json]": "", "Found docker, but the docker service isn't running. Try restarting the docker service.": "", "Found driver(s) but none were healthy. See above for suggestions how to fix installed drivers.": "", diff --git a/translations/strings.txt b/translations/strings.txt index 7a1cdc638b..ebbd1f9acf 100644 --- a/translations/strings.txt +++ b/translations/strings.txt @@ -277,6 +277,7 @@ "For more information, see: {{.url}}": "", "Force environment to be configured for a specified shell: [fish, cmd, powershell, tcsh, bash, zsh], default is auto-detect": "", "Force minikube to perform possibly dangerous operations": "", + "Format output. One of: short|table|json|yaml": "", "Format to print stdout in. Options include: [text,json]": "", "Found docker, but the docker service isn't running. Try restarting the docker service.": "", "Found driver(s) but none were healthy. See above for suggestions how to fix installed drivers.": "", diff --git a/translations/zh-CN.json b/translations/zh-CN.json index 024f5b4455..925e0c58b7 100644 --- a/translations/zh-CN.json +++ b/translations/zh-CN.json @@ -374,6 +374,7 @@ "For more information, see: {{.url}}": "", "Force environment to be configured for a specified shell: [fish, cmd, powershell, tcsh, bash, zsh], default is auto-detect": "强制为指定的 shell 配置环境:[fish, cmd, powershell, tcsh, bash, zsh],默认为 auto-detect", "Force minikube to perform possibly dangerous operations": "强制 minikube 执行可能有风险的操作", + "Format output. One of: short|table|json|yaml": "", "Format to print stdout in. Options include: [text,json]": "", "Found docker, but the docker service isn't running. Try restarting the docker service.": "", "Found driver(s) but none were healthy. See above for suggestions how to fix installed drivers.": "",