Skip to content
Draft
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
9 changes: 9 additions & 0 deletions cli/command/builder/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,24 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
}
}

progress := pruner.NewProgressPrinter(dockerCli, pruner.TypeBuildCache)
resp, err := dockerCli.Client().BuildCachePrune(ctx, client.BuildCachePruneOptions{
All: options.all,
ReservedSpace: options.reservedSpace.Value(),
Filters: pruneFilters,
Stream: progress.Stream,
})
if err != nil {
return 0, "", err
}
resp.Report, err = pruner.Consume(ctx, resp, progress)
if err != nil {
return 0, "", err
}
report := resp.Report
if progress.Started {
return report.SpaceReclaimed, "", progress.Finish()
}
if len(report.CachesDeleted) > 0 {
var sb strings.Builder
sb.WriteString("Deleted build cache objects:\n")
Expand Down
9 changes: 9 additions & 0 deletions cli/command/container/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,23 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
}
}

progress := pruner.NewProgressPrinter(dockerCli, pruner.TypeContainer)
res, err := dockerCli.Client().ContainerPrune(ctx, client.ContainerPruneOptions{
Filters: pruneFilters,
Stream: progress.Stream,
})
if err != nil {
return 0, "", err
}
res.Report, err = pruner.Consume(ctx, res, progress)
if err != nil {
return 0, "", err
}

var out strings.Builder
if progress.Started {
return res.Report.SpaceReclaimed, "", progress.Finish()
}
if len(res.Report.ContainersDeleted) > 0 {
out.WriteString("Deleted Containers:\n")
for _, id := range res.Report.ContainersDeleted {
Expand Down
34 changes: 34 additions & 0 deletions cli/command/container/prune_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"

"github.com/docker/cli/internal/test"
"github.com/moby/moby/client"
"gotest.tools/v3/assert"
)

func TestContainerPrunePromptTermination(t *testing.T) {
Expand All @@ -25,3 +28,34 @@ func TestContainerPrunePromptTermination(t *testing.T) {
cmd.SetErr(io.Discard)
test.TerminatePrompt(ctx, t, cmd, cli)
}

// pruneReportReader checks that progress was printed before the final report is read.
type pruneRoundTripper func(*http.Request) (*http.Response, error)

func (r pruneRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return r(req) }

type pruneReportReader func([]byte) (int, error)

func (r pruneReportReader) Read(p []byte) (int, error) { return r(p) }
func TestContainerPruneProgressBeforeReport(t *testing.T) {
var cli *test.FakeCli
final := strings.NewReader(`{"report":{"ContainersDeleted":["removed"],"SpaceReclaimed":42}}`)
realClient, err := client.New(client.WithAPIVersion("1.56"), client.WithHTTPClient(&http.Client{Transport: pruneRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/jsonl"}}, Body: io.NopCloser(io.MultiReader(
strings.NewReader("{\"object\":{\"id\":\"removed\",\"action\":\"deleted\"}}\n"),
pruneReportReader(func(p []byte) (int, error) {
assert.Equal(t, cli.OutBuffer().String(), "Deleted Containers:\nremoved\n")
return final.Read(p)
}),
))}, nil
})}))
assert.NilError(t, err)
cli = test.NewFakeCli(&fakeClient{containerPruneFunc: func(ctx context.Context, opts client.ContainerPruneOptions) (client.ContainerPruneResult, error) {
assert.Assert(t, opts.Stream)
return realClient.ContainerPrune(ctx, opts)
}})
cmd := newPruneCommand(cli)
cmd.SetArgs([]string{"--force"})
assert.NilError(t, cmd.Execute())
assert.Equal(t, cli.OutBuffer().String(), "Deleted Containers:\nremoved\n\nTotal reclaimed space: 42B\n")
}
9 changes: 9 additions & 0 deletions cli/command/image/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,23 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
}
}

progress := pruner.NewProgressPrinter(dockerCli, pruner.TypeImage)
res, err := dockerCli.Client().ImagePrune(ctx, client.ImagePruneOptions{
Filters: pruneFilters,
Stream: progress.Stream,
})
if err != nil {
return 0, "", err
}
res.Report, err = pruner.Consume(ctx, res, progress)
if err != nil {
return 0, "", err
}

var sb strings.Builder
if progress.Started {
return res.Report.SpaceReclaimed, "", progress.Finish()
}
if len(res.Report.ImagesDeleted) > 0 {
sb.WriteString("Deleted Images:\n")
for _, st := range res.Report.ImagesDeleted {
Expand Down
9 changes: 9 additions & 0 deletions cli/command/network/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,23 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
}
}

progress := pruner.NewProgressPrinter(dockerCli, pruner.TypeNetwork)
res, err := dockerCli.Client().NetworkPrune(ctx, client.NetworkPruneOptions{
Filters: pruneFilters,
Stream: progress.Stream,
})
if err != nil {
return "", err
}
res.Report, err = pruner.Consume(ctx, res, progress)
if err != nil {
return "", err
}

var out strings.Builder
if progress.Started {
return "", progress.Finish()
}
if len(res.Report.NetworksDeleted) > 0 {
out.WriteString("Deleted Networks:\n")
for _, id := range res.Report.NetworksDeleted {
Expand Down
78 changes: 78 additions & 0 deletions cli/command/system/pruner/progress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package pruner

import (
"context"
"fmt"
"io"

"github.com/moby/moby/api/types/prune"

"github.com/docker/cli/cli/command"
"github.com/moby/moby/client"
"github.com/moby/moby/client/pkg/versions"
)

// ProgressPrinter prints confirmed deletions as they arrive from the daemon.
type ProgressPrinter struct {
Stream bool
Print func(prune.Object) error
Started bool
out io.Writer
}

// NewProgressPrinter uses streaming when supported by the negotiated API version.
// Older daemons continue to use the final prune report.
func NewProgressPrinter(cli command.Cli, contentType ContentType) *ProgressPrinter {
p := &ProgressPrinter{out: cli.Out()}
if versions.LessThan(cli.CurrentVersion(), "1.56") {
return p
}
headings := map[ContentType]string{
TypeContainer: "Deleted Containers:",
TypeNetwork: "Deleted Networks:",
TypeVolume: "Deleted Volumes:",
TypeImage: "Deleted Images:",
TypeBuildCache: "Deleted build cache objects:",
}
p.Stream = true
p.Print = func(progress prune.Object) error {
if !p.Started {
if _, err := fmt.Fprintln(p.out, headings[contentType]); err != nil {
return err
}
p.Started = true
}
if contentType == TypeImage {
_, err := fmt.Fprintf(p.out, "%s: %s\n", progress.Action, progress.ID)
return err
}
_, err := fmt.Fprintln(p.out, progress.ID)
return err
}
return p
}

// Finish preserves the blank line after a nonempty prune report.
func (p *ProgressPrinter) Finish() error {
if !p.Started {
return nil
}
_, err := fmt.Fprintln(p.out)
return err
}

// Consume prints typed object events and returns the complete report.
func Consume[T any](ctx context.Context, response client.PruneResponse[T], printer *ProgressPrinter) (T, error) {
defer response.Close()
for object, err := range response.Events(ctx) {
if err != nil {
var zero T
return zero, err
}
if err := printer.Print(object); err != nil {
var zero T
return zero, err
}
}
return response.Wait(ctx)
}
71 changes: 71 additions & 0 deletions cli/command/system/pruner/progress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package pruner

import (
"errors"
"io"
"testing"

"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/test"
"github.com/moby/moby/api/types/prune"
"gotest.tools/v3/assert"
)

type versionedCLI struct {
command.Cli
version string
}

func (c versionedCLI) CurrentVersion() string { return c.version }

func TestProgressPrinter(t *testing.T) {
for _, tc := range []struct {
contentType ContentType
expected string
}{
{TypeContainer, "Deleted Containers:\nfirst\nsecond\n\n"},
{TypeNetwork, "Deleted Networks:\nfirst\nsecond\n\n"},
{TypeVolume, "Deleted Volumes:\nfirst\nsecond\n\n"},
{TypeImage, "Deleted Images:\nuntagged: first\ndeleted: second\n\n"},
{TypeBuildCache, "Deleted build cache objects:\nfirst\nsecond\n\n"},
} {
t.Run(string(tc.contentType), func(t *testing.T) {
cli := test.NewFakeCli(nil)
p := NewProgressPrinter(versionedCLI{Cli: cli, version: "1.56"}, tc.contentType)
assert.Assert(t, p.Print != nil)
assert.NilError(t, p.Print(prune.Object{ID: "first", Action: "untagged"}))
assert.Assert(t, p.Started)
assert.NilError(t, p.Print(prune.Object{ID: "second", Action: "deleted"}))
assert.NilError(t, p.Finish())
assert.Equal(t, cli.OutBuffer().String(), tc.expected)
})
}
}

func TestProgressPrinterOldAPI(t *testing.T) {
cli := test.NewFakeCli(nil)
p := NewProgressPrinter(versionedCLI{Cli: cli, version: "1.55"}, TypeContainer)
assert.Assert(t, p.Print == nil)
assert.NilError(t, p.Finish())
assert.Equal(t, cli.OutBuffer().String(), "")
}

func TestProgressPrinterEmpty(t *testing.T) {
cli := test.NewFakeCli(nil)
p := NewProgressPrinter(versionedCLI{Cli: cli, version: "1.56"}, TypeContainer)
assert.NilError(t, p.Finish())
assert.Equal(t, cli.OutBuffer().String(), "")
}

type failingWriter struct{}

func (failingWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }

func TestProgressPrinterOutputError(t *testing.T) {
cli := test.NewFakeCli(nil)
cli.SetOut(streams.NewOut(failingWriter{}))
p := NewProgressPrinter(versionedCLI{Cli: cli, version: "1.56"}, TypeContainer)
err := p.Print(prune.Object{ID: "first", Action: "deleted"})
assert.Assert(t, errors.Is(err, io.ErrClosedPipe))
}
9 changes: 9 additions & 0 deletions cli/command/volume/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,23 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions)
}
}

progress := pruner.NewProgressPrinter(dockerCli, pruner.TypeVolume)
res, err := dockerCli.Client().VolumePrune(ctx, client.VolumePruneOptions{
Filters: pruneFilters,
Stream: progress.Stream,
})
if err != nil {
return 0, "", err
}
res.Report, err = pruner.Consume(ctx, res, progress)
if err != nil {
return 0, "", err
}

var out strings.Builder
if progress.Started {
return res.Report.SpaceReclaimed, "", progress.Finish()
}
if len(res.Report.VolumesDeleted) > 0 {
out.WriteString("Deleted Volumes:\n")
for _, id := range res.Report.VolumesDeleted {
Expand Down