package console import ( "net/http" "os" "path/filepath" "strings" "testing" ) func TestScratchLimitsPerAppAndDeployment(t *testing.T) { p := newSeedScratch(t.TempDir(), func(string) (int64, error) { return 100 << 30, nil }) rel1, ref := p.reserve("acct/shop", 1<<30) if ref != nil { t.Fatalf("first: %+v", ref) } if _, ref := p.reserve("acct/shop", 1<<30); ref == nil || ref.Status != http.StatusTooManyRequests { t.Fatalf("second for the same app: %+v, want 429", ref) } else if want := "another copy or upload is running for shop. Try again when it finishes."; ref.Msg != want { t.Fatalf("per-app message %q, want %q", ref.Msg, want) } rel2, ref := p.reserve("other/kass", 1<<30) if ref != nil { t.Fatalf("another account's app: %+v", ref) } if _, ref := p.reserve("third/blog", 1<<30); ref == nil || ref.Status != http.StatusTooManyRequests { t.Fatalf("a third across the deployment: %+v, want 429", ref) } else if want := "the console is already running two copies or uploads. Try again in a few minutes."; ref.Msg != want { t.Fatalf("deployment message %q, want %q", ref.Msg, want) } rel1() rel1() // releasing twice is harmless if rel3, ref := p.reserve("third/blog", 1<<30); ref != nil { t.Fatalf("after a release: %+v", ref) } else { rel3() } rel2() } // Final review finding 6: one account holds at most one of the pool's // slots, so a tenant with slow uploads to two apps cannot occupy the whole // pool and turn every other account's ship and seed away. The refusal // names no app: it must not tell a caller that a sibling app exists. func TestScratchLimitsPerAccount(t *testing.T) { p := newSeedScratch(t.TempDir(), func(string) (int64, error) { return 100 << 30, nil }) relShop, ref := p.reserve("acct/shop", 1<<30) if ref != nil { t.Fatalf("first: %+v", ref) } if _, ref := p.reserve("acct/blog", 1<<30); ref == nil || ref.Status != http.StatusTooManyRequests { t.Fatalf("a second app of the same account: %+v, want 429", ref) } else if want := "another copy or upload is running in this account. try again when it finishes"; ref.Msg != want { t.Fatalf("per-account message %q, want %q", ref.Msg, want) } else if strings.Contains(ref.Msg, "shop") { t.Fatalf("per-account message %q names the running app", ref.Msg) } relKass, ref := p.reserve("other/kass", 1<<30) if ref != nil { t.Fatalf("another account beside the refused one: %+v, want admitted", ref) } relShop() relBlog, ref := p.reserve("acct/blog", 1<<30) if ref != nil { t.Fatalf("the account's next app after its release: %+v, want admitted", ref) } relBlog() relKass() if p.running != 0 || len(p.perApp) != 0 || len(p.perAccount) != 0 { t.Fatalf("after every release: running %d, perApp %v, perAccount %v", p.running, p.perApp, p.perAccount) } } func TestScratchCapacityIsFreeSpaceLessAGiB(t *testing.T) { free := int64(5 << 30) reads := 0 p := newSeedScratch(t.TempDir(), func(string) (int64, error) { reads++; return free, nil }) if _, ref := p.reserve("a/x", 4<<30+1); ref == nil || ref.Status != http.StatusInsufficientStorage { t.Fatalf("over capacity: %+v, want 507", ref) } else if want := "the console has no room for this right now. Try again later."; ref.Msg != want { t.Fatalf("capacity message %q, want %q", ref.Msg, want) } rel, ref := p.reserve("a/x", 4<<30) if ref != nil { t.Fatalf("at capacity: %+v", ref) } heldReads := reads if _, ref := p.reserve("b/y", 1); ref == nil || ref.Status != http.StatusInsufficientStorage { t.Fatalf("a full pool: %+v, want 507", ref) } if reads != heldReads { t.Fatalf("free space re-read while a reservation was held (%d reads, was %d)", reads, heldReads) } rel() free = 2 << 30 // the disk filled from elsewhere; an empty pool re-reads it emptyReads := reads if _, ref := p.reserve("a/x", 2<<30); ref == nil || ref.Status != http.StatusInsufficientStorage { t.Fatalf("after a re-read: %+v, want 507", ref) } if reads != emptyReads+1 { t.Fatalf("free space read %d times after the pool emptied, want exactly one more than %d", reads, emptyReads) } } func TestSeedScratchReleaseIsExactlyOnce(t *testing.T) { p := newSeedScratch(t.TempDir(), func(string) (int64, error) { return 10 << 30, nil }) relA, _ := p.reserve("acct/shop", 1<<30) relB, _ := p.reserve("other/blog", 1<<30) relA() relA() // must not give back a second reservation's bytes or slot if _, ref := p.reserve("third/kass", 1<<30); ref != nil { t.Fatalf("one slot is free: %+v", ref) } if _, ref := p.reserve("fourth/more", 1<<30); ref == nil || ref.Status != http.StatusTooManyRequests { t.Fatalf("a double release freed a second slot: %+v", ref) } relB() } func TestSeedScratchWithoutADirectoryIsUnavailable(t *testing.T) { p := startSeedScratch("") if _, ref := p.reserve("acct/shop", 1); ref == nil || ref.Status != http.StatusInsufficientStorage { t.Fatalf("no directory: %+v, want 507", ref) } // Not even a zero-byte reservation: a caller would go on to spool into dir(). if _, ref := p.reserve("acct/shop", 0); ref == nil || ref.Status != http.StatusInsufficientStorage { t.Fatalf("no directory, zero bytes: %+v, want 507", ref) } } func TestSeedScratchRefusedDirectoryRefusesZeroBytes(t *testing.T) { p := startSeedScratch("relative/dir") if _, ref := p.reserve("acct/shop", 0); ref == nil || ref.Status != http.StatusInsufficientStorage { t.Fatalf("refused directory, zero bytes: %+v, want 507", ref) } } func TestSeedScratchClearNarrowsAnExistingDirectory(t *testing.T) { dir := filepath.Join(t.TempDir(), "seeds") if err := os.Mkdir(dir, 0o755); err != nil { t.Fatal(err) } if err := os.Chmod(dir, 0o755); err != nil { // whatever the umask was t.Fatal(err) } if err := clearSeedScratch(dir); err != nil { t.Fatal(err) } st, err := os.Stat(dir) if err != nil || st.Mode().Perm() != 0o700 { t.Fatalf("mode %v, %v; want 0700", st.Mode().Perm(), err) } } func TestClearSeedScratch(t *testing.T) { dir := filepath.Join(t.TempDir(), "scratch", "seeds") if err := os.MkdirAll(filepath.Join(dir, "from-123"), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "from-123", "restore.db"), []byte("spool"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "from-456"), []byte("spool"), 0o600); err != nil { t.Fatal(err) } if err := clearSeedScratch(dir); err != nil { t.Fatal(err) } ents, err := os.ReadDir(dir) if err != nil || len(ents) != 0 { t.Fatalf("after clear: %v, %v", ents, err) } st, _ := os.Stat(dir) if st.Mode().Perm() != 0o700 { t.Fatalf("mode %v, want 0700", st.Mode().Perm()) } } func TestSeedScratchClearKeepsWhatThePoolDidNotCreate(t *testing.T) { dir := t.TempDir() keep := filepath.Join(dir, "shop.example.com.db") keepDir := filepath.Join(dir, "notes") if err := os.WriteFile(keep, []byte("not ours"), 0o600); err != nil { t.Fatal(err) } if err := os.MkdirAll(keepDir, 0o700); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(dir, "from-9"), 0o700); err != nil { t.Fatal(err) } p := startSeedScratch(dir) if _, err := os.Stat(keep); err != nil { t.Fatalf("a file the pool did not create was removed: %v", err) } if _, err := os.Stat(keepDir); err != nil { t.Fatalf("a directory the pool did not create was removed: %v", err) } if _, err := os.Stat(filepath.Join(dir, "from-9")); !os.IsNotExist(err) { t.Fatalf("the pool's own leftover survived: %v", err) } if p.dir() != dir { t.Fatalf("dir() = %q, want %q", p.dir(), dir) } } func TestSeedScratchClearRefusesUnsafePaths(t *testing.T) { for _, dir := range []string{"/", "scratch/seeds", ".", "../x"} { if err := clearSeedScratch(dir); err == nil { t.Errorf("clearSeedScratch(%q) = nil, want a refusal", dir) } } if err := clearSeedScratch(""); err != nil { t.Errorf("empty directory: %v, want nil", err) } // A refused path leaves the pool unavailable rather than counting space. p := startSeedScratch("relative/dir") if _, ref := p.reserve("acct/shop", 1); ref == nil || ref.Status != http.StatusInsufficientStorage { t.Fatalf("refused directory: %+v, want 507", ref) } } func TestSeedScratchClearDoesNotFollowSymlinks(t *testing.T) { dir := t.TempDir() outside := t.TempDir() victim := filepath.Join(outside, "victim.db") if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(dir, "from-link")); err != nil { t.Skip("no symlinks here") } if err := clearSeedScratch(dir); err != nil { t.Fatal(err) } if _, err := os.Stat(victim); err != nil { t.Fatalf("clearing followed a symlink out of the directory: %v", err) } if _, err := os.Lstat(filepath.Join(dir, "from-link")); !os.IsNotExist(err) { t.Fatalf("the link itself survived: %v", err) } }