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
This commit is contained in:
Evan Jarrett
2026-09-02 21:12:13 -05:00
co-authored by Claude Opus 5
parent af7522b154
commit dfd604b106
7 changed files with 430 additions and 34 deletions
+15
View File
@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"atcr.io/pkg/logging"
"atcr.io/scanner/internal/client"
"atcr.io/scanner/internal/config"
"atcr.io/scanner/internal/queue"
@@ -50,6 +51,20 @@ Environment variables always override file values (SCANNER_ prefix).`,
return fmt.Errorf("failed to load config: %w", err)
}
// Without this the scanner runs on slog's default handler: log_level and
// log_shipper are silently ignored, nothing is shipped anywhere, and an
// idle scanner is indistinguishable from a wedged one.
logging.InitLoggerWithShipper(cfg.LogLevel, logging.ShipperConfig{
Backend: cfg.LogShipper.Backend,
URL: cfg.LogShipper.URL,
BatchSize: cfg.LogShipper.BatchSize,
FlushInterval: cfg.LogShipper.FlushInterval,
Service: "scanner",
Username: cfg.LogShipper.Username,
Password: cfg.LogShipper.Password,
})
defer logging.Shutdown()
slog.Info("Starting ATCR scanner",
"hold_url", cfg.Hold.URL,
"workers", cfg.Scanner.Workers,
+2 -2
View File
@@ -125,11 +125,11 @@ func scanVulnerabilities(ctx context.Context, s *sbom.SBOM, vulnDBPath string) (
"low", summary.Low,
"total", summary.Total)
report := map[string]interface{}{
report := map[string]any{
"matches": allMatches.Sorted(),
"source": s.Source,
"distro": s.Artifacts.LinuxDistribution,
"descriptor": map[string]interface{}{
"descriptor": map[string]any{
"name": "grype",
"version": "v0.107.1",
},
+34 -2
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"os"
"runtime"
"strings"
"sync"
"time"
@@ -151,6 +152,37 @@ var unscannableConfigTypes = map[string]bool{
"application/vnd.dsse.envelope.v1+json": true, // DSSE envelopes (SLSA)
}
// skipReason reports why a job cannot be scanned, or "" when it can be.
func skipReason(job *scanner.ScanJob) string {
if unscannableConfigTypes[job.Config.MediaType] {
return fmt.Sprintf("unscannable artifact type %s", job.Config.MediaType)
}
// A buildx attestation manifest carries an ordinary image config with a
// single in-toto or DSSE payload as its layer, so the config media type
// alone does not identify it. buildOCILayout drops every non-tar layer,
// which would hand Syft an image with nothing in it.
if len(job.Layers) > 0 && !hasScannableLayer(job.Layers) {
return fmt.Sprintf("no scannable layers (%s)", job.Layers[0].MediaType)
}
return ""
}
// hasScannableLayer mirrors the layer filter in buildOCILayout: anything that
// is not a tar of some flavour is not something Syft can read.
func hasScannableLayer(layers []scanner.BlobDescriptor) bool {
for _, layer := range layers {
if layer.Digest == "" {
continue
}
if layer.MediaType == "" || strings.Contains(layer.MediaType, "tar") {
return true
}
}
return false
}
func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*scanner.ScanResult, error) {
startTime := time.Now()
@@ -158,8 +190,8 @@ func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*sc
// Returning *SkipError tells the worker dispatch loop to send a "skipped"
// message rather than an "error" — the hold marks these records as
// permanently skipped and won't retry them on the rescan interval.
if unscannableConfigTypes[job.Config.MediaType] {
return nil, &SkipError{Reason: fmt.Sprintf("unscannable artifact type %s", job.Config.MediaType)}
if reason := skipReason(job); reason != "" {
return nil, &SkipError{Reason: reason}
}
// Ensure tmp dir exists
+83
View File
@@ -0,0 +1,83 @@
package scan
import (
"testing"
scanner "atcr.io/scanner"
)
// TestSkipReason covers the artifact shapes the scanner must refuse before it
// spends a download on them. The attestation case is the one that reached
// production: an in-toto SLSA provenance manifest carries an ordinary image
// config, so the config media type check alone waved it through and Syft was
// handed an OCI layout with no layers in it.
func TestSkipReason(t *testing.T) {
tests := []struct {
name string
configType string
layerTypes []string
wantSkip bool
}{
{
name: "container image",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{"application/vnd.oci.image.layer.v1.tar+gzip"},
},
{
name: "docker image",
configType: "application/vnd.docker.container.image.v1+json",
layerTypes: []string{"application/vnd.docker.image.rootfs.diff.tar.gzip"},
},
{
name: "layer media type absent",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{""},
},
{
name: "helm chart",
configType: "application/vnd.cncf.helm.config.v1+json",
layerTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"},
wantSkip: true,
},
{
name: "in-toto attestation with an image config",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{"application/vnd.in-toto+json"},
wantSkip: true,
},
{
name: "dsse envelope layer",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{"application/vnd.dsse.envelope.v1+json"},
wantSkip: true,
},
{
name: "mixed layers keep the scannable one",
configType: "application/vnd.oci.image.config.v1+json",
layerTypes: []string{"application/vnd.in-toto+json", "application/vnd.oci.image.layer.v1.tar"},
},
{
name: "no layers at all is left to the pipeline",
configType: "application/vnd.oci.image.config.v1+json",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
job := &scanner.ScanJob{
Config: scanner.BlobDescriptor{MediaType: tt.configType, Digest: "sha256:config"},
}
for i, mt := range tt.layerTypes {
job.Layers = append(job.Layers, scanner.BlobDescriptor{
MediaType: mt,
Digest: "sha256:layer" + string(rune('a'+i)),
})
}
reason := skipReason(job)
if got := reason != ""; got != tt.wantSkip {
t.Errorf("skipReason = %q, wantSkip=%v", reason, tt.wantSkip)
}
})
}
}
+10 -10
View File
@@ -6,16 +6,16 @@ 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"`
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"`
}