feat: add storage:create, storage:destroy, storage:info, storage:list-entries
Wires the new entry-management commands on top of the Entry type. storage:create accepts both docker-local entries (with idempotent host-directory creation) and k3s entries (which delegate PVC provisioning to a forthcoming scheduler-side storage-create trigger). storage:destroy refuses to remove an entry that any app still mounts. storage:ensure-directory now emits a deprecation warning. storage:mount and storage:unmount keep the existing colon-form behavior so this commit is non-breaking; the named-entry mount form follows in a later commit.
This commit is contained in:
@@ -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
|
||||
|
||||
302
plugins/storage/commands_entries.go
Normal file
302
plugins/storage/commands_entries.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -18,8 +18,12 @@ Manage mounted volumes
|
||||
Additional commands:`
|
||||
|
||||
helpContent = `
|
||||
storage:ensure-directory [--chown option] <directory>, Creates a persistent storage directory in the recommended storage path
|
||||
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: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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -19,8 +19,12 @@ Manage mounted volumes
|
||||
Additional commands:`
|
||||
|
||||
helpContent = `
|
||||
storage:ensure-directory [--chown option] <directory>, Creates a persistent storage directory in the recommended storage path
|
||||
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: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`
|
||||
|
||||
Reference in New Issue
Block a user