A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
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.