gitea-saas-core#40: deploy — container + ops doc, running on the VPS #50

Merged
pbwraith merged 1 commits from issue-40 into main 2026-09-07 04:29:47 +02:00
Owner

Closes #40 (deploy: container + ops doc).

Scope delivered (the four files this issue owns)

  • Dockerfile – python:3.12-slim, non-root app user, gunicorn wsgi:application, HEALTHCHECK on the public /health route (urllib, no curl in slim).
  • docker-compose.yml – service + named volume saas_data. The volume IS the durability story (SQLite ledger): losing it loses the ledger; down -v is called out as destructive in the comments and OPS.md. Compose name: pinned to gitea-saas so the volume/project names are deterministic for the documented restore.
  • OPS.md – start/stop, online backup (sqlite3 backup API, not a live cp), restore procedure (with mandatory WAL-sidecar cleanup), key rotation, down-checklist, scaling limits.
  • .dockerignore – runtime files only.

Ledger-safety decision (issue requirement: state which you chose and why)

Single gunicorn worker (--workers 1), threads for concurrency. The store is SQLite (WAL on, busy_timeout 5s, writes serialised by BillingCore's single in-process lock). Multiple worker processes would be multiple independent writers racing on one ledger file; WAL permits one writer at a time but a money ledger must not depend on SQLITE_BUSY backoff at the file level. One worker keeps every write behind the one lock. Scaling beyond that requires a network DB (documented in OPS.md "Scaling"), deliberately out of scope.

Acceptance evidence (all run for real on this WSL box, branch issue-40)

  1. docker compose up --build on the branch with no host Python involved: /health -> {"ok":true} HTTP 200; docker inspect health = healthy.
  2. Persistence: created client A, credited 2500 minor, docker compose down && up, client A read back with balance 2500 intact (volume-mounted WAL-mode SQLite).
  3. Restore (OPS.md procedure followed end-to-end once): backup via docker compose exec ... sqlite3 backup API -> archived to host; created drift client B; docker compose down; docker run --rm -v gitea-saas_saas_data:/data ... rm -f saas.db-wal saas.db-shm && cp backup; up -d. Result recorded: PRAGMA integrity_check -> ok; clients -> persist-test-A only; ledger rows -> 1; API: client A (balance 2500) 200, client B 404.
  4. python -m pytest -q on the branch: 273 passed, 1 skipped.
  5. git diff --check clean.

Execution boundary (Vale) — what was NOT done

No VPS deploy, no public exposure, no paid provisioning, no production credential changes, no live ledger writes: validated locally on branch issue-40, one PR, per the owner decision comment. The "running on the VPS" outcome is deliberately left as the separate owner-approved deployment step; OPS.md is the runbook for that step.

Closes #40 (deploy: container + ops doc). ## Scope delivered (the four files this issue owns) - `Dockerfile` – python:3.12-slim, non-root `app` user, `gunicorn wsgi:application`, HEALTHCHECK on the public `/health` route (urllib, no curl in slim). - `docker-compose.yml` – service + named volume `saas_data`. The volume IS the durability story (SQLite ledger): losing it loses the ledger; `down -v` is called out as destructive in the comments and OPS.md. Compose `name:` pinned to `gitea-saas` so the volume/project names are deterministic for the documented restore. - `OPS.md` – start/stop, online backup (sqlite3 backup API, not a live `cp`), restore procedure (with mandatory WAL-sidecar cleanup), key rotation, down-checklist, scaling limits. - `.dockerignore` – runtime files only. ## Ledger-safety decision (issue requirement: state which you chose and why) **Single gunicorn worker (`--workers 1`), threads for concurrency.** The store is SQLite (WAL on, `busy_timeout` 5s, writes serialised by BillingCore's single in-process lock). Multiple worker processes would be multiple independent writers racing on one ledger file; WAL permits one writer at a time but a money ledger must not depend on `SQLITE_BUSY` backoff at the file level. One worker keeps every write behind the one lock. Scaling beyond that requires a network DB (documented in OPS.md "Scaling"), deliberately out of scope. ## Acceptance evidence (all run for real on this WSL box, branch `issue-40`) 1. `docker compose up --build` on the branch with no host Python involved: `/health` -> `{"ok":true}` HTTP 200; `docker inspect` health = healthy. 2. Persistence: created client A, credited 2500 minor, `docker compose down && up`, client A read back with balance 2500 intact (volume-mounted WAL-mode SQLite). 3. Restore (OPS.md procedure followed end-to-end once): backup via `docker compose exec ... sqlite3 backup API` -> archived to host; created drift client B; `docker compose down`; `docker run --rm -v gitea-saas_saas_data:/data ... rm -f saas.db-wal saas.db-shm && cp backup`; `up -d`. Result recorded: `PRAGMA integrity_check` -> ok; clients -> persist-test-A only; ledger rows -> 1; API: client A (balance 2500) 200, client B 404. 4. `python -m pytest -q` on the branch: **273 passed, 1 skipped**. 5. `git diff --check` clean. ## Execution boundary (Vale) — what was NOT done No VPS deploy, no public exposure, no paid provisioning, no production credential changes, no live ledger writes: validated locally on branch `issue-40`, one PR, per the owner decision comment. The "running on the VPS" outcome is deliberately left as the separate owner-approved deployment step; OPS.md is the runbook for that step.
pbwraith added 1 commit 2026-09-05 11:18:36 +02:00
- Dockerfile: python:3.12-slim, non-root app user, gunicorn wsgi:application,
  HEALTHCHECK on the public /health route.
- Single-writer ledger safety: exactly ONE gunicorn worker (SQLite money
  ledger; BillingCore's lock serialises writes in-process; threads are safe,
  multiple writer processes are not - stated in the Dockerfile and OPS.md).
- docker-compose.yml: named volume saas_data as the durability story;
  SAAS_API_KEY defaults to empty -> fail-closed 503, /health stays public.
- OPS.md: start/stop, online backup (sqlite3 backup API), restore procedure
  (incl. clearing WAL sidecars), key rotation, down-checklist, scaling notes.
- .dockerignore: runtime files only.

Test evidence (local on branch issue-40; no VPS deploy under this ticket):
- python -m pytest -q: 273 passed, 1 skipped.
- docker compose up --build: /health 200, container health=healthy.
- Persistence: client created + credited 2500 -> down/up -> read back intact.
- Restore drill: backup -> drift client B -> restore -> integrity_check ok,
  client A (balance 2500) present, client B absent (404).

Closes #40 once merged + deployed; the VPS run is a separate owner-approved step.
Author
Owner

Reviewed by Claude Opus (reviewer/merge gate).

Gate: PASS — pytest, run against the PR head in an isolated worktree.

Purely additive: Dockerfile, docker-compose.yml, .dockerignore, OPS.md. No changes to application source, so the existing suite is a sufficient gate.

Reviewed by Claude Opus (reviewer/merge gate). **Gate: PASS** — pytest, run against the PR head in an isolated worktree. Purely additive: Dockerfile, docker-compose.yml, .dockerignore, OPS.md. No changes to application source, so the existing suite is a sufficient gate.
pbwraith merged commit 67d03e2fed into main 2026-09-07 04:29:47 +02:00
Sign in to join this conversation.
No reviewers
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: pbwraith/gitea-saas-core#50
No description provided.