Files
ping-mee-self-service-platform/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-project-scaffold-configuration-and-container-plan.md
T
Ebbe BaßandClaude Sonnet 5.5 840bea1cba fix: gunicorn control socket error and host bytecode in the image
Found by running the container on Docker Desktop. Disable gunicorn's control
socket (the home directory is read-only) and ignore __pycache__ and .pyc at
any depth so host bytecode with the Windows source path stays out of the image.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
2026-10-15 14:58:32 +02:00

11 KiB

title, type, ticket, created, status, baseline_revision, route, route_source, risk, review, review_source, lenses_ran, review_loop_iteration, context
title type ticket created status baseline_revision route route_source risk review review_source lenses_ran review_loop_iteration context
Project scaffold, configuration and container feature 1 2026-10-15 built 24de5878ed full auto medium quick pinned
quick
0
{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md

Intent

Problem: The repository holds only planning documents. Every later story needs a runnable, secure and configurable Django container to build on.

Approach: Create the Django 5.2 LTS project with environment-variable configuration that fails fast, a database-only health check, JSON logs with a request id, a strict security-header layer, a multi-stage Docker image with compose file, and a test setup.

Boundaries & Constraints

Always: All configuration comes from environment variables, validated at startup; one error message lists every invalid or missing variable and the process exits non-zero. Nothing in the repository names ping-mee, a domain, an OU, a group, an IP or a path. Secrets never appear in logs. One gunicorn worker with threads. The container runs as a non-root user; only /data and /backups are writable. /healthz checks the database only. Python 3.13, Django 5.2 LTS. Spine rules AD-13 and AD-18 apply. Decision: the image is built and run on the Linux host from a checkout of this repository (Docker is not installed on the development machine); no registry or pipeline in this story.

Never: No AD, OIDC, mail, share or feature-app code (later entries). No python-ldap dependency yet. No inline script or external asset anywhere. No Django admin. No commit of secrets, and no real .env.

I/O & Edge-Case Matrix

Scenario Input / State Expected Output / Behavior Error Handling
Valid start All required variables valid Migrations run, server answers No error expected
Invalid config A required variable missing or malformed Process exits non-zero, message names every bad variable No traceback, no secret value printed
Health ok GET /healthz, database reachable 200 {"status":"ok"} No error expected
Health down Database unreachable 503 {"status":"unavailable"} Logged without secrets
Headers Any response CSP, nosniff, frame deny, referrer policy, X-Request-ID present No error expected
Request log Any request One JSON log line with request id, method, path, status Query strings and bodies not logged

Code Map

Greenfield: only planning documents exist. New files, using the spine's source tree.

  • pyproject.toml, uv.lock, .python-version -- dependencies (Django, gunicorn, whitenoise; dev: pytest, pytest-django, ruff)
  • manage.py, portal/config/ -- settings.py, env.py (typed parsing, collects all errors), urls.py, wsgi.py, logging.py (JSON formatter), middleware.py (request id, security headers), health.py
  • portal/adapters/__init__.py -- empty package for later adapters and fakes
  • portal/tests/ -- conftest.py, config, health, header and logging tests
  • Dockerfile, compose.yaml, .dockerignore, docker/entrypoint.sh -- multi-stage image, non-root, migrate then gunicorn
  • .env.example, README.md -- documented variables and run steps
  • Reuse: spine AD-13, AD-18. Do not change: anything under _bmad/, _bmad-output/, .agents/, context-resources/.

Tasks & Acceptance

Execution:

  • pyproject.toml, .python-version -- project metadata and pinned dependencies -- repeatable installs
  • portal/config/env.py -- typed variables (SECRET_KEY, ALLOWED_HOSTS, DATA_DIR, DEBUG, TRUSTED_PROXIES, LOG_LEVEL, DEFAULT_TIMEZONE) with defaults only for safe values -- fail fast
  • portal/config/settings.py, urls.py, wsgi.py -- SQLite in WAL mode under DATA_DIR, secure cookies, CSRF, whitenoise, UTC storage, translation on -- spine baseline
  • portal/config/middleware.py, logging.py, health.py -- request id, headers, JSON logs, /healthz
  • Dockerfile, docker/entrypoint.sh, compose.yaml, .dockerignore -- image and run definition
  • portal/tests/ -- unit-test the matrix rows
  • .env.example, README.md -- document every variable and the run steps

Acceptance Criteria:

  • Given valid variables, when the app starts, then migrations apply and /healthz returns 200.
  • Given a missing SECRET_KEY and a malformed LOG_LEVEL, when the app starts, then it exits non-zero naming both.
  • Given the database file is unreadable, when /healthz is called, then it returns 503.
  • Given any request, when the response is sent, then the CSP forbids inline scripts and external sources and the log line has the request id.
  • Given the repository, when searched, then it contains no ping-mee name, domain, IP or path.

Implementation Notes

  • Implemented by a coding subagent from this plan; the diff was read and uv run pytest (12 passed, none skipped) and uv run ruff check . were re-run by the orchestrator.
  • Beyond the plan: docker/healthcheck.py (the container probe needs a Host header that matches ALLOWED_HOSTS, otherwise it gets a 400); .gitattributes (keeps LF in .sh and Dockerfile on Windows checkouts); extra headers (Permissions-Policy, COOP); HSTS of one year when DEBUG=false, which needs the proxy to send X-Forwarded-Proto.
  • README.md was rewritten and no longer carries the original project name line.
  • Ruff excludes _bmad, _bmad-output, .agents and context-resources.
  • The 503 criterion is tested with a mocked unreachable database, not an unreadable file.
  • Docker was verified on the development machine afterwards (Docker Desktop 29.8.2, Linux engine): the image builds, the container is healthy, runs as uid 10001, has a read-only root file system with /data, /backups and /tmp writable, answers /healthz with 200 and all security headers, returns 400 with the CSP for an unlisted Host, keeps the database across a restart, exits 1 on invalid configuration naming every bad variable, and logs no query strings.
  • The live test found two defects the unit tests could not: gunicorn 26 logged a startup error because its control socket could not be created in the read-only home directory (fixed with --no-control-socket), and host __pycache__ files, which embed the Windows source path, were copied into the image because .dockerignore only matched the top-level folder (fixed with **/__pycache__ and **/*.pyc). The image no longer contains the project name.
  • Still to confirm on the Linux host: the same checks behind Zoraxy, and that the uv image tag builds there.
  • compose.yaml publishes port 8000 on all host interfaces. Bind it to a specific address if the reverse proxy runs on the same host.
  • Resolved versions in uv.lock: Django 5.2.18, gunicorn 26.2.0, whitenoise 6.12.0.

Plan Change Log

Review Triage Log

Pass 1 (quick lens). Counts: high 1, medium 3, low 3, false 1, maybe-false 0.

# Finding Verdict Route Evidence
1 docker/entrypoint.sh not executable high patch Added as mode 100644 and COPY docker ./docker has no chmod, so ENTRYPOINT on the Linux host fails with permission denied; "Valid start" would not hold.
2 Security headers missing on responses from outer middleware medium patch SecurityHeadersMiddleware is innermost; a bad Host 400 from CommonMiddleware, whitenoise static files and SecurityMiddleware redirects never pass through it. The AC says any request.
3 Query string and request repr in Django's own error log lines, and a second line per 4xx high patch Reproduced: a 404 for /missing?token=supersecret writes a django.request line containing <WSGIRequest: GET '/missing?token=supersecret'>. Breaks "query strings not logged" and "one line per request".
4 TRUSTED_PROXIES parsed but unused; SECURE_PROXY_SSL_HEADER trusts any client medium patch Nothing reads it until the sign-in entry (client IP rule); docs say it restricts proxies. The fix here is wording; wiring is entry 5. The proxy-header trust has no demonstrated harm yet (low, rejected).
5 /tmp tmpfs is a third writable location low rejected Ephemeral and needed for gunicorn workers; the rule's intent (persistent writable paths) holds; changing it adds complexity.
6 Gunicorn error log is plain text on stderr low rejected Server start and error lines only; app logs are JSON. A JSON gunicorn log config is more than a direct correction.
7 "No ping-mee name" not satisfiable because of the plan file false rejected The criterion covers code and image; .dockerignore excludes _bmad-output and a search outside protected folders found nothing.
8 503 criterion tested with a mock, not an unreadable file low rejected Disclosed in Implementation Notes; the view returns 503 on any exception from cursor(), so a real failure cannot return 200. A real-file test needs database wrapper plumbing.
9 Gunicorn startup error: control socket cannot be created (found in the live container test) medium patch [ERROR] Control server error: [Errno 30] Read-only file system: '/home/app' at every start; fixed with --no-control-socket, error gone.
10 Host __pycache__ copied into the image, embedding the Windows source path (found in the live container test) medium patch grep in the image found the project name in .pyc files; .dockerignore matched only the top-level folder; fixed with **/ patterns, rescan is clean.

Design Notes

CSP is a constant in middleware.py (default-src 'self', script-src 'self', frame-ancestors 'none') so story 6 can adjust the style rules for the Fluent components without weakening script rules. Dependencies were checked on the web: Django 5.2 supports Python 3.10 to 3.14 and is LTS to April 2028; gunicorn 26.x and whitenoise 6.12 support it. Python 3.13 is pinned because this machine's default 3.15 is not supported by Django 5.2.

Verification

Commands:

  • uv run pytest -- expected: all tests pass
  • uv run ruff check . -- expected: no findings
  • uv run python manage.py check --deploy with a valid test environment -- expected: no errors
  • uv run python manage.py check with missing SECRET_KEY -- expected: exit non-zero listing the variable

Manual checks (if no CLI):

  • Docker is not installed on this machine. On the Linux host: docker compose up --build, then curl http://localhost:8000/healthz returns {"status":"ok"}, and starting with an invalid variable shows the error and exits.