mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
2509 lines
81 KiB
Python
2509 lines
81 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 knoe import config as inst_config
|
|
from knoe import knoe_conf
|
|
from knoe.core.policy import OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES, POLICY_CFG_KEY
|
|
|
|
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
|
|
|
|
|
|
def resolve_knoe_home(env: dict[str, str] | None = None) -> Path:
|
|
"""Resolve `KNOE_HOME` from `env`/process environment.
|
|
|
|
Falls back to `$HOME/.knoe` when `KNOE_HOME` is not set.
|
|
"""
|
|
|
|
env_map = env or os.environ
|
|
raw = (env_map.get("KNOE_HOME") or "").strip()
|
|
if raw:
|
|
expanded = os.path.expanduser(os.path.expandvars(raw))
|
|
return Path(expanded)
|
|
return Path.home() / ".knoe"
|
|
|
|
|
|
# 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 = "knoe-"
|
|
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 knoe.cfg)
|
|
KNOE_SECRET_PREFIX = "${KNOE_SECRET:"
|
|
KNOE_SECRET_SUFFIX = "}"
|
|
OPENBAO_PREFIX = "${OPENBAO:"
|
|
OPENBAO_SUFFIX = "}"
|
|
KNOE_SECRET_VERSION = "v1"
|
|
KNOE_SECRET_SERVICE = "knoe-installer"
|
|
|
|
# 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_knoe_secret(value: str | None) -> bool:
|
|
return (
|
|
bool(value)
|
|
and value.startswith(KNOE_SECRET_PREFIX)
|
|
and value.endswith(KNOE_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_knoe_secret(plaintext) or _is_openbao_ref(plaintext):
|
|
return plaintext
|
|
try:
|
|
return _encrypt_knoe_secret(plaintext)
|
|
except Exception:
|
|
return str(plaintext)
|
|
|
|
|
|
def _get_secret_key_file() -> Path:
|
|
return resolve_knoe_home() / "secrets" / "knoe.key"
|
|
|
|
|
|
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 "knoe"
|
|
if system == "Darwin":
|
|
return _get_keychain_key(KNOE_SECRET_SERVICE, account)
|
|
return _get_file_key(_get_secret_key_file())
|
|
|
|
|
|
def _encrypt_knoe_secret(plaintext: str) -> str:
|
|
if plaintext is None:
|
|
return ""
|
|
if _is_knoe_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"{KNOE_SECRET_PREFIX}{KNOE_SECRET_VERSION}:{nonce_b64}:{ct_b64}{KNOE_SECRET_SUFFIX}"
|
|
|
|
|
|
def _decrypt_knoe_secret(value: str) -> str:
|
|
if not _is_knoe_secret(value):
|
|
return value
|
|
inner = value[len(KNOE_SECRET_PREFIX) : -len(KNOE_SECRET_SUFFIX)]
|
|
parts = inner.split(":")
|
|
if len(parts) != 3 or parts[0] != KNOE_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_knoe_secret(token):
|
|
try:
|
|
token = _decrypt_knoe_secret(token)
|
|
except Exception:
|
|
return ""
|
|
if _is_knoe_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 knoe.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
|
|
``KnoeInstallerBase`` instance (e.g. from the ncurses UI).
|
|
"""
|
|
if cfg_path is None:
|
|
conf_dir = knoe_conf.resolve_knoe_conf_dir(PROJECT_ROOT)
|
|
try:
|
|
cfg_path = knoe_conf.ensure_entrypoint(conf_dir)
|
|
except Exception:
|
|
cfg_path = knoe_conf.entrypoint_path(conf_dir)
|
|
cfg_path = Path(cfg_path)
|
|
if not cfg_path.exists():
|
|
return "", "", ""
|
|
|
|
try:
|
|
cfg = knoe_conf.load_layered_config(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. knoe.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)
|
|
|
|
# knoe.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 (k3s node discovery only — no vault decryption)
|
|
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"])
|
|
|
|
# 1Password fallback for k3s token
|
|
if not token:
|
|
try:
|
|
from knoe.core.onepassword import get_secret, op_available
|
|
if op_available():
|
|
token = _normalize_k3s_token(get_secret("k3s-token", "credential"))
|
|
except Exception:
|
|
pass
|
|
|
|
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"}
|
|
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,
|
|
)
|
|
if res.returncode != 0:
|
|
# Avoid reporting a misleading "Missing token" when the user is
|
|
# authenticated via kubeconfig/context instead of K3S_TOKEN.
|
|
return {k: "Error" for k in status}
|
|
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"))
|
|
|
|
knoe_service = (env.get("KNOE_SERVICE") or "").strip()
|
|
if knoe_service:
|
|
candidates.append(str(Path(knoe_service) / "secrets" / "k3s.kubeconfig"))
|
|
for key in ("PROLE_K3S_KUBECONFIG", "PROLE_KUBECONFIG"):
|
|
val = (env.get(key) or "").strip()
|
|
if val:
|
|
candidates.append(val)
|
|
knoe_home = (env.get("KNOE_HOME") or "").strip()
|
|
if knoe_home:
|
|
candidates.append(str(Path(knoe_home) / "knoe-k3s.kubeconfig"))
|
|
candidates.append(str(Path(knoe_home) / "secrets" / "k3s.kubeconfig"))
|
|
|
|
# Other fallback locations
|
|
candidates.append(str(resolve_knoe_home(env) / "secrets" / "k3s.kubeconfig"))
|
|
candidates.append(str(PROJECT_ROOT / "knoe-k3s.kubeconfig"))
|
|
candidates.append(str(PROJECT_ROOT / "etc" / "secrets" / "k3s.kubeconfig"))
|
|
candidates.append("/etc/rancher/k3s/knoe-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_knoe_data_volume_args(knoe_data: str | None) -> list[str]:
|
|
path = (knoe_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 _openbao_placeholder(namespace: str, leaf: str, key: str | None = None) -> str:
|
|
ns = (namespace or "").strip() or "default"
|
|
path = f"kv/knoe/{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("KNOE_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 ""
|
|
# Expand $VAR/${VAR} (including nested refs like KNOE_HOME="$HOME/..."),
|
|
# then expand ~.
|
|
expanded = _expand_cfg_value(str(val), env=os.environ, max_depth=10)
|
|
return os.path.expanduser(expanded)
|
|
|
|
|
|
def _host_from_url(val: str | None) -> str:
|
|
raw = (val or "").strip()
|
|
if not raw:
|
|
return ""
|
|
# urlparse requires a scheme to reliably detect hostnames.
|
|
if "://" not in raw:
|
|
raw = f"https://{raw}"
|
|
try:
|
|
parsed = urllib.parse.urlparse(raw)
|
|
except Exception:
|
|
return ""
|
|
return (parsed.hostname or "").strip()
|
|
|
|
|
|
_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_knoe_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() / "knoe" / "supabase",
|
|
Path.home() / "dev" / "supabase",
|
|
project_root / "supabase",
|
|
]
|
|
knoe_home = (os.environ.get("KNOE_HOME") or "").strip()
|
|
if knoe_home:
|
|
try:
|
|
candidates.append(Path(_expand_path(knoe_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("knoe_domain:"):
|
|
domain = _clean_yaml_value(s.split(":", 1)[1])
|
|
if s.startswith("knoe_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 _detect_ansible_storage_mounts(inventory_path: Path) -> dict[str, dict[str, str]]:
|
|
"""Detect iSCSI mountpoints from Ansible host vars.
|
|
|
|
Returns a mapping like:
|
|
{
|
|
"d001": {"host": "myrddin.knoe.org", "path": "/synology/d001"},
|
|
...
|
|
}
|
|
|
|
This is used to derive storage base paths for local PVs and similar
|
|
node-pinned workloads.
|
|
"""
|
|
|
|
mounts: dict[str, dict[str, str]] = {}
|
|
host_vars = inventory_path / "host_vars"
|
|
if not host_vars.is_dir():
|
|
return mounts
|
|
|
|
try:
|
|
import yaml # type: ignore
|
|
except Exception:
|
|
return mounts
|
|
|
|
def _load(path: Path) -> dict:
|
|
try:
|
|
raw = path.read_text()
|
|
except Exception:
|
|
return {}
|
|
try:
|
|
data = yaml.safe_load(raw)
|
|
except Exception:
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
for hv in sorted(list(host_vars.glob("*.yml")) + list(host_vars.glob("*.yaml"))):
|
|
host = hv.stem
|
|
data = _load(hv)
|
|
targets = data.get("iscsi_targets") or []
|
|
if not isinstance(targets, list):
|
|
continue
|
|
for tgt in targets:
|
|
if not isinstance(tgt, dict):
|
|
continue
|
|
for mnt in (tgt.get("mounts") or []):
|
|
if not isinstance(mnt, dict):
|
|
continue
|
|
mnt_path = str(mnt.get("path") or "").strip().rstrip("/")
|
|
mnt_name = str(mnt.get("name") or "").strip()
|
|
vol = ""
|
|
if re.fullmatch(r"d\d{3}", mnt_name or ""):
|
|
vol = mnt_name
|
|
if not vol and mnt_path:
|
|
base = os.path.basename(mnt_path)
|
|
if re.fullmatch(r"d\d{3}", base or ""):
|
|
vol = base
|
|
if not vol or not mnt_path:
|
|
continue
|
|
mounts.setdefault(vol, {"host": host, "path": mnt_path})
|
|
|
|
return mounts
|
|
|
|
|
|
def _detect_ansible_node_labels(inventory_path: Path) -> dict[str, dict[str, str]]:
|
|
"""Detect Kubernetes node labels from Ansible host vars.
|
|
|
|
Ansible commonly expresses these as `k3s_service_node_labels` list entries
|
|
like `key=value`.
|
|
"""
|
|
|
|
labels_by_host: dict[str, dict[str, str]] = {}
|
|
host_vars = inventory_path / "host_vars"
|
|
if not host_vars.is_dir():
|
|
return labels_by_host
|
|
|
|
try:
|
|
import yaml # type: ignore
|
|
except Exception:
|
|
return labels_by_host
|
|
|
|
def _load(path: Path) -> dict:
|
|
try:
|
|
raw = path.read_text()
|
|
except Exception:
|
|
return {}
|
|
try:
|
|
data = yaml.safe_load(raw)
|
|
except Exception:
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
for hv in sorted(list(host_vars.glob("*.yml")) + list(host_vars.glob("*.yaml"))):
|
|
host = hv.stem
|
|
data = _load(hv)
|
|
raw_labels = (
|
|
data.get("k3s_service_node_labels")
|
|
or data.get("k3s_node_labels")
|
|
or data.get("k3s_service_labels")
|
|
or []
|
|
)
|
|
if not isinstance(raw_labels, list):
|
|
continue
|
|
labels: dict[str, str] = {}
|
|
for item in raw_labels:
|
|
s = str(item or "").strip()
|
|
if not s or "=" not in s:
|
|
continue
|
|
k, v = s.split("=", 1)
|
|
k = k.strip()
|
|
v = v.strip()
|
|
if not k:
|
|
continue
|
|
labels[k] = v
|
|
if labels:
|
|
labels_by_host[host] = labels
|
|
|
|
return labels_by_host
|
|
|
|
|
|
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("knoe-dev-")
|
|
):
|
|
return "dev"
|
|
if s in ("service", "k3s", "k3s-service", "knoe-service-cluster") or s.startswith(
|
|
"knoe-service-"
|
|
):
|
|
return "service"
|
|
if s in ("prod", "production", "k8s", "knoe-prod-cluster") or s.startswith(
|
|
"knoe-prod-"
|
|
):
|
|
return "prod"
|
|
if s in ("min", "minimal", "containerd", "knoe-min-standalone"):
|
|
return "min"
|
|
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 "knoe-service-cluster"
|
|
if key == "prod":
|
|
return "knoe-prod-cluster"
|
|
if key == "min":
|
|
return "knoe-min-standalone"
|
|
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"
|
|
if key == "min":
|
|
return "min"
|
|
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 _detect_ansible_k3s_settings(
|
|
inventory_path: Path, groups: dict, domain: str, ip_map: dict
|
|
) -> dict:
|
|
k3s_hosts = groups.get("k3s_hosts") or []
|
|
# Support inventories that declare hosts via a children group, e.g.:
|
|
# [k3s_hosts:children]
|
|
# k3s_servers
|
|
# k3s_agents
|
|
if not k3s_hosts:
|
|
expanded: list[str] = []
|
|
for child in (groups.get("k3s_hosts:children") or []):
|
|
expanded.extend(groups.get(child) or [])
|
|
if not expanded:
|
|
expanded.extend(groups.get("k3s_servers") or [])
|
|
expanded.extend(groups.get("k3s_agents") or [])
|
|
# Deduplicate while preserving order.
|
|
seen: set[str] = set()
|
|
k3s_hosts = []
|
|
for h in expanded:
|
|
if not h or h in seen:
|
|
continue
|
|
seen.add(h)
|
|
k3s_hosts.append(h)
|
|
server_url = ""
|
|
server_host = ""
|
|
# If an explicit servers group exists, prefer it as the default server host.
|
|
servers = groups.get("k3s_servers") or []
|
|
if servers:
|
|
server_host = servers[0]
|
|
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_host and servers:
|
|
server_host = servers[0]
|
|
if not server_host and k3s_hosts:
|
|
server_host = k3s_hosts[0]
|
|
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"
|
|
|
|
return {
|
|
"server_url": server_url,
|
|
"server_host": server_host,
|
|
"token": "",
|
|
}
|
|
|
|
|
|
def _detect_ansible_topology(project_root: Path) -> dict:
|
|
knoe_home = (os.environ.get("KNOE_HOME") or "").strip()
|
|
base_candidates = []
|
|
if knoe_home:
|
|
base_candidates.append(Path(_expand_path(knoe_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",
|
|
"knoe_domain",
|
|
"kerberos_realm",
|
|
"krb5_realm",
|
|
"kerberos_kdc",
|
|
"krb5_kdc",
|
|
"kerberos_kdc_ip",
|
|
},
|
|
)
|
|
domain = vars_vals.get("knoe_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()
|
|
mode = (
|
|
os.environ.get("KNOE_MODE")
|
|
or os.environ.get("DEPLOYMENT_MODE")
|
|
or os.environ.get("DEPLOY_MODE")
|
|
or ""
|
|
).strip().lower()
|
|
|
|
# In k3s mode we must not rely on localhost/port-forwards.
|
|
if mode == "k3s":
|
|
if url:
|
|
host = _host_from_url(url)
|
|
if host and host not in ("localhost", "127.0.0.1"):
|
|
return url
|
|
# If user provided a localhost URL in k3s, ignore it and try to derive.
|
|
url = ""
|
|
|
|
k3s_server = (
|
|
os.environ.get("PROLE_K3S_SERVER") or os.environ.get("K3S_SERVER_URL") or ""
|
|
).strip()
|
|
k3s_host = _host_from_url(k3s_server)
|
|
if k3s_host:
|
|
return f"http://{k3s_host}:8080"
|
|
return ""
|
|
|
|
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.
|
|
|
|
If Docker push fails due to TLS/CA issues, this will try `skopeo copy`.
|
|
When a registry CA is configured (via `PROLE_REGISTRY_CA_CERT` or
|
|
`PROLE_REGISTRY_CERT_DIR`), it will prefer a secure skopeo push.
|
|
"""
|
|
|
|
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
|
|
skopeo = shutil.which("skopeo")
|
|
if skopeo:
|
|
cert_dir = (os.environ.get("PROLE_REGISTRY_CERT_DIR") or "").strip()
|
|
cert_file = (
|
|
os.environ.get("PROLE_REGISTRY_CA_CERT")
|
|
or os.environ.get("REGISTRY_CA_CERT")
|
|
or ""
|
|
).strip()
|
|
|
|
# Convenience auto-detection (matches how Ansible ships certs from this repo).
|
|
# If a registry host is `myrddin.knoe.org:5000`, look for
|
|
# `ssl/knoe/myrddin-registry.crt` when no env override is provided.
|
|
if not cert_dir and not cert_file:
|
|
try:
|
|
ref = (image_tag or "").strip()
|
|
if "/" in ref:
|
|
registry = ref.split("/", 1)[0]
|
|
host_only = registry.split(":", 1)[0]
|
|
short = host_only.split(".", 1)[0]
|
|
candidates = [
|
|
PROJECT_ROOT / "ssl" / "knoe" / f"{short}-registry.crt",
|
|
PROJECT_ROOT / "ssl" / "knoe" / f"{host_only}-registry.crt",
|
|
]
|
|
for c in candidates:
|
|
if c.is_file():
|
|
cert_file = str(c)
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
# Prefer a secure skopeo push if we have a CA configured.
|
|
try:
|
|
if cert_dir:
|
|
p = Path(cert_dir).expanduser()
|
|
if p.is_dir():
|
|
_log("Retrying with skopeo (TLS verify, custom cert dir) ...\n")
|
|
cmd = [
|
|
skopeo,
|
|
"copy",
|
|
"--dest-tls-verify=true",
|
|
"--dest-cert-dir",
|
|
str(p),
|
|
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 (TLS verified)\n")
|
|
return True
|
|
_log(
|
|
f"[WARN] Skopeo TLS-verified push failed: {res2.stderr.strip() if res2.stderr else 'unknown error'}\n"
|
|
)
|
|
except Exception as e:
|
|
_log(f"[WARN] Unable to use PROLE_REGISTRY_CERT_DIR: {e}\n")
|
|
|
|
try:
|
|
if cert_file:
|
|
p = Path(cert_file).expanduser()
|
|
if p.is_file():
|
|
_log("Retrying with skopeo (TLS verify, custom CA cert) ...\n")
|
|
with tempfile.TemporaryDirectory(prefix="knoe-registry-cert-") as td:
|
|
ca_dest = Path(td) / "ca.crt"
|
|
shutil.copyfile(str(p), str(ca_dest))
|
|
cmd = [
|
|
skopeo,
|
|
"copy",
|
|
"--dest-tls-verify=true",
|
|
"--dest-cert-dir",
|
|
td,
|
|
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 (TLS verified)\n"
|
|
)
|
|
return True
|
|
_log(
|
|
f"[WARN] Skopeo TLS-verified push failed: {res2.stderr.strip() if res2.stderr else 'unknown error'}\n"
|
|
)
|
|
except Exception as e:
|
|
_log(f"[WARN] Unable to use PROLE_REGISTRY_CA_CERT: {e}\n")
|
|
|
|
# Backward-compatible fallback (explicitly insecure).
|
|
_log("Retrying with skopeo (insecure registry) ...\n")
|
|
cmd = [
|
|
skopeo,
|
|
"copy",
|
|
"--dest-tls-verify=false",
|
|
f"docker-daemon:{image_tag}",
|
|
f"docker://{image_tag}",
|
|
]
|
|
res3 = subprocess.run(cmd, capture_output=True, text=True)
|
|
if res3.returncode == 0:
|
|
_log(f"[OK] Pushed {image_tag} using skopeo\n")
|
|
return True
|
|
_log(
|
|
f"[ERROR] Skopeo push failed: {res3.stderr.strip() if res3.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 _registry_image_ref_exists(image_tag: str, timeout: float = 3.0) -> bool:
|
|
"""Check whether an image ref already exists in a Docker Registry (v2).
|
|
|
|
This performs a cheap manifest probe (HEAD/GET) so callers can skip large
|
|
pushes/pulls when a tag is already present.
|
|
|
|
Expected format: `host:port/repo:tag` (or `host:port/repo@sha256:...`).
|
|
"""
|
|
|
|
try:
|
|
import http.client
|
|
import urllib.parse
|
|
|
|
ref = (image_tag or "").strip()
|
|
if not ref or "/" not in ref:
|
|
return False
|
|
|
|
# Strip scheme if present.
|
|
if "://" in ref:
|
|
ref = ref.split("://", 1)[1]
|
|
|
|
registry, remainder = ref.split("/", 1)
|
|
if not registry or not remainder:
|
|
return False
|
|
|
|
host = registry
|
|
port = 80
|
|
if ":" in registry:
|
|
host, port_s = registry.rsplit(":", 1)
|
|
try:
|
|
port = int(port_s)
|
|
except Exception:
|
|
host = registry
|
|
port = 80
|
|
|
|
repo = remainder
|
|
manifest_ref = "latest"
|
|
if "@" in remainder:
|
|
repo, manifest_ref = remainder.split("@", 1)
|
|
else:
|
|
last = remainder.rsplit("/", 1)[-1]
|
|
if ":" in last:
|
|
repo, manifest_ref = remainder.rsplit(":", 1)
|
|
|
|
repo = repo.strip("/")
|
|
manifest_ref = manifest_ref.strip()
|
|
if not repo or not manifest_ref:
|
|
return False
|
|
|
|
path = (
|
|
f"/v2/{urllib.parse.quote(repo, safe='/')}/manifests/"
|
|
f"{urllib.parse.quote(manifest_ref, safe=':@')}"
|
|
)
|
|
headers = {
|
|
"Accept": ", ".join(
|
|
[
|
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
"application/vnd.oci.image.index.v1+json",
|
|
]
|
|
)
|
|
}
|
|
|
|
def _req(method: str) -> int:
|
|
conn = http.client.HTTPConnection(host, port, timeout=timeout)
|
|
try:
|
|
conn.request(method, path, headers=headers)
|
|
resp = conn.getresponse()
|
|
status = resp.status
|
|
resp.read() # drain
|
|
return status
|
|
finally:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
status = _req("HEAD")
|
|
if status == 200:
|
|
return True
|
|
if status == 404:
|
|
return False
|
|
if status in (405, 400):
|
|
return _req("GET") == 200
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _default_k3s_kubeconfig_path() -> Path:
|
|
knoe_service = (os.environ.get("KNOE_SERVICE") or "").strip()
|
|
if knoe_service:
|
|
return Path(knoe_service).expanduser() / "secrets" / "k3s.kubeconfig"
|
|
knoe_home = (os.environ.get("KNOE_HOME") or "").strip()
|
|
if knoe_home:
|
|
return Path(knoe_home).expanduser() / "knoe-k3s.kubeconfig"
|
|
return PROJECT_ROOT / "knoe-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: knoe-k3s\n"
|
|
"contexts:\n"
|
|
"- context:\n"
|
|
" cluster: knoe-k3s\n"
|
|
" user: knoe-k3s\n"
|
|
" name: knoe-k3s\n"
|
|
"current-context: knoe-k3s\n"
|
|
"users:\n"
|
|
"- name: knoe-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" / "knoe",
|
|
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/knoe-dev/knoe.git"
|
|
|
|
target_revision = (os.environ.get("PROLE_GIT_BRANCH") or "").strip() or "main"
|
|
|
|
components = ["knoe", "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: knoe-{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 Knoe 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 Knoe 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_knoe_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()}
|
|
|
|
# Persist the central optional-workloads policy threshold into knoe.cfg so
|
|
# both the Python installer and Ansible can use the same configured value.
|
|
globals_to_save.setdefault(
|
|
POLICY_CFG_KEY, str(OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES)
|
|
)
|
|
|
|
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()
|
|
|
|
def normalize_runtime_mode(value: str) -> str:
|
|
v = str(value or "").strip().lower()
|
|
if v in {"dev", "k3d"}:
|
|
return "dev"
|
|
if v in {"service", "k3s", "k3s-hosts", "knoe-service-cluster"}:
|
|
return "service"
|
|
if v in {"prod", "k8s", "knoe-prod-cluster"}:
|
|
return "prod"
|
|
return v
|
|
|
|
runtime_mode = normalize_runtime_mode(
|
|
first_non_empty(
|
|
str(globals_to_save.get("DEPLOYMENT_MODE", "")).strip(),
|
|
get_section("Deployment", "MODE"),
|
|
get_input("init_cluster.mode"),
|
|
get_input("build.deploy_env"),
|
|
str(globals_to_save.get("CLUSTER_ENV", "")).strip(),
|
|
get_section("Initialize Cluster", "ENVIRONMENT"),
|
|
)
|
|
)
|
|
remote_mode = runtime_mode in {"service", "prod"}
|
|
|
|
base_home = first_non_empty(
|
|
str(globals_to_save.get("KNOE_HOME", "")).strip(),
|
|
get_section("System Environment", "KNOE_HOME"),
|
|
get_input("env_setup.KNOE_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("KNOE_CONF", "")).strip(),
|
|
get_section("System Environment", "KNOE_CONF"),
|
|
get_input("env_setup.KNOE_CONF"),
|
|
)
|
|
if not base_conf and base_home:
|
|
base_conf = f"{base_home}/conf"
|
|
base_service = first_non_empty(
|
|
str(globals_to_save.get("KNOE_SERVICE", "")).strip(),
|
|
get_section("System Environment", "KNOE_SERVICE"),
|
|
get_input("env_setup.KNOE_SERVICE"),
|
|
)
|
|
if not base_service and base_home:
|
|
base_service = f"{base_home}/etc"
|
|
|
|
base_database_namespace = first_non_empty(
|
|
str(globals_to_save.get("DATABASE_NAMESPACE", "")).strip(),
|
|
get_input("env_setup.DATABASE_NAMESPACE"),
|
|
get_section("Database Creation", "DATABASE_NAMESPACE"),
|
|
get_input("env_setup.NAMESPACE"),
|
|
get_section("Database Creation", "NAMESPACE"),
|
|
str(globals_to_save.get("NAMESPACE", "")).strip(),
|
|
)
|
|
base_cluster_name = first_non_empty(
|
|
str(globals_to_save.get("CLUSTER_NAME", "")).strip(),
|
|
get_input("env_setup.CLUSTER_NAME"),
|
|
get_input("init_password.cluster_name"),
|
|
get_section("Database Creation", "CLUSTER_NAME"),
|
|
str(globals_to_save.get("CNPG_CLUSTER_NAME", "")).strip(),
|
|
)
|
|
if not base_cluster_name:
|
|
base_cluster_name = "knoe-db"
|
|
base_service_namespace = first_non_empty(
|
|
str(globals_to_save.get("SERVICE_NAMESPACE", "")).strip(),
|
|
get_section("Global", "SERVICE_NAMESPACE"),
|
|
)
|
|
if base_service_namespace == "${SERVICE_NAMESPACE}":
|
|
base_service_namespace = ""
|
|
if not base_service_namespace and not remote_mode:
|
|
base_service_namespace = base_database_namespace
|
|
|
|
base_service_hostname = first_non_empty(
|
|
str(globals_to_save.get("SERVICE_HOSTNAME", "")).strip(),
|
|
get_section("User", "SERVICE_HOSTNAME"),
|
|
get_section("Global", "SERVICE_HOSTNAME"),
|
|
)
|
|
if not base_service_hostname and not remote_mode:
|
|
base_service_hostname = "svc.knoe.org"
|
|
|
|
base_supabase_hostname = first_non_empty(
|
|
str(globals_to_save.get("supabase_hostname", "")).strip(),
|
|
str(globals_to_save.get("SUPABASE_HOSTNAME", "")).strip(),
|
|
get_section("User", "supabase_hostname"),
|
|
get_section("User", "SUPABASE_HOSTNAME"),
|
|
get_section("Global", "supabase_hostname"),
|
|
get_section("Global", "SUPABASE_HOSTNAME"),
|
|
)
|
|
default_supabase_hostname = "db.0.knoe.dev" if runtime_mode == "prod" else "db.knoe.org"
|
|
if not base_supabase_hostname:
|
|
base_supabase_hostname = default_supabase_hostname
|
|
|
|
def derive_value(current: str, derived: str, placeholder: str) -> str:
|
|
"""Return a placeholder when `current` is unset or matches the derived value."""
|
|
|
|
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 = {}
|
|
# New policy: do not emit variable placeholders for filesystem paths.
|
|
# Only persist literal overrides; omit derived defaults.
|
|
try:
|
|
default_home = str(Path.home() / "dev" / "knoe")
|
|
except Exception:
|
|
default_home = ""
|
|
|
|
def _norm_path_expr(expr: str) -> str:
|
|
s = str(expr or "").strip()
|
|
if not s:
|
|
return ""
|
|
try:
|
|
expanded = os.path.expanduser(os.path.expandvars(s))
|
|
return os.path.normpath(expanded)
|
|
except Exception:
|
|
return s
|
|
|
|
base_home_norm = _norm_path_expr(base_home)
|
|
default_home_norm = _norm_path_expr(default_home)
|
|
|
|
if base_home and (not default_home_norm or base_home_norm != default_home_norm):
|
|
user_section["KNOE_HOME"] = base_home
|
|
if (
|
|
base_conf
|
|
and base_home
|
|
and _norm_path_expr(base_conf) != _norm_path_expr(base_home + "/conf")
|
|
):
|
|
user_section["KNOE_CONF"] = base_conf
|
|
if (
|
|
base_service
|
|
and base_home
|
|
and _norm_path_expr(base_service) != _norm_path_expr(base_home + "/etc")
|
|
):
|
|
user_section["KNOE_SERVICE"] = base_service
|
|
if (
|
|
base_data
|
|
and base_home
|
|
and _norm_path_expr(base_data) != _norm_path_expr(base_home + "/data")
|
|
):
|
|
user_section["PROLE_DATA"] = base_data
|
|
if (
|
|
base_logs
|
|
and base_home
|
|
and _norm_path_expr(base_logs) != _norm_path_expr(base_home + "/logs")
|
|
):
|
|
user_section["PROLE_LOGS"] = base_logs
|
|
if base_service_hostname and base_service_hostname != "svc.knoe.org":
|
|
user_section["SERVICE_HOSTNAME"] = base_service_hostname
|
|
|
|
# Canonical public hostname for Supabase (front-door)
|
|
if base_supabase_hostname and base_supabase_hostname != default_supabase_hostname:
|
|
user_section["supabase_hostname"] = base_supabase_hostname
|
|
|
|
# Derived values for repeated touch-points
|
|
# NOTE: Do not emit placeholder filesystem paths into knoe.cfg.
|
|
if base_database_namespace:
|
|
inputs["env_setup.DATABASE_NAMESPACE"] = derive_value(
|
|
inputs.get("env_setup.DATABASE_NAMESPACE", ""),
|
|
base_database_namespace,
|
|
"${DATABASE_NAMESPACE}",
|
|
)
|
|
inputs["init_password.db_namespace"] = derive_value(
|
|
inputs.get("init_password.db_namespace", ""),
|
|
base_database_namespace,
|
|
"${DATABASE_NAMESPACE}",
|
|
)
|
|
inputs.pop("env_setup.NAMESPACE", None)
|
|
|
|
if base_cluster_name:
|
|
inputs["env_setup.CLUSTER_NAME"] = derive_value(
|
|
inputs.get("env_setup.CLUSTER_NAME", ""),
|
|
base_cluster_name,
|
|
"${CLUSTER_NAME}",
|
|
)
|
|
inputs["init_password.cluster_name"] = derive_value(
|
|
inputs.get("init_password.cluster_name", ""),
|
|
base_cluster_name,
|
|
"${CLUSTER_NAME}",
|
|
)
|
|
|
|
# Do not inject env_setup filesystem keys nor normalize them into placeholders.
|
|
# Inputs and globals are persisted only when explicitly set by the operator.
|
|
if base_database_namespace:
|
|
cur = str(globals_to_save.get("DATABASE_NAMESPACE", "") or "").strip()
|
|
if not cur or (cur.startswith("${") and cur.endswith("}")):
|
|
globals_to_save["DATABASE_NAMESPACE"] = base_database_namespace
|
|
if base_service_namespace:
|
|
cur = str(globals_to_save.get("SERVICE_NAMESPACE", "") or "").strip()
|
|
if not cur or (cur.startswith("${") and cur.endswith("}")):
|
|
globals_to_save["SERVICE_NAMESPACE"] = base_service_namespace
|
|
if base_cluster_name:
|
|
cur = str(globals_to_save.get("CLUSTER_NAME", "") or "").strip()
|
|
if not cur or (cur.startswith("${") and cur.endswith("}")):
|
|
globals_to_save["CLUSTER_NAME"] = base_cluster_name
|
|
globals_to_save.pop("NAMESPACE", None)
|
|
globals_to_save.pop("CNPG_CLUSTER_NAME", None)
|
|
|
|
# System Environment / Network sections are persisted by the UI only when
|
|
# explicit overrides are set; avoid injecting derived filesystem paths.
|
|
|
|
db_create = sections.get("Database Creation", {})
|
|
if isinstance(db_create, dict):
|
|
if "NAMESPACE" in db_create:
|
|
v = str(db_create.get("NAMESPACE", "") or "").strip()
|
|
if (
|
|
not v
|
|
or v == base_database_namespace
|
|
or (v.startswith("${") and v.endswith("}"))
|
|
):
|
|
db_create.pop("NAMESPACE", None)
|
|
if "DATABASE_NAMESPACE" in db_create:
|
|
v = str(db_create.get("DATABASE_NAMESPACE", "") or "").strip()
|
|
if (
|
|
not v
|
|
or v == base_database_namespace
|
|
or (v.startswith("${") and v.endswith("}"))
|
|
):
|
|
db_create.pop("DATABASE_NAMESPACE", None)
|
|
if "CLUSTER_NAME" in db_create:
|
|
v = str(db_create.get("CLUSTER_NAME", "") or "").strip()
|
|
if not v or v == base_cluster_name or (v.startswith("${") and v.endswith("}")):
|
|
db_create.pop("CLUSTER_NAME", None)
|
|
if db_create:
|
|
sections["Database Creation"] = db_create
|
|
|
|
# Avoid injecting derived filesystem paths into cluster sections.
|
|
|
|
# Normalize port-forward namespaces back to placeholders if they match base namespace
|
|
if base_database_namespace:
|
|
pf = sections.get("Port Forwards", {})
|
|
if pf:
|
|
ns_pat = re.compile(
|
|
r"(namespace=)" + re.escape(base_database_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${DATABASE_NAMESPACE}", v)
|
|
sections["Port Forwards"] = pf
|
|
|
|
content = []
|
|
content.append("; Knoe 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 = {
|
|
"DATABASE_NAMESPACE",
|
|
"SERVICE_NAMESPACE",
|
|
"env_setup.DATABASE_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)",
|
|
"GCP",
|
|
"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/knoe-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("__")]
|