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.
This commit is contained in:
Jose Diaz-Gonzalez
2026-04-29 14:36:47 -04:00
parent 4257c17eab
commit 4e87d1f447
5 changed files with 732 additions and 0 deletions

View File

@@ -21,4 +21,5 @@ use (
./plugins/scheduler
./plugins/scheduler-docker-local
./plugins/scheduler-k3s
./plugins/storage
)

View File

@@ -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
}

View File

@@ -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())
}

281
plugins/storage/entry.go Normal file
View File

@@ -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-<name>" 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/<name>.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,
}
}

View File

@@ -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())
}