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

Configure Feed

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

wording

+227 -227
+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).