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 [] [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` ) // 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) }