Files
dokku/plugins/storage/subcommands.go
Jose Diaz-Gonzalez 74f9acf7ba refactor: align storage:set with dokku conventions
`storage:set` now takes `<name> <property> [<value>]` like every other `:set` command, where omitting the value unsets the property. Previously it took flags and could not distinguish an empty value from an omitted one, so nothing it set could ever be cleared. The flag form keeps working and emits a deprecation warning.

Annotations and labels move to `storage:annotations:set`, `storage:annotations:report`, `storage:labels:set`, and `storage:labels:report`, matching the `scheduler-k3s` equivalents. These operate on a single key, so clearing one leaves the rest in place rather than replacing the whole map as the `--annotation` and `--label` flags do.
2026-08-10 15:00:45 -04:00

370 lines
11 KiB
Go

package storage
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/dokku/dokku/plugins/common"
)
const (
helpHeader = `Usage: dokku storage[:COMMAND]
Manage mounted volumes
Additional commands:`
helpContent = `
storage:annotations:report [<name>] [<flag>], Displays annotations for one or more storage entries
storage:annotations:set <name> <key> [<value>], Set or clear an annotation on a storage entry
storage:create <name> [<path>] [flags], Register a named storage entry
storage:destroy <name> [--force] [--destroy-host-dir], 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:labels:report [<name>] [<flag>], Displays labels for one or more storage entries
storage:labels:set <name> <key> [<value>], Set or clear a label on a 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:migrate [<app>|--all], Re-run the legacy -v to attachment migration for an app
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:set <name> <property> [<value>], 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
func CommandHelp() error {
common.CommandUsage(helpHeader, helpContent)
return nil
}
// CommandEnsureDirectory creates a persistent storage directory
func CommandEnsureDirectory(directory string, chownFlag string) error {
if err := ValidateDirectoryName(directory); err != nil {
return err
}
chownID, err := ResolveChownID(chownFlag)
if err != nil {
return err
}
storageDirectory := filepath.Join(GetStorageDirectory(), directory)
common.LogInfo1(fmt.Sprintf("Ensuring %s exists", storageDirectory))
if err := os.MkdirAll(storageDirectory, 0755); err != nil {
return fmt.Errorf("Unable to create directory: %s", err.Error())
}
if chownID != "false" {
common.LogVerboseQuiet(fmt.Sprintf("Setting directory ownership to %s:%s", chownID, chownID))
if err := callStorageDirScript("chown-storage-dir", directory, chownID); err != nil {
return fmt.Errorf("Unable to set directory ownership: %s", err.Error())
}
}
common.LogVerboseQuiet("Directory ready for mounting")
return nil
}
// ResolveChownID converts a chown flag value to a numeric UID
func ResolveChownID(chownFlag string) (string, error) {
var chownID string
switch chownFlag {
case "herokuish":
chownID = "32767"
case "heroku":
chownID = "1000"
case "packeto":
common.LogVerbose("Detected deprecated chown flag 'packeto'. Using 'paketo' instead. Please update your configuration.")
chownID = "2000"
case "paketo":
chownID = "2000"
case "root":
chownID = "0"
case "false":
return "false", nil
default:
id, err := strconv.ParseUint(chownFlag, 10, 16)
if err != nil {
return "", errors.New("Unsupported chown permissions")
} else {
chownID = fmt.Sprint(id)
}
}
userns, err := isUserNamespacesEnabled()
if err != nil {
return "", err
}
if userns && chownID != "false" {
uid := 0
fmt.Sscanf(chownID, "%d", &uid)
uid += 165536
chownID = fmt.Sprintf("%d", uid)
}
return chownID, nil
}
// isUserNamespacesEnabled checks if Docker user namespaces are enabled
func isUserNamespacesEnabled() (bool, error) {
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: common.DockerBin(),
Args: []string{"info", "-f", "{{range .SecurityOptions}}{{if eq . \"name=userns\"}}true{{end}}{{end}}"},
})
if err != nil {
return false, err
}
return strings.TrimSpace(result.StdoutContents()) == "true", nil
}
// CommandMountInput captures the optional flags accepted by storage:mount
// when the second argument is a named entry rather than a colon-form path.
type CommandMountInput struct {
AppName string
NameOrPath string
ContainerDir string
Phases []string
ProcessType string
Subpath string
Readonly bool
VolumeOptions string
VolumeChown string
}
// CommandMount creates a new bind mount for an app. The second positional
// argument may be either a legacy host:container[:options] string (kept
// for back-compat on docker-local) or a registered storage entry name.
func CommandMount(input CommandMountInput) error {
if err := common.VerifyAppName(input.AppName); err != nil {
return err
}
// Legacy colon form: synthesize a legacy-<hash> entry plus an
// attachment so storage:list (now attachment-only) sees the mount.
// The storage docker-args trigger emits the corresponding -v flag at
// deploy time, so behavior at the docker-run boundary is unchanged.
if strings.Contains(input.NameOrPath, ":") {
return mountLegacyColon(input.AppName, input.NameOrPath)
}
// Named-entry form: persist as an attachment.
if !EntryExists(input.NameOrPath) {
return fmt.Errorf("storage entry %q does not exist; create it first with `dokku storage:create`", input.NameOrPath)
}
if input.ContainerDir == "" {
return errors.New("--container-dir is required when mounting a named storage entry")
}
entry, err := LoadEntry(input.NameOrPath)
if err != nil {
return err
}
phases := input.Phases
if len(phases) == 0 {
phases = []string{PhaseDeploy, PhaseRun}
}
processType := input.ProcessType
if processType == "" {
processType = DefaultProcessType
}
attachment := &Attachment{
EntryName: entry.Name,
ContainerPath: input.ContainerDir,
Phases: phases,
ProcessType: processType,
Subpath: input.Subpath,
Readonly: input.Readonly,
VolumeOptions: input.VolumeOptions,
VolumeChown: input.VolumeChown,
}
// Idempotent contract: same (entry, container_dir, process_type)
// tuple updates the mount-time fields in place rather than appending
// a duplicate attachment. Lets declarative tooling change
// --volume-options / --volume-chown / --phase / etc. without an
// unmount-then-remount dance that briefly drops the volume.
created, err := UpsertAttachment(input.AppName, attachment)
if err != nil {
return err
}
if created {
common.LogInfo1(fmt.Sprintf("Storage entry %s mounted at %s on %s", entry.Name, input.ContainerDir, input.AppName))
} else {
common.LogInfo1(fmt.Sprintf("Storage entry %s mount at %s on %s updated", entry.Name, input.ContainerDir, input.AppName))
}
return nil
}
// CommandUnmountInput captures the flags accepted by storage:unmount when
// the second argument is a named entry.
type CommandUnmountInput struct {
AppName string
NameOrPath string
ContainerDir string
}
// CommandUnmount removes an existing bind mount from an app.
func CommandUnmount(input CommandUnmountInput) error {
if err := common.VerifyAppName(input.AppName); err != nil {
return err
}
if strings.Contains(input.NameOrPath, ":") {
return unmountLegacyColon(input.AppName, input.NameOrPath)
}
return RemoveAttachment(input.AppName, input.NameOrPath, input.ContainerDir)
}
// mountLegacyColon translates a `<host>:<container>[:options]` mount
// string into a synthesized legacy-<hash> entry plus an attachment.
// Idempotent: re-running the same mount errors with the existing
// "already mounted" message via AddAttachment's duplicate check.
func mountLegacyColon(appName string, mountPath string) error {
if err := VerifyPaths(mountPath); err != nil {
return err
}
parsed := ParseMountPath(mountPath)
if parsed.ContainerPath == "" {
return errors.New("Storage path must be two valid paths divided by colon.")
}
entry := LegacyMountToEntry(mountPath)
if !EntryExists(entry.Name) {
if err := SaveEntry(entry); err != nil {
return err
}
}
attachment := &Attachment{
EntryName: entry.Name,
ContainerPath: parsed.ContainerPath,
Phases: []string{PhaseDeploy, PhaseRun},
ProcessType: DefaultProcessType,
Readonly: parsed.Readonly,
VolumeOptions: parsed.VolumeOptions,
}
if err := AddAttachment(appName, attachment); err != nil {
// AddAttachment's duplicate error mentions the entry name, but
// the legacy form historically said "Mount path already
// exists." - preserve that exact wording so existing automation
// and the bats suite keep matching.
if strings.Contains(err.Error(), "is already mounted at") {
return errors.New("Mount path already exists.")
}
return err
}
return nil
}
// unmountLegacyColon is the inverse of mountLegacyColon. The legacy
// mount string identifies an entry+container-path tuple deterministically
// via LegacyMountToEntry, so we can route to RemoveAttachment.
func unmountLegacyColon(appName string, mountPath string) error {
if err := VerifyPaths(mountPath); err != nil {
return err
}
parsed := ParseMountPath(mountPath)
if parsed.ContainerPath == "" {
return errors.New("Storage path must be two valid paths divided by colon.")
}
entry := LegacyMountToEntry(mountPath)
if err := RemoveAttachment(appName, entry.Name, parsed.ContainerPath); err != nil {
// Match the legacy wording for "not currently mounted".
if strings.Contains(err.Error(), "is not mounted") {
return errors.New("Mount path does not exist.")
}
return err
}
return nil
}
// CommandList lists all bind mounts for an app. Reads attachments
// directly from the storage plugin's own state rather than going through
// the deprecated `storage-list` plugn trigger.
func CommandList(appName string, format string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
}
if format != "text" && format != "json" {
return errors.New("Invalid --format value specified")
}
rows, err := ListAppMountEntries(appName, PhaseDeploy)
if err != nil {
return err
}
if format == "json" {
output, err := json.Marshal(rows)
if err != nil {
return err
}
fmt.Println(string(output))
return nil
}
common.LogInfo1Quiet(fmt.Sprintf("%s volume bind-mounts:", appName))
for _, row := range rows {
line := formatStorageListEntry(row)
if os.Getenv("DOKKU_QUIET_OUTPUT") != "" {
fmt.Println(line)
} else {
common.LogVerbose(line)
}
}
return nil
}
// 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 {
if errors.Is(err, common.NoAppsExist) {
common.LogWarn(err.Error())
return nil
}
return err
}
for _, app := range apps {
if err := ReportSingleApp(app, format, infoFlag); err != nil {
return err
}
}
return nil
}
return ReportSingleApp(appName, format, infoFlag)
}