# 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
# Single-thread BLAS on erable's old kernel (see §4 gotcha #2):
OPENBLAS_NUM_THREADS=1
OMP_NUM_THREADS=1
NUMEXPR_NUM_THREADS=1
MKL_NUM_THREADS=1
# 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 \
  --security-opt seccomp=unconfined \
  --env-file ~/.config/douanier/douanier.env \
  -v /home/pln/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.

### Two erable gotchas (verified at first deploy, 2026-06-28)

1. **`--security-opt seccomp=unconfined` is REQUIRED.** erable runs Docker 19.03
   on kernel 4.9 (Debian 9); its default seccomp profile rejects the `clone3`
   syscall that the image's glibc 2.36 (python:3.12-slim) uses for
   `pthread_create`. Without the flag the container starts uvicorn, then
   segfaults (exit 139) / aborts with `OpenBLAS blas_thread_init: pthread_create
   failed … Operation not permitted` the moment numpy/librosa touch threads.
   Unconfined seccomp is safe here: the container is loopback-only behind the
   gateway. (Real fix is a newer Docker; this is the pragmatic one.)
2. **Single-thread the BLAS stack** via the env file (`OPENBLAS_NUM_THREADS=1`,
   `OMP_NUM_THREADS=1`, `NUMEXPR_NUM_THREADS=1`, `MKL_NUM_THREADS=1`). Belt-and-
   braces with #1 on the old kernel, and right-sized for a shared 4-vCPU / ~2 GB
   box doing CPU-light work — avoids OpenBLAS spawning a pool that fights for RAM.
3. **Data volume** is `/home/pln/srv/douanier/data` (no sudo for `/srv`); it's
   pure transient cache, so the path is immaterial — just keep it capped (§5).

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.
