Merge pull request #8920 from dokku/8913-storage-host-directories-have-no-mode-flag-and-no-removal-path

Add storage directory mode and removal flags
This commit is contained in:
Jose Diaz-Gonzalez
2026-08-10 17:22:31 -04:00
committed by GitHub
19 changed files with 2199 additions and 316 deletions

View File

@@ -1,5 +1,5 @@
GOARCH ?= amd64
SUBCOMMANDS = subcommands/default subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/exec subcommands/info subcommands/list subcommands/list-entries subcommands/migrate subcommands/mount subcommands/report subcommands/set subcommands/unmount subcommands/wait
SUBCOMMANDS = subcommands/default subcommands/annotations:set subcommands/annotations:report subcommands/create subcommands/destroy subcommands/ensure-directory subcommands/exec subcommands/info subcommands/labels:set subcommands/labels:report subcommands/list subcommands/list-entries subcommands/migrate subcommands/mount subcommands/report subcommands/set subcommands/unmount subcommands/wait
TRIGGERS = triggers/install triggers/storage-list triggers/storage-app-mounts triggers/docker-args-deploy triggers/docker-args-run triggers/post-delete triggers/post-app-clone-setup triggers/post-app-rename-setup
BUILD = commands subcommands triggers
PLUGIN_NAME = storage

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
main() {
declare desc="chmods a storage directory"
declare DIRECTORY="$1" MODE="$2"
if [[ -z "$DIRECTORY" ]]; then
echo " ! Please specify a directory to chmod" 1>&2
exit 1
fi
if [[ ! "$DIRECTORY" =~ ^[A-Za-z0-9\\_-]+$ ]]; then
echo " ! Directory can only contain the following set of characters: [A-Za-z0-9_-]" 1>&2
exit 1
fi
if [[ ! "$MODE" =~ ^[0-7]{3,4}$ ]]; then
echo " ! Unsupported directory mode. Value must be a 3 or 4 digit octal mode, such as 0755" 1>&2
exit 1
fi
chmod "$MODE" "${DOKKU_LIB_ROOT}/data/storage/$DIRECTORY"
}
main "$@"

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
main() {
declare desc="removes a storage directory and its contents"
declare DIRECTORY="$1"
if [[ -z "$DIRECTORY" ]]; then
echo " ! Please specify a directory to destroy" 1>&2
exit 1
fi
if [[ ! "$DIRECTORY" =~ ^[A-Za-z0-9\\_-]+$ ]]; then
echo " ! Directory can only contain the following set of characters: [A-Za-z0-9_-]" 1>&2
exit 1
fi
local storage_path="${DOKKU_LIB_ROOT}/data/storage/$DIRECTORY"
if [[ ! -e "$storage_path" ]]; then
exit 0
fi
if [[ ! -d "$storage_path" ]]; then
echo " ! $storage_path exists but is not a directory" 1>&2
exit 1
fi
rm -rf "$storage_path"
}
main "$@"

View File

@@ -13,17 +13,18 @@ import (
// CommandCreateInput captures the flags accepted by storage:create.
type CommandCreateInput struct {
Name string
Path string
Scheduler string
Size string
AccessMode string
StorageClass string
Namespace string
Chown string
ReclaimPolicy string
Annotations map[string]string
Labels map[string]string
Name string
Path string
Scheduler string
Size string
AccessMode string
StorageClass string
Namespace string
Chown string
Mode string
ReclaimPolicy string
Annotations map[string]string
Labels map[string]string
}
// CommandCreate registers a new storage entry.
@@ -42,6 +43,11 @@ func CommandCreate(input CommandCreateInput) error {
hostPath = filepath.Join(GetStorageDirectory(), input.Name)
}
mode, err := NormalizeDirectoryMode(input.Mode)
if err != nil {
return err
}
entry := &Entry{
Name: input.Name,
Scheduler: scheduler,
@@ -51,6 +57,7 @@ func CommandCreate(input CommandCreateInput) error {
StorageClass: input.StorageClass,
Namespace: input.Namespace,
Chown: input.Chown,
Mode: mode,
ReclaimPolicy: input.ReclaimPolicy,
Annotations: input.Annotations,
Labels: input.Labels,
@@ -98,7 +105,12 @@ func CommandCreate(input CommandCreateInput) error {
// an entry that any app still has attached. Prompts for confirmation
// unless force is set (or the global --force flag exported
// DOKKU_APPS_FORCE_DELETE).
func CommandDestroy(name string, force bool) error {
//
// On docker-local, the host directory is removed when destroyHostDir is
// set or when the entry's reclaim policy is Delete - the same distinction
// a k3s PV reclaim policy draws between keeping and dropping the backing
// data. Removal is recursive.
func CommandDestroy(name string, force bool, destroyHostDir bool) error {
if name == "" {
return errors.New("storage entry name is required")
}
@@ -114,26 +126,56 @@ func CommandDestroy(name string, force bool) error {
return fmt.Errorf("storage entry %q is still mounted by app(s): %s", name, strings.Join(using, ", "))
}
entry, err := LoadEntry(name)
if err != nil {
return err
}
if destroyHostDir && entry.Scheduler != SchedulerDockerLocal {
return fmt.Errorf("--destroy-host-dir only applies to docker-local storage entries; %q is scheduler %q and follows --reclaim-policy", name, entry.Scheduler)
}
removeHostDir := false
if entry.Scheduler == SchedulerDockerLocal && (destroyHostDir || entry.ReclaimPolicy == ReclaimPolicyDelete) {
err := requireDefaultHostPath(entry, "--destroy-host-dir")
if err == nil {
removeHostDir = true
} else if destroyHostDir {
return err
} else {
// An entry written before the reclaim policy was honored on
// docker-local can carry Delete on a custom path. Refusing
// here would wedge storage:destroy for that entry, so warn
// and leave the path alone.
common.LogWarn(fmt.Sprintf("Leaving %s in place; reclaim policy Delete only removes the default host path", entry.HostPath))
}
}
if os.Getenv("DOKKU_APPS_FORCE_DELETE") == "1" {
force = true
}
if !force {
if removeHostDir {
common.LogWarn(fmt.Sprintf("Storage entry %s is backed by %s, which will be removed along with its contents.", name, entry.HostPath))
}
if err := common.AskForDestructiveConfirmation(name, "storage entry"); err != nil {
return err
}
}
entry, err := LoadEntry(name)
if err != nil {
return err
}
if entry.Scheduler == SchedulerK3s {
if err := callSchedulerDestroyTrigger(entry); err != nil {
return fmt.Errorf("scheduler refused to remove storage entry %q: %w", name, err)
}
}
if removeHostDir {
if err := callStorageDirScript("destroy-storage-dir", entry.Name); err != nil {
return fmt.Errorf("unable to remove %s: %w", entry.HostPath, err)
}
common.LogVerbose(fmt.Sprintf("Removed %s", entry.HostPath))
}
if err := DeleteEntry(name); err != nil {
return err
}
@@ -184,28 +226,49 @@ func CommandInfo(name string, format string) error {
if entry.Chown != "" {
common.LogVerbose(fmt.Sprintf("Chown: %s", entry.Chown))
}
if entry.Mode != "" {
common.LogVerbose(fmt.Sprintf("Mode: %s", entry.Mode))
}
if entry.ReclaimPolicy != "" {
common.LogVerbose(fmt.Sprintf("Reclaim policy: %s", entry.ReclaimPolicy))
}
return nil
}
// CommandSetInput captures the flags accepted by storage:set.
// PropertyChange is a single assignment against a storage entry. An empty
// Value unsets the property, restoring whatever the entry defaults to.
type PropertyChange struct {
Property string
Value string
}
// SettableProperties lists the entry fields storage:set understands, in the
// sorted order the "invalid property" error reports them.
var SettableProperties = []string{
"access-mode",
"chown",
"mode",
"namespace",
"reclaim-policy",
"size",
"storage-class-name",
}
// CommandSetInput captures the inputs accepted by storage:set.
type CommandSetInput struct {
Name string
Size string
AccessMode string
StorageClass string
Namespace string
Chown string
ReclaimPolicy string
Annotations map[string]string
Labels map[string]string
Name string
Changes []PropertyChange
// Annotations and Labels back the deprecated --annotation / --label
// flags only, and replace the whole map. The property form has no
// equivalent; storage:annotations:set supersedes them.
Annotations map[string]string
Labels map[string]string
}
// CommandSet edits an existing entry's mutable fields and re-fires the
// scheduler-side helm release. Refuses changes Kubernetes can't apply
// in place (access-mode swap, storage-class swap, size shrink).
// in place (access-mode swap, storage-class swap).
func CommandSet(input CommandSetInput) error {
if !EntryExists(input.Name) {
return fmt.Errorf("storage entry %q does not exist", input.Name)
@@ -215,24 +278,16 @@ func CommandSet(input CommandSetInput) error {
return err
}
if input.AccessMode != "" && input.AccessMode != entry.AccessMode {
return fmt.Errorf("storage:set cannot change access-mode in place; recreate the entry")
}
if input.StorageClass != "" && input.StorageClass != entry.StorageClass {
return fmt.Errorf("storage:set cannot change storage-class-name in place; recreate the entry")
}
if input.Size != "" {
entry.Size = input.Size
}
if input.Namespace != "" {
entry.Namespace = input.Namespace
}
if input.Chown != "" {
entry.Chown = input.Chown
}
if input.ReclaimPolicy != "" {
entry.ReclaimPolicy = input.ReclaimPolicy
touchesDirectory := false
for _, change := range input.Changes {
if err := applyPropertyChange(entry, change); err != nil {
return err
}
if change.Property == "chown" || change.Property == "mode" {
touchesDirectory = true
}
}
if input.Annotations != nil {
entry.Annotations = input.Annotations
}
@@ -246,6 +301,14 @@ func CommandSet(input CommandSetInput) error {
if err := SaveEntry(entry); err != nil {
return err
}
// Only converge the directory when the caller actually asked to change
// its permissions; an unrelated storage:set should not create or touch
// anything on disk.
if entry.Scheduler == SchedulerDockerLocal && touchesDirectory {
if err := ensureDockerLocalPath(entry); err != nil {
return err
}
}
if entry.Scheduler == SchedulerK3s {
if err := callSchedulerCreateTrigger(entry); err != nil {
return fmt.Errorf("scheduler refused storage:set for %q: %w", entry.Name, err)
@@ -255,6 +318,43 @@ func CommandSet(input CommandSetInput) error {
return nil
}
// applyPropertyChange writes a single property onto an entry. An empty
// value clears the field rather than being ignored, which is what lets
// storage:set undo itself the way every other :set command can.
func applyPropertyChange(entry *Entry, change PropertyChange) error {
switch change.Property {
case "access-mode":
// Kubernetes cannot swap these on a bound PVC, so any change -
// including clearing one that is set - has to be refused.
if change.Value != entry.AccessMode {
return errors.New("storage:set cannot change access-mode in place; recreate the entry")
}
case "storage-class-name":
if change.Value != entry.StorageClass {
return errors.New("storage:set cannot change storage-class-name in place; recreate the entry")
}
case "size":
entry.Size = change.Value
case "namespace":
entry.Namespace = change.Value
case "chown":
entry.Chown = change.Value
case "mode":
mode, err := NormalizeDirectoryMode(change.Value)
if err != nil {
return err
}
entry.Mode = mode
case "reclaim-policy":
entry.ReclaimPolicy = change.Value
case "":
return errors.New("No property specified")
default:
return fmt.Errorf("Invalid property specified, valid properties include: %s", strings.Join(SettableProperties, ", "))
}
return nil
}
// CommandExecInput captures the storage:exec subcommand inputs.
type CommandExecInput struct {
Name string
@@ -421,8 +521,8 @@ func CommandReportGlobal(format string) error {
return err
}
type entryWithUse struct {
Entry *Entry `json:"entry"`
MountedBy []string `json:"mounted_by"`
Entry *Entry `json:"entry"`
MountedBy []string `json:"mounted_by"`
}
rows := []entryWithUse{}
for _, entry := range entries {
@@ -492,15 +592,29 @@ func CommandListEntries(scheduler string, format string) error {
}
// ensureDockerLocalPath creates the host directory referenced by a
// docker-local entry if it doesn't already exist. Idempotent: a
// pre-existing directory is left in place.
// docker-local entry if it doesn't already exist, then applies the entry's
// --mode and --chown. Idempotent: a pre-existing directory keeps its
// contents, and mode/ownership are re-applied on every run so a
// declarative caller converges by re-running storage:create.
func ensureDockerLocalPath(entry *Entry) error {
if entry.Chown != "" && entry.Chown != "false" {
defaultHostPath := filepath.Join(GetStorageDirectory(), entry.Name)
if entry.HostPath != defaultHostPath {
return fmt.Errorf("--chown is only supported when the storage entry uses the default host path (%s); use --chown false and chown %s manually", defaultHostPath, entry.HostPath)
wantsChown := entry.Chown != "" && entry.Chown != "false"
if wantsChown {
if err := requireDefaultHostPath(entry, "--chown"); err != nil {
return err
}
}
if entry.Mode != "" {
if err := requireDefaultHostPath(entry, "--mode"); err != nil {
return err
}
}
// A docker named volume is resolved by the docker engine, not by us.
// Without this guard the stat and mkdir below run against a relative
// token and create a stray directory in the process working directory.
if !filepath.IsAbs(entry.HostPath) {
return nil
}
info, err := os.Stat(entry.HostPath)
if err != nil && !os.IsNotExist(err) {
@@ -516,29 +630,39 @@ func ensureDockerLocalPath(entry *Entry) error {
common.LogVerbose(fmt.Sprintf("Created %s", entry.HostPath))
}
if entry.Chown != "" && entry.Chown != "false" {
if entry.Mode != "" {
common.LogVerbose(fmt.Sprintf("Setting directory mode to %s", entry.Mode))
if err := callStorageDirScript("chmod-storage-dir", entry.Name, entry.Mode); err != nil {
return fmt.Errorf("unable to chmod %s: %w", entry.HostPath, err)
}
}
if wantsChown {
chownID, err := ResolveChownID(entry.Chown)
if err != nil {
return err
}
if chownID != "false" {
pluginPath := common.MustGetEnv("PLUGIN_AVAILABLE_PATH")
chownScript := filepath.Join(pluginPath, "storage", "bin", "chown-storage-dir")
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: "sudo",
Args: []string{chownScript, entry.Name, chownID},
})
if err != nil {
common.LogVerbose(fmt.Sprintf("Setting directory ownership to %s:%s", chownID, chownID))
if err := callStorageDirScript("chown-storage-dir", entry.Name, chownID); err != nil {
return fmt.Errorf("unable to chown %s: %w", entry.HostPath, err)
}
if result.ExitCode != 0 {
return fmt.Errorf("unable to chown %s: %s", entry.HostPath, result.StderrContents())
}
}
}
return nil
}
// requireDefaultHostPath refuses flags that are implemented by the sudo
// helpers in bin/, which only ever operate on the default host path. An
// operator pointing an entry at their own path owns that path themselves.
func requireDefaultHostPath(entry *Entry, flagName string) error {
defaultHostPath := filepath.Join(GetStorageDirectory(), entry.Name)
if entry.HostPath == defaultHostPath {
return nil
}
return fmt.Errorf("%s is only supported when the storage entry uses the default host path (%s); omit %s and manage %s manually", flagName, defaultHostPath, flagName, entry.HostPath)
}
// callSchedulerCreateTrigger asks the scheduler plugin (k3s) to provision
// the underlying PVC/PV. The scheduler is responsible for any
// cluster-level validation (storage class existence, etc.).

View File

@@ -0,0 +1,220 @@
package storage
import (
"path/filepath"
"testing"
. "github.com/onsi/gomega"
)
func TestEnsureDockerLocalPathCreatesDefaultPath(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
hostPath := filepath.Join(GetStorageDirectory(), "demo")
entry := &Entry{Name: "demo", Scheduler: SchedulerDockerLocal, HostPath: hostPath}
Expect(ensureDockerLocalPath(entry)).To(Succeed())
Expect(hostPath).To(BeADirectory())
// Idempotent: a second run leaves the existing directory in place.
Expect(ensureDockerLocalPath(entry)).To(Succeed())
Expect(hostPath).To(BeADirectory())
}
// TestEnsureDockerLocalPathSkipsNamedVolumes guards the docker named-volume
// case: the host path is a token the docker engine resolves, not a path on
// disk, so stat'ing and creating it would produce a stray directory in
// whatever working directory the command happened to run from.
func TestEnsureDockerLocalPathSkipsNamedVolumes(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
cwd := t.TempDir()
t.Chdir(cwd)
entry := &Entry{Name: "demo", Scheduler: SchedulerDockerLocal, HostPath: "myvolume"}
Expect(ensureDockerLocalPath(entry)).To(Succeed())
Expect(filepath.Join(cwd, "myvolume")).NotTo(BeADirectory())
}
func TestEnsureDockerLocalPathRefusesModeOnCustomPath(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
entry := &Entry{
Name: "demo",
Scheduler: SchedulerDockerLocal,
HostPath: filepath.Join(t.TempDir(), "custom"),
Mode: "0777",
}
err := ensureDockerLocalPath(entry)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("--mode is only supported when the storage entry uses the default host path"))
Expect(entry.HostPath).NotTo(BeADirectory())
}
func TestEnsureDockerLocalPathRefusesChownOnCustomPath(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
entry := &Entry{
Name: "demo",
Scheduler: SchedulerDockerLocal,
HostPath: filepath.Join(t.TempDir(), "custom"),
Chown: "herokuish",
}
err := ensureDockerLocalPath(entry)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("--chown is only supported when the storage entry uses the default host path"))
}
// TestEnsureDockerLocalPathAllowsChownFalseOnCustomPath documents that the
// escape hatch the refusal message points at actually works.
func TestEnsureDockerLocalPathAllowsChownFalseOnCustomPath(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
hostPath := filepath.Join(t.TempDir(), "custom")
entry := &Entry{Name: "demo", Scheduler: SchedulerDockerLocal, HostPath: hostPath, Chown: "false"}
Expect(ensureDockerLocalPath(entry)).To(Succeed())
Expect(hostPath).To(BeADirectory())
}
func TestApplyPropertyChangeScalars(t *testing.T) {
RegisterTestingT(t)
entry := &Entry{Name: "demo", Scheduler: SchedulerDockerLocal}
Expect(applyPropertyChange(entry, PropertyChange{Property: "chown", Value: "herokuish"})).To(Succeed())
Expect(entry.Chown).To(Equal("herokuish"))
Expect(applyPropertyChange(entry, PropertyChange{Property: "namespace", Value: "dokku"})).To(Succeed())
Expect(entry.Namespace).To(Equal("dokku"))
Expect(applyPropertyChange(entry, PropertyChange{Property: "reclaim-policy", Value: ReclaimPolicyDelete})).To(Succeed())
Expect(entry.ReclaimPolicy).To(Equal(ReclaimPolicyDelete))
Expect(applyPropertyChange(entry, PropertyChange{Property: "size", Value: "2Gi"})).To(Succeed())
Expect(entry.Size).To(Equal("2Gi"))
// mode is canonicalized on the way in
Expect(applyPropertyChange(entry, PropertyChange{Property: "mode", Value: "770"})).To(Succeed())
Expect(entry.Mode).To(Equal("0770"))
}
// TestApplyPropertyChangeUnsets is the hole this shape exists to close: an
// empty value clears the field rather than being indistinguishable from an
// omitted flag.
func TestApplyPropertyChangeUnsets(t *testing.T) {
RegisterTestingT(t)
entry := &Entry{
Name: "demo",
Scheduler: SchedulerDockerLocal,
Chown: "herokuish",
Mode: "0770",
Namespace: "dokku",
ReclaimPolicy: ReclaimPolicyDelete,
Size: "2Gi",
}
for _, property := range []string{"chown", "mode", "namespace", "reclaim-policy", "size"} {
Expect(applyPropertyChange(entry, PropertyChange{Property: property})).To(Succeed(), "unsetting %q", property)
}
Expect(entry.Chown).To(BeEmpty())
Expect(entry.Mode).To(BeEmpty())
Expect(entry.Namespace).To(BeEmpty())
Expect(entry.ReclaimPolicy).To(BeEmpty())
Expect(entry.Size).To(BeEmpty())
}
func TestApplyPropertyChangeRejectsInvalidInput(t *testing.T) {
RegisterTestingT(t)
entry := &Entry{Name: "demo", Scheduler: SchedulerDockerLocal}
err := applyPropertyChange(entry, PropertyChange{Property: "bogus", Value: "x"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Invalid property specified, valid properties include: access-mode, chown, mode, namespace, reclaim-policy, size, storage-class-name"))
err = applyPropertyChange(entry, PropertyChange{Property: "", Value: "x"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("No property specified"))
err = applyPropertyChange(entry, PropertyChange{Property: "mode", Value: "0888"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Unsupported directory mode"))
}
// TestApplyPropertyChangeRefusesInPlaceSwaps covers both a differing value
// and an empty one, since clearing a bound PVC's access-mode or storage
// class is equally a change Kubernetes cannot apply.
func TestApplyPropertyChangeRefusesInPlaceSwaps(t *testing.T) {
RegisterTestingT(t)
entry := &Entry{
Name: "demo",
Scheduler: SchedulerK3s,
Size: "2Gi",
AccessMode: "ReadWriteOnce",
StorageClass: "longhorn",
}
err := applyPropertyChange(entry, PropertyChange{Property: "access-mode", Value: "ReadWriteMany"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot change access-mode in place"))
err = applyPropertyChange(entry, PropertyChange{Property: "access-mode"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot change access-mode in place"))
err = applyPropertyChange(entry, PropertyChange{Property: "storage-class-name", Value: "other"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot change storage-class-name in place"))
err = applyPropertyChange(entry, PropertyChange{Property: "storage-class-name"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot change storage-class-name in place"))
// Re-stating the current value is a no-op, not a change.
Expect(applyPropertyChange(entry, PropertyChange{Property: "access-mode", Value: "ReadWriteOnce"})).To(Succeed())
Expect(applyPropertyChange(entry, PropertyChange{Property: "storage-class-name", Value: "longhorn"})).To(Succeed())
}
func TestCommandSetPersistsAndValidates(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
stageDockerLocalEntry(t, "demo")
Expect(CommandSet(CommandSetInput{
Name: "demo",
Changes: []PropertyChange{{Property: "namespace", Value: "dokku"}},
})).To(Succeed())
loaded, err := LoadEntry("demo")
Expect(err).NotTo(HaveOccurred())
Expect(loaded.Namespace).To(Equal("dokku"))
// An invalid change leaves the stored entry untouched.
err = CommandSet(CommandSetInput{
Name: "demo",
Changes: []PropertyChange{{Property: "bogus", Value: "x"}},
})
Expect(err).To(HaveOccurred())
loaded, err = LoadEntry("demo")
Expect(err).NotTo(HaveOccurred())
Expect(loaded.Namespace).To(Equal("dokku"))
err = CommandSet(CommandSetInput{
Name: "missing",
Changes: []PropertyChange{{Property: "namespace", Value: "dokku"}},
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("does not exist"))
}

View File

@@ -0,0 +1,212 @@
package storage
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/dokku/dokku/plugins/common"
)
// entryMapField selects which of an Entry's string maps a metadata command
// operates on, so the annotations and labels commands can share one
// implementation the way scheduler-k3s does.
type entryMapField struct {
Name string
Singular string
ReportType string
RowLabel string
Get func(*Entry) map[string]string
Set func(*Entry, map[string]string)
}
var annotationsField = entryMapField{
Name: "annotations",
Singular: "annotation",
ReportType: "storage-annotations",
RowLabel: "Annotation",
Get: func(e *Entry) map[string]string { return e.Annotations },
Set: func(e *Entry, m map[string]string) { e.Annotations = m },
}
var labelsField = entryMapField{
Name: "labels",
Singular: "label",
ReportType: "storage-labels",
RowLabel: "Label",
Get: func(e *Entry) map[string]string { return e.Labels },
Set: func(e *Entry, m map[string]string) { e.Labels = m },
}
// CommandAnnotationsSet sets or clears a single annotation on an entry.
func CommandAnnotationsSet(name string, key string, value string) error {
return setEntryMapKey(annotationsField, name, key, value)
}
// CommandLabelsSet sets or clears a single label on an entry.
func CommandLabelsSet(name string, key string, value string) error {
return setEntryMapKey(labelsField, name, key, value)
}
// CommandAnnotationsReport displays the annotations on one entry, or on
// every entry when no name is given.
func CommandAnnotationsReport(name string, format string, infoFlag string) error {
return reportEntryMap(annotationsField, name, format, infoFlag)
}
// CommandLabelsReport displays the labels on one entry, or on every entry
// when no name is given.
func CommandLabelsReport(name string, format string, infoFlag string) error {
return reportEntryMap(labelsField, name, format, infoFlag)
}
// setEntryMapKey writes a single key on one of an entry's metadata maps.
// An empty value deletes just that key, leaving its siblings in place -
// the behavior the wholesale --annotation flag could never express.
func setEntryMapKey(field entryMapField, name string, key string, value string) error {
if name == "" {
return errors.New("storage entry name is required")
}
if key == "" {
return fmt.Errorf("No %s key specified", field.Singular)
}
if !EntryExists(name) {
return fmt.Errorf("storage entry %q does not exist", name)
}
entry, err := LoadEntry(name)
if err != nil {
return err
}
values := field.Get(entry)
if value == "" {
delete(values, key)
common.LogInfo2Quiet(fmt.Sprintf("Unsetting %s %s", field.Singular, key))
} else {
if values == nil {
values = map[string]string{}
}
values[key] = value
common.LogInfo2Quiet(fmt.Sprintf("Setting %s %s to %s", field.Singular, key, value))
}
if len(values) == 0 {
// Drop the empty map so the omitempty tag keeps it out of the JSON.
values = nil
}
field.Set(entry, values)
if err := entry.Validate(); err != nil {
return err
}
if err := SaveEntry(entry); err != nil {
return err
}
// k3s renders annotations and labels onto the PVC and PV through the
// entry's helm release, so the cluster only sees this once the chart
// is re-applied.
if entry.Scheduler == SchedulerK3s {
if err := callSchedulerCreateTrigger(entry); err != nil {
return fmt.Errorf("scheduler refused %s change for %q: %w", field.Name, name, err)
}
}
return nil
}
// reportEntryMap renders one entry's metadata map, or every entry's when
// no name is given.
func reportEntryMap(field entryMapField, name string, format string, infoFlag string) error {
if format != "stdout" && format != "text" && format != "json" {
return fmt.Errorf("Invalid format: %s", format)
}
if format == "json" && infoFlag != "" {
return errors.New("--format flag cannot be specified when specifying an info flag")
}
if name != "" {
if !EntryExists(name) {
return fmt.Errorf("storage entry %q does not exist", name)
}
entry, err := LoadEntry(name)
if err != nil {
return err
}
return renderEntryMap(field, entry, format, infoFlag)
}
if infoFlag != "" {
return fmt.Errorf("storage:%s:report requires a storage entry name when an info flag is specified", field.Name)
}
entries, err := ListEntries()
if err != nil {
return err
}
if len(entries) == 0 {
common.LogInfo1Quiet("No storage entries registered")
return nil
}
for _, entry := range entries {
if err := renderEntryMap(field, entry, format, ""); err != nil {
return err
}
}
return nil
}
// renderEntryMap prints one entry's metadata map. Keys are emitted
// verbatim rather than through common.ReportSingleApp, which rewrites
// dots and dashes into spaces and would mangle keys like
// app.kubernetes.io/part-of.
func renderEntryMap(field entryMapField, entry *Entry, format string, infoFlag string) error {
values := field.Get(entry)
keys := []string{}
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
if infoFlag != "" {
flagPrefix := "--" + field.ReportType + "."
validFlags := []string{}
for _, key := range keys {
flag := flagPrefix + key
if flag == infoFlag {
fmt.Println(values[key])
return nil
}
validFlags = append(validFlags, flag)
}
return fmt.Errorf("Invalid flag passed, valid flags: %s", strings.Join(validFlags, ", "))
}
if format == "json" {
flat := map[string]string{}
for _, key := range keys {
flat[key] = values[key]
}
data, err := json.Marshal(flat)
if err != nil {
return fmt.Errorf("Unable to marshal json: %w", err)
}
fmt.Println(string(data))
return nil
}
common.LogInfo2Quiet(fmt.Sprintf("%s %s information", entry.Name, field.Name))
length := 31
for _, key := range keys {
if label := fmt.Sprintf("%s %s:", field.RowLabel, key); len(label) > length {
length = len(label)
}
}
for _, key := range keys {
label := fmt.Sprintf("%s %s:", field.RowLabel, key)
common.LogVerbose(fmt.Sprintf("%s%s", common.RightPad(label, length, " "), values[key]))
}
return nil
}

View File

@@ -0,0 +1,164 @@
package storage
import (
"path/filepath"
"testing"
. "github.com/onsi/gomega"
)
// stageDockerLocalEntry writes a docker-local entry at the default host
// path so the metadata commands have something to operate on.
func stageDockerLocalEntry(t *testing.T, name string) *Entry {
t.Helper()
entry := &Entry{
Name: name,
Scheduler: SchedulerDockerLocal,
HostPath: filepath.Join(GetStorageDirectory(), name),
}
if err := SaveEntry(entry); err != nil {
t.Fatalf("SaveEntry: %v", err)
}
return entry
}
func TestSetEntryMapKeyAddsAndOverwrites(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
stageDockerLocalEntry(t, "demo")
Expect(setEntryMapKey(annotationsField, "demo", "first", "one")).To(Succeed())
Expect(setEntryMapKey(annotationsField, "demo", "second", "two")).To(Succeed())
loaded, err := LoadEntry("demo")
Expect(err).NotTo(HaveOccurred())
Expect(loaded.Annotations).To(Equal(map[string]string{"first": "one", "second": "two"}))
Expect(setEntryMapKey(annotationsField, "demo", "first", "rewritten")).To(Succeed())
loaded, err = LoadEntry("demo")
Expect(err).NotTo(HaveOccurred())
Expect(loaded.Annotations).To(Equal(map[string]string{"first": "rewritten", "second": "two"}))
}
// TestSetEntryMapKeyDeletesOneKey is the behavior the wholesale
// --annotation flag could never express: clearing one key without
// disturbing its siblings.
func TestSetEntryMapKeyDeletesOneKey(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
stageDockerLocalEntry(t, "demo")
Expect(setEntryMapKey(annotationsField, "demo", "first", "one")).To(Succeed())
Expect(setEntryMapKey(annotationsField, "demo", "second", "two")).To(Succeed())
Expect(setEntryMapKey(annotationsField, "demo", "first", "")).To(Succeed())
loaded, err := LoadEntry("demo")
Expect(err).NotTo(HaveOccurred())
Expect(loaded.Annotations).To(Equal(map[string]string{"second": "two"}))
}
// TestSetEntryMapKeyDropsEmptyMap keeps the omitempty tag honest: once the
// last key is gone the field should be absent from the JSON, not an empty
// object, and should still round-trip.
func TestSetEntryMapKeyDropsEmptyMap(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
stageDockerLocalEntry(t, "demo")
Expect(setEntryMapKey(labelsField, "demo", "only", "value")).To(Succeed())
Expect(setEntryMapKey(labelsField, "demo", "only", "")).To(Succeed())
loaded, err := LoadEntry("demo")
Expect(err).NotTo(HaveOccurred())
Expect(loaded.Labels).To(BeEmpty())
Expect(SaveEntry(loaded)).To(Succeed())
reloaded, err := LoadEntry("demo")
Expect(err).NotTo(HaveOccurred())
Expect(reloaded.Labels).To(BeEmpty())
}
func TestSetEntryMapKeyKeepsAnnotationsAndLabelsSeparate(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
stageDockerLocalEntry(t, "demo")
Expect(setEntryMapKey(annotationsField, "demo", "shared", "annotation")).To(Succeed())
Expect(setEntryMapKey(labelsField, "demo", "shared", "label")).To(Succeed())
loaded, err := LoadEntry("demo")
Expect(err).NotTo(HaveOccurred())
Expect(loaded.Annotations).To(Equal(map[string]string{"shared": "annotation"}))
Expect(loaded.Labels).To(Equal(map[string]string{"shared": "label"}))
}
// TestSetEntryMapKeyPreservesSlashKeys covers Kubernetes-style keys, which
// are the common case for both annotations and labels.
func TestSetEntryMapKeyPreservesSlashKeys(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
stageDockerLocalEntry(t, "demo")
key := "backup.velero.io/backup-volumes"
Expect(setEntryMapKey(annotationsField, "demo", key, "demo")).To(Succeed())
loaded, err := LoadEntry("demo")
Expect(err).NotTo(HaveOccurred())
Expect(loaded.Annotations).To(HaveKeyWithValue(key, "demo"))
}
func TestSetEntryMapKeyValidatesInputs(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
stageDockerLocalEntry(t, "demo")
err := setEntryMapKey(annotationsField, "", "key", "value")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("name is required"))
err = setEntryMapKey(annotationsField, "demo", "", "value")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("No annotation key specified"))
err = setEntryMapKey(labelsField, "demo", "", "value")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("No label key specified"))
err = setEntryMapKey(annotationsField, "missing", "key", "value")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("does not exist"))
}
func TestReportEntryMapValidatesFormatAndInfoFlag(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
stageDockerLocalEntry(t, "demo")
err := reportEntryMap(annotationsField, "demo", "yaml", "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Invalid format"))
err = reportEntryMap(annotationsField, "demo", "json", "--storage-annotations.first")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot be specified when specifying an info flag"))
// An info flag has no single answer across every entry, so it needs a name.
err = reportEntryMap(annotationsField, "", "stdout", "--storage-annotations.first")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("requires a storage entry name"))
err = reportEntryMap(annotationsField, "missing", "stdout", "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("does not exist"))
}
func TestReportEntryMapRejectsUnknownInfoFlag(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
stageDockerLocalEntry(t, "demo")
Expect(setEntryMapKey(annotationsField, "demo", "first", "one")).To(Succeed())
err := reportEntryMap(annotationsField, "demo", "stdout", "--storage-annotations.nope")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Invalid flag passed, valid flags: --storage-annotations.first"))
}

View File

@@ -48,6 +48,9 @@ var (
// 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,
@@ -66,18 +69,19 @@ var (
// 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"`
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
@@ -213,6 +217,23 @@ func ValidateEntryName(name string, allowLegacyPrefix bool) error {
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 {
@@ -227,6 +248,12 @@ func (e *Entry) Validate() error {
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 == "" {
@@ -244,6 +271,16 @@ func (e *Entry) Validate() error {
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)
@@ -257,8 +294,8 @@ func (e *Entry) Validate() error {
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)
if e.Mode != "" {
return fmt.Errorf("storage entry %q (k3s) does not accept --mode", e.Name)
}
}

View File

@@ -101,6 +101,70 @@ func TestEntryValidateDockerLocal(t *testing.T) {
withClass := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/data", StorageClass: "longhorn"}
Expect(withClass.Validate()).To(HaveOccurred())
withMode := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/var/lib/dokku/data/storage/foo", Mode: "0777"}
Expect(withMode.Validate()).To(Succeed())
withBadMode := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/var/lib/dokku/data/storage/foo", Mode: "0999"}
Expect(withBadMode.Validate()).To(HaveOccurred())
}
// TestEntryValidateDockerLocalReclaimPolicy covers the reclaim policy now
// that it governs whether storage:destroy removes the host directory on
// docker-local, not just the k3s PV.
func TestEntryValidateDockerLocalReclaimPolicy(t *testing.T) {
RegisterTestingT(t)
defaultPath := "/var/lib/dokku/data/storage/foo"
retain := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: defaultPath, ReclaimPolicy: ReclaimPolicyRetain}
Expect(retain.Validate()).To(Succeed())
del := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: defaultPath, ReclaimPolicy: ReclaimPolicyDelete}
Expect(del.Validate()).To(Succeed())
bad := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: defaultPath, ReclaimPolicy: "Recycle"}
err := bad.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("reclaim policy"))
// Delete can only be honored where the sudo helper is allowed to
// operate, so a custom host path is refused up front rather than
// silently ignored at destroy time.
custom := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/mnt/custom", ReclaimPolicy: ReclaimPolicyDelete}
err = custom.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("default host path"))
// Retain on a custom path stays legal; nothing gets removed either way.
customRetain := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/mnt/custom", ReclaimPolicy: ReclaimPolicyRetain}
Expect(customRetain.Validate()).To(Succeed())
}
func TestNormalizeDirectoryMode(t *testing.T) {
RegisterTestingT(t)
accepted := map[string]string{
"": "",
"755": "0755",
"777": "0777",
"0755": "0755",
"0777": "0777",
"2775": "2775",
"1777": "1777",
"0000": "0000",
}
for input, expected := range accepted {
normalized, err := NormalizeDirectoryMode(input)
Expect(err).NotTo(HaveOccurred(), "expected %q to be accepted", input)
Expect(normalized).To(Equal(expected), "unexpected normalization of %q", input)
}
for _, input := range []string{"8", "88", "888", "0888", "07555", "0x1ff", "u+rwx", "-1", " 755 ", "rwx"} {
_, err := NormalizeDirectoryMode(input)
Expect(err).To(HaveOccurred(), "expected %q to be rejected", input)
Expect(err.Error()).To(ContainSubstring("Unsupported directory mode"))
}
}
func TestEntryValidateK3s(t *testing.T) {
@@ -125,6 +189,11 @@ func TestEntryValidateK3s(t *testing.T) {
badReclaim := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", ReclaimPolicy: "Recycle"}
Expect(badReclaim.Validate()).To(HaveOccurred())
withMode := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", Mode: "0777"}
err = withMode.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("--mode"))
}
func TestEntryValidateScheduler(t *testing.T) {
@@ -166,6 +235,23 @@ func TestEntryRoundTrip(t *testing.T) {
Expect(loaded.Labels).To(Equal(original.Labels))
Expect(loaded.SchemaVersion).To(Equal(SchemaVersion))
// mode is docker-local only, so it round-trips through its own entry.
local := &Entry{
Name: "demo-local",
Scheduler: SchedulerDockerLocal,
HostPath: filepath.Join(GetStorageDirectory(), "demo-local"),
Chown: "herokuish",
Mode: "0777",
ReclaimPolicy: ReclaimPolicyDelete,
}
Expect(SaveEntry(local)).To(Succeed())
loadedLocal, err := LoadEntry("demo-local")
Expect(err).NotTo(HaveOccurred())
Expect(loadedLocal.Mode).To(Equal("0777"))
Expect(loadedLocal.Chown).To(Equal("herokuish"))
Expect(loadedLocal.ReclaimPolicy).To(Equal(ReclaimPolicyDelete))
expectedPath := filepath.Join(root, "data", "storage-registry", "entries", "demo-data.json")
Expect(expectedPath).To(BeARegularFile())

View File

@@ -18,17 +18,21 @@ Manage mounted volumes
Additional commands:`
helpContent = `
storage:annotations:report [<name>] [<flag>], Displays annotations for one or more storage entries
storage:annotations:set <name> <key> [<value>], Set or clear an annotation on a storage entry
storage:create <name> [<path>] [flags], Register a named storage entry
storage:destroy <name> [--force], Remove a named storage entry (must be unmounted from every app first)
storage:destroy <name> [--force] [--destroy-host-dir], Remove a named storage entry (must be unmounted from every app first)
storage:ensure-directory [--chown option] <directory>, [DEPRECATED] use storage:create instead
storage:exec <name> [-- <cmd>...], Run a command (or shell) in a temporary container that mounts the entry
storage:info <name> [--format text|json], Show details for one storage entry
storage:labels:report [<name>] [<flag>], Displays labels for one or more storage entries
storage:labels:set <name> <key> [<value>], Set or clear a label on a storage entry
storage:list <app> [--format text|json], List bind mounts for app's container(s) (host:container)
storage:list-entries [--scheduler s] [--format text|json], List registered storage entries
storage:migrate [<app>|--all], Re-run the legacy -v to attachment migration for an app
storage:mount <app> <host-dir:container-dir>, Create a new bind mount
storage:report [<app>] [<flag>], Displays a storage report for one or more apps
storage:set <name> [flags], Update a storage entry in place
storage:set <name> <property> [<value>], Update a storage entry in place
storage:unmount <app> <host-dir:container-dir>, Remove an existing bind mount
storage:wait <name>, Wait for a storage entry's PVC to be bound (k3s)`
)

View File

@@ -19,6 +19,34 @@ func main() {
switch subcommand {
case "default":
err = storage.CommandHelp()
case "annotations:set":
args := flag.NewFlagSet("storage:annotations:set", flag.ExitOnError)
args.Parse(os.Args[2:])
err = storage.CommandAnnotationsSet(args.Arg(0), args.Arg(1), args.Arg(2))
case "annotations:report":
args := flag.NewFlagSet("storage:annotations:report", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")
reportArgs, flagErr := common.ParseReportArgs("storage", os.Args[2:])
if flagErr != nil {
err = flagErr
break
}
args.Parse(reportArgs.OSArgs)
err = storage.CommandAnnotationsReport(args.Arg(0), *format, reportArgs.InfoFlag)
case "labels:set":
args := flag.NewFlagSet("storage:labels:set", flag.ExitOnError)
args.Parse(os.Args[2:])
err = storage.CommandLabelsSet(args.Arg(0), args.Arg(1), args.Arg(2))
case "labels:report":
args := flag.NewFlagSet("storage:labels:report", flag.ExitOnError)
format := args.String("format", "stdout", "format: [ stdout | json ]")
reportArgs, flagErr := common.ParseReportArgs("storage", os.Args[2:])
if flagErr != nil {
err = flagErr
break
}
args.Parse(reportArgs.OSArgs)
err = storage.CommandLabelsReport(args.Arg(0), *format, reportArgs.InfoFlag)
case "create":
args := flag.NewFlagSet("storage:create", flag.ExitOnError)
scheduler := args.String("scheduler", storage.SchedulerDockerLocal, "--scheduler: target scheduler (docker-local, k3s)")
@@ -27,7 +55,8 @@ func main() {
storageClass := args.String("storage-class-name", "", "--storage-class-name: PVC storage class (k3s only)")
namespace := args.String("namespace", "", "--namespace: PVC namespace (k3s only)")
chown := args.String("chown", "", "--chown: chown option (docker-local only)")
reclaim := args.String("reclaim-policy", "", "--reclaim-policy: PV reclaim policy (Retain or Delete, k3s only)")
mode := args.String("mode", "", "--mode: octal permissions for the host directory, such as 0755 (docker-local only)")
reclaim := args.String("reclaim-policy", "", "--reclaim-policy: reclaim policy for the underlying volume (Retain or Delete)")
annotations := args.StringSlice("annotation", nil, "--annotation key=value: PVC annotation (repeatable)")
labels := args.StringSlice("label", nil, "--label key=value: PVC label (repeatable)")
args.Parse(os.Args[2:])
@@ -52,6 +81,7 @@ func main() {
StorageClass: *storageClass,
Namespace: *namespace,
Chown: *chown,
Mode: *mode,
ReclaimPolicy: *reclaim,
Annotations: annotMap,
Labels: labelMap,
@@ -59,8 +89,9 @@ func main() {
case "destroy":
args := flag.NewFlagSet("storage:destroy", flag.ExitOnError)
force := args.Bool("force", false, "--force: force destroy without confirmation")
destroyHostDir := args.Bool("destroy-host-dir", false, "--destroy-host-dir: also remove the host directory and its contents (docker-local only)")
args.Parse(os.Args[2:])
err = storage.CommandDestroy(args.Arg(0), *force)
err = storage.CommandDestroy(args.Arg(0), *force, *destroyHostDir)
case "ensure-directory":
args := flag.NewFlagSet("storage:ensure-directory", flag.ExitOnError)
chown := args.String("chown", "herokuish", "--chown: chown option (herokuish, heroku, paketo, root, false)")
@@ -81,36 +112,73 @@ func main() {
err = storage.CommandList(appName, *format)
case "set":
args := flag.NewFlagSet("storage:set", flag.ExitOnError)
size := args.String("size", "", "--size: new PVC size (k3s)")
accessMode := args.String("access-mode", "", "--access-mode: existing access mode (must match)")
storageClass := args.String("storage-class-name", "", "--storage-class-name: existing storage class (must match)")
namespace := args.String("namespace", "", "--namespace: new namespace")
chown := args.String("chown", "", "--chown: chown option")
reclaim := args.String("reclaim-policy", "", "--reclaim-policy: PV reclaim policy")
annotations := args.StringSlice("annotation", nil, "--annotation key=value: PVC annotation (repeatable, replaces all)")
labels := args.StringSlice("label", nil, "--label key=value: PVC label (repeatable, replaces all)")
args.String("size", "", "--size: [DEPRECATED] use 'storage:set <name> size <value>'")
args.String("access-mode", "", "--access-mode: [DEPRECATED] use 'storage:set <name> access-mode <value>'")
args.String("storage-class-name", "", "--storage-class-name: [DEPRECATED] use 'storage:set <name> storage-class-name <value>'")
args.String("namespace", "", "--namespace: [DEPRECATED] use 'storage:set <name> namespace <value>'")
args.String("chown", "", "--chown: [DEPRECATED] use 'storage:set <name> chown <value>'")
args.String("mode", "", "--mode: [DEPRECATED] use 'storage:set <name> mode <value>'")
args.String("reclaim-policy", "", "--reclaim-policy: [DEPRECATED] use 'storage:set <name> reclaim-policy <value>'")
annotations := args.StringSlice("annotation", nil, "--annotation key=value: [DEPRECATED] use 'storage:annotations:set'")
labels := args.StringSlice("label", nil, "--label key=value: [DEPRECATED] use 'storage:labels:set'")
args.Parse(os.Args[2:])
annotMap, parseErr := parseKVPairs(*annotations)
if parseErr != nil {
err = parseErr
input := storage.CommandSetInput{Name: args.Arg(0)}
property := args.Arg(1)
for _, name := range storage.SettableProperties {
if !args.Changed(name) {
continue
}
value, lookupErr := args.GetString(name)
if lookupErr != nil {
err = lookupErr
break
}
input.Changes = append(input.Changes, storage.PropertyChange{Property: name, Value: value})
}
if err != nil {
break
}
labelMap, parseErr := parseKVPairs(*labels)
if parseErr != nil {
err = parseErr
usedMapFlags := args.Changed("annotation") || args.Changed("label")
if usedMapFlags {
annotMap, parseErr := parseKVPairs(*annotations)
if parseErr != nil {
err = parseErr
break
}
labelMap, parseErr := parseKVPairs(*labels)
if parseErr != nil {
err = parseErr
break
}
input.Annotations = annotMap
input.Labels = labelMap
}
usedFlags := len(input.Changes) > 0 || usedMapFlags
if property != "" && usedFlags {
err = fmt.Errorf("storage:set accepts either a property and a value or flags, not both")
break
}
err = storage.CommandSet(storage.CommandSetInput{
Name: args.Arg(0),
Size: *size,
AccessMode: *accessMode,
StorageClass: *storageClass,
Namespace: *namespace,
Chown: *chown,
ReclaimPolicy: *reclaim,
Annotations: annotMap,
Labels: labelMap,
})
if property == "" && !usedFlags {
err = fmt.Errorf("No property specified")
break
}
if usedFlags {
if len(input.Changes) > 0 {
common.LogWarn("Deprecated: please use 'storage:set <name> <property> [<value>]' instead of flags")
}
if usedMapFlags {
common.LogWarn("Deprecated: please use 'storage:annotations:set' and 'storage:labels:set' instead of --annotation and --label")
}
} else {
input.Changes = append(input.Changes, storage.PropertyChange{Property: property, Value: args.Arg(2)})
}
err = storage.CommandSet(input)
case "exec":
args := flag.NewFlagSet("storage:exec", flag.ExitOnError)
image := args.String("image", "", "--image: container image to use (default alpine:3)")

View File

@@ -3,6 +3,7 @@ package storage
import (
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
@@ -189,6 +190,50 @@ func GetStorageDirectory() string {
return fmt.Sprintf("%s/data/storage", dokkuLibRoot)
}
// storageDirScriptNames lists the sudo helpers shipped in the plugin's bin
// directory. Each one takes a storage entry basename and builds the path
// under $DOKKU_LIB_ROOT/data/storage itself, so no caller can point them
// outside the storage root.
var storageDirScriptNames = []string{
"chown-storage-dir",
"chmod-storage-dir",
"destroy-storage-dir",
}
// StorageDirScriptPath returns the absolute path to a storage directory
// sudo helper.
func StorageDirScriptPath(name string) string {
pluginPath := common.MustGetEnv("PLUGIN_AVAILABLE_PATH")
return filepath.Join(pluginPath, "storage", "bin", name)
}
// StorageDirScripts returns the absolute path of every storage directory
// sudo helper. TriggerInstall whitelists each one in the plugin's sudoers
// file.
func StorageDirScripts() []string {
paths := []string{}
for _, name := range storageDirScriptNames {
paths = append(paths, StorageDirScriptPath(name))
}
return paths
}
// callStorageDirScript runs a storage directory sudo helper, surfacing the
// helper's own stderr message when it refuses the arguments.
func callStorageDirScript(name string, args ...string) error {
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: "sudo",
Args: append([]string{StorageDirScriptPath(name)}, args...),
})
if err != nil {
return err
}
if result.ExitCode != 0 {
return errors.New(strings.TrimSpace(result.StderrContents()))
}
return nil
}
// ValidateDirectoryName validates a storage directory name
func ValidateDirectoryName(directory string) error {
if directory == "" {

View File

@@ -20,17 +20,21 @@ Manage mounted volumes
Additional commands:`
helpContent = `
storage:annotations:report [<name>] [<flag>], Displays annotations for one or more storage entries
storage:annotations:set <name> <key> [<value>], Set or clear an annotation on a storage entry
storage:create <name> [<path>] [flags], Register a named storage entry
storage:destroy <name> [--force], Remove a named storage entry (must be unmounted from every app first)
storage:destroy <name> [--force] [--destroy-host-dir], Remove a named storage entry (must be unmounted from every app first)
storage:ensure-directory [--chown option] <directory>, [DEPRECATED] use storage:create instead
storage:exec <name> [-- <cmd>...], Run a command (or shell) in a temporary container that mounts the entry
storage:info <name> [--format text|json], Show details for one storage entry
storage:labels:report [<name>] [<flag>], Displays labels for one or more storage entries
storage:labels:set <name> <key> [<value>], Set or clear a label on a storage entry
storage:list <app> [--format text|json], List bind mounts for app's container(s) (host:container)
storage:list-entries [--scheduler s] [--format text|json], List registered storage entries
storage:migrate [<app>|--all], Re-run the legacy -v to attachment migration for an app
storage:mount <app> <host-dir:container-dir>, Create a new bind mount
storage:report [<app>] [<flag>], Displays a storage report for one or more apps
storage:set <name> [flags], Update a storage entry in place
storage:set <name> <property> [<value>], Update a storage entry in place
storage:unmount <app> <host-dir:container-dir>, Remove an existing bind mount
storage:wait <name>, Wait for a storage entry's PVC to be bound (k3s)`
)
@@ -62,19 +66,9 @@ func CommandEnsureDirectory(directory string, chownFlag string) error {
if chownID != "false" {
common.LogVerboseQuiet(fmt.Sprintf("Setting directory ownership to %s:%s", chownID, chownID))
pluginPath := common.MustGetEnv("PLUGIN_AVAILABLE_PATH")
chownScript := filepath.Join(pluginPath, "storage", "bin", "chown-storage-dir")
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: "sudo",
Args: []string{chownScript, directory, chownID},
})
if err != nil {
if err := callStorageDirScript("chown-storage-dir", directory, chownID); err != nil {
return fmt.Errorf("Unable to set directory ownership: %s", err.Error())
}
if result.ExitCode != 0 {
return fmt.Errorf("Unable to set directory ownership: %s", result.StderrContents())
}
}
common.LogVerboseQuiet("Directory ready for mounting")

View File

@@ -66,11 +66,11 @@ func TriggerInstall() error {
return nil
}
pluginPath := common.MustGetEnv("PLUGIN_AVAILABLE_PATH")
chownScript := filepath.Join(pluginPath, "storage", "bin", "chown-storage-dir")
sudoersFile := "/etc/sudoers.d/dokku-storage"
content := fmt.Sprintf("%%dokku ALL=(ALL) NOPASSWD:%s *\n", chownScript)
content := ""
for _, script := range StorageDirScripts() {
content += fmt.Sprintf("%%dokku ALL=(ALL) NOPASSWD:%s *\n", script)
}
content += "Defaults env_keep += \"DOKKU_LIB_ROOT\"\n"
if err := os.WriteFile(sudoersFile, []byte(content), 0440); err != nil {