diff --git a/docs/advanced-usage/persistent-storage.md b/docs/advanced-usage/persistent-storage.md index 5eac24fe7..76aef20b4 100644 --- a/docs/advanced-usage/persistent-storage.md +++ b/docs/advanced-usage/persistent-storage.md @@ -118,6 +118,15 @@ In the first example, Dokku will then mount the shared contents of `/var/lib/dok > If the `/storage` path within the container had pre-existing content, the container files will be over-written. This may be an issue for users that create assets at build time but then mount a directory at the same place during runtime. Files are not merged. +For named storage entries, additional Docker mount options can be passed via `--volume-options`. The value is a comma-separated mount-options string stored verbatim on the attachment and rendered into the `-v` flag at deploy time. This is useful for SELinux labels (`Z`, `z`) or hardening flags (`noexec,nosuid`): + +```shell +dokku storage:create node-js-data +dokku storage:mount node-js-app node-js-data --container-dir /app/storage --volume-options Z +``` + +When combined with `--volume-readonly`, the rendered options become `ro,` - for example, `--volume-options noexec,nosuid --volume-readonly` renders as `:ro,noexec,nosuid`. + Once persistent storage is mounted, the app requires a restart. See the [process scaling documentation](/docs/processes/process-management.md) for more information. ```shell diff --git a/plugins/storage/attachment_test.go b/plugins/storage/attachment_test.go index 6c698ff77..035900cd5 100644 --- a/plugins/storage/attachment_test.go +++ b/plugins/storage/attachment_test.go @@ -129,6 +129,48 @@ func TestListAppMountEntriesK3sUsesEntryName(t *testing.T) { Expect(formatStorageListEntry(rows[0])).To(Equal("demo-pvc:/data")) } +func TestListAppMountEntriesVolumeOptions(t *testing.T) { + RegisterTestingT(t) + root := withTempLibRoot(t) + + Expect(SaveEntry(&Entry{ + Name: "demo-data", + Scheduler: SchedulerDockerLocal, + HostPath: "/var/lib/dokku/data/storage/demo-data", + })).To(Succeed()) + + writeAttachmentsFile(t, root, "demo", []*Attachment{ + { + EntryName: "demo-data", + ContainerPath: "/data", + Phases: []string{PhaseDeploy, PhaseRun}, + VolumeOptions: "Z", + }, + { + EntryName: "demo-data", + ContainerPath: "/ro", + Phases: []string{PhaseDeploy, PhaseRun}, + Readonly: true, + VolumeOptions: "noexec,nosuid", + }, + }) + + rows, err := ListAppMountEntries("demo", PhaseDeploy) + Expect(err).NotTo(HaveOccurred()) + Expect(rows).To(HaveLen(2)) + + byPath := map[string]StorageListEntry{} + for _, row := range rows { + byPath[row.ContainerPath] = row + } + + Expect(byPath["/data"].VolumeOptions).To(Equal("Z")) + Expect(formatStorageListEntry(byPath["/data"])).To(Equal("/var/lib/dokku/data/storage/demo-data:/data:Z")) + + Expect(byPath["/ro"].VolumeOptions).To(Equal("ro,noexec,nosuid")) + Expect(formatStorageListEntry(byPath["/ro"])).To(Equal("/var/lib/dokku/data/storage/demo-data:/ro:ro,noexec,nosuid")) +} + func TestListAppMountEntriesPhaseFilter(t *testing.T) { RegisterTestingT(t) root := withTempLibRoot(t) diff --git a/plugins/storage/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index e2cce2c4b..b49509115 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -155,16 +155,18 @@ func main() { subpath := args.String("volume-subpath", "", "--volume-subpath: subpath within the entry") readonly := args.Bool("volume-readonly", false, "--volume-readonly: mount the volume read-only") volumeChown := args.String("volume-chown", "", "--volume-chown: chown option applied at mount time") + volumeOptions := args.String("volume-options", "", "--volume-options: comma-separated mount options (e.g. Z, noexec,nosuid)") args.Parse(os.Args[2:]) err = storage.CommandMount(storage.CommandMountInput{ - AppName: args.Arg(0), - NameOrPath: args.Arg(1), - ContainerDir: *containerDir, - Phases: *phases, - ProcessType: *processType, - Subpath: *subpath, - Readonly: *readonly, - VolumeChown: *volumeChown, + AppName: args.Arg(0), + NameOrPath: args.Arg(1), + ContainerDir: *containerDir, + Phases: *phases, + ProcessType: *processType, + Subpath: *subpath, + Readonly: *readonly, + VolumeChown: *volumeChown, + VolumeOptions: *volumeOptions, }) case "report": args := flag.NewFlagSet("storage:report", flag.ExitOnError) diff --git a/plugins/storage/triggers_test.go b/plugins/storage/triggers_test.go index b0f046e25..ac4e6731e 100644 --- a/plugins/storage/triggers_test.go +++ b/plugins/storage/triggers_test.go @@ -38,3 +38,25 @@ func TestRepairRegistryOwnership(t *testing.T) { Expect(os.RemoveAll(RegistryDirectory())).To(Succeed()) Expect(repairRegistryOwnership()).To(Succeed()) } + +// TestBuildDockerVFlagVolumeOptions covers the rendering rules in +// buildDockerVFlag for the various combinations of Readonly and +// VolumeOptions. This is the docker-args path that the running +// container actually receives. +func TestBuildDockerVFlagVolumeOptions(t *testing.T) { + RegisterTestingT(t) + + entry := &Entry{HostPath: "/host"} + + plain := buildDockerVFlag(entry, &Attachment{ContainerPath: "/container"}) + Expect(plain).To(Equal("-v /host:/container")) + + opts := buildDockerVFlag(entry, &Attachment{ContainerPath: "/container", VolumeOptions: "Z"}) + Expect(opts).To(Equal("-v /host:/container:Z")) + + ro := buildDockerVFlag(entry, &Attachment{ContainerPath: "/container", Readonly: true}) + Expect(ro).To(Equal("-v /host:/container:ro")) + + roOpts := buildDockerVFlag(entry, &Attachment{ContainerPath: "/container", Readonly: true, VolumeOptions: "noexec,nosuid"}) + Expect(roOpts).To(Equal("-v /host:/container:ro,noexec,nosuid")) +} diff --git a/tests/unit/storage.bats b/tests/unit/storage.bats index 1ab58c27e..eba0b21f2 100644 --- a/tests/unit/storage.bats +++ b/tests/unit/storage.bats @@ -345,6 +345,56 @@ teardown() { assert_success } +@test "(storage:mount) --volume-options sets option on named-entry attachment" { + run /bin/bash -c "dokku storage:create rdmtest-opts" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:mount $TEST_APP rdmtest-opts --container-dir /data --volume-options Z" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:list $TEST_APP --format json | jq -r '.[].volume_options'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "Z" + + run /bin/bash -c "dokku storage:list $TEST_APP --format json | jq -r '.[].container_path'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "/data" + + run /bin/bash -c "dokku storage:report $TEST_APP --format json | jq -r '.\"storage-deploy-mounts\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains ":Z" + + # combine with --volume-readonly: rendered as ro, + run /bin/bash -c "dokku storage:mount $TEST_APP rdmtest-opts --container-dir /ro --volume-options noexec,nosuid --volume-readonly" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:report $TEST_APP --format json | jq -r '.\"storage-deploy-mounts\"'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains ":ro,noexec,nosuid" + + # cleanup + run /bin/bash -c "dokku storage:unmount $TEST_APP rdmtest-opts --container-dir /data" + assert_success + run /bin/bash -c "dokku storage:unmount $TEST_APP rdmtest-opts --container-dir /ro" + assert_success + run /bin/bash -c "dokku storage:destroy rdmtest-opts --force" + assert_success +} + @test "(storage) storage:destroy refuses to remove a still-mounted entry" { run /bin/bash -c "dokku storage:create rdmtest-busy" assert_success