diff --git a/cmd/kpod/common.go b/cmd/kpod/common.go index 6475debe..17f0a94d 100644 --- a/cmd/kpod/common.go +++ b/cmd/kpod/common.go @@ -1,7 +1,11 @@ package main import ( + "encoding/json" + "fmt" "io" + "strings" + "time" cp "github.com/containers/image/copy" is "github.com/containers/image/storage" @@ -11,10 +15,21 @@ import ( "github.com/urfave/cli" ) +type imageMetadata struct { + Tag string `json:"tag"` + CreatedTime time.Time `json:"created-time"` + ID string `json:"id"` + Blobs []types.BlobInfo `json:"blob-list"` + Layers map[string][]string `json:"layers"` + SignatureSizes []string `json:"signature-sizes"` +} + func getStore(c *cli.Context) (storage.Store, error) { options := storage.DefaultStoreOptions - if c.GlobalIsSet("root") || c.GlobalIsSet("runroot") { + if c.GlobalIsSet("root") { options.GraphRoot = c.GlobalString("root") + } + if c.GlobalIsSet("runroot") { options.RunRoot = c.GlobalString("runroot") } @@ -67,3 +82,34 @@ func getSystemContext(signaturePolicyPath string) *types.SystemContext { } return sc } + +func parseMetadata(image storage.Image) (imageMetadata, error) { + var im imageMetadata + + dec := json.NewDecoder(strings.NewReader(image.Metadata)) + if err := dec.Decode(&im); err != nil { + return imageMetadata{}, err + } + return im, nil +} + +func getSize(image storage.Image, store storage.Store) (int64, error) { + + is.Transport.SetStore(store) + storeRef, err := is.Transport.ParseStoreReference(store, "@"+image.ID) + if err != nil { + fmt.Println(err) + return -1, err + } + img, err := storeRef.NewImage(nil) + if err != nil { + fmt.Println("Error with NewImage") + return -1, err + } + imgSize, err := img.Size() + if err != nil { + fmt.Println("Error getting size") + return -1, err + } + return imgSize, nil +} diff --git a/cmd/kpod/common_test.go b/cmd/kpod/common_test.go index b975fcd3..8bb78568 100644 --- a/cmd/kpod/common_test.go +++ b/cmd/kpod/common_test.go @@ -1,21 +1,20 @@ package main import ( + "os/exec" "os/user" "testing" "flag" + is "github.com/containers/image/storage" + "github.com/containers/storage" "github.com/urfave/cli" ) func TestGetStore(t *testing.T) { - u, err := user.Current() - if err != nil { - t.Log("Could not determine user. Running as root may cause tests to fail") - } else if u.Uid != "0" { - t.Fatal("tests will fail unless run as root") - } + // Make sure the tests are running as root + failTestIfNotRoot(t) set := flag.NewFlagSet("test", 0) globalSet := flag.NewFlagSet("test", 0) @@ -25,8 +24,72 @@ func TestGetStore(t *testing.T) { c := cli.NewContext(nil, set, globalCtx) c.Command = command - _, err = getStore(c) + _, err := getStore(c) if err != nil { t.Error(err) } } + +func TestParseMetadata(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } else if len(images) == 0 { + t.Fatalf("no images with metadata to parse") + } + + _, err = parseMetadata(images[0]) + if err != nil { + t.Error(err) + } +} + +func TestGetSize(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + _, err = getSize(images[0], store) + if err != nil { + t.Error(err) + } +} + +func failTestIfNotRoot(t *testing.T) { + u, err := user.Current() + if err != nil { + t.Log("Could not determine user. Running without root may cause tests to fail") + } else if u.Uid != "0" { + t.Fatal("tests will fail unless run as root") + } +} + +func pullTestImage(name string) error { + cmd := exec.Command("crioctl", "image", "pull", name) + err := cmd.Run() + if err != nil { + return err + } + return nil +} diff --git a/cmd/kpod/images.go b/cmd/kpod/images.go new file mode 100644 index 00000000..7dfbb9ed --- /dev/null +++ b/cmd/kpod/images.go @@ -0,0 +1,378 @@ +package main + +import ( + "fmt" + "os" + "strings" + "text/template" + + is "github.com/containers/image/storage" + "github.com/containers/storage" + "github.com/pkg/errors" + "github.com/urfave/cli" +) + +type imageOutputParams struct { + ID string + Name string + Digest string + CreatedAt string + Size string +} + +type filterParams struct { + dangling string + label string + beforeImage string // Images are sorted by date, so we can just output until we see the image + sinceImage string // Images are sorted by date, so we can just output until we don't see the image + seenImage bool // Hence this boolean + referencePattern string +} + +var ( + imagesFlags = []cli.Flag{ + cli.BoolFlag{ + Name: "quiet, q", + Usage: "display only image IDs", + }, + cli.BoolFlag{ + Name: "noheading, n", + Usage: "do not print column headings", + }, + cli.BoolFlag{ + Name: "no-trunc, notruncate", + Usage: "do not truncate output", + }, + cli.BoolFlag{ + Name: "digests", + Usage: "show digests", + }, + cli.StringFlag{ + Name: "format", + Usage: "pretty-print images using a Go template. will override --quiet", + }, + cli.StringFlag{ + Name: "filter, f", + Usage: "filter output based on conditions provided (default [])", + }, + } + + imagesDescription = "lists locally stored images." + imagesCommand = cli.Command{ + Name: "images", + Usage: "list images in local storage", + Description: imagesDescription, + Flags: imagesFlags, + Action: imagesCmd, + ArgsUsage: "", + } +) + +func imagesCmd(c *cli.Context) error { + store, err := getStore(c) + if err != nil { + return err + } + + quiet := false + if c.IsSet("quiet") { + quiet = c.Bool("quiet") + } + noheading := false + if c.IsSet("noheading") { + noheading = c.Bool("noheading") + } + truncate := true + if c.IsSet("no-trunc") { + truncate = !c.Bool("no-trunc") + } + digests := false + if c.IsSet("digests") { + digests = c.Bool("digests") + } + formatString := "" + hasTemplate := false + if c.IsSet("format") { + formatString = c.String("format") + hasTemplate = true + } + + name := "" + if len(c.Args()) == 1 { + name = c.Args().Get(0) + } else if len(c.Args()) > 1 { + return errors.New("'buildah images' requires at most 1 argument") + } + + images, err := store.Images() + if err != nil { + return errors.Wrapf(err, "error reading images") + } + + var params *filterParams + if c.IsSet("filter") { + params, err = parseFilter(images, c.String("filter")) + if err != nil { + return errors.Wrapf(err, "error parsing filter") + } + } else { + params = nil + } + + if len(images) > 0 && !noheading && !quiet && !hasTemplate { + outputHeader(truncate, digests) + } + + return outputImages(images, formatString, store, params, name, hasTemplate, truncate, digests, quiet) +} + +func parseFilter(images []storage.Image, filter string) (*filterParams, error) { + params := new(filterParams) + filterStrings := strings.Split(filter, ",") + for _, param := range filterStrings { + pair := strings.SplitN(param, "=", 2) + switch strings.TrimSpace(pair[0]) { + case "dangling": + if pair[1] == "true" || pair[1] == "false" { + params.dangling = pair[1] + } else { + return nil, fmt.Errorf("invalid filter: '%s=[%s]'", pair[0], pair[1]) + } + case "label": + params.label = pair[1] + case "before": + if imageExists(images, pair[1]) { + params.beforeImage = pair[1] + } else { + return nil, fmt.Errorf("no such id: %s", pair[0]) + } + case "since": + if imageExists(images, pair[1]) { + params.sinceImage = pair[1] + } else { + return nil, fmt.Errorf("no such id: %s``", pair[0]) + } + case "reference": + params.referencePattern = pair[1] + default: + return nil, fmt.Errorf("invalid filter: '%s'", pair[0]) + } + } + return params, nil +} + +func imageExists(images []storage.Image, ref string) bool { + for _, image := range images { + if matchesID(image.ID, ref) { + return true + } + for _, name := range image.Names { + if matchesReference(name, ref) { + return true + } + } + } + return false +} + +func outputHeader(truncate, digests bool) { + if truncate { + fmt.Printf("%-20s %-56s ", "IMAGE ID", "IMAGE NAME") + } else { + fmt.Printf("%-64s %-56s ", "IMAGE ID", "IMAGE NAME") + } + + if digests { + fmt.Printf("%-64s ", "DIGEST") + } + + fmt.Printf("%-22s %s\n", "CREATED AT", "SIZE") +} + +func outputImages(images []storage.Image, format string, store storage.Store, filters *filterParams, argName string, hasTemplate, truncate, digests, quiet bool) error { + for _, image := range images { + imageMetadata, err := parseMetadata(image) + if err != nil { + fmt.Println(err) + } + createdTime := imageMetadata.CreatedTime.Format("Jan 2, 2006 15:04") + digest := "" + if len(imageMetadata.Blobs) > 0 { + digest = string(imageMetadata.Blobs[0].Digest) + } + size, _ := getSize(image, store) + + names := []string{""} + if len(image.Names) > 0 { + names = image.Names + } else { + // images without names should be printed with "" as the image name + names = append(names, "") + } + for _, name := range names { + if !matchesFilter(image, store, name, filters) || !matchesReference(name, argName) { + continue + } + if quiet { + fmt.Printf("%-64s\n", image.ID) + // We only want to print each id once + break + } + + params := imageOutputParams{ + ID: image.ID, + Name: name, + Digest: digest, + CreatedAt: createdTime, + Size: formattedSize(size), + } + if hasTemplate { + err = outputUsingTemplate(format, params) + if err != nil { + return err + } + continue + } + + outputUsingFormatString(truncate, digests, params) + } + } + return nil +} + +func matchesFilter(image storage.Image, store storage.Store, name string, params *filterParams) bool { + if params == nil { + return true + } + if params.dangling != "" && !matchesDangling(name, params.dangling) { + return false + } else if params.label != "" && !matchesLabel(image, store, params.label) { + return false + } else if params.beforeImage != "" && !matchesBeforeImage(image, name, params) { + return false + } else if params.sinceImage != "" && !matchesSinceImage(image, name, params) { + return false + } else if params.referencePattern != "" && !matchesReference(name, params.referencePattern) { + return false + } + return true +} + +func matchesDangling(name string, dangling string) bool { + if dangling == "false" && name != "" { + return true + } else if dangling == "true" && name == "" { + return true + } + return false +} + +func matchesLabel(image storage.Image, store storage.Store, label string) bool { + storeRef, err := is.Transport.ParseStoreReference(store, "@"+image.ID) + if err != nil { + + } + img, err := storeRef.NewImage(nil) + if err != nil { + return false + } + info, err := img.Inspect() + if err != nil { + return false + } + + pair := strings.SplitN(label, "=", 2) + for key, value := range info.Labels { + if key == pair[0] { + if len(pair) == 2 { + if value == pair[1] { + return true + } + } else { + return false + } + } + } + return false +} + +// Returns true if the image was created since the filter image. Returns +// false otherwise +func matchesBeforeImage(image storage.Image, name string, params *filterParams) bool { + if params.seenImage { + return false + } + if matchesReference(name, params.beforeImage) || matchesID(image.ID, params.beforeImage) { + params.seenImage = true + return false + } + return true +} + +// Returns true if the image was created since the filter image. Returns +// false otherwise +func matchesSinceImage(image storage.Image, name string, params *filterParams) bool { + if params.seenImage { + return true + } + if matchesReference(name, params.sinceImage) || matchesID(image.ID, params.sinceImage) { + params.seenImage = true + } + return false +} + +func matchesID(id, argID string) bool { + return strings.HasPrefix(argID, id) +} + +func matchesReference(name, argName string) bool { + if argName == "" { + return true + } + splitName := strings.Split(name, ":") + // If the arg contains a tag, we handle it differently than if it does not + if strings.Contains(argName, ":") { + splitArg := strings.Split(argName, ":") + return strings.HasSuffix(splitName[0], splitArg[0]) && (splitName[1] == splitArg[1]) + } + return strings.HasSuffix(splitName[0], argName) +} + +func formattedSize(size int64) string { + suffixes := [5]string{"B", "KB", "MB", "GB", "TB"} + + count := 0 + formattedSize := float64(size) + for formattedSize >= 1024 && count < 4 { + formattedSize /= 1024 + count++ + } + return fmt.Sprintf("%.4g %s", formattedSize, suffixes[count]) +} + +func outputUsingTemplate(format string, params imageOutputParams) error { + tmpl, err := template.New("image").Parse(format) + if err != nil { + return errors.Wrapf(err, "Template parsing error") + } + + err = tmpl.Execute(os.Stdout, params) + if err != nil { + return err + } + fmt.Println() + return nil +} + +func outputUsingFormatString(truncate, digests bool, params imageOutputParams) { + if truncate { + fmt.Printf("%-20.12s %-56s", params.ID, params.Name) + } else { + fmt.Printf("%-64s %-56s", params.ID, params.Name) + } + + if digests { + fmt.Printf(" %-64s", params.Digest) + } + fmt.Printf(" %-22s %s\n", params.CreatedAt, params.Size) +} diff --git a/cmd/kpod/images_test.go b/cmd/kpod/images_test.go new file mode 100644 index 00000000..e714166d --- /dev/null +++ b/cmd/kpod/images_test.go @@ -0,0 +1,678 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + is "github.com/containers/image/storage" + "github.com/containers/storage" +) + +func TestTemplateOutputBlankTemplate(t *testing.T) { + params := imageOutputParams{ + ID: "0123456789abcdef", + Name: "test/image:latest", + Digest: "sha256:012345789abcdef012345789abcdef012345789abcdef012345789abcdef", + CreatedAt: "Jan 01 2016 10:45", + Size: "97 KB", + } + + err := outputUsingTemplate("", params) + //Output: Words + if err != nil { + t.Error(err) + } +} + +func TestTemplateOutputValidTemplate(t *testing.T) { + params := imageOutputParams{ + ID: "0123456789abcdef", + Name: "test/image:latest", + Digest: "sha256:012345789abcdef012345789abcdef012345789abcdef012345789abcdef", + CreatedAt: "Jan 01 2016 10:45", + Size: "97 KB", + } + + templateString := "{{.ID}}" + + output, err := captureOutputWithError(func() error { + return outputUsingTemplate(templateString, params) + }) + if err != nil { + t.Error(err) + } else if strings.TrimSpace(output) != strings.TrimSpace(params.ID) { + t.Errorf("Error with template output:\nExpected: %s\nReceived: %s\n", params.ID, output) + } +} + +func TestFormatStringOutput(t *testing.T) { + params := imageOutputParams{ + ID: "012345789abcdef", + Name: "test/image:latest", + Digest: "sha256:012345789abcdef012345789abcdef012345789abcdef012345789abcdef", + CreatedAt: "Jan 01 2016 10:45", + Size: "97 KB", + } + + output := captureOutput(func() { + outputUsingFormatString(true, true, params) + }) + expectedOutput := fmt.Sprintf("%-12.12s %-40s %-64s %-22s %s\n", params.ID, params.Name, params.Digest, params.CreatedAt, params.Size) + if output != expectedOutput { + t.Errorf("Error outputting using format string:\n\texpected: %s\n\treceived: %s\n", expectedOutput, output) + } +} + +func TestSizeFormatting(t *testing.T) { + size := formattedSize(0) + if size != "0 B" { + t.Errorf("Error formatting size: expected '%s' got '%s'", "0 B", size) + } + + size = formattedSize(1024) + if size != "1 KB" { + t.Errorf("Error formatting size: expected '%s' got '%s'", "1 KB", size) + } + + size = formattedSize(1024 * 1024 * 1024 * 1024 * 1024) + if size != "1024 TB" { + t.Errorf("Error formatting size: expected '%s' got '%s'", "1024 TB", size) + } +} + +func TestOutputHeader(t *testing.T) { + output := captureOutput(func() { + outputHeader(true, false) + }) + expectedOutput := fmt.Sprintf("%-12s %-40s %-22s %s\n", "IMAGE ID", "IMAGE NAME", "CREATED AT", "SIZE") + if output != expectedOutput { + t.Errorf("Error outputting header:\n\texpected: %s\n\treceived: %s\n", expectedOutput, output) + } + + output = captureOutput(func() { + outputHeader(true, true) + }) + expectedOutput = fmt.Sprintf("%-12s %-40s %-64s %-22s %s\n", "IMAGE ID", "IMAGE NAME", "DIGEST", "CREATED AT", "SIZE") + if output != expectedOutput { + t.Errorf("Error outputting header:\n\texpected: %s\n\treceived: %s\n", expectedOutput, output) + } + + output = captureOutput(func() { + outputHeader(false, false) + }) + expectedOutput = fmt.Sprintf("%-64s %-40s %-22s %s\n", "IMAGE ID", "IMAGE NAME", "CREATED AT", "SIZE") + if output != expectedOutput { + t.Errorf("Error outputting header:\n\texpected: %s\n\treceived: %s\n", expectedOutput, output) + } +} + +func TestMatchWithTag(t *testing.T) { + isMatch := matchesReference("docker.io/kubernetes/pause:latest", "pause:latest") + if !isMatch { + t.Error("expected match, got not match") + } + + isMatch = matchesReference("docker.io/kubernetes/pause:latest", "kubernetes/pause:latest") + if !isMatch { + t.Error("expected match, got no match") + } +} + +func TestNoMatchesReferenceWithTag(t *testing.T) { + isMatch := matchesReference("docker.io/kubernetes/pause:latest", "redis:latest") + if isMatch { + t.Error("expected no match, got match") + } + + isMatch = matchesReference("docker.io/kubernetes/pause:latest", "kubernetes/redis:latest") + if isMatch { + t.Error("expected no match, got match") + } +} + +func TestMatchesReferenceWithoutTag(t *testing.T) { + isMatch := matchesReference("docker.io/kubernetes/pause:latest", "pause") + if !isMatch { + t.Error("expected match, got not match") + } + + isMatch = matchesReference("docker.io/kubernetes/pause:latest", "kubernetes/pause") + if !isMatch { + t.Error("expected match, got no match") + } +} + +func TestNoMatchesReferenceWithoutTag(t *testing.T) { + isMatch := matchesReference("docker.io/kubernetes/pause:latest", "redis") + if isMatch { + t.Error("expected no match, got match") + } + + isMatch = matchesReference("docker.io/kubernetes/pause:latest", "kubernetes/redis") + if isMatch { + t.Error("expected no match, got match") + } +} + +func TestOutputImagesQuietTruncated(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // Tests quiet and truncated output + output, err := captureOutputWithError(func() error { + return outputImages(images[:1], "", store, nil, "", false, true, false, true) + }) + expectedOutput := fmt.Sprintf("%-64s\n", images[0].ID) + if err != nil { + t.Error("quiet/truncated output produces error") + } else if strings.TrimSpace(output) != strings.TrimSpace(expectedOutput) { + t.Errorf("quiet/truncated output does not match expected value\nExpected: %s\nReceived: %s\n", expectedOutput, output) + } +} + +func TestOutputImagesQuietNotTruncated(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // Tests quiet and non-truncated output + output, err := captureOutputWithError(func() error { + return outputImages(images[:1], "", store, nil, "", false, false, false, true) + }) + expectedOutput := fmt.Sprintf("%-64s\n", images[0].ID) + if err != nil { + t.Error("quiet/non-truncated output produces error") + } else if strings.TrimSpace(output) != strings.TrimSpace(expectedOutput) { + t.Errorf("quiet/non-truncated output does not match expected value\nExpected: %s\nReceived: %s\n", expectedOutput, output) + } +} + +func TestOutputImagesFormatString(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // Tests output with format template + output, err := captureOutputWithError(func() error { + return outputImages(images[:1], "{{.ID}}", store, nil, "", true, true, false, false) + }) + expectedOutput := fmt.Sprintf("%s", images[0].ID) + if err != nil { + t.Error("format string output produces error") + } else if strings.TrimSpace(output) != strings.TrimSpace(expectedOutput) { + t.Errorf("format string output does not match expected value\nExpected: %s\nReceived: %s\n", expectedOutput, output) + } +} + +func TestOutputImagesFormatTemplate(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // Tests quiet and non-truncated output + output, err := captureOutputWithError(func() error { + return outputImages(images[:1], "", store, nil, "", false, false, false, true) + }) + expectedOutput := fmt.Sprintf("%-64s\n", images[0].ID) + if err != nil { + t.Error("format template output produces error") + } else if strings.TrimSpace(output) != strings.TrimSpace(expectedOutput) { + t.Errorf("format template output does not match expected value\nExpected: %s\nReceived: %s\n", expectedOutput, output) + } +} + +func TestOutputImagesArgNoMatch(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // Tests output with an arg name that does not match. Args ending in ":" cannot match + // because all images in the repository must have a tag, and here the tag is an + // empty string + output, err := captureOutputWithError(func() error { + return outputImages(images[:1], "", store, nil, "foo:", false, true, false, false) + }) + expectedOutput := fmt.Sprintf("") + if err != nil { + t.Error("arg no match output produces error") + } else if strings.TrimSpace(output) != strings.TrimSpace(expectedOutput) { + t.Error("arg no match output should be empty") + } +} + +func TestOutputMultipleImages(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // Tests quiet and truncated output + output, err := captureOutputWithError(func() error { + return outputImages(images[:2], "", store, nil, "", false, true, false, true) + }) + expectedOutput := fmt.Sprintf("%-64s\n%-64s\n", images[0].ID, images[1].ID) + if err != nil { + t.Error("multi-image output produces error") + } else if strings.TrimSpace(output) != strings.TrimSpace(expectedOutput) { + t.Errorf("multi-image output does not match expected value\nExpected: %s\nReceived: %s\n", expectedOutput, output) + } +} + +func TestParseFilterAllParams(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + // Pull an image so we know we have it + err = pullTestImage("busybox:latest") + if err != nil { + t.Fatalf("could not pull image to remove: %v", err) + } + + label := "dangling=true,label=a=b,before=busybox:latest,since=busybox:latest,reference=abcdef" + params, err := parseFilter(images, label) + if err != nil { + t.Fatalf("error parsing filter") + } + + expectedParams := &filterParams{dangling: "true", label: "a=b", beforeImage: "busybox:latest", sinceImage: "busybox:latest", referencePattern: "abcdef"} + if *params != *expectedParams { + t.Errorf("filter did not return expected result\n\tExpected: %v\n\tReceived: %v", expectedParams, params) + } +} + +func TestParseFilterInvalidDangling(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + // Pull an image so we know we have it + err = pullTestImage("busybox:latest") + if err != nil { + t.Fatalf("could not pull image to remove: %v", err) + } + + label := "dangling=NO,label=a=b,before=busybox:latest,since=busybox:latest,reference=abcdef" + _, err = parseFilter(images, label) + if err == nil || err.Error() != "invalid filter: 'dangling=[NO]'" { + t.Fatalf("expected error parsing filter") + } +} + +func TestParseFilterInvalidBefore(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + // Pull an image so we know we have it + err = pullTestImage("busybox:latest") + if err != nil { + t.Fatalf("could not pull image to remove: %v", err) + } + + label := "dangling=false,label=a=b,before=:,since=busybox:latest,reference=abcdef" + _, err = parseFilter(images, label) + if err == nil || !strings.Contains(err.Error(), "no such id") { + t.Fatalf("expected error parsing filter") + } +} + +func TestParseFilterInvalidSince(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + // Pull an image so we know we have it + err = pullTestImage("busybox:latest") + if err != nil { + t.Fatalf("could not pull image to remove: %v", err) + } + + label := "dangling=false,label=a=b,before=busybox:latest,since=:,reference=abcdef" + _, err = parseFilter(images, label) + if err == nil || !strings.Contains(err.Error(), "no such id") { + t.Fatalf("expected error parsing filter") + } +} + +func TestParseFilterInvalidFilter(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + // Pull an image so we know we have it + err = pullTestImage("busybox:latest") + if err != nil { + t.Fatalf("could not pull image to remove: %v", err) + } + + label := "foo=bar" + _, err = parseFilter(images, label) + if err == nil || err.Error() != "invalid filter: 'foo'" { + t.Fatalf("expected error parsing filter") + } +} + +func TestImageExistsTrue(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + // Pull an image so we know we have it + err = pullTestImage("busybox:katest") + if err != nil { + t.Fatalf("could not pull image to remove: %v", err) + } + + if !imageExists(images, "busybox:latest") { + t.Errorf("expected image %s to exist", "busybox:latest") + } +} + +func TestImageExistsFalse(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + if imageExists(images, ":") { + t.Errorf("image %s should not exist", ":") + } +} + +func TestMatchesDangingTrue(t *testing.T) { + if !matchesDangling("", "true") { + t.Error("matchesDangling() should return true with dangling=true and name=") + } + + if !matchesDangling("hello", "false") { + t.Error("matchesDangling() should return true with dangling=false and name='hello'") + } +} + +func TestMatchesDangingFalse(t *testing.T) { + if matchesDangling("hello", "true") { + t.Error("matchesDangling() should return false with dangling=true and name=hello") + } + + if matchesDangling("", "false") { + t.Error("matchesDangling() should return false with dangling=false and name=") + } +} + +func TestMatchesLabelTrue(t *testing.T) { + //TODO: How do I implement this? +} + +func TestMatchesLabelFalse(t *testing.T) { + // TODO: How do I implement this? +} + +func TestMatchesBeforeImageTrue(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // by default, params.seenImage is false + params := new(filterParams) + params.seenImage = false + params.beforeImage = "foo:bar" + if !matchesBeforeImage(images[0], ":", params) { + t.Error("should have matched beforeImage") + } +} + +func TestMatchesBeforeImageFalse(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // by default, params.seenImage is false + params := new(filterParams) + params.seenImage = true + params.beforeImage = "foo:bar" + // Should return false because the image has been seen + if matchesBeforeImage(images[0], ":", params) { + t.Error("should not have matched beforeImage") + } + + params.seenImage = false + if matchesBeforeImage(images[0], "foo:bar", params) { + t.Error("image should have been filtered out") + } +} + +func TestMatchesSinceeImageTrue(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // by default, params.seenImage is false + params := new(filterParams) + params.seenImage = true + params.sinceImage = "foo:bar" + if !matchesSinceImage(images[0], ":", params) { + t.Error("should have matched SinceImage") + } +} + +func TestMatchesSinceImageFalse(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + store, err := storage.GetStore(storage.DefaultStoreOptions) + if err != nil { + t.Fatal(err) + } else if store != nil { + is.Transport.SetStore(store) + } + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + + // by default, params.seenImage is false + params := new(filterParams) + params.seenImage = false + params.sinceImage = "foo:bar" + // Should return false because the image has been seen + if matchesSinceImage(images[0], ":", params) { + t.Error("should not have matched sinceImage") + } + + if matchesSinceImage(images[0], "foo:bar", params) { + t.Error("image should have been filtered out") + } +} + +func captureOutputWithError(f func() error) (string, error) { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := f() + + w.Close() + os.Stdout = old + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String(), err +} + +// Captures output so that it can be compared to expected values +func captureOutput(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + w.Close() + os.Stdout = old + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} diff --git a/cmd/kpod/main.go b/cmd/kpod/main.go index dd4e3d2e..02190c85 100644 --- a/cmd/kpod/main.go +++ b/cmd/kpod/main.go @@ -22,7 +22,9 @@ func main() { app.Version = Version app.Commands = []cli.Command{ + imagesCommand, launchCommand, + rmiCommand, tagCommand, versionCommand, pullCommand, @@ -45,6 +47,24 @@ func main() { Usage: "used to pass an option to the storage driver", }, } + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "root", + Usage: "path to the root directory in which data, including images, is stored", + }, + cli.StringFlag{ + Name: "runroot", + Usage: "path to the 'run directory' where all state information is stored", + }, + cli.StringFlag{ + Name: "storage-driver, s", + Usage: "select which storage driver is used to manage storage of images and containers (default is overlay2)", + }, + cli.StringSliceFlag{ + Name: "storage-opt", + Usage: "used to pass an option to the storage driver", + }, + } if err := app.Run(os.Args); err != nil { logrus.Fatal(err) diff --git a/cmd/kpod/rmi.go b/cmd/kpod/rmi.go new file mode 100644 index 00000000..e1128da8 --- /dev/null +++ b/cmd/kpod/rmi.go @@ -0,0 +1,238 @@ +package main + +import ( + "fmt" + + is "github.com/containers/image/storage" + "github.com/containers/image/transports" + "github.com/containers/image/transports/alltransports" + "github.com/containers/image/types" + "github.com/containers/storage" + "github.com/pkg/errors" + "github.com/urfave/cli" +) + +var ( + rmiDescription = "removes one or more locally stored images." + rmiFlags = []cli.Flag{ + cli.BoolFlag{ + Name: "force, f", + Usage: "force removal of the image", + }, + } + rmiCommand = cli.Command{ + Name: "rmi", + Usage: "removes one or more images from local storage", + Description: rmiDescription, + Action: rmiCmd, + ArgsUsage: "IMAGE-NAME-OR-ID [...]", + Flags: rmiFlags, + } +) + +func rmiCmd(c *cli.Context) error { + + force := false + if c.IsSet("force") { + force = c.Bool("force") + } + + args := c.Args() + if len(args) == 0 { + return errors.Errorf("image name or ID must be specified") + } + + store, err := getStore(c) + if err != nil { + return err + } + + for _, id := range args { + image, err := getImage(id, store) + if err != nil { + return errors.Wrapf(err, "could not get image %q", id) + } + if image != nil { + ctrIDs, err := runningContainers(image, store) + if err != nil { + return errors.Wrapf(err, "error getting running containers for image %q", id) + } + if len(ctrIDs) > 0 && len(image.Names) <= 1 { + if force { + removeContainers(ctrIDs, store) + } else { + for ctrID := range ctrIDs { + return fmt.Errorf("Could not remove image %q (must force) - container %q is using its reference image", id, ctrID) + } + } + } + // If the user supplied an ID, we cannot delete the image if it is referred to by multiple tags + if matchesID(image.ID, id) { + if len(image.Names) > 1 && !force { + return fmt.Errorf("unable to delete %s (must force) - image is referred to in multiple tags", image.ID) + } + // If it is forced, we have to untag the image so that it can be deleted + image.Names = image.Names[:0] + } else { + name, err2 := untagImage(id, image, store) + if err2 != nil { + return err + } + fmt.Printf("untagged: %s", name) + } + + if len(image.Names) > 0 { + continue + } + id, err := removeImage(image, store) + if err != nil { + return err + } + fmt.Printf("%s\n", id) + } + } + + return nil +} + +func getImage(id string, store storage.Store) (*storage.Image, error) { + var ref types.ImageReference + ref, err := properImageRef(id) + if err != nil { + //logrus.Debug(err) + } + if ref == nil { + if ref, err = storageImageRef(store, id); err != nil { + //logrus.Debug(err) + } + } + if ref == nil { + if ref, err = storageImageID(store, id); err != nil { + //logrus.Debug(err) + } + } + if ref != nil { + image, err2 := is.Transport.GetStoreImage(store, ref) + if err2 != nil { + return nil, err2 + } + return image, nil + } + return nil, err +} + +func untagImage(imgArg string, image *storage.Image, store storage.Store) (string, error) { + // Remove name from image.Names and set the new name in the ImageStore + imgStore, err := store.ImageStore() + if err != nil { + return "", errors.Wrap(err, "could not untag image") + } + newNames := []string{} + removedName := "" + for _, name := range image.Names { + if matchesReference(name, imgArg) { + removedName = name + continue + } + newNames = append(newNames, name) + } + imgStore.SetNames(image.ID, newNames) + err = imgStore.Save() + return removedName, err +} + +func removeImage(image *storage.Image, store storage.Store) (string, error) { + imgStore, err := store.ImageStore() + if err != nil { + return "", errors.Wrapf(err, "could not open image store") + } + err = imgStore.Delete(image.ID) + if err != nil { + return "", errors.Wrapf(err, "could not remove image") + } + err = imgStore.Save() + if err != nil { + return "", errors.Wrapf(err, "could not save image store") + } + return image.ID, nil +} + +// Returns a list of running containers associated with the given ImageReference +func runningContainers(image *storage.Image, store storage.Store) ([]string, error) { + ctrIDs := []string{} + ctrStore, err := store.ContainerStore() + if err != nil { + return nil, err + } + + containers, err := ctrStore.Containers() + if err != nil { + return nil, err + } + for _, ctr := range containers { + if ctr.ImageID == image.ID { + ctrIDs = append(ctrIDs, ctr.ID) + } + } + return ctrIDs, nil +} + +func removeContainers(ctrIDs []string, store storage.Store) error { + ctrStore, err := store.ContainerStore() + if err != nil { + return err + } + for _, ctrID := range ctrIDs { + if err = ctrStore.Delete(ctrID); err != nil { + return errors.Wrapf(err, "could not remove container %q", ctrID) + } + } + return nil +} + +// If it's looks like a proper image reference, parse it and check if it +// corresponds to an image that actually exists. +func properImageRef(id string) (types.ImageReference, error) { + var ref types.ImageReference + var err error + if ref, err = alltransports.ParseImageName(id); err == nil { + if img, err2 := ref.NewImage(nil); err2 == nil { + img.Close() + return ref, nil + } + return nil, fmt.Errorf("error confirming presence of image reference %q: %v", transports.ImageName(ref), err) + } + return nil, fmt.Errorf("error parsing %q as an image reference: %v", id, err) +} + +// If it's looks like an image reference that's relative to our storage, parse +// it and check if it corresponds to an image that actually exists. +func storageImageRef(store storage.Store, id string) (types.ImageReference, error) { + var ref types.ImageReference + var err error + if ref, err = is.Transport.ParseStoreReference(store, id); err == nil { + if img, err2 := ref.NewImage(nil); err2 == nil { + img.Close() + return ref, nil + } + return nil, fmt.Errorf("error confirming presence of storage image reference %q: %v", transports.ImageName(ref), err) + } + return nil, fmt.Errorf("error parsing %q as a storage image reference: %v", id, err) +} + +// If it might be an ID that's relative to our storage, parse it and check if it +// corresponds to an image that actually exists. This _should_ be redundant, +// since we already tried deleting the image using the ID directly above, but it +// can't hurt either. +func storageImageID(store storage.Store, id string) (types.ImageReference, error) { + var ref types.ImageReference + var err error + if ref, err = is.Transport.ParseStoreReference(store, "@"+id); err == nil { + if img, err2 := ref.NewImage(nil); err2 == nil { + img.Close() + return ref, nil + } + return nil, fmt.Errorf("error confirming presence of storage image reference %q: %v", transports.ImageName(ref), err) + } + return nil, fmt.Errorf("error parsing %q as a storage image reference: %v", "@"+id, err) +} diff --git a/cmd/kpod/rmi_test.go b/cmd/kpod/rmi_test.go new file mode 100644 index 00000000..c10c8eeb --- /dev/null +++ b/cmd/kpod/rmi_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "strings" + "testing" + + is "github.com/containers/image/storage" + "github.com/containers/storage" +) + +func TestProperImageRefTrue(t *testing.T) { + // Pull an image so we know we have it + err := pullTestImage("busybox:latest") + if err != nil { + t.Fatalf("could not pull image to remove") + } + // This should match a url path + imgRef, err := properImageRef("docker://busybox:latest") + if err != nil { + t.Errorf("could not match image: %v", err) + } else if imgRef == nil { + t.Error("Returned nil Image Reference") + } +} + +func TestProperImageRefFalse(t *testing.T) { + // Pull an image so we know we have it + err := pullTestImage("busybox:latest") + if err != nil { + t.Fatal("could not pull image to remove") + } + // This should match a url path + imgRef, _ := properImageRef("docker://:") + if imgRef != nil { + t.Error("should not have found an Image Reference") + } +} + +func TestStorageImageRefTrue(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + options := storage.DefaultStoreOptions + store, err := storage.GetStore(options) + if store != nil { + is.Transport.SetStore(store) + } + if err != nil { + t.Fatalf("could not get store: %v", err) + } + // Pull an image so we know we have it + err = pullTestImage("busybox:latest") + if err != nil { + t.Fatalf("could not pull image to remove: %v", err) + } + imgRef, err := storageImageRef(store, "busybox") + if err != nil { + t.Errorf("could not match image: %v", err) + } else if imgRef == nil { + t.Error("Returned nil Image Reference") + } +} + +func TestStorageImageRefFalse(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + options := storage.DefaultStoreOptions + store, err := storage.GetStore(options) + if store != nil { + is.Transport.SetStore(store) + } + if err != nil { + t.Fatalf("could not get store: %v", err) + } + // Pull an image so we know we have it + err = pullTestImage("busybox:latest") + if err != nil { + t.Fatalf("could not pull image to remove: %v", err) + } + imgRef, _ := storageImageRef(store, "") + if imgRef != nil { + t.Error("should not have found an Image Reference") + } +} + +func TestStorageImageIDTrue(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + options := storage.DefaultStoreOptions + store, err := storage.GetStore(options) + if store != nil { + is.Transport.SetStore(store) + } + if err != nil { + t.Fatalf("could not get store: %v", err) + } + // Pull an image so we know we have it + err = pullTestImage("busybox:latest") + if err != nil { + t.Fatalf("could not pull image to remove: %v", err) + } + //Somehow I have to get the id of the image I just pulled + images, err := store.Images() + if err != nil { + t.Fatalf("Error reading images: %v", err) + } + id, err := captureOutputWithError(func() error { + return outputImages(images, "", store, nil, "busybox:latest", false, false, false, true) + }) + if err != nil { + t.Fatalf("Error getting id of image: %v", err) + } + id = strings.TrimSpace(id) + + imgRef, err := storageImageID(store, id) + if err != nil { + t.Errorf("could not match image: %v", err) + } else if imgRef == nil { + t.Error("Returned nil Image Reference") + } +} + +func TestStorageImageIDFalse(t *testing.T) { + // Make sure the tests are running as root + failTestIfNotRoot(t) + + options := storage.DefaultStoreOptions + store, err := storage.GetStore(options) + if store != nil { + is.Transport.SetStore(store) + } + if err != nil { + t.Fatalf("could not get store: %v", err) + } + // Pull an image so we know we have it + + id := "" + + imgRef, _ := storageImageID(store, id) + if imgRef != nil { + t.Error("should not have returned Image Reference") + } +} diff --git a/completions/bash/kpod b/completions/bash/kpod index ca681342..751beb1c 100644 --- a/completions/bash/kpod +++ b/completions/bash/kpod @@ -2,6 +2,37 @@ : ${PROG:=$(basename ${BASH_SOURCE})} +__kpod_list_images() { + COMPREPLY=($(compgen -W "$(kpod images -q)" -- $cur)) +} + +_kpod_images() { + local boolean_options=" + --help + -h + --quiet + -q + --noheading + -n + --no-trunc + --digests + --format + --filter + -f + " + + local options_with_args=" + " + + local all_options="$options_with_args $boolean_options" + + case "$cur" in + -*) + COMPREPLY=($(compgen -W "$boolean_options $options_with_args" -- "$cur")) + ;; + esac +} + _complete_() { local options_with_args=$1 local boolean_options="$2 -h --help" @@ -19,6 +50,24 @@ _complete_() { esac } +_kpod_rmi() { + local boolean_options=" + --help + -h + --force + -f + " + + case "$cur" in + -*) + COMPREPLY=($(compgen -W "$boolean_options $options_with_args" -- "$cur")) + ;; + *) + __kpod_list_images + ;; + esac +} + _kpod_launch() { local options_with_args=" " @@ -60,7 +109,9 @@ _kpod_kpod() { --help -h " commands=" + images launch + rmi tag version pull diff --git a/docs/kpod-images.1.md b/docs/kpod-images.1.md new file mode 100644 index 00000000..f5aaef45 --- /dev/null +++ b/docs/kpod-images.1.md @@ -0,0 +1,48 @@ +## kpod-images "1" "March 2017" "kpod" + +## NAME +kpod images - List images in local storage. + +## SYNOPSIS +**kpod** **images** [*options* [...]] + +## DESCRIPTION +Displays locally stored images, their names, and their IDs. + +## OPTIONS + +**--digests** + +Show image digests + +**--filter, -f=[]** + +Filter output based on conditions provided (default []) + +**--format="TEMPLATE"** + +Pretty-print images using a Go template. Will override --quiet + +**--noheading, -n** + +Omit the table headings from the listing of images. + +**--no-trunc, --notruncate** + +Do not truncate output. + +**--quiet, -q** + +Lists only the image IDs. + + +## EXAMPLE + +kpod images + +kpod images --quiet + +kpod images -q --noheading --notruncate + +## SEE ALSO +kpod(1) diff --git a/docs/kpod-rmi.1.md b/docs/kpod-rmi.1.md new file mode 100644 index 00000000..a365a859 --- /dev/null +++ b/docs/kpod-rmi.1.md @@ -0,0 +1,27 @@ +## kpod-rmi "1" "March 2017" "kpod" + +## NAME +kpod rmi - Removes one or more images. + +## SYNOPSIS +**kpod** **rmi** **imageID [...]** + +## DESCRIPTION +Removes one or more locally stored images. + +## OPTIONS + +**--force, -f** + +Executing this command will stop all containers that are using the image and remove them from the system + +## EXAMPLE + +kpod rmi imageID + +kpod rmi --force imageID + +kpod rmi imageID1 imageID2 imageID3 + +## SEE ALSO +kpod(1) diff --git a/docs/kpod.1.md b/docs/kpod.1.md index c29a66ed..a2cb5730 100644 --- a/docs/kpod.1.md +++ b/docs/kpod.1.md @@ -29,9 +29,15 @@ has the capability to debug pods/images created by crio. # COMMANDS +## images +List images in local storage + ## launch Launch a pod +## rmi +Removes one or more locally stored images + ## tag Add one or more additional names to locally-stored image