mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
1510 lines
50 KiB
Python
1510 lines
50 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
|
|
|
|
# Get the project root directory (installer/ui -> installer -> repo root)
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
|
|
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 _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)
|
|
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"))
|
|
candidates.append(str(Path(prole_home) / ".kube" / "config"))
|
|
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-prole-dev-cluster', 'prole-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 'prole-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) -> Path:
|
|
pipeline_dir = project_root / 'deploy' / 'opentofu' / 'k3s'
|
|
manifest_root = pipeline_dir / 'manifests'
|
|
manifest_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
sources = (
|
|
project_root / 'k8s' / 'prole',
|
|
project_root / 'k8s' / 'openbao',
|
|
project_root / 'k8s' / 'opentofu',
|
|
)
|
|
for src in sources:
|
|
if not src.exists():
|
|
continue
|
|
dst = manifest_root / src.name
|
|
dst.mkdir(parents=True, exist_ok=True)
|
|
for path in src.glob('*.yaml'):
|
|
shutil.copy2(path, dst / path.name)
|
|
|
|
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))
|
|
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:
|
|
if val:
|
|
return val
|
|
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'}
|
|
|
|
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 or 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', '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,
|
|
) -> 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 = "8088"
|
|
|
|
# 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_all, "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")
|
|
)
|
|
|
|
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('__')]
|