+1
-1
cmd/hold/main.go
+1
-1
cmd/hold/main.go
+10
-10
deploy/quotas.yaml
+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
+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
+2
-2
lexicons/io/atcr/hold/crew.json
+3
-3
pkg/appview/handlers/storage.go
+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
+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
+3
-3
pkg/appview/templates/partials/storage_stats.html
+33
-33
pkg/atproto/cbor_gen.go
+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
+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
+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
+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
+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
+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
+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
+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
+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).