diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c6a7110 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# Build context: runtime files only. The container runs the app, not +# the repo's dev/test surface, and it must not re-pull git or cached +# bytecode into the image. +.git +.gitignore +.gitattributes +__pycache__ +*.py[cod] +*.egg-info +.pytest_cache +tests +venv +.venv +OPS.md +Dockerfile +docker-compose.yml +.dockerignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..92e7c10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# gitea-saas-core container image (issue #40). +# +# Slim Python base, non-root app user, gunicorn serving the repo-root +# wsgi.py entrypoint (``gunicorn wsgi:application``, assembled app from +# issue #38). +# +# Ledger-safety: exactly ONE gunicorn worker. This process owns a SQLite +# money ledger; the DB is WAL-mode with writes serialised by BillingCore's +# single in-process lock (gitea_saas/db.py + billing_core). Multiple +# workers would be multiple writer processes racing on one SQLite file — +# WAL permits one writer at a time but a ledger must not depend on SQLITE_BUSY +# backoff at the file level. Threads are safe: every request in this process +# funnels through that one lock. Scaling to more writer processes requires a +# network database (see OPS.md "Scaling"); it is deliberately out of scope here. +FROM python:3.12-slim + +# Runtime deps only. flask matches requirements.txt's "flask>=3.0"; gunicorn +# is the WSGI server and is installed here rather than added to +# requirements.txt, which belongs to earlier issues. pytest stays out of the +# image. The ``stripe`` package is NOT installed: the adapter only imports it +# lazily and CI/dev run against the built-in mock seam. +RUN pip install --no-cache-dir "flask>=3.0" "gunicorn>=23.0" + +# Non-root runtime user. +RUN groupadd --system app && useradd --system --gid app --home-dir /app app + +WORKDIR /app +COPY . /app + +ENV SAAS_PORT=8080 \ + SAAS_DB_PATH=/data/saas.db \ + PYTHONPATH=/app \ + PYTHONUNBUFFERED=1 + +# /data is where the SQLite ledger lives. In compose it is a named volume — +# that volume IS the durability story (see docker-compose.yml). +RUN mkdir -p /data && chown -R app:app /app /data + +USER app +EXPOSE 8080 + +# /health is public (auth.PUBLIC_PATHS) and answers without an API key, so +# the check works in every deployment state, including the fail-closed +# unconfigured one. urllib only: the slim base has no curl. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5).status == 200 else 1)" + +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", \ + "--access-logfile", "-", "--error-logfile", "-", "wsgi:application"] diff --git a/OPS.md b/OPS.md new file mode 100644 index 0000000..8d5eb05 --- /dev/null +++ b/OPS.md @@ -0,0 +1,132 @@ +# gitea-saas-core — operations (issue #40) + +Containerised deployment of the billing service. The app is the assembled +Flask app from issue #38 (`wsgi.py` -> `gitea_saas.app.create_app`), served +by gunicorn inside a slim Python container. This document covers day-to-day +operation: start/stop, backups, key rotation, and what to check when it is +down. + +## Ledger reality check (read first) + +- The ledger is a **single SQLite file** at `/data/saas.db` inside the + `saas_data` named volume. The volume **is** the durability story: losing + the volume loses the ledger. Nothing else holds the data. +- The store is **single-writer**: the Dockerfile deliberately runs exactly + one gunicorn worker, because multiple writer processes on one SQLite file + corrupt or lock. Never raise `--workers` (or run a second `saas` + container against the same volume). +- SQLite WAL is on and writes are serialised by the app's single lock; + threads are safe, workers are not. +- An untested restore is worse than no backup: run the restore procedure + below against a scratch/compose copy before you ever need it, and after + any change to it. + +## Quick start + + git clone && cd + export SAAS_API_KEY='' # absent key -> app boots FAIL-CLOSED (503 on everything but /health) + docker compose up -d --build + curl -fsS http://127.0.0.1:8080/health # -> {"ok":true} + +For convenience the key can live in a `.env` file next to +`docker-compose.yml` (`SAAS_API_KEY=`) and compose will pick it up +automatically. `.env` is local configuration — do not commit it. + +## Environment variables + +| Variable | Default | Meaning | +|-----------------|------------------|------------------------------------------------------| +| `SAAS_API_KEY` | (empty -> 503s) | X-API-Key accepted by every authed route. Required before traffic. | +| `SAAS_DB_PATH` | `/data/saas.db` | SQLite ledger path inside the container (volume). | +| `SAAS_PORT` | `8080` | Port gunicorn binds inside the container. | + +## Day to day + + docker compose ps # state + health of the service + docker compose logs --tail=200 # recent logs (stdout/stderr) + docker compose up -d # (re)create/start, e.g. after a deploy + docker compose restart # restart the process + docker compose down # stop; volume is KEPT + docker compose down -v # !!! DESTROYS THE LEDGER — never, without a verified backup + +Health endpoint is public (no API key): `/health` and `/healthz` return +`{"ok":true}`. Authed routes need `X-API-Key: `; with no key +configured they return 503 `SERVICE_UNAVAILABLE` (fail-closed by design). + +## Backup + +Online, consistent (uses SQLite's backup API, safe while the service is +running — do not `cp` the live file): + + docker compose exec saas python -c "import sqlite3; s=sqlite3.connect('/data/saas.db'); d=sqlite3.connect('/data/saas-backup.db'); s.backup(d); d.close(); s.close(); print('backup OK')" + docker compose cp saas:/data/saas-backup.db ./saas-backup-$(date +%F).db + +Check the archive landed and is readable before moving on: + + ls -l saas-backup-*.db + docker compose exec saas python -c "import sqlite3; c=sqlite3.connect('/data/saas.db'); print(c.execute('PRAGMA integrity_check').fetchone()); print(c.execute('select count(*) from clients').fetchone()[0], 'clients')" + +Keep the copy off-machine (the compose down /data copy dies with the host). +A crontab line like `docker compose ... exec saas python -c "..." && docker compose cp ...` off-site is enough. + +## Restore (tested end-to-end once — see the issue PR for the recorded run) + +Run from the directory that contains the backup file. `gitea-saas` is the +compose project name (pinned in docker-compose.yml), so the volume is +`gitea-saas_saas_data`. + +The container runs as the non-root `app` user, so the directory holding the +backup file must be traversable by it (e.g. mode 0755, file 0644); a backup +stashed in a private home directory will fail with "Permission denied". + + docker compose down # no writer while we swap files + docker run --rm -v gitea-saas_saas_data:/data -v "$(pwd)":/restore \ + gitea-saas-core:local sh -c "rm -f /data/saas.db-wal /data/saas.db-shm && cp /restore/saas-backup.db /data/saas.db" + docker compose up -d + +Why the `rm` of `-wal`/`-shm`: the running app leaves WAL sidecar files; if +they survived when we replace `saas.db`, SQLite would replay stale frames +onto the restored file. Removing them first is required, not cosmetic. + +Verify the restored ledger: + + curl -fsS http://127.0.0.1:8080/health + docker compose exec saas python -c "import sqlite3; c=sqlite3.connect('/data/saas.db'); print(c.execute('PRAGMA integrity_check').fetchone()); print('clients:', c.execute('select coalesce(group_concat(name, \", \"), \"(none)\") from clients').fetchone()[0]); print('ledger rows:', c.execute('select count(*) from ledger').fetchone()[0])" + +## Rotate SAAS_API_KEY + + docker compose exec saas env # sanity: what the container sees + # edit .env (or re-export) with the new key, then: + docker compose up -d # recreates the container with the new env + +Verify the old key now 401s and the new one works: + + curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/api/clients -H 'X-API-Key: old-key' + curl -s http://127.0.0.1:8080/api/clients -H 'X-API-Key: new-key' + +## When it is down — checklist + +1. `docker compose ps` — is the container running? `restart: unless-stopped` + brings it back after most exits; a container stuck in `Restarting` loops + is crashing at boot. +2. `docker compose logs --tail=200 saas` — traceback? DB path error? + `PermissionError` on `/data`? If the volume mount is missing the app + still starts but the ledger is ephemeral — check `ls` inside. +3. `curl -fsS http://127.0.0.1:8080/health` — health is public and keyless; + a time-out here while the container is up means the app is not listening + (bind/port issue): `docker compose port saas 8080`, `ss -tlnp | grep 8080`. +4. Authed calls 401? Wrong/rotated key. Authed calls 503? `SAAS_API_KEY` is + unset/empty in the container env — fail-closed on purpose. +5. Ledger questions: `docker compose exec saas python -c "import sqlite3; c=sqlite3.connect('/data/saas.db'); print(c.execute('PRAGMA integrity_check').fetchone())"` + — should print `('ok',)`. Check the volume's disk space: + `df -h /var/lib/docker/volumes/gitea-saas_saas_data/_data`. +6. Still broken and the ledger looks intact? Restore from the last verified + backup (above) after confirming the backup file itself passes + `integrity_check`. + +## Scaling (and why not to do it casually) + +This deployment is intentionally a single SQLite writer. To grow you must +move the ledger off SQLite onto a network-capable database (Postgres etc.) +first — raising `--workers` against SQLite will corrupt or lock the ledger. +Until then: one worker, threads for concurrency, backups before traffic. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..183d4a7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +# gitea-saas-core deployment (issue #40). +# +# The MEANING OF THE VOLUME (read before changing it): +# The service stores its money ledger in SQLite at /data/saas.db. The +# named volume `saas_data` is therefore the ENTIRE durability story: +# losing the volume loses the ledger. `docker compose down` keeps the +# volume. `docker compose down -v` DESTROYS it — never run that without +# a verified backup (OPS.md "Backup"/"Restore"). Backups before traffic. +# +# SQLite is a single-writer store: the container runs exactly one gunicorn +# worker (see Dockerfile) so all writes funnel through one process lock. +# Do not raise --workers here. +name: gitea-saas + +services: + saas: + build: . + image: gitea-saas-core:local + restart: unless-stopped + ports: + - "8080:8080" + environment: + # Empty by default on purpose: with no key the app still boots and + # /health stays public, but every authed route fails closed with 503 + # (see gitea_saas/app.py). Set a real key before serving traffic: + # docker compose --env-file .env up -d + # with SAAS_API_KEY= in .env, or export SAAS_API_KEY first. + SAAS_API_KEY: ${SAAS_API_KEY:-} + SAAS_DB_PATH: ${SAAS_DB_PATH:-/data/saas.db} + SAAS_PORT: "8080" + volumes: + - saas_data:/data + healthcheck: + test: ["CMD", "python", "-c", "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5).status == 200 else 1)"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + +volumes: + saas_data: