mirror of
https://github.com/dokku/dokku.git
synced 2026-07-11 04:51:57 +02:00
The `storage:list <app> --format json` payload conflated the attachment's `Readonly` flag and its `VolumeOptions` field into a single derived `volume_options` string that rendered as `ro`, `<options>`, or `ro,<options>` depending on which fields were set on the underlying attachment. That shape is fine for the legacy `host:container[:options]` text view but it leaves drift-detection tooling unable to tell whether a `ro` token came from `Attachment.Readonly == true` or from the operator setting `Attachment.VolumeOptions = "ro"` directly. The JSON now exposes `readonly` (boolean) and `volume_options` (string) as separate omitempty keys populated straight from the attachment, and `formatStorageListEntry` combines them at format time for the colon- form text view. `ParseMountPath` was extended in lockstep so callers of the legacy `host:container:opts` form no longer have to special- case the `ro` token themselves.
207 lines
6.2 KiB
Go
207 lines
6.2 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.
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|