Re-running `storage:mount <app> <entry> --container-dir <path>` against an existing `(entry, container_dir, process_type)` tuple now updates the attachment's mount-time fields in place instead of erroring with `already mounted`. Declarative tooling that wants to change `--volume-options`, `--volume-chown`, `--phase`, `--volume-subpath`, or `--volume-readonly` on an existing mount no longer has to unmount-then-remount, which briefly dropped the volume from `storage:report` and raced against any deploy that fired in the window. The legacy `host:container[:opts]` form keeps its strict `Mount path already exists.` failure.
346 lines
11 KiB
Go
346 lines
11 KiB
Go
package storage
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// writeAttachmentsFile drops the JSON-encoded attachment list into the
|
|
// per-app property file directly, bypassing common.PropertyListWrite (which
|
|
// calls SetPermissions and chowns to dokku:dokku, which a developer machine
|
|
// or CI runner generally doesn't have).
|
|
func writeAttachmentsFile(t *testing.T, root string, appName string, attachments []*Attachment) {
|
|
t.Helper()
|
|
dir := filepath.Join(root, "config", "storage", appName)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
t.Fatalf("mkdir attachment dir: %v", err)
|
|
}
|
|
path := filepath.Join(dir, AttachmentsProperty)
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatalf("create attachment file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
for _, a := range attachments {
|
|
data, err := json.Marshal(a)
|
|
if err != nil {
|
|
t.Fatalf("marshal attachment: %v", err)
|
|
}
|
|
f.Write(append(data, '\n'))
|
|
}
|
|
}
|
|
|
|
func TestAttachmentValidate(t *testing.T) {
|
|
RegisterTestingT(t)
|
|
|
|
good := &Attachment{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy, PhaseRun},
|
|
}
|
|
Expect(good.Validate()).To(Succeed())
|
|
|
|
noEntry := &Attachment{ContainerPath: "/data", Phases: []string{PhaseDeploy}}
|
|
Expect(noEntry.Validate()).To(HaveOccurred())
|
|
|
|
noPath := &Attachment{EntryName: "demo-data", Phases: []string{PhaseDeploy}}
|
|
Expect(noPath.Validate()).To(HaveOccurred())
|
|
|
|
relative := &Attachment{EntryName: "demo-data", ContainerPath: "data", Phases: []string{PhaseDeploy}}
|
|
Expect(relative.Validate()).To(HaveOccurred())
|
|
|
|
noPhases := &Attachment{EntryName: "demo-data", ContainerPath: "/data"}
|
|
Expect(noPhases.Validate()).To(HaveOccurred())
|
|
|
|
badPhase := &Attachment{EntryName: "demo-data", ContainerPath: "/data", Phases: []string{"build"}}
|
|
Expect(badPhase.Validate()).To(HaveOccurred())
|
|
}
|
|
|
|
func TestListAppMountEntriesDockerLocal(t *testing.T) {
|
|
RegisterTestingT(t)
|
|
root := withTempLibRoot(t)
|
|
|
|
Expect(SaveEntry(&Entry{
|
|
Name: "demo-data",
|
|
Scheduler: SchedulerDockerLocal,
|
|
HostPath: "/var/lib/dokku/data/storage/demo-data",
|
|
})).To(Succeed())
|
|
|
|
writeAttachmentsFile(t, root, "demo", []*Attachment{
|
|
{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy, PhaseRun},
|
|
Readonly: true,
|
|
},
|
|
})
|
|
|
|
rows, err := ListAppMountEntries("demo", PhaseDeploy)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rows).To(HaveLen(1))
|
|
Expect(rows[0].EntryName).To(Equal("demo-data"))
|
|
Expect(rows[0].HostPath).To(Equal("/var/lib/dokku/data/storage/demo-data"))
|
|
Expect(rows[0].ContainerPath).To(Equal("/data"))
|
|
Expect(rows[0].Readonly).To(BeTrue())
|
|
Expect(rows[0].VolumeOptions).To(BeEmpty())
|
|
|
|
// Run phase shows it too because the attachment includes both phases.
|
|
runRows, err := ListAppMountEntries("demo", PhaseRun)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(runRows).To(HaveLen(1))
|
|
|
|
// formatStorageListEntry produces the legacy colon form including options.
|
|
Expect(formatStorageListEntry(rows[0])).To(Equal("/var/lib/dokku/data/storage/demo-data:/data:ro"))
|
|
}
|
|
|
|
func TestListAppMountEntriesK3sUsesEntryName(t *testing.T) {
|
|
RegisterTestingT(t)
|
|
root := withTempLibRoot(t)
|
|
|
|
// k3s entry has no host path because the cluster provisions it.
|
|
Expect(SaveEntry(&Entry{
|
|
Name: "demo-pvc",
|
|
Scheduler: SchedulerK3s,
|
|
Size: "2Gi",
|
|
StorageClass: "longhorn",
|
|
AccessMode: "ReadWriteOnce",
|
|
})).To(Succeed())
|
|
|
|
writeAttachmentsFile(t, root, "demo", []*Attachment{
|
|
{
|
|
EntryName: "demo-pvc",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy},
|
|
Subpath: "uploads",
|
|
},
|
|
})
|
|
|
|
rows, err := ListAppMountEntries("demo", PhaseDeploy)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rows).To(HaveLen(1))
|
|
// HostPath falls back to the entry name so the colon form remains
|
|
// well-formed for callers that only know about the legacy shape.
|
|
Expect(rows[0].HostPath).To(Equal("demo-pvc"))
|
|
Expect(rows[0].EntryName).To(Equal("demo-pvc"))
|
|
Expect(rows[0].ContainerPath).To(Equal("/data"))
|
|
Expect(formatStorageListEntry(rows[0])).To(Equal("demo-pvc:/data"))
|
|
}
|
|
|
|
func TestListAppMountEntriesVolumeOptions(t *testing.T) {
|
|
RegisterTestingT(t)
|
|
root := withTempLibRoot(t)
|
|
|
|
Expect(SaveEntry(&Entry{
|
|
Name: "demo-data",
|
|
Scheduler: SchedulerDockerLocal,
|
|
HostPath: "/var/lib/dokku/data/storage/demo-data",
|
|
})).To(Succeed())
|
|
|
|
writeAttachmentsFile(t, root, "demo", []*Attachment{
|
|
{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy, PhaseRun},
|
|
VolumeOptions: "Z",
|
|
},
|
|
{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/ro",
|
|
Phases: []string{PhaseDeploy, PhaseRun},
|
|
Readonly: true,
|
|
VolumeOptions: "noexec,nosuid",
|
|
},
|
|
})
|
|
|
|
rows, err := ListAppMountEntries("demo", PhaseDeploy)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rows).To(HaveLen(2))
|
|
|
|
byPath := map[string]StorageListEntry{}
|
|
for _, row := range rows {
|
|
byPath[row.ContainerPath] = row
|
|
}
|
|
|
|
Expect(byPath["/data"].Readonly).To(BeFalse())
|
|
Expect(byPath["/data"].VolumeOptions).To(Equal("Z"))
|
|
Expect(formatStorageListEntry(byPath["/data"])).To(Equal("/var/lib/dokku/data/storage/demo-data:/data:Z"))
|
|
|
|
Expect(byPath["/ro"].Readonly).To(BeTrue())
|
|
Expect(byPath["/ro"].VolumeOptions).To(Equal("noexec,nosuid"))
|
|
Expect(formatStorageListEntry(byPath["/ro"])).To(Equal("/var/lib/dokku/data/storage/demo-data:/ro:ro,noexec,nosuid"))
|
|
|
|
// The wire shape exposes Readonly and VolumeOptions as separate
|
|
// keys so external drift-detection tooling can compare them
|
|
// against the underlying Attachment fields one for one.
|
|
dataJSON, err := json.Marshal(byPath["/data"])
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(dataJSON)).To(ContainSubstring(`"volume_options":"Z"`))
|
|
Expect(string(dataJSON)).NotTo(ContainSubstring(`"readonly"`))
|
|
|
|
roJSON, err := json.Marshal(byPath["/ro"])
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(roJSON)).To(ContainSubstring(`"readonly":true`))
|
|
Expect(string(roJSON)).To(ContainSubstring(`"volume_options":"noexec,nosuid"`))
|
|
}
|
|
|
|
func TestListAppMountEntriesPhaseFilter(t *testing.T) {
|
|
RegisterTestingT(t)
|
|
root := withTempLibRoot(t)
|
|
|
|
Expect(SaveEntry(&Entry{
|
|
Name: "demo-deploy-only",
|
|
Scheduler: SchedulerDockerLocal,
|
|
HostPath: "/srv/deploy",
|
|
})).To(Succeed())
|
|
Expect(SaveEntry(&Entry{
|
|
Name: "demo-run-only",
|
|
Scheduler: SchedulerDockerLocal,
|
|
HostPath: "/srv/run",
|
|
})).To(Succeed())
|
|
|
|
writeAttachmentsFile(t, root, "demo", []*Attachment{
|
|
{EntryName: "demo-deploy-only", ContainerPath: "/d", Phases: []string{PhaseDeploy}},
|
|
{EntryName: "demo-run-only", ContainerPath: "/r", Phases: []string{PhaseRun}},
|
|
})
|
|
|
|
deployRows, err := ListAppMountEntries("demo", PhaseDeploy)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(deployRows).To(HaveLen(1))
|
|
Expect(deployRows[0].EntryName).To(Equal("demo-deploy-only"))
|
|
|
|
runRows, err := ListAppMountEntries("demo", PhaseRun)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(runRows).To(HaveLen(1))
|
|
Expect(runRows[0].EntryName).To(Equal("demo-run-only"))
|
|
}
|
|
|
|
func TestUpsertAttachmentInsert(t *testing.T) {
|
|
RegisterTestingT(t)
|
|
withTempLibRoot(t)
|
|
|
|
created, err := UpsertAttachment("demo", &Attachment{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy, PhaseRun},
|
|
ProcessType: DefaultProcessType,
|
|
VolumeOptions: "Z",
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(created).To(BeTrue())
|
|
|
|
attachments, err := LoadAttachments("demo")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(attachments).To(HaveLen(1))
|
|
Expect(attachments[0].VolumeOptions).To(Equal("Z"))
|
|
}
|
|
|
|
func TestUpsertAttachmentUpdatesInPlace(t *testing.T) {
|
|
RegisterTestingT(t)
|
|
withTempLibRoot(t)
|
|
|
|
base := &Attachment{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy, PhaseRun},
|
|
ProcessType: DefaultProcessType,
|
|
VolumeOptions: "Z",
|
|
Subpath: "uploads",
|
|
Readonly: false,
|
|
VolumeChown: "herokuish",
|
|
}
|
|
created, err := UpsertAttachment("demo", base)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(created).To(BeTrue())
|
|
|
|
// Same identity, new mount-time fields - update in place.
|
|
created, err = UpsertAttachment("demo", &Attachment{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy, PhaseRun},
|
|
ProcessType: DefaultProcessType,
|
|
VolumeOptions: "noexec,nosuid",
|
|
Readonly: true,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(created).To(BeFalse())
|
|
|
|
attachments, err := LoadAttachments("demo")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(attachments).To(HaveLen(1))
|
|
Expect(attachments[0].VolumeOptions).To(Equal("noexec,nosuid"))
|
|
Expect(attachments[0].Readonly).To(BeTrue())
|
|
// Wholesale rewrite: Subpath and VolumeChown cleared since the second
|
|
// call didn't supply them.
|
|
Expect(attachments[0].Subpath).To(BeEmpty())
|
|
Expect(attachments[0].VolumeChown).To(BeEmpty())
|
|
}
|
|
|
|
func TestUpsertAttachmentDistinctIdentity(t *testing.T) {
|
|
RegisterTestingT(t)
|
|
withTempLibRoot(t)
|
|
|
|
created, err := UpsertAttachment("demo", &Attachment{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy},
|
|
ProcessType: DefaultProcessType,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(created).To(BeTrue())
|
|
|
|
// Different container path on the same entry is a distinct attachment.
|
|
created, err = UpsertAttachment("demo", &Attachment{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/other",
|
|
Phases: []string{PhaseDeploy},
|
|
ProcessType: DefaultProcessType,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(created).To(BeTrue())
|
|
|
|
// Different process type on the same (entry, container_path) is also distinct.
|
|
created, err = UpsertAttachment("demo", &Attachment{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy},
|
|
ProcessType: "web",
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(created).To(BeTrue())
|
|
|
|
attachments, err := LoadAttachments("demo")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(attachments).To(HaveLen(3))
|
|
}
|
|
|
|
func TestUpsertAttachmentValidationFailureLeavesStoreUntouched(t *testing.T) {
|
|
RegisterTestingT(t)
|
|
withTempLibRoot(t)
|
|
|
|
created, err := UpsertAttachment("demo", &Attachment{
|
|
EntryName: "demo-data",
|
|
ContainerPath: "/data",
|
|
Phases: []string{PhaseDeploy},
|
|
ProcessType: DefaultProcessType,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(created).To(BeTrue())
|
|
|
|
// Invalid attachment (empty ContainerPath) fails validation and the
|
|
// existing list stays exactly as it was.
|
|
_, err = UpsertAttachment("demo", &Attachment{
|
|
EntryName: "demo-data",
|
|
Phases: []string{PhaseDeploy},
|
|
})
|
|
Expect(err).To(HaveOccurred())
|
|
|
|
attachments, err := LoadAttachments("demo")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(attachments).To(HaveLen(1))
|
|
Expect(attachments[0].ContainerPath).To(Equal("/data"))
|
|
}
|