+5
.gitignore
+5
.gitignore
+84
Makefile
+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
+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
+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
+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
+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
+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
+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
+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