Spaces:
Running
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. |
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.pystill reference ascripts.inspector_v2_seed.setup_spaceprovisioning 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/):
- frontend-build (
node:20-alpine) βnpm ci+npm run buildβdist/. - ffmpeg-build (
alpine:3.23) β compiles a minimal static ffmpeg/ffprobe (mp3 decode +pcm_s16le/wav for peaks +libmp3lame/mp3 muxer for thesegment-cliproute).libmp3lame 3.100is built from source--enable-staticbecause Alpine ships no static lame archive β without itclip.pyreturns 200/0-bytes and playback hangs. http/https protocols enabled so ffmpeg can decode remote chapters via HTTP Range. - runtime (
python:3.11-alpine) β installsinspector/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, andfrontend/distfrom stage 1).qua_jobs/+.github/config/+LICENSEare read by the HF-Job entrypoints (config_loader, cut_release asset upload). Runs as non-root uid/gid 1000.EXPOSE 7860. Adding aCOPYhere 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.