refactor: align storage:set with dokku conventions
`storage:set` now takes `<name> <property> [<value>]` like every other `:set` command, where omitting the value unsets the property. Previously it took flags and could not distinguish an empty value from an omitted one, so nothing it set could ever be cleared. The flag form keeps working and emits a deprecation warning. Annotations and labels move to `storage:annotations:set`, `storage:annotations:report`, `storage:labels:set`, and `storage:labels:report`, matching the `scheduler-k3s` equivalents. These operate on a single key, so clearing one leaves the rest in place rather than replacing the whole map as the `--annotation` and `--label` flags do.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -235,23 +235,40 @@ func CommandInfo(name string, format string) error {
|
||||
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
|
||||
Mode 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)
|
||||
@@ -261,31 +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.Mode != "" {
|
||||
mode, err := NormalizeDirectoryMode(input.Mode)
|
||||
if err != nil {
|
||||
touchesDirectory := false
|
||||
for _, change := range input.Changes {
|
||||
if err := applyPropertyChange(entry, change); err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Mode = mode
|
||||
}
|
||||
if input.ReclaimPolicy != "" {
|
||||
entry.ReclaimPolicy = input.ReclaimPolicy
|
||||
if change.Property == "chown" || change.Property == "mode" {
|
||||
touchesDirectory = true
|
||||
}
|
||||
}
|
||||
|
||||
if input.Annotations != nil {
|
||||
entry.Annotations = input.Annotations
|
||||
}
|
||||
@@ -302,7 +304,7 @@ func CommandSet(input CommandSetInput) error {
|
||||
// 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 && (input.Chown != "" || input.Mode != "") {
|
||||
if entry.Scheduler == SchedulerDockerLocal && touchesDirectory {
|
||||
if err := ensureDockerLocalPath(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -316,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
|
||||
|
||||
@@ -83,3 +83,138 @@ func TestEnsureDockerLocalPathAllowsChownFalseOnCustomPath(t *testing.T) {
|
||||
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"))
|
||||
}
|
||||
|
||||
212
plugins/storage/commands_metadata.go
Normal file
212
plugins/storage/commands_metadata.go
Normal 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
|
||||
}
|
||||
164
plugins/storage/commands_metadata_test.go
Normal file
164
plugins/storage/commands_metadata_test.go
Normal 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"))
|
||||
}
|
||||
@@ -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] [--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)`
|
||||
)
|
||||
|
||||
@@ -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)")
|
||||
@@ -84,38 +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")
|
||||
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, 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,
|
||||
Mode: *mode,
|
||||
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)")
|
||||
|
||||
@@ -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] [--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)`
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user