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:
1
go.work
1
go.work
@@ -21,4 +21,5 @@ use (
|
||||
./plugins/scheduler
|
||||
./plugins/scheduler-docker-local
|
||||
./plugins/scheduler-k3s
|
||||
./plugins/storage
|
||||
)
|
||||
|
||||
221
plugins/storage/attachment.go
Normal file
221
plugins/storage/attachment.go
Normal 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
|
||||
}
|
||||
33
plugins/storage/attachment_test.go
Normal file
33
plugins/storage/attachment_test.go
Normal 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
281
plugins/storage/entry.go
Normal 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,
|
||||
}
|
||||
}
|
||||
196
plugins/storage/entry_test.go
Normal file
196
plugins/storage/entry_test.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user