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:
0
soleprint/ctrl/__init__.py
Normal file
0
soleprint/ctrl/__init__.py
Normal file
3
soleprint/ctrl/k8s/__init__.py
Normal file
3
soleprint/ctrl/k8s/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .render import render_k8s
|
||||
|
||||
__all__ = ["render_k8s"]
|
||||
98
soleprint/ctrl/k8s/render.py
Normal file
98
soleprint/ctrl/k8s/render.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Render per-room manifests for deploy into the shared `spr` kind cluster.
|
||||
|
||||
The `spr` cluster itself is created via `ctrl/kind-up.sh` at the repo root
|
||||
(one cluster, all rooms). Each room becomes a namespace inside it.
|
||||
|
||||
Called from build.py when a room opts in to k8s output. Emits:
|
||||
|
||||
gen/<room>/ctrl/k8s/ base + overlays/dev manifests
|
||||
gen/<room>/ctrl/k8s-up.sh apply this room's manifests into spr
|
||||
gen/<room>/ctrl/k8s-down.sh delete this room's namespace from spr
|
||||
gen/<room>/ctrl/k8s-load.sh build images and `kind load` into spr
|
||||
|
||||
No jinja2 dep — Python-string templates, matching init/core.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from . import templates as T
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CLUSTER = "spr"
|
||||
DEFAULT_NODEPORT = 30080
|
||||
|
||||
|
||||
def _write(path: Path, content: str, mode: int | None = None) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
if mode is not None:
|
||||
path.chmod(mode)
|
||||
|
||||
|
||||
def render_k8s(*, room: str, config: dict, gen_dir: Path) -> None:
|
||||
managed = config.get("managed") or {}
|
||||
managed_name = managed.get("name") or room
|
||||
has_frontend = "frontend" in (managed.get("repos") or {})
|
||||
has_link = (gen_dir / "link").exists()
|
||||
has_managed = bool(managed)
|
||||
|
||||
k8s_cfg = config.get("k8s") or {}
|
||||
nodeport = int(k8s_cfg.get("nodeport", DEFAULT_NODEPORT))
|
||||
|
||||
ctrl_dir = gen_dir / "ctrl"
|
||||
k8s_dir = ctrl_dir / "k8s"
|
||||
base_dir = k8s_dir / "base"
|
||||
dev_dir = k8s_dir / "overlays" / "dev"
|
||||
|
||||
log.info("Rendering k8s manifests (cluster=%s, namespace=%s, nodeport=%d)...",
|
||||
CLUSTER, room, nodeport)
|
||||
|
||||
resources: list[str] = []
|
||||
_write(base_dir / "namespace.yaml", T.namespace(room=room))
|
||||
resources.append("namespace.yaml")
|
||||
|
||||
_write(base_dir / "configmap.yaml", T.configmap(room=room))
|
||||
resources.append("configmap.yaml")
|
||||
|
||||
if has_managed:
|
||||
_write(base_dir / "postgres.yaml", T.postgres(room=room))
|
||||
resources.append("postgres.yaml")
|
||||
|
||||
_write(base_dir / "backend.yaml", T.backend(room=room, managed_name=managed_name))
|
||||
resources.append("backend.yaml")
|
||||
if has_frontend:
|
||||
_write(base_dir / "frontend.yaml", T.frontend(room=room, managed_name=managed_name))
|
||||
resources.append("frontend.yaml")
|
||||
|
||||
if has_link:
|
||||
_write(base_dir / "link.yaml", T.link(room=room))
|
||||
resources.append("link.yaml")
|
||||
|
||||
_write(base_dir / "soleprint.yaml", T.soleprint(room=room))
|
||||
resources.append("soleprint.yaml")
|
||||
|
||||
_write(base_dir / "gateway.yaml", T.gateway(room=room, nodeport=nodeport))
|
||||
resources.append("gateway.yaml")
|
||||
_write(base_dir / "envoy.yaml", T.envoy(
|
||||
room=room, has_backend=has_managed, has_frontend=has_frontend,
|
||||
))
|
||||
|
||||
_write(base_dir / "kustomization.yaml", T.kustomization_base(resources=resources))
|
||||
_write(dev_dir / "kustomization.yaml", T.kustomization_dev(room=room))
|
||||
|
||||
_write(ctrl_dir / "k8s-up.sh", T.k8s_up_sh(room=room, cluster=CLUSTER, nodeport=nodeport), mode=0o755)
|
||||
_write(ctrl_dir / "k8s-down.sh", T.k8s_down_sh(room=room, cluster=CLUSTER), mode=0o755)
|
||||
_write(ctrl_dir / "k8s-load.sh", T.k8s_load_sh(
|
||||
room=room, cluster=CLUSTER, managed_name=managed_name,
|
||||
has_managed=has_managed, has_frontend=has_frontend, has_link=has_link,
|
||||
), mode=0o755)
|
||||
|
||||
log.info(" %d manifests + 3 lifecycle scripts", len(resources) + 1)
|
||||
|
||||
|
||||
def k8s_enabled(config: dict) -> bool:
|
||||
return bool((config.get("k8s") or {}).get("enabled"))
|
||||
487
soleprint/ctrl/k8s/templates.py
Normal file
487
soleprint/ctrl/k8s/templates.py
Normal file
@@ -0,0 +1,487 @@
|
||||
"""String templates for kind-cluster manifests.
|
||||
|
||||
Each function returns a YAML/shell blob. Keep plain strings — no jinja2
|
||||
dep, matches the init/core.py convention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# ─── Base manifests ─────────────────────────────────────────────────
|
||||
|
||||
def namespace(*, room: str) -> str:
|
||||
return f"""\
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {room}
|
||||
labels:
|
||||
soleprint-room: "{room}"
|
||||
"""
|
||||
|
||||
|
||||
def configmap(*, room: str) -> str:
|
||||
return f"""\
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {room}-env
|
||||
namespace: {room}
|
||||
data:
|
||||
DEPLOYMENT_NAME: "{room}"
|
||||
POSTGRES_DB: "fixture"
|
||||
POSTGRES_USER: "postgres"
|
||||
POSTGRES_PASSWORD: "localdev123"
|
||||
ARTERY_EXTERNAL_URL: "/artery"
|
||||
ATLAS_EXTERNAL_URL: "/atlas"
|
||||
STATION_EXTERNAL_URL: "/station"
|
||||
AUTH_BYPASS: "true"
|
||||
"""
|
||||
|
||||
|
||||
def postgres(*, room: str) -> str:
|
||||
return f"""\
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: pgdata
|
||||
namespace: {room}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: db
|
||||
namespace: {room}
|
||||
labels: {{app: db}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {{matchLabels: {{app: db}}}}
|
||||
template:
|
||||
metadata:
|
||||
labels: {{app: db}}
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
envFrom:
|
||||
- configMapRef: {{name: {room}-env}}
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- name: pgdata
|
||||
mountPath: /var/lib/postgresql/data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: [sh, -c, "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||
periodSeconds: 5
|
||||
volumes:
|
||||
- name: pgdata
|
||||
persistentVolumeClaim: {{claimName: pgdata}}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: db
|
||||
namespace: {room}
|
||||
spec:
|
||||
selector: {{app: db}}
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
"""
|
||||
|
||||
|
||||
def backend(*, room: str, managed_name: str) -> str:
|
||||
return f"""\
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: {room}
|
||||
labels: {{app: backend}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {{matchLabels: {{app: backend}}}}
|
||||
template:
|
||||
metadata:
|
||||
labels: {{app: backend}}
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: {room}-backend:dev
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- {{name: DB_HOST, value: db}}
|
||||
- {{name: DB_PORT, value: "5432"}}
|
||||
- {{name: SEED_ON_START, value: "true"}}
|
||||
envFrom:
|
||||
- configMapRef: {{name: {room}-env}}
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: {room}
|
||||
spec:
|
||||
selector: {{app: backend}}
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
"""
|
||||
|
||||
|
||||
def frontend(*, room: str, managed_name: str) -> str:
|
||||
return f"""\
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
namespace: {room}
|
||||
labels: {{app: frontend}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {{matchLabels: {{app: frontend}}}}
|
||||
template:
|
||||
metadata:
|
||||
labels: {{app: frontend}}
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: {room}-frontend:dev
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- {{name: VITE_API_URL, value: ""}}
|
||||
ports:
|
||||
- containerPort: 5173
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: frontend
|
||||
namespace: {room}
|
||||
spec:
|
||||
selector: {{app: frontend}}
|
||||
ports:
|
||||
- port: 5173
|
||||
targetPort: 5173
|
||||
"""
|
||||
|
||||
|
||||
def link(*, room: str) -> str:
|
||||
return f"""\
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: link
|
||||
namespace: {room}
|
||||
labels: {{app: link}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {{matchLabels: {{app: link}}}}
|
||||
template:
|
||||
metadata:
|
||||
labels: {{app: link}}
|
||||
spec:
|
||||
containers:
|
||||
- name: link
|
||||
image: {room}-link:dev
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- {{name: DB_HOST, value: db}}
|
||||
- {{name: DB_PORT, value: "5432"}}
|
||||
envFrom:
|
||||
- configMapRef: {{name: {room}-env}}
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: link
|
||||
namespace: {room}
|
||||
spec:
|
||||
selector: {{app: link}}
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
"""
|
||||
|
||||
|
||||
def soleprint(*, room: str) -> str:
|
||||
return f"""\
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: soleprint
|
||||
namespace: {room}
|
||||
labels: {{app: soleprint}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {{matchLabels: {{app: soleprint}}}}
|
||||
template:
|
||||
metadata:
|
||||
labels: {{app: soleprint}}
|
||||
spec:
|
||||
containers:
|
||||
- name: soleprint
|
||||
image: {room}-soleprint:dev
|
||||
imagePullPolicy: IfNotPresent
|
||||
envFrom:
|
||||
- configMapRef: {{name: {room}-env}}
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: soleprint
|
||||
namespace: {room}
|
||||
spec:
|
||||
selector: {{app: soleprint}}
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
"""
|
||||
|
||||
|
||||
def gateway(*, room: str, nodeport: int) -> str:
|
||||
return f"""\
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: {room}
|
||||
labels: {{app: gateway, soleprint-room: "{room}"}}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {{matchLabels: {{app: gateway}}}}
|
||||
template:
|
||||
metadata:
|
||||
labels: {{app: gateway, soleprint-room: "{room}"}}
|
||||
spec:
|
||||
containers:
|
||||
- name: envoy
|
||||
image: envoyproxy/envoy:v1.30-latest
|
||||
ports:
|
||||
- containerPort: 10000
|
||||
volumeMounts:
|
||||
- name: envoy-config
|
||||
mountPath: /etc/envoy
|
||||
volumes:
|
||||
- name: envoy-config
|
||||
configMap:
|
||||
name: envoy-config
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: {room}
|
||||
spec:
|
||||
type: NodePort
|
||||
selector: {{app: gateway}}
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 10000
|
||||
nodePort: {nodeport}
|
||||
"""
|
||||
|
||||
|
||||
def envoy(*, room: str, has_backend: bool, has_frontend: bool) -> str:
|
||||
"""Envoy config with /spr → soleprint, /api → backend, / → frontend."""
|
||||
routes = [' - match: {prefix: "/spr/"}\n route: {cluster: soleprint, prefix_rewrite: "/"}']
|
||||
clusters = [_cluster("soleprint", "soleprint", 8000)]
|
||||
if has_backend:
|
||||
routes.append(' - match: {prefix: "/api/"}\n route: {cluster: backend}')
|
||||
clusters.append(_cluster("backend", "backend", 8000))
|
||||
if has_frontend:
|
||||
routes.append(' - match: {prefix: "/"}\n route: {cluster: frontend}')
|
||||
clusters.append(_cluster("frontend", "frontend", 5173))
|
||||
else:
|
||||
# Fall back to soleprint as default if no frontend
|
||||
routes.append(' - match: {prefix: "/"}\n route: {cluster: soleprint}')
|
||||
routes_yaml = "\n".join(routes)
|
||||
clusters_yaml = "\n".join(clusters)
|
||||
|
||||
envoy_yaml = f"""\
|
||||
admin:
|
||||
address:
|
||||
socket_address: {{address: 0.0.0.0, port_value: 9901}}
|
||||
|
||||
static_resources:
|
||||
listeners:
|
||||
- name: main
|
||||
address:
|
||||
socket_address: {{address: 0.0.0.0, port_value: 10000}}
|
||||
filter_chains:
|
||||
- filters:
|
||||
- name: envoy.filters.network.http_connection_manager
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||
stat_prefix: ingress_http
|
||||
upgrade_configs: [{{upgrade_type: websocket}}]
|
||||
route_config:
|
||||
name: main
|
||||
virtual_hosts:
|
||||
- name: default
|
||||
domains: ["*"]
|
||||
routes:
|
||||
{_indent(routes_yaml, 14)}
|
||||
http_filters:
|
||||
- name: envoy.filters.http.router
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
||||
|
||||
clusters:
|
||||
{clusters_yaml}
|
||||
"""
|
||||
return f"""\
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: envoy-config
|
||||
namespace: {room}
|
||||
data:
|
||||
envoy.yaml: |
|
||||
{_indent(envoy_yaml, 4)}
|
||||
"""
|
||||
|
||||
|
||||
def _cluster(name: str, host: str, port: int) -> str:
|
||||
return f"""\
|
||||
- name: {name}
|
||||
type: STRICT_DNS
|
||||
connect_timeout: 1s
|
||||
load_assignment:
|
||||
cluster_name: {name}
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address: {{address: {host}, port_value: {port}}}"""
|
||||
|
||||
|
||||
def _indent(text: str, spaces: int) -> str:
|
||||
pad = " " * spaces
|
||||
return "\n".join(pad + line if line else line for line in text.splitlines())
|
||||
|
||||
|
||||
def kustomization_base(*, resources: list[str]) -> str:
|
||||
# envoy.yaml is itself a ConfigMap manifest, so it belongs in resources.
|
||||
res = "\n".join(f" - {r}" for r in resources + ["envoy.yaml"])
|
||||
return f"""\
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
{res}
|
||||
"""
|
||||
|
||||
|
||||
def kustomization_dev(*, room: str) -> str:
|
||||
return f"""\
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: {room}
|
||||
resources:
|
||||
- ../../base
|
||||
"""
|
||||
|
||||
|
||||
# ─── Lifecycle scripts ──────────────────────────────────────────────
|
||||
# These target the shared `spr` kind cluster (created via repo-root
|
||||
# ctrl/kind-up.sh). Each room owns a namespace inside that cluster.
|
||||
|
||||
def k8s_up_sh(*, room: str, cluster: str, nodeport: int) -> str:
|
||||
return f"""\
|
||||
#!/bin/bash
|
||||
# Apply the "{room}" room into the shared `{cluster}` kind cluster.
|
||||
# (Run repo-root ctrl/kind-up.sh first if the cluster doesn't exist.)
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
||||
K8S_DIR="$SCRIPT_DIR/k8s"
|
||||
|
||||
if ! kind get clusters 2>/dev/null | grep -q '^{cluster}$'; then
|
||||
echo "Kind cluster '{cluster}' not found — run ctrl/kind-up.sh from the repo root first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CTX="kind-{cluster}"
|
||||
|
||||
echo "Loading images for room '{room}' into '{cluster}'..."
|
||||
"$SCRIPT_DIR/k8s-load.sh"
|
||||
|
||||
echo "Applying manifests (namespace={room})..."
|
||||
kubectl --context "$CTX" apply -k "$K8S_DIR/overlays/dev"
|
||||
|
||||
echo
|
||||
echo "Done."
|
||||
echo " Gateway: http://localhost:{nodeport}/"
|
||||
echo " (add `127.0.0.1 {room}.spr.local.ar` to /etc/hosts for Host routing)"
|
||||
"""
|
||||
|
||||
|
||||
def k8s_down_sh(*, room: str, cluster: str) -> str:
|
||||
return f"""\
|
||||
#!/bin/bash
|
||||
# Remove the "{room}" namespace from the shared `{cluster}` cluster.
|
||||
# Leaves the cluster itself running (use repo-root ctrl/kind-down.sh to drop everything).
|
||||
set -e
|
||||
|
||||
CTX="kind-{cluster}"
|
||||
|
||||
if ! kind get clusters 2>/dev/null | grep -q '^{cluster}$'; then
|
||||
echo "Kind cluster '{cluster}' not running — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if kubectl --context "$CTX" get namespace {room} >/dev/null 2>&1; then
|
||||
kubectl --context "$CTX" delete namespace {room}
|
||||
else
|
||||
echo "Namespace '{room}' not found in '{cluster}'."
|
||||
fi
|
||||
"""
|
||||
|
||||
|
||||
def k8s_load_sh(
|
||||
*, room: str, cluster: str, managed_name: str,
|
||||
has_managed: bool, has_frontend: bool, has_link: bool,
|
||||
) -> str:
|
||||
steps = [f'echo "Building soleprint image…"',
|
||||
f'docker build -t {room}-soleprint:dev "$ROOT_DIR/soleprint"']
|
||||
if has_managed:
|
||||
steps += [f'echo "Building backend image…"',
|
||||
f'docker build -t {room}-backend:dev "$ROOT_DIR/{managed_name}/backend"']
|
||||
if has_frontend:
|
||||
steps += [f'echo "Building frontend image…"',
|
||||
f'docker build -t {room}-frontend:dev "$ROOT_DIR/{managed_name}/frontend"']
|
||||
if has_link:
|
||||
steps += [f'echo "Building link image…"',
|
||||
f'docker build -t {room}-link:dev "$ROOT_DIR/link"']
|
||||
|
||||
loads = [f'kind load docker-image {room}-soleprint:dev --name {cluster}']
|
||||
if has_managed:
|
||||
loads.append(f'kind load docker-image {room}-backend:dev --name {cluster}')
|
||||
if has_frontend:
|
||||
loads.append(f'kind load docker-image {room}-frontend:dev --name {cluster}')
|
||||
if has_link:
|
||||
loads.append(f'kind load docker-image {room}-link:dev --name {cluster}')
|
||||
|
||||
body = "\n".join(steps + [""] + [f'echo "Loading into `{cluster}` kind cluster…"'] + loads)
|
||||
return f"""\
|
||||
#!/bin/bash
|
||||
# Build all images for room "{room}" and load them into the shared `{cluster}` cluster.
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
{body}
|
||||
"""
|
||||
@@ -19,7 +19,10 @@ def _schema_paths() -> list[Path]:
|
||||
"""Return ordered list of directories to search for schema."""
|
||||
paths = []
|
||||
|
||||
# cfg/<room>/station/tools/graphgen/ (room-specific, highest priority)
|
||||
# station/tools/graphgen/ (room-merged schema.json lives here after build)
|
||||
paths.append(SPR_ROOT / "station" / "tools" / "graphgen")
|
||||
|
||||
# cfg/<room>/station/tools/graphgen/ (source-tree room-specific, highest priority)
|
||||
cfg_dir = SPR_ROOT / "cfg"
|
||||
if cfg_dir.exists():
|
||||
for room in sorted(cfg_dir.iterdir()):
|
||||
|
||||
Reference in New Issue
Block a user