Files
at-container-registry/docs/hold.md
Evan Jarrett e3843db9d8 Implement did:plc support for holds with the ability to import/export CARs.
did:plc Identity Support (pkg/hold/pds/did.go, pkg/hold/config.go, pkg/hold/server.go)

  The big feature — holds can now use did:plc identities instead of only did:web. This adds:
  - LoadOrCreateDID() — resolves hold DID by priority: config DID > did.txt on disk > create new
  - CreatePLCIdentity() — builds a genesis operation, signs with rotation key, submits to PLC directory
  - EnsurePLCCurrent() — on boot, compares local signing key + URL against PLC directory and auto-updates if they've drifted (requires rotation key)
  - New config fields: did_method (web/plc), did, plc_directory_url, rotation_key_path
  - GenerateDIDDocument() now uses the stored DID instead of always deriving did:web from URL
  - NewHoldServer wired up to call LoadOrCreateDID instead of GenerateDIDFromURL

  CAR Export/Import (pkg/hold/pds/export.go, pkg/hold/pds/import.go, cmd/hold/repo.go)

  New CLI subcommands for repo backup/restore:
  - atcr-hold repo export — streams the hold's repo as a CAR file to stdout
  - atcr-hold repo import <file>... — reads CAR files, upserts all records in a single atomic commit. Uses a bulkImportRecords method that opens a delta session, checks each record for
  create vs update, commits once, and fires repo events.
  - openHoldPDS() helper to spin up a HoldPDS from config for offline CLI operations

  Admin UI Fixes (pkg/hold/admin/)

  - Logout changed from GET to POST — nav template now uses a <form method=POST> instead of an <a> link (prevents CSRF on logout)
  - Removed return_to parameter from login flow — simplified redirect logic, auth middleware now redirects to /admin/auth/login without query params

  Config/Deploy

  - config-hold.example.yaml and deploy/upcloud/configs/hold.yaml.tmpl updated with the four new did:plc config fields
  - go.mod / go.sum — added github.com/did-method-plc/go-didplc dependency
2026-02-14 15:17:53 -06:00

191 lines
7.8 KiB
Markdown

# ATCR Hold Service
Hold Service is the BYOS (Bring Your Own Storage) blob storage backend for ATCR. It stores container image layers in your own S3-compatible storage (AWS S3, Storj, Minio, UpCloud, etc.) and generates presigned URLs so clients transfer data directly to/from S3. Each hold runs an embedded ATProto PDS with its own DID, repository, and crew-based access control.
Hold Service is one component of the ATCR ecosystem:
1. **[AppView](https://atcr.io/r/evan.jarrett.net/atcr-appview)** — Registry API + web interface
2. **Hold Service** (this component) — Storage backend with embedded PDS
3. **Credential Helper** — Client-side tool for ATProto OAuth authentication
```
Docker Client --> AppView (resolves identity) --> User's PDS (stores manifest)
|
Hold Service (generates presigned URL)
|
S3/Storj/etc. (client uploads/downloads directly)
```
Manifests (small JSON metadata) live in users' ATProto PDS. Blobs (large binary layers) live in hold services. AppView orchestrates the routing.
## When to Run Your Own Hold
Most users can push to the default hold at **https://hold01.atcr.io** — you don't need to run your own.
Run your own hold if you want to:
- Control where your container layer data is stored (own S3 bucket, geographic region)
- Manage access for a team or organization via crew membership
- Run a shared hold for a community or project
- Use a CDN pull zone for faster downloads
**Prerequisites:** S3-compatible storage with a bucket already created, and a domain with TLS for production.
## Quick Start
### 1. Generate Configuration
```bash
# Build the hold binary
go build -o bin/atcr-hold ./cmd/hold
# Generate a fully-commented config file with all defaults
./bin/atcr-hold config init config-hold.yaml
```
Or generate config from Docker without building locally:
```bash
docker run --rm -i $(docker build -q -f Dockerfile.hold .) config init > config-hold.yaml
```
The generated file documents every option with inline comments. Edit only what you need.
### 2. Minimal Configuration
Only three things need to be set — everything else has sensible defaults:
```yaml
storage:
access_key: "YOUR_S3_ACCESS_KEY"
secret_key: "YOUR_S3_SECRET_KEY"
bucket: "your-bucket-name"
endpoint: "https://gateway.storjshare.io" # omit for AWS S3
server:
public_url: "https://hold.example.com"
registration:
owner_did: "did:plc:your-did-here"
```
- **`server.public_url`** — Your hold's public HTTPS URL. This becomes the hold's `did:web` identity.
- **`storage.bucket`** — S3 bucket name (must already exist).
- **`registration.owner_did`** — Your ATProto DID. Creates you as captain (admin) on first boot. Get yours from: `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.bsky.social`
### 3. Build and Run with Docker
```bash
# Build the image
docker build -f Dockerfile.hold -t atcr-hold:latest .
# Run it
docker run -d \
--name atcr-hold \
-p 8080:8080 \
-v $(pwd)/config-hold.yaml:/config.yaml:ro \
-v atcr-hold-data:/var/lib/atcr-hold \
atcr-hold:latest serve --config /config.yaml
```
- **`/var/lib/atcr-hold`** — Persistent volume for the embedded PDS (carstore database + signing keys). Back this up.
- **Port 8080** — Default listen address. Put a reverse proxy (Caddy, nginx) in front for TLS.
- The image is built `FROM scratch` — the binary includes SQLite statically linked.
- Optional: `docker build --build-arg BILLING_ENABLED=true` to include Stripe billing support.
## Configuration
Config loads in layers: **defaults → YAML file → environment variables**. Later layers override earlier ones.
All YAML fields can be overridden with environment variables using the `HOLD_` prefix and `_` path separators. For example, `server.public_url` becomes `HOLD_SERVER_PUBLIC_URL`.
S3 credentials also accept standard AWS environment variable names: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET`, `S3_ENDPOINT`.
For the complete configuration reference with all options and defaults, see [`config-hold.example.yaml`](../config-hold.example.yaml) or run `atcr-hold config init`.
## Access Control
| Setting | Who can pull | Who can push |
|---|---|---|
| `server.public: true` | Anyone | Captain + crew with `blob:write` |
| `server.public: false` (default) | Crew with `blob:read` | Captain + crew with `blob:write` |
| + `registration.allow_all_crew: true` | (per above) | Any authenticated user |
The captain (set via `registration.owner_did`) has all permissions implicitly. `blob:write` implies `blob:read`.
Authentication uses ATProto service tokens: AppView requests a token from the user's PDS scoped to the hold's DID, then includes it in XRPC requests. The hold validates the token and checks crew membership.
See [BYOS.md](BYOS.md) for the full authorization model.
## Optional Subsystems
| Subsystem | Default | Config key | Notes |
|---|---|---|---|
| Admin panel | Enabled | `admin.enabled` | Web UI for crew, settings, and storage management |
| Quotas | Disabled | `quota.tiers` | Tier-based storage limits (e.g., deckhand=5GB, bosun=50GB) |
| Garbage collection | Disabled | `gc.enabled` | Nightly cleanup of orphaned blobs and records |
| Vulnerability scanner | Disabled | `scanner.secret` | Requires separate scanner service; see [SBOM_SCANNING.md](SBOM_SCANNING.md) |
| Billing (Stripe) | Disabled | Build flag + env | Build with `--build-arg BILLING_ENABLED=true`; see [BILLING.md](BILLING.md) |
| Bluesky posts | Disabled | `registration.enable_bluesky_posts` | Posts push notifications from hold's identity |
## Hold Identity
**did:web (default)** — Derived from `server.public_url` with zero setup. `https://hold.example.com` becomes `did:web:hold.example.com`. The DID document is served at `/.well-known/did.json`. Tied to domain ownership — if you lose the domain, you lose the identity.
**did:plc (portable)** — Set `database.did_method: plc` in config. Registered with plc.directory. Survives domain changes. Requires a rotation key (auto-generated at `{database.path}/rotation.key`). Use `database.did` to adopt an existing DID for recovery or migration.
## Verification
After starting your hold, verify it's working:
```bash
# Health check — should return {"version":"..."}
curl https://hold.example.com/xrpc/_health
# DID document — should return valid JSON with service endpoints
curl https://hold.example.com/.well-known/did.json
# Captain record — should show your owner DID
curl "https://hold.example.com/xrpc/com.atproto.repo.listRecords?repo=HOLD_DID&collection=io.atcr.hold.captain"
# Crew records
curl "https://hold.example.com/xrpc/com.atproto.repo.listRecords?repo=HOLD_DID&collection=io.atcr.hold.crew"
```
Replace `HOLD_DID` with your hold's DID (from the `/.well-known/did.json` response).
## Docker Compose
```yaml
services:
atcr-hold:
build:
context: .
dockerfile: Dockerfile.hold
command: ["serve", "--config", "/config.yaml"]
volumes:
- ./config-hold.yaml:/config.yaml:ro
- atcr-hold-data:/var/lib/atcr-hold
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "/healthcheck", "http://localhost:8080/xrpc/_health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
atcr-hold-data:
```
For production with TLS termination, see [`deploy/docker-compose.prod.yml`](../deploy/docker-compose.prod.yml) which includes a Caddy reverse proxy.
## Further Reading
- [`config-hold.example.yaml`](../config-hold.example.yaml) — Complete configuration reference with inline comments
- [BYOS.md](BYOS.md) — Bring Your Own Storage architecture and authorization model
- [HOLD_XRPC_ENDPOINTS.md](HOLD_XRPC_ENDPOINTS.md) — XRPC endpoint reference
- [BILLING.md](BILLING.md) — Stripe billing integration
- [QUOTAS.md](QUOTAS.md) — Quota management
- [SBOM_SCANNING.md](SBOM_SCANNING.md) — Vulnerability scanning