- 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.
50 lines
2.2 KiB
Docker
50 lines
2.2 KiB
Docker
# 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"]
|