"""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 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: """Safely activate `env` by updating `$PROLE_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" entry = entrypoint_path(conf_dir) # If entrypoint is a regular file, preserve it before switching to symlink-based layout. if entry.exists() and not entry.is_symlink() and migrate_legacy_entrypoint: try: legacy_text = 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(): # Seed/update the env base from the legacy entrypoint only if base is missing # or looks like a generated placeholder. base.write_text(legacy_text, encoding="utf-8") legacy_name = f"prole.cfg.legacy.{int(time.time())}" 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)) target = base tmp = conf_dir / f".prole.cfg.tmp.{os.getpid()}" try: if tmp.exists() or tmp.is_symlink(): tmp.unlink() except Exception: pass os.symlink(str(target), str(tmp)) os.replace(str(tmp), str(entry)) return entry def ensure_entrypoint( conf_dir: Path, *, preferred_env: str | None = None, create_base_if_missing: bool = True, ) -> Path: """Ensure `$PROLE_CONF/prole.cfg` is a valid entrypoint symlink. - Creates missing env directories. - If the entrypoint is a legacy regular file, migrates it into the inferred/desired env and preserves it as `prole.cfg.legacy.`. - If the entrypoint is missing or a broken symlink, creates/activates a base config. Returns the entrypoint path. """ conf_dir = Path(conf_dir) ensure_environment_dirs(conf_dir) entry = entrypoint_path(conf_dir) env = _env_from_mode_or_env_hint(preferred_env) if not env: if entry.exists() and not entry.is_symlink(): env = _infer_env_from_cfg_file(entry) if not env: env = "dev" if entry.is_symlink(): # Heal broken symlinks (target missing). try: _ = entry.resolve() except Exception: activate_environment(conf_dir, env, create_base_if_missing=create_base_if_missing, migrate_legacy_entrypoint=False) return entry if not entry.exists(): activate_environment(conf_dir, env, create_base_if_missing=create_base_if_missing, migrate_legacy_entrypoint=False) return entry return entry if entry.exists() and not entry.is_symlink(): activate_environment(conf_dir, env, create_base_if_missing=create_base_if_missing, migrate_legacy_entrypoint=True) return entry if not entry.exists(): activate_environment(conf_dir, env, create_base_if_missing=create_base_if_missing, migrate_legacy_entrypoint=False) return entry def active_environment(conf_dir: Path) -> str: """Return the environment name implied by the `$PROLE_CONF/prole.cfg` symlink.""" conf_dir = Path(conf_dir) entry = entrypoint_path(conf_dir) if not entry.exists() or not entry.is_symlink(): 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 ""