diff --git a/.env.example b/.env.example index 0dc9d14..cb4f6e9 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ POSTGRES_PASSWORD=change-me-for-local-development # Future Django application configuration. Supply real production values through Cloud Run # secrets and configuration, never through a tracked file. +APP_PORT=8000 DJANGO_SECRET_KEY=replace-with-a-local-development-only-value DJANGO_DEBUG=true ALLOWED_HOSTS=localhost,127.0.0.1 diff --git a/Dockerfile b/Dockerfile index 8b7daa2..48a627b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ FROM python:3.14.7-slim-bookworm AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/src \ PATH=/usr/local/bin:$PATH WORKDIR /app @@ -24,5 +25,7 @@ USER app EXPOSE 8000 -# No CMD or HEALTHCHECK is intentionally defined. Those require the future Django -# project's application-owned ASGI/WSGI entrypoint and health route. +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import os; from urllib.request import urlopen; urlopen(f'http://127.0.0.1:{os.getenv(\"PORT\", \"8000\")}/healthz', timeout=2).read()" + +CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:${PORT:-8000} skillstreak.wsgi:application"] diff --git a/README.md b/README.md index b5f459a..4569a27 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ SkillStreak is planned as a public, multi-account Django web application with PostgreSQL. This repository currently contains its development, quality, Docker, -and delivery foundation only; no Django project, application routes, database -schema, product code, or production deployment exists yet. +delivery foundation, plus a minimal Django source framework and liveness route. +No database schema, product apps, product routes, or production deployment +exists yet. ## Repository map @@ -11,11 +12,12 @@ schema, product code, or production deployment exists yet. |---|---|---| | `infrastructure_plan.md` | Approved infrastructure decisions | Current source of truth | | `requirements.in`, `requirements.txt`, `pyproject.toml` | Python dependencies, resolved lockfile, and tool configuration | Ready | -| `compose.yml`, `Dockerfile`, `.dockerignore` | Local PostgreSQL and a hardened future application-image base | Ready; no Django entrypoint yet | +| `compose.yml`, `Dockerfile`, `.dockerignore` | Local Django/PostgreSQL stack and hardened runtime image | Ready | | `scripts/` | Infrastructure validation and disposable Docker smoke tests | Ready | -| `tests/infrastructure/` | Infrastructure-harness tests | Ready | +| `tests/application/`, `tests/infrastructure/` | Django framework and infrastructure tests | Ready | +| `docs/specs/`, `docs/plans/` | Reviewed specifications and implementation plans | Bootstrap work documented | | `.github/workflows/` | Pull-request checks and guarded Cloud Run release workflow | Ready | -| `src/`, Django project, API, frontend | Future application implementation | Not created yet | +| `src/`, `manage.py` | Django project framework and liveness endpoint | Ready; product apps not created | | `.agents/skills/` | Repository-specific agent guidance | Available | ## Getting Started @@ -35,7 +37,30 @@ schema, product code, or production deployment exists yet. docker run --rm --volume "$PWD:/workspace" --workdir /workspace python:3.14.7-slim-bookworm \ sh -c 'python -m pip install "pip-tools>=7.5,<8" && python -m piptools compile --strip-extras --output-file requirements.txt requirements.in' ``` -4. Validate the static infrastructure configuration: +4. Start the local Django and PostgreSQL services: + + ```sh + docker compose up --build + ``` + + The application listens on `http://localhost:8000` by default. Set + `APP_PORT` in `.env` to choose another host port. Verify its liveness endpoint + from another terminal: + + ```sh + curl --fail http://localhost:8000/healthz + ``` + + Run Django commands inside the Docker-only development service: + + ```sh + docker compose exec web python manage.py check + docker compose exec web pytest + docker compose exec web ruff format --check . + docker compose exec web ruff check . + ``` + +5. Validate the static infrastructure configuration: ```sh ./scripts/check-infrastructure.sh @@ -43,34 +68,40 @@ schema, product code, or production deployment exists yet. ./scripts/smoke-postgres.sh ``` - The PostgreSQL smoke test removes its disposable Compose volume when it finishes. -5. To keep the local PostgreSQL service running for future Django work, run - `docker compose up -d db`; stop it with `docker compose down`. Remove local - database data deliberately with `docker compose down --volumes`. + The PostgreSQL smoke test stops the Compose stack and removes its named + PostgreSQL volume when it finishes. Run it only when local database data is + disposable. +6. Stop the local stack with `docker compose down`. Remove local database data + deliberately with `docker compose down --volumes`. -The Docker image intentionally has no application command or health endpoint -until the application phase creates a Django ASGI/WSGI entrypoint and `/healthz`. -Similarly, full Django checks, coverage enforcement, migrations, and Playwright -workflows activate after `manage.py` and application tests exist. +The production image runs Gunicorn and reports a liveness-only `/healthz` +endpoint; it does not query PostgreSQL. Cloud Run must supply +`DJANGO_SETTINGS_MODULE=skillstreak.settings.production`, `DATABASE_URL`, +`DJANGO_SECRET_KEY`, and `ALLOWED_HOSTS` through managed configuration and +secrets. Full Django checks and coverage enforcement now run in pull requests; +migrations and browser workflows remain future product work. ## Quality and CI `requirements.txt` is the committed pip-tools lockfile. Pull requests verify the lockfile, Ruff formatting and linting, the infrastructure harness, Gitleaks, -CodeQL, and an image build with a critical-vulnerability scan. Django checks, -coverage (80% branch and line threshold), and browser tests are intentionally -conditional on the future Django application bootstrap. +CodeQL, and an image build with a critical-vulnerability scan. Django system +checks and the 80% branch-and-line coverage gate now run in pull requests. +Browser tests remain future product work. Release tags (`v*`) target Google Cloud Run through Artifact Registry. Before a release can run, create the Google Cloud project resources and GitHub production environment listed in `infrastructure_plan.md`: workload identity provider, service account, project/region/repository/service/migration-job variables, and -Cloud Run-managed application secrets. The release workflow fails before cloud -authentication while `manage.py` is absent. +Cloud Run-managed application secrets. The release workflow now runs Django +checks and tests before cloud authentication; it cannot deploy until the named +Google Cloud and GitHub production configuration exists. ## Troubleshooting - **Docker connection refused or permission denied:** start Docker Desktop, then rerun the command. -- **Port conflicts:** this foundation does not publish PostgreSQL to the host. A future application port will be configurable when its entrypoint exists. +- **Port conflicts:** set `APP_PORT` in `.env` to choose a different web port. + PostgreSQL is not published to the host. - **Lockfile differs in CI:** regenerate it with the exact container command above and commit both dependency files. -- **Cloud Run release is blocked:** create the Django project first, then configure Google Cloud Workload Identity Federation and the named GitHub production variables/secrets. +- **Cloud Run release is blocked:** configure Google Cloud Workload Identity + Federation and the named GitHub production variables/secrets. diff --git a/compose.yml b/compose.yml index 2295792..1205b44 100644 --- a/compose.yml +++ b/compose.yml @@ -1,6 +1,24 @@ name: skillstreak services: + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + depends_on: + db: + condition: service_healthy + environment: + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1} + DATABASE_URL: ${DATABASE_URL:-postgresql://skillstreak:change-me-for-local-development@db:5432/skillstreak} + DJANGO_DEBUG: ${DJANGO_DEBUG:-true} + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-replace-with-a-local-development-only-value} + DJANGO_SETTINGS_MODULE: skillstreak.settings.local + networks: [application] + ports: + - "${APP_PORT:-8000}:8000" + volumes: + - .:/app + db: image: postgres:17.7-bookworm environment: @@ -18,8 +36,7 @@ services: - postgres-data:/var/lib/postgresql/data networks: - application: - internal: true + application: {} volumes: postgres-data: diff --git a/docs/plans/django-source-framework-implementation.md b/docs/plans/django-source-framework-implementation.md new file mode 100644 index 0000000..4d77dec --- /dev/null +++ b/docs/plans/django-source-framework-implementation.md @@ -0,0 +1,246 @@ +# Implementation Plan: SkillStreak Django Source Framework + +## Status + +Implemented — derived from the accepted +[`skillstreak-django-source-framework` specification](../specs/skillstreak-django-source-framework.md). + +## Completion Record + +The framework was delivered in the following pushed commits: + +- `8f6dd10` — Django package, local settings, and liveness endpoint. +- `ad013e8` — secure production settings and their tests. +- `4db8b34` — Gunicorn runtime command, health check, and lockfile. +- `8d91b2f` — Docker Compose web workflow, documentation, and external-port + regression coverage. + +Final verification passed with six tests and 96.91% coverage, Ruff formatting +and lint checks, Django system checks, a host request to `/healthz`, and all +three required infrastructure smoke commands. + +## Overview + +Bootstrap the Docker-first Django project under `src/skillstreak`, with local +and production settings, a liveness-only `/healthz` endpoint, and a Cloud +Run-suitable Gunicorn image command. No product app, data model, migration, +authentication flow, or readiness endpoint is included. + +## Architecture Decisions + +- Use Django's conventional project package contents (`settings`, `urls`, + `asgi`, and `wsgi`) under `src/skillstreak`; a project package may contain + resources that are not tied to an application. Source: + . +- Keep `base`, `local`, and `production` settings separate. Production settings + will be selected explicitly through `DJANGO_SETTINGS_MODULE` and verified + with `check --deploy`, which Django recommends against the production + settings file. Source: + . +- Make `/healthz` liveness-only and use it for the container health check. It + must not query PostgreSQL. +- Add Gunicorn as the production WSGI server. Django's development `runserver` + is not intended for production. Source: + . +- Make Compose wait for the existing PostgreSQL health check before starting + the development web service, using `depends_on.condition: service_healthy`. + Source: . + +## Dependency Graph + +```text +pytest configuration + failing health test + | + v +project package + local settings + | + v +URL route + health view + ASGI/WSGI + | + +-------------------+ + v v +production settings Docker/Gunicorn image + | | + +---------+---------+ + v + Compose web service +``` + +## Task List + +### Task 1: Establish the failing health-check contract + +**Description:** Configure pytest-django to load local settings from `src` and +add a test asserting that `/healthz` returns HTTP 200 with a minimal response. +Run it before project code exists to demonstrate the red state. + +**Acceptance criteria:** + +- [x] Pytest discovers Django tests from `src` using local settings. +- [x] The health-check test fails because the project implementation is absent. + +**Verification:** + +- [x] Build the current runtime image with + `docker build --target runtime --tag skillstreak-framework-test .`, then run + `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test pytest tests/application/test_health.py`. + It fails for the expected missing-project behavior. Compose cannot run this + test yet because the `web` service is created in Task 6. + +**Dependencies:** None. + +**Files likely touched:** `pyproject.toml`, `tests/application/test_health.py`. + +### Task 2: Add the minimal project and local-settings foundation + +**Description:** Create `manage.py`, the `skillstreak` package, and `base` / +`local` settings. Local settings use environment-based PostgreSQL configuration +and development-safe defaults; no migrations or domain apps are created. + +**Acceptance criteria:** + +- [x] `manage.py check` loads `skillstreak.settings.local`. +- [x] The local database configuration targets the Compose `db` host. +- [x] Configuration does not contain a real secret. + +**Verification:** + +- [x] `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test python manage.py diffsettings` + loads the local settings successfully. Full `manage.py check` follows when + the URL configuration exists in Task 3. +- [x] Ruff formatting and linting pass for the new files. + +**Dependencies:** Task 1. + +**Files likely touched:** `manage.py`, `src/skillstreak/__init__.py`, +`src/skillstreak/settings/__init__.py`, `src/skillstreak/settings/base.py`, +`src/skillstreak/settings/local.py`. + +### Task 3: Implement the liveness route and application entrypoints + +**Description:** Add the URL configuration, health view, ASGI, and WSGI +entrypoints. Make the Task 1 test green with the smallest HTTP response that +does not disclose configuration or query the database. + +**Acceptance criteria:** + +- [x] `GET /healthz` returns HTTP 200. +- [x] The response does not require a database connection. +- [x] Both ASGI and WSGI point to local settings by default. + +**Verification:** + +- [x] `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test pytest tests/application/test_health.py` + passes. +- [x] `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test ruff format --check .` + and `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test ruff check .` + pass. + +**Dependencies:** Task 2. + +**Files likely touched:** `src/skillstreak/health.py`, +`src/skillstreak/urls.py`, `src/skillstreak/asgi.py`, +`src/skillstreak/wsgi.py`. + +### Checkpoint: Application foundation + +- [x] Health test passes after first failing. +- [x] `python manage.py check` passes in the web container. +- [x] Working changes are committed as an atomic application-foundation slice. + +### Task 4: Add and test production settings + +**Description:** Add `production.py` and focused tests for required +configuration. Require a production secret key, allowed hosts, and database +URL; configure HTTPS, proxy, secure-cookie, and HSTS protections appropriate +to a TLS-terminating Cloud Run deployment. + +**Acceptance criteria:** + +- [x] Production settings fail clearly when required values are absent. +- [x] With required values supplied, production settings load and + `DEBUG=False`. +- [x] `check --deploy` runs against production settings in a controlled test + environment. + +**Verification:** + +- [x] `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test pytest tests/application/test_production_settings.py` + passes. +- [x] A `docker run` command with + `DJANGO_SETTINGS_MODULE=skillstreak.settings.production` executes + `python manage.py check --deploy` using non-secret test values. + +**Dependencies:** Task 2. + +**Files likely touched:** `src/skillstreak/settings/production.py`, +`tests/application/test_production_settings.py`. + +### Task 5: Make the image production-runnable + +**Description:** Add the approved Gunicorn dependency, regenerate the +pip-tools lockfile, and give the runtime image a non-root Gunicorn command and +liveness health check. + +**Acceptance criteria:** + +- [x] `requirements.in` and `requirements.txt` include Gunicorn. +- [x] The image starts Gunicorn on the Cloud Run `PORT` (falling back to 8000). +- [x] Docker's health check calls `/healthz` without requiring `curl`. + +**Verification:** + +- [x] Lockfile regeneration matches CI's pip-tools validation. +- [x] `./scripts/smoke-image.sh` builds the image and verifies its liveness + endpoint. + +**Dependencies:** Tasks 3 and 4. + +**Files likely touched:** `requirements.in`, `requirements.txt`, `Dockerfile`, +`scripts/smoke-image.sh`. + +### Task 6: Add the Docker Compose web workflow and update guidance + +**Description:** Add the `web` Compose service with a source bind mount, +development command, configurable application port, environment forwarding, +and a database-health dependency. Update templates and onboarding commands. + +**Acceptance criteria:** + +- [x] `docker compose up --build` starts database then web service. +- [x] `curl --fail http://localhost:${APP_PORT:-8000}/healthz` succeeds. +- [x] The README and environment template describe the Docker-only workflow. + +**Verification:** + +- [x] `docker compose config --quiet` passes. +- [x] `./scripts/check-infrastructure.sh`, `./scripts/smoke-image.sh`, and + `./scripts/smoke-postgres.sh` pass. + +**Dependencies:** Tasks 3 and 5. + +**Files likely touched:** `compose.yml`, `.env.example`, `README.md`. + +### Checkpoint: Complete framework + +- [x] Django tests and the 80% coverage gate pass. +- [x] Ruff formatting and lint checks pass. +- [x] The web container serves `/healthz` and the image health check passes. +- [x] Infrastructure smoke checks pass. +- [x] Each completed slice is committed separately. + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Docker runtime cannot import code from `src` | High | Add the import path explicitly in `manage.py` and test inside the image. | +| Database startup race | Medium | Gate `web` on the existing `db` health check. | +| Deployment settings pass locally but fail on Cloud Run | High | Require settings inputs, run `check --deploy`, and bind Gunicorn to `PORT`. | +| Health endpoint leaks deployment details | Medium | Use a constant minimal response and no database query. | + +## Scope Exclusions + +- No Django apps, models, migrations, admin customization, templates, product + routes, authentication flows, or browser tests. +- No Google Cloud provisioning, image publication, deployment, or remote + migrations. diff --git a/docs/specs/skillstreak-django-source-framework.md b/docs/specs/skillstreak-django-source-framework.md new file mode 100644 index 0000000..a5ea723 --- /dev/null +++ b/docs/specs/skillstreak-django-source-framework.md @@ -0,0 +1,152 @@ +# Spec: SkillStreak Django Source Framework + +## Status + +Accepted — settings structure, Docker-first development, project name, minimal +source layout, and liveness-only health-check scope are agreed. + +## Objective + +Create the minimal, Docker-first Django source framework for SkillStreak. It +must establish the project package, safely separate local and production +settings, connect to the existing PostgreSQL Compose service, and expose a +dependency-free `/healthz` endpoint. + +It deliberately excludes product apps, models, migrations, authentication +flows, pages, APIs, and business logic. + +## Context and Decisions + +The repository already provides an approved infrastructure foundation: Python +3.14, Django 5.2 LTS, PostgreSQL, Docker Compose for local development, and a +future Google Cloud Run deployment. It contains no application source. + +The following decisions are accepted for this bootstrap: + +- The Django project package is named `skillstreak`. +- Local development is Docker-only; host-Python commands are not a supported + developer workflow. +- Settings use a `base` / `local` / `production` module split. +- The source layout remains minimal until a product feature defines its first + app boundary. +- A `/healthz` endpoint is included as application plumbing rather than a + product feature. + +Separate settings modules keep Cloud Run security requirements distinct from +local convenience defaults. A single conditional settings module would start +smaller but becomes harder to audit as deployment settings expand. + +## Tech Stack + +- Python 3.14 and Django 5.2, using the existing locked dependencies. +- PostgreSQL through the existing Compose `db` service. +- Docker Compose as the only supported local developer workflow. +- Django project package: `skillstreak`. + +## Commands + +After implementation, the supported local application commands will be: + +```sh +docker compose up --build +docker compose exec web python manage.py check +docker compose exec web pytest +docker compose exec web ruff format --check . +docker compose exec web ruff check . +curl --fail http://localhost:8000/healthz +``` + +The existing infrastructure validation remains required: + +```sh +./scripts/check-infrastructure.sh +./scripts/smoke-image.sh +./scripts/smoke-postgres.sh +``` + +## Project Structure + +```text +manage.py +src/ + skillstreak/ + __init__.py + asgi.py + wsgi.py + urls.py + health.py + settings/ + __init__.py + base.py + local.py + production.py +``` + +`health.py` remains project plumbing, not a product app. It must not establish +an app-domain convention before a product specification exists. + +## Settings Design + +- `base.py`: shared Django configuration, installed Django components, + middleware, templates, and static-file configuration. +- `local.py`: `DEBUG=True`, values supplied through the tracked-safe + `.env.example` / untracked `.env` convention, and the local Compose + PostgreSQL connection. +- `production.py`: `DEBUG=False`, production-only HTTPS and secure-cookie + settings, plus required secret-key, allowed-host, and database configuration. +- `manage.py`, ASGI, and WSGI default to local settings. Cloud Run explicitly + selects production settings. + +## Code Style + +Framework code keeps configuration explicit and puts environment access at the +settings boundary rather than scattering it through views: + +```python +# settings/production.py +from .base import * + +DEBUG = False +SECRET_KEY = required_environment_value("DJANGO_SECRET_KEY") +ALLOWED_HOSTS = comma_separated_environment_value("ALLOWED_HOSTS") +``` + +Use Python and Django naming conventions, Ruff's existing 100-character line +limit, and direct type annotations for new non-trivial functions. + +## Testing Strategy + +- Add focused Django tests for `/healthz` and settings selection. +- Preserve the infrastructure harness unchanged except where it must recognize + the new application framework. +- Run Django checks and the existing pytest coverage gate once `manage.py` + exists. +- Defer browser testing until the first user-facing feature. + +## Boundaries + +- **Always:** use Docker for local commands; load secrets and deployment + configuration from environment variables; keep `/healthz` free of sensitive + information. +- **Ask first:** add dependencies; add migrations or a database schema; change + CI or infrastructure-plan decisions; create a product app. +- **Never:** commit `.env` values; add domain behavior; weaken production + security settings; create a separate frontend or API service. + +## Success Criteria + +- `docker compose up --build` starts Django and PostgreSQL. +- `/healthz` returns HTTP 200 from the running application. +- Django starts with local settings and connects to Compose PostgreSQL. +- Production settings reject missing required production configuration and + enable secure deployment defaults. +- The image has a Django command and health check suitable for the planned + Cloud Run delivery. +- Existing infrastructure checks and the new Django test suite pass. + +## Health-Check Scope + +`/healthz` is liveness-only: it returns HTTP 200 without querying PostgreSQL. +This avoids transient database availability producing container-health churn. +A database-readiness endpoint may be added later when deployment monitoring +needs it. diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..0e9031b --- /dev/null +++ b/manage.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys +from pathlib import Path + + +def main() -> None: + """Run Django administrative commands with the source directory importable.""" + sys.path.insert(0, str(Path(__file__).resolve().parent / "src")) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "skillstreak.settings.local") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 94774b4..502ea72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ select = ["E", "F", "I", "UP", "B"] [tool.pytest.ini_options] addopts = "-ra --strict-config --strict-markers" testpaths = ["tests"] +DJANGO_SETTINGS_MODULE = "skillstreak.settings.local" +pythonpath = ["src"] [tool.coverage.run] branch = true diff --git a/requirements.in b/requirements.in index 80eae50..1853be4 100644 --- a/requirements.in +++ b/requirements.in @@ -1,5 +1,6 @@ # Application and development tooling. `requirements.txt` is the resolved lockfile. Django>=5.2.17,<5.3 +gunicorn>=26.2,<27 psycopg[binary]>=3.2,<3.4 pip-tools>=7.5,<8 pytest>=9,<10 diff --git a/requirements.txt b/requirements.txt index f7a83fd..4206a47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,8 @@ django==5.2.17 # via -r requirements.in greenlet==3.5.6 # via playwright +gunicorn==26.2.0 + # via -r requirements.in iniconfig==2.3.0 # via pytest packaging==26.3 diff --git a/scripts/smoke-image.sh b/scripts/smoke-image.sh index a595ee0..835c696 100755 --- a/scripts/smoke-image.sh +++ b/scripts/smoke-image.sh @@ -2,5 +2,25 @@ set -eu image=skillstreak-infrastructure-smoke +container_id= + +cleanup() { + if [ -n "$container_id" ]; then + docker rm --force "$container_id" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT INT TERM + docker build --target runtime --tag "$image" . test "$(docker image inspect --format '{{.Config.User}}' "$image")" = "app" +container_id=$(docker run --detach "$image") + +attempt=0 +until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container_id")" = "healthy" ]; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 15 ]; then + echo "Application image did not become healthy within 30 seconds." >&2 + exit 1 + fi + sleep 2 +done diff --git a/src/skillstreak/__init__.py b/src/skillstreak/__init__.py new file mode 100644 index 0000000..64f2deb --- /dev/null +++ b/src/skillstreak/__init__.py @@ -0,0 +1 @@ +"""SkillStreak Django project package.""" diff --git a/src/skillstreak/asgi.py b/src/skillstreak/asgi.py new file mode 100644 index 0000000..6bb107e --- /dev/null +++ b/src/skillstreak/asgi.py @@ -0,0 +1,9 @@ +"""ASGI configuration for SkillStreak.""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "skillstreak.settings.local") + +application = get_asgi_application() diff --git a/src/skillstreak/health.py b/src/skillstreak/health.py new file mode 100644 index 0000000..9c8f086 --- /dev/null +++ b/src/skillstreak/health.py @@ -0,0 +1,9 @@ +"""Liveness endpoint for container orchestration.""" + +from django.http import JsonResponse +from django.http.request import HttpRequest + + +def health_check(_: HttpRequest) -> JsonResponse: + """Return a constant liveness response without querying dependencies.""" + return JsonResponse({"status": "ok"}) diff --git a/src/skillstreak/settings/__init__.py b/src/skillstreak/settings/__init__.py new file mode 100644 index 0000000..bf565ed --- /dev/null +++ b/src/skillstreak/settings/__init__.py @@ -0,0 +1 @@ +"""Settings modules for local and production environments.""" diff --git a/src/skillstreak/settings/base.py b/src/skillstreak/settings/base.py new file mode 100644 index 0000000..089fcff --- /dev/null +++ b/src/skillstreak/settings/base.py @@ -0,0 +1,83 @@ +"""Shared Django settings for every SkillStreak environment.""" + +import os +from pathlib import Path +from urllib.parse import unquote, urlparse + +from django.core.exceptions import ImproperlyConfigured + +BASE_DIR = Path(__file__).resolve().parents[3] + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "skillstreak.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "skillstreak.wsgi.application" +ASGI_APPLICATION = "skillstreak.asgi.application" + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +def database_configuration(database_url: str) -> dict[str, dict[str, str | int]]: + """Convert a PostgreSQL URL into Django's database configuration.""" + parsed_url = urlparse(database_url) + if parsed_url.scheme not in {"postgres", "postgresql"} or not parsed_url.path: + message = "DATABASE_URL must be a PostgreSQL connection URL." + raise ImproperlyConfigured(message) + + return { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": unquote(parsed_url.path.lstrip("/")), + "USER": unquote(parsed_url.username or ""), + "PASSWORD": unquote(parsed_url.password or ""), + "HOST": parsed_url.hostname or "", + "PORT": parsed_url.port or "", + }, + } + + +def required_environment_value(name: str) -> str: + """Return a required deployment setting without exposing its value.""" + value = os.getenv(name) + if value: + return value + + raise ImproperlyConfigured(f"{name} must be set.") diff --git a/src/skillstreak/settings/local.py b/src/skillstreak/settings/local.py new file mode 100644 index 0000000..f3c080d --- /dev/null +++ b/src/skillstreak/settings/local.py @@ -0,0 +1,21 @@ +"""Development settings for the Docker Compose environment.""" + +import os + +from .base import * # noqa: F403 +from .base import database_configuration + +DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "django-insecure-local-development-only") +ALLOWED_HOSTS = [ + host.strip() + for host in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + if host.strip() +] + +DATABASES = database_configuration( + os.getenv( + "DATABASE_URL", + "postgresql://skillstreak:change-me-for-local-development@db:5432/skillstreak", + ) +) diff --git a/src/skillstreak/settings/production.py b/src/skillstreak/settings/production.py new file mode 100644 index 0000000..7c6c907 --- /dev/null +++ b/src/skillstreak/settings/production.py @@ -0,0 +1,20 @@ +"""Secure deployment settings for Cloud Run.""" + +from .base import * # noqa: F403 +from .base import database_configuration, required_environment_value + +DEBUG = False +SECRET_KEY = required_environment_value("DJANGO_SECRET_KEY") +ALLOWED_HOSTS = [ + host.strip() for host in required_environment_value("ALLOWED_HOSTS").split(",") if host.strip() +] +DATABASES = database_configuration(required_environment_value("DATABASE_URL")) + +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_HSTS_SECONDS = 31_536_000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = "DENY" diff --git a/src/skillstreak/urls.py b/src/skillstreak/urls.py new file mode 100644 index 0000000..04fb22b --- /dev/null +++ b/src/skillstreak/urls.py @@ -0,0 +1,9 @@ +"""Top-level URL configuration for project-level endpoints.""" + +from django.urls import path + +from .health import health_check + +urlpatterns = [ + path("healthz", health_check, name="health-check"), +] diff --git a/src/skillstreak/wsgi.py b/src/skillstreak/wsgi.py new file mode 100644 index 0000000..6da0d5e --- /dev/null +++ b/src/skillstreak/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI configuration for SkillStreak.""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "skillstreak.settings.local") + +application = get_wsgi_application() diff --git a/tests/application/test_health.py b/tests/application/test_health.py new file mode 100644 index 0000000..a30a655 --- /dev/null +++ b/tests/application/test_health.py @@ -0,0 +1,5 @@ +def test_health_check_returns_ok(client) -> None: + response = client.get("/healthz") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/tests/application/test_production_settings.py b/tests/application/test_production_settings.py new file mode 100644 index 0000000..5d21919 --- /dev/null +++ b/tests/application/test_production_settings.py @@ -0,0 +1,24 @@ +import importlib + +import pytest +from django.core.exceptions import ImproperlyConfigured + + +def test_production_settings_require_a_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DJANGO_SECRET_KEY", raising=False) + + with pytest.raises(ImproperlyConfigured, match="DJANGO_SECRET_KEY"): + importlib.import_module("skillstreak.settings.production") + + +def test_production_settings_load_secure_values(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DJANGO_SECRET_KEY", "test-only-secret-key") + monkeypatch.setenv("ALLOWED_HOSTS", "example.com,api.example.com") + monkeypatch.setenv("DATABASE_URL", "postgresql://user:password@db:5432/skillstreak") + + production = importlib.import_module("skillstreak.settings.production") + + assert production.DEBUG is False + assert production.ALLOWED_HOSTS == ["example.com", "api.example.com"] + assert production.SESSION_COOKIE_SECURE is True + assert production.DATABASES["default"]["HOST"] == "db" diff --git a/tests/infrastructure/test_repository_configuration.py b/tests/infrastructure/test_repository_configuration.py index fe45dc2..6e6a560 100644 --- a/tests/infrastructure/test_repository_configuration.py +++ b/tests/infrastructure/test_repository_configuration.py @@ -25,3 +25,11 @@ def test_required_infrastructure_files_are_present() -> None: ) assert all((ROOT / path).is_file() for path in required_files) + + +def test_compose_exposes_the_web_service_without_exposing_postgresql() -> None: + configuration = (ROOT / "compose.yml").read_text() + + assert ' - "${APP_PORT:-8000}:8000"' in configuration + assert "internal: true" not in configuration + assert ' - "5432:5432"' not in configuration