Files
dokku/plugins/storage/storage.go
Jose Diaz-Gonzalez e9160db769 fix: expose attachment readonly and volume_options in storage:list json
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.
2026-05-30 03:00:21 -04:00

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
}