add fixture-invoicing example, sample-room wrap, kind cluster support

- examples/fixture-invoicing/: FastAPI + Vue + Postgres demo (4-entity invoice fixture)
- cfg/sample/: wraps the fixture (managed.repos points at examples/)
- ctrl/kind-{up,down,status}.sh + per-room k8s render in soleprint/ctrl/k8s/
- build.py: relative repo paths, resilient rmtree, optional k8s render hook
- cfg/.gitignore: stop ignoring sample/ and standalone/ template rooms

Manifests render cleanly but kind cluster has not been run end-to-end yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 05:30:52 -03:00
parent b886455431
commit 5f9cac1947
78 changed files with 3022 additions and 198 deletions

View File

View File

@@ -0,0 +1,3 @@
from .render import render_k8s
__all__ = ["render_k8s"]

View File

@@ -0,0 +1,98 @@
"""Render per-room manifests for deploy into the shared `spr` kind cluster.
The `spr` cluster itself is created via `ctrl/kind-up.sh` at the repo root
(one cluster, all rooms). Each room becomes a namespace inside it.
Called from build.py when a room opts in to k8s output. Emits:
gen/<room>/ctrl/k8s/ base + overlays/dev manifests
gen/<room>/ctrl/k8s-up.sh apply this room's manifests into spr
gen/<room>/ctrl/k8s-down.sh delete this room's namespace from spr
gen/<room>/ctrl/k8s-load.sh build images and `kind load` into spr
No jinja2 dep — Python-string templates, matching init/core.py.
"""
from __future__ import annotations
import logging
from pathlib import Path
from . import templates as T
log = logging.getLogger(__name__)
CLUSTER = "spr"
DEFAULT_NODEPORT = 30080
def _write(path: Path, content: str, mode: int | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
if mode is not None:
path.chmod(mode)
def render_k8s(*, room: str, config: dict, gen_dir: Path) -> None:
managed = config.get("managed") or {}
managed_name = managed.get("name") or room
has_frontend = "frontend" in (managed.get("repos") or {})
has_link = (gen_dir / "link").exists()
has_managed = bool(managed)
k8s_cfg = config.get("k8s") or {}
nodeport = int(k8s_cfg.get("nodeport", DEFAULT_NODEPORT))
ctrl_dir = gen_dir / "ctrl"
k8s_dir = ctrl_dir / "k8s"
base_dir = k8s_dir / "base"
dev_dir = k8s_dir / "overlays" / "dev"
log.info("Rendering k8s manifests (cluster=%s, namespace=%s, nodeport=%d)...",
CLUSTER, room, nodeport)
resources: list[str] = []
_write(base_dir / "namespace.yaml", T.namespace(room=room))
resources.append("namespace.yaml")
_write(base_dir / "configmap.yaml", T.configmap(room=room))
resources.append("configmap.yaml")
if has_managed:
_write(base_dir / "postgres.yaml", T.postgres(room=room))
resources.append("postgres.yaml")
_write(base_dir / "backend.yaml", T.backend(room=room, managed_name=managed_name))
resources.append("backend.yaml")
if has_frontend:
_write(base_dir / "frontend.yaml", T.frontend(room=room, managed_name=managed_name))
resources.append("frontend.yaml")
if has_link:
_write(base_dir / "link.yaml", T.link(room=room))
resources.append("link.yaml")
_write(base_dir / "soleprint.yaml", T.soleprint(room=room))
resources.append("soleprint.yaml")
_write(base_dir / "gateway.yaml", T.gateway(room=room, nodeport=nodeport))
resources.append("gateway.yaml")
_write(base_dir / "envoy.yaml", T.envoy(
room=room, has_backend=has_managed, has_frontend=has_frontend,
))
_write(base_dir / "kustomization.yaml", T.kustomization_base(resources=resources))
_write(dev_dir / "kustomization.yaml", T.kustomization_dev(room=room))
_write(ctrl_dir / "k8s-up.sh", T.k8s_up_sh(room=room, cluster=CLUSTER, nodeport=nodeport), mode=0o755)
_write(ctrl_dir / "k8s-down.sh", T.k8s_down_sh(room=room, cluster=CLUSTER), mode=0o755)
_write(ctrl_dir / "k8s-load.sh", T.k8s_load_sh(
room=room, cluster=CLUSTER, managed_name=managed_name,
has_managed=has_managed, has_frontend=has_frontend, has_link=has_link,
), mode=0o755)
log.info(" %d manifests + 3 lifecycle scripts", len(resources) + 1)
def k8s_enabled(config: dict) -> bool:
return bool((config.get("k8s") or {}).get("enabled"))

View File

@@ -0,0 +1,487 @@
"""String templates for kind-cluster manifests.
Each function returns a YAML/shell blob. Keep plain strings — no jinja2
dep, matches the init/core.py convention.
"""
from __future__ import annotations
# ─── Base manifests ─────────────────────────────────────────────────
def namespace(*, room: str) -> str:
return f"""\
apiVersion: v1
kind: Namespace
metadata:
name: {room}
labels:
soleprint-room: "{room}"
"""
def configmap(*, room: str) -> str:
return f"""\
apiVersion: v1
kind: ConfigMap
metadata:
name: {room}-env
namespace: {room}
data:
DEPLOYMENT_NAME: "{room}"
POSTGRES_DB: "fixture"
POSTGRES_USER: "postgres"
POSTGRES_PASSWORD: "localdev123"
ARTERY_EXTERNAL_URL: "/artery"
ATLAS_EXTERNAL_URL: "/atlas"
STATION_EXTERNAL_URL: "/station"
AUTH_BYPASS: "true"
"""
def postgres(*, room: str) -> str:
return f"""\
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pgdata
namespace: {room}
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: db
namespace: {room}
labels: {{app: db}}
spec:
replicas: 1
selector: {{matchLabels: {{app: db}}}}
template:
metadata:
labels: {{app: db}}
spec:
containers:
- name: postgres
image: postgres:16-alpine
envFrom:
- configMapRef: {{name: {room}-env}}
ports:
- containerPort: 5432
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: [sh, -c, "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
periodSeconds: 5
volumes:
- name: pgdata
persistentVolumeClaim: {{claimName: pgdata}}
---
apiVersion: v1
kind: Service
metadata:
name: db
namespace: {room}
spec:
selector: {{app: db}}
ports:
- port: 5432
targetPort: 5432
"""
def backend(*, room: str, managed_name: str) -> str:
return f"""\
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: {room}
labels: {{app: backend}}
spec:
replicas: 1
selector: {{matchLabels: {{app: backend}}}}
template:
metadata:
labels: {{app: backend}}
spec:
containers:
- name: backend
image: {room}-backend:dev
imagePullPolicy: IfNotPresent
env:
- {{name: DB_HOST, value: db}}
- {{name: DB_PORT, value: "5432"}}
- {{name: SEED_ON_START, value: "true"}}
envFrom:
- configMapRef: {{name: {room}-env}}
ports:
- containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: {room}
spec:
selector: {{app: backend}}
ports:
- port: 8000
targetPort: 8000
"""
def frontend(*, room: str, managed_name: str) -> str:
return f"""\
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: {room}
labels: {{app: frontend}}
spec:
replicas: 1
selector: {{matchLabels: {{app: frontend}}}}
template:
metadata:
labels: {{app: frontend}}
spec:
containers:
- name: frontend
image: {room}-frontend:dev
imagePullPolicy: IfNotPresent
env:
- {{name: VITE_API_URL, value: ""}}
ports:
- containerPort: 5173
---
apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: {room}
spec:
selector: {{app: frontend}}
ports:
- port: 5173
targetPort: 5173
"""
def link(*, room: str) -> str:
return f"""\
apiVersion: apps/v1
kind: Deployment
metadata:
name: link
namespace: {room}
labels: {{app: link}}
spec:
replicas: 1
selector: {{matchLabels: {{app: link}}}}
template:
metadata:
labels: {{app: link}}
spec:
containers:
- name: link
image: {room}-link:dev
imagePullPolicy: IfNotPresent
env:
- {{name: DB_HOST, value: db}}
- {{name: DB_PORT, value: "5432"}}
envFrom:
- configMapRef: {{name: {room}-env}}
ports:
- containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: link
namespace: {room}
spec:
selector: {{app: link}}
ports:
- port: 8000
targetPort: 8000
"""
def soleprint(*, room: str) -> str:
return f"""\
apiVersion: apps/v1
kind: Deployment
metadata:
name: soleprint
namespace: {room}
labels: {{app: soleprint}}
spec:
replicas: 1
selector: {{matchLabels: {{app: soleprint}}}}
template:
metadata:
labels: {{app: soleprint}}
spec:
containers:
- name: soleprint
image: {room}-soleprint:dev
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef: {{name: {room}-env}}
ports:
- containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: soleprint
namespace: {room}
spec:
selector: {{app: soleprint}}
ports:
- port: 8000
targetPort: 8000
"""
def gateway(*, room: str, nodeport: int) -> str:
return f"""\
apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway
namespace: {room}
labels: {{app: gateway, soleprint-room: "{room}"}}
spec:
replicas: 1
selector: {{matchLabels: {{app: gateway}}}}
template:
metadata:
labels: {{app: gateway, soleprint-room: "{room}"}}
spec:
containers:
- name: envoy
image: envoyproxy/envoy:v1.30-latest
ports:
- containerPort: 10000
volumeMounts:
- name: envoy-config
mountPath: /etc/envoy
volumes:
- name: envoy-config
configMap:
name: envoy-config
---
apiVersion: v1
kind: Service
metadata:
name: gateway
namespace: {room}
spec:
type: NodePort
selector: {{app: gateway}}
ports:
- port: 80
targetPort: 10000
nodePort: {nodeport}
"""
def envoy(*, room: str, has_backend: bool, has_frontend: bool) -> str:
"""Envoy config with /spr → soleprint, /api → backend, / → frontend."""
routes = [' - match: {prefix: "/spr/"}\n route: {cluster: soleprint, prefix_rewrite: "/"}']
clusters = [_cluster("soleprint", "soleprint", 8000)]
if has_backend:
routes.append(' - match: {prefix: "/api/"}\n route: {cluster: backend}')
clusters.append(_cluster("backend", "backend", 8000))
if has_frontend:
routes.append(' - match: {prefix: "/"}\n route: {cluster: frontend}')
clusters.append(_cluster("frontend", "frontend", 5173))
else:
# Fall back to soleprint as default if no frontend
routes.append(' - match: {prefix: "/"}\n route: {cluster: soleprint}')
routes_yaml = "\n".join(routes)
clusters_yaml = "\n".join(clusters)
envoy_yaml = f"""\
admin:
address:
socket_address: {{address: 0.0.0.0, port_value: 9901}}
static_resources:
listeners:
- name: main
address:
socket_address: {{address: 0.0.0.0, port_value: 10000}}
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
upgrade_configs: [{{upgrade_type: websocket}}]
route_config:
name: main
virtual_hosts:
- name: default
domains: ["*"]
routes:
{_indent(routes_yaml, 14)}
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
{clusters_yaml}
"""
return f"""\
apiVersion: v1
kind: ConfigMap
metadata:
name: envoy-config
namespace: {room}
data:
envoy.yaml: |
{_indent(envoy_yaml, 4)}
"""
def _cluster(name: str, host: str, port: int) -> str:
return f"""\
- name: {name}
type: STRICT_DNS
connect_timeout: 1s
load_assignment:
cluster_name: {name}
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: {{address: {host}, port_value: {port}}}"""
def _indent(text: str, spaces: int) -> str:
pad = " " * spaces
return "\n".join(pad + line if line else line for line in text.splitlines())
def kustomization_base(*, resources: list[str]) -> str:
# envoy.yaml is itself a ConfigMap manifest, so it belongs in resources.
res = "\n".join(f" - {r}" for r in resources + ["envoy.yaml"])
return f"""\
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
{res}
"""
def kustomization_dev(*, room: str) -> str:
return f"""\
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: {room}
resources:
- ../../base
"""
# ─── Lifecycle scripts ──────────────────────────────────────────────
# These target the shared `spr` kind cluster (created via repo-root
# ctrl/kind-up.sh). Each room owns a namespace inside that cluster.
def k8s_up_sh(*, room: str, cluster: str, nodeport: int) -> str:
return f"""\
#!/bin/bash
# Apply the "{room}" room into the shared `{cluster}` kind cluster.
# (Run repo-root ctrl/kind-up.sh first if the cluster doesn't exist.)
set -e
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
K8S_DIR="$SCRIPT_DIR/k8s"
if ! kind get clusters 2>/dev/null | grep -q '^{cluster}$'; then
echo "Kind cluster '{cluster}' not found — run ctrl/kind-up.sh from the repo root first."
exit 1
fi
CTX="kind-{cluster}"
echo "Loading images for room '{room}' into '{cluster}'..."
"$SCRIPT_DIR/k8s-load.sh"
echo "Applying manifests (namespace={room})..."
kubectl --context "$CTX" apply -k "$K8S_DIR/overlays/dev"
echo
echo "Done."
echo " Gateway: http://localhost:{nodeport}/"
echo " (add `127.0.0.1 {room}.spr.local.ar` to /etc/hosts for Host routing)"
"""
def k8s_down_sh(*, room: str, cluster: str) -> str:
return f"""\
#!/bin/bash
# Remove the "{room}" namespace from the shared `{cluster}` cluster.
# Leaves the cluster itself running (use repo-root ctrl/kind-down.sh to drop everything).
set -e
CTX="kind-{cluster}"
if ! kind get clusters 2>/dev/null | grep -q '^{cluster}$'; then
echo "Kind cluster '{cluster}' not running — nothing to do."
exit 0
fi
if kubectl --context "$CTX" get namespace {room} >/dev/null 2>&1; then
kubectl --context "$CTX" delete namespace {room}
else
echo "Namespace '{room}' not found in '{cluster}'."
fi
"""
def k8s_load_sh(
*, room: str, cluster: str, managed_name: str,
has_managed: bool, has_frontend: bool, has_link: bool,
) -> str:
steps = [f'echo "Building soleprint image…"',
f'docker build -t {room}-soleprint:dev "$ROOT_DIR/soleprint"']
if has_managed:
steps += [f'echo "Building backend image…"',
f'docker build -t {room}-backend:dev "$ROOT_DIR/{managed_name}/backend"']
if has_frontend:
steps += [f'echo "Building frontend image…"',
f'docker build -t {room}-frontend:dev "$ROOT_DIR/{managed_name}/frontend"']
if has_link:
steps += [f'echo "Building link image…"',
f'docker build -t {room}-link:dev "$ROOT_DIR/link"']
loads = [f'kind load docker-image {room}-soleprint:dev --name {cluster}']
if has_managed:
loads.append(f'kind load docker-image {room}-backend:dev --name {cluster}')
if has_frontend:
loads.append(f'kind load docker-image {room}-frontend:dev --name {cluster}')
if has_link:
loads.append(f'kind load docker-image {room}-link:dev --name {cluster}')
body = "\n".join(steps + [""] + [f'echo "Loading into `{cluster}` kind cluster…"'] + loads)
return f"""\
#!/bin/bash
# Build all images for room "{room}" and load them into the shared `{cluster}` cluster.
set -e
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
{body}
"""

View File

@@ -19,7 +19,10 @@ def _schema_paths() -> list[Path]:
"""Return ordered list of directories to search for schema."""
paths = []
# cfg/<room>/station/tools/graphgen/ (room-specific, highest priority)
# station/tools/graphgen/ (room-merged schema.json lives here after build)
paths.append(SPR_ROOT / "station" / "tools" / "graphgen")
# cfg/<room>/station/tools/graphgen/ (source-tree room-specific, highest priority)
cfg_dir = SPR_ROOT / "cfg"
if cfg_dir.exists():
for room in sorted(cfg_dir.iterdir()):