Files
dokku/plugins/storage/entry_test.go
Jose Diaz-Gonzalez 179d09fdd5 fix: chown migrated legacy storage entries to dokku
`SaveEntry` and `touchMigrationFlag` wrote files via `os.WriteFile` and `os.Create` and never chowned them, so the install-time legacy-mount migration produced root-owned `legacy-*.json` files in `/var/lib/dokku/data/storage-registry/entries/`. The dokku user that runs `ps:rebuild` could not read them and every rebuild on a 0.37.x to 0.38.0 upgrade failed with permission denied. The companion `repairRegistryOwnership` pass in `TriggerInstall` rewrites ownership across the whole registry tree so installs that already ran the buggy code are fixed on the next package upgrade, since the per-app migration flag would otherwise cause `MigrateLegacyMounts` to skip the broken files forever.
2026-05-02 05:37:11 -04:00

248 lines
8.4 KiB
Go

package storage
import (
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
. "github.com/onsi/gomega"
)
// withTempLibRoot points DOKKU_LIB_ROOT at a temp dir and overrides the
// permission helpers' target user/group to the current process user so
// SetPermissions is a no-op chown rather than a hard failure on dev and
// CI machines that lack a dokku system user.
func withTempLibRoot(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("DOKKU_LIB_ROOT", dir)
current, err := user.Current()
if err != nil {
t.Fatalf("user.Current: %v", err)
}
group, err := user.LookupGroupId(current.Gid)
if err != nil {
t.Fatalf("user.LookupGroupId: %v", err)
}
t.Setenv("DOKKU_SYSTEM_USER", current.Username)
t.Setenv("DOKKU_SYSTEM_GROUP", group.Name)
return dir
}
func TestValidateEntryName(t *testing.T) {
RegisterTestingT(t)
Expect(ValidateEntryName("foo", false)).To(Succeed())
Expect(ValidateEntryName("foo-bar", false)).To(Succeed())
Expect(ValidateEntryName("a", false)).To(Succeed())
Expect(ValidateEntryName("foo123", false)).To(Succeed())
err := ValidateEntryName("", false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("required"))
err = ValidateEntryName("Foo", false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("DNS-1123"))
err = ValidateEntryName("foo_bar", false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("DNS-1123"))
err = ValidateEntryName("-foo", false)
Expect(err).To(HaveOccurred())
err = ValidateEntryName("foo-", false)
Expect(err).To(HaveOccurred())
long := strings.Repeat("a", MaxEntryNameLength+1)
err = ValidateEntryName(long, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("too long"))
maxOK := strings.Repeat("a", MaxEntryNameLength)
Expect(ValidateEntryName(maxOK, false)).To(Succeed())
err = ValidateEntryName("legacy-foo", false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("reserved"))
Expect(ValidateEntryName("legacy-foo", true)).To(Succeed())
}
func TestEntryValidateDockerLocal(t *testing.T) {
RegisterTestingT(t)
good := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/var/lib/dokku/data/storage/foo"}
Expect(good.Validate()).To(Succeed())
noPath := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal}
Expect(noPath.Validate()).To(HaveOccurred())
// A leading-slash-less but DNS-1123-ish token is treated as a docker
// named volume and accepted.
namedVolume := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "myvolume"}
Expect(namedVolume.Validate()).To(Succeed())
// Slash-containing relative paths still fail, as do tokens with bad chars.
relativePath := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "relative/path"}
Expect(relativePath.Validate()).To(HaveOccurred())
badToken := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "with spaces"}
Expect(badToken.Validate()).To(HaveOccurred())
withSize := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/data", Size: "2Gi"}
Expect(withSize.Validate()).To(HaveOccurred())
withClass := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/data", StorageClass: "longhorn"}
Expect(withClass.Validate()).To(HaveOccurred())
}
func TestEntryValidateK3s(t *testing.T) {
RegisterTestingT(t)
dynamic := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", AccessMode: "ReadWriteOnce"}
Expect(dynamic.Validate()).To(Succeed())
hostPath := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", HostPath: "/data/foo"}
Expect(hostPath.Validate()).To(Succeed())
noSize := &Entry{Name: "foo", Scheduler: SchedulerK3s, StorageClass: "longhorn"}
Expect(noSize.Validate()).To(HaveOccurred())
bothPathAndClass := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", HostPath: "/data", StorageClass: "longhorn"}
err := bothPathAndClass.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("storage-class-name"))
badAccessMode := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", AccessMode: "Bogus"}
Expect(badAccessMode.Validate()).To(HaveOccurred())
badReclaim := &Entry{Name: "foo", Scheduler: SchedulerK3s, Size: "2Gi", StorageClass: "longhorn", ReclaimPolicy: "Recycle"}
Expect(badReclaim.Validate()).To(HaveOccurred())
}
func TestEntryValidateScheduler(t *testing.T) {
RegisterTestingT(t)
bad := &Entry{Name: "foo", Scheduler: "nomad"}
err := bad.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("scheduler"))
}
func TestEntryRoundTrip(t *testing.T) {
RegisterTestingT(t)
root := withTempLibRoot(t)
original := &Entry{
Name: "demo-data",
Scheduler: SchedulerK3s,
Size: "2Gi",
StorageClass: "longhorn",
AccessMode: "ReadWriteOnce",
Namespace: "dokku",
Annotations: map[string]string{"backup.velero.io/backup-volumes": "demo-data"},
Labels: map[string]string{"app.kubernetes.io/managed-by": "dokku"},
}
Expect(SaveEntry(original)).To(Succeed())
Expect(EntryExists("demo-data")).To(BeTrue())
loaded, err := LoadEntry("demo-data")
Expect(err).NotTo(HaveOccurred())
Expect(loaded.Name).To(Equal(original.Name))
Expect(loaded.Scheduler).To(Equal(original.Scheduler))
Expect(loaded.Size).To(Equal(original.Size))
Expect(loaded.StorageClass).To(Equal(original.StorageClass))
Expect(loaded.AccessMode).To(Equal(original.AccessMode))
Expect(loaded.Namespace).To(Equal(original.Namespace))
Expect(loaded.Annotations).To(Equal(original.Annotations))
Expect(loaded.Labels).To(Equal(original.Labels))
Expect(loaded.SchemaVersion).To(Equal(SchemaVersion))
expectedPath := filepath.Join(root, "data", "storage-registry", "entries", "demo-data.json")
Expect(expectedPath).To(BeARegularFile())
Expect(DeleteEntry("demo-data")).To(Succeed())
Expect(EntryExists("demo-data")).To(BeFalse())
}
func TestListEntries(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
entries, err := ListEntries()
Expect(err).NotTo(HaveOccurred())
Expect(entries).To(BeEmpty())
Expect(SaveEntry(&Entry{Name: "b", Scheduler: SchedulerDockerLocal, HostPath: "/b"})).To(Succeed())
Expect(SaveEntry(&Entry{Name: "a", Scheduler: SchedulerDockerLocal, HostPath: "/a"})).To(Succeed())
entries, err = ListEntries()
Expect(err).NotTo(HaveOccurred())
Expect(entries).To(HaveLen(2))
Expect(entries[0].Name).To(Equal("a"))
Expect(entries[1].Name).To(Equal("b"))
}
func TestLegacyMountToEntry(t *testing.T) {
RegisterTestingT(t)
a := LegacyMountToEntry("/host/path:/container/path")
b := LegacyMountToEntry("/host/path:/container/different")
c := LegacyMountToEntry("/host/path:/container/path:ro")
// Same host path produces the same entry name regardless of container
// path or options - container path lives on the attachment, not the
// entry, so apps mounting the same source converge.
Expect(a.Name).To(Equal(b.Name))
Expect(a.Name).To(Equal(c.Name))
Expect(a.Name).To(HavePrefix(LegacyEntryPrefix))
Expect(len(a.Name)).To(Equal(len(LegacyEntryPrefix) + 10))
// Different host paths produce different names.
other := LegacyMountToEntry("/different/path:/container/path")
Expect(other.Name).NotTo(Equal(a.Name))
// Named docker volumes are distinguished from absolute paths.
named := LegacyMountToEntry("my_volume:/container/path")
Expect(named.Name).NotTo(Equal(a.Name))
Expect(named.HostPath).To(Equal("my_volume"))
// The synthesized entry is a valid docker-local entry that passes
// Validate (other than the legacy- prefix, which Validate accepts).
abs := LegacyMountToEntry("/host/path:/container/path")
Expect(abs.Scheduler).To(Equal(SchedulerDockerLocal))
Expect(abs.HostPath).To(Equal("/host/path"))
Expect(abs.Validate()).To(Succeed())
}
// TestSaveEntryChownsToSystemUser is a regression test for #8557: the
// install trigger runs as root, so SaveEntry must chown the file to the
// dokku user before ps:rebuild can read it.
func TestSaveEntryChownsToSystemUser(t *testing.T) {
RegisterTestingT(t)
withTempLibRoot(t)
Expect(SaveEntry(&Entry{
Name: "demo-data",
Scheduler: SchedulerDockerLocal,
HostPath: "/data/demo",
})).To(Succeed())
info, err := os.Stat(entryPath("demo-data"))
Expect(err).NotTo(HaveOccurred())
stat, ok := info.Sys().(*syscall.Stat_t)
Expect(ok).To(BeTrue())
current, err := user.Current()
Expect(err).NotTo(HaveOccurred())
Expect(strconv.Itoa(int(stat.Uid))).To(Equal(current.Uid))
}