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

Configure Feed

Select the types of activity you want to include in your feed.

add scan on push to quota

+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")