# Deploying Douanier (the `audio` sub-API) on erable

The contract between **us** (the audio API) and **SRE** (who owns the platform
gateway + the seam to the internet).

`api.nech.pl` is a **multi-API platform**. Douanier is the internal codename for
its **`audio`** sub-API, served publicly at **`https://api.nech.pl/audio/v1/...`**.
The edge is already live: `curl https://api.nech.pl/audio/v1/healthz` returns
`503 {"status":"warming_up"}` until this container is running. The moment it's
up, that 503 becomes the real green `/healthz`.

This image is **CPU-only, torch-free** (the light V/A engine — see SRE.md). It is
~1 GB on disk and runs in well under 2 GB RAM.

## Two platform rules (SRE update 2026-06-25)

1. **Public path `/audio/v1/...`, app serves at root.** The gateway strips the
   prefix (`location /audio/v1/ { proxy_pass http://127.0.0.1:9780/; }`), so the
   container receives `/healthz`, `/analyze/emotion`, … at root. We set
   `root_path=/audio/v1` (env `DOUANIER_ROOT_PATH`) so `/openapi.json` + docs
   advertise the correct public paths.
2. **Central auth — no token store of ours.** The platform's `_platform` service
   authenticates the bearer at the edge (`nginx auth_request`) and injects
   **`X-Tenant`** + **`X-Scopes`**. We bind **loopback-only**, so only the gateway
   can reach us, and we **trust those headers**. (Supersedes our old SQLite auth.)
   Metering/quota is the platform's job too.

## 1. Build

```bash
docker build -t douanier:latest armada/api      # ~1 GB image, light engine only
```

(Or build on the dev box and `docker save douanier:latest | ssh erable docker load`.)

## 2. State volume

One writable dir at `/data` holds the content-addressed **cache** (transient,
evictable, rebuildable — losing it is just a cache miss). With central auth there
is **no tokens DB to back up** anymore; `/data` is pure throwaway cache.

```bash
mkdir -p /srv/douanier/data        # SRE-owned; cap at 2 GB (see §5)
```

## 3. Env file

`~/.config/douanier/douanier.env` (SRE-owned, NOT in git):

```env
# image default DOUANIER_EMOTION_ENGINE=light is correct for erable — leave unset
# image default DOUANIER_ROOT_PATH=/audio/v1 matches the gateway — leave unset
DOUANIER_MAX_UPLOAD_MB=40
# DOUANIER_TENANT_HEADER / DOUANIER_SCOPES_HEADER default to X-Tenant / X-Scopes;
# override only if the platform injects different header names.
# Do NOT set DOUANIER_DEV_TOKEN in prod (it's a local-dev-only bearer fallback).
```

## 4. Run

```bash
docker run -d --name douanier --restart unless-stopped \
  --env-file ~/.config/douanier/douanier.env \
  -v /srv/douanier/data:/data \
  -p 127.0.0.1:9780:9780 \
  douanier:latest
```

`-p 127.0.0.1:9780:9780` is the whole security model: loopback-only means the
gateway is the *only* way in, so the injected `X-Tenant`/`X-Scopes` headers can be
trusted. SRE wraps this in a keep-alive systemd unit.

Verify on the box, then from the internet:

```bash
curl -s http://127.0.0.1:9780/healthz                 # green, "selected":"light" (internal root path)
curl -s https://api.nech.pl/audio/v1/healthz          # same JSON → edge green end-to-end
curl -s https://api.nech.pl/audio/v1/openapi.json     # advertises /audio/v1/... public paths
```

## 5. Cache cap (transient storage)

The cache is rebuildable; keep it bounded — either cron the built-in GC or cap
the host dir:

```bash
docker exec douanier python douanier.py cache gc --max-mb 2048
```

## 6. Observability

`GET /metrics` (internal) / `https://api.nech.pl/audio/v1/metrics` exposes
Prometheus text — `douanier_up`, `douanier_requests_total{path,status}`,
`douanier_cache_rows{kind}`, `douanier_cache_hits_total{kind}`. Unauthenticated
by design (counts only, no secrets); reachable only via loopback/gateway. Point
the erable scraper at it. App logs → journald via the container.

## 7. Onboarding hexa

Token minting + scope grants are now the **platform's** job (central auth), not
ours — ask SRE to provision the `hexa` tenant with the `emotion` scope (and `*`
when more engines land). Hexa then calls
`https://api.nech.pl/audio/v1/analyze/emotion` with its platform bearer; the
gateway injects `X-Tenant: hexa` / `X-Scopes: emotion` for us.

## 8. What's NOT in this image (by design)

- **CLAP / torch emotion** — too heavy for erable. Behind a future `precise=true`
  tier on a GPU runner; same response shape, transparent upgrade.
- **Separation (demucs/roformer)** — no CPU path. `/audio/v1/separate` returns
  `503 compute_unavailable` (retriable) until a GPU-runner backend is wired
  (env-selected). The route exists so hexa can code against it today.
- **Corpus emotion search** — precomputed offline on the GPU box, shipped as a
  compact index; not part of the live container.
