"""Knoe configuration environment management. This module centralizes how Knoe resolves configuration paths and how it loads configuration with per-environment layering. Target on-disk layout (under `$KNOE_CONF`): - `k3d.cfg` -> local development configuration - `k3s.cfg` -> k3s/service configuration - `gke.cfg` -> production/GKE configuration Legacy `knoe.cfg` and `conf//knoe.cfg` paths are still recognized for backward compatibility. """ from __future__ import annotations import configparser import os import time from pathlib import Path ENVIRONMENTS: tuple[str, ...] = ("dev", "service", "prod", "test") ENV_TO_CFG_FILE: dict[str, str] = { "dev": "k3d.cfg", "service": "k3s.cfg", "prod": "gke.cfg", "test": "test.cfg", } CFG_FILE_TO_ENV: dict[str, str] = {cfg: env for env, cfg in ENV_TO_CFG_FILE.items()} PREFERRED_CFG_ORDER: tuple[str, ...] = ("k3d.cfg", "k3s.cfg", "gke.cfg", "test.cfg") def cfg_file_for_env(env: str | None) -> str: env_key = normalize_environment(env) return ENV_TO_CFG_FILE.get(env_key, "") def env_for_cfg_file(name: str | None) -> str: if not name: return "" return CFG_FILE_TO_ENV.get(str(name).strip().lower(), "") 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 knoe.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-", "knoe-dev-")) or s == "knoe-dev-cluster": return "dev" if s in ("service", "services", "k3s", "knoe-service-cluster") or s.startswith("knoe-service-"): return "service" if s in ("prod", "production", "k8s", "knoe-prod-cluster") or s.startswith(("knoe-prod-", "gke_")): return "prod" if s in ("test", "testing"): return "test" return s def resolve_knoe_conf_dir(project_root: Path) -> Path: raw = os.environ.get("KNOE_CONF", "").strip() if raw: return Path(raw).expanduser() return project_root / "conf" def entrypoint_path(conf_dir: Path) -> Path: """Return the active configuration file path. Prefers named root config files (`k3d.cfg`, `k3s.cfg`, `gke.cfg`) resolved from CLUSTER_ENV/KNOE_MODE/DEPLOYMENT_MODE. Falls back to legacy env-dir `knoe.cfg` files and finally `conf/knoe.cfg` for compatibility. """ conf_dir = Path(conf_dir) # Determine active env from environment variables (set by the UI on save). for var in ("CLUSTER_ENV", "KNOE_MODE", "DEPLOYMENT_MODE"): hint = os.environ.get(var, "").strip() if hint: env = _env_from_mode_or_env_hint(hint) if env in ENVIRONMENTS: named = cfg_file_for_env(env) if named: named_path = conf_dir / named if named_path.exists(): return named_path env_path = conf_dir / env / "knoe.cfg" if env_path.exists(): return env_path for cfg_name in PREFERRED_CFG_ORDER: path = conf_dir / cfg_name if path.exists(): return path for env in ENVIRONMENTS: env_path = conf_dir / env / "knoe.cfg" if env_path.exists(): return env_path legacy = conf_dir / "knoe.cfg" if legacy.exists(): return legacy # Default target when creating a fresh config. return conf_dir / "k3d.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: - `$KNOE_CONF/{k3d.cfg|k3s.cfg|gke.cfg|test.cfg}` base files - legacy `$KNOE_CONF/knoe.cfg` entrypoint (symlink/plain) - legacy `$KNOE_CONF//knoe.cfg` base files """ cfg_path = Path(cfg_path) # New layout: named config directly in conf dir. env_from_name = env_for_cfg_file(cfg_path.name) if env_from_name: return cfg_path.parent, env_from_name, cfg_path.parent / env_from_name # If cfg_path is an entrypoint symlink, resolve its target. try: if cfg_path.is_symlink(): target = cfg_path.resolve() target_env_from_name = env_for_cfg_file(target.name) if target_env_from_name: return target.parent, target_env_from_name, target.parent / target_env_from_name env_dir = target.parent conf_dir = env_dir.parent env = normalize_environment(env_dir.name) expected = cfg_file_for_env(env) if env and _is_known_env_dir(conf_dir, env_dir) and target.name in {"knoe.cfg", expected}: 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) expected = cfg_file_for_env(env) if env and env in ENVIRONMENTS and cfg_path.name in {"knoe.cfg", expected} 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] = [] # New layout uses single named config files in conf dir (no overlays). if env_for_cfg_file(cfg_path.name): files.append(cfg_path) return files if env and _is_known_env_dir(conf_dir, env_dir): base_name = cfg_file_for_env(env) base = env_dir / base_name if base_name else env_dir / "knoe.cfg" if not base.exists(): base = env_dir / "knoe.cfg" if base.exists(): files.append(base) try: skip_names = { "prod.cfg", # standalone deploy config, not an overlay for knoe.cfg "gcp.cfg", # generated metadata file (key=value, no INI sections) } overrides = [ p for p in env_dir.iterdir() if p.is_file() and p.name != base.name and p.name not in skip_names and p.suffix == ".cfg" and not p.name.startswith(".") ] overrides.sort(key=lambda p: p.name) files.extend(overrides) except Exception: pass if files: 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 = "knoe-test" service_ns = "knoe-test" cluster_name = "knoe-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 ( "; Knoe Master Configuration File\n" "; Generated by knoe_conf\n\n" "[Global]\n" f"CLUSTER_ENV = {env}\n" f"NAMESPACE = {database_ns}\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 knoe_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}") # Primary location: conf/{env}/knoe.cfg (env-dir layout) env_dir = conf_dir / env_key env_dir.mkdir(parents=True, exist_ok=True) base = env_dir / "knoe.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 its named root config path. Sets CLUSTER_ENV in the process environment so that subsequent entrypoint_path() calls resolve the correct config without needing a conf/knoe.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) base = ensure_base_cfg(conf_dir, env_key, create_if_missing=create_base_if_missing) # Migrate any legacy plain-file entrypoint into the target base. if migrate_legacy_entrypoint: legacy_entry = conf_dir / "knoe.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"knoe.cfg.legacy.{int(time.time())}" legacy_entry.rename(conf_dir / legacy_name) except Exception: pass # Migrate legacy env-dir base into new named root config when helpful. legacy_env_base = conf_dir / env_key / "knoe.cfg" if legacy_env_base.exists() and legacy_env_base.is_file(): try: legacy_text = legacy_env_base.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") 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)) # Create / update conf/knoe.cfg as a symlink pointing to the active env base. entry = conf_dir / "knoe.cfg" try: if entry.exists() or entry.is_symlink(): if entry.is_symlink(): entry.unlink() elif entry.is_file(): # Back up any remaining plain file before replacing with symlink. legacy_name = f"knoe.cfg.legacy.{int(time.time())}" entry.rename(conf_dir / legacy_name) entry.symlink_to(base) except Exception: pass # Persist the active environment in the process so entrypoint_path() resolves # conf/{env}/knoe.cfg without a conf/knoe.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 named config 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 config path (e.g. conf/k3d.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 / "knoe.cfg" if legacy.exists() and not legacy.is_symlink(): env = _infer_env_from_cfg_file(legacy) if not env: for cfg_name in PREFERRED_CFG_ORDER: if (conf_dir / cfg_name).exists(): env = env_for_cfg_file(cfg_name) if env: break 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/KNOE_MODE env vars first (set by activate_environment), then falls back to inferring from the resolved config file path. """ conf_dir = Path(conf_dir) for var in ("CLUSTER_ENV", "KNOE_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 "" env = env_for_cfg_file(entry.name) if env in ENVIRONMENTS: return env try: target = entry.resolve() env = env_for_cfg_file(target.name) if env in ENVIRONMENTS: return env env = normalize_environment(target.parent.name) expected = cfg_file_for_env(env) if env in ENVIRONMENTS and _is_known_env_dir(conf_dir, target.parent) and target.name in {"knoe.cfg", expected}: return env except Exception: pass return ""