gitea-saas-core#40: deploy — container + ops doc (#50)

This commit is contained in:
pbwraith 2026-09-07 04:29:46 +02:00
commit 67d03e2fed
4 changed files with 239 additions and 0 deletions

17
.dockerignore Normal file
View File

@ -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

49
Dockerfile Normal file
View File

@ -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"]

132
OPS.md Normal file
View File

@ -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 <repo> && cd <repo>
export SAAS_API_KEY='<a real secret>' # 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=<secret>`) 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: <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.

41
docker-compose.yml Normal file
View File

@ -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=<secret> 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: