From 157aa37accfdb2b2fe4d47b2a129bc786e29ebec Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 6 Aug 2026 22:54:02 +0200 Subject: [PATCH] feat(cli): ship Docker assets with every smpy new scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every new app now gets a working container story by default instead of only when background_tasks is selected: - docker/host.Dockerfile: uv + Node builder that runs gen-pages before the Vite build (the old optional template skipped it, so the frontend stage could not build any app with module pages), slim non-root runtime, /health healthcheck, `alembic upgrade heads` on start. - docker-compose.yml matched to the --db choice: sqlite scaffolds run the app container alone with the DB on a named volume, postgres scaffolds get a postgres service. Migration histories are dialect-frozen at autogenerate time (sa.false() renders as DEFAULT 0 under SQLite, which Postgres rejects), so containers must run the same dialect the migrations were generated against. - background_tasks adds redis/worker/beat services that reuse the app image with a celery command; the separate worker.Dockerfile is gone. The app service also gets the compose-network broker URLs because BackgroundTasksSettings refuses a localhost broker in production. - smpy new generates real SM_USERS_*_TOKEN_SECRET values into .env.example (like SM_SECRET_KEY) so UsersSettings passes its production-mode boot validation inside containers. - .dockerignore + make docker-up / docker-build / docker-down targets. The BackgroundTasksRecipe slims down to run_worker.py + broker env keys + Make targets; compose/Dockerfile emission moved to the always-run docker_assets step, which knows the whole module selection. Verified end-to-end: built a default scaffold's image and booted the stack — app container healthy, /health 200, landing page 200. Claude-Session: https://claude.ai/code/session_016YtWRT8AVeG84jf7uNMv2s --- CHANGELOG.md | 16 ++ docs/guide/installation.md | 4 +- docs/guide/project-structure.md | 2 +- ...26-08-06-default-docker-scaffold-design.md | 87 ++++++++++ .../cli/simple_module_cli/app_project.py | 12 ++ .../cli/simple_module_cli/docker_assets.py | 100 +++++++++++ framework/cli/simple_module_cli/new.py | 2 + framework/cli/simple_module_cli/recipes.py | 33 ++-- .../templates/docker/Makefile.snippet | 13 ++ .../docker/compose-base-postgres.yml.tpl | 34 ++++ .../docker/compose-base-sqlite.yml.tpl | 22 +++ .../compose-tasks-postgres.yml.tpl} | 58 ++----- .../docker/compose-tasks-sqlite.yml.tpl | 70 ++++++++ .../templates/docker/dockerignore | 16 ++ .../templates/docker/host-flat.Dockerfile | 68 ++++++++ .../templates/docker/host.Dockerfile | 76 +++++++++ .../templates/host/README.md.tpl | 5 + .../background_tasks/host.Dockerfile | 44 ----- .../background_tasks/worker.Dockerfile | 37 ----- .../templates/workspace/README.md.tpl | 17 ++ framework/cli/tests/test_cli_docker_assets.py | 156 ++++++++++++++++++ framework/cli/tests/test_cli_new.py | 18 +- framework/cli/tests/test_cli_recipes.py | 20 +-- 23 files changed, 750 insertions(+), 160 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-06-default-docker-scaffold-design.md create mode 100644 framework/cli/simple_module_cli/docker_assets.py create mode 100644 framework/cli/simple_module_cli/templates/docker/Makefile.snippet create mode 100644 framework/cli/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl create mode 100644 framework/cli/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl rename framework/cli/simple_module_cli/templates/{host/_optional/background_tasks/docker-compose.yml => docker/compose-tasks-postgres.yml.tpl} (50%) create mode 100644 framework/cli/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl create mode 100644 framework/cli/simple_module_cli/templates/docker/dockerignore create mode 100644 framework/cli/simple_module_cli/templates/docker/host-flat.Dockerfile create mode 100644 framework/cli/simple_module_cli/templates/docker/host.Dockerfile delete mode 100644 framework/cli/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile delete mode 100644 framework/cli/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile create mode 100644 framework/cli/tests/test_cli_docker_assets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c97032bf..4698ad7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### Added +- Every `smpy new` scaffold now ships Docker assets by default: a multi-stage + `docker/host.Dockerfile` (uv + Node builder that runs `gen-pages` before the + Vite build, slim non-root runtime that applies migrations on start), a + `docker-compose.yml` matched to the `--db` choice (`app` on a SQLite named + volume, or `postgres` + `app` — migration histories are dialect-frozen at + autogenerate time, so containers run the same DB the migrations were + generated against), plus `redis`/`worker`/`beat` reusing the app image when + `background_tasks` is selected, a `.dockerignore`, and `make docker-up` / + `docker-build` / `docker-down` targets. Previously Docker files only + appeared with `background_tasks`, and their frontend stage couldn't build + real apps (no `gen-pages` step). The separate `worker.Dockerfile` is gone; + worker/beat run the same image with a celery command. `smpy new` also + generates real `SM_USERS_*_TOKEN_SECRET` values into `.env.example` so the + production-mode containers pass `UsersSettings` boot validation. + ### Fixed - Vite's dev-mode dependency pre-bundling now resolves cross-package bare imports (e.g. `maplibre-gl`, `pmtiles`) from module pages whose importers sit diff --git a/docs/guide/installation.md b/docs/guide/installation.md index c99e628f..f0d659e0 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -88,14 +88,14 @@ For Postgres, scaffold with `--db postgres` (or pick Postgres in the wizard) and SM_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/myapp ``` -A `docker-compose.yml` is only generated when you include the `background_tasks` module; it brings up `postgres` (db `simple_module`, user `sm`/`sm`) on `:5432`, `redis` on `:6379`, and the `host`/`worker`/`beat` services: +Every scaffold ships a `docker-compose.yml` that matches your `--db` choice (a migration history is dialect-frozen at autogenerate time, so containers must run the same database the migrations were generated against). With `--db postgres` it brings up `postgres` (db named after your app, user `postgres`/`postgres`) on `:5432` and the `app` service; the default SQLite scaffold runs the `app` container alone with the database on a named volume. Including the `background_tasks` module adds `redis` on `:6379` plus `worker`/`beat` services that reuse the app image. With `--db postgres`, the container also doubles as a local dev database: ```bash docker compose up -d postgres make migrate ``` -Without `background_tasks`, bring your own Postgres (or stay on the default SQLite). See [Configuration](/guide/configuration) for the full list of env vars. +Or run the whole stack in containers — `make docker-up` builds the image (`docker/host.Dockerfile`), applies migrations on start, and serves on `:8000` with `SM_ENVIRONMENT=production`. See [Configuration](/guide/configuration) for the full list of env vars. ## Create the first admin diff --git a/docs/guide/project-structure.md b/docs/guide/project-structure.md index 3f2b523f..73e22943 100644 --- a/docs/guide/project-structure.md +++ b/docs/guide/project-structure.md @@ -35,7 +35,7 @@ myapp/ └── … (see "Anatomy of a module" below) ``` -`smpy new --flat` skips the workspace wrapper and the sample `hello` module, emitting a single-host layout (`main.py`, `client_app/`, `migrations/` at the top level) — use it when the app only consumes published modules and never authors its own. `smpy new --preset minimal` ships fewer pre-installed modules. A `docker-compose.yml` is added only when the `background_tasks` module is included. +`smpy new --flat` skips the workspace wrapper and the sample `hello` module, emitting a single-host layout (`main.py`, `client_app/`, `migrations/` at the top level) — use it when the app only consumes published modules and never authors its own. `smpy new --preset minimal` ships fewer pre-installed modules. Every scaffold also ships Docker assets by default: a `docker/host.Dockerfile`, a `docker-compose.yml` matched to your `--db` choice (`app` on a SQLite volume, or `postgres` + `app`; plus `redis`/`worker`/`beat` when `background_tasks` is included), a `.dockerignore`, and `make docker-up` / `docker-build` / `docker-down` targets. ## Bundled modules diff --git a/docs/superpowers/specs/2026-08-06-default-docker-scaffold-design.md b/docs/superpowers/specs/2026-08-06-default-docker-scaffold-design.md new file mode 100644 index 00000000..15aed5a6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-default-docker-scaffold-design.md @@ -0,0 +1,87 @@ +# Default Docker assets for `smpy new` — design + +**Date:** 2026-08-06 +**Status:** approved (autonomous session — assumptions listed below) + +## Goal + +Every application created by `smpy new` ships a working container story by +default: a `docker/host.Dockerfile`, a `docker-compose.yml`, a +`.dockerignore`, and `make docker-up` / `make docker-build` targets — for +every preset, both DB choices, and both layouts (workspace + flat). + +Today Docker assets appear only when the `background_tasks` module is +selected, via its recipe. A default `smpy new myapp` (standard preset) +produces no Docker files at all. The optional templates are also broken for +real apps: the frontend stage never runs `gen-pages`, so the Vite build +fails on the missing `modules.generated.{ts,css}`, and `worker.Dockerfile` +copies `client_app/` from the root — a path that only exists in flat mode. + +## Decisions + +1. **Docker emission moves to an always-run scaffold step** — + `simple_module_cli/docker_assets.py`, called from `create_app_project` + after module recipes. It knows the resolved module selection, so it can + compose the right service set in one place. The `background_tasks` + recipe no longer writes compose/Dockerfiles (it keeps `run_worker.py`, + the broker env keys, and its Make targets). +2. **One image for app, worker, and beat.** The workspace venv already + contains everything (`uv sync --all-packages`), so worker/beat services + build the same `docker/host.Dockerfile` with a different `command:`. + `worker.Dockerfile` is deleted from the templates. +3. **Dockerfile follows the proven smpy_saas pattern** (the only known + working containerisation of a SimpleModule app): a + `ghcr.io/astral-sh/uv:python3.12-bookworm` builder stage with Node 22 + installed runs `uv sync --all-packages` → `npm ci` → `smpy gen-pages` → + `vite build` → re-sync (so hatch force-include picks up `static/dist`); + a `python:3.12-slim-bookworm` runtime stage copies `.venv` + sources, + runs as a non-root user, healthchecks `GET /health`, and starts with + `alembic upgrade heads` (plural — singular errors once a second module + ships its own branch label) before uvicorn. +4. **Compose matches the scaffold's `--db` choice.** Migration histories + are dialect-frozen at autogenerate time (`sa.false()` compiles to + `DEFAULT 0` on SQLite, which Postgres rejects — found by booting a real + scaffold), so a sqlite scaffold's containers stay on SQLite (named + volume at `/app/data`) and only `--db postgres` scaffolds get a + `postgres` service (db/user matching `.env.example`: + `postgres`/`postgres`, db = kebab-case app slug). All app containers set + `SM_ENVIRONMENT=production` (a container has no Vite dev server; + development mode would emit asset tags pointing at `localhost:5050`). + Selecting `background_tasks` appends `redis`, `worker`, and `beat` + service fragments plus the `redisdata` volume — and injects the broker + URLs into the *app* service too, whose `BackgroundTasksSettings` + otherwise fails production boot on the localhost default. +5. **Compose is assembled from fragments** (per-DB `services` base + + optional per-DB tasks services + a computed `volumes:` block) rather + than merged YAML, keeping the templates dumb and the logic in one small + Python function. `smpy new` also generates real + `SM_USERS_RESET_PASSWORD_TOKEN_SECRET` / + `SM_USERS_VERIFICATION_TOKEN_SECRET` values into `.env.example` (like + `SM_SECRET_KEY`), because `UsersSettings` refuses its placeholder + secrets in production. +6. **Flat mode gets a flat variant Dockerfile** (`npm install` inside + `client_app/`, no `cd host`). Flat is legacy but should not silently + lose the default. + +## Assumptions (would normally be clarifying questions) + +- "Application" means the `smpy new` scaffold (not `make new-module` + modules, which are libraries and don't run standalone). +- Even sqlite-selected apps get the Postgres-backed compose: sqlite is the + zero-config *local dev* default, but a containerised deployment should + not write its database into an ephemeral container layer. +- The shared `~/Repos/dev-services` stack convention is a rule for this + workspace's own repos, not for scaffolded apps shipped to other users — + a self-contained compose is the correct default for the scaffold. + +## Testing + +- New `framework/cli/tests/test_cli_docker_assets.py`: default scaffold + emits compose/Dockerfile/dockerignore/Make targets; compose contains + `postgres` + `app` only; bg-tasks selection adds redis/worker/beat and + reuses `host.Dockerfile`; flat mode emits the flat variant; db name is + substituted. +- `test_cli_new.py` / `test_cli_recipes.py` updated: `worker.Dockerfile` + is gone; the recipe no longer owns compose. +- `docker compose config` validation of a scaffolded app's compose file + (cheap, no image pulls) as part of manual verification. diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index 41d847bf..64e3cea2 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -22,6 +22,7 @@ from simple_module_cli._env import set_env_key from simple_module_cli.case import to_kebab_case, to_pascal_case from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps +from simple_module_cli.docker_assets import scaffold_docker_assets from simple_module_cli.recipes import RECIPES, ScaffoldCtx from simple_module_cli.scaffolding import ( SAFE_PRESERVED_NAMES, @@ -141,6 +142,15 @@ def create_app_project( env_path = target / ".env.example" env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" env_text = set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32)) + if "users" in resolved: + # UsersSettings refuses its placeholder token secrets when + # SM_ENVIRONMENT=production — the mode the compose containers run + # in — so generate real ones, same as SM_SECRET_KEY above. + for key in ( + "SM_USERS_RESET_PASSWORD_TOKEN_SECRET", + "SM_USERS_VERIFICATION_TOKEN_SECRET", + ): + env_text = set_env_key(env_text, key, _secrets.token_urlsafe(32)) env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name), flat=flat)) env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false") env_path.write_text(env_text, encoding="utf-8") @@ -176,6 +186,8 @@ def create_app_project( if recipe_key is not None and recipe_key in RECIPES: RECIPES[recipe_key].apply(target, ctx) + scaffold_docker_assets(target, ctx, flat=flat) + return host_dir, preserved diff --git a/framework/cli/simple_module_cli/docker_assets.py b/framework/cli/simple_module_cli/docker_assets.py new file mode 100644 index 00000000..72459859 --- /dev/null +++ b/framework/cli/simple_module_cli/docker_assets.py @@ -0,0 +1,100 @@ +"""Default Docker assets for ``smpy new`` scaffolds. + +Every new app ships a container story out of the box: a multi-stage +``docker/host.Dockerfile``, a ``docker-compose.yml`` matched to the +scaffold's DB choice (app on a SQLite volume, or postgres + app; plus +redis/worker/beat when ``background_tasks`` is selected), a +``.dockerignore``, and ``docker-*`` Make targets. This runs for every +scaffold — unlike per-module recipes — because the compose service set +depends on the *whole* module selection, which only the scaffolder knows. + +worker/beat reuse the app image with a different command: the workspace +venv already contains everything (``uv sync --all-packages``), so a +separate worker Dockerfile would only duplicate build logic. +""" + +from __future__ import annotations + +import importlib.resources +import shutil +from pathlib import Path + +from simple_module_cli.case import to_kebab_case +from simple_module_cli.recipes import ScaffoldCtx + +__all__ = ["scaffold_docker_assets"] + +_MAKEFILE_MARKER = "# --- docker --" +_PG_DB_TOKEN = "{{PG_DB}}" +_APP_EXTRA_ENV_TOKEN = "{{APP_EXTRA_ENV}}" + +# BackgroundTasksSettings refuses a localhost broker when +# SM_ENVIRONMENT=production, so the app container needs the compose-network +# broker URLs too — not just worker/beat. +_APP_BROKER_ENV = ( + " SM_BG_TASKS_BROKER_URL: redis://redis:6379/0\n" + " SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1\n" +) + + +def _template_root() -> Path: + base = importlib.resources.files("simple_module_cli") + return Path(str(base / "templates" / "docker")) + + +def scaffold_docker_assets(target: Path, ctx: ScaffoldCtx, *, flat: bool = False) -> None: + """Emit compose + Dockerfile + .dockerignore + Make targets at ``target``. + + The compose file is assembled from fragments: a per-DB base (app alone + on SQLite, or postgres + app), per-DB worker services when + ``background_tasks`` is selected, and a computed ``volumes:`` block — + appending fragments keeps the templates plain YAML instead of merge + logic. + """ + templates = _template_root() + compose_dest = target / "docker-compose.yml" + dockerfile_dest = target / "docker" / "host.Dockerfile" + for path in (compose_dest, dockerfile_dest): + if path.exists(): + raise FileExistsError( + f"{path} already exists — refusing to clobber. " + "Remove the file or run `smpy new` against an empty directory." + ) + + dockerfile_dest.parent.mkdir(parents=True, exist_ok=True) + src_name = "host-flat.Dockerfile" if flat else "host.Dockerfile" + shutil.copy2(templates / src_name, dockerfile_dest) + + # The compose stack must match the scaffold's DB choice: a migration + # history is dialect-frozen at autogenerate time (e.g. sa.false() + # compiles to DEFAULT 0 on SQLite, which Postgres rejects), so pointing + # a sqlite-scaffolded app at a Postgres container fails on first boot. + db = "postgres" if ctx.db == "postgres" else "sqlite" + pg_db = to_kebab_case(ctx.name) + with_tasks = "background_tasks" in ctx.selected + compose = ( + _read(templates / f"compose-base-{db}.yml.tpl") + .replace(_PG_DB_TOKEN, pg_db) + .replace(_APP_EXTRA_ENV_TOKEN, _APP_BROKER_ENV if with_tasks else "") + ) + volumes = ["pgdata"] if db == "postgres" else ["appdata"] + if with_tasks: + compose += _read(templates / f"compose-tasks-{db}.yml.tpl").replace(_PG_DB_TOKEN, pg_db) + volumes.append("redisdata") + compose += "\nvolumes:\n" + "".join(f" {name}:\n" for name in volumes) + compose_dest.write_text(compose, encoding="utf-8") + + ignore_dest = target / ".dockerignore" + if not ignore_dest.exists(): + shutil.copy2(templates / "dockerignore", ignore_dest) + + makefile_path = target / "Makefile" + existing = makefile_path.read_text(encoding="utf-8") if makefile_path.exists() else "" + if _MAKEFILE_MARKER not in existing: + snippet = _read(templates / "Makefile.snippet") + sep = "" if existing.endswith("\n") or not existing else "\n" + makefile_path.write_text(existing + sep + snippet, encoding="utf-8") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") diff --git a/framework/cli/simple_module_cli/new.py b/framework/cli/simple_module_cli/new.py index b331818c..0cfaa462 100644 --- a/framework/cli/simple_module_cli/new.py +++ b/framework/cli/simple_module_cli/new.py @@ -141,6 +141,7 @@ def new_project( typer.echo(' make migration msg="initial schema"') typer.echo(" make migrate") typer.echo(" make dev") + typer.echo(" make docker-up # or run the full stack in containers") if "background_tasks" in resolved: typer.echo(" docker compose up -d redis worker beat # background jobs") return @@ -168,6 +169,7 @@ def new_project( # errors once a second module ships its own migration branch label. subprocess.run([*_ALEMBIC, "upgrade", "heads"], cwd=host_dir, check=False) typer.echo("\nSetup complete. Run `make dev` in the new directory.") + typer.echo("To run the full stack in containers instead: make docker-up") if "background_tasks" in resolved: typer.echo("For background jobs, also run: docker compose up -d redis worker beat") diff --git a/framework/cli/simple_module_cli/recipes.py b/framework/cli/simple_module_cli/recipes.py index 6af9825d..ed949b02 100644 --- a/framework/cli/simple_module_cli/recipes.py +++ b/framework/cli/simple_module_cli/recipes.py @@ -49,37 +49,26 @@ def _optional_template_root(name: str) -> Path: class BackgroundTasksRecipe: - """Lays down run_worker.py + compose + Dockerfiles + Make targets.""" + """Lays down run_worker.py + broker env keys + Make targets. + + The compose services (redis/worker/beat) and the shared app image come + from the default Docker assets every scaffold gets — see + :mod:`simple_module_cli.docker_assets`. + """ def apply(self, target: Path, ctx: ScaffoldCtx) -> None: templates = _optional_template_root("background_tasks") run_worker_dest = target / "scripts" / "run_worker.py" - compose_dest = target / "docker-compose.yml" - host_dockerfile_dest = target / "docker" / "host.Dockerfile" - worker_dockerfile_dest = target / "docker" / "worker.Dockerfile" - - for path in ( - run_worker_dest, - compose_dest, - host_dockerfile_dest, - worker_dockerfile_dest, - ): - if path.exists(): - raise FileExistsError( - f"{path} already exists — refusing to clobber. " - "Remove the file or run `smpy new` against an empty directory." - ) + if run_worker_dest.exists(): + raise FileExistsError( + f"{run_worker_dest} already exists — refusing to clobber. " + "Remove the file or run `smpy new` against an empty directory." + ) run_worker_dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(templates / "run_worker.py", run_worker_dest) - shutil.copy2(templates / "docker-compose.yml", compose_dest) - - host_dockerfile_dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(templates / "host.Dockerfile", host_dockerfile_dest) - shutil.copy2(templates / "worker.Dockerfile", worker_dockerfile_dest) - env_path = target / ".env.example" env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" env_path.write_text( diff --git a/framework/cli/simple_module_cli/templates/docker/Makefile.snippet b/framework/cli/simple_module_cli/templates/docker/Makefile.snippet new file mode 100644 index 00000000..27c86dd7 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/docker/Makefile.snippet @@ -0,0 +1,13 @@ +# --- docker -------------------------------------------------------------- +.PHONY: docker-build docker-up docker-down + +docker-build: ## Build the app image + docker compose build + +docker-up: ## Run the full stack in containers + @test -f .env || cp .env.example .env + docker compose up --build + +docker-down: ## Stop the compose stack + docker compose down +# --- end docker ---------------------------------------------------------- diff --git a/framework/cli/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl b/framework/cli/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl new file mode 100644 index 00000000..32025c8f --- /dev/null +++ b/framework/cli/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl @@ -0,0 +1,34 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: "{{PG_DB}}" + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d {{PG_DB}}"] + interval: 5s + timeout: 5s + retries: 10 + + app: + build: + context: . + dockerfile: docker/host.Dockerfile + env_file: + - path: .env + required: false + environment: + # Containers serve the built bundle; development mode would emit + # asset tags pointing at the (absent) Vite dev server. + SM_ENVIRONMENT: production + SM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/{{PG_DB}} +{{APP_EXTRA_ENV}} ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy diff --git a/framework/cli/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl b/framework/cli/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl new file mode 100644 index 00000000..13bb3c8a --- /dev/null +++ b/framework/cli/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl @@ -0,0 +1,22 @@ +services: + app: + build: + context: . + dockerfile: docker/host.Dockerfile + env_file: + - path: .env + required: false + environment: + # Containers serve the built bundle; development mode would emit + # asset tags pointing at the (absent) Vite dev server. + SM_ENVIRONMENT: production + # The scaffold's migrations were autogenerated against SQLite, so the + # container stays on SQLite too (a migration history is dialect-frozen + # at autogenerate time). The named volume keeps the DB across restarts. + # To move to Postgres, re-scaffold with --db postgres or add a postgres + # service and regenerate migrations against it. + SM_DATABASE_URL: sqlite+aiosqlite:////app/data/app.db +{{APP_EXTRA_ENV}} ports: + - "8000:8000" + volumes: + - appdata:/app/data diff --git a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml b/framework/cli/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl similarity index 50% rename from framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml rename to framework/cli/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl index 40f13dff..546f80d0 100644 --- a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +++ b/framework/cli/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl @@ -1,19 +1,3 @@ -services: - postgres: - image: postgres:16 - environment: - POSTGRES_DB: simple_module - POSTGRES_USER: sm - POSTGRES_PASSWORD: sm - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U sm -d simple_module"] - interval: 5s - timeout: 5s - retries: 10 redis: image: redis:7-alpine @@ -27,36 +11,26 @@ services: timeout: 3s retries: 10 - host: - build: - context: . - dockerfile: docker/host.Dockerfile - env_file: .env - environment: - SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module} - ports: - - "8000:8000" - depends_on: - postgres: - condition: service_healthy - + # worker/beat reuse the app image — same code, different command. worker: build: context: . - dockerfile: docker/worker.Dockerfile - env_file: .env + dockerfile: docker/host.Dockerfile + env_file: + - path: .env + required: false environment: + SM_ENVIRONMENT: production + SM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/{{PG_DB}} SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 - SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module} + working_dir: /app depends_on: redis: condition: service_healthy postgres: condition: service_healthy command: - - "uv" - - "run" - "celery" - "-A" - "scripts.run_worker:celery" @@ -68,27 +42,25 @@ services: beat: build: context: . - dockerfile: docker/worker.Dockerfile - env_file: .env + dockerfile: docker/host.Dockerfile + env_file: + - path: .env + required: false environment: + SM_ENVIRONMENT: production + SM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/{{PG_DB}} SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 - SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module} + working_dir: /app depends_on: redis: condition: service_healthy worker: condition: service_started command: - - "uv" - - "run" - "celery" - "-A" - "scripts.run_worker:celery" - "beat" - "-l" - "info" - -volumes: - pgdata: - redisdata: diff --git a/framework/cli/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl b/framework/cli/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl new file mode 100644 index 00000000..738d8770 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl @@ -0,0 +1,70 @@ + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + # worker/beat reuse the app image — same code, different command. They + # share the app's SQLite volume; WAL handles the modest concurrency, but + # move to --db postgres if the task volume grows. + worker: + build: + context: . + dockerfile: docker/host.Dockerfile + env_file: + - path: .env + required: false + environment: + SM_ENVIRONMENT: production + SM_DATABASE_URL: sqlite+aiosqlite:////app/data/app.db + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + working_dir: /app + volumes: + - appdata:/app/data + depends_on: + redis: + condition: service_healthy + command: + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "worker" + - "-l" + - "info" + - "--concurrency=4" + + beat: + build: + context: . + dockerfile: docker/host.Dockerfile + env_file: + - path: .env + required: false + environment: + SM_ENVIRONMENT: production + SM_DATABASE_URL: sqlite+aiosqlite:////app/data/app.db + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + working_dir: /app + volumes: + - appdata:/app/data + depends_on: + redis: + condition: service_healthy + worker: + condition: service_started + command: + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "beat" + - "-l" + - "info" diff --git a/framework/cli/simple_module_cli/templates/docker/dockerignore b/framework/cli/simple_module_cli/templates/docker/dockerignore new file mode 100644 index 00000000..dd3b419d --- /dev/null +++ b/framework/cli/simple_module_cli/templates/docker/dockerignore @@ -0,0 +1,16 @@ +# Kept out of the image build context. Deliberately narrow: modules' +# static/dist placeholders must stay in (hatch force-include needs them). +.git +.venv +node_modules +**/node_modules +__pycache__ +**/__pycache__ +*.py[cod] +.pytest_cache +.ruff_cache +*.db +*.sqlite3 +.env +host/static/dist +static/dist diff --git a/framework/cli/simple_module_cli/templates/docker/host-flat.Dockerfile b/framework/cli/simple_module_cli/templates/docker/host-flat.Dockerfile new file mode 100644 index 00000000..686233b7 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/docker/host-flat.Dockerfile @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1.7 +# App image for a flat-layout SimpleModule host (`smpy new --flat`). +# +# One builder stage holds both uv and Node: the Vite build imports +# modules.generated.{ts,css}, which `smpy gen-pages` emits from the +# *installed Python modules* — so the frontend cannot build in a +# Node-only stage. The same image also serves the celery worker/beat +# services (background_tasks): docker-compose swaps the command. + +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 22 for the Vite build. +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Warm the third-party dep layer before copying the full source. +COPY pyproject.toml uv.lock* ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev --no-install-project + +COPY . . +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +# Flat mode keeps the frontend's own package.json under client_app/. +RUN cd client_app && if [ -f package-lock.json ]; then npm ci; else npm install; fi + +# Page manifest + generated module imports, then the production bundle. +RUN uv run python -m simple_module_hosting gen-pages --host-dir=client_app +RUN cd client_app && npm run build + +# node_modules never ships in the runtime image. +RUN rm -rf node_modules client_app/node_modules + +FROM python:3.12-slim-bookworm AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" + +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 + +# /app/data backs the SQLite named volume (harmless for Postgres apps). +RUN mkdir -p /app/data \ + && useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin app \ + && chown -R app:app /app +USER app + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:8000/health || exit 1 + +# `upgrade heads` (plural) applies every per-module migration branch; +# `upgrade head` (singular) errors once a second module ships its own +# branch label. +WORKDIR /app +CMD ["sh", "-c", "alembic upgrade heads && uvicorn main:app --host 0.0.0.0 --port 8000"] diff --git a/framework/cli/simple_module_cli/templates/docker/host.Dockerfile b/framework/cli/simple_module_cli/templates/docker/host.Dockerfile new file mode 100644 index 00000000..182d1293 --- /dev/null +++ b/framework/cli/simple_module_cli/templates/docker/host.Dockerfile @@ -0,0 +1,76 @@ +# syntax=docker/dockerfile:1.7 +# App image for a workspace-layout SimpleModule host. +# +# One builder stage holds both uv and Node: the Vite build imports +# modules.generated.{ts,css}, which `smpy gen-pages` emits from the +# *installed Python modules* — so the frontend cannot build in a +# Node-only stage. The same image also serves the celery worker/beat +# services (background_tasks): docker-compose swaps the command. + +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 22 for the Vite build. +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Warm the third-party dep layer before copying the full source; workspace +# members themselves install after the real COPY below. +COPY pyproject.toml uv.lock* ./ +COPY host/pyproject.toml host/ +COPY modules/ modules/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --all-packages --no-dev --no-install-workspace + +COPY package.json package-lock.json* ./ +COPY host/client_app/package.json host/client_app/ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi + +COPY . . +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --all-packages --no-dev + +# Page manifest + generated module imports, then the production bundle. +RUN cd host && uv run python -m simple_module_hosting gen-pages --host-dir=client_app +RUN npm run build + +# Re-sync so hatch force-include picks up freshly built module static/dist. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --all-packages --no-dev + +# node_modules never ships in the runtime image. +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" + +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 + +# /app/data backs the SQLite named volume (harmless for Postgres apps). +RUN mkdir -p /app/data \ + && useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin app \ + && chown -R app:app /app +USER app + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:8000/health || exit 1 + +# `upgrade heads` (plural) applies every per-module migration branch; +# `upgrade head` (singular) errors once a second module ships its own +# branch label. +WORKDIR /app/host +CMD ["sh", "-c", "alembic upgrade heads && uvicorn main:app --host 0.0.0.0 --port 8000"] diff --git a/framework/cli/simple_module_cli/templates/host/README.md.tpl b/framework/cli/simple_module_cli/templates/host/README.md.tpl index f53095f3..591a1bbe 100644 --- a/framework/cli/simple_module_cli/templates/host/README.md.tpl +++ b/framework/cli/simple_module_cli/templates/host/README.md.tpl @@ -26,6 +26,11 @@ Dev UI + API together (when you have a `client_app/` alongside this file): make dev # if you ship a Makefile; otherwise run vite + uvicorn in two shells ``` +Apps scaffolded by `smpy new` also ship Docker assets — `make docker-up` +builds `docker/host.Dockerfile` and runs the full stack from +`docker-compose.yml` (services match your `--db` choice), applying +migrations on container start. + ## Adding a module ```bash diff --git a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile deleted file mode 100644 index 3d400f8d..00000000 --- a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +++ /dev/null @@ -1,44 +0,0 @@ -# FastAPI host image. Multi-stage: Node builds the Vite client bundle, -# Python serves uvicorn. Migrations run on container start. - -FROM node:22-slim AS frontend -WORKDIR /app -COPY package.json package-lock.json* ./ -COPY host/client_app/package.json host/client_app/ -COPY modules/ modules/ -RUN npm ci --workspaces --include-workspace-root -COPY host/ host/ -RUN npm --workspace host/client_app run build - -FROM python:3.12-slim AS runtime - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - UV_LINK_MODE=copy \ - UV_COMPILE_BYTECODE=1 \ - UV_SYSTEM_PYTHON=1 - -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates build-essential \ - && rm -rf /var/lib/apt/lists/* \ - && pip install --no-cache-dir uv - -WORKDIR /app - -COPY pyproject.toml uv.lock* ./ -COPY host/pyproject.toml host/ -COPY modules/ modules/ -RUN uv sync --all-packages --no-dev - -COPY host/ host/ -COPY --from=frontend /app/host/static/dist host/static/dist - -RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin app \ - && chown -R app:app /app -USER app - -EXPOSE 8000 -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD curl -fsS http://localhost:8000/health || exit 1 - -CMD ["sh", "-c", "cd host && uv run alembic upgrade head && uv run uvicorn main:app --host 0.0.0.0 --port 8000"] diff --git a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile deleted file mode 100644 index 120e96f3..00000000 --- a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# Celery worker image for the BackgroundTasks module. -# Serves both the worker and beat services in docker-compose — they -# differ only by command. - -FROM python:3.12-slim AS base - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - UV_LINK_MODE=copy \ - UV_COMPILE_BYTECODE=1 \ - UV_SYSTEM_PYTHON=1 - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - curl \ - ca-certificates \ - build-essential \ - && rm -rf /var/lib/apt/lists/* \ - && pip install --no-cache-dir uv - -WORKDIR /app - -COPY pyproject.toml uv.lock ./ -COPY scripts/ scripts/ -COPY client_app/ client_app/ - -RUN uv sync --frozen --no-dev - -RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin worker \ - && chown -R worker:worker /app -USER worker - -ENV CELERY_APP=scripts.run_worker:celery -HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD uv run celery -A $CELERY_APP inspect ping -d celery@$HOSTNAME || exit 1 - -CMD ["uv", "run", "celery", "-A", "scripts.run_worker:celery", "worker", "-l", "info"] diff --git a/framework/cli/simple_module_cli/templates/workspace/README.md.tpl b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl index cffea9b4..c9d0aac8 100644 --- a/framework/cli/simple_module_cli/templates/workspace/README.md.tpl +++ b/framework/cli/simple_module_cli/templates/workspace/README.md.tpl @@ -35,6 +35,23 @@ make dev The API listens on http://localhost:8000 and Vite on http://localhost:5050. +## Running in Docker + +The scaffold ships `docker/host.Dockerfile`, a `docker-compose.yml`, and a +`.dockerignore`: + +```bash +make docker-up # build the image and run the full stack in containers +make docker-down # stop it +``` + +The container builds the frontend, applies migrations on start, and serves +on http://localhost:8000 with `SM_ENVIRONMENT=production`. The compose +services match the database you scaffolded with: SQLite apps run the app +container alone (DB on a named volume), `--db postgres` apps also get a +`postgres` service you can reuse as a local dev database +(`docker compose up -d postgres`). + ## Adding a module ```bash diff --git a/framework/cli/tests/test_cli_docker_assets.py b/framework/cli/tests/test_cli_docker_assets.py new file mode 100644 index 00000000..fe4adbe9 --- /dev/null +++ b/framework/cli/tests/test_cli_docker_assets.py @@ -0,0 +1,156 @@ +"""Tests for the default Docker assets every ``smpy new`` app receives.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from simple_module_cli.app_project import create_app_project + + +def _create( + target: Path, + selected: list[str] | None = None, + *, + db: str = "sqlite", + flat: bool = False, +) -> None: + create_app_project( + target, + name="demo-app", + db=db, + tenancy=False, + selected=selected, + flat=flat, + ) + + +def test_default_app_ships_docker_assets(tmp_path: Path) -> None: + target = tmp_path / "demo" + _create(target) + assert (target / "docker-compose.yml").is_file() + assert (target / "docker" / "host.Dockerfile").is_file() + assert (target / ".dockerignore").is_file() + assert not (target / "docker" / "worker.Dockerfile").exists() + + +def test_sqlite_scaffold_compose_stays_on_sqlite(tmp_path: Path) -> None: + # Migrations are dialect-frozen at autogenerate time (sa.false() renders + # as DEFAULT 0 on SQLite, which Postgres rejects), so a sqlite scaffold + # must not point its containers at a Postgres service. + target = tmp_path / "demo" + _create(target) + compose = (target / "docker-compose.yml").read_text() + assert "sqlite+aiosqlite:////app/data/app.db" in compose + assert "postgresql+asyncpg" not in compose + assert "image: postgres" not in compose + assert "appdata:/app/data" in compose + assert "redis:" not in compose + assert "worker:" not in compose + + +def test_postgres_scaffold_compose_ships_postgres(tmp_path: Path) -> None: + target = tmp_path / "demo" + _create(target, db="postgres") + compose = (target / "docker-compose.yml").read_text() + assert 'POSTGRES_DB: "demo-app"' in compose + assert "postgresql+asyncpg://postgres:postgres@postgres:5432/demo-app" in compose + assert "pgdata:" in compose + assert "sqlite" not in compose + + +def test_compose_containers_run_production_env(tmp_path: Path) -> None: + # Development mode emits asset tags pointing at the (absent) Vite dev + # server, so containers must boot in production mode. + target = tmp_path / "demo" + _create(target) + compose = (target / "docker-compose.yml").read_text() + assert "SM_ENVIRONMENT: production" in compose + + +def test_background_tasks_adds_worker_services_on_same_image(tmp_path: Path) -> None: + target = tmp_path / "demo" + _create(target, selected=["users", "background_tasks"]) + compose = (target / "docker-compose.yml").read_text() + for service in ("redis:", "worker:", "beat:"): + assert service in compose + assert "scripts.run_worker:celery" in compose + assert "redisdata:" in compose + # worker/beat build the app image — no separate worker Dockerfile. + assert "worker.Dockerfile" not in compose + assert not (target / "docker" / "worker.Dockerfile").exists() + + +def test_app_service_gets_broker_urls_with_background_tasks(tmp_path: Path) -> None: + # BackgroundTasksSettings fails production boot on a localhost broker, + # so the *app* container needs the compose-network broker too. + yaml = pytest.importorskip("yaml") + target = tmp_path / "demo" + _create(target, selected=["users", "background_tasks"]) + data = yaml.safe_load((target / "docker-compose.yml").read_text()) + app_env = data["services"]["app"]["environment"] + assert app_env["SM_BG_TASKS_BROKER_URL"] == "redis://redis:6379/0" + assert app_env["SM_BG_TASKS_RESULT_BACKEND"] == "redis://redis:6379/1" + # ...and a plain app must not carry background-tasks config. + plain = tmp_path / "plain" + _create(plain) + plain_env = yaml.safe_load((plain / "docker-compose.yml").read_text())["services"]["app"][ + "environment" + ] + assert "SM_BG_TASKS_BROKER_URL" not in plain_env + + +def test_compose_parses_as_yaml_in_every_shape(tmp_path: Path) -> None: + yaml = pytest.importorskip("yaml") + shapes = { + "sqlite-plain": ({}, {"app"}, "appdata"), + "sqlite-tasks": ( + {"selected": ["users", "background_tasks"]}, + {"app", "redis", "worker", "beat"}, + "appdata", + ), + "pg-plain": ({"db": "postgres"}, {"postgres", "app"}, "pgdata"), + "pg-tasks": ( + {"db": "postgres", "selected": ["users", "background_tasks"]}, + {"postgres", "app", "redis", "worker", "beat"}, + "pgdata", + ), + } + for name, (kwargs, services, volume) in shapes.items(): + target = tmp_path / name + _create(target, **kwargs) + data = yaml.safe_load((target / "docker-compose.yml").read_text()) + assert set(data["services"]) == services, name + assert volume in data["volumes"], name + + +def test_dockerfile_runs_gen_pages_before_frontend_build(tmp_path: Path) -> None: + # The Vite build imports modules.generated.{ts,css}, which gen-pages + # emits from the installed Python modules — order is load-bearing. + target = tmp_path / "demo" + _create(target) + dockerfile = (target / "docker" / "host.Dockerfile").read_text() + assert "gen-pages" in dockerfile + assert dockerfile.index("gen-pages") < dockerfile.index("npm run build") + # Plural `heads` — singular errors once a second module ships its own + # migration branch label. + assert "alembic upgrade heads" in dockerfile + + +def test_flat_mode_gets_flat_dockerfile_variant(tmp_path: Path) -> None: + target = tmp_path / "demo" + _create(target, flat=True) + dockerfile = (target / "docker" / "host.Dockerfile").read_text() + assert "flat-layout" in dockerfile + assert "cd host" not in dockerfile + assert (target / "docker-compose.yml").is_file() + assert (target / ".dockerignore").is_file() + + +def test_makefile_gets_docker_targets(tmp_path: Path) -> None: + target = tmp_path / "demo" + _create(target) + makefile = (target / "Makefile").read_text() + assert "docker-build:" in makefile + assert "docker-up:" in makefile + assert "docker-down:" in makefile diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index a1162711..1a1fe04a 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -71,6 +71,21 @@ def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: assert len(secret_line.split("=", 1)[1]) >= 20 +def test_sm_new_writes_generated_users_token_secrets(tmp_path: Path) -> None: + # UsersSettings refuses its placeholder token secrets in production — + # the mode the scaffold's compose containers boot in. + runner = CliRunner() + target = tmp_path / "my-app" + runner.invoke( + app, + ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + env_text = (target / ".env.example").read_text() + for key in ("SM_USERS_RESET_PASSWORD_TOKEN_SECRET", "SM_USERS_VERIFICATION_TOKEN_SECRET"): + line = next(ln for ln in env_text.splitlines() if ln.startswith(f"{key}=")) + assert len(line.split("=", 1)[1]) >= 20 + + def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: from simple_module_cli.app_project import create_app_project @@ -104,7 +119,8 @@ def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> assert (target / "scripts" / "run_worker.py").is_file() assert (target / "docker-compose.yml").is_file() assert (target / "docker" / "host.Dockerfile").is_file() - assert (target / "docker" / "worker.Dockerfile").is_file() + # worker/beat reuse the app image — no separate worker Dockerfile. + assert not (target / "docker" / "worker.Dockerfile").exists() makefile_text = (target / "Makefile").read_text() assert "worker:" in makefile_text diff --git a/framework/cli/tests/test_cli_recipes.py b/framework/cli/tests/test_cli_recipes.py index bd6ab95d..02d67ffd 100644 --- a/framework/cli/tests/test_cli_recipes.py +++ b/framework/cli/tests/test_cli_recipes.py @@ -36,22 +36,22 @@ def test_recipe_writes_run_worker_script(tmp_path: Path) -> None: assert "celery = build_celery(BackgroundTasksSettings())" in text -def test_recipe_writes_compose_with_redis_worker_beat(tmp_path: Path) -> None: +def test_recipe_leaves_docker_assets_to_the_scaffold(tmp_path: Path) -> None: + # Compose + Dockerfile are default scaffold output (docker_assets.py); + # the recipe must not write or clobber them. _scaffold_minimal_host(tmp_path) BackgroundTasksRecipe().apply(tmp_path, _ctx()) - compose = (tmp_path / "docker-compose.yml").read_text() - assert "redis:" in compose - assert "worker:" in compose - assert "beat:" in compose - assert "scripts.run_worker:celery" in compose + assert not (tmp_path / "docker-compose.yml").exists() + assert not (tmp_path / "docker").exists() -def test_recipe_writes_worker_dockerfile(tmp_path: Path) -> None: +def test_recipe_tolerates_pre_existing_compose(tmp_path: Path) -> None: + # docker_assets.py runs after recipes in create_app_project, but the + # recipe API is also public — a compose file on disk is not an error. _scaffold_minimal_host(tmp_path) + (tmp_path / "docker-compose.yml").write_text("services: {}\n") BackgroundTasksRecipe().apply(tmp_path, _ctx()) - dockerfile = (tmp_path / "docker" / "worker.Dockerfile").read_text() - assert "FROM python:3.12-slim" in dockerfile - assert "scripts.run_worker:celery" in dockerfile + assert (tmp_path / "scripts" / "run_worker.py").is_file() def test_recipe_appends_makefile_targets(tmp_path: Path) -> None: