From 487fc8a47e86c66fa4451dc44a55d269956ceabd Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 4 Jan 2026 23:37:31 -0600 Subject: [PATCH] wording --- cmd/hold/main.go | 2 +- deploy/quotas.yaml | 20 ++-- docs/QUOTAS.md | 22 ++-- lexicons/io/atcr/hold/crew.json | 4 +- pkg/appview/handlers/storage.go | 6 +- pkg/appview/templates/pages/settings.html | 12 +- .../templates/partials/storage_stats.html | 6 +- pkg/atproto/cbor_gen.go | 76 ++++++------- pkg/atproto/lexicon.go | 4 +- pkg/hold/oci/xrpc.go | 4 +- pkg/hold/pds/layer.go | 24 ++-- pkg/hold/pds/layer_test.go | 106 +++++++++--------- pkg/hold/pds/xrpc.go | 4 +- pkg/hold/quota/config.go | 86 +++++++------- pkg/hold/quota/config_test.go | 68 +++++------ quotas.yaml.example | 20 ++-- 16 files changed, 232 insertions(+), 232 deletions(-) diff --git a/cmd/hold/main.go b/cmd/hold/main.go index ae20c80..3df1c8e 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -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)") } diff --git a/deploy/quotas.yaml b/deploy/quotas.yaml index d92bef4..1671da5 100644 --- a/deploy/quotas.yaml +++ b/deploy/quotas.yaml @@ -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). diff --git a/docs/QUOTAS.md b/docs/QUOTAS.md index 232e71d..d67a30a 100644 --- a/docs/QUOTAS.md +++ b/docs/QUOTAS.md @@ -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" } ``` diff --git a/lexicons/io/atcr/hold/crew.json b/lexicons/io/atcr/hold/crew.json index 5a0912a..0709bba 100644 --- a/lexicons/io/atcr/hold/crew.json +++ b/lexicons/io/atcr/hold/crew.json @@ -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": { diff --git a/pkg/appview/handlers/storage.go b/pkg/appview/handlers/storage.go index 9a79ad5..2c24a55 100644 --- a/pkg/appview/handlers/storage.go +++ b/pkg/appview/handlers/storage.go @@ -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") diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html index 3eacdbd..1397fb4 100644 --- a/pkg/appview/templates/pages/settings.html +++ b/pkg/appview/templates/pages/settings.html @@ -31,7 +31,7 @@
-

Storage Usage

+

Stowage

Estimated storage usage on your default hold.

Loading...

@@ -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; } diff --git a/pkg/appview/templates/partials/storage_stats.html b/pkg/appview/templates/partials/storage_stats.html index b2df312..950653c 100644 --- a/pkg/appview/templates/partials/storage_stats.html +++ b/pkg/appview/templates/partials/storage_stats.html @@ -1,9 +1,9 @@ {{ define "storage_stats" }}
- {{ if .Berth }} + {{ if .Tier }}
- Berth: - {{ .Berth }} + Tier: + {{ .Tier }}
{{ end }}
diff --git a/pkg/atproto/cbor_gen.go b/pkg/atproto/cbor_gen.go index 9285ce4..1af73ea 100644 --- a/pkg/atproto/cbor_gen.go +++ b/pkg/atproto/cbor_gen.go @@ -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": diff --git a/pkg/atproto/lexicon.go b/pkg/atproto/lexicon.go index 5481235..5bc6946 100644 --- a/pkg/atproto/lexicon.go +++ b/pkg/atproto/lexicon.go @@ -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 diff --git a/pkg/hold/oci/xrpc.go b/pkg/hold/oci/xrpc.go index 130b88c..f114a4d 100644 --- a/pkg/hold/oci/xrpc.go +++ b/pkg/hold/oci/xrpc.go @@ -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, diff --git a/pkg/hold/pds/layer.go b/pkg/hold/pds/layer.go index 1282160..783a387 100644 --- a/pkg/hold/pds/layer.go +++ b/pkg/hold/pds/layer.go @@ -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 } } diff --git a/pkg/hold/pds/layer_test.go b/pkg/hold/pds/layer_test.go index a6f7acf..372d0b9 100644 --- a/pkg/hold/pds/layer_test.go +++ b/pkg/hold/pds/layer_test.go @@ -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) diff --git a/pkg/hold/pds/xrpc.go b/pkg/hold/pds/xrpc.go index daaa257..7fd7d4d 100644 --- a/pkg/hold/pds/xrpc.go +++ b/pkg/hold/pds/xrpc.go @@ -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) diff --git a/pkg/hold/quota/config.go b/pkg/hold/quota/config.go index 3a10e41..f95f99b 100644 --- a/pkg/hold/quota/config.go +++ b/pkg/hold/quota/config.go @@ -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" diff --git a/pkg/hold/quota/config_test.go b/pkg/hold/quota/config_test.go index 70aafdf..5dc9586 100644 --- a/pkg/hold/quota/config_test.go +++ b/pkg/hold/quota/config_test.go @@ -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) diff --git a/quotas.yaml.example b/quotas.yaml.example index d92bef4..1671da5 100644 --- a/quotas.yaml.example +++ b/quotas.yaml.example @@ -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).