From 4e87d1f44741aa431438707a43abf8c1aa1bffef Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 29 Apr 2026 14:36:47 -0400 Subject: [PATCH] feat: add storage entry and attachment types Introduces the Entry and Attachment types that the storage plugin will use as the source of truth for named storage volumes, replacing the colon-delimited mount strings stored under docker-options. Entries persist as JSON under config/storage/entries and validate against scheduler-specific rules; attachments persist via the property list and capture how an app uses an entry. The legacy migration name synthesizer is also added so existing colon-form mounts can converge on a deterministic legacy- entry name. Existing storage commands are unchanged in this commit. --- go.work | 1 + plugins/storage/attachment.go | 221 +++++++++++++++++++++++ plugins/storage/attachment_test.go | 33 ++++ plugins/storage/entry.go | 281 +++++++++++++++++++++++++++++ plugins/storage/entry_test.go | 196 ++++++++++++++++++++ 5 files changed, 732 insertions(+) create mode 100644 plugins/storage/attachment.go create mode 100644 plugins/storage/attachment_test.go create mode 100644 plugins/storage/entry.go create mode 100644 plugins/storage/entry_test.go diff --git a/go.work b/go.work index 814223fc1..bf66069d0 100644 --- a/go.work +++ b/go.work @@ -21,4 +21,5 @@ use ( ./plugins/scheduler ./plugins/scheduler-docker-local ./plugins/scheduler-k3s + ./plugins/storage ) diff --git a/plugins/storage/attachment.go b/plugins/storage/attachment.go new file mode 100644 index 000000000..f43070fab --- /dev/null +++ b/plugins/storage/attachment.go @@ -0,0 +1,221 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/dokku/dokku/plugins/common" +) + +// AttachmentsProperty is the property-list key used to store attachments +// for an app. All process types live under one list and carry their own +// ProcessType field; we filter on read. +const AttachmentsProperty = "mounts" + +// PluginName is the name we register with the property system. +const PluginName = "storage" + +// PhaseDeploy is the standard deploy phase identifier. +const PhaseDeploy = "deploy" + +// PhaseRun is the standard run phase identifier. +const PhaseRun = "run" + +// DefaultProcessType is the wildcard process type that applies to every +// process. Mirrors docker-options' DefaultProcessType. +const DefaultProcessType = "_default_" + +// Attachment is the source of truth for *how* an app uses a storage entry. +// One Attachment binds one entry into one container path on one app. +type Attachment struct { + EntryName string `json:"entry_name"` + ContainerPath string `json:"container_path"` + Phases []string `json:"phases"` + ProcessType string `json:"process_type,omitempty"` + Subpath string `json:"subpath,omitempty"` + Readonly bool `json:"readonly,omitempty"` + VolumeOptions string `json:"volume_options,omitempty"` + VolumeChown string `json:"volume_chown,omitempty"` +} + +// Validate checks an Attachment's fields against structural rules. +func (a *Attachment) Validate() error { + if a == nil { + return errors.New("attachment is nil") + } + if a.EntryName == "" { + return errors.New("attachment is missing entry name") + } + if a.ContainerPath == "" { + return errors.New("attachment is missing container path") + } + if !strings.HasPrefix(a.ContainerPath, "/") { + return fmt.Errorf("attachment container path %q must be absolute", a.ContainerPath) + } + if len(a.Phases) == 0 { + return errors.New("attachment must specify at least one phase") + } + for _, phase := range a.Phases { + if phase != PhaseDeploy && phase != PhaseRun { + return fmt.Errorf("attachment phase %q is not supported (must be %q or %q)", phase, PhaseDeploy, PhaseRun) + } + } + return nil +} + +// LoadAttachments returns every attachment registered against an app. +func LoadAttachments(appName string) ([]*Attachment, error) { + lines, err := common.PropertyListGet(PluginName, appName, AttachmentsProperty) + if err != nil { + return nil, fmt.Errorf("unable to read storage attachments for %q: %w", appName, err) + } + + attachments := []*Attachment{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + attachment := &Attachment{} + if err := json.Unmarshal([]byte(line), attachment); err != nil { + return nil, fmt.Errorf("unable to parse storage attachment for %q: %w", appName, err) + } + attachments = append(attachments, attachment) + } + return attachments, nil +} + +// SaveAttachments overwrites the entire attachment list for an app. +func SaveAttachments(appName string, attachments []*Attachment) error { + lines := make([]string, 0, len(attachments)) + for _, attachment := range attachments { + data, err := json.Marshal(attachment) + if err != nil { + return fmt.Errorf("unable to encode storage attachment for %q: %w", appName, err) + } + lines = append(lines, string(data)) + } + return common.PropertyListWrite(PluginName, appName, AttachmentsProperty, lines) +} + +// AddAttachment appends an attachment, rejecting duplicates of the same +// (entry_name, container_path, process_type) tuple. +func AddAttachment(appName string, attachment *Attachment) error { + if err := attachment.Validate(); err != nil { + return err + } + + attachments, err := LoadAttachments(appName) + if err != nil { + return err + } + + for _, existing := range attachments { + if existing.EntryName == attachment.EntryName && + existing.ContainerPath == attachment.ContainerPath && + existing.ProcessType == attachment.ProcessType { + return fmt.Errorf("storage entry %q is already mounted at %q for process type %q on app %q", + attachment.EntryName, attachment.ContainerPath, attachment.ProcessType, appName) + } + } + + attachments = append(attachments, attachment) + return SaveAttachments(appName, attachments) +} + +// RemoveAttachment removes the attachment matching the given entry and +// optional container path. If containerPath is empty and there is more +// than one match, returns an error. +func RemoveAttachment(appName string, entryName string, containerPath string) error { + attachments, err := LoadAttachments(appName) + if err != nil { + return err + } + + matches := []*Attachment{} + keep := []*Attachment{} + for _, attachment := range attachments { + if attachment.EntryName == entryName && (containerPath == "" || attachment.ContainerPath == containerPath) { + matches = append(matches, attachment) + continue + } + keep = append(keep, attachment) + } + + if len(matches) == 0 { + if containerPath == "" { + return fmt.Errorf("storage entry %q is not mounted on app %q", entryName, appName) + } + return fmt.Errorf("storage entry %q is not mounted at %q on app %q", entryName, containerPath, appName) + } + if len(matches) > 1 && containerPath == "" { + paths := []string{} + for _, attachment := range matches { + paths = append(paths, attachment.ContainerPath) + } + sort.Strings(paths) + return fmt.Errorf("storage entry %q is mounted at multiple paths on app %q (%s); pass --container-dir to disambiguate", + entryName, appName, strings.Join(paths, ", ")) + } + + return SaveAttachments(appName, keep) +} + +// AttachmentsForPhase returns the subset of an app's attachments that +// apply to the given phase, sorted for stable output. +func AttachmentsForPhase(appName string, phase string) ([]*Attachment, error) { + attachments, err := LoadAttachments(appName) + if err != nil { + return nil, err + } + + filtered := []*Attachment{} + for _, attachment := range attachments { + for _, p := range attachment.Phases { + if p == phase { + filtered = append(filtered, attachment) + break + } + } + } + + sort.Slice(filtered, func(i, j int) bool { + if filtered[i].EntryName != filtered[j].EntryName { + return filtered[i].EntryName < filtered[j].EntryName + } + return filtered[i].ContainerPath < filtered[j].ContainerPath + }) + return filtered, nil +} + +// AppsUsingEntry returns the list of app names that have at least one +// attachment referencing the given entry name. Used by storage:destroy +// to refuse removing an entry that's still mounted. +func AppsUsingEntry(entryName string) ([]string, error) { + apps, err := common.DokkuApps() + if err != nil { + if errors.Is(err, common.NoAppsExist) { + return nil, nil + } + return nil, err + } + + using := []string{} + for _, app := range apps { + attachments, err := LoadAttachments(app) + if err != nil { + return nil, err + } + for _, attachment := range attachments { + if attachment.EntryName == entryName { + using = append(using, app) + break + } + } + } + sort.Strings(using) + return using, nil +} diff --git a/plugins/storage/attachment_test.go b/plugins/storage/attachment_test.go new file mode 100644 index 000000000..d6be3e31b --- /dev/null +++ b/plugins/storage/attachment_test.go @@ -0,0 +1,33 @@ +package storage + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestAttachmentValidate(t *testing.T) { + RegisterTestingT(t) + + good := &Attachment{ + EntryName: "demo-data", + ContainerPath: "/data", + Phases: []string{PhaseDeploy, PhaseRun}, + } + Expect(good.Validate()).To(Succeed()) + + noEntry := &Attachment{ContainerPath: "/data", Phases: []string{PhaseDeploy}} + Expect(noEntry.Validate()).To(HaveOccurred()) + + noPath := &Attachment{EntryName: "demo-data", Phases: []string{PhaseDeploy}} + Expect(noPath.Validate()).To(HaveOccurred()) + + relative := &Attachment{EntryName: "demo-data", ContainerPath: "data", Phases: []string{PhaseDeploy}} + Expect(relative.Validate()).To(HaveOccurred()) + + noPhases := &Attachment{EntryName: "demo-data", ContainerPath: "/data"} + Expect(noPhases.Validate()).To(HaveOccurred()) + + badPhase := &Attachment{EntryName: "demo-data", ContainerPath: "/data", Phases: []string{"build"}} + Expect(badPhase.Validate()).To(HaveOccurred()) +} diff --git a/plugins/storage/entry.go b/plugins/storage/entry.go new file mode 100644 index 000000000..68ef5d79f --- /dev/null +++ b/plugins/storage/entry.go @@ -0,0 +1,281 @@ +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])?$`) + + // 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"` + 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"` +} + +// EntriesDirectory returns the directory holding per-entry JSON files. +func EntriesDirectory() string { + root := common.GetenvWithDefault("DOKKU_LIB_ROOT", "/var/lib/dokku") + return filepath.Join(root, "config", "storage", "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) + } + + path := entryPath(entry.Name) + if err := os.WriteFile(path, data, 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 +} + +// 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) + } + + 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) { + return fmt.Errorf("storage entry %q host_path must be absolute, 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) + } + 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.ReclaimPolicy != "" && e.ReclaimPolicy != ReclaimPolicyRetain && e.ReclaimPolicy != ReclaimPolicyDelete { + return fmt.Errorf("storage entry %q has unsupported reclaim policy %q", e.Name, e.ReclaimPolicy) + } + } + + 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, + } +} diff --git a/plugins/storage/entry_test.go b/plugins/storage/entry_test.go new file mode 100644 index 000000000..683032008 --- /dev/null +++ b/plugins/storage/entry_test.go @@ -0,0 +1,196 @@ +package storage + +import ( + "path/filepath" + "strings" + "testing" + + . "github.com/onsi/gomega" +) + +func withTempLibRoot(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("DOKKU_LIB_ROOT", dir) + return dir +} + +func TestValidateEntryName(t *testing.T) { + RegisterTestingT(t) + + Expect(ValidateEntryName("foo", false)).To(Succeed()) + Expect(ValidateEntryName("foo-bar", false)).To(Succeed()) + Expect(ValidateEntryName("a", false)).To(Succeed()) + Expect(ValidateEntryName("foo123", false)).To(Succeed()) + + err := ValidateEntryName("", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("required")) + + err = ValidateEntryName("Foo", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("DNS-1123")) + + err = ValidateEntryName("foo_bar", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("DNS-1123")) + + err = ValidateEntryName("-foo", false) + Expect(err).To(HaveOccurred()) + + err = ValidateEntryName("foo-", false) + Expect(err).To(HaveOccurred()) + + long := strings.Repeat("a", MaxEntryNameLength+1) + err = ValidateEntryName(long, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("too long")) + + maxOK := strings.Repeat("a", MaxEntryNameLength) + Expect(ValidateEntryName(maxOK, false)).To(Succeed()) + + err = ValidateEntryName("legacy-foo", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("reserved")) + + Expect(ValidateEntryName("legacy-foo", true)).To(Succeed()) +} + +func TestEntryValidateDockerLocal(t *testing.T) { + RegisterTestingT(t) + + good := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/var/lib/dokku/data/storage/foo"} + Expect(good.Validate()).To(Succeed()) + + noPath := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal} + Expect(noPath.Validate()).To(HaveOccurred()) + + relativePath := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "relative/path"} + Expect(relativePath.Validate()).To(HaveOccurred()) + + withSize := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/data", Size: "2Gi"} + Expect(withSize.Validate()).To(HaveOccurred()) + + withClass := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/data", StorageClass: "longhorn"} + Expect(withClass.Validate()).To(HaveOccurred()) +} + +func TestEntryValidateK3s(t *testing.T) { + RegisterTestingT(t) + + dynamic := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", AccessMode: "ReadWriteOnce"} + Expect(dynamic.Validate()).To(Succeed()) + + hostPath := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", HostPath: "/data/foo"} + Expect(hostPath.Validate()).To(Succeed()) + + noSize := &Entry{Name: "foo", Scheduler: SchedulerK3s, StorageClass: "longhorn"} + Expect(noSize.Validate()).To(HaveOccurred()) + + bothPathAndClass := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", HostPath: "/data", StorageClass: "longhorn"} + err := bothPathAndClass.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("storage-class-name")) + + badAccessMode := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", AccessMode: "Bogus"} + Expect(badAccessMode.Validate()).To(HaveOccurred()) + + badReclaim := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", ReclaimPolicy: "Recycle"} + Expect(badReclaim.Validate()).To(HaveOccurred()) +} + +func TestEntryValidateScheduler(t *testing.T) { + RegisterTestingT(t) + + bad := &Entry{Name: "foo", Scheduler: "nomad"} + err := bad.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("scheduler")) +} + +func TestEntryRoundTrip(t *testing.T) { + RegisterTestingT(t) + root := withTempLibRoot(t) + + original := &Entry{ + Name: "demo-data", + Scheduler: SchedulerK3s, + Size: "2Gi", + StorageClass: "longhorn", + AccessMode: "ReadWriteOnce", + Namespace: "dokku", + Annotations: map[string]string{"backup.velero.io/backup-volumes": "demo-data"}, + Labels: map[string]string{"app.kubernetes.io/managed-by": "dokku"}, + } + + Expect(SaveEntry(original)).To(Succeed()) + Expect(EntryExists("demo-data")).To(BeTrue()) + + loaded, err := LoadEntry("demo-data") + Expect(err).NotTo(HaveOccurred()) + Expect(loaded.Name).To(Equal(original.Name)) + Expect(loaded.Scheduler).To(Equal(original.Scheduler)) + Expect(loaded.Size).To(Equal(original.Size)) + Expect(loaded.StorageClass).To(Equal(original.StorageClass)) + Expect(loaded.AccessMode).To(Equal(original.AccessMode)) + Expect(loaded.Namespace).To(Equal(original.Namespace)) + Expect(loaded.Annotations).To(Equal(original.Annotations)) + Expect(loaded.Labels).To(Equal(original.Labels)) + Expect(loaded.SchemaVersion).To(Equal(SchemaVersion)) + + expectedPath := filepath.Join(root, "config", "storage", "entries", "demo-data.json") + Expect(expectedPath).To(BeARegularFile()) + + Expect(DeleteEntry("demo-data")).To(Succeed()) + Expect(EntryExists("demo-data")).To(BeFalse()) +} + +func TestListEntries(t *testing.T) { + RegisterTestingT(t) + withTempLibRoot(t) + + entries, err := ListEntries() + Expect(err).NotTo(HaveOccurred()) + Expect(entries).To(BeEmpty()) + + Expect(SaveEntry(&Entry{Name: "b", Scheduler: SchedulerDockerLocal, HostPath: "/b"})).To(Succeed()) + Expect(SaveEntry(&Entry{Name: "a", Scheduler: SchedulerDockerLocal, HostPath: "/a"})).To(Succeed()) + + entries, err = ListEntries() + Expect(err).NotTo(HaveOccurred()) + Expect(entries).To(HaveLen(2)) + Expect(entries[0].Name).To(Equal("a")) + Expect(entries[1].Name).To(Equal("b")) +} + +func TestLegacyMountToEntry(t *testing.T) { + RegisterTestingT(t) + + a := LegacyMountToEntry("/host/path:/container/path") + b := LegacyMountToEntry("/host/path:/container/different") + c := LegacyMountToEntry("/host/path:/container/path:ro") + + // Same host path produces the same entry name regardless of container + // path or options - container path lives on the attachment, not the + // entry, so apps mounting the same source converge. + Expect(a.Name).To(Equal(b.Name)) + Expect(a.Name).To(Equal(c.Name)) + Expect(a.Name).To(HavePrefix(LegacyEntryPrefix)) + Expect(len(a.Name)).To(Equal(len(LegacyEntryPrefix) + 10)) + + // Different host paths produce different names. + other := LegacyMountToEntry("/different/path:/container/path") + Expect(other.Name).NotTo(Equal(a.Name)) + + // Named docker volumes are distinguished from absolute paths. + named := LegacyMountToEntry("my_volume:/container/path") + Expect(named.Name).NotTo(Equal(a.Name)) + Expect(named.HostPath).To(Equal("my_volume")) + + // The synthesized entry is a valid docker-local entry that passes + // Validate (other than the legacy- prefix, which Validate accepts). + abs := LegacyMountToEntry("/host/path:/container/path") + Expect(abs.Scheduler).To(Equal(SchedulerDockerLocal)) + Expect(abs.HostPath).To(Equal("/host/path")) + Expect(abs.Validate()).To(Succeed()) +}