This commit is contained in:
Evan Jarrett
2026-01-04 23:37:31 -06:00
parent e5e59fdcbf
commit 487fc8a47e
16 changed files with 232 additions and 232 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ func main() {
os.Exit(1)
}
if quotaMgr.IsEnabled() {
slog.Info("Quota enforcement enabled", "berths", quotaMgr.BerthCount(), "defaultBerth", quotaMgr.GetDefaultBerth())
slog.Info("Quota enforcement enabled", "tiers", quotaMgr.TierCount(), "defaultTier", quotaMgr.GetDefaultTier())
} else {
slog.Info("Quota enforcement disabled (no quotas.yaml found)")
}
+10 -10
View File
@@ -2,10 +2,10 @@
# Copy this file to quotas.yaml to enable quota enforcement.
# If quotas.yaml doesn't exist, quotas are disabled (unlimited for all users).
# Berths define quota tiers using nautical crew ranks.
# Each berth has a quota limit specified in human-readable format.
# Tiers define quota levels using nautical crew ranks.
# Each tier has a quota limit specified in human-readable format.
# Supported units: B, KB, MB, GB, TB, PB (case-insensitive)
berths:
tiers:
# Entry-level crew - suitable for new or casual users
deckhand:
quota: 5GB
@@ -18,18 +18,18 @@ berths:
quartermaster:
quota: 100GB
# You can add custom berths with any name:
# You can add custom tiers with any name:
# unlimited_crew:
# quota: 1TB
defaults:
# Default berth assigned to new crew members who don't have an explicit berth.
# This berth must exist in the berths section above.
new_crew_berth: deckhand
# Default tier assigned to new crew members who don't have an explicit tier.
# This tier must exist in the tiers section above.
new_crew_tier: deckhand
# Notes:
# - The hold captain (owner) always has unlimited quota regardless of berths.
# - Crew members can be assigned a specific berth in their crew record.
# - If a crew member's berth doesn't exist in config, they fall back to the default.
# - The hold captain (owner) always has unlimited quota regardless of tiers.
# - Crew members can be assigned a specific tier in their crew record.
# - If a crew member's tier doesn't exist in config, they fall back to the default.
# - Quota is calculated per-user by summing unique blob sizes (deduplicated).
# - Quota is checked when pushing manifests (after blobs are already uploaded).
+11 -11
View File
@@ -507,13 +507,13 @@ GET /xrpc/io.atcr.hold.getQuotaBreakdown - Storage by repository
- Email/webhook notifications
- Grace period before hard enforcement
### 3. Berth-Based Quotas (Implemented)
### 3. Tier-Based Quotas (Implemented)
ATCR uses nautical-themed "berths" for quota tiers, configured via `quotas.yaml`:
ATCR uses quota tiers to limit storage per crew member, configured via `quotas.yaml`:
```yaml
# quotas.yaml
berths:
tiers:
deckhand: # Entry-level crew
quota: 5GB
bosun: # Mid-level crew
@@ -522,21 +522,21 @@ berths:
quota: 100GB
defaults:
new_crew_berth: deckhand # Default berth for new crew members
new_crew_tier: deckhand # Default tier for new crew members
```
| Berth | Limit | Description |
|-------|-------|-------------|
| Tier | Limit | Description |
|------|-------|-------------|
| deckhand | 5 GB | Entry-level crew member |
| bosun | 50 GB | Mid-level crew member |
| quartermaster | 100 GB | Senior crew member |
| owner (captain) | Unlimited | Hold owner always has unlimited |
**Berth Resolution:**
**Tier Resolution:**
1. If user is captain (owner) → unlimited
2. If crew member has explicit berth → use that berth's limit
3. If crew member has no berth → use `defaults.new_crew_berth`
4. If default berth not found → unlimited
2. If crew member has explicit tier → use that tier's limit
3. If crew member has no tier → use `defaults.new_crew_tier`
4. If default tier not found → unlimited
**Crew Record Example:**
```json
@@ -545,7 +545,7 @@ defaults:
"member": "did:plc:alice123",
"role": "writer",
"permissions": ["blob:write"],
"berth": "bosun",
"tier": "bosun",
"addedAt": "2026-01-04T12:00:00Z"
}
```
+2 -2
View File
@@ -29,9 +29,9 @@
"maxLength": 64
}
},
"berth": {
"tier": {
"type": "string",
"description": "Optional berth (nautical rank) for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster'). If empty, uses defaults.new_crew_berth from quotas.yaml.",
"description": "Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster'). If empty, uses defaults.new_crew_tier from quotas.yaml.",
"maxLength": 32
},
"addedAt": {
+3 -3
View File
@@ -26,7 +26,7 @@ type QuotaStats struct {
UniqueBlobs int `json:"uniqueBlobs"`
TotalSize int64 `json:"totalSize"`
Limit *int64 `json:"limit,omitempty"` // nil = unlimited
Berth string `json:"berth,omitempty"` // e.g., "deckhand", "bosun", "owner"
Tier string `json:"tier,omitempty"` // e.g., "deckhand", "bosun", "owner"
}
func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -110,7 +110,7 @@ func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats) {
HasLimit bool
HumanLimit string
UsagePercent int
Berth string
Tier string
}{
UniqueBlobs: stats.UniqueBlobs,
TotalSize: stats.TotalSize,
@@ -118,7 +118,7 @@ func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats) {
HasLimit: hasLimit,
HumanLimit: humanLimit,
UsagePercent: usagePercent,
Berth: stats.Berth,
Tier: stats.Tier,
}
w.Header().Set("Content-Type", "text/html")
+6 -6
View File
@@ -31,7 +31,7 @@
<!-- Storage Usage Section -->
<section class="settings-section storage-section">
<h2>Storage Usage</h2>
<h2>Stowage</h2>
<p>Estimated storage usage on your default hold.</p>
<div id="storage-stats" hx-get="/api/storage" hx-trigger="load" hx-swap="innerHTML">
<p><i data-lucide="loader-2" class="spin"></i> Loading...</p>
@@ -293,8 +293,8 @@
white-space: nowrap;
}
/* Berth Badge */
.storage-section .berth-badge {
/* Tier Badge */
.storage-section .tier-badge {
text-transform: capitalize;
padding: 0.125rem 0.5rem;
border-radius: 4px;
@@ -302,15 +302,15 @@
background: var(--accent-bg, #e0f2fe);
color: var(--accent, #0369a1);
}
.storage-section .berth-owner {
.storage-section .tier-owner {
background: #fef3c7;
color: #92400e;
}
.storage-section .berth-quartermaster {
.storage-section .tier-quartermaster {
background: #dcfce7;
color: #166534;
}
.storage-section .berth-bosun {
.storage-section .tier-bosun {
background: #e0e7ff;
color: #3730a3;
}
@@ -1,9 +1,9 @@
{{ define "storage_stats" }}
<div class="storage-stats">
{{ if .Berth }}
{{ if .Tier }}
<div class="stat-row">
<span class="stat-label">Berth:</span>
<span class="stat-value berth-badge berth-{{ .Berth }}">{{ .Berth }}</span>
<span class="stat-label">Tier:</span>
<span class="stat-value tier-badge tier-{{ .Tier }}">{{ .Tier }}</span>
</div>
{{ end }}
<div class="stat-row">
+38 -38
View File
@@ -27,7 +27,7 @@ func (t *CrewRecord) MarshalCBOR(w io.Writer) error {
cw := cbg.NewCborWriter(w)
fieldCount := 6
if t.Berth == "" {
if t.Tier == "" {
fieldCount--
}
@@ -58,6 +58,32 @@ func (t *CrewRecord) MarshalCBOR(w io.Writer) error {
return err
}
// t.Tier (string) (string)
if t.Tier != "" {
if len("tier") > 8192 {
return xerrors.Errorf("Value in field \"tier\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tier"))); err != nil {
return err
}
if _, err := cw.WriteString(string("tier")); err != nil {
return err
}
if len(t.Tier) > 8192 {
return xerrors.Errorf("Value in field t.Tier was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Tier))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Tier)); err != nil {
return err
}
}
// t.Type (string) (string)
if len("$type") > 8192 {
return xerrors.Errorf("Value in field \"$type\" was too long")
@@ -81,32 +107,6 @@ func (t *CrewRecord) MarshalCBOR(w io.Writer) error {
return err
}
// t.Berth (string) (string)
if t.Berth != "" {
if len("berth") > 8192 {
return xerrors.Errorf("Value in field \"berth\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("berth"))); err != nil {
return err
}
if _, err := cw.WriteString(string("berth")); err != nil {
return err
}
if len(t.Berth) > 8192 {
return xerrors.Errorf("Value in field t.Berth was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Berth))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Berth)); err != nil {
return err
}
}
// t.Member (string) (string)
if len("member") > 8192 {
return xerrors.Errorf("Value in field \"member\" was too long")
@@ -240,6 +240,17 @@ func (t *CrewRecord) UnmarshalCBOR(r io.Reader) (err error) {
t.Role = string(sval)
}
// t.Tier (string) (string)
case "tier":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Tier = string(sval)
}
// t.Type (string) (string)
case "$type":
@@ -251,17 +262,6 @@ func (t *CrewRecord) UnmarshalCBOR(r io.Reader) (err error) {
t.Type = string(sval)
}
// t.Berth (string) (string)
case "berth":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Berth = string(sval)
}
// t.Member (string) (string)
case "member":
+2 -2
View File
@@ -594,8 +594,8 @@ type CrewRecord struct {
Member string `json:"member" cborgen:"member"`
Role string `json:"role" cborgen:"role"`
Permissions []string `json:"permissions" cborgen:"permissions"`
Berth string `json:"berth,omitempty" cborgen:"berth,omitempty"` // Optional berth for quota limits (nautical rank)
AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster')
AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
}
// LayerRecord represents metadata about a container layer stored in the hold
+2 -2
View File
@@ -24,7 +24,7 @@ type XRPCHandler struct {
pds *pds.HoldPDS
httpClient pds.HTTPClient
enableBlueskyPosts bool
quotaMgr *quota.Manager // Quota manager for berth-based limits
quotaMgr *quota.Manager // Quota manager for tier-based limits
}
// NewXRPCHandler creates a new OCI XRPC handler
@@ -281,7 +281,7 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
if operation == "push" {
// Soft limit check: block if ALREADY over quota
// (blobs already uploaded to S3 by this point, no sense rejecting)
stats, err := h.pds.GetQuotaForUserWithBerth(ctx, req.UserDID, h.quotaMgr)
stats, err := h.pds.GetQuotaForUserWithTier(ctx, req.UserDID, h.quotaMgr)
if err == nil && stats.Limit != nil && stats.TotalSize > *stats.Limit {
slog.Warn("Quota exceeded for push",
"userDid", req.UserDID,
+12 -12
View File
@@ -67,7 +67,7 @@ type QuotaStats struct {
UniqueBlobs int `json:"uniqueBlobs"`
TotalSize int64 `json:"totalSize"`
Limit *int64 `json:"limit,omitempty"` // nil = unlimited
Berth string `json:"berth,omitempty"` // nautical rank for quota tier
Tier string `json:"tier,omitempty"` // quota tier (e.g., 'deckhand', 'bosun', 'quartermaster')
}
// GetQuotaForUser calculates storage quota for a specific user
@@ -164,10 +164,10 @@ func (p *HoldPDS) GetQuotaForUser(ctx context.Context, userDID string) (*QuotaSt
}, nil
}
// GetQuotaForUserWithBerth calculates quota with berth-aware limits
// It returns the base quota stats plus the berth limit and berth name.
// GetQuotaForUserWithTier calculates quota with tier-aware limits
// It returns the base quota stats plus the tier limit and tier name.
// Captain (owner) always has unlimited quota.
func (p *HoldPDS) GetQuotaForUserWithBerth(ctx context.Context, userDID string, quotaMgr *quota.Manager) (*QuotaStats, error) {
func (p *HoldPDS) GetQuotaForUserWithTier(ctx context.Context, userDID string, quotaMgr *quota.Manager) (*QuotaStats, error) {
// Get base stats
stats, err := p.GetQuotaForUser(ctx, userDID)
if err != nil {
@@ -182,23 +182,23 @@ func (p *HoldPDS) GetQuotaForUserWithBerth(ctx context.Context, userDID string,
// Check if user is captain (owner) - always unlimited
_, captain, err := p.GetCaptainRecord(ctx)
if err == nil && captain.Owner == userDID {
stats.Berth = "owner"
stats.Tier = "owner"
// Limit remains nil (unlimited)
return stats, nil
}
// Get crew record to find berth
crewBerth := p.getCrewBerth(ctx, userDID)
// Get crew record to find tier
crewTier := p.getCrewTier(ctx, userDID)
// Resolve limit from quota manager
stats.Limit = quotaMgr.GetBerthLimit(crewBerth)
stats.Berth = quotaMgr.GetBerthName(crewBerth)
stats.Limit = quotaMgr.GetTierLimit(crewTier)
stats.Tier = quotaMgr.GetTierName(crewTier)
return stats, nil
}
// getCrewBerth returns the berth for a crew member, or empty string if not found
func (p *HoldPDS) getCrewBerth(ctx context.Context, userDID string) string {
// getCrewTier returns the tier for a crew member, or empty string if not found
func (p *HoldPDS) getCrewTier(ctx context.Context, userDID string) string {
crewMembers, err := p.ListCrewMembers(ctx)
if err != nil {
return ""
@@ -206,7 +206,7 @@ func (p *HoldPDS) getCrewBerth(ctx context.Context, userDID string) string {
for _, member := range crewMembers {
if member.Record.Member == userDID {
return member.Record.Berth
return member.Record.Tier
}
}
+53 -53
View File
@@ -328,8 +328,8 @@ func setupTestPDSWithIndex(t *testing.T, ownerDID string) (*HoldPDS, func()) {
return pds, cleanup
}
// addCrewMemberWithBerth adds a crew member with a specific berth (nautical rank)
func addCrewMemberWithBerth(t *testing.T, pds *HoldPDS, memberDID, role string, permissions []string, berth string) {
// addCrewMemberWithTier adds a crew member with a specific tier
func addCrewMemberWithTier(t *testing.T, pds *HoldPDS, memberDID, role string, permissions []string, tier string) {
t.Helper()
crewRecord := &atproto.CrewRecord{
@@ -337,17 +337,17 @@ func addCrewMemberWithBerth(t *testing.T, pds *HoldPDS, memberDID, role string,
Member: memberDID,
Role: role,
Permissions: permissions,
Berth: berth,
Tier: tier,
AddedAt: "2026-01-04T12:00:00Z",
}
_, _, err := pds.repomgr.CreateRecord(sharedCtx, pds.uid, atproto.CrewCollection, crewRecord)
if err != nil {
t.Fatalf("Failed to add crew member with berth: %v", err)
t.Fatalf("Failed to add crew member with tier: %v", err)
}
}
func TestGetQuotaForUserWithBerth_OwnerUnlimited(t *testing.T) {
func TestGetQuotaForUserWithTier_OwnerUnlimited(t *testing.T) {
ownerDID := "did:plc:owner123"
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
defer cleanup()
@@ -358,14 +358,14 @@ func TestGetQuotaForUserWithBerth_OwnerUnlimited(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
berths:
tiers:
deckhand:
quota: 5GB
bosun:
quota: 50GB
defaults:
new_crew_berth: deckhand
new_crew_tier: deckhand
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("Failed to write quota config: %v", err)
@@ -391,9 +391,9 @@ defaults:
}
// Get quota for owner
stats, err := pds.GetQuotaForUserWithBerth(ctx, ownerDID, quotaMgr)
stats, err := pds.GetQuotaForUserWithTier(ctx, ownerDID, quotaMgr)
if err != nil {
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
}
// Owner should have unlimited quota (nil limit)
@@ -401,9 +401,9 @@ defaults:
t.Errorf("Expected nil limit for owner, got %d", *stats.Limit)
}
// Berth should be "owner"
if stats.Berth != "owner" {
t.Errorf("Expected berth 'owner', got %q", stats.Berth)
// Tier should be "owner"
if stats.Tier != "owner" {
t.Errorf("Expected tier 'owner', got %q", stats.Tier)
}
// Should have 3 unique blobs
@@ -420,7 +420,7 @@ defaults:
t.Logf("Owner quota stats: %+v", stats)
}
func TestGetQuotaForUserWithBerth_CrewWithDefaultBerth(t *testing.T) {
func TestGetQuotaForUserWithTier_CrewWithDefaultTier(t *testing.T) {
ownerDID := "did:plc:owner456"
crewDID := "did:plc:crew123"
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
@@ -432,14 +432,14 @@ func TestGetQuotaForUserWithBerth_CrewWithDefaultBerth(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
berths:
tiers:
deckhand:
quota: 5GB
bosun:
quota: 50GB
defaults:
new_crew_berth: deckhand
new_crew_tier: deckhand
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("Failed to write quota config: %v", err)
@@ -450,8 +450,8 @@ defaults:
t.Fatalf("Failed to create quota manager: %v", err)
}
// Add crew member with no berth (should use default)
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "")
// Add crew member with no tier (should use default)
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "")
// Create layer records for crew member
for i := range 2 {
@@ -468,12 +468,12 @@ defaults:
}
// Get quota for crew member
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
if err != nil {
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
}
// Should have 5GB limit (deckhand berth)
// Should have 5GB limit (deckhand tier)
expectedLimit := int64(5 * 1024 * 1024 * 1024)
if stats.Limit == nil {
t.Fatal("Expected non-nil limit for crew member")
@@ -482,9 +482,9 @@ defaults:
t.Errorf("Expected limit %d, got %d", expectedLimit, *stats.Limit)
}
// Berth should be "deckhand"
if stats.Berth != "deckhand" {
t.Errorf("Expected berth 'deckhand', got %q", stats.Berth)
// Tier should be "deckhand"
if stats.Tier != "deckhand" {
t.Errorf("Expected tier 'deckhand', got %q", stats.Tier)
}
// Should have 2 unique blobs
@@ -492,10 +492,10 @@ defaults:
t.Errorf("Expected 2 unique blobs, got %d", stats.UniqueBlobs)
}
t.Logf("Crew (deckhand berth) quota stats: %+v", stats)
t.Logf("Crew (deckhand tier) quota stats: %+v", stats)
}
func TestGetQuotaForUserWithBerth_CrewWithExplicitBerth(t *testing.T) {
func TestGetQuotaForUserWithTier_CrewWithExplicitTier(t *testing.T) {
ownerDID := "did:plc:owner789"
crewDID := "did:plc:bosuncrew456"
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
@@ -507,14 +507,14 @@ func TestGetQuotaForUserWithBerth_CrewWithExplicitBerth(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
berths:
tiers:
deckhand:
quota: 5GB
bosun:
quota: 50GB
defaults:
new_crew_berth: deckhand
new_crew_tier: deckhand
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("Failed to write quota config: %v", err)
@@ -525,8 +525,8 @@ defaults:
t.Fatalf("Failed to create quota manager: %v", err)
}
// Add crew member with explicit "bosun" berth
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
// Add crew member with explicit "bosun" tier
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
// Create layer records for crew member
record := atproto.NewLayerRecord(
@@ -541,12 +541,12 @@ defaults:
}
// Get quota for crew member
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
if err != nil {
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
}
// Should have 50GB limit (bosun berth)
// Should have 50GB limit (bosun tier)
expectedLimit := int64(50 * 1024 * 1024 * 1024)
if stats.Limit == nil {
t.Fatal("Expected non-nil limit for crew member")
@@ -555,15 +555,15 @@ defaults:
t.Errorf("Expected limit %d, got %d", expectedLimit, *stats.Limit)
}
// Berth should be "bosun"
if stats.Berth != "bosun" {
t.Errorf("Expected berth 'bosun', got %q", stats.Berth)
// Tier should be "bosun"
if stats.Tier != "bosun" {
t.Errorf("Expected tier 'bosun', got %q", stats.Tier)
}
t.Logf("Crew (bosun berth) quota stats: %+v", stats)
t.Logf("Crew (bosun tier) quota stats: %+v", stats)
}
func TestGetQuotaForUserWithBerth_NoQuotaManager(t *testing.T) {
func TestGetQuotaForUserWithTier_NoQuotaManager(t *testing.T) {
ownerDID := "did:plc:ownerabc"
crewDID := "did:plc:crewabc"
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
@@ -572,7 +572,7 @@ func TestGetQuotaForUserWithBerth_NoQuotaManager(t *testing.T) {
ctx := sharedCtx
// Add crew member
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "deckhand")
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "deckhand")
// Create layer record
record := atproto.NewLayerRecord(
@@ -587,9 +587,9 @@ func TestGetQuotaForUserWithBerth_NoQuotaManager(t *testing.T) {
}
// Get quota with nil quota manager (no enforcement)
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, nil)
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, nil)
if err != nil {
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
}
// Should have nil limit (unlimited)
@@ -597,15 +597,15 @@ func TestGetQuotaForUserWithBerth_NoQuotaManager(t *testing.T) {
t.Errorf("Expected nil limit when quota manager is nil, got %d", *stats.Limit)
}
// Berth should be empty
if stats.Berth != "" {
t.Errorf("Expected empty berth, got %q", stats.Berth)
// Tier should be empty
if stats.Tier != "" {
t.Errorf("Expected empty tier, got %q", stats.Tier)
}
t.Logf("No quota manager stats: %+v", stats)
}
func TestGetQuotaForUserWithBerth_DisabledQuotas(t *testing.T) {
func TestGetQuotaForUserWithTier_DisabledQuotas(t *testing.T) {
ownerDID := "did:plc:ownerdef"
crewDID := "did:plc:crewdef"
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
@@ -624,7 +624,7 @@ func TestGetQuotaForUserWithBerth_DisabledQuotas(t *testing.T) {
}
// Add crew member
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "bosun")
// Create layer record
record := atproto.NewLayerRecord(
@@ -639,9 +639,9 @@ func TestGetQuotaForUserWithBerth_DisabledQuotas(t *testing.T) {
}
// Get quota with disabled quota manager
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
if err != nil {
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
}
// Should have nil limit (unlimited when quotas disabled)
@@ -652,7 +652,7 @@ func TestGetQuotaForUserWithBerth_DisabledQuotas(t *testing.T) {
t.Logf("Disabled quotas stats: %+v", stats)
}
func TestGetQuotaForUserWithBerth_DeduplicatesBlobs(t *testing.T) {
func TestGetQuotaForUserWithTier_DeduplicatesBlobs(t *testing.T) {
ownerDID := "did:plc:ownerghi"
crewDID := "did:plc:crewghi"
pds, cleanup := setupTestPDSWithIndex(t, ownerDID)
@@ -664,12 +664,12 @@ func TestGetQuotaForUserWithBerth_DeduplicatesBlobs(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
berths:
tiers:
deckhand:
quota: 5GB
defaults:
new_crew_berth: deckhand
new_crew_tier: deckhand
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("Failed to write quota config: %v", err)
@@ -681,7 +681,7 @@ defaults:
}
// Add crew member
addCrewMemberWithBerth(t, pds, crewDID, "writer", []string{"blob:write"}, "")
addCrewMemberWithTier(t, pds, crewDID, "writer", []string{"blob:write"}, "")
// Create multiple layer records with same digest (should be deduplicated)
digest := "sha256:duplicatelayer"
@@ -699,9 +699,9 @@ defaults:
}
// Get quota
stats, err := pds.GetQuotaForUserWithBerth(ctx, crewDID, quotaMgr)
stats, err := pds.GetQuotaForUserWithTier(ctx, crewDID, quotaMgr)
if err != nil {
t.Fatalf("GetQuotaForUserWithBerth failed: %v", err)
t.Fatalf("GetQuotaForUserWithTier failed: %v", err)
}
// Should have 1 unique blob (deduplicated)
+2 -2
View File
@@ -1537,8 +1537,8 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
return
}
// Get quota stats with berth-aware limits
stats, err := h.pds.GetQuotaForUserWithBerth(r.Context(), userDID, h.quotaMgr)
// Get quota stats with tier-aware limits
stats, err := h.pds.GetQuotaForUserWithTier(r.Context(), userDID, h.quotaMgr)
if err != nil {
slog.Error("Failed to get quota", "userDid", userDID, "error", err)
http.Error(w, fmt.Sprintf("failed to get quota: %v", err), http.StatusInternalServerError)
+43 -43
View File
@@ -13,30 +13,30 @@ import (
// Config represents the quotas.yaml configuration
type Config struct {
Berths map[string]BerthConfig `yaml:"berths"`
Defaults DefaultsConfig `yaml:"defaults"`
Tiers map[string]TierConfig `yaml:"tiers"`
Defaults DefaultsConfig `yaml:"defaults"`
}
// BerthConfig represents a single berth's configuration
type BerthConfig struct {
// TierConfig represents a single tier's configuration
type TierConfig struct {
Quota string `yaml:"quota"` // Human-readable size: "5GB", "50GB", etc.
}
// DefaultsConfig represents default settings
type DefaultsConfig struct {
NewCrewBerth string `yaml:"new_crew_berth"`
NewCrewTier string `yaml:"new_crew_tier"`
}
// Manager manages quota configuration and berth resolution
// Manager manages quota configuration and tier resolution
type Manager struct {
config *Config
berths map[string]int64 // resolved berth name -> bytes
tiers map[string]int64 // resolved tier name -> bytes
}
// NewManager creates a quota manager, loading config from file if present
func NewManager(configPath string) (*Manager, error) {
m := &Manager{
berths: make(map[string]int64),
tiers: make(map[string]int64),
}
// Try to load config file
@@ -56,13 +56,13 @@ func NewManager(configPath string) (*Manager, error) {
m.config = &cfg
// Parse and resolve all berths
for name, berth := range cfg.Berths {
bytes, err := ParseHumanBytes(berth.Quota)
// Parse and resolve all tiers
for name, tier := range cfg.Tiers {
bytes, err := ParseHumanBytes(tier.Quota)
if err != nil {
return nil, fmt.Errorf("invalid quota for berth %q: %w", name, err)
return nil, fmt.Errorf("invalid quota for tier %q: %w", name, err)
}
m.berths[name] = bytes
m.tiers[name] = bytes
}
return m, nil
@@ -73,72 +73,72 @@ func (m *Manager) IsEnabled() bool {
return m.config != nil
}
// GetBerthLimit resolves the quota limit for a berth key
// Returns nil for unlimited (captain, no config, or berth not found with no default)
// GetTierLimit resolves the quota limit for a tier key
// Returns nil for unlimited (captain, no config, or tier not found with no default)
//
// Resolution order:
// 1. If quotas disabled → nil (unlimited)
// 2. If berthKey provided and found → return that berth's limit
// 3. If berthKey not found or empty → use defaults.new_crew_berth
// 4. If default berth not found → nil (unlimited)
func (m *Manager) GetBerthLimit(berthKey string) *int64 {
// 2. If tierKey provided and found → return that tier's limit
// 3. If tierKey not found or empty → use defaults.new_crew_tier
// 4. If default tier not found → nil (unlimited)
func (m *Manager) GetTierLimit(tierKey string) *int64 {
if !m.IsEnabled() {
return nil
}
// Try the provided berth key first
if berthKey != "" {
if limit, ok := m.berths[berthKey]; ok {
// Try the provided tier key first
if tierKey != "" {
if limit, ok := m.tiers[tierKey]; ok {
return &limit
}
}
// Fall back to default berth
if m.config.Defaults.NewCrewBerth != "" {
if limit, ok := m.berths[m.config.Defaults.NewCrewBerth]; ok {
// Fall back to default tier
if m.config.Defaults.NewCrewTier != "" {
if limit, ok := m.tiers[m.config.Defaults.NewCrewTier]; ok {
return &limit
}
}
// No valid berth found - unlimited
// No valid tier found - unlimited
return nil
}
// GetBerthName resolves the berth name for a berth key
// Returns the actual berth name being used (after fallback resolution)
func (m *Manager) GetBerthName(berthKey string) string {
// GetTierName resolves the tier name for a tier key
// Returns the actual tier name being used (after fallback resolution)
func (m *Manager) GetTierName(tierKey string) string {
if !m.IsEnabled() {
return ""
}
// Try the provided berth key first
if berthKey != "" {
if _, ok := m.berths[berthKey]; ok {
return berthKey
// Try the provided tier key first
if tierKey != "" {
if _, ok := m.tiers[tierKey]; ok {
return tierKey
}
}
// Fall back to default berth
if m.config.Defaults.NewCrewBerth != "" {
if _, ok := m.berths[m.config.Defaults.NewCrewBerth]; ok {
return m.config.Defaults.NewCrewBerth
// Fall back to default tier
if m.config.Defaults.NewCrewTier != "" {
if _, ok := m.tiers[m.config.Defaults.NewCrewTier]; ok {
return m.config.Defaults.NewCrewTier
}
}
return ""
}
// GetDefaultBerth returns the default berth name for new crew members
func (m *Manager) GetDefaultBerth() string {
// GetDefaultTier returns the default tier name for new crew members
func (m *Manager) GetDefaultTier() string {
if m.config == nil {
return ""
}
return m.config.Defaults.NewCrewBerth
return m.config.Defaults.NewCrewTier
}
// BerthCount returns the number of configured berths
func (m *Manager) BerthCount() int {
return len(m.berths)
// TierCount returns the number of configured tiers
func (m *Manager) TierCount() int {
return len(m.tiers)
}
// ParseHumanBytes parses human-readable byte sizes like "5GB", "100MB", "1.5TB"
+34 -34
View File
@@ -97,7 +97,7 @@ func TestNewManager_NoConfigFile(t *testing.T) {
if m.IsEnabled() {
t.Error("expected quotas to be disabled when file missing")
}
if m.GetBerthLimit("anything") != nil {
if m.GetTierLimit("anything") != nil {
t.Error("expected nil limit when quotas disabled")
}
}
@@ -107,7 +107,7 @@ func TestNewManager_ValidConfig(t *testing.T) {
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
berths:
tiers:
deckhand:
quota: 5GB
bosun:
@@ -116,7 +116,7 @@ berths:
quota: 100GB
defaults:
new_crew_berth: deckhand
new_crew_tier: deckhand
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
@@ -131,21 +131,21 @@ defaults:
t.Error("expected quotas to be enabled")
}
if m.BerthCount() != 3 {
t.Errorf("expected 3 berths, got %d", m.BerthCount())
if m.TierCount() != 3 {
t.Errorf("expected 3 tiers, got %d", m.TierCount())
}
// Test default berth (empty string)
limit := m.GetBerthLimit("")
// Test default tier (empty string)
limit := m.GetTierLimit("")
if limit == nil {
t.Fatal("expected non-nil limit for default berth")
t.Fatal("expected non-nil limit for default tier")
}
if *limit != 5*1024*1024*1024 {
t.Errorf("expected 5GB limit for default, got %d", *limit)
}
// Test explicit berth
limit = m.GetBerthLimit("bosun")
// Test explicit tier
limit = m.GetTierLimit("bosun")
if limit == nil {
t.Fatal("expected non-nil limit for bosun")
}
@@ -153,12 +153,12 @@ defaults:
t.Errorf("expected 50GB limit for bosun, got %d", *limit)
}
// Test berth name resolution
if m.GetBerthName("") != "deckhand" {
t.Errorf("expected berth name 'deckhand' for empty key, got %q", m.GetBerthName(""))
// Test tier name resolution
if m.GetTierName("") != "deckhand" {
t.Errorf("expected tier name 'deckhand' for empty key, got %q", m.GetTierName(""))
}
if m.GetBerthName("bosun") != "bosun" {
t.Errorf("expected berth name 'bosun', got %q", m.GetBerthName("bosun"))
if m.GetTierName("bosun") != "bosun" {
t.Errorf("expected tier name 'bosun', got %q", m.GetTierName("bosun"))
}
}
@@ -167,14 +167,14 @@ func TestNewManager_FallbackToDefault(t *testing.T) {
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
berths:
tiers:
deckhand:
quota: 5GB
quartermaster:
quota: 50GB
defaults:
new_crew_berth: deckhand
new_crew_tier: deckhand
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
@@ -185,18 +185,18 @@ defaults:
t.Fatalf("failed to load config: %v", err)
}
// Unknown berth should fall back to default
limit := m.GetBerthLimit("unknown_berth")
// Unknown tier should fall back to default
limit := m.GetTierLimit("unknown_tier")
if limit == nil {
t.Fatal("expected fallback to default berth")
t.Fatal("expected fallback to default tier")
}
if *limit != 5*1024*1024*1024 {
t.Errorf("expected 5GB limit from default fallback, got %d", *limit)
}
// Berth name should also fall back
if m.GetBerthName("unknown_berth") != "deckhand" {
t.Errorf("expected berth name 'deckhand' for unknown berth, got %q", m.GetBerthName("unknown_berth"))
// Tier name should also fall back
if m.GetTierName("unknown_tier") != "deckhand" {
t.Errorf("expected tier name 'deckhand' for unknown tier, got %q", m.GetTierName("unknown_tier"))
}
}
@@ -220,12 +220,12 @@ func TestNewManager_InvalidQuotaSize(t *testing.T) {
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
berths:
tiers:
deckhand:
quota: invalid_size
defaults:
new_crew_berth: deckhand
new_crew_tier: deckhand
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
@@ -237,17 +237,17 @@ defaults:
}
}
func TestNewManager_NoDefaultBerth(t *testing.T) {
func TestNewManager_NoDefaultTier(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "quotas.yaml")
configContent := `
berths:
tiers:
quartermaster:
quota: 50GB
defaults:
new_crew_berth: nonexistent
new_crew_tier: nonexistent
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
@@ -258,16 +258,16 @@ defaults:
t.Fatalf("failed to load config: %v", err)
}
// Empty berth key with nonexistent default should return nil (unlimited)
limit := m.GetBerthLimit("")
// Empty tier key with nonexistent default should return nil (unlimited)
limit := m.GetTierLimit("")
if limit != nil {
t.Error("expected nil limit when default berth doesn't exist")
t.Error("expected nil limit when default tier doesn't exist")
}
// Explicit berth should still work
limit = m.GetBerthLimit("quartermaster")
// Explicit tier should still work
limit = m.GetTierLimit("quartermaster")
if limit == nil {
t.Fatal("expected non-nil limit for quartermaster berth")
t.Fatal("expected non-nil limit for quartermaster tier")
}
if *limit != 50*1024*1024*1024 {
t.Errorf("expected 50GB limit for quartermaster, got %d", *limit)
+10 -10
View File
@@ -2,10 +2,10 @@
# Copy this file to quotas.yaml to enable quota enforcement.
# If quotas.yaml doesn't exist, quotas are disabled (unlimited for all users).
# Berths define quota tiers using nautical crew ranks.
# Each berth has a quota limit specified in human-readable format.
# Tiers define quota levels using nautical crew ranks.
# Each tier has a quota limit specified in human-readable format.
# Supported units: B, KB, MB, GB, TB, PB (case-insensitive)
berths:
tiers:
# Entry-level crew - suitable for new or casual users
deckhand:
quota: 5GB
@@ -18,18 +18,18 @@ berths:
quartermaster:
quota: 100GB
# You can add custom berths with any name:
# You can add custom tiers with any name:
# unlimited_crew:
# quota: 1TB
defaults:
# Default berth assigned to new crew members who don't have an explicit berth.
# This berth must exist in the berths section above.
new_crew_berth: deckhand
# Default tier assigned to new crew members who don't have an explicit tier.
# This tier must exist in the tiers section above.
new_crew_tier: deckhand
# Notes:
# - The hold captain (owner) always has unlimited quota regardless of berths.
# - Crew members can be assigned a specific berth in their crew record.
# - If a crew member's berth doesn't exist in config, they fall back to the default.
# - The hold captain (owner) always has unlimited quota regardless of tiers.
# - Crew members can be assigned a specific tier in their crew record.
# - If a crew member's tier doesn't exist in config, they fall back to the default.
# - Quota is calculated per-user by summing unique blob sizes (deduplicated).
# - Quota is checked when pushing manifests (after blobs are already uploaded).