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

wording

evan.jarrett.net 487fc8a4 e5e59fdc

verified
Changed files
+227 -227
cmd
hold
deploy
docs
lexicons
io
atcr
hold
pkg
appview
handlers
templates
atproto
hold
+1 -1
cmd/hold/main.go
··· 106 106 os.Exit(1) 107 107 } 108 108 if quotaMgr.IsEnabled() { 109 - slog.Info("Quota enforcement enabled", "berths", quotaMgr.BerthCount(), "defaultBerth", quotaMgr.GetDefaultBerth()) 109 + slog.Info("Quota enforcement enabled", "tiers", quotaMgr.TierCount(), "defaultTier", quotaMgr.GetDefaultTier()) 110 110 } else { 111 111 slog.Info("Quota enforcement disabled (no quotas.yaml found)") 112 112 }
+10 -10
deploy/quotas.yaml
··· 2 2 # Copy this file to quotas.yaml to enable quota enforcement. 3 3 # If quotas.yaml doesn't exist, quotas are disabled (unlimited for all users). 4 4 5 - # Berths define quota tiers using nautical crew ranks. 6 - # Each berth has a quota limit specified in human-readable format. 5 + # Tiers define quota levels using nautical crew ranks. 6 + # Each tier has a quota limit specified in human-readable format. 7 7 # Supported units: B, KB, MB, GB, TB, PB (case-insensitive) 8 - berths: 8 + tiers: 9 9 # Entry-level crew - suitable for new or casual users 10 10 deckhand: 11 11 quota: 5GB ··· 18 18 quartermaster: 19 19 quota: 100GB 20 20 21 - # You can add custom berths with any name: 21 + # You can add custom tiers with any name: 22 22 # unlimited_crew: 23 23 # quota: 1TB 24 24 25 25 defaults: 26 - # Default berth assigned to new crew members who don't have an explicit berth. 27 - # This berth must exist in the berths section above. 28 - new_crew_berth: deckhand 26 + # Default tier assigned to new crew members who don't have an explicit tier. 27 + # This tier must exist in the tiers section above. 28 + new_crew_tier: deckhand 29 29 30 30 # Notes: 31 - # - The hold captain (owner) always has unlimited quota regardless of berths. 32 - # - Crew members can be assigned a specific berth in their crew record. 33 - # - If a crew member's berth doesn't exist in config, they fall back to the default. 31 + # - The hold captain (owner) always has unlimited quota regardless of tiers. 32 + # - Crew members can be assigned a specific tier in their crew record. 33 + # - If a crew member's tier doesn't exist in config, they fall back to the default. 34 34 # - Quota is calculated per-user by summing unique blob sizes (deduplicated). 35 35 # - Quota is checked when pushing manifests (after blobs are already uploaded).
+11 -11
docs/QUOTAS.md
··· 507 507 - Email/webhook notifications 508 508 - Grace period before hard enforcement 509 509 510 - ### 3. Berth-Based Quotas (Implemented) 510 + ### 3. Tier-Based Quotas (Implemented) 511 511 512 - ATCR uses nautical-themed "berths" for quota tiers, configured via `quotas.yaml`: 512 + ATCR uses quota tiers to limit storage per crew member, configured via `quotas.yaml`: 513 513 514 514 ```yaml 515 515 # quotas.yaml 516 - berths: 516 + tiers: 517 517 deckhand: # Entry-level crew 518 518 quota: 5GB 519 519 bosun: # Mid-level crew ··· 522 522 quota: 100GB 523 523 524 524 defaults: 525 - new_crew_berth: deckhand # Default berth for new crew members 525 + new_crew_tier: deckhand # Default tier for new crew members 526 526 ``` 527 527 528 - | Berth | Limit | Description | 529 - |-------|-------|-------------| 528 + | Tier | Limit | Description | 529 + |------|-------|-------------| 530 530 | deckhand | 5 GB | Entry-level crew member | 531 531 | bosun | 50 GB | Mid-level crew member | 532 532 | quartermaster | 100 GB | Senior crew member | 533 533 | owner (captain) | Unlimited | Hold owner always has unlimited | 534 534 535 - **Berth Resolution:** 535 + **Tier Resolution:** 536 536 1. If user is captain (owner) → unlimited 537 - 2. If crew member has explicit berth → use that berth's limit 538 - 3. If crew member has no berth → use `defaults.new_crew_berth` 539 - 4. If default berth not found → unlimited 537 + 2. If crew member has explicit tier → use that tier's limit 538 + 3. If crew member has no tier → use `defaults.new_crew_tier` 539 + 4. If default tier not found → unlimited 540 540 541 541 **Crew Record Example:** 542 542 ```json ··· 545 545 "member": "did:plc:alice123", 546 546 "role": "writer", 547 547 "permissions": ["blob:write"], 548 - "berth": "bosun", 548 + "tier": "bosun", 549 549 "addedAt": "2026-01-04T12:00:00Z" 550 550 } 551 551 ```
+2 -2
lexicons/io/atcr/hold/crew.json
··· 29 29 "maxLength": 64 30 30 } 31 31 }, 32 - "berth": { 32 + "tier": { 33 33 "type": "string", 34 - "description": "Optional berth (nautical rank) for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster'). If empty, uses defaults.new_crew_berth from quotas.yaml.", 34 + "description": "Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster'). If empty, uses defaults.new_crew_tier from quotas.yaml.", 35 35 "maxLength": 32 36 36 }, 37 37 "addedAt": {
+3 -3
pkg/appview/handlers/storage.go
··· 26 26 UniqueBlobs int `json:"uniqueBlobs"` 27 27 TotalSize int64 `json:"totalSize"` 28 28 Limit *int64 `json:"limit,omitempty"` // nil = unlimited 29 - Berth string `json:"berth,omitempty"` // e.g., "deckhand", "bosun", "owner" 29 + Tier string `json:"tier,omitempty"` // e.g., "deckhand", "bosun", "owner" 30 30 } 31 31 32 32 func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 110 110 HasLimit bool 111 111 HumanLimit string 112 112 UsagePercent int 113 - Berth string 113 + Tier string 114 114 }{ 115 115 UniqueBlobs: stats.UniqueBlobs, 116 116 TotalSize: stats.TotalSize, ··· 118 118 HasLimit: hasLimit, 119 119 HumanLimit: humanLimit, 120 120 UsagePercent: usagePercent, 121 - Berth: stats.Berth, 121 + Tier: stats.Tier, 122 122 } 123 123 124 124 w.Header().Set("Content-Type", "text/html")
+6 -6
pkg/appview/templates/pages/settings.html
··· 31 31 32 32 <!-- Storage Usage Section --> 33 33 <section class="settings-section storage-section"> 34 - <h2>Storage Usage</h2> 34 + <h2>Stowage</h2> 35 35 <p>Estimated storage usage on your default hold.</p> 36 36 <div id="storage-stats" hx-get="/api/storage" hx-trigger="load" hx-swap="innerHTML"> 37 37 <p><i data-lucide="loader-2" class="spin"></i> Loading...</p> ··· 293 293 white-space: nowrap; 294 294 } 295 295 296 - /* Berth Badge */ 297 - .storage-section .berth-badge { 296 + /* Tier Badge */ 297 + .storage-section .tier-badge { 298 298 text-transform: capitalize; 299 299 padding: 0.125rem 0.5rem; 300 300 border-radius: 4px; ··· 302 302 background: var(--accent-bg, #e0f2fe); 303 303 color: var(--accent, #0369a1); 304 304 } 305 - .storage-section .berth-owner { 305 + .storage-section .tier-owner { 306 306 background: #fef3c7; 307 307 color: #92400e; 308 308 } 309 - .storage-section .berth-quartermaster { 309 + .storage-section .tier-quartermaster { 310 310 background: #dcfce7; 311 311 color: #166534; 312 312 } 313 - .storage-section .berth-bosun { 313 + .storage-section .tier-bosun { 314 314 background: #e0e7ff; 315 315 color: #3730a3; 316 316 }
+3 -3
pkg/appview/templates/partials/storage_stats.html
··· 1 1 {{ define "storage_stats" }} 2 2 <div class="storage-stats"> 3 - {{ if .Berth }} 3 + {{ if .Tier }} 4 4 <div class="stat-row"> 5 - <span class="stat-label">Berth:</span> 6 - <span class="stat-value berth-badge berth-{{ .Berth }}">{{ .Berth }}</span> 5 + <span class="stat-label">Tier:</span> 6 + <span class="stat-value tier-badge tier-{{ .Tier }}">{{ .Tier }}</span> 7 7 </div> 8 8 {{ end }} 9 9 <div class="stat-row">
+33 -33
pkg/atproto/cbor_gen.go
··· 27 27 cw := cbg.NewCborWriter(w) 28 28 fieldCount := 6 29 29 30 - if t.Berth == "" { 30 + if t.Tier == "" { 31 31 fieldCount-- 32 32 } 33 33 ··· 58 58 return err 59 59 } 60 60 61 + // t.Tier (string) (string) 62 + if t.Tier != "" { 63 + 64 + if len("tier") > 8192 { 65 + return xerrors.Errorf("Value in field \"tier\" was too long") 66 + } 67 + 68 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tier"))); err != nil { 69 + return err 70 + } 71 + if _, err := cw.WriteString(string("tier")); err != nil { 72 + return err 73 + } 74 + 75 + if len(t.Tier) > 8192 { 76 + return xerrors.Errorf("Value in field t.Tier was too long") 77 + } 78 + 79 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Tier))); err != nil { 80 + return err 81 + } 82 + if _, err := cw.WriteString(string(t.Tier)); err != nil { 83 + return err 84 + } 85 + } 86 + 61 87 // t.Type (string) (string) 62 88 if len("$type") > 8192 { 63 89 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 79 105 } 80 106 if _, err := cw.WriteString(string(t.Type)); err != nil { 81 107 return err 82 - } 83 - 84 - // t.Berth (string) (string) 85 - if t.Berth != "" { 86 - 87 - if len("berth") > 8192 { 88 - return xerrors.Errorf("Value in field \"berth\" was too long") 89 - } 90 - 91 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("berth"))); err != nil { 92 - return err 93 - } 94 - if _, err := cw.WriteString(string("berth")); err != nil { 95 - return err 96 - } 97 - 98 - if len(t.Berth) > 8192 { 99 - return xerrors.Errorf("Value in field t.Berth was too long") 100 - } 101 - 102 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Berth))); err != nil { 103 - return err 104 - } 105 - if _, err := cw.WriteString(string(t.Berth)); err != nil { 106 - return err 107 - } 108 108 } 109 109 110 110 // t.Member (string) (string) ··· 240 240 241 241 t.Role = string(sval) 242 242 } 243 - // t.Type (string) (string) 244 - case "$type": 243 + // t.Tier (string) (string) 244 + case "tier": 245 245 246 246 { 247 247 sval, err := cbg.ReadStringWithMax(cr, 8192) ··· 249 249 return err 250 250 } 251 251 252 - t.Type = string(sval) 252 + t.Tier = string(sval) 253 253 } 254 - // t.Berth (string) (string) 255 - case "berth": 254 + // t.Type (string) (string) 255 + case "$type": 256 256 257 257 { 258 258 sval, err := cbg.ReadStringWithMax(cr, 8192) ··· 260 260 return err 261 261 } 262 262 263 - t.Berth = string(sval) 263 + t.Type = string(sval) 264 264 } 265 265 // t.Member (string) (string) 266 266 case "member":
+2 -2
pkg/atproto/lexicon.go
··· 594 594 Member string `json:"member" cborgen:"member"` 595 595 Role string `json:"role" cborgen:"role"` 596 596 Permissions []string `json:"permissions" cborgen:"permissions"` 597 - Berth string `json:"berth,omitempty" cborgen:"berth,omitempty"` // Optional berth for quota limits (nautical rank) 598 - AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp 597 + Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster') 598 + AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp 599 599 } 600 600 601 601 // LayerRecord represents metadata about a container layer stored in the hold
+2 -2
pkg/hold/oci/xrpc.go
··· 24 24 pds *pds.HoldPDS 25 25 httpClient pds.HTTPClient 26 26 enableBlueskyPosts bool 27 - quotaMgr *quota.Manager // Quota manager for berth-based limits 27 + quotaMgr *quota.Manager // Quota manager for tier-based limits 28 28 } 29 29 30 30 // NewXRPCHandler creates a new OCI XRPC handler ··· 281 281 if operation == "push" { 282 282 // Soft limit check: block if ALREADY over quota 283 283 // (blobs already uploaded to S3 by this point, no sense rejecting) 284 - stats, err := h.pds.GetQuotaForUserWithBerth(ctx, req.UserDID, h.quotaMgr) 284 + stats, err := h.pds.GetQuotaForUserWithTier(ctx, req.UserDID, h.quotaMgr) 285 285 if err == nil && stats.Limit != nil && stats.TotalSize > *stats.Limit { 286 286 slog.Warn("Quota exceeded for push", 287 287 "userDid", req.UserDID,
+12 -12
pkg/hold/pds/layer.go
··· 67 67 UniqueBlobs int `json:"uniqueBlobs"` 68 68 TotalSize int64 `json:"totalSize"` 69 69 Limit *int64 `json:"limit,omitempty"` // nil = unlimited 70 - Berth string `json:"berth,omitempty"` // nautical rank for quota tier 70 + Tier string `json:"tier,omitempty"` // quota tier (e.g., 'deckhand', 'bosun', 'quartermaster') 71 71 } 72 72 73 73 // GetQuotaForUser calculates storage quota for a specific user ··· 164 164 }, nil 165 165 } 166 166 167 - // GetQuotaForUserWithBerth calculates quota with berth-aware limits 168 - // It returns the base quota stats plus the berth limit and berth name. 167 + // GetQuotaForUserWithTier calculates quota with tier-aware limits 168 + // It returns the base quota stats plus the tier limit and tier name. 169 169 // Captain (owner) always has unlimited quota. 170 - func (p *HoldPDS) GetQuotaForUserWithBerth(ctx context.Context, userDID string, quotaMgr *quota.Manager) (*QuotaStats, error) { 170 + func (p *HoldPDS) GetQuotaForUserWithTier(ctx context.Context, userDID string, quotaMgr *quota.Manager) (*QuotaStats, error) { 171 171 // Get base stats 172 172 stats, err := p.GetQuotaForUser(ctx, userDID) 173 173 if err != nil { ··· 182 182 // Check if user is captain (owner) - always unlimited 183 183 _, captain, err := p.GetCaptainRecord(ctx) 184 184 if err == nil && captain.Owner == userDID { 185 - stats.Berth = "owner" 185 + stats.Tier = "owner" 186 186 // Limit remains nil (unlimited) 187 187 return stats, nil 188 188 } 189 189 190 - // Get crew record to find berth 191 - crewBerth := p.getCrewBerth(ctx, userDID) 190 + // Get crew record to find tier 191 + crewTier := p.getCrewTier(ctx, userDID) 192 192 193 193 // Resolve limit from quota manager 194 - stats.Limit = quotaMgr.GetBerthLimit(crewBerth) 195 - stats.Berth = quotaMgr.GetBerthName(crewBerth) 194 + stats.Limit = quotaMgr.GetTierLimit(crewTier) 195 + stats.Tier = quotaMgr.GetTierName(crewTier) 196 196 197 197 return stats, nil 198 198 } 199 199 200 - // getCrewBerth returns the berth for a crew member, or empty string if not found 201 - func (p *HoldPDS) getCrewBerth(ctx context.Context, userDID string) string { 200 + // getCrewTier returns the tier for a crew member, or empty string if not found 201 + func (p *HoldPDS) getCrewTier(ctx context.Context, userDID string) string { 202 202 crewMembers, err := p.ListCrewMembers(ctx) 203 203 if err != nil { 204 204 return "" ··· 206 206 207 207 for _, member := range crewMembers { 208 208 if member.Record.Member == userDID { 209 - return member.Record.Berth 209 + return member.Record.Tier 210 210 } 211 211 } 212 212
+53 -53
pkg/hold/pds/layer_test.go
··· 328 328 return pds, cleanup 329 329 } 330 330 331 - // addCrewMemberWithBerth adds a crew member with a specific berth (nautical rank) 332 - func addCrewMemberWithBerth(t *testing.T, pds *HoldPDS, memberDID, role string, permissions []string, berth string) { 331 + // addCrewMemberWithTier adds a crew member with a specific tier 332 + func addCrewMemberWithTier(t *testing.T, pds *HoldPDS, memberDID, role string, permissions []string, tier string) { 333 333 t.Helper() 334 334 335 335 crewRecord := &atproto.CrewRecord{ ··· 337 337 Member: memberDID, 338 338 Role: role, 339 339 Permissions: permissions, 340 - Berth: berth, 340 + Tier: tier, 341 341 AddedAt: "2026-01-04T12:00:00Z", 342 342 } 343 343 344 344 _, _, err := pds.repomgr.CreateRecord(sharedCtx, pds.uid, atproto.CrewCollection, crewRecord) 345 345 if err != nil { 346 - t.Fatalf("Failed to add crew member with berth: %v", err) 346 + t.Fatalf("Failed to add crew member with tier: %v", err) 347 347 } 348 348 } 349 349 350 - func TestGetQuotaForUserWithBerth_OwnerUnlimited(t *testing.T) { 350 + func TestGetQuotaForUserWithTier_OwnerUnlimited(t *testing.T) { 351 351 ownerDID := "did:plc:owner123" 352 352 pds, cleanup := setupTestPDSWithIndex(t, ownerDID) 353 353 defer cleanup() ··· 358 358 tmpDir := t.TempDir() 359 359 configPath := filepath.Join(tmpDir, "quotas.yaml") 360 360 configContent := ` 361 - berths: 361 + tiers: 362 362 deckhand: 363 363 quota: 5GB 364 364 bosun: 365 365 quota: 50GB 366 366 367 367 defaults: 368 - new_crew_berth: deckhand 368 + new_crew_tier: deckhand 369 369 ` 370 370 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 371 371 t.Fatalf("Failed to write quota config: %v", err) ··· 391 391 } 392 392 393 393 // Get quota for owner 394 - stats, err := pds.GetQuotaForUserWithBerth(ctx, ownerDID, quotaMgr) 394 + stats, err := pds.GetQuotaForUserWithTier(ctx, ownerDID, quotaMgr) 395 395 if err != nil { 396 - t.Fatalf("GetQuotaForUserWithBerth failed: %v", err) 396 + t.Fatalf("GetQuotaForUserWithTier failed: %v", err) 397 397 } 398 398 399 399 // Owner should have unlimited quota (nil limit) ··· 401 401 t.Errorf("Expected nil limit for owner, got %d", *stats.Limit) 402 402 } 403 403 404 - // Berth should be "owner" 405 - if stats.Berth != "owner" { 406 - t.Errorf("Expected berth 'owner', got %q", stats.Berth) 404 + // Tier should be "owner" 405 + if stats.Tier != "owner" { 406 + t.Errorf("Expected tier 'owner', got %q", stats.Tier) 407 407 } 408 408 409 409 // Should have 3 unique blobs ··· 420 420 t.Logf("Owner quota stats: %+v", stats) 421 421 } 422 422 423 - func TestGetQuotaForUserWithBerth_CrewWithDefaultBerth(t *testing.T) { 423 + func TestGetQuotaForUserWithTier_CrewWithDefaultTier(t *testing.T) { 424 424 ownerDID := "did:plc:owner456" 425 425 crewDID := "did:plc:crew123" 426 426 pds, cleanup := setupTestPDSWithIndex(t, ownerDID) ··· 432 432 tmpDir := t.TempDir() 433 433 configPath := filepath.Join(tmpDir, "quotas.yaml") 434 434 configContent := ` 435 - berths: 435 + tiers: 436 436 deckhand: 437 437 quota: 5GB 438 438 bosun: 439 439 quota: 50GB 440 440 441 441 defaults: 442 - new_crew_berth: deckhand 442 + new_crew_tier: deckhand 443 443 ` 444 444 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 445 445 t.Fatalf("Failed to write quota config: %v", err) ··· 450 450 t.Fatalf("Failed to create quota manager: %v", err) 451 451 } 452 452 453 - // Add crew member with no berth (should use default) 454 - addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "") 453 + // Add crew member with no tier (should use default) 454 + addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "") 455 455 456 456 // Create layer records for crew member 457 457 for i := range 2 { ··· 468 468 } 469 469 470 470 // Get quota for crew member 471 - stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr) 471 + stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr) 472 472 if err != nil { 473 - t.Fatalf("GetQuotaForUserWithBerth failed: %v", err) 473 + t.Fatalf("GetQuotaForUserWithTier failed: %v", err) 474 474 } 475 475 476 - // Should have 5GB limit (deckhand berth) 476 + // Should have 5GB limit (deckhand tier) 477 477 expectedLimit := int64(5 * 1024 * 1024 * 1024) 478 478 if stats.Limit == nil { 479 479 t.Fatal("Expected non-nil limit for crew member") ··· 482 482 t.Errorf("Expected limit %d, got %d", expectedLimit, *stats.Limit) 483 483 } 484 484 485 - // Berth should be "deckhand" 486 - if stats.Berth != "deckhand" { 487 - t.Errorf("Expected berth 'deckhand', got %q", stats.Berth) 485 + // Tier should be "deckhand" 486 + if stats.Tier != "deckhand" { 487 + t.Errorf("Expected tier 'deckhand', got %q", stats.Tier) 488 488 } 489 489 490 490 // Should have 2 unique blobs ··· 492 492 t.Errorf("Expected 2 unique blobs, got %d", stats.UniqueBlobs) 493 493 } 494 494 495 - t.Logf("Crew (deckhand berth) quota stats: %+v", stats) 495 + t.Logf("Crew (deckhand tier) quota stats: %+v", stats) 496 496 } 497 497 498 - func TestGetQuotaForUserWithBerth_CrewWithExplicitBerth(t *testing.T) { 498 + func TestGetQuotaForUserWithTier_CrewWithExplicitTier(t *testing.T) { 499 499 ownerDID := "did:plc:owner789" 500 500 crewDID := "did:plc:bosuncrew456" 501 501 pds, cleanup := setupTestPDSWithIndex(t, ownerDID) ··· 507 507 tmpDir := t.TempDir() 508 508 configPath := filepath.Join(tmpDir, "quotas.yaml") 509 509 configContent := ` 510 - berths: 510 + tiers: 511 511 deckhand: 512 512 quota: 5GB 513 513 bosun: 514 514 quota: 50GB 515 515 516 516 defaults: 517 - new_crew_berth: deckhand 517 + new_crew_tier: deckhand 518 518 ` 519 519 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 520 520 t.Fatalf("Failed to write quota config: %v", err) ··· 525 525 t.Fatalf("Failed to create quota manager: %v", err) 526 526 } 527 527 528 - // Add crew member with explicit "bosun" berth 529 - addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun") 528 + // Add crew member with explicit "bosun" tier 529 + addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun") 530 530 531 531 // Create layer records for crew member 532 532 record := atproto.NewLayerRecord( ··· 541 541 } 542 542 543 543 // Get quota for crew member 544 - stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr) 544 + stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr) 545 545 if err != nil { 546 - t.Fatalf("GetQuotaForUserWithBerth failed: %v", err) 546 + t.Fatalf("GetQuotaForUserWithTier failed: %v", err) 547 547 } 548 548 549 - // Should have 50GB limit (bosun berth) 549 + // Should have 50GB limit (bosun tier) 550 550 expectedLimit := int64(50 * 1024 * 1024 * 1024) 551 551 if stats.Limit == nil { 552 552 t.Fatal("Expected non-nil limit for crew member") ··· 555 555 t.Errorf("Expected limit %d, got %d", expectedLimit, *stats.Limit) 556 556 } 557 557 558 - // Berth should be "bosun" 559 - if stats.Berth != "bosun" { 560 - t.Errorf("Expected berth 'bosun', got %q", stats.Berth) 558 + // Tier should be "bosun" 559 + if stats.Tier != "bosun" { 560 + t.Errorf("Expected tier 'bosun', got %q", stats.Tier) 561 561 } 562 562 563 - t.Logf("Crew (bosun berth) quota stats: %+v", stats) 563 + t.Logf("Crew (bosun tier) quota stats: %+v", stats) 564 564 } 565 565 566 - func TestGetQuotaForUserWithBerth_NoQuotaManager(t *testing.T) { 566 + func TestGetQuotaForUserWithTier_NoQuotaManager(t *testing.T) { 567 567 ownerDID := "did:plc:ownerabc" 568 568 crewDID := "did:plc:crewabc" 569 569 pds, cleanup := setupTestPDSWithIndex(t, ownerDID) ··· 572 572 ctx := sharedCtx 573 573 574 574 // Add crew member 575 - addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "deckhand") 575 + addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "deckhand") 576 576 577 577 // Create layer record 578 578 record := atproto.NewLayerRecord( ··· 587 587 } 588 588 589 589 // Get quota with nil quota manager (no enforcement) 590 - stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, nil) 590 + stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, nil) 591 591 if err != nil { 592 - t.Fatalf("GetQuotaForUserWithBerth failed: %v", err) 592 + t.Fatalf("GetQuotaForUserWithTier failed: %v", err) 593 593 } 594 594 595 595 // Should have nil limit (unlimited) ··· 597 597 t.Errorf("Expected nil limit when quota manager is nil, got %d", *stats.Limit) 598 598 } 599 599 600 - // Berth should be empty 601 - if stats.Berth != "" { 602 - t.Errorf("Expected empty berth, got %q", stats.Berth) 600 + // Tier should be empty 601 + if stats.Tier != "" { 602 + t.Errorf("Expected empty tier, got %q", stats.Tier) 603 603 } 604 604 605 605 t.Logf("No quota manager stats: %+v", stats) 606 606 } 607 607 608 - func TestGetQuotaForUserWithBerth_DisabledQuotas(t *testing.T) { 608 + func TestGetQuotaForUserWithTier_DisabledQuotas(t *testing.T) { 609 609 ownerDID := "did:plc:ownerdef" 610 610 crewDID := "did:plc:crewdef" 611 611 pds, cleanup := setupTestPDSWithIndex(t, ownerDID) ··· 624 624 } 625 625 626 626 // Add crew member 627 - addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun") 627 + addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun") 628 628 629 629 // Create layer record 630 630 record := atproto.NewLayerRecord( ··· 639 639 } 640 640 641 641 // Get quota with disabled quota manager 642 - stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr) 642 + stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr) 643 643 if err != nil { 644 - t.Fatalf("GetQuotaForUserWithBerth failed: %v", err) 644 + t.Fatalf("GetQuotaForUserWithTier failed: %v", err) 645 645 } 646 646 647 647 // Should have nil limit (unlimited when quotas disabled) ··· 652 652 t.Logf("Disabled quotas stats: %+v", stats) 653 653 } 654 654 655 - func TestGetQuotaForUserWithBerth_DeduplicatesBlobs(t *testing.T) { 655 + func TestGetQuotaForUserWithTier_DeduplicatesBlobs(t *testing.T) { 656 656 ownerDID := "did:plc:ownerghi" 657 657 crewDID := "did:plc:crewghi" 658 658 pds, cleanup := setupTestPDSWithIndex(t, ownerDID) ··· 664 664 tmpDir := t.TempDir() 665 665 configPath := filepath.Join(tmpDir, "quotas.yaml") 666 666 configContent := ` 667 - berths: 667 + tiers: 668 668 deckhand: 669 669 quota: 5GB 670 670 671 671 defaults: 672 - new_crew_berth: deckhand 672 + new_crew_tier: deckhand 673 673 ` 674 674 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 675 675 t.Fatalf("Failed to write quota config: %v", err) ··· 681 681 } 682 682 683 683 // Add crew member 684 - addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "") 684 + addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "") 685 685 686 686 // Create multiple layer records with same digest (should be deduplicated) 687 687 digest := "sha256:duplicatelayer" ··· 699 699 } 700 700 701 701 // Get quota 702 - stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr) 702 + stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr) 703 703 if err != nil { 704 - t.Fatalf("GetQuotaForUserWithBerth failed: %v", err) 704 + t.Fatalf("GetQuotaForUserWithTier failed: %v", err) 705 705 } 706 706 707 707 // Should have 1 unique blob (deduplicated)
+2 -2
pkg/hold/pds/xrpc.go
··· 1537 1537 return 1538 1538 } 1539 1539 1540 - // Get quota stats with berth-aware limits 1541 - stats, err := h.pds.GetQuotaForUserWithBerth(r.Context(), userDID, h.quotaMgr) 1540 + // Get quota stats with tier-aware limits 1541 + stats, err := h.pds.GetQuotaForUserWithTier(r.Context(), userDID, h.quotaMgr) 1542 1542 if err != nil { 1543 1543 slog.Error("Failed to get quota", "userDid", userDID, "error", err) 1544 1544 http.Error(w, fmt.Sprintf("failed to get quota: %v", err), http.StatusInternalServerError)
+43 -43
pkg/hold/quota/config.go
··· 13 13 14 14 // Config represents the quotas.yaml configuration 15 15 type Config struct { 16 - Berths map[string]BerthConfig `yaml:"berths"` 17 - Defaults DefaultsConfig `yaml:"defaults"` 16 + Tiers map[string]TierConfig `yaml:"tiers"` 17 + Defaults DefaultsConfig `yaml:"defaults"` 18 18 } 19 19 20 - // BerthConfig represents a single berth's configuration 21 - type BerthConfig struct { 20 + // TierConfig represents a single tier's configuration 21 + type TierConfig struct { 22 22 Quota string `yaml:"quota"` // Human-readable size: "5GB", "50GB", etc. 23 23 } 24 24 25 25 // DefaultsConfig represents default settings 26 26 type DefaultsConfig struct { 27 - NewCrewBerth string `yaml:"new_crew_berth"` 27 + NewCrewTier string `yaml:"new_crew_tier"` 28 28 } 29 29 30 - // Manager manages quota configuration and berth resolution 30 + // Manager manages quota configuration and tier resolution 31 31 type Manager struct { 32 32 config *Config 33 - berths map[string]int64 // resolved berth name -> bytes 33 + tiers map[string]int64 // resolved tier name -> bytes 34 34 } 35 35 36 36 // NewManager creates a quota manager, loading config from file if present 37 37 func NewManager(configPath string) (*Manager, error) { 38 38 m := &Manager{ 39 - berths: make(map[string]int64), 39 + tiers: make(map[string]int64), 40 40 } 41 41 42 42 // Try to load config file ··· 56 56 57 57 m.config = &cfg 58 58 59 - // Parse and resolve all berths 60 - for name, berth := range cfg.Berths { 61 - bytes, err := ParseHumanBytes(berth.Quota) 59 + // Parse and resolve all tiers 60 + for name, tier := range cfg.Tiers { 61 + bytes, err := ParseHumanBytes(tier.Quota) 62 62 if err != nil { 63 - return nil, fmt.Errorf("invalid quota for berth %q: %w", name, err) 63 + return nil, fmt.Errorf("invalid quota for tier %q: %w", name, err) 64 64 } 65 - m.berths[name] = bytes 65 + m.tiers[name] = bytes 66 66 } 67 67 68 68 return m, nil ··· 73 73 return m.config != nil 74 74 } 75 75 76 - // GetBerthLimit resolves the quota limit for a berth key 77 - // Returns nil for unlimited (captain, no config, or berth not found with no default) 76 + // GetTierLimit resolves the quota limit for a tier key 77 + // Returns nil for unlimited (captain, no config, or tier not found with no default) 78 78 // 79 79 // Resolution order: 80 80 // 1. If quotas disabled → nil (unlimited) 81 - // 2. If berthKey provided and found → return that berth's limit 82 - // 3. If berthKey not found or empty → use defaults.new_crew_berth 83 - // 4. If default berth not found → nil (unlimited) 84 - func (m *Manager) GetBerthLimit(berthKey string) *int64 { 81 + // 2. If tierKey provided and found → return that tier's limit 82 + // 3. If tierKey not found or empty → use defaults.new_crew_tier 83 + // 4. If default tier not found → nil (unlimited) 84 + func (m *Manager) GetTierLimit(tierKey string) *int64 { 85 85 if !m.IsEnabled() { 86 86 return nil 87 87 } 88 88 89 - // Try the provided berth key first 90 - if berthKey != "" { 91 - if limit, ok := m.berths[berthKey]; ok { 89 + // Try the provided tier key first 90 + if tierKey != "" { 91 + if limit, ok := m.tiers[tierKey]; ok { 92 92 return &limit 93 93 } 94 94 } 95 95 96 - // Fall back to default berth 97 - if m.config.Defaults.NewCrewBerth != "" { 98 - if limit, ok := m.berths[m.config.Defaults.NewCrewBerth]; ok { 96 + // Fall back to default tier 97 + if m.config.Defaults.NewCrewTier != "" { 98 + if limit, ok := m.tiers[m.config.Defaults.NewCrewTier]; ok { 99 99 return &limit 100 100 } 101 101 } 102 102 103 - // No valid berth found - unlimited 103 + // No valid tier found - unlimited 104 104 return nil 105 105 } 106 106 107 - // GetBerthName resolves the berth name for a berth key 108 - // Returns the actual berth name being used (after fallback resolution) 109 - func (m *Manager) GetBerthName(berthKey string) string { 107 + // GetTierName resolves the tier name for a tier key 108 + // Returns the actual tier name being used (after fallback resolution) 109 + func (m *Manager) GetTierName(tierKey string) string { 110 110 if !m.IsEnabled() { 111 111 return "" 112 112 } 113 113 114 - // Try the provided berth key first 115 - if berthKey != "" { 116 - if _, ok := m.berths[berthKey]; ok { 117 - return berthKey 114 + // Try the provided tier key first 115 + if tierKey != "" { 116 + if _, ok := m.tiers[tierKey]; ok { 117 + return tierKey 118 118 } 119 119 } 120 120 121 - // Fall back to default berth 122 - if m.config.Defaults.NewCrewBerth != "" { 123 - if _, ok := m.berths[m.config.Defaults.NewCrewBerth]; ok { 124 - return m.config.Defaults.NewCrewBerth 121 + // Fall back to default tier 122 + if m.config.Defaults.NewCrewTier != "" { 123 + if _, ok := m.tiers[m.config.Defaults.NewCrewTier]; ok { 124 + return m.config.Defaults.NewCrewTier 125 125 } 126 126 } 127 127 128 128 return "" 129 129 } 130 130 131 - // GetDefaultBerth returns the default berth name for new crew members 132 - func (m *Manager) GetDefaultBerth() string { 131 + // GetDefaultTier returns the default tier name for new crew members 132 + func (m *Manager) GetDefaultTier() string { 133 133 if m.config == nil { 134 134 return "" 135 135 } 136 - return m.config.Defaults.NewCrewBerth 136 + return m.config.Defaults.NewCrewTier 137 137 } 138 138 139 - // BerthCount returns the number of configured berths 140 - func (m *Manager) BerthCount() int { 141 - return len(m.berths) 139 + // TierCount returns the number of configured tiers 140 + func (m *Manager) TierCount() int { 141 + return len(m.tiers) 142 142 } 143 143 144 144 // ParseHumanBytes parses human-readable byte sizes like "5GB", "100MB", "1.5TB"
+34 -34
pkg/hold/quota/config_test.go
··· 97 97 if m.IsEnabled() { 98 98 t.Error("expected quotas to be disabled when file missing") 99 99 } 100 - if m.GetBerthLimit("anything") != nil { 100 + if m.GetTierLimit("anything") != nil { 101 101 t.Error("expected nil limit when quotas disabled") 102 102 } 103 103 } ··· 107 107 configPath := filepath.Join(tmpDir, "quotas.yaml") 108 108 109 109 configContent := ` 110 - berths: 110 + tiers: 111 111 deckhand: 112 112 quota: 5GB 113 113 bosun: ··· 116 116 quota: 100GB 117 117 118 118 defaults: 119 - new_crew_berth: deckhand 119 + new_crew_tier: deckhand 120 120 ` 121 121 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 122 122 t.Fatalf("failed to write config: %v", err) ··· 131 131 t.Error("expected quotas to be enabled") 132 132 } 133 133 134 - if m.BerthCount() != 3 { 135 - t.Errorf("expected 3 berths, got %d", m.BerthCount()) 134 + if m.TierCount() != 3 { 135 + t.Errorf("expected 3 tiers, got %d", m.TierCount()) 136 136 } 137 137 138 - // Test default berth (empty string) 139 - limit := m.GetBerthLimit("") 138 + // Test default tier (empty string) 139 + limit := m.GetTierLimit("") 140 140 if limit == nil { 141 - t.Fatal("expected non-nil limit for default berth") 141 + t.Fatal("expected non-nil limit for default tier") 142 142 } 143 143 if *limit != 5*1024*1024*1024 { 144 144 t.Errorf("expected 5GB limit for default, got %d", *limit) 145 145 } 146 146 147 - // Test explicit berth 148 - limit = m.GetBerthLimit("bosun") 147 + // Test explicit tier 148 + limit = m.GetTierLimit("bosun") 149 149 if limit == nil { 150 150 t.Fatal("expected non-nil limit for bosun") 151 151 } ··· 153 153 t.Errorf("expected 50GB limit for bosun, got %d", *limit) 154 154 } 155 155 156 - // Test berth name resolution 157 - if m.GetBerthName("") != "deckhand" { 158 - t.Errorf("expected berth name 'deckhand' for empty key, got %q", m.GetBerthName("")) 156 + // Test tier name resolution 157 + if m.GetTierName("") != "deckhand" { 158 + t.Errorf("expected tier name 'deckhand' for empty key, got %q", m.GetTierName("")) 159 159 } 160 - if m.GetBerthName("bosun") != "bosun" { 161 - t.Errorf("expected berth name 'bosun', got %q", m.GetBerthName("bosun")) 160 + if m.GetTierName("bosun") != "bosun" { 161 + t.Errorf("expected tier name 'bosun', got %q", m.GetTierName("bosun")) 162 162 } 163 163 } 164 164 ··· 167 167 configPath := filepath.Join(tmpDir, "quotas.yaml") 168 168 169 169 configContent := ` 170 - berths: 170 + tiers: 171 171 deckhand: 172 172 quota: 5GB 173 173 quartermaster: 174 174 quota: 50GB 175 175 176 176 defaults: 177 - new_crew_berth: deckhand 177 + new_crew_tier: deckhand 178 178 ` 179 179 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 180 180 t.Fatalf("failed to write config: %v", err) ··· 185 185 t.Fatalf("failed to load config: %v", err) 186 186 } 187 187 188 - // Unknown berth should fall back to default 189 - limit := m.GetBerthLimit("unknown_berth") 188 + // Unknown tier should fall back to default 189 + limit := m.GetTierLimit("unknown_tier") 190 190 if limit == nil { 191 - t.Fatal("expected fallback to default berth") 191 + t.Fatal("expected fallback to default tier") 192 192 } 193 193 if *limit != 5*1024*1024*1024 { 194 194 t.Errorf("expected 5GB limit from default fallback, got %d", *limit) 195 195 } 196 196 197 - // Berth name should also fall back 198 - if m.GetBerthName("unknown_berth") != "deckhand" { 199 - t.Errorf("expected berth name 'deckhand' for unknown berth, got %q", m.GetBerthName("unknown_berth")) 197 + // Tier name should also fall back 198 + if m.GetTierName("unknown_tier") != "deckhand" { 199 + t.Errorf("expected tier name 'deckhand' for unknown tier, got %q", m.GetTierName("unknown_tier")) 200 200 } 201 201 } 202 202 ··· 220 220 configPath := filepath.Join(tmpDir, "quotas.yaml") 221 221 222 222 configContent := ` 223 - berths: 223 + tiers: 224 224 deckhand: 225 225 quota: invalid_size 226 226 227 227 defaults: 228 - new_crew_berth: deckhand 228 + new_crew_tier: deckhand 229 229 ` 230 230 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 231 231 t.Fatalf("failed to write config: %v", err) ··· 237 237 } 238 238 } 239 239 240 - func TestNewManager_NoDefaultBerth(t *testing.T) { 240 + func TestNewManager_NoDefaultTier(t *testing.T) { 241 241 tmpDir := t.TempDir() 242 242 configPath := filepath.Join(tmpDir, "quotas.yaml") 243 243 244 244 configContent := ` 245 - berths: 245 + tiers: 246 246 quartermaster: 247 247 quota: 50GB 248 248 249 249 defaults: 250 - new_crew_berth: nonexistent 250 + new_crew_tier: nonexistent 251 251 ` 252 252 if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { 253 253 t.Fatalf("failed to write config: %v", err) ··· 258 258 t.Fatalf("failed to load config: %v", err) 259 259 } 260 260 261 - // Empty berth key with nonexistent default should return nil (unlimited) 262 - limit := m.GetBerthLimit("") 261 + // Empty tier key with nonexistent default should return nil (unlimited) 262 + limit := m.GetTierLimit("") 263 263 if limit != nil { 264 - t.Error("expected nil limit when default berth doesn't exist") 264 + t.Error("expected nil limit when default tier doesn't exist") 265 265 } 266 266 267 - // Explicit berth should still work 268 - limit = m.GetBerthLimit("quartermaster") 267 + // Explicit tier should still work 268 + limit = m.GetTierLimit("quartermaster") 269 269 if limit == nil { 270 - t.Fatal("expected non-nil limit for quartermaster berth") 270 + t.Fatal("expected non-nil limit for quartermaster tier") 271 271 } 272 272 if *limit != 50*1024*1024*1024 { 273 273 t.Errorf("expected 50GB limit for quartermaster, got %d", *limit)
+10 -10
quotas.yaml.example
··· 2 2 # Copy this file to quotas.yaml to enable quota enforcement. 3 3 # If quotas.yaml doesn't exist, quotas are disabled (unlimited for all users). 4 4 5 - # Berths define quota tiers using nautical crew ranks. 6 - # Each berth has a quota limit specified in human-readable format. 5 + # Tiers define quota levels using nautical crew ranks. 6 + # Each tier has a quota limit specified in human-readable format. 7 7 # Supported units: B, KB, MB, GB, TB, PB (case-insensitive) 8 - berths: 8 + tiers: 9 9 # Entry-level crew - suitable for new or casual users 10 10 deckhand: 11 11 quota: 5GB ··· 18 18 quartermaster: 19 19 quota: 100GB 20 20 21 - # You can add custom berths with any name: 21 + # You can add custom tiers with any name: 22 22 # unlimited_crew: 23 23 # quota: 1TB 24 24 25 25 defaults: 26 - # Default berth assigned to new crew members who don't have an explicit berth. 27 - # This berth must exist in the berths section above. 28 - new_crew_berth: deckhand 26 + # Default tier assigned to new crew members who don't have an explicit tier. 27 + # This tier must exist in the tiers section above. 28 + new_crew_tier: deckhand 29 29 30 30 # Notes: 31 - # - The hold captain (owner) always has unlimited quota regardless of berths. 32 - # - Crew members can be assigned a specific berth in their crew record. 33 - # - If a crew member's berth doesn't exist in config, they fall back to the default. 31 + # - The hold captain (owner) always has unlimited quota regardless of tiers. 32 + # - Crew members can be assigned a specific tier in their crew record. 33 + # - If a crew member's tier doesn't exist in config, they fall back to the default. 34 34 # - Quota is calculated per-user by summing unique blob sizes (deduplicated). 35 35 # - Quota is checked when pushing manifests (after blobs are already uploaded).