prole/installer/config.py
chrisfu 6e2d3e9011 refactor: modernize installer and monitoring setup
- Monitoring: Migrated from manual Grafana/Prometheus manifests to kube-prometheus-stack based setup in etc/init_monitoring.sh. Removed old manifest files from deploy/ and k8s/.
- Installer Core: Refactored installer with new modules for actions, environment handling, and UI screens. Enhanced Milestone logic to support advanced configuration (ArgoCD, Registry namespaces, Kerberos flags, etc.).
- Service & Init Scripts: Updated multiple initialization scripts (init_*.sh) for better integration with OpenBao, Kerberos, and the new monitoring stack. Added new scripts for Nginx Ingress, Ollama parsing, and K3D route fixes.
- Infrastructure: Enhanced Samba AD DC Ansible role with realm derivation, provisioning guidance, and group management. Updated K3s role tasks.
- Configuration: Refined default settings in conf/ to align with the new deployment architecture.
- App & Tools: Updated prole-app Swift code and prole.sh for improved environment variable handling and installation flow.
2026-02-19 21:07:39 -08:00

679 lines
21 KiB
Python

"""
Shared configuration and utility functions for the Prole installer (root-level).
This mirrors `prole.installer.config` but is located under the root `installer/`
package per the refactor request. UI code should import from `installer.config`.
"""
from __future__ import annotations
import base64
import getpass
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path
from typing import Tuple, Optional
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# 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 _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)
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return plaintext.decode("utf-8")
except Exception:
return value
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 _resolve_openbao_ref(value: str) -> str:
if not _is_openbao_ref(value):
return value
inner = value[len(OPENBAO_PREFIX):-len(OPENBAO_SUFFIX)]
if ":" not in inner:
return value
path, key = inner.split(":", 1)
if not path or not key:
return ""
mount = "kv"
secret_path = path
if "/" in path:
maybe_mount, rest = path.split("/", 1)
if maybe_mount:
mount = maybe_mount
secret_path = rest
token = os.environ.get("OPENBAO_ROOT_TOKEN", "")
if not token:
prole_service = os.environ.get("PROLE_SERVICE")
if prole_service:
token_path = Path(prole_service) / "secrets" / "openbao-root-token"
if token_path.exists():
token = token_path.read_text().strip()
if not token:
return value
url = os.environ.get("PROLE_OPENBAO_URL")
if not url:
for p in ["8200", "18200"]:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{p}/v1/sys/health", timeout=0.5) as r:
if r.getcode() == 200:
url = f"http://127.0.0.1:{p}"
break
except Exception:
pass
if not url:
url = "http://127.0.0.1:8200"
url = url.rstrip("/")
try:
req = urllib.request.Request(f"{url}/v1/{mount}/data/{secret_path}")
req.add_header("X-Vault-Token", token)
with urllib.request.urlopen(req, timeout=4) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return payload.get("data", {}).get("data", {}).get(key, "") or ""
except Exception:
return value
def _resolve_secret_value(value: str) -> str:
if _is_prole_secret(value):
return _decrypt_prole_secret(value)
if _is_openbao_ref(value):
return _resolve_openbao_ref(value)
return value
# .properties loader (simple key=value, # comments)
def _load_properties(path: Path) -> dict:
props: dict[str, str] = {}
try:
text = path.read_text(encoding="utf-8")
except Exception:
return props
for line in text.splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
if "=" in s:
k, v = s.split("=", 1)
k = k.strip()
v = v.strip()
if k:
props[k] = v
return props
# Repository root: this file lives at <repo>/installer/config.py → parent is repo
PROJECT_ROOT = Path(__file__).resolve().parents[1]
PROLE_APP_DIR = PROJECT_ROOT / "prole-app"
PROLE_PROPS_PATH = PROLE_APP_DIR / "prole.properties"
import logging
def setup_logging(verbose=False, debug=False):
"""Set up logging for the installer."""
level = logging.INFO
if debug:
level = logging.DEBUG
elif verbose:
level = logging.INFO # INFO is already default, but we can be more explicit if needed
# We use a custom format that's clean but informative
log_format = '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
handlers: list[logging.Handler] = [logging.StreamHandler()]
if debug:
# Debug logging to file
log_dir = PROJECT_ROOT / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
from datetime import datetime
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
debug_log = log_dir / f"install-debug-{ts}.log"
handlers.append(logging.FileHandler(debug_log))
# Use more verbose format for file
file_formatter = logging.Formatter(log_format)
handlers[-1].setFormatter(file_formatter)
print(f"Debug logging enabled to {debug_log}")
logging.basicConfig(
level=level,
format=log_format,
handlers=handlers,
force=True # Override any existing config
)
_PROLE_PROPS_CACHE: Optional[dict] = None
def get_properties() -> dict:
"""Load and cache prole.properties from the repo (installer context).
Order:
- Repo default at prole-app/prole.properties
- Optional env override: PROLE_PROPERTIES points to a file
"""
global _PROLE_PROPS_CACHE
if _PROLE_PROPS_CACHE is None:
props: dict[str, str] = {}
# repo default
if PROLE_PROPS_PATH.exists():
props.update(_load_properties(PROLE_PROPS_PATH))
# env override (absolute path)
env_path = os.environ.get("PROLE_PROPERTIES")
if env_path:
p = Path(env_path)
if p.exists():
props.update(_load_properties(p))
_PROLE_PROPS_CACHE = props
return dict(_PROLE_PROPS_CACHE)
def get_config_value(key: str, default: Optional[str] = None) -> Optional[str]:
return get_properties().get(key, default)
def get_ui_icon_image_path() -> Path:
"""Return absolute path to the UI icon image for the installer.
Priority (new → legacy):
- `icon` key in prole.properties (requested)
- legacy `ui.icon`
Defaults to img/proleIcon.png under repo root if not set or missing.
"""
# Prefer new key `icon`, fall back to old `ui.icon`
rel = (
get_config_value("icon")
or get_config_value("ui.icon")
or "img/proleIcon.png"
)
p = (PROJECT_ROOT / rel).resolve()
if p.exists():
return p
# fallback
return (PROJECT_ROOT / "img/proleIcon.png").resolve()
def get_ui_background_image_path() -> Path:
"""Return absolute path to the UI background image.
Priority (new → legacy):
- `background` key in prole.properties (requested)
- legacy `ui.background`
Defaults to img/proleLogoSepia.png under repo root if not set or missing.
"""
rel = (
get_config_value("background")
or get_config_value("ui.background")
or "img/proleLogoSepia.png"
)
p = (PROJECT_ROOT / rel).resolve()
if p.exists():
return p
# fallback
return (PROJECT_ROOT / "img/proleLogoSepia.png").resolve()
def is_apple_silicon() -> bool:
return platform.machine() == "arm64" and platform.system() == "Darwin"
def get_docker_build_platform_args(target_env: Optional[str] = None) -> list[str]:
override = os.environ.get("PROLE_DOCKER_PLATFORM", "").strip()
if override:
return ["--platform", override]
env_key = (target_env or "").strip().lower()
if env_key == "dev" and is_apple_silicon():
return ["--platform", "linux/arm64"]
if env_key in ("service", "k3s", "prole-service-cluster"):
return ["--platform", "linux/arm64"]
if is_apple_silicon():
return ["--platform", "linux/amd64"]
return []
def normalize_version(text: str) -> str:
if not text:
return ""
m = re.search(r"(\d+(?:[._-]\d+){0,5})", text)
if not m:
nums = re.findall(r"\d+", text)
else:
nums = re.findall(r"\d+", m.group(1))
if not nums:
return text.strip()
parts = (nums + ["0", "0"])[:3]
try:
parts = [f"{int(p):02d}" for p in parts]
except Exception:
parts = [p.zfill(2) for p in parts]
return ".".join(parts)
def get_resource_path(relative_path: str | Path) -> 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 = sys._MEIPASS
except Exception:
base_path = PROJECT_ROOT
return Path(base_path) / relative_path
def _expand_path(val: str | None) -> str:
if not val:
return ""
return str(Path(val).expanduser())
def _collect_cfg_vars(cfg: any) -> dict:
variables = {}
if cfg.has_section('Global'):
variables.update(dict(cfg.items('Global')))
if cfg.has_section('System Environment'):
variables.update(dict(cfg.items('System Environment')))
return variables
def _expand_cfg_value(val: str, variables: dict) -> str:
if not val or not isinstance(val, str):
return val
# Basic ${VAR} expansion
import re
result = val
for m in re.finditer(r"\$\{([^}]+)\}", val):
full, var = m.group(0), m.group(1)
if var in variables:
result = result.replace(full, variables[var])
return result
def _parse_bool(val: any, default: bool | None = False) -> bool | None:
if val is None:
return default
if isinstance(val, bool):
return val
s = str(val).lower().strip()
if s in ('true', '1', 'yes', 'on'):
return True
if s in ('false', '0', 'no', 'off'):
return False
return default
def _extract_yaml_scalar_from_text(text: str, key: str) -> str:
# Look for key: "value" or key: value
m = re.search(rf'^{key}:\s*["\']?(.*?)["\']?\s*$', text, re.MULTILINE)
if m:
return m.group(1)
return ""
def _extract_inline_vault_block(text: str, key: str) -> str:
lines = text.splitlines()
for i, line in enumerate(lines):
if line.strip().startswith(f"{key}:"):
base_indent = len(line) - len(line.lstrip())
block = []
i += 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 ''
# Simple search in plain text first
try:
raw_text = vault_path.read_text()
plain = _extract_yaml_scalar_from_text(raw_text, key)
if plain and not plain.startswith('$ANSIBLE_VAULT') and not plain.startswith('!vault'):
return plain
except Exception:
pass
password_file = (os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') or '').strip()
if not password_file:
candidate = PROJECT_ROOT / '.vault_pass'
if candidate.is_file():
password_file = str(candidate)
if not password_file or shutil.which('ansible-vault') is None:
return ''
def _vault_view(path: str) -> str:
try:
res = subprocess.run(
['ansible-vault', 'view', path, '--vault-password-file', password_file],
capture_output=True,
text=True,
timeout=5,
env=os.environ.copy(),
stdin=subprocess.DEVNULL
)
if res.returncode == 0:
return res.stdout or ''
except Exception:
pass
return ''
try:
output = _vault_view(str(vault_path))
if output:
return _extract_yaml_scalar_from_text(output, key)
# Inline vault
inline_block = _extract_inline_vault_block(vault_path.read_text(), key)
if not inline_block:
return ''
import tempfile
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
tmp.write(inline_block)
tmp_name = tmp.name
try:
output = _vault_view(tmp_name)
return output.strip()
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
except Exception:
pass
return ''
def _update_prole_cfg_value(section: str, key: str, value: str):
cfg_path = PROJECT_ROOT / "conf" / "prole.cfg"
if not cfg_path.exists():
return
import configparser
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
cfg.read(cfg_path)
if not cfg.has_section(section):
cfg.add_section(section)
cfg.set(section, key, value)
with open(cfg_path, 'w') as f:
cfg.write(f)
def _write_k3s_kubeconfig(server_url: str, token: str) -> Path:
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"
)
prole_service = os.environ.get("PROLE_SERVICE", "").strip()
if prole_service:
path = Path(prole_service).expanduser() / "secrets" / "k3s.kubeconfig"
else:
prole_home = os.environ.get("PROLE_HOME", "").strip()
if prole_home:
path = Path(prole_home).expanduser() / "prole-k3s.kubeconfig"
else:
path = PROJECT_ROOT / "prole-k3s.kubeconfig"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(cfg, encoding="utf-8")
os.chmod(path, 0o600)
return path
DEPENDENCIES = [
{
"id": "brew",
"name": "Homebrew",
"description": "Package manager for macOS",
"url": "https://brew.sh",
"install_cmd": '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
"check_cmd": "brew --version",
"bin": "brew",
},
{
"id": "python",
"name": "python",
"parent": "brew",
"description": "Python programming language",
"url": "https://www.python.org",
"install_cmd": "brew install python",
"check_cmd": "python3 --version",
"bin": "python3",
},
{
"id": "ansible",
"name": "ansible",
"parent": "brew",
"description": "Infrastructure automation tool",
"url": "https://www.ansible.com",
"install_cmd": "brew install ansible",
"check_cmd": "ansible --version",
"bin": "ansible",
},
{
"id": "kubectl",
"name": "kubectl",
"parent": "brew",
"description": "Kubernetes command-line tool",
"url": "https://kubernetes.io/docs/reference/kubectl/",
"install_cmd": "brew install kubectl",
"check_cmd": "kubectl version --client",
"bin": "kubectl",
},
{
"id": "docker",
"name": "Docker",
"description": "Container platform for running Prole services",
"url": "https://www.docker.com/products/docker-desktop",
"install_cmd": None,
"check_cmd": "docker --version",
"bin": "docker",
},
{
"id": "k3d",
"name": "k3d",
"description": "Lightweight wrapper to run k3s in Docker",
"url": "https://k3d.io",
"install_cmd": "brew install k3d",
"check_cmd": "k3d --version",
"bin": "k3d",
},
]
def get_dep_info(dep: dict) -> Tuple[bool, Optional[str], Optional[str]]:
bin_name = dep.get("bin") or dep["name"]
location = None
version = None
installed = False
try:
if bin_name:
res = subprocess.run(["bash", "-lc", f"command -v {bin_name}"], capture_output=True, text=True)
if res.returncode == 0:
location = res.stdout.strip()
installed = True
check_cmd = dep.get("check_cmd")
if check_cmd:
res2 = subprocess.run(["bash", "-lc", check_cmd], capture_output=True, text=True)
if res2.returncode == 0:
version = " ".join(res2.stdout.strip().splitlines()[:1])
# If check_cmd succeeded, consider it installed
installed = True
if not location:
location = "Installed via Python"
except Exception:
pass
return installed, location, version
def load_dependencies(refresh: bool = True) -> list[dict]:
"""Return a list of dependency descriptors.
Each item mirrors entries from ``DEPENDENCIES`` and is enriched with:
- installed: bool
- location: Optional[str]
- version: Optional[str]
"""
deps: list[dict] = [dict(d) for d in DEPENDENCIES]
if refresh:
for dep in deps:
try:
installed, location, version = get_dep_info(dep)
dep["installed"] = bool(installed)
if location is not None:
dep["location"] = location
if version is not None:
dep["version"] = version
except Exception:
dep["installed"] = False
return deps