Compare commits

10 Commits

Author SHA1 Message Date
859256b68d remove dated examples 2026-10-07 16:47:42 -03:00
ec31abecea Merge branch 'rig-work' 2026-09-30 10:12:35 -03:00
c669662088 updated make docs 2026-09-30 10:12:32 -03:00
2b36799acc Merge branch 'rig-work' 2026-09-30 09:53:53 -03:00
85276d34c3 updated make docs 2026-09-30 09:53:32 -03:00
8185d16c07 Merge branch 'rig-work' 2026-09-28 10:04:31 -03:00
f00055033e proper docs and .version 2026-09-28 10:02:55 -03:00
d8e7de1c59 Merge branch 'rig-work' 2026-09-25 08:33:33 -03:00
100f389719 overlay 2026-09-25 08:32:28 -03:00
3bbfa413a3 big update based on real wokr 2026-09-23 18:52:57 -03:00
93 changed files with 226 additions and 2777 deletions

6
.gitignore vendored
View File

@@ -28,12 +28,10 @@ gen/
# browser, not source — and often a client's real data.
soleprint/station/tools/shuntgen/uploads/
# Room configurations (separate repo - contains credentials and room-specific data)
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
# Client room configurations. They carry credentials and a client's data, and were
# moved out of cfg/ (2026-10-07); still ignored so a restored copy is never committed.
cfg/amar/
cfg/dlt/
# Add new rooms here as they are created
# cfg/<room>/
# Client rigs. A rig is a copy of rig/ renamed after the environment it models,
# so its k8s files spell out a real architecture — exactly the thing that must

View File

@@ -29,8 +29,8 @@ export PYTHON
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),)
# Declares each extra word as a target that does nothing: `:` is an empty rule
# body and `@` silences it. Without this, `make build sample` runs the build and
# then fails with "No rule to make target 'sample'", because make reads every
# body and `@` silences it. Without this, `make build myroom` runs the build and
# then fails with "No rule to make target 'myroom'", because make reads every
# word on the line as something it has been asked to build.
$(eval $(ARGS):;@:)
# ...and as PHONY, because some of those words name real directories. `cfg`,

17
cfg/.gitignore vendored
View File

@@ -1,17 +0,0 @@
# Environment files with credentials (use .env.example as template)
**/.env
# Database dumps (sensitive data)
**/dumps/*.sql
# Python
__pycache__/
*.pyc
*.pyo
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.

View File

@@ -1,125 +1,11 @@
# Soleprint Room Configurations
# cfg
Private repository containing room-specific configurations for Soleprint instances.
Room configurations for `build.py` (`make build [room]` → `gen/<room>/`).
## Structure
- `standalone/`: the base room, built by default.
```
cfg/
├── amar/ # Amar managed room
│ ├── config.json
│ ├── data/
│ ├── soleprint/ # Soleprint customizations
│ ├── link/ # Bridge to managed app
│ └── ctrl/ # Build/run scripts
├── dlt/ # DLT placeholder room
└── README.md
```
Rooms are a pre-rig way of running spr beside an app. Wrapping an inherited site,
with spr's sidebar injected by nginx `sub_filter`, is meant to become a rig overlay.
## Setup
This repo lives inside the main soleprint repo at `cfg/`. The `standalone/` folder is tracked in the main soleprint repo as a sample.
### Fresh clone (new machine)
```bash
# Clone main soleprint repo
git clone <soleprint-repo-url> spr
cd spr
# Clone this cfg repo into cfg/ (standalone/ already exists from main repo)
git clone <this-cfg-repo-url> cfg-private
mv cfg-private/.git cfg/
mv cfg-private/* cfg/ 2>/dev/null
rm -rf cfg-private
# Now cfg/ has both:
# - standalone/ (from main soleprint repo)
# - amar/, dlt/ (from this cfg repo)
```
### Alternative: Separate directories
```bash
# Keep repos separate, use --cfg-path
git clone <soleprint-repo-url> spr
git clone <this-cfg-repo-url> spr-cfg
# Build with external cfg path
cd spr
python build.py --cfg amar --cfg-path ../spr-cfg
```
## Building
From the main soleprint repo:
```bash
python build.py # Build standalone (sample)
python build.py --cfg amar # Build amar room
python build.py --cfg dlt # Build dlt room
```
## Deploy/Sync Workflow
### Local Development
```bash
# 1. Build the room
python build.py --cfg amar
# 2. Start with Docker
cd gen/amar/soleprint && docker compose up -d
# 3. For managed rooms, also start the app
cd gen/amar/amar && docker compose up -d
```
### Production Deploy
```bash
# On deploy server:
# 1. Pull both repos
cd /opt/spr && git pull
cd /opt/spr/cfg && git pull
# 2. Rebuild
python build.py --cfg amar
# 3. Restart services
cd gen/amar/soleprint && docker compose up -d --build
```
### CI/CD Pipeline (example)
```yaml
# Deploy soleprint changes
deploy-soleprint:
script:
- ssh deploy@server "cd /opt/spr && git pull && python build.py --cfg amar"
- ssh deploy@server "cd /opt/spr/gen/amar/soleprint && docker compose up -d --build"
# Deploy cfg changes (private repo)
deploy-cfg:
script:
- ssh deploy@server "cd /opt/spr/cfg && git pull && python ../build.py --cfg amar"
- ssh deploy@server "cd /opt/spr/gen/amar/soleprint && docker compose up -d --build"
```
## Credentials
- `.env` files are gitignored - copy from `.env.example`
- Never commit actual credentials
- Database dumps in `*/dumps/` are also gitignored
## Adding a New Room
```bash
# From cfg/ directory
mkdir -p newroom/data newroom/soleprint
cp ../cfg/standalone/config.json newroom/ # Use standalone as template
# Edit config.json for your room
# Add room-specific customizations in newroom/soleprint/
git add newroom && git commit -m "Add newroom configuration"
```
The client rooms that lived here (with their own git repo, untracked by spr) were moved
out on 2026-10-07 and are kept as reference outside spr.

View File

@@ -1,150 +0,0 @@
{
"framework": {
"name": "soleprint",
"slug": "soleprint",
"version": "0.1.0",
"description": "Development workflow and documentation system",
"tagline": "Mapping development footprints",
"icon": "",
"hub_port": 12030
},
"auth": {
"enabled": true,
"provider": "google",
"allowed_domains": [],
"allowed_emails": [],
"session_secret": "ENV:AUTH_SESSION_SECRET"
},
"veins": ["google"],
"managed": {
"name": "sample",
"repos": {
"backend": "examples/fixture-invoicing/backend",
"frontend": "examples/fixture-invoicing/frontend"
}
},
"k8s": {
"enabled": true
},
"systems": [
{
"key": "data_flow",
"name": "artery",
"slug": "artery",
"title": "Artery",
"tagline": "Todo lo vital",
"icon": ""
},
{
"key": "documentation",
"name": "atlas",
"slug": "atlas",
"title": "Atlas",
"tagline": "Documentacion accionable",
"icon": ""
},
{
"key": "execution",
"name": "station",
"slug": "station",
"title": "Station",
"tagline": "Monitores, Entornos y Herramientas",
"icon": ""
}
],
"components": {
"shared": {
"config": {
"name": "room",
"title": "Room",
"description": "Runtime environment configuration",
"plural": "rooms"
},
"data": {
"name": "depot",
"title": "Depot",
"description": "Data storage / provisions",
"plural": "depots"
}
},
"data_flow": {
"connector": {
"name": "vein",
"title": "Vein",
"description": "Stateless API connector",
"plural": "veins"
},
"mock": {
"name": "shunt",
"title": "Shunt",
"description": "Fake connector for testing",
"plural": "shunts"
},
"composed": {
"name": "pulse",
"title": "Pulse",
"description": "Composed data flow",
"plural": "pulses",
"formula": "Vein + Room + Depot"
},
"app": {
"name": "plexus",
"title": "Plexus",
"description": "Full app with backend, frontend and DB",
"plural": "plexus"
}
},
"documentation": {
"pattern": {
"name": "template",
"title": "Template",
"description": "Documentation pattern",
"plural": "templates"
},
"library": {
"name": "book",
"title": "Book",
"description": "Documentation library"
},
"composed": {
"name": "book",
"title": "Book",
"description": "Composed documentation",
"plural": "books",
"formula": "Template + Depot"
}
},
"execution": {
"utility": {
"name": "tool",
"title": "Tool",
"description": "Execution utility",
"plural": "tools"
},
"watcher": {
"name": "monitor",
"title": "Monitor",
"description": "Service monitor",
"plural": "monitors"
},
"container": {
"name": "cabinet",
"title": "Cabinet",
"description": "Tool container",
"plural": "cabinets"
},
"workspace": {
"name": "desk",
"title": "Desk",
"description": "Execution workspace"
},
"composed": {
"name": "desk",
"title": "Desk",
"description": "Composed execution bundle",
"plural": "desks",
"formula": "Cabinet + Room + Depots"
}
}
}
}

View File

@@ -1,62 +0,0 @@
#!/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"

View File

@@ -1,45 +0,0 @@
#!/bin/bash
# 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=""
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
# 3) Soleprint (+ nginx sidebar injection)
(cd "$SPR_DIR" && docker compose -f docker-compose.yml $NGINX_OVERRIDE up $DETACH $BUILD)

View File

@@ -1,15 +0,0 @@
#!/bin/bash
# Stop sample room
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Stopping sample ==="
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

View File

@@ -1,156 +0,0 @@
"""
Soleprint 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()],
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,3 +0,0 @@
{
"items": []
}

View File

@@ -1,37 +0,0 @@
{
"items": [
{
"name": "datagen",
"slug": "datagen",
"title": "Datagen",
"status": "live",
"system": "station",
"type": "app",
"description": "Generate realistic test data via faker-backed generators.",
"path": "station/tools/datagen",
"url": "/station/tools/datagen/"
},
{
"name": "graphgen",
"slug": "graphgen",
"title": "Graphgen",
"status": "live",
"system": "station",
"type": "app",
"description": "Render the data model as an interactive graph (entities + FK edges).",
"path": "station/tools/graphgen",
"url": "/station/tools/graphgen/"
},
{
"name": "modelgen",
"slug": "modelgen",
"title": "Modelgen",
"status": "live",
"system": "station",
"type": "cli",
"description": "Generate platform-specific models (Pydantic, Django, Prisma) from JSON Schema.",
"path": "station/tools/modelgen",
"cli": "python -m modelgen"
}
]
}

View File

@@ -1,12 +0,0 @@
{
"items": [
{
"name": "google",
"slug": "google",
"title": "Google",
"status": "live",
"system": "artery",
"description": "Google OAuth login + Drive/Calendar/Gmail APIs."
}
]
}

View File

@@ -1,74 +0,0 @@
# 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}

View File

@@ -1,15 +0,0 @@
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"]

View File

@@ -1,43 +0,0 @@
"""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]}

View File

@@ -1,24 +0,0 @@
# 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}

View File

@@ -1,19 +0,0 @@
"""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")

View File

@@ -1,4 +0,0 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10

View File

@@ -1,14 +0,0 @@
# =============================================================================
# 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

View File

@@ -1,23 +0,0 @@
# =============================================================================
# Sample Soleprint Configuration — copy to .env and fill in
# =============================================================================
DEPLOYMENT_NAME=sample_spr
NETWORK_NAME=sample_network
# Ports (unique per room)
SOLEPRINT_PORT=12030
NGINX_PORT=8130
# =============================================================================
# Google OAuth — get values from console.cloud.google.com
# =============================================================================
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URI=http://sample.spr.local.ar/artery/google/oauth/callback
# =============================================================================
# Auth
# =============================================================================
AUTH_BYPASS=true
AUTH_SESSION_SECRET=change-me-to-a-long-random-string

View File

@@ -1,35 +0,0 @@
# Nginx Reverse Proxy for Docker-based Local Development
#
# Usage:
# # Start all services including nginx:
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
#
# # Or start nginx separately after other services:
# docker compose -f docker-compose.nginx.yml up -d
#
# Routes:
# - sample.spr.local.ar -> frontend with sidebar injection
# - sample.local.ar -> frontend without sidebar
#
# Note: Requires /etc/hosts entries:
# 127.0.0.1 sample.spr.local.ar sample.local.ar
name: ${DEPLOYMENT_NAME}_nginx
services:
nginx:
image: nginx:alpine
container_name: ${DEPLOYMENT_NAME}_nginx
ports:
- "${NGINX_PORT:-80}:80"
volumes:
- ./nginx/local.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- default
depends_on:
- soleprint
restart: unless-stopped
networks:
default:
name: ${NETWORK_NAME}

View File

@@ -1,37 +0,0 @@
# Soleprint Services - Docker Compose
#
# Runs soleprint hub as a single service
# Artery, atlas, station are accessed via path-based routing
#
# Usage:
# cd gen/<room>/soleprint && docker compose up -d
name: ${DEPLOYMENT_NAME}
services:
soleprint:
build:
context: .
dockerfile: Dockerfile
container_name: ${DEPLOYMENT_NAME}
user: "${UID:-1000}:${GID:-1000}"
volumes:
- .:/app
ports:
- "${SOLEPRINT_PORT}:8000"
env_file:
- .env
environment:
# For single-port mode, all subsystems are internal routes
- ARTERY_EXTERNAL_URL=/artery
- ATLAS_EXTERNAL_URL=/atlas
- STATION_EXTERNAL_URL=/station
networks:
- default
# Use run.py for single-port bare-metal mode
command: uvicorn run:app --host 0.0.0.0 --port 8000 --reload
networks:
default:
external: true
name: ${NETWORK_NAME}

View File

@@ -1,78 +0,0 @@
# Sample Room — Nginx Config
# Uses Docker DNS resolver so upstreams resolve at request time
# (lets nginx start before backend/frontend containers are up).
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;
set $soleprint sample_spr;
set $backend sample_backend;
set $frontend sample_frontend;
# Soleprint routes (sidebar API + static assets)
location /spr/ {
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;
}
# 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 (Vite dev server on 5173) with sidebar injection
location / {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Accept-Encoding "";
sub_filter '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
sub_filter_once off;
}
}
# sample.local.ar — direct fixture access (no sidebar)
server {
listen 80;
server_name sample.local.ar;
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: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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

View File

@@ -1,60 +0,0 @@
{
"$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"}
}
}
}
}

View File

@@ -1,57 +0,0 @@
{
"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"]
}
]
}

View File

@@ -1,124 +0,0 @@
"""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"},
},
},
}
}

View File

@@ -1,44 +0,0 @@
{
"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"}
}
}
}
}

View File

@@ -1,15 +0,0 @@
{
"environments": [
{
"name": "docker",
"url": "http://sample_backend:8000",
"auth_type": "none",
"default": true
},
{
"name": "local",
"url": "http://localhost:8120",
"auth_type": "none"
}
]
}

View File

@@ -1,52 +0,0 @@
"""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)

View File

@@ -3,8 +3,8 @@
#
# Usage:
# ./ctrl/start.sh # standalone, foreground
# ./ctrl/start.sh amar -d # amar, detached
# ./ctrl/start.sh sample --build # flags pass straight through
# ./ctrl/start.sh myroom -d # a named room, detached
# ./ctrl/start.sh myroom --build # flags pass straight through
#
# Every room ships its own start script (gen/<room>/ctrl/start.sh) and stays
# runnable on its own — this only saves cd'ing there and picks the default room.

View File

@@ -10,7 +10,6 @@ The logic lives in the scripts, never in the Makefile.
```bash
make build # cfg/standalone -> gen/standalone
make build sample # cfg/sample -> gen/sample
make start # run it
```
@@ -27,8 +26,8 @@ make start # run it
| `make component [list\|publish\|sync\|watch\|diff]` | `ctrl/spr.py` | publish a distributable component |
| `make deploy` | `ctrl/deploy.sh` | rsync `gen/standalone` to the server and restart |
Bare words pass straight through, so `make build sample` becomes
`ctrl/build.sh sample`. Anything starting with a dash would be eaten by make
Bare words pass straight through, so `make build myroom` becomes
`ctrl/build.sh myroom`. Anything starting with a dash would be eaten by make
itself, so those go through `ARGS`:
```bash

View File

@@ -59,10 +59,10 @@ Open [http://localhost:9000](http://localhost:9000) and configure your room from
Start from a sample configuration:
```bash
python -m init.cli myroom --from sample
python -m init.cli myroom --from standalone
```
This copies the sample room's config as your starting point.
This copies the standalone room's config as your starting point.
## Rebuild

View File

@@ -10,7 +10,7 @@ python -m init.cli myroom
python -m init.web
# Clone from existing room
python -m init.cli myroom --from sample
python -m init.cli myroom --from standalone
```
## Layers

View File

@@ -28,16 +28,16 @@ files:
Then build. The compose merge is a step in [Export / Compile](#export):
```bash
python build.py --cfg sample
cd gen/sample && docker compose up -d
python build.py --cfg <room>
cd gen/<room> && docker compose up -d
```
`build.py` merges each cabinet's compose fragment into the room's
`docker-compose.yml`, declares its named volumes, and appends its settings to
`.env.example` — never to `.env`.
**A service the room already declares wins.** `cfg/amar/docker-compose.yml`
ships its own `db`; switching the postgres cabinet on will not replace it. The
**A service the room already declares wins.** A room whose `docker-compose.yml`
ships its own `db`: switching the postgres cabinet on will not replace it. The
build says so when it skips one:
```

6
examples/.gitignore vendored
View File

@@ -1,6 +0,0 @@
node_modules/
dist/
.vite/
__pycache__/
*.pyc
.venv/

View File

@@ -1,46 +0,0 @@
# 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
```

View File

@@ -1,15 +0,0 @@
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"]

View File

@@ -1,53 +0,0 @@
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()

View File

@@ -1,71 +0,0 @@
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()

View File

@@ -1,40 +0,0 @@
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()

View File

@@ -1,49 +0,0 @@
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()

View File

@@ -1,29 +0,0 @@
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()

View File

@@ -1,52 +0,0 @@
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"])

View File

@@ -1,63 +0,0 @@
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")

View File

@@ -1,5 +0,0 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
pydantic==2.9.2

View File

@@ -1,58 +0,0 @@
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

View File

@@ -1,64 +0,0 @@
"""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()

View File

@@ -1,54 +0,0 @@
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:

View File

@@ -1,11 +0,0 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -1,12 +0,0 @@
<!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>

View File

@@ -1,19 +0,0 @@
{
"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"
}
}

View File

@@ -1,17 +0,0 @@
<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>

View File

@@ -1,42 +0,0 @@
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" }),
};

View File

@@ -1,6 +0,0 @@
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router.js";
import "./style.css";
createApp(App).use(router).mount("#app");

View File

@@ -1,15 +0,0 @@
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 },
],
});

View File

@@ -1,119 +0,0 @@
: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; }

View File

@@ -1,71 +0,0 @@
<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>

View File

@@ -1,148 +0,0 @@
<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>

View File

@@ -1,93 +0,0 @@
<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>

View File

@@ -1,12 +0,0 @@
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 },
},
});

View File

@@ -7,7 +7,7 @@ generating cfg/<room>/ for a new soleprint room.
Usage:
python -m init.cli myroom # Interactive wizard
python -m init.cli myroom --from sample # Clone existing room as variant
python -m init.cli myroom --from standalone # Clone existing room as variant
"""
import argparse

9
rig/.gitignore vendored
View File

@@ -6,14 +6,7 @@ def
.env.local
ctrl/.env
# generated: the .dot is a build artifact rendered from arch/*.json, never hand-edited.
# The .svg IS committed — onboarding material should render in a repo browser.
arch/*.dot
# ctrl/Tiltfile.gen was here for a generator that no longer exists. ctrl/Tiltfile
# is now a real, committed file that derives its values when Tilt parses it, so
# there is nothing generated to ignore.
# binaries pulled by `make deps-bundle` for the air-gapped installer image
# the manifests the air-gapped installer image unpacks (docs/notes/Dockerfile.deps.md)
vendor
# Overlays that live with no version control of their own, or clones of their own

1
rig/.version Normal file
View File

@@ -0,0 +1 @@
0.1.3

View File

@@ -83,9 +83,8 @@ fetches with `curl`. A stock `debian:trixie-slim` has no curl — detection runs
fine, then the first download dies with `curl: command not found` and an exit
code of 127. That is the bootstrap paradox `ctrl/Dockerfile.deps` exists
to kill — the installer carries its own toolchain so the host needs only Docker —
but building the image and running it are two different things, and only the
build has a Makefile target today. **On a genuinely bare machine, run it by
hand:**
but `make deps image` only builds the image; running it is the step below.
**On a genuinely bare machine, run it by hand:**
```bash
make deps image # builds rig-deps:deps
@@ -207,13 +206,13 @@ never tracks it), or keep it anywhere else and name it by path.
## Run it
```bash
make kind-up # idempotent create, then selects the context
make tilt-up # context + your assigned port
make cluster up # idempotent create, then selects the context
make tilt # context + your assigned port
```
`tilt-up` passes `--context kind-<name>` every time, which is the point of going
`make tilt` passes `--context kind-<name>` every time, which is the point of going
through `make` at all: tilt cannot deploy into whichever cluster you last looked
at.
`make tilt-down` and `make kind-down` close the loop, and `make kind-reset` is
`make tilt down` and `make cluster down` close the loop, and `make cluster reset` is
delete-and-recreate for when a cluster wedges.

View File

@@ -1,200 +1,99 @@
# rig
A runnable local model of a large, regulated estate — legacy and new side by
side. Its job is onboarding and exploration, not a production replica: most
services are deliberately mocked, because what has to be faithful is the
topology, not the workloads.
rig builds a disposable Kubernetes environment on your machine, so you can see how a system
fits together without access to any of it. It is for **onboarding and exploration**, not a
production replica.
## Prerequisite
**Docker.** Nothing else — no curl, no jq, no python, no apt repositories.
### Starting from plain Windows
Everything here is bash and runs *inside* a Linux shell, so on a Windows machine
that means WSL. Nothing in rig installs WSL, and nothing will: `wsl --install`
enables Windows features and requires a reboot, which is not something a script
should do to a machine on your behalf — and there is no tested undo for it.
From an elevated PowerShell or Command Prompt, once:
Most services in it are **not real**. What has to be faithful is the topology: names, ports,
dependency order, who can reach whom, and how it fails. That is what makes a whole estate fit on
a laptop. A real 20-service platform does not fit once in 14 GB; stubs of about 30 MB each fit
three times.
```powershell
wsl --install
```
Under the hood it is a kind cluster, a local image registry and a Tilt dev loop, each with its
own name and ports, so several environments run side by side. What runs on it is an
**overlay**: a folder outside rig that holds one use case.
Then reboot and open the Linux shell it installed.
## Using rig from a project
**If you cloned this on the Windows side, copy it into WSL before carrying on.**
WSL can reach the Windows drives at `/mnt/c`, and working from there mostly
functions — slowly — but file watching does not: that filesystem raises no
inotify events, so anything watching for edits silently stops seeing them.
You don't need to read rig to use it. Get a release (`rig-<version>.tar.gz`, or a checkout at
the tag `rig-<version>`), extract it anywhere, and point the project at it (`RIG_DIR`). Then run
`make check`: it says what the machine still lacks and which command comes next. `.version`
names the release; a project records the one it was last run against. See
[`docs/notes/overlay.md`](docs/notes/overlay.md).
```bash
cp -r /mnt/c/Users/<you>/rig ~/rig
cd ~/rig
```
## Prerequisite
`make deps` reports it if you are running from `/mnt/...`. Then carry on below.
**Docker.** Nothing else: no curl, jq, python or apt repositories.
If it fails, the usual causes give unhelpful messages:
On Windows, rig runs inside WSL. Run `wsl --install` once, from an elevated shell, then reboot.
Work inside the Linux filesystem, not `/mnt/c`: file watching doesn't work there, and
`make deps` warns about it. Git Bash, MSYS and Cygwin don't work (no `/proc`, no docker socket).
| symptom | cause |
| --- | --- |
| "the virtual machine could not be started" | virtualization disabled in BIOS/UEFI |
| the command is not recognised | Windows build too old — needs 2004 or later |
| the command is not recognised | Windows build too old (needs 2004 or later) |
| the install starts, then nothing works | a reboot is still pending |
Running the scripts from **Git Bash, MSYS or Cygwin does not work** — those look
close enough to a Linux shell to get started and then fail without `/proc` or a
docker socket. `ctrl/deps.sh` detects that and says so rather than letting you
find out the slow way.
## Read the docs first
```bash
make docs # serves on localhost, prints the URL
```
They run before anything is installed, which matters because they are the
instructions for everything else. No cluster and no toolchain required.
Why the code is the way it is — the reasoning, measurements and gotchas — lives
in [`docs/notes/`](docs/notes/), one file per script, so the code keeps short comments.
## Then
```bash
make check # is this machine ready? short; `make check all` for every detail
make deps # install the toolchain (add `core` on a managed machine)
make cluster up # cluster + registry + the profile's addons
```
On a machine with nothing but Docker, [BOOTSTRAP.md](BOOTSTRAP.md) runs the toolchain through
the installer container.
That is the whole setup. `make cluster up` also starts this environment's local
registry and wires it into the node, so an image built locally is pullable by the
cluster without going near docker.io. `make check` shows its port, among
everything else:
## What it does
```bash
make check # ... registry localhost:<port> (running)
docker build -t localhost:<port>/app:1 .
docker push localhost:<port>/app:1
kubectl --context kind-$(basename $PWD) run app --image=localhost:<port>/app:1
```
| command | does |
| --- | --- |
| `make docs` | serves the docs locally; works before anything is installed |
| `make check` | is this machine ready? memory, toolchain, cluster, the next command (`check all` for every detail) |
| `make deps` | installs the pinned toolchain (`deps core` on a managed machine) |
| `make cluster up` | the cluster, its registry and the configured addons (`down`, `reset`, `list`, `free`) |
| `make tilt` | the dev loop: rig's part, then the overlay's Tiltfile |
| `make selftest` | checks rig itself |
| `make help` | every target |
The port block is derived from the directory name, so two copies of rig never
collide — nothing to configure. `make check all` lists it; `bash ctrl/ports.sh persist`
pins it into `ctrl/.env` if you want it fixed:
Every verb is a `Makefile` target calling one script in `ctrl/`. The aliases (`kind-up`,
`tilt-up`, …) call the same scripts.
```bash
make cluster list # every cluster on this machine, with memory
make cluster free # stop the others if memory is tight
make cluster down # remove this cluster and its registry
```
## Overlays
**The verbs are yours to change.** `cluster` is the script — `ctrl/cluster.sh` —
and every spelling above is a `Makefile` target that calls it. `make kind-up` is
an alias for `make cluster up`, kept because the other projects on this machine
answer to that spelling and muscle memory spans repos rather than stopping at
one. Nothing outside the `Makefile` reads these names, so rename them, drop the
ones you never type, or add whatever your own projects already say: each alias
is two lines at the bottom of the file, calling the same script the canonical
target does.
**`make tilt` works on a fresh copy, unedited.** rig's `ctrl/Tiltfile` does
rig's part — identity, context guard, registry, the manifests — and then includes
the overlay's own Tiltfile. With no overlay named that is `examples/starter`, so
the dev loop comes up with its two examples running and nothing to configure.
It hardcodes nothing. It asks `ctrl/ports.sh active` for this environment's
cluster name, kube context, ports and paths — the same values every other rig
script resolves through `ctrl/lib/config.sh` — so a copied and renamed rig, or a
moved overlay, deploys into its own cluster with no edits.
`make help` lists every target. `make selftest` checks rig itself, the installer's
detection included; `make selftest install` runs the installer on clean containers
([`docs/notes/installer-testing.md`](docs/notes/installer-testing.md)).
On a machine where Docker really is the only thing installed, `make deps` has
nothing to download with — see [BOOTSTRAP.md](BOOTSTRAP.md), which runs the
toolchain through the installer container and carries on to scaffolding and running
a new project.
## What runs is an overlay
rig is the machine: toolchain, cluster, registry, port block, the dev loop's
plumbing. What runs on it is an **overlay** — one folder, outside rig's version
control, holding a use case: its settings (`rig.env`), its manifests
(`k8s/overlays/dev`), its images and Tiltfile, its addons, its kind config if it
needs its own. rig reads it and never writes into it. See
[`docs/notes/overlay.md`](docs/notes/overlay.md).
An overlay holds its settings (`rig.env`), its manifests (`k8s/overlays/dev`), its Tiltfile and
images, its addons, and its kind config if it needs one. rig reads it and never writes into it.
```bash
cp -r examples/starter local/myenv # local/ is gitignored
OVERLAY=local/myenv make cluster up # or OVERLAY=local/myenv in ctrl/.env
cp -r examples/starter local/myenv # local/ is ignored
OVERLAY=local/myenv make cluster up
OVERLAY=local/myenv make tilt
```
An overlay can also be a repo of its own, anywhere, or a project folder that
carries rig at `<project>/rig/` with a three-line forwarding Makefile.
With no overlay named, rig runs `examples/starter`. [`examples/data`](examples/data/) adds
postgres, redis and airflow as plain manifests. A project can carry rig at `./rig`, or keep it
anywhere and name it with `RIG_DIR`.
## One environment per folder
The cluster name, context, image tags and ports derive from the overlay folder's name (rig's
own, with no overlay), so environments never collide, and one's teardown never touches another.
Cluster name, kubectl context, image tags and the host port block all derive
from a folder name — the overlay's when one is named, else rig's own — so copies
never collide and neither one's teardown can touch the other. Two overlays run
side by side from one rig; two copies of rig do too.
## Configuration
## Profiles
Layers, weakest first: built-in defaults < `ctrl/versions.env` < the profile < the overlay's
`rig.env` < `ctrl/.env` < the command line. A profile (`ctrl/env.d/`) is optional and says how
this machine reaches the world: `mirror` (a pull-through cache) and `offline` (air-gapped) ship
as examples. The cluster itself is one kind template: the overlay's, or rig's one-node default.
**rig needs no profile.** With none named it runs on built-in defaults: one node,
no addons, a local registry, the newest Kubernetes version it pins. A profile is
an optional file in `ctrl/env.d/`, named by `PROFILE`, that says how this machine
reaches the world. rig ships two as **examples**; copy one to use it (the copy is
gitignored):
rig's own addons make the cluster work: `metallb` (LoadBalancer addresses), `cert-manager` (a
local CA), `metrics-server` (`kubectl top`). A workload's database, cache or scheduler belongs to
its overlay. There is no ingress controller: services are reached through LoadBalancer
addresses, or through the overlay's own gateway on the environment's HTTP port.
| example | what it changes |
| --- | --- |
| `mirror.env.example` | images through a pull-through cache of an internal registry |
| `offline.env.example` | air-gapped: everything from a preloaded local registry |
## Where things are
```bash
cp ctrl/env.d/mirror.env.example ctrl/env.d/mirror.env
PROFILE=mirror make cluster up
```
The layers, weakest first: built-in defaults < `ctrl/versions.env` < the profile
< the overlay's `rig.env` < `ctrl/.env` < your command line.
The **cluster itself** is one file: the overlay's `kind-config.yaml.tpl` if it has
one, else rig's `ctrl/k8s/kind-config.yaml.tpl` (one node). To change it — more
nodes, other port mappings, mounts — edit it and `make cluster reset`. The node
count is read back out of it, so there is nothing to drift.
## Addons
Each addon is its own idempotent script, and `ADDONS` names the ones to install,
in order. Adding one is adding a file — there is no dispatcher to edit. An
overlay's `addons/<name>.sh` is found before rig's own.
**There is no ingress controller, deliberately.** They pin a narrow window of
Kubernetes versions, so depending on one would constrain which k8s a rig can be
built with — and running a trailing-edge control plane is often the point.
Services are reached through MetalLB and `type: LoadBalancer`, which carries no
such constraint and is also what a real cluster does.
rig's own addons make the cluster work:
| Addon | Does |
| --- | --- |
| `metallb` | gives `type: LoadBalancer` an address it can actually reach |
| `cert-manager` | a local CA, so TLS works offline |
| `metrics-server` | makes `kubectl top` work on kind |
What a workload needs — a database, a cache, a scheduler — belongs to its
overlay. [`examples/data`](examples/data/) carries postgres, redis and airflow as
plain manifests (no helm: a chart repo is a network dependency), with passwords
generated on first install and kept across re-runs:
```bash
OVERLAY=examples/data make cluster up
Makefile the verbs
.version this release: patch and minor steps are edits here; milestones get a tag
ctrl/ one script per verb; lib/config.sh (settings and layers); Tiltfile (rig's
half of the dev loop); k8s/kind-config.yaml.tpl; addons/; env.d/; versions.env
docs/ index.html (make docs); notes/, one per script: why the code is as it is
examples/ starter (the default overlay), data (postgres, redis, airflow)
standalone/ single-file kits generated from ctrl/ (make standalone); never edited by hand
tests/ the installer's host fixtures
BOOTSTRAP.md from a bare machine with only Docker
STALE.md assumptions withdrawn, each checked by selftest
```

View File

@@ -1,5 +1,5 @@
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
# Cluster SHAPE: an optional profile in ctrl/env.d/. Architecture MODEL: arch/<name>.json.
# Cluster SHAPE: an optional profile in ctrl/env.d/.
# Notes: docs/notes/env.md
# A profile in ctrl/env.d/ to build. Empty means rig's built-in defaults, which

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Documentation: render the diagrams, and serve the pages from a throwaway nginx container.
# Usage: docs.sh serve | graphs
# Whose: DOCS_DIR, the docs/ of the folder `make docs` runs from (a project's launcher sets
# it); unset, rig's own.
# Notes: docs/notes/docs.md
set -euo pipefail
cd "$(dirname "$0")"
@@ -9,38 +11,51 @@ source ./lib/config.sh
load_config
REPO="$(cd .. && pwd)"
DOCS="${DOCS_DIR:-$REPO/docs}"
[ -d "$DOCS" ] && DOCS="$(cd "$DOCS" && pwd)"
DOCS_PORT="${DOCS_PORT:-$((HTTP_PORT + 4))}" # +4 sits inside this env's block
serve() {
if [ ! -f "$REPO/docs/index.html" ]; then
echo "no docs/index.html" >&2
if [ ! -f "$DOCS/index.html" ]; then
echo "no index.html in $DOCS" >&2
exit 1
fi
# One worker and no access log: a few static pages need no more, and the
# terminal then shows only real errors. The config is a temp file, gone on exit.
# nginx runs as the caller, not as its own user: it reads what the caller can read,
# whatever the umask (a 027 one leaves files unreadable to "others", which is a 403).
# So no port below 1024 inside, and pid and temp files go to /tmp.
# Global, not local: the trap runs after serve() has returned.
DOCS_CONF=$(mktemp)
trap 'rm -f "$DOCS_CONF"' EXIT
cat > "$DOCS_CONF" <<'NGINX'
worker_processes 1;
error_log stderr error;
pid /tmp/nginx.pid;
events { worker_connections 64; }
http {
include /etc/nginx/mime.types;
access_log off;
server { listen 80; root /usr/share/nginx/html; }
client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;
server { listen 8080; root /usr/share/nginx/html; }
}
NGINX
chmod 644 "$DOCS_CONF"
echo "docs for '$CLUSTER' on http://localhost:${DOCS_PORT}"
echo "docs of $DOCS"
echo " for '$CLUSTER' on http://localhost:${DOCS_PORT}"
echo " (ctrl-c to stop; nothing is installed and nothing persists)"
docker run --rm \
--name "${CLUSTER}-docs" \
-e NGINX_ENTRYPOINT_QUIET_LOGS=1 \
-p "${DOCS_PORT}:80" \
-v "$REPO/docs:/usr/share/nginx/html:ro" \
--user "$(id -u):$(id -g)" \
--entrypoint nginx \
-p "${DOCS_PORT}:8080" \
-v "$DOCS:/usr/share/nginx/html:ro" \
-v "$DOCS_CONF:/etc/nginx/nginx.conf:ro" \
nginx:alpine
nginx:alpine -g 'daemon off;'
}
graphs() {
@@ -51,13 +66,13 @@ graphs() {
fi
shopt -s nullglob
local found=0 f out
for f in "$REPO"/docs/graphs/*.dot; do
for f in "$DOCS"/graphs/*.dot; do
out="${f%.dot}.svg"
echo " graphviz $(basename "$f") → $(basename "$out")"
dot -Tsvg "$f" -o "$out"
found=1
done
[ "$found" -eq 1 ] || echo " no .dot files in docs/graphs/"
[ "$found" -eq 1 ] || echo " no .dot files in $DOCS/graphs/"
}
case "${1:-serve}" in

View File

@@ -193,6 +193,13 @@ check "the caller beats rig.env" "from-caller" \
# Identity follows the overlay's folder, so one rig serves several without collisions.
check "identity follows the overlay's folder" "my-env kind-my-env" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $1, $2}')"
mkdir -p "$OVR/local/renamed"; printf 'CLUSTER=pinned\n' > "$OVR/local/renamed/rig.env"
check "rig.env's CLUSTER names it, whatever the folder" "pinned kind-pinned $(derive_port_base pinned)" \
"$(in_ov env OVERLAY=local/renamed bash ports.sh active | awk '{print $1, $2, $3}')"
check "the caller's CLUSTER beats rig.env's" "second kind-second" \
"$(in_ov env OVERLAY=local/renamed CLUSTER=second bash ports.sh active | awk '{print $1, $2}')"
check "make docs serves DOCS_DIR, the folder it runs from" "no index.html in $OVR/local/renamed" \
"$(in_ov env DOCS_DIR="$OVR/local/renamed" bash docs.sh serve 2>&1 >/dev/null | head -1)"
check "paths in rig.env are relative to the overlay" "$OVR/local/My_Env/k8s/prod" \
"$(in_ov env OVERLAY=local/My_Env bash ports.sh active | awk '{print $7}')"
check "a named overlay that does not exist is an error" "yes" \

View File

@@ -386,7 +386,8 @@ docker load &lt; rig.tgz &amp;&amp; make cluster up PROFILE=offline <span clas
derived from the cluster's Docker network at install time, because Docker picks
that subnet and it differs between machines.</p>
<p>Those addresses are reachable from the Linux side, including inside WSL. A
browser on Windows has no route to them: use the ingress host ports instead.</p>
browser on Windows has no route to them: use the environment's HTTP port instead, where
an overlay's gateway listens (its kind config maps it to the host).</p>
<h3>Networking and memory</h3>
<p>The cluster uses kind's built-in networking, which does enforce standard
@@ -417,9 +418,9 @@ OVERLAY=local/myenv make cluster up
</code></pre>
<p>With none named, rig runs its own <code>examples/starter</code>.
<code>examples/data</code> carries postgres, redis and airflow as an overlay's own
addons. A project can also carry rig at <code>&lt;project&gt;/rig/</code> and be
the overlay itself, with a three-line forwarding Makefile — see
<code>docs/notes/overlay.md</code>.</p>
addons. A project can carry rig at <code>./rig</code>, or keep it anywhere and name
it with <code>RIG_DIR</code>. <code>.version</code> names the release; the project
records the one it was last run against. See <code>docs/notes/overlay.md</code>.</p>
<h3>What runs in it</h3>
<p>Each component is one of three things, and switching between them is a

View File

@@ -15,7 +15,7 @@ docker build -f ctrl/Dockerfile.deps --target deps -t <slug>-deps .
docker build -f ctrl/Dockerfile.deps --target deps-full -t <slug>-deps:full .
```
`deps-full` bakes every pinned binary in at build time, and the manifests rig's own addons apply (metallb, cert-manager, metrics-server). `docker save` it and you have the whole installer as one file to carry into an air-gapped network. There, put the manifests where the addons look for them:
`make deps image full` builds the second. `deps-full` bakes every pinned binary in at build time, and the manifests rig's own addons apply (metallb, cert-manager, metrics-server). `docker save` it and you have the whole installer as one file to carry into an air-gapped network. There, put the manifests where the addons look for them:
```
docker run --rm -v "$PWD/vendor:/out/vendor" rig-deps:full manifests --to /out/vendor/manifests

View File

@@ -31,7 +31,7 @@ Read positionally, so the order is a contract; ctrl/selftest.sh pins it. The two
`OVERLAY` and `CLUSTER` given as make arguments (`make tilt OVERLAY=local/x`) are handed to that `$(shell ...)` explicitly. Before make 4.4, `$(shell)` runs with make's own environment and does not see command-line variables, while the recipes do: tilt would then be told one context and the Tiltfile would guard on another. An overlay's forwarder avoids the question by putting `OVERLAY` in the environment.
This used to be sed over ctrl/.env plus a slug computed in the Makefile, which is a SECOND derivation of values lib/config.sh already owns, and the two could disagree about the port after `ports.sh persist`, or about the name for any directory whose sanitised form differs from its raw one. One source now; the Tiltfile reads the same line.
One source: lib/config.sh owns these values, and the Tiltfile reads the same line. A second derivation in the Makefile could disagree about the port after `ports.sh persist`, or about the name of any directory whose sanitised form differs from its raw one.
## CLUSTER / KCTX fallback
@@ -39,7 +39,7 @@ The fallback matters: ports.sh sources config.sh, and if a profile or .env is br
## ARGS as .PHONY
Words after the target become the script's subcommand; each gets a no-op rule so make does not treat them as goals. They are also marked PHONY, because some of those words name real directories. `cfg`, `ctrl`, `docs`, `gen` and `init` all exist at this level, and make considers a target that is an existing directory already built, so `make build ctrl` ran the build and then printed "make: 'ctrl' is up to date". The empty rule is not enough on its own; only .PHONY stops make consulting the filesystem.
Words after the target become the script's subcommand; each gets a no-op rule so make does not treat them as goals. They are also marked PHONY, because some of those words name real directories (`ctrl`, `docs`, `examples`), and make considers a target that is an existing directory already built: without it, `make docs ctrl` would print "make: 'ctrl' is up to date". The empty rule is not enough on its own; only .PHONY stops make consulting the filesystem.
## tilt: --port guard

View File

@@ -54,7 +54,7 @@ switch for the host part, so the standalone `rigdeps.sh detect` is short too.
rig knows what a node costs (NODE_MB), not what a workload does, so the parts come from the
overlay's rig.env: `MEMORY_BUDGET="name=MB ..."`, in the order listed; a 0 is left out, and an
entry that is not `name=MB` is a `!` line. rig.env is sourced, so an overlay can size a part by
its own knob (poc_rig's airflow follows its mode). When one part outweighs the rest together
its own knob (an airflow sized by the mode the overlay runs it in). When one part outweighs the rest together
(and is at least 1 GB), check names it as the first thing to switch off; `MEMORY_BUDGET_TIP`
says how, in the overlay's words. No budget: one `?` row says where to add it.

View File

@@ -8,6 +8,12 @@ out `python -m http.server` too — a minimal Debian has no python3. What it doe
have, by definition, is Docker: the single prerequisite rig already demands. So a
throwaway nginx container serves a read-only bind mount.
nginx runs as the caller (`--user`), not as its own `nginx` user. A machine whose umask is
027 creates files readable by their owner and group only, and nginx's own user would get a
403 on every page. As the caller it reads whatever the caller can, so nobody has to chmod
their docs. Unprivileged, it listens on 8080 inside the container and keeps its pid and temp
files in /tmp.
It runs with a config of its own: one worker (nginx's `auto` starts one per core,
twelve on an ordinary laptop, for a handful of static pages), no access log and a
quiet entrypoint, so the terminal shows the URL and nothing else unless something
@@ -18,3 +24,12 @@ fails. The pages declare an empty icon, so the browser does not ask for a
Rendered SVGs are committed alongside their `.dot` sources for the same reason:
the pages have to read on a machine with no Graphviz installed.
## Whose docs
`make docs` serves the docs of the folder it runs from. In rig that is rig's own `docs/`. A
project's launcher sets `DOCS_DIR` to its own `docs/` (an absolute path), so `make docs` there
serves the project's pages through this same script, on the same terms: an `index.html` at
the top, the environment's block for the port (`HTTP_PORT + 4`), a container named after the
environment. rig's own docs stay one `make -C <rig> docs` away. The project keeps no copy of
this script.

View File

@@ -2,7 +2,7 @@
## ctrl/.env.example: header
Machine-local config. Copy to ctrl/.env (gitignored) and edit. The cluster SHAPE is an optional profile in ctrl/env.d/ — see the *.env.example there. The architecture MODEL lives in arch/<name>.json — not in .env either.
Machine-local config. Copy to ctrl/.env (gitignored) and edit. The cluster SHAPE is an optional profile in ctrl/env.d/ — see the *.env.example there.
## ctrl/.env.example: CLUSTER

View File

@@ -74,6 +74,36 @@ follow the overlay folder's name, sanitised the same way a rig folder's name is.
One rig can therefore serve several overlays, each in its own cluster, and a
project that carries rig at `./rig` does not name every cluster `rig`.
That is the default. The convention is to pin the name in the overlay's `rig.env`:
```
CLUSTER=<name>
```
The name only has to be unique on the machine: the port block derives from it, so two
overlays with the same name share one cluster and one set of ports. Pinned, it no longer
follows the folder, so moving or renaming folders changes nothing. A second copy of the
same project (another worktree, say) runs beside it by setting its own `CLUSTER=` in the
project's ignored `.env.local`: the caller beats `rig.env`. Ports the workload forwards
itself (a Tiltfile's `port_forwards`) are the overlay's, not the block's, and can still
collide.
A repository that runs on rig keeps it in a `devenv/` folder, with rig as the project's
sibling:
```
devenv/
rig/ rig
<project>/ the forwarder (RIG_DIR ?= $(HERE)/../rig), its knobs, its data
overlay/ what rig reads
```
The overlay folder is then called `overlay/` in every project, so its name says nothing
and `CLUSTER=` in its `rig.env` is required, not optional: left to the default, every such
project would be the cluster `overlay`, on the same ports. The project folder, the cluster
and the workload's namespace are three names: a folder may hold `_`, the other two are DNS
labels.
`ports.sh persist` refuses while an overlay is set: it writes to rig's
`ctrl/.env`, and a pin there would follow every overlay.
@@ -155,21 +185,37 @@ $(wordlist 2,$(words $(GOALS)),$(GOALS)):
The cluster is then named after `<project>`, exactly as a copied rig named
`<project>` was, so moving a copied rig to this layout keeps its cluster and ports.
`rig.env` holds no secrets, by this contract. A repository whose `.gitignore` has a
broad `*.env` (a common secrets rule) would still hide it, so an overlay living in
such a repo re-includes it in its own `.gitignore`: `!rig.env`.
rig can also live outside the project, which then holds only the overlay. The
forwarder names it with one variable, a machine path that is never committed (the
shell, or an ignored `.env.local`), defaulting to the vendored layout:
## Moving a copied rig to an overlay
```make
RIG_DIR ?= $(HERE)/rig
override RIG_DIR := $(abspath $(patsubst ~/%,$(HOME)/%,$(RIG_DIR)))
```
A rig copied into a project and edited there splits cleanly:
`make docs` should serve the project's pages, not rig's: the forwarder names them with
`DOCS_DIR := $(HERE)/docs` (see docs.md). rig's own stay `make -C <rig> docs`.
| was, in the copy | goes to |
| --- | --- |
| `ctrl/k8s/base`, `ctrl/k8s/overlays` | `k8s/` |
| the workload parts of `ctrl/Tiltfile` | `Tiltfile` (paths now relative to the overlay) |
| `ctrl/env.d/<name>.env` | `rig.env` |
| edits to `ctrl/k8s/kind-config.yaml.tpl` | `kind-config.yaml.tpl` (`${HOST_WORKDIR}` → `${OVERLAY_DIR}`) |
| workload addons | `addons/` |
| Dockerfiles for the workload | beside the Tiltfile |
| `ctrl/.env` | `rig/ctrl/.env` (this machine's; drop a `MANIFESTS_DIR=ctrl/k8s/overlays/dev` line) |
| everything else of rig's | replaced by rig as it is |
No search path: an unset `RIG_DIR` means `./rig`, and nothing else is tried. Moving rig
changes nothing in the cluster's name, context or ports; moving or renaming the overlay
folder does.
`.version`, at rig's top, names the rig. It travels with the folder, vendored, a
worktree, a plain copy or a `git archive` release, and needs no git. Patch and minor
steps are edits to that file, with no tag; a `rig-<version>` tag marks a milestone only.
A project records the version it was last run against and warns on another; it does not
refuse. Releases before 0.1.1 carry `VERSION` instead, so the project reads that as a
fallback:
```make
RIG_REF := 0.1.1
RIG_AT := $(strip $(shell cat '$(RIG_DIR)/.version' 2>/dev/null || cat '$(RIG_DIR)/VERSION' 2>/dev/null))
ifneq ($(RIG_AT),$(RIG_REF))
$(warning rig at $(RIG_DIR) is $(or $(RIG_AT),unversioned); this overlay was last run against rig $(RIG_REF))
endif
```
`rig.env` holds no secrets, by this contract. A repository whose `.gitignore` has a
broad `*.env` (a common secrets rule) would still hide it, so an overlay living in
such a repo re-includes it in its own `.gitignore`: `!rig.env`.

View File

@@ -28,7 +28,7 @@ So the loop is generated FROM the list: add a key to `CONFIG_OVERRIDABLE` and th
## one derivation, not three
The Makefile used to compute the cluster name itself and sed `TILT_PORT` out of `ctrl/.env`: a second derivation of values `lib/config.sh` already owns, which could disagree with it after `ports.sh persist`. It now reads `ports.sh active`. Nothing structurally prevents the sed coming back, so the agreement is asserted against the real `make -n` output rather than against the source.
The Makefile reads `ports.sh active` rather than deriving the cluster name and `TILT_PORT` itself: a second derivation of values `lib/config.sh` already owns could disagree with it after `ports.sh persist`. Nothing structurally prevents one coming back, so the agreement is asserted against the real `make -n` output rather than against the source.
`--no-print-directory` and a grep, not `tail -1`: run from `make selftest` this is a RECURSIVE make, and the "Entering/Leaving directory" lines go to STDOUT. `tail -1` then reads "make[1]: Leaving directory ..." and both checks fail, but only when invoked through make, never when the script is run directly. A test that passes one way and fails the other is worse than no test.

View File

@@ -759,7 +759,7 @@ python3 -m docgen view ir.json --shape <span class="c"># what will this
<a href="viewer.html?src=img/erd.svg">
<img src="img/erd.svg" alt="an entity-relationship diagram">
</a>
<figcaption>A schema from the sample room. Same emitter, same style file as
<figcaption>An invoicing schema. Same emitter, same style file as
every other diagram here.</figcaption>
</figure>

View File

@@ -9,7 +9,7 @@ reading it means this extractor works for every source modelgen supports
(Django, SQLAlchemy, OpenAPI, CSV/ODS, a live database) without knowing about
any of them.
python3 -m docgen extract db --schema cfg/sample/.../graphgen/schema.json
python3 -m docgen extract db --schema path/to/schema.json
Connecting to a live database is **not here**. `modelgen from-db --url ...`
reflects via SQLAlchemy's Inspector across dialects and writes the schema.json

View File

@@ -11,7 +11,7 @@ so the background goes transparent and the text goes initial-black on a design
that assumed dark. Unstyled, not merely unbranded.
That matters because `/theme.css` is an absolute path and soleprint is not
always at the root. In the sample room's nginx, soleprint sits under `/spr/`
always at the root. In a wrapped site's nginx, soleprint sits under `/spr/`
while `location /` proxies to the frontend, so `/theme.css` reaches the wrong
service. Same story for a page opened over file://.

View File

@@ -35,12 +35,12 @@ Then build. `python build.py --cfg <room>` merges each cabinet's `service.yml`
into the room's `docker-compose.yml` and appends its settings to `.env.example`:
```bash
python build.py --cfg sample
cd gen/sample && docker compose up -d
python build.py --cfg <room>
cd gen/<room> && docker compose up -d
```
**A service the room already declares wins.** `cfg/amar/docker-compose.yml`
ships its own `db`; switching on the postgres cabinet will not overwrite it.
**A service the room already declares wins.** A room whose `docker-compose.yml`
ships its own `db`: switching on the postgres cabinet will not overwrite it.
Build says so when it skips one.
## On a cluster

View File

@@ -26,8 +26,7 @@ method name *is* the model name — there is no registry to update.
```python
from faker import Faker
# Guarded so the file also runs on its own, outside a built instance —
# see cfg/sample/soleprint/station/tools/datagen/fixture.py.
# Guarded so the file also runs on its own, outside a built instance.
try:
from station.tools.datagen.base import BaseDataGenerator
except ImportError:

View File

@@ -80,7 +80,7 @@ Select one via `POST /tools/tester/api/environment/select`, or set the env direc
WRITING ONE
───────────
The domain below is the in-repo invoicing fixture (cfg/sample) — deliberately
The domain below is an invoicing example — deliberately
generic. Substitute your own; the shape is the part that transfers.
from ..base import ContractTestCase