prole/knoe/prole_conf.py
chrisfu 4a8d9cc90d feat: full GKE/prod deployment pipeline from UI to Artifact Registry
## GCP / Cluster Environment Screen
- Auto-populate Cloud tab from conf/prod/gcp.cfg on screen open (org_id,
  billing_account, billing_project, project_id)
- gcloud auth validity checked on screen startup; friendly modal dialog
  streams gcloud auth login output live so user never leaves the app
- Live GKE cluster browser: fetches clusters via gcloud container clusters
  list, displays with checkmark selector, auto-selects saved cluster
- Selecting a cluster runs get-credentials, sets KUBECONFIG/KUBECONTEXT,
  and syncs the region dropdown to the selected cluster's location
- Region dropdown populated live from gcloud compute regions list with
  checkmark on currently selected region; graceful fallback when offline
- New 'GCP Storage' tab with workload->StorageClass mapping (CNPG->premium-rwo,
  Redis/Monitoring->standard-rwo, Garage->garage-hdd) and Fetch from Cluster
- Provider readonly field styled correctly (no solid-black on macOS)
- Stale prole.cfg/conf/prole.cfg symlinks removed; all config I/O now
  resolves env-specific paths via prole_conf.entrypoint_path()

## GKE Autopilot Compatibility (Common Services)
- Synology iSCSI StorageClass and static PVs guarded behind PROLE_MODE!=k8s
  in init_openbao.sh (GKE Autopilot forbids hostPath/iSCSI volumes)
- In-cluster Docker registry (hostPath) skipped in k8s mode; GCP Artifact
  Registry used instead
- Kong renamed knoe-svc-kong in k8s mode; all health-check kubectl calls in
  init_common_services.sh and status_common_services.sh updated accordingly
- DNS endpoints switched from *.prole.org to *.knoe.dev in k8s mode
  (api.knoe.dev, git.knoe.dev, svc.knoe.dev); ingress uses gce class
- New GKE-clean Kong manifests under deploy/opentofu/k8s/manifests/prole/:
  no k3s node affinity, explicit Autopilot resource requests/limits

## Garage S3 Store (GKE)
- New garage-statefulset-gcp.yaml targeting garage-hdd StorageClass
  (pd-standard, avoids SSD_TOTAL_GB quota exhaustion in us-west3)
- New storageclass-gcp-hdd.yaml (pd-standard, Retain, WaitForFirstConsumer)
- GCP StorageClass manifests skipped on re-runs (Autopilot built-ins are
  immutable; skip-if-exists guard added)
- PVC deletion guard extended to cover any storageClass (not just synology)
  so stale claims are cleaned before StatefulSet recreation

## Topology (GKE Autopilot)
- DaemonSet collector skipped in prod mode (forbidden in kube-system by
  GKE Warden); Kubernetes-only node facts path used instead
- All ready GKE nodes assumed cnpg-eligible and monitoring-eligible without
  taint/synology-mount checks (skip_collector + assume_nodes_eligible flags)

## KUBECONFIG / kubectl (k8s mode)
- actions.py: new elif mode==k8s branch sets KUBECONFIG=~/.kube/config
  and injects KUBECONTEXT from prole_cfg_data into script env
- _build_kubectl_cmd falls back to Global.KUBECONTEXT when
  init_cluster.selected_kubectx is empty
- _activate_selected_gke_cluster persists KUBECONFIG/KUBECONTEXT to
  prole_cfg_data and saves prole.cfg immediately after get-credentials

## Database Build Screen (GKE)
- Registry display shows correct Artifact Registry URL
  (<region>-docker.pkg.dev/<project>/<namespace>/knoe-db) in green
- Build+push: gcloud auth configure-docker, auto-creates AR repository
  named after SERVICE_NAMESPACE (e.g. knoe-system) if missing, then
  docker tag + push; falls back to gcr.io if region unavailable
- GCP config loaded from conf/prod/gcp.cfg on every screen entry;
  keys normalised to lowercase so project_id lookup is always consistent

## Config / Namespace persistence
- prole_conf.py activate_environment: symlink creation removed; sets
  CLUSTER_ENV env-var so all subsequent calls resolve correct env directory
- knoe/ui/screens/__init__.py: startup config load uses entrypoint_path()
  instead of hardcoded conf/prole.cfg; seeds SERVICE_NAMESPACE=knoe-system
  for managed envs so Common Services never defaults to 'default'
- cfg.py _save_prole_cfg: saves to env-specific path via entrypoint_path()
- etc/prole_cfg.sh: removed all ln -snf symlink creation

Co-authored-by: Junie <junie@jetbrains.com>
2026-04-04 12:38:16 -07:00

381 lines
12 KiB
Python

"""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
- `<env>/prole.cfg` -> base config for that environment
- `<env>/*.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/<env>/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 ""