Merge branch 'chore/phase-0-foundation' into dev: Phase 0 complete
This commit is contained in:
48
.env.example
Normal file
48
.env.example
Normal file
@@ -0,0 +1,48 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — environment contract
|
||||
#
|
||||
# Copy this file to `.env` and fill in the real values locally. The .env file
|
||||
# is gitignored. This file (.env.example) is the public contract and MUST
|
||||
# contain only placeholder / safe-default values — never real secrets.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# --- Runtime mode -----------------------------------------------------------
|
||||
# development | production
|
||||
APP_ENV=development
|
||||
|
||||
# --- Signing / sessions -----------------------------------------------------
|
||||
# itsdangerous signer for cookies / CSRF tokens.
|
||||
# Generate locally with: python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
# The string below is a DEV-ONLY sentinel; the app refuses to start in
|
||||
# production if SECRET_KEY still matches this value.
|
||||
SECRET_KEY=dev-insecure-change-me
|
||||
|
||||
# --- Database ---------------------------------------------------------------
|
||||
DATABASE_URL=sqlite:///data/app.db
|
||||
|
||||
# --- Email (Resend) ---------------------------------------------------------
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM=no-reply@chickenbabies.example
|
||||
|
||||
# --- Admin allowlist / contact routing --------------------------------------
|
||||
# Comma-separated list of emails allowed to request admin magic links.
|
||||
ADMIN_EMAILS=
|
||||
# Inbox that the public contact form messages get routed to.
|
||||
ADMIN_CONTACT_EMAIL=
|
||||
|
||||
# --- hCaptcha ---------------------------------------------------------------
|
||||
HCAPTCHA_SITE_KEY=
|
||||
HCAPTCHA_SECRET=
|
||||
|
||||
# --- Reverse proxy / Uvicorn ------------------------------------------------
|
||||
# Caddy's LAN IP (comma-separated allowed). Only headers from these IPs are
|
||||
# trusted for X-Forwarded-For / X-Forwarded-Proto.
|
||||
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
|
||||
# --- Session / auth tuning --------------------------------------------------
|
||||
SESSION_MAX_DAYS=30
|
||||
MAGIC_LINK_TTL_MIN=15
|
||||
|
||||
# --- Build metadata ---------------------------------------------------------
|
||||
# Injected at Docker build time. Surfaced by /healthz. Optional in dev.
|
||||
GIT_COMMIT_SHA=unknown
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -25,8 +25,11 @@ env/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Runtime data (DB + media uploads live here in prod)
|
||||
data/
|
||||
# Runtime data (DB + media uploads live here in prod).
|
||||
# Ignore everything inside data/ except the .gitkeep marker; negating a
|
||||
# path whose *directory* is ignored does not work, so we ignore the
|
||||
# contents with a trailing glob instead.
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
# Editors / IDE
|
||||
|
||||
90
Dockerfile
Normal file
90
Dockerfile
Normal file
@@ -0,0 +1,90 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — container image
|
||||
#
|
||||
# Multi-stage build:
|
||||
# builder - install build deps, create a venv, resolve Python requirements
|
||||
# runtime - slim image with only runtime libs + the copied venv + app code
|
||||
#
|
||||
# Phase 0 scope: runs as root and has no HEALTHCHECK directive. Phase 6
|
||||
# (per docs/ROADMAP.md) introduces a non-root user and a HEALTHCHECK.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ARG PYTHON_IMAGE=python:3.12-slim-bookworm
|
||||
|
||||
# ==== Stage 1: builder =====================================================
|
||||
FROM ${PYTHON_IMAGE} AS builder
|
||||
|
||||
# Avoid interactive apt prompts and keep pip quiet + deterministic.
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Build dependencies: `build-essential` covers any wheel that needs to
|
||||
# compile from source; libmagic headers are required by python-magic.
|
||||
# Keep this list minimal to reduce attack surface of the builder stage.
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
build-essential \
|
||||
libmagic1 \
|
||||
libmagic-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Dedicated virtualenv at a stable path so the runtime stage can copy it
|
||||
# verbatim. This keeps the runtime image free of build tooling.
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install Python dependencies. Copying only requirements.txt first lets
|
||||
# Docker cache the dependency layer when application code changes.
|
||||
COPY requirements.txt /build/requirements.txt
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
# ==== Stage 2: runtime =====================================================
|
||||
FROM ${PYTHON_IMAGE} AS runtime
|
||||
|
||||
# Runtime-only libs: python-magic needs libmagic1 at import time. Build
|
||||
# tooling is intentionally NOT installed here.
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the pre-built virtualenv from the builder stage and put it first
|
||||
# on PATH so `python` and installed console scripts (uvicorn) resolve.
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Application source. Only the `app/` package is needed at runtime; tests
|
||||
# and docs stay out of the image.
|
||||
COPY app /app/app
|
||||
|
||||
# Git commit SHA wired in at build time. Declared as an ARG with a safe
|
||||
# default so local builds without --build-arg still succeed; surfaced to
|
||||
# runtime via an ENV so the Settings loader can pick it up.
|
||||
ARG GIT_COMMIT_SHA=unknown
|
||||
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run Uvicorn directly. --proxy-headers + --forwarded-allow-ips make
|
||||
# Starlette's ProxyHeadersMiddleware trust X-Forwarded-* only from the
|
||||
# listed peer IPs (Caddy on the host). No --reload: this is a prod-shape
|
||||
# image; local hot-reload is a dev concern and runs outside Docker.
|
||||
CMD ["uvicorn", "app.main:app", \
|
||||
"--host", "0.0.0.0", \
|
||||
"--port", "8000", \
|
||||
"--proxy-headers", \
|
||||
"--forwarded-allow-ips", "127.0.0.1"]
|
||||
14
app/__init__.py
Normal file
14
app/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Chicken Babies R Us FastAPI application package.
|
||||
|
||||
This module intentionally keeps only the package version constant. All
|
||||
runtime wiring lives in :mod:`app.main`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Semantic version of the application. Bump per release. Surfaced via
|
||||
# FastAPI's OpenAPI metadata and the /healthz endpoint so ops can verify
|
||||
# which build is live in a given environment.
|
||||
__version__: str = "0.1.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
149
app/config.py
Normal file
149
app/config.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Typed application configuration loader.
|
||||
|
||||
Loads environment variables (with `.env` support for local development)
|
||||
into a strongly-typed :class:`Settings` instance via `pydantic-settings`.
|
||||
|
||||
The full environment contract lives here so every consumer of configuration
|
||||
can import :func:`get_settings` and get a validated object. Values that
|
||||
aren't needed until later roadmap phases are declared as :data:`Optional`
|
||||
so the app boots cleanly in Phase 0 without mandating yet-unused secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
# Sentinel value for the SECRET_KEY in .env.example. The production
|
||||
# validator below refuses to start the app if this sentinel survives into
|
||||
# a production deployment. Keeping it as a named constant makes the
|
||||
# security control explicit and searchable.
|
||||
_DEV_SECRET_KEY_SENTINEL: str = "dev-insecure-change-me"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Strongly-typed runtime configuration.
|
||||
|
||||
Populated from process environment variables and (for local dev) an
|
||||
optional `.env` file. All fields correspond 1:1 with the env-var
|
||||
contract documented in ``docs/ROADMAP.md`` and ``.env.example``.
|
||||
"""
|
||||
|
||||
# Pydantic-settings config: read from .env when present, ignore unknown
|
||||
# keys (so adding new vars to .env ahead of code changes is non-fatal),
|
||||
# and treat env var names case-insensitively.
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# --- Runtime mode ------------------------------------------------------
|
||||
app_env: Literal["development", "production"] = Field(
|
||||
default="development",
|
||||
description="Runtime mode; gates debug behavior and logging renderer.",
|
||||
)
|
||||
|
||||
# --- Signing / sessions -----------------------------------------------
|
||||
# Used by itsdangerous for signed cookies and CSRF tokens. The default
|
||||
# is intentionally a well-known sentinel so local dev just works; the
|
||||
# model validator below blocks it from reaching production.
|
||||
secret_key: str = Field(
|
||||
default=_DEV_SECRET_KEY_SENTINEL,
|
||||
description="itsdangerous signer key; MUST be overridden in production.",
|
||||
)
|
||||
|
||||
# --- Database ----------------------------------------------------------
|
||||
database_url: str = Field(
|
||||
default="sqlite:///data/app.db",
|
||||
description="SQLAlchemy URL for the application database.",
|
||||
)
|
||||
|
||||
# --- Email (Resend) ----------------------------------------------------
|
||||
# Optional at Phase 0: contact form and magic-link delivery come online
|
||||
# in Phases 3 and 5 respectively.
|
||||
resend_api_key: Optional[str] = Field(default=None)
|
||||
resend_from: Optional[str] = Field(default=None)
|
||||
|
||||
# --- Admin allowlist / contact routing ---------------------------------
|
||||
# Comma-separated list; stored raw so we preserve exact input for audit,
|
||||
# and split on demand via the `admin_emails_list` property.
|
||||
admin_emails: str = Field(
|
||||
default="",
|
||||
description="Comma-separated allowlist of admin email addresses.",
|
||||
)
|
||||
admin_contact_email: Optional[str] = Field(default=None)
|
||||
|
||||
# --- hCaptcha ----------------------------------------------------------
|
||||
hcaptcha_site_key: Optional[str] = Field(default=None)
|
||||
hcaptcha_secret: Optional[str] = Field(default=None)
|
||||
|
||||
# --- Reverse proxy -----------------------------------------------------
|
||||
# Only requests whose peer IP is in this list will have their
|
||||
# X-Forwarded-* headers trusted by Uvicorn's proxy-headers support.
|
||||
forwarded_allow_ips: str = Field(default="127.0.0.1")
|
||||
|
||||
# --- Session / auth tuning --------------------------------------------
|
||||
session_max_days: int = Field(default=30, ge=1, le=365)
|
||||
magic_link_ttl_min: int = Field(default=15, ge=1, le=60)
|
||||
|
||||
# --- Build metadata ----------------------------------------------------
|
||||
# Injected at Docker build time via an ARG/ENV. Surfaced via /healthz so
|
||||
# operators can confirm which build is live.
|
||||
git_commit_sha: str = Field(default="unknown")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Derived helpers
|
||||
# ----------------------------------------------------------------------
|
||||
@property
|
||||
def admin_emails_list(self) -> list[str]:
|
||||
"""Return the admin allowlist as a clean list of lowercase strings.
|
||||
|
||||
Splits ``admin_emails`` on commas, strips whitespace, drops empty
|
||||
tokens, and lowercases — matching typical email canonicalization
|
||||
for allowlist comparisons. The raw string is preserved on the
|
||||
instance for audit / diagnostic purposes.
|
||||
"""
|
||||
return [
|
||||
part.strip().lower()
|
||||
for part in self.admin_emails.split(",")
|
||||
if part.strip()
|
||||
]
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Validators
|
||||
# ----------------------------------------------------------------------
|
||||
@model_validator(mode="after")
|
||||
def _refuse_dev_secret_in_production(self) -> "Settings":
|
||||
"""Fail fast if the dev sentinel SECRET_KEY reaches production.
|
||||
|
||||
Security control: prevents accidental deployment with the
|
||||
publicly-documented placeholder secret, which would make every
|
||||
signed cookie / CSRF token forgeable by anyone with the source.
|
||||
"""
|
||||
if (
|
||||
self.app_env == "production"
|
||||
and self.secret_key == _DEV_SECRET_KEY_SENTINEL
|
||||
):
|
||||
raise ValueError(
|
||||
"SECRET_KEY must be overridden in production; "
|
||||
"the dev sentinel value is not permitted."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return a process-wide cached :class:`Settings` instance.
|
||||
|
||||
Using :func:`functools.lru_cache` ensures the environment is parsed
|
||||
exactly once per process, which is both cheaper and — more importantly
|
||||
— guarantees every caller sees the same validated values. Tests that
|
||||
need a fresh parse can call ``get_settings.cache_clear()``.
|
||||
"""
|
||||
return Settings()
|
||||
72
app/logging_config.py
Normal file
72
app/logging_config.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Structlog initialization.
|
||||
|
||||
Single entry point :func:`configure_logging` sets up structlog with an
|
||||
``APP_ENV``-driven renderer: a pretty console renderer during development
|
||||
and JSON-lines output in production so logs plug straight into any log
|
||||
aggregator. Must be called exactly once at app startup, before any
|
||||
module obtains a logger via ``structlog.get_logger()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def configure_logging(app_env: str) -> None:
|
||||
"""Configure structlog + stdlib logging for the application.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
app_env:
|
||||
The resolved ``APP_ENV`` value. ``"development"`` selects a
|
||||
colorized console renderer; anything else (including
|
||||
``"production"``) selects the JSON renderer so logs are
|
||||
machine-parseable.
|
||||
|
||||
Notes
|
||||
-----
|
||||
This function is idempotent within a process: structlog accepts
|
||||
repeated calls to :func:`structlog.configure`. It deliberately keeps
|
||||
the setup small for Phase 0 — a proper ``ProcessorFormatter`` bridge
|
||||
between stdlib and structlog can be layered in later without touching
|
||||
call sites.
|
||||
"""
|
||||
# Route stdlib logging through a root handler writing to stdout. Our
|
||||
# own loggers go through structlog's pipeline; third-party libraries
|
||||
# that use the stdlib logger will at least surface at INFO.
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
stream=sys.stdout,
|
||||
format="%(message)s",
|
||||
)
|
||||
|
||||
# Processors shared across environments. Order matters: contextvars
|
||||
# first so bound context is present for every later step; exception
|
||||
# info extraction before rendering so tracebacks serialize cleanly.
|
||||
shared_processors: list[Any] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
|
||||
# Environment-specific final renderer. Dev gets human-friendly
|
||||
# colorized output; everywhere else emits JSON lines.
|
||||
if app_env == "development":
|
||||
final_renderer: Any = structlog.dev.ConsoleRenderer(colors=True)
|
||||
else:
|
||||
final_renderer = structlog.processors.JSONRenderer()
|
||||
|
||||
structlog.configure(
|
||||
processors=[*shared_processors, final_renderer],
|
||||
# PrintLoggerFactory writes to stdout without requiring a stdlib
|
||||
# logger bridge; sufficient for Phase 0.
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
66
app/main.py
Normal file
66
app/main.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""FastAPI application factory and module-level app instance.
|
||||
|
||||
The factory pattern (``create_app``) keeps test setup straightforward and
|
||||
lets us swap in alternate configurations without module-level side
|
||||
effects. ``app = create_app()`` at import time is what Uvicorn references
|
||||
via ``app.main:app``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app import __version__
|
||||
from app.config import get_settings
|
||||
from app.logging_config import configure_logging
|
||||
from app.routes.health import router as health_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Build and return the FastAPI application.
|
||||
|
||||
Responsibilities:
|
||||
- Load validated configuration via :func:`get_settings`.
|
||||
- Initialize structured logging *before* any logger is used.
|
||||
- Instantiate FastAPI with canonical title + version.
|
||||
- Register routers (Phase 0: only ``/healthz``).
|
||||
- Emit a single ``app_started`` structured log event.
|
||||
"""
|
||||
# Parse + validate configuration first so a bad environment fails fast
|
||||
# with a clear pydantic error before we touch logging / FastAPI.
|
||||
settings = get_settings()
|
||||
|
||||
# Configure structlog *before* acquiring any logger in the app so the
|
||||
# very first log line already flows through our processor chain.
|
||||
configure_logging(settings.app_env)
|
||||
|
||||
application = FastAPI(
|
||||
title="Chicken Babies R Us",
|
||||
version=__version__,
|
||||
# Docs stay on for Phase 0; a later phase can gate these behind
|
||||
# admin auth or disable them entirely in production.
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# Register routers. Kept explicit (no dynamic discovery) so the set of
|
||||
# mounted endpoints is trivially auditable.
|
||||
application.include_router(health_router)
|
||||
|
||||
# Single structured startup event. Do NOT include secret material.
|
||||
logger = structlog.get_logger(__name__)
|
||||
logger.info(
|
||||
"app_started",
|
||||
app_env=settings.app_env,
|
||||
version=__version__,
|
||||
commit_sha=settings.git_commit_sha,
|
||||
)
|
||||
|
||||
return application
|
||||
|
||||
|
||||
# Module-level ASGI handle. Uvicorn / gunicorn import this as
|
||||
# ``app.main:app``. Building it at import time is intentional: it fails
|
||||
# loudly at container start if configuration is invalid.
|
||||
app: FastAPI = create_app()
|
||||
7
app/models/__init__.py
Normal file
7
app/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Domain models and persistence mappers.
|
||||
|
||||
Populated in Phase 2 with dataclasses + SQL schema per
|
||||
``docs/ROADMAP.md``. Intentionally empty in Phase 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
8
app/routes/__init__.py
Normal file
8
app/routes/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""HTTP route packages.
|
||||
|
||||
Routers live as sibling modules and are wired into the app in
|
||||
:mod:`app.main`. Phase 0 only exposes ``health``; public, admin, and auth
|
||||
routers arrive in later phases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
69
app/routes/health.py
Normal file
69
app/routes/health.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Liveness / version endpoint.
|
||||
|
||||
The ``/healthz`` endpoint is intentionally minimal: it must be safe to
|
||||
expose unauthenticated (Caddy + uptime checks will hit it), so it leaks
|
||||
only the app version and the build's git commit SHA — no hostnames,
|
||||
paths, config values, or environment details.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app import __version__
|
||||
from app.config import Settings, get_settings
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Response schema for ``GET /healthz``.
|
||||
|
||||
Kept as an explicit :class:`pydantic.BaseModel` so FastAPI publishes
|
||||
the shape in the OpenAPI schema and so any drift is caught at typing
|
||||
time rather than via a loose ``dict``.
|
||||
"""
|
||||
|
||||
status: Literal["ok"] = Field(
|
||||
default="ok",
|
||||
description="Liveness indicator; this endpoint only returns 'ok'.",
|
||||
)
|
||||
version: str = Field(description="Application semantic version.")
|
||||
commit_sha: str = Field(
|
||||
description="Git commit SHA of the running build ('unknown' in dev).",
|
||||
)
|
||||
|
||||
|
||||
# Module-level router; mounted by `app.main.create_app`. No prefix and no
|
||||
# auth dependencies — /healthz is intentionally public.
|
||||
router: APIRouter = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/healthz",
|
||||
response_model=HealthResponse,
|
||||
summary="Liveness + version probe",
|
||||
)
|
||||
def healthz(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
"""Return a minimal liveness envelope.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
settings:
|
||||
Injected via FastAPI's dependency system so tests can override
|
||||
configuration cleanly.
|
||||
|
||||
Returns
|
||||
-------
|
||||
HealthResponse
|
||||
``status`` is always ``"ok"``; ``version`` and ``commit_sha``
|
||||
identify the running build.
|
||||
"""
|
||||
# Intentionally does not touch the DB, filesystem, or any external
|
||||
# service. This is liveness, not readiness — a readiness probe with
|
||||
# deeper checks can be added in a later phase behind a different path.
|
||||
return HealthResponse(
|
||||
version=__version__,
|
||||
commit_sha=settings.git_commit_sha,
|
||||
)
|
||||
7
app/services/__init__.py
Normal file
7
app/services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Application services (auth, email, cache, markdown, media, hcaptcha).
|
||||
|
||||
Populated phase-by-phase per ``docs/ROADMAP.md``. Intentionally empty in
|
||||
Phase 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
0
app/static/.gitkeep
Normal file
0
app/static/.gitkeep
Normal file
0
app/templates/.gitkeep
Normal file
0
app/templates/.gitkeep
Normal file
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — local / on-host compose file.
|
||||
#
|
||||
# Modern Docker Compose does not require a top-level `version:` key. The
|
||||
# `web` service builds the multi-stage Dockerfile, loads secrets from
|
||||
# `.env`, and mounts the runtime `data/` directory so the SQLite DB and
|
||||
# media uploads survive container restarts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Pass the host's current commit SHA through at build time so
|
||||
# /healthz can report which build is live. Falls back to
|
||||
# "unknown" when the env var is unset, matching the Dockerfile
|
||||
# default.
|
||||
GIT_COMMIT_SHA: ${GIT_COMMIT_SHA:-unknown}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
# Uvicorn listens on 8000 inside the container. In the production
|
||||
# topology Caddy fronts this; for local runs it's directly on the
|
||||
# host loopback via the mapped port.
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
# SQLite DB + media uploads live under data/. Mounting it keeps
|
||||
# state on the host so container rebuilds don't wipe content.
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -4,14 +4,38 @@ High-level phased plan. Each phase ends in a mergeable `dev` state and a passing
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Foundation
|
||||
## Phase 0 — Foundation ✅
|
||||
|
||||
- Scaffold `app/` package, `requirements.txt`, `.env.example`, `Dockerfile`, `docker-compose.yml`.
|
||||
- Pinned deps (upper bounds):
|
||||
`fastapi`, `uvicorn[standard]`, `jinja2`, `pydantic`, `pydantic-settings`, `sqlalchemy` (Core only), `markdown-it-py`, `bleach`, `Pillow`, `python-magic`, `resend`, `slowapi`, `structlog`, `itsdangerous`, `python-multipart`, `pytest`, `httpx`.
|
||||
- `structlog` init at app startup.
|
||||
- Health endpoint `/healthz` (returns app version + commit SHA from env).
|
||||
- Typed config loader reading env via `pydantic-settings`.
|
||||
**Completed:** 2026-04-21
|
||||
|
||||
**Summary:** Scaffolded the FastAPI skeleton — package layout, pinned deps, multi-stage Dockerfile, compose file, typed config loader, structlog init, and a `/healthz` liveness endpoint surfacing app version + git commit SHA.
|
||||
|
||||
**Key files:**
|
||||
- `app/__init__.py` — package `__version__ = "0.1.0"`
|
||||
- `app/main.py` — `create_app()` factory + module-level `app`; configures logging then mounts routers
|
||||
- `app/config.py` — `Settings(BaseSettings)` with the full env contract (Optional where unused in Phase 0) + model validator refusing the dev-sentinel `SECRET_KEY` in production; `get_settings()` is `lru_cache`-d
|
||||
- `app/logging_config.py` — `configure_logging(app_env)`; `ConsoleRenderer` in dev, `JSONRenderer` otherwise
|
||||
- `app/routes/health.py` — `APIRouter` mounting `GET /healthz`, typed `HealthResponse` pydantic model
|
||||
- `app/models/`, `app/services/`, `app/templates/`, `app/static/` — placeholder dirs with `.gitkeep`
|
||||
- `tests/test_healthz.py` — FastAPI `TestClient` smoke test
|
||||
- `requirements.txt` — 17 pinned packages, `>=X,<next-major` ranges
|
||||
- `.env.example` — public env contract; `.env` stays gitignored
|
||||
- `Dockerfile` — multi-stage (`builder` + `runtime`), `python:3.12-slim-bookworm`, `libmagic1` runtime; root user and no HEALTHCHECK (Phase 6 hardening)
|
||||
- `docker-compose.yml` — `web` service with `env_file: .env`, `./data:/app/data` bind mount, `GIT_COMMIT_SHA` build arg
|
||||
- `.gitignore` — adjusted `data/` rule to `data/*` so `!data/.gitkeep` works
|
||||
|
||||
**Endpoints created:**
|
||||
- `GET /healthz` — public, unauthenticated. Returns minimal flat JSON `{"status":"ok","version":"0.1.0","commit_sha":"<sha-or-unknown>"}`.
|
||||
|
||||
**Key details:**
|
||||
- **Healthz shape:** minimal flat JSON (not the full code_guidelines envelope). Future phases that add JSON APIs will use the envelope; the healthcheck stays small on purpose.
|
||||
- **Version source:** `app.__version__` string constant. **Commit SHA source:** `GIT_COMMIT_SHA` env var, baked into the image via `ARG GIT_COMMIT_SHA=unknown` + `ENV GIT_COMMIT_SHA`. Surfaces as `"unknown"` when unset (dev).
|
||||
- **Config loader:** single `Settings` class covers every ROADMAP env-var row. Fields not yet used at Phase 0 (`RESEND_*`, `HCAPTCHA_*`, `ADMIN_CONTACT_EMAIL`) are `Optional[str] = None`. `SECRET_KEY` defaults to the sentinel `"dev-insecure-change-me"`; a `@model_validator(mode="after")` refuses to boot if that sentinel survives into `APP_ENV=production`.
|
||||
- **Admin emails:** stored as raw comma-separated string; `admin_emails_list` property returns stripped+lowercased list for allowlist comparisons (used by Phase 3 auth).
|
||||
- **Logging:** `configure_logging` runs inside `create_app()` before any `structlog.get_logger` call; `app_started` structured event fires once at startup with `app_env`, `version`, `commit_sha` (no secrets).
|
||||
- **Docker CMD:** uvicorn runs with `--proxy-headers --forwarded-allow-ips=127.0.0.1` (Phase 6 will swap the IP for Caddy's LAN address).
|
||||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 1 passed ✓ · `curl /healthz` returned both the default `"unknown"` payload and the real commit SHA when `GIT_COMMIT_SHA=$(git rev-parse HEAD)` was set ✓ · `docker compose config` exit 0 ✓.
|
||||
- **Branch:** built on `chore/phase-0-foundation` off `dev`; merged `--no-ff` into `dev` on completion. Not pushed.
|
||||
|
||||
## Phase 1 — Public Site Skeleton
|
||||
|
||||
|
||||
17
requirements.txt
Normal file
17
requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
fastapi>=0.115,<0.120
|
||||
uvicorn[standard]>=0.32,<0.35
|
||||
jinja2>=3.1,<4.0
|
||||
pydantic>=2.9,<3.0
|
||||
pydantic-settings>=2.6,<3.0
|
||||
sqlalchemy>=2.0,<2.1
|
||||
markdown-it-py>=3.0,<4.0
|
||||
bleach>=6.2,<7.0
|
||||
Pillow>=11.0,<12.0
|
||||
python-magic>=0.4.27,<0.5
|
||||
resend>=2.4,<3.0
|
||||
slowapi>=0.1.9,<0.2
|
||||
structlog>=24.4,<26.0
|
||||
itsdangerous>=2.2,<3.0
|
||||
python-multipart>=0.0.12,<0.1
|
||||
pytest>=8.3,<9.0
|
||||
httpx>=0.28,<0.29
|
||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Pytest suite for the Chicken Babies R Us app."""
|
||||
|
||||
from __future__ import annotations
|
||||
35
tests/test_healthz.py
Normal file
35
tests/test_healthz.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Tests for the ``/healthz`` endpoint.
|
||||
|
||||
Kept deliberately small in Phase 0: we verify contract and shape, not
|
||||
content beyond what the response model guarantees.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import __version__
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_healthz_returns_ok_and_version() -> None:
|
||||
"""``GET /healthz`` returns 200 with status=ok and the app version.
|
||||
|
||||
Also asserts ``commit_sha`` is present as a non-empty string — it
|
||||
defaults to ``"unknown"`` in dev, which still satisfies the contract
|
||||
surfaced to uptime probes.
|
||||
"""
|
||||
# TestClient uses the module-level app, which has already been wired
|
||||
# by create_app() at import time — exactly the path uvicorn takes in
|
||||
# production, so the test exercises real startup behavior.
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["version"] == __version__
|
||||
assert isinstance(payload["commit_sha"], str)
|
||||
assert payload["commit_sha"], "commit_sha must be a non-empty string"
|
||||
Reference in New Issue
Block a user