A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

add scan on push to quota

evan.jarrett.net 33548ecf 76383ec7

verified
+161 -28
+2 -2
pkg/hold/config.go
··· 249 249 cfg.Quota = quota.Config{ 250 250 Tiers: map[string]quota.TierConfig{ 251 251 "deckhand": {Quota: "5GB"}, 252 - "bosun": {Quota: "50GB"}, 253 - "quartermaster": {Quota: "100GB"}, 252 + "bosun": {Quota: "50GB", ScanOnPush: true}, 253 + "quartermaster": {Quota: "100GB", ScanOnPush: true}, 254 254 }, 255 255 Defaults: quota.DefaultsConfig{ 256 256 NewCrewTier: "deckhand",
+33 -19
pkg/hold/oci/xrpc.go
··· 387 387 tier = stats.Tier 388 388 } 389 389 390 - configJSON, _ := json.Marshal(req.Manifest.Config) 391 - layersJSON, _ := json.Marshal(req.Manifest.Layers) 390 + // Check if this tier gets scan-on-push. 391 + // Captain ("owner") always gets scan-on-push. 392 + // When quotas are disabled, all pushes trigger scans (backwards compat). 393 + shouldScan := tier == "owner" || 394 + h.quotaMgr == nil || !h.quotaMgr.IsEnabled() || 395 + h.quotaMgr.ScanOnPush(tier) 392 396 393 - // Resolve handle for scanner context 394 - _, userHandle, _, resolveErr := atproto.ResolveIdentity(ctx, req.UserDID) 395 - if resolveErr != nil { 396 - userHandle = req.UserDID 397 - } 397 + if shouldScan { 398 + configJSON, _ := json.Marshal(req.Manifest.Config) 399 + layersJSON, _ := json.Marshal(req.Manifest.Layers) 398 400 399 - if err := h.scanBroadcaster.Enqueue(&pds.ScanJobEvent{ 400 - ManifestDigest: req.ManifestDigest, 401 - Repository: req.Repository, 402 - Tag: req.Tag, 403 - UserDID: req.UserDID, 404 - UserHandle: userHandle, 405 - Tier: tier, 406 - Config: configJSON, 407 - Layers: layersJSON, 408 - }); err != nil { 409 - slog.Error("Failed to enqueue scan job", 401 + // Resolve handle for scanner context 402 + _, userHandle, _, resolveErr := atproto.ResolveIdentity(ctx, req.UserDID) 403 + if resolveErr != nil { 404 + userHandle = req.UserDID 405 + } 406 + 407 + if err := h.scanBroadcaster.Enqueue(&pds.ScanJobEvent{ 408 + ManifestDigest: req.ManifestDigest, 409 + Repository: req.Repository, 410 + Tag: req.Tag, 411 + UserDID: req.UserDID, 412 + UserHandle: userHandle, 413 + Tier: tier, 414 + Config: configJSON, 415 + Layers: layersJSON, 416 + }); err != nil { 417 + slog.Error("Failed to enqueue scan job", 418 + "repository", req.Repository, 419 + "error", err) 420 + } 421 + } else { 422 + slog.Debug("Scan-on-push skipped for tier", 423 + "tier", tier, 410 424 "repository", req.Repository, 411 - "error", err) 425 + "userDid", req.UserDID) 412 426 } 413 427 } 414 428 }
-7
pkg/hold/pds/scan_broadcaster.go
··· 598 598 "error", msg.Error) 599 599 } 600 600 601 - func truncateError(s string, maxLen int) string { 602 - if len(s) <= maxLen { 603 - return s 604 - } 605 - return s[:maxLen] 606 - } 607 - 608 601 // drainPendingJobs sends pending/timed-out jobs to a newly connected scanner. 609 602 // Collects all pending rows first, closes cursor, then assigns and dispatches 610 603 // to avoid holding a SELECT cursor open during UPDATEs (prevents SQLite BUSY).
+30
pkg/hold/quota/config.go
··· 24 24 type TierConfig struct { 25 25 // Human-readable size limit, e.g. "5GB", "50GB", "1TB". 26 26 Quota string `yaml:"quota" comment:"Storage quota limit (e.g. \"5GB\", \"50GB\", \"1TB\")."` 27 + 28 + // Whether pushing triggers an immediate vulnerability scan. 29 + ScanOnPush bool `yaml:"scan_on_push" comment:"Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling."` 27 30 } 28 31 29 32 // DefaultsConfig represents default settings. ··· 163 166 return "" 164 167 } 165 168 return m.config.Defaults.NewCrewTier 169 + } 170 + 171 + // ScanOnPush returns whether scan-on-push is enabled for a tier. 172 + // Follows the same fallback logic as GetTierLimit: 173 + // 1. If quotas disabled → false (caller decides default) 174 + // 2. If tierKey provided and found → that tier's ScanOnPush 175 + // 3. If tierKey not found or empty → use defaults.new_crew_tier 176 + // 4. If default tier not found → false 177 + func (m *Manager) ScanOnPush(tierKey string) bool { 178 + if !m.IsEnabled() { 179 + return false 180 + } 181 + 182 + if tierKey != "" { 183 + if tier, ok := m.config.Tiers[tierKey]; ok { 184 + return tier.ScanOnPush 185 + } 186 + } 187 + 188 + // Fall back to default tier 189 + if m.config.Defaults.NewCrewTier != "" { 190 + if tier, ok := m.config.Tiers[m.config.Defaults.NewCrewTier]; ok { 191 + return tier.ScanOnPush 192 + } 193 + } 194 + 195 + return false 166 196 } 167 197 168 198 // TierCount returns the number of configured tiers
+96
pkg/hold/quota/config_test.go
··· 294 294 } 295 295 } 296 296 297 + func TestScanOnPush_Disabled(t *testing.T) { 298 + // Quotas disabled → ScanOnPush returns false 299 + m, err := NewManagerFromConfig(nil) 300 + if err != nil { 301 + t.Fatalf("unexpected error: %v", err) 302 + } 303 + if m.ScanOnPush("bosun") { 304 + t.Error("expected false when quotas disabled") 305 + } 306 + } 307 + 308 + func TestScanOnPush_ExplicitTier(t *testing.T) { 309 + cfg := &Config{ 310 + Tiers: map[string]TierConfig{ 311 + "deckhand": {Quota: "5GB", ScanOnPush: false}, 312 + "bosun": {Quota: "50GB", ScanOnPush: true}, 313 + "quartermaster": {Quota: "100GB", ScanOnPush: true}, 314 + }, 315 + Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, 316 + } 317 + m, err := NewManagerFromConfig(cfg) 318 + if err != nil { 319 + t.Fatalf("unexpected error: %v", err) 320 + } 321 + 322 + if m.ScanOnPush("deckhand") { 323 + t.Error("expected false for deckhand") 324 + } 325 + if !m.ScanOnPush("bosun") { 326 + t.Error("expected true for bosun") 327 + } 328 + if !m.ScanOnPush("quartermaster") { 329 + t.Error("expected true for quartermaster") 330 + } 331 + } 332 + 333 + func TestScanOnPush_FallbackToDefault(t *testing.T) { 334 + cfg := &Config{ 335 + Tiers: map[string]TierConfig{ 336 + "deckhand": {Quota: "5GB", ScanOnPush: false}, 337 + "bosun": {Quota: "50GB", ScanOnPush: true}, 338 + }, 339 + Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, 340 + } 341 + m, err := NewManagerFromConfig(cfg) 342 + if err != nil { 343 + t.Fatalf("unexpected error: %v", err) 344 + } 345 + 346 + // Unknown tier falls back to default (deckhand) which is false 347 + if m.ScanOnPush("unknown") { 348 + t.Error("expected false for unknown tier (fallback to deckhand)") 349 + } 350 + 351 + // Empty tier also falls back 352 + if m.ScanOnPush("") { 353 + t.Error("expected false for empty tier (fallback to deckhand)") 354 + } 355 + } 356 + 357 + func TestScanOnPush_FallbackToDefaultTrue(t *testing.T) { 358 + cfg := &Config{ 359 + Tiers: map[string]TierConfig{ 360 + "deckhand": {Quota: "5GB", ScanOnPush: true}, 361 + }, 362 + Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, 363 + } 364 + m, err := NewManagerFromConfig(cfg) 365 + if err != nil { 366 + t.Fatalf("unexpected error: %v", err) 367 + } 368 + 369 + // Unknown tier falls back to default (deckhand) which is true 370 + if !m.ScanOnPush("unknown") { 371 + t.Error("expected true for unknown tier (fallback to deckhand with scan_on_push: true)") 372 + } 373 + } 374 + 375 + func TestScanOnPush_ZeroValue(t *testing.T) { 376 + // When scan_on_push is omitted from config, Go zero value = false 377 + cfg := &Config{ 378 + Tiers: map[string]TierConfig{ 379 + "deckhand": {Quota: "5GB"}, // ScanOnPush not set 380 + }, 381 + Defaults: DefaultsConfig{NewCrewTier: "deckhand"}, 382 + } 383 + m, err := NewManagerFromConfig(cfg) 384 + if err != nil { 385 + t.Fatalf("unexpected error: %v", err) 386 + } 387 + 388 + if m.ScanOnPush("deckhand") { 389 + t.Error("expected false when scan_on_push is omitted (zero value)") 390 + } 391 + } 392 + 297 393 func TestNewManager_NoDefaultTier(t *testing.T) { 298 394 tmpDir := t.TempDir() 299 395 configPath := filepath.Join(tmpDir, "quotas.yaml")