quranic-universal-audio / docs /reference /config-deploy.md
hetchyy's picture
deploy: inspector prod @ bced5bc
9854b3f verified
|
Raw
History Blame Contribute Delete
19.6 kB
# Config & deploy
Every runtime knob the Inspector reads, plus how the image is built and shipped to the HF Spaces. Deployment tunables that aren't env-driven live in `inspector/config.py` (one constant per line, each commented β€” read it directly when changing a threshold). This doc covers the **env surface** + **build/deploy/provisioning**.
## Runtime profiles
Same code, profile selected by env presence:
| | **Local dev** (`python inspector/app.py`) | **Deployed** (HF Space, gunicorn) |
|---|---|---|
| Identity | `INSPECTOR_DEV_MODE=1` auto-set when OAuth unconfigured β†’ synthetic per-role user, OAuth bypassed | HF OAuth β†’ signed `inspector_session` cookie |
| DB | `inspector.db` at `INSPECTOR_DB_PATH` (defaults to a tmp path) | same; canonical bucket artefact is `<mount>/db/inspector.db` |
| Bucket | FUSE auto-mount via `hf-mount` (unless opted out) or `hffs` fall-back | NFS mount provided by the Space runtime at `INSPECTOR_BUCKET_MOUNT` |
| Port | `:5000` (Flask dev server) | `:7860` (gunicorn, `EXPOSE 7860`) |
| Workers | Flask dev server | gunicorn-gthread `-w 1 --threads 16` (single-worker invariant) |
`INSPECTOR_BEHIND_PROXY=1` is the deployed-mode signal: it enables `ProxyFix` and skips the local auto-mount (the Space already provides the mount). Set in the image.
## Spaces
| Env | Space repo | Bucket (`INSPECTOR_BUCKET_REPO`) |
|---|---|---|
| dev | `hetchyy/quranic-inspector-dev` | `hetchyy/quranic-inspector-bucket-dev` |
| prod | `hetchyy/quranic-universal-audio` | `hetchyy/quranic-inspector-bucket` |
The image bakes the **dev** bucket as default (`INSPECTOR_BUCKET_REPO=hetchyy/quranic-inspector-bucket-dev`); the prod Space overrides it via a Space variable. Both ship the Docker SDK and bind 7860.
## Env vars β€” what the code actually reads
### Storage / bucket / DB
| Var | Default | Read by | Purpose |
|---|---|---|---|
| `INSPECTOR_BACKEND` | `bucket` | `services/storage/hf_bucket.py`, `auto_mount.py` | `bucket` (HF storage) or `filesystem` (tests + the Tier-0 fixtures/offline path seeded by `scripts/devenv/seed_fixtures.py`). |
| `INSPECTOR_BUCKET_REPO` | `…-bucket-dev` | `auto_mount.py`, `hf_bucket.py` | HF bucket repo id. Resolved by `resolve_bucket_repo()`: defaults to the **dev** bucket for every non-deployed process and **refuses prod** unless `INSPECTOR_ALLOW_PROD_BUCKET=1`. |
| `INSPECTOR_ALLOW_PROD_BUCKET` | unset | `hf_bucket.py`, `auto_mount.py` | `1` lets a local/non-deployed process use the prod bucket (otherwise `ProdBucketRefused`). The deployed prod Space is exempt (it runs behind the proxy). |
| `INSPECTOR_DB_SYNC` | unset (`1`) | `app.py`, `services/db/sync.py` | `0` disarms per-commit bucket DB write-back at boot. Boot still pulls the DB; DB commits stay local and never upload. Stops only the **DB** sync β€” pair with `INSPECTOR_READ_ONLY` to also stop direct content writes. |
| `INSPECTOR_READ_ONLY` | unset | `services/storage/hf_bucket.py` | `1` wraps the storage backend in `ReadOnlyBackend`, which refuses **every** mutating op (`write_*`/`append_jsonl`/`copy`/`move`/`delete`) with `StorageReadOnly`. Covers the writes `INSPECTOR_DB_SYNC` misses β€” segment save (`detailed.json`/`edit_history.jsonl`), intake manifests, job records β€” since they all go straight through the backend, not the DB sync. `launch.py --mode prod` sets both, so a local process on the prod bucket cannot mutate it by any path. |
| `INSPECTOR_AUDIO_FROM_BUCKET` | `1` | `services/audio/audio_fetch.py` | `0` makes `read_prefetched_audio_*` short-circuit so `audio_source.resolve` skips the bucket (mount + hffs full-MP3 read) and streams from the **CDN** β€” faster locally. `launch.py` sets `0` for `dev`/`prod` (override with `--bucket-audio`), `1` for `fixtures` (offline β†’ local audio, no CDN). Deployed leaves it on (mount). |
| `INSPECTOR_PEAKS_FROM_BUCKET` | `1` | `services/audio/audio_fetch.py` | `0` makes `read_prefetched_peaks` short-circuit to `None` so every chapter misses and the FE falls through to per-segment **ffmpeg** compute. `launch.py --ffmpeg-peaks` sets `0`. |
| `INSPECTOR_BUCKET_MOUNT` | unset local; `/data/inspector-bucket` (image) | `app.py`, `health.py`, `hf_bucket.py`, `auto_mount.py` | Mount path. Presence β‡’ "deployed mode" for `/healthz`. When unset locally the backend falls back to per-call `hffs` reads (~50–500Γ— slower). |
| `INSPECTOR_FILESYSTEM_ROOT` | unset | `hf_bucket.py` | Required when `INSPECTOR_BACKEND=filesystem`. `seed_fixtures.py` sets it to `inspector/.fixtures`. |
| `INSPECTOR_AUTO_MOUNT` | `1` | `auto_mount.py` | `0` opts out of local FUSE auto-mount. |
| `INSPECTOR_DB_PATH` | `<tmpdir>/inspector.db` | `services/db/connection.py` | On-disk SQLite path. |
| `INSPECTOR_HF_TOKEN` / `HF_TOKEN` | unset | `hf_bucket.py`, `auto_mount.py` | Token for bucket read/write (needs write scope). `INSPECTOR_HF_TOKEN` wins; `HF_TOKEN` is the fall-back + what `hf-mount` reads. |
### Data (bundled reference)
| Var | Default | Purpose |
|---|---|---|
| `INSPECTOR_DATA_DIR` | `/app/data` (image) / `<repo>/data` (local) | Root for bundled static data (surah_info + linguistic JSONs). |
| `INSPECTOR_QUA_DATA_PATH` | mirrors `INSPECTOR_DATA_DIR` | Base for `qpc_hafs.json`, `digital_khatt_v2_script.json`, `phoneme_sub_costs.json`. |
### Server / proxy / process
| Var | Default | Read by | Purpose |
|---|---|---|---|
| `INSPECTOR_HOST` | `0.0.0.0` | `config.py` | Bind host. |
| `INSPECTOR_BEHIND_PROXY` | unset; `1` (image) | `app.py`, `auth.py`, `auto_mount.py` | Deployed signal β€” enables `ProxyFix`, skips auto-mount. |
| `INSPECTOR_PUBLIC_URL` | unset | `routes/auth/auth.py` | Override the public https base used to build the OAuth callback. |
| `INSPECTOR_COMMIT_SHA` | `unknown` | `health.py` | Reported by `/healthz` so deploys are identifiable. |
| `GUNICORN_WORKERS` / `WEB_CONCURRENCY` / `GUNICORN_CMD_ARGS` | `1` | `app.py::_assert_single_worker` | Any multi-worker signal aborts boot. The single-worker invariant is load-bearing (single writer, in-process caches, per-slug locks). |
| `FLASK_ENV` | unset | `config.py` / `app.py` | `development` β†’ Flask debug + reloader (local only). |
### Background loops
| Var | Default | Purpose |
|---|---|---|
| `INSPECTOR_AUTO_DETECT` | off; `1` (image) | Background loop that scans the bucket for newly-aligned reciters and folds them into state. |
| `INSPECTOR_AUTO_DETECT_INTERVAL_S` | (see `app.py`) | Tick interval for the auto-detect loop. |
| `INSPECTOR_AUTOMATIONS` | off; `1` (image) | Release-automation reconciler daemon (per-automation enable lives in the owner's config blob; the daemon no-ops while all are off). See [automation.md](automation.md). |
| `INSPECTOR_AUTOMATIONS_INTERVAL_S` | `60` | Tick cadence for the automation reconciler. |
| `INSPECTOR_PUBLIC_BASE_URL` | empty | Public https root the daemon threads into job completion webhooks (no `request.url_root` in a thread). Required for automated GH cuts β€” the cut job is webhook-only. Set as a Space variable per environment. |
### Auth / identity / secrets
| Var | Source | Purpose |
|---|---|---|
| `OAUTH_CLIENT_ID` / `OAUTH_CLIENT_SECRET` / `OPENID_PROVIDER_URL` | HF-injected (`hf_oauth: true`) | OAuth client + OIDC discovery. Auto-managed by HF; never persisted. |
| `OAUTH_SCOPES` | `openid profile` | OAuth scopes requested. |
| `INSPECTOR_SESSION_SECRET` | Space secret (β‰₯32 hex) | Signs the `inspector_session` cookie. `services/auth/secrets_guard.py::get_session_secret` rejects unset/short values. Rotating logs everyone out. |
| `INSPECTOR_DEV_MODE` | `1` auto-set locally when OAuth unconfigured | Bypasses OAuth; mints a synthetic per-role user. |
| `INSPECTOR_DEV_<ROLE>_HF_ID` / `INSPECTOR_DEV_<ROLE>_LOGIN` | unset | Override the synthetic dev identity per role (`OWNER`/`MAINTAINER`/`CONTRIBUTOR`); default `dev-<role>`. Set to a real HF id+login when local dev points at the prod bucket so audit attribution is correct. |
| `INSPECTOR_GITHUB_DISPATCH_TOKEN` | Space secret | Fine-grained PAT (`actions:write` on the project repo). Fires `repository_dispatch` after publish. `secrets_guard.get_dispatch_token` raises if unset or still a `PLACEHOLDER_` value. |
| `INSPECTOR_WEBHOOK_SECRET` | Space secret (optional) | Shared secret for the timestamps-job completion webhook (`POST /api/webhooks/ts-job-complete`). When set, `launch()` threads it (+ the job's callback URL) to the HF job so it auto-publishes the reciter on success; the route validates it with `hmac.compare_digest`. **Unset β†’ webhook disabled (503)** and release falls back to the drawer-poll path. Read by `routes/webhooks/ts_jobs.py` + `services/admin/timestamps_jobs.py::launch`. |
| `INSPECTOR_WEBHOOK_URL` | set on the **job**, not the Space | The Inspector's public callback URL. The launch route derives it from `request.url_root` (deployed `ProxyFix` β†’ `https://…hf.space/`) and passes it to the job as env β€” operators don't set it on the Space. |
| `BREVO_API_KEY` | Space secret (optional) | Brevo transactional REST API key for the email-notifications sender (`services/email/`). HF Spaces block outbound SMTP on every port, so delivery is over HTTPS (`POST /v3/smtp/email`), not `smtplib`. **Unset β†’ no email is sent; the sender logs the rendered message instead** (dev exercises the flow without secrets). |
| `INSPECTOR_EMAIL_FROM_ADDRESS` / `GMAIL` | Space secret | From address for outgoing email β€” MUST be a Brevo-verified sender. `INSPECTOR_EMAIL_FROM_ADDRESS` wins; falls back to `GMAIL` (the already-verified single-sender account). |
| `INSPECTOR_EMAIL_SITE_URL` / `INSPECTOR_EMAIL_GH_RELEASES_URL` / `INSPECTOR_EMAIL_FROM_NAME` | defaults in `config.py` | Email link targets + From display name. Functional links (manage/unsubscribe) use `INSPECTOR_PUBLIC_BASE_URL` (localhost in dev). |
> The error strings in `secrets_guard.py` still reference a `scripts.inspector_v2_seed.setup_space` provisioning module that no longer exists β€” secrets are now set by hand in the Space secrets panel. Treat that hint as stale.
### Quran Foundation (see `architecture.md` β†’ QF integration)
| Var | Read by | Purpose |
|---|---|---|
| `QF_PREPROD_CLIENT_ID` / `QF_PREPROD_CLIENT_SECRET` | `services/quran_foundation/config.py` | User-API (bookmarks/collections) pre-prod OAuth client. `client_secret_basic` only. |
| `QF_OAUTH_REDIRECT_URI` | same | Registered callback; default `http://localhost:5001/api/qf/callback`. Must match the QF client exactly. |
| `QF_CONTENT_CLIENT_ID` / `QF_CONTENT_CLIENT_SECRET` | same | Content-API server-to-server (`client_credentials`) client. Production endpoints. |
Local QF creds live in `inspector/.env` (gitignored), loaded by `app.py`.
### Misc tunables (env-overridable constants)
| Var | Default | Purpose |
|---|---|---|
| `INSPECTOR_MFA_SPACE_URL` | `https://hetchyy-quran-phoneme-mfa-dev.hf.space` | MFA Space the cross-verse "Auto Split" calls. |
| `MISSED_BASMALA_FLAG_MIN_DELETED` | `10` | Min pipeline-stripped basmalas before flagging non-stripped chapters as "missed Basmala". |
## Image build β€” `inspector/Dockerfile`
Three-stage build, context is the **repo root** (so `qua_shared/` ships alongside `inspector/`):
1. **frontend-build** (`node:20-alpine`) β€” `npm ci` + `npm run build` β†’ `dist/`.
2. **ffmpeg-build** (`alpine:3.23`) β€” compiles a minimal *static* ffmpeg/ffprobe (mp3 decode + `pcm_s16le`/wav for peaks + `libmp3lame`/mp3 muxer for the `segment-clip` route). `libmp3lame 3.100` is built from source `--enable-static` because Alpine ships no static lame archive β€” without it `clip.py` returns 200/0-bytes and playback hangs. http/https protocols enabled so ffmpeg can decode remote chapters via HTTP Range.
3. **runtime** (`python:3.11-alpine`) β€” installs `inspector/requirements.txt`, purges pip/setuptools/wheel, **selective COPY** (`app.py/config.py/constants.py`, `adapters/ domain/ routes/ services/ utils/`, `scripts/__init__.py + qua_shared/ + qua_jobs/`, `.github/config/`, `LICENSE`, the 4 bundled data JSONs, and `frontend/dist` from stage 1). `qua_jobs/` + `.github/config/` + `LICENSE` are read by the HF-Job entrypoints (`config_loader`, cut_release asset upload). Runs as non-root uid/gid 1000. `EXPOSE 7860`. **Adding a `COPY` here needs no staging edit** β€” the deploy stages the whole tracked tree (see Deploy).
`CMD`: `gunicorn -k gthread -w 1 --threads 16 --max-requests 5000 --max-requests-jitter 500 --timeout 60 --graceful-timeout 30 --bind 0.0.0.0:7860 --access-logfile - --error-logfile - --chdir /app/inspector app:app`. `-w 1` is load-bearing (asserted at import). `--threads 16` sizes the I/O-bound pool. `--max-requests` recycles the worker to bound slow leaks. The access log (one line per request β†’ stdout) is de-noised by `app._AccessLogFilter`, attached to the `gunicorn.access` + `werkzeug` loggers: it drops static-asset (`/assets/*`), `/healthz`, and 304 lines while always keeping 4xx/5xx and real API/page hits.
`docker-compose.yml` (repo `inspector/`) is the local reviewer entry (`docker compose up` β†’ `:5000`, bind-mounts `data/`).
## Deploy β€” `inspector-deploy.yml`
Push to `dev` or `main` (paths under `inspector/**`, `qua_shared/**`, `scripts/deploy/upload_inspector.py`, the bundled data JSONs, `.dockerignore`) → workflow runs `inspector-checks.yml` (reused), then `deploy` runs `scripts/deploy/upload_inspector.py <env>`. Branch→env: `dev`→dev Space, `main`→prod Space; `workflow_dispatch` can force `dev`/`prod`. The Space then rebuilds the Docker image from the uploaded files.
**Fast-path dev dispatch.** A manual `workflow_dispatch` that resolves to the **dev** Space (`env=dev`, or `env=auto` off any non-`main` branch) **skips the `checks` job entirely** for a quick deploy β€” the dev Space is a verification target, not prod. Every push and any prod-targeting dispatch still runs the full check suite. The `deploy` gate accepts a skipped `checks` (`success` *or* `skipped`), so it never confuses "intentionally skipped" with "failed".
**Staging = the whole git-tracked tree.** `upload_inspector._stage()` copies **every git-tracked file** verbatim (then overlays the root `Dockerfile` from `inspector/Dockerfile` + a frontmatter `README.md`), and `upload_folder(delete_patterns="*")` mirrors it to the Space. `.dockerignore` is the **single source of truth** that prunes the build context β€” there is no hand-curated allowlist to drift from the Dockerfile's `COPY` set (that drift took prod down once: a `COPY LICENSE` with no matching staging entry β†’ `BUILD_ERROR "/LICENSE: not found"`). Consequence: the Space build context equals the `context: .` that `docker-publish.yml` builds on every push, so its green image build is a faithful "the Space will compile" signal. `dist/` is built inside the image (stage 1), so the deploy does **not** pre-build the frontend.
**Caveat β€” async build, no CI feedback.** `upload_inspector.py` only uploads + `restart_space(factory_reboot=True)` and returns; the HF docker rebuild is asynchronous. A green `inspector-deploy` run does **not** mean the Space built. After a deploy, poll the runtime stage (`…/api/spaces/<id>/runtime` β†’ `RUNNING_BUILDING β†’ RUNNING_APP_STARTING β†’ RUNNING`; build logs at `…/logs/build`).
**Opt-in boot check (prod-only, default off).** `scripts/deploy/smoke_boot.py` builds the image and boots it offline (filesystem backend + public `seed_fixtures`, no secrets, no bucket), polling `/healthz` for `200 + state_loaded + db.open` (offline it's `200`/`status: degraded` since no bucket is mounted β€” so it does **not** assert `status == ok`). Two entry points: `scripts/deploy/upload_inspector.py --verify-boot` (gates a local/manual deploy on a passing boot), and a `boot-smoke` CI job that runs **only** on a manual `workflow_dispatch` with `verify_boot=true` targeting prod. The `deploy` job's `if` is `always() && (needs.checks.result=='success' || =='skipped') && needs.boot-smoke.result != 'failure' && != 'cancelled'` so a skipped `boot-smoke` (every normal push) or a skipped `checks` (fast-path dev dispatch) still deploys, while a failing one blocks the deploy. Automatic deploys stay lean (no image build on the deploy path; `docker-publish.yml` builds the identical context in parallel as a non-blocking red signal).
The canonical dev/prod Spaces have **no** auto-provisioning script β€” their secrets/variables (`INSPECTOR_SESSION_SECRET`, `INSPECTOR_GITHUB_DISPATCH_TOKEN`, QF creds, `INSPECTOR_BUCKET_REPO` override, bucket attachment) are configured by hand in the Space settings panels. OAuth vars are auto-injected by HF via `hf_oauth: true` in the Space README frontmatter.
**Contributor (personal) Spaces** are fully scripted β€” no manual Space-settings clicks. `scripts/devenv/bootstrap_dev_env.py <name>` provisions a private bucket + Space under the contributor's own account, **attaches the bucket as a Space volume at `/data/inspector-bucket`** (`HfApi.set_space_volumes`), auto-generates `INSPECTOR_SESSION_SECRET`, sets `HF_TOKEN` + the `INSPECTOR_BUCKET_REPO` variable, and (with `--deploy`) pushes code via `scripts/deploy/deploy_space.py <space-id>` (the committed, parameterized cousin of `scripts/deploy/upload_inspector.py`). The only manual prerequisite is an HF token with write scope; OAuth vars are auto-injected via `hf_oauth: true`. See `inspector/README.md` for the three-tier dev workflow.
## Type-check gate (pyright)
The entire Python backend (`inspector/`, `qua_jobs/`, `qua_shared/`) is
type-checked in basic mode as a **blocking** CI gate (`type-check` job in
`inspector-checks.yml`). It runs via `npx -y pyright --level error inspector
qua_jobs qua_shared` so no Python type-checker dependency is added to the image
β€” node 24 is already present. The `extraPaths` in `pyrightconfig.json` make the
flat-layout imports resolve cleanly across the three packages.
Touching any file under `qua_shared/schemas/` also requires regenerating the
codegen'd FE types and committing the result β€” see `schema-codegen-check` in
`inspector-checks.yml` and `docs/reference/schemas.md`.
## Secret rotation
- `INSPECTOR_HF_TOKEN` β€” new token β†’ update Space secret β†’ restart. Revoke the old after verifying.
- `INSPECTOR_SESSION_SECRET` β€” replace the Space secret β†’ restart. **All in-flight cookies invalidate** (everyone logged out).
- `INSPECTOR_GITHUB_DISPATCH_TOKEN` β€” mint new PAT β†’ update secret β†’ restart.
## Health
`GET /healthz` (`routes/auth/health.py`) returns the resolved config + substrate status: `status`, `mode` (deployed iff `INSPECTOR_BUCKET_MOUNT` set), `bucket_mounted` (checks `<mount>/db/inspector.db` exists), `state_loaded`, `reciters_count`, `oauth_configured`, `auto_detect_loop`, `commit`, and a `db` block (`open`, `schema_version`, `last_bucket_upload_ts`, `bucket_lag_seconds`, `last_error`). Returns **503** in deployed mode when degraded so probes fail loud; always 200 in local mode (no mount to check). `GET /livez` is the always-200 liveness probe.
`GET /healthz?deep=1` adds an **opt-in** `sample_validation` block β€” a bounded external-bucket drift probe (`services/storage/bucket_audit.py::sample_validation`): it round-trips the DB catalog through `repo_catalog.snapshot()` and audits a small reciter-folder sample (default 3, spread across the sorted slug list) against the `qua_shared/schemas` definitions. Shape: `{ok, catalog_ok, sampled, n_reciters, errors}`. The default probe never walks the bucket (latency unchanged); a deep probe that finds drift flips `status` to degraded (503 in deployed mode) so external-file drift surfaces at health-check time instead of mid-request.