diff --git a/README.md b/README.md index 3590820a..a5d63641 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ It is currently in active development in the Kubernetes community through the [d | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | [crio(8)](/docs/crio.8.md) | Enable OCI Kubernetes Container Runtime daemon | | [kpod(1)](/docs/kpod.1.md) | Simple management tool for pods and images | +| [kpod-history(1)](/docs/kpod-history.1.md)] | Shows the history of an image | | [kpod-images(1)](/docs/kpod-images.1.md) | List images in local storage | | [kpod-pull(1)](/docs/kpod-pull.1.md) | Pull an image from a registry | | [kpod-rmi(1)](/docs/kpod-rmi.1.md) | Removes one or more images | diff --git a/cmd/kpod/history.go b/cmd/kpod/history.go new file mode 100644 index 00000000..aaa6df7d --- /dev/null +++ b/cmd/kpod/history.go @@ -0,0 +1,353 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "text/template" + "time" + + "os" + + "strconv" + + "github.com/Sirupsen/logrus" + is "github.com/containers/image/storage" + "github.com/containers/storage" + units "github.com/docker/go-units" + "github.com/pkg/errors" + "github.com/urfave/cli" +) + +const ( + createdByTruncLength = 45 + idTruncLength = 13 +) + +// historyOutputParams stores info about each layer +type historyOutputParams struct { + ID string `json:"id"` + Created *time.Time `json:"created"` + CreatedBy string `json:"createdby"` + Size int64 `json:"size"` + Comment string `json:"comment"` +} + +// historyOptions stores cli flag values +type historyOptions struct { + image string + human bool + noTrunc bool + quiet bool + format string +} + +var ( + historyFlags = []cli.Flag{ + cli.BoolFlag{ + Name: "human, H", + Usage: "Display sizes and dates in human readable format", + }, + cli.BoolFlag{ + Name: "no-trunc", + Usage: "Do not truncate the output", + }, + cli.BoolFlag{ + Name: "quiet, q", + Usage: "Display the numeric IDs only", + }, + cli.StringFlag{ + Name: "format", + Usage: "Pretty-print history of the image using a Go template", + }, + cli.BoolFlag{ + Name: "json", + Usage: "Print the history in JSON format", + }, + } + + historyDescription = "Displays the history of an image. The information can be printed out in an easy to read, " + + "or user specified format, and can be truncated." + historyCommand = cli.Command{ + Name: "history", + Usage: "Show history of a specified image", + Description: historyDescription, + Flags: historyFlags, + Action: historyCmd, + ArgsUsage: "", + } +) + +func historyCmd(c *cli.Context) error { + store, err := getStore(c) + if err != nil { + return err + } + + human := true + if c.IsSet("human") { + human = c.Bool("human") + } + noTruncate := false + if c.IsSet("no-trunc") { + noTruncate = c.Bool("no-trunc") + } + quiet := false + if c.IsSet("quiet") { + quiet = c.Bool("quiet") + } + json := false + if c.IsSet("json") { + json = c.Bool("json") + } + format := "" + if c.IsSet("format") { + format = c.String("format") + } + + args := c.Args() + if len(args) == 0 { + logrus.Errorf("an image name must be specified") + return nil + } + if len(args) > 1 { + logrus.Errorf("Kpod history takes at most 1 argument") + return nil + } + imgName := args[0] + + opts := historyOptions{ + image: imgName, + human: human, + noTrunc: noTruncate, + quiet: quiet, + format: format, + } + + var history []byte + if json { + history, err = createJSON(store, opts) + fmt.Println(string(history)) + } else { + if format == "" && !quiet { + outputHeading(noTruncate) + } + err = outputHistory(store, opts) + } + return err +} + +// outputHeader outputs the heading +func outputHeading(noTrunc bool) { + if !noTrunc { + fmt.Printf("%-12s\t\t%-16s\t\t%-45s\t\t", "IMAGE", "CREATED", "CREATED BY") + fmt.Printf("%-16s\t\t%s\n", "SIZE", "COMMENT") + } else { + fmt.Printf("%-64s\t%-18s\t%-60s\t", "IMAGE", "CREATED", "CREATED BY") + fmt.Printf("%-16s\t%s\n", "SIZE", "COMMENT") + } +} + +// outputString outputs the information in historyOutputParams +func outputString(noTrunc, human bool, params historyOutputParams) { + var ( + createdTime string + outputSize string + ) + + if human { + createdTime = outputHumanTime(params.Created) + " ago" + outputSize = units.HumanSize(float64(params.Size)) + } else { + createdTime = outputTime(params.Created) + outputSize = strconv.FormatInt(params.Size, 10) + } + + if !noTrunc { + fmt.Printf("%-12.12s\t\t%-16s\t\t%-45.45s\t\t", params.ID, createdTime, params.CreatedBy) + fmt.Printf("%-16s\t\t%s\n", outputSize, params.Comment) + } else { + fmt.Printf("%-64s\t%-18s\t%-60s\t", params.ID, createdTime, params.CreatedBy) + fmt.Printf("%-16s\t%s\n\n", outputSize, params.Comment) + } +} + +// outputWithTemplate is called when --format is given a template +func outputWithTemplate(format string, params historyOutputParams, human bool) error { + templ, err := template.New("history").Parse(format) + if err != nil { + return errors.Wrapf(err, "error parsing template") + } + + createdTime := outputTime(params.Created) + outputSize := strconv.FormatInt(params.Size, 10) + + if human { + createdTime = outputHumanTime(params.Created) + " ago" + outputSize = units.HumanSize(float64(params.Size)) + } + + // templParams is used to store the info from params and the time and + // size that have been converted to type string for when the human flag + // is set + templParams := struct { + ID string + Created string + CreatedBy string + Size string + Comment string + }{ + params.ID, + createdTime, + params.CreatedBy, + outputSize, + params.Comment, + } + + if err = templ.Execute(os.Stdout, templParams); err != nil { + return err + } + fmt.Println() + return nil +} + +// outputTime displays the time stamp in "2017-06-20T20:24:10Z" format +func outputTime(tm *time.Time) string { + return tm.Format(time.RFC3339) +} + +// outputHumanTime displays the time elapsed since creation +func outputHumanTime(tm *time.Time) string { + return units.HumanDuration(time.Since(*tm)) +} + +// createJSON retrieves the history of the image and returns a JSON object +func createJSON(store storage.Store, opts historyOptions) ([]byte, error) { + var ( + size int64 + img *storage.Image + imageID string + layerAll []historyOutputParams + ) + + ref, err := is.Transport.ParseStoreReference(store, opts.image) + if err != nil { + return nil, errors.Errorf("error parsing reference to image %q: %v", opts.image, err) + } + + img, err = is.Transport.GetStoreImage(store, ref) + if err != nil { + return nil, errors.Errorf("no such image %q: %v", opts.image, err) + } + + systemContext := getSystemContext("") + + src, err := ref.NewImage(systemContext) + if err != nil { + return nil, errors.Errorf("error instantiating image %q: %v", opts.image, err) + } + + oci, err := src.OCIConfig() + if err != nil { + return nil, err + } + + history := oci.History + layers := src.LayerInfos() + count := 1 + // iterating backwards to get newwest to oldest + for i := len(history) - 1; i >= 0; i-- { + if i == len(history)-1 { + imageID = img.ID + } else { + imageID = "" + } + + if !history[i].EmptyLayer { + size = layers[len(layers)-count].Size + count++ + } else { + size = 0 + } + + params := historyOutputParams{ + ID: imageID, + Created: history[i].Created, + CreatedBy: history[i].CreatedBy, + Size: size, + Comment: history[i].Comment, + } + + layerAll = append(layerAll, params) + } + + output, err := json.MarshalIndent(layerAll, "", "\t\t") + if err != nil { + return nil, errors.Errorf("error marshalling to JSON: %v", err) + } + + if err = src.Close(); err != nil { + return nil, err + } + + return output, nil +} + +// outputHistory gets the history of the image from the JSON object +// and pretty prints it to the screen +func outputHistory(store storage.Store, opts historyOptions) error { + var ( + outputCreatedBy string + imageID string + history []historyOutputParams + ) + + raw, err := createJSON(store, opts) + if err != nil { + return errors.Errorf("error creating JSON: %v", err) + } + + if err = json.Unmarshal(raw, &history); err != nil { + return errors.Errorf("error Unmarshalling JSON: %v", err) + } + + for i := 0; i < len(history); i++ { + imageID = history[i].ID + + outputCreatedBy = strings.Join(strings.Fields(history[i].CreatedBy), " ") + if !opts.noTrunc && len(outputCreatedBy) > createdByTruncLength { + outputCreatedBy = outputCreatedBy[:createdByTruncLength-3] + "..." + } + + if !opts.noTrunc && i == 0 { + imageID = history[i].ID[:idTruncLength] + } + + if opts.quiet { + if !opts.noTrunc { + fmt.Printf("%-12.12s\n", imageID) + } else { + fmt.Printf("%-s\n", imageID) + } + continue + } + + params := historyOutputParams{ + ID: imageID, + Created: history[i].Created, + CreatedBy: outputCreatedBy, + Size: history[i].Size, + Comment: history[i].Comment, + } + + if len(opts.format) > 0 { + if err = outputWithTemplate(opts.format, params, opts.human); err != nil { + return errors.Errorf("error outputing with template: %v", err) + } + continue + } + + outputString(opts.noTrunc, opts.human, params) + } + return nil +} diff --git a/cmd/kpod/main.go b/cmd/kpod/main.go index bc5845fd..4fc5bb76 100644 --- a/cmd/kpod/main.go +++ b/cmd/kpod/main.go @@ -28,6 +28,7 @@ func main() { tagCommand, versionCommand, pullCommand, + historyCommand, } app.Flags = []cli.Flag{ cli.StringFlag{ diff --git a/completions/bash/kpod b/completions/bash/kpod index 13d86488..f24ccdd2 100644 --- a/completions/bash/kpod +++ b/completions/bash/kpod @@ -93,6 +93,28 @@ _kpod_pull() { _complete_ "$options_with_args" "$boolean_options" } +_kpod_history() { + local options_with_args=" + --format + " + local boolean_options=" + --human -H + --no-trunc + --quiet -q + --json + " + _complete_ "$options_with_args" "$boolean_options" + + case "$cur" in + -*) + COMPREPLY=($(compgen -W "$boolean_options $options_with_args" -- "$cur")) + ;; + *) + __kpod_list_images + ;; + esac +} + _kpod_kpod() { local options_with_args=" " @@ -106,6 +128,7 @@ _kpod_kpod() { tag version pull + history " case "$prev" in diff --git a/docs/kpod-history.1.md b/docs/kpod-history.1.md new file mode 100644 index 00000000..6f48debb --- /dev/null +++ b/docs/kpod-history.1.md @@ -0,0 +1,69 @@ +% kpod(8) # kpod-history - Simple tool to view the history of an image +% Urvashi Mohnani +% JULY 2017 +# NAME +kpod-history - Shows the history of an image. + +# SYNOPSIS +**kpod history [OPTIONS] IMAGE[:TAG|DIGEST]** + +# DESCRIPTION +**kpod history** displays the history of an image by printing out information +about each layer used in the image. The information printed out for each layer +include Created (time and date), Created By, Size, and Comment. The output can +be truncated or not using the **--no-trunc** flag. If the **--human** flag is +set, the time of creation and size are printed out in a human readable format. +The **--quiet** flag displays the ID of the image only when set and the **--format** +flag is used to print the information using the Go template provided by the user. + +Valid placeholders for the Go template are listed below: +| **Placeholder** | **Description** | +|-----------------|------------------------------------------------------------------------------| +| .ID | Image ID | +| .Created | if **--human**, time elapsed since creation, otherwise time stamp of creation| +| .CreatedBy | Command used to create the layer | +| .Size | Size of layer on disk | +| .Comment | Comment for the layer | + +**kpod [GLOBAL OPTIONS]** + +**kpod [GLOBAL OPTIONS] history [OPTIONS]** + +# GLOBAL OPTIONS + +**--help, -h** + Print usage statement + +# OPTIONS + +**--human, -H** + Display sizes and dates in human readable format + +**--no-trunc** + Do not truncate the output + +**--quiet, -q** + Print the numeric IDs only + +**--format** + Pretty-print history of the image using a Go template + +**--json** + Print the history in JSON form + +# COMMANDS + +**kpod history debian** + +**kpod history --no-trunc=true --human=false debian** + +**kpod history --format "{{.ID}} {{.Created}}" debian** + +## history +Show the history of an image + +# SEE ALSO +kpod(1), crio(8), crio.conf(5) + +# HISTORY +July 2017, Originally compiled by Urvashi Mohnani diff --git a/test/kpod.bats b/test/kpod.bats index 057d82f5..acd31d0d 100644 --- a/test/kpod.bats +++ b/test/kpod.bats @@ -2,6 +2,7 @@ load helpers +IMAGE="alpine" ROOT="$TESTDIR/crio" RUNROOT="$TESTDIR/crio-run" KPOD_OPTIONS="--root $ROOT --runroot $RUNROOT --storage-driver vfs" @@ -16,30 +17,40 @@ KPOD_OPTIONS="--root $ROOT --runroot $RUNROOT --storage-driver vfs" run ${KPOD_BINARY} $KPOD_OPTIONS pull debian:6.0.10 echo "$output" [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi debian:6.0.10 + [ "$status" -eq 0 ] } @test "kpod pull from docker without tag" { run ${KPOD_BINARY} $KPOD_OPTIONS pull debian echo "$output" [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi debian + [ "$status" -eq 0 ] } @test "kpod pull from a non-docker registry with tag" { run ${KPOD_BINARY} $KPOD_OPTIONS pull registry.fedoraproject.org/fedora:rawhide echo "$output" [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi registry.fedoraproject.org/fedora:rawhide + [ "$status" -eq 0 ] } @test "kpod pull from a non-docker registry without tag" { run ${KPOD_BINARY} $KPOD_OPTIONS pull registry.fedoraproject.org/fedora echo "$output" [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi registry.fedoraproject.org/fedora + [ "$status" -eq 0 ] } @test "kpod pull using digest" { - run ${KPOD_BINARY} $KPOD_OPTIONS pull debian@sha256:7d067f77d2ae5a23fe6920f8fbc2936c4b0d417e9d01b26372561860750815f0 + run ${KPOD_BINARY} $KPOD_OPTIONS pull alpine@sha256:1072e499f3f655a032e88542330cf75b02e7bdf673278f701d7ba61629ee3ebe echo "$output" [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi alpine:latest + [ "$status" -eq 0 ] } @test "kpod pull from a non existent image" { @@ -47,3 +58,62 @@ KPOD_OPTIONS="--root $ROOT --runroot $RUNROOT --storage-driver vfs" echo "$output" [ "$status" -ne 0 ] } + +@test "kpod history default" { + run ${KPOD_BINARY} ${KPOD_OPTIONS} pull $IMAGE + [ "$status" -eq 0 ] + run ${KPOD_BINARY} ${KPOD_OPTIONS} history $IMAGE + echo "$output" + [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi $IMAGE + [ "$status" -eq 0 ] +} + +@test "kpod history with format" { + run ${KPOD_BINARY} ${KPOD_OPTIONS} pull $IMAGE + [ "$status" -eq 0 ] + run ${KPOD_BINARY} ${KPOD_OPTIONS} history --format "{{.ID}} {{.Created}}" $IMAGE + echo "$output" + [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi $IMAGE + [ "$status" -eq 0 ] +} + +@test "kpod history human flag" { + run ${KPOD_BINARY} ${KPOD_OPTIONS} pull $IMAGE + [ "$status" -eq 0 ] + run ${KPOD_BINARY} ${KPOD_OPTIONS} history --human=false $IMAGE + echo "$output" + [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi $IMAGE + [ "$status" -eq 0 ] +} + +@test "kpod history quiet flag" { + run ${KPOD_BINARY} ${KPOD_OPTIONS} pull $IMAGE + [ "$status" -eq 0 ] + run ${KPOD_BINARY} ${KPOD_OPTIONS} history -q $IMAGE + echo "$output" + [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi $IMAGE + [ "$status" -eq 0 ] +} + +@test "kpod history no-trunc flag" { + run ${KPOD_BINARY} ${KPOD_OPTIONS} pull $IMAGE + [ "$status" -eq 0 ] + run ${KPOD_BINARY} ${KPOD_OPTIONS} history --no-trunc $IMAGE + echo "$output" + [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi $IMAGE + [ "$status" -eq 0 ] +} + +@test "kpod history json flag" { + run ${KPOD_BINARY} ${KPOD_OPTIONS} pull $IMAGE + run bash -c "${KPOD_BINARY} ${KPOD_OPTIONS} history --json $IMAGE | python -m json.tool" + echo "$output" + [ "$status" -eq 0 ] + run ${KPOD_BINARY} $KPOD_OPTIONS rmi $IMAGE + [ "$status" -eq 0 ] +}