diff --git a/cli/command/builder/prune.go b/cli/command/builder/prune.go index a7c88dcc42ac..d8301f71388b 100644 --- a/cli/command/builder/prune.go +++ b/cli/command/builder/prune.go @@ -85,15 +85,20 @@ 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, + OnProgress: progress.OnProgress, }) 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..6944d9e38e53 100644 --- a/cli/command/container/prune.go +++ b/cli/command/container/prune.go @@ -75,14 +75,19 @@ 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, + Filters: pruneFilters, + OnProgress: progress.OnProgress, }) 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..60e892701b50 100644 --- a/cli/command/container/prune_test.go +++ b/cli/command/container/prune_test.go @@ -7,7 +7,9 @@ import ( "testing" "github.com/docker/cli/internal/test" + "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" + "gotest.tools/v3/assert" ) func TestContainerPrunePromptTermination(t *testing.T) { @@ -25,3 +27,22 @@ func TestContainerPrunePromptTermination(t *testing.T) { cmd.SetErr(io.Discard) test.TerminatePrompt(ctx, t, cmd, cli) } + +func TestContainerPruneProgressBeforeReport(t *testing.T) { + var cli *test.FakeCli + cli = test.NewFakeCli(&fakeClient{ + containerPruneFunc: func(_ context.Context, opts client.ContainerPruneOptions) (client.ContainerPruneResult, error) { + assert.Assert(t, opts.OnProgress != nil) + assert.NilError(t, opts.OnProgress(client.PruneProgress{ID: "removed", Action: "deleted"})) + assert.Equal(t, cli.OutBuffer().String(), "Deleted Containers:\nremoved\n") + return client.ContainerPruneResult{Report: container.PruneReport{ + ContainersDeleted: []string{"removed"}, + SpaceReclaimed: 42, + }}, nil + }, + }) + 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..df9190a1f3fc 100644 --- a/cli/command/image/prune.go +++ b/cli/command/image/prune.go @@ -87,14 +87,19 @@ 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, + Filters: pruneFilters, + OnProgress: progress.OnProgress, }) 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..4cb457c935f5 100644 --- a/cli/command/network/prune.go +++ b/cli/command/network/prune.go @@ -72,14 +72,19 @@ 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, + Filters: pruneFilters, + OnProgress: progress.OnProgress, }) 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..96bd3393ba49 --- /dev/null +++ b/cli/command/system/pruner/progress.go @@ -0,0 +1,57 @@ +package pruner + +import ( + "fmt" + "io" + + "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 { + OnProgress func(client.PruneProgress) 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.OnProgress = func(progress client.PruneProgress) 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 +} diff --git a/cli/command/system/pruner/progress_test.go b/cli/command/system/pruner/progress_test.go new file mode 100644 index 000000000000..f8a4f541006d --- /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/client" + "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.OnProgress != nil) + assert.NilError(t, p.OnProgress(client.PruneProgress{ID: "first", Action: "untagged"})) + assert.Assert(t, p.Started) + assert.NilError(t, p.OnProgress(client.PruneProgress{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.OnProgress == 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.OnProgress(client.PruneProgress{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..be03c40aff12 100644 --- a/cli/command/volume/prune.go +++ b/cli/command/volume/prune.go @@ -90,14 +90,19 @@ 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, + Filters: pruneFilters, + OnProgress: progress.OnProgress, }) 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 {