diff --git a/cli/command/builder/prune.go b/cli/command/builder/prune.go index a7c88dcc42ac..631613536d3a 100644 --- a/cli/command/builder/prune.go +++ b/cli/command/builder/prune.go @@ -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") diff --git a/cli/command/container/prune.go b/cli/command/container/prune.go index 90aa540e2467..5ecc769bbfc2 100644 --- a/cli/command/container/prune.go +++ b/cli/command/container/prune.go @@ -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 { diff --git a/cli/command/container/prune_test.go b/cli/command/container/prune_test.go index 68ac21c3e08f..0ba3aff0ceee 100644 --- a/cli/command/container/prune_test.go +++ b/cli/command/container/prune_test.go @@ -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) { @@ -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") +} diff --git a/cli/command/image/prune.go b/cli/command/image/prune.go index ca12bcf5998d..4621cb4e672b 100644 --- a/cli/command/image/prune.go +++ b/cli/command/image/prune.go @@ -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 { diff --git a/cli/command/network/prune.go b/cli/command/network/prune.go index 2d7dc8571ce7..413e9c74c3ff 100644 --- a/cli/command/network/prune.go +++ b/cli/command/network/prune.go @@ -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 { diff --git a/cli/command/system/pruner/progress.go b/cli/command/system/pruner/progress.go new file mode 100644 index 000000000000..0c39a6724dd9 --- /dev/null +++ b/cli/command/system/pruner/progress.go @@ -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) +} diff --git a/cli/command/system/pruner/progress_test.go b/cli/command/system/pruner/progress_test.go new file mode 100644 index 000000000000..fda294edbd35 --- /dev/null +++ b/cli/command/system/pruner/progress_test.go @@ -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)) +} diff --git a/cli/command/volume/prune.go b/cli/command/volume/prune.go index 53c218203aaf..ed8ee721efcd 100644 --- a/cli/command/volume/prune.go +++ b/cli/command/volume/prune.go @@ -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 {