diff --git a/cmd/nerdctl/image_history.go b/cmd/nerdctl/image_history.go index d6024f9c17c..b420e53b447 100644 --- a/cmd/nerdctl/image_history.go +++ b/cmd/nerdctl/image_history.go @@ -23,17 +23,18 @@ import ( "fmt" "io" "os" + "strconv" "text/tabwriter" "text/template" "time" "github.com/containerd/containerd" - "github.com/containerd/containerd/pkg/progress" "github.com/containerd/log" "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/formatter" "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/docker/go-units" "github.com/opencontainers/image-spec/identity" "github.com/spf13/cobra" ) @@ -58,11 +59,16 @@ func addHistoryFlags(cmd *cobra.Command) { return []string{"json"}, cobra.ShellCompDirectiveNoFileComp }) cmd.Flags().BoolP("quiet", "q", false, "Only show numeric IDs") + cmd.Flags().BoolP("human", "H", true, "Print sizes and dates in human readable format (default true)") cmd.Flags().Bool("no-trunc", false, "Don't truncate output") } type historyPrintable struct { + creationTime *time.Time + size int64 + Snapshot string + CreatedAt string CreatedSince string CreatedBy string Size string @@ -101,7 +107,7 @@ func historyAction(cmd *cobra.Command, args []string) error { } var historys []historyPrintable for _, h := range configHistories { - var size string + var size int64 var snapshotName string if !h.EmptyLayer { if len(diffIDs) <= layerCounter { @@ -119,18 +125,18 @@ func historyAction(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to get usage: %w", err) } - size = progress.Bytes(use.Size).String() + size = use.Size snapshotName = stat.Name layerCounter++ } else { - size = progress.Bytes(0).String() + size = 0 snapshotName = "" } history := historyPrintable{ + creationTime: h.Created, + size: size, Snapshot: snapshotName, - CreatedSince: formatter.TimeSinceInHuman(*h.Created), CreatedBy: h.CreatedBy, - Size: size, Comment: h.Comment, } historys = append(historys, history) @@ -147,9 +153,9 @@ func historyAction(cmd *cobra.Command, args []string) error { } type historyPrinter struct { - w io.Writer - quiet, noTrunc bool - tmpl *template.Template + w io.Writer + quiet, noTrunc, human bool + tmpl *template.Template } func printHistory(cmd *cobra.Command, historys []historyPrintable) error { @@ -161,6 +167,11 @@ func printHistory(cmd *cobra.Command, historys []historyPrintable) error { if err != nil { return err } + human, err := cmd.Flags().GetBool("human") + if err != nil { + return err + } + var w io.Writer w = os.Stdout @@ -179,9 +190,7 @@ func printHistory(cmd *cobra.Command, historys []historyPrintable) error { case "raw": return errors.New("unsupported format: \"raw\"") default: - if quiet { - return errors.New("format and quiet must not be specified together") - } + quiet = false var err error tmpl, err = formatter.ParseTemplate(format) if err != nil { @@ -193,6 +202,7 @@ func printHistory(cmd *cobra.Command, historys []historyPrintable) error { w: w, quiet: quiet, noTrunc: noTrunc, + human: human, tmpl: tmpl, } @@ -208,31 +218,47 @@ func printHistory(cmd *cobra.Command, historys []historyPrintable) error { return nil } -func (x *historyPrinter) printHistory(p historyPrintable) error { +func (x *historyPrinter) printHistory(printable historyPrintable) error { + // Truncate long values unless --no-trunc is passed if !x.noTrunc { - if len(p.CreatedBy) > 45 { - p.CreatedBy = p.CreatedBy[0:44] + "…" + if len(printable.CreatedBy) > 45 { + printable.CreatedBy = printable.CreatedBy[0:44] + "…" + } + // Do not truncate snapshot id if quiet is being passed + if !x.quiet && len(printable.Snapshot) > 45 { + printable.Snapshot = printable.Snapshot[0:44] + "…" } } + + // Format date and size for display based on --human preference + printable.CreatedAt = printable.creationTime.Local().Format(time.RFC3339) + if x.human { + printable.CreatedSince = formatter.TimeSinceInHuman(*printable.creationTime) + printable.Size = units.HumanSize(float64(printable.size)) + } else { + printable.CreatedSince = printable.CreatedAt + printable.Size = strconv.FormatInt(printable.size, 10) + } + if x.tmpl != nil { var b bytes.Buffer - if err := x.tmpl.Execute(&b, p); err != nil { + if err := x.tmpl.Execute(&b, printable); err != nil { return err } if _, err := fmt.Fprintln(x.w, b.String()); err != nil { return err } } else if x.quiet { - if _, err := fmt.Fprintln(x.w, p.Snapshot); err != nil { + if _, err := fmt.Fprintln(x.w, printable.Snapshot); err != nil { return err } } else { if _, err := fmt.Fprintf(x.w, "%s\t%s\t%s\t%s\t%s\n", - p.Snapshot, - p.CreatedSince, - p.CreatedBy, - p.Size, - p.Comment, + printable.Snapshot, + printable.CreatedSince, + printable.CreatedBy, + printable.Size, + printable.Comment, ); err != nil { return err } diff --git a/cmd/nerdctl/image_history_test.go b/cmd/nerdctl/image_history_test.go new file mode 100644 index 00000000000..128c292c3e6 --- /dev/null +++ b/cmd/nerdctl/image_history_test.go @@ -0,0 +1,162 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package main + +import ( + "encoding/json" + "fmt" + "io" + "runtime" + "strings" + "testing" + "time" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "gotest.tools/v3/assert" +) + +type historyObj struct { + Snapshot string + CreatedAt string + CreatedSince string + CreatedBy string + Size string + Comment string +} + +func imageHistoryJSONHelper(base *testutil.Base, reference string, noTrunc bool, quiet bool, human bool) []historyObj { + cmd := []string{"image", "history"} + if noTrunc { + cmd = append(cmd, "--no-trunc") + } + if quiet { + cmd = append(cmd, "--quiet") + } + cmd = append(cmd, fmt.Sprintf("--human=%t", human)) + cmd = append(cmd, "--format", "json") + cmd = append(cmd, reference) + + cmdResult := base.Cmd(cmd...).Run() + assert.Equal(base.T, cmdResult.ExitCode, 0, cmdResult.Stdout()) + + fmt.Println(cmdResult.Stderr()) + + dec := json.NewDecoder(strings.NewReader(cmdResult.Stdout())) + object := []historyObj{} + for { + var v historyObj + if err := dec.Decode(&v); err == io.EOF { + break + } else if err != nil { + base.T.Fatal(err) + } + object = append(object, v) + } + + return object +} + +func imageHistoryRawHelper(base *testutil.Base, reference string, noTrunc bool, quiet bool, human bool) string { + cmd := []string{"image", "history"} + if noTrunc { + cmd = append(cmd, "--no-trunc") + } + if quiet { + cmd = append(cmd, "--quiet") + } + cmd = append(cmd, fmt.Sprintf("--human=%t", human)) + cmd = append(cmd, reference) + + cmdResult := base.Cmd(cmd...).Run() + assert.Equal(base.T, cmdResult.ExitCode, 0, cmdResult.Stdout()) + + return cmdResult.Stdout() +} + +func TestImageHistory(t *testing.T) { + // Here are the current issues with regard to docker true compatibility: + // - we have a different definition of what a layer id is (snapshot vs. id) + // this will require indepth convergence when moby will handle multi-platform images + // - our definition of size is different + // this requires some investigation to figure out why it differs + // possibly one is unpacked on the filessystem while the other is the tar file size? + // - we do not truncate ids when --quiet has been provided + // this is a conscious decision here - truncating with --quiet does not make much sense + testutil.DockerIncompatible(t) + + base := testutil.NewBase(t) + + // XXX the results here are obviously platform dependent - and it seems like windows cannot pull a linux image? + // Disabling for now + if runtime.GOOS == "windows" { + t.Skip("Windows is not supported for this test right now") + } + + base.Cmd("pull", "--platform", "linux/arm64", testutil.CommonImage).AssertOK() + + localTimeL1, _ := time.Parse(time.RFC3339, "2021-03-31T10:21:23-07:00") + localTimeL2, _ := time.Parse(time.RFC3339, "2021-03-31T10:21:21-07:00") + + // Human, no quiet, truncate + history := imageHistoryJSONHelper(base, testutil.CommonImage, false, false, true) + compTime1, _ := time.Parse(time.RFC3339, history[0].CreatedAt) + compTime2, _ := time.Parse(time.RFC3339, history[1].CreatedAt) + + fmt.Printf("history[0].CreatedAt: %s - parsed: %s - to UTC: %s - toString: %s\n", history[0].CreatedAt, compTime1, compTime1.UTC(), compTime1.UTC().String()) + + panic("foo") + // Two layers + assert.Equal(base.T, len(history), 2) + // First layer is a comment - zero size, no snap, + assert.Equal(base.T, history[0].Size, "0B") + assert.Equal(base.T, history[0].CreatedSince, "3 years ago") + assert.Equal(base.T, history[0].Snapshot, "") + assert.Equal(base.T, history[0].Comment, "") + + assert.Equal(base.T, compTime1.UTC().String(), localTimeL1.UTC().String()) + assert.Equal(base.T, history[0].CreatedBy, "/bin/sh -c #(nop) CMD [\"/bin/sh\"]") + + assert.Equal(base.T, compTime2.UTC().String(), localTimeL2.UTC().String()) + assert.Equal(base.T, history[1].CreatedBy, "/bin/sh -c #(nop) ADD file:3b16ffee2b26d8af5…") + + assert.Equal(base.T, history[1].Size, "5.947MB") + assert.Equal(base.T, history[1].CreatedSince, "3 years ago") + assert.Equal(base.T, history[1].Snapshot, "sha256:56bf55b8eed1f0b4794a30386e4d1d3da949c…") + assert.Equal(base.T, history[1].Comment, "") + + // No human - dates and sizes and not prettyfied + history = imageHistoryJSONHelper(base, testutil.CommonImage, false, false, false) + + assert.Equal(base.T, history[0].Size, "0") + assert.Equal(base.T, history[0].CreatedSince, history[0].CreatedAt) + + assert.Equal(base.T, history[1].Size, "5947392") + assert.Equal(base.T, history[1].CreatedSince, history[1].CreatedAt) + + // No trunc - do not truncate sha or cmd + history = imageHistoryJSONHelper(base, testutil.CommonImage, true, false, true) + assert.Equal(base.T, history[1].Snapshot, "sha256:56bf55b8eed1f0b4794a30386e4d1d3da949c25bcb5155e898097cd75dc77c2a") + assert.Equal(base.T, history[1].CreatedBy, "/bin/sh -c #(nop) ADD file:3b16ffee2b26d8af5db152fcc582aaccd9e1ec9e3343874e9969a205550fe07d in / ") + + // Quiet has no effect with format, so, go no-json, no-trunc + rawHistory := imageHistoryRawHelper(base, testutil.CommonImage, true, true, true) + assert.Equal(base.T, rawHistory, "\nsha256:56bf55b8eed1f0b4794a30386e4d1d3da949c25bcb5155e898097cd75dc77c2a\n") + + // With quiet, trunc has no effect + rawHistory = imageHistoryRawHelper(base, testutil.CommonImage, false, true, true) + assert.Equal(base.T, rawHistory, "\nsha256:56bf55b8eed1f0b4794a30386e4d1d3da949c25bcb5155e898097cd75dc77c2a\n") +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 40457da177f..da1d416e544 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -872,6 +872,7 @@ Flags: - :whale: `--no-trunc`: Don't truncate output - :whale: `-q, --quiet`: Only display snapshots IDs - :whale: `--format`: Format the output using the given Go template, e.g, `{{json .}}` +- :whale: `-H, --human`: Print sizes and dates in human readable format (default true) ### :whale: nerdctl image prune diff --git a/pkg/imgutil/imgutil.go b/pkg/imgutil/imgutil.go index e51850920a9..83e6e27f612 100644 --- a/pkg/imgutil/imgutil.go +++ b/pkg/imgutil/imgutil.go @@ -348,12 +348,15 @@ func ReadImageConfig(ctx context.Context, img containerd.Image) (ocispec.Image, return config, configDesc, err } p, err := content.ReadBlob(ctx, img.ContentStore(), configDesc) + log.G(ctx).Warnf("from blob: %s", string(p)) if err != nil { return config, configDesc, err } if err := json.Unmarshal(p, &config); err != nil { return config, configDesc, err } + deb, _ := json.MarshalIndent(config, "", " ") + log.G(ctx).Warnf("marshalled: %s", deb) return config, configDesc, nil }