feat: add Django project scaffold, configuration and container
Environment-variable configuration that fails fast, database-only health check, JSON request logs with a request id, security headers with a strict CSP, and a multi-stage non-root Docker image with compose file. Ticket 1.1. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
24de5878ed
commit
f8b0584687
+117
@@ -0,0 +1,117 @@
|
||||
---
|
||||
title: 'Project scaffold, configuration and container'
|
||||
type: 'feature'
|
||||
ticket: '1'
|
||||
created: '2026-10-15'
|
||||
status: 'built'
|
||||
baseline_revision: '24de5878ed7f8d297e10aa3cf1e4f772fa5b0fa0'
|
||||
route: 'full'
|
||||
route_source: 'auto'
|
||||
risk: 'medium'
|
||||
review: 'quick'
|
||||
review_source: 'pinned'
|
||||
lenses_ran: ['quick']
|
||||
review_loop_iteration: 0
|
||||
context:
|
||||
- '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md'
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
|
||||
## 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 |
|
||||
|
||||
</frozen-after-approval>
|
||||
|
||||
## 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.
|
||||
- Not verified: the Docker build, the read-only root file system with gunicorn's worker temp dir, the `uv` image tag and the container health check. They need `docker compose up --build` on the Linux host.
|
||||
- `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. |
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user