"""Prole configuration environment management. This module centralizes how Prole resolves configuration paths and how it loads configuration with per-environment layering. Target on-disk layout (under `$PROLE_CONF`): - `prole.cfg` -> symlink entrypoint to the active environment - `/prole.cfg` -> base config for that environment - `/*.cfg` -> optional overrides applied in deterministic order The entrypoint path remains stable for all existing code: `$PROLE_CONF/prole.cfg`. """ from __future__ import annotations import configparser import os import time from pathlib import Path ENVIRONMENTS: tuple[str, ...] = ("dev", "service", "prod", "test") def _env_from_mode_or_env_hint(raw: str | None) -> str: """Map a mode/env hint to one of the supported environments.""" s = normalize_environment(raw or "") if not s: return "" # normalize_environment already maps k3d/k3s/k8s-ish values to env names. if s in ENVIRONMENTS: return s return "" def _infer_env_from_cfg_file(cfg_path: Path) -> str: """Best-effort inference of active env from a legacy standalone prole.cfg.""" cfg_path = Path(cfg_path) if not cfg_path.exists() or not cfg_path.is_file() or cfg_path.is_symlink(): return "" try: cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str cfg.read([str(cfg_path)]) except Exception: return "" # Priority order: explicit deployment mode -> explicit env -> UI/inputs hints. for section, key in ( ("Global", "DEPLOYMENT_MODE"), ("Global", "MODE"), ("Global", "CLUSTER_ENV"), ("Initialize Cluster", "ENVIRONMENT"), ("Inputs", "init_cluster.mode"), ("Inputs", "init_cluster.cluster_env"), ): try: if cfg.has_section(section): env = _env_from_mode_or_env_hint(cfg.get(section, key, fallback="")) if env: return env except Exception: continue return "" def normalize_environment(env: str | None) -> str: if not env: return "" s = str(env).strip().lower() if s in ("dev", "k3d") or s.startswith(("k3d-", "prole-dev-")) or s == "knoe-dev-cluster": return "dev" if s in ("service", "k3s", "prole-service-cluster") or s.startswith("prole-service-"): return "service" if s in ("prod", "production", "k8s", "prole-prod-cluster") or s.startswith("prole-prod-"): return "prod" if s in ("test", "testing"): return "test" return s def resolve_prole_conf_dir(project_root: Path) -> Path: raw = os.environ.get("PROLE_CONF", "").strip() if raw: return Path(raw).expanduser() return project_root / "conf" def entrypoint_path(conf_dir: Path) -> Path: """Return the active env-specific prole.cfg path. Prefers a direct env-specific file (conf/{env}/prole.cfg) resolved from CLUSTER_ENV or PROLE_MODE so that no symlink is required. Falls back to the legacy conf/prole.cfg entrypoint (symlink or plain file) for backward compatibility. """ conf_dir = Path(conf_dir) # Determine active env from environment variables (set by the UI on save). for var in ("CLUSTER_ENV", "PROLE_MODE", "DEPLOYMENT_MODE"): hint = os.environ.get(var, "").strip() if hint: env = _env_from_mode_or_env_hint(hint) if env in ENVIRONMENTS: env_path = conf_dir / env / "prole.cfg" if env_path.exists(): return env_path # Fallback: legacy symlink or plain file at conf/prole.cfg. return conf_dir / "prole.cfg" def _is_known_env_dir(conf_dir: Path, env_dir: Path) -> bool: try: return env_dir.is_dir() and env_dir.parent.resolve() == conf_dir.resolve() and env_dir.name in ENVIRONMENTS except Exception: return False def resolve_env_dir_from_cfg_path(cfg_path: Path) -> tuple[Path, str, Path]: """Return (conf_dir, env_name, env_dir). Supports both: - `$PROLE_CONF/prole.cfg` entrypoint (symlink) - `$PROLE_CONF//prole.cfg` base file (direct) """ cfg_path = Path(cfg_path) # If cfg_path is an entrypoint symlink, resolve its target. try: if cfg_path.is_symlink(): target = cfg_path.resolve() env_dir = target.parent conf_dir = env_dir.parent env = normalize_environment(env_dir.name) if env and _is_known_env_dir(conf_dir, env_dir) and target.name == "prole.cfg": return conf_dir, env, env_dir except Exception: pass # If cfg_path is already a base file inside an env dir. try: env_dir = cfg_path.parent conf_dir = env_dir.parent env = normalize_environment(env_dir.name) if env and env in ENVIRONMENTS and cfg_path.name == "prole.cfg" and _is_known_env_dir(conf_dir, env_dir): return conf_dir, env, env_dir except Exception: pass # Fallback: treat as a standalone file. return cfg_path.parent, "", cfg_path.parent def layered_cfg_files(cfg_path: Path) -> list[Path]: """Return config files to load in order (base then overrides).""" cfg_path = Path(cfg_path) conf_dir, env, env_dir = resolve_env_dir_from_cfg_path(cfg_path) files: list[Path] = [] if env and (env_dir / "prole.cfg").exists(): files.append(env_dir / "prole.cfg") try: overrides = [ p for p in env_dir.iterdir() if p.is_file() and p.name != "prole.cfg" and p.suffix == ".cfg" and not p.name.startswith(".") ] overrides.sort(key=lambda p: p.name) files.extend(overrides) except Exception: pass return files # Legacy/standalone: load only the given path. files.append(cfg_path) return files def load_layered_config(cfg_path: Path, require_exists: bool = False) -> configparser.ConfigParser: """Load config from `cfg_path` applying env directory overrides if applicable.""" cfg_path = Path(cfg_path) if require_exists and not cfg_path.exists(): raise FileNotFoundError(str(cfg_path)) cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str files = [p for p in layered_cfg_files(cfg_path) if p.exists()] if files: cfg.read([str(p) for p in files]) return cfg def ensure_environment_dirs(conf_dir: Path) -> None: conf_dir = Path(conf_dir) conf_dir.mkdir(parents=True, exist_ok=True) for env in ENVIRONMENTS: try: (conf_dir / env).mkdir(parents=True, exist_ok=True) except Exception: pass def _default_base_cfg_text(env: str) -> str: env = normalize_environment(env) if env not in ENVIRONMENTS: env = "dev" # Keep defaults conservative and compatible with prior single-file defaults: # - non-test uses a stable DB namespace default # - test is isolated if env == "test": database_ns = "prole-test" service_ns = "prole-test" cluster_name = "prole-db" elif env == "dev": database_ns = "knoe-db" service_ns = "default" cluster_name = "knoe-db" else: database_ns = "knoe-db" service_ns = "knoe-system" cluster_name = "knoe-db" return ( "; Prole Master Configuration File\n" "; Generated by prole_conf\n\n" "[Global]\n" f"CLUSTER_ENV = {env}\n" f"DATABASE_NAMESPACE = {database_ns}\n" f"SERVICE_NAMESPACE = {service_ns}\n" f"CLUSTER_NAME = {cluster_name}\n" ) def _looks_like_generated_base_cfg(path: Path, env: str) -> bool: """True if `path` looks like a default prole_conf-generated base.""" path = Path(path) if not path.exists() or not path.is_file(): return False try: cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str cfg.read([str(path)]) if cfg.sections() != ["Global"]: return False return _env_from_mode_or_env_hint(cfg.get("Global", "CLUSTER_ENV", fallback="")) == env except Exception: return False def ensure_base_cfg(conf_dir: Path, env: str, *, create_if_missing: bool = True) -> Path: conf_dir = Path(conf_dir) env_key = normalize_environment(env) if env_key not in ENVIRONMENTS: raise ValueError(f"Unsupported environment: {env!r}") env_dir = conf_dir / env_key env_dir.mkdir(parents=True, exist_ok=True) base = env_dir / "prole.cfg" if not base.exists() and create_if_missing: base.write_text(_default_base_cfg_text(env_key), encoding="utf-8") return base def activate_environment( conf_dir: Path, env: str, *, create_base_if_missing: bool = True, migrate_legacy_entrypoint: bool = True, ) -> Path: """Activate `env` and return the env-specific prole.cfg path. Sets CLUSTER_ENV in the process environment so that subsequent entrypoint_path() calls resolve the correct env directory without needing a conf/prole.cfg symlink. This never overwrites another environment's curated config. """ conf_dir = Path(conf_dir) env_key = normalize_environment(env) if env_key not in ENVIRONMENTS: raise ValueError(f"Unsupported environment: {env!r}") ensure_environment_dirs(conf_dir) env_dir = conf_dir / env_key env_dir.mkdir(parents=True, exist_ok=True) base = env_dir / "prole.cfg" # Migrate any legacy plain-file entrypoint into the env-specific base. if migrate_legacy_entrypoint: legacy_entry = conf_dir / "prole.cfg" if legacy_entry.exists() and not legacy_entry.is_symlink(): try: legacy_text = legacy_entry.read_text(encoding="utf-8") if not base.exists() or _looks_like_generated_base_cfg(base, env_key) or not base.read_text( encoding="utf-8", errors="ignore" ).strip(): base.write_text(legacy_text, encoding="utf-8") legacy_name = f"prole.cfg.legacy.{int(time.time())}" legacy_entry.rename(conf_dir / legacy_name) except Exception: pass if not base.exists() and create_base_if_missing: base.write_text(_default_base_cfg_text(env_key), encoding="utf-8") if not base.exists(): raise FileNotFoundError(str(base)) # Persist the active environment in the process so entrypoint_path() resolves # conf/{env}/prole.cfg without a conf/prole.cfg symlink. os.environ["CLUSTER_ENV"] = env_key return base def ensure_entrypoint( conf_dir: Path, *, preferred_env: str | None = None, create_base_if_missing: bool = True, ) -> Path: """Ensure the active env-specific prole.cfg exists and return its path. - Creates missing env directories. - Infers the environment from preferred_env, env vars, or existing config. - If the env-specific base is missing, creates a default placeholder. Returns the resolved entrypoint path (conf/{env}/prole.cfg). """ conf_dir = Path(conf_dir) ensure_environment_dirs(conf_dir) env = _env_from_mode_or_env_hint(preferred_env) if not env: # Try to infer from the legacy plain-file entrypoint if it still exists. legacy = conf_dir / "prole.cfg" if legacy.exists() and not legacy.is_symlink(): env = _infer_env_from_cfg_file(legacy) if not env: env = "dev" return activate_environment( conf_dir, env, create_base_if_missing=create_base_if_missing, migrate_legacy_entrypoint=True, ) def active_environment(conf_dir: Path) -> str: """Return the currently active environment name. Resolves from CLUSTER_ENV/PROLE_MODE env vars first (set by activate_environment), then falls back to inferring from the entrypoint config file. """ conf_dir = Path(conf_dir) for var in ("CLUSTER_ENV", "PROLE_MODE", "DEPLOYMENT_MODE"): hint = os.environ.get(var, "").strip() if hint: env = _env_from_mode_or_env_hint(hint) if env in ENVIRONMENTS: return env entry = entrypoint_path(conf_dir) if not entry.exists(): return "" try: target = entry.resolve() env = normalize_environment(target.parent.name) if env in ENVIRONMENTS and _is_known_env_dir(conf_dir, target.parent) and target.name == "prole.cfg": return env except Exception: pass return ""