# Deploying Fourier (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**. Fourier 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 `FOURIER_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 fourier:latest armada/api      # ~1 GB image, light engine only
```

(Or build on the dev box and `docker save fourier: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/fourier/data        # SRE-owned; cap at 2 GB (see §5)
```

## 3. Env file

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

```env
# image default FOURIER_EMOTION_ENGINE=light is correct for erable — leave unset
# image default FOURIER_ROOT_PATH=/audio/v1 matches the gateway — leave unset
FOURIER_MAX_UPLOAD_MB=40
# artifact store + /sources (#27/#29) — see §5b:
FOURIER_ARTIFACTS_MAX_MB=1024     # LRU cap for fetched sources / stems / loops
FOURIER_X_ACCEL=1                 # nginx streams artifacts (needs the §5b location)
# FOURIER_FETCH_MAX_MB / FOURIER_FETCH_TIMEOUT cap each yt-dlp download (defaults 60/180)
# 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
# FOURIER_TENANT_HEADER / FOURIER_SCOPES_HEADER default to X-Tenant / X-Scopes;
# override only if the platform injects different header names.
# Do NOT set FOURIER_DEV_TOKEN in prod (it's a local-dev-only bearer fallback).
```

## 4. Run

Use the launcher — it bakes in every flag idempotently (rm + run), including the
**memory cap** that a hand-typed `docker run` used to omit:

```bash
deploy/fourier.sh up      # (re)launches BOTH the API and the worker container
deploy/fourier.sh ps      # status + effective mem caps
```

It launches two containers off the same image: **`fourier`** (uvicorn, the HTTP
API) and **`fourier-worker`** (`python -m worker`, the heavy-engine job runner).
Both get `--restart unless-stopped`, `--security-opt seccomp=unconfined`, the
`--env-file`, the `/data` volume, and `--memory` (default `3g` each — override
via `FOURIER_MEM` / `FOURIER_API_MEM` / `FOURIER_WORKER_MEM`).

**Why the cap matters:** erable is a shared ~7.7 GB box (Postgres + the platform
live there too). Uncapped, a runaway separate/sources job could OOM its
neighbours. `docker update --memory` only survives a *restart*, not a rm+run
redeploy — the script is the durable home for the cap, so redeploys keep it.

The API publishes `-p 127.0.0.1:9780:9780`; loopback-only is the whole security
model — the gateway is the *only* way in, so the injected `X-Tenant`/`X-Scopes`
headers can be trusted. uvicorn binds `0.0.0.0` *inside* the container (the
loopback restriction is the host-side `-p`). The worker publishes no ports.

### 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/fourier/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 fourier python fourier.py cache gc --max-mb 2048
```

## 5b. /sources fetch + artifact serving (#29 / #27)

`/sources` fetches a URL's audio in the **worker** (not the API container) and
stores it as a content-addressed WAV under `FOURIER_ARTIFACTS`
(default `<cache>/artifacts`, LRU-capped by `FOURIER_ARTIFACTS_MAX_MB`, 1 GB).
The worker host needs two binaries on PATH:

```bash
# on the box that runs the worker (erable):
python -m pip install yt-dlp          # or pin via requirements-deploy.txt
apt-get install -y ffmpeg             # yt-dlp shells out to it for the WAV transcode
```

Artifacts are served **zero-copy by nginx**, not the Python process. Set
`FOURIER_X_ACCEL=1` in the env file and add the internal location to the vhost
(SRE-owned; the app emits `X-Accel-Redirect: /_artifacts/<cid>/<name>`):

```nginx
# internal: only reachable via an X-Accel-Redirect from the app, never directly
location /_artifacts/ {
    internal;
    alias /srv/fourier/data/artifacts/;   # = the container's FOURIER_ARTIFACTS
}
```

Without `FOURIER_X_ACCEL` the app streams a `FileResponse` itself (fine for dev,
not for the box). `GET /audio/v1/artifacts/{cid}/{name}` is auth-gated (any tenant
holding the opaque ref); the bytes are Range-capable through nginx.

## 6. Observability

`GET /metrics` (internal) / `https://api.nech.pl/audio/v1/metrics` exposes
Prometheus text — `fourier_up`, `fourier_requests_total{path,status}`,
`fourier_cache_rows{kind}`, `fourier_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.
