#!/usr/bin/env python3 """Metadata-driven instantiation of the Financial Crime data domain. This driver turns the MD-DDL metadata in this repo into a running, registered domain by orchestrating the semprini-data-domain template scripts. It does not re-implement them — it wires this repo as the template's "data root", ensures secrets, then calls new-domain.sh / docker compose / domain-integration.sh in the right order. Usage: python3 deploy/instantiate.py [ ...] python3 deploy/instantiate.py all # full stand-up python3 deploy/instantiate.py teardown # reverse it Steps (also runnable individually): preflight validate metadata (blocks on findings) wire create repo-local symlinks the template expects secrets ensure domain secrets exist in the shared .env scaffold new-domain.sh -> domains// build build the postgres-ducklake + ariadne images up bring the stack up; wait for bucket + postgres health ducklake attach the DuckLake catalog in the analytics DB register register with core (Keycloak/DNS/Kuma/Prometheus) + mgmt (OpenMetadata) activate restart ariadne + flink to pick up OIDC + metrics verify health-check the running domain """ from __future__ import annotations import os import secrets as secretslib import subprocess import sys import time from pathlib import Path import yaml REPO_ROOT = Path(__file__).resolve().parent.parent CONFIG_PATH = REPO_ROOT / "deploy" / "domain.config.yml" # --------------------------------------------------------------------------- # # helpers # --------------------------------------------------------------------------- # def log(msg: str) -> None: print(f"\033[1;36m›\033[0m {msg}", flush=True) def ok(msg: str) -> None: print(f" \033[1;32m✓\033[0m {msg}", flush=True) def die(msg: str) -> None: print(f"\033[1;31m✗ {msg}\033[0m", file=sys.stderr, flush=True) sys.exit(1) def run(cmd: list[str], cwd: Path | None = None, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess: printable = " ".join(cmd) log(f"$ {printable}") return subprocess.run( cmd, cwd=str(cwd or REPO_ROOT), check=check, text=True, capture_output=capture, ) class Cfg: def __init__(self, raw: dict): self.metadata_dir: str = raw["metadata_dir"] self.domain: str = raw["domain"] self.port_base: int = int(raw["port_base"]) self.template_dir: str = raw["template_dir"] self.platform_repo: str = raw["platform_repo"] self.secrets: list[str] = list(raw["secrets"]) @property def upper(self) -> str: return self.domain.replace("-", "_").upper() @property def template(self) -> Path: return REPO_ROOT / self.template_dir @property def compose(self) -> Path: return REPO_ROOT / "domains" / self.domain / "compose.yml" @property def env_file(self) -> Path: return REPO_ROOT / ".env" def load_cfg() -> Cfg: if not CONFIG_PATH.exists(): die(f"config not found: {CONFIG_PATH}") return Cfg(yaml.safe_load(CONFIG_PATH.read_text())) def compose_cmd(cfg: Cfg, *args: str) -> list[str]: return ["docker", "compose", "-f", str(cfg.compose), *args] def env_value(cfg: Cfg, key: str) -> str | None: for line in cfg.env_file.read_text().splitlines(): if line.startswith(f"{key}="): return line.split("=", 1)[1] return None # --------------------------------------------------------------------------- # # steps # --------------------------------------------------------------------------- # def step_preflight(cfg: Cfg) -> None: log("preflight: validating MD-DDL metadata") checker = REPO_ROOT / ".github" / "scripts" / "preflight.py" run([sys.executable, str(checker), cfg.metadata_dir]) ok("metadata pre-flight passed") def step_wire(cfg: Cfg) -> None: """Repo-local symlinks so the template treats this repo as the data root.""" log("wire: creating repo-local symlinks") links = { # compose build contexts use ../../semprini-data-domain "semprini-data-domain": cfg.template_dir, # shared platform secrets + admin creds ".env": f"{cfg.platform_repo}/.env", # domain-integration.sh sources lib/core-integration.sh "lib": f"{cfg.platform_repo}/lib", } for name, target in links.items(): link = REPO_ROOT / name if link.is_symlink() or link.exists(): ok(f"{name} -> {os.readlink(link) if link.is_symlink() else '(exists)'}") continue link.symlink_to(target) ok(f"created {name} -> {target}") # ensure the bind-mount dirs ariadne expects are present for d in ("schemas", "resolvers", "flink-jobs"): (REPO_ROOT / "domains" / cfg.domain / d).mkdir(parents=True, exist_ok=True) def step_secrets(cfg: Cfg) -> None: log("secrets: ensuring domain secrets in shared .env") env_file = cfg.env_file if not env_file.exists(): die(f"{env_file} missing (run 'wire' first; check {cfg.platform_repo}/.env)") existing = { line.split("=", 1)[0] for line in env_file.read_text().splitlines() if "=" in line and not line.startswith("#") } to_add: list[str] = [] for key in cfg.secrets: if key in existing: ok(f"{key} present") continue if "ACCESS_KEY" in key: val = "fincrime" + secretslib.token_hex(8) else: val = secretslib.token_urlsafe(24) to_add.append(f"{key}={val}") ok(f"generated {key}") port_key = f"{cfg.upper}_PORT_BASE" if port_key not in existing: to_add.append(f"{port_key}={cfg.port_base}") ok(f"set {port_key}={cfg.port_base}") if to_add: with env_file.open("a") as fh: fh.write("\n# --- " + cfg.domain + " domain secrets ---\n") fh.write("\n".join(to_add) + "\n") def step_scaffold(cfg: Cfg) -> None: log(f"scaffold: new-domain.sh {cfg.domain} {cfg.port_base}") if cfg.compose.exists(): ok(f"already scaffolded at {cfg.compose.parent}") return run([str(cfg.template / "new-domain.sh"), cfg.domain, str(cfg.port_base)]) ok("domain scaffolded") def step_build(cfg: Cfg) -> None: log("build: postgres-ducklake + ariadne images") run(compose_cmd(cfg, "build", f"postgres-{cfg.domain}", f"ariadne-{cfg.domain}")) ok("images built") def _wait(cfg: Cfg, desc: str, check_fn, timeout: int = 180, interval: int = 5) -> None: log(f"waiting for {desc} (≤{timeout}s)") deadline = time.time() + timeout while time.time() < deadline: if check_fn(): ok(desc) return time.sleep(interval) die(f"timed out waiting for {desc}") def step_up(cfg: Cfg) -> None: log("up: starting the domain stack") run(compose_cmd(cfg, "up", "-d")) def bucket_ready() -> bool: r = subprocess.run( ["docker", "inspect", "-f", "{{.State.Status}}:{{.State.ExitCode}}", f"minio-init-{cfg.domain}"], text=True, capture_output=True, ) return r.stdout.strip() == "exited:0" def pg_ready() -> bool: r = subprocess.run( ["docker", "inspect", "-f", "{{.State.Health.Status}}", f"postgres-{cfg.domain}"], text=True, capture_output=True, ) return r.stdout.strip() == "healthy" _wait(cfg, f"{cfg.domain}-iceberg bucket (minio-init exit 0)", bucket_ready) _wait(cfg, f"postgres-{cfg.domain} healthy", pg_ready) def step_ducklake(cfg: Cfg) -> None: """Post-startup: attach the DuckLake catalog inside the analytics DB.""" log("ducklake: attaching catalog in analytics DB") access = env_value(cfg, f"MINIO_{cfg.upper}_ACCESS_KEY") secret = env_value(cfg, f"MINIO_{cfg.upper}_SECRET_KEY") pg_pw = env_value(cfg, f"POSTGRES_{cfg.upper}_PASSWORD") if not all([access, secret, pg_pw]): die("missing MinIO/postgres secrets in .env for analytics attach") sql = f""" SELECT duckdb.create_secret( '{cfg.domain}_minio', 'S3', key_id := '{access}', secret := '{secret}', endpoint := 'minio-{cfg.domain}:9000', url_style := 'path', use_ssl := false ); SELECT duckdb.raw_query($q$ ATTACH 'ducklake:postgres:host=postgres-{cfg.domain} port=5432 dbname={cfg.domain} user={cfg.domain} password={pg_pw}' AS "{cfg.domain}_lake"; $q$); """ # analytics service runs as user 'analytics', db '' run([ "docker", "exec", "-i", f"analytics-{cfg.domain}", "psql", "-v", "ON_ERROR_STOP=1", "-U", "analytics", "-d", cfg.domain, ] + ["-c", sql], check=False) ok("analytics DuckLake attach attempted (idempotent; safe to re-run)") def step_register(cfg: Cfg) -> None: log("register: core + management (reversible, one-way)") run([str(cfg.template / "domain-integration.sh"), cfg.domain, "register", "all"]) ok("registered with core + management") def step_activate(cfg: Cfg) -> None: log("activate: restart ariadne + flink to pick up OIDC + metrics") run(compose_cmd(cfg, "restart", f"ariadne-{cfg.domain}", f"flink-jm-{cfg.domain}")) ok("ariadne + flink restarted") def step_verify(cfg: Cfg) -> None: log("verify: checking domain stability") expected = [f"{p}-{cfg.domain}" for p in ("postgres", "kafka", "flink-jm", "flink-tm", "minio", "analytics", "ariadne")] r = subprocess.run(["docker", "ps", "--format", "{{.Names}}"], text=True, capture_output=True) running = set(r.stdout.split()) missing = [c for c in expected if c not in running] if missing: die(f"services not running: {', '.join(missing)}") ok(f"{len(expected)} services running: {', '.join(expected)}") # MinIO bucket rc = subprocess.run( ["docker", "exec", f"minio-{cfg.domain}", "sh", "-c", f"mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD >/dev/null 2>&1; " f"mc ls local/{cfg.domain}-iceberg >/dev/null 2>&1 && echo OK"], text=True, capture_output=True, ) ok(f"{cfg.domain}-iceberg bucket present") if "OK" in rc.stdout else \ print(f" ! could not confirm bucket (non-fatal): {rc.stderr.strip()}") print("\n Next (VPN required) — manual spot checks:") print(f" GraphQL : http://api.{cfg.domain}.data.internal:{cfg.port_base}/health") print(f" Flink : http://flink.{cfg.domain}.data.internal:{cfg.port_base + 1}") print(f" Keycloak: client data-{cfg.domain} + /data-{cfg.domain}/* groups") print(f" Catalog : OpenMetadata domain '{cfg.domain}'") def step_teardown(cfg: Cfg) -> None: log("teardown: unregister + stop (reverses the stand-up)") run([str(cfg.template / "domain-integration.sh"), cfg.domain, "unregister", "all"], check=False) run(compose_cmd(cfg, "down", "-v"), check=False) ok("domain unregistered + stopped") STEPS = { "preflight": step_preflight, "wire": step_wire, "secrets": step_secrets, "scaffold": step_scaffold, "build": step_build, "up": step_up, "ducklake": step_ducklake, "register": step_register, "activate": step_activate, "verify": step_verify, "teardown": step_teardown, } ALL = ["preflight", "wire", "secrets", "scaffold", "build", "up", "ducklake", "register", "activate", "verify"] def main() -> None: args = sys.argv[1:] or ["all"] requested: list[str] = [] for a in args: if a == "all": requested.extend(ALL) elif a in STEPS: requested.append(a) else: die(f"unknown step '{a}'. valid: {', '.join(STEPS)}, all") cfg = load_cfg() log(f"domain={cfg.domain} metadata={cfg.metadata_dir} port_base={cfg.port_base}") for name in requested: STEPS[name](cfg) log("done") if __name__ == "__main__": main()