mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
knoe/knoe_conf.py: normalize_environment() now recognises the gke_ prefix (e.g. gke_plenary-truck-485623-p7_us-west3_knoe-dev-0) as the 'prod' environment, matching real GKE kubecontext naming. status.py: add _current_kubecontext() and _cfg_path_from_kubecontext() so status.py auto-selects the correct cfg file based on the active kubectl context without manual KNOE_CONF overrides. Co-authored-by: Junie <junie@jetbrains.com>
492 lines
16 KiB
Python
492 lines
16 KiB
Python
"""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/<env>/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/<env>/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 ""
|