Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Keep the build context lean — anything not required to run the web
# builder in production should be excluded so layer caching stays fast
# and secrets/dev artifacts never end up in the image.

# VCS / CI
.git/
.gitignore
.gitattributes
.github/
.gitleaks.toml

# Claude Code worktrees and local config
.claude/

# Virtualenvs
.venv/
venv/
env/
ENV/

# Python caches
__pycache__/
**/__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
*.egg-info/
*.egg
.eggs/

# Tests, coverage, type/lint caches
tests/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
.coverage.*
htmlcov/
.tox/
coverage.xml

# Local secrets — never bake into an image
.env
.env.*
!.env.example

# Editor
.idea/
.vscode/
*.swp
*.swo

# macOS / Windows cruft
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db

# Tool outputs not needed at runtime
output/
*.log

# Docs and dev tooling not used by the runtime
docs/
examples/
CHANGELOG.md
CONTRIBUTING.md
SECURITY.md
.pre-commit-config.yaml
.pip-audit-allowlist.txt
.secrets.baseline
scripts/
Makefile

# Container artefacts that should not nest into the image itself
Dockerfile
.dockerignore
fly.toml
18 changes: 18 additions & 0 deletions .github/workflows/fly-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/

name: Fly Deploy
on:
push:
branches:
- main
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
concurrency: deploy-group # optional: ensure only one action runs at a time
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,45 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]

### Added
- **Per-IP rate limit on `POST /generate`** (10 requests / hour /
client IP, sliding window). Caps abuse cost at the
most-expensive route — each `/generate` triggers an Anthropic
narrative call costing ~$0.10–$0.30, so without a cap a single
abusive IP could burn the published Anthropic spend ceiling in
minutes. Limit lives in-process (`web.ratelimit.RateLimiter`) and
is keyed on `Fly-Client-IP` (Fly's edge proxy) → `X-Forwarded-For`
→ socket peer; `request.client.host` alone would be useless on
Fly because it is one of Fly's load-balancer addresses. Bounded
to 10 000 tracked IPs (LRU-by-insert eviction) so a flood of
unique sources cannot OOM the process. Over-quota responses are
HTTP 429 with a `Retry-After` header, returned before the
multipart body is parsed (FastAPI dependency runs first when the
dep only takes `Request`). Picked over a global cap because per-IP
bounds the worst case from a single attacker; a global counter
would not have helped against a botnet hitting each IP once and
would have hurt legitimate concurrent use during a launch.
- **Fly.io deployment config** (`Dockerfile`, `fly.toml`, `.dockerignore`)
for the web builder. The image is `python:3.12-slim` with the project
installed in editable mode so `trailstory.renderers.html` keeps
finding the top-level `templates/` directory at runtime; the renderer
resolves it via `Path(__file__).parents[2] / "templates"`, which only
works when the package source lives next to `templates/` — a
non-editable install would relocate `trailstory/` into site-packages
and break the path. `fly.toml` ships a 512 MB shared-cpu-1x VM in
`fra` with a `/healthz` HTTP check, `force_https`, auto-stop on idle,
and no persistent volume — the 30-min retention sweep runs against
`/tmp` and restarts wipe in-flight workspaces, which is *stronger*
than the published privacy promise.
- **`/version` endpoint** that returns the running image's git SHA
(sourced from the `GIT_SHA` build arg, falls back to `"unknown"` for
local runs). Wired to the `make deploy` target so every Fly deploy
stamps the current commit into the image and a deploy-correlated bug
can be tied back to the source without log archaeology.
- **`make docker-build` / `make deploy` targets**. `deploy` refuses to
ship a dirty working tree and forwards `GIT_SHA` to
`flyctl deploy --build-arg`, so the SHA in `/version` always matches
the commit Fly built from. `docker-build` exists for local smoke
tests before the first deploy.
- **Streaming narrative generation via Server-Sent Events** for the web
builder. `POST /generate` now runs the deterministic prep phase
(parse GPX + load_photos + persist `pending.json`), wipes the raw
Expand Down
50 changes: 50 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1.7
#
# Trailstory web builder — single-stage image for Fly.io.
#
# We install the package in editable mode on purpose: the HTML renderer
# resolves the memory template via ``Path(__file__).parents[2] / "templates"``
# (see trailstory/renderers/html.py:27), which only works when the
# package source lives next to the top-level templates/ directory at
# runtime. A non-editable install would relocate trailstory/ into
# site-packages and break that path. See CLAUDE.md → "Architecture and
# file map" for the layout this depends on.

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1

WORKDIR /app

# Project metadata first so the dependency-install layer is cached on
# any change to source files but invalidated when pyproject.toml moves.
COPY pyproject.toml README.md LICENSE ./
COPY trailstory/ ./trailstory/
COPY web/ ./web/
COPY templates/ ./templates/

# Editable install: dependencies + package, with trailstory/ pointing
# back at /app/trailstory so the renderer's parents[2] still resolves
# to /app/templates.
RUN pip install -e .

# Drop privileges. The retention sweeper writes under $TMPDIR
# (default /tmp) which is world-writeable, so a non-root user is fine.
RUN useradd --create-home --uid 1000 app && chown -R app:app /app
USER app

# Build identity — set by `make deploy` / `flyctl deploy --build-arg`.
# Surfaced via GET /version so a deploy can be tied back to a commit.
ARG GIT_SHA=unknown
ENV GIT_SHA=${GIT_SHA}

# Fly injects PORT at runtime; default keeps `docker run` ergonomic.
ENV PORT=8080
EXPOSE 8080

# Shell form so $PORT expands. One uvicorn worker — the LLM call is
# I/O-bound and v0 traffic does not justify multi-worker complexity.
CMD ["sh", "-c", "exec uvicorn web.__main__:app --host 0.0.0.0 --port ${PORT}"]
22 changes: 21 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.DEFAULT_GOAL := help
.PHONY: help setup dev install install-hooks test lint format typecheck ci clean generate test-render golden-update eval eval-live eval-update-golden web web-dev
.PHONY: help setup dev install install-hooks test lint format typecheck ci clean generate test-render golden-update eval eval-live eval-update-golden web web-dev docker-build deploy

PYTHON ?= python3.12
VENV := .venv
Expand Down Expand Up @@ -109,6 +109,26 @@ web: ## Run the FastAPI builder against the real Anthropic API (n
web-dev: ## Run the FastAPI builder with a fake LLM (free, deterministic narrative)
$(PY) -m web --fake-llm --reload

# ── Deploy ─────────────────────────────────────────────────────────────────────

# Resolved at make-invocation time so the same value reaches the Dockerfile
# build arg and the /version endpoint. `--short` keeps the SHA readable.
GIT_SHA := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)

docker-build: ## Build the production image locally (smoke test before deploy)
docker build --build-arg GIT_SHA=$(GIT_SHA) -t trailstory:$(GIT_SHA) -t trailstory:latest .

deploy: ## Deploy to Fly.io with the current git SHA stamped into /version
@command -v flyctl >/dev/null 2>&1 || { \
echo "→ flyctl not found. Install: brew install flyctl"; exit 1; \
}
@if ! git diff-index --quiet HEAD --; then \
echo "→ working tree is dirty. Commit or stash before deploying."; \
git status --short; \
exit 1; \
fi
flyctl deploy --build-arg GIT_SHA=$(GIT_SHA)

# ── Cleanup ────────────────────────────────────────────────────────────────────

clean: ## Remove build artifacts, cache, and generated output
Expand Down
34 changes: 34 additions & 0 deletions fly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# fly.toml app configuration file generated for trailstory on 2026-04-30T17:16:08+02:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#

app = 'trailstory'
primary_region = 'fra'

[build]

[env]
PORT = '8080'

[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']

[[http_service.checks]]
interval = '30s'
timeout = '5s'
grace_period = '10s'
method = 'GET'
path = '/healthz'

[[vm]]
size = 'shared-cpu-1x'
memory = '512mb'
cpu_kind = 'shared'
cpus = 1
memory_mb = 512
Loading
Loading