A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

add makefile fix race conditions

evan.jarrett.net e296971c d7eba25f

verified
+5
.gitignore
··· 11 11 # Environment configuration 12 12 .env 13 13 14 + # Generated assets (run go generate to rebuild) 15 + pkg/appview/licenses/spdx-licenses.json 16 + pkg/appview/static/js/htmx.min.js 17 + pkg/appview/static/js/lucide.min.js 18 + 14 19 # IDE 15 20 .claude/ 16 21 .vscode/
+84
Makefile
··· 1 + # ATCR Makefile 2 + # Build targets for the ATProto Container Registry 3 + 4 + .PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \ 5 + generate test test-race test-verbose lint clean help 6 + 7 + .DEFAULT_GOAL := help 8 + 9 + help: ## Show this help message 10 + @echo "ATCR Build Targets:" 11 + @echo "" 12 + @awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-28s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 13 + 14 + all: generate build ## Generate assets and build all binaries (default) 15 + 16 + # Generated asset files 17 + GENERATED_ASSETS = \ 18 + pkg/appview/static/js/htmx.min.js \ 19 + pkg/appview/static/js/lucide.min.js \ 20 + pkg/appview/licenses/spdx-licenses.json 21 + 22 + generate: $(GENERATED_ASSETS) ## Run go generate to download vendor assets 23 + 24 + $(GENERATED_ASSETS): 25 + @echo "→ Generating vendor assets and code..." 26 + go generate ./... 27 + 28 + ##@ Build Targets 29 + 30 + build: build-appview build-hold build-credential-helper ## Build all binaries 31 + 32 + build-appview: $(GENERATED_ASSETS) ## Build appview binary only 33 + @echo "→ Building appview..." 34 + @mkdir -p bin 35 + go build -o bin/atcr-appview ./cmd/appview 36 + 37 + build-hold: $(GENERATED_ASSETS) ## Build hold binary only 38 + @echo "→ Building hold..." 39 + @mkdir -p bin 40 + go build -o bin/atcr-hold ./cmd/hold 41 + 42 + build-credential-helper: $(GENERATED_ASSETS) ## Build credential helper only 43 + @echo "→ Building credential helper..." 44 + @mkdir -p bin 45 + go build -o bin/docker-credential-atcr ./cmd/credential-helper 46 + 47 + build-oauth-helper: $(GENERATED_ASSETS) ## Build OAuth helper only 48 + @echo "→ Building OAuth helper..." 49 + @mkdir -p bin 50 + go build -o bin/oauth-helper ./cmd/oauth-helper 51 + 52 + ##@ Test Targets 53 + 54 + test: ## Run all tests 55 + @echo "→ Running tests..." 56 + go test -cover ./... 57 + 58 + test-race: ## Run tests with race detector 59 + @echo "→ Running tests with race detector..." 60 + go test -race ./... 61 + 62 + test-verbose: ## Run tests with verbose output 63 + @echo "→ Running tests with verbose output..." 64 + go test -v ./... 65 + 66 + ##@ Quality Targets 67 + 68 + .PHONY: check-golangci-lint 69 + check-golangci-lint: 70 + @which golangci-lint > /dev/null || (echo "→ Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) 71 + 72 + lint: check-golangci-lint ## Run golangci-lint 73 + @echo "→ Running golangci-lint..." 74 + golangci-lint run ./... 75 + 76 + ##@ Utility Targets 77 + 78 + clean: ## Remove built binaries and generated assets 79 + @echo "→ Cleaning build artifacts..." 80 + rm -rf bin/ 81 + rm -f pkg/appview/static/js/htmx.min.js 82 + rm -f pkg/appview/static/js/lucide.min.js 83 + rm -f pkg/appview/licenses/spdx-licenses.json 84 + @echo "✓ Clean complete"
+18
pkg/appview/storage/context_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "sync" 5 6 "testing" 6 7 7 8 "atcr.io/pkg/atproto" ··· 9 10 10 11 // Mock implementations for testing 11 12 type mockDatabaseMetrics struct { 13 + mu sync.Mutex 12 14 pullCount int 13 15 pushCount int 14 16 } 15 17 16 18 func (m *mockDatabaseMetrics) IncrementPullCount(did, repository string) error { 19 + m.mu.Lock() 20 + defer m.mu.Unlock() 17 21 m.pullCount++ 18 22 return nil 19 23 } 20 24 21 25 func (m *mockDatabaseMetrics) IncrementPushCount(did, repository string) error { 26 + m.mu.Lock() 27 + defer m.mu.Unlock() 22 28 m.pushCount++ 23 29 return nil 30 + } 31 + 32 + func (m *mockDatabaseMetrics) getPullCount() int { 33 + m.mu.Lock() 34 + defer m.mu.Unlock() 35 + return m.pullCount 36 + } 37 + 38 + func (m *mockDatabaseMetrics) getPushCount() int { 39 + m.mu.Lock() 40 + defer m.mu.Unlock() 41 + return m.pushCount 24 42 } 25 43 26 44 type mockReadmeCache struct{}
+6
pkg/appview/storage/manifest_store.go
··· 11 11 "maps" 12 12 "net/http" 13 13 "strings" 14 + "sync" 14 15 "time" 15 16 16 17 "atcr.io/pkg/atproto" ··· 22 23 // It stores manifests in ATProto as records 23 24 type ManifestStore struct { 24 25 ctx *RegistryContext // Context with user/hold info 26 + mu sync.RWMutex // Protects lastFetchedHoldDID 25 27 lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull) 26 28 blobStore distribution.BlobStore // Blob store for fetching config during push 27 29 } ··· 67 69 // Store the hold DID for subsequent blob requests during pull 68 70 // Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format) 69 71 // The routing repository will cache this for concurrent blob fetches 72 + s.mu.Lock() 70 73 if manifestRecord.HoldDID != "" { 71 74 // New format: DID reference (preferred) 72 75 s.lastFetchedHoldDID = manifestRecord.HoldDID ··· 74 77 // Legacy format: URL reference - convert to DID 75 78 s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) 76 79 } 80 + s.mu.Unlock() 77 81 78 82 var ociManifest []byte 79 83 ··· 232 236 // GetLastFetchedHoldDID returns the hold DID from the most recently fetched manifest 233 237 // This is used by the routing repository to cache the hold for blob requests 234 238 func (s *ManifestStore) GetLastFetchedHoldDID() string { 239 + s.mu.RLock() 240 + defer s.mu.RUnlock() 235 241 return s.lastFetchedHoldDID 236 242 } 237 243
+3 -3
pkg/appview/storage/manifest_store_test.go
··· 669 669 670 670 if tt.expectPullIncrement { 671 671 // Check that IncrementPullCount was called 672 - if mockDB.pullCount == 0 { 672 + if mockDB.getPullCount() == 0 { 673 673 t.Error("Expected pull count to be incremented for GET request, but it wasn't") 674 674 } 675 675 } else { 676 676 // Check that IncrementPullCount was NOT called 677 - if mockDB.pullCount > 0 { 678 - t.Errorf("Expected pull count NOT to be incremented for %s request, but it was (count=%d)", tt.httpMethod, mockDB.pullCount) 677 + if mockDB.getPullCount() > 0 { 678 + t.Errorf("Expected pull count NOT to be incremented for %s request, but it was (count=%d)", tt.httpMethod, mockDB.getPullCount()) 679 679 } 680 680 } 681 681 })
+11 -3
pkg/appview/storage/profile_test.go
··· 219 219 // Clear migration locks before each test 220 220 migrationLocks = sync.Map{} 221 221 222 + var mu sync.Mutex 222 223 putRecordCalled := false 223 224 var migrationRequest map[string]any 224 225 ··· 232 233 233 234 // PutRecord (migration) 234 235 if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { 236 + mu.Lock() 235 237 putRecordCalled = true 236 238 json.NewDecoder(r.Body).Decode(&migrationRequest) 239 + mu.Unlock() 237 240 w.WriteHeader(http.StatusOK) 238 241 w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) 239 242 return ··· 270 273 // Give goroutine time to execute 271 274 time.Sleep(50 * time.Millisecond) 272 275 273 - if !putRecordCalled { 276 + mu.Lock() 277 + called := putRecordCalled 278 + request := migrationRequest 279 + mu.Unlock() 280 + 281 + if !called { 274 282 t.Error("Expected migration PutRecord to be called") 275 283 } 276 284 277 - if migrationRequest != nil { 278 - recordData := migrationRequest["record"].(map[string]any) 285 + if request != nil { 286 + recordData := request["record"].(map[string]any) 279 287 migratedHold := recordData["defaultHold"] 280 288 if migratedHold != tt.expectedHoldDID { 281 289 t.Errorf("Migrated defaultHold = %v, want %v", migratedHold, tt.expectedHoldDID)
+21 -5
pkg/appview/storage/routing_repository.go
··· 7 7 import ( 8 8 "context" 9 9 "log/slog" 10 + "sync" 10 11 "time" 11 12 12 13 "github.com/distribution/distribution/v3" ··· 17 18 type RoutingRepository struct { 18 19 distribution.Repository 19 20 Ctx *RegistryContext // All context and services (exported for token updates) 21 + mu sync.Mutex // Protects manifestStore and blobStore 20 22 manifestStore *ManifestStore // Cached manifest store instance 21 23 blobStore *ProxyBlobStore // Cached blob store instance 22 24 } ··· 31 33 32 34 // Manifests returns the ATProto-backed manifest service 33 35 func (r *RoutingRepository) Manifests(ctx context.Context, options ...distribution.ManifestServiceOption) (distribution.ManifestService, error) { 36 + r.mu.Lock() 34 37 // Create or return cached manifest store 35 38 if r.manifestStore == nil { 36 39 // Ensure blob store is created first (needed for label extraction during push) 40 + // Release lock while calling Blobs to avoid deadlock 41 + r.mu.Unlock() 37 42 blobStore := r.Blobs(ctx) 43 + r.mu.Lock() 38 44 39 - r.manifestStore = NewManifestStore(r.Ctx, blobStore) 45 + // Double-check after reacquiring lock (another goroutine might have set it) 46 + if r.manifestStore == nil { 47 + r.manifestStore = NewManifestStore(r.Ctx, blobStore) 48 + } 40 49 } 50 + manifestStore := r.manifestStore 51 + r.mu.Unlock() 41 52 42 53 // After any manifest operation, cache the hold DID for blob fetches 43 54 // We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete 44 55 go func() { 45 56 time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete 46 - if holdDID := r.manifestStore.GetLastFetchedHoldDID(); holdDID != "" { 57 + if holdDID := manifestStore.GetLastFetchedHoldDID(); holdDID != "" { 47 58 // Cache for 10 minutes - should cover typical pull operations 48 59 GetGlobalHoldCache().Set(r.Ctx.DID, r.Ctx.Repository, holdDID, 10*time.Minute) 49 60 slog.Debug("Cached hold DID", "component", "storage/routing", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", holdDID) 50 61 } 51 62 }() 52 63 53 - return r.manifestStore, nil 64 + return manifestStore, nil 54 65 } 55 66 56 67 // Blobs returns a proxy blob store that routes to external hold service 57 68 // The registry (AppView) NEVER stores blobs locally - all blobs go through hold service 58 69 func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore { 70 + r.mu.Lock() 59 71 // Return cached blob store if available 60 72 if r.blobStore != nil { 73 + blobStore := r.blobStore 74 + r.mu.Unlock() 61 75 slog.Debug("Returning cached blob store", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository) 62 - return r.blobStore 76 + return blobStore 63 77 } 64 78 65 79 // For pull operations, check if we have a cached hold DID from a recent manifest fetch ··· 85 99 86 100 // Create and cache proxy blob store 87 101 r.blobStore = NewProxyBlobStore(r.Ctx) 88 - return r.blobStore 102 + blobStore := r.blobStore 103 + r.mu.Unlock() 104 + return blobStore 89 105 } 90 106 91 107 // Tags returns the tag service
+4 -4
pkg/appview/templates/components/head.html
··· 12 12 <!-- Stylesheets --> 13 13 <link rel="stylesheet" href="/css/style.css"> 14 14 15 - <!-- HTMX --> 16 - <script src="https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js"></script> 15 + <!-- HTMX (vendored) --> 16 + <script src="/js/htmx.min.js"></script> 17 17 18 - <!-- Lucide Icons --> 19 - <script src="https://unpkg.com/lucide@latest"></script> 18 + <!-- Lucide Icons (vendored) --> 19 + <script src="/js/lucide.min.js"></script> 20 20 21 21 <!-- App Scripts --> 22 22 <script src="/js/app.js"></script>
+3
pkg/appview/ui.go
··· 12 12 "atcr.io/pkg/appview/licenses" 13 13 ) 14 14 15 + //go:generate curl -fsSL -o static/js/htmx.min.js https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js 16 + //go:generate curl -fsSL -o static/js/lucide.min.js https://unpkg.com/lucide@latest/dist/umd/lucide.min.js 17 + 15 18 //go:embed templates/**/*.html 16 19 var templatesFS embed.FS 17 20
-2
pkg/atproto/generate.go
··· 35 35 fmt.Printf("Failed to generate CBOR encoders: %v\n", err) 36 36 os.Exit(1) 37 37 } 38 - 39 - fmt.Println("Generated CBOR encoders in pkg/atproto/cbor_gen.go") 40 38 }