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.
This commit is contained in:
Jose Diaz-Gonzalez
2026-04-29 14:51:49 -04:00
parent cfb7da8ec0
commit 1ca961251d
5 changed files with 300 additions and 3 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -21,12 +21,15 @@ Additional commands:`
storage:create <name> [<path>] [flags], Register a named storage entry
storage:destroy <name>, Remove a named storage entry (must be unmounted from every app first)
storage:ensure-directory [--chown option] <directory>, [DEPRECATED] use storage:create instead
storage:exec <name> [-- <cmd>...], Run a command (or shell) in a temporary container that mounts the entry
storage:info <name> [--format text|json], Show details for one storage entry
storage:list <app> [--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 <app> <host-dir:container-dir>, Create a new bind mount
storage:report [<app>] [<flag>], Displays a storage report for one or more apps
storage:unmount <app> <host-dir:container-dir>, Remove an existing bind mount`
storage:set <name> [flags], Update a storage entry in place
storage:unmount <app> <host-dir:container-dir>, Remove an existing bind mount
storage:wait <name>, Wait for a storage entry's PVC to be bound (k3s)`
)
func main() {

View File

@@ -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")

View File

@@ -22,12 +22,15 @@ Additional commands:`
storage:create <name> [<path>] [flags], Register a named storage entry
storage:destroy <name>, Remove a named storage entry (must be unmounted from every app first)
storage:ensure-directory [--chown option] <directory>, [DEPRECATED] use storage:create instead
storage:exec <name> [-- <cmd>...], Run a command (or shell) in a temporary container that mounts the entry
storage:info <name> [--format text|json], Show details for one storage entry
storage:list <app> [--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 <app> <host-dir:container-dir>, Create a new bind mount
storage:report [<app>] [<flag>], Displays a storage report for one or more apps
storage:unmount <app> <host-dir:container-dir>, Remove an existing bind mount`
storage:set <name> [flags], Update a storage entry in place
storage:unmount <app> <host-dir:container-dir>, Remove an existing bind mount
storage:wait <name>, 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 {