feat: phase 0 foundation — FastAPI scaffold + /healthz
Scaffold app/ package, pinned requirements.txt, multi-stage Dockerfile,
docker-compose.yml, and .env.example. Add typed pydantic-settings loader
with full env contract and a production validator that refuses the
dev-sentinel SECRET_KEY. Wire structlog with an APP_ENV-driven renderer
(console in dev, JSON in prod). Ship a minimal unauthenticated /healthz
returning {status, version, commit_sha} with commit SHA fed through a
GIT_COMMIT_SHA build arg.
Also mark Phase 0 complete in docs/ROADMAP.md.
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-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
||||||
# Runtime data (DB + media uploads live here in prod)
|
# Runtime data (DB + media uploads live here in prod).
|
||||||
data/
|
# 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
|
!data/.gitkeep
|
||||||
|
|
||||||
# Editors / IDE
|
# 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`.
|
**Completed:** 2026-04-21
|
||||||
- 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`.
|
**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.
|
||||||
- `structlog` init at app startup.
|
|
||||||
- Health endpoint `/healthz` (returns app version + commit SHA from env).
|
**Key files:**
|
||||||
- Typed config loader reading env via `pydantic-settings`.
|
- `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
|
## 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