diff --git a/cmd/podman/volumes/prune.go b/cmd/podman/volumes/prune.go index 7b478d4e7f7..8b2b92467aa 100644 --- a/cmd/podman/volumes/prune.go +++ b/cmd/podman/volumes/prune.go @@ -45,6 +45,7 @@ func init() { _ = pruneCommand.RegisterFlagCompletionFunc(filterFlagName, common.AutocompleteVolumePruneFilters) flags.BoolP("force", "f", false, "Do not prompt for confirmation") flags.BoolP("all", "a", false, "Remove all unused volumes, both anonymous and named") + flags.Bool("dry-run", false, "Show what would be pruned without actually pruning") } func prune(cmd *cobra.Command, _ []string) error { @@ -74,7 +75,13 @@ func prune(cmd *cobra.Command, _ []string) error { pruneOptions.Filters.Set("all", "true") } - if !force { + dryRun, _ := cmd.Flags().GetBool("dry-run") + if force && dryRun { + return errors.New("--force and --dry-run cannot be used together") + } + pruneOptions.DryRun = dryRun + + if !force && !dryRun { reader := bufio.NewReader(os.Stdin) if allFlag { fmt.Println("WARNING! This will remove all volumes not used by at least one container. The following volumes will be removed:") @@ -135,6 +142,9 @@ func prune(cmd *cobra.Command, _ []string) error { if err != nil { return err } + if dryRun { + fmt.Println("Volumes that would be pruned:") + } return utils.PrintVolumePruneResults(responses, false) } diff --git a/docs/source/markdown/podman-volume-prune.1.md b/docs/source/markdown/podman-volume-prune.1.md index e41e1c8e26a..224a9429b6e 100644 --- a/docs/source/markdown/podman-volume-prune.1.md +++ b/docs/source/markdown/podman-volume-prune.1.md @@ -21,6 +21,10 @@ removal unless **--force** is used. Remove all unused volumes (anonymous and named). Without this option, only anonymous unused volumes are removed. +#### **--dry-run** + +Show which volumes would be pruned without removing them. + #### **--filter** Provide filter values. @@ -98,6 +102,12 @@ Prune unused volumes that do NOT have a specific label key: $ podman volume prune --filter label!=environment ``` +Preview all unused volumes without removing them. + +``` +$ podman volume prune --all --dry-run +``` + ## SEE ALSO **[podman(1)](podman.1.md)**, **[podman-volume(1)](podman-volume.1.md)** diff --git a/libpod/runtime_volume.go b/libpod/runtime_volume.go index 8d5ebcc73b7..27cccfb39c7 100644 --- a/libpod/runtime_volume.go +++ b/libpod/runtime_volume.go @@ -111,7 +111,7 @@ func (r *Runtime) GetAllVolumes() ([]*Volume, error) { } // PruneVolumes removes unused volumes from the system -func (r *Runtime) PruneVolumes(ctx context.Context, filterFuncs []VolumeFilter) ([]*reports.PruneReport, error) { +func (r *Runtime) PruneVolumes(ctx context.Context, filterFuncs []VolumeFilter, dryRun bool) ([]*reports.PruneReport, error) { preports := make([]*reports.PruneReport, 0) vols, err := r.Volumes(filterFuncs...) if err != nil { @@ -119,6 +119,17 @@ func (r *Runtime) PruneVolumes(ctx context.Context, filterFuncs []VolumeFilter) } for _, vol := range vols { + dangling, err := vol.IsDangling() + if err != nil { + preports = append(preports, &reports.PruneReport{ + Id: vol.Name(), + Err: err, + }) + } + if !dangling { + continue + } + report := new(reports.PruneReport) volSize, err := vol.Size() if err != nil { @@ -126,16 +137,18 @@ func (r *Runtime) PruneVolumes(ctx context.Context, filterFuncs []VolumeFilter) } report.Size = volSize report.Id = vol.Name() - var timeout *uint - if err := r.RemoveVolume(ctx, vol, false, timeout); err != nil { - if !errors.Is(err, define.ErrVolumeBeingUsed) && !errors.Is(err, define.ErrVolumeRemoved) { - report.Err = err + if !dryRun { + var timeout *uint + if err := r.RemoveVolume(ctx, vol, false, timeout); err != nil { + if !errors.Is(err, define.ErrVolumeBeingUsed) && !errors.Is(err, define.ErrVolumeRemoved) { + report.Err = err + } else { + // We didn't remove the volume for some reason + continue + } } else { - // We didn't remove the volume for some reason - continue + vol.newVolumeEvent(events.Prune) } - } else { - vol.newVolumeEvent(events.Prune) } preports = append(preports, report) } diff --git a/pkg/api/handlers/compat/volumes.go b/pkg/api/handlers/compat/volumes.go index 9f65afdbf38..907f1ba7143 100644 --- a/pkg/api/handlers/compat/volumes.go +++ b/pkg/api/handlers/compat/volumes.go @@ -298,7 +298,7 @@ func PruneVolumes(w http.ResponseWriter, r *http.Request) { filterFuncs = append(filterFuncs, filterFunc) } - pruned, err := runtime.PruneVolumes(r.Context(), filterFuncs) + pruned, err := runtime.PruneVolumes(r.Context(), filterFuncs, false) if err != nil { utils.InternalServerError(w, err) return diff --git a/pkg/api/handlers/libpod/volumes.go b/pkg/api/handlers/libpod/volumes.go index b8b122a77bb..36a59673201 100644 --- a/pkg/api/handlers/libpod/volumes.go +++ b/pkg/api/handlers/libpod/volumes.go @@ -146,7 +146,16 @@ func PruneVolumes(w http.ResponseWriter, r *http.Request) { } func pruneVolumesHelper(r *http.Request) ([]*reports.PruneReport, error) { + decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder) runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + + query := struct { + DryRun bool `schema:"dryrun"` + }{} + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + return nil, err + } filterMap, err := util.PrepareFilters(r) if err != nil { return nil, err @@ -162,7 +171,7 @@ func pruneVolumesHelper(r *http.Request) ([]*reports.PruneReport, error) { filterFuncs = append(filterFuncs, filterFunc) } - reports, err := runtime.PruneVolumes(r.Context(), filterFuncs) + reports, err := runtime.PruneVolumes(r.Context(), filterFuncs, query.DryRun) if err != nil { return nil, err } diff --git a/pkg/api/server/register_volumes.go b/pkg/api/server/register_volumes.go index f2fd5c2e4b3..433706e444a 100644 --- a/pkg/api/server/register_volumes.go +++ b/pkg/api/server/register_volumes.go @@ -95,6 +95,12 @@ func (s *APIServer) registerVolumeHandlers(r *mux.Router) error { // - `anonymous` When true/false, restrict to anonymous or named volumes only. // - `until=` Prune volumes created before this timestamp. The `` can be Unix timestamps, date formatted timestamps, or Go duration strings (e.g. `10m`, `1h30m`) computed relative to the daemon machine’s time. // - `label` (`label=`, `label==`, `label!=`, or `label!==`) Prune volumes with (or without, in case `label!=...` is used) the specified labels. + // - in: query + // name: dryrun + // type: boolean + // required: false + // default: false + // description: Show which volumes would be pruned without removing them. // responses: // '200': // "$ref": "#/responses/volumePruneLibpod" diff --git a/pkg/bindings/test/volumes_test.go b/pkg/bindings/test/volumes_test.go index c215b7c5462..894dca4f528 100644 --- a/pkg/bindings/test/volumes_test.go +++ b/pkg/bindings/test/volumes_test.go @@ -226,4 +226,20 @@ var _ = Describe("Podman volumes", func() { Expect(err).ToNot(HaveOccurred()) Expect(pruned).To(HaveLen(1)) }) + + It("prune volume with dry-run", func() { + vol, err := volumes.Create(connText, entities.VolumeCreateOptions{Name: "vol"}, nil) + Expect(err).ToNot(HaveOccurred()) + + options := new(volumes.PruneOptions). + WithFilters(map[string][]string{"all": {"true"}}). + WithDryRun(true) + + vols, err := volumes.Prune(connText, options) + Expect(err).ToNot(HaveOccurred()) + Expect(reports.PruneReportsIds(vols)).To(ContainElement(vol.Name)) + + _, err = volumes.Inspect(connText, vol.Name, nil) + Expect(err).ToNot(HaveOccurred()) + }) }) diff --git a/pkg/bindings/volumes/types.go b/pkg/bindings/volumes/types.go index 0be3238641f..2b1b636625e 100644 --- a/pkg/bindings/volumes/types.go +++ b/pkg/bindings/volumes/types.go @@ -24,6 +24,8 @@ type ListOptions struct { type PruneOptions struct { // Filters applied to the pruning of volumes Filters map[string][]string + // DryRun lists volumes that would be pruned without removing them. + DryRun *bool } // RemoveOptions are optional options for removing volumes diff --git a/pkg/bindings/volumes/types_prune_options.go b/pkg/bindings/volumes/types_prune_options.go index 42ccec24777..17d3bf5a026 100644 --- a/pkg/bindings/volumes/types_prune_options.go +++ b/pkg/bindings/volumes/types_prune_options.go @@ -31,3 +31,18 @@ func (o *PruneOptions) GetFilters() map[string][]string { } return o.Filters } + +// WithDryRun set field DryRun to given value +func (o *PruneOptions) WithDryRun(value bool) *PruneOptions { + o.DryRun = &value + return o +} + +// GetDryRun returns value of field DryRun +func (o *PruneOptions) GetDryRun() bool { + if o.DryRun == nil { + var z bool + return z + } + return *o.DryRun +} diff --git a/pkg/domain/entities/volumes.go b/pkg/domain/entities/volumes.go index 4150b882149..f257d26227f 100644 --- a/pkg/domain/entities/volumes.go +++ b/pkg/domain/entities/volumes.go @@ -29,6 +29,7 @@ type VolumeInspectReport = types.VolumeInspectReport // - when filter "all" is true, all unused volumes are pruned. type VolumePruneOptions struct { Filters url.Values `json:"filters" schema:"filters"` + DryRun bool `json:"dry_run" schema:"dryrun"` } type VolumeListOptions struct { diff --git a/pkg/domain/infra/abi/volumes.go b/pkg/domain/infra/abi/volumes.go index 7973b5e3264..b977ce68d87 100644 --- a/pkg/domain/infra/abi/volumes.go +++ b/pkg/domain/infra/abi/volumes.go @@ -144,11 +144,11 @@ func (ic *ContainerEngine) VolumePrune(ctx context.Context, options entities.Vol } funcs = append(funcs, filterFunc) } - return ic.pruneVolumesHelper(ctx, funcs) + return ic.pruneVolumesHelper(ctx, funcs, options.DryRun) } -func (ic *ContainerEngine) pruneVolumesHelper(ctx context.Context, filterFuncs []libpod.VolumeFilter) ([]*reports.PruneReport, error) { - pruned, err := ic.Libpod.PruneVolumes(ctx, filterFuncs) +func (ic *ContainerEngine) pruneVolumesHelper(ctx context.Context, filterFuncs []libpod.VolumeFilter, dryRun bool) ([]*reports.PruneReport, error) { + pruned, err := ic.Libpod.PruneVolumes(ctx, filterFuncs, dryRun) if err != nil { return nil, err } diff --git a/pkg/domain/infra/tunnel/volumes.go b/pkg/domain/infra/tunnel/volumes.go index 60b7ad73384..fd520c05069 100644 --- a/pkg/domain/infra/tunnel/volumes.go +++ b/pkg/domain/infra/tunnel/volumes.go @@ -76,7 +76,7 @@ func (ic *ContainerEngine) VolumeInspect(_ context.Context, namesOrIds []string, } func (ic *ContainerEngine) VolumePrune(_ context.Context, opts entities.VolumePruneOptions) ([]*reports.PruneReport, error) { - options := new(volumes.PruneOptions).WithFilters(opts.Filters) + options := new(volumes.PruneOptions).WithFilters(opts.Filters).WithDryRun(opts.DryRun) return volumes.Prune(ic.ClientCtx, options) } diff --git a/test/e2e/volume_prune_test.go b/test/e2e/volume_prune_test.go index 3bfcde56e71..7622b222877 100644 --- a/test/e2e/volume_prune_test.go +++ b/test/e2e/volume_prune_test.go @@ -5,6 +5,7 @@ package integration import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + . "go.podman.io/podman/v6/test/utils" ) var _ = Describe("Podman volume prune", func() { @@ -139,4 +140,80 @@ var _ = Describe("Podman volume prune", func() { Expect(session.OutputToStringArray()).To(HaveLen(1)) Expect(session.OutputToStringArray()[0]).To(Equal(vol1)) }) + + It("podman volume prune --all --dry-run", func() { + vol1 := "vol1" + vol2 := "vol2" + + podmanTest.PodmanExitCleanly("volume", "create", vol1) + podmanTest.PodmanExitCleanly("volume", "create", vol2) + + session := podmanTest.PodmanExitCleanly("volume", "prune", "--all", "--dry-run") + Expect(session.OutputToString()).To(ContainSubstring("Volumes that would be pruned:")) + Expect(session.OutputToString()).To(ContainSubstring(vol1)) + Expect(session.OutputToString()).To(ContainSubstring(vol2)) + + session = podmanTest.PodmanExitCleanly("volume", "ls", "-q") + Expect(session.OutputToStringArray()).To(HaveLen(2)) + Expect(session.OutputToString()).To(ContainSubstring(vol1)) + Expect(session.OutputToString()).To(ContainSubstring(vol2)) + }) + + It("podman volume prune --filter --dry-run", func() { + vol1 := "vol1" + vol2 := "vol2" + + podmanTest.PodmanExitCleanly("volume", "create", "--label", "dryrun=true", vol1) + podmanTest.PodmanExitCleanly("volume", "create", "--label", "dryrun=false", vol2) + + session := podmanTest.PodmanExitCleanly("volume", "prune", "--dry-run", "--filter", "label=dryrun=true") + Expect(session.OutputToString()).To(ContainSubstring("Volumes that would be pruned:")) + Expect(session.OutputToString()).To(ContainSubstring(vol1)) + Expect(session.OutputToString()).ToNot(ContainSubstring(vol2)) + + session = podmanTest.PodmanExitCleanly("volume", "ls", "-q") + Expect(session.OutputToStringArray()).To(HaveLen(2)) + Expect(session.OutputToString()).To(ContainSubstring(vol1)) + Expect(session.OutputToString()).To(ContainSubstring(vol2)) + }) + + It("podman volume prune --dry-run only shows anonymous volumes", func() { + vol1 := "vol1" + anon_vol := podmanTest.PodmanExitCleanly("volume", "create").OutputToString() + podmanTest.PodmanExitCleanly("volume", "create", vol1) + + session := podmanTest.PodmanExitCleanly("volume", "prune", "--dry-run") + Expect(session.OutputToString()).To(ContainSubstring("Volumes that would be pruned:")) + Expect(session.OutputToString()).To(ContainSubstring(anon_vol)) + + session = podmanTest.PodmanExitCleanly("volume", "ls", "-q") + Expect(session.OutputToStringArray()).To(HaveLen(2)) + Expect(session.OutputToString()).To(ContainSubstring(vol1)) + Expect(session.OutputToString()).To(ContainSubstring(anon_vol)) + }) + + It("podman volume prune --all --dry-run excludes volumes in use", func() { + vol1 := "vol1" + vol2 := "vol2" + + podmanTest.PodmanExitCleanly("volume", "create", vol1) + podmanTest.PodmanExitCleanly("volume", "create", vol2) + podmanTest.PodmanExitCleanly("create", "-v", "vol2:/data", ALPINE, "ls") + + session := podmanTest.PodmanExitCleanly("volume", "prune", "--all", "--dry-run") + Expect(session.OutputToString()).To(ContainSubstring("Volumes that would be pruned:")) + Expect(session.OutputToString()).To(ContainSubstring(vol1)) + Expect(session.OutputToString()).ToNot(ContainSubstring(vol2)) + + session = podmanTest.PodmanExitCleanly("volume", "ls", "-q") + Expect(session.OutputToStringArray()).To(HaveLen(2)) + Expect(session.OutputToString()).To(ContainSubstring(vol1)) + Expect(session.OutputToString()).To(ContainSubstring(vol2)) + }) + + It("podman volume prune --force --dry-run fails", func() { + session := podmanTest.Podman([]string{"volume", "prune", "--force", "--dry-run"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitWithError(125, "--force and --dry-run cannot be used together")) + }) })