mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 18:44:31 +00:00
- 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.
679 lines
21 KiB
Python
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
|