diff --git a/docs/advanced-usage/persistent-storage.md b/docs/advanced-usage/persistent-storage.md index 8989224dc..70c837123 100644 --- a/docs/advanced-usage/persistent-storage.md +++ b/docs/advanced-usage/persistent-storage.md @@ -7,7 +7,7 @@ The preferred method to attach persistent storage to a Dokku-managed container i ``` storage:create [] [flags] # Register a named storage entry -storage:destroy # Remove a named storage entry (must be unmounted from every app first) +storage:destroy [--force] # Remove a named storage entry (must be unmounted from every app first) storage:ensure-directory [--chown option] # [DEPRECATED] use storage:create instead storage:exec [-- ...] # Run a command (or shell) in a temporary container that mounts the entry storage:info [--format text|json] # Show details for one storage entry @@ -140,6 +140,36 @@ Once persistent storage is unmounted, the app requires a restart. See the [proce dokku ps:restart app-name ``` +### Destroying storage entries + +A named storage entry can be removed with the `storage:destroy` command. The entry must first be unmounted from every app that mounts it. + +```shell +dokku storage:destroy rdmtest-entry +``` + +As the command is destructive - removing the registry entry and, depending on the scheduler and reclaim policy, the underlying volume - it will default to asking for confirmation before executing the removal. + +``` + ! WARNING: Potentially Destructive Action + ! This command will destroy storage entry rdmtest-entry. + ! To proceed, type "rdmtest-entry" +> rdmtest-entry +-----> Storage entry rdmtest-entry destroyed +``` + +The confirmation may be avoided by providing the `--force` flag, which is useful for non-interactive or automated callers: + +```shell +dokku storage:destroy rdmtest-entry --force +``` + +The global `--force` flag is also supported: + +```shell +dokku --force storage:destroy rdmtest-entry +``` + ### Displaying storage reports for an app > [!IMPORTANT] diff --git a/plugins/storage/commands_entries.go b/plugins/storage/commands_entries.go index a79d1cdff..80e104468 100644 --- a/plugins/storage/commands_entries.go +++ b/plugins/storage/commands_entries.go @@ -95,8 +95,10 @@ func CommandCreate(input CommandCreateInput) error { } // CommandDestroy removes a registered storage entry. Refuses to remove -// an entry that any app still has attached. -func CommandDestroy(name string) error { +// an entry that any app still has attached. Prompts for confirmation +// unless force is set (or the global --force flag exported +// DOKKU_APPS_FORCE_DELETE). +func CommandDestroy(name string, force bool) error { if name == "" { return errors.New("storage entry name is required") } @@ -112,6 +114,15 @@ func CommandDestroy(name string) error { return fmt.Errorf("storage entry %q is still mounted by app(s): %s", name, strings.Join(using, ", ")) } + if os.Getenv("DOKKU_APPS_FORCE_DELETE") == "1" { + force = true + } + if !force { + if err := common.AskForDestructiveConfirmation(name, "storage entry"); err != nil { + return err + } + } + entry, err := LoadEntry(name) if err != nil { return err diff --git a/plugins/storage/src/commands/commands.go b/plugins/storage/src/commands/commands.go index a026f0709..a7aef6198 100644 --- a/plugins/storage/src/commands/commands.go +++ b/plugins/storage/src/commands/commands.go @@ -19,7 +19,7 @@ Additional commands:` helpContent = ` storage:create [] [flags], Register a named storage entry - storage:destroy , Remove a named storage entry (must be unmounted from every app first) + storage:destroy [--force], Remove a named storage entry (must be unmounted from every app first) storage:ensure-directory [--chown option] , [DEPRECATED] use storage:create instead storage:exec [-- ...], Run a command (or shell) in a temporary container that mounts the entry storage:info [--format text|json], Show details for one storage entry diff --git a/plugins/storage/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 5040e6418..e2cce2c4b 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -58,8 +58,9 @@ func main() { }) case "destroy": args := flag.NewFlagSet("storage:destroy", flag.ExitOnError) + force := args.Bool("force", false, "--force: force destroy without confirmation") args.Parse(os.Args[2:]) - err = storage.CommandDestroy(args.Arg(0)) + err = storage.CommandDestroy(args.Arg(0), *force) case "ensure-directory": args := flag.NewFlagSet("storage:ensure-directory", flag.ExitOnError) chown := args.String("chown", "herokuish", "--chown: chown option (herokuish, heroku, paketo, root, false)") diff --git a/plugins/storage/subcommands.go b/plugins/storage/subcommands.go index 2e92125ad..8e0c91580 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -20,7 +20,7 @@ Additional commands:` helpContent = ` storage:create [] [flags], Register a named storage entry - storage:destroy , Remove a named storage entry (must be unmounted from every app first) + storage:destroy [--force], Remove a named storage entry (must be unmounted from every app first) storage:ensure-directory [--chown option] , [DEPRECATED] use storage:create instead storage:exec [-- ...], Run a command (or shell) in a temporary container that mounts the entry storage:info [--format text|json], Show details for one storage entry diff --git a/tests/unit/storage.bats b/tests/unit/storage.bats index 7c8ca4121..6da3920fd 100644 --- a/tests/unit/storage.bats +++ b/tests/unit/storage.bats @@ -234,7 +234,7 @@ teardown() { assert_success assert_output "docker-local" - run /bin/bash -c "dokku storage:destroy rdmtest-entry" + run /bin/bash -c "dokku storage:destroy rdmtest-entry --force" echo "output: $output" echo "status: $status" assert_success @@ -307,9 +307,9 @@ teardown() { assert_success assert_output "0" - run /bin/bash -c "dokku storage:destroy rdmtest-data" + run /bin/bash -c "dokku storage:destroy rdmtest-data --force" assert_success - run /bin/bash -c "dokku storage:destroy rdmtest-cache" + run /bin/bash -c "dokku storage:destroy rdmtest-cache --force" assert_success } @@ -327,10 +327,64 @@ teardown() { run /bin/bash -c "dokku storage:unmount $TEST_APP rdmtest-busy" assert_success - run /bin/bash -c "dokku storage:destroy rdmtest-busy" + run /bin/bash -c "dokku storage:destroy rdmtest-busy --force" assert_success } +@test "(storage:destroy) requires confirmation without --force" { + run /bin/bash -c "dokku storage:create rdmtest-confirm" + assert_success + + # No --force and no matching stdin: aborts, entry remains. + run /bin/bash -c "dokku storage:destroy rdmtest-confirm < /dev/null" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku storage:list-entries --format json | jq -r '.[].name' | grep '^rdmtest-confirm$'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "rdmtest-confirm" + + # Matching confirmation via stdin: succeeds and removes the entry. + run /bin/bash -c "echo rdmtest-confirm | dokku storage:destroy rdmtest-confirm" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:list-entries --format json | jq -r '.[].name' | grep '^rdmtest-confirm$' || true" + echo "output: $output" + echo "status: $status" + assert_output "" +} + +@test "(storage:destroy) --force skips confirmation" { + run /bin/bash -c "dokku storage:create rdmtest-force" + assert_success + + run /bin/bash -c "dokku storage:destroy rdmtest-force --force < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:list-entries --format json | jq -r '.[].name' | grep '^rdmtest-force$' || true" + assert_output "" +} + +@test "(storage:destroy) global --force skips confirmation" { + run /bin/bash -c "dokku storage:create rdmtest-gforce" + assert_success + + run /bin/bash -c "dokku --force storage:destroy rdmtest-gforce < /dev/null" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku storage:list-entries --format json | jq -r '.[].name' | grep '^rdmtest-gforce$' || true" + assert_output "" +} + @test "(storage) storage:exec runs a non-interactive command and propagates exit code" { run /bin/bash -c "dokku storage:create rdmtest-exec" assert_success @@ -350,7 +404,7 @@ teardown() { echo "status: $status" assert_equal "$status" 42 - run /bin/bash -c "dokku storage:destroy rdmtest-exec" + run /bin/bash -c "dokku storage:destroy rdmtest-exec --force" assert_success } @@ -433,7 +487,7 @@ teardown() { if [[ -n "$legacy_name" ]]; then run /bin/bash -c "dokku storage:unmount $TEST_APP $legacy_name --container-dir /legacy" assert_success - run /bin/bash -c "dokku storage:destroy $legacy_name" + run /bin/bash -c "dokku storage:destroy $legacy_name --force" assert_success fi }