fix quota message

This commit is contained in:
Evan Jarrett
2026-05-16 17:20:12 -05:00
parent 038993c814
commit 04e10b6818
8 changed files with 124 additions and 90 deletions
+29
View File
@@ -2,6 +2,7 @@
# Build targets for the ATProto Container Registry
.PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \
build-trixie \
generate test test-race test-verbose lint lex-lint clean help install-credential-helper \
develop develop-detached develop-down dev \
docker docker-appview docker-hold docker-scanner
@@ -59,6 +60,34 @@ build-oauth-helper: ## Build OAuth helper only
@mkdir -p bin
go build -o bin/oauth-helper ./cmd/oauth-helper
# Trixie cross-build (Debian 13, glibc 2.41) — produces binaries that run on
# any glibc ≥ 2.41 target, even when the host glibc is newer (e.g. Fedora's
# 2.43, which otherwise stamps sqrtf@GLIBC_2.43 onto cgo-linked output).
TRIXIE_BUILDER_IMAGE ?= golang:1-trixie
build-trixie: $(GENERATED_ASSETS) ## Build all production binaries (appview, hold, credential-helper, scanner, labeler) for linux/amd64 in a Debian 13 (glibc 2.41) container
@echo "→ Building in $(TRIXIE_BUILDER_IMAGE) for glibc 2.41 compatibility..."
@mkdir -p bin
docker run --rm \
--user $$(id -u):$$(id -g) \
-v "$(CURDIR)":/src \
-w /src \
-e HOME=/tmp \
-e GOCACHE=/tmp/.gocache \
-e GOMODCACHE=/tmp/.gomodcache \
-e CGO_ENABLED=1 \
-e GOOS=linux \
-e GOARCH=amd64 \
$(TRIXIE_BUILDER_IMAGE) \
bash -c '\
set -e && \
go build -trimpath -ldflags="-s -w $(APPVIEW_LDFLAGS)" -o bin/atcr-appview ./cmd/appview && \
go build -trimpath -ldflags="-s -w" -o bin/atcr-hold ./cmd/hold && \
go build -trimpath -ldflags="-s -w" -o bin/docker-credential-atcr ./cmd/credential-helper && \
go build -trimpath -ldflags="-s -w" -o bin/atcr-labeler ./cmd/labeler && \
cd scanner && go build -trimpath -ldflags="-s -w" -o ../bin/atcr-scanner ./cmd/scanner'
@echo "✓ Built to bin/ (glibc ≥ 2.41 compatible)"
##@ Test Targets
test: ## Run all tests
+3 -26
View File
@@ -371,30 +371,8 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, w
return fmt.Errorf("go generate: %w", err)
}
fmt.Println("\nBuilding locally (GOOS=linux GOARCH=amd64)...")
if appviewCreated {
outputPath := filepath.Join(rootDir, "bin", "atcr-appview")
if err := buildLocal(rootDir, outputPath, "./cmd/appview"); err != nil {
return fmt.Errorf("build appview: %w", err)
}
if state.LabelerEnabled {
outputPath := filepath.Join(rootDir, "bin", "atcr-labeler")
if err := buildLocal(rootDir, outputPath, "./cmd/labeler"); err != nil {
return fmt.Errorf("build labeler: %w", err)
}
}
}
if holdCreated {
outputPath := filepath.Join(rootDir, "bin", "atcr-hold")
if err := buildLocal(rootDir, outputPath, "./cmd/hold"); err != nil {
return fmt.Errorf("build hold: %w", err)
}
if state.ScannerEnabled {
outputPath := filepath.Join(rootDir, "bin", "atcr-scanner")
if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil {
return fmt.Errorf("build scanner: %w", err)
}
}
if err := runMakeBuildTrixie(rootDir); err != nil {
return fmt.Errorf("build: %w", err)
}
fmt.Println("\nWaiting for cloud-init to complete on new servers...")
@@ -447,9 +425,8 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, w
if err := runGenerate(rootDir); err != nil {
return fmt.Errorf("go generate: %w", err)
}
fmt.Println("\nBuilding labeler locally (GOOS=linux GOARCH=amd64)...")
labelerLocal := filepath.Join(rootDir, "bin", "atcr-labeler")
if err := buildLocal(rootDir, labelerLocal, "./cmd/labeler"); err != nil {
if err := runMakeBuildTrixie(rootDir); err != nil {
return fmt.Errorf("build labeler: %w", err)
}
labelerRemote := naming.InstallDir() + "/bin/" + naming.Labeler()
+12 -53
View File
@@ -127,44 +127,10 @@ func cmdUpdate(target string, withScanner, withLabeler bool) error {
return fmt.Errorf("go generate: %w", err)
}
// Build all binaries locally before touching servers
fmt.Println("Building locally (GOOS=linux GOARCH=amd64)...")
for _, name := range toUpdate {
t := targets[name]
outputPath := filepath.Join(rootDir, "bin", t.localBinary)
if err := buildLocal(rootDir, outputPath, "./cmd/"+t.buildCmd); err != nil {
return fmt.Errorf("build %s: %w", name, err)
}
}
// Build scanner locally if needed
needScanner := false
for _, name := range toUpdate {
if name == "hold" && state.ScannerEnabled {
needScanner = true
break
}
}
if needScanner {
outputPath := filepath.Join(rootDir, "bin", "atcr-scanner")
if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil {
return fmt.Errorf("build scanner: %w", err)
}
}
// Build labeler locally if needed
needLabeler := false
for _, name := range toUpdate {
if name == "appview" && state.LabelerEnabled {
needLabeler = true
break
}
}
if needLabeler {
outputPath := filepath.Join(rootDir, "bin", "atcr-labeler")
if err := buildLocal(rootDir, outputPath, "./cmd/labeler"); err != nil {
return fmt.Errorf("build labeler: %w", err)
}
// Build all binaries via `make build-trixie` so output links against
// glibc 2.41 (the deploy target's glibc) regardless of the host's glibc.
if err := runMakeBuildTrixie(rootDir); err != nil {
return fmt.Errorf("build: %w", err)
}
// Deploy each target
@@ -392,21 +358,14 @@ func runGenerate(dir string) error {
return cmd.Run()
}
// buildLocal compiles a Go binary locally with cross-compilation flags for linux/amd64.
func buildLocal(dir, outputPath, buildPkg string) error {
fmt.Printf(" building %s...\n", filepath.Base(outputPath))
cmd := exec.Command("go", "build",
"-ldflags=-s -w",
"-trimpath",
"-o", outputPath,
buildPkg,
)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GOOS=linux",
"GOARCH=amd64",
"CGO_ENABLED=1",
)
// runMakeBuildTrixie shells out to `make build-trixie`, which builds all
// production binaries (appview, hold, credential-helper, labeler, scanner)
// inside a Debian 13 container so they link against glibc 2.41. Centralizing
// the build recipe in the Makefile keeps deploy and local builds in sync.
func runMakeBuildTrixie(rootDir string) error {
fmt.Println("Running `make build-trixie` (linux/amd64, glibc 2.41)...")
cmd := exec.Command("make", "build-trixie")
cmd.Dir = rootDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
+21 -2
View File
@@ -239,12 +239,31 @@ func (a *Authorizer) checkQuota(ctx context.Context, userDID, holdDID string) er
}
if stats.Limit != nil && stats.TotalSize >= *stats.Limit {
return fmt.Errorf("quota exceeded: %d / %d bytes used by %s. Delete images to free space",
stats.TotalSize, *stats.Limit, userDID)
return fmt.Errorf("quota exceeded: %s / %s used by %s. Delete images to free space",
formatGB(stats.TotalSize), formatGB(*stats.Limit), a.identityLabel(ctx, userDID))
}
return nil
}
// formatGB renders bytes as gigabytes with two decimals — matches the
// percentage display in the appview UI so users see consistent units.
func formatGB(b int64) string {
return fmt.Sprintf("%.2f GB", float64(b)/(1024*1024*1024))
}
// identityLabel returns "handle (did)" when the local users table knows the
// handle, falling back to the bare DID. The users table is Jetstream-fed, so
// for first-seen identities (rare on a push, since they've already OAuth'd)
// the handle may be missing — the DID alone still uniquely identifies them.
func (a *Authorizer) identityLabel(ctx context.Context, did string) string {
var handle string
err := a.db.QueryRowContext(ctx, "SELECT handle FROM users WHERE did = ?", did).Scan(&handle)
if err != nil || handle == "" || handle == did {
return did
}
return fmt.Sprintf("%s (%s)", handle, did)
}
// serviceTokenFetcher returns the appropriate fetcher for the auth method,
// or nil if none is available (e.g. OAuth flow with no refresher configured).
// ctx is used for the cold-cache identity lookup inside resolvePDS.
+25 -3
View File
@@ -230,21 +230,43 @@ func TestCheckQuota_OverLimit(t *testing.T) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
// 5 GiB exactly so the formatted message shows "5.00 GB / 5.00 GB".
srv := quotaServer(t, 200, `{"totalSize":5368709120,"limit":5368709120}`)
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.bsky.social", "")
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID)
if err == nil {
t.Fatal("checkQuota at limit should deny")
}
msg := err.Error()
for _, want := range []string{"quota exceeded", "1000", "did:plc:alice"} {
for _, want := range []string{"quota exceeded", "5.00 GB", "did:plc:alice", "alice.bsky.social"} {
if !strings.Contains(msg, want) {
t.Errorf("expected %q in error %q", want, msg)
}
}
}
// When no users row exists for the DID (handle unknown), the error still
// formats correctly with the bare DID.
func TestCheckQuota_OverLimit_NoHandle(t *testing.T) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
srv := quotaServer(t, 200, `{"totalSize":5368709120,"limit":5368709120}`)
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
err := a.checkQuota(context.Background(), "did:plc:bob", srv.holdDID)
if err == nil {
t.Fatal("checkQuota at limit should deny")
}
msg := err.Error()
if !strings.Contains(msg, "did:plc:bob") || strings.Contains(msg, "(did:") {
t.Errorf("expected bare DID (no parenthesized form) in error %q", msg)
}
}
func TestCheckQuota_NilLimitAllows(t *testing.T) {
// A user on the unlimited tier has limit == nil. Even huge totalSize
// must not deny.
+17 -4
View File
File diff suppressed because one or more lines are too long
@@ -33,7 +33,9 @@
</div>
{{/* Color alone on the progress bar doesn't meet AAA — repeat the warning
as an alert block once usage crosses the threshold. */}}
{{ if ge .UsagePercent 95 }}
{{ if ge .UsagePercent 100 }}
{{ template "alert" (dict "Type" "error" "Message" "You've exceeded your storage limit. New pushes will be rejected until you delete images to free space.") }}
{{ else if ge .UsagePercent 95 }}
{{ template "alert" (dict "Type" "error" "Message" "You're nearly at your storage limit. Pushes may fail once you exceed it.") }}
{{ else if ge .UsagePercent 80 }}
{{ template "alert" (dict "Type" "warning" "Message" "You're using most of your storage quota. Consider cleaning up untagged images.") }}
File diff suppressed because one or more lines are too long