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")) }