add fixture-invoicing example, sample-room wrap, kind cluster support
- examples/fixture-invoicing/: FastAPI + Vue + Postgres demo (4-entity invoice fixture)
- cfg/sample/: wraps the fixture (managed.repos points at examples/)
- ctrl/kind-{up,down,status}.sh + per-room k8s render in soleprint/ctrl/k8s/
- build.py: relative repo paths, resilient rmtree, optional k8s render hook
- cfg/.gitignore: stop ignoring sample/ and standalone/ template rooms
Manifests render cleanly but kind cluster has not been run end-to-end yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
46
build.py
46
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}")
|
||||
|
||||
|
||||
|
||||
5
cfg/.gitignore
vendored
5
cfg/.gitignore
vendored
@@ -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.
|
||||
|
||||
14
cfg/sample/.env.example
Normal file
14
cfg/sample/.env.example
Normal file
@@ -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
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
62
cfg/sample/ctrl/deploy.sh
Executable file
62
cfg/sample/ctrl/deploy.sh
Executable file
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
156
cfg/sample/data/__init__.py
Normal file
156
cfg/sample/data/__init__.py
Normal file
@@ -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()],
|
||||
}
|
||||
14
cfg/sample/data/books.json
Normal file
14
cfg/sample/data/books.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
cfg/sample/data/depots.json
Normal file
12
cfg/sample/data/depots.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
cfg/sample/data/desks.json
Normal file
3
cfg/sample/data/desks.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
22
cfg/sample/data/monitors.json
Normal file
22
cfg/sample/data/monitors.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
cfg/sample/data/plexuses.json
Normal file
3
cfg/sample/data/plexuses.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
3
cfg/sample/data/pulses.json
Normal file
3
cfg/sample/data/pulses.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
5
cfg/sample/data/rooms.json
Normal file
5
cfg/sample/data/rooms.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"items": [
|
||||
{"name": "pawprint-local", "slug": "pawprint-local", "title": "Pawprint Local", "status": "dev", "config_path": "deploy/pawprint-local"}
|
||||
]
|
||||
}
|
||||
18
cfg/sample/data/shunts.json
Normal file
18
cfg/sample/data/shunts.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
cfg/sample/data/tables.json
Normal file
3
cfg/sample/data/tables.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
38
cfg/sample/data/template/feature-form/template.md
Normal file
38
cfg/sample/data/template/feature-form/template.md
Normal file
@@ -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}}
|
||||
12
cfg/sample/data/templates.json
Normal file
12
cfg/sample/data/templates.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
48
cfg/sample/data/tools.json
Normal file
48
cfg/sample/data/tools.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
60
cfg/sample/data/veins.json
Normal file
60
cfg/sample/data/veins.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
74
cfg/sample/docker-compose.yml
Normal file
74
cfg/sample/docker-compose.yml
Normal file
@@ -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}
|
||||
15
cfg/sample/link/Dockerfile
Normal file
15
cfg/sample/link/Dockerfile
Normal file
@@ -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"]
|
||||
0
cfg/sample/link/adapters/__init__.py
Normal file
0
cfg/sample/link/adapters/__init__.py
Normal file
43
cfg/sample/link/adapters/sqlalchemy_bridge.py
Normal file
43
cfg/sample/link/adapters/sqlalchemy_bridge.py
Normal file
@@ -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]}
|
||||
24
cfg/sample/link/docker-compose.yml
Normal file
24
cfg/sample/link/docker-compose.yml
Normal file
@@ -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}
|
||||
19
cfg/sample/link/main.py
Normal file
19
cfg/sample/link/main.py
Normal file
@@ -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")
|
||||
4
cfg/sample/link/requirements.txt
Normal file
4
cfg/sample/link/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg2-binary==2.9.10
|
||||
@@ -1,15 +1,21 @@
|
||||
# =============================================================================
|
||||
# Sample Managed App - Environment Configuration
|
||||
# sample room — Managed App (Fixture Invoicing)
|
||||
# =============================================================================
|
||||
# Copy this to cfg/<your-room>/<app-name>/.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
|
||||
|
||||
@@ -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}
|
||||
@@ -1,102 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sample - Public Demo</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #0d1b2a 100%);
|
||||
color: #e5e5e5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, #3a86ff, #8338ec);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
color: #a3a3a3;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(58, 134, 255, 0.2);
|
||||
border: 1px solid #3a86ff;
|
||||
border-radius: 4px;
|
||||
color: #3a86ff;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.info {
|
||||
margin-top: 3rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.info h3 {
|
||||
color: #8338ec;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.info ul {
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
}
|
||||
.info li {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.info li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
a {
|
||||
color: #3a86ff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Sample</h1>
|
||||
<p>Public demo - open to any Gmail account</p>
|
||||
<span class="status">Public Demo</span>
|
||||
|
||||
<div class="info">
|
||||
<h3>Soleprint Managed Room</h3>
|
||||
<ul>
|
||||
<li>
|
||||
With sidebar:
|
||||
<a href="https://sample.spr.mcrn.ar"
|
||||
>sample.spr.mcrn.ar</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Standalone:
|
||||
<a href="https://sample.mcrn.ar">sample.mcrn.ar</a>
|
||||
</li>
|
||||
<li>Login with any Google account</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,6 +16,7 @@ NETWORK_NAME=sample_network
|
||||
# PORTS (unique per room)
|
||||
# =============================================================================
|
||||
SOLEPRINT_PORT=12030
|
||||
NGINX_PORT=8130
|
||||
|
||||
# =============================================================================
|
||||
# GOOGLE OAUTH
|
||||
|
||||
44
cfg/sample/soleprint/atlas/books/fixture/index.md
Normal file
44
cfg/sample/soleprint/atlas/books/fixture/index.md
Normal file
@@ -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 |
|
||||
@@ -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:
|
||||
|
||||
@@ -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 '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
|
||||
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;
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
124
cfg/sample/soleprint/station/tools/datagen/fixture.py
Normal file
124
cfg/sample/soleprint/station/tools/datagen/fixture.py
Normal file
@@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
44
cfg/sample/soleprint/station/tools/graphgen/schema.json
Normal file
44
cfg/sample/soleprint/station/tools/graphgen/schema.json
Normal file
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
cfg/sample/soleprint/station/tools/tester/environments.json
Normal file
15
cfg/sample/soleprint/station/tools/tester/environments.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
20
ctrl/k8s/kind-config.yaml
Normal file
20
ctrl/k8s/kind-config.yaml
Normal file
@@ -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}
|
||||
12
ctrl/kind-down.sh
Executable file
12
ctrl/kind-down.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Delete the shared `spr` kind cluster (drops every room's namespace too).
|
||||
# Use `gen/<room>/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
|
||||
12
ctrl/kind-status.sh
Executable file
12
ctrl/kind-status.sh
Executable file
@@ -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
|
||||
21
ctrl/kind-up.sh
Executable file
21
ctrl/kind-up.sh
Executable file
@@ -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/<room>/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/<room> && ./ctrl/k8s-up.sh"
|
||||
6
examples/.gitignore
vendored
Normal file
6
examples/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
46
examples/fixture-invoicing/README.md
Normal file
46
examples/fixture-invoicing/README.md
Normal file
@@ -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
|
||||
```
|
||||
15
examples/fixture-invoicing/backend/Dockerfile
Normal file
15
examples/fixture-invoicing/backend/Dockerfile
Normal file
@@ -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"]
|
||||
0
examples/fixture-invoicing/backend/api/__init__.py
Normal file
0
examples/fixture-invoicing/backend/api/__init__.py
Normal file
53
examples/fixture-invoicing/backend/api/customers.py
Normal file
53
examples/fixture-invoicing/backend/api/customers.py
Normal file
@@ -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()
|
||||
71
examples/fixture-invoicing/backend/api/invoices.py
Normal file
71
examples/fixture-invoicing/backend/api/invoices.py
Normal file
@@ -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()
|
||||
40
examples/fixture-invoicing/backend/api/line_items.py
Normal file
40
examples/fixture-invoicing/backend/api/line_items.py
Normal file
@@ -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()
|
||||
49
examples/fixture-invoicing/backend/api/payments.py
Normal file
49
examples/fixture-invoicing/backend/api/payments.py
Normal file
@@ -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()
|
||||
29
examples/fixture-invoicing/backend/db.py
Normal file
29
examples/fixture-invoicing/backend/db.py
Normal file
@@ -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()
|
||||
52
examples/fixture-invoicing/backend/main.py
Normal file
52
examples/fixture-invoicing/backend/main.py
Normal file
@@ -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"])
|
||||
63
examples/fixture-invoicing/backend/models.py
Normal file
63
examples/fixture-invoicing/backend/models.py
Normal file
@@ -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")
|
||||
5
examples/fixture-invoicing/backend/requirements.txt
Normal file
5
examples/fixture-invoicing/backend/requirements.txt
Normal file
@@ -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
|
||||
58
examples/fixture-invoicing/backend/schemas.py
Normal file
58
examples/fixture-invoicing/backend/schemas.py
Normal file
@@ -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
|
||||
64
examples/fixture-invoicing/backend/seed.py
Normal file
64
examples/fixture-invoicing/backend/seed.py
Normal file
@@ -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()
|
||||
54
examples/fixture-invoicing/docker-compose.yml
Normal file
54
examples/fixture-invoicing/docker-compose.yml
Normal file
@@ -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:
|
||||
11
examples/fixture-invoicing/frontend/Dockerfile
Normal file
11
examples/fixture-invoicing/frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
12
examples/fixture-invoicing/frontend/index.html
Normal file
12
examples/fixture-invoicing/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fixture Invoicing — Soleprint Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
examples/fixture-invoicing/frontend/package.json
Normal file
19
examples/fixture-invoicing/frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
17
examples/fixture-invoicing/frontend/src/App.vue
Normal file
17
examples/fixture-invoicing/frontend/src/App.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="fixture-banner">
|
||||
⚠ FIXTURE APP — SOLEPRINT DEMO — NOT A REAL PRODUCT ⚠
|
||||
</div>
|
||||
|
||||
<header class="app-nav">
|
||||
<h1>Fixture Invoicing</h1>
|
||||
<nav>
|
||||
<router-link to="/customers">Customers</router-link>
|
||||
<router-link to="/invoices">Invoices</router-link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</template>
|
||||
42
examples/fixture-invoicing/frontend/src/api.js
Normal file
42
examples/fixture-invoicing/frontend/src/api.js
Normal file
@@ -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" }),
|
||||
};
|
||||
6
examples/fixture-invoicing/frontend/src/main.js
Normal file
6
examples/fixture-invoicing/frontend/src/main.js
Normal file
@@ -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");
|
||||
15
examples/fixture-invoicing/frontend/src/router.js
Normal file
15
examples/fixture-invoicing/frontend/src/router.js
Normal file
@@ -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 },
|
||||
],
|
||||
});
|
||||
119
examples/fixture-invoicing/frontend/src/style.css
Normal file
119
examples/fixture-invoicing/frontend/src/style.css
Normal file
@@ -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; }
|
||||
71
examples/fixture-invoicing/frontend/src/views/Customers.vue
Normal file
71
examples/fixture-invoicing/frontend/src/views/Customers.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<h2>Customers</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Add customer</h3>
|
||||
<div class="actions">
|
||||
<input v-model="draft.name" placeholder="Name" />
|
||||
<input v-model="draft.email" placeholder="Email" />
|
||||
<button @click="add" :disabled="!draft.name || !draft.email">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-if="customers.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in customers" :key="c.id">
|
||||
<td>{{ c.id }}</td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.email }}</td>
|
||||
<td>
|
||||
<button class="ghost" @click="remove(c.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No customers yet.</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from "vue";
|
||||
import { api } from "../api.js";
|
||||
|
||||
const customers = ref([]);
|
||||
const draft = reactive({ name: "", email: "" });
|
||||
|
||||
async function load() {
|
||||
customers.value = await api.listCustomers();
|
||||
}
|
||||
|
||||
async function add() {
|
||||
await api.createCustomer({ name: draft.name, email: draft.email });
|
||||
draft.name = "";
|
||||
draft.email = "";
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await api.deleteCustomer(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334;
|
||||
border-radius: 4px;
|
||||
background: #0d1b2a;
|
||||
color: var(--text);
|
||||
min-width: 180px;
|
||||
}
|
||||
</style>
|
||||
148
examples/fixture-invoicing/frontend/src/views/InvoiceDetail.vue
Normal file
148
examples/fixture-invoicing/frontend/src/views/InvoiceDetail.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div v-if="invoice">
|
||||
<h2>
|
||||
Invoice {{ invoice.number }}
|
||||
<span :class="['status', invoice.status]">{{ invoice.status }}</span>
|
||||
</h2>
|
||||
<p class="muted">
|
||||
Customer: <strong>{{ invoice.customer.name }}</strong> ({{ invoice.customer.email }})
|
||||
· Issued {{ invoice.issued_at?.slice(0, 10) }}
|
||||
<span v-if="invoice.due_at"> · Due {{ invoice.due_at.slice(0, 10) }}</span>
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<h3>Line items</h3>
|
||||
<table v-if="invoice.line_items.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Qty</th>
|
||||
<th>Unit price</th>
|
||||
<th>Total</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="li in invoice.line_items" :key="li.id">
|
||||
<td>{{ li.description }}</td>
|
||||
<td>{{ li.quantity }}</td>
|
||||
<td>${{ li.unit_price }}</td>
|
||||
<td>${{ (li.quantity * li.unit_price).toFixed(2) }}</td>
|
||||
<td><button class="ghost" @click="removeLine(li.id)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No line items.</p>
|
||||
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<input v-model="lineDraft.description" placeholder="Description" />
|
||||
<input type="number" v-model.number="lineDraft.quantity" style="width: 80px;" />
|
||||
<input type="number" step="0.01" v-model.number="lineDraft.unit_price" style="width: 120px;" />
|
||||
<button @click="addLine" :disabled="!lineDraft.description">Add line</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Payments</h3>
|
||||
<table v-if="invoice.payments.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Amount</th>
|
||||
<th>Method</th>
|
||||
<th>Paid at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in invoice.payments" :key="p.id">
|
||||
<td>${{ p.amount }}</td>
|
||||
<td>{{ p.method }}</td>
|
||||
<td>{{ p.paid_at?.slice(0, 10) }}</td>
|
||||
<td><button class="ghost" @click="removePayment(p.id)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No payments.</p>
|
||||
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<input type="number" step="0.01" v-model.number="payDraft.amount" placeholder="Amount" />
|
||||
<select v-model="payDraft.method">
|
||||
<option value="cash">cash</option>
|
||||
<option value="card">card</option>
|
||||
<option value="transfer">transfer</option>
|
||||
</select>
|
||||
<button @click="addPayment" :disabled="!payDraft.amount">Record payment</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="muted">
|
||||
Total billed: <strong>${{ totalBilled.toFixed(2) }}</strong>
|
||||
· Total paid: <strong>${{ totalPaid.toFixed(2) }}</strong>
|
||||
· Balance: <strong>${{ (totalBilled - totalPaid).toFixed(2) }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="muted">Loading…</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from "vue";
|
||||
import { api } from "../api.js";
|
||||
|
||||
const props = defineProps({ id: { type: [String, Number], required: true } });
|
||||
|
||||
const invoice = ref(null);
|
||||
const lineDraft = reactive({ description: "", quantity: 1, unit_price: 0 });
|
||||
const payDraft = reactive({ amount: 0, method: "cash" });
|
||||
|
||||
const totalBilled = computed(() =>
|
||||
(invoice.value?.line_items || []).reduce(
|
||||
(sum, li) => sum + Number(li.quantity) * Number(li.unit_price), 0
|
||||
)
|
||||
);
|
||||
const totalPaid = computed(() =>
|
||||
(invoice.value?.payments || []).reduce(
|
||||
(sum, p) => sum + Number(p.amount), 0
|
||||
)
|
||||
);
|
||||
|
||||
async function load() {
|
||||
invoice.value = await api.getInvoice(props.id);
|
||||
}
|
||||
|
||||
async function addLine() {
|
||||
await api.addLineItem(props.id, { ...lineDraft });
|
||||
lineDraft.description = "";
|
||||
lineDraft.quantity = 1;
|
||||
lineDraft.unit_price = 0;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removeLine(id) {
|
||||
await api.deleteLineItem(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function addPayment() {
|
||||
await api.recordPayment(props.id, { ...payDraft });
|
||||
payDraft.amount = 0;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removePayment(id) {
|
||||
await api.deletePayment(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
watch(() => props.id, load);
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions input, .actions select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334;
|
||||
border-radius: 4px;
|
||||
background: #0d1b2a;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
93
examples/fixture-invoicing/frontend/src/views/Invoices.vue
Normal file
93
examples/fixture-invoicing/frontend/src/views/Invoices.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<h2>Invoices</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Create invoice</h3>
|
||||
<div class="actions">
|
||||
<input v-model="draft.number" placeholder="Number (e.g. DEMO-2026-004)" />
|
||||
<select v-model="draft.customer_id">
|
||||
<option :value="null" disabled>Customer…</option>
|
||||
<option v-for="c in customers" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="add" :disabled="!draft.number || !draft.customer_id">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-if="invoices.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Number</th>
|
||||
<th>Customer</th>
|
||||
<th>Issued</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="inv in invoices" :key="inv.id">
|
||||
<td>{{ inv.id }}</td>
|
||||
<td>
|
||||
<router-link :to="`/invoices/${inv.id}`" class="link">
|
||||
{{ inv.number }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ customerName(inv.customer_id) }}</td>
|
||||
<td>{{ inv.issued_at?.slice(0, 10) }}</td>
|
||||
<td><span :class="['status', inv.status]">{{ inv.status }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No invoices yet.</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive, computed } from "vue";
|
||||
import { api } from "../api.js";
|
||||
|
||||
const invoices = ref([]);
|
||||
const customers = ref([]);
|
||||
const draft = reactive({ number: "", customer_id: null });
|
||||
|
||||
const customerById = computed(() =>
|
||||
Object.fromEntries(customers.value.map((c) => [c.id, c]))
|
||||
);
|
||||
|
||||
function customerName(id) {
|
||||
return customerById.value[id]?.name || `#${id}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[invoices.value, customers.value] = await Promise.all([
|
||||
api.listInvoices(),
|
||||
api.listCustomers(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function add() {
|
||||
await api.createInvoice({
|
||||
number: draft.number,
|
||||
customer_id: draft.customer_id,
|
||||
status: "draft",
|
||||
});
|
||||
draft.number = "";
|
||||
draft.customer_id = null;
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions input, .actions select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334;
|
||||
border-radius: 4px;
|
||||
background: #0d1b2a;
|
||||
color: var(--text);
|
||||
min-width: 200px;
|
||||
}
|
||||
</style>
|
||||
12
examples/fixture-invoicing/frontend/vite.config.js
Normal file
12
examples/fixture-invoicing/frontend/vite.config.js
Normal file
@@ -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 },
|
||||
},
|
||||
});
|
||||
0
soleprint/ctrl/__init__.py
Normal file
0
soleprint/ctrl/__init__.py
Normal file
3
soleprint/ctrl/k8s/__init__.py
Normal file
3
soleprint/ctrl/k8s/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .render import render_k8s
|
||||
|
||||
__all__ = ["render_k8s"]
|
||||
98
soleprint/ctrl/k8s/render.py
Normal file
98
soleprint/ctrl/k8s/render.py
Normal file
@@ -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/<room>/ctrl/k8s/ base + overlays/dev manifests
|
||||
gen/<room>/ctrl/k8s-up.sh apply this room's manifests into spr
|
||||
gen/<room>/ctrl/k8s-down.sh delete this room's namespace from spr
|
||||
gen/<room>/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"))
|
||||
487
soleprint/ctrl/k8s/templates.py
Normal file
487
soleprint/ctrl/k8s/templates.py
Normal file
@@ -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}
|
||||
"""
|
||||
@@ -19,7 +19,10 @@ def _schema_paths() -> list[Path]:
|
||||
"""Return ordered list of directories to search for schema."""
|
||||
paths = []
|
||||
|
||||
# cfg/<room>/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/<room>/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()):
|
||||
|
||||
Reference in New Issue
Block a user