package storage import ( "crypto/sha1" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "github.com/dokku/dokku/plugins/common" ) // SchemaVersion is the on-disk schema version emitted by this plugin. // Bumped when the format of Entry / Attachment changes incompatibly. const SchemaVersion = 1 // SchedulerDockerLocal is the default scheduler value for storage entries. const SchedulerDockerLocal = "docker-local" // SchedulerK3s is the k3s scheduler value for storage entries. const SchedulerK3s = "k3s" // LegacyEntryPrefix is reserved for migration-synthesized entries; users // cannot create entries whose name starts with this prefix. const LegacyEntryPrefix = "legacy-" // MaxEntryNameLength caps the storage entry name so that the helm release // name "storage-" stays within Helm's practical 53-char ceiling. const MaxEntryNameLength = 45 // ReclaimPolicyRetain keeps the underlying PV after the entry is destroyed. const ReclaimPolicyRetain = "Retain" // ReclaimPolicyDelete deletes the underlying PV after the entry is destroyed. const ReclaimPolicyDelete = "Delete" var ( // dns1123LabelRegexp matches a Kubernetes DNS-1123 label, the format // required for resource names like PVCs and Helm releases. dns1123LabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) // dockerNamedVolumeRegexp matches a Docker named-volume token. Same // rule docker uses to disambiguate volume names from bind paths. dockerNamedVolumeRegexp = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) // directoryModeRegexp matches a 3 or 4 digit octal directory mode. directoryModeRegexp = regexp.MustCompile(`^[0-7]{3,4}$`) // supportedSchedulers lists the values accepted for --scheduler. supportedSchedulers = map[string]bool{ SchedulerDockerLocal: true, SchedulerK3s: true, } // supportedAccessModes lists the values accepted for --access-mode on k3s. supportedAccessModes = map[string]bool{ "ReadWriteOnce": true, "ReadOnlyMany": true, "ReadWriteMany": true, "ReadWriteOncePod": true, } ) // Entry is the source of truth for a storage volume. One file per entry // lives at $DOKKU_LIB_ROOT/config/storage/entries/.json. type Entry struct { Name string `json:"name"` Scheduler string `json:"scheduler"` HostPath string `json:"host_path,omitempty"` Size string `json:"size,omitempty"` AccessMode string `json:"access_mode,omitempty"` StorageClass string `json:"storage_class,omitempty"` Namespace string `json:"namespace,omitempty"` Chown string `json:"chown,omitempty"` Mode string `json:"mode,omitempty"` ReclaimPolicy string `json:"reclaim_policy,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` Labels map[string]string `json:"labels,omitempty"` SchemaVersion int `json:"schema_version"` } // RegistryDirectory returns the parent directory for storage-plugin // state that lives outside the property store. Kept under data/ rather // than config/ so it doesn't collide with the property-list paths // `config/storage//` that the rest of the plugin // relies on for per-app attachments. func RegistryDirectory() string { root := common.GetenvWithDefault("DOKKU_LIB_ROOT", "/var/lib/dokku") return filepath.Join(root, "data", "storage-registry") } // EntriesDirectory returns the directory holding per-entry JSON files. func EntriesDirectory() string { return filepath.Join(RegistryDirectory(), "entries") } // entryPath returns the on-disk path for a named entry. func entryPath(name string) string { return filepath.Join(EntriesDirectory(), name+".json") } // EnsureEntriesDirectory makes sure the entries directory exists with // permissive enough mode that the dokku user can read it. func EnsureEntriesDirectory() error { dir := EntriesDirectory() if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("unable to create storage entries directory: %w", err) } return nil } // EntryExists reports whether an entry with the given name is registered. func EntryExists(name string) bool { _, err := os.Stat(entryPath(name)) return err == nil } // LoadEntry reads a registered entry by name. func LoadEntry(name string) (*Entry, error) { data, err := os.ReadFile(entryPath(name)) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("storage entry %q does not exist", name) } return nil, fmt.Errorf("unable to read storage entry %q: %w", name, err) } entry := &Entry{} if err := json.Unmarshal(data, entry); err != nil { return nil, fmt.Errorf("unable to parse storage entry %q: %w", name, err) } return entry, nil } // SaveEntry persists an entry to its JSON file. func SaveEntry(entry *Entry) error { if err := EnsureEntriesDirectory(); err != nil { return err } if entry.SchemaVersion == 0 { entry.SchemaVersion = SchemaVersion } data, err := json.MarshalIndent(entry, "", " ") if err != nil { return fmt.Errorf("unable to encode storage entry %q: %w", entry.Name, err) } if err := common.WriteBytesToFile(common.WriteBytesToFileInput{ Bytes: data, Filename: entryPath(entry.Name), Mode: 0600, }); err != nil { return fmt.Errorf("unable to write storage entry %q: %w", entry.Name, err) } return nil } // DeleteEntry removes the on-disk record for an entry. func DeleteEntry(name string) error { if err := os.Remove(entryPath(name)); err != nil && !os.IsNotExist(err) { return fmt.Errorf("unable to remove storage entry %q: %w", name, err) } return nil } // ListEntries returns every registered entry sorted by name. func ListEntries() ([]*Entry, error) { dir := EntriesDirectory() dirEntries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("unable to list storage entries: %w", err) } entries := []*Entry{} for _, dirEntry := range dirEntries { if dirEntry.IsDir() || !strings.HasSuffix(dirEntry.Name(), ".json") { continue } name := strings.TrimSuffix(dirEntry.Name(), ".json") entry, err := LoadEntry(name) if err != nil { return nil, err } entries = append(entries, entry) } sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) return entries, nil } // ValidateEntryName checks the user-supplied name against length and DNS // label rules. Returns nil for legacy names if allowLegacyPrefix is true, // which is only set during migration. func ValidateEntryName(name string, allowLegacyPrefix bool) error { if name == "" { return errors.New("storage entry name is required") } if len(name) > MaxEntryNameLength { return fmt.Errorf("storage entry name %q is too long (max %d characters); helm release names cap our budget", name, MaxEntryNameLength) } if !dns1123LabelRegexp.MatchString(name) { return fmt.Errorf("storage entry name %q must be a DNS-1123 label (lowercase letters, digits, and dashes; cannot start or end with a dash)", name) } if !allowLegacyPrefix && strings.HasPrefix(name, LegacyEntryPrefix) { return fmt.Errorf("storage entry name %q must not start with reserved prefix %q", name, LegacyEntryPrefix) } return nil } // NormalizeDirectoryMode canonicalizes a user-supplied octal directory // mode to its 4 digit form so the stored value is stable regardless of // whether the caller wrote 755 or 0755. An empty mode is passed through, // meaning "leave the directory's permissions alone". func NormalizeDirectoryMode(mode string) (string, error) { if mode == "" { return "", nil } if !directoryModeRegexp.MatchString(mode) { return "", fmt.Errorf("Unsupported directory mode %q. Value must be a 3 or 4 digit octal mode, such as 0755", mode) } if len(mode) == 3 { return "0" + mode, nil } return mode, nil } // Validate checks an Entry's fields against the cross-field rules for its // scheduler. It is reused by both storage:create and the legacy migration. func (e *Entry) Validate() error { if e == nil { return errors.New("entry is nil") } if err := ValidateEntryName(e.Name, strings.HasPrefix(e.Name, LegacyEntryPrefix)); err != nil { return err } if !supportedSchedulers[e.Scheduler] { return fmt.Errorf("storage entry %q has unsupported scheduler %q (supported: docker-local, k3s)", e.Name, e.Scheduler) } // The reclaim policy governs whether the underlying volume survives a // storage:destroy on both schedulers, so it is validated for both. if e.ReclaimPolicy != "" && e.ReclaimPolicy != ReclaimPolicyRetain && e.ReclaimPolicy != ReclaimPolicyDelete { return fmt.Errorf("storage entry %q has unsupported reclaim policy %q", e.Name, e.ReclaimPolicy) } switch e.Scheduler { case SchedulerDockerLocal: if e.HostPath == "" { return fmt.Errorf("storage entry %q (docker-local) is missing host_path", e.Name) } if !filepath.IsAbs(e.HostPath) && !dockerNamedVolumeRegexp.MatchString(e.HostPath) { return fmt.Errorf("storage entry %q host_path must be an absolute path or a docker named volume token, got %q", e.Name, e.HostPath) } if e.Size != "" { return fmt.Errorf("storage entry %q (docker-local) does not accept --size", e.Name) } if e.StorageClass != "" { return fmt.Errorf("storage entry %q (docker-local) does not accept --storage-class-name", e.Name) } if e.AccessMode != "" { return fmt.Errorf("storage entry %q (docker-local) does not accept --access-mode", e.Name) } if _, err := NormalizeDirectoryMode(e.Mode); err != nil { return err } // Removing the host path is implemented by a sudo helper that only // ever operates on the default location, so a Delete policy on any // other path could never be honored. defaultHostPath := filepath.Join(GetStorageDirectory(), e.Name) if e.ReclaimPolicy == ReclaimPolicyDelete && e.HostPath != defaultHostPath { return fmt.Errorf("storage entry %q (docker-local) only accepts --reclaim-policy Delete on the default host path (%s)", e.Name, defaultHostPath) } case SchedulerK3s: if e.Size == "" { return fmt.Errorf("storage entry %q (k3s) requires --size", e.Name) } if e.StorageClass != "" && e.HostPath != "" { return fmt.Errorf("storage entry %q (k3s) cannot set both --storage-class-name and a host path; the cluster provisions class-backed volumes", e.Name) } if e.AccessMode != "" && !supportedAccessModes[e.AccessMode] { return fmt.Errorf("storage entry %q has unsupported access mode %q", e.Name, e.AccessMode) } if e.HostPath != "" && !filepath.IsAbs(e.HostPath) { return fmt.Errorf("storage entry %q host_path must be absolute, got %q", e.Name, e.HostPath) } if e.Mode != "" { return fmt.Errorf("storage entry %q (k3s) does not accept --mode", e.Name) } } return nil } // LegacyMountToEntry synthesizes a deterministic Entry for a legacy // "host:container[:options]" mount string. The synthesized name is // stable across runs so re-running the migration is idempotent and so // two apps mounting the same host path converge on a single entry. func LegacyMountToEntry(mountPath string) *Entry { parsed := ParseMountPath(mountPath) host := parsed.HostPath hashInput := host if !strings.HasPrefix(host, "/") { hashInput = "vol:" + host } sum := sha1.Sum([]byte(hashInput)) name := LegacyEntryPrefix + hex.EncodeToString(sum[:])[:10] hostPath := host if !strings.HasPrefix(host, "/") { // Named docker volumes pass through unchanged on docker-local; // the docker engine resolves them. Store as-is so the args // shim can emit the original token. hostPath = host } return &Entry{ Name: name, Scheduler: SchedulerDockerLocal, HostPath: hostPath, SchemaVersion: SchemaVersion, } }