Files
dokku/plugins/storage/storage.go
Jose Diaz-Gonzalez d75228f165 fix: storage:list reads from attachments, deprecate storage-list trigger
storage:list was calling the storage-list plugn trigger which read -v lines from docker-options. After the install-time migration drains those lines into the attachment store, that source is empty for every migrated app and for any app that only ever used storage:create + storage:mount. The fix moves CommandList to call a new in-process ListAppMountEntries helper that reads attachments directly, surfaces the entry name in JSON output via a new entry_name field, and falls back to the entry name as the host token for k3s entries with no host path so the colon form remains well-formed. The storage-list plugn trigger is kept for back-compat with external callers but emits a deprecation warning and now reads from the same attachment-driven source.
2026-04-29 22:51:50 -04:00

188 lines
5.3 KiB
Go

package storage
import (
"errors"
"fmt"
"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.
type StorageListEntry struct {
EntryName string `json:"entry_name,omitempty"`
HostPath string `json:"host_path"`
ContainerPath string `json:"container_path"`
VolumeOptions string `json:"volume_options"`
}
// 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
}
options := ""
switch {
case attachment.Readonly && attachment.VolumeOptions != "":
options = "ro," + attachment.VolumeOptions
case attachment.Readonly:
options = "ro"
case attachment.VolumeOptions != "":
options = attachment.VolumeOptions
}
rows = append(rows, StorageListEntry{
EntryName: entry.Name,
HostPath: host,
ContainerPath: attachment.ContainerPath,
VolumeOptions: options,
})
}
return rows, nil
}
// formatStorageListEntry renders a StorageListEntry into the legacy
// host:container[:options] colon form for textual output.
func formatStorageListEntry(entry StorageListEntry) string {
if entry.VolumeOptions == "" {
return fmt.Sprintf("%s:%s", entry.HostPath, entry.ContainerPath)
}
return fmt.Sprintf("%s:%s:%s", entry.HostPath, entry.ContainerPath, entry.VolumeOptions)
}
// ParseMountPath parses a mount path into its components
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 {
entry.VolumeOptions = parts[2]
}
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)
}
// 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
}