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
4 changes: 4 additions & 0 deletions data-science/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ target/
# DotEnv configuration
.env

# Database dumps (multi-GB pg_dump files staged for prod restore)
postgis_db/dumps/*
!postgis_db/dumps/.gitkeep

# Database
*.db
*.rdb
Expand Down
23 changes: 23 additions & 0 deletions data-science/postgis_db/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
raw_data/
**/shapefile/
**/*_shapefile.zip
**/.DS_Store
**/__pycache__/
*.md
datacontract.yaml
.dockerignore
Dockerfile
docker-compose.yml
.env
.env.*

# Never needed in either image, and .venv alone is ~260 MB of build context
# that would otherwise be uploaded to the daemon on every build.
.venv/
.git/
.gitattributes
dumps/
tests/
deploy/
*.ps1
*.cmd
65 changes: 65 additions & 0 deletions data-science/postgis_db/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copy to .env. Locally, scripts/preflight.sh writes this file for you with a
# generated password; for production run scripts/gen_secrets.sh and edit.
# Never commit .env — it is gitignored.

# --- Database ---------------------------------------------------------------
POSTGRES_DB=lucky_parking
POSTGRES_USER=lucky
# Required. No default: Compose refuses to start without it.
POSTGRES_PASSWORD=

# --- Access control ---------------------------------------------------------
# Contract API: comma-separated keys accepted in the X-API-Key header.
# Issue one key per consumer so any single key can be revoked on its own.
API_KEYS=

# Explorer UI: HTTP basic auth credentials.
WEB_USER=explorer
WEB_PASSWORD=

# Local development shortcut ONLY — disables both checks above.
# Leave unset (or 0) anywhere reachable from the internet.
ALLOW_UNAUTHENTICATED=0

# Requests per minute, per API key (or per client IP when unauthenticated).
# 0 disables throttling. The explorer defaults to 30 when unset.
RATE_LIMIT_PER_MINUTE=60

# Serve /docs and /openapi.json on the API. Off in production.
API_DOCS_PUBLIC=0

# --- TLS (docker-compose.prod.yml + deploy/Caddyfile) -----------------------
# Both names must already resolve to this host for certificates to be issued.
API_DOMAIN=api.example.org
WEB_DOMAIN=explorer.example.org
ACME_EMAIL=you@example.org

# --- Ports ------------------------------------------------------------------
# Local compose publishes these on all interfaces plus Postgres :5432.
# Prod compose binds them to 127.0.0.1 and puts Caddy on 80/443 instead.
API_PORT=8000
WEB_PORT=8080

# --- Query safety valves ----------------------------------------------------
DB_CONNECT_TIMEOUT_SECONDS=5
DB_STATEMENT_TIMEOUT_MS=15000

# --- Memory caps ------------------------------------------------------------
# COMPOSE_MEM_LIMIT=8g
# COMPOSE_MEM_LIMIT_POSTGIS=2560m
# COMPOSE_MEM_LIMIT_API=512m
# COMPOSE_MEM_LIMIT_WEB=512m

# --- Data loading -----------------------------------------------------------
# First-boot CSV load. Use 0 locally (load from raw_data/). Use 1 on the VPS
# and restore a pg_dump instead.
SKIP_CITATIONS_LOAD=0

# Load only the first N citation rows on first boot. Turns a multi-hour first
# boot into a few minutes, which is how you verify a clean install end to end.
# Leave blank to load the full dataset.
# CITATIONS_LOAD_LIMIT=200000

# Compose injects DATABASE_URL pointing at hostname "postgis" for api/web.
# Host-side uvicorn / CLI builds one from POSTGRES_* above when unset.
# DATABASE_URL=postgresql://lucky:PASSWORD@localhost:5432/lucky_parking
59 changes: 59 additions & 0 deletions data-science/postgis_db/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Line-ending policy for postgis_db.
#
# Git for Windows defaults to core.autocrlf=true, which rewrites checked-out
# files to CRLF. Shell scripts with CRLF break inside Linux containers: bash
# reads the trailing \r as part of the command and fails with errors like
# "$'\r': command not found" or "bad interpreter". The Docker build COPYs
# init/*.sh and scripts/*.sh into the PostGIS image, and compose bind-mounts
# postgis-healthcheck.sh, so a Windows clone would fail on first boot without
# this file.
#
# Scoped to this directory rather than the repo root so it stays easy to
# upstream and does not impose a policy on the JS/TS side of the monorepo.
# The Dockerfile also strips CR before chmod as a second line of defence.

* text=auto eol=lf

# Must be LF: executed by bash/psql/python inside Linux containers.
*.sh text eol=lf
*.sql text eol=lf
*.py text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.ini text eol=lf
*.txt text eol=lf
Dockerfile text eol=lf
Dockerfile.* text eol=lf
Caddyfile text eol=lf
.env.example text eol=lf

# Batch files are the one case where CRLF genuinely matters: cmd.exe can
# mis-parse LF-only files. PowerShell handles LF fine, so .ps1 stays LF to
# avoid needless churn for contributors on macOS/Linux.
*.cmd text eol=crlf
*.ps1 text eol=lf

# Binary - never touch. `text=auto` would normally auto-detect these, but
# being explicit avoids any chance of a shapefile or marker image being
# mangled by an unusual client config.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.zip binary
*.gz binary
*.dump binary
*.pdf binary
*.shp binary
*.shx binary
*.dbf binary
*.prj binary
*.cpg binary
*.sbn binary
*.sbx binary

# Large data files: keep LF, but skip diffing them.
*.geojson text eol=lf -diff
*.csv text eol=lf -diff
94 changes: 94 additions & 0 deletions data-science/postgis_db/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# PostGIS sized for a small VPS (2 vCPU / 2 GB RAM / 90 GB NVMe).
# First boot: boundary layers, then citations from /raw_data if present.
# postgis/postgis publishes amd64 only; pin platform so Apple Silicon
# can still build/run locally via emulation (VPS is amd64 natively).
FROM --platform=linux/amd64 postgis/postgis:16-3.5

# gdal → ogr2ogr for GeoJSON; Python → contract citations loader.
#
# This base image is Debian 11 (bullseye), which reached end-of-LTS on
# 2026-08-31. Its .deb files have since been withdrawn from deb.debian.org, so
# a fresh build fails: `apt-get update` still succeeds and advertises
# python3.9 3.9.2-1+deb11u7, but fetching it 404s and apt exits 100. Builds
# only appeared to work while the layer was still warm in a local cache.
#
# Staying on bullseye is deliberate. postgis/postgis ships no bookworm/trixie
# build for PostGIS 3.5 on PG16 - 17-3.5 is bullseye too, and the only current
# option (18-3.6, Debian 13) would force a PostgreSQL major upgrade that makes
# existing data directories unreadable. So take packages from
# archive.debian.org, which still serves bullseye:
# - bullseye-security has no suite on archive.debian.org, so drop it. There
# will be no further security updates for this release either way; moving
# to 18-3.6 with a pg_upgrade is the real fix when there is time for it.
# - Archived Release files are past Valid-Until, hence Check-Valid-Until=0.
# apt.postgresql.org is a separate, still-live repo and is left alone.
RUN set -eux; \
sed -i \
-e '/bullseye-security/d' \
-e 's|http://deb.debian.org/debian|http://archive.debian.org/debian|' \
/etc/apt/sources.list; \
apt-get -o Acquire::Check-Valid-Until=false update; \
apt-get install -y --no-install-recommends \
gdal-bin \
python3 \
python3-pip; \
pip3 install --no-cache-dir \
'polars>=1.30,<2' \
'psycopg[binary]>=3.2,<4'; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*

# No POSTGRES_PASSWORD default on purpose: the entrypoint refuses to
# initialize without one, which is safer than baking in a known value.
ENV POSTGRES_DB=lucky_parking \
POSTGRES_USER=lucky \
# Checksums help catch NVMe corruption; cheap at this data size.
POSTGRES_INITDB_ARGS="--data-checksums"

# Reference layers only (citation CSVs stay on a runtime mount / raw_data).
COPY boundaries/neighborhood_councils/neighborhood_councils.geojson /data/neighborhood_councils.geojson
COPY boundaries/zipcodes/zipcodes.geojson /data/zipcodes.geojson
COPY boundaries/council_districts/council_districts.geojson /data/council_districts.geojson
COPY boundaries/neighborhoods/neighborhoods.geojson /data/neighborhoods.geojson
COPY boundaries/places/places.geojson /data/places.geojson

# Portable boundary loader (also used by init/02_load_boundaries.sh).
RUN mkdir -p /usr/local/lib/lucky-parking
COPY scripts/load_boundaries.sh scripts/normalize_boundaries.sql /usr/local/lib/lucky-parking/
COPY scripts/load_contract_citations.py /usr/local/bin/load_contract_citations.py
COPY scripts/postgis-healthcheck.sh /usr/local/bin/postgis-healthcheck.sh
COPY init/01_extensions.sql /docker-entrypoint-initdb.d/01_extensions.sql
COPY init/02_load_boundaries.sh /docker-entrypoint-initdb.d/02_load_boundaries.sh
COPY init/03_load_citations.sh /docker-entrypoint-initdb.d/03_load_citations.sh

# Strip CR before chmod. .gitattributes should already guarantee LF, but a
# contributor cloning on Windows with a stale config would otherwise ship
# CRLF scripts that bash refuses to run. Cheap insurance on first boot.
RUN sed -i 's/\r$//' \
/usr/local/lib/lucky-parking/load_boundaries.sh \
/usr/local/lib/lucky-parking/normalize_boundaries.sql \
/usr/local/bin/postgis-healthcheck.sh \
/docker-entrypoint-initdb.d/01_extensions.sql \
/docker-entrypoint-initdb.d/02_load_boundaries.sh \
/docker-entrypoint-initdb.d/03_load_citations.sh \
&& chmod +x \
/usr/local/lib/lucky-parking/load_boundaries.sh \
/usr/local/bin/postgis-healthcheck.sh \
/docker-entrypoint-initdb.d/02_load_boundaries.sh \
/docker-entrypoint-initdb.d/03_load_citations.sh

# Memory-safe defaults for a 2 GB VPS (leave headroom for OS + PostGIS).
CMD ["postgres", \
"-c", "shared_buffers=256MB", \
"-c", "effective_cache_size=768MB", \
"-c", "maintenance_work_mem=64MB", \
"-c", "work_mem=4MB", \
"-c", "max_connections=40", \
"-c", "wal_buffers=8MB", \
"-c", "random_page_cost=1.1", \
"-c", "checkpoint_completion_target=0.9"]

EXPOSE 5432
# First boot may load ~25M citations; allow a long grace period before unhealthy.
HEALTHCHECK --interval=30s --timeout=5s --retries=5 --start-period=3600s \
CMD pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" || exit 1
30 changes: 30 additions & 0 deletions data-science/postgis_db/Dockerfile.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# FastAPI contract API + citation explorer UI (no PostGIS inside).
FROM python:3.12-slim-bookworm

WORKDIR /app

# curl is used by the Compose healthchecks for both services.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

# constraints.txt pins the exact resolution so rebuilds are reproducible.
COPY requirements.txt constraints.txt ./
RUN pip install --no-cache-dir -r requirements.txt -c constraints.txt

COPY lucky_parking ./lucky_parking
COPY api ./api
COPY web_sheet ./web_sheet

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1

EXPOSE 8000 8080

# Neither app writes to disk, so it runs unprivileged against root-owned,
# read-only application files.
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin appuser
USER appuser

# Default: contract API. Compose `web` overrides command to web_sheet on :8080.
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
Loading
Loading