prole/installer/core/env.py
chrisfu 5618b662dd Remove prole-db-manager; simplify deployment via prole-authority; fix pg18 downgrade & cluster name
Summary:
Removed the prole-db-manager microservice and simplified deployment to use
prole-authority as the internal management and authorization point. Fixed two
blocking bugs that prevented silent install from completing on knoe-dev-cluster.

Removed: prole-db-manager
- Deleted db-manager-deployment.yaml and db-manager-service.yaml from opentofu manifests
- Deleted src/db-manager/ (Dockerfile, server.js, package.json, tests)
- Removed prole-db-manager port-forward mapping from installer/core/env.py
- Removed init_db_manager.sh from Initialization Scripts (milestones.py, actions.py)
- Removed init_certmgr.sh and init_db_manager.sh tabs from services screen (services.py)
- Removed live k8s Deployment/Service from knoe-dev-cluster

Fixed: PostgreSQL version downgrade error (pg17 -> pg18)
- Created conf/postgresql/.version with value 18
- Updated k8s/prole/prole-db.yaml and prole-db-recovery.yaml.tpl imageName to prole-db:18-089
- Fixed _init_database_options_state() to restore saved version_type from prole.cfg
  so db_version_type defaults to v18 (pg18) instead of silently reverting to pg17
- Added database_options.* keys to _collect_input_snapshot() in cfg.py so
  distribution, version_type, and all extension toggles persist to prole.cfg

Fixed: Cluster name inconsistency
- Removed stale prole-dev-cluster references; all scripts now use knoe-dev-cluster
- Added knoe-dev-cluster to mode-detection case in etc/prole_cfg.sh

Config: conf/prole.cfg
- Set kerberos_config.enabled = False, KERBEROS_AUTO_ENABLED = False
- Added database_options.distribution = percona, version_type = v18
- Added all 13 extension flags set to True (postgis, pgvector, pgcrypto, pgaudit,
  pg_repack, pg_stat_statements, pg_buffercache, pg_freespacemap, pgrowlocks,
  postgres_fdw, dblink, pg_stat_monitor, pgbadger)

Verification:
./install.py -s -l -v -c conf/prole.cfg completed successfully.
CNPG deployed prole-db:18-089 to knoe-dev-cluster; all milestones passed.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-01 20:40:44 -08:00

2204 lines
69 KiB
Python

"""
Core environment/config helpers shared by installer UI and actions.
"""
from __future__ import annotations
import base64
import configparser
import getpass
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from installer import config as inst_config
from typing import Any
# Get the project root directory (installer/ui -> installer -> repo root)
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def _safe_str(val: Any) -> str:
"""Return a string representation of val, returning empty string if it's a MagicMock."""
if val is None:
return ""
if hasattr(val, "__class__") and "MagicMock" in val.__class__.__name__:
return ""
return str(val)
def get_resource_path(relative_path):
"""Get absolute path to resource, works for dev and for PyInstaller."""
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = Path(sys._MEIPASS)
except AttributeError:
# Running from source
base_path = PROJECT_ROOT
return base_path / relative_path
# Standard Console Theme (Light background, Dark text)
CONSOLE_BG = "#F5F5DC" # Cream/Light background similar to our images
CONSOLE_FG = "#1d1d1f" # Dark text
CONSOLE_INSERT = "#1d1d1f"
CONSOLE_FONT = ("Menlo", 10)
NAMESPACE_PREFIX = "prole-"
POSTGRES_DB_NAME_MAX_LEN = 63
DEFAULT_OLLAMA_PORT = "11434"
# Default action behavior for unattended replays
DEFAULT_ACTION_FLAGS = {
"dependencies.auto_install_missing": True,
"network_scan.run": True,
"init_password.generate_ssh_key": True,
"init_db_build.run_build": True,
"init_cluster.start_cluster": True,
"init_scripts.run_scripts": True,
"init_cnpg_deploy.run_deploy": True,
"init_cnpg_deploy.force_rollout": False,
"kerberos_config.test_connection": False,
"build.run_build": False,
}
# Secret handling (temporary encrypted values in prole.cfg)
PROLE_SECRET_PREFIX = "${PROLE_SECRET:"
PROLE_SECRET_SUFFIX = "}"
OPENBAO_PREFIX = "${OPENBAO:"
OPENBAO_SUFFIX = "}"
PROLE_SECRET_VERSION = "v1"
PROLE_SECRET_SERVICE = "prole-installer"
PROLE_SECRET_KEY_FILE = Path.home() / ".prole" / "secrets" / "installer.key"
# Map config keys to OpenBao paths (namespace injected at runtime)
SECRET_KEY_SPECS = {
("Inputs", "init_password.db_password"): ("db", "password"),
("Inputs", "init_password.db_password_confirm"): ("db", "password"),
("Inputs", "kerberos_config.password"): ("kerberos", "password"),
("Global", "DB_PASSWORD"): ("db", "password"),
("Kerberos Authentication", "PASSWORD"): ("kerberos", "password"),
("Monitoring", "GRAFANA_ADMIN_PASSWORD"): ("monitoring", "grafana_admin_password"),
}
def _is_prole_secret(value: str | None) -> bool:
return (
bool(value)
and value.startswith(PROLE_SECRET_PREFIX)
and value.endswith(PROLE_SECRET_SUFFIX)
)
def _is_openbao_ref(value: str | None) -> bool:
return (
bool(value)
and value.startswith(OPENBAO_PREFIX)
and value.endswith(OPENBAO_SUFFIX)
)
def _encrypt_cfg_secret(plaintext: str | None) -> str:
if not plaintext:
return ""
if _is_prole_secret(plaintext) or _is_openbao_ref(plaintext):
return plaintext
try:
return _encrypt_prole_secret(plaintext)
except Exception:
return str(plaintext)
def _get_secret_key_file() -> Path:
return PROLE_SECRET_KEY_FILE
def _get_keychain_key(service: str, account: str) -> bytes:
try:
res = subprocess.run(
["security", "find-generic-password", "-a", account, "-s", service, "-w"],
capture_output=True,
text=True,
)
if res.returncode == 0 and res.stdout.strip():
return base64.urlsafe_b64decode(res.stdout.strip().encode("utf-8"))
except Exception:
pass
key_b64 = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8")
try:
subprocess.run(
[
"security",
"add-generic-password",
"-a",
account,
"-s",
service,
"-w",
key_b64,
"-U",
],
capture_output=True,
text=True,
)
except Exception:
pass
return base64.urlsafe_b64decode(key_b64.encode("utf-8"))
def _get_file_key(path: Path) -> bytes:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
raw = path.read_text().strip()
try:
return base64.urlsafe_b64decode(raw.encode("utf-8"))
except Exception:
pass
key_b64 = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8")
path.write_text(key_b64)
try:
os.chmod(path, 0o600)
except Exception:
pass
return base64.urlsafe_b64decode(key_b64.encode("utf-8"))
def _get_secret_key() -> bytes:
system = platform.system()
account = getpass.getuser() or "prole"
if system == "Darwin":
return _get_keychain_key(PROLE_SECRET_SERVICE, account)
return _get_file_key(_get_secret_key_file())
def _encrypt_prole_secret(plaintext: str) -> str:
if plaintext is None:
return ""
if _is_prole_secret(plaintext):
return plaintext
key = _get_secret_key()
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
nonce_b64 = base64.urlsafe_b64encode(nonce).decode("utf-8")
ct_b64 = base64.urlsafe_b64encode(ciphertext).decode("utf-8")
return f"{PROLE_SECRET_PREFIX}{PROLE_SECRET_VERSION}:{nonce_b64}:{ct_b64}{PROLE_SECRET_SUFFIX}"
def _decrypt_prole_secret(value: str) -> str:
if not _is_prole_secret(value):
return value
inner = value[len(PROLE_SECRET_PREFIX) : -len(PROLE_SECRET_SUFFIX)]
parts = inner.split(":")
if len(parts) != 3 or parts[0] != PROLE_SECRET_VERSION:
return value
try:
nonce = base64.urlsafe_b64decode(parts[1].encode("utf-8"))
ciphertext = base64.urlsafe_b64decode(parts[2].encode("utf-8"))
key = _get_secret_key()
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext, None).decode("utf-8")
except Exception:
return value
def _normalize_k3s_token(value: str | None) -> str:
token = (value or "").strip()
if not token:
return ""
if _is_openbao_ref(token):
return ""
if _is_prole_secret(token):
try:
token = _decrypt_prole_secret(token)
except Exception:
return ""
if _is_prole_secret(token):
return ""
return token
def _read_k3s_cfg(cfg_path: "Path | str | None" = None) -> tuple[str, str, str]:
"""Return (cluster_env, server_url, token) from prole.cfg.
Searches Global, Initialize Cluster, and Service Cluster (k3s) sections
in the same priority order used by both the Tk UI and the silent
installer so that all presentation layers agree.
This is a module-level function so it can be called without needing a
``ProleInstallerBase`` instance (e.g. from the ncurses UI).
"""
if cfg_path is None:
prole_conf = os.environ.get("PROLE_CONF")
if prole_conf:
cfg_path = Path(prole_conf) / "prole.cfg"
else:
cfg_path = PROJECT_ROOT / "conf" / "prole.cfg"
cfg_path = Path(cfg_path)
if not cfg_path.exists():
return "", "", ""
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
try:
cfg.read(cfg_path)
except Exception:
return "", "", ""
cfg_vars = _collect_cfg_vars(cfg)
env_val = ""
server_val = ""
token_val = ""
if cfg.has_section("Global"):
env_val = _expand_cfg_value(
cfg["Global"].get("CLUSTER_ENV", env_val), cfg_vars
).strip()
server_val = _expand_cfg_value(
cfg["Global"].get("PROLE_K3S_SERVER", server_val), cfg_vars
).strip()
if not server_val:
server_val = _expand_cfg_value(
cfg["Global"].get("K3S_SERVER_URL", server_val), cfg_vars
).strip()
token_val = _expand_cfg_value(
cfg["Global"].get("PROLE_K3S_TOKEN", token_val), cfg_vars
).strip()
if not token_val:
token_val = _expand_cfg_value(
cfg["Global"].get("K3S_TOKEN", token_val), cfg_vars
).strip()
if cfg.has_section("Initialize Cluster"):
server_val = _expand_cfg_value(
cfg["Initialize Cluster"].get("K3S_SERVER_URL", server_val), cfg_vars
).strip()
token_val = _expand_cfg_value(
cfg["Initialize Cluster"].get("K3S_TOKEN", token_val), cfg_vars
).strip()
if cfg.has_section("Service Cluster (k3s)"):
server_val = _expand_cfg_value(
cfg["Service Cluster (k3s)"].get("K3S_SERVER_URL", server_val), cfg_vars
).strip()
token_val = _expand_cfg_value(
cfg["Service Cluster (k3s)"].get("K3S_TOKEN", token_val), cfg_vars
).strip()
return env_val, server_val, token_val
def _resolve_k3s_connection(
project_root: "Path | None" = None,
cfg_path: "Path | str | None" = None,
ansible_topology: dict | None = None,
) -> tuple[str, str]:
"""Resolve k3s server URL and token from all sources.
Priority order (first non-empty wins for each field):
1. Environment variables (PROLE_K3S_SERVER / PROLE_K3S_TOKEN)
2. prole.cfg values
3. Ansible topology auto-detection
Returns ``(server_url, token)`` with the server URL normalised to
include an ``https://`` scheme.
This is a module-level function so it can be called from any
presentation layer (Tk, ncurses, silent).
"""
server = (
os.environ.get("PROLE_K3S_SERVER") or os.environ.get("K3S_SERVER_URL") or ""
).strip()
token = (
os.environ.get("PROLE_K3S_TOKEN") or os.environ.get("K3S_TOKEN") or ""
).strip()
token = _normalize_k3s_token(token)
# prole.cfg fallback
_cfg_env, cfg_server, cfg_token = _read_k3s_cfg(cfg_path)
if not server and cfg_server:
server = cfg_server
if not token and cfg_token:
token = _normalize_k3s_token(cfg_token)
# Ansible topology fallback
if not ansible_topology:
try:
root = project_root or PROJECT_ROOT
ansible_topology = _detect_ansible_topology(root)
except Exception:
ansible_topology = None
if ansible_topology:
if not server and ansible_topology.get("k3s_server_url"):
server = ansible_topology["k3s_server_url"]
if not token and ansible_topology.get("k3s_token"):
token = _normalize_k3s_token(ansible_topology["k3s_token"])
if server and not server.startswith("http"):
server = f"https://{server}"
return server, token
def _kubectl_base_cmd_for_k3s(
managed_kubeconfig: str | None = None, project_root: "Path | None" = None
) -> list[str]:
"""Build a kubectl command prefix suitable for the k3s service cluster.
Prefers an existing kubeconfig file (e.g. Ansible-fetched with
client-certificate auth) over token-based auth.
"""
if managed_kubeconfig and os.path.exists(managed_kubeconfig):
return ["kubectl", "--kubeconfig", managed_kubeconfig]
kubeconfig = _find_kubeconfig_file()
if kubeconfig:
return ["kubectl", "--kubeconfig", kubeconfig]
server, token = _resolve_k3s_connection(project_root=project_root)
if server and token and _looks_like_k8s_bearer_token(token):
return [
"kubectl",
"--server=" + server,
"--token=" + token,
"--insecure-skip-tls-verify=true",
]
return ["kubectl"]
def _verify_k3s_services_status(
project_root: "Path | None" = None, managed_kubeconfig: str | None = None
) -> dict[str, str]:
"""Check registry, OpenBao and OpenTofu on a remote k3s cluster.
Returns a dict with keys ``registry``, ``openbao``, ``opentofu``
mapped to status strings (``Good``, ``Failing``, ``Error``, …).
"""
status = {"registry": "Unknown", "openbao": "Unknown", "opentofu": "Unknown"}
server, token = _resolve_k3s_connection(project_root=project_root)
if not token:
return {k: "Missing token" for k in status}
base_cmd = _kubectl_base_cmd_for_k3s(
managed_kubeconfig=managed_kubeconfig, project_root=project_root
)
try:
res = subprocess.run(
base_cmd + ["get", "service", "-A"],
capture_output=True,
text=True,
timeout=15,
)
out = res.stdout.lower() if res.stdout else ""
status["registry"] = "Good" if "registry" in out else "Failing"
status["openbao"] = "Good" if ("openbao" in out or "bao" in out) else "Failing"
status["opentofu"] = "Good" if "opentofu" in out else "Failing"
except Exception:
return {k: "Error" for k in status}
return status
def _looks_like_k8s_bearer_token(token: str | None) -> bool:
tok = (token or "").strip()
if not tok:
return False
# K3s node tokens include '::' and are not valid API bearer tokens.
if "::" in tok:
return False
# JWT-style tokens contain two dots.
if tok.count(".") >= 2:
return True
# Bootstrap tokens are usually id.secret with a dot separator.
if tok.count(".") == 1:
token_id, token_secret = tok.split(".", 1)
if len(token_id) >= 6 and len(token_secret) >= 16:
return True
return False
def _parse_ollama_host(
value: str, default_port: str = DEFAULT_OLLAMA_PORT
) -> tuple[str, str]:
raw = (value or "").strip()
if not raw:
return "", ""
if "://" not in raw:
raw = f"http://{raw}"
try:
parsed = urllib.parse.urlparse(raw)
except Exception:
return "", ""
host = parsed.hostname or ""
port = str(parsed.port) if parsed.port else ""
if not port and default_port:
port = default_port
return host, port
def _format_ollama_host(host: str, port: str) -> str:
raw_host = (host or "").strip()
if not raw_host:
return ""
scheme = "https" if raw_host.startswith("https://") else "http"
parsed_host, parsed_port = _parse_ollama_host(raw_host, default_port="")
host_val = parsed_host or raw_host
port_val = parsed_port or (port or "").strip()
if port_val:
return f"{scheme}://{host_val}:{port_val}"
return f"{scheme}://{host_val}"
def _find_kubeconfig_file(env: dict | None = None) -> str:
env = env or os.environ
candidates = []
kubeconfig_env = (env.get("KUBECONFIG") or "").strip()
if kubeconfig_env:
candidates.append(kubeconfig_env)
# Prefer standard system kubeconfig as the primary source of truth
candidates.append(str(Path.home() / ".kube" / "config"))
prole_service = (env.get("PROLE_SERVICE") or "").strip()
if prole_service:
candidates.append(str(Path(prole_service) / "secrets" / "k3s.kubeconfig"))
for key in ("PROLE_K3S_KUBECONFIG", "PROLE_KUBECONFIG"):
val = (env.get(key) or "").strip()
if val:
candidates.append(val)
prole_home = (env.get("PROLE_HOME") or "").strip()
if prole_home:
candidates.append(str(Path(prole_home) / "prole-k3s.kubeconfig"))
candidates.append(str(Path(prole_home) / "secrets" / "k3s.kubeconfig"))
# Other fallback locations
candidates.append(str(Path.home() / ".prole" / "secrets" / "k3s.kubeconfig"))
candidates.append(str(PROJECT_ROOT / "etc" / "secrets" / "k3s.kubeconfig"))
candidates.append(str(PROJECT_ROOT / "prole-k3s.kubeconfig"))
candidates.append("/etc/rancher/k3s/prole-kubeconfig.yaml")
candidates.append("/etc/rancher/k3s/k3s.yaml")
for path in candidates:
try:
if path and Path(path).expanduser().exists():
return path
except Exception:
continue
return ""
def _k3d_prole_data_volume_args(prole_data: str | None) -> list[str]:
path = (prole_data or "").strip()
if not path:
return []
try:
p = Path(path).expanduser()
except Exception:
return []
try:
p.mkdir(parents=True, exist_ok=True)
except Exception:
pass
return ["--volume", f"{p}:/var/lib/rancher/k3s/storage@all"]
def _ensure_ansible_vault_credentials(prompt_ui: bool = False, root=None) -> None:
password_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip()
password = (os.environ.get("ANSIBLE_VAULT_PASSWORD") or "").strip()
if password_file:
try:
if not Path(password_file).expanduser().is_file():
password_file = ""
os.environ.pop("ANSIBLE_VAULT_PASSWORD_FILE", None)
except Exception:
password_file = ""
os.environ.pop("ANSIBLE_VAULT_PASSWORD_FILE", None)
if password or password_file:
return
for base in (Path.cwd(), PROJECT_ROOT):
try:
candidate = base / ".vault_pass"
if candidate.is_file():
os.environ["ANSIBLE_VAULT_PASSWORD_FILE"] = str(candidate)
print(f"[INFO] Using Ansible Vault password file: {candidate}")
return
except Exception:
continue
vault_password = ""
if prompt_ui and root is not None:
try:
from tkinter import simpledialog
vault_password = (
simpledialog.askstring(
"Ansible Vault",
"Enter Ansible Vault password:",
show="*",
parent=root,
)
or ""
)
except Exception:
vault_password = ""
if not vault_password:
try:
vault_password = getpass.getpass("Ansible Vault password: ")
except Exception:
vault_password = ""
if vault_password:
os.environ["ANSIBLE_VAULT_PASSWORD"] = vault_password
def _openbao_placeholder(namespace: str, leaf: str, key: str | None = None) -> str:
ns = (namespace or "").strip() or "default"
path = f"kv/prole/{ns}/{leaf}"
if key:
return f"{OPENBAO_PREFIX}{path}#{key}{OPENBAO_SUFFIX}"
return f"{OPENBAO_PREFIX}{path}{OPENBAO_SUFFIX}"
def _bool_str(val: bool) -> str:
return "true" if bool(val) else "false"
def _parse_bool(val, default=False) -> bool:
if val is None:
return default
s = str(val).strip().lower()
if s in ("1", "true", "yes", "y", "on"):
return True
if s in ("0", "false", "no", "n", "off"):
return False
return default
def _local_registry_enabled(mode_hint: str | None = None) -> bool:
raw = (
os.environ.get("PROLE_ENABLE_LOCAL_REGISTRY")
or os.environ.get("ENABLE_LOCAL_REGISTRY")
or ""
).strip()
if raw:
return _parse_bool(raw, False)
if mode_hint:
mode = _deployment_mode_from_env(mode_hint)
else:
mode_hint = (
os.environ.get("PROLE_MODE")
or os.environ.get("DEPLOYMENT_MODE")
or os.environ.get("CLUSTER_ENV")
or ""
)
mode = _deployment_mode_from_env(mode_hint)
return mode in ("k3d", "k3s")
def _expand_path(val: str | None) -> str:
if val is None:
return ""
return os.path.expandvars(os.path.expanduser(str(val)))
_CFG_VAR_PATTERN = re.compile(r"\$(\w+)|\$\{(\w+)\}")
def _expand_cfg_value(
val: str | None,
cfg_vars: dict[str, str] | None = None,
env: dict[str, str] | None = None,
max_depth: int = 5,
) -> str:
if val is None:
return ""
raw = str(val)
if _is_openbao_ref(raw) or _is_prole_secret(raw):
return raw
cfg_vars = cfg_vars or {}
env = env or os.environ
def repl(match):
var = match.group(1) or match.group(2)
if var in cfg_vars and cfg_vars[var] is not None:
return str(cfg_vars[var])
if var in env and env[var] is not None:
return str(env[var])
return match.group(0)
out = raw
for _ in range(max_depth):
new = _CFG_VAR_PATTERN.sub(repl, out)
if new == out:
break
out = new
return out
def _collect_cfg_vars(cfg: configparser.ConfigParser) -> dict[str, str]:
cfg_vars: dict[str, str] = {}
for section in cfg.sections():
for k, v in cfg.items(section):
if k in cfg_vars:
continue
cfg_vars[k] = _expand_cfg_value(v, cfg_vars)
return cfg_vars
def _resolve_supabase_home(project_root: Path) -> Path | None:
env_home = (os.environ.get("SUPABASE_HOME") or "").strip()
if env_home:
try:
candidate = Path(_expand_path(env_home))
if candidate.is_dir():
return candidate
except Exception:
pass
candidates = [
Path.home() / "prole" / "supabase",
Path.home() / "dev" / "supabase",
project_root / "supabase",
]
prole_home = (os.environ.get("PROLE_HOME") or "").strip()
if prole_home:
try:
candidates.append(Path(_expand_path(prole_home)).parent / "supabase")
except Exception:
pass
for candidate in candidates:
try:
if candidate.is_dir():
return candidate
except Exception:
continue
return None
def _collect_images_from_files(paths: list[Path]) -> set[str]:
images = set()
for path in paths:
if not path or not path.exists():
continue
try:
for line in path.read_text().splitlines():
m = re.match(r"^\s*image:\s*([^\s#]+)", line)
if not m:
continue
img = m.group(1).strip().strip('"').strip("'")
if not img:
continue
img = os.path.expandvars(img)
if "${" in img or "}" in img or "$" in img:
continue
images.add(img)
except Exception:
continue
return images
def _clean_yaml_value(raw: str) -> str:
if raw is None:
return ""
val = raw.strip()
if "#" in val:
val = val.split("#", 1)[0].strip()
if len(val) >= 2 and ((val[0] == val[-1]) and val.startswith(("'", '"'))):
val = val[1:-1]
return val.strip()
def _parse_yaml_scalar_values(path: Path, keys: set[str]) -> dict:
data = {}
if not path.exists():
return data
try:
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or line == "---":
continue
if line.startswith("- "):
continue
if ":" not in line:
continue
key, val = line.split(":", 1)
key = key.strip()
if key not in keys:
continue
clean_val = _clean_yaml_value(val)
if clean_val:
data[key] = clean_val
except Exception:
pass
return data
def _parse_internal_a_records(path: Path) -> tuple[str, dict, dict]:
domain = ""
ip_map: dict[str, str] = {}
fqdn_map: dict[str, str] = {}
if not path.exists():
return domain, ip_map, fqdn_map
try:
lines = path.read_text().splitlines()
except Exception:
return domain, ip_map, fqdn_map
in_block = False
current: dict[str, str] = {}
records = []
for raw in lines:
if not in_block:
s = raw.strip()
if s.startswith("prole_domain:"):
domain = _clean_yaml_value(s.split(":", 1)[1])
if s.startswith("prole_internal_a_records:"):
in_block = True
continue
if raw and not raw.startswith((" ", "\t")):
break
s = raw.strip()
if not s:
continue
if s.startswith("- "):
if current:
records.append(current)
current = {}
s = s[2:].strip()
if ":" in s:
key, val = s.split(":", 1)
key = key.strip()
if key in ("fqdn", "ipv4"):
current[key] = _clean_yaml_value(val)
if current:
records.append(current)
for rec in records:
fqdn = rec.get("fqdn")
ip = rec.get("ipv4")
if not fqdn or not ip:
continue
fqdn_map[fqdn] = ip
ip_map[fqdn] = ip
short = fqdn.split(".")[0]
if short and short not in ip_map:
ip_map[short] = ip
return domain, ip_map, fqdn_map
def _parse_ansible_inventory_hosts(path: Path) -> dict:
groups: dict[str, list[str]] = {}
if not path.exists():
return groups
try:
current_group = None
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith(("#", ";")):
continue
if line.startswith("[") and line.endswith("]"):
current_group = line[1:-1].strip()
groups.setdefault(current_group, [])
continue
if current_group is None:
continue
host = line.split()[0]
if host and host not in groups[current_group]:
groups[current_group].append(host)
except Exception:
pass
return groups
def _resolve_host_ip(host: str, domain: str, ip_map: dict) -> str:
if not host:
return ""
if host in ip_map:
return ip_map[host]
if domain:
if "." not in host:
fqdn = f"{host}.{domain}"
if fqdn in ip_map:
return ip_map[fqdn]
else:
short = host.split(".")[0]
if short in ip_map:
return ip_map[short]
return ""
def _normalize_cluster_env(env: str | None) -> str:
if not env:
return ""
s = str(env).strip().lower()
if (
s in ("dev", "k3d", "k3d-dev", "k3d-knoe-dev-cluster", "knoe-dev-cluster")
or s.startswith("k3d-")
or s.startswith("prole-dev-")
):
return "dev"
if s in ("service", "k3s", "k3s-service", "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"
return s
def _cluster_env_radio_value(env: str | None) -> str:
key = _normalize_cluster_env(env)
if key == "dev":
return "dev"
if key == "service":
return "service"
if key == "prod":
return "prod"
return env or ""
def _deployment_target_label(env: str | None) -> str:
key = _normalize_cluster_env(env)
if key == "dev":
return "knoe-dev-cluster"
if key == "service":
return "prole-service-cluster"
if key == "prod":
return "prole-prod-cluster"
return env or ""
def _deployment_mode_from_env(env: str | None) -> str:
key = _normalize_cluster_env(env)
if key == "dev":
return "k3d"
if key == "service":
return "k3s"
if key == "prod":
return "k8s"
return ""
def _extract_yaml_scalar_from_text(text: str, key: str) -> str:
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith(f"{key}:"):
return _clean_yaml_value(line.split(":", 1)[1])
return ""
def _extract_inline_vault_block(text: str, key: str) -> str:
lines = text.splitlines()
for idx, raw in enumerate(lines):
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith(f"{key}:") and "!vault" in stripped:
base_indent = len(raw) - len(raw.lstrip())
block = []
i = idx + 1
while i < len(lines):
line = lines[i]
if not line.strip():
i += 1
continue
indent = len(line) - len(line.lstrip())
if indent <= base_indent:
break
block.append(line.strip())
i += 1
if block and block[0].startswith("$ANSIBLE_VAULT"):
return "\n".join(block) + "\n"
return ""
return ""
def _try_read_ansible_vault_value(vault_path: Path, key: str) -> str:
if not vault_path.exists():
return ""
# First, try plain YAML parsing (in case the file is not encrypted).
try:
plain = _parse_yaml_scalar_values(vault_path, {key}).get(key, "")
except Exception:
plain = ""
if (
plain
and not plain.lower().startswith("!vault")
and not plain.startswith("$ANSIBLE_VAULT")
):
return plain
password_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip()
password = (os.environ.get("ANSIBLE_VAULT_PASSWORD") or "").strip()
if password_file:
try:
if not Path(password_file).expanduser().exists():
password_file = ""
except Exception:
password_file = ""
if not password_file and not password:
for base in (Path.cwd(), PROJECT_ROOT):
try:
candidate = base / ".vault_pass"
if candidate.is_file():
password_file = str(candidate)
os.environ["ANSIBLE_VAULT_PASSWORD_FILE"] = password_file
break
except Exception:
continue
if not password_file and not password:
return ""
if shutil.which("ansible-vault") is None:
return ""
tmp_path = None
if password_file:
password_file = password_file
elif password:
try:
tmp = tempfile.NamedTemporaryFile(delete=False)
tmp.write(password.encode("utf-8"))
tmp.flush()
tmp.close()
tmp_path = tmp.name
password_file = tmp_path
except Exception:
tmp_path = None
return ""
def _vault_view(path: str) -> str:
res = subprocess.run(
["ansible-vault", "view", path]
+ (["--vault-password-file", password_file] if password_file else []),
capture_output=True,
text=True,
timeout=5,
env=os.environ.copy(),
stdin=subprocess.DEVNULL,
)
if res.returncode != 0:
return ""
return res.stdout or ""
try:
output = _vault_view(str(vault_path))
if output:
return _extract_yaml_scalar_from_text(output, key)
# Inline vault: extract the block and decrypt separately.
try:
raw_text = vault_path.read_text()
except Exception:
raw_text = ""
inline_block = _extract_inline_vault_block(raw_text, key)
if not inline_block:
return ""
tmp_inline = tempfile.NamedTemporaryFile(delete=False)
tmp_inline.write(inline_block.encode("utf-8"))
tmp_inline.flush()
tmp_inline.close()
output = _vault_view(tmp_inline.name)
try:
os.unlink(tmp_inline.name)
except Exception:
pass
return output.strip()
except Exception:
return ""
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except Exception:
pass
def _detect_ansible_k3s_settings(
inventory_path: Path, groups: dict, domain: str, ip_map: dict
) -> dict:
k3s_hosts = groups.get("k3s_hosts") or []
server_url = ""
server_host = ""
for host in k3s_hosts:
host_vars_path = inventory_path / "host_vars" / f"{host}.yml"
vals = _parse_yaml_scalar_values(
host_vars_path, {"k3s_server_url", "k3s_cluster_init", "k3s_role"}
)
if not server_url and vals.get("k3s_server_url"):
server_url = vals["k3s_server_url"]
if not server_host:
if (
_parse_bool(vals.get("k3s_cluster_init"), False)
or vals.get("k3s_role") == "server"
):
server_host = host
if not server_url and server_host:
host_for_url = server_host
if domain and "." not in host_for_url:
host_for_url = f"{host_for_url}.{domain}"
server_url = f"https://{host_for_url}:6443"
vault_path = inventory_path / "group_vars" / "all" / "vault_k3s.yml"
token = _try_read_ansible_vault_value(vault_path, "vault_k3s_token")
return {
"server_url": server_url,
"server_host": server_host,
"token": token,
"vault_path": str(vault_path) if vault_path.exists() else "",
}
def _detect_ansible_topology(project_root: Path) -> dict:
prole_home = (os.environ.get("PROLE_HOME") or "").strip()
base_candidates = []
if prole_home:
base_candidates.append(Path(_expand_path(prole_home)))
base_candidates.append(project_root)
infra_path = None
for base in base_candidates:
try:
candidate = base / "infrastructure"
if candidate.is_dir():
infra_path = candidate
break
except Exception:
continue
if infra_path is None:
return {}
inventory_path = infra_path / "inventory"
if not inventory_path.is_dir():
return {}
groups = _parse_ansible_inventory_hosts(inventory_path / "hosts.ini")
vars_vals = _parse_yaml_scalar_values(
inventory_path / "group_vars" / "all" / "vars.yml",
{
"ad_dc_ip",
"prole_domain",
"kerberos_realm",
"krb5_realm",
"kerberos_kdc",
"krb5_kdc",
"kerberos_kdc_ip",
},
)
domain = vars_vals.get("prole_domain", "")
ad_dc_ip = vars_vals.get("ad_dc_ip", "")
explicit_realm = (
vars_vals.get("kerberos_realm") or vars_vals.get("krb5_realm") or ""
)
explicit_kdc = (
vars_vals.get("kerberos_kdc")
or vars_vals.get("krb5_kdc")
or vars_vals.get("kerberos_kdc_ip")
or ""
)
if explicit_kdc and not ad_dc_ip:
ad_dc_ip = explicit_kdc
dns_domain, ip_map, fqdn_records = _parse_internal_a_records(
inventory_path / "group_vars" / "all" / "dns.yml"
)
if not domain and dns_domain:
domain = dns_domain
ad_vars = _parse_yaml_scalar_values(
inventory_path / "group_vars" / "ad_dc" / "vars.yml", {"samba_dns_server"}
)
samba_dns_server = ad_vars.get("samba_dns_server", "")
ad_dc_host = ""
ad_dc_hosts = groups.get("ad_dc") or []
if ad_dc_hosts:
ad_dc_host = ad_dc_hosts[0]
if samba_dns_server:
ad_dc_host = ad_dc_host or samba_dns_server
if not ad_dc_ip:
ad_dc_ip = _resolve_host_ip(samba_dns_server, domain, ip_map)
if not ad_dc_ip and ad_dc_host:
ad_dc_ip = _resolve_host_ip(ad_dc_host, domain, ip_map)
kdc_ip = explicit_kdc or ad_dc_ip or ""
realm = explicit_realm or (domain.upper() if domain else "")
all_hosts = set()
for hosts in groups.values():
all_hosts.update(hosts)
if ad_dc_host:
all_hosts.add(ad_dc_host)
host_ip_map = {}
unmapped_hosts = []
for host in sorted(all_hosts):
ip = _resolve_host_ip(host, domain, ip_map)
if ip:
host_ip_map[host] = ip
else:
unmapped_hosts.append(host)
k3s_info = _detect_ansible_k3s_settings(inventory_path, groups, domain, ip_map)
k3s_server_url = k3s_info.get("server_url") or ""
k3s_server_host = k3s_info.get("server_host") or ""
k3s_token = k3s_info.get("token") or ""
k3s_vault_path = k3s_info.get("vault_path") or ""
topology = {
"domain": domain,
"realm": realm,
"internal_records": fqdn_records,
"ad_dc": {
"host": ad_dc_host,
"ip": ad_dc_ip,
},
"k3s": {
"server_url": k3s_server_url,
"server_host": k3s_server_host,
"token_present": bool(k3s_token),
},
"groups": groups,
"hosts": host_ip_map,
"unmapped_hosts": unmapped_hosts,
}
try:
topology_json = json.dumps(topology, separators=(",", ":"))
except Exception:
topology_json = ""
return {
"infrastructure_path": str(infra_path),
"inventory_path": str(inventory_path),
"domain": domain,
"realm": realm,
"ad_dc_ip": ad_dc_ip,
"ad_dc_host": ad_dc_host,
"kdc_ip": kdc_ip,
"k3s_server_url": k3s_server_url,
"k3s_server_host": k3s_server_host,
"k3s_token": k3s_token,
"k3s_vault_path": k3s_vault_path,
"groups": groups,
"hosts": host_ip_map,
"unmapped_hosts": unmapped_hosts,
"topology": topology,
"topology_json": topology_json,
}
def _format_ansible_topology_summary(info: dict) -> str:
if not info:
return ""
lines = []
inv = info.get("inventory_path") or ""
if inv:
lines.append(f"Ansible inventory detected at {inv}.")
domain = info.get("domain") or ""
realm = info.get("realm") or ""
if domain or realm:
if domain and realm:
lines.append(f"Domain: {domain} (Realm: {realm})")
elif domain:
lines.append(f"Domain: {domain}")
else:
lines.append(f"Realm: {realm}")
ad_dc_host = info.get("ad_dc_host") or ""
ad_dc_ip = info.get("ad_dc_ip") or ""
if ad_dc_host or ad_dc_ip:
if ad_dc_host and ad_dc_ip:
lines.append(f"AD DC: {ad_dc_host} -> {ad_dc_ip}")
elif ad_dc_ip:
lines.append(f"AD DC IP: {ad_dc_ip}")
else:
lines.append(f"AD DC Host: {ad_dc_host}")
groups = info.get("groups") or {}
if groups:
group_bits = []
for name in sorted(groups.keys()):
group_bits.append(f"{name}({len(groups[name])})")
lines.append("Groups: " + ", ".join(group_bits))
hosts = info.get("hosts") or {}
if hosts:
host_items = sorted(hosts.items())
preview = host_items[:8]
host_str = ", ".join([f"{h}={ip}" for h, ip in preview])
if len(host_items) > len(preview):
host_str += f", +{len(host_items) - len(preview)} more"
lines.append("Hosts: " + host_str)
unmapped = info.get("unmapped_hosts") or []
if unmapped:
lines.append(f"Hosts without IPs: {len(unmapped)}")
return "\n".join(lines)
def _default_opentofu_pipeline_url() -> str:
url = (
os.environ.get("PROLE_OPENTOFU_URL") or os.environ.get("OPENTOFU_URL") or ""
).strip()
return url or "http://127.0.0.1:8080"
def _push_docker_image(image_tag: str, log_fn=None) -> bool:
"""Push Docker image with skopeo fallback for insecure registries."""
def _log(msg):
if log_fn:
try:
log_fn(msg)
except:
print(msg, end="")
else:
print(msg, end="", flush=True)
_log(f"Pushing image {image_tag} ...\n")
# 1. Try standard docker push
res = subprocess.run(["docker", "push", image_tag], capture_output=True, text=True)
if res.returncode == 0:
_log(f"[OK] Pushed {image_tag}\n")
return True
_log(
f"[WARN] Docker push failed: {res.stderr.strip() if res.stderr else 'unknown error'}\n"
)
# 2. Try skopeo fallback for insecure registry
skopeo = shutil.which("skopeo")
if skopeo:
_log("Retrying with skopeo (insecure registry) ...\n")
# skopeo copy --dest-tls-verify=false docker-daemon:TAG docker://TAG
cmd = [
skopeo,
"copy",
"--dest-tls-verify=false",
f"docker-daemon:{image_tag}",
f"docker://{image_tag}",
]
res2 = subprocess.run(cmd, capture_output=True, text=True)
if res2.returncode == 0:
_log(f"[OK] Pushed {image_tag} using skopeo\n")
return True
_log(
f"[ERROR] Skopeo push failed: {res2.stderr.strip() if res2.stderr else 'unknown error'}\n"
)
else:
_log("[ERROR] skopeo not found; cannot retry push.\n")
return False
def _http_ping_registry(host: str, port: int) -> bool:
try:
import http.client
conn = http.client.HTTPConnection(host, port, timeout=3)
conn.request("GET", "/v2/")
resp = conn.getresponse()
# Docker registry typically returns 200 or 401 for /v2/
return resp.status in (200, 401)
except Exception:
return False
def _default_k3s_kubeconfig_path() -> Path:
prole_service = (os.environ.get("PROLE_SERVICE") or "").strip()
if prole_service:
return Path(prole_service).expanduser() / "secrets" / "k3s.kubeconfig"
prole_home = (os.environ.get("PROLE_HOME") or "").strip()
if prole_home:
return Path(prole_home).expanduser() / "prole-k3s.kubeconfig"
return PROJECT_ROOT / "prole-k3s.kubeconfig"
def _write_k3s_kubeconfig(server_url: str, token: str) -> Path:
if not server_url:
raise ValueError("K3s server URL is required.")
if not token:
raise ValueError("K3s token is required.")
if not server_url.startswith("http"):
server_url = f"https://{server_url}"
cfg = (
"apiVersion: v1\n"
"kind: Config\n"
"clusters:\n"
"- cluster:\n"
f" server: {server_url}\n"
" insecure-skip-tls-verify: true\n"
" name: prole-k3s\n"
"contexts:\n"
"- context:\n"
" cluster: prole-k3s\n"
" user: prole-k3s\n"
" name: prole-k3s\n"
"current-context: prole-k3s\n"
"users:\n"
"- name: prole-k3s\n"
" user:\n"
f" token: {token}\n"
)
path = _default_k3s_kubeconfig_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(cfg, encoding="utf-8")
os.chmod(path, 0o600)
return path
def _sync_opentofu_pipeline(
project_root: Path, namespace: str, k3s_server_url: str, k3s_token: str, log_fn=None
) -> Path:
def _log(msg):
if log_fn:
try:
log_fn(msg)
except:
print(msg, end="", flush=True)
else:
print(msg, end="", flush=True)
pipeline_dir = project_root / "deploy" / "opentofu" / "k3s"
manifest_root = pipeline_dir / "manifests"
manifest_root.mkdir(parents=True, exist_ok=True)
argocd_dir = pipeline_dir / "argocd"
argocd_dir.mkdir(parents=True, exist_ok=True)
_log(f"==> Syncing OpenTofu k3s pipeline to {pipeline_dir}\n")
# --- Stage 1: Copy k8s manifests ---
sources = (
project_root / "k8s" / "prole",
project_root / "k8s" / "openbao",
project_root / "k8s" / "opentofu",
)
total_copied = 0
for src in sources:
if not src.exists():
_log(f" [SKIP] {src.name}/ (not found)\n")
continue
dst = manifest_root / src.name
dst.mkdir(parents=True, exist_ok=True)
count = 0
for path in src.glob("*.yaml"):
shutil.copy2(path, dst / path.name)
count += 1
total_copied += count
_log(f" [OK] {src.name}/ -> manifests/{src.name}/ ({count} files)\n")
_log(f" Total manifests staged: {total_copied}\n")
# --- Stage 2: Copy Supabase k8s manifests if present ---
supabase_k8s = project_root / "supabase" / "k8s"
if supabase_k8s.exists():
dst = manifest_root / "supabase"
dst.mkdir(parents=True, exist_ok=True)
count = 0
for path in supabase_k8s.glob("*.yaml"):
shutil.copy2(path, dst / path.name)
count += 1
if count:
_log(f" [OK] supabase/k8s/ -> manifests/supabase/ ({count} files)\n")
# --- Stage 3: Write tfvars ---
token_display = (
k3s_token[:8] + "..." if k3s_token and len(k3s_token) > 8 else "(not set)"
)
_log(f"\n==> Writing opentofu.auto.tfvars\n")
_log(f" k3s_server_url = {k3s_server_url or '(not set)'}\n")
_log(f" k3s_token = {token_display}\n")
_log(f" namespace = {namespace or 'default'}\n")
tfvars = [
f'k3s_server_url = "{k3s_server_url}"',
f'k3s_token = "{k3s_token}"',
f'namespace = "{namespace}"',
"",
]
(pipeline_dir / "opentofu.auto.tfvars").write_text("\n".join(tfvars))
# --- Stage 4: Generate ArgoCD Application manifests ---
_log(f"\n==> Generating ArgoCD Application manifests in argocd/\n")
repo_url = (os.environ.get("PROLE_GIT_REPO") or "").strip()
if not repo_url:
# Attempt to detect from git remote
try:
res = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
cwd=str(project_root),
)
if res.returncode == 0 and res.stdout.strip():
repo_url = res.stdout.strip()
except Exception:
pass
if not repo_url:
repo_url = "https://github.com/prole-dev/prole.git"
target_revision = (os.environ.get("PROLE_GIT_BRANCH") or "").strip() or "main"
components = ["prole", "openbao", "opentofu"]
supabase_dir = manifest_root / "supabase"
if supabase_dir.exists() and list(supabase_dir.glob("*.yaml")):
components.append("supabase")
for component in components:
dest_namespace = namespace or "default"
if component == "supabase":
dest_namespace = "supabase"
app_manifest = (
"apiVersion: argoproj.io/v1alpha1\n"
"kind: Application\n"
"metadata:\n"
f" name: prole-{component}\n"
" namespace: argocd\n"
"spec:\n"
" project: default\n"
" source:\n"
f" repoURL: {repo_url}\n"
f" targetRevision: {target_revision}\n"
f" path: deploy/opentofu/k3s/manifests/{component}\n"
" destination:\n"
f" server: {k3s_server_url or 'https://kubernetes.default.svc'}\n"
f" namespace: {dest_namespace}\n"
" syncPolicy:\n"
" automated:\n"
" prune: true\n"
" selfHeal: true\n"
" syncOptions:\n"
" - CreateNamespace=true\n"
)
app_path = argocd_dir / f"application-{component}.yaml"
app_path.write_text(app_manifest, encoding="utf-8")
_log(f" [OK] application-{component}.yaml\n")
# --- Stage 5: Generate k3s-to-prod pipeline scaffold ---
prod_pipeline_dir = project_root / "deploy" / "opentofu" / "prod"
if not prod_pipeline_dir.exists():
prod_pipeline_dir.mkdir(parents=True, exist_ok=True)
_log(f"\n==> Scaffolding prod pipeline at {prod_pipeline_dir}\n")
prod_main_tf = (
"terraform {\n"
' required_version = ">= 1.6.0"\n'
" required_providers {\n"
" kubernetes = {\n"
' source = "hashicorp/kubernetes"\n'
' version = "~> 2.30"\n'
" }\n"
" }\n"
"}\n"
"\n"
"# Configure for AWS EKS or GCloud GKE via variables\n"
'provider "kubernetes" {\n'
" host = var.cluster_endpoint\n"
" token = var.cluster_token\n"
' cluster_ca_certificate = var.cluster_ca_cert != "" ? base64decode(var.cluster_ca_cert) : null\n'
' insecure = var.cluster_ca_cert == ""\n'
"}\n"
"\n"
"locals {\n"
' manifest_dir = "${path.module}/manifests"\n'
' manifest_files = fileset(local.manifest_dir, "**/*.yaml")\n'
" raw_documents = flatten([\n"
" for f in local.manifest_files : [\n"
' for doc in split("\\n---", trimspace(file("${local.manifest_dir}/${f}"))) :\n'
" trimspace(doc)\n"
' if trimspace(doc) != ""\n'
" ]\n"
" ])\n"
" decoded_documents = [\n"
" for doc in local.raw_documents : yamldecode(doc)\n"
' if try(yamldecode(doc).kind, "") != ""\n'
" ]\n"
" cluster_scoped_kinds = toset([\n"
' "Namespace", "CustomResourceDefinition", "ClusterRole",\n'
' "ClusterRoleBinding", "PersistentVolume", "StorageClass",\n'
" ])\n"
" namespaced_documents = [\n"
" for m in local.decoded_documents :\n"
" contains(local.cluster_scoped_kinds, m.kind) ? m : merge(\n"
' m, { metadata = merge(lookup(m, "metadata", {}), { namespace = var.namespace }) }\n'
" )\n"
" ]\n"
"}\n"
"\n"
'resource "kubernetes_manifest" "namespace" {\n'
" manifest = {\n"
' apiVersion = "v1"\n'
' kind = "Namespace"\n'
" metadata = { name = var.namespace }\n"
" }\n"
"}\n"
"\n"
'resource "kubernetes_manifest" "resources" {\n'
" for_each = { for idx, m in local.namespaced_documents : tostring(idx) => m }\n"
" manifest = each.value\n"
" depends_on = [kubernetes_manifest.namespace]\n"
"}\n"
)
(prod_pipeline_dir / "main.tf").write_text(prod_main_tf, encoding="utf-8")
prod_variables_tf = (
'variable "cluster_endpoint" {\n'
" type = string\n"
' description = "Production cluster API endpoint (EKS/GKE)"\n'
"}\n"
"\n"
'variable "cluster_token" {\n'
" type = string\n"
' description = "Production cluster auth token"\n'
" sensitive = true\n"
"}\n"
"\n"
'variable "cluster_ca_cert" {\n'
" type = string\n"
' description = "Base64-encoded CA certificate (leave empty for insecure)"\n'
' default = ""\n'
"}\n"
"\n"
'variable "namespace" {\n'
" type = string\n"
' description = "Target namespace for Prole resources"\n'
' default = "default"\n'
"}\n"
)
(prod_pipeline_dir / "variables.tf").write_text(
prod_variables_tf, encoding="utf-8"
)
(prod_pipeline_dir / "manifests").mkdir(parents=True, exist_ok=True)
(prod_pipeline_dir / "manifests" / ".gitkeep").write_text("", encoding="utf-8")
prod_readme = (
"# OpenTofu Production Pipeline\n"
"\n"
"This pipeline deploys Prole into a production cluster (AWS EKS / GCloud GKE).\n"
"\n"
"## Usage\n"
"1. Copy manifests from the k3s pipeline into `manifests/`.\n"
"2. Set `cluster_endpoint`, `cluster_token`, and optionally `cluster_ca_cert`.\n"
"3. Run:\n"
"```bash\n"
"tofu init\n"
"tofu plan\n"
"tofu apply\n"
"```\n"
)
(prod_pipeline_dir / "README.md").write_text(prod_readme, encoding="utf-8")
_log(f" [OK] main.tf, variables.tf, README.md\n")
else:
_log(f"\n==> Prod pipeline already exists at {prod_pipeline_dir} (skipped)\n")
_log(f"\n==> OpenTofu pipeline sync complete.\n")
_log(f" k3s pipeline: {pipeline_dir}\n")
_log(f" ArgoCD apps: {argocd_dir}\n")
_log(f" Prod scaffold: {prod_pipeline_dir}\n")
return pipeline_dir
def _render_prole_cfg(
inputs: dict, globals_to_save: dict, sections: dict, generated_at: str | None = None
) -> str:
inputs = dict(inputs or {})
globals_to_save = dict(globals_to_save or {})
sections = {k: dict(v or {}) for k, v in (sections or {}).items()}
def first_non_empty(*vals: str) -> str:
for val in vals:
v = str(val or "").strip()
if v and not (v.startswith("${") and v.endswith("}")):
return v
return ""
def get_input(key: str) -> str:
return str(inputs.get(key, "") or "").strip()
def get_section(section: str, key: str) -> str:
return str((sections.get(section, {}) or {}).get(key, "") or "").strip()
base_home = first_non_empty(
str(globals_to_save.get("PROLE_HOME", "")).strip(),
get_section("System Environment", "PROLE_HOME"),
get_input("env_setup.PROLE_HOME"),
)
base_data = first_non_empty(
str(globals_to_save.get("PROLE_DATA", "")).strip(),
get_section("System Environment", "PROLE_DATA"),
get_input("env_setup.PROLE_DATA"),
)
base_logs = first_non_empty(
str(globals_to_save.get("PROLE_LOGS", "")).strip(),
get_section("System Environment", "PROLE_LOGS"),
get_input("env_setup.PROLE_LOGS"),
)
base_conf = first_non_empty(
str(globals_to_save.get("PROLE_CONF", "")).strip(),
get_section("System Environment", "PROLE_CONF"),
get_input("env_setup.PROLE_CONF"),
)
if not base_conf and base_home:
base_conf = f"{base_home}/conf"
base_service = first_non_empty(
str(globals_to_save.get("PROLE_SERVICE", "")).strip(),
get_section("System Environment", "PROLE_SERVICE"),
get_input("env_setup.PROLE_SERVICE"),
)
if not base_service and base_home:
base_service = f"{base_home}/etc"
base_namespace = first_non_empty(
str(globals_to_save.get("NAMESPACE", "")).strip(),
get_input("env_setup.NAMESPACE"),
get_section("Database Creation", "NAMESPACE"),
)
base_service_namespace = (
first_non_empty(
str(globals_to_save.get("SERVICE_NAMESPACE", "")).strip(),
get_section("Global", "SERVICE_NAMESPACE"),
)
or base_namespace
)
if base_service_namespace == "${SERVICE_NAMESPACE}":
base_service_namespace = "default"
def derive_value(current: str, derived: str, placeholder: str) -> str:
cur = str(current or "").strip()
if not derived:
return cur
if not cur or cur == derived:
return placeholder
return cur
# User-editable values surfaced at the top
user_section = {}
if base_home:
user_section["PROLE_HOME"] = base_home
if base_conf:
user_section["PROLE_CONF"] = derive_value(
base_conf, base_home + "/conf" if base_home else "", "${PROLE_HOME}/conf"
)
if base_service:
user_section["PROLE_SERVICE"] = derive_value(
base_service, base_home + "/etc" if base_home else "", "${PROLE_HOME}/etc"
)
if base_data:
user_section["PROLE_DATA"] = base_data
if base_logs:
user_section["PROLE_LOGS"] = base_logs
if base_namespace:
user_section["NAMESPACE"] = base_namespace
if base_service_namespace:
user_section["SERVICE_NAMESPACE"] = base_service_namespace
# Derived values for repeated touch-points
if base_home:
inputs["disk_selection.local_path"] = derive_value(
inputs.get("disk_selection.local_path", ""),
f"{base_home}/prole-tools-app/dist",
"${PROLE_HOME}/prole-tools-app/dist",
)
if base_namespace:
inputs["env_setup.NAMESPACE"] = derive_value(
inputs.get("env_setup.NAMESPACE", ""), base_namespace, "${NAMESPACE}"
)
inputs["init_password.db_namespace"] = derive_value(
inputs.get("init_password.db_namespace", ""), base_namespace, "${NAMESPACE}"
)
if base_home:
inputs["env_setup.PROLE_HOME"] = derive_value(
inputs.get("env_setup.PROLE_HOME", ""), base_home, "${PROLE_HOME}"
)
if base_conf:
inputs["env_setup.PROLE_CONF"] = derive_value(
inputs.get("env_setup.PROLE_CONF", ""), base_conf, "${PROLE_CONF}"
)
if base_data:
inputs["env_setup.PROLE_DATA"] = derive_value(
inputs.get("env_setup.PROLE_DATA", ""), base_data, "${PROLE_DATA}"
)
if base_logs:
inputs["env_setup.PROLE_LOGS"] = derive_value(
inputs.get("env_setup.PROLE_LOGS", ""), base_logs, "${PROLE_LOGS}"
)
if base_service:
inputs["env_setup.PROLE_SERVICE"] = derive_value(
inputs.get("env_setup.PROLE_SERVICE", ""), base_service, "${PROLE_SERVICE}"
)
globals_to_save["PROLE_HOME"] = derive_value(
globals_to_save.get("PROLE_HOME", ""), base_home, "${PROLE_HOME}"
)
if base_namespace:
globals_to_save["NAMESPACE"] = derive_value(
globals_to_save.get("NAMESPACE", ""), base_namespace, "${NAMESPACE}"
)
if base_service_namespace:
globals_to_save["SERVICE_NAMESPACE"] = base_service_namespace
sys_env = sections.get("System Environment", {})
if base_home:
sys_env["PROLE_HOME"] = derive_value(
sys_env.get("PROLE_HOME", ""), base_home, "${PROLE_HOME}"
)
if base_conf:
sys_env["PROLE_CONF"] = derive_value(
sys_env.get("PROLE_CONF", ""), base_conf, "${PROLE_CONF}"
)
if base_data:
sys_env["PROLE_DATA"] = derive_value(
sys_env.get("PROLE_DATA", ""), base_data, "${PROLE_DATA}"
)
if base_logs:
sys_env["PROLE_LOGS"] = derive_value(
sys_env.get("PROLE_LOGS", ""), base_logs, "${PROLE_LOGS}"
)
if base_service:
sys_env["PROLE_SERVICE"] = derive_value(
sys_env.get("PROLE_SERVICE", ""), base_service, "${PROLE_SERVICE}"
)
if sys_env:
sections["System Environment"] = sys_env
net = sections.get("Network", {})
if base_home:
net["ANSIBLE_INFRASTRUCTURE"] = derive_value(
net.get("ANSIBLE_INFRASTRUCTURE", ""),
f"{base_home}/infrastructure",
"${PROLE_HOME}/infrastructure",
)
net["ANSIBLE_INVENTORY"] = derive_value(
net.get("ANSIBLE_INVENTORY", ""),
f"{base_home}/infrastructure/inventory",
"${PROLE_HOME}/infrastructure/inventory",
)
if net:
sections["Network"] = net
db_create = sections.get("Database Creation", {})
if base_namespace:
db_create["NAMESPACE"] = derive_value(
db_create.get("NAMESPACE", ""), base_namespace, "${NAMESPACE}"
)
if db_create:
sections["Database Creation"] = db_create
prod = sections.get("Prod Cluster (k8s)", {})
if base_data:
prod["ARTIFACTS_DIR"] = derive_value(
prod.get("ARTIFACTS_DIR", ""),
f"{base_data}/staging",
"${PROLE_DATA}/staging",
)
if prod:
sections["Prod Cluster (k8s)"] = prod
# Normalize port-forward namespaces back to placeholders if they match base namespace
if base_namespace:
pf = sections.get("Port Forwards", {})
if pf:
ns_pat = re.compile(
r"(namespace=)" + re.escape(base_namespace) + r"(?=;|$)"
)
for k, v in pf.items():
if not isinstance(v, str):
continue
if "namespace=" in v:
pf[k] = ns_pat.sub(r"\1${NAMESPACE}", v)
sections["Port Forwards"] = pf
content = []
content.append("; Prole Master Configuration File")
content.append(
"; Generated by install.py on "
+ (generated_at or time.strftime("%Y-%m-%d %H:%M:%S"))
)
content.append(
"; This file is used as input for Ansible deployment and k8s cluster creation."
)
content.append("")
allowed_namespace_keys = {
"NAMESPACE",
"SERVICE_NAMESPACE",
"env_setup.NAMESPACE",
"init_password.db_namespace",
}
def is_namespace_key(key: str) -> bool:
return "namespace" in key.lower()
def emit_kv(data: dict, allow_namespace: bool = False) -> None:
for k in sorted(data.keys()):
if (
is_namespace_key(k)
and not allow_namespace
and k not in allowed_namespace_keys
):
continue
content.append(f"{k} = {data[k]}")
# User overrides section
content.append("[User]")
content.append(
"; User-editable values; derived values below reference these by default."
)
if not user_section:
content.append("; No user values captured yet for this section.")
else:
emit_kv(user_section, allow_namespace=True)
content.append("")
# Inputs section (replayable UI inputs)
content.append("[Inputs]")
content.append("; Screen-scoped inputs used for unattended replays (-S)")
if not inputs:
content.append("; No input values captured yet for this section.")
else:
emit_kv(inputs, allow_namespace=False)
content.append("")
# Global Section
content.append("[Global]")
content.append(
"; Variables used by name in more than one place or assumed global scope"
)
emit_kv(globals_to_save, allow_namespace=True)
content.append("")
sections_order = [
"Welcome",
"Dependencies",
"Network",
"Port Forwards",
"System Environment",
"Monitoring",
"Kerberos Authentication",
"Ollama",
"Optional Features",
"GitOps",
"Database Creation",
"Initialize Cluster",
"Dev Cluster (k3d)",
"Service Cluster (k3s)",
"Prod Cluster (k8s)",
"Docker Build",
"Initialization Scripts",
"Deployment",
"Install",
]
for section in sections_order:
data = sections.get(section, {})
content.append(f"[{section}]")
if not data:
content.append("; No configuration values captured yet for this section.")
else:
emit_kv(data, allow_namespace=False)
content.append("")
return "\n".join(content)
def _pf_extract_id(mapping_str: str) -> str:
if not mapping_str:
return ""
for part in str(mapping_str).split(";"):
part = part.strip()
if part.startswith("id="):
return part[3:].strip()
return ""
def _pf_mapping_str(
mapping_id: str,
namespace: str,
target: str,
host_port: str | int,
service_port: str | int,
address: str = "0.0.0.0",
protocol: str = "TCP",
description: str = "",
) -> str:
return (
f"id={mapping_id};"
f"namespace={namespace};"
f"target={target};"
f"address={address};"
f"hostPort={host_port};"
f"servicePort={service_port};"
f"protocol={protocol};"
f"description={description}"
)
def _pf_upsert_mapping(pf_section: dict, prefix: str, mapping_str: str) -> bool:
if pf_section is None:
return False
mapping_id = _pf_extract_id(mapping_str)
if not mapping_id:
return False
updated = False
for k, v in list(pf_section.items()):
if not k.startswith(prefix):
continue
if v == mapping_str:
return False
if f"id={mapping_id};" in v or v.strip() == f"id={mapping_id}":
pf_section[k] = mapping_str
updated = True
if updated:
return True
existing_indices = []
for k in pf_section.keys():
if k.startswith(prefix):
try:
existing_indices.append(int(k[len(prefix) :]))
except ValueError:
pass
next_idx = max(existing_indices, default=0) + 1
pf_section[f"{prefix}{next_idx}"] = mapping_str
return True
def _build_required_port_forwards(
mode: str,
service_ns: str,
argocd_ns: str,
db_ns: str,
db_host_port: str,
supabase_enabled: bool,
supabase_namespace: str,
gitops_enabled: bool = False,
gitops_namespace: str = "gitea",
) -> list[str]:
service_ns = (service_ns or "").strip() or "default"
argocd_ns = (argocd_ns or "").strip() or "argocd"
db_ns = (db_ns or "").strip() or "default"
supabase_namespace = (supabase_namespace or "").strip() or "supabase"
db_host_port = (db_host_port or "5432").strip()
addr_all = "0.0.0.0"
addr_local = "127.0.0.1"
openbao_addr = addr_local if mode == "k3d" else addr_all
mappings = [
_pf_mapping_str(
"argocd",
argocd_ns,
"svc/argocd-server",
"8081",
"80",
addr_all,
"TCP",
"ArgoCD",
),
_pf_mapping_str(
"garage",
service_ns,
"svc/garage",
"3900",
"3900",
addr_all,
"TCP",
"Garage S3",
),
_pf_mapping_str(
"openbao",
service_ns,
"svc/openbao",
"8200",
"8200",
openbao_addr,
"TCP",
"OpenBao",
),
_pf_mapping_str(
"opentofu",
service_ns,
"svc/opentofu",
"8080",
"8080",
addr_all,
"TCP",
"OpenTofu",
),
_pf_mapping_str(
"dashboard",
"kubernetes-dashboard",
"svc/kubernetes-dashboard-kong-proxy",
"8443",
"443",
addr_local,
"TCP",
"Kubernetes Dashboard",
),
_pf_mapping_str(
"postgres",
db_ns,
"svc/prole-db-rw",
db_host_port,
"5432",
addr_all,
"TCP",
"PostgreSQL (primary)",
),
_pf_mapping_str(
"prometheus",
"monitoring",
"svc/kps-kube-prometheus-stack-prometheus",
"9090",
"9090",
addr_local,
"TCP",
"Prometheus UI",
),
_pf_mapping_str(
"grafana",
"monitoring",
"svc/kps-grafana",
"3000",
"80",
addr_all,
"TCP",
"Grafana UI",
),
]
if supabase_enabled:
used_ports = {
part.split("hostPort=", 1)[1].split(";", 1)[0]
for part in mappings
if "hostPort=" in part
}
supabase_port = "8080"
if supabase_port in used_ports:
supabase_port = "18080"
if supabase_port in used_ports:
supabase_port = "28080"
# Core Supabase services (minimal exposure for client + studio)
mappings.append(
_pf_mapping_str(
"supabase-kong",
supabase_namespace,
"svc/kong",
"8000",
"8000",
addr_all,
"TCP",
"Supabase API (Kong)",
)
)
mappings.append(
_pf_mapping_str(
"supabase-studio",
supabase_namespace,
"svc/studio",
supabase_port,
"3000",
addr_all,
"TCP",
"Supabase Studio",
)
)
mappings.append(
_pf_mapping_str(
"supabase-auth",
supabase_namespace,
"svc/auth",
"9999",
"9999",
addr_local,
"TCP",
"Supabase Auth (GoTrue)",
)
)
mappings.append(
_pf_mapping_str(
"supabase-rest",
supabase_namespace,
"svc/rest",
"3001",
"3000",
addr_all,
"TCP",
"Supabase REST (PostgREST)",
)
)
mappings.append(
_pf_mapping_str(
"supabase-realtime",
supabase_namespace,
"svc/realtime",
"4000",
"4000",
addr_all,
"TCP",
"Supabase Realtime",
)
)
if gitops_enabled:
used_ports = {
part.split("hostPort=", 1)[1].split(";", 1)[0]
for part in mappings
if "hostPort=" in part
}
def _next_free(port: int) -> str:
p = str(port)
if p not in used_ports:
return p
p = str(port + 10000)
if p not in used_ports:
return p
return str(port + 20000)
gitea_http_port = _next_free(3000)
gitea_ssh_port = _next_free(22)
mappings.append(
_pf_mapping_str(
"gitea-http",
gitops_namespace or "gitea",
"svc/gitea-http",
gitea_http_port,
"3000",
addr_all,
"TCP",
"Gitea Web",
)
)
mappings.append(
_pf_mapping_str(
"gitea-ssh",
gitops_namespace or "gitea",
"svc/gitea-ssh",
gitea_ssh_port,
"22",
addr_all,
"TCP",
"Gitea SSH",
)
)
return mappings
def is_apple_silicon():
"""Check if running on Apple Silicon (ARM64)."""
return inst_config.is_apple_silicon()
def get_docker_build_platform_args(target_env: str | None = None):
"""Get Docker build platform arguments for the target environment (from config)."""
return inst_config.get_docker_build_platform_args(target_env)
__all__ = [name for name in globals() if not name.startswith("__")]