mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
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>
2204 lines
69 KiB
Python
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("__")]
|