diff --git a/.gitignore b/.gitignore index fe518d8..22451b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,32 @@ /.DS_Store +**/.DS_Store /.gitignore -/__pycache__/ +# Python artifacts +**/__pycache__/ +*.py[cod] +*$py.class +*.pyc + +# Build and distribution +/build/ +/dist/ +/installer/build/ +/installer/dist/ +/prole-app/dist/ +/prole-app/dist-*/ +/prole-tools-app/dist/ +/prole-tools-app/.build/ +/prole-tools-app/.build-cli/ + +# Binaries and generated tools +/net/prole-agent +/net/prole-scan +/bin/prole-agent +/bin/prole-scan + +# MagicMock artifacts (likely accidental) +/MagicMock/ + /bin/* !/bin/prole-env.sh !/bin/prole-kpf.sh @@ -10,12 +36,13 @@ /.vagrant/ /.vscode/ /.venv/ -/prole-tools-app/dist/ /prole-tools-app/.build/ /prole-tools-app/.build-cli/ /prole/backup/ # Secrets and local config +etc/secrets/ +prole/conf/ *-password.txt *secret.yaml .vault_pass diff --git a/etc/init_cloudnative_pg.sh b/etc/init_cloudnative_pg.sh index 8879e8d..cf29880 100755 --- a/etc/init_cloudnative_pg.sh +++ b/etc/init_cloudnative_pg.sh @@ -60,6 +60,7 @@ BAO_NAMESPACE="${NAMESPACE:-default}" BAO_PATH_PREFIX="prole/${BAO_NAMESPACE}" BAO_PATH_ADMIN="${BAO_PATH_PREFIX}/admin" BAO_PATH_DB="${BAO_PATH_PREFIX}/db" +BAO_PATH_MONITORING="${BAO_PATH_PREFIX}/monitoring" ensure_tools() { for t in kubectl curl openssl base64 jq; do @@ -78,6 +79,71 @@ ensure_namespace() { fi } +openbao_url() { + if [[ -n "${PROLE_OPENBAO_URL:-}" ]]; then + echo "$PROLE_OPENBAO_URL" + elif curl -sS "http://127.0.0.1:18200/v1/sys/health" >/dev/null 2>&1; then + echo "http://127.0.0.1:18200" + else + echo "http://openbao.${NAMESPACE}.svc.cluster.local:8200" + fi +} + +openbao_token() { + if [[ -f "$OPENBAO_TOKEN_FILE" ]]; then + cat "$OPENBAO_TOKEN_FILE" + else + echo "${OPENBAO_ROOT_TOKEN:-}" + fi +} + +fetch_openbao_secret() { + local path="$1" + local key="$2" + local token url + token=$(openbao_token) + url=$(openbao_url) + if [[ -z "$token" ]]; then + echo "" + return 0 + fi + curl -sS -H "X-Vault-Token: $token" "$url/v1/kv/data/$path" | jq -r ".data.data.\"$key\"" || echo "" +} + +resolve_db_password() { + local db_pw="${DB_PASSWORD:-}" + if [[ -z "$db_pw" || "$db_pw" == '${OPENBAO:'* || "$db_pw" == '${PROLE_SECRET:'* ]]; then + local fetched_db + fetched_db=$(fetch_openbao_secret "$BAO_PATH_DB" "password") + if [[ -n "$fetched_db" && "$fetched_db" != "null" ]]; then + db_pw="$fetched_db" + fi + fi + printf '%s' "$db_pw" +} + +ensure_grafana_admin_secret() { + local db_pw + db_pw=$(resolve_db_password) + if [[ -z "$db_pw" ]]; then + echo "ERROR: DB_PASSWORD unavailable; cannot create Grafana admin secret." >&2 + return 1 + fi + kubectl create secret generic grafana-admin \ + -n "$NAMESPACE" \ + --from-literal=admin-password="$db_pw" \ + --dry-run=client -o yaml | kubectl apply -f - >/dev/null + + local token url + token=$(openbao_token) + url=$(openbao_url) + if [[ -n "$token" ]]; then + curl -sS -H "X-Vault-Token: $token" -H 'Content-Type: application/json' \ + -X POST "$url/v1/kv/data/$BAO_PATH_MONITORING" \ + -d "{\"data\":{\"grafana_admin_password\":\"$db_pw\"}}" >/dev/null || true + fi +} + ensure_cnpg_operator() { if kubectl get deployment -n cnpg-system cnpg-controller-manager >/dev/null 2>&1; then return 0 @@ -98,6 +164,7 @@ ensure_cnpg_operator() { ensure_prole_stack_resources() { echo "Applying CloudNative-PG cluster and related resources ..." + ensure_grafana_admin_secret if [[ -n "$CNPG_MANIFEST_OVERRIDE" ]]; then if [[ ! -f "$CNPG_MANIFEST_OVERRIDE" ]]; then echo "ERROR: CNPG_MANIFEST_OVERRIDE not found: $CNPG_MANIFEST_OVERRIDE" >&2 diff --git a/etc/init_monitoring.sh b/etc/init_monitoring.sh index 05b550d..d9b6cf5 100755 --- a/etc/init_monitoring.sh +++ b/etc/init_monitoring.sh @@ -27,7 +27,7 @@ err() { } ensure_tools() { - for t in helm kubectl; do + for t in helm kubectl curl jq; do command -v "$t" >/dev/null || { err "Missing required tool: $t"; exit 1; } done } @@ -40,6 +40,73 @@ ensure_namespace() { fi } +openbao_url() { + if [[ -n "${PROLE_OPENBAO_URL:-}" ]]; then + echo "$PROLE_OPENBAO_URL" + elif curl -sS "http://127.0.0.1:18200/v1/sys/health" >/dev/null 2>&1; then + echo "http://127.0.0.1:18200" + else + echo "http://openbao.${NAMESPACE}.svc.cluster.local:8200" + fi +} + +openbao_token() { + if [[ -f "$PROLE_SERVICE/secrets/openbao-root-token" ]]; then + cat "$PROLE_SERVICE/secrets/openbao-root-token" + else + echo "${OPENBAO_ROOT_TOKEN:-}" + fi +} + +fetch_openbao_secret() { + local path="$1" + local key="$2" + local token url + token=$(openbao_token) + url=$(openbao_url) + if [[ -z "$token" ]]; then + echo "" + return 0 + fi + curl -sS -H "X-Vault-Token: $token" "$url/v1/kv/data/$path" | jq -r ".data.data.\"$key\"" || echo "" +} + +resolve_grafana_password() { + if [[ -z "${GRAFANA_ADMIN_PASSWORD:-}" || "${GRAFANA_ADMIN_PASSWORD}" == '${OPENBAO:'* || "${GRAFANA_ADMIN_PASSWORD}" == '${PROLE_SECRET:'* ]]; then + local fetched + fetched=$(fetch_openbao_secret "prole/${NAMESPACE:-default}/monitoring" "grafana_admin_password") + if [[ -n "$fetched" && "$fetched" != "null" ]]; then + GRAFANA_ADMIN_PASSWORD="$fetched" + fi + fi + + if [[ -z "${GRAFANA_ADMIN_PASSWORD:-}" ]]; then + local db_pw="${DB_PASSWORD:-}" + if [[ -z "$db_pw" || "$db_pw" == '${OPENBAO:'* || "$db_pw" == '${PROLE_SECRET:'* ]]; then + local fetched_db + fetched_db=$(fetch_openbao_secret "prole/${NAMESPACE:-default}/db" "password") + if [[ -n "$fetched_db" && "$fetched_db" != "null" ]]; then + db_pw="$fetched_db" + fi + fi + if [[ -n "$db_pw" ]]; then + GRAFANA_ADMIN_PASSWORD="$db_pw" + fi + fi +} + +write_grafana_password_to_openbao() { + local token url + token=$(openbao_token) + url=$(openbao_url) + if [[ -z "$token" || -z "${GRAFANA_ADMIN_PASSWORD:-}" ]]; then + return 0 + fi + curl -sS -H "X-Vault-Token: $token" -H 'Content-Type: application/json' \ + -X POST "$url/v1/kv/data/prole/${NAMESPACE:-default}/monitoring" \ + -d "{\"data\":{\"grafana_admin_password\":\"$GRAFANA_ADMIN_PASSWORD\"}}" >/dev/null || true +} + cleanup_grafana_rbac_conflicts() { local release="grafana-prole" local ns="$NAMESPACE" @@ -112,6 +179,13 @@ install_monitoring() { local prometheus_svc="http://prometheus-community-kube-prometheus.$monitoring_ns.svc.cluster.local:9090" cleanup_grafana_rbac_conflicts + + resolve_grafana_password + if [[ -z "${GRAFANA_ADMIN_PASSWORD:-}" ]]; then + err "GRAFANA_ADMIN_PASSWORD is empty. Set it or ensure DB_PASSWORD is available." + exit 1 + fi + write_grafana_password_to_openbao # Note: kube-prometheus-stack may have already been installed with Grafana enabled in 'default'. # If we want to move Grafana to $NAMESPACE, we install it there. @@ -122,13 +196,15 @@ install_monitoring() { --force-conflicts \ --server-side=true \ --set "rbac.namespaced=true" \ + --set "persistence.enabled=true" \ + --set "persistence.size=5Gi" \ --set "datasources.datasources\.yaml.apiVersion=1" \ --set "datasources.datasources\.yaml.datasources[0].name=Prometheus" \ --set "datasources.datasources\.yaml.datasources[0].type=prometheus" \ --set "datasources.datasources\.yaml.datasources[0].url=$prometheus_svc" \ --set "datasources.datasources\.yaml.datasources[0].access=proxy" \ --set "datasources.datasources\.yaml.datasources[0].isDefault=true" \ - --set "adminPassword=admin" \ + --set "adminPassword=$GRAFANA_ADMIN_PASSWORD" \ grafana-prole grafana/grafana log "Applying CNPG prometheus rules in namespace '$NAMESPACE'..." diff --git a/etc/init_openbao.sh b/etc/init_openbao.sh index 9427808..a1a9d1a 100755 --- a/etc/init_openbao.sh +++ b/etc/init_openbao.sh @@ -20,7 +20,8 @@ if [[ -z "${PROLE_SERVICE:-}" ]]; then exit 1 fi -ACTION=${1:-} +ACTION="" +NAMESPACE_OVERRIDE="" # Defaults OPENBAO_NAME=${OPENBAO_NAME:-openbao} @@ -47,6 +48,49 @@ mkdir -p "$SECRETS_DIR" admin_key_priv="$SECRETS_DIR/admin_ed25519.key" admin_key_pub="$SECRETS_DIR/admin_ed25519.pub" root_token_file="$SECRETS_DIR/openbao-root-token" + +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--namespace) + shift + if [[ -z "${1:-}" ]]; then + echo "ERROR: -n/--namespace requires a value" >&2 + exit 2 + fi + NAMESPACE_OVERRIDE="$1" + ;; + -n=*|--namespace=*) + NAMESPACE_OVERRIDE="${1#*=}" + ;; + start|stop|status|restart|initialize|update|reload) + ACTION="$1" + ;; + --) + shift + break + ;; + *) + if [[ -z "$ACTION" && "$1" != -* ]]; then + ACTION="$1" + else + echo "Unknown argument: $1" >&2 + exit 2 + fi + ;; + esac + shift +done + +if [[ -n "$NAMESPACE_OVERRIDE" ]]; then + NAMESPACE="$NAMESPACE_OVERRIDE" + export NAMESPACE +fi + +if [[ -z "${NAMESPACE:-}" ]]; then + NAMESPACE="default" + export NAMESPACE +fi + BAO_NAMESPACE="${NAMESPACE:-default}" BAO_PATH_PREFIX="prole/${BAO_NAMESPACE}" BAO_PATH_ADMIN="${BAO_PATH_PREFIX}/admin" @@ -56,6 +100,9 @@ BAO_PATH_TDE="${BAO_PATH_PREFIX}/tde" BAO_PATH_KERBEROS="${BAO_PATH_PREFIX}/kerberos" BAO_PATH_MONITORING="${BAO_PATH_PREFIX}/monitoring" +# Default Grafana password to the database root password when not explicitly set. +GRAFANA_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-${DB_PASSWORD:-}} + ensure_tools() { for t in curl openssl base64 jq; do command -v "$t" >/dev/null || { echo "Missing required tool: $t" >&2; exit 1; } @@ -651,7 +698,7 @@ case "$ACTION" in echo "Re-applied manifests." ;; *) - echo "Usage: $0 {start|stop|status|restart|initialize|update|reload}" >&2 + echo "Usage: $0 [-n|--namespace NAME] {start|stop|status|restart|initialize|update|reload}" >&2 exit 2 ;; esac diff --git a/etc/init_prole-db-backup.sh b/etc/init_prole-db-backup.sh index 88c2cd3..0e17ee8 100755 --- a/etc/init_prole-db-backup.sh +++ b/etc/init_prole-db-backup.sh @@ -23,6 +23,7 @@ GARAGE_BACKUP_BUCKET=${GARAGE_BACKUP_BUCKET:-prole-db-backups} GARAGE_BACKUP_KEY_NAME=${GARAGE_BACKUP_KEY_NAME:-prole-db-backup} GARAGE_BACKUP_SECRET_NAME=${GARAGE_BACKUP_SECRET_NAME:-prole-db-barman-s3} GARAGE_S3_ENDPOINT=${GARAGE_S3_ENDPOINT:-http://$GARAGE_NAME.$NAMESPACE.svc.cluster.local:3900} +GARAGE_S3_REGION=${GARAGE_S3_REGION:-garage} RUN_FIRST_BACKUP=${RUN_FIRST_BACKUP:-1} usage() { @@ -132,6 +133,7 @@ ensure_garage_bucket_and_key() { kubectl create secret generic "$GARAGE_BACKUP_SECRET_NAME" -n "$NAMESPACE" \ --from-literal=ACCESS_KEY_ID="$access_key" \ --from-literal=SECRET_ACCESS_KEY="$secret_key" \ + --from-literal=REGION="$GARAGE_S3_REGION" \ --dry-run=client -o yaml | kubectl apply -f - } @@ -145,7 +147,8 @@ configure_cnpg_backup() { \"endpointURL\": \"$GARAGE_S3_ENDPOINT\", \"s3Credentials\": { \"accessKeyId\": {\"name\": \"$GARAGE_BACKUP_SECRET_NAME\", \"key\": \"ACCESS_KEY_ID\"}, - \"secretAccessKey\": {\"name\": \"$GARAGE_BACKUP_SECRET_NAME\", \"key\": \"SECRET_ACCESS_KEY\"} + \"secretAccessKey\": {\"name\": \"$GARAGE_BACKUP_SECRET_NAME\", \"key\": \"SECRET_ACCESS_KEY\"}, + \"region\": {\"name\": \"$GARAGE_BACKUP_SECRET_NAME\", \"key\": \"REGION\"} }, \"wal\": {\"compression\": \"gzip\"}, \"data\": {\"compression\": \"gzip\"} diff --git a/install.py b/install.py index ed4bca4..9f49839 100755 --- a/install.py +++ b/install.py @@ -115,6 +115,17 @@ def _is_openbao_ref(value: str | None) -> bool: return bool(value) and value.startswith(OPENBAO_PREFIX) and value.endswith(OPENBAO_SUFFIX) +def _encrypt_cfg_secret(plaintext: str | None) -> str: + if not plaintext: + return '' + if _is_prole_secret(plaintext) or _is_openbao_ref(plaintext): + return plaintext + try: + return _encrypt_prole_secret(plaintext) + except Exception: + return str(plaintext) + + def _get_secret_key_file() -> Path: return PROLE_SECRET_KEY_FILE @@ -409,6 +420,134 @@ def _resolve_host_ip(host: str, domain: str, ip_map: dict) -> str: return '' +def _normalize_cluster_env(env: str | None) -> str: + if not env: + return '' + s = str(env).strip().lower() + if s in ('dev', 'k3d', 'k3d-dev', 'k3d-prole-dev-cluster', 'prole-dev-cluster'): + return 'dev' + if s in ('service', 'k3s', 'k3s-service', 'prole-service-cluster'): + return 'service' + if s in ('prod', 'production', 'prole-prod-cluster'): + return 'prod' + return s + + +def _cluster_env_radio_value(env: str | None) -> str: + key = _normalize_cluster_env(env) + if key == 'dev': + return 'k3d-prole-dev-cluster' + if key == 'service': + return 'prole-service-cluster' + if key == 'prod': + return 'prole-prod-cluster' + return (env or '') + + +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 _try_read_ansible_vault_value(vault_path: Path, key: str) -> str: + if not vault_path.exists(): + return '' + # First, try plain YAML parsing (in case the file is not encrypted). + try: + plain = _parse_yaml_scalar_values(vault_path, {key}).get(key, '') + except Exception: + plain = '' + if plain and not plain.lower().startswith('!vault') and not plain.startswith('$ANSIBLE_VAULT'): + return plain + + password_file = (os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') or '').strip() + password = (os.environ.get('ANSIBLE_VAULT_PASSWORD') or '').strip() + if password_file: + try: + if not Path(password_file).expanduser().exists(): + password_file = '' + except Exception: + password_file = '' + if not password_file and not password: + return '' + if shutil.which('ansible-vault') is None: + return '' + + tmp_path = None + args = ['ansible-vault', 'view', str(vault_path)] + if password_file: + args += ['--vault-password-file', password_file] + elif password: + try: + tmp = tempfile.NamedTemporaryFile(delete=False) + tmp.write(password.encode('utf-8')) + tmp.flush() + tmp.close() + tmp_path = tmp.name + args += ['--vault-password-file', tmp_path] + except Exception: + tmp_path = None + return '' + + try: + res = subprocess.run( + args, + capture_output=True, + text=True, + timeout=5, + env=os.environ.copy(), + stdin=subprocess.DEVNULL + ) + if res.returncode != 0: + return '' + return _extract_yaml_scalar_from_text(res.stdout or '', key) + except Exception: + return '' + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except Exception: + pass + + +def _detect_ansible_k3s_settings(inventory_path: Path, groups: dict, domain: str, ip_map: dict) -> dict: + k3s_hosts = groups.get('k3s_hosts') or [] + server_url = '' + server_host = '' + for host in k3s_hosts: + host_vars_path = inventory_path / 'host_vars' / f'{host}.yml' + vals = _parse_yaml_scalar_values( + host_vars_path, + {'k3s_server_url', 'k3s_cluster_init', 'k3s_role'} + ) + if not server_url and vals.get('k3s_server_url'): + server_url = vals['k3s_server_url'] + if not server_host: + if _parse_bool(vals.get('k3s_cluster_init'), False) or vals.get('k3s_role') == 'server': + server_host = host + if not server_url and server_host: + host_for_url = server_host + if domain and '.' not in host_for_url: + host_for_url = f"{host_for_url}.{domain}" + server_url = f"https://{host_for_url}:6443" + + vault_path = inventory_path / 'group_vars' / 'all' / 'vault_k3s.yml' + token = _try_read_ansible_vault_value(vault_path, 'vault_k3s_token') + + return { + 'server_url': server_url, + 'server_host': server_host, + 'token': token, + 'vault_path': str(vault_path) if vault_path.exists() else '', + } + + def _detect_ansible_topology(project_root: Path) -> dict: prole_home = (os.environ.get('PROLE_HOME') or '').strip() base_candidates = [] @@ -475,6 +614,12 @@ def _detect_ansible_topology(project_root: Path) -> dict: 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, @@ -483,6 +628,11 @@ def _detect_ansible_topology(project_root: Path) -> dict: '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, @@ -499,6 +649,10 @@ def _detect_ansible_topology(project_root: Path) -> dict: '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, @@ -736,9 +890,9 @@ def is_apple_silicon(): return inst_config.is_apple_silicon() -def get_docker_build_platform_args(): - """Get Docker build platform arguments for Apple Silicon (from config).""" - return inst_config.get_docker_build_platform_args() +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) class ProleInstaller: @@ -966,6 +1120,8 @@ class ProleInstaller: self.kubectx_list = tk.Variable(value=self._get_kubectx_list()) self.selected_kubectx = tk.StringVar() self.prod_artifacts_path = tk.StringVar(value=str(PROJECT_ROOT / "data" / "staging")) + self.k3s_server_url = tk.StringVar() + self.k3s_token = tk.StringVar() self.k3s_services_status = {"registry": tk.StringVar(value="Unknown"), "openbao": tk.StringVar(value="Unknown")} try: self.db_username = tk.StringVar(value=os.getlogin()) @@ -1002,6 +1158,14 @@ class ProleInstaller: self._load_secret_cache_from_cfg() except Exception: pass + try: + self._apply_cluster_env_default_from_cfg() + except Exception: + pass + try: + self._apply_k3s_defaults() + except Exception: + pass # Create pages self.page_frames = {} @@ -1277,6 +1441,91 @@ class ProleInstaller: except Exception: pass + def _read_k3s_cfg_values(self) -> tuple[str, str, str]: + """Return (cluster_env, server_url, token) from prole.cfg if present.""" + cfg_path = None + try: + if self._cfg_path_override is not None: + cfg_path = self._cfg_path_override + if cfg_path.is_dir(): + cfg_path = cfg_path / 'prole.cfg' + else: + cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' + except Exception: + cfg_path = None + if not cfg_path or not Path(cfg_path).exists(): + return '', '', '' + + cfg = configparser.ConfigParser(interpolation=None) + cfg.optionxform = str + try: + cfg.read(cfg_path) + except Exception: + return '', '', '' + + env_val = '' + server_val = '' + token_val = '' + if cfg.has_section('Global'): + env_val = cfg['Global'].get('CLUSTER_ENV', env_val).strip() + server_val = cfg['Global'].get('PROLE_K3S_SERVER', server_val).strip() + if not server_val: + server_val = cfg['Global'].get('K3S_SERVER_URL', server_val).strip() + token_val = cfg['Global'].get('PROLE_K3S_TOKEN', token_val).strip() + if not token_val: + token_val = cfg['Global'].get('K3S_TOKEN', token_val).strip() + if cfg.has_section('Initialize Cluster'): + server_val = cfg['Initialize Cluster'].get('K3S_SERVER_URL', server_val).strip() + token_val = cfg['Initialize Cluster'].get('K3S_TOKEN', token_val).strip() + + if token_val: + if _is_prole_secret(token_val): + try: + token_val = _decrypt_prole_secret(token_val) + except Exception: + token_val = '' + elif _is_openbao_ref(token_val): + token_val = '' + return env_val, server_val, token_val + + def _apply_cluster_env_default_from_cfg(self): + cfg_env, _cfg_server, _cfg_token = self._read_k3s_cfg_values() + if not cfg_env: + return + try: + current = (self.cluster_env.get() or '').strip() + except Exception: + current = '' + if not current or current == 'k3d-prole-dev-cluster': + try: + self.cluster_env.set(_cluster_env_radio_value(cfg_env)) + except Exception: + pass + + def _apply_k3s_defaults(self): + # Environment overrides + env_server = (os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() + env_token = (os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() + if env_server and not self.k3s_server_url.get().strip(): + self.k3s_server_url.set(env_server) + if env_token and not self.k3s_token.get().strip(): + self.k3s_token.set(env_token) + + # Config defaults + _cfg_env, cfg_server, cfg_token = self._read_k3s_cfg_values() + if cfg_server and not self.k3s_server_url.get().strip(): + self.k3s_server_url.set(cfg_server) + if cfg_token and not self.k3s_token.get().strip(): + self.k3s_token.set(cfg_token) + + # Ansible defaults + info = getattr(self, 'ansible_topology', None) or _detect_ansible_topology(PROJECT_ROOT) + if info: + if info.get('k3s_server_url') and not self.k3s_server_url.get().strip(): + self.k3s_server_url.set(info['k3s_server_url']) + if info.get('k3s_token') and not self.k3s_token.get().strip(): + self.k3s_token.set(info['k3s_token']) + def _create_sidebar_nav(self): """Create the left-hand navigation menu.""" tk.Label(self.sidebar, text="INSTALLER", bg='#F5F5DC', fg='#8B8B7A', @@ -1949,8 +2198,39 @@ class ProleInstaller: env["KRB5_USER"] = self.kerberos_user.get().strip() if self.kerberos_password.get().strip(): env["KRB5_PASSWORD"] = self.kerberos_password.get().strip() + if self._cluster_env_key() == 'service': + k3s_server, k3s_token = self._k3s_connection_info() + if k3s_server: + env["PROLE_K3S_SERVER"] = k3s_server + if k3s_token: + env["PROLE_K3S_TOKEN"] = k3s_token return env + def _cluster_env_key(self, env: str | None = None) -> str: + try: + current = env if env is not None else self.cluster_env.get() + except Exception: + current = env or '' + return _normalize_cluster_env(current) + + def _k3s_connection_info(self) -> tuple[str, str]: + server = (self.k3s_server_url.get() or os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() + token = (self.k3s_token.get() or os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() + if server and not server.startswith('http'): + server = f"https://{server}" + return server, token + + def _k3s_kubectl_base_cmd(self) -> list[str]: + server, token = self._k3s_connection_info() + if server and token: + return [ + "kubectl", + "--server=" + server, + "--token=" + token, + "--insecure-skip-tls-verify=true" + ] + return ["kubectl"] + def _db_add_namespace(self): ns = (self.db_namespace.get() or '').strip() if not ns: @@ -2349,6 +2629,8 @@ class ProleInstaller: # Cluster init + optional features _set('init_cluster.cluster_env', _get_var(self.cluster_env, 'k3d-prole-dev-cluster')) + _set('init_cluster.k3s_server_url', _get_var(self.k3s_server_url, '')) + _set('init_cluster.k3s_token', _encrypt_cfg_secret(_get_var(self.k3s_token, ''))) _set_bool('init_cluster.supabase_enabled', _get_var(self.supabase_enabled, False)) _set_bool('init_cluster.kerberos_enabled', _get_var(self.kerberos_enabled, False)) _set_bool('init_cluster.at_rest_encryption_enabled', _get_var(self.at_rest_encryption_enabled, False)) @@ -2585,6 +2867,8 @@ class ProleInstaller: globals_to_save['DB_PASSWORD'] = self._secret_cfg_value( 'Global', 'DB_PASSWORD', self.db_password.get(), 'db', 'password' ) + globals_to_save['PROLE_K3S_SERVER'] = (self.k3s_server_url.get() or '').strip() + globals_to_save['PROLE_K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') # Inputs section (replayable UI inputs) try: @@ -3697,8 +3981,12 @@ class ProleInstaller: def _on_cluster_env_change(self, *args): # Trigger status check or refresh UI - env = self.cluster_env.get() - if env == 'prole-service-cluster': + env_key = self._cluster_env_key() + if env_key == 'service': + try: + self._apply_k3s_defaults() + except Exception: + pass self._verify_k3s_services() self.show_page('init_cluster') @@ -3740,9 +4028,10 @@ class ProleInstaller: y += 32 selected_env = self.cluster_env.get() + selected_env_key = self._cluster_env_key(selected_env) y += 20 - if selected_env == 'k3d-prole-dev-cluster': + if selected_env_key == 'dev': # kubectx dropdown self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Available kubectx contexts:', fill='black', font=('SF Pro Text', 12, 'bold'))) y += 30 @@ -3766,7 +4055,29 @@ class ProleInstaller: self._overlay_widgets.append(combo) y += 40 - elif selected_env == 'prole-service-cluster': + elif selected_env_key == 'service': + # K3s connection settings + try: + self._apply_k3s_defaults() + except Exception: + pass + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'K3s Connection:', fill='black', font=('SF Pro Text', 12, 'bold'))) + y += 30 + + ui.canvas_text(self, x_label + 20, y, 'Server URL:', fill='black', font=('SF Pro Text', 11)) + server_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_server_url, width=60) + server_win = self.bg_canvas.create_window(x_label + 140, y - 10, window=server_entry, anchor='nw') + self._canvas_items.append(server_win) + self._overlay_widgets.append(server_entry) + y += 30 + + ui.canvas_text(self, x_label + 20, y, 'Token (vault_k3s_token):', fill='black', font=('SF Pro Text', 11)) + token_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_token, show='*', width=60) + token_win = self.bg_canvas.create_window(x_label + 200, y - 10, window=token_entry, anchor='nw') + self._canvas_items.append(token_win) + self._overlay_widgets.append(token_entry) + y += 34 + # Service Status self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Remote K3s Service Status:', fill='black', font=('SF Pro Text', 12, 'bold'))) y += 30 @@ -3793,8 +4104,10 @@ class ProleInstaller: self._canvas_items.append(deploy_win) self._overlay_widgets.append(deploy_btn) y += 40 + # Async service status check + self._verify_k3s_services() - elif selected_env == 'prole-prod-cluster': + elif selected_env_key == 'prod': # Staging directory input self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Local Artifact Staging Directory:', fill='black', font=('SF Pro Text', 12, 'bold'))) y += 30 @@ -3814,7 +4127,7 @@ class ProleInstaller: y += 30 # Docker/K3D Status (only if dev) - if selected_env == 'k3d-prole-dev-cluster': + if selected_env_key == 'dev': y += 10 self.docker_status_label = ui.canvas_text(self, x_label, y, 'Docker: Checking...', fill='#6e6e73', font=('SF Pro Text', 12)) self._canvas_items.append(self.docker_status_label) @@ -3854,13 +4167,13 @@ class ProleInstaller: def _check_openbao_health(self) -> bool: """Check OpenBao health in the currently selected namespace/pod. Preference order: - 1) If in dev k3d cluster, check k8s resources in selected namespace (deployment/statefulset ready) + 1) If in dev k3d or service k3s cluster, check k8s resources in selected namespace (deployment/statefulset ready) 2) Fallback to HTTP health endpoint if PROLE_OPENBAO_URL (or default) responds """ try: - env_label = (self.cluster_env.get() or '').strip().lower() + env_key = self._cluster_env_key() except Exception: - env_label = 'k3d-prole-dev-cluster' + env_key = 'dev' # 1) Check k8s readiness in selected namespace when using dev/service clusters try: @@ -3869,18 +4182,26 @@ class ProleInstaller: ns = 'default' try: - # deployment/openbao readiness - res_dep = subprocess.run(['kubectl', '-n', ns, 'get', 'deploy', 'openbao', '-o', 'jsonpath={.status.readyReplicas}'], - capture_output=True, text=True, timeout=3) - ready_dep = (res_dep.returncode == 0 and (res_dep.stdout or '0').strip() not in ('', '0')) + base_cmd = ["kubectl"] + if env_key == 'service': + server, token = self._k3s_connection_info() + if server and token: + base_cmd = self._k3s_kubectl_base_cmd() + else: + base_cmd = [] + if base_cmd: + # deployment/openbao readiness + res_dep = subprocess.run(base_cmd + ['-n', ns, 'get', 'deploy', 'openbao', '-o', 'jsonpath={.status.readyReplicas}'], + capture_output=True, text=True, timeout=3) + ready_dep = (res_dep.returncode == 0 and (res_dep.stdout or '0').strip() not in ('', '0')) - # statefulset/openbao readiness - res_sts = subprocess.run(['kubectl', '-n', ns, 'get', 'statefulset', 'openbao', '-o', 'jsonpath={.status.readyReplicas}'], - capture_output=True, text=True, timeout=3) - ready_sts = (res_sts.returncode == 0 and (res_sts.stdout or '0').strip() not in ('', '0')) + # statefulset/openbao readiness + res_sts = subprocess.run(base_cmd + ['-n', ns, 'get', 'statefulset', 'openbao', '-o', 'jsonpath={.status.readyReplicas}'], + capture_output=True, text=True, timeout=3) + ready_sts = (res_sts.returncode == 0 and (res_sts.stdout or '0').strip() not in ('', '0')) - if ready_dep or ready_sts: - return True + if ready_dep or ready_sts: + return True except Exception: pass @@ -3899,12 +4220,20 @@ class ProleInstaller: return False def _check_k8s_cluster(self, env_label: str) -> tuple[bool, str]: - label = "K3s Cluster" if env_label == 'prole-service-cluster' else "Prod Cluster" if env_label == 'prole-prod-cluster' else "Kubernetes Cluster" + env_key = self._cluster_env_key(env_label) + label = "K3s Cluster" if env_key == 'service' else "Prod Cluster" if env_key == 'prod' else "Kubernetes Cluster" try: kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) if kubectl.returncode != 0: return False, f"{label}: kubectl not found" - res = subprocess.run(['kubectl', 'cluster-info'], capture_output=True, text=True, timeout=8) + if env_key == 'service': + server, token = self._k3s_connection_info() + if not server or not token: + return False, f"{label}: missing server URL or token" + cmd = self._k3s_kubectl_base_cmd() + ['cluster-info'] + else: + cmd = ['kubectl', 'cluster-info'] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=8) ok = res.returncode == 0 except Exception: ok = False @@ -3933,20 +4262,17 @@ class ProleInstaller: self.k3s_services_status["openbao"].set("Checking...") # Connection details for remote k3s (same as in ncurses version) - k3s_server = os.environ.get("PROLE_K3S_SERVER", "https://pi.prole.org:6443") - k3s_token = os.environ.get("PROLE_K3S_TOKEN", "") - + k3s_server, k3s_token = self._k3s_connection_info() + if not k3s_server: + self.k3s_services_status["registry"].set("Missing server") + self.k3s_services_status["openbao"].set("Missing server") + return if not k3s_token: self.k3s_services_status["registry"].set("Missing token") self.k3s_services_status["openbao"].set("Missing token") return - base_cmd = [ - "kubectl", - "--server=" + k3s_server, - "--token=" + k3s_token, - "--insecure-skip-tls-verify=true" - ] + base_cmd = self._k3s_kubectl_base_cmd() try: # Check registry @@ -3981,8 +4307,8 @@ class ProleInstaller: def _on_save_cluster_config(self): """Verify the config then write the values to prole.cfg.""" # Verification logic - env = self.cluster_env.get() - if env == 'k3d-prole-dev-cluster': + env_key = self._cluster_env_key() + if env_key == 'dev': ctx = self.selected_kubectx.get() if not ctx: messagebox.showwarning("Validation", "Please select a kubectx context.") @@ -3996,7 +4322,14 @@ class ProleInstaller: except Exception as e: messagebox.showerror("Error", f"Failed to switch to context {ctx}: {e}") return - elif env == 'prole-prod-cluster': + elif env_key == 'service': + if not (self.k3s_server_url.get() or '').strip(): + messagebox.showwarning("Validation", "Please specify the K3s server URL.") + return + if not (self.k3s_token.get() or '').strip(): + messagebox.showwarning("Validation", "Please specify the K3s token.") + return + elif env_key == 'prod': path = self.prod_artifacts_path.get().strip() if not path: messagebox.showwarning("Validation", "Please specify an artifact staging directory.") @@ -4009,6 +4342,11 @@ class ProleInstaller: messagebox.showerror("Error", f"Failed to create directory {path}: {e}") return + # Capture cluster info for config + self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = self.cluster_env.get() + self.prole_cfg_data['Initialize Cluster']['K3S_SERVER_URL'] = (self.k3s_server_url.get() or '').strip() + self.prole_cfg_data['Initialize Cluster']['K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') + # Save config self._save_prole_cfg() @@ -4017,8 +4355,8 @@ class ProleInstaller: def worker(): try: env_vars = self._script_env_for_namespace(ns) - # Use 'update' to (re)apply manifests and config - self.controller.run_script("init_openbao.sh", args=["update"], env=env_vars) + # Use 'update' to (re)apply manifests and config in the selected namespace + self.controller.run_script("init_openbao.sh", args=["-n", ns, "update"], env=env_vars) except Exception as e: print(f"init_openbao.sh update failed: {e}") finally: @@ -4030,7 +4368,7 @@ class ProleInstaller: messagebox.showinfo("Success", "Cluster configuration saved and OpenBao applied.") def _cluster_status_snapshot(self) -> dict: - env = (self.cluster_env.get() or 'k3d-prole-dev-cluster').strip().lower() + env = self._cluster_env_key() docker_ok = self.controller.check_docker_running() docker_msg = 'Docker: Running' if docker_ok else 'Docker: Not running' @@ -4043,7 +4381,7 @@ class ProleInstaller: registry_ok = False registry_msg = '' registry_fill = '#6e6e73' - if env == 'k3d-prole-dev-cluster': + if env == 'dev': if docker_ok: try: ps = subprocess.run(['docker', 'ps', '--format', '{{.Names}} {{.Image}}'], capture_output=True, text=True) @@ -4066,7 +4404,7 @@ class ProleInstaller: registry_fill = '#6e6e73' cluster_ok = False - if env == 'k3d-prole-dev-cluster': + if env == 'dev': cluster_name = "prole-dev-cluster" try: res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) @@ -4120,29 +4458,36 @@ class ProleInstaller: return False if not status['openbao_ok']: - # Attempt to start OpenBao locally - if status['docker_ok']: - env_vars = self._script_env_for_namespace((self.db_namespace.get() or '').strip()) - env_vars["PROLE_DB_USER"] = self.db_username.get().strip() - env_vars["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) - rc_openbao = self.controller.run_script("init_openbao.sh", args=["start"], env=env_vars) - if rc_openbao != 0: + if env == 'dev': + # Attempt to start OpenBao locally + if status['docker_ok']: + env_vars = self._script_env_for_namespace((self.db_namespace.get() or '').strip()) + env_vars["PROLE_DB_USER"] = self.db_username.get().strip() + env_vars["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) + rc_openbao = self.controller.run_script("init_openbao.sh", args=["start"], env=env_vars) + if rc_openbao != 0: + try: + messagebox.showerror('OpenBao', f'Failed to start OpenBao (code {rc_openbao}).') + except Exception: + pass + return False + # Re-check after start + status = self._cluster_status_snapshot() + if not status['openbao_ok']: + try: + messagebox.showerror('OpenBao', 'OpenBao is not reachable after startup.') + except Exception: + pass + return False + else: try: - messagebox.showerror('OpenBao', f'Failed to start OpenBao (code {rc_openbao}).') - except Exception: - pass - return False - # Re-check after start - status = self._cluster_status_snapshot() - if not status['openbao_ok']: - try: - messagebox.showerror('OpenBao', 'OpenBao is not reachable after startup.') + messagebox.showerror('OpenBao', 'OpenBao is not reachable and Docker is not running.') except Exception: pass return False else: try: - messagebox.showerror('OpenBao', 'OpenBao is not reachable and Docker is not running.') + messagebox.showerror('OpenBao', 'OpenBao is not reachable for the selected cluster.') except Exception: pass return False @@ -4178,33 +4523,33 @@ class ProleInstaller: self._action_flags['init_cluster.start_cluster'] = True # Implementation of cluster creation/startup def worker(): - # 1. Start Docker if not running - if not self.controller.check_docker_running(): - # Attempt to start Docker on macOS - subprocess.run(['open', '-a', 'Docker'], capture_output=True) - # Wait for it to start - for _ in range(30): - time.sleep(2) - if self.controller.check_docker_running(): - break - - if not self.controller.check_docker_running(): - self.root.after(0, lambda: messagebox.showerror('Docker', 'Could not start Docker. Please start it manually.')) - return + cluster_env = self._cluster_env_key() - # 2. Ensure OpenBao container is running (local-only) - env = self._script_env_for_namespace((self.db_namespace.get() or '').strip()) - env["PROLE_DB_USER"] = self.db_username.get().strip() - env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) - rc_openbao = self.controller.run_script("init_openbao.sh", args=["start"], env=env) - if rc_openbao != 0: - self.root.after(0, lambda: messagebox.showerror('OpenBao', f'Failed to start OpenBao (code {rc_openbao}).')) - return + if cluster_env == 'dev': + # 1. Start Docker if not running + if not self.controller.check_docker_running(): + # Attempt to start Docker on macOS + subprocess.run(['open', '-a', 'Docker'], capture_output=True) + # Wait for it to start + for _ in range(30): + time.sleep(2) + if self.controller.check_docker_running(): + break + + if not self.controller.check_docker_running(): + self.root.after(0, lambda: messagebox.showerror('Docker', 'Could not start Docker. Please start it manually.')) + return - cluster_env = (self.cluster_env.get() or 'k3d-prole-dev-cluster').strip().lower() + # 2. Ensure OpenBao container is running (local-only) + env = self._script_env_for_namespace((self.db_namespace.get() or '').strip()) + env["PROLE_DB_USER"] = self.db_username.get().strip() + env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) + rc_openbao = self.controller.run_script("init_openbao.sh", args=["start"], env=env) + if rc_openbao != 0: + self.root.after(0, lambda: messagebox.showerror('OpenBao', f'Failed to start OpenBao (code {rc_openbao}).')) + return - # 3. Ensure local registry is available before cluster creation (dev only) - if cluster_env == 'k3d-prole-dev-cluster': + # 3. Ensure local registry is available before cluster creation (dev only) try: reg_info = self.ensure_local_registry_available() except Exception: @@ -4213,8 +4558,7 @@ class ProleInstaller: self.root.after(0, lambda: messagebox.showerror('Registry', 'Local registry is not running. Please start the registry and try again.')) return - # 4. Manage or verify cluster - if cluster_env == 'k3d-prole-dev-cluster': + # 4. Manage or verify cluster cluster_name = 'prole-dev-cluster' res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) if cluster_name not in (res.stdout or ''): @@ -4351,10 +4695,14 @@ class ProleInstaller: pub_key = pub_key_path.read_text().strip() username = self.db_username.get() - cmd = ['docker', 'build', - '--build-arg', f"PROLE_USER={username}", - '--build-arg', f"PROLE_SSH_PUB_KEY={pub_key}", - '-t', image_name, '.'] + env_key = self._cluster_env_key() + cmd = ['docker', 'build'] + cmd.extend(get_docker_build_platform_args(env_key)) + cmd += [ + '--build-arg', f"PROLE_USER={username}", + '--build-arg', f"PROLE_SSH_PUB_KEY={pub_key}", + '-t', image_name, '.' + ] self._db_build_console.clear() self._db_build_console.write(f"Building {image_name} in {cwd}...\n\n") @@ -4372,12 +4720,16 @@ class ProleInstaller: self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build successful! Importing...", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) self._db_built_success = True - # Import to k3d - cluster_name = f"prole-{self.cluster_env.get()}-cluster" - self._db_build_console.write(f"Importing image to {cluster_name}...\n") - subprocess.run(['k3d', 'image', 'import', image_name, '-c', cluster_name]) - self._db_build_console.write("Import complete.\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build and Import complete.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + if env_key == 'dev': + # Import to k3d + cluster_name = "prole-dev-cluster" + self._db_build_console.write(f"Importing image to {cluster_name}...\n") + subprocess.run(['k3d', 'image', 'import', image_name, '-c', cluster_name]) + self._db_build_console.write("Import complete.\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build and Import complete.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + else: + self._db_build_console.write("Import skipped for non-dev clusters.\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build complete. Import skipped.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) else: self._db_build_console.write(f"\nBuild failed with code {proc.returncode}\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text=f"Build failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) @@ -5788,6 +6140,8 @@ class ProleInstaller: if current_id == 'init_cluster': # Capture cluster info self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = self.cluster_env.get() + self.prole_cfg_data['Initialize Cluster']['K3S_SERVER_URL'] = (self.k3s_server_url.get() or '').strip() + self.prole_cfg_data['Initialize Cluster']['K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get()) self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = str(self.kerberos_enabled.get()) self.prole_cfg_data['Optional Features']['AT_REST_ENCRYPTION_ENABLED'] = str(self.at_rest_encryption_enabled.get()) @@ -7743,7 +8097,7 @@ exec "$DIR/ProleTools.bin" "$@" env_combo = ttk.Combobox(env_frame, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18) env_combo.pack(side='left') env_help = tk.Label(env_frame, - text="Dev = local k3d (prole-dev-cluster), Service = remote k3s at retropie.prole.org:6443, Prod = stretch of prole-service-cluster", + text="Dev = local k3d (prole-dev-cluster), Service = remote k3s at pi.prole.org:6443, Prod = stretch of prole-service-cluster", bg='#1a1a1a', fg='#888888', font=('Helvetica', 9)) env_help.pack(side='left', padx=(12, 0)) @@ -8319,12 +8673,22 @@ esac """Ensure target cluster depending on environment selection.""" if env == 'Dev': self.create_or_recreate_k3d_dev_cluster() - elif env in ('Service', 'Prod'): - # For now, just check kubectl availability and inform user. Real connectivity requires kubeconfig. + elif env == 'Service': + kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) + if kubectl.returncode != 0: + raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.") + server, token = self._k3s_connection_info() + if server and token: + res = subprocess.run(self._k3s_kubectl_base_cmd() + ['cluster-info'], capture_output=True, text=True) + if res.returncode != 0: + raise Exception("K3s cluster not reachable with provided server/token.") + else: + # Fallback to local kubeconfig if no token provided + subprocess.run(['kubectl', 'version', '--client'], check=True) + elif env == 'Prod': kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) if kubectl.returncode != 0: raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.") - # Optionally, try a quick cluster-info; don't fail hard on auth errors. subprocess.run(['kubectl', 'version', '--client'], check=True) else: raise Exception(f"Unknown environment: {env}") @@ -8384,9 +8748,15 @@ esac shutil.copytree(conf_src, conf_dst) build_cmd = ['docker', 'build', '-t', image_tag] - - # Add platform flag for Apple Silicon (ARM64 needs amd64 for compatibility) - build_cmd.extend(get_docker_build_platform_args()) + + target_env = self._cluster_env_key() + if getattr(self, 'deploy_environment', None): + try: + target_env = _normalize_cluster_env(self.deploy_environment.get()) + except Exception: + pass + # Add platform flag for target environment (k3s on Pi uses arm64) + build_cmd.extend(get_docker_build_platform_args(target_env)) build_cmd.append('.') @@ -8429,12 +8799,13 @@ esac if new_content != content: manifest_path.write_text(new_content) - def build_mssql_docker_image(self, image_tag='prole-mssql-db:latest'): - """Build mssql Docker image (with platform detection for Apple Silicon)""" + def build_mssql_docker_image(self, image_tag='prole-mssql-db:latest', target_env: str | None = None): + """Build mssql Docker image (with platform detection for target env).""" build_cmd = ['docker', 'build', '-t', image_tag] - - # Add platform flag for Apple Silicon (mssql requires amd64) - build_cmd.extend(get_docker_build_platform_args()) + + env_key = target_env or self._cluster_env_key() + # Add platform flag for Apple Silicon / k3s target + build_cmd.extend(get_docker_build_platform_args(env_key)) build_cmd.append('.') @@ -8717,7 +9088,7 @@ class ProleSilentInstaller: inputs.update({k: v for k, v in cfg.items('Inputs')}) mark_auto(inputs) # Resolve encrypted/anchored secrets for runtime use - for key in ('init_password.db_password', 'init_password.db_password_confirm', 'kerberos_config.password'): + for key in ('init_password.db_password', 'init_password.db_password_confirm', 'kerberos_config.password', 'init_cluster.k3s_token'): if key in inputs and inputs[key]: inputs[key] = self._resolve_secret_value(inputs[key]) if inputs.get('init_password.db_password') and not inputs.get('init_password.db_password_confirm'): @@ -8736,6 +9107,14 @@ class ProleSilentInstaller: legacy['env_setup.PROLE_HOME'] = sec.get('PROLE_HOME', '') if 'CLUSTER_ENV' in sec: legacy['init_cluster.cluster_env'] = sec.get('CLUSTER_ENV', '') + if 'PROLE_K3S_SERVER' in sec: + legacy['init_cluster.k3s_server_url'] = sec.get('PROLE_K3S_SERVER', '') + if 'K3S_SERVER_URL' in sec and 'init_cluster.k3s_server_url' not in legacy: + legacy['init_cluster.k3s_server_url'] = sec.get('K3S_SERVER_URL', '') + if 'PROLE_K3S_TOKEN' in sec: + legacy['init_cluster.k3s_token'] = sec.get('PROLE_K3S_TOKEN', '') + if 'K3S_TOKEN' in sec and 'init_cluster.k3s_token' not in legacy: + legacy['init_cluster.k3s_token'] = sec.get('K3S_TOKEN', '') if 'NAMESPACE' in sec: legacy['init_password.db_namespace'] = sec.get('NAMESPACE', '') legacy['env_setup.NAMESPACE'] = sec.get('NAMESPACE', '') @@ -8779,6 +9158,10 @@ class ProleSilentInstaller: sec = cfg['Initialize Cluster'] if 'ENVIRONMENT' in sec: legacy['init_cluster.cluster_env'] = sec.get('ENVIRONMENT', '') + if 'K3S_SERVER_URL' in sec and 'init_cluster.k3s_server_url' not in legacy: + legacy['init_cluster.k3s_server_url'] = sec.get('K3S_SERVER_URL', '') + if 'K3S_TOKEN' in sec and 'init_cluster.k3s_token' not in legacy: + legacy['init_cluster.k3s_token'] = sec.get('K3S_TOKEN', '') if cfg.has_section('Database Creation'): sec = cfg['Database Creation'] if 'DB_NAME' in sec: @@ -8790,7 +9173,7 @@ class ProleSilentInstaller: if 'DB_USER' in sec: legacy['init_password.db_username'] = sec.get('DB_USER', '') mark_auto(legacy) - for key in ('init_password.db_password', 'init_password.db_password_confirm', 'kerberos_config.password'): + for key in ('init_password.db_password', 'init_password.db_password_confirm', 'kerberos_config.password', 'init_cluster.k3s_token'): if key in legacy and legacy[key]: legacy[key] = self._resolve_secret_value(legacy[key]) if legacy.get('init_password.db_password') and not legacy.get('init_password.db_password_confirm'): @@ -8829,6 +9212,8 @@ class ProleSilentInstaller: # Cluster init + optional features inputs['init_cluster.cluster_env'] = 'dev' + inputs['init_cluster.k3s_server_url'] = '' + inputs['init_cluster.k3s_token'] = '' inputs['init_cluster.supabase_enabled'] = _bool_str(False) inputs['init_cluster.kerberos_enabled'] = _bool_str(False) inputs['init_cluster.at_rest_encryption_enabled'] = _bool_str(True) @@ -9403,6 +9788,13 @@ class ProleSilentInstaller: if info.get('kdc_ip') and not self._get_input_bool('kerberos_config.enabled', False): self.inputs['kerberos_config.enabled'] = _bool_str(True) + if 'init_cluster.k3s_server_url' not in loaded_keys: + if not self._get_input('init_cluster.k3s_server_url', '').strip() and info.get('k3s_server_url'): + self.inputs['init_cluster.k3s_server_url'] = info['k3s_server_url'] + if 'init_cluster.k3s_token' not in loaded_keys: + if not self._get_input('init_cluster.k3s_token', '').strip() and info.get('k3s_token'): + self.inputs['init_cluster.k3s_token'] = info['k3s_token'] + def _write_cfg(self): inputs = dict(self.inputs) db_pw = self._get_input('init_password.db_password', '') @@ -9416,6 +9808,9 @@ class ProleSilentInstaller: krb_pw = self._get_input('kerberos_config.password', '') if krb_pw: inputs['kerberos_config.password'] = self._secret_cfg_value('Inputs', 'kerberos_config.password', krb_pw, 'kerberos', 'password') + k3s_token = self._get_input('init_cluster.k3s_token', '') + if k3s_token: + inputs['init_cluster.k3s_token'] = _encrypt_cfg_secret(k3s_token) globals_to_save = { 'PROLE_HOME': self._get_input('env_setup.PROLE_HOME', ''), @@ -9424,7 +9819,9 @@ class ProleSilentInstaller: 'CLUSTER_ENV': self._get_input('init_cluster.cluster_env', ''), 'NAMESPACE': (self._get_input('init_password.db_namespace', '') or '').strip(), 'DB_HOST_PORT': (self._get_input('init_password.db_host_port', '5432') or '5432').strip(), - 'DOCKER_IMPORT_DIR': self.docker_import_dir or '' + 'DOCKER_IMPORT_DIR': self.docker_import_dir or '', + 'PROLE_K3S_SERVER': (self._get_input('init_cluster.k3s_server_url', '') or '').strip(), + 'PROLE_K3S_TOKEN': _encrypt_cfg_secret(self._get_input('init_cluster.k3s_token', '') or '') } globals_to_save.update(self.prole_cfg_data.get('Global', {})) globals_to_save['DB_PASSWORD'] = self._secret_cfg_value('Global', 'DB_PASSWORD', db_pw, 'db', 'password') @@ -9626,6 +10023,7 @@ class ProleSilentInstaller: env["PROLE_SERVICE"] = str(self.project_root) env["PROLE_DB_USER"] = user env["DB_PASSWORD"] = p1 + env["GRAFANA_ADMIN_PASSWORD"] = p1 env["NAMESPACE"] = ns env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self._get_input_bool('init_cluster.at_rest_encryption_enabled', False)) realm = self._get_input('kerberos_config.realm', '').strip() @@ -9686,8 +10084,10 @@ class ProleSilentInstaller: pub_key = pub_key_path.read_text().strip() if pub_key_path.exists() else "" username = self._get_input('init_password.db_username', '') - cmd = [ - 'docker', 'build', + env_key = _normalize_cluster_env(self._get_input('init_cluster.cluster_env', 'dev')) + cmd = ['docker', 'build'] + cmd.extend(get_docker_build_platform_args(env_key)) + cmd += [ '--build-arg', f"PROLE_USER={username}", '--build-arg', f"PROLE_SSH_PUB_KEY={pub_key}", '-t', image_name, '.' @@ -9696,9 +10096,12 @@ class ProleSilentInstaller: if rc == 0: self._db_built_success = True self.log("[OK] Build successful.") - cluster_name = f"prole-{self._get_input('init_cluster.cluster_env', 'dev')}-cluster" - self.log(f"Importing image to {cluster_name}...") - self._run_cmd(['k3d', 'image', 'import', image_name, '-c', cluster_name]) + if env_key == 'dev': + cluster_name = "prole-dev-cluster" + self.log(f"Importing image to {cluster_name}...") + self._run_cmd(['k3d', 'image', 'import', image_name, '-c', cluster_name]) + else: + self.log("[INFO] Import skipped for non-dev clusters.") self.prole_cfg_data['Docker Build']['STATUS'] = 'Built' else: self._db_built_success = False @@ -9708,7 +10111,10 @@ class ProleSilentInstaller: def _step_cluster(self) -> None: self.log("==> Cluster setup") cluster_env = self._get_input('init_cluster.cluster_env', 'dev') + env_key = _normalize_cluster_env(cluster_env) self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = cluster_env + self.prole_cfg_data['Initialize Cluster']['K3S_SERVER_URL'] = self._get_input('init_cluster.k3s_server_url', '') + self.prole_cfg_data['Initialize Cluster']['K3S_TOKEN'] = _encrypt_cfg_secret(self._get_input('init_cluster.k3s_token', '') or '') self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = _bool_str(self._get_input_bool('init_cluster.supabase_enabled', False)) self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = _bool_str(self._get_input_bool('init_cluster.kerberos_enabled', False)) self.prole_cfg_data['Optional Features']['AT_REST_ENCRYPTION_ENABLED'] = _bool_str(self._get_input_bool('init_cluster.at_rest_encryption_enabled', False)) @@ -9717,39 +10123,36 @@ class ProleSilentInstaller: self.log("[SKIP] Cluster start disabled.") return - if not self.controller.check_docker_running(): - if platform.system() == 'Darwin': - self.log("Starting Docker...") - self._run_cmd(['open', '-a', 'Docker']) - for _ in range(30): - time.sleep(2) - if self.controller.check_docker_running(): - break - if not self.controller.check_docker_running(): - raise Exception("Docker is not running.") + if env_key == 'dev': + if not self.controller.check_docker_running(): + if platform.system() == 'Darwin': + self.log("Starting Docker...") + self._run_cmd(['open', '-a', 'Docker']) + for _ in range(30): + time.sleep(2) + if self.controller.check_docker_running(): + break + if not self.controller.check_docker_running(): + raise Exception("Docker is not running.") - # Ensure OpenBao container is running (local-only) - env = os.environ.copy() - env["PROLE_HOME"] = str(self.project_root) - env["PROLE_SERVICE"] = str(self.project_root) - env["NAMESPACE"] = (self._get_input('init_password.db_namespace', '') or '').strip() - env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self._get_input_bool('init_cluster.at_rest_encryption_enabled', False)) - rc_bao = self._run_script("init_openbao.sh", args=["start"], env=env) - if rc_bao != 0: - raise Exception(f"OpenBao start failed (code {rc_bao})") + # Ensure OpenBao container is running (local-only) + env = os.environ.copy() + env["PROLE_HOME"] = str(self.project_root) + env["PROLE_SERVICE"] = str(self.project_root) + env["NAMESPACE"] = (self._get_input('init_password.db_namespace', '') or '').strip() + env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self._get_input_bool('init_cluster.at_rest_encryption_enabled', False)) + rc_bao = self._run_script("init_openbao.sh", args=["start"], env=env) + if rc_bao != 0: + raise Exception(f"OpenBao start failed (code {rc_bao})") - # Ensure local registry is available before cluster creation (dev only) - if cluster_env == 'dev': + # Ensure local registry is available before cluster creation (dev only) if not self._ensure_local_registry_available(): raise Exception("Local registry is not running.") - cluster_name = f"prole-{cluster_env}-cluster" - res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) - if cluster_name not in res.stdout: - cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2'] - if cluster_env == 'service': - cmd += ['--registry-create', 'k8s-prole-org-registry:k8s.prole.org:5000', '--api-port', '10.0.0.205:6443'] - elif cluster_env == 'dev': + cluster_name = "prole-dev-cluster" + res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) + if cluster_name not in res.stdout: + cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2'] reg_args = [] try: if self._ensure_local_registry_available(): @@ -9757,10 +10160,35 @@ class ProleSilentInstaller: except Exception: reg_args = [] cmd += reg_args + ['--api-port', '0.0.0.0:6443'] - self._run_cmd(cmd) + self._run_cmd(cmd) + else: + self._run_cmd(['k3d', 'cluster', 'start', cluster_name]) + self.log(f"[OK] Cluster ready: {cluster_name}") else: - self._run_cmd(['k3d', 'cluster', 'start', cluster_name]) - self.log(f"[OK] Cluster ready: {cluster_name}") + kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) + if kubectl.returncode != 0: + raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.") + + if env_key == 'service': + server = (self._get_input('init_cluster.k3s_server_url', '') or '').strip() + token = (self._get_input('init_cluster.k3s_token', '') or '').strip() + if not server or not token: + raise Exception("K3s server URL/token missing for service cluster.") + if not server.startswith('http'): + server = f"https://{server}" + cmd = [ + 'kubectl', + '--server=' + server, + '--token=' + token, + '--insecure-skip-tls-verify=true', + 'cluster-info' + ] + else: + cmd = ['kubectl', 'cluster-info'] + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + raise Exception("Cluster is not reachable.") + self.log(f"[OK] Cluster ready: {env_key}") def _step_kerberos(self) -> None: enabled = self._get_input_bool('kerberos_config.enabled', False) diff --git a/installer/config.py b/installer/config.py index 5749c51..ffbc615 100644 --- a/installer/config.py +++ b/installer/config.py @@ -110,7 +110,13 @@ def is_apple_silicon() -> bool: return platform.machine() == "arm64" and platform.system() == "Darwin" -def get_docker_build_platform_args() -> list[str]: +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 in ("service", "k3s", "prole-service-cluster"): + return ["--platform", "linux/arm64"] if is_apple_silicon(): return ["--platform", "linux/amd64"] return [] diff --git a/k8s/prole/grafana-deployment.yaml b/k8s/prole/grafana-deployment.yaml index e1644c6..d33fac5 100644 --- a/k8s/prole/grafana-deployment.yaml +++ b/k8s/prole/grafana-deployment.yaml @@ -24,10 +24,15 @@ spec: - name: GF_SECURITY_ADMIN_USER value: admin - name: GF_SECURITY_ADMIN_PASSWORD - value: admin + valueFrom: + secretKeyRef: + name: grafana-admin + key: admin-password volumeMounts: - name: provisioning mountPath: /etc/grafana/provisioning/datasources + - name: grafana-data + mountPath: /var/lib/grafana volumes: - name: provisioning configMap: @@ -35,3 +40,6 @@ spec: items: - key: datasource.yml path: datasource.yml + - name: grafana-data + persistentVolumeClaim: + claimName: grafana-data diff --git a/k8s/prole/grafana-pvc.yaml b/k8s/prole/grafana-pvc.yaml new file mode 100644 index 0000000..3e10c85 --- /dev/null +++ b/k8s/prole/grafana-pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: grafana-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/k8s/prole/kustomization.yaml b/k8s/prole/kustomization.yaml index 972ed3d..9040973 100644 --- a/k8s/prole/kustomization.yaml +++ b/k8s/prole/kustomization.yaml @@ -18,4 +18,5 @@ resources: - prometheus-service.yaml - grafana-configmap.yaml - grafana-deployment.yaml + - grafana-pvc.yaml - grafana-service.yaml diff --git a/k8s/prole/prole-db.yaml b/k8s/prole/prole-db.yaml index 03ccc91..46af30b 100644 --- a/k8s/prole/prole-db.yaml +++ b/k8s/prole/prole-db.yaml @@ -48,6 +48,11 @@ spec: - GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA topology TO prole; enableSuperuserAccess: true + env: + - name: AWS_REGION + value: garage + - name: AWS_DEFAULT_REGION + value: garage storage: size: 1Gi @@ -65,7 +70,7 @@ spec: backup: barmanObjectStore: destinationPath: s3://prole-db-backups/ - endpointURL: http://garage.default.svc.cluster.local:3900 + endpointURL: http://garage:3900 s3Credentials: accessKeyId: name: prole-db-barman-s3 @@ -73,6 +78,9 @@ spec: secretAccessKey: name: prole-db-barman-s3 key: SECRET_ACCESS_KEY + region: + name: prole-db-barman-s3 + key: REGION wal: compression: gzip data: diff --git a/prole-net/prole-agent b/prole-net/prole-agent index a425b17..a10e5bb 100755 Binary files a/prole-net/prole-agent and b/prole-net/prole-agent differ