package release import ( "os" "path/filepath" "testing" "time" ) // stage writes a version tree the way StageArtifact lays one out, and dates // every file it wrote `age` in the past. func stage(t *testing.T, optDir, acct, ver, app string, size int, age time.Duration) string { t.Helper() p := VersionPath(optDir, acct, ver, app) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, make([]byte, size), 0o755); err != nil { t.Fatal(err) } when := time.Now().Add(-age) if err := os.Chtimes(p, when, when); err != nil { t.Fatal(err) } return p } func TestSweepStagedRemovesOnlyUnpinnedAndOld(t *testing.T) { opt := t.TempDir() stage(t, opt, OpsAccount, "live", "edge", 10, 48*time.Hour) stage(t, opt, OpsAccount, "pinned-by-registry", "edge", 20, 48*time.Hour) stage(t, opt, "acct1", "old", "blog", 30, 48*time.Hour) stage(t, opt, "acct1", "fresh", "blog", 40, time.Minute) if err := PointLive(opt, "edge.example.com", OpsAccount, "live", "edge"); err != nil { t.Fatal(err) } pins, err := LivePins(opt) if err != nil { t.Fatal(err) } if !pins["live"] || len(pins) != 1 { t.Fatalf("LivePins = %v, want just {live}", pins) } pins["pinned-by-registry"] = true res, err := SweepStaged(opt, pins, 6*time.Hour, time.Now()) if err != nil { t.Fatal(err) } if len(res.Removed) != 1 || res.Removed[0] != "old" { t.Fatalf("Removed = %v, want [old]", res.Removed) } if res.Bytes != 30 { t.Errorf("Bytes = %d, want 30", res.Bytes) } if res.Pinned != 2 || res.Young != 1 || res.Scanned != 4 { t.Errorf("res = %+v, want 2 pinned, 1 young, 4 scanned", res) } if len(res.Errs) != 0 { t.Errorf("Errs = %v, want none", res.Errs) } for _, keep := range []string{"live", "pinned-by-registry", "fresh"} { if _, err := os.Stat(filepath.Join(opt, "versions", keep)); err != nil { t.Errorf("%s was removed: %v", keep, err) } } if _, err := os.Stat(filepath.Join(opt, "versions", "old")); !os.IsNotExist(err) { t.Errorf("old survived: %v", err) } // The live link must still resolve — the point of the pin. if _, err := os.Stat(LiveLink(opt, "edge.example.com")); err != nil { t.Errorf("live link broken: %v", err) } } // A version dir holds every account's build at that version, so a pin from // one account keeps the whole directory — including a second account's app // inside it. func TestSweepStagedPinsWholeVersionDir(t *testing.T) { opt := t.TempDir() stage(t, opt, "acct1", "v1", "blog", 10, 48*time.Hour) stage(t, opt, "acct2", "v1", "shop", 10, 48*time.Hour) res, err := SweepStaged(opt, map[string]bool{"v1": true}, 6*time.Hour, time.Now()) if err != nil { t.Fatal(err) } if len(res.Removed) != 0 { t.Fatalf("Removed = %v, want none", res.Removed) } if _, err := os.Stat(VersionPath(opt, "acct2", "v1", "shop")); err != nil { t.Errorf("acct2's build removed by acct1's pin: %v", err) } } // The age of a tree is its newest FILE, not its newest directory: deleting // entries bumps the parent's mtime, and a half-swept tree that read as // freshly written would renew its own grace forever. func TestSweepStagedAgesOnFileMtimeNotDirMtime(t *testing.T) { opt := t.TempDir() p := stage(t, opt, "acct1", "v1", "blog", 10, 48*time.Hour) // A sibling left behind and then removed, as a partial RemoveAll would: // the parent directory's mtime is now. sib := filepath.Join(filepath.Dir(p), "leftover") if err := os.WriteFile(sib, []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.Remove(sib); err != nil { t.Fatal(err) } res, err := SweepStaged(opt, nil, 6*time.Hour, time.Now()) if err != nil { t.Fatal(err) } if len(res.Removed) != 1 { t.Fatalf("Removed = %v, want [v1] — a fresh dir mtime must not renew the grace", res.Removed) } } // An emptied husk has no file to age it and must still be collectable. func TestSweepStagedRemovesEmptyHusk(t *testing.T) { opt := t.TempDir() dir := filepath.Join(opt, "versions", "v1") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } when := time.Now().Add(-48 * time.Hour) if err := os.Chtimes(dir, when, when); err != nil { t.Fatal(err) } res, err := SweepStaged(opt, nil, 6*time.Hour, time.Now()) if err != nil { t.Fatal(err) } if len(res.Removed) != 1 { t.Fatalf("Removed = %v, want [v1]", res.Removed) } } // Entries this package did not write are not ours to delete, however old. func TestSweepStagedLeavesForeignEntries(t *testing.T) { opt := t.TempDir() vdir := filepath.Join(opt, "versions") if err := os.MkdirAll(vdir, 0o755); err != nil { t.Fatal(err) } loose := filepath.Join(vdir, "notes.txt") if err := os.WriteFile(loose, []byte("hand-written"), 0o644); err != nil { t.Fatal(err) } odd := filepath.Join(vdir, "backup 2026") // a space: ValidVersion rejects it if err := os.MkdirAll(odd, 0o755); err != nil { t.Fatal(err) } when := time.Now().Add(-30 * 24 * time.Hour) for _, p := range []string{loose, odd} { if err := os.Chtimes(p, when, when); err != nil { t.Fatal(err) } } res, err := SweepStaged(opt, nil, 6*time.Hour, time.Now()) if err != nil { t.Fatal(err) } if len(res.Removed) != 0 { t.Fatalf("Removed = %v, want none", res.Removed) } if res.Skipped != 2 { t.Errorf("Skipped = %d, want 2", res.Skipped) } for _, p := range []string{loose, odd} { if _, err := os.Stat(p); err != nil { t.Errorf("%s removed: %v", p, err) } } } func TestSweepStagedNoVersionsDir(t *testing.T) { res, err := SweepStaged(t.TempDir(), nil, 6*time.Hour, time.Now()) if err != nil { t.Fatalf("a box that has never staged anything is not an error: %v", err) } if res.Scanned != 0 { t.Errorf("Scanned = %d, want 0", res.Scanned) } } // A `.tmp` link from a crashed PointLive names a real staged tree and // pins it: reading every entry is cheaper than deciding which links are // current. func TestLivePinsIncludesTempLinks(t *testing.T) { opt := t.TempDir() stage(t, opt, OpsAccount, "v1", "edge", 10, time.Hour) stage(t, opt, OpsAccount, "v2", "edge", 10, time.Hour) if err := PointLive(opt, "edge.example.com", OpsAccount, "v1", "edge"); err != nil { t.Fatal(err) } if err := os.Symlink(VersionPath(opt, OpsAccount, "v2", "edge"), LiveLink(opt, "edge.example.com")+".tmp"); err != nil { t.Fatal(err) } pins, err := LivePins(opt) if err != nil { t.Fatal(err) } if !pins["v1"] || !pins["v2"] { t.Fatalf("LivePins = %v, want v1 and v2", pins) } } // A dangling link still pins: the version it names is what the unit's // ExecStart resolves through, and the sweep must not widen the hole. func TestLivePinsDanglingLink(t *testing.T) { opt := t.TempDir() if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(VersionPath(opt, OpsAccount, "gone", "edge"), LiveLink(opt, "edge.example.com")); err != nil { t.Fatal(err) } pins, err := LivePins(opt) if err != nil { t.Fatal(err) } if !pins["gone"] { t.Fatalf("LivePins = %v, want gone pinned", pins) } } // A live dir this package cannot read is an error, not a narrowed pin set: // the caller must decline to delete rather than delete a live binary. func TestLivePinsErrorsOnUnreadableEntry(t *testing.T) { opt := t.TempDir() if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { t.Fatal(err) } // A regular file where a symlink belongs: Readlink returns EINVAL. if err := os.WriteFile(LiveLink(opt, "stray"), []byte("not a link"), 0o644); err != nil { t.Fatal(err) } if _, err := LivePins(opt); err == nil { t.Fatal("LivePins accepted a non-symlink entry; it must refuse") } } func TestLivePinsNoLiveDir(t *testing.T) { pins, err := LivePins(t.TempDir()) if err != nil { t.Fatalf("a box with no live dir is not an error: %v", err) } if len(pins) != 0 { t.Errorf("pins = %v, want empty", pins) } }