mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 11:44:16 +00:00
An audit of the scan pipeline and the hold side of scanning found several ways scanning stops without saying so. Each fix here was written test-first: a test expressing the wanted behaviour, confirmed failing for the right reason, then the change. A summary-less result crash-looped both processes. worker.go dereferenced result.Summary unconditionally, but processJob only sets it when Grype runs, and SendResult puts the nil on the wire before the scanner dies on it, so handleResult's unguarded log killed the hold too. A nil Summary now means "not scanned for vulnerabilities", deliberately distinct from "scanned, found zero" — inventing a zeroed summary would report every image as clean when Grype never ran. The hold writes a record rather than orphaning the uploaded SBOM, and the appview renders an "SBOM only" state instead of a green Clean badge. The Grype database could wedge with no way back short of a restart. All three throttles in loadVulnDatabase were guarded by vulnDB != nil, so a scanner holding no provider retried a full download on every scan under the exclusive lock. Two earlier attempts at this bug each added one more condition to the same chain; this replaces the chain with a single decision function over a state snapshot, consulted by both call sites so they cannot disagree. That disagreement was itself a bug: the 50-scan reload had never once executed. Two independent halts. An unparseable frame was dropped in silence, stranding a row that held the hold's only dispatch slot forever; it is now answered "skipped" on first delivery. The 10-minute sweep leaked the in-flight digest and wrote no record, permanently retiring one image per timeout. A digest went unvalidated into filepath.Join and os.Create, so a layer digest of sha256:../../../x wrote outside the scan directory, and nothing verified that downloaded bytes hashed to the digest naming them. Digests come from records in a user's own PDS. Both are fixed together: verification is what makes an escaping write self-defeating. Concurrency did not work on either axis. The proactive capacity gate was depth-one hold-wide, so neither extra workers nor extra scanner processes received work. Depth is now the sum of the worker counts scanners advertise on connect, the gate is scoped to proactive work, and dispatch prefers the least-loaded scanner. Disconnects no longer hand a running scan to someone else: a scanner keeps a stable per-process identity and reclaims its own rows within a grace window, while a process that truly restarted returns with a new identity and has its work reclaimed, which is correct because the restart did lose it. The hold's scanning deadline measured queueing rather than scanning, because the scanner acks on receipt and handleAck never refreshed assigned_at. A new "started" message, sent by the worker that dequeues the job, separates the two budgets. An older scanner never sends it and falls under the queueing budget, which is more forgiving than the deadline it gets today. Adds an in-process mock hold and an e2e harness that runs the real client, queue and worker pool, seeded with 84 real manifest records fetched from a live PDS. Real image layouts and the Grype database are fetched by scripts and gitignored; suites needing them skip cleanly, so the default run stays offline and fast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1Km3N3uUmeGaj7VbaM8PF
219 lines
7.8 KiB
Go
219 lines
7.8 KiB
Go
// Package e2e wires the real scanner components (WebSocket client, priority
|
|
// queue, worker pool) to a mock hold, so scan scenarios run through the same
|
|
// code path production uses.
|
|
//
|
|
// What this can and cannot cover is worth stating plainly. It drives the
|
|
// scanner through hold behaviour: job dispatch, disconnects, blob faults,
|
|
// artifact shapes. It cannot cover the hold's own state machine (the
|
|
// pending/assigned/processing rows, the ack timeout, the stale-scan loop),
|
|
// because a mock hold has no rows. That half belongs to the ScanBroadcaster
|
|
// tests in pkg/hold/pds, and the two must be kept honest against each other:
|
|
// whenever a scenario here encodes an assumption about what the real hold
|
|
// does on reconnect, a hold-side test should pin that assumption.
|
|
package e2e
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/scanner/internal/client"
|
|
"atcr.io/scanner/internal/config"
|
|
"atcr.io/scanner/internal/mockhold"
|
|
"atcr.io/scanner/internal/queue"
|
|
"atcr.io/scanner/internal/scan"
|
|
)
|
|
|
|
const testSecret = "test-scanner-secret"
|
|
|
|
// Harness holds a running scanner attached to a mock hold.
|
|
type Harness struct {
|
|
Hold *mockhold.Hold
|
|
Queue *queue.JobQueue
|
|
Client *client.HoldClient
|
|
Cfg *config.Config
|
|
|
|
pool *scan.WorkerPool
|
|
}
|
|
|
|
// Option customizes the harness before the scanner starts.
|
|
type Option func(*config.Config)
|
|
|
|
// WithWorkers sets the worker pool size. The default of 1 matches the shipped
|
|
// config default and keeps job ordering deterministic.
|
|
func WithWorkers(n int) Option {
|
|
return func(c *config.Config) { c.Scanner.Workers = n }
|
|
}
|
|
|
|
// WithMaxImageSize sets the compressed-size ceiling checked before download.
|
|
func WithMaxImageSize(n int64) Option {
|
|
return func(c *config.Config) { c.Vuln.MaxImageSize = n }
|
|
}
|
|
|
|
// VulnDBEnv gates every scenario that runs Grype against the real
|
|
// vulnerability database. It follows the ATCR_SCANNER_PERF precedent: a clean
|
|
// checkout runs the ordinary suite offline and in the same time it always did,
|
|
// and the expensive coverage is opt-in.
|
|
const VulnDBEnv = "ATCR_SCANNER_VULNDB"
|
|
|
|
// VulnDBDir is where internal/mockhold/testdata/fetch-vulndb.sh caches the
|
|
// Grype database. It is gitignored: ~2 GB of SQLite.
|
|
func VulnDBDir() string {
|
|
return filepath.Join("..", "mockhold", "testdata", "vulndb")
|
|
}
|
|
|
|
// WithVulnDB turns Grype on and points it at the cached database, skipping the
|
|
// test when either the env gate is unset or the database has not been fetched
|
|
// — the same shape layoutFor uses for blob fixtures, for the same reason:
|
|
// this fixture is too large to commit and too slow to build implicitly.
|
|
//
|
|
// The database is a package-level global in scan guarded by an RWMutex, so the
|
|
// first scenario in a test binary pays the load (tens of seconds, and the
|
|
// mmap'd file is ~2 GB) and every one after it reuses the provider. That is
|
|
// per test binary, not per package: the stubbed-loader tests in
|
|
// internal/scan/vulndb_refresh_test.go link their own binary and their own
|
|
// copy of these globals, so nothing here reaches them and they stay offline.
|
|
//
|
|
// Note that db_path is deliberately the shared cache rather than a t.TempDir:
|
|
// a per-test copy would re-download 2 GB every run. The consequence is that a
|
|
// scenario running against a database more than 14 days old will refresh it
|
|
// in-process (Grype's MaxAllowedBuiltAge), which is slow but correct. Run
|
|
// fetch-vulndb.sh to refresh it out of band.
|
|
func WithVulnDB(t *testing.T) Option {
|
|
t.Helper()
|
|
|
|
if os.Getenv(VulnDBEnv) != "1" {
|
|
t.Skipf("set %s=1 to run scenarios against the real Grype database", VulnDBEnv)
|
|
}
|
|
dir := VulnDBDir()
|
|
if _, err := os.Stat(filepath.Join(dir, "6", "import.json")); err != nil {
|
|
t.Skipf("no Grype database at %s; run scanner/internal/mockhold/testdata/fetch-vulndb.sh", dir)
|
|
}
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
t.Fatalf("resolve vulndb path: %v", err)
|
|
}
|
|
|
|
return func(c *config.Config) {
|
|
c.Vuln.Enabled = true
|
|
c.Vuln.DBPath = abs
|
|
}
|
|
}
|
|
|
|
// testJobCooldown replaces the production 10s inter-job pause. Scenarios here
|
|
// run several jobs through one worker, and the real cooldown would dominate
|
|
// their runtime entirely. A scenario that genuinely depends on the production
|
|
// pause should restore it explicitly and say why.
|
|
const testJobCooldown = 10 * time.Millisecond
|
|
|
|
// Start brings up a mock hold serving blobs, then a real scanner connected to
|
|
// it. Everything is torn down via t.Cleanup.
|
|
//
|
|
// Grype is disabled by default. Enabling it would pull a multi-hundred-MB
|
|
// vulnerability database on first run and make results move as the upstream
|
|
// feed changes; the pipeline still runs blob download plus a real Syft
|
|
// catalog, which is what these scenarios exercise. Database refresh behaviour
|
|
// is covered separately by stubbing loadVulnDB in the scan package, and the
|
|
// matcher itself by the opt-in scenarios in vulnreport_test.go, which pass
|
|
// WithVulnDB to turn Grype on against a locally cached database.
|
|
func Start(t *testing.T, blobs mockhold.BlobSource, opts ...Option) *Harness {
|
|
t.Helper()
|
|
|
|
hold := mockhold.New(blobs, mockhold.WithSecret(testSecret))
|
|
t.Cleanup(hold.Close)
|
|
|
|
cfg := config.DefaultConfig()
|
|
cfg.Hold.URL = hold.URL()
|
|
cfg.Hold.Secret = testSecret
|
|
cfg.Scanner.Workers = 1
|
|
cfg.Vuln.Enabled = false
|
|
cfg.Vuln.TmpDir = t.TempDir()
|
|
|
|
// WorkerPool.Start exports TMPDIR process-wide and deliberately never
|
|
// restores it, which is right in production (Grype's database download and
|
|
// stereoscope's extraction must not land on a small tmpfs) but leaks
|
|
// between tests: the next test's t.TempDir() would resolve against this
|
|
// test's directory, which cleanup has already removed. Restore it here.
|
|
// These tests must not run in parallel for the same reason.
|
|
origTmpDir, hadTmpDir := os.LookupEnv("TMPDIR")
|
|
t.Cleanup(func() {
|
|
if hadTmpDir {
|
|
os.Setenv("TMPDIR", origTmpDir)
|
|
return
|
|
}
|
|
os.Unsetenv("TMPDIR")
|
|
})
|
|
for _, opt := range opts {
|
|
opt(cfg)
|
|
}
|
|
|
|
restoreCooldown := scan.JobCooldown
|
|
scan.JobCooldown = testJobCooldown
|
|
|
|
q := queue.NewJobQueue(cfg.Scanner.QueueSize)
|
|
c := client.NewHoldClient(cfg.Hold.URL, cfg.Hold.Secret, q)
|
|
pool := scan.NewWorkerPool(cfg, q, c)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
pool.Start(ctx)
|
|
go c.Connect()
|
|
|
|
t.Cleanup(func() {
|
|
// Order matters. Workers read scan.JobCooldown on every loop
|
|
// iteration, so restoring it before they have exited is a data race
|
|
// that -race reports against worker.go. Cancelling the context and
|
|
// closing the queue is what releases them (Dequeue returns nil once
|
|
// closed, and the cooldown select wakes on ctx.Done), so join them
|
|
// before touching the global back.
|
|
cancel()
|
|
c.Close()
|
|
q.Close()
|
|
pool.Wait()
|
|
scan.JobCooldown = restoreCooldown
|
|
})
|
|
|
|
if err := hold.WaitForScanner(10 * time.Second); err != nil {
|
|
t.Fatalf("scanner never connected: %v", err)
|
|
}
|
|
|
|
return &Harness{Hold: hold, Queue: q, Client: c, Cfg: cfg, pool: pool}
|
|
}
|
|
|
|
// AwaitTerminal waits for the scanner's final word on a job: a result, an
|
|
// error, or a skip. Returning whichever arrived (rather than asserting a type)
|
|
// lets a test report what actually happened, which matters most for the
|
|
// scenarios where the current behaviour is the thing under examination.
|
|
func (h *Harness) AwaitTerminal(t *testing.T, seq int64, timeout time.Duration) mockhold.Message {
|
|
t.Helper()
|
|
msg, err := h.Hold.WaitForMessage(func(m mockhold.Message) bool {
|
|
return m.Seq == seq && (m.Type == "result" || m.Type == "error" || m.Type == "skipped")
|
|
}, timeout)
|
|
if err != nil {
|
|
t.Fatalf("no terminal message for seq %d within %s; transcript: %s",
|
|
seq, timeout, describe(h.Hold.Transcript()))
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func describe(msgs []mockhold.Message) string {
|
|
if len(msgs) == 0 {
|
|
return "(empty)"
|
|
}
|
|
out := ""
|
|
for _, m := range msgs {
|
|
if out != "" {
|
|
out += ", "
|
|
}
|
|
out += m.Type
|
|
if m.Error != "" {
|
|
out += "(" + m.Error + ")"
|
|
}
|
|
if m.Reason != "" {
|
|
out += "(" + m.Reason + ")"
|
|
}
|
|
}
|
|
return out
|
|
}
|