Files
dokku/plugins/storage/subcommands.go
Jose Diaz-Gonzalez e1b6bdb1d4 feat: storage attachments, docker-args triggers, install-time migration
storage:mount and storage:unmount now accept the named-entry form (--container-dir, --phase, --process-type, --volume-subpath, --volume-readonly, --volume-chown), persisting attachments via the property list. The legacy host:container colon form is preserved for back-compat. Storage now emits its own docker-args-deploy and docker-args-run triggers so docker-local apps continue to receive bind mounts even after their data moves out of docker-options. The install trigger walks every app once per upgrade and converts legacy -v lines into legacy-<hash> entries plus attachments, draining the originals from docker-options last so a partial run leaves the old behavior intact. Lifecycle triggers post-delete, post-app-clone-setup, and post-app-rename-setup keep the attachment store in sync with app state.
2026-04-29 14:43:51 -04:00

288 lines
8.0 KiB
Go

package storage
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/dokku/dokku/plugins/common"
dockeroptions "github.com/dokku/dokku/plugins/docker-options"
)
const (
helpHeader = `Usage: dokku storage[:COMMAND]
Manage mounted volumes
Additional commands:`
helpContent = `
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`
)
// 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))
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, directory, chownID},
})
if err != nil {
return fmt.Errorf("Unable to set directory ownership: %s", err.Error())
}
if result.ExitCode != 0 {
return fmt.Errorf("Unable to set directory ownership: %s", result.StderrContents())
}
}
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:
return "", errors.New("Unsupported chown permissions")
}
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: keep writing into docker-options so existing
// users see no behavior change.
if strings.Contains(input.NameOrPath, ":") {
if err := VerifyPaths(input.NameOrPath); err != nil {
return err
}
if CheckIfPathExists(input.AppName, input.NameOrPath, MountPhases) {
return errors.New("Mount path already exists.")
}
return dockeroptions.AddDockerOptionToPhases(input.AppName, MountPhases, fmt.Sprintf("-v %s", 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,
}
return AddAttachment(input.AppName, attachment)
}
// 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, ":") {
if err := VerifyPaths(input.NameOrPath); err != nil {
return err
}
if !CheckIfPathExists(input.AppName, input.NameOrPath, MountPhases) {
return errors.New("Mount path does not exist.")
}
return dockeroptions.RemoveDockerOptionFromPhases(input.AppName, MountPhases, fmt.Sprintf("-v %s", input.NameOrPath))
}
return RemoveAttachment(input.AppName, input.NameOrPath, input.ContainerDir)
}
// CommandList lists all bind mounts for an app
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")
}
if format == "text" {
common.LogInfo1Quiet(fmt.Sprintf("%s volume bind-mounts:", appName))
}
results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "storage-list",
Args: []string{appName, "deploy", format},
})
if err != nil {
return err
}
output := results.StdoutContents()
if format == "text" && output != "" {
lines := strings.Split(strings.TrimSpace(output), "\n")
for _, line := range lines {
if line != "" {
if os.Getenv("DOKKU_QUIET_OUTPUT") != "" {
fmt.Println(line)
} else {
common.LogVerbose(line)
}
}
}
} else if output != "" {
fmt.Println(output)
}
return nil
}
// CommandReport displays a storage report for one or more apps
func CommandReport(appName string, format string, infoFlag string) error {
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)
}