diff --git a/plugins/storage/Makefile b/plugins/storage/Makefile index 754299f43..c719dde7d 100644 --- a/plugins/storage/Makefile +++ b/plugins/storage/Makefile @@ -1,5 +1,5 @@ GOARCH ?= amd64 -SUBCOMMANDS = subcommands/default subcommands/ensure-directory subcommands/list subcommands/mount subcommands/report subcommands/unmount +SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/info subcommands/list subcommands/list-entries subcommands/mount subcommands/report subcommands/unmount TRIGGERS = triggers/install triggers/storage-list BUILD = commands subcommands triggers PLUGIN_NAME = storage diff --git a/plugins/storage/commands_entries.go b/plugins/storage/commands_entries.go new file mode 100644 index 000000000..4f5c6ff5f --- /dev/null +++ b/plugins/storage/commands_entries.go @@ -0,0 +1,302 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// CommandCreateInput captures the flags accepted by storage:create. +type CommandCreateInput struct { + Name string + Path string + Scheduler string + Size string + AccessMode string + StorageClass string + Namespace string + Chown string + ReclaimPolicy string + Annotations map[string]string + Labels map[string]string +} + +// CommandCreate registers a new storage entry. +func CommandCreate(input CommandCreateInput) error { + if err := ValidateEntryName(input.Name, false); err != nil { + return err + } + + scheduler := input.Scheduler + if scheduler == "" { + scheduler = SchedulerDockerLocal + } + + hostPath := input.Path + if scheduler == SchedulerDockerLocal && hostPath == "" { + hostPath = filepath.Join(GetStorageDirectory(), input.Name) + } + + entry := &Entry{ + Name: input.Name, + Scheduler: scheduler, + HostPath: hostPath, + Size: input.Size, + AccessMode: input.AccessMode, + StorageClass: input.StorageClass, + Namespace: input.Namespace, + Chown: input.Chown, + ReclaimPolicy: input.ReclaimPolicy, + Annotations: input.Annotations, + Labels: input.Labels, + SchemaVersion: SchemaVersion, + } + + if err := entry.Validate(); err != nil { + return err + } + + if EntryExists(entry.Name) { + existing, err := LoadEntry(entry.Name) + if err != nil { + return err + } + if existing.Scheduler != entry.Scheduler { + return fmt.Errorf("storage entry %q already exists with scheduler %q; refusing to redefine", entry.Name, existing.Scheduler) + } + common.LogInfo1Quiet(fmt.Sprintf("Storage entry %s already exists, leaving in place", entry.Name)) + } + + if scheduler == SchedulerDockerLocal { + if err := ensureDockerLocalPath(entry); err != nil { + return err + } + } + + if err := SaveEntry(entry); err != nil { + return err + } + + if scheduler == SchedulerK3s { + if err := callSchedulerCreateTrigger(entry); err != nil { + // Roll back the on-disk entry so the disk and the cluster stay in sync. + _ = DeleteEntry(entry.Name) + return fmt.Errorf("scheduler refused storage entry %q: %w", entry.Name, err) + } + } + + common.LogInfo1(fmt.Sprintf("Storage entry %s created", entry.Name)) + return nil +} + +// CommandDestroy removes a registered storage entry. Refuses to remove +// an entry that any app still has attached. +func CommandDestroy(name string) error { + if name == "" { + return errors.New("storage entry name is required") + } + if !EntryExists(name) { + return fmt.Errorf("storage entry %q does not exist", name) + } + + using, err := AppsUsingEntry(name) + if err != nil { + return err + } + if len(using) > 0 { + return fmt.Errorf("storage entry %q is still mounted by app(s): %s", name, strings.Join(using, ", ")) + } + + entry, err := LoadEntry(name) + if err != nil { + return err + } + + if entry.Scheduler == SchedulerK3s { + if err := callSchedulerDestroyTrigger(entry); err != nil { + return fmt.Errorf("scheduler refused to remove storage entry %q: %w", name, err) + } + } + + if err := DeleteEntry(name); err != nil { + return err + } + common.LogInfo1(fmt.Sprintf("Storage entry %s destroyed", name)) + return nil +} + +// CommandInfo prints a single entry's details, optionally as JSON. +func CommandInfo(name string, format string) error { + if name == "" { + return errors.New("storage entry name is required") + } + if !EntryExists(name) { + return fmt.Errorf("storage entry %q does not exist", name) + } + + entry, err := LoadEntry(name) + if err != nil { + return err + } + + if format == "json" { + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + common.LogInfo1Quiet(fmt.Sprintf("Storage entry %s", entry.Name)) + common.LogVerbose(fmt.Sprintf("Scheduler: %s", entry.Scheduler)) + if entry.HostPath != "" { + common.LogVerbose(fmt.Sprintf("Host path: %s", entry.HostPath)) + } + if entry.Size != "" { + common.LogVerbose(fmt.Sprintf("Size: %s", entry.Size)) + } + if entry.AccessMode != "" { + common.LogVerbose(fmt.Sprintf("Access mode: %s", entry.AccessMode)) + } + if entry.StorageClass != "" { + common.LogVerbose(fmt.Sprintf("Storage class: %s", entry.StorageClass)) + } + if entry.Namespace != "" { + common.LogVerbose(fmt.Sprintf("Namespace: %s", entry.Namespace)) + } + if entry.Chown != "" { + common.LogVerbose(fmt.Sprintf("Chown: %s", entry.Chown)) + } + if entry.ReclaimPolicy != "" { + common.LogVerbose(fmt.Sprintf("Reclaim policy: %s", entry.ReclaimPolicy)) + } + 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 { + entries, err := ListEntries() + if err != nil { + return err + } + + filtered := []*Entry{} + for _, entry := range entries { + if scheduler != "" && entry.Scheduler != scheduler { + continue + } + filtered = append(filtered, entry) + } + + if format == "json" { + data, err := json.MarshalIndent(filtered, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + if len(filtered) == 0 { + common.LogInfo1Quiet("No storage entries registered") + return nil + } + common.LogInfo1Quiet("Storage entries:") + for _, entry := range filtered { + common.LogVerbose(fmt.Sprintf("%s\t%s\t%s", entry.Name, entry.Scheduler, entry.HostPath)) + } + return nil +} + +// ensureDockerLocalPath creates the host directory referenced by a +// docker-local entry if it doesn't already exist. Idempotent: a +// pre-existing directory is left in place. +func ensureDockerLocalPath(entry *Entry) error { + info, err := os.Stat(entry.HostPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("unable to stat %s: %w", entry.HostPath, err) + } + if err == nil && !info.IsDir() { + return fmt.Errorf("storage entry path %s exists but is not a directory", entry.HostPath) + } + if err != nil { + if mkdirErr := os.MkdirAll(entry.HostPath, 0755); mkdirErr != nil { + return fmt.Errorf("unable to create %s: %w", entry.HostPath, mkdirErr) + } + common.LogVerbose(fmt.Sprintf("Created %s", entry.HostPath)) + } + + if entry.Chown != "" && entry.Chown != "false" { + chownID, err := resolveChownID(entry.Chown) + if err != nil { + return err + } + if chownID != "false" { + pluginPath := common.MustGetEnv("PLUGIN_AVAILABLE_PATH") + chownScript := filepath.Join(pluginPath, "storage", "bin", "chown-storage-dir") + result, err := common.CallExecCommand(common.ExecCommandInput{ + Command: "sudo", + Args: []string{chownScript, entry.HostPath, chownID}, + }) + if err != nil { + return fmt.Errorf("unable to chown %s: %w", entry.HostPath, err) + } + if result.ExitCode != 0 { + return fmt.Errorf("unable to chown %s: %s", entry.HostPath, result.StderrContents()) + } + } + } + return nil +} + +// callSchedulerCreateTrigger asks the scheduler plugin (k3s) to provision +// the underlying PVC/PV. The scheduler is responsible for any +// cluster-level validation (storage class existence, etc.). +func callSchedulerCreateTrigger(entry *Entry) error { + data, err := json.Marshal(entry) + if err != nil { + return err + } + results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "storage-create", + Args: []string{entry.Name}, + StreamStdio: true, + Stdin: strings.NewReader(string(data)), + }) + if err != nil { + return err + } + if results.ExitCode != 0 { + return fmt.Errorf("storage-create trigger exited with %d: %s", results.ExitCode, results.StderrContents()) + } + return nil +} + +// callSchedulerDestroyTrigger asks the scheduler plugin to release the +// underlying PVC/PV. +func callSchedulerDestroyTrigger(entry *Entry) error { + data, err := json.Marshal(entry) + if err != nil { + return err + } + results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ + Trigger: "storage-destroy", + Args: []string{entry.Name}, + StreamStdio: true, + Stdin: strings.NewReader(string(data)), + }) + if err != nil { + return err + } + if results.ExitCode != 0 { + return fmt.Errorf("storage-destroy trigger exited with %d: %s", results.ExitCode, results.StderrContents()) + } + return nil +} diff --git a/plugins/storage/src/commands/commands.go b/plugins/storage/src/commands/commands.go index 8ad0b2de8..0c5ed04a1 100644 --- a/plugins/storage/src/commands/commands.go +++ b/plugins/storage/src/commands/commands.go @@ -18,8 +18,12 @@ Manage mounted volumes Additional commands:` helpContent = ` - storage:ensure-directory [--chown option] , Creates a persistent storage directory in the recommended storage path + 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: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` diff --git a/plugins/storage/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 3e051c341..86ce98ecf 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -19,18 +19,71 @@ func main() { switch subcommand { case "default": err = storage.CommandHelp() + case "create": + args := flag.NewFlagSet("storage:create", flag.ExitOnError) + scheduler := args.String("scheduler", storage.SchedulerDockerLocal, "--scheduler: target scheduler (docker-local, k3s)") + size := args.String("size", "", "--size: PVC size (k3s only, e.g. 2Gi)") + accessMode := args.String("access-mode", "", "--access-mode: PVC access mode (k3s only)") + storageClass := args.String("storage-class-name", "", "--storage-class-name: PVC storage class (k3s only)") + namespace := args.String("namespace", "", "--namespace: PVC namespace (k3s only)") + chown := args.String("chown", "", "--chown: chown option (docker-local only)") + reclaim := args.String("reclaim-policy", "", "--reclaim-policy: PV reclaim policy (Retain or Delete, k3s only)") + annotations := args.StringSlice("annotation", nil, "--annotation key=value: PVC annotation (repeatable)") + labels := args.StringSlice("label", nil, "--label key=value: PVC label (repeatable)") + args.Parse(os.Args[2:]) + name := args.Arg(0) + path := args.Arg(1) + annotMap, parseErr := parseKVPairs(*annotations) + if parseErr != nil { + err = parseErr + break + } + labelMap, parseErr := parseKVPairs(*labels) + if parseErr != nil { + err = parseErr + break + } + err = storage.CommandCreate(storage.CommandCreateInput{ + Name: name, + Path: path, + Scheduler: *scheduler, + Size: *size, + AccessMode: *accessMode, + StorageClass: *storageClass, + Namespace: *namespace, + Chown: *chown, + ReclaimPolicy: *reclaim, + Annotations: annotMap, + Labels: labelMap, + }) + case "destroy": + args := flag.NewFlagSet("storage:destroy", flag.ExitOnError) + args.Parse(os.Args[2:]) + err = storage.CommandDestroy(args.Arg(0)) case "ensure-directory": args := flag.NewFlagSet("storage:ensure-directory", flag.ExitOnError) chown := args.String("chown", "herokuish", "--chown: chown option (herokuish, heroku, paketo, root, false)") args.Parse(os.Args[2:]) directory := args.Arg(0) + common.LogWarn("storage:ensure-directory is deprecated; use storage:create instead.") err = storage.CommandEnsureDirectory(directory, *chown) + case "info": + args := flag.NewFlagSet("storage:info", flag.ExitOnError) + format := args.String("format", "text", "--format: output format (text, json)") + args.Parse(os.Args[2:]) + err = storage.CommandInfo(args.Arg(0), *format) case "list": args := flag.NewFlagSet("storage:list", flag.ExitOnError) format := args.String("format", "text", "--format: output format (text, json)") args.Parse(os.Args[2:]) appName := args.Arg(0) err = storage.CommandList(appName, *format) + case "list-entries": + args := flag.NewFlagSet("storage:list-entries", flag.ExitOnError) + scheduler := args.String("scheduler", "", "--scheduler: filter to a single scheduler") + format := args.String("format", "text", "--format: output format (text, json)") + args.Parse(os.Args[2:]) + err = storage.CommandListEntries(*scheduler, *format) case "mount": args := flag.NewFlagSet("storage:mount", flag.ExitOnError) args.Parse(os.Args[2:]) @@ -63,3 +116,18 @@ func main() { common.LogFailWithError(err) } } + +func parseKVPairs(pairs []string) (map[string]string, error) { + if len(pairs) == 0 { + return nil, nil + } + out := map[string]string{} + for _, pair := range pairs { + idx := strings.Index(pair, "=") + if idx <= 0 { + return nil, fmt.Errorf("expected key=value, got %q", pair) + } + out[pair[:idx]] = pair[idx+1:] + } + return out, nil +} diff --git a/plugins/storage/subcommands.go b/plugins/storage/subcommands.go index 4ff579a65..b84575b84 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -19,8 +19,12 @@ Manage mounted volumes Additional commands:` helpContent = ` - storage:ensure-directory [--chown option] , Creates a persistent storage directory in the recommended storage path + 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: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`