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 [] [], Displays annotations for one or more storage entries storage:annotations:set [], Set or clear an annotation on a storage entry storage:create [] [flags], Register a named storage entry storage:destroy [--force] [--destroy-host-dir], Remove a named storage entry (must be unmounted from every app first) storage:ensure-directory [--chown option] , [DEPRECATED] use storage:create instead storage:exec [-- ...], Run a command (or shell) in a temporary container that mounts the entry storage:info [--format text|json], Show details for one storage entry storage:labels:report [] [], Displays labels for one or more storage entries storage:labels:set [], Set or clear a label on a 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:migrate [|--all], Re-run the legacy -v to attachment migration for an app storage:mount , Create a new bind mount storage:report [] [], Displays a storage report for one or more apps storage:set [], Update a storage entry in place storage:unmount , Remove an existing bind mount storage:wait , 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- 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 `:[:options]` mount // string into a synthesized legacy- 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) }