A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

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

refactor appview to use envvars. move distribution configurations to code

+1042 -116
+90
.env.appview.example
··· 1 + # ATCR AppView Configuration 2 + # Copy this file to .env.appview and fill in your values 3 + # Load with: source .env.appview && ./bin/atcr-appview serve 4 + 5 + # ============================================================================== 6 + # Server Configuration 7 + # ============================================================================== 8 + 9 + # HTTP listen address (default: :5000) 10 + ATCR_HTTP_ADDR=:5000 11 + 12 + # Debug listen address (default: :5001) 13 + # ATCR_DEBUG_ADDR=:5001 14 + 15 + # Base URL for the AppView service (REQUIRED for production) 16 + # Used to generate OAuth redirect URIs and JWT realms 17 + # Development: Auto-detected from ATCR_HTTP_ADDR (e.g., http://127.0.0.1:5000) 18 + # Production: Set to your public URL (e.g., https://atcr.io) 19 + # ATCR_BASE_URL=http://127.0.0.1:5000 20 + 21 + # Service name (used for JWT service/issuer fields) 22 + # Default: Derived from base URL hostname, or "atcr.io" 23 + # ATCR_SERVICE_NAME=atcr.io 24 + 25 + # ============================================================================== 26 + # Storage Configuration 27 + # ============================================================================== 28 + 29 + # Default hold service endpoint for users without their own storage (REQUIRED) 30 + # Users with a sailor profile defaultHold setting will override this 31 + # Docker: Use container name (http://atcr-hold:8080) 32 + # Local dev: Use localhost (http://127.0.0.1:8080) 33 + ATCR_DEFAULT_HOLD=http://127.0.0.1:8080 34 + 35 + # ============================================================================== 36 + # Authentication Configuration 37 + # ============================================================================== 38 + 39 + # Path to JWT signing private key (auto-generated if missing) 40 + # Default: /var/lib/atcr/auth/private-key.pem 41 + # ATCR_AUTH_KEY_PATH=/var/lib/atcr/auth/private-key.pem 42 + 43 + # Path to JWT signing certificate (auto-generated if missing) 44 + # Default: /var/lib/atcr/auth/private-key.crt 45 + # ATCR_AUTH_CERT_PATH=/var/lib/atcr/auth/private-key.crt 46 + 47 + # JWT token expiration in seconds (default: 300 = 5 minutes) 48 + # ATCR_TOKEN_EXPIRATION=300 49 + 50 + # ============================================================================== 51 + # UI Configuration 52 + # ============================================================================== 53 + 54 + # Enable web UI (default: true) 55 + # Set to "false" to disable web interface and run registry-only 56 + ATCR_UI_ENABLED=true 57 + 58 + # SQLite database path for UI data (sessions, stars, pull counts, etc.) 59 + # Default: /var/lib/atcr/ui.db 60 + # ATCR_UI_DATABASE_PATH=/var/lib/atcr/ui.db 61 + 62 + # ============================================================================== 63 + # Logging Configuration 64 + # ============================================================================== 65 + 66 + # Log level: debug, info, warn, error (default: info) 67 + # ATCR_LOG_LEVEL=info 68 + 69 + # Log formatter: text, json (default: text) 70 + # ATCR_LOG_FORMATTER=text 71 + 72 + # ============================================================================== 73 + # Jetstream Configuration (ATProto event streaming) 74 + # ============================================================================== 75 + 76 + # Jetstream WebSocket URL for real-time ATProto events 77 + # Default: wss://jetstream2.us-west.bsky.network/subscribe 78 + # JETSTREAM_URL=wss://jetstream2.us-west.bsky.network/subscribe 79 + 80 + # Enable backfill worker to sync historical records (default: false) 81 + # Set to "true" to enable periodic syncing of ATProto records 82 + # ATCR_BACKFILL_ENABLED=true 83 + 84 + # ATProto relay endpoint for backfill sync API 85 + # Default: https://relay1.us-east.bsky.network 86 + # ATCR_RELAY_ENDPOINT=https://relay1.us-east.bsky.network 87 + 88 + # Backfill interval (default: 1h) 89 + # Examples: 30m, 1h, 2h, 24h 90 + # ATCR_BACKFILL_INTERVAL=1h
+48 -23
CLAUDE.md
··· 31 31 # Or use docker-compose 32 32 docker-compose up -d 33 33 34 - # Run locally (AppView) 35 - export ATPROTO_DID=did:plc:your-did 36 - export ATPROTO_ACCESS_TOKEN=your-token 37 - ./atcr-appview serve config/config.yml 34 + # Run locally (AppView) - configure via env vars (see .env.appview.example) 35 + export ATCR_HTTP_ADDR=:5000 36 + export ATCR_DEFAULT_HOLD=http://127.0.0.1:8080 37 + ./bin/atcr-appview serve 38 + 39 + # Or use .env file: 40 + cp .env.appview.example .env.appview 41 + # Edit .env.appview with your settings 42 + source .env.appview 43 + ./bin/atcr-appview serve 44 + 45 + # Legacy mode (still supported): 46 + # ./bin/atcr-appview serve config/config.yml 38 47 39 - # Run hold service (configure via env vars - see .env.example) 48 + # Run hold service (configure via env vars - see .env.hold.example) 40 49 export HOLD_PUBLIC_URL=http://127.0.0.1:8080 41 50 export STORAGE_DRIVER=filesystem 42 51 export STORAGE_ROOT_DIR=/tmp/atcr-hold 43 52 export HOLD_OWNER=did:plc:your-did-here 44 - ./atcr-hold 53 + ./bin/atcr-hold 45 54 # Check logs for OAuth URL, visit in browser to complete registration 46 55 ``` 47 56 ··· 433 442 434 443 ### Configuration 435 444 436 - **AppView configuration** (`config/config.yml`): 437 - - S3 bucket settings under `storage.s3` 438 - - ATProto middleware under `middleware.repository` 439 - - Name resolver under `middleware.registry` 440 - - Default storage endpoint: `middleware.registry.options.default_storage_endpoint` 441 - - Auth token signing keys and expiration 442 - - Database path: `db.path` (SQLite database location) 443 - - Jetstream endpoint: `jetstream.endpoint` (for ATProto event streaming) 445 + **AppView configuration** (environment variables): 446 + 447 + Both AppView and Hold service follow the same pattern: **zero config files, all configuration via environment variables**. 448 + 449 + See `.env.appview.example` for all available options. Key environment variables: 450 + 451 + **Server:** 452 + - `ATCR_HTTP_ADDR` - HTTP listen address (default: `:5000`) 453 + - `ATCR_BASE_URL` - Public URL for OAuth/JWT realm (auto-detected in dev) 454 + - `ATCR_DEFAULT_HOLD` - Default hold endpoint for blob storage (REQUIRED) 455 + 456 + **Authentication:** 457 + - `ATCR_AUTH_KEY_PATH` - JWT signing key path (default: `/var/lib/atcr/auth/private-key.pem`) 458 + - `ATCR_TOKEN_EXPIRATION` - JWT expiration in seconds (default: 300) 459 + 460 + **UI:** 461 + - `ATCR_UI_ENABLED` - Enable web interface (default: true) 462 + - `ATCR_UI_DATABASE_PATH` - SQLite database path (default: `/var/lib/atcr/ui.db`) 463 + 464 + **Jetstream:** 465 + - `JETSTREAM_URL` - ATProto event stream URL 466 + - `ATCR_BACKFILL_ENABLED` - Enable periodic sync (default: false) 467 + 468 + **Legacy:** `config/config.yml` is still supported but deprecated. Use environment variables instead. 444 469 445 470 **Hold Service configuration** (environment variables): 446 - - Storage driver config via env vars: `STORAGE_DRIVER`, `AWS_*`, `S3_*` 447 - - Authorization: Based on PDS records (`hold.public`, crew records) 448 - - Server settings: `HOLD_SERVER_ADDR`, `HOLD_PUBLIC_URL`, `HOLD_PUBLIC` 449 - - Auto-registration: `HOLD_OWNER` (optional) 471 + 472 + See `.env.hold.example` for all available options. Key environment variables: 473 + - `HOLD_PUBLIC_URL` - Public URL of hold service (REQUIRED) 474 + - `STORAGE_DRIVER` - Storage backend (s3, filesystem) 475 + - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials 476 + - `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration 477 + - `HOLD_PUBLIC` - Allow public reads (default: false) 478 + - `HOLD_OWNER` - DID for auto-registration (optional) 450 479 451 480 **Credential Helper**: 452 481 - Token storage: `~/.atcr/oauth-token.json` 453 482 - Contains: access token, refresh token, DPoP key (PEM), DID, handle 454 - 455 - Environment variables: 456 - - `ATPROTO_DID`: DID for authentication with PDS (AppView only) 457 - - `ATPROTO_ACCESS_TOKEN`: Access token for PDS operations (AppView only) 458 483 459 484 ### Development Notes 460 485 ··· 524 549 - Queries in `pkg/appview/db/queries.go` 525 550 - Stores for OAuth, devices, sessions in separate files 526 551 - Run migrations automatically on startup 527 - - Database path configurable via config.yml 552 + - Database path configurable via `ATCR_UI_DATABASE_PATH` env var 528 553 529 554 **Adding web UI features**: 530 555 - Add handler in `pkg/appview/handlers/`
+2 -8
Dockerfile Dockerfile.appview
··· 31 31 # Copy binary from builder 32 32 COPY --from=builder /build/atcr-appview . 33 33 34 - # Copy default configuration 35 - COPY config/config.yml /etc/atcr/config.yml 36 - 37 34 # Create directories for storage 38 35 RUN mkdir -p /var/lib/atcr/blobs /var/lib/atcr/auth 39 36 40 37 # Expose ports 41 38 EXPOSE 5000 5001 42 39 43 - # Set environment variables 44 - ENV ATCR_CONFIG=/etc/atcr/config.yml 45 - 46 40 # OCI image annotations 47 41 LABEL org.opencontainers.image.title="ATCR AppView" \ 48 42 org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \ ··· 53 47 org.opencontainers.image.version="0.1.0" \ 54 48 io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" 55 49 56 - # Run the AppView 50 + # Run the AppView (no config file - uses environment variables) 57 51 ENTRYPOINT ["/app/atcr-appview"] 58 - CMD ["serve", "/etc/atcr/config.yml"] 52 + CMD ["serve"]
+213
cmd/appview/config.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + "os" 7 + "strconv" 8 + "time" 9 + 10 + "github.com/distribution/distribution/v3/configuration" 11 + ) 12 + 13 + // loadConfigFromEnv builds a complete configuration from environment variables 14 + // This follows the same pattern as the hold service (no config files, only env vars) 15 + func loadConfigFromEnv() (*configuration.Configuration, error) { 16 + config := &configuration.Configuration{} 17 + 18 + // Version 19 + config.Version = configuration.MajorMinorVersion(0, 1) 20 + 21 + // Logging 22 + config.Log = buildLogConfig() 23 + 24 + // HTTP server 25 + httpConfig, err := buildHTTPConfig() 26 + if err != nil { 27 + return nil, fmt.Errorf("failed to build HTTP config: %w", err) 28 + } 29 + config.HTTP = httpConfig 30 + 31 + // Storage (fake in-memory placeholder - all real storage is proxied) 32 + config.Storage = buildStorageConfig() 33 + 34 + // Middleware (ATProto resolver) 35 + defaultHold := os.Getenv("ATCR_DEFAULT_HOLD") 36 + if defaultHold == "" { 37 + return nil, fmt.Errorf("ATCR_DEFAULT_HOLD is required") 38 + } 39 + config.Middleware = buildMiddlewareConfig(defaultHold) 40 + 41 + // Auth 42 + baseURL := getBaseURL(httpConfig.Addr) 43 + authConfig, err := buildAuthConfig(baseURL) 44 + if err != nil { 45 + return nil, fmt.Errorf("failed to build auth config: %w", err) 46 + } 47 + config.Auth = authConfig 48 + 49 + // Health checks 50 + config.Health = buildHealthConfig() 51 + 52 + return config, nil 53 + } 54 + 55 + // buildLogConfig creates logging configuration from environment variables 56 + func buildLogConfig() configuration.Log { 57 + level := getEnvOrDefault("ATCR_LOG_LEVEL", "info") 58 + formatter := getEnvOrDefault("ATCR_LOG_FORMATTER", "text") 59 + 60 + return configuration.Log{ 61 + Level: configuration.Loglevel(level), 62 + Formatter: formatter, 63 + Fields: map[string]interface{}{ 64 + "service": "atcr-appview", 65 + }, 66 + } 67 + } 68 + 69 + // buildHTTPConfig creates HTTP server configuration from environment variables 70 + func buildHTTPConfig() (configuration.HTTP, error) { 71 + addr := getEnvOrDefault("ATCR_HTTP_ADDR", ":5000") 72 + debugAddr := getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001") 73 + 74 + return configuration.HTTP{ 75 + Addr: addr, 76 + Headers: map[string][]string{ 77 + "X-Content-Type-Options": {"nosniff"}, 78 + }, 79 + Debug: configuration.Debug{ 80 + Addr: debugAddr, 81 + }, 82 + }, nil 83 + } 84 + 85 + // buildStorageConfig creates a fake in-memory storage config 86 + // This is required for distribution validation but is never actually used 87 + // All storage is routed through middleware to ATProto (manifests) and hold services (blobs) 88 + func buildStorageConfig() configuration.Storage { 89 + storage := configuration.Storage{} 90 + 91 + // Use in-memory storage as a placeholder 92 + storage["inmemory"] = configuration.Parameters{} 93 + 94 + // Disable upload purging 95 + // NOTE: Must use map[interface{}]interface{} for uploadpurging (not configuration.Parameters) 96 + // because distribution's validation code does a type assertion to map[interface{}]interface{} 97 + storage["maintenance"] = configuration.Parameters{ 98 + "uploadpurging": map[interface{}]interface{}{ 99 + "enabled": false, 100 + "age": 7 * 24 * time.Hour, // 168h 101 + "interval": 24 * time.Hour, // 24h 102 + "dryrun": false, 103 + }, 104 + } 105 + 106 + return storage 107 + } 108 + 109 + // buildMiddlewareConfig creates middleware configuration 110 + func buildMiddlewareConfig(defaultHold string) map[string][]configuration.Middleware { 111 + return map[string][]configuration.Middleware{ 112 + "registry": { 113 + { 114 + Name: "atproto-resolver", 115 + Options: configuration.Parameters{ 116 + "default_storage_endpoint": defaultHold, 117 + }, 118 + }, 119 + }, 120 + } 121 + } 122 + 123 + // buildAuthConfig creates authentication configuration from environment variables 124 + func buildAuthConfig(baseURL string) (configuration.Auth, error) { 125 + // Token configuration 126 + privateKeyPath := getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem") 127 + certPath := getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt") 128 + 129 + // Token expiration in seconds (default: 5 minutes) 130 + expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300") 131 + expiration, err := strconv.Atoi(expirationStr) 132 + if err != nil { 133 + return configuration.Auth{}, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err) 134 + } 135 + 136 + // Auto-derive service name from base URL or use env var 137 + serviceName := getServiceName(baseURL) 138 + 139 + // Auto-derive realm from base URL 140 + realm := baseURL + "/auth/token" 141 + 142 + return configuration.Auth{ 143 + "token": configuration.Parameters{ 144 + "realm": realm, 145 + "service": serviceName, 146 + "issuer": serviceName, 147 + "rootcertbundle": certPath, 148 + "privatekey": privateKeyPath, 149 + "expiration": expiration, 150 + }, 151 + }, nil 152 + } 153 + 154 + // buildHealthConfig creates health check configuration 155 + func buildHealthConfig() configuration.Health { 156 + return configuration.Health{ 157 + StorageDriver: configuration.StorageDriver{ 158 + Enabled: true, 159 + Interval: 10 * time.Second, 160 + Threshold: 3, 161 + }, 162 + } 163 + } 164 + 165 + // getBaseURL determines the base URL for the service 166 + // Priority: ATCR_BASE_URL env var, then derived from HTTP addr 167 + func getBaseURL(httpAddr string) string { 168 + baseURL := os.Getenv("ATCR_BASE_URL") 169 + if baseURL != "" { 170 + return baseURL 171 + } 172 + 173 + // Auto-detect from HTTP addr 174 + if httpAddr[0] == ':' { 175 + // Just a port, assume localhost 176 + return fmt.Sprintf("http://127.0.0.1%s", httpAddr) 177 + } 178 + 179 + // Full address provided 180 + return fmt.Sprintf("http://%s", httpAddr) 181 + } 182 + 183 + // getServiceName extracts service name from base URL or uses env var 184 + func getServiceName(baseURL string) string { 185 + // Check env var first 186 + if serviceName := os.Getenv("ATCR_SERVICE_NAME"); serviceName != "" { 187 + return serviceName 188 + } 189 + 190 + // Try to extract from base URL 191 + parsed, err := url.Parse(baseURL) 192 + if err == nil && parsed.Hostname() != "" { 193 + hostname := parsed.Hostname() 194 + 195 + // Strip localhost/127.0.0.1 and use default 196 + if hostname == "localhost" || hostname == "127.0.0.1" { 197 + return "atcr.io" 198 + } 199 + 200 + return hostname 201 + } 202 + 203 + // Default fallback 204 + return "atcr.io" 205 + } 206 + 207 + // getEnvOrDefault gets an environment variable or returns a default value 208 + func getEnvOrDefault(key, defaultValue string) string { 209 + if val := os.Getenv(key); val != "" { 210 + return val 211 + } 212 + return defaultValue 213 + }
+12 -15
cmd/appview/serve.go
··· 60 60 } 61 61 62 62 var serveCmd = &cobra.Command{ 63 - Use: "serve <config>", 63 + Use: "serve", 64 64 Short: "Start the ATCR registry server", 65 - Long: "Start the ATCR registry server with authentication endpoints", 66 - Args: cobra.ExactArgs(1), 67 - RunE: serveRegistry, 65 + Long: `Start the ATCR registry server with authentication endpoints. 66 + 67 + Configuration is loaded from environment variables. 68 + See .env.appview.example for available environment variables.`, 69 + Args: cobra.NoArgs, 70 + RunE: serveRegistry, 68 71 } 69 72 70 73 func init() { ··· 87 90 } 88 91 89 92 func serveRegistry(cmd *cobra.Command, args []string) error { 90 - configPath := args[0] 91 - 92 - // Parse configuration 93 - fp, err := os.Open(configPath) 94 - if err != nil { 95 - return fmt.Errorf("failed to open config file: %w", err) 96 - } 97 - defer fp.Close() 98 - 99 - config, err := configuration.Parse(fp) 93 + // Load configuration from environment variables 94 + fmt.Println("Loading configuration from environment variables...") 95 + config, err := loadConfigFromEnv() 100 96 if err != nil { 101 - return fmt.Errorf("failed to parse configuration: %w", err) 97 + return fmt.Errorf("failed to load config from environment: %w", err) 102 98 } 99 + fmt.Println("Configuration loaded successfully from environment") 103 100 104 101 // Initialize UI database first (required for all stores) 105 102 fmt.Println("Initializing UI database...")
-57
config/config.yml
··· 1 - version: 0.1 2 - log: 3 - level: info 4 - formatter: text 5 - fields: 6 - service: atcr-appview 7 - 8 - # Storage is handled by external services: 9 - # - Manifests/Tags -> ATProto PDS (user's personal data server) 10 - # - Blobs/Layers -> Hold service (default or BYOS) 11 - # The AppView should be stateless with no local storage 12 - # 13 - # NOTE: The storage section below is required for distribution config validation 14 - # but is NOT actually used - all blob operations are routed through hold service 15 - storage: 16 - inmemory: {} 17 - 18 - http: 19 - addr: :5000 20 - headers: 21 - X-Content-Type-Options: [nosniff] 22 - debug: 23 - addr: :5001 24 - 25 - middleware: 26 - registry: 27 - # Name resolution middleware 28 - - name: atproto-resolver 29 - options: 30 - # Default hold service for blob storage 31 - # Users without their own hold will use this endpoint 32 - default_storage_endpoint: http://atcr-hold:8080 33 - 34 - # Authentication - all endpoints on port 5000 35 - auth: 36 - token: 37 - # Token service realm (where Docker gets tokens) 38 - realm: http://127.0.0.1:5000/auth/token 39 - service: atcr.io 40 - issuer: atcr.io 41 - expiration: 1800 # 30 minutes (in seconds) 42 - 43 - # Certificate bundle for validating JWTs 44 - rootcertbundle: /var/lib/atcr/auth/private-key.crt 45 - 46 - # Private key for signing JWTs (used by custom auth handlers) 47 - privatekey: /var/lib/atcr/auth/private-key.pem 48 - 49 - # Token expiration in seconds (5 minutes) 50 - expiration: 300 51 - 52 - # Health check 53 - health: 54 - storagedriver: 55 - enabled: true 56 - interval: 10s 57 - threshold: 3
+18 -9
docker-compose.yml
··· 2 2 atcr-appview: 3 3 build: 4 4 context: . 5 - dockerfile: Dockerfile 5 + dockerfile: Dockerfile.appview 6 6 image: atcr-appview:latest 7 7 container_name: atcr-appview 8 8 ports: 9 9 - "5000:5000" 10 + # Optional: Load from .env.appview file (create from .env.appview.example) 11 + # env_file: 12 + # - .env.appview 10 13 environment: 11 - - ATCR_UI_ENABLED=true 12 - - ATCR_BACKFILL_ENABLED=true 14 + # Server configuration 15 + ATCR_HTTP_ADDR: :5000 16 + ATCR_DEFAULT_HOLD: http://atcr-hold:8080 17 + # UI configuration 18 + ATCR_UI_ENABLED: true 19 + ATCR_BACKFILL_ENABLED: true 20 + # Logging 21 + ATCR_LOG_LEVEL: info 13 22 volumes: 14 23 # Auth keys (JWT signing keys) 15 24 - atcr-auth:/var/lib/atcr/auth 16 - # UI database (includes OAuth sessions, devices, and firehose cache) 25 + # UI database (includes OAuth sessions, devices, and Jetstream cache) 17 26 - atcr-ui:/var/lib/atcr 18 27 restart: unless-stopped 19 28 dns: ··· 22 31 networks: 23 32 atcr-network: 24 33 ipv4_address: 172.28.0.2 25 - # The AppView should be stateless - all storage is external: 26 - # - Manifests/Tags -> ATProto PDS 27 - # - Blobs/Layers -> Hold service 28 - # - OAuth tokens -> Persistent volume (atcr-tokens) 29 - # Future: Add read_only: true for production deployments 34 + # The AppView is stateless - all storage is external: 35 + # - Manifests/Tags -> ATProto PDS (via middleware) 36 + # - Blobs/Layers -> Hold service (via ProxyBlobStore) 37 + # - OAuth tokens -> SQLite database (atcr-ui volume) 38 + # - No config.yml needed - all config via environment variables 30 39 31 40 atcr-hold: 32 41 env_file:
+22 -4
docs/BYOS.md
··· 471 471 3. **In-memory cache** - Hold endpoint cache is in-memory (for production, use Redis) 472 472 4. **Manual profile updates** - No UI for updating sailor profile (must use ATProto client) 473 473 474 + ## Performance Optimization: S3 Presigned URLs 475 + 476 + **Status:** Planned implementation (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md)) 477 + 478 + Currently, hold services act as proxies for blob data. With presigned URLs: 479 + 480 + - **Downloads:** Docker → S3 direct (via 307 redirect) 481 + - **Uploads:** Docker → AppView → S3 (via presigned URL) 482 + - **Hold service bandwidth:** Reduced by 99.98% (only orchestration) 483 + 484 + **Benefits:** 485 + - Hold services can run on minimal infrastructure ($5/month instances) 486 + - Direct S3 transfers at maximum speed 487 + - Scales to arbitrarily large images 488 + - Works with Storj, MinIO, Backblaze B2, Cloudflare R2 489 + 490 + See [PRESIGNED_URLS.md](./PRESIGNED_URLS.md) for complete technical details and implementation guide. 491 + 474 492 ## Future Improvements 475 493 476 - 1. **Automatic failover** - Multiple storage endpoints, fallback to default 477 - 2. **Storage analytics** - Track usage per DID 478 - 3. **Quota integration** - Optional quota tracking in storage service 479 - 4. **Direct presigned URL support** - S3 native presigned URLs (bypass proxy) 494 + 1. **S3 Presigned URLs** - Implement direct S3 URLs (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md)) 495 + 2. **Automatic failover** - Multiple storage endpoints, fallback to default 496 + 3. **Storage analytics** - Track usage per DID 497 + 4. **Quota integration** - Optional quota tracking in storage service 480 498 5. **Profile management UI** - Web interface for users to manage their sailor profile 481 499 6. **Distributed cache** - Redis/Memcached for hold endpoint cache in multi-instance deployments 482 500
+637
docs/PRESIGNED_URLS.md
··· 1 + # S3 Presigned URLs Implementation 2 + 3 + ## Overview 4 + 5 + Currently, ATCR's hold service acts as a proxy for all blob data, meaning every byte flows through the hold service when uploading or downloading container images. This document describes the implementation of **S3 presigned URLs** to eliminate this bottleneck, allowing direct data transfer between clients and S3-compatible storage. 6 + 7 + ### Current Architecture (Proxy Mode) 8 + 9 + ``` 10 + Downloads: Docker → AppView → Hold Service → S3 → Hold Service → AppView → Docker 11 + Uploads: Docker → AppView → Hold Service → S3 12 + ``` 13 + 14 + **Problems:** 15 + - All blob data flows through hold service 16 + - Hold service bandwidth = total image bandwidth 17 + - Latency from extra hops 18 + - Hold service becomes bottleneck for large images 19 + 20 + ### Target Architecture (Presigned URLs) 21 + 22 + ``` 23 + Downloads: Docker → AppView (gets presigned URL) → S3 (direct download) 24 + Uploads: Docker → AppView → S3 (via presigned URL) 25 + Move: AppView → Hold Service → S3 (server-side CopyObject API) 26 + ``` 27 + 28 + **Benefits:** 29 + - ✅ Hold service only orchestrates (no data transfer) 30 + - ✅ Blob data never touches hold service 31 + - ✅ Direct S3 uploads/downloads at wire speed 32 + - ✅ Hold service can run on minimal resources 33 + - ✅ Works with all S3-compatible services 34 + 35 + ## How Presigned URLs Work 36 + 37 + ### For Downloads (GET) 38 + 39 + 1. **Docker requests blob:** `GET /v2/alice/myapp/blobs/sha256:abc123` 40 + 2. **AppView asks hold service:** `POST /get-presigned-url` 41 + ```json 42 + {"did": "did:plc:alice123", "digest": "sha256:abc123"} 43 + ``` 44 + 3. **Hold service generates presigned URL:** 45 + ```go 46 + req, _ := s3Client.GetObjectRequest(&s3.GetObjectInput{ 47 + Bucket: "my-bucket", 48 + Key: "blobs/sha256/ab/abc123.../data", 49 + }) 50 + url, _ := req.Presign(15 * time.Minute) 51 + // Returns: https://gateway.storjshare.io/bucket/blobs/...?X-Amz-Signature=... 52 + ``` 53 + 4. **AppView redirects Docker:** `HTTP 307 Location: <presigned-url>` 54 + 5. **Docker downloads directly from S3** using the presigned URL 55 + 56 + **Data path:** Docker → S3 (direct) 57 + **Hold service bandwidth:** ~1KB (API request/response) 58 + 59 + ### For Uploads (PUT) 60 + 61 + **Small blobs (< 5MB) using Put():** 62 + 63 + 1. **Docker sends blob to AppView:** `PUT /v2/alice/myapp/blobs/uploads/{uuid}` 64 + 2. **AppView asks hold service:** `POST /put-presigned-url` 65 + ```json 66 + {"did": "did:plc:alice123", "digest": "sha256:abc123", "size": 1024} 67 + ``` 68 + 3. **Hold service generates presigned URL:** 69 + ```go 70 + req, _ := s3Client.PutObjectRequest(&s3.PutObjectInput{ 71 + Bucket: "my-bucket", 72 + Key: "blobs/sha256/ab/abc123.../data", 73 + }) 74 + url, _ := req.Presign(15 * time.Minute) 75 + ``` 76 + 4. **AppView uploads to S3** using presigned URL 77 + 5. **AppView confirms to Docker:** `201 Created` 78 + 79 + **Data path:** Docker → AppView → S3 (via presigned URL) 80 + **Hold service bandwidth:** ~1KB (API request/response) 81 + 82 + ### For Streaming Uploads (Create/Commit) 83 + 84 + **Large blobs (> 5MB) using streaming:** 85 + 86 + 1. **Docker starts upload:** `POST /v2/alice/myapp/blobs/uploads/` 87 + 2. **AppView creates upload session** with UUID 88 + 3. **AppView gets presigned URL for temp location:** 89 + ```json 90 + POST /put-presigned-url 91 + {"did": "...", "digest": "uploads/temp-{uuid}", "size": 0} 92 + ``` 93 + 4. **Docker streams data:** `PATCH /v2/alice/myapp/blobs/uploads/{uuid}` 94 + 5. **AppView streams to S3** using presigned URL to `uploads/temp-{uuid}/data` 95 + 6. **Docker finalizes:** `PUT /v2/.../uploads/{uuid}?digest=sha256:abc123` 96 + 7. **AppView requests move:** `POST /move?from=uploads/temp-{uuid}&to=sha256:abc123` 97 + 8. **Hold service executes S3 server-side copy:** 98 + ```go 99 + s3.CopyObject(&s3.CopyObjectInput{ 100 + Bucket: "my-bucket", 101 + CopySource: "/my-bucket/uploads/temp-{uuid}/data", 102 + Key: "blobs/sha256/ab/abc123.../data", 103 + }) 104 + s3.DeleteObject(&s3.DeleteObjectInput{ 105 + Key: "uploads/temp-{uuid}/data", 106 + }) 107 + ``` 108 + 109 + **Data path:** Docker → AppView → S3 (temp location) 110 + **Move path:** S3 internal copy (no data transfer!) 111 + **Hold service bandwidth:** ~2KB (presigned URL + CopyObject API) 112 + 113 + ## Why the Temp → Final Move is Required 114 + 115 + This is **not an ATCR implementation detail** — it's required by the [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push). 116 + 117 + ### The Problem: Unknown Digest 118 + 119 + Docker doesn't know the blob's digest until **after** uploading: 120 + 121 + 1. **Streaming data:** Can't buffer 5GB layer in memory to calculate digest first 122 + 2. **Stdin pipes:** `docker build . | docker push` generates data on-the-fly 123 + 3. **Chunked uploads:** Multiple PATCH requests, digest calculated as data streams 124 + 125 + ### The Solution: Upload to Temp, Verify, Move 126 + 127 + **All OCI registries do this:** 128 + 129 + 1. Client: `POST /v2/{name}/blobs/uploads/` → Get upload UUID 130 + 2. Client: `PATCH /v2/{name}/blobs/uploads/{uuid}` → Stream data to temp location 131 + 3. Client: `PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:abc` → Provide digest 132 + 4. Registry: Verify digest matches uploaded data 133 + 5. Registry: Move `uploads/{uuid}` → `blobs/sha256/abc123...` 134 + 135 + **Docker Hub, GHCR, ECR, Harbor — all use this pattern.** 136 + 137 + ### Why It's Efficient with S3 138 + 139 + **For S3, the move is a CopyObject API call:** 140 + 141 + ```go 142 + // This happens INSIDE S3 servers - no data transfer! 143 + s3.CopyObject(&s3.CopyObjectInput{ 144 + Bucket: "my-bucket", 145 + CopySource: "/my-bucket/uploads/temp-12345/data", // 5GB blob 146 + Key: "blobs/sha256/ab/abc123.../data", 147 + }) 148 + // S3 copies internally, hold service only sends ~1KB API request 149 + ``` 150 + 151 + **For a 5GB layer:** 152 + - Hold service bandwidth: **~1KB** (API request/response) 153 + - S3 internal copy: Instant (metadata operation on S3 side) 154 + - No data leaves S3, no network transfer 155 + 156 + This is why the move operation is essentially free! 157 + 158 + ## Implementation Details 159 + 160 + ### 1. Add S3 Client to Hold Service 161 + 162 + **File: `cmd/hold/main.go`** 163 + 164 + Modify `HoldService` struct: 165 + ```go 166 + type HoldService struct { 167 + driver storagedriver.StorageDriver 168 + config *Config 169 + s3Client *s3.S3 // NEW: S3 client for presigned URLs 170 + bucket string // NEW: Bucket name 171 + s3PathPrefix string // NEW: Path prefix (if any) 172 + } 173 + ``` 174 + 175 + Add initialization function: 176 + ```go 177 + func (s *HoldService) initS3Client() error { 178 + if s.config.Storage.Type() != "s3" { 179 + log.Printf("Storage driver is %s (not S3), presigned URLs disabled", s.config.Storage.Type()) 180 + return nil 181 + } 182 + 183 + params := s.config.Storage.Parameters()["s3"].(configuration.Parameters) 184 + 185 + // Build AWS config 186 + awsConfig := &aws.Config{ 187 + Region: aws.String(params["region"].(string)), 188 + Credentials: credentials.NewStaticCredentials( 189 + params["accesskey"].(string), 190 + params["secretkey"].(string), 191 + "", 192 + ), 193 + } 194 + 195 + // Add custom endpoint for S3-compatible services (Storj, MinIO, etc.) 196 + if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" { 197 + awsConfig.Endpoint = aws.String(endpoint) 198 + awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj 199 + } 200 + 201 + sess, err := session.NewSession(awsConfig) 202 + if err != nil { 203 + return fmt.Errorf("failed to create AWS session: %w", err) 204 + } 205 + 206 + s.s3Client = s3.New(sess) 207 + s.bucket = params["bucket"].(string) 208 + 209 + log.Printf("S3 presigned URLs enabled for bucket: %s", s.bucket) 210 + return nil 211 + } 212 + ``` 213 + 214 + Call during service initialization: 215 + ```go 216 + func NewHoldService(cfg *Config) (*HoldService, error) { 217 + // ... existing driver creation ... 218 + 219 + service := &HoldService{ 220 + driver: driver, 221 + config: cfg, 222 + } 223 + 224 + // Initialize S3 client for presigned URLs 225 + if err := service.initS3Client(); err != nil { 226 + log.Printf("WARNING: S3 presigned URLs disabled: %v", err) 227 + } 228 + 229 + return service, nil 230 + } 231 + ``` 232 + 233 + ### 2. Implement Presigned URL Generation 234 + 235 + **For Downloads:** 236 + 237 + ```go 238 + func (s *HoldService) getDownloadURL(ctx context.Context, digest string, did string) (string, error) { 239 + path := blobPath(digest) 240 + 241 + // Check if blob exists 242 + if _, err := s.driver.Stat(ctx, path); err != nil { 243 + return "", fmt.Errorf("blob not found: %w", err) 244 + } 245 + 246 + // If S3 client available, generate presigned URL 247 + if s.s3Client != nil { 248 + s3Key := strings.TrimPrefix(path, "/") 249 + 250 + req, _ := s.s3Client.GetObjectRequest(&s3.GetObjectInput{ 251 + Bucket: aws.String(s.bucket), 252 + Key: aws.String(s3Key), 253 + }) 254 + 255 + url, err := req.Presign(15 * time.Minute) 256 + if err != nil { 257 + log.Printf("WARN: Presigned URL generation failed, falling back to proxy: %v", err) 258 + return s.getProxyDownloadURL(digest, did), nil 259 + } 260 + 261 + log.Printf("Generated presigned download URL for %s (expires in 15min)", digest) 262 + return url, nil 263 + } 264 + 265 + // Fallback: return proxy URL 266 + return s.getProxyDownloadURL(digest, did), nil 267 + } 268 + 269 + func (s *HoldService) getProxyDownloadURL(digest, did string) string { 270 + return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did) 271 + } 272 + ``` 273 + 274 + **For Uploads:** 275 + 276 + ```go 277 + func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) { 278 + path := blobPath(digest) 279 + 280 + // If S3 client available, generate presigned URL 281 + if s.s3Client != nil { 282 + s3Key := strings.TrimPrefix(path, "/") 283 + 284 + req, _ := s.s3Client.PutObjectRequest(&s3.PutObjectInput{ 285 + Bucket: aws.String(s.bucket), 286 + Key: aws.String(s3Key), 287 + }) 288 + 289 + url, err := req.Presign(15 * time.Minute) 290 + if err != nil { 291 + log.Printf("WARN: Presigned URL generation failed, falling back to proxy: %v", err) 292 + return s.getProxyUploadURL(digest, did), nil 293 + } 294 + 295 + log.Printf("Generated presigned upload URL for %s (expires in 15min)", digest) 296 + return url, nil 297 + } 298 + 299 + // Fallback: return proxy URL 300 + return s.getProxyUploadURL(digest, did), nil 301 + } 302 + 303 + func (s *HoldService) getProxyUploadURL(digest, did string) string { 304 + return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did) 305 + } 306 + ``` 307 + 308 + ### 3. No Changes Needed for Move Operation 309 + 310 + The existing `/move` endpoint already uses `driver.Move()`, which for S3: 311 + - Calls `s3.CopyObject()` (server-side copy) 312 + - Calls `s3.DeleteObject()` (delete source) 313 + - No data transfer through hold service! 314 + 315 + **File: `cmd/hold/main.go:296` (already exists, no changes needed)** 316 + 317 + ```go 318 + func (s *HoldService) HandleMove(w http.ResponseWriter, r *http.Request) { 319 + // ... existing auth and parsing ... 320 + 321 + sourcePath := blobPath(fromPath) // uploads/temp-{uuid}/data 322 + destPath := blobPath(toDigest) // blobs/sha256/ab/abc123.../data 323 + 324 + // For S3, this does CopyObject + DeleteObject (server-side) 325 + if err := s.driver.Move(ctx, sourcePath, destPath); err != nil { 326 + // ... error handling ... 327 + } 328 + } 329 + ``` 330 + 331 + ### 4. AppView Changes (Optional Optimization) 332 + 333 + **File: `pkg/storage/proxy_blob_store.go:228`** 334 + 335 + Currently streams to hold service proxy URL. Could be optimized to use presigned URL: 336 + 337 + ```go 338 + // In Create() - line 228 339 + go func() { 340 + defer pipeReader.Close() 341 + 342 + tempPath := fmt.Sprintf("uploads/temp-%s", writer.id) 343 + 344 + // Try to get presigned URL for temp location 345 + url, err := p.getUploadURL(ctx, digest.FromString(tempPath), 0) 346 + if err != nil { 347 + // Fallback to direct proxy URL 348 + url = fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, tempPath, p.did) 349 + } 350 + 351 + req, err := http.NewRequestWithContext(uploadCtx, "PUT", url, pipeReader) 352 + // ... rest unchanged 353 + }() 354 + ``` 355 + 356 + **Note:** This optimization is optional. The presigned URL will be returned by hold service's `getUploadURL()` anyway. 357 + 358 + ## S3-Compatible Service Support 359 + 360 + ### Storj 361 + 362 + ```bash 363 + # .env file 364 + STORAGE_DRIVER=s3 365 + AWS_ACCESS_KEY_ID=your-storj-access-key 366 + AWS_SECRET_ACCESS_KEY=your-storj-secret-key 367 + S3_BUCKET=your-bucket-name 368 + S3_REGION=global 369 + S3_ENDPOINT=https://gateway.storjshare.io 370 + ``` 371 + 372 + **Presigned URL example:** 373 + ``` 374 + https://gateway.storjshare.io/your-bucket/blobs/sha256/ab/abc123.../data?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=... 375 + ``` 376 + 377 + ### MinIO 378 + 379 + ```bash 380 + STORAGE_DRIVER=s3 381 + AWS_ACCESS_KEY_ID=minioadmin 382 + AWS_SECRET_ACCESS_KEY=minioadmin 383 + S3_BUCKET=registry 384 + S3_REGION=us-east-1 385 + S3_ENDPOINT=http://minio.example.com:9000 386 + ``` 387 + 388 + ### Backblaze B2 389 + 390 + ```bash 391 + STORAGE_DRIVER=s3 392 + AWS_ACCESS_KEY_ID=your-b2-key-id 393 + AWS_SECRET_ACCESS_KEY=your-b2-application-key 394 + S3_BUCKET=your-bucket-name 395 + S3_REGION=us-west-002 396 + S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com 397 + ``` 398 + 399 + ### Cloudflare R2 400 + 401 + ```bash 402 + STORAGE_DRIVER=s3 403 + AWS_ACCESS_KEY_ID=your-r2-access-key-id 404 + AWS_SECRET_ACCESS_KEY=your-r2-secret-access-key 405 + S3_BUCKET=your-bucket-name 406 + S3_REGION=auto 407 + S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com 408 + ``` 409 + 410 + **All these services support presigned URLs with AWS SDK v1!** 411 + 412 + ## Performance Impact 413 + 414 + ### Bandwidth Savings 415 + 416 + **Before (proxy mode):** 417 + - 5GB layer upload: Hold service receives 5GB, sends 5GB to S3 = **10GB** bandwidth 418 + - 5GB layer download: S3 sends 5GB to hold, hold sends 5GB to client = **10GB** bandwidth 419 + - **Total for push+pull: 20GB hold service bandwidth** 420 + 421 + **After (presigned URLs):** 422 + - 5GB layer upload: Hold generates URL (1KB), AppView → S3 direct (5GB), CopyObject API (1KB) = **~2KB** hold bandwidth 423 + - 5GB layer download: Hold generates URL (1KB), client → S3 direct = **~1KB** hold bandwidth 424 + - **Total for push+pull: ~3KB hold service bandwidth** 425 + 426 + **Savings: 99.98% reduction in hold service bandwidth!** 427 + 428 + ### Latency Improvements 429 + 430 + **Before:** 431 + - Download: Client → AppView → Hold → S3 → Hold → AppView → Client (4 hops) 432 + - Upload: Client → AppView → Hold → S3 (3 hops) 433 + 434 + **After:** 435 + - Download: Client → AppView (redirect) → S3 (1 hop to data) 436 + - Upload: Client → AppView → S3 (2 hops) 437 + - Move: S3 internal (no network hops) 438 + 439 + ### Resource Requirements 440 + 441 + **Before:** 442 + - Hold service needs bandwidth = sum of all image operations 443 + - For 100 concurrent 1GB pushes: 100GB/s bandwidth needed 444 + - Expensive, hard to scale 445 + 446 + **After:** 447 + - Hold service needs minimal CPU for presigned URL signing 448 + - For 100 concurrent 1GB pushes: ~100KB/s bandwidth needed (API traffic) 449 + - Can run on $5/month instance! 450 + 451 + ## Security Considerations 452 + 453 + ### Presigned URL Expiration 454 + 455 + - Default: **15 minutes** expiration 456 + - Presigned URL includes embedded credentials in query params 457 + - After expiry, URL becomes invalid (S3 rejects with 403) 458 + - No long-lived URLs floating around 459 + 460 + ### Authorization Flow 461 + 462 + 1. **AppView validates user** via ATProto OAuth 463 + 2. **AppView passes DID to hold service** in presigned URL request 464 + 3. **Hold service validates DID** (owner or crew member) 465 + 4. **Hold service generates presigned URL** if authorized 466 + 5. **Client uses presigned URL** directly with S3 467 + 468 + **Security boundary:** Hold service controls who gets presigned URLs, S3 validates the URLs. 469 + 470 + ### Fallback Security 471 + 472 + If presigned URL generation fails: 473 + - Falls back to proxy URLs (existing behavior) 474 + - Still requires hold service authorization 475 + - Data flows through hold service (original security model) 476 + 477 + ## Testing & Validation 478 + 479 + ### Verify Presigned URLs are Used 480 + 481 + **1. Check hold service logs:** 482 + ```bash 483 + docker logs atcr-hold | grep -i presigned 484 + # Should see: "Generated presigned download/upload URL for sha256:..." 485 + ``` 486 + 487 + **2. Monitor network traffic:** 488 + ```bash 489 + # Before: Large data transfers to/from hold service 490 + docker stats atcr-hold 491 + 492 + # After: Minimal network usage on hold service 493 + docker stats atcr-hold 494 + ``` 495 + 496 + **3. Inspect redirect responses:** 497 + ```bash 498 + # Should see 307 redirect to S3 URL 499 + curl -v http://appview:5000/v2/alice/myapp/blobs/sha256:abc123 \ 500 + -H "Authorization: Bearer $TOKEN" 501 + 502 + # Look for: 503 + # < HTTP/1.1 307 Temporary Redirect 504 + # < Location: https://gateway.storjshare.io/...?X-Amz-Signature=... 505 + ``` 506 + 507 + ### Test Fallback Behavior 508 + 509 + **1. With filesystem driver (should use proxy URLs):** 510 + ```bash 511 + STORAGE_DRIVER=filesystem docker-compose up atcr-hold 512 + # Logs should show: "Storage driver is filesystem (not S3), presigned URLs disabled" 513 + ``` 514 + 515 + **2. With S3 but invalid credentials (should fall back):** 516 + ```bash 517 + AWS_ACCESS_KEY_ID=invalid docker-compose up atcr-hold 518 + # Logs should show: "WARN: Presigned URL generation failed, falling back to proxy" 519 + ``` 520 + 521 + ### Bandwidth Monitoring 522 + 523 + **Track hold service bandwidth over time:** 524 + ```bash 525 + # Install bandwidth monitoring 526 + docker exec atcr-hold apt-get update && apt-get install -y vnstat 527 + 528 + # Monitor 529 + docker exec atcr-hold vnstat -l 530 + ``` 531 + 532 + **Expected results:** 533 + - Before: Bandwidth correlates with image operations 534 + - After: Bandwidth stays minimal regardless of image operations 535 + 536 + ## Migration Guide 537 + 538 + ### For Existing ATCR Deployments 539 + 540 + **1. Update hold service code** (this implementation) 541 + 542 + **2. No configuration changes needed** if already using S3: 543 + ```bash 544 + # Existing S3 config works automatically 545 + STORAGE_DRIVER=s3 546 + AWS_ACCESS_KEY_ID=... 547 + AWS_SECRET_ACCESS_KEY=... 548 + S3_BUCKET=... 549 + S3_ENDPOINT=... 550 + ``` 551 + 552 + **3. Restart hold service:** 553 + ```bash 554 + docker-compose restart atcr-hold 555 + ``` 556 + 557 + **4. Verify in logs:** 558 + ``` 559 + S3 presigned URLs enabled for bucket: my-bucket 560 + ``` 561 + 562 + **5. Test with image push/pull:** 563 + ```bash 564 + docker push atcr.io/alice/myapp:latest 565 + docker pull atcr.io/alice/myapp:latest 566 + ``` 567 + 568 + **6. Monitor bandwidth** to confirm reduction 569 + 570 + ### Rollback Plan 571 + 572 + If issues arise: 573 + 574 + **Option 1: Disable presigned URLs via env var** (if we add this feature) 575 + ```bash 576 + PRESIGNED_URLS_ENABLED=false docker-compose restart atcr-hold 577 + ``` 578 + 579 + **Option 2: Revert code changes** to previous hold service version 580 + 581 + The implementation has automatic fallbacks, so partial failures won't break functionality. 582 + 583 + ## Future Enhancements 584 + 585 + ### 1. Configurable Expiration 586 + 587 + Allow customizing presigned URL expiry: 588 + ```bash 589 + PRESIGNED_URL_EXPIRY=30m # Default: 15m 590 + ``` 591 + 592 + ### 2. Presigned URL Caching 593 + 594 + Cache presigned URLs for frequently accessed blobs (with shorter TTL). 595 + 596 + ### 3. CloudFront/CDN Integration 597 + 598 + For downloads, use CloudFront presigned URLs instead of direct S3: 599 + - Better global distribution 600 + - Lower egress costs 601 + - Faster downloads 602 + 603 + ### 4. Multipart Upload Support 604 + 605 + For very large layers (>5GB), use presigned URLs with multipart upload: 606 + - Generate presigned URLs for each part 607 + - Client uploads parts directly to S3 608 + - Hold service finalizes multipart upload 609 + 610 + ### 5. Metrics & Monitoring 611 + 612 + Track presigned URL usage: 613 + - Count of presigned URLs generated 614 + - Fallback rate (proxy vs presigned) 615 + - Bandwidth savings metrics 616 + 617 + ## References 618 + 619 + - [OCI Distribution Specification - Push](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push) 620 + - [AWS SDK Go v1 - Presigned URLs](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/s3-example-presigned-urls.html) 621 + - [Storj - Using Presigned URLs](https://docs.storj.io/dcs/api-reference/s3-compatible-gateway/using-presigned-urls) 622 + - [MinIO - Presigned Upload via Browser](https://docs.min.io/community/minio-object-store/integrations/presigned-put-upload-via-browser.html) 623 + - [Cloudflare R2 - Presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/) 624 + - [Backblaze B2 - S3 Compatible API](https://help.backblaze.com/hc/en-us/articles/360047815993-Does-the-B2-S3-Compatible-API-support-Pre-Signed-URLs) 625 + 626 + ## Summary 627 + 628 + Implementing S3 presigned URLs transforms ATCR's hold service from a **data proxy** to a **lightweight orchestrator**: 629 + 630 + ✅ **99.98% bandwidth reduction** for hold service 631 + ✅ **Direct client → S3 transfers** for maximum speed 632 + ✅ **Works with all S3-compatible services** (Storj, MinIO, R2, B2) 633 + ✅ **OCI-compliant** temp → final move pattern 634 + ✅ **Automatic fallback** to proxy mode for non-S3 drivers 635 + ✅ **No breaking changes** to existing deployments 636 + 637 + This makes BYOS (Bring Your Own Storage) truly scalable and cost-effective, as users can run hold services on minimal infrastructure while serving arbitrarily large container images.