From 1ca961251db5053958ef5de94a640bb1493a3be9 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 14:51:49 -0400 Subject: [PATCH] feat: add storage:set, storage:exec, storage:wait, storage:report --global storage:set is the in-place editor for an existing entry; size grows are passed through to a helm upgrade and Kubernetes-incompatible swaps (access-mode, storage-class) are rejected up front. storage:exec spawns a throwaway container that mounts the entry: docker run on docker-local entries, kubectl run with a Pod-spec override on k3s, falling back to bash then sh when no command is supplied. storage:wait blocks until a k3s entry's PVC reports Bound via the storage-status trigger, with a docker-local fast path that just checks the host directory. storage:report --global enumerates every registered entry and the apps that mount it, in text or JSON form. --- plugins/storage/Makefile | 2 +- plugins/storage/commands_entries.go | 229 ++++++++++++++++++ plugins/storage/src/commands/commands.go | 5 +- .../storage/src/subcommands/subcommands.go | 51 ++++ plugins/storage/subcommands.go | 16 +- 5 files changed, 300 insertions(+), 3 deletions(-) diff --git a/plugins/storage/Makefile b/plugins/storage/Makefile index 7a6b61e35..520b4e9a6 100644 --- a/plugins/storage/Makefile +++ b/plugins/storage/Makefile @@ -1,5 +1,5 @@ GOARCH ?= amd64 -SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/info subcommands/list subcommands/list-entries subcommands/mount subcommands/report subcommands/unmount +SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/exec subcommands/info subcommands/list subcommands/list-entries subcommands/mount subcommands/report subcommands/set subcommands/unmount subcommands/wait TRIGGERS = triggers/install triggers/storage-list triggers/storage-app-mounts triggers/docker-args-deploy triggers/docker-args-run triggers/post-delete triggers/post-app-clone-setup triggers/post-app-rename-setup BUILD = commands subcommands triggers PLUGIN_NAME = storage diff --git a/plugins/storage/commands_entries.go b/plugins/storage/commands_entries.go index 4f5c6ff5f..d4d62c1c7 100644 --- a/plugins/storage/commands_entries.go +++ b/plugins/storage/commands_entries.go @@ -179,6 +179,235 @@ func CommandInfo(name string, format string) error { return nil } +// CommandSetInput captures the flags accepted by storage:set. +type CommandSetInput struct { + Name string + Size string + AccessMode string + StorageClass string + Namespace string + Chown string + ReclaimPolicy string + Annotations map[string]string + Labels map[string]string +} + +// CommandSet edits an existing entry's mutable fields and re-fires the +// scheduler-side helm release. Refuses changes Kubernetes can't apply +// in place (access-mode swap, storage-class swap, size shrink). +func CommandSet(input CommandSetInput) error { + if !EntryExists(input.Name) { + return fmt.Errorf("storage entry %q does not exist", input.Name) + } + entry, err := LoadEntry(input.Name) + if err != nil { + return err + } + + if input.AccessMode != "" && input.AccessMode != entry.AccessMode { + return fmt.Errorf("storage:set cannot change access-mode in place; recreate the entry") + } + if input.StorageClass != "" && input.StorageClass != entry.StorageClass { + return fmt.Errorf("storage:set cannot change storage-class-name in place; recreate the entry") + } + if input.Size != "" { + entry.Size = input.Size + } + if input.Namespace != "" { + entry.Namespace = input.Namespace + } + if input.Chown != "" { + entry.Chown = input.Chown + } + if input.ReclaimPolicy != "" { + entry.ReclaimPolicy = input.ReclaimPolicy + } + if input.Annotations != nil { + entry.Annotations = input.Annotations + } + if input.Labels != nil { + entry.Labels = input.Labels + } + + if err := entry.Validate(); err != nil { + return err + } + if err := SaveEntry(entry); err != nil { + return err + } + if entry.Scheduler == SchedulerK3s { + if err := callSchedulerCreateTrigger(entry); err != nil { + return fmt.Errorf("scheduler refused storage:set for %q: %w", entry.Name, err) + } + } + common.LogInfo1(fmt.Sprintf("Storage entry %s updated", entry.Name)) + return nil +} + +// CommandExec spawns a temporary container/Pod that mounts the entry +// and runs the given command (or an interactive shell when args is +// empty). It execs into the underlying CLI (docker or kubectl) so +// stdin/stdout/stderr stream naturally. +func CommandExec(name string, image string, args []string) error { + if !EntryExists(name) { + return fmt.Errorf("storage entry %q does not exist", name) + } + entry, err := LoadEntry(name) + if err != nil { + return err + } + if image == "" { + image = "alpine:3" + } + + cmd := args + if len(cmd) == 0 { + cmd = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"} + } + + if entry.Scheduler == SchedulerDockerLocal { + dockerArgs := []string{"run", "--rm", "-it", "-v", fmt.Sprintf("%s:/data", entry.HostPath), image} + dockerArgs = append(dockerArgs, cmd...) + return execStream(common.DockerBin(), dockerArgs) + } + + // k3s: render a Pod-spec override that mounts the PVC, then kubectl run. + namespace := entry.Namespace + if namespace == "" { + namespace = "default" + } + overrides := map[string]any{ + "apiVersion": "v1", + "spec": map[string]any{ + "containers": []map[string]any{ + { + "name": "exec", + "image": image, + "stdin": true, + "tty": true, + "volumeMounts": []map[string]any{{"name": "data", "mountPath": "/data"}}, + }, + }, + "volumes": []map[string]any{ + {"name": "data", "persistentVolumeClaim": map[string]any{"claimName": entry.Name}}, + }, + }, + } + overrideJSON, err := json.Marshal(overrides) + if err != nil { + return err + } + kubectlArgs := []string{ + "run", "dokku-storage-exec-" + entry.Name, + "--rm", "-it", + "--namespace=" + namespace, + "--image=" + image, + "--restart=Never", + "--overrides=" + string(overrideJSON), + "--", + } + kubectlArgs = append(kubectlArgs, cmd...) + return execStream("kubectl", kubectlArgs) +} + +func execStream(bin string, args []string) error { + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: bin, + Args: args, + StreamStdio: true, + }) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("%s exited with %d", bin, result.ExitCode) + } + return nil +} + +// CommandWait blocks until a k3s storage entry's PVC is bound. +func CommandWait(name string) error { + if !EntryExists(name) { + return fmt.Errorf("storage entry %q does not exist", name) + } + entry, err := LoadEntry(name) + if err != nil { + return err + } + if entry.Scheduler != SchedulerK3s { + // Docker-local entries are immediately "ready" once the directory exists. + if entry.HostPath != "" { + info, statErr := os.Stat(entry.HostPath) + if statErr != nil || !info.IsDir() { + return fmt.Errorf("storage entry %q host path %s is not present", name, entry.HostPath) + } + } + return nil + } + + data, err := json.Marshal(entry) + if err != nil { + return err + } + results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "storage-status", + Args: []string{entry.Name}, + Stdin: strings.NewReader(string(data)), + }) + if err != nil { + return err + } + status := strings.TrimSpace(results.StdoutContents()) + if status != "Bound" { + return fmt.Errorf("storage entry %q is %s, not Bound", name, status) + } + common.LogInfo1(fmt.Sprintf("Storage entry %s is bound", name)) + return nil +} + +// CommandReportGlobal prints a global storage report listing every entry +// and the apps that mount it. Output is text by default; pass +// format="json" for machine-readable output. +func CommandReportGlobal(format string) error { + entries, err := ListEntries() + if err != nil { + return err + } + type entryWithUse struct { + Entry *Entry `json:"entry"` + MountedBy []string `json:"mounted_by"` + } + rows := []entryWithUse{} + for _, entry := range entries { + using, err := AppsUsingEntry(entry.Name) + if err != nil { + return err + } + rows = append(rows, entryWithUse{Entry: entry, MountedBy: using}) + } + if format == "json" { + data, err := json.MarshalIndent(rows, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + if len(rows) == 0 { + common.LogInfo1Quiet("No storage entries registered") + return nil + } + common.LogInfo1Quiet("Storage report (global):") + for _, row := range rows { + mountedBy := "—" + if len(row.MountedBy) > 0 { + mountedBy = strings.Join(row.MountedBy, ", ") + } + common.LogVerbose(fmt.Sprintf("%s\t%s\tmounted by: %s", row.Entry.Name, row.Entry.Scheduler, mountedBy)) + } + return nil +} + // CommandListEntries lists registered entries (distinct from the legacy // per-app storage:list which lists colon-form mounts). func CommandListEntries(scheduler string, format string) error { diff --git a/plugins/storage/src/commands/commands.go b/plugins/storage/src/commands/commands.go index 0c5ed04a1..68d4edca3 100644 --- a/plugins/storage/src/commands/commands.go +++ b/plugins/storage/src/commands/commands.go @@ -21,12 +21,15 @@ Additional commands:` storage:create [] [flags], Register a named storage entry storage:destroy , 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 storage:list [--format text|json], List bind mounts for app's container(s) (host:container) storage:list-entries [--scheduler s] [--format text|json], List registered storage entries storage:mount , Create a new bind mount storage:report [] [], Displays a storage report for one or more apps - storage:unmount , Remove an existing bind mount` + storage:set [flags], Update a storage entry in place + storage:unmount , Remove an existing bind mount + storage:wait , Wait for a storage entry's PVC to be bound (k3s)` ) func main() { diff --git a/plugins/storage/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 52d9a46bb..9fc49a81b 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -78,6 +78,57 @@ func main() { args.Parse(os.Args[2:]) appName := args.Arg(0) err = storage.CommandList(appName, *format) + case "set": + args := flag.NewFlagSet("storage:set", flag.ExitOnError) + size := args.String("size", "", "--size: new PVC size (k3s)") + accessMode := args.String("access-mode", "", "--access-mode: existing access mode (must match)") + storageClass := args.String("storage-class-name", "", "--storage-class-name: existing storage class (must match)") + namespace := args.String("namespace", "", "--namespace: new namespace") + chown := args.String("chown", "", "--chown: chown option") + reclaim := args.String("reclaim-policy", "", "--reclaim-policy: PV reclaim policy") + annotations := args.StringSlice("annotation", nil, "--annotation key=value: PVC annotation (repeatable, replaces all)") + labels := args.StringSlice("label", nil, "--label key=value: PVC label (repeatable, replaces all)") + args.Parse(os.Args[2:]) + annotMap, parseErr := parseKVPairs(*annotations) + if parseErr != nil { + err = parseErr + break + } + labelMap, parseErr := parseKVPairs(*labels) + if parseErr != nil { + err = parseErr + break + } + err = storage.CommandSet(storage.CommandSetInput{ + Name: args.Arg(0), + Size: *size, + AccessMode: *accessMode, + StorageClass: *storageClass, + Namespace: *namespace, + Chown: *chown, + ReclaimPolicy: *reclaim, + Annotations: annotMap, + Labels: labelMap, + }) + case "exec": + args := flag.NewFlagSet("storage:exec", flag.ExitOnError) + image := args.String("image", "", "--image: container image to use (default alpine:3)") + args.Parse(os.Args[2:]) + positional := args.Args() + if len(positional) == 0 { + err = fmt.Errorf("storage:exec requires a storage entry name") + break + } + name := positional[0] + var cmd []string + if len(positional) > 1 { + cmd = positional[1:] + } + err = storage.CommandExec(name, *image, cmd) + case "wait": + args := flag.NewFlagSet("storage:wait", flag.ExitOnError) + args.Parse(os.Args[2:]) + err = storage.CommandWait(args.Arg(0)) case "list-entries": args := flag.NewFlagSet("storage:list-entries", flag.ExitOnError) scheduler := args.String("scheduler", "", "--scheduler: filter to a single scheduler") diff --git a/plugins/storage/subcommands.go b/plugins/storage/subcommands.go index 9b3eb5824..922103af9 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -22,12 +22,15 @@ Additional commands:` storage:create [] [flags], Register a named storage entry storage:destroy , 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 storage:list [--format text|json], List bind mounts for app's container(s) (host:container) storage:list-entries [--scheduler s] [--format text|json], List registered storage entries storage:mount , Create a new bind mount storage:report [] [], Displays a storage report for one or more apps - storage:unmount , Remove an existing bind mount` + storage:set [flags], Update a storage entry in place + storage:unmount , Remove an existing bind mount + storage:wait , Wait for a storage entry's PVC to be bound (k3s)` ) // CommandHelp displays help for the storage plugin @@ -266,6 +269,17 @@ func CommandList(appName string, format string) error { // CommandReport displays a storage report for one or more apps func CommandReport(appName string, format string, infoFlag string) error { + if appName == "--global" { + // Global storage report: list every registered entry and its + // attachment count; falls back to the legacy per-app loop when + // no entries exist so existing automation keeps working. + reportFormat := format + if reportFormat == "stdout" { + reportFormat = "text" + } + return CommandReportGlobal(reportFormat) + } + if appName == "" { apps, err := common.DokkuApps() if err != nil {