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
25 changes: 24 additions & 1 deletion .dockerignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
# VCS — nothing in the build reads git metadata (every package version is
# static), and .git is the single biggest thing in the context.
.git/

# Python / uv
__pycache__/
*.py[cod]
Expand All@@ -18,6 +22,10 @@ host/client_app/dist/
packages/*/dist/
.npm/

# Built frontend — the image runs its own `npm run build`, and a stale local
# bundle would shadow it (same host/static/dist path).
host/static/dist/

# Editors / OS
.vscode/
.idea/
Expand All@@ -28,7 +36,22 @@ packages/*/dist/
*.db
*.sqlite
.memray/
uploads/
var/

# Agent + tooling scratch. `.claude/worktrees/` holds full checkouts of this
# repo, so leaving it in would multiply the build context by every worktree.
.claude/
.worktrees/
.emdash/
.qa/
.verify/
.playwright-mcp/
.playwright-cli/
.benchmarks/
qa-shots/

# Tests / docs not needed at runtime
# Tests / docs / sample data not needed at runtime
tests/e2e/
docs/
dataset/
104 changes: 104 additions & 0 deletions Dockerfile
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
# syntax=docker/dockerfile:1.7
# Default image for the SimpleModule reference app — the host plus the bundled
# modules that a single web process needs (auth/users, dashboard, permissions,
# settings, file storage, feature flags, audit log, branding, site lock).
#
# Background tasks are deliberately not part of it: Celery is a second process
# plus a broker, which is the opposite of a standalone image. The worker/beat
# services in docker-compose.yml build docker/worker.Dockerfile for that.
#
# docker build -t simple-module-python .
# docker run --rm -p 8000:8000 simple-module-python
#
# Standalone by design: SQLite under /app/data, no Postgres and no Redis
# needed to boot. `docker-compose.yml`'s `app` service is the same image with
# a named volume; worker/beat (Celery) stay opt-in.
#
# One builder stage carries both uv and Node because the Vite build imports
# `modules.generated.{ts,css}`, which `smpy host gen-pages` emits from the
# *installed Python modules* — a Node-only stage would have nothing to read.

FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS builder

ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /app

# Node 24 — same major as NODE_VERSION in .github/workflows/pr.yml, so the
# image builds the bundle CI validates.
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*

# Dependency layer: every workspace member's manifest, resolved before the
# full source arrives. `uv.lock` is gitignored in this repo, so it's an
# optional glob and the sync deliberately isn't `--frozen`.
COPY pyproject.toml uv.lock* ./
COPY framework/ framework/
COPY modules/ modules/
COPY host/pyproject.toml host/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --all-packages --no-dev --no-install-workspace

# npm workspaces span host/client_app, packages/* and modules/* — every
# member's package.json must exist before `npm ci` will honour the lockfile.
COPY package.json package-lock.json ./
COPY packages/ packages/
COPY host/client_app/package.json host/client_app/
RUN --mount=type=cache,target=/root/.npm npm ci

# --no-install-package drops the Celery module from this image: with no entry
# point installed, discovery never sees it, so nothing here needs a broker and
# the bundle carries none of its pages. Drop the flag (and point
# SM_BG_TASKS_BROKER_URL at a real Redis) to run tasks from the web process.
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --all-packages --no-dev --no-install-package simple-module-background-tasks

# Page manifest + generated module imports first, then the production bundle
# into host/static/dist (with its .vite/manifest.json and precompressed
# .gz/.br siblings, which the host serves from the /static mount).
# The venv binary directly rather than `uv run`, which re-resolves and re-syncs
# the environment on every invocation — the layer above already installed
# exactly what this image should contain.
RUN /app/.venv/bin/smpy host gen-pages --host-dir=host/client_app
RUN npm run build

# node_modules is a build-time artifact only; the runtime serves static files.
RUN rm -rf node_modules host/client_app/node_modules

FROM python:3.12-slim-bookworm AS runtime

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/app/.venv/bin:$PATH"

# Containers serve the built bundle; development mode would emit asset tags
# pointing at a Vite dev server that isn't in this image.
ENV SM_ENVIRONMENT=production

# Absolute sqlite path: /app/data is the volume mount point, so the DB is
# cwd-independent and survives restarts whenever a volume is attached.
ENV SM_DATABASE_URL=sqlite+aiosqlite:////app/data/app.db

# curl backs the HEALTHCHECK below.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /app /app

RUN mkdir -p /app/data \
&& useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin app \
&& chown -R app:app /app
USER app

WORKDIR /app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1

ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["uvicorn", "host.main:app", "--host", "0.0.0.0", "--port", "8000"]
19 changes: 18 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker

# Install
install:
Expand DownExpand Up@@ -184,6 +184,23 @@ kill:
@-lsof -ti:8000,5050,5173 | xargs kill -9 2>/dev/null
@echo "Ports 8000, 5050, 5173 freed."

# Docker — the default app image (./Dockerfile). Standalone: SQLite inside the
# container, no Postgres or Redis needed. Both run targets honour SM_APP_PORT
# so they don't collide with a `make dev` already holding 8000.
SM_APP_PORT ?= 8000
export SM_APP_PORT

docker-build: ## Build the default app image
docker build -t simple-module-python .

docker-app: docker-build ## Run the built image standalone on http://localhost:$(SM_APP_PORT)
docker run --rm -p $(SM_APP_PORT):8000 \
-v simple-module-python-data:/app/data \
simple-module-python

docker-compose-app: ## Same image via compose (named volume, .env-overridable)
docker compose up --build app

# Docker — Postgres/Redis now live in the shared ../dev-services stack.
# docker-up brings that shared stack up (idempotent, shared with other repos).
docker-up:
Expand Down
67 changes: 67 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,72 @@ make dev

Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard/` is the authenticated home, and `/admin/doctor/` is the admin-only "smpy doctor" panel (static checks, migrations, dev server, modules).

## Run it in Docker

The repo ships a default image (`./Dockerfile`) that builds the host plus every
bundled module — Python workspace, `gen-pages`, and the production Vite bundle —
and serves it with uvicorn. It is standalone: SQLite lives inside the container
under `/app/data`, so no Postgres and no Redis are needed to boot.

```bash
make docker-app # build ./Dockerfile, then run it on http://localhost:8000
```

Both run targets take `SM_APP_PORT=8010` when a `make dev` already holds
8000.

or without make:

```bash
docker build -t simple-module-python .
docker run --rm -p 8000:8000 -v simple-module-python-data:/app/data simple-module-python
```

**Logging in.** With no `SM_USERS_BOOTSTRAP_*` set, the container seeds
**`admin@example.com` / `changeme`** — the same pair `.env.example` uses for
local dev — and says so on every boot that uses it:

```
entrypoint: WARNING - no SM_USERS_BOOTSTRAP_PASSWORD set, so the
entrypoint: first-boot admin is the public default:
entrypoint: admin@example.com / changeme
```

Pass `-e SM_USERS_BOOTSTRAP_EMAIL=… -e SM_USERS_BOOTSTRAP_PASSWORD=…` to seed
your own instead, and change it before the container is reachable by anyone but
you. The seed only applies while the users table is empty, so on a reused volume
the existing password still stands — reset it with
`smpy users create-admin --email … --password … --force`.

`make docker-compose-app` (or `docker compose up --build app`) runs the same
image through compose with a named volume — independent of the shared
dev-services stack that `worker`/`beat` use.

What the entrypoint does before uvicorn binds: applies `alembic upgrade heads`
(a fresh volume has no tables, and production fails boot on `SM010` when the DB
is behind head), and generates an ephemeral `SM_SECRET_KEY` when none is set —
enough to boot and log in, but sessions die on restart, so set one for anything
real.

The image runs with `SM_ENVIRONMENT=production` because it serves the built
bundle rather than a Vite dev server. Useful overrides:

| Variable | Image default | Why you'd change it |
|---|---|---|
| `SM_SECRET_KEY` | generated per start | Persist sessions across restarts |
| `SM_DATABASE_URL` | `sqlite+aiosqlite:////app/data/app.db` | Point at Postgres |
| `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` | `admin@example.com` / `changeme` | Seed a first admin nobody else can guess |
| `SM_TRUSTED_PROXY` | unset | Set to `*` behind a TLS-terminating reverse proxy |

**No background tasks.** The image skips installing the Celery module
(`uv sync … --no-install-package simple-module-background-tasks`), so nothing in
it wants a broker — a queue means a second process and a Redis, which is the
opposite of a standalone image. `docker-compose.yml`'s `worker` / `beat`
services cover that path: they build `docker/worker.Dockerfile` against the
shared `../dev-services` Postgres + Redis on the external `devnet` network. To
run tasks from the web process instead, drop the `--no-install-package` flag
from the Dockerfile and set `SM_BG_TASKS_BROKER_URL` / `_RESULT_BACKEND`.

## Create a new module

```bash
Expand DownExpand Up@@ -105,6 +171,7 @@ docs/
| `make migration msg="..."` | Autogenerate a new migration |
| `make new-module name=<name>` | Scaffold a new module |
| `make kill` | Stop any running dev servers (ports 8000, 5050, 5173) |
| `make docker-build` / `docker-app` | Build the default app image / run it standalone on port 8000 |
| `make docker-up` / `docker-down` | `docker-up` brings up the shared dev-services stack (Postgres/Redis/MinIO); `docker-down` stops only this repo's worker/beat (SQLite needs no Docker) |

## Configuration
Expand Down
35 changes: 33 additions & 2 deletions docker-compose.yml
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,28 @@
services:
# The default app, built from ./Dockerfile. Self-contained: SQLite on a
# named volume, no Postgres and no Redis required, so
# `docker compose up --build app` works with nothing else running. The image
# ships no Celery module — worker/beat below are the background-jobs half and
# still expect the shared ../dev-services stack.
app:
build:
context: .
dockerfile: Dockerfile
environment:
# Left unset the entrypoint generates an ephemeral key (fine for a demo,
# logs a warning, invalidates sessions on restart).
- SM_SECRET_KEY
# First-boot admin seed, applied only while the users table is empty.
# Unset, the entrypoint seeds admin@example.com with a generated
# password and prints it — see `docker compose logs app`.
- SM_USERS_BOOTSTRAP_EMAIL
- SM_USERS_BOOTSTRAP_PASSWORD
ports:
# SM_APP_PORT frees you from a host-port clash with `make dev`.
- "${SM_APP_PORT:-8000}:8000"
volumes:
- appdata:/app/data

# Postgres + Redis now come from the shared ../dev-services stack
# (one PostGIS + one Redis on the external `devnet` network). Start it first
# with `make up` in ~/Repos/dev-services. This project uses:
Expand All@@ -9,7 +33,9 @@ services:
build:
context: .
dockerfile: docker/worker.Dockerfile
env_file: .env
env_file:
- path: .env
required: false
environment:
SM_BG_TASKS_BROKER_URL: redis://redis:6379/4
SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/5
Expand All@@ -30,7 +56,9 @@ services:
build:
context: .
dockerfile: docker/worker.Dockerfile
env_file: .env
env_file:
- path: .env
required: false
environment:
SM_BG_TASKS_BROKER_URL: redis://redis:6379/4
SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/5
Expand All@@ -52,3 +80,6 @@ services:
networks:
devnet:
external: true

volumes:
appdata:
60 changes: 60 additions & 0 deletions docker/entrypoint.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
#!/bin/sh
# Container entrypoint for the default app image (see ../Dockerfile).
#
# Three things have to happen before uvicorn binds, all of which the image can
# do for itself so `docker run -p 8000:8000 <image>` is enough:
#
# 1. Fill in the secrets production refuses to run without. The image ships
# no baked-in keys (an image everyone can pull is the worst possible
# place for one), so any that are still unset get an ephemeral random
# value — enough to boot and log in, gone on the next start.
# 2. Seed an admin, so a bare `docker run` lands on a login you can pass
# (admin@example.com / changeme unless SM_USERS_BOOTSTRAP_* say otherwise).
# 3. Apply migrations. A fresh SQLite volume has no tables at all, and the
# boot-time SM010 check fails the app in production when the DB revision
# is behind head.
set -e

# SM_SECRET_KEY signs session cookies; the two SM_USERS_* secrets sign
# password-reset and email-verification tokens. All three reject their
# placeholder default when SM_ENVIRONMENT is a production value.
_ephemeral=""
for _var in SM_SECRET_KEY SM_USERS_RESET_PASSWORD_TOKEN_SECRET SM_USERS_VERIFICATION_TOKEN_SECRET; do
eval "_current=\${$_var:-}"
if [ -z "$_current" ]; then
eval "export $_var=\"\$(python -c 'import secrets; print(secrets.token_urlsafe(48))')\""
_ephemeral="$_ephemeral $_var"
fi
done

if [ -n "$_ephemeral" ]; then
echo "entrypoint: generated ephemeral secrets for:$_ephemeral" >&2
echo "entrypoint: sessions and any reset/verification links they sign die on restart — set these to persist them." >&2
fi

# The seed the users module applies only while its table is empty, so this
# can't overwrite an account on a persistent volume — and without it a fresh
# container serves a login page nobody holds credentials for. The default is
# deliberately a well-known one: this image is a starting point you are meant
# to log straight into. Anything running where that matters should pass both
# vars, which is why the banner below is loud rather than silent.
: "${SM_USERS_BOOTSTRAP_EMAIL:=admin@example.com}"
: "${SM_USERS_BOOTSTRAP_PASSWORD:=changeme}"
export SM_USERS_BOOTSTRAP_EMAIL SM_USERS_BOOTSTRAP_PASSWORD

if [ "$SM_USERS_BOOTSTRAP_PASSWORD" = "changeme" ]; then
echo "entrypoint: WARNING - no SM_USERS_BOOTSTRAP_PASSWORD set, so the" >&2
echo "entrypoint: first-boot admin is the public default:" >&2
echo "entrypoint: $SM_USERS_BOOTSTRAP_EMAIL / changeme" >&2
echo "entrypoint: Seeded only while no user exists — an existing install is" >&2
echo "entrypoint: left alone. Set SM_USERS_BOOTSTRAP_EMAIL/_PASSWORD before" >&2
echo "entrypoint: first boot, or change it later with:" >&2
echo "entrypoint: smpy users create-admin --email ... --password ... --force" >&2
fi

# `upgrade heads` (plural) applies every per-module migration branch;
# `upgrade head` (singular) errors once a second module ships a branch label.
echo "entrypoint: applying migrations..." >&2
alembic -c host/alembic.ini upgrade heads

exec "$@"
Loading
Loading