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