diff --git a/docs/source/markdown/options/volume.md b/docs/source/markdown/options/volume.md index 1f58237b13..c652b69e65 100644 --- a/docs/source/markdown/options/volume.md +++ b/docs/source/markdown/options/volume.md @@ -8,10 +8,11 @@ Create a bind mount. If `-v /HOST-DIR:/CONTAINER-DIR` is specified, Podman bind mounts `/HOST-DIR` from the host into `/CONTAINER-DIR` in the Podman container. Similarly, `-v SOURCE-VOLUME:/CONTAINER-DIR` mounts the named volume from the host into the container. If no such named volume exists, -Podman creates one. If no source is given, the volume is created -as an anonymously named volume with a randomly generated name, and is -removed when the <> is removed via the `--rm` flag or -the `podman rm --volumes` command. +Podman creates one. The **nocreate** option can be used to disable this +behavior and require the volume to already exist. If no source is given, +the volume is created as an anonymously named volume with a randomly +generated name, and is removed when the <> is removed via +the `--rm` flag or the `podman rm --volumes` command. (Note when using the remote client, including Mac and Windows (excluding WSL2) machines, the volumes are mounted from the remote server, not necessarily the client machine.) @@ -28,6 +29,7 @@ The _OPTIONS_ is a comma-separated list and can be one or more of: * [**r**]**bind** * [**r**]**shared**|[**r**]**slave**|[**r**]**private**[**r**]**unbindable** [[1]](#Footnote1) * **idmap**[=**options**] +* **nocreate** The `CONTAINER-DIR` must be an absolute path such as `/src/docs`. The volume is mounted into the container at this directory. @@ -43,6 +45,13 @@ a named volume. If a volume with that name does not exist, it is created. Volumes created with names are not anonymous, and they are not removed by the `--rm` option and the `podman rm --volumes` command. +The **nocreate** option can be specified for named volumes to prevent automatic +volume creation. If **nocreate** is set and the volume does not exist, Podman +returns an error instead of creating the volume. This is useful when you want +to ensure that a volume was explicitly created before use. + + $ podman <> -v myvolume:/data:nocreate alpine + Specify multiple **-v** options to mount one or more volumes into a <>. diff --git a/libpod/container.go b/libpod/container.go index 295450838d..83ac7ced7f 100644 --- a/libpod/container.go +++ b/libpod/container.go @@ -255,6 +255,9 @@ type ContainerNamedVolume struct { IsAnonymous bool `json:"setAnonymous,omitempty"` // SubPath determines which part of the Source will be mounted in the container SubPath string `json:",omitempty"` + // NoCreate indicates that the volume must already exist and should not + // be created automatically if it doesn't exist. + NoCreate bool `json:"noCreate,omitempty"` } // ContainerOverlayVolume is an overlay volume that will be mounted into the diff --git a/libpod/options.go b/libpod/options.go index c4abfe4afe..f344ae3e84 100644 --- a/libpod/options.go +++ b/libpod/options.go @@ -1330,7 +1330,7 @@ func WithNamedVolumes(volumes []*ContainerNamedVolume) CtrCreateOption { } for _, vol := range volumes { - mountOpts, err := util.ProcessOptions(vol.Options, false, "") + mountOpts, noCreate, err := util.ProcessOptions(vol.Options, false, "") if err != nil { return fmt.Errorf("processing options for named volume %q mounted at %q: %w", vol.Name, vol.Dest, err) } @@ -1341,6 +1341,7 @@ func WithNamedVolumes(volumes []*ContainerNamedVolume) CtrCreateOption { Options: mountOpts, IsAnonymous: vol.IsAnonymous, SubPath: vol.SubPath, + NoCreate: noCreate, }) } diff --git a/libpod/runtime_ctr.go b/libpod/runtime_ctr.go index a7af59dd36..d2d3a55ff6 100644 --- a/libpod/runtime_ctr.go +++ b/libpod/runtime_ctr.go @@ -511,6 +511,10 @@ func (r *Runtime) setupContainer(ctx context.Context, ctr *Container) (_ *Contai } else if !errors.Is(err, define.ErrNoSuchVolume) { return nil, fmt.Errorf("retrieving named volume %s for new container: %w", vol.Name, err) } + // Volume does not exist - check if nocreate option is set + if vol.NoCreate { + return nil, fmt.Errorf("volume %s does not exist and nocreate option is set: %w", vol.Name, define.ErrNoSuchVolume) + } } if vol.IsAnonymous { // If SetAnonymous is true, make this an anonymous volume diff --git a/pkg/specgen/generate/storage.go b/pkg/specgen/generate/storage.go index 0a955bd6a8..8ecbc0e813 100644 --- a/pkg/specgen/generate/storage.go +++ b/pkg/specgen/generate/storage.go @@ -443,13 +443,13 @@ func InitFSMounts(mounts []spec.Mount) error { for i, m := range mounts { switch { case m.Type == define.TypeBind: - opts, err := util.ProcessOptions(m.Options, false, m.Source) + opts, _, err := util.ProcessOptions(m.Options, false, m.Source) if err != nil { return err } mounts[i].Options = opts case m.Type == define.TypeTmpfs && filepath.Clean(m.Destination) != "/dev": - opts, err := util.ProcessOptions(m.Options, true, "") + opts, _, err := util.ProcessOptions(m.Options, true, "") if err != nil { return err } diff --git a/pkg/specgenutil/volumes.go b/pkg/specgenutil/volumes.go index 1cef786e7d..f5a56b1b50 100644 --- a/pkg/specgenutil/volumes.go +++ b/pkg/specgenutil/volumes.go @@ -504,6 +504,11 @@ func parseMountOptions(mountType string, args []string) (*universalMount, error) return nil, fmt.Errorf("%q option not supported for %q mount types", name, mountType) } mnt.mount.Options = append(mnt.mount.Options, arg) + case "nocreate": + if mountType != define.TypeVolume { + return nil, fmt.Errorf("%q option not supported for %q mount types", name, mountType) + } + mnt.mount.Options = append(mnt.mount.Options, "nocreate") default: return nil, fmt.Errorf("%s: %w", name, util.ErrBadMntOption) } diff --git a/pkg/util/mount_opts.go b/pkg/util/mount_opts.go index e20a6334ae..308c7fceff 100644 --- a/pkg/util/mount_opts.go +++ b/pkg/util/mount_opts.go @@ -28,12 +28,13 @@ type getDefaultMountOptionsFn func(path string) (defaultMountOptions, error) // they are sensible and follow convention. The isTmpfs variable controls // whether extra, tmpfs-specific options will be allowed. // The sourcePath variable, if not empty, contains a bind mount source. -func ProcessOptions(options []string, isTmpfs bool, sourcePath string) ([]string, error) { +// Returns the processed options, a boolean indicating if nocreate was specified, and an error. +func ProcessOptions(options []string, isTmpfs bool, sourcePath string) ([]string, bool, error) { return processOptionsInternal(options, isTmpfs, sourcePath, getDefaultMountOptions) } -func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, getDefaultMountOptions getDefaultMountOptionsFn) ([]string, error) { - var foundWrite, foundSize, foundProp, foundMode, foundExec, foundSuid, foundDev, foundCopyUp, foundBind, foundZ, foundU, foundOverlay, foundIdmap, foundCopy, foundNoSwap, foundNoDereference bool +func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, getDefaultMountOptions getDefaultMountOptionsFn) ([]string, bool, error) { + var foundWrite, foundSize, foundProp, foundMode, foundExec, foundSuid, foundDev, foundCopyUp, foundBind, foundZ, foundU, foundOverlay, foundIdmap, foundCopy, foundNoSwap, foundNoDereference, foundNoCreate bool recursiveBind := true @@ -59,7 +60,7 @@ func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, g } if strings.HasPrefix(key, "idmap") { if foundIdmap { - return nil, fmt.Errorf("the 'idmap' option can only be set once: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("the 'idmap' option can only be set once: %w", ErrDupeMntOption) } foundIdmap = true newOptions = append(newOptions, opt) @@ -69,7 +70,7 @@ func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, g switch key { case "copy", "nocopy": if foundCopy { - return nil, fmt.Errorf("only one of 'nocopy' and 'copy' can be used: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one of 'nocopy' and 'copy' can be used: %w", ErrDupeMntOption) } foundCopy = true case "O": @@ -79,51 +80,51 @@ func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, g newOptions = append(newOptions, opt) case "exec", "noexec": if foundExec { - return nil, fmt.Errorf("only one of 'noexec' and 'exec' can be used: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one of 'noexec' and 'exec' can be used: %w", ErrDupeMntOption) } foundExec = true case "suid", "nosuid": if foundSuid { - return nil, fmt.Errorf("only one of 'nosuid' and 'suid' can be used: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one of 'nosuid' and 'suid' can be used: %w", ErrDupeMntOption) } foundSuid = true case "nodev", "dev": if foundDev { - return nil, fmt.Errorf("only one of 'nodev' and 'dev' can be used: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one of 'nodev' and 'dev' can be used: %w", ErrDupeMntOption) } foundDev = true case "rw", "ro": if foundWrite { - return nil, fmt.Errorf("only one of 'rw' and 'ro' can be used: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one of 'rw' and 'ro' can be used: %w", ErrDupeMntOption) } foundWrite = true case "private", "rprivate", "slave", "rslave", "shared", "rshared", "unbindable", "runbindable": if foundProp { - return nil, fmt.Errorf("only one root propagation mode can be used: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one root propagation mode can be used: %w", ErrDupeMntOption) } foundProp = true case "size": if !isTmpfs { - return nil, fmt.Errorf("the 'size' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) + return nil, false, fmt.Errorf("the 'size' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) } if foundSize { - return nil, fmt.Errorf("only one tmpfs size can be specified: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one tmpfs size can be specified: %w", ErrDupeMntOption) } foundSize = true case "mode": if !isTmpfs { - return nil, fmt.Errorf("the 'mode' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) + return nil, false, fmt.Errorf("the 'mode' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) } if foundMode { - return nil, fmt.Errorf("only one tmpfs mode can be specified: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one tmpfs mode can be specified: %w", ErrDupeMntOption) } foundMode = true case "tmpcopyup": if !isTmpfs { - return nil, fmt.Errorf("the 'tmpcopyup' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) + return nil, false, fmt.Errorf("the 'tmpcopyup' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) } if foundCopyUp { - return nil, fmt.Errorf("the 'tmpcopyup' or 'notmpcopyup' option can only be set once: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("the 'tmpcopyup' or 'notmpcopyup' option can only be set once: %w", ErrDupeMntOption) } foundCopyUp = true case "consistency": @@ -132,10 +133,10 @@ func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, g continue case "notmpcopyup": if !isTmpfs { - return nil, fmt.Errorf("the 'notmpcopyup' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) + return nil, false, fmt.Errorf("the 'notmpcopyup' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) } if foundCopyUp { - return nil, fmt.Errorf("the 'tmpcopyup' or 'notmpcopyup' option can only be set once: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("the 'tmpcopyup' or 'notmpcopyup' option can only be set once: %w", ErrDupeMntOption) } foundCopyUp = true // do not propagate notmpcopyup to the OCI runtime @@ -143,20 +144,20 @@ func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, g case "noswap": if !isTmpfs { - return nil, fmt.Errorf("the 'noswap' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) + return nil, false, fmt.Errorf("the 'noswap' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) } if rootless.IsRootless() { - return nil, fmt.Errorf("the 'noswap' option is only allowed with rootful tmpfs mounts: %w", ErrBadMntOption) + return nil, false, fmt.Errorf("the 'noswap' option is only allowed with rootful tmpfs mounts: %w", ErrBadMntOption) } if foundNoSwap { - return nil, fmt.Errorf("the 'tmpswap' option can only be set once: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("the 'tmpswap' option can only be set once: %w", ErrDupeMntOption) } foundNoSwap = true newOptions = append(newOptions, opt) continue case "no-dereference": if foundNoDereference { - return nil, fmt.Errorf("the 'no-dereference' option can only be set once: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("the 'no-dereference' option can only be set once: %w", ErrDupeMntOption) } foundNoDereference = true case define.TypeBind: @@ -164,31 +165,35 @@ func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, g fallthrough case "rbind": if isTmpfs { - return nil, fmt.Errorf("the 'bind' and 'rbind' options are not allowed with tmpfs mounts: %w", ErrBadMntOption) + return nil, false, fmt.Errorf("the 'bind' and 'rbind' options are not allowed with tmpfs mounts: %w", ErrBadMntOption) } if foundBind { - return nil, fmt.Errorf("only one of 'rbind' and 'bind' can be used: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one of 'rbind' and 'bind' can be used: %w", ErrDupeMntOption) } foundBind = true case "z", "Z": if isTmpfs { - return nil, fmt.Errorf("the 'z' and 'Z' options are not allowed with tmpfs mounts: %w", ErrBadMntOption) + return nil, false, fmt.Errorf("the 'z' and 'Z' options are not allowed with tmpfs mounts: %w", ErrBadMntOption) } if foundZ { - return nil, fmt.Errorf("only one of 'z' and 'Z' can be used: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("only one of 'z' and 'Z' can be used: %w", ErrDupeMntOption) } foundZ = true case "U": if foundU { - return nil, fmt.Errorf("the 'U' option can only be set once: %w", ErrDupeMntOption) + return nil, false, fmt.Errorf("the 'U' option can only be set once: %w", ErrDupeMntOption) } foundU = true case "noatime": if !isTmpfs { - return nil, fmt.Errorf("the 'noatime' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) + return nil, false, fmt.Errorf("the 'noatime' option is only allowed with tmpfs mounts: %w", ErrBadMntOption) } + case "nocreate": + // nocreate is handled separately and not passed to the runtime + foundNoCreate = true + continue default: - return nil, fmt.Errorf("unknown mount option %q: %w", opt, ErrBadMntOption) + return nil, false, fmt.Errorf("unknown mount option %q: %w", opt, ErrBadMntOption) } newOptions = append(newOptions, opt) } @@ -202,7 +207,7 @@ func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, g } defaults, err := getDefaultMountOptions(sourcePath) if err != nil { - return nil, err + return nil, false, err } if !foundExec && defaults.noexec { newOptions = append(newOptions, "noexec") @@ -220,7 +225,7 @@ func processOptionsInternal(options []string, isTmpfs bool, sourcePath string, g newOptions = append(newOptions, "rbind") } - return newOptions, nil + return newOptions, foundNoCreate, nil } func ParseDriverOpts(option string) (string, string, error) { diff --git a/pkg/util/utils_test.go b/pkg/util/utils_test.go index 4be022cdb1..95c04ba349 100644 --- a/pkg/util/utils_test.go +++ b/pkg/util/utils_test.go @@ -706,12 +706,13 @@ func getDefaultMountOptionsNoStat(_ string) (defaultMountOptions, error) { func TestProcessOptions(t *testing.T) { tests := []struct { - name string - options []string - isTmpfs bool - sourcePath string - expected []string - expectErr bool + name string + options []string + isTmpfs bool + sourcePath string + expected []string + expectedNoCreate bool + expectErr bool }{ { name: "tmpfs", @@ -816,11 +817,32 @@ func TestProcessOptions(t *testing.T) { options: []string{"noatime"}, expectErr: true, }, + { + name: "nocreate option is parsed and filtered", + sourcePath: "/path/to/source", + options: []string{"nocreate", "ro"}, + expected: []string{"nodev", "nosuid", "rbind", "ro", "rprivate"}, + expectedNoCreate: true, + }, + { + name: "nocreate with other options", + sourcePath: "/path/to/source", + options: []string{"rw", "nocreate", "z"}, + expected: []string{"nodev", "nosuid", "rbind", "rprivate", "rw", "z"}, + expectedNoCreate: true, + }, + { + name: "no nocreate option", + sourcePath: "/path/to/source", + options: []string{"ro"}, + expected: []string{"nodev", "nosuid", "rbind", "ro", "rprivate"}, + expectedNoCreate: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - opts, err := processOptionsInternal(tt.options, tt.isTmpfs, tt.sourcePath, getDefaultMountOptionsNoStat) + opts, noCreate, err := processOptionsInternal(tt.options, tt.isTmpfs, tt.sourcePath, getDefaultMountOptionsNoStat) if tt.expectErr { assert.NotNil(t, err) } else { @@ -828,6 +850,7 @@ func TestProcessOptions(t *testing.T) { sort.Strings(opts) sort.Strings(tt.expected) assert.Equal(t, opts, tt.expected) + assert.Equal(t, noCreate, tt.expectedNoCreate) } }) } diff --git a/test/e2e/run_volume_test.go b/test/e2e/run_volume_test.go index 8baa09c673..88d93a665e 100644 --- a/test/e2e/run_volume_test.go +++ b/test/e2e/run_volume_test.go @@ -1164,4 +1164,94 @@ RUN chmod 755 /test1 /test2 /test3`, ALPINE) output = session.OutputToString() Expect(output).ToNot(ContainSubstring("noatime")) }) + + It("podman run -v with nocreate option fails when volume doesn't exist", func() { + volName := "testvol-nocreate-nonexistent" + // Ensure volume doesn't exist + session := podmanTest.Podman([]string{"volume", "rm", "-f", volName}) + session.WaitWithDefaultTimeout() + + // Run with nocreate option should error + session = podmanTest.Podman([]string{"run", "--rm", "-v", fmt.Sprintf("%s:/mnt:nocreate", volName), ALPINE, "true"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitWithError(125, fmt.Sprintf("volume %s does not exist", volName))) + }) + + It("podman run -v with nocreate option succeeds when volume exists", func() { + volName := "testvol-nocreate-exists" + // Create volume first + session := podmanTest.Podman([]string{"volume", "create", volName}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + // Run with nocreate option should succeed since volume exists + session = podmanTest.Podman([]string{"run", "--rm", "-v", fmt.Sprintf("%s:/mnt:nocreate", volName), ALPINE, "touch", "/mnt/testfile"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + // Cleanup + session = podmanTest.Podman([]string{"volume", "rm", volName}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + }) + + It("podman run --mount type=volume with nocreate option fails when volume doesn't exist", func() { + volName := "testvol-mount-nocreate-nonexistent" + // Ensure volume doesn't exist + session := podmanTest.Podman([]string{"volume", "rm", "-f", volName}) + session.WaitWithDefaultTimeout() + + // Run with nocreate option should error + session = podmanTest.Podman([]string{"run", "--rm", "--mount", fmt.Sprintf("type=volume,src=%s,dst=/mnt,nocreate", volName), ALPINE, "true"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitWithError(125, fmt.Sprintf("volume %s does not exist", volName))) + }) + + It("podman run --mount type=volume with nocreate option succeeds when volume exists", func() { + volName := "testvol-mount-nocreate-exists" + // Create volume first + session := podmanTest.Podman([]string{"volume", "create", volName}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + // Run with nocreate option should succeed since volume exists + session = podmanTest.Podman([]string{"run", "--rm", "--mount", fmt.Sprintf("type=volume,src=%s,dst=/mnt,nocreate", volName), ALPINE, "touch", "/mnt/testfile"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + // Cleanup + session = podmanTest.Podman([]string{"volume", "rm", volName}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + }) + + It("podman run -v with nocreate combined with other options", func() { + volName := "testvol-nocreate-combo" + // Create volume first + session := podmanTest.Podman([]string{"volume", "create", volName}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + // Run with nocreate and ro options should succeed + session = podmanTest.Podman([]string{"run", "--rm", "-v", fmt.Sprintf("%s:/mnt:ro,nocreate", volName), ALPINE, "ls", "/mnt"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + // Cleanup + session = podmanTest.Podman([]string{"volume", "rm", volName}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + }) + + It("podman create -v with nocreate option fails when volume doesn't exist", func() { + volName := "testvol-create-nocreate" + // Ensure volume doesn't exist + session := podmanTest.Podman([]string{"volume", "rm", "-f", volName}) + session.WaitWithDefaultTimeout() + + // Create with nocreate option should error + session = podmanTest.Podman([]string{"create", "-v", fmt.Sprintf("%s:/mnt:nocreate", volName), ALPINE, "true"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitWithError(125, fmt.Sprintf("volume %s does not exist", volName))) + }) })