"""Load unified control-plane environment from file + os.environ.""" from __future__ import annotations import os from pathlib import Path _ENV_OVERRIDE_KEYS = ( "API_KEY", "SERVER_UI_HOST", "SERVER_UI_PORT", "REPO_ROOT", "DOCKER_GID", "GPU_FAN_AGENT_URL", "GPU_FAN_API_HOST", "GPU_FAN_API_PORT", "GPU_FAN_HOST", "GPU_FAN_PORT", "CURVE_PATH", "POLL_INTERVAL", "GPU_INDEX", "DRY_RUN", "FILE_EXPLORER_ROOT", "FILE_EXPLORER_MAX_BYTES", "CLI_ENABLED", "CLI_SHELL", "CLI_DEFAULT_CWD", "CLI_MAX_SESSIONS", ) def _parse_env_file(path: Path) -> dict[str, str]: values: dict[str, str] = {} if not path.is_file(): return values try: text = path.read_text(encoding="utf-8") except OSError: # e.g. /opt/control-plane/.env is root-only; systemd still injects via os.environ return values for line in text.splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") values[key.strip()] = val.strip() return values def _merge_into(target: dict[str, str], source: dict[str, str]) -> None: for key, val in source.items(): if val: target[key] = val def _is_production_stack_dir(stack_dir: Path) -> bool: try: return stack_dir.resolve().parts[1:2] == ("opt",) except IndexError: return False def control_plane_env_paths(stack_dir: Path) -> list[Path]: """Candidate .env files in load order (earlier = lower priority among files).""" paths: list[Path] = [] custom = os.environ.get("CONTROL_PLANE_ENV", "").strip() if custom: paths.append(Path(custom)) if _is_production_stack_dir(stack_dir): # Production (/opt/server-ui, /opt/gpu-fan): single canonical file only. prod = Path("/opt/control-plane/.env") if prod not in paths: paths.append(prod) docker_repo = Path("/repo/stacks/control-plane/.env") if docker_repo.is_file() and docker_repo not in paths: paths.append(docker_repo) return paths # Dev (repo stacks/*): stacks/control-plane/.env only — no legacy per-service .env. repo_control_plane = stack_dir.parent / "control-plane" / ".env" if repo_control_plane.is_file() and repo_control_plane not in paths: paths.append(repo_control_plane) docker_repo = Path("/repo/stacks/control-plane/.env") if docker_repo.is_file() and docker_repo not in paths: paths.append(docker_repo) return paths def api_key_source(stack_dir: Path, values: dict[str, str]) -> str: """Human-readable hint for logs (no secret values).""" if os.environ.get("API_KEY"): return "systemd/os.environ" for path in control_plane_env_paths(stack_dir): parsed = _parse_env_file(path) if parsed.get("API_KEY"): return str(path) if values.get("API_KEY"): return "merged env" return "not configured" def load_control_plane_env(stack_dir: Path) -> dict[str, str]: """Merge env files then apply os.environ (systemd EnvironmentFile wins).""" values: dict[str, str] = {} for path in control_plane_env_paths(stack_dir): _merge_into(values, _parse_env_file(path)) for key in _ENV_OVERRIDE_KEYS: if key in os.environ: values[key] = os.environ[key] values.setdefault("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090") return values def ensure_control_plane_import_path() -> None: """Add /opt/control-plane to sys.path for production imports.""" import sys opt = "/opt/control-plane" if opt not in sys.path: sys.path.insert(0, opt) repo = Path(__file__).resolve().parent repo_str = str(repo) if repo_str not in sys.path: sys.path.insert(0, repo_str)