Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance image history #3020

Merged
merged 1 commit into from
Jun 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 48 additions & 22 deletions cmd/nerdctl/image_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)")
apostasie marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 = "<missing>"
}
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)
Expand All @@ -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 {
Expand All @@ -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

Expand All @@ -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 {
Expand All @@ -193,6 +202,7 @@ func printHistory(cmd *cobra.Command, historys []historyPrintable) error {
w: w,
quiet: quiet,
noTrunc: noTrunc,
human: human,
tmpl: tmpl,
}

Expand All @@ -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 {
apostasie marked this conversation as resolved.
Show resolved Hide resolved
// 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
}
Expand Down
164 changes: 164 additions & 0 deletions cmd/nerdctl/image_history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
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")
}

// XXX Currently, history does not work on non-native platform, so, we cannot test reliably on other platforms
if runtime.GOARCH != "arm64" {
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)

// 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, "<missing>")
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, "<missing>\nsha256:56bf55b8eed1f0b4794a30386e4d1d3da949c25bcb5155e898097cd75dc77c2a\n")

// With quiet, trunc has no effect
rawHistory = imageHistoryRawHelper(base, testutil.CommonImage, false, true, true)
assert.Equal(base.T, rawHistory, "<missing>\nsha256:56bf55b8eed1f0b4794a30386e4d1d3da949c25bcb5155e898097cd75dc77c2a\n")
}
1 change: 1 addition & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions pkg/imgutil/imgutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading