diff --git a/pkg/hold/config.go b/pkg/hold/config.go index 31aca26..48bddd7 100644 --- a/pkg/hold/config.go +++ b/pkg/hold/config.go @@ -249,8 +249,8 @@ func ExampleYAML() ([]byte, error) { cfg.Quota = quota.Config{ Tiers: map[string]quota.TierConfig{ "deckhand": {Quota: "5GB"}, - "bosun": {Quota: "50GB"}, - "quartermaster": {Quota: "100GB"}, + "bosun": {Quota: "50GB", ScanOnPush: true}, + "quartermaster": {Quota: "100GB", ScanOnPush: true}, }, Defaults: quota.DefaultsConfig{ NewCrewTier: "deckhand", diff --git a/pkg/hold/oci/xrpc.go b/pkg/hold/oci/xrpc.go index 909a122..1a27c28 100644 --- a/pkg/hold/oci/xrpc.go +++ b/pkg/hold/oci/xrpc.go @@ -387,28 +387,42 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques tier = stats.Tier } - configJSON, _ := json.Marshal(req.Manifest.Config) - layersJSON, _ := json.Marshal(req.Manifest.Layers) + // Check if this tier gets scan-on-push. + // Captain ("owner") always gets scan-on-push. + // When quotas are disabled, all pushes trigger scans (backwards compat). + shouldScan := tier == "owner" || + h.quotaMgr == nil || !h.quotaMgr.IsEnabled() || + h.quotaMgr.ScanOnPush(tier) - // Resolve handle for scanner context - _, userHandle, _, resolveErr := atproto.ResolveIdentity(ctx, req.UserDID) - if resolveErr != nil { - userHandle = req.UserDID - } + if shouldScan { + configJSON, _ := json.Marshal(req.Manifest.Config) + layersJSON, _ := json.Marshal(req.Manifest.Layers) - if err := h.scanBroadcaster.Enqueue(&pds.ScanJobEvent{ - ManifestDigest: req.ManifestDigest, - Repository: req.Repository, - Tag: req.Tag, - UserDID: req.UserDID, - UserHandle: userHandle, - Tier: tier, - Config: configJSON, - Layers: layersJSON, - }); err != nil { - slog.Error("Failed to enqueue scan job", + // Resolve handle for scanner context + _, userHandle, _, resolveErr := atproto.ResolveIdentity(ctx, req.UserDID) + if resolveErr != nil { + userHandle = req.UserDID + } + + if err := h.scanBroadcaster.Enqueue(&pds.ScanJobEvent{ + ManifestDigest: req.ManifestDigest, + Repository: req.Repository, + Tag: req.Tag, + UserDID: req.UserDID, + UserHandle: userHandle, + Tier: tier, + Config: configJSON, + Layers: layersJSON, + }); err != nil { + slog.Error("Failed to enqueue scan job", + "repository", req.Repository, + "error", err) + } + } else { + slog.Debug("Scan-on-push skipped for tier", + "tier", tier, "repository", req.Repository, - "error", err) + "userDid", req.UserDID) } } } diff --git a/pkg/hold/pds/scan_broadcaster.go b/pkg/hold/pds/scan_broadcaster.go index 5850fa1..a445ff2 100644 --- a/pkg/hold/pds/scan_broadcaster.go +++ b/pkg/hold/pds/scan_broadcaster.go @@ -598,13 +598,6 @@ func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage) "error", msg.Error) } -func truncateError(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] -} - // drainPendingJobs sends pending/timed-out jobs to a newly connected scanner. // Collects all pending rows first, closes cursor, then assigns and dispatches // to avoid holding a SELECT cursor open during UPDATEs (prevents SQLite BUSY). diff --git a/pkg/hold/quota/config.go b/pkg/hold/quota/config.go index ac7a33b..20c2ad3 100644 --- a/pkg/hold/quota/config.go +++ b/pkg/hold/quota/config.go @@ -24,6 +24,9 @@ type Config struct { type TierConfig struct { // Human-readable size limit, e.g. "5GB", "50GB", "1TB". Quota string `yaml:"quota" comment:"Storage quota limit (e.g. \"5GB\", \"50GB\", \"1TB\")."` + + // Whether pushing triggers an immediate vulnerability scan. + ScanOnPush bool `yaml:"scan_on_push" comment:"Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling."` } // DefaultsConfig represents default settings. @@ -165,6 +168,33 @@ func (m *Manager) GetDefaultTier() string { return m.config.Defaults.NewCrewTier } +// ScanOnPush returns whether scan-on-push is enabled for a tier. +// Follows the same fallback logic as GetTierLimit: +// 1. If quotas disabled → false (caller decides default) +// 2. If tierKey provided and found → that tier's ScanOnPush +// 3. If tierKey not found or empty → use defaults.new_crew_tier +// 4. If default tier not found → false +func (m *Manager) ScanOnPush(tierKey string) bool { + if !m.IsEnabled() { + return false + } + + if tierKey != "" { + if tier, ok := m.config.Tiers[tierKey]; ok { + return tier.ScanOnPush + } + } + + // Fall back to default tier + if m.config.Defaults.NewCrewTier != "" { + if tier, ok := m.config.Tiers[m.config.Defaults.NewCrewTier]; ok { + return tier.ScanOnPush + } + } + + return false +} + // TierCount returns the number of configured tiers func (m *Manager) TierCount() int { return len(m.tiers) diff --git a/pkg/hold/quota/config_test.go b/pkg/hold/quota/config_test.go index 8c70e55..24317af 100644 --- a/pkg/hold/quota/config_test.go +++ b/pkg/hold/quota/config_test.go @@ -294,6 +294,102 @@ defaults: } } +func TestScanOnPush_Disabled(t *testing.T) { + // Quotas disabled → ScanOnPush returns false + m, err := NewManagerFromConfig(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.ScanOnPush("bosun") { + t.Error("expected false when quotas disabled") + } +} + +func TestScanOnPush_ExplicitTier(t *testing.T) { + cfg := &Config{ + Tiers: map[string]TierConfig{ + "deckhand": {Quota: "5GB", ScanOnPush: false}, + "bosun": {Quota: "50GB", ScanOnPush: true}, + "quartermaster": {Quota: "100GB", ScanOnPush: true}, + }, + Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, + } + m, err := NewManagerFromConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if m.ScanOnPush("deckhand") { + t.Error("expected false for deckhand") + } + if !m.ScanOnPush("bosun") { + t.Error("expected true for bosun") + } + if !m.ScanOnPush("quartermaster") { + t.Error("expected true for quartermaster") + } +} + +func TestScanOnPush_FallbackToDefault(t *testing.T) { + cfg := &Config{ + Tiers: map[string]TierConfig{ + "deckhand": {Quota: "5GB", ScanOnPush: false}, + "bosun": {Quota: "50GB", ScanOnPush: true}, + }, + Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, + } + m, err := NewManagerFromConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Unknown tier falls back to default (deckhand) which is false + if m.ScanOnPush("unknown") { + t.Error("expected false for unknown tier (fallback to deckhand)") + } + + // Empty tier also falls back + if m.ScanOnPush("") { + t.Error("expected false for empty tier (fallback to deckhand)") + } +} + +func TestScanOnPush_FallbackToDefaultTrue(t *testing.T) { + cfg := &Config{ + Tiers: map[string]TierConfig{ + "deckhand": {Quota: "5GB", ScanOnPush: true}, + }, + Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, + } + m, err := NewManagerFromConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Unknown tier falls back to default (deckhand) which is true + if !m.ScanOnPush("unknown") { + t.Error("expected true for unknown tier (fallback to deckhand with scan_on_push: true)") + } +} + +func TestScanOnPush_ZeroValue(t *testing.T) { + // When scan_on_push is omitted from config, Go zero value = false + cfg := &Config{ + Tiers: map[string]TierConfig{ + "deckhand": {Quota: "5GB"}, // ScanOnPush not set + }, + Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, + } + m, err := NewManagerFromConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if m.ScanOnPush("deckhand") { + t.Error("expected false when scan_on_push is omitted (zero value)") + } +} + func TestNewManager_NoDefaultTier(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "quotas.yaml")