From b82770b5c493b4146365f908c582daf1bc29a327 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 9 May 2026 22:25:52 +0000 Subject: [PATCH] =?UTF-8?q?deploy:=20combined=20Dockerfile=20=E2=80=94=20F?= =?UTF-8?q?astAPI=20serves=20the=20SPA=20in=20prod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-stage build: 1. node:22-alpine — `npm ci` + `npm run build` produces frontend/dist 2. python:3.12-slim — poetry installs backend deps into a venv 3. python:3.12-slim — runtime, copies the venv + frontend/dist, sets FRONTEND_DIST=/app/frontend_dist Backend gates the API surface on FRONTEND_DIST: - Unset (dev / tests): routers mount at root (/networth, /scenarios, …). 172 tests still pass unchanged. The Vite dev server proxies `/api/*` → backend stripping the prefix. - Set (prod): routers mount under `/api/*`. The SPA bundle mounts at `/` with html=True so React Router owns client routing for paths like `/scenarios`, `/what-if`. Same-origin, no CORS, one deploy. Operational endpoints (`/healthz`, `/metrics`, `/recompute`) stay at root in both shapes. Existing Woodpecker pipeline picks this up unchanged — same context, same Dockerfile path, just produces a richer image. Co-Authored-By: Claude Opus 4.7 --- .dockerignore | 22 ++++++++++++++++++++++ Dockerfile | 22 +++++++++++++++++++--- fire_planner/app.py | 46 +++++++++++++++++++++++++++++++-------------- 3 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dacdaea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Caches and build artifacts +.venv +__pycache__ +*.pyc +.pytest_cache +.ruff_cache +.mypy_cache +.hypothesis + +# Frontend +frontend/node_modules +frontend/dist +frontend/.npm +frontend/*.tsbuildinfo + +# Git, IDE +.git +.vscode +.idea + +# Dev-only docs (README.md is needed by poetry; keep it) +PLAYBOOK_VIKTOR.md diff --git a/Dockerfile b/Dockerfile index 737d99c..6e6000c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,17 @@ -FROM python:3.12-slim AS builder +# ── Stage 1: build the React SPA ───────────────────────────────────── +FROM node:22-alpine AS frontend-builder + +WORKDIR /frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + + +# ── Stage 2: install backend Python deps ───────────────────────────── +FROM python:3.12-slim AS backend-builder ENV POETRY_VERSION=1.8.4 \ POETRY_VIRTUALENVS_IN_PROJECT=true \ @@ -16,16 +29,19 @@ COPY alembic.ini ./alembic.ini RUN poetry install --only main +# ── Stage 3: runtime ───────────────────────────────────────────────── FROM python:3.12-slim WORKDIR /app RUN useradd --system --uid 10003 --home /app --shell /usr/sbin/nologin firep -COPY --from=builder --chown=firep:firep /app /app +COPY --from=backend-builder --chown=firep:firep /app /app +COPY --from=frontend-builder --chown=firep:firep /frontend/dist /app/frontend_dist ENV PATH="/app/.venv/bin:${PATH}" \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + FRONTEND_DIST=/app/frontend_dist EXPOSE 8080 USER firep diff --git a/fire_planner/app.py b/fire_planner/app.py index ac9fe46..7932842 100644 --- a/fire_planner/app.py +++ b/fire_planner/app.py @@ -1,14 +1,17 @@ """FastAPI application — wires routers + middleware + lifespan. -Routers: -- /healthz, /metrics, /recompute — operational -- /networth, /networth/history — read NW from account_snapshot -- /scenarios/... — scenario CRUD + projection -- /scenarios/{id}/life-events, - /life-events/{id} — life event CRUD -- /scenarios/{id}/goals, - /goals/{id} — retirement goal CRUD -- /simulate, /compare — sync simulate (no DB write) +Two deployment shapes: + +- **Dev / tests**: `FRONTEND_DIST` unset. API routers mount at root + (e.g. `/networth`, `/scenarios`). The Vite dev server (port 5173) + hits `/api/*` and proxies to the backend stripping the prefix. + +- **Prod**: `FRONTEND_DIST=/path/to/frontend/dist` set. API routers + mount under `/api/*`; the SPA static bundle mounts at `/` with + HTML fallback so React Router can own the URL. + +Operational endpoints (`/healthz`, `/metrics`, `/recompute`) stay at +root in both shapes. Auth: write/compute paths take Bearer auth via the `require_bearer` dependency when `API_BEARER_TOKEN` is set. Read paths skip auth so the @@ -26,10 +29,12 @@ import logging import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path from typing import Any from fastapi import Depends, FastAPI, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from prometheus_fastapi_instrumentator import Instrumentator from fire_planner.api.auth import require_bearer @@ -100,6 +105,9 @@ async def _drain_queue(app: FastAPI) -> None: queue.task_done() +_FRONTEND_DIST = os.environ.get("FRONTEND_DIST") +_API_PREFIX = "/api" if _FRONTEND_DIST else "" + app = FastAPI(title="fire-planner", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -110,11 +118,11 @@ app.add_middleware( ) Instrumentator().instrument(app).expose(app, endpoint="/metrics") -app.include_router(networth_router) -app.include_router(scenarios_router) -app.include_router(life_events_router) -app.include_router(goals_router) -app.include_router(simulate_router) +app.include_router(networth_router, prefix=_API_PREFIX) +app.include_router(scenarios_router, prefix=_API_PREFIX) +app.include_router(life_events_router, prefix=_API_PREFIX) +app.include_router(goals_router, prefix=_API_PREFIX) +app.include_router(simulate_router, prefix=_API_PREFIX) @app.post( @@ -134,3 +142,13 @@ async def healthz() -> dict[str, Any]: queue = getattr(app.state, "queue", None) depth = queue.qsize() if queue is not None else 0 return {"status": "ok", "queue_depth": depth} + + +# Mount the SPA last so it catches any path the API didn't claim. With +# `html=True`, requests for paths like `/scenarios` (no file at that +# path) return `index.html`, letting React Router own client routing. +if _FRONTEND_DIST and Path(_FRONTEND_DIST).is_dir(): + app.mount("/", StaticFiles(directory=_FRONTEND_DIST, html=True), name="spa") + log.info("Mounted SPA from %s; API at %s/*", _FRONTEND_DIST, _API_PREFIX) +elif _FRONTEND_DIST: + log.warning("FRONTEND_DIST=%s does not exist; SPA not mounted", _FRONTEND_DIST)