#!/usr/bin/env python3 """Validate and run the standalone NexusCRM L0 Docker Compose project.""" from __future__ import annotations import argparse import os import subprocess import sys from pathlib import Path from typing import Mapping, Sequence HERE = Path(__file__).resolve().parent COMPOSE_FILE = HERE / "compose.yaml" WORKER_ROLES = ( "audit-history-fanout", "file-upload-lifecycle", "platform-command", "record-command-lifecycle", "record-event-dispatch", "rollup-delta", "scheduled-flow", ) TRUE_VALUES = {"1", "true"} class DeployError(RuntimeError): pass def run( args: Sequence[str], *, environment: Mapping[str, str] | None = None, capture: bool = False, ) -> subprocess.CompletedProcess[str]: return subprocess.run( list(args), check=True, text=True, capture_output=capture, env=dict(environment) if environment is not None else None, ) def read_env(path: Path) -> dict[str, str]: values: dict[str, str] = {} for line_number, raw in enumerate(path.read_text().splitlines(), start=1): line = raw.strip() if not line or line.startswith("#"): continue key, separator, value = line.partition("=") if not separator or not key: raise DeployError(f"invalid environment entry at {path}:{line_number}") values[key] = value return values def require_file(path: Path, label: str) -> Path: if not path.is_file(): raise DeployError(f"missing {label}: {path}") return path.resolve() def require_directory(path: Path, label: str) -> Path: if not path.is_dir(): raise DeployError(f"missing {label}: {path}") return path.resolve() def port(value: str, label: str) -> int: try: parsed = int(value) except ValueError as error: raise DeployError(f"{label} must be an integer") from error if not 1 <= parsed <= 65535: raise DeployError(f"{label} is outside 1..65535") return parsed def validate_transport_state(root: Path) -> Path: root = root.resolve() required_files = { root / "secrets/tls/server.crt": "Runtime TLS certificate", root / "secrets/tls/server.key": "Runtime TLS private key", } for path, label in required_files.items(): require_file(path, label) return root def validate_serving_state(root: Path) -> Path: root = validate_transport_state(root) required_files = { root / "config/runtime.env": "Runtime API environment", root / "config/serving.json": "Catalog-published Runtime serving artifact", root / "secrets/workload-identities.json": "Runtime workload identities", root / "secrets/signing-keys.json": "Runtime signing keys", } for role in WORKER_ROLES: required_files[root / f"config/workers/{role}.json"] = f"{role} Worker config" required_files[root / f"secrets/workers/{role}.token"] = f"{role} Worker token" for path, label in required_files.items(): require_file(path, label) require_directory(root / "data/catalog", "Catalog snapshot store") require_directory(root / "data/files", "Runtime file storage") require_directory(root / "data/uploads", "Runtime legacy upload storage") return root def validate_database(path: Path, mode: str) -> tuple[Path, dict[str, str]]: path = require_file(path, "TiDB environment file") values = read_env(path) required = ("TIDB_HOST", "TIDB_PORT", "TIDB_USER", "TIDB_PASSWORD", "TIDB_DATABASE") missing = [key for key in required if key not in values or (key != "TIDB_PASSWORD" and not values[key])] if missing: raise DeployError("TiDB environment is incomplete: " + ", ".join(missing)) port(values["TIDB_PORT"], "TIDB_PORT") tls_disabled = values.get("TIDB_TLS_DISABLE", "").lower() in TRUE_VALUES if mode == "local": if not tls_disabled: raise DeployError("local TiDB mode requires TIDB_TLS_DISABLE=1") if values["TIDB_HOST"].lower() != "host.docker.internal": raise DeployError("local TiDB mode requires TIDB_HOST=host.docker.internal") if mode == "remote": if tls_disabled: raise DeployError("remote TiDB mode forbids TIDB_TLS_DISABLE") if values["TIDB_HOST"].lower() in {"localhost", "127.0.0.1", "host.docker.internal"}: raise DeployError("remote TiDB mode requires a non-local TLS hostname") if not values["TIDB_PASSWORD"]: raise DeployError("remote TiDB mode requires TIDB_PASSWORD") return path, values def validate_controlplane_env(path: Path | None, mode: str) -> Path | None: if path is None: if mode == "remote": raise DeployError("remote init requires --controlplane-env-file") return None path = require_file(path, "L0 Controlplane environment file") values = read_env(path) if mode == "remote": missing = [key for key in ("JWT_SECRET", "NEXUS_ADMIN_PASSWORD") if not values.get(key)] if missing: raise DeployError("remote L0 Controlplane environment is incomplete: " + ", ".join(missing)) return path def compose_environment( state_root: Path, database_file: Path | None, database_mode: str | None, project: str, runtime_api_image: str, runtime_worker_image: str | None, grpc_port: int, ) -> dict[str, str]: if not project or not runtime_api_image: raise DeployError("project and the exact Runtime API image reference are required") port(str(grpc_port), "Runtime gRPC host port") return { **os.environ, "NEXUSCRM_L0_PROJECT": project, "NEXUSCRM_L0_STATE_ROOT": str(state_root), "NEXUSCRM_L0_RUNTIME_ENV_FILE": str( state_root / "config/runtime.env" if (state_root / "config/runtime.env").is_file() else Path("/dev/null") ), "NEXUSCRM_L0_CONTAINER_USER": f"{os.getuid()}:{os.getgid()}", # Compose parses inactive profiles too. /dev/null is a harmless env # file for the metadata-independent bootstrap, which never opens TiDB. "NEXUSCRM_L0_DATABASE_ENV_FILE": str(database_file or Path("/dev/null")), "NEXUSCRM_L0_TIDB_TLS_DISABLE": "1" if database_mode == "local" else "", "NEXUSCRM_RUNTIME_API_IMAGE": runtime_api_image, "NEXUSCRM_RUNTIME_WORKER_IMAGE": runtime_worker_image or runtime_api_image, "NEXUSCRM_RUNTIME_API_GRPC_HOST_PORT": str(grpc_port), } def compose_command(project: str, *args: str) -> list[str]: return ["docker", "compose", "-p", project, "-f", str(COMPOSE_FILE), *args] def add_database_arguments(value: argparse.ArgumentParser) -> None: value.add_argument("--database-env-file", required=True) value.add_argument("--database-mode", required=True, choices=("local", "remote")) def parser() -> argparse.ArgumentParser: value = argparse.ArgumentParser(description="Run the standalone NexusCRM L0 composition") commands = value.add_subparsers(dest="command", required=True) initialize = commands.add_parser("init", help="initialize the L0 substrate in TiDB") add_database_arguments(initialize) initialize.add_argument("--runtime-api-image", required=True) initialize.add_argument("--controlplane-env-file") deploy = commands.add_parser( "deploy", help="start L0 live and fail closed without L1 serving authority" ) deploy.add_argument("--state-root", required=True) deploy.add_argument("--runtime-api-image", required=True) deploy.add_argument("--project", default="nexuscrm-l0") deploy.add_argument("--runtime-grpc-port", type=int, default=9090) deploy.add_argument("--timeout", type=int, default=120) activate = commands.add_parser( "activate", help="replace bootstrap with the Catalog-bound serving composition" ) add_database_arguments(activate) activate.add_argument("--state-root", required=True) activate.add_argument("--runtime-api-image", required=True) activate.add_argument("--runtime-worker-image", required=True) activate.add_argument("--project", default="nexuscrm-l0") activate.add_argument("--runtime-grpc-port", type=int, default=9090) activate.add_argument("--timeout", type=int, default=120) for name in ("status", "down"): command = commands.add_parser(name) command.add_argument("--project", default="nexuscrm-l0") return value def main() -> int: args = parser().parse_args() try: if args.command == "init": database_file, _ = validate_database(Path(args.database_env_file), args.database_mode) controlplane_env = validate_controlplane_env( Path(args.controlplane_env_file) if args.controlplane_env_file else None, args.database_mode, ) command = [ "docker", "run", "--rm", ] if controlplane_env is not None: command.extend(("--env-file", str(controlplane_env))) command.extend( ( "--env-file", str(database_file), "--env", "TIDB_TLS_DISABLE=1" if args.database_mode == "local" else "TIDB_TLS_DISABLE=", "--entrypoint", "/usr/local/bin/l0-controlplane", ) ) if args.database_mode == "local": command.extend(("--add-host", "host.docker.internal:host-gateway")) command.extend((args.runtime_api_image, "init-l0")) run(command) print("deploy-l0: init complete") return 0 if args.command in {"status", "down"}: environment = compose_environment( HERE, None, None, args.project, "nexuscrm-runtime-api:unused", None, 9090, ) compose_args = ("--profile", "bootstrap", "--profile", "serving") if args.command == "down": run( compose_command( args.project, *compose_args, "down", "--remove-orphans" ), environment=environment, ) else: run( compose_command(args.project, *compose_args, "ps"), environment=environment, ) print(f"deploy-l0: {args.command} complete") return 0 if args.command == "deploy": state = validate_transport_state(Path(args.state_root)) database_file = None database_mode = None worker_image = None profile = "bootstrap" service = ("runtime-bootstrap",) else: state = validate_serving_state(Path(args.state_root)) database_file, _ = validate_database( Path(args.database_env_file), args.database_mode ) database_mode = args.database_mode worker_image = args.runtime_worker_image profile = "serving" service = () environment = compose_environment( state, database_file, database_mode, args.project, args.runtime_api_image, worker_image, args.runtime_grpc_port, ) # V1 is a stopped transition: never keep bootstrap and serving roles # alive together or introduce a rolling/hot-reload compatibility path. run( compose_command( args.project, "--profile", "bootstrap", "--profile", "serving", "down", "--remove-orphans", ), environment=environment, ) run( compose_command( args.project, "--profile", profile, "up", "--detach", "--wait", "--wait-timeout", str(args.timeout), *service, ), environment=environment, ) print(f"deploy-l0: {args.command} complete") return 0 except (DeployError, OSError, subprocess.CalledProcessError) as error: print(f"deploy-l0: {error}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())