diff --git a/cmd/podman/system/prune.go b/cmd/podman/system/prune.go index d8991c21ad..930914bf2a 100644 --- a/cmd/podman/system/prune.go +++ b/cmd/podman/system/prune.go @@ -36,7 +36,8 @@ var ( ValidArgsFunction: completion.AutocompleteNone, Example: `podman system prune`, } - force bool + force bool + includePinned bool ) func init() { @@ -50,6 +51,7 @@ func init() { flags.BoolVar(&pruneOptions.External, "external", false, "Remove container data in storage not controlled by podman") flags.BoolVar(&pruneOptions.Build, "build", false, "Remove build containers") flags.BoolVar(&pruneOptions.Volume, "volumes", false, "Prune volumes") + flags.BoolVar(&includePinned, "include-pinned", false, "Include pinned volumes in prune operation") filterFlagName := "filter" flags.StringArrayVar(&filters, filterFlagName, []string{}, "Provide filter values (e.g. 'label==')") _ = pruneCommand.RegisterFlagCompletionFunc(filterFlagName, common.AutocompletePruneFilters) @@ -80,12 +82,22 @@ func prune(cmd *cobra.Command, args []string) error { return nil } } + + // Set the include pinned flag for volume pruning + if pruneOptions.Volume { + pruneOptions.VolumePruneOptions.IncludePinned = includePinned + } // Remove all unused pods, containers, images, networks, and volume data. pruneOptions.Filters, err = parse.FilterArgumentsIntoFilters(filters) if err != nil { return err } + + // Set the include pinned flag for volume pruning + if pruneOptions.Volume { + pruneOptions.VolumePruneOptions.IncludePinned = includePinned + } response, err := registry.ContainerEngine().SystemPrune(context.Background(), pruneOptions) if err != nil { @@ -126,6 +138,11 @@ func prune(cmd *cobra.Command, args []string) error { } func createPruneWarningMessage(pruneOpts entities.SystemPruneOptions) string { + pinnedNote := "" + if pruneOpts.Volume && !pruneOpts.VolumePruneOptions.IncludePinned { + pinnedNote = " (excluding pinned volumes)" + } + if pruneOpts.All { return `WARNING! This command removes: - all stopped containers @@ -137,7 +154,7 @@ func createPruneWarningMessage(pruneOpts entities.SystemPruneOptions) string { } return `WARNING! This command removes: - all stopped containers - - all networks not used by at least one container%s%s + - all networks not used by at least one container%s%s` + pinnedNote + ` - all dangling images - all dangling build cache diff --git a/cmd/podman/volumes/create.go b/cmd/podman/volumes/create.go index 52817bb99f..f1dc3bf2d5 100644 --- a/cmd/podman/volumes/create.go +++ b/cmd/podman/volumes/create.go @@ -36,6 +36,7 @@ var ( Ignore bool UID int GID int + Pinned bool }{} ) @@ -68,6 +69,10 @@ func init() { gidFlagName := "gid" flags.IntVar(&opts.GID, gidFlagName, 0, "Set the GID of the volume owner") _ = createCommand.RegisterFlagCompletionFunc(gidFlagName, completion.AutocompleteNone) + + pinnedFlagName := "pinned" + flags.BoolVar(&opts.Pinned, pinnedFlagName, false, "Mark volume as pinned (excluded from system prune by default)") + _ = createCommand.RegisterFlagCompletionFunc(pinnedFlagName, completion.AutocompleteNone) } func create(cmd *cobra.Command, args []string) error { @@ -94,6 +99,7 @@ func create(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("gid") { createOpts.GID = &opts.GID } + createOpts.Pinned = opts.Pinned response, err := registry.ContainerEngine().VolumeCreate(context.Background(), createOpts) if err != nil { return err diff --git a/cmd/podman/volumes/pin.go b/cmd/podman/volumes/pin.go new file mode 100644 index 0000000000..6f3a2d356c --- /dev/null +++ b/cmd/podman/volumes/pin.go @@ -0,0 +1,66 @@ +package volumes + +import ( + "context" + "fmt" + + "github.com/containers/podman/v5/cmd/podman/common" + "github.com/containers/podman/v5/cmd/podman/registry" + "github.com/containers/podman/v5/pkg/domain/entities" + "github.com/spf13/cobra" +) + +var ( + pinDescription = `Mark or unmark a volume as pinned. + +Pinned volumes are excluded from system prune operations by default.` + + pinCommand = &cobra.Command{ + Use: "pin [options] VOLUME [VOLUME...]", + Short: "Mark or unmark volume as pinned", + Long: pinDescription, + RunE: pin, + ValidArgsFunction: common.AutocompleteVolumes, + Example: `podman volume pin myvol + podman volume pin --unpin myvol + podman volume pin vol1 vol2 vol3`, + } +) + +var ( + pinOptions = entities.VolumePinOptions{} +) + +func init() { + registry.Commands = append(registry.Commands, registry.CliCommand{ + Command: pinCommand, + Parent: volumeCmd, + }) + flags := pinCommand.Flags() + flags.BoolVar(&pinOptions.Unpin, "unpin", false, "Remove pinning from volume") +} + +func pin(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("must specify at least one volume name") + } + + responses, err := registry.ContainerEngine().VolumePin(context.Background(), args, pinOptions) + if err != nil { + return err + } + + for _, r := range responses { + if r.Err != nil { + fmt.Printf("Error pinning volume %s: %v\n", r.Id, r.Err) + } else { + if pinOptions.Unpin { + fmt.Printf("Volume %s is now unpinned\n", r.Id) + } else { + fmt.Printf("Volume %s is now pinned\n", r.Id) + } + } + } + + return nil +} diff --git a/cmd/podman/volumes/rm.go b/cmd/podman/volumes/rm.go index 358a3704c2..9ec15c6c93 100644 --- a/cmd/podman/volumes/rm.go +++ b/cmd/podman/volumes/rm.go @@ -33,8 +33,9 @@ var ( ) var ( - rmOptions = entities.VolumeRmOptions{} - stopTimeout int + rmOptions = entities.VolumeRmOptions{} + stopTimeout int + includePinned bool ) func init() { @@ -45,6 +46,7 @@ func init() { flags := rmCommand.Flags() flags.BoolVarP(&rmOptions.All, "all", "a", false, "Remove all volumes") flags.BoolVarP(&rmOptions.Force, "force", "f", false, "Remove a volume by force, even if it is being used by a container") + flags.BoolVar(&includePinned, "include-pinned", false, "Include pinned volumes in removal operation") timeFlagName := "time" flags.IntVarP(&stopTimeout, timeFlagName, "t", int(containerConfig.Engine.StopTimeout), "Seconds to wait for running containers to stop before killing the container") _ = rmCommand.RegisterFlagCompletionFunc(timeFlagName, completion.AutocompleteNone) @@ -64,6 +66,7 @@ func rm(cmd *cobra.Command, args []string) error { timeout := uint(stopTimeout) rmOptions.Timeout = &timeout } + rmOptions.IncludePinned = includePinned responses, err := registry.ContainerEngine().VolumeRm(context.Background(), args, rmOptions) if err != nil { if rmOptions.Force && strings.Contains(err.Error(), define.ErrNoSuchVolume.Error()) { @@ -76,9 +79,6 @@ func rm(cmd *cobra.Command, args []string) error { if r.Err == nil { fmt.Println(r.Id) } else { - if rmOptions.Force && strings.Contains(r.Err.Error(), define.ErrNoSuchVolume.Error()) { - continue - } setExitCode(r.Err) errs = append(errs, r.Err) } diff --git a/libpod/define/volume_inspect.go b/libpod/define/volume_inspect.go index c4b45a04f5..a0f22b311c 100644 --- a/libpod/define/volume_inspect.go +++ b/libpod/define/volume_inspect.go @@ -63,6 +63,9 @@ type InspectVolumeData struct { StorageID string `json:"StorageID,omitempty"` // LockNumber is the number of the volume's Libpod lock. LockNumber uint32 + // Pinned indicates that this volume should be excluded from + // system prune operations by default. + Pinned bool `json:"Pinned,omitempty"` } type VolumeReload struct { diff --git a/libpod/options.go b/libpod/options.go index 5a24d89015..0e50cc94a9 100644 --- a/libpod/options.go +++ b/libpod/options.go @@ -1753,6 +1753,20 @@ func withSetAnon() VolumeCreateOption { } } +// WithVolumePinned sets the pinned flag for the volume. +// Pinned volumes are excluded from system prune operations by default. +func WithVolumePinned() VolumeCreateOption { + return func(volume *Volume) error { + if volume.valid { + return define.ErrVolumeFinalized + } + + volume.state.Pinned = true + + return nil + } +} + // WithVolumeDriverTimeout sets the volume creation timeout period. // Only usable if a non-local volume driver is in use. func WithVolumeDriverTimeout(timeout uint) VolumeCreateOption { diff --git a/libpod/runtime_volume.go b/libpod/runtime_volume.go index ff04eec3cc..9cbd8581a0 100644 --- a/libpod/runtime_volume.go +++ b/libpod/runtime_volume.go @@ -141,3 +141,55 @@ func (r *Runtime) PruneVolumes(ctx context.Context, filterFuncs []VolumeFilter) } return preports, nil } + +// PruneVolumesWithOptions removes unused volumes from the system with options +func (r *Runtime) PruneVolumesWithOptions(ctx context.Context, filterFuncs []VolumeFilter, includePinned bool) ([]*reports.PruneReport, error) { + preports := make([]*reports.PruneReport, 0) + vols, err := r.Volumes(filterFuncs...) + if err != nil { + return nil, err + } + + for _, vol := range vols { + // Skip pinned volumes unless explicitly requested + if vol.Pinned() && !includePinned { + continue + } + + report := new(reports.PruneReport) + volSize, err := vol.Size() + if err != nil { + volSize = 0 + } + 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 + } else { + // We didn't remove the volume for some reason + continue + } + } else { + vol.newVolumeEvent(events.Prune) + } + preports = append(preports, report) + } + return preports, nil +} + +// SetVolumePinned sets the pinned status of a volume by name. +// Pinned volumes are excluded from system prune operations by default. +func (r *Runtime) SetVolumePinned(volumeName string, pinned bool) error { + if !r.valid { + return define.ErrRuntimeStopped + } + + vol, err := r.state.Volume(volumeName) + if err != nil { + return err + } + + return vol.SetPinned(pinned) +} diff --git a/libpod/volume.go b/libpod/volume.go index 68c15009c3..42d98acca7 100644 --- a/libpod/volume.go +++ b/libpod/volume.go @@ -111,6 +111,9 @@ type VolumeState struct { UIDChowned int `json:"uidChowned,omitempty"` // GIDChowned is the GID the volume was chowned to. GIDChowned int `json:"gidChowned,omitempty"` + // Pinned indicates that this volume should be excluded from + // system prune operations by default + Pinned bool `json:"pinned,omitempty"` } // Name retrieves the volume's name @@ -283,6 +286,38 @@ func (v *Volume) UsesVolumeDriver() bool { return v.config.Driver != define.VolumeDriverLocal && v.config.Driver != "" } +// Pinned returns whether this volume is marked as pinned. +// Pinned volumes are excluded from system prune operations by default. +func (v *Volume) Pinned() bool { + v.lock.Lock() + defer v.lock.Unlock() + + if err := v.update(); err != nil { + return false + } + + return v.state.Pinned +} + +// SetPinned sets the pinned status of the volume. +// Pinned volumes are excluded from system prune operations by default. +func (v *Volume) SetPinned(pinned bool) error { + if !v.valid { + return define.ErrVolumeRemoved + } + + v.lock.Lock() + defer v.lock.Unlock() + + if err := v.update(); err != nil { + return err + } + + v.state.Pinned = pinned + + return v.save() +} + func (v *Volume) Mount() (string, error) { v.lock.Lock() defer v.lock.Unlock() diff --git a/libpod/volume_inspect.go b/libpod/volume_inspect.go index f4a3cc889d..1a350311fe 100644 --- a/libpod/volume_inspect.go +++ b/libpod/volume_inspect.go @@ -75,5 +75,7 @@ func (v *Volume) Inspect() (*define.InspectVolumeData, error) { data.Timeout = v.runtime.config.Engine.VolumePluginTimeout } + data.Pinned = v.state.Pinned + return data, nil } diff --git a/pkg/api/handlers/libpod/volumes.go b/pkg/api/handlers/libpod/volumes.go index 5f9d5e3376..a1a5daa13c 100644 --- a/pkg/api/handlers/libpod/volumes.go +++ b/pkg/api/handlers/libpod/volumes.go @@ -83,6 +83,10 @@ func CreateVolume(w http.ResponseWriter, r *http.Request) { volumeOptions = append(volumeOptions, libpod.WithVolumeGID(*input.GID), libpod.WithVolumeNoChown()) } + if input.Pinned { + volumeOptions = append(volumeOptions, libpod.WithVolumePinned()) + } + vol, err := runtime.NewVolume(r.Context(), volumeOptions...) if err != nil { utils.InternalServerError(w, err) @@ -163,7 +167,13 @@ func pruneVolumesHelper(r *http.Request) ([]*reports.PruneReport, error) { filterFuncs = append(filterFuncs, filterFunc) } - reports, err := runtime.PruneVolumes(r.Context(), filterFuncs) + // Check for includePinned parameter + includePinned := false + if includeParam := r.URL.Query().Get("includePinned"); includeParam == "true" { + includePinned = true + } + + reports, err := runtime.PruneVolumesWithOptions(r.Context(), filterFuncs, includePinned) if err != nil { return nil, err } @@ -176,8 +186,9 @@ func RemoveVolume(w http.ResponseWriter, r *http.Request) { decoder = r.Context().Value(api.DecoderKey).(*schema.Decoder) ) query := struct { - Force bool `schema:"force"` - Timeout *uint `schema:"timeout"` + Force bool `schema:"force"` + Timeout *uint `schema:"timeout"` + IncludePinned bool `schema:"includePinned"` }{ // override any golang type defaults } @@ -193,6 +204,13 @@ func RemoveVolume(w http.ResponseWriter, r *http.Request) { utils.VolumeNotFound(w, name, err) return } + // Check if volume is pinned and --include-pinned flag is not set + if vol.Pinned() && !query.IncludePinned { + utils.Error(w, http.StatusBadRequest, + fmt.Errorf("volume %s is pinned and cannot be removed without includePinned=true parameter", vol.Name())) + return + } + if err := runtime.RemoveVolume(r.Context(), vol, query.Force, query.Timeout); err != nil { if errors.Is(err, define.ErrVolumeBeingUsed) { utils.Error(w, http.StatusConflict, err) diff --git a/pkg/domain/entities/engine_container.go b/pkg/domain/entities/engine_container.go index 8af843bb97..923b486923 100644 --- a/pkg/domain/entities/engine_container.go +++ b/pkg/domain/entities/engine_container.go @@ -122,4 +122,5 @@ type ContainerEngine interface { //nolint:interfacebloat VolumeReload(ctx context.Context) (*VolumeReloadReport, error) VolumeExport(ctx context.Context, nameOrID string, options VolumeExportOptions) error VolumeImport(ctx context.Context, nameOrID string, options VolumeImportOptions) error + VolumePin(ctx context.Context, namesOrIds []string, opts VolumePinOptions) ([]*VolumePinReport, error) } diff --git a/pkg/domain/entities/types/system.go b/pkg/domain/entities/types/system.go index 97310428ec..c1bdb78f67 100644 --- a/pkg/domain/entities/types/system.go +++ b/pkg/domain/entities/types/system.go @@ -39,11 +39,18 @@ type SystemCheckReport struct { // SystemPruneOptions provides options to prune system. type SystemPruneOptions struct { - All bool - Volume bool - Filters map[string][]string `json:"filters" schema:"filters"` - External bool - Build bool + All bool + Volume bool + Filters map[string][]string `json:"filters" schema:"filters"` + External bool + Build bool + VolumePruneOptions VolumePruneOptions `json:"volumePruneOptions" schema:"volumePruneOptions"` +} + +// VolumePruneOptions describes the options needed +// to prune a volume from the CLI +type VolumePruneOptions struct { + IncludePinned bool `json:"includePinned" schema:"includePinned"` } // SystemPruneReport provides report after system prune is executed. diff --git a/pkg/domain/entities/types/volumes.go b/pkg/domain/entities/types/volumes.go index 06e3727f10..bce0bebac6 100644 --- a/pkg/domain/entities/types/volumes.go +++ b/pkg/domain/entities/types/volumes.go @@ -22,6 +22,9 @@ type VolumeCreateOptions struct { UID *int `schema:"uid"` // GID that the volume will be created as GID *int `schema:"gid"` + // Pinned indicates that this volume should be excluded from + // system prune operations by default + Pinned bool `schema:"pinned"` } type VolumeRmReport struct { diff --git a/pkg/domain/entities/volumes.go b/pkg/domain/entities/volumes.go index fe310f4fe3..8eb626f40a 100644 --- a/pkg/domain/entities/volumes.go +++ b/pkg/domain/entities/volumes.go @@ -13,10 +13,11 @@ type VolumeCreateOptions = types.VolumeCreateOptions type VolumeConfigResponse = types.VolumeConfigResponse type VolumeRmOptions struct { - All bool - Force bool - Ignore bool - Timeout *uint + All bool + Force bool + Ignore bool + Timeout *uint + IncludePinned bool } type VolumeRmReport = types.VolumeRmReport @@ -26,7 +27,8 @@ type VolumeInspectReport = types.VolumeInspectReport // VolumePruneOptions describes the options needed // to prune a volume from the CLI type VolumePruneOptions struct { - Filters url.Values `json:"filters" schema:"filters"` + Filters url.Values `json:"filters" schema:"filters"` + IncludePinned bool `json:"includePinned" schema:"includePinned"` } type VolumeListOptions struct { @@ -54,3 +56,14 @@ type VolumeImportOptions struct { // Input will be closed upon being fully consumed Input io.Reader } + +// VolumePinOptions describes the options for pinning/unpinning volumes +type VolumePinOptions struct { + Unpin bool +} + +// VolumePinReport describes the response from pinning/unpinning a volume +type VolumePinReport struct { + Id string + Err error +} diff --git a/pkg/domain/filters/volumes.go b/pkg/domain/filters/volumes.go index 07e2cac86c..26e01f2b5b 100644 --- a/pkg/domain/filters/volumes.go +++ b/pkg/domain/filters/volumes.go @@ -61,6 +61,31 @@ func GenerateVolumeFilters(filter string, filterValues []string, runtime *libpod }, nil case "until": return createUntilFilterVolumeFunction(filterValues) + case "pinned": + for _, val := range filterValues { + switch strings.ToLower(val) { + case "true", "1", "false", "0": + default: + return nil, fmt.Errorf("%q is not a valid value for the \"pinned\" filter - must be true or false", val) + } + } + return func(v *libpod.Volume) bool { + for _, val := range filterValues { + pinned := v.Pinned() + + switch strings.ToLower(val) { + case "true", "1": + if pinned { + return true + } + case "false", "0": + if !pinned { + return true + } + } + } + return false + }, nil case "dangling": for _, val := range filterValues { switch strings.ToLower(val) { @@ -110,6 +135,31 @@ func GeneratePruneVolumeFilters(filter string, filterValues []string, runtime *l }, nil case "until": return createUntilFilterVolumeFunction(filterValues) + case "pinned": + for _, val := range filterValues { + switch strings.ToLower(val) { + case "true", "1", "false", "0": + default: + return nil, fmt.Errorf("%q is not a valid value for the \"pinned\" filter - must be true or false", val) + } + } + return func(v *libpod.Volume) bool { + for _, val := range filterValues { + pinned := v.Pinned() + + switch strings.ToLower(val) { + case "true", "1": + if pinned { + return true + } + case "false", "0": + if !pinned { + return true + } + } + } + return false + }, nil } return nil, fmt.Errorf("%q is an invalid volume filter", filter) } diff --git a/pkg/domain/infra/abi/volumes.go b/pkg/domain/infra/abi/volumes.go index ce32fa571c..1c4ccfbc6a 100644 --- a/pkg/domain/infra/abi/volumes.go +++ b/pkg/domain/infra/abi/volumes.go @@ -48,6 +48,10 @@ func (ic *ContainerEngine) VolumeCreate(ctx context.Context, opts entities.Volum volumeOptions = append(volumeOptions, libpod.WithVolumeGID(*opts.GID), libpod.WithVolumeNoChown()) } + if opts.Pinned { + volumeOptions = append(volumeOptions, libpod.WithVolumePinned()) + } + vol, err := ic.Libpod.NewVolume(ctx, volumeOptions...) if err != nil { return nil, err @@ -84,6 +88,15 @@ func (ic *ContainerEngine) VolumeRm(ctx context.Context, namesOrIds []string, op } } for _, vol := range vols { + // Check if volume is pinned and --include-pinned flag is not set + if vol.Pinned() && !opts.IncludePinned { + reports = append(reports, &entities.VolumeRmReport{ + Err: fmt.Errorf("volume %s is pinned and cannot be removed without --include-pinned flag", vol.Name()), + Id: vol.Name(), + }) + continue + } + reports = append(reports, &entities.VolumeRmReport{ Err: ic.Libpod.RemoveVolume(ctx, vol, opts.Force, opts.Timeout), Id: vol.Name(), @@ -143,11 +156,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.IncludePinned) } -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, includePinned bool) ([]*reports.PruneReport, error) { + pruned, err := ic.Libpod.PruneVolumesWithOptions(ctx, filterFuncs, includePinned) if err != nil { return nil, err } @@ -267,6 +280,22 @@ func (ic *ContainerEngine) VolumeExport(ctx context.Context, nameOrID string, op return nil } +func (ic *ContainerEngine) VolumePin(ctx context.Context, namesOrIds []string, opts entities.VolumePinOptions) ([]*entities.VolumePinReport, error) { + var reports []*entities.VolumePinReport + + for _, nameOrId := range namesOrIds { + report := &entities.VolumePinReport{Id: nameOrId} + + if err := ic.Libpod.SetVolumePinned(nameOrId, !opts.Unpin); err != nil { + report.Err = err + } + + reports = append(reports, report) + } + + return reports, nil +} + func (ic *ContainerEngine) VolumeImport(ctx context.Context, nameOrID string, options entities.VolumeImportOptions) error { vol, err := ic.Libpod.LookupVolume(nameOrID) if err != nil { diff --git a/pkg/domain/infra/tunnel/volumes.go b/pkg/domain/infra/tunnel/volumes.go index d8ba4007f1..86d08ef10e 100644 --- a/pkg/domain/infra/tunnel/volumes.go +++ b/pkg/domain/infra/tunnel/volumes.go @@ -121,3 +121,15 @@ func (ic *ContainerEngine) VolumeExport(ctx context.Context, nameOrID string, op func (ic *ContainerEngine) VolumeImport(ctx context.Context, nameOrID string, options entities.VolumeImportOptions) error { return volumes.Import(ic.ClientCtx, nameOrID, options.Input) } + +func (ic *ContainerEngine) VolumePin(ctx context.Context, namesOrIds []string, opts entities.VolumePinOptions) ([]*entities.VolumePinReport, error) { + reports := make([]*entities.VolumePinReport, 0, len(namesOrIds)) + for _, nameOrId := range namesOrIds { + report := &entities.VolumePinReport{ + Id: nameOrId, + Err: errors.New("volume pinning is not supported for remote clients"), + } + reports = append(reports, report) + } + return reports, nil +}