diff --git a/build.py b/build.py index 94070f5..031c334 100644 --- a/build.py +++ b/build.py @@ -26,6 +26,8 @@ import argparse import json import logging import shutil +import stat +import subprocess import sys from pathlib import Path @@ -48,6 +50,35 @@ def ensure_dir(path: Path): path.mkdir(parents=True, exist_ok=True) +def _rmtree_resilient(path: Path): + """Remove path tree, tolerating root-owned files written by containers. + + Docker containers that mount gen/ as a volume sometimes write files as + root (e.g. __pycache__). A plain shutil.rmtree then fails with EACCES. + We first try shutil.rmtree; if that hits a PermissionError we fall back + to deleting the offending files from inside an ephemeral alpine container. + """ + def _chmod_and_retry(func, target, exc_info): + try: + Path(target).chmod(stat.S_IWUSR | stat.S_IRUSR | stat.S_IXUSR) + func(target) + except Exception: + raise + + try: + shutil.rmtree(path, onerror=_chmod_and_retry) + return + except PermissionError: + pass + + log.info(" (falling back to docker-based cleanup)") + subprocess.run( + ["docker", "run", "--rm", "-v", f"{path.parent}:/work", + "alpine:3", "sh", "-c", f"rm -rf /work/{path.name}"], + check=True, + ) + + def copy_path(source: Path, target: Path, quiet: bool = False): """Copy file or directory, resolving symlinks.""" if target.is_symlink(): @@ -162,9 +193,11 @@ def build_managed(output_dir: Path, cfg_name: str, config: dict): log.info(f"Building managed ({managed_name})...") - # Copy repos + # Copy repos (relative paths resolve from SPR_ROOT) for repo_name, repo_path in repos.items(): source = Path(repo_path) + if not source.is_absolute(): + source = SPR_ROOT / source target = managed_dir / repo_name if copy_repo(source, target): log.info(f" {repo_name}/") @@ -332,7 +365,7 @@ def build(output_dir: Path, cfg_name: str | None = None, clean: bool = True): # Clean output directory first if clean and output_dir.exists(): log.info(f"Cleaning {output_dir}...") - shutil.rmtree(output_dir) + _rmtree_resilient(output_dir) ensure_dir(output_dir) @@ -349,6 +382,15 @@ def build(output_dir: Path, cfg_name: str | None = None, clean: bool = True): # Standalone: everything in output_dir build_soleprint(output_dir, room) + # Layer 7 (optional): render kind-cluster manifests + try: + from soleprint.ctrl.k8s import render_k8s + from soleprint.ctrl.k8s.render import k8s_enabled + if k8s_enabled(config): + render_k8s(room=room, config=config, gen_dir=output_dir) + except ImportError as e: + log.warning(f"k8s rendering unavailable: {e}") + log.info(f"\n✓ Built: {output_dir}") diff --git a/cfg/.gitignore b/cfg/.gitignore index 8dcd0a1..4e9fc9e 100644 --- a/cfg/.gitignore +++ b/cfg/.gitignore @@ -10,6 +10,5 @@ __pycache__/ *.pyc *.pyo -# These are kept in main soleprint repo as templates -standalone/ -sample/ +# standalone/ and sample/ are kept in the main soleprint repo as templates. +# Other rooms (amar/, dlt/, etc.) are listed in the repo-root .gitignore. diff --git a/cfg/sample/.env.example b/cfg/sample/.env.example new file mode 100644 index 0000000..5bb57b9 --- /dev/null +++ b/cfg/sample/.env.example @@ -0,0 +1,14 @@ +# ============================================================================= +# sample room — Copy to .env and customize +# ============================================================================= + +DEPLOYMENT_NAME=sample +NETWORK_NAME=sample_network + +BACKEND_PORT=8120 +FRONTEND_PORT=3120 +DB_PORT=5532 + +POSTGRES_DB=fixture +POSTGRES_USER=postgres +POSTGRES_PASSWORD=localdev123 diff --git a/cfg/sample/config.json b/cfg/sample/config.json index 2442333..bac7a7c 100644 --- a/cfg/sample/config.json +++ b/cfg/sample/config.json @@ -17,7 +17,14 @@ }, "veins": ["google"], "managed": { - "name": "sample" + "name": "sample", + "repos": { + "backend": "examples/fixture-invoicing/backend", + "frontend": "examples/fixture-invoicing/frontend" + } + }, + "k8s": { + "enabled": true }, "systems": [ { diff --git a/cfg/sample/ctrl/deploy.sh b/cfg/sample/ctrl/deploy.sh new file mode 100755 index 0000000..dcd5299 --- /dev/null +++ b/cfg/sample/ctrl/deploy.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Deploy dlt to AWS +# +# Usage: +# ./ctrl/deploy.sh # Sync and restart +# ./ctrl/deploy.sh --rebuild # Rebuild container image +# ./ctrl/deploy.sh --dry-run # Preview sync + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOM_DIR="$(dirname "$SCRIPT_DIR")" +ROOM_NAME="$(basename "$ROOM_DIR")" +SPR_DIR="$(cd "$ROOM_DIR/../.." && pwd)" + +SERVER="${DEPLOY_SERVER:-mcrn.ar}" +REMOTE_BASE="${DEPLOY_REMOTE_PATH:-~/soleprint/gen}" +REMOTE_PATH="$REMOTE_BASE/$ROOM_NAME/soleprint" +LOCAL_PATH="$SPR_DIR/gen/$ROOM_NAME/soleprint" + +DRY_RUN="" +REBUILD="" +[ "$1" == "--dry-run" ] && DRY_RUN="--dry-run" +[ "$1" == "--rebuild" ] && REBUILD="--build" + +# Check if built +if [[ ! -d "$LOCAL_PATH" ]]; then + echo "Error: $LOCAL_PATH not found" + echo "Build first: cd $SPR_DIR && python build.py --cfg $ROOM_NAME" + exit 1 +fi + +echo "=== Deploying $ROOM_NAME to $SERVER ===" + +# Sync files, excluding local-only +rsync -avz --delete --progress $DRY_RUN \ + --exclude='.env' \ + --exclude='__pycache__/' \ + --exclude='*.pyc' \ + --exclude='**/storage/' \ + "$LOCAL_PATH/" \ + "$SERVER:$REMOTE_PATH/" + +if [ -n "$DRY_RUN" ]; then + echo "=== Dry run complete ===" + exit 0 +fi + +# Get container name from AWS .env +CONTAINER_NAME=$(ssh "$SERVER" "grep DEPLOYMENT_NAME $REMOTE_PATH/.env 2>/dev/null | cut -d= -f2" || echo "${ROOM_NAME}_spr") + +echo "" +echo "=== Restarting $CONTAINER_NAME ===" +ssh "$SERVER" "cd $REMOTE_PATH && docker compose down && docker compose up -d $REBUILD" + +echo "" +echo "=== Connecting to gateway network ===" +ssh "$SERVER" "docker network connect gateway $CONTAINER_NAME 2>/dev/null || true" + +echo "" +echo "=== Done ===" +echo "Test at: https://$ROOM_NAME.spr.mcrn.ar" diff --git a/cfg/sample/ctrl/start.sh b/cfg/sample/ctrl/start.sh index bc40696..a32838b 100755 --- a/cfg/sample/ctrl/start.sh +++ b/cfg/sample/ctrl/start.sh @@ -1,28 +1,45 @@ #!/bin/bash -# Start all sample services -# Usage: ./ctrl/start.sh [-d] +# Start sample room (fixture invoicing + link + soleprint) +# +# Usage: +# ./start.sh # Start all (foreground, with nginx) +# ./start.sh -d # Start all (detached) +# ./start.sh --no-nginx # Skip nginx reverse proxy +# ./start.sh --build # Rebuild images + +set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" +APP_DIR="$ROOT_DIR/sample" +LINK_DIR="$ROOT_DIR/link" +SPR_DIR="$ROOT_DIR/soleprint" +BUILD="" DETACH="" -if [[ "$1" == "-d" ]]; then - DETACH="-d" +NGINX_OVERRIDE="-f docker-compose.nginx.yml" + +for arg in "$@"; do + case $arg in + -d|--detached) DETACH="-d" ;; + --build) BUILD="--build" ;; + --no-nginx) NGINX_OVERRIDE="" ;; + esac +done + +# Ensure shared docker network exists (soleprint + managed app share it) +NETWORK="$(grep -m1 '^NETWORK_NAME=' "$APP_DIR/.env" | cut -d= -f2)" +docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" + +echo "=== Starting sample ===" + +# 1) Managed app (db + backend + frontend) +(cd "$APP_DIR" && docker compose up -d $BUILD) + +# 2) Link bridge +if [[ -f "$LINK_DIR/docker-compose.yml" ]]; then + (cd "$LINK_DIR" && docker compose --env-file "$APP_DIR/.env" up -d $BUILD) fi -echo "=== Starting sample services ===" - -# Start soleprint -echo "Starting soleprint..." -cd "$ROOT_DIR/soleprint" -docker compose up $DETACH & - -# Start sample app -if [[ -f "$ROOT_DIR/sample/docker-compose.yml" ]]; then - echo "Starting sample app..." - cd "$ROOT_DIR/sample" - docker compose up $DETACH & -fi - -wait -echo "=== All services started ===" +# 3) Soleprint (+ nginx sidebar injection) +(cd "$SPR_DIR" && docker compose -f docker-compose.yml $NGINX_OVERRIDE up $DETACH $BUILD) diff --git a/cfg/sample/ctrl/stop.sh b/cfg/sample/ctrl/stop.sh index d45169b..6852d08 100755 --- a/cfg/sample/ctrl/stop.sh +++ b/cfg/sample/ctrl/stop.sh @@ -1,22 +1,15 @@ #!/bin/bash -# Stop all sample services -# Usage: ./ctrl/stop.sh +# Stop sample room + +set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" -echo "=== Stopping sample services ===" +echo "=== Stopping sample ===" -# Stop sample app -if [[ -f "$ROOT_DIR/sample/docker-compose.yml" ]]; then - echo "Stopping sample app..." - cd "$ROOT_DIR/sample" - docker compose down -fi - -# Stop soleprint -echo "Stopping soleprint..." -cd "$ROOT_DIR/soleprint" -docker compose down - -echo "=== All services stopped ===" +for d in "$ROOT_DIR/soleprint" "$ROOT_DIR/link" "$ROOT_DIR/sample"; do + if [[ -f "$d/docker-compose.yml" ]]; then + (cd "$d" && docker compose down) + fi +done diff --git a/cfg/sample/data/__init__.py b/cfg/sample/data/__init__.py new file mode 100644 index 0000000..9836af5 --- /dev/null +++ b/cfg/sample/data/__init__.py @@ -0,0 +1,156 @@ +""" +Pawprint Data Layer + +JSON file storage (future: MongoDB) +""" + +import json +from pathlib import Path +from typing import List, Optional + +# Add parent to path for models import +import sys +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from models.pydantic import ( + Vein, Nest, Larder, Template, Tool, + Pulse, Book, Table, + VeinCollection, NestCollection, LarderCollection, + TemplateCollection, ToolCollection, + PulseCollection, BookCollection, TableCollection, + Status +) + +DATA_DIR = Path(__file__).parent.resolve() + +# Debug: print data dir on import +print(f"[data] DATA_DIR: {DATA_DIR}") +print(f"[data] DATA_DIR exists: {DATA_DIR.exists()}") +print(f"[data] veins.json exists: {(DATA_DIR / 'veins.json').exists()}") + + +def _load_json(filename: str) -> dict: + filepath = DATA_DIR / filename + if filepath.exists(): + with open(filepath) as f: + data = json.load(f) + print(f"[data] Loaded {filename}: {len(data.get('items', []))} items") + return data + print(f"[data] File not found: {filepath}") + return {"items": []} + + +def _save_json(filename: str, data: dict): + filepath = DATA_DIR / filename + with open(filepath, 'w') as f: + json.dump(data, f, indent=2) + + +# === Loaders === + +def get_veins() -> List[Vein]: + data = _load_json("veins.json") + return VeinCollection(**data).items + + +def get_nests() -> List[Nest]: + data = _load_json("nests.json") + return NestCollection(**data).items + + +def get_larders() -> List[Larder]: + data = _load_json("larders.json") + return LarderCollection(**data).items + + +def get_templates() -> List[Template]: + data = _load_json("templates.json") + return TemplateCollection(**data).items + + +def get_tools() -> List[Tool]: + data = _load_json("tools.json") + return ToolCollection(**data).items + + +def get_cabinets() -> list: + """Load cabinets (simple dict, no pydantic yet).""" + data = _load_json("cabinets.json") + return data.get("items", []) + + +def get_monitors() -> list: + """Load monitors (simple dict, no pydantic yet).""" + data = _load_json("monitors.json") + return data.get("items", []) + + +def get_pulses() -> List[Pulse]: + data = _load_json("pulses.json") + return PulseCollection(**data).items + + +def get_books() -> List[Book]: + data = _load_json("books.json") + return BookCollection(**data).items + + +def get_tables() -> List[Table]: + data = _load_json("tables.json") + return TableCollection(**data).items + + +# === Helpers === + +def get_vein(name: str) -> Optional[Vein]: + for v in get_veins(): + if v.name == name: + return v + return None + + +def get_nest(name: str) -> Optional[Nest]: + for n in get_nests(): + if n.name == name: + return n + return None + + +def get_larder(name: str) -> Optional[Larder]: + for l in get_larders(): + if l.name == name: + return l + return None + + +# === For frontend rendering === + +def get_artery_data() -> dict: + """Data for artery frontend.""" + return { + "veins": [v.model_dump() for v in get_veins()], + "nests": [n.model_dump() for n in get_nests()], + "larders": [l.model_dump() for l in get_larders()], + "pulses": [p.model_dump() for p in get_pulses()], + } + + +def get_album_data() -> dict: + """Data for album frontend.""" + return { + "templates": [t.model_dump() for t in get_templates()], + "larders": [l.model_dump() for l in get_larders()], + "books": [b.model_dump() for b in get_books()], + } + + +def get_ward_data() -> dict: + """Data for ward frontend.""" + return { + "tools": [t.model_dump() for t in get_tools()], + "monitors": get_monitors(), + "cabinets": get_cabinets(), + "nests": [n.model_dump() for n in get_nests()], + "larders": [l.model_dump() for l in get_larders()], + "tables": [t.model_dump() for t in get_tables()], + } diff --git a/cfg/sample/data/books.json b/cfg/sample/data/books.json new file mode 100644 index 0000000..69852c7 --- /dev/null +++ b/cfg/sample/data/books.json @@ -0,0 +1,14 @@ +{ + "items": [ + { + "name": "feature-flow", + "slug": "feature-flow", + "title": "Feature Flow Pipeline", + "status": "ready", + "template": null, + "larder": null, + "output_larder": null, + "system": "atlas" + } + ] +} diff --git a/cfg/sample/data/depots.json b/cfg/sample/data/depots.json new file mode 100644 index 0000000..72be870 --- /dev/null +++ b/cfg/sample/data/depots.json @@ -0,0 +1,12 @@ +{ + "items": [ + { + "name": "feature-form", + "slug": "feature-form", + "title": "Feature Forms", + "status": "ready", + "source_template": "feature-form", + "data_path": "album/book/feature-form-samples/feature-form" + } + ] +} diff --git a/cfg/sample/data/desks.json b/cfg/sample/data/desks.json new file mode 100644 index 0000000..2feb210 --- /dev/null +++ b/cfg/sample/data/desks.json @@ -0,0 +1,3 @@ +{ + "items": [] +} diff --git a/cfg/sample/data/monitors.json b/cfg/sample/data/monitors.json new file mode 100644 index 0000000..503de9d --- /dev/null +++ b/cfg/sample/data/monitors.json @@ -0,0 +1,22 @@ +{ + "items": [ + { + "name": "turnos", + "slug": "turnos", + "title": "Turnos Monitor", + "status": "dev", + "system": "ward", + "description": "Pipeline view of requests → turnos. Shows vet-petowner at a glance.", + "path": "ward/monitor/turnos" + }, + { + "name": "data_browse", + "slug": "data-browse", + "title": "Data Browse", + "status": "ready", + "system": "ward", + "description": "Quick navigation to test users and data states. Book/larder pattern with SQL mode for manual testing workflows.", + "path": "ward/monitor/data_browse" + } + ] +} diff --git a/cfg/sample/data/plexuses.json b/cfg/sample/data/plexuses.json new file mode 100644 index 0000000..2feb210 --- /dev/null +++ b/cfg/sample/data/plexuses.json @@ -0,0 +1,3 @@ +{ + "items": [] +} diff --git a/cfg/sample/data/pulses.json b/cfg/sample/data/pulses.json new file mode 100644 index 0000000..2feb210 --- /dev/null +++ b/cfg/sample/data/pulses.json @@ -0,0 +1,3 @@ +{ + "items": [] +} diff --git a/cfg/sample/data/rooms.json b/cfg/sample/data/rooms.json new file mode 100644 index 0000000..9310a6e --- /dev/null +++ b/cfg/sample/data/rooms.json @@ -0,0 +1,5 @@ +{ + "items": [ + {"name": "pawprint-local", "slug": "pawprint-local", "title": "Pawprint Local", "status": "dev", "config_path": "deploy/pawprint-local"} + ] +} diff --git a/cfg/sample/data/shunts.json b/cfg/sample/data/shunts.json new file mode 100644 index 0000000..bff7fcf --- /dev/null +++ b/cfg/sample/data/shunts.json @@ -0,0 +1,18 @@ +{ + "items": [ + { + "name": "mercadopago", + "slug": "mercadopago", + "title": "MercadoPago", + "status": "ready", + "description": "Mock payment API for testing" + }, + { + "name": "example", + "slug": "example", + "title": "Example", + "status": "ready", + "description": "Example shunt template" + } + ] +} diff --git a/cfg/sample/data/tables.json b/cfg/sample/data/tables.json new file mode 100644 index 0000000..2feb210 --- /dev/null +++ b/cfg/sample/data/tables.json @@ -0,0 +1,3 @@ +{ + "items": [] +} diff --git a/cfg/sample/data/template/feature-form/template.md b/cfg/sample/data/template/feature-form/template.md new file mode 100644 index 0000000..6b49e99 --- /dev/null +++ b/cfg/sample/data/template/feature-form/template.md @@ -0,0 +1,38 @@ +# {{nombre_flujo}} + +## Tipo de usuario +{{tipo_usuario}} + +## Donde empieza +{{punto_entrada}} + +## Que quiere hacer el usuario +{{objetivo}} + +## Pasos + +1. {{paso_1}} +2. {{paso_2}} +3. {{paso_3}} + +## Que deberia pasar + +- {{resultado_1}} +- {{resultado_2}} + +## Problemas comunes + +- {{problema_1}} +- {{problema_2}} + +## Casos especiales + +- {{caso_especial_1}} + +## Flujos relacionados + +- {{flujo_relacionado_1}} + +## Notas tecnicas + +- {{nota_tecnica_1}} diff --git a/cfg/sample/data/templates.json b/cfg/sample/data/templates.json new file mode 100644 index 0000000..781b31d --- /dev/null +++ b/cfg/sample/data/templates.json @@ -0,0 +1,12 @@ +{ + "items": [ + { + "name": "feature-form", + "slug": "feature-form", + "title": "Feature Form Template", + "status": "ready", + "template_path": "data/template/feature-form", + "system": "album" + } + ] +} diff --git a/cfg/sample/data/tools.json b/cfg/sample/data/tools.json new file mode 100644 index 0000000..165fcc0 --- /dev/null +++ b/cfg/sample/data/tools.json @@ -0,0 +1,48 @@ +{ + "items": [ + { + "name": "tester", + "slug": "tester", + "title": "Contract Tests", + "status": "live", + "system": "ward", + "type": "app", + "description": "HTTP contract test runner with multi-environment support. Filter, run, and track tests against dev/stage/prod.", + "path": "ward/tools/tester", + "url": "/tools/tester/" + }, + { + "name": "datagen", + "slug": "datagen", + "title": "Test Data Generator", + "status": "live", + "system": "ward", + "type": "cli", + "description": "Generate realistic test data for Amar domain (users, pets, services) and MercadoPago API responses. Used by mock veins and test seeders.", + "path": "ward/tools/datagen", + "cli": "python -m datagen" + }, + { + "name": "generate_test_data", + "slug": "generate-test-data", + "title": "DB Test Data Extractor", + "status": "dev", + "system": "ward", + "type": "cli", + "description": "Extract representative subsets from PostgreSQL dumps for testing/development.", + "path": "ward/tools/generate_test_data", + "cli": "python -m generate_test_data" + }, + { + "name": "modelgen", + "slug": "modelgen", + "title": "Model Generator", + "status": "dev", + "system": "ward", + "type": "cli", + "description": "Generate platform-specific models (Pydantic, Django, Prisma) from JSON Schema.", + "path": "ward/tools/modelgen", + "cli": "python -m modelgen" + } + ] +} diff --git a/cfg/sample/data/veins.json b/cfg/sample/data/veins.json new file mode 100644 index 0000000..5b0cd5c --- /dev/null +++ b/cfg/sample/data/veins.json @@ -0,0 +1,60 @@ +{ + "items": [ + { + "name": "jira", + "slug": "jira", + "title": "Jira", + "status": "live", + "system": "artery" + }, + { + "name": "slack", + "slug": "slack", + "title": "Slack", + "status": "building", + "system": "artery" + }, + { + "name": "google", + "slug": "google", + "title": "Google", + "status": "building", + "system": "artery" + }, + { + "name": "maps", + "slug": "maps", + "title": "Maps", + "status": "planned", + "system": "artery" + }, + { + "name": "whatsapp", + "slug": "whatsapp", + "title": "WhatsApp", + "status": "planned", + "system": "artery" + }, + { + "name": "gnucash", + "slug": "gnucash", + "title": "GNUCash", + "status": "planned", + "system": "artery" + }, + { + "name": "vpn", + "slug": "vpn", + "title": "VPN", + "status": "planned", + "system": "artery" + }, + { + "name": "ia", + "slug": "ia", + "title": "IA", + "status": "live", + "system": "artery" + } + ] +} diff --git a/cfg/sample/docker-compose.yml b/cfg/sample/docker-compose.yml new file mode 100644 index 0000000..95f4182 --- /dev/null +++ b/cfg/sample/docker-compose.yml @@ -0,0 +1,74 @@ +# Sample Room — Managed App Services (Fixture Invoicing) +# +# Runs backend + frontend + postgres on the shared sample_network, +# so soleprint + nginx can wrap them from cfg/sample/soleprint/. +# +# Usage (from gen/sample/sample/): +# docker compose up -d + +name: ${DEPLOYMENT_NAME} + +services: + db: + image: postgres:16-alpine + container_name: ${DEPLOYMENT_NAME}_db + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "${DB_PORT}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 10 + networks: + - default + + backend: + build: + context: ./backend + container_name: ${DEPLOYMENT_NAME}_backend + environment: + DB_HOST: db + DB_PORT: 5432 + DB_NAME: ${POSTGRES_DB} + DB_USER: ${POSTGRES_USER} + DB_PASSWORD: ${POSTGRES_PASSWORD} + SEED_ON_START: "true" + ports: + - "${BACKEND_PORT}:8000" + depends_on: + db: + condition: service_healthy + networks: + - default + + frontend: + build: + context: ./frontend + container_name: ${DEPLOYMENT_NAME}_frontend + environment: + # Browser hits /api/ via nginx, so leave blank to use same-origin + VITE_API_URL: "" + ports: + - "${FRONTEND_PORT}:5173" + depends_on: + - backend + volumes: + - ./frontend/src:/app/src + - ./frontend/index.html:/app/index.html + - ./frontend/vite.config.js:/app/vite.config.js + networks: + - default + +volumes: + pgdata: + +networks: + default: + external: true + name: ${NETWORK_NAME} diff --git a/cfg/sample/link/Dockerfile b/cfg/sample/link/Dockerfile new file mode 100644 index 0000000..b65b53e --- /dev/null +++ b/cfg/sample/link/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/cfg/sample/link/adapters/__init__.py b/cfg/sample/link/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cfg/sample/link/adapters/sqlalchemy_bridge.py b/cfg/sample/link/adapters/sqlalchemy_bridge.py new file mode 100644 index 0000000..0eccb2d --- /dev/null +++ b/cfg/sample/link/adapters/sqlalchemy_bridge.py @@ -0,0 +1,43 @@ +"""SQLAlchemy bridge — read tables via reflection. + +Works against the fixture's postgres. Reflection means the link does not +need to import the app's model code — it discovers tables at runtime. +""" + +import os + +from fastapi import APIRouter, HTTPException +from sqlalchemy import MetaData, create_engine, inspect, select + +router = APIRouter() + + +def _db_url() -> str: + host = os.getenv("DB_HOST", "db") + port = os.getenv("DB_PORT", "5432") + name = os.getenv("DB_NAME", "fixture") + user = os.getenv("DB_USER", "postgres") + password = os.getenv("DB_PASSWORD", "localdev123") + return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{name}" + + +engine = create_engine(_db_url(), future=True) +metadata = MetaData() + + +@router.get("/tables") +def list_tables(): + insp = inspect(engine) + return {"tables": insp.get_table_names()} + + +@router.get("/tables/{name}") +def table_rows(name: str, limit: int = 50): + insp = inspect(engine) + if name not in insp.get_table_names(): + raise HTTPException(404, f"table '{name}' not found") + metadata.reflect(bind=engine, only=[name]) + table = metadata.tables[name] + with engine.connect() as conn: + rows = conn.execute(select(table).limit(limit)).mappings().all() + return {"table": name, "rows": [dict(r) for r in rows]} diff --git a/cfg/sample/link/docker-compose.yml b/cfg/sample/link/docker-compose.yml new file mode 100644 index 0000000..aec5187 --- /dev/null +++ b/cfg/sample/link/docker-compose.yml @@ -0,0 +1,24 @@ +# Link — Fixture DB bridge +name: ${DEPLOYMENT_NAME}_link + +services: + link: + build: + context: . + dockerfile: Dockerfile + container_name: ${DEPLOYMENT_NAME}_link + environment: + DB_HOST: db + DB_PORT: 5432 + DB_NAME: ${POSTGRES_DB} + DB_USER: ${POSTGRES_USER} + DB_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "${LINK_PORT:-8121}:8000" + networks: + - default + +networks: + default: + external: true + name: ${NETWORK_NAME} diff --git a/cfg/sample/link/main.py b/cfg/sample/link/main.py new file mode 100644 index 0000000..2c92272 --- /dev/null +++ b/cfg/sample/link/main.py @@ -0,0 +1,19 @@ +"""Link — DB bridge for the fixture invoicing app. + +Exposes a small HTTP API that wraps SQLAlchemy access to the fixture's +postgres so soleprint tools can read/write without touching the app. +""" + +from fastapi import FastAPI + +from adapters.sqlalchemy_bridge import router as db_router + +app = FastAPI(title="Link — Fixture Bridge") + + +@app.get("/health") +def health(): + return {"status": "ok", "target": "fixture-invoicing"} + + +app.include_router(db_router, prefix="/db") diff --git a/cfg/sample/link/requirements.txt b/cfg/sample/link/requirements.txt new file mode 100644 index 0000000..e688ca5 --- /dev/null +++ b/cfg/sample/link/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 diff --git a/cfg/sample/sample/.env b/cfg/sample/sample/.env index d3032ae..db8c3e1 100644 --- a/cfg/sample/sample/.env +++ b/cfg/sample/sample/.env @@ -1,15 +1,21 @@ # ============================================================================= -# Sample Managed App - Environment Configuration +# sample room — Managed App (Fixture Invoicing) # ============================================================================= -# Copy this to cfg///.env and customize +# This room wraps examples/fixture-invoicing/ — a deliberate fixture, not a real app. -# ============================================================================= -# DEPLOYMENT -# ============================================================================= DEPLOYMENT_NAME=sample NETWORK_NAME=sample_network # ============================================================================= -# PORTS +# PORTS (host-exposed) # ============================================================================= -FRONTEND_PORT=3020 +BACKEND_PORT=8120 +FRONTEND_PORT=3120 +DB_PORT=5532 + +# ============================================================================= +# DATABASE (Postgres) +# ============================================================================= +POSTGRES_DB=fixture +POSTGRES_USER=postgres +POSTGRES_PASSWORD=localdev123 diff --git a/cfg/sample/sample/docker-compose.yml b/cfg/sample/sample/docker-compose.yml deleted file mode 100644 index 596122c..0000000 --- a/cfg/sample/sample/docker-compose.yml +++ /dev/null @@ -1,19 +0,0 @@ -# Sample Mock Frontend -# Simple nginx serving static HTML -# -# For a real app, customize this with your actual services - -services: - frontend: - image: nginx:alpine - container_name: ${DEPLOYMENT_NAME}_frontend - volumes: - - ./index.html:/usr/share/nginx/html/index.html:ro - ports: - - "${FRONTEND_PORT}:80" - networks: - - default - -networks: - default: - name: ${NETWORK_NAME} diff --git a/cfg/sample/sample/index.html b/cfg/sample/sample/index.html deleted file mode 100644 index 5531a5b..0000000 --- a/cfg/sample/sample/index.html +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - Sample - Public Demo - - - -
-

Sample

-

Public demo - open to any Gmail account

- Public Demo - -
-

Soleprint Managed Room

- -
-
- - diff --git a/cfg/sample/soleprint/.env b/cfg/sample/soleprint/.env index 251662f..a8f0313 100644 --- a/cfg/sample/soleprint/.env +++ b/cfg/sample/soleprint/.env @@ -16,6 +16,7 @@ NETWORK_NAME=sample_network # PORTS (unique per room) # ============================================================================= SOLEPRINT_PORT=12030 +NGINX_PORT=8130 # ============================================================================= # GOOGLE OAUTH diff --git a/cfg/sample/soleprint/atlas/books/fixture/index.md b/cfg/sample/soleprint/atlas/books/fixture/index.md new file mode 100644 index 0000000..013e89d --- /dev/null +++ b/cfg/sample/soleprint/atlas/books/fixture/index.md @@ -0,0 +1,44 @@ +# Fixture Invoicing — Soleprint Demo + +> **This book describes a deliberately-fake invoicing app used as a test +> fixture for the Soleprint framework.** It is not a real product. + +## Purpose + +The fixture exercises every soleprint tool end-to-end so we can iterate on +the framework without needing a real managed app. See +[`examples/fixture-invoicing/`](../../../../../../examples/fixture-invoicing/) +for the app itself. + +## Data model + +``` +Customer ──< Invoice ──< LineItem + └──────< Payment +``` + +| Table | Key fields | +|-------------|-------------------------------------------------| +| `customer` | id · name · email · created_at | +| `invoice` | id · number · customer_id · issued_at · status | +| `line_item` | id · invoice_id · description · qty · unit_price| +| `payment` | id · invoice_id · amount · method · paid_at | + +## Happy-path flow + +1. Create a customer (`POST /api/customers`) +2. Create a draft invoice for them (`POST /api/invoices`) +3. Add one or more line items (`POST /api/line-items/invoices/{id}`) +4. Record a payment (`POST /api/payments/invoices/{id}`) — if the total + paid ≥ total billed, the invoice auto-transitions to `paid`. + +## How this connects to Soleprint + +| Soleprint tool | Fixture hook | +|----------------|--------------| +| datagen | `station/tools/datagen/fixture.py` — FixtureInvoicingGenerator | +| graphgen | `station/tools/graphgen/schema.json` — 4 models + FK edges | +| databrowse | `station/tools/databrowse/depot/{schema,views}.json` | +| tester | `station/tools/tester/tests/fixture/` | +| link | SQLAlchemy reflection against `customer`, `invoice`, etc. | +| sbwrapper | Injected into fixture's Vue frontend by nginx | diff --git a/cfg/sample/soleprint/docker-compose.nginx.yml b/cfg/sample/soleprint/docker-compose.nginx.yml index d5700bc..260dd45 100644 --- a/cfg/sample/soleprint/docker-compose.nginx.yml +++ b/cfg/sample/soleprint/docker-compose.nginx.yml @@ -21,7 +21,7 @@ services: image: nginx:alpine container_name: ${DEPLOYMENT_NAME}_nginx ports: - - "80:80" + - "${NGINX_PORT:-80}:80" volumes: - ./nginx/local.conf:/etc/nginx/conf.d/default.conf:ro networks: diff --git a/cfg/sample/soleprint/nginx/local.conf b/cfg/sample/soleprint/nginx/local.conf index 2e84712..9d0886b 100644 --- a/cfg/sample/soleprint/nginx/local.conf +++ b/cfg/sample/soleprint/nginx/local.conf @@ -1,34 +1,40 @@ -# Sample Room - Nginx Config for Docker -# -# This config uses docker service names (soleprint, frontend, backend) -# which resolve within the docker network. +# Sample Room — Nginx Config +# Uses Docker DNS resolver so upstreams resolve at request time +# (lets nginx start before backend/frontend containers are up). -# sample.spr.local.ar - frontend with soleprint sidebar +resolver 127.0.0.11 valid=10s; + +# sample.spr.local.ar — fixture wrapped by soleprint (sidebar injected) server { listen 80; server_name sample.spr.local.ar; - # Soleprint routes - sidebar API and assets + set $soleprint sample_spr; + set $backend sample_backend; + set $frontend sample_frontend; + + # Soleprint routes (sidebar API + static assets) location /spr/ { - proxy_pass http://soleprint:8000/; + rewrite ^/spr/(.*)$ /$1 break; + proxy_pass http://$soleprint:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - # Backend API (uncomment if your app has a backend) - # location /api/ { - # proxy_pass http://backend:8000/api/; - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # proxy_set_header X-Forwarded-Proto $scheme; - # } + # Fixture backend API + location /api/ { + proxy_pass http://$backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } - # Frontend with sidebar injection + # Frontend (Vite dev server on 5173) with sidebar injection location / { - proxy_pass http://frontend:80; + proxy_pass http://$frontend:5173; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -38,29 +44,29 @@ server { proxy_set_header Connection "upgrade"; proxy_set_header Accept-Encoding ""; - # Inject sidebar CSS and JS into head sub_filter '' ''; sub_filter_once off; - sub_filter_types text/html; } } -# sample.local.ar - frontend without sidebar (direct access) +# sample.local.ar — direct fixture access (no sidebar) server { listen 80; server_name sample.local.ar; - # Backend API (uncomment if your app has a backend) - # location /api/ { - # proxy_pass http://backend:8000/api/; - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # proxy_set_header X-Forwarded-Proto $scheme; - # } + set $backend sample_backend; + set $frontend sample_frontend; + + location /api/ { + proxy_pass http://$backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } location / { - proxy_pass http://frontend:80; + proxy_pass http://$frontend:5173; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/cfg/sample/soleprint/station/tools/databrowse/depot/schema.json b/cfg/sample/soleprint/station/tools/databrowse/depot/schema.json new file mode 100644 index 0000000..d453bc3 --- /dev/null +++ b/cfg/sample/soleprint/station/tools/databrowse/depot/schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Fixture Invoicing Data Model", + "description": "Tables of the fixture-invoicing app — all data is obviously placeholder.", + "version": "0.1.0", + "meta": { + "purpose": "Browse and QA fixture data while iterating on soleprint tools.", + "modes": ["sql"] + }, + "definitions": { + "Customer": { + "type": "object", + "description": "A fixture customer.", + "table": "customer", + "properties": { + "id": {"type": "integer", "column": "id"}, + "name": {"type": "string", "column": "name"}, + "email": {"type": "string", "column": "email"}, + "created_at": {"type": "string", "format": "date-time", "column": "created_at"} + } + }, + "Invoice": { + "type": "object", + "description": "An invoice issued to a fixture customer.", + "table": "invoice", + "properties": { + "id": {"type": "integer", "column": "id"}, + "number": {"type": "string", "column": "number"}, + "customer_id": {"type": "integer", "column": "customer_id", "fk": "Customer"}, + "issued_at": {"type": "string", "format": "date-time", "column": "issued_at"}, + "due_at": {"type": "string", "format": "date-time", "column": "due_at"}, + "status": {"type": "string", "column": "status"} + } + }, + "LineItem": { + "type": "object", + "description": "A billable line on an invoice.", + "table": "line_item", + "properties": { + "id": {"type": "integer", "column": "id"}, + "invoice_id": {"type": "integer", "column": "invoice_id", "fk": "Invoice"}, + "description": {"type": "string", "column": "description"}, + "quantity": {"type": "integer", "column": "quantity"}, + "unit_price": {"type": "number", "column": "unit_price"} + } + }, + "Payment": { + "type": "object", + "description": "A payment recorded against an invoice.", + "table": "payment", + "properties": { + "id": {"type": "integer", "column": "id"}, + "invoice_id": {"type": "integer", "column": "invoice_id", "fk": "Invoice"}, + "amount": {"type": "number", "column": "amount"}, + "method": {"type": "string", "column": "method"}, + "paid_at": {"type": "string", "format": "date-time", "column": "paid_at"} + } + } + } +} diff --git a/cfg/sample/soleprint/station/tools/databrowse/depot/views.json b/cfg/sample/soleprint/station/tools/databrowse/depot/views.json new file mode 100644 index 0000000..8d96457 --- /dev/null +++ b/cfg/sample/soleprint/station/tools/databrowse/depot/views.json @@ -0,0 +1,57 @@ +{ + "views": [ + { + "name": "customers", + "title": "Customers", + "slug": "customers", + "description": "All fixture customers.", + "mode": "sql", + "entity": "Customer", + "order_by": "id ASC", + "fields": ["id", "name", "email", "created_at"], + "display_fields": { + "id": {"label": "ID", "width": "60px"}, + "name": {"label": "Name", "width": "200px", "primary": true}, + "email": {"label": "Email", "width": "220px"}, + "created_at": {"label": "Created", "width": "180px"} + } + }, + { + "name": "invoices_by_status", + "title": "Invoices by Status", + "slug": "invoices-by-status", + "description": "Invoices grouped by lifecycle status.", + "mode": "sql", + "entity": "Invoice", + "group_by": "status", + "order_by": "issued_at DESC", + "fields": ["id", "number", "customer_id", "issued_at", "due_at", "status"], + "display_fields": { + "number": {"label": "Number", "width": "150px", "primary": true}, + "status": {"label": "Status", "width": "100px"}, + "issued_at": {"label": "Issued", "width": "140px"}, + "due_at": {"label": "Due", "width": "140px"} + } + }, + { + "name": "line_items", + "title": "Line Items", + "slug": "line-items", + "description": "All line items across fixture invoices.", + "mode": "sql", + "entity": "LineItem", + "order_by": "invoice_id, id", + "fields": ["id", "invoice_id", "description", "quantity", "unit_price"] + }, + { + "name": "payments", + "title": "Payments", + "slug": "payments", + "description": "All payments recorded against fixture invoices.", + "mode": "sql", + "entity": "Payment", + "order_by": "paid_at DESC", + "fields": ["id", "invoice_id", "amount", "method", "paid_at"] + } + ] +} diff --git a/cfg/sample/soleprint/station/tools/datagen/fixture.py b/cfg/sample/soleprint/station/tools/datagen/fixture.py new file mode 100644 index 0000000..da47e18 --- /dev/null +++ b/cfg/sample/soleprint/station/tools/datagen/fixture.py @@ -0,0 +1,124 @@ +"""Datagen for the fixture-invoicing app. + +Generates Customer / Invoice / LineItem / Payment records that match the +fixture's SQLAlchemy models. Obviously-fake names make it clear these are +not real business records. +""" + +import random +import uuid +from datetime import datetime, timedelta +from decimal import Decimal + +try: + from station.tools.datagen.base import BaseDataGenerator +except ImportError: + class BaseDataGenerator: + pass + + +_COMPANIES = [ + "Acme Widget Co.", "Fixture Industries", "Demo Corp.", "Placeholder LLC", + "Test Customer Holdings", "Lorem Enterprises", "Ipsum & Sons", + "Sample Partners", "Dummy Data Group", "Stub Systems", +] +_METHODS = ["cash", "card", "transfer"] +_STATUSES = ["draft", "sent", "paid", "void"] + + +class FixtureInvoicingGenerator(BaseDataGenerator): + """Generates obviously-fake records for the fixture app.""" + + def customer(self, **kwargs) -> dict: + name = kwargs.pop("name", None) or random.choice(_COMPANIES) + slug = name.lower().replace(" ", "-").replace(".", "").replace(",", "") + return { + "id": str(uuid.uuid4()), + "name": name, + "email": f"billing+{slug}@example.invalid", + "created_at": datetime.utcnow().isoformat(), + **kwargs, + } + + def invoice(self, customer_id: int | str | None = None, **kwargs) -> dict: + number = kwargs.pop("number", f"DEMO-{random.randint(1000, 9999)}") + issued = datetime.utcnow() - timedelta(days=random.randint(0, 60)) + return { + "id": str(uuid.uuid4()), + "number": number, + "customer_id": customer_id or 1, + "issued_at": issued.isoformat(), + "due_at": (issued + timedelta(days=30)).isoformat(), + "status": kwargs.pop("status", random.choice(_STATUSES)), + **kwargs, + } + + def line_item(self, invoice_id: int | str | None = None, **kwargs) -> dict: + qty = kwargs.pop("quantity", random.randint(1, 20)) + price = kwargs.pop("unit_price", round(random.uniform(5, 500), 2)) + return { + "id": str(uuid.uuid4()), + "invoice_id": invoice_id or 1, + "description": kwargs.pop( + "description", f"Widget {random.choice(['A', 'B', 'C'])} (fixture)" + ), + "quantity": qty, + "unit_price": float(Decimal(str(price))), + **kwargs, + } + + def payment(self, invoice_id: int | str | None = None, **kwargs) -> dict: + return { + "id": str(uuid.uuid4()), + "invoice_id": invoice_id or 1, + "amount": kwargs.pop("amount", round(random.uniform(50, 2000), 2)), + "method": kwargs.pop("method", random.choice(_METHODS)), + "paid_at": datetime.utcnow().isoformat(), + **kwargs, + } + + def schema(self) -> dict: + return { + "models": { + "Customer": { + "doc": "A fixture customer — obviously-placeholder name.", + "fields": { + "id": {"type": "UUID", "pk": True}, + "name": {"type": "str"}, + "email": {"type": "str"}, + "created_at": {"type": "datetime"}, + }, + }, + "Invoice": { + "doc": "An invoice issued to a fixture customer.", + "fields": { + "id": {"type": "UUID", "pk": True}, + "number": {"type": "str"}, + "customer_id": {"type": "FK:Customer"}, + "issued_at": {"type": "datetime"}, + "due_at": {"type": "datetime", "nullable": True}, + "status": {"type": "str"}, + }, + }, + "LineItem": { + "doc": "A single billable line on an invoice.", + "fields": { + "id": {"type": "UUID", "pk": True}, + "invoice_id": {"type": "FK:Invoice"}, + "description": {"type": "str"}, + "quantity": {"type": "int"}, + "unit_price": {"type": "Decimal"}, + }, + }, + "Payment": { + "doc": "A payment recorded against an invoice.", + "fields": { + "id": {"type": "UUID", "pk": True}, + "invoice_id": {"type": "FK:Invoice"}, + "amount": {"type": "Decimal"}, + "method": {"type": "str"}, + "paid_at": {"type": "datetime"}, + }, + }, + } + } diff --git a/cfg/sample/soleprint/station/tools/graphgen/schema.json b/cfg/sample/soleprint/station/tools/graphgen/schema.json new file mode 100644 index 0000000..99f9b49 --- /dev/null +++ b/cfg/sample/soleprint/station/tools/graphgen/schema.json @@ -0,0 +1,44 @@ +{ + "models": { + "Customer": { + "doc": "A fixture customer (obviously-placeholder names).", + "fields": { + "id": {"type": "int", "pk": true}, + "name": {"type": "str"}, + "email": {"type": "str"}, + "created_at": {"type": "datetime"} + } + }, + "Invoice": { + "doc": "An invoice issued to a customer. Owns line items and payments.", + "fields": { + "id": {"type": "int", "pk": true}, + "number": {"type": "str"}, + "customer_id": {"type": "FK:Customer"}, + "issued_at": {"type": "datetime"}, + "due_at": {"type": "datetime", "nullable": true}, + "status": {"type": "str"} + } + }, + "LineItem": { + "doc": "A single billable line on an invoice.", + "fields": { + "id": {"type": "int", "pk": true}, + "invoice_id": {"type": "FK:Invoice"}, + "description": {"type": "str"}, + "quantity": {"type": "int"}, + "unit_price": {"type": "Decimal"} + } + }, + "Payment": { + "doc": "A payment recorded against an invoice.", + "fields": { + "id": {"type": "int", "pk": true}, + "invoice_id": {"type": "FK:Invoice"}, + "amount": {"type": "Decimal"}, + "method": {"type": "str"}, + "paid_at": {"type": "datetime"} + } + } + } +} diff --git a/cfg/sample/soleprint/station/tools/tester/environments.json b/cfg/sample/soleprint/station/tools/tester/environments.json new file mode 100644 index 0000000..07330d8 --- /dev/null +++ b/cfg/sample/soleprint/station/tools/tester/environments.json @@ -0,0 +1,15 @@ +{ + "environments": [ + { + "name": "docker", + "url": "http://sample_backend:8000", + "auth_type": "none", + "default": true + }, + { + "name": "local", + "url": "http://localhost:8120", + "auth_type": "none" + } + ] +} diff --git a/cfg/sample/soleprint/station/tools/tester/tests/fixture/__init__.py b/cfg/sample/soleprint/station/tools/tester/tests/fixture/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cfg/sample/soleprint/station/tools/tester/tests/fixture/test_customers.py b/cfg/sample/soleprint/station/tools/tester/tests/fixture/test_customers.py new file mode 100644 index 0000000..dfd7c04 --- /dev/null +++ b/cfg/sample/soleprint/station/tools/tester/tests/fixture/test_customers.py @@ -0,0 +1,52 @@ +"""Smoke tests for the fixture-invoicing API. + +Runs via the soleprint tester. Target URL is read from environments.json +(local = http://sample_backend:8000 inside the soleprint docker network). +""" + +from tests.base import ContractTestCase + + +class CustomersContractTest(ContractTestCase): + def test_list_customers_returns_list(self): + resp = self.get("/api/customers") + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertIsInstance(data, list) + # seed should have produced Acme + Test Customer + names = {c["name"] for c in data} + self.assertIn("Acme Widget Co.", names) + + def test_create_and_delete_customer(self): + payload = {"name": "Ephemeral Corp.", "email": "e@example.invalid"} + created = self.post("/api/customers", json=payload) + self.assertEqual(created.status_code, 201) + customer_id = created.json()["id"] + + fetched = self.get(f"/api/customers/{customer_id}") + self.assertEqual(fetched.status_code, 200) + self.assertEqual(fetched.json()["name"], "Ephemeral Corp.") + + self.delete(f"/api/customers/{customer_id}") + missing = self.get(f"/api/customers/{customer_id}") + self.assertEqual(missing.status_code, 404) + + +class InvoicesContractTest(ContractTestCase): + def test_list_invoices_returns_seeded(self): + resp = self.get("/api/invoices") + self.assertEqual(resp.status_code, 200) + invoices = resp.json() + self.assertGreaterEqual(len(invoices), 3) + numbers = {i["number"] for i in invoices} + self.assertIn("DEMO-2026-001", numbers) + + def test_invoice_detail_has_customer_and_lines(self): + invoices = self.get("/api/invoices").json() + first = invoices[0] + detail = self.get(f"/api/invoices/{first['id']}") + self.assertEqual(detail.status_code, 200) + body = detail.json() + self.assertIn("customer", body) + self.assertIn("line_items", body) + self.assertIn("payments", body) diff --git a/ctrl/k8s/kind-config.yaml b/ctrl/k8s/kind-config.yaml new file mode 100644 index 0000000..38dceef --- /dev/null +++ b/ctrl/k8s/kind-config.yaml @@ -0,0 +1,20 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +# Single shared cluster for all soleprint rooms. +# Each room deploys into its own namespace; gateway Services pick a +# NodePort from the 30080-30099 range mapped here. +name: spr +nodes: + - role: control-plane + extraPortMappings: + # Room gateway NodePorts (one per active room). + - {containerPort: 30080, hostPort: 30080, protocol: TCP} + - {containerPort: 30081, hostPort: 30081, protocol: TCP} + - {containerPort: 30082, hostPort: 30082, protocol: TCP} + - {containerPort: 30083, hostPort: 30083, protocol: TCP} + - {containerPort: 30084, hostPort: 30084, protocol: TCP} + - {containerPort: 30085, hostPort: 30085, protocol: TCP} + - {containerPort: 30086, hostPort: 30086, protocol: TCP} + - {containerPort: 30087, hostPort: 30087, protocol: TCP} + - {containerPort: 30088, hostPort: 30088, protocol: TCP} + - {containerPort: 30089, hostPort: 30089, protocol: TCP} diff --git a/ctrl/kind-down.sh b/ctrl/kind-down.sh new file mode 100755 index 0000000..6d26114 --- /dev/null +++ b/ctrl/kind-down.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Delete the shared `spr` kind cluster (drops every room's namespace too). +# Use `gen//ctrl/k8s-down.sh` instead if you only want to remove +# a single room's namespace. +set -e + +if kind get clusters 2>/dev/null | grep -q '^spr$'; then + echo "Deleting kind cluster 'spr'..." + kind delete cluster --name spr +else + echo "No kind cluster 'spr' to delete." +fi diff --git a/ctrl/kind-status.sh b/ctrl/kind-status.sh new file mode 100755 index 0000000..dcaec55 --- /dev/null +++ b/ctrl/kind-status.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Show what's running on the shared `spr` cluster. +set -e + +if ! kind get clusters 2>/dev/null | grep -q '^spr$'; then + echo "No 'spr' kind cluster — run ctrl/kind-up.sh" + exit 0 +fi + +kubectl --context kind-spr get namespaces -l soleprint-room +echo +kubectl --context kind-spr get pods -A -l soleprint-room diff --git a/ctrl/kind-up.sh b/ctrl/kind-up.sh new file mode 100755 index 0000000..be30337 --- /dev/null +++ b/ctrl/kind-up.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Create (or no-op) the single shared `spr` kind cluster used by every +# soleprint room. Per-room work happens inside namespaces — see +# `gen//ctrl/k8s-up.sh`. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KIND_CONFIG="$SCRIPT_DIR/k8s/kind-config.yaml" + +if kind get clusters 2>/dev/null | grep -q '^spr$'; then + echo "Kind cluster 'spr' already exists." +else + echo "Creating kind cluster 'spr'..." + kind create cluster --config "$KIND_CONFIG" +fi + +kubectl config use-context kind-spr >/dev/null + +echo +echo "Cluster ready. Per-room deploy:" +echo " cd gen/ && ./ctrl/k8s-up.sh" diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..002b51b --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.vite/ +__pycache__/ +*.pyc +.venv/ diff --git a/examples/fixture-invoicing/README.md b/examples/fixture-invoicing/README.md new file mode 100644 index 0000000..9b19945 --- /dev/null +++ b/examples/fixture-invoicing/README.md @@ -0,0 +1,46 @@ +# Fixture Invoicing + +> **⚠ THIS IS A FIXTURE, NOT A PRODUCT.** +> A deliberately minimal invoicing app used as a test surface for the +> [Soleprint](../../README.md) framework. Seed data is obviously placeholder +> ("Acme Widget Co.", "Test Customer 001"). Do not mistake this for real +> invoicing software. + +## What's here + +- `backend/` — FastAPI + SQLAlchemy against Postgres +- `frontend/` — Vue 3 + Vite, three pages (customers, invoices, invoice detail) +- `docker-compose.yml` — backend + frontend + postgres as three containers + +## Entities + +``` +Customer ──< Invoice ──< LineItem + └──────< Payment +``` + +- `Customer` — name, email +- `Invoice` — number, customer_id (FK), issued_at, due_at, status +- `LineItem` — invoice_id (FK), description, quantity, unit_price +- `Payment` — invoice_id (FK), amount, method, paid_at + +## Run it standalone + +```bash +cd examples/fixture-invoicing +docker compose up --build +``` + +- Backend: http://localhost:8100 +- Frontend: http://localhost:3100 +- Postgres: localhost:5532 (user `postgres`, password `fixture`, db `fixture`) + +## Run it wrapped by soleprint + +The `cfg/sample/` room wraps this fixture. See the main soleprint docs. + +```bash +cd ../.. # back to repo root +python build.py --cfg sample +cd gen/sample && ./ctrl/start.sh +``` diff --git a/examples/fixture-invoicing/backend/Dockerfile b/examples/fixture-invoicing/backend/Dockerfile new file mode 100644 index 0000000..b65b53e --- /dev/null +++ b/examples/fixture-invoicing/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/examples/fixture-invoicing/backend/api/__init__.py b/examples/fixture-invoicing/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/fixture-invoicing/backend/api/customers.py b/examples/fixture-invoicing/backend/api/customers.py new file mode 100644 index 0000000..3eb6a52 --- /dev/null +++ b/examples/fixture-invoicing/backend/api/customers.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from db import get_session +from models import Customer +from schemas import CustomerIn, CustomerOut + +router = APIRouter() + + +@router.get("", response_model=list[CustomerOut]) +def list_customers(session: Session = Depends(get_session)): + return session.query(Customer).order_by(Customer.id).all() + + +@router.post("", response_model=CustomerOut, status_code=201) +def create_customer(payload: CustomerIn, session: Session = Depends(get_session)): + customer = Customer(**payload.model_dump()) + session.add(customer) + session.commit() + session.refresh(customer) + return customer + + +@router.get("/{customer_id}", response_model=CustomerOut) +def get_customer(customer_id: int, session: Session = Depends(get_session)): + customer = session.get(Customer, customer_id) + if not customer: + raise HTTPException(404, "customer not found") + return customer + + +@router.put("/{customer_id}", response_model=CustomerOut) +def update_customer( + customer_id: int, payload: CustomerIn, session: Session = Depends(get_session) +): + customer = session.get(Customer, customer_id) + if not customer: + raise HTTPException(404, "customer not found") + for k, v in payload.model_dump().items(): + setattr(customer, k, v) + session.commit() + session.refresh(customer) + return customer + + +@router.delete("/{customer_id}", status_code=204) +def delete_customer(customer_id: int, session: Session = Depends(get_session)): + customer = session.get(Customer, customer_id) + if not customer: + raise HTTPException(404, "customer not found") + session.delete(customer) + session.commit() diff --git a/examples/fixture-invoicing/backend/api/invoices.py b/examples/fixture-invoicing/backend/api/invoices.py new file mode 100644 index 0000000..8d64f7b --- /dev/null +++ b/examples/fixture-invoicing/backend/api/invoices.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload + +from db import get_session +from models import Invoice +from schemas import InvoiceDetail, InvoiceIn, InvoiceOut + +router = APIRouter() + + +@router.get("", response_model=list[InvoiceOut]) +def list_invoices( + status: str | None = None, + customer_id: int | None = None, + session: Session = Depends(get_session), +): + query = session.query(Invoice).order_by(Invoice.issued_at.desc()) + if status: + query = query.filter(Invoice.status == status) + if customer_id: + query = query.filter(Invoice.customer_id == customer_id) + return query.all() + + +@router.post("", response_model=InvoiceOut, status_code=201) +def create_invoice(payload: InvoiceIn, session: Session = Depends(get_session)): + invoice = Invoice(**payload.model_dump()) + session.add(invoice) + session.commit() + session.refresh(invoice) + return invoice + + +@router.get("/{invoice_id}", response_model=InvoiceDetail) +def get_invoice(invoice_id: int, session: Session = Depends(get_session)): + invoice = ( + session.query(Invoice) + .options( + joinedload(Invoice.customer), + joinedload(Invoice.line_items), + joinedload(Invoice.payments), + ) + .filter(Invoice.id == invoice_id) + .first() + ) + if not invoice: + raise HTTPException(404, "invoice not found") + return invoice + + +@router.put("/{invoice_id}", response_model=InvoiceOut) +def update_invoice( + invoice_id: int, payload: InvoiceIn, session: Session = Depends(get_session) +): + invoice = session.get(Invoice, invoice_id) + if not invoice: + raise HTTPException(404, "invoice not found") + for k, v in payload.model_dump().items(): + setattr(invoice, k, v) + session.commit() + session.refresh(invoice) + return invoice + + +@router.delete("/{invoice_id}", status_code=204) +def delete_invoice(invoice_id: int, session: Session = Depends(get_session)): + invoice = session.get(Invoice, invoice_id) + if not invoice: + raise HTTPException(404, "invoice not found") + session.delete(invoice) + session.commit() diff --git a/examples/fixture-invoicing/backend/api/line_items.py b/examples/fixture-invoicing/backend/api/line_items.py new file mode 100644 index 0000000..4f7112d --- /dev/null +++ b/examples/fixture-invoicing/backend/api/line_items.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from db import get_session +from models import Invoice, LineItem +from schemas import LineItemIn, LineItemOut + +router = APIRouter() + + +@router.get("", response_model=list[LineItemOut]) +def list_line_items( + invoice_id: int | None = None, session: Session = Depends(get_session) +): + query = session.query(LineItem).order_by(LineItem.id) + if invoice_id: + query = query.filter(LineItem.invoice_id == invoice_id) + return query.all() + + +@router.post("/invoices/{invoice_id}", response_model=LineItemOut, status_code=201) +def add_line_item( + invoice_id: int, payload: LineItemIn, session: Session = Depends(get_session) +): + if not session.get(Invoice, invoice_id): + raise HTTPException(404, "invoice not found") + item = LineItem(invoice_id=invoice_id, **payload.model_dump()) + session.add(item) + session.commit() + session.refresh(item) + return item + + +@router.delete("/{line_item_id}", status_code=204) +def delete_line_item(line_item_id: int, session: Session = Depends(get_session)): + item = session.get(LineItem, line_item_id) + if not item: + raise HTTPException(404, "line item not found") + session.delete(item) + session.commit() diff --git a/examples/fixture-invoicing/backend/api/payments.py b/examples/fixture-invoicing/backend/api/payments.py new file mode 100644 index 0000000..e98bd1c --- /dev/null +++ b/examples/fixture-invoicing/backend/api/payments.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from db import get_session +from models import Invoice, Payment +from schemas import PaymentIn, PaymentOut + +router = APIRouter() + + +@router.get("", response_model=list[PaymentOut]) +def list_payments( + invoice_id: int | None = None, session: Session = Depends(get_session) +): + query = session.query(Payment).order_by(Payment.paid_at.desc()) + if invoice_id: + query = query.filter(Payment.invoice_id == invoice_id) + return query.all() + + +@router.post("/invoices/{invoice_id}", response_model=PaymentOut, status_code=201) +def record_payment( + invoice_id: int, payload: PaymentIn, session: Session = Depends(get_session) +): + invoice = session.get(Invoice, invoice_id) + if not invoice: + raise HTTPException(404, "invoice not found") + payment = Payment(invoice_id=invoice_id, **payload.model_dump()) + session.add(payment) + + total_paid = sum( + (p.amount for p in invoice.payments), start=payment.amount + ) + total_owed = sum((li.unit_price * li.quantity for li in invoice.line_items), start=0) + if total_owed > 0 and total_paid >= total_owed: + invoice.status = "paid" + + session.commit() + session.refresh(payment) + return payment + + +@router.delete("/{payment_id}", status_code=204) +def delete_payment(payment_id: int, session: Session = Depends(get_session)): + payment = session.get(Payment, payment_id) + if not payment: + raise HTTPException(404, "payment not found") + session.delete(payment) + session.commit() diff --git a/examples/fixture-invoicing/backend/db.py b/examples/fixture-invoicing/backend/db.py new file mode 100644 index 0000000..f2c6e0a --- /dev/null +++ b/examples/fixture-invoicing/backend/db.py @@ -0,0 +1,29 @@ +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + + +def _db_url() -> str: + host = os.getenv("DB_HOST", "localhost") + port = os.getenv("DB_PORT", "5432") + name = os.getenv("DB_NAME", "fixture") + user = os.getenv("DB_USER", "postgres") + password = os.getenv("DB_PASSWORD", "fixture") + return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{name}" + + +engine = create_engine(_db_url(), echo=False, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +class Base(DeclarativeBase): + pass + + +def get_session(): + session = SessionLocal() + try: + yield session + finally: + session.close() diff --git a/examples/fixture-invoicing/backend/main.py b/examples/fixture-invoicing/backend/main.py new file mode 100644 index 0000000..4d9b008 --- /dev/null +++ b/examples/fixture-invoicing/backend/main.py @@ -0,0 +1,52 @@ +import logging +import os + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from api import customers, invoices, line_items, payments +from db import Base, engine +from seed import seed_if_empty + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") +log = logging.getLogger("fixture") + +app = FastAPI( + title="Fixture Invoicing", + description="SOLEPRINT FIXTURE APP — not a real invoicing product", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.on_event("startup") +def startup(): + Base.metadata.create_all(bind=engine) + if os.getenv("SEED_ON_START", "false").lower() == "true": + seed_if_empty() + + +@app.get("/") +def root(): + return { + "app": "fixture-invoicing", + "warning": "THIS IS A SOLEPRINT FIXTURE — NOT A REAL PRODUCT", + "docs": "/docs", + } + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +app.include_router(customers.router, prefix="/api/customers", tags=["customers"]) +app.include_router(invoices.router, prefix="/api/invoices", tags=["invoices"]) +app.include_router(line_items.router, prefix="/api/line-items", tags=["line-items"]) +app.include_router(payments.router, prefix="/api/payments", tags=["payments"]) diff --git a/examples/fixture-invoicing/backend/models.py b/examples/fixture-invoicing/backend/models.py new file mode 100644 index 0000000..7267fd5 --- /dev/null +++ b/examples/fixture-invoicing/backend/models.py @@ -0,0 +1,63 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from db import Base + + +class Customer(Base): + __tablename__ = "customer" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + email: Mapped[str] = mapped_column(String(200), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + invoices: Mapped[list["Invoice"]] = relationship( + back_populates="customer", cascade="all, delete-orphan" + ) + + +class Invoice(Base): + __tablename__ = "invoice" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + number: Mapped[str] = mapped_column(String(40), nullable=False, unique=True) + customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id"), nullable=False) + issued_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + due_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="draft") + + customer: Mapped[Customer] = relationship(back_populates="invoices") + line_items: Mapped[list["LineItem"]] = relationship( + back_populates="invoice", cascade="all, delete-orphan" + ) + payments: Mapped[list["Payment"]] = relationship( + back_populates="invoice", cascade="all, delete-orphan" + ) + + +class LineItem(Base): + __tablename__ = "line_item" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice.id"), nullable=False) + description: Mapped[str] = mapped_column(String(200), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, default=1) + unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0) + + invoice: Mapped[Invoice] = relationship(back_populates="line_items") + + +class Payment(Base): + __tablename__ = "payment" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice.id"), nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + method: Mapped[str] = mapped_column(String(20), default="cash") + paid_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + invoice: Mapped[Invoice] = relationship(back_populates="payments") diff --git a/examples/fixture-invoicing/backend/requirements.txt b/examples/fixture-invoicing/backend/requirements.txt new file mode 100644 index 0000000..18b8d67 --- /dev/null +++ b/examples/fixture-invoicing/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 +pydantic==2.9.2 diff --git a/examples/fixture-invoicing/backend/schemas.py b/examples/fixture-invoicing/backend/schemas.py new file mode 100644 index 0000000..fc0ec7a --- /dev/null +++ b/examples/fixture-invoicing/backend/schemas.py @@ -0,0 +1,58 @@ +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + + +class CustomerIn(BaseModel): + name: str + email: str + + +class CustomerOut(CustomerIn): + model_config = ConfigDict(from_attributes=True) + id: int + created_at: datetime + + +class LineItemIn(BaseModel): + description: str + quantity: int = 1 + unit_price: Decimal = Decimal("0") + + +class LineItemOut(LineItemIn): + model_config = ConfigDict(from_attributes=True) + id: int + invoice_id: int + + +class PaymentIn(BaseModel): + amount: Decimal + method: str = "cash" + + +class PaymentOut(PaymentIn): + model_config = ConfigDict(from_attributes=True) + id: int + invoice_id: int + paid_at: datetime + + +class InvoiceIn(BaseModel): + number: str + customer_id: int + due_at: datetime | None = None + status: str = "draft" + + +class InvoiceOut(InvoiceIn): + model_config = ConfigDict(from_attributes=True) + id: int + issued_at: datetime + + +class InvoiceDetail(InvoiceOut): + line_items: list[LineItemOut] = [] + payments: list[PaymentOut] = [] + customer: CustomerOut diff --git a/examples/fixture-invoicing/backend/seed.py b/examples/fixture-invoicing/backend/seed.py new file mode 100644 index 0000000..5ca1ea9 --- /dev/null +++ b/examples/fixture-invoicing/backend/seed.py @@ -0,0 +1,64 @@ +"""Seed obviously-fake demo data so the fixture is non-empty on first boot.""" + +import logging +from datetime import datetime, timedelta +from decimal import Decimal + +from db import SessionLocal +from models import Customer, Invoice, LineItem, Payment + +log = logging.getLogger(__name__) + + +def seed_if_empty() -> None: + session = SessionLocal() + try: + if session.query(Customer).count() > 0: + return + + log.info("Seeding fixture data…") + acme = Customer(name="Acme Widget Co.", email="billing@acme.example") + test = Customer(name="Test Customer 001", email="test@example.invalid") + session.add_all([acme, test]) + session.flush() + + inv1 = Invoice( + number="DEMO-2026-001", + customer_id=acme.id, + issued_at=datetime(2026, 1, 15), + due_at=datetime(2026, 2, 15), + status="sent", + ) + inv2 = Invoice( + number="DEMO-2026-002", + customer_id=acme.id, + issued_at=datetime(2026, 2, 1), + due_at=datetime(2026, 3, 1), + status="paid", + ) + inv3 = Invoice( + number="DEMO-2026-003", + customer_id=test.id, + issued_at=datetime(2026, 3, 10), + due_at=datetime(2026, 4, 10), + status="draft", + ) + session.add_all([inv1, inv2, inv3]) + session.flush() + + session.add_all([ + LineItem(invoice_id=inv1.id, description="Widget type A", quantity=10, unit_price=Decimal("19.99")), + LineItem(invoice_id=inv1.id, description="Widget type B", quantity=5, unit_price=Decimal("49.00")), + LineItem(invoice_id=inv2.id, description="Consulting (hours)", quantity=8, unit_price=Decimal("120.00")), + LineItem(invoice_id=inv3.id, description="Placeholder line item", quantity=1, unit_price=Decimal("0")), + ]) + + session.add_all([ + Payment(invoice_id=inv2.id, amount=Decimal("960.00"), method="transfer", paid_at=datetime(2026, 2, 20)), + Payment(invoice_id=inv1.id, amount=Decimal("100.00"), method="card", paid_at=datetime(2026, 2, 1)), + ]) + + session.commit() + log.info("Seed complete.") + finally: + session.close() diff --git a/examples/fixture-invoicing/docker-compose.yml b/examples/fixture-invoicing/docker-compose.yml new file mode 100644 index 0000000..347776e --- /dev/null +++ b/examples/fixture-invoicing/docker-compose.yml @@ -0,0 +1,54 @@ +name: fixture_invoicing + +services: + db: + image: postgres:16-alpine + container_name: fixture_db + environment: + POSTGRES_DB: fixture + POSTGRES_USER: postgres + POSTGRES_PASSWORD: fixture + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5532:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d fixture"] + interval: 5s + timeout: 3s + retries: 10 + + backend: + build: + context: ./backend + container_name: fixture_backend + environment: + DB_HOST: db + DB_PORT: 5432 + DB_NAME: fixture + DB_USER: postgres + DB_PASSWORD: fixture + SEED_ON_START: "true" + ports: + - "8100:8000" + depends_on: + db: + condition: service_healthy + + frontend: + build: + context: ./frontend + container_name: fixture_frontend + environment: + VITE_API_URL: http://localhost:8100 + ports: + - "3100:5173" + depends_on: + - backend + volumes: + - ./frontend/src:/app/src + - ./frontend/index.html:/app/index.html + - ./frontend/vite.config.js:/app/vite.config.js + +volumes: + pgdata: diff --git a/examples/fixture-invoicing/frontend/Dockerfile b/examples/fixture-invoicing/frontend/Dockerfile new file mode 100644 index 0000000..25f4f55 --- /dev/null +++ b/examples/fixture-invoicing/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-slim + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 +CMD ["npm", "run", "dev"] diff --git a/examples/fixture-invoicing/frontend/index.html b/examples/fixture-invoicing/frontend/index.html new file mode 100644 index 0000000..3c29757 --- /dev/null +++ b/examples/fixture-invoicing/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Fixture Invoicing — Soleprint Demo + + +
+ + + diff --git a/examples/fixture-invoicing/frontend/package.json b/examples/fixture-invoicing/frontend/package.json new file mode 100644 index 0000000..7e3e27d --- /dev/null +++ b/examples/fixture-invoicing/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "fixture-invoicing-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "vue": "^3.5.12", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^5.4.11" + } +} diff --git a/examples/fixture-invoicing/frontend/src/App.vue b/examples/fixture-invoicing/frontend/src/App.vue new file mode 100644 index 0000000..f45abbc --- /dev/null +++ b/examples/fixture-invoicing/frontend/src/App.vue @@ -0,0 +1,17 @@ + diff --git a/examples/fixture-invoicing/frontend/src/api.js b/examples/fixture-invoicing/frontend/src/api.js new file mode 100644 index 0000000..2891f2f --- /dev/null +++ b/examples/fixture-invoicing/frontend/src/api.js @@ -0,0 +1,42 @@ +const apiBase = import.meta.env.VITE_API_URL || ""; + +async function request(path, options = {}) { + const resp = await fetch(`${apiBase}${path}`, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`); + if (resp.status === 204) return null; + return resp.json(); +} + +export const api = { + listCustomers: () => request("/api/customers"), + createCustomer: (data) => + request("/api/customers", { method: "POST", body: JSON.stringify(data) }), + deleteCustomer: (id) => request(`/api/customers/${id}`, { method: "DELETE" }), + + listInvoices: (params = {}) => { + const qs = new URLSearchParams(params).toString(); + return request(`/api/invoices${qs ? "?" + qs : ""}`); + }, + getInvoice: (id) => request(`/api/invoices/${id}`), + createInvoice: (data) => + request("/api/invoices", { method: "POST", body: JSON.stringify(data) }), + deleteInvoice: (id) => request(`/api/invoices/${id}`, { method: "DELETE" }), + + addLineItem: (invoiceId, data) => + request(`/api/line-items/invoices/${invoiceId}`, { + method: "POST", + body: JSON.stringify(data), + }), + deleteLineItem: (id) => + request(`/api/line-items/${id}`, { method: "DELETE" }), + + recordPayment: (invoiceId, data) => + request(`/api/payments/invoices/${invoiceId}`, { + method: "POST", + body: JSON.stringify(data), + }), + deletePayment: (id) => request(`/api/payments/${id}`, { method: "DELETE" }), +}; diff --git a/examples/fixture-invoicing/frontend/src/main.js b/examples/fixture-invoicing/frontend/src/main.js new file mode 100644 index 0000000..d12af53 --- /dev/null +++ b/examples/fixture-invoicing/frontend/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import { router } from "./router.js"; +import "./style.css"; + +createApp(App).use(router).mount("#app"); diff --git a/examples/fixture-invoicing/frontend/src/router.js b/examples/fixture-invoicing/frontend/src/router.js new file mode 100644 index 0000000..8707cc3 --- /dev/null +++ b/examples/fixture-invoicing/frontend/src/router.js @@ -0,0 +1,15 @@ +import { createRouter, createWebHistory } from "vue-router"; + +import Customers from "./views/Customers.vue"; +import Invoices from "./views/Invoices.vue"; +import InvoiceDetail from "./views/InvoiceDetail.vue"; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/", redirect: "/invoices" }, + { path: "/customers", component: Customers }, + { path: "/invoices", component: Invoices }, + { path: "/invoices/:id", component: InvoiceDetail, props: true }, + ], +}); diff --git a/examples/fixture-invoicing/frontend/src/style.css b/examples/fixture-invoicing/frontend/src/style.css new file mode 100644 index 0000000..871a49f --- /dev/null +++ b/examples/fixture-invoicing/frontend/src/style.css @@ -0,0 +1,119 @@ +:root { + --bg: #0d1b2a; + --panel: #152438; + --panel-2: #1b2e45; + --text: #e5e5e5; + --muted: #8ca0b4; + --accent: #6fc3df; + --warn: #ffb454; + --danger: #ff6b6b; + --good: #7dd87d; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +#app { min-height: 100vh; display: flex; flex-direction: column; } + +.fixture-banner { + background: repeating-linear-gradient( + 45deg, #3a1f00, #3a1f00 10px, #4a2800 10px, #4a2800 20px + ); + color: var(--warn); + text-align: center; + padding: 8px; + font-weight: 700; + letter-spacing: 2px; + font-size: 12px; + border-bottom: 2px solid var(--warn); +} + +header.app-nav { + background: var(--panel); + padding: 16px 24px; + display: flex; + align-items: center; + gap: 24px; + border-bottom: 1px solid #223; +} + +header.app-nav h1 { + font-size: 18px; + color: var(--accent); +} + +header.app-nav nav a { + color: var(--muted); + text-decoration: none; + margin-right: 16px; + padding: 4px 8px; + border-radius: 4px; +} +header.app-nav nav a.router-link-active { + color: var(--text); + background: var(--panel-2); +} + +main.app-main { + flex: 1; + padding: 24px; + max-width: 1100px; + width: 100%; + margin: 0 auto; +} + +h2 { color: var(--accent); margin-bottom: 16px; font-size: 22px; } +h3 { color: var(--text); margin: 16px 0 8px; font-size: 16px; } + +table { + width: 100%; + border-collapse: collapse; + background: var(--panel); + border-radius: 6px; + overflow: hidden; +} +th, td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #223; +} +th { background: var(--panel-2); color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; } +tr:last-child td { border-bottom: none; } + +.status { padding: 2px 8px; border-radius: 10px; font-size: 11px; text-transform: uppercase; } +.status.draft { background: #334; color: var(--muted); } +.status.sent { background: #3a2c10; color: var(--warn); } +.status.paid { background: #1e3a1e; color: var(--good); } +.status.void { background: #3a1e1e; color: var(--danger); } + +.card { + background: var(--panel); + padding: 16px; + border-radius: 6px; + margin-bottom: 16px; +} + +.muted { color: var(--muted); font-size: 13px; } + +button, .btn { + background: var(--accent); + color: #0d1b2a; + border: none; + padding: 8px 14px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-size: 13px; +} +button.ghost { background: transparent; color: var(--muted); border: 1px solid #334; } + +a.link { color: var(--accent); text-decoration: none; } +a.link:hover { text-decoration: underline; } + +.actions { margin-top: 12px; display: flex; gap: 8px; } diff --git a/examples/fixture-invoicing/frontend/src/views/Customers.vue b/examples/fixture-invoicing/frontend/src/views/Customers.vue new file mode 100644 index 0000000..ed0803b --- /dev/null +++ b/examples/fixture-invoicing/frontend/src/views/Customers.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/examples/fixture-invoicing/frontend/src/views/InvoiceDetail.vue b/examples/fixture-invoicing/frontend/src/views/InvoiceDetail.vue new file mode 100644 index 0000000..47b4252 --- /dev/null +++ b/examples/fixture-invoicing/frontend/src/views/InvoiceDetail.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/examples/fixture-invoicing/frontend/src/views/Invoices.vue b/examples/fixture-invoicing/frontend/src/views/Invoices.vue new file mode 100644 index 0000000..f389131 --- /dev/null +++ b/examples/fixture-invoicing/frontend/src/views/Invoices.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/examples/fixture-invoicing/frontend/vite.config.js b/examples/fixture-invoicing/frontend/vite.config.js new file mode 100644 index 0000000..a514463 --- /dev/null +++ b/examples/fixture-invoicing/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + server: { + host: "0.0.0.0", + port: 5173, + allowedHosts: true, + watch: { usePolling: true }, + }, +}); diff --git a/soleprint/ctrl/__init__.py b/soleprint/ctrl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/soleprint/ctrl/k8s/__init__.py b/soleprint/ctrl/k8s/__init__.py new file mode 100644 index 0000000..6d2cb75 --- /dev/null +++ b/soleprint/ctrl/k8s/__init__.py @@ -0,0 +1,3 @@ +from .render import render_k8s + +__all__ = ["render_k8s"] diff --git a/soleprint/ctrl/k8s/render.py b/soleprint/ctrl/k8s/render.py new file mode 100644 index 0000000..256364b --- /dev/null +++ b/soleprint/ctrl/k8s/render.py @@ -0,0 +1,98 @@ +"""Render per-room manifests for deploy into the shared `spr` kind cluster. + +The `spr` cluster itself is created via `ctrl/kind-up.sh` at the repo root +(one cluster, all rooms). Each room becomes a namespace inside it. + +Called from build.py when a room opts in to k8s output. Emits: + + gen//ctrl/k8s/ base + overlays/dev manifests + gen//ctrl/k8s-up.sh apply this room's manifests into spr + gen//ctrl/k8s-down.sh delete this room's namespace from spr + gen//ctrl/k8s-load.sh build images and `kind load` into spr + +No jinja2 dep — Python-string templates, matching init/core.py. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from . import templates as T + +log = logging.getLogger(__name__) + +CLUSTER = "spr" +DEFAULT_NODEPORT = 30080 + + +def _write(path: Path, content: str, mode: int | None = None) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + if mode is not None: + path.chmod(mode) + + +def render_k8s(*, room: str, config: dict, gen_dir: Path) -> None: + managed = config.get("managed") or {} + managed_name = managed.get("name") or room + has_frontend = "frontend" in (managed.get("repos") or {}) + has_link = (gen_dir / "link").exists() + has_managed = bool(managed) + + k8s_cfg = config.get("k8s") or {} + nodeport = int(k8s_cfg.get("nodeport", DEFAULT_NODEPORT)) + + ctrl_dir = gen_dir / "ctrl" + k8s_dir = ctrl_dir / "k8s" + base_dir = k8s_dir / "base" + dev_dir = k8s_dir / "overlays" / "dev" + + log.info("Rendering k8s manifests (cluster=%s, namespace=%s, nodeport=%d)...", + CLUSTER, room, nodeport) + + resources: list[str] = [] + _write(base_dir / "namespace.yaml", T.namespace(room=room)) + resources.append("namespace.yaml") + + _write(base_dir / "configmap.yaml", T.configmap(room=room)) + resources.append("configmap.yaml") + + if has_managed: + _write(base_dir / "postgres.yaml", T.postgres(room=room)) + resources.append("postgres.yaml") + + _write(base_dir / "backend.yaml", T.backend(room=room, managed_name=managed_name)) + resources.append("backend.yaml") + if has_frontend: + _write(base_dir / "frontend.yaml", T.frontend(room=room, managed_name=managed_name)) + resources.append("frontend.yaml") + + if has_link: + _write(base_dir / "link.yaml", T.link(room=room)) + resources.append("link.yaml") + + _write(base_dir / "soleprint.yaml", T.soleprint(room=room)) + resources.append("soleprint.yaml") + + _write(base_dir / "gateway.yaml", T.gateway(room=room, nodeport=nodeport)) + resources.append("gateway.yaml") + _write(base_dir / "envoy.yaml", T.envoy( + room=room, has_backend=has_managed, has_frontend=has_frontend, + )) + + _write(base_dir / "kustomization.yaml", T.kustomization_base(resources=resources)) + _write(dev_dir / "kustomization.yaml", T.kustomization_dev(room=room)) + + _write(ctrl_dir / "k8s-up.sh", T.k8s_up_sh(room=room, cluster=CLUSTER, nodeport=nodeport), mode=0o755) + _write(ctrl_dir / "k8s-down.sh", T.k8s_down_sh(room=room, cluster=CLUSTER), mode=0o755) + _write(ctrl_dir / "k8s-load.sh", T.k8s_load_sh( + room=room, cluster=CLUSTER, managed_name=managed_name, + has_managed=has_managed, has_frontend=has_frontend, has_link=has_link, + ), mode=0o755) + + log.info(" %d manifests + 3 lifecycle scripts", len(resources) + 1) + + +def k8s_enabled(config: dict) -> bool: + return bool((config.get("k8s") or {}).get("enabled")) diff --git a/soleprint/ctrl/k8s/templates.py b/soleprint/ctrl/k8s/templates.py new file mode 100644 index 0000000..af35b95 --- /dev/null +++ b/soleprint/ctrl/k8s/templates.py @@ -0,0 +1,487 @@ +"""String templates for kind-cluster manifests. + +Each function returns a YAML/shell blob. Keep plain strings — no jinja2 +dep, matches the init/core.py convention. +""" + +from __future__ import annotations + + +# ─── Base manifests ───────────────────────────────────────────────── + +def namespace(*, room: str) -> str: + return f"""\ +apiVersion: v1 +kind: Namespace +metadata: + name: {room} + labels: + soleprint-room: "{room}" +""" + + +def configmap(*, room: str) -> str: + return f"""\ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {room}-env + namespace: {room} +data: + DEPLOYMENT_NAME: "{room}" + POSTGRES_DB: "fixture" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "localdev123" + ARTERY_EXTERNAL_URL: "/artery" + ATLAS_EXTERNAL_URL: "/atlas" + STATION_EXTERNAL_URL: "/station" + AUTH_BYPASS: "true" +""" + + +def postgres(*, room: str) -> str: + return f"""\ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: pgdata + namespace: {room} +spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: db + namespace: {room} + labels: {{app: db}} +spec: + replicas: 1 + selector: {{matchLabels: {{app: db}}}} + template: + metadata: + labels: {{app: db}} + spec: + containers: + - name: postgres + image: postgres:16-alpine + envFrom: + - configMapRef: {{name: {room}-env}} + ports: + - containerPort: 5432 + volumeMounts: + - name: pgdata + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: [sh, -c, "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] + periodSeconds: 5 + volumes: + - name: pgdata + persistentVolumeClaim: {{claimName: pgdata}} +--- +apiVersion: v1 +kind: Service +metadata: + name: db + namespace: {room} +spec: + selector: {{app: db}} + ports: + - port: 5432 + targetPort: 5432 +""" + + +def backend(*, room: str, managed_name: str) -> str: + return f"""\ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: {room} + labels: {{app: backend}} +spec: + replicas: 1 + selector: {{matchLabels: {{app: backend}}}} + template: + metadata: + labels: {{app: backend}} + spec: + containers: + - name: backend + image: {room}-backend:dev + imagePullPolicy: IfNotPresent + env: + - {{name: DB_HOST, value: db}} + - {{name: DB_PORT, value: "5432"}} + - {{name: SEED_ON_START, value: "true"}} + envFrom: + - configMapRef: {{name: {room}-env}} + ports: + - containerPort: 8000 +--- +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: {room} +spec: + selector: {{app: backend}} + ports: + - port: 8000 + targetPort: 8000 +""" + + +def frontend(*, room: str, managed_name: str) -> str: + return f"""\ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: {room} + labels: {{app: frontend}} +spec: + replicas: 1 + selector: {{matchLabels: {{app: frontend}}}} + template: + metadata: + labels: {{app: frontend}} + spec: + containers: + - name: frontend + image: {room}-frontend:dev + imagePullPolicy: IfNotPresent + env: + - {{name: VITE_API_URL, value: ""}} + ports: + - containerPort: 5173 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: {room} +spec: + selector: {{app: frontend}} + ports: + - port: 5173 + targetPort: 5173 +""" + + +def link(*, room: str) -> str: + return f"""\ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: link + namespace: {room} + labels: {{app: link}} +spec: + replicas: 1 + selector: {{matchLabels: {{app: link}}}} + template: + metadata: + labels: {{app: link}} + spec: + containers: + - name: link + image: {room}-link:dev + imagePullPolicy: IfNotPresent + env: + - {{name: DB_HOST, value: db}} + - {{name: DB_PORT, value: "5432"}} + envFrom: + - configMapRef: {{name: {room}-env}} + ports: + - containerPort: 8000 +--- +apiVersion: v1 +kind: Service +metadata: + name: link + namespace: {room} +spec: + selector: {{app: link}} + ports: + - port: 8000 + targetPort: 8000 +""" + + +def soleprint(*, room: str) -> str: + return f"""\ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: soleprint + namespace: {room} + labels: {{app: soleprint}} +spec: + replicas: 1 + selector: {{matchLabels: {{app: soleprint}}}} + template: + metadata: + labels: {{app: soleprint}} + spec: + containers: + - name: soleprint + image: {room}-soleprint:dev + imagePullPolicy: IfNotPresent + envFrom: + - configMapRef: {{name: {room}-env}} + ports: + - containerPort: 8000 +--- +apiVersion: v1 +kind: Service +metadata: + name: soleprint + namespace: {room} +spec: + selector: {{app: soleprint}} + ports: + - port: 8000 + targetPort: 8000 +""" + + +def gateway(*, room: str, nodeport: int) -> str: + return f"""\ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gateway + namespace: {room} + labels: {{app: gateway, soleprint-room: "{room}"}} +spec: + replicas: 1 + selector: {{matchLabels: {{app: gateway}}}} + template: + metadata: + labels: {{app: gateway, soleprint-room: "{room}"}} + spec: + containers: + - name: envoy + image: envoyproxy/envoy:v1.30-latest + ports: + - containerPort: 10000 + volumeMounts: + - name: envoy-config + mountPath: /etc/envoy + volumes: + - name: envoy-config + configMap: + name: envoy-config +--- +apiVersion: v1 +kind: Service +metadata: + name: gateway + namespace: {room} +spec: + type: NodePort + selector: {{app: gateway}} + ports: + - port: 80 + targetPort: 10000 + nodePort: {nodeport} +""" + + +def envoy(*, room: str, has_backend: bool, has_frontend: bool) -> str: + """Envoy config with /spr → soleprint, /api → backend, / → frontend.""" + routes = [' - match: {prefix: "/spr/"}\n route: {cluster: soleprint, prefix_rewrite: "/"}'] + clusters = [_cluster("soleprint", "soleprint", 8000)] + if has_backend: + routes.append(' - match: {prefix: "/api/"}\n route: {cluster: backend}') + clusters.append(_cluster("backend", "backend", 8000)) + if has_frontend: + routes.append(' - match: {prefix: "/"}\n route: {cluster: frontend}') + clusters.append(_cluster("frontend", "frontend", 5173)) + else: + # Fall back to soleprint as default if no frontend + routes.append(' - match: {prefix: "/"}\n route: {cluster: soleprint}') + routes_yaml = "\n".join(routes) + clusters_yaml = "\n".join(clusters) + + envoy_yaml = f"""\ +admin: + address: + socket_address: {{address: 0.0.0.0, port_value: 9901}} + +static_resources: + listeners: + - name: main + address: + socket_address: {{address: 0.0.0.0, port_value: 10000}} + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + upgrade_configs: [{{upgrade_type: websocket}}] + route_config: + name: main + virtual_hosts: + - name: default + domains: ["*"] + routes: +{_indent(routes_yaml, 14)} + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: +{clusters_yaml} +""" + return f"""\ +apiVersion: v1 +kind: ConfigMap +metadata: + name: envoy-config + namespace: {room} +data: + envoy.yaml: | +{_indent(envoy_yaml, 4)} +""" + + +def _cluster(name: str, host: str, port: int) -> str: + return f"""\ + - name: {name} + type: STRICT_DNS + connect_timeout: 1s + load_assignment: + cluster_name: {name} + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: {{address: {host}, port_value: {port}}}""" + + +def _indent(text: str, spaces: int) -> str: + pad = " " * spaces + return "\n".join(pad + line if line else line for line in text.splitlines()) + + +def kustomization_base(*, resources: list[str]) -> str: + # envoy.yaml is itself a ConfigMap manifest, so it belongs in resources. + res = "\n".join(f" - {r}" for r in resources + ["envoy.yaml"]) + return f"""\ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +{res} +""" + + +def kustomization_dev(*, room: str) -> str: + return f"""\ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: {room} +resources: + - ../../base +""" + + +# ─── Lifecycle scripts ────────────────────────────────────────────── +# These target the shared `spr` kind cluster (created via repo-root +# ctrl/kind-up.sh). Each room owns a namespace inside that cluster. + +def k8s_up_sh(*, room: str, cluster: str, nodeport: int) -> str: + return f"""\ +#!/bin/bash +# Apply the "{room}" room into the shared `{cluster}` kind cluster. +# (Run repo-root ctrl/kind-up.sh first if the cluster doesn't exist.) +set -e + +SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)" +K8S_DIR="$SCRIPT_DIR/k8s" + +if ! kind get clusters 2>/dev/null | grep -q '^{cluster}$'; then + echo "Kind cluster '{cluster}' not found — run ctrl/kind-up.sh from the repo root first." + exit 1 +fi + +CTX="kind-{cluster}" + +echo "Loading images for room '{room}' into '{cluster}'..." +"$SCRIPT_DIR/k8s-load.sh" + +echo "Applying manifests (namespace={room})..." +kubectl --context "$CTX" apply -k "$K8S_DIR/overlays/dev" + +echo +echo "Done." +echo " Gateway: http://localhost:{nodeport}/" +echo " (add `127.0.0.1 {room}.spr.local.ar` to /etc/hosts for Host routing)" +""" + + +def k8s_down_sh(*, room: str, cluster: str) -> str: + return f"""\ +#!/bin/bash +# Remove the "{room}" namespace from the shared `{cluster}` cluster. +# Leaves the cluster itself running (use repo-root ctrl/kind-down.sh to drop everything). +set -e + +CTX="kind-{cluster}" + +if ! kind get clusters 2>/dev/null | grep -q '^{cluster}$'; then + echo "Kind cluster '{cluster}' not running — nothing to do." + exit 0 +fi + +if kubectl --context "$CTX" get namespace {room} >/dev/null 2>&1; then + kubectl --context "$CTX" delete namespace {room} +else + echo "Namespace '{room}' not found in '{cluster}'." +fi +""" + + +def k8s_load_sh( + *, room: str, cluster: str, managed_name: str, + has_managed: bool, has_frontend: bool, has_link: bool, +) -> str: + steps = [f'echo "Building soleprint image…"', + f'docker build -t {room}-soleprint:dev "$ROOT_DIR/soleprint"'] + if has_managed: + steps += [f'echo "Building backend image…"', + f'docker build -t {room}-backend:dev "$ROOT_DIR/{managed_name}/backend"'] + if has_frontend: + steps += [f'echo "Building frontend image…"', + f'docker build -t {room}-frontend:dev "$ROOT_DIR/{managed_name}/frontend"'] + if has_link: + steps += [f'echo "Building link image…"', + f'docker build -t {room}-link:dev "$ROOT_DIR/link"'] + + loads = [f'kind load docker-image {room}-soleprint:dev --name {cluster}'] + if has_managed: + loads.append(f'kind load docker-image {room}-backend:dev --name {cluster}') + if has_frontend: + loads.append(f'kind load docker-image {room}-frontend:dev --name {cluster}') + if has_link: + loads.append(f'kind load docker-image {room}-link:dev --name {cluster}') + + body = "\n".join(steps + [""] + [f'echo "Loading into `{cluster}` kind cluster…"'] + loads) + return f"""\ +#!/bin/bash +# Build all images for room "{room}" and load them into the shared `{cluster}` cluster. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +{body} +""" diff --git a/soleprint/station/tools/graphgen/api.py b/soleprint/station/tools/graphgen/api.py index e6f730c..7090ae9 100644 --- a/soleprint/station/tools/graphgen/api.py +++ b/soleprint/station/tools/graphgen/api.py @@ -19,7 +19,10 @@ def _schema_paths() -> list[Path]: """Return ordered list of directories to search for schema.""" paths = [] - # cfg//station/tools/graphgen/ (room-specific, highest priority) + # station/tools/graphgen/ (room-merged schema.json lives here after build) + paths.append(SPR_ROOT / "station" / "tools" / "graphgen") + + # cfg//station/tools/graphgen/ (source-tree room-specific, highest priority) cfg_dir = SPR_ROOT / "cfg" if cfg_dir.exists(): for room in sorted(cfg_dir.iterdir()):