updates 33.1 84
This commit is contained in:
220
build.py
220
build.py
@@ -317,6 +317,221 @@ def copy_cfg(output_dir: Path, room: str):
|
||||
copy_path(item, output_dir / item.name)
|
||||
|
||||
|
||||
def load_cabinets(room: str) -> list[dict]:
|
||||
"""The dependency containers a room asked for, in the order it listed them.
|
||||
|
||||
Read from cfg/<room>/data/cabinets.json — the same shape and the same place
|
||||
as its sibling data/*.json files, so nothing new has to know about it.
|
||||
Entries are {"name": "postgres"} and may carry an "env" override.
|
||||
"""
|
||||
path = SPR_ROOT / "cfg" / room / "data" / "cabinets.json"
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
entries = json.loads(path.read_text())
|
||||
except ValueError as e:
|
||||
log.warning(f" cabinets.json is not valid JSON, ignoring: {e}")
|
||||
return []
|
||||
|
||||
out = []
|
||||
for entry in entries if isinstance(entries, list) else []:
|
||||
if isinstance(entry, str):
|
||||
entry = {"name": entry}
|
||||
if isinstance(entry, dict) and entry.get("name"):
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_cabinets(requested: list[dict]) -> list[dict]:
|
||||
"""Expand each request into its definition, pulling in what it depends on.
|
||||
|
||||
Airflow without postgres is a container that exits on boot, so a cabinet's
|
||||
depends_on is added for you rather than left as something to remember.
|
||||
"""
|
||||
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
|
||||
resolved: dict[str, dict] = {}
|
||||
|
||||
def add(name: str, overrides: dict) -> None:
|
||||
if name in resolved:
|
||||
# Already pulled in as somebody's dependency. The room asking for it
|
||||
# by name is the more specific statement, so its env still applies —
|
||||
# otherwise declaring airflow before postgres would silently drop
|
||||
# postgres's settings.
|
||||
if overrides.get("env"):
|
||||
resolved[name]["env"] = {
|
||||
**resolved[name].get("env", {}),
|
||||
**overrides["env"],
|
||||
}
|
||||
return
|
||||
definition_path = cabinets_dir / name / "cabinet.json"
|
||||
if not definition_path.exists():
|
||||
available = sorted(
|
||||
p.name for p in cabinets_dir.iterdir() if p.is_dir()
|
||||
) if cabinets_dir.exists() else []
|
||||
log.warning(f" no such cabinet: {name} (available: {', '.join(available) or 'none'})")
|
||||
return
|
||||
try:
|
||||
definition = json.loads(definition_path.read_text())
|
||||
except ValueError as e:
|
||||
log.warning(f" cabinet {name} has invalid cabinet.json: {e}")
|
||||
return
|
||||
|
||||
# Mark it claimed before recursing, so a dependency cycle terminates.
|
||||
resolved[name] = definition
|
||||
for dependency in definition.get("depends_on", []) or []:
|
||||
add(dependency, {})
|
||||
|
||||
definition["env"] = {**definition.get("env", {}), **overrides.get("env", {})}
|
||||
|
||||
for entry in requested:
|
||||
add(entry["name"], entry)
|
||||
|
||||
# Dependencies first, so compose reads in the order things start.
|
||||
ordered: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def emit(name: str) -> None:
|
||||
if name in seen or name not in resolved:
|
||||
return
|
||||
seen.add(name)
|
||||
for dependency in resolved[name].get("depends_on", []) or []:
|
||||
emit(dependency)
|
||||
ordered.append(resolved[name])
|
||||
|
||||
for name in resolved:
|
||||
emit(name)
|
||||
return ordered
|
||||
|
||||
|
||||
def compose_cabinets(output_dir: Path, room: str):
|
||||
"""Merge the room's cabinets into its docker-compose.yml and .env.example.
|
||||
|
||||
This is the compile step for dependencies: a room declares postgres, and the
|
||||
built instance comes out with postgres in its compose file rather than with
|
||||
instructions for adding it.
|
||||
"""
|
||||
requested = load_cabinets(room)
|
||||
if not requested:
|
||||
return
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
log.warning(
|
||||
" cabinets need PyYAML to merge into docker-compose.yml "
|
||||
"(pip install pyyaml) — skipping"
|
||||
)
|
||||
return
|
||||
|
||||
cabinets = resolve_cabinets(requested)
|
||||
if not cabinets:
|
||||
return
|
||||
|
||||
compose_path = output_dir / "docker-compose.yml"
|
||||
if not compose_path.exists():
|
||||
log.warning(
|
||||
f" no docker-compose.yml in {output_dir.name}, "
|
||||
f"so there is nothing to merge {len(cabinets)} cabinet(s) into"
|
||||
)
|
||||
return
|
||||
|
||||
original = compose_path.read_text()
|
||||
# A YAML round-trip drops every comment, and the room's compose file leads
|
||||
# with the one that says how to run it. Keep the header block; the rest is
|
||||
# generated anyway.
|
||||
header = []
|
||||
for line in original.splitlines():
|
||||
if line.startswith("#") or not line.strip():
|
||||
header.append(line)
|
||||
else:
|
||||
break
|
||||
while header and not header[-1].strip():
|
||||
header.pop()
|
||||
|
||||
compose = yaml.safe_load(original) or {}
|
||||
services = compose.setdefault("services", {})
|
||||
volumes = compose.setdefault("volumes", {}) or {}
|
||||
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
|
||||
|
||||
added, skipped = [], []
|
||||
for cabinet in cabinets:
|
||||
name = cabinet["name"]
|
||||
service_name = cabinet.get("service", name)
|
||||
|
||||
# The room's own compose file is the authority. A room that already
|
||||
# declares `db` has arranged it deliberately, and silently replacing it
|
||||
# would be the worst possible outcome of switching a cabinet on.
|
||||
if service_name in services:
|
||||
skipped.append(service_name)
|
||||
continue
|
||||
|
||||
fragment_path = cabinets_dir / name / "service.yml"
|
||||
if not fragment_path.exists():
|
||||
log.warning(f" cabinet {name} has no service.yml")
|
||||
continue
|
||||
|
||||
fragment = yaml.safe_load(fragment_path.read_text()) or {}
|
||||
for key, value in fragment.items():
|
||||
if key in services:
|
||||
skipped.append(key)
|
||||
continue
|
||||
services[key] = value
|
||||
added.append(key)
|
||||
|
||||
for volume in cabinet.get("volumes", []) or []:
|
||||
volumes.setdefault(volume, None)
|
||||
|
||||
if volumes:
|
||||
compose["volumes"] = volumes
|
||||
|
||||
rendered = yaml.safe_dump(compose, sort_keys=False, default_flow_style=False)
|
||||
banner = f"# Cabinets merged in by build.py: {', '.join(c['name'] for c in cabinets)}.\n"
|
||||
preamble = ("\n".join(header) + "\n" + banner + "\n") if header else banner + "\n"
|
||||
compose_path.write_text(preamble + rendered)
|
||||
|
||||
if added:
|
||||
log.info(f" cabinets: {', '.join(added)}")
|
||||
if skipped:
|
||||
log.info(f" cabinets already declared by the room, left alone: {', '.join(skipped)}")
|
||||
|
||||
_append_cabinet_env(output_dir, cabinets)
|
||||
|
||||
|
||||
def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
|
||||
"""Add each cabinet's settings to .env.example, without touching .env."""
|
||||
example = output_dir / ".env.example"
|
||||
existing = example.read_text() if example.exists() else ""
|
||||
# Match whole settings, not substrings: `POSTGRES_DB=` appears inside
|
||||
# `MY_POSTGRES_DB=`, and a substring test would decide the setting was
|
||||
# already there and skip it.
|
||||
declared = {
|
||||
line.split("=", 1)[0].strip()
|
||||
for line in existing.splitlines()
|
||||
if "=" in line and not line.lstrip().startswith("#")
|
||||
}
|
||||
|
||||
lines = []
|
||||
for cabinet in cabinets:
|
||||
env = cabinet.get("env", {})
|
||||
if not env:
|
||||
continue
|
||||
block = [f"\n# ── {cabinet.get('title', cabinet['name'])} (cabinet) ──"]
|
||||
for note in cabinet.get("notes", []) or []:
|
||||
block.append(f"# {note}")
|
||||
wrote = False
|
||||
for key, value in env.items():
|
||||
if key in declared:
|
||||
continue
|
||||
block.append(f"{key}={value}")
|
||||
declared.add(key)
|
||||
wrote = True
|
||||
if wrote:
|
||||
lines.extend(block)
|
||||
|
||||
if lines:
|
||||
example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def build_soleprint(output_dir: Path, room: str):
|
||||
"""Build soleprint folder with core + room config merged."""
|
||||
soleprint = SPR_ROOT / "soleprint"
|
||||
@@ -348,6 +563,11 @@ def build_soleprint(output_dir: Path, room: str):
|
||||
# Room config (includes merging room-specific artery/atlas/station)
|
||||
copy_cfg(output_dir, room)
|
||||
|
||||
# Dependency containers the room asked for, merged into its compose file.
|
||||
# After copy_cfg, because the compose file being merged into is the room's.
|
||||
log.info("Composing cabinets...")
|
||||
compose_cabinets(output_dir, room)
|
||||
|
||||
# Generate models
|
||||
log.info("Generating models...")
|
||||
if not generate_models(output_dir, room):
|
||||
|
||||
Reference in New Issue
Block a user