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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user