mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-23 18:54:16 +00:00
Hold discovery in the registry middleware called getRecord on the repository owner's PDS for every request under /v2/: every HEAD, POST, PATCH, PUT and GET. A 10-layer push was 40 or more PDS round trips, and it was the last per-request network call on the push path that had nothing to do with moving bytes. Only two profile fields are used there: the default hold and the auto-remove-untagged flag. The users row already caches the default hold, written by the Jetstream processor on every profile event and prefilled by the backfill, and the auth gate already reads it from there. This makes the row a faithful copy of what the registry needs and switches the middleware to it. The auto-remove flag gets a nullable users column. NULL means the value has never been learned; the processor writes 0 or 1 on every profile event and never NULL. On a request whose row is missing or still NULL, the middleware does one live fetch, uses it, and writes both fields back, including a 0 for a user with no profile at all, so the fallback runs at most once per user. A failed fetch writes nothing and uses the appview default for that request, so a network error is never cached. That single mechanism covers the minutes after a deploy while the startup backfill fills the column, a brand-new user, and a user the backfill has not reached. The processor also stops returning early on an empty default hold, which left a user who removed their custom hold pushing to it forever. Empty is now written through and means the appview default, matching what the auth gate already reads. Tests count PDS requests with a test server: a populated row makes none, a NULL row makes exactly one and then none, a missing profile is cached as known, and a failed fetch degrades without writing. The migration was applied to a fresh database and to one built from the previous schema. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5
416 lines
14 KiB
Markdown
416 lines
14 KiB
Markdown
# Bring Your Own Storage (BYOS)
|
|
|
|
## Overview
|
|
|
|
ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. Users can:
|
|
- Deploy their own hold service with embedded PDS
|
|
- Control access via crew membership in the hold's PDS
|
|
- Keep blob data in their own S3-compatible storage (AWS S3, Storj, Minio, UpCloud, etc.) while manifests stay in their user PDS
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌──────────────────────────────────────────┐
|
|
│ ATCR AppView (API) │
|
|
│ - Manifests → User's PDS │
|
|
│ - Auth & service token management │
|
|
│ - Blob routing via XRPC │
|
|
│ - Profile management │
|
|
└────────────┬─────────────────────────────┘
|
|
│
|
|
│ Hold discovery (findHoldDIDAndPrefs), read from
|
|
│ the local users row, not the PDS:
|
|
│ 1. io.atcr.sailor.profile.defaultHold (DID)
|
|
│ 2. AppView default hold (server.managed_holds[0])
|
|
│
|
|
│ Then resolveSuccessor: if the chosen hold's
|
|
│ captain record sets a successor DID, apply a
|
|
│ single-hop redirect to it (hold migration).
|
|
▼
|
|
┌──────────────────────────────────────────┐
|
|
│ User's PDS │
|
|
│ - io.atcr.sailor.profile (hold DID) │
|
|
│ - io.atcr.manifest (with holdDid) │
|
|
└────────────┬─────────────────────────────┘
|
|
│
|
|
│ Service token from user's PDS
|
|
▼
|
|
┌──────────────────────────────────────────┐
|
|
│ Hold Service (did:web:hold.example.com) │
|
|
│ ├── Embedded PDS │
|
|
│ │ ├── Captain record (ownership) │
|
|
│ │ └── Crew records (access control) │
|
|
│ ├── XRPC multipart upload endpoints │
|
|
│ └── Storage driver (S3/Storj/etc.) │
|
|
└──────────────────────────────────────────┘
|
|
```
|
|
|
|
## Hold Service Components
|
|
|
|
Each hold is a full ATProto actor with:
|
|
- **DID**: `did:web:hold.example.com` (hold's identity)
|
|
- **Embedded PDS**: Stores captain + crew records (shared data)
|
|
- **Storage backend**: S3-compatible (AWS S3, Storj, Minio, UpCloud, etc.)
|
|
- **XRPC endpoints**: Standard ATProto + custom OCI multipart upload
|
|
|
|
### Records in Hold's PDS
|
|
|
|
**Captain record** (`io.atcr.hold.captain/self`):
|
|
```json
|
|
{
|
|
"$type": "io.atcr.hold.captain",
|
|
"owner": "did:plc:alice123",
|
|
"public": false,
|
|
"allowAllCrew": false,
|
|
"enableBlueskyPosts": false,
|
|
"deployedAt": "2025-10-14T...",
|
|
"region": "iad",
|
|
"successor": ""
|
|
}
|
|
```
|
|
|
|
`region` and `successor` are optional. `successor` holds the DID of a replacement hold; when set, the AppView applies a single-hop redirect to it during hold discovery (see the Architecture diagram above).
|
|
|
|
**Crew records** (`io.atcr.hold.crew/{rkey}`):
|
|
```json
|
|
{
|
|
"$type": "io.atcr.hold.crew",
|
|
"member": "did:plc:bob456",
|
|
"role": "captain",
|
|
"permissions": ["blob:read", "blob:write"],
|
|
"tier": "bosun",
|
|
"plankowner": false,
|
|
"addedAt": "2025-10-14T..."
|
|
}
|
|
```
|
|
|
|
Authorization is driven by the `permissions` array (`blob:read`, `blob:write`, `crew:admin`), not the `role` string. `blob:write` implicitly grants `blob:read` (you can't push without being able to pull). `tier` and `plankowner` are optional and feed quota limits.
|
|
|
|
### Sailor Profile (User's PDS)
|
|
|
|
Users set their preferred hold in their sailor profile:
|
|
|
|
```json
|
|
{
|
|
"$type": "io.atcr.sailor.profile",
|
|
"defaultHold": "did:web:hold.example.com",
|
|
"createdAt": "2025-10-02T...",
|
|
"updatedAt": "2025-10-02T..."
|
|
}
|
|
```
|
|
|
|
## Deployment
|
|
|
|
### Configuration
|
|
|
|
The hold service is configured with Viper: a YAML file is the primary source, and
|
|
environment variables override individual fields. Env var names are `HOLD_` plus the
|
|
YAML path with `_` separators (e.g. `server.public_url` → `HOLD_SERVER_PUBLIC_URL`).
|
|
S3 credentials use the standard AWS names.
|
|
|
|
Generate a fully commented config and run with it:
|
|
|
|
```bash
|
|
./bin/atcr-hold config init config-hold.yaml
|
|
# edit config-hold.yaml, then:
|
|
./bin/atcr-hold serve --config config-hold.yaml
|
|
```
|
|
|
|
Key fields (YAML on the left, env override on the right):
|
|
|
|
```yaml
|
|
server:
|
|
public_url: https://hold.example.com # HOLD_SERVER_PUBLIC_URL (REQUIRED)
|
|
public: false # HOLD_SERVER_PUBLIC (allow anonymous reads)
|
|
|
|
registration:
|
|
owner_did: did:plc:your-did-here # HOLD_REGISTRATION_OWNER_DID
|
|
allow_all_crew: false # HOLD_REGISTRATION_ALLOW_ALL_CREW
|
|
|
|
database:
|
|
path: /var/lib/atcr-hold # HOLD_DATABASE_PATH (carstore + SQLite)
|
|
key_path: "" # HOLD_DATABASE_KEY_PATH (defaults to {path}/signing.key)
|
|
|
|
storage:
|
|
bucket: my-blobs # S3_BUCKET (REQUIRED)
|
|
region: us-east-1 # AWS_REGION
|
|
endpoint: "" # S3_ENDPOINT (for non-AWS providers)
|
|
```
|
|
|
|
S3 credentials are read from the standard AWS env vars (`AWS_ACCESS_KEY_ID`,
|
|
`AWS_SECRET_ACCESS_KEY`).
|
|
|
|
### Running Locally
|
|
|
|
For local development, use Minio as an S3-compatible storage:
|
|
|
|
```bash
|
|
# Start Minio (in separate terminal)
|
|
docker run -p 9000:9000 -p 9001:9001 minio/minio server /data --console-address ":9001"
|
|
|
|
# Build
|
|
go build -o bin/atcr-hold ./cmd/hold
|
|
|
|
# Run (env overrides shown; a YAML config works too)
|
|
export HOLD_SERVER_PUBLIC_URL=http://localhost:8080
|
|
export HOLD_REGISTRATION_OWNER_DID=did:plc:your-did-here
|
|
export AWS_ACCESS_KEY_ID=minioadmin
|
|
export AWS_SECRET_ACCESS_KEY=minioadmin
|
|
export S3_BUCKET=test
|
|
export S3_ENDPOINT=http://localhost:9000
|
|
export HOLD_DATABASE_PATH=/tmp/atcr-hold
|
|
|
|
./bin/atcr-hold serve
|
|
```
|
|
|
|
On first run, the hold service creates:
|
|
- Captain record in embedded PDS (making you the owner)
|
|
- Crew record for owner with all permissions
|
|
- DID document at `/.well-known/did.json`
|
|
|
|
### Deploy to Fly.io
|
|
|
|
```bash
|
|
# Create fly.toml
|
|
cat > fly.toml <<EOF
|
|
app = "my-atcr-hold"
|
|
primary_region = "ord"
|
|
|
|
[env]
|
|
HOLD_SERVER_PUBLIC_URL = "https://my-atcr-hold.fly.dev"
|
|
AWS_REGION = "us-east-1"
|
|
S3_BUCKET = "my-blobs"
|
|
HOLD_SERVER_PUBLIC = "false"
|
|
HOLD_REGISTRATION_ALLOW_ALL_CREW = "false"
|
|
|
|
[http_service]
|
|
internal_port = 8080
|
|
force_https = true
|
|
auto_stop_machines = true
|
|
auto_start_machines = true
|
|
min_machines_running = 0
|
|
|
|
[[vm]]
|
|
cpu_kind = "shared"
|
|
cpus = 1
|
|
memory_mb = 256
|
|
EOF
|
|
|
|
# Deploy
|
|
fly launch
|
|
fly deploy
|
|
|
|
# Set secrets
|
|
fly secrets set AWS_ACCESS_KEY_ID=...
|
|
fly secrets set AWS_SECRET_ACCESS_KEY=...
|
|
fly secrets set HOLD_REGISTRATION_OWNER_DID=did:plc:your-did-here
|
|
```
|
|
|
|
## Request Flow
|
|
|
|
### Push with BYOS
|
|
|
|
```
|
|
1. Client: docker push atcr.io/alice/myapp:latest
|
|
|
|
2. AppView resolves alice → did:plc:alice123
|
|
|
|
3. AppView discovers hold DID:
|
|
- Read alice's cached defaultHold from the local users row, which Jetstream
|
|
keeps current from her sailor profile. Only a row that has never been
|
|
filled costs one live profile fetch, and that fetch is written back.
|
|
- Returns: "did:web:alice-storage.fly.dev"
|
|
|
|
4. AppView gets service token from alice's PDS:
|
|
GET /xrpc/com.atproto.server.getServiceAuth?aud=did:web:alice-storage.fly.dev
|
|
Response: { "token": "eyJ..." }
|
|
|
|
5. AppView buffers the blob (16MB limit) and verifies the bytes it received
|
|
against the digest the client claimed. A mismatch is rejected here, before
|
|
anything reaches storage.
|
|
|
|
5a. Small blob (fits in the buffer, which is every config blob and most layers):
|
|
AppView asks for one write capability and PUTs the whole blob to its final
|
|
content-addressed key. No multipart session, no temp object, no copy.
|
|
GET /xrpc/com.atproto.sync.getBlob?did=...&cid=sha256:abc...&method=PUT
|
|
Authorization: Bearer {serviceToken}
|
|
Response: { "url": "https://s3.../presigned" }
|
|
AppView: PUT that URL with Content-Type: application/octet-stream
|
|
|
|
5b. Large blob (outgrew the buffer): multipart, as below.
|
|
|
|
6. AppView initiates multipart upload to hold, on the first flush:
|
|
POST https://alice-storage.fly.dev/xrpc/io.atcr.hold.initiateUpload
|
|
Authorization: Bearer {serviceToken}
|
|
Body: { "digest": "sha256:abc..." }
|
|
Response: { "uploadId": "xyz" }
|
|
|
|
7. For each part:
|
|
- AppView: POST /xrpc/io.atcr.hold.getPartUploadUrl
|
|
- Hold validates service token, checks crew membership
|
|
- Hold returns: { "url": "https://s3.../presigned" }
|
|
- AppView uploads the part to the S3 presigned URL
|
|
|
|
8. AppView completes upload:
|
|
POST /xrpc/io.atcr.hold.completeUpload
|
|
Body: { "uploadId": "xyz", "digest": "sha256:abc...", "parts": [...] }
|
|
|
|
9. Manifest stored in alice's PDS:
|
|
- holdDid: "did:web:alice-storage.fly.dev"
|
|
- holdEndpoint: "https://alice-storage.fly.dev" (backward compat)
|
|
```
|
|
|
|
### Pull with BYOS
|
|
|
|
```
|
|
1. Client: docker pull atcr.io/alice/myapp:latest
|
|
|
|
2. AppView fetches manifest from alice's PDS
|
|
|
|
3. Manifest contains:
|
|
- holdDid: "did:web:alice-storage.fly.dev"
|
|
|
|
4. Client requests blob: GET /v2/alice/myapp/blobs/sha256:abc123
|
|
|
|
5. AppView reads the hold DID from the manifest's holdDid field (per request)
|
|
|
|
6. AppView gets service token from alice's PDS
|
|
(validated service tokens are cached ~45s to absorb a burst of blob requests)
|
|
|
|
7. AppView calls hold XRPC:
|
|
GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123
|
|
Authorization: Bearer {serviceToken}
|
|
Response: { "url": "https://s3.../presigned-download" }
|
|
|
|
8. AppView redirects client to presigned S3 URL
|
|
|
|
9. Client downloads directly from S3
|
|
```
|
|
|
|
**Key insight:** Pull uses the `holdDid` stored in the manifest, ensuring blobs are fetched from where they were originally pushed.
|
|
|
|
## Access Control
|
|
|
|
### Read Access
|
|
|
|
- **Public hold** (`server.public: true`): Anonymous + authenticated users
|
|
- **Private hold** (`server.public: false`): Authenticated users with crew membership
|
|
|
|
### Write Access
|
|
|
|
- Hold owner (captain) OR crew members only
|
|
- Verified via `io.atcr.hold.crew` records in hold's embedded PDS
|
|
- Service token proves user identity (from user's PDS)
|
|
|
|
### Authorization Flow
|
|
|
|
```go
|
|
1. AppView gets service token from user's PDS
|
|
2. AppView sends request to hold with service token
|
|
3. Hold validates service token (checks it's from user's PDS)
|
|
4. Hold extracts user's DID from token
|
|
5. Hold checks crew records in its embedded PDS
|
|
6. If crew member found → allow, else → deny
|
|
```
|
|
|
|
## Managing Crew Members
|
|
|
|
### Add Crew Member
|
|
|
|
Use ATProto client to create crew record in hold's PDS:
|
|
|
|
```bash
|
|
# Via XRPC (if hold supports it)
|
|
POST https://hold.example.com/xrpc/io.atcr.hold.requestCrew
|
|
Authorization: Bearer {userOAuthToken}
|
|
|
|
# Or manually via captain's OAuth to hold's PDS
|
|
atproto put-record \
|
|
--pds https://hold.example.com \
|
|
--collection io.atcr.hold.crew \
|
|
--rkey "{memberDID}" \
|
|
--value '{
|
|
"$type": "io.atcr.hold.crew",
|
|
"member": "did:plc:bob456",
|
|
"role": "crew",
|
|
"permissions": ["blob:read", "blob:write"]
|
|
}'
|
|
```
|
|
|
|
### Remove Crew Member
|
|
|
|
```bash
|
|
atproto delete-record \
|
|
--pds https://hold.example.com \
|
|
--collection io.atcr.hold.crew \
|
|
--rkey "{memberDID}"
|
|
```
|
|
|
|
## Storage Backends
|
|
|
|
Hold service requires S3-compatible storage. Supported providers:
|
|
- **AWS S3** - Amazon Simple Storage Service
|
|
- **Storj** - Decentralized cloud storage (via S3 gateway)
|
|
- **Minio** - High-performance object storage (great for local development)
|
|
- **UpCloud** - European cloud provider
|
|
- **Azure** - Azure Blob Storage (via S3-compatible API)
|
|
- **GCS** - Google Cloud Storage (via S3-compatible API)
|
|
|
|
## Example: Team Hold
|
|
|
|
```bash
|
|
# 1. Deploy hold service
|
|
export HOLD_SERVER_PUBLIC_URL=https://team-hold.fly.dev
|
|
export HOLD_REGISTRATION_OWNER_DID=did:plc:admin
|
|
export HOLD_SERVER_PUBLIC=false # Private
|
|
export AWS_ACCESS_KEY_ID=...
|
|
export AWS_SECRET_ACCESS_KEY=...
|
|
export S3_BUCKET=team-blobs
|
|
|
|
fly deploy
|
|
|
|
# 2. Hold auto-creates captain + crew records on first run
|
|
|
|
# 3. Admin adds team members via hold's PDS (requires OAuth)
|
|
# (TODO: Implement crew management UI/CLI)
|
|
|
|
# 4. Team members set their sailor profile:
|
|
atproto put-record \
|
|
--collection io.atcr.sailor.profile \
|
|
--rkey "self" \
|
|
--value '{
|
|
"$type": "io.atcr.sailor.profile",
|
|
"defaultHold": "did:web:team-hold.fly.dev"
|
|
}'
|
|
|
|
# 5. Team members can now push/pull using team hold
|
|
```
|
|
|
|
## Limitations
|
|
|
|
### Current IAM Challenges
|
|
|
|
See [EMBEDDED_PDS.md](./EMBEDDED_PDS.md#iam-challenges) for detailed discussion.
|
|
|
|
**Known issues:**
|
|
1. **RPC permission format**: Service tokens don't work with IP-based DIDs in local dev
|
|
2. **Dynamic hold discovery**: AppView can't dynamically OAuth arbitrary holds from sailor profiles
|
|
3. **Manual profile management**: No UI for updating sailor profile (must use ATProto client)
|
|
|
|
**Workaround:** Use hostname-based DIDs (`did:web:hold.example.com`) and public holds for now.
|
|
|
|
## Future Improvements
|
|
|
|
1. **Crew management UI** - Web interface for adding/removing crew members
|
|
2. **Dynamic OAuth** - Support for arbitrary BYOS holds without pre-configuration
|
|
3. **Hold migration** - Tools for moving blobs between holds
|
|
4. **Storage analytics** - Track usage per user/repository
|
|
5. **Distributed cache** - Redis for hold DID cache in multi-instance deployments
|
|
|
|
## References
|
|
|
|
- [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Embedded PDS architecture and IAM details
|
|
- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
|
|
- [Distribution Storage Drivers](https://distribution.github.io/distribution/storage-drivers/)
|
|
- [S3 Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html)
|