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

Configure Feed

Select the types of activity you want to include in your feed.

add makefile fix race conditions

evan.jarrett.net e296971c d7eba25f

verified
+155 -17
+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 }