Files
at-container-registry/scanner/types.go
T
Evan JarrettandClaude Opus 5 dfd604b106 hold/scanner: stop one undispatchable job freezing all scanning
Vulnerability scanning produced nothing across the whole deployment for
nine days, from 2026-08-25 01:20:48 until a scanner restart on 2026-09-03.
The scanner was connected and idle, the hold's discovery pass kept
reporting unscannedFound=15 every four hours, and no scan_jobs row was
created in that entire window.

hasActiveJobs counted pending, assigned and processing rows globally with
no age bound, and waitForCapacity spins while it is true. dispatchLoop
calls it before popping any candidate, so a single pending row that never
reached a terminal state reported "busy" forever: discovery kept pushing
candidates into unscannedQueue and nothing ever popped them. That is why
the symptom was an empty queue rather than a growing one.

Nothing papered over it because push-triggered enqueue only fires for
owner or a tier with scan_on_push, which in production means pro alone.
All 210 manifests pushed to this hold in that window came from free,
supporter, or accounts with no crew row, so the frozen proactive loop was
the only source of jobs.

Nor could it recover on its own. Only Enqueue and drainPendingJobs
dispatch a pending row, and drainPendingJobs runs only when a scanner
newly connects; reDispatchTimedOut considered assigned rows only. The
hold had been up since Aug 14 and the scanner since Aug 21 on the same
websocket, so the drain path had not run since the row appeared.

So bound the capacity gate to pending rows younger than pendingStaleAfter,
give reDispatchTimedOut a pending reclaim, and check RowsAffected on the
assign UPDATE now that two dispatchers can race for a row. waitForCapacity
warns and names the blocking jobs after ten minutes without capacity,
because the failure mode above was completely silent.

Two adjacent fixes for the same outage. The scanner never called
InitLogger, so log_level and log_shipper were dead config and an idle
scanner was mute, which is what made nine days invisible. And skipReason
now also skips a job whose layers contain nothing tar-shaped: the job that
wedged this queue was an in-toto attestation whose config mediaType is an
ordinary image config, so the existing config-type check missed it and
buildOCILayout would have handed Syft an empty image.

The regression tests were verified against the old logic first: three of
them fail on it and pass on the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPWkeCKcbtGoyXyyeMhSps
2026-09-02 21:12:13 -05:00

96 lines
3.6 KiB
Go

// Package scanner defines shared types for the ATCR scanner service.
// These types are self-contained with no imports from the root module.
package scanner
import "encoding/json"
// ScanJob represents a vulnerability scanning job received from the hold service
type ScanJob struct {
Seq int64 `json:"seq"`
ManifestDigest string `json:"manifestDigest"`
Repository string `json:"repository"`
Tag string `json:"tag"`
UserDID string `json:"userDid"`
UserHandle string `json:"userHandle"`
HoldDID string `json:"holdDid"`
HoldEndpoint string `json:"holdEndpoint"`
Tier string `json:"tier"`
Config BlobDescriptor `json:"config"`
Layers []BlobDescriptor `json:"layers"`
}
// ScanJobRaw is the raw WebSocket message with JSON config/layers
type ScanJobRaw struct {
Type string `json:"type"` // "job"
Seq int64 `json:"seq"`
ManifestDigest string `json:"manifestDigest"`
Repository string `json:"repository"`
Tag string `json:"tag"`
UserDID string `json:"userDid"`
UserHandle string `json:"userHandle"`
HoldDID string `json:"holdDid"`
HoldEndpoint string `json:"holdEndpoint"`
Tier string `json:"tier"`
Config json.RawMessage `json:"config"`
Layers json.RawMessage `json:"layers"`
}
// BlobDescriptor describes a blob (layer or config) in a container image
type BlobDescriptor struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
MediaType string `json:"mediaType"`
}
// ScanResult contains the output of a completed scan
type ScanResult struct {
ManifestDigest string `json:"manifestDigest"`
SBOM []byte `json:"sbom,omitempty"`
SBOMDigest string `json:"sbomDigest,omitempty"`
VulnReport []byte `json:"vulnReport,omitempty"`
VulnDigest string `json:"vulnDigest,omitempty"`
Summary *VulnerabilitySummary `json:"summary,omitempty"`
}
// VulnerabilitySummary contains counts of vulnerabilities by severity
type VulnerabilitySummary struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Total int `json:"total"`
}
// AckMessage is sent from scanner to hold to acknowledge job receipt
type AckMessage struct {
Type string `json:"type"` // "ack"
Seq int64 `json:"seq"`
}
// ResultMessage is sent from scanner to hold with scan results
type ResultMessage struct {
Type string `json:"type"` // "result"
Seq int64 `json:"seq"`
SBOM string `json:"sbom,omitempty"`
VulnReport string `json:"vulnReport,omitempty"`
Summary *VulnerabilitySummary `json:"summary,omitempty"`
}
// ErrorMessage is sent from scanner to hold when a scan fails
type ErrorMessage struct {
Type string `json:"type"` // "error"
Seq int64 `json:"seq"`
Error string `json:"error"`
}
// SkippedMessage is sent from scanner to hold when an artifact is intentionally
// not scanned (e.g., helm charts, in-toto attestations). Distinct from
// ErrorMessage so the hold can mark the scan record as "skipped" rather than
// "failed" — the stale-scan loop will leave skipped records alone since the
// outcome won't change without a code change in the scanner.
type SkippedMessage struct {
Type string `json:"type"` // "skipped"
Seq int64 `json:"seq"`
Reason string `json:"reason"`
}