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