diff --git a/cmd/nerdctl/system_prune.go b/cmd/nerdctl/system_prune.go index 0eab374a6ae..99e28f4b554 100644 --- a/cmd/nerdctl/system_prune.go +++ b/cmd/nerdctl/system_prune.go @@ -17,7 +17,11 @@ package main import ( + "bufio" + "bytes" + "encoding/json" "fmt" + "github.com/containerd/nerdctl/pkg/buildkitutil" "strings" "github.com/sirupsen/logrus" @@ -72,6 +76,7 @@ func systemPruneAction(cmd *cobra.Command, args []string) error { } msg += ` - all images without at least one container associated to them + - all build cache ` msg += "\nAre you sure you want to continue? [y/N] " fmt.Fprintf(cmd.OutOrStdout(), "WARNING! %s", msg) @@ -99,5 +104,29 @@ func systemPruneAction(cmd *cobra.Command, args []string) error { return err } } - return imagePrune(cmd, client, ctx) + if err := imagePrune(cmd, client, ctx); err != nil { + return err + } + + var buf bytes.Buffer + outWriter := bufio.NewWriter(&buf) + copiedCmd := *cmd + copiedCmd.SetOut(outWriter) + if err := builderPruneAction(&copiedCmd, args); err != nil { + return err + } + parsedOutput, err := buildkitutil.ParseBuildctlPruneTableOutput(buf.Bytes()) + if err != nil { + return err + } + // pretty print the output of prune build cache + out := cmd.OutOrStdout() + for _, row := range parsedOutput.Rows { + _, _ = fmt.Fprintf(out, "%s\n", row.ID) + } + j, _ := json.Marshal(parsedOutput) + fmt.Fprintf(out, "%s\n", j) + + fmt.Fprintf(out, "%s", buf.Bytes()) + return err } diff --git a/pkg/buildkitutil/buildctl_prune_output_parser.go b/pkg/buildkitutil/buildctl_prune_output_parser.go new file mode 100644 index 00000000000..4fde2c17aea --- /dev/null +++ b/pkg/buildkitutil/buildctl_prune_output_parser.go @@ -0,0 +1,97 @@ +/* + 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. +*/ + +/* + Portions from https://github.com/docker/cli/blob/v20.10.9/cli/command/image/build/context.go + Copyright (C) Docker authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/docker/cli/blob/v20.10.9/NOTICE +*/ + +package buildkitutil + +import ( + "fmt" + "github.com/containerd/nerdctl/pkg/tabutil" + "strings" +) + +type BuildctlPruneOutput struct { + TotalSize string + Rows []BuildctlPruneOutputRow +} + +type BuildctlPruneOutputRow struct { + ID string + Reclaimable string + Size string + LastAccessed string +} + +const HeaderID = "ID" +const HeaderReclaimable = "RECLAIMABLE" +const HeaderSize = "SIZE" +const HeaderLastAccessed = "LAST ACCESSED" +const FinalizerTotal = "Total:" + +func ParseBuildctlPruneTableOutput(out []byte) (*BuildctlPruneOutput, error) { + tabReader := tabutil.NewReader(fmt.Sprintf("%s\t%s\t%s\t%s", HeaderID, HeaderReclaimable, HeaderSize, HeaderLastAccessed)) + lines := strings.Split(string(out), "\n") + + totalSize, err := parseTotalSize(lines[len(lines)-2]) + if err != nil { + return nil, err + } + + if len(lines) == 2 { + return &BuildctlPruneOutput{ + TotalSize: totalSize, + Rows: nil, + }, nil + } + + if err := tabReader.ParseHeader(lines[0]); err != nil { + return nil, err + } + + var rows []BuildctlPruneOutputRow + + for _, line := range lines[1 : len(lines)-2] { + // best effort parse row + id, _ := tabReader.ReadRow(line, HeaderID) + reclaimable, _ := tabReader.ReadRow(line, HeaderReclaimable) + size, _ := tabReader.ReadRow(line, HeaderSize) + lastAccessed, _ := tabReader.ReadRow(line, HeaderLastAccessed) + rows = append(rows, BuildctlPruneOutputRow{ + ID: id, + Reclaimable: reclaimable, + Size: size, + LastAccessed: lastAccessed, + }) + } + + return &BuildctlPruneOutput{ + TotalSize: totalSize, + Rows: rows, + }, nil +} + +func parseTotalSize(line string) (string, error) { + if strings.HasPrefix(line, FinalizerTotal) { + return strings.TrimSpace(line[len(FinalizerTotal):]), nil + } + return "", fmt.Errorf("parse total size from buildctl prune command ouput, unexpected line, does not contains total size: %s", line) +} diff --git a/pkg/buildkitutil/buildctl_prune_output_parser_test.go b/pkg/buildkitutil/buildctl_prune_output_parser_test.go new file mode 100644 index 00000000000..215561e5053 --- /dev/null +++ b/pkg/buildkitutil/buildctl_prune_output_parser_test.go @@ -0,0 +1,148 @@ +/* + 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. +*/ + +/* + Portions from https://github.com/docker/cli/blob/v20.10.9/cli/command/image/build/context.go + Copyright (C) Docker authors. + Licensed under the Apache License, Version 2.0 + NOTICE: https://github.com/docker/cli/blob/v20.10.9/NOTICE +*/ + +package buildkitutil + +import ( + "reflect" + "testing" +) + +func TestParseBuildctlPruneOutput(t *testing.T) { + type args struct { + out []byte + } + tests := []struct { + name string + args args + want *BuildctlPruneOutput + wantErr bool + }{ + { + name: "builtctl prune frees several spaces", + args: args{out: []byte(`ID RECLAIMABLE SIZE LAST ACCESSED +4kaw6aqapf3qvmqj3jskct89r true 0B +i0ys2vka15idnu952zc3le36o* true 0B +zn1cxnxqyxa18mzi5u2bnnqqk* true 4.10kB +x43clde10rasyy42vvzwo5w1r true 156B +kt007lnewdnssgukycqux8rcc true 0B +th54xgnz12r1rsgaoy55pfla1 true 0B +66b6kbtv4iul87bzibzxwgo51 true 0B +nlanvxjtqluiipuwva3uwfwvk true 0B +yh3oha6crdq9y34lmi2jshwhb true 0B +hai2h463u7o8xuybj0339e139 true 0B +Total: 4.25kB +`)}, + want: &BuildctlPruneOutput{ + TotalSize: "584.31MB", + Rows: []BuildctlPruneOutputRow{ + { + ID: "4kaw6aqapf3qvmqj3jskct89r", + Reclaimable: "true", + Size: "0B", + LastAccessed: "", + }, + { + ID: "i0ys2vka15idnu952zc3le36o*", + Reclaimable: "true", + Size: "0B", + LastAccessed: "", + }, + { + ID: "zn1cxnxqyxa18mzi5u2bnnqqk*", + Reclaimable: "true", + Size: "4.10kB", + LastAccessed: "", + }, + { + ID: "x43clde10rasyy42vvzwo5w1r", + Reclaimable: "true", + Size: "156B", + LastAccessed: "", + }, + { + ID: "kt007lnewdnssgukycqux8rcc", + Reclaimable: "true", + Size: "0B", + LastAccessed: "", + }, + { + ID: "th54xgnz12r1rsgaoy55pfla1", + Reclaimable: "true", + Size: "0B", + LastAccessed: "", + }, + { + ID: "66b6kbtv4iul87bzibzxwgo51", + Reclaimable: "true", + Size: "0B", + LastAccessed: "", + }, + { + ID: "nlanvxjtqluiipuwva3uwfwvk", + Reclaimable: "true", + Size: "0B", + LastAccessed: "", + }, + { + ID: "yh3oha6crdq9y34lmi2jshwhb", + Reclaimable: "true", + Size: "0B", + LastAccessed: "", + }, + { + ID: "hai2h463u7o8xuybj0339e139", + Reclaimable: "true", + Size: "0B", + LastAccessed: "", + }, + }, + }, + wantErr: false, + }, + { + name: "buildctl prune frees no spaces", + args: args{ + out: []byte(`Total: 0B +`), + }, + want: &BuildctlPruneOutput{ + TotalSize: "0B", + Rows: nil, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseBuildctlPruneTableOutput(tt.args.out) + if (err != nil) != tt.wantErr { + t.Errorf("ParseBuildctlPruneTableOutput() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseBuildctlPruneTableOutput() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/tabutil/tabutil.go b/pkg/tabutil/tabutil.go index b1986ed4c77..a036aa13cc2 100644 --- a/pkg/tabutil/tabutil.go +++ b/pkg/tabutil/tabutil.go @@ -61,11 +61,21 @@ func (r *TabReader) ReadRow(row, key string) (string, bool) { if !ok { return "", false } + if idx.start > len(row) { + return "", false + } var value string if idx.end == -1 { value = row[idx.start:] } else { - value = row[idx.start:idx.end] + end := min(idx.end, len(row)) + value = row[idx.start:end] } return strings.TrimSpace(value), true } +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/tabutil/tabutil_test.go b/pkg/tabutil/tabutil_test.go index e490434f347..1705f46ab65 100644 --- a/pkg/tabutil/tabutil_test.go +++ b/pkg/tabutil/tabutil_test.go @@ -44,3 +44,37 @@ func TestTabReader(t *testing.T) { value, _ = reader.ReadRow(tabRows[2], "b") assert.Equal(t, value, "456") } + +func TestTabReaderParseTrimmedRow(t *testing.T) { + // for example, row 2 and row 3 are trimmed because they do not contain value for the header c + tabRows := strings.Split(`a b c +1 2 +123 456`, "\n") + reader := NewReader("a\tb\tc\t") + + err := reader.ParseHeader(tabRows[0]) + assert.NilError(t, err) + + var ( + value string + ok bool + ) + value, ok = reader.ReadRow(tabRows[1], "a") + assert.Equal(t, value, "1") + assert.Equal(t, ok, true) + value, ok = reader.ReadRow(tabRows[1], "b") + assert.Equal(t, value, "2") + assert.Equal(t, ok, true) + value, ok = reader.ReadRow(tabRows[1], "c") + assert.Equal(t, value, "") + assert.Equal(t, ok, false) + value, ok = reader.ReadRow(tabRows[2], "a") + assert.Equal(t, value, "123") + assert.Equal(t, ok, true) + value, ok = reader.ReadRow(tabRows[2], "b") + assert.Equal(t, value, "456") + assert.Equal(t, ok, true) + value, ok = reader.ReadRow(tabRows[2], "c") + assert.Equal(t, value, "") + assert.Equal(t, ok, false) +}