Files
dokku/plugins/storage/storage.go
Jose Diaz-Gonzalez 87b240054f feat: add storage directory mode and removal flags
`storage:create` and `storage:set` accept a `--mode` flag that sets the octal permissions of a docker-local host directory, and `storage:destroy` accepts a `--destroy-host-dir` flag that removes the directory along with its contents. A docker-local entry also honors `--reclaim-policy Delete` at destroy time now, matching how that policy governs a k3s PersistentVolume. Both are limited to the default `/var/lib/dokku/data/storage/<name>` location, the same restriction `--chown` already carries. `storage:set` applies `--chown` and `--mode` to the directory rather than only recording them.
2026-08-10 01:23:10 -04:00

252 lines
7.6 KiB
Go

package storage
import (
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
"github.com/dokku/dokku/plugins/common"
dockeroptions "github.com/dokku/dokku/plugins/docker-options"
)
// MountPhases are the phases where storage mounts are applied
var MountPhases = []string{"deploy", "run"}
// VerifyPaths validates the storage mount path format
func VerifyPaths(mountPath string) error {
if strings.HasPrefix(mountPath, "/") {
matched, err := regexp.MatchString(`^/.*:/`, mountPath)
if err != nil {
return err
}
if !matched {
return errors.New("Storage path must be two valid paths divided by colon.")
}
} else {
matched, err := regexp.MatchString(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+:/`, mountPath)
if err != nil {
return err
}
if !matched {
return errors.New("Volume name must be two characters or more. Volume name must not contain invalid characters. Storage path must be two valid paths divided by colon.")
}
}
return nil
}
// CheckIfPathExists checks if a mount path exists in the specified phases
func CheckIfPathExists(appName string, mountPath string, phases []string) bool {
for _, phase := range phases {
options, err := dockeroptions.GetDockerOptionsForPhase(appName, phase)
if err != nil {
continue
}
for _, option := range options {
if option == fmt.Sprintf("-v %s", mountPath) {
return true
}
}
}
return false
}
// GetBindMounts returns the bind mounts for an app and phase, synthesized
// from the attachment store. The returned strings use the legacy colon
// form (host:container[:options]) so existing display paths keep
// working unchanged.
func GetBindMounts(appName string, phase string) ([]string, error) {
entries, err := ListAppMountEntries(appName, phase)
if err != nil {
return nil, err
}
mounts := make([]string, 0, len(entries))
for _, entry := range entries {
mounts = append(mounts, formatStorageListEntry(entry))
}
return mounts, nil
}
// GetBindMountsForDisplay returns the bind mounts formatted for display
func GetBindMountsForDisplay(appName string, phase string) string {
mounts, err := GetBindMounts(appName, phase)
if err != nil {
return ""
}
result := []string{}
for _, mount := range mounts {
result = append(result, fmt.Sprintf("-v %s", mount))
}
return strings.Join(result, " ")
}
// StorageListEntry represents a storage mount entry for JSON output.
// Readonly and VolumeOptions mirror the underlying Attachment fields one
// for one so external drift-detection tooling can compare against them
// directly; the combined "ro,<opts>" colon-form string used by the text
// view is derived at format time by formatStorageListEntry rather than
// cached on the struct.
type StorageListEntry struct {
EntryName string `json:"entry_name,omitempty"`
HostPath string `json:"host_path"`
ContainerPath string `json:"container_path"`
Readonly bool `json:"readonly,omitempty"`
VolumeOptions string `json:"volume_options,omitempty"`
}
// ListAppMountEntries returns one StorageListEntry per attachment on
// an app for the requested phase, joining each attachment with its
// referenced storage entry. Used by storage:list and the deprecated
// storage-list trigger.
func ListAppMountEntries(appName string, phase string) ([]StorageListEntry, error) {
if phase == "" {
phase = PhaseDeploy
}
attachments, err := AttachmentsForPhase(appName, phase)
if err != nil {
return nil, err
}
rows := make([]StorageListEntry, 0, len(attachments))
for _, attachment := range attachments {
entry, err := LoadEntry(attachment.EntryName)
if err != nil {
return nil, fmt.Errorf("attachment on %q references missing entry %q: %w", appName, attachment.EntryName, err)
}
host := entry.HostPath
if host == "" {
// k3s-only entries with no host path: surface the entry
// name as the host token so the colon-form output is
// well-formed and parseable.
host = entry.Name
}
rows = append(rows, StorageListEntry{
EntryName: entry.Name,
HostPath: host,
ContainerPath: attachment.ContainerPath,
Readonly: attachment.Readonly,
VolumeOptions: attachment.VolumeOptions,
})
}
return rows, nil
}
// formatStorageListEntry renders a StorageListEntry into the legacy
// host:container[:options] colon form for textual output. Combines
// Readonly and VolumeOptions into a single "ro,<opts>" token to match
// the historical shape consumers expect.
func formatStorageListEntry(entry StorageListEntry) string {
options := ""
switch {
case entry.Readonly && entry.VolumeOptions != "":
options = "ro," + entry.VolumeOptions
case entry.Readonly:
options = "ro"
case entry.VolumeOptions != "":
options = entry.VolumeOptions
}
if options == "" {
return fmt.Sprintf("%s:%s", entry.HostPath, entry.ContainerPath)
}
return fmt.Sprintf("%s:%s:%s", entry.HostPath, entry.ContainerPath, options)
}
// ParseMountPath parses a mount path into its components. The optional
// third colon-separated section is a comma-separated mount-options list;
// any "ro" token is hoisted into the Readonly field and the remaining
// tokens (preserving order) are rejoined into VolumeOptions.
func ParseMountPath(mountPath string) StorageListEntry {
parts := strings.SplitN(mountPath, ":", 3)
entry := StorageListEntry{}
if len(parts) >= 1 {
entry.HostPath = parts[0]
}
if len(parts) >= 2 {
entry.ContainerPath = parts[1]
}
if len(parts) >= 3 && parts[2] != "" {
remaining := []string{}
for _, token := range strings.Split(parts[2], ",") {
if token == "ro" {
entry.Readonly = true
continue
}
remaining = append(remaining, token)
}
entry.VolumeOptions = strings.Join(remaining, ",")
}
return entry
}
// GetStorageDirectory returns the storage directory path
func GetStorageDirectory() string {
dokkuLibRoot := common.GetenvWithDefault("DOKKU_LIB_ROOT", "/var/lib/dokku")
return fmt.Sprintf("%s/data/storage", dokkuLibRoot)
}
// storageDirScriptNames lists the sudo helpers shipped in the plugin's bin
// directory. Each one takes a storage entry basename and builds the path
// under $DOKKU_LIB_ROOT/data/storage itself, so no caller can point them
// outside the storage root.
var storageDirScriptNames = []string{
"chown-storage-dir",
"chmod-storage-dir",
"destroy-storage-dir",
}
// StorageDirScriptPath returns the absolute path to a storage directory
// sudo helper.
func StorageDirScriptPath(name string) string {
pluginPath := common.MustGetEnv("PLUGIN_AVAILABLE_PATH")
return filepath.Join(pluginPath, "storage", "bin", name)
}
// StorageDirScripts returns the absolute path of every storage directory
// sudo helper. TriggerInstall whitelists each one in the plugin's sudoers
// file.
func StorageDirScripts() []string {
paths := []string{}
for _, name := range storageDirScriptNames {
paths = append(paths, StorageDirScriptPath(name))
}
return paths
}
// callStorageDirScript runs a storage directory sudo helper, surfacing the
// helper's own stderr message when it refuses the arguments.
func callStorageDirScript(name string, args ...string) error {
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: "sudo",
Args: append([]string{StorageDirScriptPath(name)}, args...),
})
if err != nil {
return err
}
if result.ExitCode != 0 {
return errors.New(strings.TrimSpace(result.StderrContents()))
}
return nil
}
// ValidateDirectoryName validates a storage directory name
func ValidateDirectoryName(directory string) error {
if directory == "" {
return errors.New("Please specify a directory to create")
}
matched, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, directory)
if err != nil {
return err
}
if !matched {
return errors.New("Directory can only contain the following set of characters: [A-Za-z0-9_-]")
}
return nil
}