mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 19:54:15 +00:00
buildOCILayout already removes its scan dir on every error path, and syft.go
defers the stereoscope generator's Cleanup. What neither can do is clean up
after a process that dies mid-scan: the deferred call never runs, and nothing
afterwards ever looks at what was left. Every restart therefore leaks the
in-flight layout and extraction permanently, and a restart is routine — a
deploy is one.
On seamark-hold that reached 8.8 GB of orphaned scan-*, syft-scan-* and
syft-cataloger-* directories under a 20 GB disk, at which point the disk was
97% full and scans began failing on it:
failed to load OCI image: unable to populate layer cache
dir="/var/lib/seamark/scanner/tmp/syft-scan-1546187834/..."
: no space left on device
failed to download layer 5: failed to write blob:
write /var/lib/seamark/scanner/tmp/scan-4160414849/blobs/sha256/...
: no space left on device
The leaked directories cluster at the scanner's restart timestamps, which is
what identifies the killed process rather than the error paths as the source.
Manual removal reclaimed 8.8 GB and took the disk from 97% to 50%.
Startup is where this belongs: it is the one moment the previous process is
known to be gone, and it is immediately after the event that caused the leak.
The sweep runs in WorkerPool.Start after TMPDIR is set and before any worker
can dequeue, so nothing it removes can be work in progress here.
Three constraints shape what it will touch:
- Only the three per-job prefixes, only as direct children, only
directories. The Grype database lives beside the tmp dir at
<parent>/vulndb and go-getter unpacks into grype-dl underneath it; both
are state the scanner needs and neither matches a prefix. The prefixes now
have one definition each, used by both the creator and the sweeper, so
renaming a directory cannot silently take it out of the sweep's scope.
- An age threshold, vuln.sweep_max_age, default 1h. A second scanner sharing
the directory has an in-flight scan-* dir that is minutes old, and
scanner.job_timeout is 8m, so an hour clears both with room to spare. 0
disables the sweep rather than removing a peer's live work.
- Nothing is fatal. A stat or removal failure is a WARN and the sweep moves
on, so a permission problem in the tmp dir cannot keep the scanner from
starting.
The sweep only runs at startup, so a scanner that is killed twice between
deploys carries the first leak until its next restart. That is the tradeoff
for never racing a live peer; a periodic sweep would be the follow-up if
processes ever live long enough for it to matter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
149 lines
5.1 KiB
Go
149 lines
5.1 KiB
Go
package scan
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// staleAge is comfortably past the maxAge every case below sweeps with, so a
|
|
// "stale" fixture is stale regardless of filesystem timestamp granularity.
|
|
const staleAge = 3 * time.Hour
|
|
|
|
// mkStaleDir creates a directory under parent holding one file of the given
|
|
// size, then backdates it so the sweep sees it as old.
|
|
func mkStaleDir(t *testing.T, parent, name string, size int, age time.Duration) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(parent, name)
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", path, err)
|
|
}
|
|
if size > 0 {
|
|
if err := os.WriteFile(filepath.Join(path, "blob"), make([]byte, size), 0o644); err != nil {
|
|
t.Fatalf("write blob in %s: %v", path, err)
|
|
}
|
|
}
|
|
when := time.Now().Add(-age)
|
|
if err := os.Chtimes(path, when, when); err != nil {
|
|
t.Fatalf("chtimes %s: %v", path, err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// TestSweepTmpDirRemovesOnlyStaleScanDirs is the whole contract of the sweep:
|
|
// the three shapes a dead scan leaves behind go, and everything else in the
|
|
// tmp dir stays. The preserved entries are not hypothetical — the Grype
|
|
// database lives at <parent>/vulndb and go-getter unpacks into grype-dl under
|
|
// the tmp dir, so a sweep that took either would cost a full DB download on
|
|
// every restart.
|
|
func TestSweepTmpDirRemovesOnlyStaleScanDirs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dir string
|
|
age time.Duration
|
|
wantGone bool
|
|
}{
|
|
{name: "orphaned OCI layout", dir: "scan-1234567890", age: staleAge, wantGone: true},
|
|
{name: "orphaned stereoscope extraction", dir: "syft-scan-1546187834", age: staleAge, wantGone: true},
|
|
{name: "orphaned syft cataloging scratch", dir: "syft-cataloger-99", age: staleAge, wantGone: true},
|
|
{name: "scan a peer may still be running", dir: "scan-inflight", age: time.Minute},
|
|
{name: "grype download dir", dir: "grype-dl", age: staleAge},
|
|
{name: "vulndb alongside the tmp dir", dir: "vulndb", age: staleAge},
|
|
{name: "unrelated directory", dir: "cache", age: staleAge},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
for _, tt := range tests {
|
|
mkStaleDir(t, tmpDir, tt.dir, 512, tt.age)
|
|
}
|
|
// A file, not a directory, that matches a swept prefix. Only directories
|
|
// are ours; a stray file is somebody else's business.
|
|
stray := filepath.Join(tmpDir, "scan-notes.txt")
|
|
if err := os.WriteFile(stray, []byte("hello"), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", stray, err)
|
|
}
|
|
when := time.Now().Add(-staleAge)
|
|
if err := os.Chtimes(stray, when, when); err != nil {
|
|
t.Fatalf("chtimes %s: %v", stray, err)
|
|
}
|
|
|
|
sweepTmpDir(tmpDir, time.Hour)
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := os.Stat(filepath.Join(tmpDir, tt.dir))
|
|
switch {
|
|
case tt.wantGone && err == nil:
|
|
t.Errorf("%s survived the sweep; nothing else ever reclaims it", tt.dir)
|
|
case !tt.wantGone && err != nil:
|
|
t.Errorf("%s was removed by the sweep: %v", tt.dir, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
if _, err := os.Stat(stray); err != nil {
|
|
t.Errorf("scan-notes.txt was removed by the sweep: %v", err)
|
|
}
|
|
if _, err := os.Stat(tmpDir); err != nil {
|
|
t.Errorf("the tmp dir itself was removed: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSweepTmpDirDisabledAndMissing covers the two ways the sweep is asked to
|
|
// do nothing. Neither may touch anything, and neither may fail: this runs on
|
|
// the startup path, before any worker exists to report an error.
|
|
func TestSweepTmpDirDisabledAndMissing(t *testing.T) {
|
|
t.Run("zero max age disables the sweep", func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
stale := mkStaleDir(t, tmpDir, "scan-old", 16, staleAge)
|
|
|
|
sweepTmpDir(tmpDir, 0)
|
|
|
|
if _, err := os.Stat(stale); err != nil {
|
|
t.Errorf("sweep ran with max age 0 and removed %s: %v", stale, err)
|
|
}
|
|
})
|
|
|
|
t.Run("tmp dir does not exist", func(t *testing.T) {
|
|
sweepTmpDir(filepath.Join(t.TempDir(), "never-created"), time.Hour)
|
|
})
|
|
|
|
t.Run("empty tmp dir path", func(t *testing.T) {
|
|
sweepTmpDir("", time.Hour)
|
|
})
|
|
}
|
|
|
|
// TestSweepTmpDirContinuesPastAnUnremovableEntry is the "never fatal" half of
|
|
// the contract. One directory the process cannot remove must not cost the
|
|
// sweep the rest of the reclaim, and must not stop the scanner from starting.
|
|
func TestSweepTmpDirContinuesPastAnUnremovableEntry(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root ignores the directory permissions this test relies on")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
|
|
// A stale scan dir made read-only, so RemoveAll cannot unlink what is
|
|
// inside it and fails partway through, the way a root-owned leftover or a
|
|
// busy mount would.
|
|
locked := mkStaleDir(t, tmpDir, "scan-locked", 32, staleAge)
|
|
if err := os.Chmod(locked, 0o555); err != nil {
|
|
t.Fatalf("chmod %s: %v", locked, err)
|
|
}
|
|
// Restore write permission or t.TempDir's own cleanup fails too.
|
|
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
|
|
|
removable := mkStaleDir(t, tmpDir, "syft-scan-removable", 32, staleAge)
|
|
|
|
sweepTmpDir(tmpDir, time.Hour)
|
|
|
|
if _, err := os.Stat(locked); err != nil {
|
|
t.Errorf("expected the unremovable dir to still be there, got: %v", err)
|
|
}
|
|
if _, err := os.Stat(removable); err == nil {
|
|
t.Error("the sweep stopped at the unremovable entry instead of continuing")
|
|
}
|
|
}
|