#!/usr/bin/env python3 """Environment-neutral offline deployment runtime state and evidence paths.""" from __future__ import annotations import json import shlex from pathlib import Path from typing import Any from common import ( DeployError, load_env_file, shell_quote_env_value, write_text_atomic, ) def runtime_paths(env_root: Path) -> dict[str, Path]: repos_root = env_root / "repos" return { "env_root": env_root, "runtime_env_file": env_root / "env" / "runtime.env", "repos_root": repos_root, "downstream_root": repos_root / "nexuscrm-pingcap-pack", "upstream_root": repos_root / "NexusCRM", "app_packs_root": repos_root / "nexuscrm-app-packs", "runtime_root": env_root / ".runtime", "recovery_sources_root": env_root / ".runtime" / "recovery-sources", "log_dir": env_root / "logs", "run_dir": env_root / "run", "frontend_dist": env_root / "frontend-dist", "frontend_build_dist": env_root / "frontend-dist-build", "artifact_dir": env_root / "artifacts", } def runtime_state(env_file: Path) -> dict[str, object]: envv = load_env_file(env_file) env_root = Path(envv["ENV_ROOT"]).expanduser().resolve() return { "env_file": env_file, "envv": envv, "env_root": env_root, "paths": runtime_paths(env_root), "framework_dir": Path(__file__).resolve().parents[1], } def update_env_file( path: Path, updates: dict[str, str], *, remove_keys: set[str] | None = None, ) -> None: remove_keys = remove_keys or set() lines = path.read_text().splitlines() if path.is_file() else [] seen: set[str] = set() out: list[str] = [] for line in lines: if not line or line.startswith("#") or "=" not in line: out.append(line) continue key, _ = line.split("=", 1) if key in remove_keys: seen.add(key) continue if key in updates: out.append(f"{key}={shell_quote_env_value(updates[key])}") seen.add(key) else: out.append(line) for key, value in updates.items(): if key not in seen: out.append(f"{key}={shell_quote_env_value(value)}") write_text_atomic(path, "\n".join(out) + "\n") def systemd_service_name(envv: dict[str, str]) -> str: return f"nexuscrm-{envv['ENV_NAME']}-backend.service" def write_deploy_summary( summary_path: Path, payload: dict[str, str], *, shell_quote: bool = True, ) -> None: summary_path.parent.mkdir(parents=True, exist_ok=True) lines = [ f"{key}={shlex.quote(value) if shell_quote else value}" for key, value in payload.items() ] summary_path.write_text("\n".join(lines) + "\n") def deploy_artifact_dir(envv: dict[str, str]) -> Path: raw = envv.get("DEPLOY_ARTIFACT_DIR", "") if not raw: raise DeployError("DEPLOY_ARTIFACT_DIR missing in runtime env") return Path(raw).expanduser().resolve() def stage_summary_path(envv: dict[str, str]) -> Path: return deploy_artifact_dir(envv) / "stage-summary.json" def write_stage_summary(envv: dict[str, str], payload: dict[str, Any]) -> None: path = stage_summary_path(envv) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") def append_stage_summary( envv: dict[str, str], stage_name: str, payload: dict[str, Any], ) -> None: path = stage_summary_path(envv) data: dict[str, Any] if path.is_file(): data = json.loads(path.read_text()) else: data = { "env_name": envv.get("ENV_NAME", ""), "deploy_id": envv.get("DEPLOY_ID", ""), "stages": {}, } stages = data.setdefault("stages", {}) if not isinstance(stages, dict): raise DeployError(f"invalid stage summary shape: {path}") stages[stage_name] = payload write_stage_summary(envv, data)