checkpoint: improve init scripts, installer flows, and kube context handling

- Fix kube context switching for k3s single-context kubeconfigs and k3d shorthand prefixes

- Update common init/status scripts (registry, kerberos, cnpg backup, service layer, common services)

- Add Gitea init script and installer ArgoCD screen

- Add Supabase realtime probe patching plus regression tests

- Extend installer core/UI test coverage
This commit is contained in:
chrisfu 2026-03-14 20:07:54 -07:00
parent 92e4101403
commit ce5ff83eb4
54 changed files with 3433 additions and 2093 deletions

View File

@ -1,3 +1,16 @@
# Port mappings for Prole Tools (generated).
# Format: key: local=... remote=... ns=... svc=... address=...
argocd: local=8081 remote=80 ns=argocd svc=argocd-server address=0.0.0.0
supabase-studio: local=18080 remote=3000 ns=supabase svc=studio address=0.0.0.0
supabase-auth: local=9999 remote=9999 ns=supabase svc=auth address=127.0.0.1
supabase-rest: local=3001 remote=3000 ns=supabase svc=rest address=0.0.0.0
supabase-realtime: local=4000 remote=4000 ns=supabase svc=realtime address=0.0.0.0
garage: local=3900 remote=3900 ns=knoe-system svc=garage address=0.0.0.0
openbao: local=8200 remote=8200 ns=knoe-system svc=openbao address=0.0.0.0
opentofu: local=8080 remote=8080 ns=knoe-system svc=opentofu address=0.0.0.0
dashboard: local=8443 remote=443 ns=kubernetes-dashboard svc=kubernetes-dashboard-kong-proxy address=127.0.0.1
postgres: local=5432 remote=5432 ns=knoe-db svc=prole-db-rw address=0.0.0.0
prometheus: local=9090 remote=9090 ns=monitoring svc=kps-kube-prometheus-stack-prometheus address=127.0.0.1
grafana: local=3000 remote=80 ns=monitoring svc=kps-grafana address=0.0.0.0
supabase-kong: local=8000 remote=8000 ns=supabase svc=kong address=0.0.0.0

View File

@ -78,8 +78,16 @@ fi
CNPG_MANIFEST="$K8S_PROLE_DIR/prole-db.yaml"
BARMAN_OBJECTSTORE_MANIFEST="$K8S_PROLE_DIR/prole-db-barman-objectstore.yaml"
SECRETS_DIR="$PROLE_SERVICE/secrets"
# Resolving CNPG admin keys.
# Secrets and token locations
# Use PROLE_SERVICE if writable locally, otherwise fallback to ~/.prole
if [[ -n "${PROLE_SERVICE:-}" ]] && _prole_usable_dir "${PROLE_SERVICE}/secrets" >/dev/null; then
SECRETS_DIR="$PROLE_SERVICE/secrets"
else
SECRETS_DIR="$HOME/.prole/etc/secrets"
fi
mkdir -p "$SECRETS_DIR" 2>/dev/null || true
# Resolving CNPG admin keys.
# We prefer names without algorithm suffixes to be more generic, matching install.py fallback strategy.
ADMIN_PRIV_ED25519="$SECRETS_DIR/admin_ed25519.key"
ADMIN_PUB_ED25519="$SECRETS_DIR/admin_ed25519.pub"
@ -101,6 +109,40 @@ ensure_tools() {
done
}
ensure_local_admin_keypair() {
mkdir -p "$SECRETS_DIR" 2>/dev/null || true
# If generic already exists, prefer it.
if [[ -f "$ADMIN_PRIV_GENERIC" && -f "$ADMIN_PUB_GENERIC" ]]; then
# Ensure legacy mirror exists for compatibility.
cp "$ADMIN_PRIV_GENERIC" "$ADMIN_PRIV_ED25519" 2>/dev/null || true
cp "$ADMIN_PUB_GENERIC" "$ADMIN_PUB_ED25519" 2>/dev/null || true
return 0
fi
# If legacy exists, normalize it into generic.
if [[ -f "$ADMIN_PRIV_ED25519" && -f "$ADMIN_PUB_ED25519" ]]; then
cp "$ADMIN_PRIV_ED25519" "$ADMIN_PRIV_GENERIC" 2>/dev/null || true
cp "$ADMIN_PUB_ED25519" "$ADMIN_PUB_GENERIC" 2>/dev/null || true
chmod 0600 "$ADMIN_PRIV_GENERIC" 2>/dev/null || true
return 0
fi
echo "Generating CNPG admin keypair in $SECRETS_DIR ..."
# Attempt ed25519, fallback to rsa if not available
if ! openssl genpkey -algorithm ED25519 -out "$ADMIN_PRIV_GENERIC" 2>/dev/null; then
echo "WARN: ED25519 not supported by openssl, falling back to RSA 4096..." >&2
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out "$ADMIN_PRIV_GENERIC"
fi
openssl pkey -in "$ADMIN_PRIV_GENERIC" -pubout -out "$ADMIN_PUB_GENERIC"
chmod 0600 "$ADMIN_PRIV_GENERIC"
# Mirror to legacy filenames for compatibility with other scripts.
cp "$ADMIN_PRIV_GENERIC" "$ADMIN_PRIV_ED25519" 2>/dev/null || true
cp "$ADMIN_PUB_GENERIC" "$ADMIN_PUB_ED25519" 2>/dev/null || true
return 0
}
_kubectl_is_transient_error() {
local msg="${1:-}"
# Errors we frequently see on k3s under load / during restart.
@ -1298,18 +1340,29 @@ wait_for_cnpg_pods() {
if [[ "$ready_pods" -ge "$target_pods" ]]; then
echo "All $ready_pods/$target_pods pods are Ready."
# Verify DB connectivity if kubectl-cnpg is available
# By default, pod readiness is sufficient. Connectivity checks can be enabled explicitly.
# This avoids unnecessary timeouts/resets during initial bootstrap when CNPG may still be
# initializing even though pods are already Ready.
if kubectl cnpg version >/dev/null 2>&1; then
echo "Verifying database connectivity via 'kubectl cnpg psql'..."
if kubectl cnpg -n "$NAMESPACE" psql "$CNPG_CLUSTER_NAME" -- -c "SELECT 1" >/dev/null 2>&1; then
echo "Database connection verified."
return 0
else
echo " [${elapsed}s/${timeout}s] Pods are Ready but database connection failed — waiting for database to accept connections..."
fi
local require_psql="${CNPG_REQUIRE_PSQL:-false}"
if [[ "$require_psql" == "1" || "$require_psql" == "true" || "$require_psql" == "True" || "$require_psql" == "yes" || "$require_psql" == "YES" ]]; then
echo "Verifying database connectivity via 'kubectl cnpg psql'..."
if kubectl cnpg -n "$NAMESPACE" psql "$CNPG_CLUSTER_NAME" -- -c "SELECT 1" >/dev/null 2>&1; then
echo "Database connection verified."
return 0
else
echo " [${elapsed}s/${timeout}s] Pods are Ready but database connection failed — waiting for database to accept connections..."
fi
else
# Best-effort connectivity check (warn-only)
if ! kubectl cnpg -n "$NAMESPACE" psql "$CNPG_CLUSTER_NAME" -- -c "SELECT 1" >/dev/null 2>&1; then
echo "WARN: CNPG pods are Ready but 'kubectl cnpg psql' is not yet available; continuing." >&2
fi
return 0
fi
else
# Fallback if no cnpg plugin: just return 0 if pods are ready
return 0
# Fallback if no cnpg plugin: just return 0 if pods are ready
return 0
fi
fi
@ -1793,19 +1846,14 @@ fetch_admin_keys_and_db_pass_from_bao_or_local() {
return 1
fi
ensure_local_admin_keypair
if [[ -f "$ADMIN_PRIV_GENERIC" && -f "$ADMIN_PUB_GENERIC" ]]; then
echo "Using local admin key pair at $SECRETS_DIR"
return 0
fi
if [[ -f "$ADMIN_PRIV_ED25519" && -f "$ADMIN_PUB_ED25519" ]]; then
echo "Using local legacy admin key pair at $SECRETS_DIR"
# Link or copy to generic for consistent use below
cp "$ADMIN_PRIV_ED25519" "$ADMIN_PRIV_GENERIC"
cp "$ADMIN_PUB_ED25519" "$ADMIN_PUB_GENERIC"
return 0
fi
echo "ERROR: Could not obtain admin key pair from OpenBao and no local files found." >&2
exit 1
echo "ERROR: Could not obtain admin key pair from OpenBao and could not generate local keys." >&2
return 1
}
apply_cnpg_admin_secret() {

View File

@ -49,6 +49,9 @@ BACKUP_STATUS_TIMEOUT=${BACKUP_STATUS_TIMEOUT:-600}
BACKUP_STATUS_INTERVAL=${BACKUP_STATUS_INTERVAL:-10}
PLUGIN_READY_TIMEOUT=${PLUGIN_READY_TIMEOUT:-180}
PLUGIN_SOCKET_DIR=${PLUGIN_SOCKET_DIR:-/plugins}
LAST_BACKUP_NAME=""
usage() {
cat <<USAGE
Usage: $0 [start|backup|status]
@ -329,8 +332,10 @@ remove_native_barman_config() {
wait_for_plugin_ready() {
local start_time now plugin_names deployment_rows ns ready replicas
local plugin_deploy_ready pod socket_path
start_time=$(date +%s)
while true; do
plugin_deploy_ready=0
plugin_names=$(kubectl -n "$NAMESPACE" get cluster "$CNPG_CLUSTER_NAME" -o jsonpath='{.spec.plugins[*].name}' 2>/dev/null || true)
if printf '%s\n' "$plugin_names" | tr ' ' '\n' | grep -Fxq "$BARMAN_PLUGIN_NAME"; then
deployment_rows=$(kubectl get deployment -A -l app.kubernetes.io/name=barman-cloud -o jsonpath='{range .items[*]}{.metadata.namespace}{"\t"}{.status.readyReplicas}{"\t"}{.status.replicas}{"\n"}{end}' 2>/dev/null || true)
@ -343,9 +348,18 @@ wait_for_plugin_ready() {
ready=${ready:-0}
replicas=${replicas:-0}
if (( ready >= 1 && replicas >= 1 )); then
return 0
plugin_deploy_ready=1
break
fi
done <<< "$deployment_rows"
if (( plugin_deploy_ready == 1 )); then
pod=$(kubectl -n "$NAMESPACE" get pods -l "cnpg.io/cluster=$CNPG_CLUSTER_NAME" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
socket_path="$PLUGIN_SOCKET_DIR/$BARMAN_PLUGIN_NAME"
if [[ -n "$pod" ]] && kubectl -n "$NAMESPACE" exec "$pod" -c postgres -- test -S "$socket_path" >/dev/null 2>&1; then
return 0
fi
fi
fi
now=$(date +%s)
@ -391,6 +405,21 @@ wait_for_successful_base_backup() {
local start_time now elapsed
start_time=$(date +%s)
while true; do
if [[ -n "$LAST_BACKUP_NAME" ]]; then
local phase err
phase=$(kubectl -n "$NAMESPACE" get backup "$LAST_BACKUP_NAME" -o jsonpath='{.status.phase}' 2>/dev/null || true)
err=$(kubectl -n "$NAMESPACE" get backup "$LAST_BACKUP_NAME" -o jsonpath='{.status.error}' 2>/dev/null || true)
case "${phase:-}" in
Completed|Succeeded|completed|succeeded)
return 0
;;
Failed|failed)
echo "ERROR: Backup '$LAST_BACKUP_NAME' failed: ${err:-<no error provided>}" >&2
return 1
;;
esac
fi
if has_successful_base_backup; then
return 0
fi
@ -410,6 +439,7 @@ trigger_backup() {
local backup_type="${1:-full}"
local backup_name
backup_name="${CNPG_CLUSTER_NAME}-backup-$(date +%Y%m%d%H%M%S)"
LAST_BACKUP_NAME="$backup_name"
echo "Triggering ${backup_type} backup $backup_name ..."
if [[ "$backup_type" == "incremental" || "$backup_type" == "incr" ]]; then
kubectl_apply_retry "$NAMESPACE" <<BACKUP

View File

@ -25,7 +25,7 @@ usage() {
cat <<EOF
Usage: init_common_services.sh [-n|--namespace NS] [-k|--kerberos] <update|start|status|verify>
Deploys common infrastructure services (ArgoCD, OpenTofu, Garage, OpenBao, Kong, Cert-Manager)
Deploys common infrastructure services (Registry, OpenTofu, Garage, OpenBao, Kong, Cert-Manager)
into the given Kubernetes namespace. Use -k to include the Kerberos/KDC service.
EOF
}
@ -86,7 +86,6 @@ if [ -z "$NS" ]; then
NS="default"
fi
ARGOCD_NS="${ARGOCD_NAMESPACE:-argocd}"
# Registry should live in the common-core/service namespace unless explicitly overridden.
REGISTRY_NS="${REGISTRY_NAMESPACE:-${NS}}"
@ -500,7 +499,7 @@ fi
OPENTOFU_NAME=${OPENTOFU_NAME:-opentofu}
OPENBAO_NAME=${OPENBAO_NAME:-openbao}
ARGOCD_SERVER_NAME=${ARGOCD_SERVER_NAME:-argocd-server}
REGISTRY_NAME=${REGISTRY_NAME:-registry}
GARAGE_NAME=${GARAGE_NAME:-garage}
KONG_NAME=${KONG_NAME:-prole-svc-kong}
OPENTOFU_CONFIGMAP=${OPENTOFU_CONFIGMAP:-opentofu-nginx}
@ -545,13 +544,13 @@ migrate_common_services() {
kubectl delete -n "$old_ns" secret "$OPENTOFU_SECRET" --ignore-not-found >/dev/null 2>&1 || true
done
for old_ns in $(collect_other_namespaces "$ARGOCD_NS" "$ARGOCD_SERVER_NAME" deployment service); do
echo "Found ArgoCD in namespace '$old_ns'; removing before deploy to '$ARGOCD_NS' ..."
for old_ns in $(collect_other_namespaces "$REGISTRY_NS" "$REGISTRY_NAME" deployment service); do
echo "Found Registry ($REGISTRY_NAME) in namespace '$old_ns'; removing before deploy to '$REGISTRY_NS' ..."
if [ -x "$SCRIPT_DIR/init_registry.sh" ]; then
REGISTRY_NAMESPACE="$REGISTRY_NS" "$SCRIPT_DIR/init_registry.sh" -n "$old_ns" stop || true
"$SCRIPT_DIR/init_registry.sh" -n "$old_ns" stop || true
else
kubectl delete -n "$old_ns" deploy "$ARGOCD_SERVER_NAME" --ignore-not-found >/dev/null 2>&1 || true
kubectl delete -n "$old_ns" svc "$ARGOCD_SERVER_NAME" --ignore-not-found >/dev/null 2>&1 || true
kubectl delete -n "$old_ns" deploy "$REGISTRY_NAME" --ignore-not-found >/dev/null 2>&1 || true
kubectl delete -n "$old_ns" svc "$REGISTRY_NAME" --ignore-not-found >/dev/null 2>&1 || true
fi
done
@ -611,10 +610,10 @@ else
fi
if [ -x "$SCRIPT_DIR/init_registry.sh" ]; then
ARGOCD_NAMESPACE="$ARGOCD_NS" REGISTRY_NAMESPACE="$REGISTRY_NS" \
"$SCRIPT_DIR/init_registry.sh" -n "$ARGOCD_NS" --registry-namespace "$REGISTRY_NS" "$ACTION" || rc=$?
REGISTRY_NAMESPACE="$REGISTRY_NS" SERVICE_NAMESPACE="$NS" \
"$SCRIPT_DIR/init_registry.sh" -n "$REGISTRY_NS" "$ACTION" || rc=$?
else
echo "WARN: init_registry.sh not found; ArgoCD deploy skipped."
echo "WARN: init_registry.sh not found; registry deploy skipped."
fi
if [ -x "$SCRIPT_DIR/init_garage_store.sh" ]; then

207
etc/init_gitea.sh Normal file
View File

@ -0,0 +1,207 @@
#!/usr/bin/env bash
set -euo pipefail
PROG="init_gitea"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1090
source "$SCRIPT_DIR/prole_cfg.sh"
MODE="$(prole_normalize_mode "${PROLE_MODE:-${DEPLOYMENT_MODE:-k3d}}")"
NAMESPACE="${GITEA_NAMESPACE:-}"
CFG_PATH=""
FORCE=0
usage() {
cat <<EOF
Usage:
$PROG [options] [deploy]
Options:
--mode <k3d|k3s|k8s|local> Deployment mode (default: ${MODE:-k3d})
-n, --namespace <name> Target namespace (default: gitea)
-c, --config <prole.cfg> Path to prole.cfg (defaults to detected)
--force Delete existing release before deploy
--help Show this help
Behavior:
- Prefers Helm install/upgrade (gitea-charts/gitea).
- Falls back to applying a generated Deployment/Service/Ingress manifest.
- If a local registry is enabled, the image is mirrored there first.
EOF
}
die() { echo "[ERROR] $*" >&2; exit 2; }
log() { echo "[INFO] $*"; }
warn() { echo "[WARN] $*" >&2; }
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) MODE="$(prole_normalize_mode "${2:-}")"; shift 2 ;;
--mode=*) MODE="$(prole_normalize_mode "${1#*=}")"; shift 1 ;;
-n|--namespace) NAMESPACE="${2:-}"; shift 2 ;;
--namespace=*) NAMESPACE="${1#*=}"; shift 1 ;;
-c|--config) CFG_PATH="${2:-}"; shift 2 ;;
--config=*) CFG_PATH="${1#*=}"; shift 1 ;;
--force) FORCE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) break ;;
esac
done
# Resolve config path and namespace defaults from prole.cfg when present
if [[ -z "$CFG_PATH" && -n "${PROLE_CONF:-}" && -f "${PROLE_CONF}/prole.cfg" ]]; then
CFG_PATH="${PROLE_CONF}/prole.cfg"
elif [[ -z "$CFG_PATH" && -f "$SCRIPT_DIR/../conf/prole.cfg" ]]; then
CFG_PATH="$SCRIPT_DIR/../conf/prole.cfg"
fi
if [[ -z "$NAMESPACE" && -n "$CFG_PATH" ]]; then
maybe_ns="$(_prole_cfg_extract_key "$CFG_PATH" "GITOPS_NAMESPACE")"
[[ -z "$maybe_ns" ]] && maybe_ns="$(_prole_cfg_extract_key "$CFG_PATH" "GITEA_NAMESPACE")"
NAMESPACE="$maybe_ns"
fi
NAMESPACE="${NAMESPACE:-gitea}"
export GITEA_NAMESPACE="$NAMESPACE"
case "$MODE" in
k3d|k3s|k8s|local) ;;
*) die "Unsupported mode '$MODE' (use k3d, k3s, k8s, or local)" ;;
esac
export PROLE_MODE="$MODE"
command -v kubectl >/dev/null || die "kubectl not found"
command -v helm >/dev/null || warn "helm not found — will use manifest fallback"
kubectl get ns "$NAMESPACE" >/dev/null 2>&1 || kubectl create namespace "$NAMESPACE" >/dev/null 2>&1
CHART_REPO="https://dl.gitea.com/charts"
CHART_NAME="gitea-charts/gitea"
RELEASE_NAME="gitea"
IMAGE_REPO_DEFAULT="gitea/gitea"
IMAGE_TAG="${GITEA_IMAGE_TAG:-1.22.3}"
IMAGE_REPO="$IMAGE_REPO_DEFAULT"
# Optional reset
if [[ "$FORCE" -eq 1 ]]; then
warn "--force specified; removing existing Gitea resources in namespace $NAMESPACE"
if command -v helm >/dev/null 2>&1; then
helm uninstall "$RELEASE_NAME" -n "$NAMESPACE" >/dev/null 2>&1 || true
fi
kubectl -n "$NAMESPACE" delete deploy/gitea svc/gitea-http svc/gitea-ssh ingress/gitea >/dev/null 2>&1 || true
fi
# Mirror to local registry if enabled
if _prole_local_registry_enabled && command -v docker >/dev/null; then
HOST_REG="${LOCAL_REGISTRY:-${PROLE_LOCAL_REGISTRY:-}}"
INTERNAL_REG="${LOCAL_REGISTRY_INTERNAL:-${PROLE_LOCAL_REGISTRY_INTERNAL:-}}"
if [[ -n "$HOST_REG" ]]; then
HOST_IMG="${HOST_REG}/${IMAGE_REPO_DEFAULT}"
log "Pulling ${IMAGE_REPO_DEFAULT}:${IMAGE_TAG}"
docker pull "${IMAGE_REPO_DEFAULT}:${IMAGE_TAG}" >/dev/null
log "Tagging ${HOST_IMG}:${IMAGE_TAG}"
docker tag "${IMAGE_REPO_DEFAULT}:${IMAGE_TAG}" "${HOST_IMG}:${IMAGE_TAG}"
log "Pushing ${HOST_IMG}:${IMAGE_TAG}"
docker push "${HOST_IMG}:${IMAGE_TAG}" >/dev/null || warn "Push to local registry failed; continuing with upstream image"
if [[ -n "$INTERNAL_REG" ]]; then
IMAGE_REPO="${INTERNAL_REG}/gitea/gitea"
else
IMAGE_REPO="${HOST_REG}/gitea/gitea"
fi
fi
fi
deploy_with_helm() {
command -v helm >/dev/null 2>&1 || return 1
helm repo add gitea-charts "$CHART_REPO" >/dev/null 2>&1 || true
helm repo update >/dev/null 2>&1 || true
# Minimal config: NodePort or ClusterIP + Ingress depending on environment.
# Avoid heavy persistence defaults; users can override via values later.
helm upgrade --install "$RELEASE_NAME" "$CHART_NAME" \
-n "$NAMESPACE" \
--set image.repository="$IMAGE_REPO" \
--set image.tag="$IMAGE_TAG" \
--set service.http.type=ClusterIP \
--set service.ssh.type=ClusterIP \
--set gitea.admin.username="${GITEA_ADMIN_USER:-gitea_admin}" \
--set gitea.admin.password="${GITEA_ADMIN_PASSWORD:-gitea_admin}" \
--set gitea.admin.email="${GITEA_ADMIN_EMAIL:-gitea_admin@example.local}" \
--wait --timeout 10m
}
apply_manifest_fallback() {
warn "Helm unavailable; applying fallback manifest"
cat <<EOF | kubectl apply -n "$NAMESPACE" -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
spec:
replicas: 1
selector:
matchLabels:
app: gitea
template:
metadata:
labels:
app: gitea
spec:
containers:
- name: gitea
image: ${IMAGE_REPO}:${IMAGE_TAG}
ports:
- name: http
containerPort: 3000
- name: ssh
containerPort: 2222
env:
- name: GITEA__server__ROOT_URL
value: "http://gitea.${NAMESPACE}.svc.cluster.local:3000"
---
apiVersion: v1
kind: Service
metadata:
name: gitea-http
spec:
selector:
app: gitea
ports:
- name: http
port: 3000
targetPort: 3000
---
apiVersion: v1
kind: Service
metadata:
name: gitea-ssh
spec:
selector:
app: gitea
ports:
- name: ssh
port: 2222
targetPort: 2222
EOF
}
ACTION="${1:-deploy}"
case "$ACTION" in
deploy|"") ;;
*) usage; die "Unknown action '$ACTION'" ;;
esac
log "Deploying Gitea to namespace '$NAMESPACE' (mode=$MODE)"
if deploy_with_helm; then
log "Gitea deployed via Helm"
else
apply_manifest_fallback
log "Gitea deployed via manifest fallback"
fi
log "Waiting for deployment rollout..."
kubectl -n "$NAMESPACE" rollout status deploy/gitea --timeout=10m
log "Done. Service endpoints:"
kubectl -n "$NAMESPACE" get svc gitea-http gitea-ssh

View File

@ -57,6 +57,40 @@ OPENBAO_PORT_FORWARD_LOCAL=${OPENBAO_PORT_FORWARD_LOCAL:-8200}
log() { printf '%s\n' "$*"; }
err() { printf '%s\n' "$*" >&2; }
wait_for_ad_forwarder_ready() {
local ns="$1"
local deploy_name="$2"
local service_name="$3"
local timeout_s="${4:-120}"
log "Waiting for AD forwarder deployment '${deploy_name}' to become available in namespace '${ns}' ..."
if ! kubectl -n "$ns" rollout status deploy/"$deploy_name" --timeout="${timeout_s}s"; then
err "ERROR: AD forwarder deployment '${deploy_name}' did not become ready in namespace '${ns}'."
kubectl -n "$ns" get pods -l "app=${deploy_name}" -o wide 1>&2 || true
kubectl -n "$ns" describe deploy "${deploy_name}" 1>&2 || true
kubectl -n "$ns" logs -l "app=${deploy_name}" --tail=200 1>&2 || true
exit 1
fi
log "Waiting for AD forwarder service '${service_name}' endpoints in namespace '${ns}' ..."
local deadline=$((SECONDS + timeout_s))
while [[ $SECONDS -lt $deadline ]]; do
local ip
ip=$(kubectl -n "$ns" get endpoints "$service_name" -o jsonpath='{.subsets[*].addresses[*].ip}' 2>/dev/null | tr ' ' '\n' | head -n 1 || true)
if [[ -n "$ip" ]]; then
log "[OK] AD forwarder service '${service_name}' has endpoints (e.g. ${ip})."
return 0
fi
sleep 2
done
err "ERROR: AD forwarder service '${service_name}' has no ready endpoints in namespace '${ns}' after ${timeout_s}s."
kubectl -n "$ns" get svc "$service_name" -o wide 1>&2 || true
kubectl -n "$ns" get endpoints "$service_name" -o yaml 1>&2 || true
kubectl -n "$ns" get pods -l "app=${deploy_name}" -o wide 1>&2 || true
exit 1
}
ensure_tools() {
for t in kubectl curl jq; do
command -v "$t" >/dev/null || { err "Missing required tool: $t"; exit 1; }
@ -817,7 +851,7 @@ $(
)
EOF
kubectl -n "$KRB5_AD_NAMESPACE" rollout status deploy/${KRB5_AD_PROXY_NAME} --timeout=120s || true
wait_for_ad_forwarder_ready "$KRB5_AD_NAMESPACE" "$KRB5_AD_PROXY_NAME" "$KRB5_AD_SERVICE_NAME" 120
}
cleanup_ad_forwarder() {

View File

@ -4,8 +4,9 @@ set -euo pipefail
# init_registry.sh
# Purpose:
# - Deploy ArgoCD into Kubernetes
# - Deploy registry:2 into k3s clusters (service namespace) when enabled
# - Deploy registry support for local clusters
# - k3s: deploy in-cluster `registry:2` into the service namespace
# - k3d: create/connect a `k3d` registry on port 5000
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
@ -42,16 +43,6 @@ ACTION=""
NAMESPACE_OVERRIDE=""
REGISTRY_NAMESPACE_OVERRIDE=""
if [[ -d "$SCRIPT_DIR/../k8s/argocd" ]]; then
ARGOCD_MANIFEST_DIR="$SCRIPT_DIR/../k8s/argocd"
elif [[ -n "${PROLE_HOME:-}" && -d "$PROLE_HOME/k8s/argocd" ]]; then
ARGOCD_MANIFEST_DIR="$PROLE_HOME/k8s/argocd"
else
ARGOCD_MANIFEST_DIR="$SCRIPT_DIR/../k8s/argocd"
fi
ARGOCD_MANIFEST_FILE=${ARGOCD_MANIFEST_FILE:-"$ARGOCD_MANIFEST_DIR/install.yaml"}
if [[ -d "$SCRIPT_DIR/../k8s/registry" ]]; then
REGISTRY_MANIFEST_DIR="$SCRIPT_DIR/../k8s/registry"
elif [[ -n "${PROLE_HOME:-}" && -d "$PROLE_HOME/k8s/registry" ]]; then
@ -66,9 +57,12 @@ usage() {
cat <<EOF
Usage: init_registry.sh [-n|--namespace NS] [-r|--registry-namespace NS] <start|stop|status|restart|initialize|update|reload>
Deploys ArgoCD into the target namespace and, for k3s, deploys registry:2
into the registry namespace (default: SERVICE_NAMESPACE).
Note: The local Docker registry (port 5000) is managed separately for k3d.
Deploys registry support for local clusters.
- In k3s mode, deploys `registry:2` into the service namespace (default: SERVICE_NAMESPACE).
- In k3d mode, manages the `k3d` registry (port 5000) and removes any in-cluster registry resources.
NOTE: `-r/--registry-namespace` is kept for backwards compatibility and is treated as an alias for `-n/--namespace`.
EOF
}
@ -123,16 +117,10 @@ while [[ $# -gt 0 ]]; do
shift
done
if [[ -n "$NAMESPACE_OVERRIDE" ]]; then
ARGOCD_NAMESPACE="$NAMESPACE_OVERRIDE"
elif [[ -n "${ARGOCD_NAMESPACE:-}" ]]; then
ARGOCD_NAMESPACE="$ARGOCD_NAMESPACE"
else
ARGOCD_NAMESPACE="argocd"
fi
if [[ -n "$REGISTRY_NAMESPACE_OVERRIDE" ]]; then
REGISTRY_NAMESPACE="$REGISTRY_NAMESPACE_OVERRIDE"
elif [[ -n "$NAMESPACE_OVERRIDE" ]]; then
REGISTRY_NAMESPACE="$NAMESPACE_OVERRIDE"
elif [[ -n "${REGISTRY_NAMESPACE:-}" ]]; then
REGISTRY_NAMESPACE="$REGISTRY_NAMESPACE"
else
@ -151,15 +139,7 @@ if [[ "$_mode_resolved" == "k3s" && ( -z "${REGISTRY_NAMESPACE:-}" || "${REGISTR
fi
unset _mode_resolved
export ARGOCD_NAMESPACE
export REGISTRY_NAMESPACE
export NAMESPACE="$ARGOCD_NAMESPACE"
ARGOCD_LABEL_SELECTOR=${ARGOCD_LABEL_SELECTOR:-"app.kubernetes.io/part-of=argocd"}
ARGOCD_SERVER_SERVICE=${ARGOCD_SERVER_SERVICE:-argocd-server}
ARGOCD_PORT_FORWARD_LOCAL=${ARGOCD_PORT_FORWARD_LOCAL:-8081}
ARGOCD_PORT_FORWARD_REMOTE=${ARGOCD_PORT_FORWARD_REMOTE:-80}
ARGOCD_NODE_SELECTOR=${ARGOCD_NODE_SELECTOR:-}
REGISTRY_NODE_SELECTOR=${REGISTRY_NODE_SELECTOR:-}
current_mode() {
@ -176,11 +156,7 @@ registry_enabled() {
[[ "$mode" == "k3s" || "$mode" == "k3d" ]]
}
resolve_argocd_node_selector() {
if [[ -n "$ARGOCD_NODE_SELECTOR" ]]; then
printf '%s' "$ARGOCD_NODE_SELECTOR"
return 0
fi
resolve_control_plane_node_selector() {
local node=""
node=$(kubectl get nodes -l 'node-role.kubernetes.io/control-plane' -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
if [[ -n "$node" ]]; then
@ -195,7 +171,7 @@ resolve_registry_node_selector() {
fi
# Default: pin to the control-plane node so hostPort:5000 is reachable via
# the k3s server host (e.g. myrddin.prole.org:5000).
resolve_argocd_node_selector
resolve_control_plane_node_selector
}
apply_registry_node_selector() {
@ -215,32 +191,6 @@ apply_registry_node_selector() {
-p "{\"spec\":{\"template\":{\"spec\":{\"nodeSelector\":{\"$key\":\"$val\"}}}}}" >/dev/null 2>&1 || true
}
apply_argocd_node_selector() {
local selector
selector="$(resolve_argocd_node_selector)"
if [[ -z "$selector" ]]; then
return 0
fi
local key="${selector%%=*}"
local val="${selector#*=}"
if [[ -z "$key" || -z "$val" ]]; then
echo "WARN: invalid ARGOCD_NODE_SELECTOR '$selector' (expected key=value); skipping." >&2
return 0
fi
for target in \
deployment/argocd-applicationset-controller \
deployment/argocd-dex-server \
deployment/argocd-notifications-controller \
deployment/argocd-repo-server \
deployment/argocd-server \
deployment/argocd-redis \
statefulset/argocd-application-controller; do
kubectl -n "$ARGOCD_NAMESPACE" patch "$target" \
--type merge \
-p "{\"spec\":{\"template\":{\"spec\":{\"nodeSelector\":{\"$key\":\"$val\"}}}}}" >/dev/null 2>&1 || true
done
}
ensure_tools() {
command -v kubectl >/dev/null || { echo "Missing required tool: kubectl" >&2; exit 1; }
}
@ -256,14 +206,6 @@ ensure_namespace() {
fi
}
render_manifest() {
if [[ ! -f "$ARGOCD_MANIFEST_FILE" ]]; then
echo "ERROR: ArgoCD manifest not found: $ARGOCD_MANIFEST_FILE" >&2
exit 1
fi
sed "s|\\${ARGOCD_NAMESPACE}|$ARGOCD_NAMESPACE|g" "$ARGOCD_MANIFEST_FILE"
}
render_registry_manifest() {
if [[ ! -f "$REGISTRY_MANIFEST_FILE" ]]; then
echo "ERROR: Registry manifest not found: $REGISTRY_MANIFEST_FILE" >&2
@ -276,19 +218,6 @@ cleanup_registry_k8s() {
render_registry_manifest | kubectl delete -n "$REGISTRY_NAMESPACE" -f - --ignore-not-found >/dev/null 2>&1 || true
}
apply_k8s() {
echo "Applying ArgoCD manifest to namespace '$ARGOCD_NAMESPACE' ..."
render_manifest | kubectl apply --server-side --force-conflicts --field-manager=prole-installer --validate=false -n "$ARGOCD_NAMESPACE" -f -
apply_argocd_node_selector
kubectl rollout status deploy/argocd-server -n "$ARGOCD_NAMESPACE" --timeout=${ROLLOUT_TIMEOUT:-300s} || true
kubectl rollout status deploy/argocd-repo-server -n "$ARGOCD_NAMESPACE" --timeout=${ROLLOUT_TIMEOUT:-300s} || true
kubectl rollout status deploy/argocd-dex-server -n "$ARGOCD_NAMESPACE" --timeout=${ROLLOUT_TIMEOUT:-300s} || true
kubectl rollout status deploy/argocd-applicationset-controller -n "$ARGOCD_NAMESPACE" --timeout=${ROLLOUT_TIMEOUT:-300s} || true
kubectl rollout status deploy/argocd-notifications-controller -n "$ARGOCD_NAMESPACE" --timeout=${ROLLOUT_TIMEOUT:-300s} || true
kubectl rollout status deploy/argocd-redis -n "$ARGOCD_NAMESPACE" --timeout=${ROLLOUT_TIMEOUT:-300s} || true
kubectl rollout status statefulset/argocd-application-controller -n "$ARGOCD_NAMESPACE" --timeout=${ROLLOUT_TIMEOUT:-300s} || true
}
configure_k3d_insecure_registry() {
local cluster_name="${1:-knoe-dev-cluster}"
local reg_host="k3d-prole-registry"
@ -454,11 +383,6 @@ apply_registry() {
kubectl rollout status deploy/registry -n "$REGISTRY_NAMESPACE" --timeout=${ROLLOUT_TIMEOUT:-300s} || true
}
delete_k8s() {
echo "Removing ArgoCD resources from namespace '$ARGOCD_NAMESPACE' ..."
render_manifest | kubectl delete -n "$ARGOCD_NAMESPACE" -f - --ignore-not-found
}
delete_registry() {
local mode
mode=$(current_mode)
@ -475,12 +399,18 @@ delete_registry() {
render_registry_manifest | kubectl delete -n "$REGISTRY_NAMESPACE" -f - --ignore-not-found
}
status_k8s() {
kubectl -n "$ARGOCD_NAMESPACE" get deploy,statefulset,svc -l "$ARGOCD_LABEL_SELECTOR" 2>/dev/null || true
kubectl -n "$ARGOCD_NAMESPACE" get pods -l "$ARGOCD_LABEL_SELECTOR" 2>/dev/null || true
}
status_registry() {
local mode
mode=$(current_mode)
if [[ "$mode" == "k3d" ]]; then
if command -v k3d >/dev/null 2>&1; then
k3d registry list 2>/dev/null || true
fi
if command -v docker >/dev/null 2>&1; then
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}' 2>/dev/null | grep -E 'k3d-prole-registry|prole-registry' || true
fi
return 0
fi
kubectl -n "$REGISTRY_NAMESPACE" get deploy,svc -l "app=registry" 2>/dev/null || true
kubectl -n "$REGISTRY_NAMESPACE" get pods -l "app=registry" 2>/dev/null || true
}
@ -488,27 +418,21 @@ status_registry() {
case "${ACTION:-}" in
start|initialize|update|reload|restart)
ensure_tools
ensure_namespace "$ARGOCD_NAMESPACE"
apply_k8s
if registry_enabled; then
ensure_namespace "$REGISTRY_NAMESPACE"
apply_registry
else
echo "Skipping registry deploy (mode=$(current_mode))"
fi
prole_register_port_forward "argocd" "$ARGOCD_NAMESPACE" "svc/${ARGOCD_SERVER_SERVICE}" \
"$ARGOCD_PORT_FORWARD_LOCAL" "$ARGOCD_PORT_FORWARD_REMOTE" "0.0.0.0" "TCP" "ArgoCD"
;;
stop)
ensure_tools
delete_k8s
if registry_enabled; then
delete_registry
fi
;;
status)
ensure_tools
status_k8s
if registry_enabled; then
status_registry
fi

View File

@ -4,7 +4,7 @@ set -euo pipefail
# init_service_layer.sh
# Purpose:
# - Deploy the Prole service layer (ArgoCD, OpenTofu, Garage, OpenBao; Kerberos optional)
# - Deploy the Prole service layer (OpenTofu, Garage, OpenBao, Kong; Kerberos optional)
# - Keep service-layer resources grouped in SERVICE_NAMESPACE
# - Migrate service layer to a new namespace
@ -36,10 +36,11 @@ SERVICE_NAMESPACE_OVERRIDE=""
FROM_NAMESPACE=""
TO_NAMESPACE=""
ENABLE_KERBEROS=0
ENABLE_ARGOCD=0
usage() {
cat <<EOF
Usage: init_service_layer.sh [-n|--namespace NS] [-k|--kerberos] [--from OLD_NS] [--to NEW_NS] <start|update|restart|status|stop|migrate>
Usage: init_service_layer.sh [-n|--namespace NS] [-k|--kerberos] [-a|--argocd] [--from OLD_NS] [--to NEW_NS] <start|update|restart|status|stop|migrate>
EOF
}
@ -71,6 +72,10 @@ while [[ $# -gt 0 ]]; do
ENABLE_KERBEROS=1
shift
;;
-a|--argocd)
ENABLE_ARGOCD=1
shift
;;
--from)
shift
FROM_NAMESPACE="${1:-}"
@ -215,8 +220,10 @@ deploy_service_layer() {
*) kdc_action="update" ;;
esac
ARGOCD_NAMESPACE="$ARGOCD_NS" REGISTRY_NAMESPACE="$REGISTRY_NS" SERVICE_NAMESPACE="$ns" \
"$SCRIPT_DIR/init_registry.sh" -n "$ARGOCD_NS" --registry-namespace "$REGISTRY_NS" "$argocd_action" || rc=$?
if [[ "$ENABLE_ARGOCD" == "1" ]]; then
ARGOCD_NAMESPACE="$ARGOCD_NS" SERVICE_NAMESPACE="$ns" \
"$SCRIPT_DIR/init_argocd.sh" -n "$ARGOCD_NS" "$argocd_action" || rc=$?
fi
OPENTOFU_NAMESPACE="$ns" OPENTOFU_SECRET_NAMESPACE="${NAMESPACE:-$ns}" OPENTOFU_OPENBAO_NAMESPACE="$ns" SERVICE_NAMESPACE="$ns" \
"$SCRIPT_DIR/init_opentofu.sh" -n "$ns" "$opentofu_action" || rc=$?
@ -244,8 +251,10 @@ deploy_service_layer() {
cleanup_old_namespace() {
local ns="$1"
log "Cleaning up service layer in old namespace '$ns' ..."
ARGOCD_NAMESPACE="$ARGOCD_NS" REGISTRY_NAMESPACE="$REGISTRY_NS" SERVICE_NAMESPACE="$ns" \
"$SCRIPT_DIR/init_registry.sh" -n "$ARGOCD_NS" --registry-namespace "$REGISTRY_NS" stop || true
if [[ "$ENABLE_ARGOCD" == "1" ]]; then
ARGOCD_NAMESPACE="$ARGOCD_NS" SERVICE_NAMESPACE="$ns" \
"$SCRIPT_DIR/init_argocd.sh" -n "$ARGOCD_NS" stop || true
fi
OPENTOFU_NAMESPACE="$ns" SERVICE_NAMESPACE="$ns" \
"$SCRIPT_DIR/init_opentofu.sh" -n "$ns" stop || true
OPENBAO_NAMESPACE="$ns" SERVICE_NAMESPACE="$ns" \

View File

@ -925,15 +925,60 @@ prole_ensure_kube_context() {
current_context=$(kubectl config current-context 2>/dev/null || true)
if [[ -n "${KUBECONTEXT:-}" && "$current_context" != "$KUBECONTEXT" ]]; then
echo "DEBUG: prole_ensure_kube_context: Switching context to '${KUBECONTEXT}'" >&2
echo "INFO: Switching kubectl context to '${KUBECONTEXT}' (prole.cfg overrides local env)" >&2
if kubectl config use-context "$KUBECONTEXT" >/dev/null 2>&1; then
echo "INFO: kubectl context set to '${KUBECONTEXT}'" >&2
return 0
else
echo "ERROR: Could not switch to context '${KUBECONTEXT}'. Available contexts:" >&2
kubectl config get-contexts --no-headers 2>/dev/null | awk '{print " "$2}' >&2 || true
return 1
local desired_context
desired_context="$KUBECONTEXT"
local context_names
context_names=$(kubectl config get-contexts -o name 2>/dev/null || true)
local do_switch
do_switch=1
# If the requested context isn't present, try to map common shorthand to real contexts.
# For k3d, contexts are typically named `k3d-<cluster_name>`.
if ! grep -Fxq "$desired_context" <<<"$context_names"; then
if [[ "$mode" == "k3d" && "$desired_context" != k3d-* ]]; then
local prefixed
prefixed="k3d-${desired_context}"
if grep -Fxq "$prefixed" <<<"$context_names"; then
desired_context="$prefixed"
fi
fi
fi
# k3s-generated kubeconfigs often contain a single context called `default`.
# If the config only has one context, use it even when the configured KUBECONTEXT name
# doesn't match.
if ! grep -Fxq "$desired_context" <<<"$context_names"; then
local only_context=""
local context_count=0
while IFS= read -r _ctx; do
[[ -z "${_ctx:-}" ]] && continue
context_count=$((context_count + 1))
only_context="$_ctx"
done <<<"$context_names"
unset _ctx
if [[ $context_count -eq 1 && -n "${only_context:-}" ]]; then
echo "WARN: Requested kubectl context '${desired_context}' not found in active KUBECONFIG; using only available context '${only_context}'" >&2
desired_context="$only_context"
else
echo "WARN: Requested kubectl context '${desired_context}' not found in active KUBECONFIG; skipping explicit context switch and falling back to mode-based selection" >&2
do_switch=0
fi
fi
if [[ $do_switch -eq 1 ]]; then
echo "DEBUG: prole_ensure_kube_context: Switching context to '${desired_context}'" >&2
echo "INFO: Switching kubectl context to '${desired_context}' (prole.cfg overrides local env)" >&2
if kubectl config use-context "$desired_context" >/dev/null 2>&1; then
echo "INFO: kubectl context set to '${desired_context}'" >&2
return 0
else
echo "ERROR: Could not switch to context '${desired_context}'. Available contexts:" >&2
kubectl config get-contexts --no-headers 2>/dev/null | awk '{print " "$2}' >&2 || true
return 1
fi
fi
else
echo "DEBUG: prole_ensure_kube_context: current_context='${current_context}', KUBECONTEXT='${KUBECONTEXT:-<not-set>}'" >&2

View File

@ -118,6 +118,8 @@ if [[ -z "$PROLE_NAMESPACE" ]]; then
PROLE_NAMESPACE="default"
fi
_deployment_mode="$(_read_cfg_value 'DEPLOYMENT_MODE')"
if [[ -z "$KUBECONFIG" ]]; then
_kc="$(_read_cfg_value 'KUBECONFIG')"
if [[ -n "$_kc" && -f "$_kc" ]]; then
@ -126,8 +128,12 @@ if [[ -z "$KUBECONFIG" ]]; then
fi
# Fallback: project-local kubeconfig (only if it can reach the cluster)
if [[ -z "${KUBECONFIG:-}" && -f "$PROJECT_ROOT/prole-k3s.kubeconfig" ]]; then
if KUBECONFIG="$PROJECT_ROOT/prole-k3s.kubeconfig" kubectl cluster-info >/dev/null 2>&1; then
export KUBECONFIG="$PROJECT_ROOT/prole-k3s.kubeconfig"
# Only auto-select the project-local k3s kubeconfig when explicitly running in k3s mode.
# In k3d mode, this kubeconfig may be reachable but is the wrong cluster.
if [[ "${_deployment_mode,,}" == "k3s" ]]; then
if KUBECONFIG="$PROJECT_ROOT/prole-k3s.kubeconfig" kubectl cluster-info >/dev/null 2>&1; then
export KUBECONFIG="$PROJECT_ROOT/prole-k3s.kubeconfig"
fi
fi
fi
@ -360,7 +366,11 @@ if [[ -f "$SCRIPT_DIR/status_common_services.sh" ]]; then
echo ""
printf "${BOLD}Common Services${NC}\n"
echo "───────────────────────────────────────────"
cs_args=("-n" "$NAMESPACE")
_service_ns="$(_read_cfg_value 'SERVICE_NAMESPACE')"
if [[ -z "${_service_ns:-}" ]]; then
_service_ns="$PROLE_NAMESPACE"
fi
cs_args=("-n" "${_service_ns:-default}")
if _is_true "${_kerberos_enabled:-false}"; then
cs_args+=("-k")
fi

View File

@ -25,7 +25,7 @@ usage() {
cat <<EOF
Usage: status_common_services.sh [-n|--namespace NS] [-k|--kerberos]
Checks common infrastructure services (ArgoCD, OpenTofu, Garage, OpenBao).
Checks common infrastructure services (Registry, OpenTofu, Garage, OpenBao, Kong, Cert-Manager).
Use -k to include the Prole KDC (auth) checks.
EOF
}
@ -78,7 +78,7 @@ if [ -z "$NS" ]; then
NS="default"
fi
ARGOCD_NS="${ARGOCD_NAMESPACE:-argocd}"
REGISTRY_NS="${REGISTRY_NAMESPACE:-${NS}}"
KONG_NS="${KONG_NAMESPACE:-${SERVICE_NAMESPACE:-${NAMESPACE:-}}}"
CERTMGR_NS="${CERTMGR_NAMESPACE:-cert-manager}"
KONG_NAME="${KONG_NAME:-prole-svc-kong}"
@ -95,6 +95,20 @@ if ! command -v kubectl >/dev/null 2>&1; then
exit 1
fi
current_mode() {
if declare -F prole_normalize_mode >/dev/null 2>&1; then
prole_normalize_mode "${PROLE_MODE:-${DEPLOYMENT_MODE:-}}"
return 0
fi
printf '%s' "${PROLE_MODE:-${DEPLOYMENT_MODE:-}}"
}
MODE="$(current_mode)"
REGISTRY_CHECK=0
if [[ "$MODE" == "k3s" || "$MODE" == "k3d" ]]; then
REGISTRY_CHECK=1
fi
timestamp=$(date "+%Y-%m-%d %H:%M:%S")
ctx=$(kubectl config current-context 2>/dev/null || true)
server=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}' 2>/dev/null || true)
@ -103,11 +117,12 @@ echo "Common service status"
echo "Time: $timestamp"
echo "Context: ${ctx:-<unknown>}"
echo "Server: ${server:-<unknown>}"
echo "Mode: ${MODE:-<unknown>}"
if [[ -n "${KUBECONFIG:-}" ]]; then
echo "Kubeconfig: $KUBECONFIG"
fi
echo "Namespace: $NS"
echo "ArgoCD Namespace: $ARGOCD_NS"
echo "Registry Namespace: $REGISTRY_NS"
echo "Kong Namespace: $KONG_NS"
echo "Cert-Manager Namespace: $CERTMGR_NS"
echo ""
@ -119,7 +134,11 @@ run_cmd() {
}
echo "== Services =="
run_cmd kubectl -n "$ARGOCD_NS" get svc argocd-server
if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then
run_cmd kubectl -n "$REGISTRY_NS" get svc registry
elif [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" == "k3d" ]]; then
run_cmd k3d registry list
fi
run_cmd kubectl -n "$NS" get svc opentofu garage openbao
if [[ "$ENABLE_KERBEROS" == "1" ]]; then
run_cmd kubectl -n "$NS" get svc auth
@ -128,8 +147,9 @@ run_cmd kubectl -n "$KONG_NS" get svc "$KONG_NAME"
run_cmd kubectl -n "$CERTMGR_NS" get svc cert-manager cert-manager-webhook
echo "== Workloads =="
run_cmd kubectl -n "$ARGOCD_NS" get deploy argocd-server argocd-repo-server argocd-dex-server argocd-applicationset-controller argocd-notifications-controller argocd-redis
run_cmd kubectl -n "$ARGOCD_NS" get statefulset argocd-application-controller
if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then
run_cmd kubectl -n "$REGISTRY_NS" get deploy registry
fi
run_cmd kubectl -n "$NS" get deploy opentofu
if kubectl -n "$NS" get statefulset openbao >/dev/null 2>&1; then
run_cmd kubectl -n "$NS" get statefulset openbao
@ -144,7 +164,9 @@ run_cmd kubectl -n "$KONG_NS" get deploy "$KONG_NAME"
run_cmd kubectl -n "$CERTMGR_NS" get deploy cert-manager cert-manager-cainjector cert-manager-webhook
echo "== Pods =="
run_cmd kubectl -n "$ARGOCD_NS" get pods | grep -Ei "argocd" || true
if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then
run_cmd kubectl -n "$REGISTRY_NS" get pods | grep -Ei "registry" || true
fi
run_cmd kubectl -n "$NS" get pods | grep -Ei "opentofu|garage|openbao" || true
if [[ "$ENABLE_KERBEROS" == "1" ]]; then
run_cmd kubectl -n "$NS" get pods | grep -Ei "auth" || true
@ -224,7 +246,52 @@ check_resource() {
fi
}
check_resource svc argocd-server "$ARGOCD_NS"
check_deploy_image() {
local name="$1"
local ns="$2"
local expected="$3"
local actual
actual=$(kubectl -n "$ns" get deploy "$name" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || true)
if [[ -z "${actual:-}" ]]; then
echo "[FAIL] image/$name is missing (ns=$ns)"
missing=$((missing + 1))
return 0
fi
if [[ "$actual" != "$expected" ]]; then
echo "[FAIL] image/$name expected '$expected' got '$actual' (ns=$ns)"
missing=$((missing + 1))
return 0
fi
echo "[OK] image/$name is '$expected' (ns=$ns)"
}
check_k3d_registry() {
if command -v k3d >/dev/null 2>&1; then
if k3d registry list --no-headers 2>/dev/null | awk '{print $1}' | grep -qx 'prole-registry'; then
echo "[OK] registry/registry exists (k3d: prole-registry)"
return 0
fi
fi
if command -v docker >/dev/null 2>&1; then
if docker ps --format '{{.Names}}' 2>/dev/null | grep -Eq '^(k3d-prole-registry|prole-registry)$'; then
echo "[OK] registry/registry exists (docker)"
return 0
fi
fi
echo "[FAIL] registry/registry is missing (k3d/docker)"
missing=$((missing + 1))
}
if [[ "$REGISTRY_CHECK" -eq 1 ]]; then
if [[ "$MODE" == "k3d" ]]; then
check_k3d_registry
else
check_resource svc registry "$REGISTRY_NS"
check_resource deploy registry "$REGISTRY_NS"
check_deploy_image registry "$REGISTRY_NS" 'registry:2'
fi
fi
check_resource svc opentofu "$NS"
check_resource svc garage "$NS"
check_resource svc openbao "$NS"
@ -235,12 +302,6 @@ check_resource svc "$KONG_NAME" "$KONG_NS"
check_resource svc cert-manager "$CERTMGR_NS"
check_resource svc cert-manager-webhook "$CERTMGR_NS"
check_resource deploy argocd-server "$ARGOCD_NS"
check_resource deploy argocd-repo-server "$ARGOCD_NS"
check_resource deploy argocd-dex-server "$ARGOCD_NS"
check_resource deploy argocd-applicationset-controller "$ARGOCD_NS"
check_resource deploy argocd-notifications-controller "$ARGOCD_NS"
check_resource deploy argocd-redis "$ARGOCD_NS"
check_resource deploy opentofu "$NS"
if kubectl -n "$NS" get statefulset openbao >/dev/null 2>&1; then
check_resource statefulset openbao "$NS"
@ -250,7 +311,6 @@ fi
if [[ "$ENABLE_KERBEROS" == "1" ]]; then
check_resource deploy auth "$NS"
fi
check_resource statefulset argocd-application-controller "$ARGOCD_NS"
check_resource statefulset garage "$NS"
check_resource deploy "$KONG_NAME" "$KONG_NS"
check_resource deploy cert-manager "$CERTMGR_NS"
@ -261,10 +321,12 @@ pod_filter="opentofu|garage|openbao|kong|cert-manager"
if [[ "$ENABLE_KERBEROS" == "1" ]]; then
pod_filter="opentofu|garage|openbao|auth|kong|cert-manager"
fi
analyze_pods "$ARGOCD_NS" "argocd"
analyze_pods "$NS" "$pod_filter"
analyze_pods "$KONG_NS" "kong"
analyze_pods "$CERTMGR_NS" "cert-manager"
if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then
analyze_pods "$REGISTRY_NS" "registry"
fi
if [[ -n "$blocked_lines" ]]; then
echo ""
@ -313,12 +375,10 @@ _workloads_for_comp() {
local _comp="$1"
local _ns="${COMP_NAMESPACE[$_comp]:-$NS}"
case "$_comp" in
argocd)
echo "statefulset argocd-application-controller $ARGOCD_NS"
for _d in argocd-server argocd-repo-server argocd-dex-server \
argocd-applicationset-controller argocd-notifications-controller argocd-redis; do
echo "deployment $_d $ARGOCD_NS"
done
registry)
if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then
echo "deployment registry $REGISTRY_NS"
fi
;;
openbao)
if kubectl -n "$_ns" get statefulset openbao >/dev/null 2>&1; then
@ -347,10 +407,12 @@ _recheck_pods() {
COMP_NAMESPACE=()
blocked_lines=""
blocked=0
analyze_pods "$ARGOCD_NS" "argocd"
analyze_pods "$NS" "$pod_filter"
analyze_pods "$KONG_NS" "kong"
analyze_pods "$CERTMGR_NS" "cert-manager"
if [[ "$REGISTRY_CHECK" -eq 1 && "$MODE" != "k3d" ]]; then
analyze_pods "$REGISTRY_NS" "registry"
fi
if [[ -n "$blocked_lines" ]]; then
blocked=1
fi
@ -358,8 +420,9 @@ _recheck_pods() {
# Print the name of each component that currently has blocked pods.
_blocked_comps() {
for _bc in argocd openbao opentofu garage auth kong certmgr; do
for _bc in registry openbao opentofu garage auth kong certmgr; do
[[ "$_bc" == "auth" && "$ENABLE_KERBEROS" != "1" ]] && continue
[[ "$_bc" == "registry" && ( "$REGISTRY_CHECK" -ne 1 || "$MODE" == "k3d" ) ]] && continue
[[ ${BLOCKED_COUNT["$_bc"]:-0} -gt 0 ]] && echo "$_bc"
done
}

View File

@ -92,7 +92,7 @@ def _configure_unbuffered_io():
pass
class ProleInstallerBase:
class ProleInstaller:
"""Shared business logic for both silent and interactive installer modes.
Subclasses must provide a :meth:`_get_input` implementation that bridges
@ -1509,6 +1509,169 @@ class ProleInstallerBase:
phase = ((ns.get("status") or {}).get("phase") or "").strip()
return phase == "Terminating"
# ------------------------------------------ blocked cluster reconciliation
def _classify_blocked_pod(self, env: dict, pod: dict) -> dict:
"""Classify common pod blockers into stable, testable categories.
Returns a dict with:
- `type`: primary classification (storage-first)
- `types`: list of all detected classification tags
"""
_ = env # reserved for future use
meta = pod.get("metadata") or {}
status = pod.get("status") or {}
name = (meta.get("name") or "").strip()
ns = (meta.get("namespace") or "").strip()
phase = (status.get("phase") or "").strip()
types: list[str] = []
# ContainerCreating / ImagePullBackOff etc.
for cs in status.get("containerStatuses") or []:
waiting = ((cs.get("state") or {}).get("waiting") or {})
reason = (waiting.get("reason") or "").strip()
if reason == "ContainerCreating":
types.append("container_creating")
elif reason:
types.append(f"container_waiting_{reason.lower()}")
# Unschedulable reasons.
for cond in status.get("conditions") or []:
try:
if (cond.get("type") or "").strip() != "PodScheduled":
continue
if (cond.get("status") or "").strip() != "False":
continue
if (cond.get("reason") or "").strip() != "Unschedulable":
continue
msg = ((cond.get("message") or "").strip() or "").lower()
if "persistent volumes" in msg and "bind" in msg:
types.append("unschedulable_due_to_pv_binding")
if "didn't match pod's node affinity" in msg or "node affinity/selector" in msg:
types.append("unschedulable_due_to_node_affinity")
if "had untolerated taint" in msg:
types.append("unschedulable_due_to_untolerated_taint")
except Exception:
continue
# Primary type: prefer storage/root-cause categories.
primary = "ok"
for preferred in (
"unschedulable_due_to_pv_binding",
"unschedulable_due_to_node_affinity",
"unschedulable_due_to_untolerated_taint",
"container_creating",
):
if preferred in types:
primary = preferred
break
if primary == "ok" and phase in ("Pending", "Unknown") and types:
primary = types[0]
# Dedupe while keeping order.
seen: set[str] = set()
deduped: list[str] = []
for t in types:
if t not in seen:
seen.add(t)
deduped.append(t)
return {
"pod": f"{ns}/{name}" if ns and name else (name or ns or "<unknown>"),
"namespace": ns,
"name": name,
"phase": phase,
"type": primary,
"types": deduped,
}
def _reconcile_blocked_cluster_state(self, env: dict, service_ns: str) -> None:
"""Detect hard cluster blockers and raise before running deployment scripts.
This is intentionally conservative: it attempts safe PV claimRef repair
and then surfaces clear diagnostics for scheduling/storage blockers.
"""
svc_ns = (service_ns or "").strip() or self._service_namespace()
# Always attempt safe stale Released PV claimRef repair first.
try:
self._repair_stale_released_pvs(env, reset=False)
except Exception as e:
self.err(f"[WARN] Stale PV claimRef repair failed: {e}")
pods_data = self._kubectl_get_json(env, ["get", "pod", "-A", "-o", "json"]) or {}
pods = pods_data.get("items") or []
classified = [self._classify_blocked_pod(env, p) for p in pods]
blocked = [c for c in classified if (c.get("type") or "") != "ok"]
# If we can't positively identify blocked pods, do not block deployment.
# This keeps behavior conservative when `kubectl` is unavailable.
if not blocked:
return
# Downstream missingness: surface but treat separately.
missing: list[str] = []
for kind in ("svc", "deploy"):
try:
res = subprocess.run(
["kubectl", "-n", svc_ns, "get", kind, "prole-svc-kong"],
env=env,
capture_output=True,
text=True,
timeout=10,
)
if res.returncode != 0:
missing.append(f"{kind}/prole-svc-kong")
except FileNotFoundError:
# If kubectl isn't available, don't guess missing resources.
pass
except Exception:
pass
all_types: set[str] = set()
for b in blocked:
for t in b.get("types") or []:
all_types.add(t)
# Group summary for easier scanning.
storage_types = [t for t in sorted(all_types) if "pv_binding" in t]
sched_types = [
t
for t in sorted(all_types)
if "node_affinity" in t or "untolerated_taint" in t
]
container_types = [t for t in sorted(all_types) if t.startswith("container_")]
lines: list[str] = ["Cluster prerequisites are blocked"]
if storage_types or any("pv_binding" in (b.get("type") or "") for b in blocked):
lines.append("\nStorage blockers:")
for t in storage_types or ["unschedulable_due_to_pv_binding"]:
lines.append(f"- {t}")
if sched_types:
lines.append("\nScheduling blockers:")
for t in sched_types:
lines.append(f"- {t}")
if container_types:
lines.append("\nContainer blockers:")
for t in container_types:
lines.append(f"- {t}")
if blocked:
lines.append("\nBlocked pods:")
for b in blocked:
lines.append(
f"- {b.get('pod')} type={b.get('type')} types={','.join(b.get('types') or [])}"
)
if missing:
lines.append("\nDownstream missing resources:")
for m in sorted(set(missing)):
lines.append(f"- {m}")
raise Exception("\n".join(lines))
def _ensure_namespace_ready(self, env: dict, name: str, timeout_s: int = 90) -> None:
"""Ensure a namespace is usable for deployments.
@ -2251,16 +2414,36 @@ class ProleInstallerBase:
images = {img for img in images if "supabase" not in img}
if include_kerberos_proxy:
images.add("ghcr.io/bsharp-tech/prole-kerberos-proxy:latest")
krb_img = os.environ.get("KRB5_AD_PROXY_IMAGE", "alpine/socat")
if krb_img:
images.add(krb_img)
return sorted(images)
def _get_docker_import_dir(self) -> str:
"""Best-effort accessor for a docker-import directory.
GUI uses a Tk variable with `.get()`, while console flows tend to store a
plain string. This helper normalizes both.
"""
val = getattr(self, "docker_import_dir", None)
if val is None:
return ""
try:
getter = getattr(val, "get", None)
if callable(getter):
val = getter()
except Exception:
pass
return (str(val) if val is not None else "").strip()
def _prepull_images_to_registry(
self,
include_supabase: bool,
include_kerberos_proxy: bool,
log: Callable[[str], None] | None = None,
) -> None:
) -> bool:
def _log(msg: str):
if log:
try:
@ -2269,48 +2452,120 @@ class ProleInstallerBase:
pass
self.log(msg)
cluster_env = ""
try:
cluster_env = self._get_input("init_cluster.cluster_env", "")
except Exception:
cluster_env = ""
if cluster_env and not _local_registry_enabled(cluster_env):
_log("[SKIP] Local registry disabled; skipping image pre-pull.")
return True
mode = _deployment_mode_from_env(cluster_env)
if mode == "k3s":
# Keep behavior consistent across installers: k3s relies on in-cluster
# image pulls/import paths rather than a local docker registry.
_log("[SKIP] Image pre-pull is not supported in k3s mode.")
return True
ensure_fn = getattr(self, "ensure_local_registry_available", None)
if not callable(ensure_fn):
ensure_fn = getattr(self, "_ensure_local_registry_available", None)
info = ensure_fn() if callable(ensure_fn) else None
if not info:
_log("[SKIP] Local registry unavailable; skipping image pre-pull.")
return False
registry, _cluster_registry = info
images = self._collect_dependent_images(
include_supabase, include_kerberos_proxy
)
if not images:
_log("[SKIP] No dependent images to prepull.")
return
reg = getattr(self, "local_registry_url", None)
if not reg:
reg = self.prole_cfg_data.get("Docker Build", {}).get("LOCAL_REGISTRY", "")
if not reg:
_log("[SKIP] No local registry configured; skipping prepull.")
return
host, _, port_str = reg.partition(":")
try:
port = int(port_str)
except (ValueError, TypeError):
port = 5000
if not _http_ping_registry(host, port):
_log(f"[SKIP] Registry {reg} not reachable; skipping prepull.")
return
_log("[SKIP] No dependent images found to pre-pull.")
return True
overall_ok = True
import_dir = self._get_docker_import_dir()
for image in images:
_log(f"[INFO] Pulling {image} ...")
try:
rc = subprocess.run(
["docker", "pull", image],
capture_output=True,
text=True,
timeout=300,
)
if rc.returncode != 0:
self.err(
f"[WARN] docker pull failed for {image}: {(rc.stderr or '').strip()}"
)
continue
except Exception as e:
self.err(f"[WARN] docker pull exception for {image}: {e}")
local_tag = image
if not image.startswith(f"{registry}/"):
local_tag = f"{registry}/{image}"
# 1. Check if already in local registry
_log(f"[INFO] Checking if {image} exists in local registry...")
check_reg = subprocess.run(
["docker", "pull", local_tag], capture_output=True, text=True
)
if check_reg.returncode == 0:
_log(f"[OK] {image} already exists in local registry as {local_tag}")
continue
ok = _push_docker_image(image, reg)
if ok:
_log(f"[OK] Pushed {image}{reg}")
else:
self.err(f"[WARN] Push failed for {image}{reg}")
# 2. Check if we already have it in local docker daemon
check_local = subprocess.run(
["docker", "image", "inspect", image],
capture_output=True,
text=True,
)
found = check_local.returncode == 0
if not found and import_dir and os.path.isdir(import_dir):
# 3. Check import directory
safe_name = image.replace("/", "_").replace(":", "_")
tar_path = Path(import_dir) / f"{safe_name}.tar"
if tar_path.exists():
_log(f"[INFO] Found {tar_path} in import directory, loading...")
load = subprocess.run(
["docker", "load", "-i", str(tar_path)],
capture_output=True,
text=True,
)
if load.returncode == 0:
found = True
else:
overall_ok = False
self.err(f"[WARN] Failed to load {tar_path}: {load.stderr}")
if not found:
# 4. Pull from upstream
_log(f"[INFO] Pulling {image} from upstream...")
pull = subprocess.run(
["docker", "pull", image], capture_output=True, text=True
)
if pull.returncode != 0:
overall_ok = False
self.err(pull.stdout or "")
self.err(pull.stderr or "")
self.err(f"[ERROR] docker pull failed for {image}")
continue
found = True
# If we have the image locally, tag and push to local registry
if found:
if local_tag != image:
tag = subprocess.run(
["docker", "tag", image, local_tag],
capture_output=True,
text=True,
)
if tag.returncode != 0:
overall_ok = False
self.err(tag.stdout or "")
self.err(tag.stderr or "")
self.err(
f"[ERROR] docker tag failed for {image} to {local_tag}"
)
continue
_log(f"[INFO] Pushing {local_tag} to local registry...")
if not _push_docker_image(local_tag, log_fn=self.log):
overall_ok = False
continue
_log(f"[OK] Stored {image} in local registry as {local_tag}")
return overall_ok
class _TeeWriter:
@ -2351,7 +2606,7 @@ class _TeeWriter:
return getattr(self._original, name)
class ProleSilentInstaller(ProleInstallerBase):
class ProleConsoleInstaller(ProleInstaller):
"""Console-based unattended installer driven by prole.cfg inputs."""
silent = True
@ -3128,237 +3383,6 @@ class ProleSilentInstaller(ProleInstallerBase):
self.prole_cfg_data["Docker Build"] = db
def _collect_dependent_images(
self, include_supabase: bool, include_kerberos_proxy: bool
) -> list[str]:
images = set()
for rel_dir in ("k8s/prole", "k8s/openbao"):
base_dir = self.project_root / rel_dir
if not base_dir.exists():
continue
images.update(_collect_images_from_files(list(base_dir.glob("*.yaml"))))
if include_supabase:
supa_home = _resolve_supabase_home(self.project_root)
if supa_home:
docker_dir = supa_home / "docker"
compose_files = [docker_dir / "docker-compose.yml"]
if os.environ.get("SUPABASE_USE_DEV_COMPOSE") == "1":
compose_files.append(docker_dir / "dev" / "docker-compose.dev.yml")
images.update(_collect_images_from_files(compose_files))
if not include_supabase:
images = {img for img in images if "supabase" not in img}
if include_kerberos_proxy:
krb_img = os.environ.get("KRB5_AD_PROXY_IMAGE", "alpine/socat")
if krb_img:
images.add(krb_img)
return sorted(images)
def _prepull_images_to_registry(
self,
include_supabase: bool,
include_kerberos_proxy: bool,
log: Callable[[str], None] | None = None,
) -> bool:
def _log(msg: str):
if log:
try:
log(msg)
except Exception:
pass
self.log(msg)
if not _local_registry_enabled(self._get_input("init_cluster.cluster_env", "")):
_log("[SKIP] Local registry disabled; skipping image pre-pull.")
return False
mode = _deployment_mode_from_env(
self._get_input("init_cluster.cluster_env", "")
)
if mode == "k3s":
return self._prepull_images_k3s(include_supabase, include_kerberos_proxy)
if mode != "k3d":
self.log("[SKIP] Local registry pre-pull only supported for k3d/k3s.")
return False
info = self._ensure_local_registry_available()
if not info:
self.err("[WARN] Local registry unavailable; skipping image pre-pull.")
return False
registry, _cluster_registry = info
images = self._collect_dependent_images(
include_supabase, include_kerberos_proxy
)
if not images:
self.log("[INFO] No dependent images found to pre-pull.")
return True
overall_ok = True
import_dir = self.docker_import_dir
for image in images:
local_tag = image
if not image.startswith(f"{registry}/"):
local_tag = f"{registry}/{image}"
# 1. Check if already in local registry
self.log(f"[INFO] Checking if {image} exists in local registry...")
check_reg = subprocess.run(
["docker", "pull", local_tag], capture_output=True, text=True
)
if check_reg.returncode == 0:
self.log(
f"[OK] {image} already exists in local registry as {local_tag}"
)
continue
# 2. Check if we already have it in local docker daemon
check_local = subprocess.run(
["docker", "image", "inspect", image], capture_output=True, text=True
)
found = check_local.returncode == 0
if not found and import_dir and os.path.isdir(import_dir):
# 3. Check import directory
safe_name = image.replace("/", "_").replace(":", "_")
tar_path = Path(import_dir) / f"{safe_name}.tar"
if tar_path.exists():
self.log(f"[INFO] Found {tar_path} in import directory, loading...")
load = subprocess.run(
["docker", "load", "-i", str(tar_path)],
capture_output=True,
text=True,
)
if load.returncode == 0:
found = True
else:
self.err(f"[WARN] Failed to load {tar_path}: {load.stderr}")
if not found:
# 4. Pull from Docker Hub
self.log(f"[INFO] Pulling {image} from Docker Hub...")
pull = subprocess.run(
["docker", "pull", image], capture_output=True, text=True
)
if pull.returncode != 0:
overall_ok = False
self.err(pull.stdout or "")
self.err(pull.stderr or "")
self.err(f"[ERROR] docker pull failed for {image}")
continue
found = True
# If we have the image locally, tag and push to local registry
if found:
if local_tag != image:
tag = subprocess.run(
["docker", "tag", image, local_tag],
capture_output=True,
text=True,
)
if tag.returncode != 0:
overall_ok = False
self.err(tag.stdout or "")
self.err(tag.stderr or "")
self.err(
f"[ERROR] docker tag failed for {image} to {local_tag}"
)
continue
self.log(f"[INFO] Pushing {local_tag} to local registry...")
if not _push_docker_image(local_tag, log_fn=self.log):
overall_ok = False
continue
self.log(f"[OK] Stored {image} in local registry as {local_tag}")
return overall_ok
def _prepull_images_k3s(
self, include_supabase: bool, include_kerberos_proxy: bool
) -> bool:
"""Stage images for k3s by pushing to the in-cluster `registry:2`."""
db_cfg = self.prole_cfg_data.get("Docker Build", {}) or {}
registry = (db_cfg.get("LOCAL_REGISTRY") or "").strip()
if not registry:
self.log(
"[SKIP] No LOCAL_REGISTRY configured for k3s; skipping image staging."
)
return False
images = self._collect_dependent_images(
include_supabase, include_kerberos_proxy
)
if not images:
self.log("[INFO] No dependent images found to stage for k3s.")
return True
overall_ok = True
import_dir = self.docker_import_dir
for image in images:
remote_tag = (
f"{registry}/{image}" if not image.startswith(f"{registry}/") else image
)
# 1. Check local docker daemon
check_local = subprocess.run(
["docker", "image", "inspect", image], capture_output=True, text=True
)
found = check_local.returncode == 0
# 2. Try docker-import directory
if not found and import_dir and os.path.isdir(import_dir):
safe_name = image.replace("/", "_").replace(":", "_")
tar_path = Path(import_dir) / f"{safe_name}.tar"
if tar_path.exists():
self.log(f"[INFO] Loading {tar_path} from import directory...")
load = subprocess.run(
["docker", "load", "-i", str(tar_path)],
capture_output=True,
text=True,
)
if load.returncode == 0:
found = True
else:
self.err(f"[WARN] Failed to load {tar_path}: {load.stderr}")
# 3. Pull from upstream
if not found:
self.log(f"[INFO] Pulling {image} ...")
pull = subprocess.run(
["docker", "pull", image], capture_output=True, text=True
)
if pull.returncode != 0:
self.err(f"[WARN] docker pull failed for {image}")
else:
found = True
if not found:
continue
# 4. Tag and push to k3s registry (skip if already present)
if remote_tag != image:
subprocess.run(
["docker", "tag", image, remote_tag], capture_output=True, text=True
)
if _registry_image_ref_exists(remote_tag):
self.log(f"[SKIP] {remote_tag} already exists in registry; skipping push.")
continue
self.log(f"[INFO] Pushing {remote_tag} to {registry} ...")
if _push_docker_image(remote_tag, log_fn=self.log):
self.log(f"[OK] Pushed {image} to {registry}")
else:
overall_ok = False
self.err(f"[ERROR] Failed to push {remote_tag} to {registry}")
return overall_ok
def _fetch_k3s_kubeconfig(self) -> Path | None:
"""Fetch a fresh kubeconfig from k3s via the Ansible playbook."""
kubeconfig = self.project_root / "prole-k3s.kubeconfig"
@ -4408,25 +4432,17 @@ class ProleSilentInstaller(ProleInstallerBase):
if env_key == "service":
try:
argocd_ns = self._argocd_namespace()
registry_ns = self._registry_namespace()
env = self._script_env_for_namespace(self._service_namespace())
env["ARGOCD_NAMESPACE"] = argocd_ns
env["REGISTRY_NAMESPACE"] = registry_ns
self.log("==> Registry/ArgoCD preflight")
self.log("==> Registry preflight")
self._run_script(
"init_registry.sh",
args=[
"-n",
argocd_ns,
"--registry-namespace",
registry_ns,
"update",
],
args=["-n", registry_ns, "update"],
env=env,
)
except Exception as e:
self.err(f"[WARN] Registry/ArgoCD preflight failed: {e}")
self.err(f"[WARN] Registry preflight failed: {e}")
# If this run was launched with a reset request, attempt to reclaim any
# strongly-matched stale Released PVs early, before later deploy/init
@ -4510,6 +4526,11 @@ class ProleSilentInstaller(ProleInstallerBase):
# Avoid deploying into a namespace that is currently being deleted.
self._ensure_namespace_ready(env, service_ns)
# Refuse to proceed when the cluster has hard prerequisites blocked
# (e.g. PV binding / scheduling issues). This prevents blind retries
# and surfaces actionable diagnostics.
self._reconcile_blocked_cluster_state(env=env, service_ns=service_ns)
# Check status before update
status_args = ["-n", service_ns]
if kerberos_enabled:
@ -5297,7 +5318,7 @@ def _prepare_k3s_pipeline(
# Load config to resolve namespace and secrets for pipeline tfvars
_log("==> Loading installer config...\n")
installer = ProleSilentInstaller(controller, str(cfg_path))
installer = ProleConsoleInstaller(controller, str(cfg_path))
try:
existing_inputs = installer._load_inputs_from_cfg()
except Exception:

View File

@ -1311,7 +1311,12 @@ def _default_opentofu_pipeline_url() -> str:
def _push_docker_image(image_tag: str, log_fn=None) -> bool:
"""Push Docker image with skopeo fallback for insecure registries."""
"""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:
@ -1333,11 +1338,93 @@ def _push_docker_image(image_tag: str, log_fn=None) -> bool:
f"[WARN] Docker push failed: {res.stderr.strip() if res.stderr else 'unknown error'}\n"
)
# 2. Try skopeo fallback for insecure registry
# 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.prole.org:5000`, look for
# `ssl/prole/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" / "prole" / f"{short}-registry.crt",
PROJECT_ROOT / "ssl" / "prole" / 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="prole-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")
# skopeo copy --dest-tls-verify=false docker-daemon:TAG docker://TAG
cmd = [
skopeo,
"copy",
@ -1345,12 +1432,12 @@ def _push_docker_image(image_tag: str, log_fn=None) -> bool:
f"docker-daemon:{image_tag}",
f"docker://{image_tag}",
]
res2 = subprocess.run(cmd, capture_output=True, text=True)
if res2.returncode == 0:
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: {res2.stderr.strip() if res2.stderr else 'unknown error'}\n"
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")

View File

@ -7,7 +7,7 @@ from pathlib import Path
from installer import prole_conf
from installer.core.controller import ProleController
from installer.core.actions import ProleSilentInstaller
from installer.core.actions import ProleConsoleInstaller
def _project_root() -> Path:
@ -81,7 +81,7 @@ def main(argv: list[str] | None = None) -> int:
cfg = str(prole_conf.entrypoint_path(prole_conf.resolve_prole_conf_dir(root)))
controller = ProleController(project_root=root, verbose=bool(args.verbose), cfg_path=Path(cfg))
installer = ProleSilentInstaller(controller, cfg_path=cfg)
installer = ProleConsoleInstaller(controller, cfg_path=cfg)
try:
existing_inputs = installer._load_inputs_from_cfg()

View File

@ -72,8 +72,8 @@ if platform.system() == "Darwin":
from installer.core.env import * # noqa: F401,F403
from installer.core.actions import (
ProleInstallerBase,
ProleSilentInstaller,
ProleInstaller,
ProleConsoleInstaller,
_prepare_k3s_pipeline,
_reset_k3s_namespace,
)
@ -98,6 +98,7 @@ from installer.ui.screens.security import SecurityScreenMixin
from installer.ui.screens.ollama import OllamaScreenMixin
from installer.ui.screens.supabase import SupabaseScreenMixin
from installer.ui.screens.gitops import GitOpsScreenMixin
from installer.ui.screens.argocd import ArgoCDScreenMixin
from installer.ui.screens.docker import DockerScreenMixin
from installer.ui.screens.build import BuildScreenMixin
from installer.ui.screens.packaging import PackagingScreenMixin
@ -110,7 +111,7 @@ from installer.ui.screens.cfg import ConfigMixin
# Composed installer class
# ---------------------------------------------------------------------------
class ProleInstaller(
ProleInstallerBase,
ProleInstaller,
ScreenBaseMixin,
NavigationMixin,
WelcomeScreenMixin,
@ -125,6 +126,7 @@ class ProleInstaller(
OllamaScreenMixin,
SupabaseScreenMixin,
GitOpsScreenMixin,
ArgoCDScreenMixin,
DockerScreenMixin,
BuildScreenMixin,
PackagingScreenMixin,
@ -152,6 +154,7 @@ class ProleInstaller(
"init_cluster.kerberos_enabled": "kerberos_enabled",
"init_cluster.supabase_enabled": "supabase_enabled",
"init_cluster.gitops_enabled": "gitops_enabled",
"init_cluster.argocd_enabled": "argocd_enabled",
"init_cluster.at_rest_encryption_enabled": "at_rest_encryption_enabled",
"init_cluster.selected_kubectx": "selected_kubectx",
"init_cluster.k3s_server_url": "k3s_server_url",
@ -160,6 +163,7 @@ class ProleInstaller(
"ollama_config.server_port": "ollama_server_port",
"ollama_config.model": "ollama_model",
"gitops.namespace": "gitops_namespace",
"argocd.namespace": "argocd_namespace",
}
def _get_input(self, key: str, default: str | None = None) -> str:
@ -353,6 +357,7 @@ class ProleInstaller(
("Initialization Scripts", "init_scripts"),
("Kerberos Authentication", "kerberos_config"),
("GitOps", "gitops_config"),
("ArgoCD", "argocd_config"),
("Supabase", "supabase_config"),
("Ollama", "ollama_config"),
("Deployment", "init_cnpg_deploy"),
@ -430,6 +435,10 @@ class ProleInstaller(
# Must exist before reading prole.cfg so saved values are applied (avoid silent fallbacks).
self.cluster_env = tk.StringVar(value="dev")
self.service_namespace = tk.StringVar(value="default")
self.gitops_enabled = tk.BooleanVar(value=False)
self.gitops_namespace = tk.StringVar(value="gitea")
self.argocd_enabled = tk.BooleanVar(value=False)
self.argocd_namespace = tk.StringVar(value="argocd")
# Load existing docker_import_dir from prole.cfg
try:
@ -460,6 +469,9 @@ class ProleInstaller(
saved_service_ns = _expand_cfg_value(
cfg["Global"].get("SERVICE_NAMESPACE", ""), cfg_vars
).strip()
saved_argocd_ns = _expand_cfg_value(
cfg["Global"].get("ARGOCD_NAMESPACE", ""), cfg_vars
).strip()
saved_gitops_ns = (
_expand_cfg_value(
cfg.get("GitOps", "GITOPS_NAMESPACE", fallback=""),
@ -468,6 +480,18 @@ class ProleInstaller(
if cfg.has_section("GitOps")
else ""
)
saved_argocd_enabled = (
_expand_cfg_value(
cfg.get(
"Optional Features",
"ARGOCD_ENABLED",
fallback="",
),
cfg_vars,
).strip()
if cfg.has_section("Optional Features")
else ""
)
saved_gitops_enabled = (
_expand_cfg_value(
cfg.get(
@ -510,6 +534,14 @@ class ProleInstaller(
self.prole_cfg_data["Global"][
"SERVICE_NAMESPACE"
] = saved_service_ns
if saved_argocd_ns:
try:
self.argocd_namespace.set(saved_argocd_ns)
except Exception:
pass
self.prole_cfg_data.setdefault("Global", {})[
"ARGOCD_NAMESPACE"
] = saved_argocd_ns
if saved_gitops_ns:
try:
self.gitops_namespace.set(saved_gitops_ns)
@ -518,6 +550,15 @@ class ProleInstaller(
self.prole_cfg_data.setdefault("GitOps", {})[
"NAMESPACE"
] = saved_gitops_ns
if saved_argocd_enabled:
val = saved_argocd_enabled.lower() in ("true", "1", "yes")
try:
self.argocd_enabled.set(val)
except Exception:
pass
self.prole_cfg_data.setdefault("Optional Features", {})[
"ARGOCD_ENABLED"
] = str(val)
if saved_gitops_enabled:
val = saved_gitops_enabled.lower() in ("true", "1", "yes")
try:
@ -623,9 +664,7 @@ class ProleInstaller(
self.kerberos_password = tk.StringVar()
self.kerberos_kdc = tk.StringVar()
self.supabase_enabled = tk.BooleanVar(value=False)
self.gitops_enabled = tk.BooleanVar(value=False)
self.at_rest_encryption_enabled = tk.BooleanVar(value=True)
self.gitops_namespace = tk.StringVar(value="gitea")
self.ollama_server_host = tk.StringVar()
self.ollama_server_port = tk.StringVar(value=DEFAULT_OLLAMA_PORT)
self.ollama_model = tk.StringVar()
@ -676,6 +715,7 @@ class ProleInstaller(
self._register_canvas_renderer(
"supabase_config", self._render_supabase_config_page
)
self._register_canvas_renderer("argocd_config", self._render_argocd_config_page)
self._register_canvas_renderer("gitops_config", self._render_gitops_config_page)
self._register_canvas_renderer("init_cluster", self._render_init_cluster_page)
self._register_canvas_renderer("init_db_build", self._render_init_db_build_page)
@ -707,6 +747,7 @@ class ProleInstaller(
self._register_page("init_db_build", None)
self._register_page("init_scripts", None)
self._register_page("kerberos_config", None)
self._register_page("argocd_config", None)
self._register_page("gitops_config", None)
self._register_page("supabase_config", None)
self._register_page("ollama_config", None)
@ -825,7 +866,7 @@ def main():
if args.silent:
_ensure_ansible_vault_credentials(prompt_ui=False)
installer = ProleSilentInstaller(
installer = ProleConsoleInstaller(
controller, args.config, reset_cluster=args.reset
)
if log_file_path:

View File

@ -0,0 +1,276 @@
"""Optional ArgoCD deployment screen.
This screen intentionally mirrors the layout and behavior of the GitOps (Gitea)
optional feature screen.
"""
from __future__ import annotations
import os
import subprocess
import threading
import tkinter as tk
from installer import screen as ui
from installer.core.env import PROJECT_ROOT, _normalize_cluster_env
class ArgoCDScreenMixin:
def _render_argocd_config_page(self):
"""Optional feature screen: ArgoCD."""
self.clear_overlay_widgets()
self._clear_canvas_items()
ui.create_section_header(
self, "Optional Feature: ArgoCD", "Deploy ArgoCD into your cluster"
)
# Enable toggle
enabled_check = tk.Checkbutton(
self.bg_canvas,
text="Enable ArgoCD",
variable=self.argocd_enabled,
bg="white",
fg="black",
activebackground="white",
activeforeground="black",
selectcolor="white",
command=self._on_argocd_toggle,
font=("SF Pro Text", 12, "bold"),
)
enabled_window = self.bg_canvas.create_window(
48, 180, window=enabled_check, anchor="nw"
)
self._canvas_items.append(enabled_window)
self._overlay_widgets.append(enabled_check)
# Namespace entry
ui.canvas_text(
self,
48,
290,
"Kubernetes Namespace",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
ns_entry = tk.Entry(
self.bg_canvas,
textvariable=self.argocd_namespace,
bg="white",
fg="black",
insertbackground="black",
highlightbackground="#CCCCCC",
highlightthickness=1,
relief="flat",
font=("SF Pro Text", 11),
)
ns_window = self.bg_canvas.create_window(
48, 315, window=ns_entry, anchor="nw", width=220, height=30
)
self._canvas_items.append(ns_window)
self._overlay_widgets.append(ns_entry)
y = 360
initial_status = "Ready" if self.argocd_enabled.get() else "Disabled"
self._argocd_status_var = tk.StringVar(value=initial_status)
status_item = ui.canvas_text(
self,
48,
y,
f"Status: {initial_status}",
fill="black",
font=("SF Pro Text", 12),
)
def update_status_text(*_):
try:
self.bg_canvas.itemconfig(
status_item, text=f"Status: {self._argocd_status_var.get()}"
)
except Exception:
pass
self._argocd_status_var.trace_add("write", update_status_text)
# Console output
self._argocd_console = self._create_console_output(
y=400, title="Deployment Output", width=880, height=330
)
# Deploy button
self._argocd_deploy_button = tk.Button(
self.bg_canvas,
text="Deploy ArgoCD",
command=self.run_argocd_deploy,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
activeforeground="black",
highlightbackground="#F5F5DC",
highlightcolor="#F5F5DC",
highlightthickness=0,
relief="flat",
bd=0,
cursor="hand2",
disabledforeground="#8B8B7A",
font=("SF Pro Text", 11),
padx=20,
pady=10,
)
btn_window = self.bg_canvas.create_window(
48, 760, window=self._argocd_deploy_button, anchor="nw"
)
self._overlay_widgets.append(self._argocd_deploy_button)
self._canvas_items.append(btn_window)
def run_argocd_deploy(self):
if getattr(self, "_argocd_deploying", False):
return
if not self.argocd_enabled.get():
self._argocd_status_var.set("Disabled")
self.update_footer()
return
self._argocd_deploying = True
self._argocd_deploy_button.configure(state="disabled")
self._argocd_status_var.set("Deploying...")
self._argocd_success = False
self._argocd_console.clear()
self.update_footer()
def worker():
namespace = (
(self.argocd_namespace.get() or "").strip()
or os.environ.get("ARGOCD_NAMESPACE")
or "argocd"
)
env = os.environ.copy()
env["PROLE_HOME"] = str(PROJECT_ROOT)
env["PROLE_SERVICE"] = str(PROJECT_ROOT)
env["NAMESPACE"] = namespace
env["ARGOCD_NAMESPACE"] = namespace
self._argocd_console.write("Starting ArgoCD deployment...\n")
script_path = PROJECT_ROOT / "etc" / "init_argocd.sh"
if not script_path.exists():
self._argocd_console.write(f"Error: {script_path} not found.\n")
self.safe_after(
lambda: self._argocd_status_var.set("Failed (Script not found)")
)
self.safe_after(
lambda: self._argocd_deploy_button.configure(state="normal")
)
self._argocd_deploying = False
return
mode = (os.environ.get("PROLE_MODE") or "").strip()
if not mode:
cluster_env = _normalize_cluster_env(self.cluster_env.get())
if cluster_env == "dev":
mode = "k3d"
elif cluster_env in ("service", "prod"):
mode = "k3s" if cluster_env == "service" else "k8s"
else:
mode = "k3d"
args = ["--mode", mode, "--namespace", namespace, "update"]
cfg_path = None
try:
if self._cfg_path_override and self._cfg_path_override.exists():
cfg_path = self._cfg_path_override
else:
conf_dir = self._resolve_prole_conf_dir()
candidate = conf_dir / "prole.cfg"
if candidate.exists():
cfg_path = candidate
if not cfg_path:
from installer import prole_conf
candidate = prole_conf.entrypoint_path(
prole_conf.resolve_prole_conf_dir(PROJECT_ROOT)
)
if candidate.exists():
cfg_path = candidate
except Exception:
cfg_path = None
if cfg_path:
args.extend(["-c", str(cfg_path)])
self._argocd_console.write(f"Mode: {mode}\n")
cmd = ["bash", str(script_path)] + args
try:
proc = subprocess.Popen(
cmd,
cwd=str(PROJECT_ROOT),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
except Exception as e:
self._argocd_console.write(f"Failed to start deploy: {e}\n")
self.safe_after(
lambda: self._argocd_status_var.set("Failed (Launch error)")
)
self.safe_after(
lambda: self._argocd_deploy_button.configure(state="normal")
)
self._argocd_deploying = False
return
if proc.stdout:
for line in iter(proc.stdout.readline, ""):
if line:
self._argocd_console.write(line)
proc.stdout.close()
rc = proc.wait()
if rc == 0:
self._argocd_success = True
self.safe_after(
lambda: self._argocd_status_var.set("Deployed Successfully")
)
self._argocd_console.write(
"\nArgoCD deployment completed successfully.\n"
)
else:
self._argocd_success = False
self.safe_after(
lambda: self._argocd_status_var.set(f"Failed (Code {rc})")
)
self._argocd_console.write(
f"\nArgoCD deployment failed with exit code {rc}.\n"
)
self._argocd_deploying = False
self.safe_after(
lambda: self._argocd_deploy_button.configure(state="normal")
)
self.safe_after(self.update_footer)
threading.Thread(target=worker, daemon=True).start()
def _on_argocd_toggle(self):
enabled = self.argocd_enabled.get()
try:
self.prole_cfg_data["Optional Features"]["ARGOCD_ENABLED"] = str(enabled)
# Keep compatibility with other parts of the installer that expect Global.ARGOCD_NAMESPACE.
self.prole_cfg_data.setdefault("Global", {})["ARGOCD_NAMESPACE"] = (
(self.argocd_namespace.get() or "").strip() or "argocd"
)
self._save_prole_cfg()
except Exception:
pass
if not enabled:
self._argocd_success = False
if hasattr(self, "_argocd_status_var"):
self._argocd_status_var.set("Disabled")
try:
self._argocd_console.write("ArgoCD disabled. Deployment skipped.\n")
except Exception:
pass
self.update_footer()

View File

@ -159,6 +159,7 @@ class ScreenBaseMixin:
"supabase_config",
"ollama_config",
"gitops_config",
"argocd_config",
"database_options",
"create_installer",
)

View File

@ -34,6 +34,10 @@ from installer.config import _collect_cfg_vars, _encrypt_cfg_secret, _expand_cfg
class ConfigMixin:
"""prole.cfg persistence, input snapshot collection and port-forward management."""
# Namespace typing can trigger many trace events; debounce propagation to avoid
# incremental substring rewrites across unrelated fields.
NAMESPACE_PROPAGATE_DEBOUNCE_MS = 250
def _save_prole_cfg(self):
"""Generates prole.cfg; master configuration file containing all values used in install process."""
try:
@ -330,6 +334,9 @@ class ConfigMixin:
"init_cluster.supabase_enabled", _get_var(self.supabase_enabled, False)
)
_set_bool("init_cluster.gitops_enabled", _get_var(self.gitops_enabled, False))
_set_bool(
"init_cluster.argocd_enabled", _get_var(self.argocd_enabled, False)
)
_set_bool(
"init_cluster.kerberos_enabled", _get_var(self.kerberos_enabled, False)
)
@ -399,6 +406,9 @@ class ConfigMixin:
# GitOps
_set("gitops.namespace", _get_var(self.gitops_namespace, "gitea"))
# ArgoCD
_set("argocd.namespace", _get_var(self.argocd_namespace, "argocd"))
# Disk selection (installer packaging)
_set("disk_selection.disk_type", _get_var(self.selected_disk_type, "local"))
_set(
@ -915,21 +925,91 @@ class ConfigMixin:
self.k3s_token.set(token_val)
def _propagate_namespace_change(self, *args):
"""Reacts to namespace change by updating all dependent inputs and config files."""
"""Reacts to namespace change by updating dependent inputs and config files.
This is triggered by a `tk.StringVar` trace while the user types. We debounce
the propagation so we only apply a single stable transition from the last
synced namespace to the final value (instead of rewriting on each keystroke).
"""
try:
new_ns = (self.db_namespace.get() or "").strip()
except Exception:
return
if not new_ns or new_ns == getattr(self, "_last_synced_ns", None):
if not new_ns:
return
old_ns = getattr(self, "_last_synced_ns", "")
self._last_synced_ns = new_ns
# If there is no Tk root (e.g. some test harnesses), fall back to immediate.
root = getattr(self, "root", None)
if root is None:
self._apply_pending_namespace_change(new_ns)
return
# Debounce: cancel any in-flight scheduled propagation and reschedule.
after_id = getattr(self, "_namespace_propagate_after_id", None)
if after_id:
try:
root.after_cancel(after_id)
except Exception:
pass
self._pending_namespace_value = new_ns
delay_ms = getattr(self, "_namespace_debounce_ms", None)
if delay_ms is None:
delay_ms = getattr(self, "NAMESPACE_PROPAGATE_DEBOUNCE_MS", 250)
try:
self._namespace_propagate_after_id = root.after(
int(delay_ms), self._apply_pending_namespace_change
)
except Exception:
# If scheduling fails, apply immediately.
self._apply_pending_namespace_change(new_ns)
def _apply_pending_namespace_change(self, namespace: str | None = None):
"""Apply the pending namespace propagation using a stable old namespace."""
# Clear the timer id if we were invoked from `after`.
try:
self._namespace_propagate_after_id = None
except Exception:
pass
if namespace is None:
try:
namespace = (getattr(self, "_pending_namespace_value", "") or "").strip()
except Exception:
namespace = ""
new_ns = (namespace or "").strip()
if not new_ns:
return
old_ns = (getattr(self, "_last_synced_ns", "") or "").strip()
if not old_ns:
# Initialize sync baseline and persist.
try:
self._last_synced_ns = new_ns
except Exception:
pass
if new_ns == old_ns:
return
# Propagate to inputs and StringVars (e.g. OpenBao paths)
if old_ns:
# Do not rewrite independent namespaces.
exclude_var_names = {
"db_namespace",
"service_namespace",
"gitops_namespace",
"argocd_namespace",
}
# 1. Sweep StringVars
for attr_name in dir(self):
if attr_name in exclude_var_names:
continue
try:
attr = getattr(self, attr_name)
if isinstance(attr, tk.StringVar) and attr != self.db_namespace:
@ -942,10 +1022,18 @@ class ConfigMixin:
# 2. Sweep inputs
if hasattr(self, "inputs"):
for k, v in list(self.inputs.items()):
if isinstance(v, str) and old_ns in v:
self.inputs[k] = v.replace(old_ns, new_ns)
if not isinstance(v, str) or old_ns not in v:
continue
lk = (k or "").lower()
if "service_namespace" in lk or lk.endswith("service_namespace"):
continue
self.inputs[k] = v.replace(old_ns, new_ns)
# Update environment and save prole.cfg
try:
self._last_synced_ns = new_ns
except Exception:
pass
try:
self._update_env_namespace(new_ns)
# Re-save prole.cfg so it's always in sync

View File

@ -388,7 +388,7 @@ class ClusterScreenMixin:
# Traffic light status indicators for each service component
self._service_traffic_lights = {}
components = [
"Argo",
"Registry",
"CertMgr",
"Garage",
"Kong",
@ -538,7 +538,13 @@ class ClusterScreenMixin:
return key
def _get_service_namespace(self) -> str:
"""Resolve service namespace with UI/env/config fallbacks."""
"""Resolve service namespace with UI/env/config fallbacks.
Safety rule: avoid defaulting to the Kubernetes `default` namespace unless we truly
cannot infer a better option.
"""
# 1) Explicit user/config/env choice wins.
try:
ns = (self.service_namespace.get() or "").strip()
except Exception:
@ -554,17 +560,126 @@ class ClusterScreenMixin:
ns = ""
if not ns:
ns = (os.environ.get("SERVICE_NAMESPACE") or "").strip()
if ns:
return ns
default_ns = "default"
# 2) If the cluster is reachable, infer the common services namespace.
inferred = ""
try:
inferred = (self._infer_common_services_namespace() or "").strip()
except Exception:
inferred = ""
if inferred:
return inferred
# 3) Stable defaults for known environments.
try:
# Common services should never fall back to the Kubernetes "default" namespace.
# For local dev (k3d) and service (k3s), keep knoe-system stable across restarts.
if self._cluster_env_key() in ("dev", "service"):
default_ns = "knoe-system"
return "knoe-system"
except Exception:
pass
return ns or default_ns
# 4) Absolute last resort.
return "default"
def _infer_common_services_namespace(self) -> str:
"""Infer the namespace hosting common services.
If the cluster is reachable, try to locate an existing Docker Registry workload
(image containing `registry:2`). If found, treat that namespace as the common
services namespace.
This method is read-only.
"""
# Cache to avoid repeated `kubectl -A` probes during periodic refresh.
cache_ttl_s = 60
now = time.time()
cached_ns = getattr(self, "_common_services_ns_cache", "")
cached_at = getattr(self, "_common_services_ns_cache_at", 0.0)
if cached_ns and (now - float(cached_at or 0.0)) < cache_ttl_s:
return cached_ns
# Ensure kubeconfig is available when possible (read-only action).
try:
self._ensure_k3s_kubeconfig_merged()
except Exception:
pass
# Build a kubectl command suitable for the active mode.
try:
mode = self._deployment_mode()
except Exception:
mode = None
try:
base_cmd = self._kubectl_base_cmd(mode)
except Exception:
base_cmd = ["kubectl"]
# Respect an existing kubeconfig if present.
env = os.environ.copy()
try:
kc = _find_kubeconfig_file(env)
except Exception:
kc = _find_kubeconfig_file()
if kc:
env["KUBECONFIG"] = kc
def _probe(kind: str) -> list[str]:
cmd = base_cmd + ["get", kind, "-A", "-o", "json"]
res = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=5,
)
if res.returncode != 0:
return []
try:
data = json.loads(res.stdout or "{}")
except Exception:
return []
out = []
for item in (data.get("items") or []):
try:
ns = (item.get("metadata") or {}).get("namespace") or ""
containers = (
(((item.get("spec") or {}).get("template") or {}).get("spec") or {}).get(
"containers"
)
or []
)
for c in containers:
img = (c.get("image") or "").strip()
if "registry:2" in img:
if ns:
out.append(ns)
break
except Exception:
continue
return out
namespaces = []
# Deployments are the most common shape; fall back to StatefulSets.
namespaces.extend(_probe("deploy"))
if not namespaces:
namespaces.extend(_probe("sts"))
# Prefer knoe-system if present, otherwise prefer a non-default namespace.
ns_choice = ""
if namespaces:
if "knoe-system" in namespaces:
ns_choice = "knoe-system"
else:
non_default = [n for n in namespaces if n and n != "default"]
ns_choice = (non_default[0] if non_default else namespaces[0]) or ""
if ns_choice:
setattr(self, "_common_services_ns_cache", ns_choice)
setattr(self, "_common_services_ns_cache_at", now)
return ns_choice
def _on_cluster_env_change(self, *args):
# Keep configuration environment selection explicit and stable.
@ -1478,6 +1593,13 @@ class ClusterScreenMixin:
threading.Thread(target=_cleanup_worker, daemon=True).start()
except Exception:
pass
# On save, immediately run a services status refresh so the UI reflects the
# newly persisted namespace/cluster settings.
try:
self._verify_k3s_services()
except Exception:
pass
return True
def _cluster_status_snapshot(self) -> dict:
@ -1544,54 +1666,27 @@ class ClusterScreenMixin:
kong_status = None
if status.get("cluster_ok"):
kong_status = self._dashboard_kong_status(status.get("env"))
self._set_cluster_env_message("Assess Actions", "#34c759")
self._set_cluster_env_message("Observe", "#34c759")
# Repair step: reset Dashboard (Kong) pods if unhealthy, with cooldown
if kong_status and kong_status.get("reset_pods"):
now = time.time()
if now - self._kong_last_repair_at >= self._kong_repair_cooldown_s:
self._set_cluster_env_message(
"Execute Actions (REPAIR): reset Dashboard (Kong) pod(s)",
"#34c759",
)
pods = kong_status.get("reset_pods") or []
cmd = (
self._kubectl_base_cmd(status.get("env"))
+ ["delete", "pod", "-n", "kubernetes-dashboard"]
+ pods
)
rc, _out = self._run_cmd_capture(cmd, timeout=10)
if rc != 0:
self._set_cluster_env_message(
"Notice: Dashboard (Kong) repair failed",
"#ff3b30",
clear_after_ms=6000,
)
notice_active = True
else:
self._kong_last_repair_at = now
# Give the cluster a moment to recreate pods before re-check
time.sleep(2)
self._set_cluster_env_message("Collect Status", "#34c759")
status = self._cluster_status_snapshot()
else:
self._set_cluster_env_message(
"Execute Actions (REPAIR): skipped (recently attempted)",
"#34c759",
clear_after_ms=1500,
)
else:
self._set_cluster_env_message(
"Execute Actions (REPAIR): none needed",
"#34c759",
clear_after_ms=1500,
)
# Run the repair pipeline if anomalies are detected on a ready cluster
# Observe-only: do not mutate the cluster automatically.
anomalies = []
try:
self._maybe_run_repair_pipeline(status, kong_status)
if kong_status and not kong_status.get("ok", True):
anomalies.append("dashboard")
except Exception:
pass
try:
if self._authority_context_missing():
anomalies.append("authority")
except Exception:
pass
if anomalies:
self._set_cluster_env_message(
"Notice: issues detected. Click Repair to reconcile.",
"#ff9f0a",
clear_after_ms=6000,
)
notice_active = True
def update_ui():
if hasattr(self, "k3d_status_label"):
@ -1870,18 +1965,12 @@ class ClusterScreenMixin:
def _parse_service_status_lines(self, lines: list[str]) -> dict[str, bool]:
"""Parse [OK]/[FAIL] lines from status_common_services.sh output.
Returns a dict mapping component keys (argo, certmgr,
Returns a dict mapping component keys (registry, certmgr,
garage, kong, openbao, opentofu) to True (healthy) or False (unhealthy).
"""
# Map resource names to traffic light component keys
name_map = {
"argocd-server": "argo",
"argocd-repo-server": "argo",
"argocd-dex-server": "argo",
"argocd-applicationset-controller": "argo",
"argocd-notifications-controller": "argo",
"argocd-redis": "argo",
"argocd-application-controller": "argo",
"registry": "registry",
"opentofu": "opentofu",
"garage": "garage",
"openbao": "openbao",
@ -1910,7 +1999,7 @@ class ClusterScreenMixin:
return result
def _deploy_k3s_services(self):
"""Deploy common services (ArgoCD/OpenTofu/Garage) to remote k3s cluster."""
"""Deploy common services (Registry/OpenTofu/Garage) to remote k3s cluster."""
def _deploy():
console = self._k3s_service_deploy_console

View File

@ -8,13 +8,14 @@ import subprocess
import threading
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from tkinter import ttk, messagebox, filedialog, simpledialog
from installer import screen as ui
from installer.config import get_docker_build_platform_args
from installer.core.env import (
PROJECT_ROOT,
_bool_str,
_deployment_mode_from_env,
_http_ping_registry,
_safe_str,
_push_docker_image,
get_resource_path,
@ -942,17 +943,43 @@ class DatabaseScreenMixin:
"Building the prole-db Postgres image. This may take a few minutes.", y=200
)
# Local registry status (checked async)
# Registry status (checked async)
self._db_registry_status_label = ui.canvas_text(
self,
48,
230,
"Local Registry: Checking...",
"Registry: Checking...",
fill="#6e6e73",
font=("SF Pro Text", 11),
)
self._canvas_items.append(self._db_registry_status_label)
# k3s registry initialization (shown only when needed)
self._db_init_registry_button = tk.Button(
self.bg_canvas,
text="Initialize Registry",
command=self._db_init_registry,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 11),
padx=16,
pady=6,
)
init_btn_window = self.bg_canvas.create_window(
48, 224, window=self._db_init_registry_button, anchor="nw", width=200
)
self._db_init_registry_button_canvas_window = init_btn_window
self._canvas_items.append(init_btn_window)
self._overlay_widgets.append(self._db_init_registry_button)
try:
self.bg_canvas.itemconfigure(init_btn_window, state="hidden")
except Exception:
pass
# Output Console
self._db_build_console = self._create_console_output(
y=260, title="Build Output", width=900, height=520
@ -991,43 +1018,112 @@ class DatabaseScreenMixin:
def _ensure_db_build_registry_async(self):
def worker():
try:
if not self.check_docker_running():
env_key = "dev"
try:
env_key = self._cluster_env_key()
except Exception:
env_key = "dev"
# If the user selected Service, treat it as k3s even when Global/DEPLOYMENT_MODE remains k3d.
mode = _deployment_mode_from_env(
((self.prole_cfg_data.get("Global", {}) or {}).get("DEPLOYMENT_MODE") or "").strip()
or env_key
)
def _show_init_button(show: bool):
try:
if not hasattr(self, "_db_init_registry_button_canvas_window"):
return
state = "normal" if show else "hidden"
self.bg_canvas.itemconfigure(
self._db_init_registry_button_canvas_window, state=state
)
except Exception:
return
# k3s: never warn about localhost registry. Instead offer init + show reachable status.
if mode == "k3s":
registry_url = ""
try:
registry_url = (self.ensure_registry_available(env_key) or "").strip()
except Exception:
registry_url = ""
reachable = False
if registry_url and ":" in registry_url:
host, port_s = registry_url.rsplit(":", 1)
try:
reachable = _http_ping_registry(host.strip(), int(port_s.strip()))
except Exception:
reachable = False
if registry_url and reachable:
self.safe_after(
lambda: (
_show_init_button(False),
self.bg_canvas.itemconfig(
self._db_registry_status_label,
text=f"Registry: {registry_url}",
fill="#34c759",
),
)
)
return
# Not reachable / not configured -> prompt action
msg = (
f"Registry: {registry_url} (not initialized)"
if registry_url
else "Registry: not initialized"
)
self.safe_after(
lambda: (
_show_init_button(True),
self.bg_canvas.itemconfig(
self._db_registry_status_label,
text="Local Registry: Docker not running",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
else None
text=msg,
fill="#ff9500",
),
)
)
return
# Non-k3s: keep local registry checks (requires Docker)
if not self.check_docker_running():
self.safe_after(
lambda: (
_show_init_button(False),
self.bg_canvas.itemconfig(
self._db_registry_status_label,
text="Registry: Docker not running",
fill="#ff3b30",
),
)
)
return
info = self.ensure_local_registry_available()
if info:
host_registry, _cluster_registry = info
self.safe_after(
lambda: (
_show_init_button(False),
self.bg_canvas.itemconfig(
self._db_registry_status_label,
text=f"Local Registry: {host_registry}",
text=f"Registry: {host_registry}",
fill="#34c759",
)
if self.bg_canvas.winfo_exists()
else None
),
)
)
else:
self.safe_after(
lambda: (
_show_init_button(False),
self.bg_canvas.itemconfig(
self._db_registry_status_label,
text="Local Registry: unavailable",
text="Registry: unavailable",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
else None
),
)
)
log_path = getattr(self, "_last_registry_log_path", None)
@ -1038,7 +1134,7 @@ class DatabaseScreenMixin:
)
)
except Exception as e:
msg = f"Local Registry: error ({e})"
msg = f"Registry: error ({e})"
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
@ -1058,6 +1154,181 @@ class DatabaseScreenMixin:
threading.Thread(target=worker, daemon=True).start()
def _db_rescan_registry_status_after_init(self):
"""Re-scan registry availability after init.
Registry pods/services may take a few seconds to become reachable after
`etc/init_registry.sh` finishes. Schedule a few checks so the UI status
flips to green once ready.
"""
try:
if getattr(self, "_db_registry_status_label", None) is not None:
self.safe_after(
lambda: self.bg_canvas.itemconfig(
self._db_registry_status_label,
text="Registry: Checking...",
fill="#6e6e73",
)
)
except Exception:
pass
# Progressive delays to allow the in-cluster registry to become ready.
for d in (0, 1000, 2000, 4000, 8000, 15000):
self.safe_after(lambda: self._ensure_db_build_registry_async(), delay=d)
def _db_init_registry(self):
"""Initialize the k3s in-cluster registry (registry:2) via etc/init_registry.sh."""
def _resolve_argocd_namespace() -> str:
ns = (
(self.prole_cfg_data.get("Global", {}) or {})
.get("ARGOCD_NAMESPACE", "")
.strip()
)
if not ns:
ns = (os.environ.get("ARGOCD_NAMESPACE") or "").strip()
return ns or "argocd"
def _resolve_service_namespace_interactive() -> str:
# Prefer explicit UI value if available
ns = ""
try:
ns = (self.service_namespace.get() or "").strip()
except Exception:
ns = ""
if not ns:
ns = (
(self.prole_cfg_data.get("Global", {}) or {})
.get("SERVICE_NAMESPACE", "")
.strip()
)
if not ns:
ns = (os.environ.get("SERVICE_NAMESPACE") or "").strip()
if ns:
return ns
# UI is interactive: ask the user.
try:
ns2 = simpledialog.askstring(
"Service Namespace",
"Enter the service namespace for the k3s registry:",
parent=self.root,
)
except Exception:
ns2 = None
return (ns2 or "").strip()
def worker():
env_key = "service"
try:
env_key = self._cluster_env_key()
except Exception:
env_key = "service"
mode = _deployment_mode_from_env(env_key)
if mode != "k3s":
try:
messagebox.showinfo(
"Registry",
"Registry initialization is only required for k3s (Service) mode.",
)
except Exception:
pass
return
service_ns = _resolve_service_namespace_interactive()
if not service_ns:
# In UI mode we must not guess; prompt is available above.
# Only fall back to Kubernetes 'default' when no input is available.
try:
if getattr(self, "root", None) is not None:
self.safe_after(
lambda: messagebox.showerror(
"Registry",
"Service namespace is required to initialize the registry.",
)
)
return
except Exception:
pass
service_ns = "default"
argocd_ns = _resolve_argocd_namespace()
script = PROJECT_ROOT / "etc" / "init_registry.sh"
cfg_path = getattr(self.controller, "cfg_path", None)
cmd = [str(script)]
if cfg_path:
cmd += ["--config", str(cfg_path)]
cmd += [
"--mode",
"k3s",
"-n",
argocd_ns,
"--registry-namespace",
service_ns,
"update",
]
self.safe_after(
lambda: (
self._db_init_registry_button.configure(state="disabled")
if self._db_init_registry_button.winfo_exists()
else None
)
)
if self._db_build_console:
self.safe_after(
lambda: self._db_build_console.write(
f"Running registry init: {' '.join(cmd)}\n\n"
)
)
try:
proc = subprocess.Popen(
cmd,
cwd=str(PROJECT_ROOT),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=os.environ.copy(),
)
if proc.stdout:
for line in iter(proc.stdout.readline, ""):
if not line:
break
if self._db_build_console:
self._db_build_console.write(line)
proc.wait()
rc = proc.returncode
except Exception as e:
rc = 1
if self._db_build_console:
self._db_build_console.write(f"Registry init failed: {e}\n")
if self._db_build_console:
self._db_build_console.write(
f"\nRegistry init {'completed' if rc == 0 else 'failed'} (exit {rc}).\n"
)
self.safe_after(
lambda: (
self._db_init_registry_button.configure(state="normal")
if self._db_init_registry_button.winfo_exists()
else None
)
)
if rc == 0:
self._db_rescan_registry_status_after_init()
else:
self._ensure_db_build_registry_async()
threading.Thread(target=worker, daemon=True).start()
def run_db_build(self):
self._action_flags["init_db_build.run_build"] = True
@ -1289,14 +1560,96 @@ class DatabaseScreenMixin:
)
)
else:
# Service/prod clusters must pull from a registry; do not skip push.
registry_url = ""
try:
registry_url = (self.ensure_registry_available(env_key) or "").strip()
except Exception:
registry_url = ""
if not registry_url:
self._db_build_console.write(
"No registry configured/resolved for this environment. Initialize the registry first.\n"
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build ok, registry not configured.",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label in self.bg_canvas.find_all()
else None
)
)
return
remote_tag = f"{registry_url}/prole-db:{tag}"
self._db_build_console.write(
"Import skipped for non-dev clusters.\n"
f"Tagging image for registry: {remote_tag}\n"
)
tag_res = subprocess.run(
["docker", "tag", image_name, remote_tag],
capture_output=True,
text=True,
)
if tag_res.returncode != 0:
self._db_build_console.write(tag_res.stdout or "")
self._db_build_console.write(tag_res.stderr or "")
self._db_build_console.write("Tag failed; cannot push.\n")
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build ok, tag failed.",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label in self.bg_canvas.find_all()
else None
)
)
return
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build complete. Import skipped.",
text="Pushing to registry...",
fill="#34c759",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label in self.bg_canvas.find_all()
else None
)
)
if not _push_docker_image(
remote_tag, log_fn=self._db_build_console.write
):
self._db_build_console.write(
"Push failed. Ensure the registry is initialized and reachable.\n"
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build ok, push failed.",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label in self.bg_canvas.find_all()
else None
)
)
return
self._db_build_console.write(f"Push complete: {remote_tag}\n")
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build, tag and push complete.",
fill="#34c759",
)
if self.bg_canvas.winfo_exists()

View File

@ -176,9 +176,27 @@ class DockerScreenMixin:
registry_container = f"k3d-{reg_name}"
host_registry = "localhost:5000"
mode = _deployment_mode_from_env(
self.prole_cfg_data.get("Global", {}).get("DEPLOYMENT_MODE")
)
# Prefer the currently selected environment (UI) over the global config.
# This avoids incorrectly treating Service (k3s) as Dev (k3d) when
# Global/DEPLOYMENT_MODE is still set to k3d.
mode_source = ""
try:
mode_source = (self._cluster_env_key() or "").strip()
except Exception:
mode_source = ""
if not mode_source:
try:
mode_source = (
(self.prole_cfg_data.get("Global", {}) or {})
.get("DEPLOYMENT_MODE", "")
.strip()
)
except Exception:
mode_source = ""
if not mode_source:
mode_source = (mode_hint or "").strip()
mode = _deployment_mode_from_env(mode_source)
if mode == "k3s":
k3s_registry = self._k3s_registry_hostport()
if not k3s_registry:
@ -344,134 +362,6 @@ class DockerScreenMixin:
log_fp.close()
return None
def _collect_dependent_images(
self, include_supabase: bool, include_kerberos_proxy: bool
) -> list[str]:
images = set()
for rel_dir in ("k8s/prole", "k8s/openbao"):
base_dir = PROJECT_ROOT / rel_dir
if not base_dir.exists():
continue
images.update(_collect_images_from_files(list(base_dir.glob("*.yaml"))))
if include_supabase:
supa_home = _resolve_supabase_home(PROJECT_ROOT)
if supa_home:
docker_dir = supa_home / "docker"
compose_files = [docker_dir / "docker-compose.yml"]
if os.environ.get("SUPABASE_USE_DEV_COMPOSE") == "1":
compose_files.append(docker_dir / "dev" / "docker-compose.dev.yml")
images.update(_collect_images_from_files(compose_files))
if not include_supabase:
images = {img for img in images if "supabase" not in img}
if include_kerberos_proxy:
krb_img = os.environ.get("KRB5_AD_PROXY_IMAGE", "alpine/socat")
if krb_img:
images.add(krb_img)
return sorted(images)
def _prepull_images_to_registry(
self, include_supabase: bool, include_kerberos_proxy: bool, log=None
) -> bool:
def _log(msg: str):
if log:
try:
log(msg)
except Exception:
pass
info = self.ensure_local_registry_available()
if not info:
_log("Local registry unavailable; skipping image pre-pull.\n")
return False
registry, _cluster_registry = info
images = self._collect_dependent_images(
include_supabase, include_kerberos_proxy
)
if not images:
_log("No dependent images found to pre-pull.\n")
return True
overall_ok = True
import_dir = self.docker_import_dir.get().strip()
for image in images:
local_tag = image
if not image.startswith(f"{registry}/"):
local_tag = f"{registry}/{image}"
# 1. Check if already in local registry
_log(f"Checking if {image} exists in local registry...\n")
check_reg = subprocess.run(
["docker", "pull", local_tag], capture_output=True, text=True
)
if check_reg.returncode == 0:
_log(f"[OK] {image} already exists in local registry as {local_tag}\n")
continue
# 2. Check if we already have it in local docker daemon
check_local = subprocess.run(
["docker", "image", "inspect", image], capture_output=True, text=True
)
found = check_local.returncode == 0
if not found and import_dir and os.path.isdir(import_dir):
# 3. Check import directory
safe_name = image.replace("/", "_").replace(":", "_")
tar_path = Path(import_dir) / f"{safe_name}.tar"
if tar_path.exists():
_log(f"Found {tar_path} in import directory, loading...\n")
load = subprocess.run(
["docker", "load", "-i", str(tar_path)],
capture_output=True,
text=True,
)
if load.returncode == 0:
found = True
else:
_log(f"[WARN] Failed to load {tar_path}: {load.stderr}\n")
if not found:
# 4. Pull from Docker Hub
_log(f"Pulling {image} from Docker Hub...\n")
pull = subprocess.run(
["docker", "pull", image], capture_output=True, text=True
)
if pull.returncode != 0:
overall_ok = False
_log(pull.stdout or "")
_log(pull.stderr or "")
_log(f"[ERROR] docker pull failed for {image}\n")
continue
found = True
# If we have the image locally, tag and push to local registry
if found:
if local_tag != image:
tag = subprocess.run(
["docker", "tag", image, local_tag],
capture_output=True,
text=True,
)
if tag.returncode != 0:
overall_ok = False
_log(tag.stdout or "")
_log(tag.stderr or "")
_log(f"[ERROR] docker tag failed for {image} to {local_tag}\n")
continue
_log(f"Pushing {local_tag} to local registry...\n")
if not _push_docker_image(local_tag, log_fn=log):
overall_ok = False
continue
_log(f"[OK] Stored {image} in local registry as {local_tag}\n")
return overall_ok
def build_docker_image(self):
"""Build prole-db Docker image"""
# If Kerberos is enabled, update pg_hba.conf in conf/postgresql before copying

View File

@ -339,9 +339,12 @@ class NavigationMixin:
if current_id == "kerberos_config":
self.show_page("init_scripts")
return
if current_id == "gitops_config":
if current_id == "argocd_config":
self.show_page("kerberos_config")
return
if current_id == "gitops_config":
self.show_page("argocd_config")
return
if current_id == "supabase_config":
self.show_page("gitops_config")
return
@ -527,7 +530,7 @@ class NavigationMixin:
if self.kerberos_enabled.get():
self.show_page("kerberos_config")
else:
self.show_page("gitops_config")
self.show_page("argocd_config")
return
if current_id == "kerberos_config":
@ -569,6 +572,29 @@ class NavigationMixin:
os.environ.get("KRB5_AD_SERVICE_NAME", "prole-kerberos-ad-dc")
)
self._save_prole_cfg()
self.show_page("argocd_config")
return
if current_id == "argocd_config":
self.prole_cfg_data["Optional Features"]["ARGOCD_ENABLED"] = str(
self.argocd_enabled.get()
)
argocd_status = (
"Skipped"
if not self.argocd_enabled.get()
else (
"Deployed"
if getattr(self, "_argocd_success", False)
else "Attempted"
)
)
argocd_ns = (self.argocd_namespace.get() or "argocd").strip() or "argocd"
self.prole_cfg_data.setdefault("Global", {})["ARGOCD_NAMESPACE"] = argocd_ns
self.prole_cfg_data["ArgoCD"] = {
"STATUS": argocd_status,
"ARGOCD_NAMESPACE": argocd_ns,
}
self._save_prole_cfg()
self.show_page("gitops_config")
return
@ -682,6 +708,14 @@ class NavigationMixin:
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
elif pid == "argocd_config":
if self.argocd_enabled.get():
if getattr(self, "_argocd_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
elif pid == "init_scripts":
# Initialization Scripts: Next is disabled until success
if getattr(self, "_scripts_success", False):

View File

@ -5,7 +5,7 @@ metadata:
name: prole-db
spec:
instances: 3
imageName: myrddin.prole.org:5000/prole-db:17.7-106
imageName: myrddin.prole.org:5000/prole-db:18-118
postgresUID: 100
postgresGID: 101
maxSyncReplicas: 1

View File

@ -4,7 +4,7 @@ metadata:
name: prole-db
spec:
instances: 3
imageName: myrddin.prole.org:5000/prole-db:17.7-106
imageName: myrddin.prole.org:5000/prole-db:18-118
postgresUID: 100
postgresGID: 101
maxSyncReplicas: 1

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,9 @@ BACKUP_STATUS_TIMEOUT=${BACKUP_STATUS_TIMEOUT:-600}
BACKUP_STATUS_INTERVAL=${BACKUP_STATUS_INTERVAL:-10}
PLUGIN_READY_TIMEOUT=${PLUGIN_READY_TIMEOUT:-180}
PLUGIN_SOCKET_DIR=${PLUGIN_SOCKET_DIR:-/plugins}
LAST_BACKUP_NAME=""
usage() {
cat <<USAGE
Usage: $0 [start|backup|status]
@ -243,8 +246,10 @@ remove_native_barman_config() {
wait_for_plugin_ready() {
local start_time now plugin_names deployment_rows ns ready replicas
local plugin_deploy_ready pod socket_path
start_time=$(date +%s)
while true; do
plugin_deploy_ready=0
plugin_names=$(kubectl -n "$NAMESPACE" get cluster "$CNPG_CLUSTER_NAME" -o jsonpath='{.spec.plugins[*].name}' 2>/dev/null || true)
if printf '%s\n' "$plugin_names" | tr ' ' '\n' | grep -Fxq "$BARMAN_PLUGIN_NAME"; then
deployment_rows=$(kubectl get deployment -A -l app.kubernetes.io/name=barman-cloud -o jsonpath='{range .items[*]}{.metadata.namespace}{"\t"}{.status.readyReplicas}{"\t"}{.status.replicas}{"\n"}{end}' 2>/dev/null || true)
@ -257,9 +262,18 @@ wait_for_plugin_ready() {
ready=${ready:-0}
replicas=${replicas:-0}
if (( ready >= 1 && replicas >= 1 )); then
return 0
plugin_deploy_ready=1
break
fi
done <<< "$deployment_rows"
if (( plugin_deploy_ready == 1 )); then
pod=$(kubectl -n "$NAMESPACE" get pods -l "cnpg.io/cluster=$CNPG_CLUSTER_NAME" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
socket_path="$PLUGIN_SOCKET_DIR/$BARMAN_PLUGIN_NAME"
if [[ -n "$pod" ]] && kubectl -n "$NAMESPACE" exec "$pod" -c postgres -- test -S "$socket_path" >/dev/null 2>&1; then
return 0
fi
fi
fi
now=$(date +%s)
@ -305,6 +319,21 @@ wait_for_successful_base_backup() {
local start_time now elapsed
start_time=$(date +%s)
while true; do
if [[ -n "$LAST_BACKUP_NAME" ]]; then
local phase err
phase=$(kubectl -n "$NAMESPACE" get backup "$LAST_BACKUP_NAME" -o jsonpath='{.status.phase}' 2>/dev/null || true)
err=$(kubectl -n "$NAMESPACE" get backup "$LAST_BACKUP_NAME" -o jsonpath='{.status.error}' 2>/dev/null || true)
case "${phase:-}" in
Completed|Succeeded|completed|succeeded)
return 0
;;
Failed|failed)
echo "ERROR: Backup '$LAST_BACKUP_NAME' failed: ${err:-<no error provided>}" >&2
return 1
;;
esac
fi
if has_successful_base_backup; then
return 0
fi
@ -324,6 +353,7 @@ trigger_backup() {
local backup_type="${1:-full}"
local backup_name
backup_name="${CNPG_CLUSTER_NAME}-backup-$(date +%Y%m%d%H%M%S)"
LAST_BACKUP_NAME="$backup_name"
echo "Triggering ${backup_type} backup $backup_name ..."
if [[ "$backup_type" == "incremental" || "$backup_type" == "incr" ]]; then
kubectl apply -n "$NAMESPACE" -f - <<BACKUP

View File

@ -1 +1 @@
111
118

View File

@ -8,7 +8,7 @@ from pathlib import Path
from installer.core.controller import ProleController
from installer.core.env import _ensure_ansible_vault_credentials, PROJECT_ROOT
from installer.core.actions import ProleSilentInstaller, _prepare_k3s_pipeline
from installer.core.actions import ProleConsoleInstaller, _prepare_k3s_pipeline
def main():
@ -89,7 +89,7 @@ def main():
if args.silent:
_ensure_ansible_vault_credentials(prompt_ui=False)
installer = ProleSilentInstaller(
installer = ProleConsoleInstaller(
controller, args.config, reset_cluster=args.reset
)
if args.log:

View File

@ -7,7 +7,7 @@ import subprocess
from pathlib import Path
from installer import prole_conf
from installer.core.actions import ProleInstallerBase
from installer.core.actions import ProleInstaller
from installer.core.env import (
_detect_ansible_topology,
_sync_opentofu_pipeline,
@ -39,7 +39,7 @@ _CFG_KEY_MAP = {
}
class ProleDeployment(ProleInstallerBase):
class ProleDeployment(ProleInstaller):
"""
Top-level class for Prole infrastructure deployment via OpenTofu.
Duplicate k3d stack and apply to k3s defined in Ansible inventory.

View File

@ -1,8 +1,15 @@
#!/bin/bash
# Run the Prole installer with execution instrumentation (coverage)
# This helps identify unused code paths during manual testing/usage.
export PYTHONPATH=$PYTHONPATH:.
coverage run install.py "$@"
coverage report -m
coverage html
echo "Execution instrumentation report generated in htmlcov/index.html"
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON="$SCRIPT_DIR/bin/python"
export PYTHONPATH="$SCRIPT_DIR:${PYTHONPATH:-}"
"$PYTHON" -m coverage run "$SCRIPT_DIR/install.py" "$@"
"$PYTHON" -m coverage report -m
"$PYTHON" -m coverage html
echo "Execution instrumentation report generated in $SCRIPT_DIR/htmlcov/index.html"

View File

@ -125,6 +125,32 @@ get_ready_kdc_pod() {
kubectl -n "$kdc_ns" get pod -l "app=${PROLE_KDC_NAME}" --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true
}
debug_kdc_service_target() {
local host="${KRB5_KDC:-}"
if [[ -z "$host" ]]; then
return 0
fi
# Only attempt Kubernetes Service diagnostics for in-cluster service FQDNs.
if [[ "$host" != *".svc.cluster.local" ]]; then
return 0
fi
local base svc ns
base="${host%.svc.cluster.local}"
svc="${base%%.*}"
ns="${base#*.}"
ns="${ns%%.*}"
if [[ -z "$svc" || -z "$ns" || "$svc" == "$base" ]]; then
return 0
fi
echo "--- KDC service diagnostics for ${svc}.${ns} ---" >&2
kubectl -n "$ns" get svc "$svc" -o wide 1>&2 || true
kubectl -n "$ns" get endpoints "$svc" -o yaml 1>&2 || true
}
wait_for_kdc_pod() {
local kdc_ns="$1"
local attempt pod
@ -242,6 +268,15 @@ run_kinit() {
else
echo "kinit produced no output." >&2
fi
echo "--- Debug: kinit failure context ---" >&2
echo "KRB5_REALM=${KRB5_REALM:-<unset>}" >&2
echo "KRB5_KDC=${KRB5_KDC:-<unset>}" >&2
echo "KRB5_CONFIG=${krb5_config}" >&2
# Print the relevant realm stanza (best-effort).
kubectl -n "$kdc_ns" exec "$pod_name" -- sh -c "grep -nE '^(\[realms\]|\[libdefaults\]|\[domain_realm\]|[[:space:]]*(kdc|admin_server)[[:space:]]*=)' '$krb5_config' || true" 1>&2 || true
debug_kdc_service_target
return 1
}

View File

@ -831,36 +831,12 @@ PYFIX
fi
# 9. Fix realtime liveness probe: kompose emits the whole curl command as a
# single quoted string instead of a proper argv list. Wrap it in sh -c.
# single argv entry, and unquoted `Authorization: ...` fragments can be
# parsed as YAML mappings. Sanitize the probe so Kubernetes receives a
# proper list of strings.
local realtime_deploy="$SUPABASE_K8S_DIR/realtime-deployment.yaml"
if [[ -f "$realtime_deploy" ]]; then
python3 - "$realtime_deploy" <<'PYFIX'
import sys, re
path = sys.argv[1]
with open(path, 'r') as f:
content = f.read()
# Match the single-string curl command in the exec probe
m = re.search(
r"(\s+command:\n)(\s+- )'(curl .+?)'",
content, re.DOTALL
)
if m:
indent = m.group(2)
curl_cmd = m.group(3)
replacement = (
m.group(1)
+ indent + "sh\n"
+ indent + "-c\n"
+ indent + curl_cmd
)
content = content[:m.start()] + replacement + content[m.end():]
with open(path, 'w') as f:
f.write(content)
print(" [OK] realtime liveness probe -> sh -c curl ...")
PYFIX
python3 "$SCRIPT_DIR/patch_realtime_probe.py" "$realtime_deploy"
fi
}
@ -871,7 +847,8 @@ patch_supabase_credentials() {
local jwt_secret=""
# Resolve postgres password from prole-db-superuser secret
local ns="${PROLE_DB_NAMESPACE:-prole-db-a0001}"
resolve_prole_db_namespace
local ns="${PROLE_DB_NAMESPACE:-${NAMESPACE:-default}}"
if kubectl get secret prole-db-superuser -n "$ns" >/dev/null 2>&1; then
pg_password=$(kubectl get secret prole-db-superuser -n "$ns" \
-o jsonpath='{.data.password}' 2>/dev/null | base64 -d 2>/dev/null || true)
@ -939,7 +916,8 @@ PYFIX
setup_prole_db_for_supabase() {
log "Ensuring Supabase roles, schemas and databases exist in prole-db..."
local ns="${PROLE_DB_NAMESPACE:-prole-db-a0001}"
resolve_prole_db_namespace
local ns="${PROLE_DB_NAMESPACE:-${NAMESPACE:-default}}"
# Find the primary CNPG pod
local primary
@ -1100,7 +1078,11 @@ run_helm() {
kubectl cluster-info >/dev/null 2>&1 || return 1
if [[ "$FORCE" == "true" ]]; then
kubectl delete namespace supabase --ignore-not-found --wait=false >/dev/null 2>&1 || true
# Helm stores release metadata as Secrets in the namespace; it will fail if
# the namespace is stuck in Terminating.
force_reset_supabase_namespace
else
ensure_supabase_namespace
fi
helm_render_values
@ -1140,10 +1122,18 @@ PY
}
ensure_supabase_namespace() {
if ! kubectl get namespace supabase >/dev/null 2>&1; then
log "Creating 'supabase' namespace..."
kubectl create namespace supabase
if kubectl get namespace supabase >/dev/null 2>&1; then
local phase
phase=$(kubectl get namespace supabase -o jsonpath='{.status.phase}' 2>/dev/null || true)
if [[ "${phase:-}" == "Terminating" ]]; then
warn "Namespace 'supabase' is Terminating; resetting it"
force_reset_supabase_namespace
fi
return 0
fi
log "Creating 'supabase' namespace..."
kubectl create namespace supabase
}
force_reset_supabase_namespace() {

View File

@ -29,13 +29,13 @@
},
"secret": {
"db": {
"password": "${OPENBAO:kv/prole/prole-db001/db#password}",
"password": "fr3styl3",
"database": "postgres"
},
"jwt": {
"secret": "4e53e1ecb98eb32ee83b890bea79e678e952c6ae237edf3f65f37bf6dd2262eb",
"anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InByb2xlLXN1cGFiYXNlIiwiaWF0IjoxNzcyNDM0NjM4LCJleHAiOjIwODc3OTQ2Mzh9.Wg05U6ioVntunXskmJs3DXMc1i9-O7UBts2cnBwPCoI",
"serviceKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoicHJvbGUtc3VwYWJhc2UiLCJpYXQiOjE3NzI0MzQ2MzgsImV4cCI6MjA4Nzc5NDYzOH0.xZ179fB412UFdgDxc8XUv2EQt-L1nGyhocll09u2u38"
"anonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InByb2xlLXN1cGFiYXNlIiwiaWF0IjoxNzczNTM4MjkyLCJleHAiOjIwODg4OTgyOTJ9.PoUrud3YpPeuw_sR1PRgLdFatkqoi6hag8kRERh-bE0",
"serviceKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoicHJvbGUtc3VwYWJhc2UiLCJpYXQiOjE3NzM1MzgyOTIsImV4cCI6MjA4ODg5ODI5Mn0.rFJn8pV-MhsG1QdqUCVFdppU69aunUh6TPjGzluh5Ic"
}
},
"ingress": {

View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
import re
import sys
def _quote_yaml_single(s: str) -> str:
# YAML single-quoted scalars escape a single quote by doubling it.
return "'" + s.replace("'", "''") + "'"
def patch_realtime_deployment_yaml(content: str) -> tuple[str, bool]:
changed = False
# Case A: kompose sometimes emits a single-quoted curl command as a single argv entry.
# Wrap it into `sh -c '<curl ...>'`.
def _wrap_single_curl(m: re.Match[str]) -> str:
nonlocal changed
changed = True
cmd_hdr = m.group(1)
indent = m.group(2)
curl_cmd = m.group(3)
return (
cmd_hdr
+ indent
+ "sh\n"
+ indent
+ "-c\n"
+ indent
+ _quote_yaml_single(curl_cmd)
+ "\n"
)
content = re.sub(
r"(\s+command:\n)(\s+- )'(curl[^\n]+)'\n",
_wrap_single_curl,
content,
count=1,
)
# Case B: unquoted scalars containing `Authorization: ...` can be parsed as YAML mappings.
# Quote them to guarantee Kubernetes receives a string.
out_lines: list[str] = []
for line in content.splitlines(True):
m = re.match(r"^(\s*-\s+)(curl\b.*)$", line)
if m:
prefix = m.group(1)
cmd = m.group(2).rstrip("\n")
if "Authorization:" in cmd and not cmd.lstrip().startswith(("'", '"')):
out_lines.append(prefix + _quote_yaml_single(cmd) + "\n")
changed = True
continue
out_lines.append(line)
return "".join(out_lines), changed
def main() -> int:
if len(sys.argv) != 2:
print("Usage: patch_realtime_probe.py <path-to-realtime-deployment.yaml>", file=sys.stderr)
return 2
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as f:
content = f.read()
patched, changed = patch_realtime_deployment_yaml(content)
if changed:
with open(path, "w", encoding="utf-8") as f:
f.write(patched)
print(" [OK] realtime liveness probe YAML sanitized")
else:
print(" [OK] realtime liveness probe already sane")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,169 @@
#!/usr/bin/env bash
# Regression test: init_cloudnative_pg.sh should bootstrap-generate CNPG admin keys
# when OpenBao is unavailable and no local admin key files exist.
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME=$(cd "$SCRIPT_DIR/../.." && pwd)
ETC_DIR="$PROLE_HOME/etc"
SCRIPT_UNDER_TEST="$ETC_DIR/init_cloudnative_pg.sh"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
export TMP_DIR
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$BIN_DIR"
mock_tool() {
cat <<M_EOF >"$BIN_DIR/$1"
#!/usr/bin/env bash
echo "Mocked $1 called with \$@" >>"$TMP_DIR/mock_calls.log"
exit 0
M_EOF
chmod +x "$BIN_DIR/$1"
}
# Custom kubectl mock: return minimal outputs so initialize() doesn't hang.
cat <<'K_EOF' >"$BIN_DIR/kubectl"
#!/usr/bin/env bash
_log_file="${TMP_DIR}/mock_calls.log"
echo "Mocked kubectl called with $@" >>"${_log_file}"
args="$*"
# API server readiness probes used by wait_for_apiserver_ready()
if [[ "${args}" == *"get --raw=/readyz"* || "${args}" == *"get --raw='/readyz'"* || "${args}" == *"get --raw=\"/readyz\""* ]]; then
echo "ok"
exit 0
fi
if [[ "${args}" == *"version --short"* ]]; then
echo "Client Version: v0.0.0"
echo "Server Version: v0.0.0"
exit 0
fi
# Simulate kubectl-cnpg plugin being present, but psql connectivity not yet ready.
if [[ "${args}" == *"cnpg"*"version"* ]]; then
exit 0
fi
if [[ "${args}" == *"cnpg"*"psql"* ]]; then
exit 1
fi
# Pretend required secrets exist (DB user + TLS artifacts)
if [[ "${args}" == *"get secret"*"prole-db-user"* ]]; then
exit 0
fi
if [[ "${args}" == *"get secret"*"prole-db-tls"* || "${args}" == *"get secret"*"prole-db-ca"* ]]; then
exit 0
fi
# CNPG webhook wait: return an endpoint IP so wait passes quickly.
if [[ "${args}" == *"get endpoints"*"cnpg-webhook-service"* ]]; then
echo "10.42.0.10"
exit 0
fi
# CNPG pods listing and readiness queries
if [[ "${args}" == *"get pods"* && "${args}" == *"cnpg.io/cluster="* ]]; then
if [[ "${args}" == *"--no-headers"* ]]; then
echo "prole-db-1 1/1 Running 0 1m"
echo "prole-db-2 1/1 Running 0 1m"
echo "prole-db-3 1/1 Running 0 1m"
exit 0
fi
if [[ "${args}" == *"-o jsonpath="* ]]; then
printf "True\nTrue\nTrue\n"
exit 0
fi
fi
# Cluster instances
if [[ "${args}" == *"get cluster"*"jsonpath="*".spec.instances"* ]]; then
echo "3"
exit 0
fi
# For kubectl create secret --dry-run=client -o yaml, emit a minimal YAML so pipes look realistic.
if [[ "${args}" == *"create secret"*"--dry-run=client"*"-o yaml"* ]]; then
echo "apiVersion: v1"
echo "kind: Secret"
exit 0
fi
exit 0
K_EOF
chmod +x "$BIN_DIR/kubectl"
# Other tool mocks
mock_tool curl
mock_tool docker
mock_tool k3d
mock_tool skopeo
mock_tool jq
# Mock prole.cfg: point PROLE_SERVICE at a non-writable dir so the script must fallback to HOME
mkdir -p "$TMP_DIR/conf"
mkdir -p "$TMP_DIR/service"
chmod 0500 "$TMP_DIR/service" || true
TMP_HOME="$TMP_DIR/home"
mkdir -p "$TMP_HOME"
cat <<C_EOF >"$TMP_DIR/conf/prole.cfg"
[User]
NAMESPACE = test-ns
SERVICE_NAMESPACE = test-system
PROLE_HOME = $PROLE_HOME
PROLE_SERVICE = $TMP_DIR/service
[Global]
DEPLOYMENT_MODE = k3d
KUBECONTEXT = test
[Docker Build]
LOCAL_REGISTRY = localhost:5000
LOCAL_REGISTRY_INTERNAL = k3d-prole-registry.localhost:5000
C_EOF
export PATH="$BIN_DIR:$PATH"
export HOME="$TMP_HOME"
export PROLE_HOME="$PROLE_HOME"
export PROLE_CONF="$TMP_DIR/conf"
export PROLE_PASSWD="test-password"
export PROLE_SERVICE="$TMP_DIR/service"
export CNPG_WAIT_TIMEOUT=5
set +e
bash "$SCRIPT_UNDER_TEST" --mode k3d initialize >"$TMP_DIR/stdout" 2>"$TMP_DIR/stderr"
RC=$?
set -e
if [[ $RC -ne 0 ]]; then
echo "FAILURE: init_cloudnative_pg.sh initialize returned rc=$RC"
echo "--- stdout ---"
sed -n '1,200p' "$TMP_DIR/stdout" || true
echo "--- stderr ---"
sed -n '1,200p' "$TMP_DIR/stderr" || true
exit 1
fi
SECRETS_DIR_FALLBACK="$TMP_HOME/.prole/etc/secrets"
if [[ ! -f "$SECRETS_DIR_FALLBACK/admin.key" || ! -f "$SECRETS_DIR_FALLBACK/admin.pub" ]]; then
echo "FAILURE: expected bootstrap-generated admin.key/admin.pub in fallback secrets dir: $SECRETS_DIR_FALLBACK"
ls -la "$SECRETS_DIR_FALLBACK" 2>/dev/null || true
exit 1
fi
if [[ ! -f "$SECRETS_DIR_FALLBACK/admin_ed25519.key" || ! -f "$SECRETS_DIR_FALLBACK/admin_ed25519.pub" ]]; then
echo "FAILURE: expected legacy-mirrored admin_ed25519.key/.pub in fallback secrets dir: $SECRETS_DIR_FALLBACK"
ls -la "$SECRETS_DIR_FALLBACK" 2>/dev/null || true
exit 1
fi
echo "SUCCESS"

View File

@ -0,0 +1,202 @@
#!/usr/bin/env bash
# Unit test for etc/init_cnpg_backup.sh (start action)
# Verifies we do not trigger the first Backup until the plugin socket exists in a CNPG pod.
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME_REPO=$(cd "$SCRIPT_DIR/../.." && pwd)
ETC_DIR="$PROLE_HOME_REPO/etc"
SCRIPT_UNDER_TEST="$ETC_DIR/init_cnpg_backup.sh"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
export TMP_DIR
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$BIN_DIR"
cat <<'K_EOF' >"$BIN_DIR/kubectl"
#!/usr/bin/env bash
set -euo pipefail
_log_file="${TMP_DIR}/mock_calls.log"
echo "Mocked kubectl called with $*" >>"${_log_file}"
args="$*"
# API server readiness probes
if [[ "${args}" == *"get --raw=/readyz"* || "${args}" == *"get --raw='/readyz'"* || "${args}" == *"get --raw=\"/readyz\""* ]]; then
echo "ok"
exit 0
fi
if [[ "${args}" == *"version --short"* ]]; then
echo "Client Version: v0.0.0"
echo "Server Version: v0.0.0"
exit 0
fi
# Namespace existence/creation
if [[ "${args}" == "get namespace test-ns" ]]; then
exit 0
fi
if [[ "${args}" == "create namespace test-ns" ]]; then
exit 0
fi
# Cluster existence and plugin config presence
if [[ "${args}" == "get cluster prole-db -n test-ns"* ]]; then
exit 0
fi
if [[ "${args}" == *"-n test-ns"*"get cluster prole-db"*"-o jsonpath="*".spec.plugins"*".name"* ]]; then
echo "barman-cloud.cloudnative-pg.io"
exit 0
fi
if [[ "${args}" == *"-n test-ns"*"get cluster prole-db"*"-o jsonpath="*".spec.plugins"* ]]; then
echo '[{"enabled":true,"isWALArchiver":true,"name":"barman-cloud.cloudnative-pg.io","parameters":{"barmanObjectName":"prole-db-barman-objectstore"}}]'
exit 0
fi
# Barman CRD present
if [[ "${args}" == "get crd objectstores.barmancloud.cnpg.io"* ]]; then
exit 0
fi
# CNPG operator webhook endpoints ready
if [[ "${args}" == *"-n cnpg-system"*"get endpoints"*"cnpg-webhook-service"* ]]; then
echo "10.42.0.10"
exit 0
fi
# Barman-cloud deployment ready
if [[ "${args}" == "get deployment -A -l app.kubernetes.io/name=barman-cloud -o jsonpath="* ]]; then
printf 'cnpg-system\t1\t1\n'
exit 0
fi
# CNPG cluster pod selection for socket check
if [[ "${args}" == *"-n test-ns"*"get pods"*"-l cnpg.io/cluster=prole-db"*"-o jsonpath={.items[0].metadata.name"* ]]; then
echo "prole-db-1"
exit 0
fi
# Plugin socket check: fail twice, succeed on the third call
if [[ "${args}" == *"-n test-ns"*"exec prole-db-1 -c postgres -- test -S /plugins/barman-cloud.cloudnative-pg.io"* ]]; then
cfile="${TMP_DIR}/socket_checks"
c=0
[[ -f "$cfile" ]] && c=$(cat "$cfile")
c=$((c + 1))
echo "$c" >"$cfile"
if (( c >= 3 )); then
exit 0
fi
exit 1
fi
# Garage pod selection
if [[ "${args}" == *"get pods -n test-system -l app=garage"*"-o jsonpath={.items[0].metadata.name"* ]]; then
echo "garage-0"
exit 0
fi
# Garage exec commands
if [[ "${args}" == *"exec -n test-system garage-0 -- /garage"*"layout show"* ]]; then
echo "Current cluster layout version: 1"
exit 0
fi
if [[ "${args}" == *"exec -n test-system garage-0 -- /garage"*"status"* ]]; then
echo "OK"
exit 0
fi
if [[ "${args}" == *"exec -n test-system garage-0 -- /garage"*"key info"*"--show-secret"* ]]; then
echo "Access key ID: TESTACCESSKEY"
echo "Secret access key: TESTSECRETKEY"
exit 0
fi
if [[ "${args}" == *"exec -n test-system garage-0 -- /garage"*"bucket info"* ]]; then
exit 0
fi
if [[ "${args}" == *"exec -n test-system garage-0 -- /garage"*"bucket allow"* ]]; then
exit 0
fi
# Capture applied manifests (ObjectStore, Secret, Backup)
if [[ "${args}" == apply*"-n"*"test-ns"*"-f"*"-"* ]]; then
cat >"${TMP_DIR}/applied.$(date +%s%N).yaml"
echo "applied"
exit 0
fi
# Simulate the triggered backup reaching Completed quickly
if [[ "${args}" == *"-n test-ns"*"get backup"*"-o jsonpath={.status.phase"* ]]; then
echo "Completed"
exit 0
fi
if [[ "${args}" == *"-n test-ns"*"get backup"*"-o jsonpath={.status.error"* ]]; then
echo ""
exit 0
fi
if [[ "${args}" == "get backup -n test-ns -o jsonpath="* ]]; then
# name\tphase\tmethod\tbackupType
printf 'prole-db-backup-XYZ\tCompleted\tplugin\t\n'
exit 0
fi
if [[ "${args}" == "get backup -n test-ns"* ]]; then
echo "prole-db-backup-XYZ Completed prole-db plugin"
exit 0
fi
exit 0
K_EOF
chmod +x "$BIN_DIR/kubectl"
export PATH="$BIN_DIR:$PATH"
# prole_cfg.sh sources $PROLE_HOME/env.sh if present; provide a minimal env.sh that points to our temp config.
CONF_DIR="$TMP_DIR/conf"
ENV_HOME="$TMP_DIR/env-home"
mkdir -p "$CONF_DIR" "$ENV_HOME"
cat <<C_EOF >"$CONF_DIR/prole.cfg"
[User]
NAMESPACE = test-ns
SERVICE_NAMESPACE = test-system
PROLE_HOME = $PROLE_HOME_REPO
PROLE_SERVICE = $TMP_DIR/service
[Global]
DEPLOYMENT_MODE = k3d
C_EOF
cat <<E_EOF >"$ENV_HOME/env.sh"
#!/usr/bin/env bash
export PROLE_HOME="$PROLE_HOME_REPO"
export PROLE_CONF="$CONF_DIR"
E_EOF
chmod +x "$ENV_HOME/env.sh"
set +e
PROLE_HOME="$ENV_HOME" bash "$SCRIPT_UNDER_TEST" start >"$TMP_DIR/stdout" 2>"$TMP_DIR/stderr"
RC=$?
set -e
if [[ $RC -ne 0 ]]; then
echo "FAILURE: init_cnpg_backup.sh start returned rc=$RC"
sed -n '1,200p' "$TMP_DIR/mock_calls.log" || true
sed -n '1,200p' "$TMP_DIR/stderr" || true
exit 1
fi
# Ensure socket checks happened before any Backup apply
socket_line=$(grep -n "exec prole-db-1 -c postgres -- test -S /plugins/barman-cloud.cloudnative-pg.io" "$TMP_DIR/mock_calls.log" | tail -n1 | cut -d: -f1 || true)
backup_apply_line=$(grep -n "apply -n test-ns -f -" "$TMP_DIR/mock_calls.log" | tail -n1 | cut -d: -f1 || true)
if [[ -z "$socket_line" || -z "$backup_apply_line" || "$socket_line" -ge "$backup_apply_line" ]]; then
echo "FAILURE: expected plugin socket check before triggering backup apply"
echo "socket_line=$socket_line backup_apply_line=$backup_apply_line"
sed -n '1,220p' "$TMP_DIR/mock_calls.log" || true
exit 1
fi
echo "SUCCESS"

View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME=$(cd "$SCRIPT_DIR/../.." && pwd)
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
export TMP_DIR
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$BIN_DIR"
cat <<'K_EOF' >"$BIN_DIR/kubectl"
#!/usr/bin/env bash
set -euo pipefail
args="$*"
if [[ "$args" == "config current-context"* ]]; then
echo "k3d-knoe-dev-cluster"
exit 0
fi
if [[ "$args" == "config get-contexts -o name"* ]]; then
echo "docker-desktop"
echo "k3d-knoe-dev-cluster"
echo "k3d-knoe-system"
exit 0
fi
if [[ "$args" == "config get-contexts --no-headers"* ]]; then
# Format: CURRENT NAME CLUSTER AUTHINFO NAMESPACE
echo "* k3d-knoe-dev-cluster k3d-knoe-dev-cluster user "
echo " k3d-knoe-system k3d-knoe-system user "
exit 0
fi
if [[ "$args" == "config use-context "* ]]; then
# shellcheck disable=SC2129
echo "${args#config use-context }" >"${TMP_DIR}/used_context"
exit 0
fi
exit 0
K_EOF
chmod +x "$BIN_DIR/kubectl"
mkdir -p "$TMP_DIR/conf"
cat <<C_EOF >"$TMP_DIR/conf/prole.cfg"
[Global]
DEPLOYMENT_MODE = k3d
KUBECONTEXT = knoe-system
C_EOF
export PATH="$BIN_DIR:$PATH"
export HOME="$TMP_DIR/home"
mkdir -p "$HOME"
export PROLE_HOME="$PROLE_HOME"
export PROLE_CONF="$TMP_DIR/conf"
export PROLE_MODE="k3d"
export KUBECONTEXT="knoe-system"
# shellcheck disable=SC1090
source "$PROLE_HOME/etc/prole_cfg.sh"
prole_ensure_kube_context
if [[ "$(cat "$TMP_DIR/used_context")" != "k3d-knoe-system" ]]; then
echo "FAILURE: expected context 'k3d-knoe-system' but got '$(cat "$TMP_DIR/used_context")'" >&2
exit 1
fi
echo "SUCCESS"

View File

@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME=$(cd "$SCRIPT_DIR/../.." && pwd)
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
export TMP_DIR
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$BIN_DIR"
cat <<'K_EOF' >"$BIN_DIR/kubectl"
#!/usr/bin/env bash
set -euo pipefail
args="$*"
if [[ "$args" == "config current-context"* ]]; then
echo "default"
exit 0
fi
if [[ "$args" == "config get-contexts -o name"* ]]; then
echo "default"
exit 0
fi
if [[ "$args" == "config get-contexts --no-headers"* ]]; then
# Format: CURRENT NAME CLUSTER AUTHINFO NAMESPACE
echo "* default default user "
exit 0
fi
if [[ "$args" == "config use-context "* ]]; then
ctx="${args#config use-context }"
if [[ "$ctx" == "default" ]]; then
echo "$ctx" >"${TMP_DIR}/used_context"
exit 0
fi
exit 1
fi
exit 0
K_EOF
chmod +x "$BIN_DIR/kubectl"
export PATH="$BIN_DIR:$PATH"
export HOME="$TMP_DIR/home"
mkdir -p "$HOME"
# Prevent prole_cfg.sh from trying to auto-discover kubeconfig during sourcing.
export KUBECONFIG="$TMP_DIR/kubeconfig"
export PROLE_HOME="$PROLE_HOME"
export PROLE_MODE="k3s"
export KUBECONTEXT="prole-k3s"
# shellcheck disable=SC1090
source "$PROLE_HOME/etc/prole_cfg.sh"
prole_ensure_kube_context
if [[ "$(cat "$TMP_DIR/used_context")" != "default" ]]; then
echo "FAILURE: expected context 'default' but got '$(cat "$TMP_DIR/used_context")'" >&2
exit 1
fi
echo "SUCCESS"

View File

@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Regression test for etc/status.sh
# - In k3d mode, it must not auto-select prole-k3s.kubeconfig via the fallback.
# - It must call status_common_services.sh using SERVICE_NAMESPACE (not DB NAMESPACE).
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME_REPO=$(cd "$SCRIPT_DIR/../.." && pwd)
ETC_DIR="$PROLE_HOME_REPO/etc"
SCRIPT_UNDER_TEST="$ETC_DIR/status.sh"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
export TMP_DIR
export PROLE_K3S_KUBECONFIG_PATH="$PROLE_HOME_REPO/prole-k3s.kubeconfig"
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$BIN_DIR"
cat <<'K_EOF' >"$BIN_DIR/kubectl"
#!/usr/bin/env bash
set -euo pipefail
_log_file="${TMP_DIR}/mock_calls.log"
echo "Mocked kubectl called with $*" >>"${_log_file}"
args="$*"
if [[ "$args" == "config current-context"* ]]; then
echo "k3d-knoe-system"
exit 0
fi
if [[ "$args" == "config view --minify"* ]]; then
echo "https://127.0.0.1:6443"
exit 0
fi
if [[ "$args" == "cluster-info"* ]]; then
if [[ "${KUBECONFIG:-}" == "${PROLE_K3S_KUBECONFIG_PATH}" ]]; then
echo "fallback-probe" >>"${TMP_DIR}/k3s_kubeconfig_probe"
fi
echo "Kubernetes control plane is running"
exit 0
fi
# Basic pods output so status.sh can iterate.
if [[ "$args" == "get pods -o wide -A"* ]]; then
echo "NAMESPACE NAME READY STATUS RESTARTS AGE IP NODE"
echo "kube-system coredns 1/1 Running 0 1h 10.0.0.1 node"
exit 0
fi
# Common services checks (SERVICE_NAMESPACE should be svc-ns)
if [[ "$args" == "-n svc-ns get svc"* ]]; then
echo "NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE"
echo "openbao ClusterIP 10.0.0.2 <none> 8200/TCP 1h"
echo "garage ClusterIP 10.0.0.3 <none> 3900/TCP 1h"
echo "opentofu ClusterIP 10.0.0.4 <none> 8080/TCP 1h"
exit 0
fi
if [[ "$args" == "-n svc-ns get svc auth"* ]]; then
echo "NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE"
echo "auth ClusterIP 10.0.0.5 <none> 88/TCP 1h"
exit 0
fi
if [[ "$args" == "-n svc-ns get deploy"* || "$args" == "-n svc-ns get statefulset"* ]]; then
exit 0
fi
exit 0
K_EOF
chmod +x "$BIN_DIR/kubectl"
export PATH="$BIN_DIR:$PATH"
# Provide a reachable prole-k3s.kubeconfig file (fallback must NOT probe it in k3d mode)
touch "$PROLE_HOME_REPO/prole-k3s.kubeconfig"
CFG_DIR="$TMP_DIR/conf"
mkdir -p "$CFG_DIR"
cat <<C_EOF >"$CFG_DIR/prole.cfg"
[User]
NAMESPACE = db-ns
SERVICE_NAMESPACE = svc-ns
[Global]
DEPLOYMENT_MODE = k3d
C_EOF
set +e
STATUS_CHECK_TIMEOUT=2 bash "$SCRIPT_UNDER_TEST" -c "$CFG_DIR/prole.cfg" >"$TMP_DIR/stdout" 2>"$TMP_DIR/stderr"
RC=$?
set -e
# We don't require rc=0; we only assert the correct calls were made.
if [[ -f "$TMP_DIR/k3s_kubeconfig_probe" ]]; then
echo "FAILURE: expected status.sh not to probe prole-k3s.kubeconfig via kubectl cluster-info in k3d mode" >&2
sed -n '1,200p' "$TMP_DIR/mock_calls.log" >&2 || true
exit 1
fi
if ! grep -q -- "-n svc-ns get svc opentofu garage openbao" "$TMP_DIR/mock_calls.log"; then
echo "FAILURE: expected status_common_services.sh checks to use SERVICE_NAMESPACE (svc-ns)" >&2
sed -n '1,200p' "$TMP_DIR/mock_calls.log" >&2 || true
exit 1
fi
echo "SUCCESS"

View File

@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME=$(cd "$SCRIPT_DIR/../.." && pwd)
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
YAML_PATH="$TMP_DIR/realtime-deployment.yaml"
cat >"$YAML_PATH" <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
name: realtime
namespace: supabase
spec:
replicas: 1
selector:
matchLabels:
app: realtime
template:
metadata:
labels:
app: realtime
spec:
containers:
- name: realtime
image: supabase/realtime:v2.76.5
livenessProbe:
exec:
command:
- sh
- -c
- curl -sSfL --head -o /dev/null -H "Authorization: Bearer abc:def" http://localhost:4000/health
initialDelaySeconds: 10
periodSeconds: 30
YAML
python3 "$PROLE_HOME/supabase/patch_realtime_probe.py" "$YAML_PATH" >/dev/null
# Validate that the curl command is YAML-quoted.
# (We avoid using `kubectl ... -o json` here because it may contact the cluster for discovery
# even in client dry-run mode, which makes the test flaky/offline-hostile.)
if ! grep -Eq "^[[:space:]]*-[[:space:]]*'curl .*Authorization: Bearer" "$YAML_PATH"; then
echo "FAILURE: expected the realtime liveness curl command to be YAML single-quoted" >&2
sed -n '1,120p' "$YAML_PATH" >&2 || true
exit 1
fi
if grep -Eq "^[[:space:]]*-[[:space:]]*curl .*Authorization: Bearer" "$YAML_PATH"; then
echo "FAILURE: found an unquoted realtime liveness curl command (YAML may parse it as a mapping)" >&2
sed -n '1,120p' "$YAML_PATH" >&2 || true
exit 1
fi
echo "SUCCESS"

View File

@ -11,8 +11,8 @@ from unittest.mock import MagicMock, patch
import pytest
from installer.core.actions import (
ProleInstallerBase,
ProleSilentInstaller,
ProleInstaller,
ProleConsoleInstaller,
_configure_unbuffered_io,
)
@ -22,7 +22,7 @@ from installer.core.actions import (
# ---------------------------------------------------------------------------
class _TestableInstaller(ProleInstallerBase):
class _TestableInstaller(ProleInstaller):
"""Minimal concrete subclass for testing base-class helpers."""
def __init__(self, inputs=None, project_root=None):
@ -268,13 +268,16 @@ spec:
inst = _TestableInstaller(project_root=tmp_path)
images = inst._collect_dependent_images(
include_supabase=True,
include_kerberos_proxy=True,
)
with mock.patch.dict(
os.environ, {"KRB5_AD_PROXY_IMAGE": "krb-proxy:test"}, clear=False
):
images = inst._collect_dependent_images(
include_supabase=True,
include_kerberos_proxy=True,
)
assert "nginx:1.2" in images
assert "supabase/postgres:15" in images
assert "ghcr.io/bsharp-tech/prole-kerberos-proxy:latest" in images
assert "krb-proxy:test" in images
# ---------------------------------------------------------------------------
@ -282,8 +285,8 @@ spec:
# ---------------------------------------------------------------------------
class _TestableSilentInstaller(ProleSilentInstaller):
"""Testable subclass of ProleSilentInstaller."""
class _TestableSilentInstaller(ProleConsoleInstaller):
"""Testable subclass of ProleConsoleInstaller."""
def __init__(self, inputs=None, project_root=None):
self._inputs_dict = inputs or {}

View File

@ -0,0 +1,48 @@
from __future__ import annotations
def test_show_page_argocd_config_hides_slide_area():
"""Regression: `argocd_config` is a canvas-rendered page and must not be covered by `slide_area`."""
from installer.ui.screens.base import ScreenBaseMixin
class _SlideArea:
def __init__(self):
self.place_forget_called = 0
self.place_called = 0
def place_forget(self):
self.place_forget_called += 1
def place(self, **_kwargs):
self.place_called += 1
def lift(self):
return None
def configure(self, **_kwargs):
return None
class _Dummy(ScreenBaseMixin):
def __init__(self):
self._showing_service_overlay = False
self.page_index = 0
self.pages = [("argocd_config", None)]
self.page_frames = {}
self.canvas_renderers = {"argocd_config": lambda: None}
self.slide_area = _SlideArea()
def _clear_canvas_page(self):
return None
def _update_nav_highlight(self, _pid: str):
return None
def update_footer(self):
return None
s = _Dummy()
s.show_page("argocd_config")
assert s.slide_area.place_forget_called == 1
assert s.slide_area.place_called == 0

View File

@ -0,0 +1,92 @@
import sys
import tkinter as tk
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# Ensure `installer` is importable when tests are invoked directly via `pytest`
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import installer.ui.screens.cluster as cluster_mod
from installer.ui.screens.base import ScreenBaseMixin
from installer.ui.screens.cluster import ClusterScreenMixin
class _DummyClusterSaveApp(ScreenBaseMixin, ClusterScreenMixin):
"""Minimal surface to exercise the Cluster Environment screen Save behavior."""
def __init__(self, root: tk.Tk):
self.root = root
# Canvas is not required by `_validate_and_save_cluster_config`, but other mixins
# assume it exists.
self.bg_canvas = tk.Canvas(root, width=1, height=1)
self._canvas_items = []
self._overlay_widgets = []
self.cluster_env = tk.StringVar(master=root, value="service")
self.selected_k3d_cluster = tk.StringVar(master=root, value="")
self.selected_kubectx = tk.StringVar(master=root, value="")
self.service_namespace = tk.StringVar(master=root, value="knoe-system")
self.prod_artifacts_path = tk.StringVar(master=root, value=str(Path.home()))
self.k3s_server_url = tk.StringVar(master=root, value="")
self.k3s_token = tk.StringVar(master=root, value="")
self.supabase_enabled = tk.BooleanVar(master=root, value=False)
self.gitops_enabled = tk.BooleanVar(master=root, value=False)
self.kerberos_enabled = tk.BooleanVar(master=root, value=False)
self.at_rest_encryption_enabled = tk.BooleanVar(master=root, value=False)
# Minimal config structure required by `_validate_and_save_cluster_config`.
self.prole_cfg_data = {
"Initialize Cluster": {
"ENVIRONMENT": "service",
"K3S_SERVER_URL": "",
"K3S_TOKEN": "",
},
"Global": {
# Keep old/new equal so we don't trigger the namespace cleanup prompt.
"SERVICE_NAMESPACE": "knoe-system",
},
"Optional Features": {},
"Prod Cluster (k8s)": {},
}
self.saved_calls = 0
self.status_check_calls = 0
def _save_prole_cfg(self):
self.saved_calls += 1
def _verify_k3s_services(self):
self.status_check_calls += 1
def test_cluster_env_save_triggers_status_check(monkeypatch):
# Some test modules replace tkinter with MagicMocks at import-time.
if isinstance(sys.modules.get("tkinter"), MagicMock):
pytest.skip("tkinter is mocked in this test run")
# Avoid depending on a real kubeconfig file.
monkeypatch.setattr(cluster_mod, "_find_kubeconfig_file", lambda: "/tmp/kubeconfig")
# Avoid any real secret handling.
monkeypatch.setattr(cluster_mod, "_encrypt_cfg_secret", lambda s: s)
root = tk.Tk()
root.withdraw()
try:
app = _DummyClusterSaveApp(root)
assert app._validate_and_save_cluster_config() is True
assert app.saved_calls == 1
# Requirement: clicking Save should immediately run a status check.
assert app.status_check_calls == 1
finally:
try:
root.destroy()
except Exception:
pass

View File

@ -12,7 +12,7 @@ from installer.state import InstallerState
from installer.milestone import Milestone, ProgressCallback
from installer.build import get_build_command, BuildMilestone
from installer.core.controller import ProleController
from installer.core.actions import ProleSilentInstaller
from installer.core.actions import ProleConsoleInstaller
# ===== InstallerState =====
@ -271,7 +271,7 @@ class TestProleController:
assert c.project_root == tmp_path
assert c.verbose is False
assert c.state is not None
installer = ProleSilentInstaller(c)
installer = ProleConsoleInstaller(c)
defaults = installer._default_inputs()
assert defaults.get("kerberos_config.user") == "administrator"
assert defaults.get("kerberos_config.password") == ""
@ -326,7 +326,7 @@ class TestProleController:
def test_silent_installer_runs_supabase_preload_before_deploy(self, tmp_path):
c = ProleController(tmp_path)
installer = ProleSilentInstaller(c)
installer = ProleConsoleInstaller(c)
with mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}), \
mock.patch.object(installer, "_write_cfg"), \
@ -349,7 +349,7 @@ class TestProleController:
self, tmp_path
):
c = ProleController(tmp_path)
installer = ProleSilentInstaller(c)
installer = ProleConsoleInstaller(c)
openbao_ref = "${OPENBAO:kv/prole/test/db#password}"

View File

@ -0,0 +1,7 @@
from __future__ import annotations
from installer.core.env import PROJECT_ROOT
def test_init_gitea_script_exists():
assert (PROJECT_ROOT / "etc" / "init_gitea.sh").is_file()

View File

@ -0,0 +1,88 @@
import sys
import time
import tkinter as tk
from unittest.mock import MagicMock
import pytest
from installer.ui.screens.cfg import ConfigMixin
class _DummyNamespaceApp(ConfigMixin):
"""Minimal surface to exercise `ConfigMixin._propagate_namespace_change`."""
def __init__(self, root: tk.Tk):
self.root = root
# Keep the test fast and deterministic.
# Use a debounce long enough that the scheduled `after()` callback will not
# run between the simulated keystrokes (which call `root.update()`).
self._namespace_debounce_ms = 100
self.db_namespace = tk.StringVar(master=root, value="prole-db")
self.service_namespace = tk.StringVar(master=root, value="knoe-system")
# Independent namespaces that must not be rewritten.
self.gitops_namespace = tk.StringVar(master=root, value="gitea")
self.argocd_namespace = tk.StringVar(master=root, value="argocd")
self._last_synced_ns = self.db_namespace.get()
# Inputs dict is used by the sweep logic; include a service namespace entry
# to ensure it is not touched.
self.inputs = {
"init_cluster.service_namespace": self.service_namespace.get(),
}
self.save_calls = 0
self.updated_namespace = None
def _update_env_namespace(self, namespace: str):
self.updated_namespace = namespace
def _save_prole_cfg(self):
self.save_calls += 1
def _drain_tk_events(root: tk.Tk, timeout_s: float = 0.2) -> None:
end = time.monotonic() + timeout_s
while time.monotonic() < end:
root.update()
time.sleep(0.001)
def test_namespace_typing_does_not_corrupt_service_namespace():
# Some test modules replace tkinter with MagicMocks at import-time.
if isinstance(sys.modules.get("tkinter"), MagicMock):
pytest.skip("tkinter is mocked in this test run")
root = tk.Tk()
root.withdraw()
try:
app = _DummyNamespaceApp(root)
app.db_namespace.trace_add("write", app._propagate_namespace_change)
# Simulate typing a new namespace in the UI entry box.
for partial in [
"k",
"kn",
"kno",
"knoe",
"knoe-",
"knoe-d",
"knoe-db",
]:
app.db_namespace.set(partial)
root.update()
_drain_tk_events(root, timeout_s=0.3)
# Regression: SERVICE_NAMESPACE must remain unchanged.
assert app.service_namespace.get() == "knoe-system"
# Debounced propagation should result in a single save.
assert app.save_calls == 1
finally:
try:
root.destroy()
except Exception:
pass

View File

@ -0,0 +1,74 @@
from __future__ import annotations
from types import SimpleNamespace
def test_push_docker_image_prefers_tls_verified_skopeo_when_ca_provided(
tmp_path, monkeypatch
):
from installer.core import env
ca = tmp_path / "registry-ca.pem"
ca.write_text("dummy-ca")
monkeypatch.setenv("PROLE_REGISTRY_CA_CERT", str(ca))
monkeypatch.setattr(env.shutil, "which", lambda name: "/usr/bin/skopeo" if name == "skopeo" else None)
monkeypatch.setattr(env.shutil, "copyfile", lambda _src, _dst: None)
calls: list[list[str]] = []
def fake_run(cmd, capture_output=True, text=True):
calls.append(list(cmd))
if cmd[:2] == ["docker", "push"]:
return SimpleNamespace(returncode=1, stdout="", stderr="x509: unknown authority")
# Secure skopeo path should be used and succeed.
assert cmd[0] == "/usr/bin/skopeo"
assert "--dest-tls-verify=true" in cmd
assert "--dest-cert-dir" in cmd
assert "--dest-tls-verify=false" not in cmd
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(env.subprocess, "run", fake_run)
ok = env._push_docker_image("myrddin.prole.org:5000/prole-db:18-118")
assert ok is True
assert len(calls) == 2
def test_push_docker_image_auto_detects_repo_cert_for_myrddin(monkeypatch):
"""Regression: when no env var is provided, prefer repo-shipped certs (Ansible prole_ssl role)."""
from installer.core import env
monkeypatch.delenv("PROLE_REGISTRY_CA_CERT", raising=False)
monkeypatch.delenv("REGISTRY_CA_CERT", raising=False)
monkeypatch.delenv("PROLE_REGISTRY_CERT_DIR", raising=False)
monkeypatch.setattr(
env.shutil,
"which",
lambda name: "/usr/bin/skopeo" if name == "skopeo" else None,
)
monkeypatch.setattr(env.shutil, "copyfile", lambda _src, _dst: None)
calls: list[list[str]] = []
def fake_run(cmd, capture_output=True, text=True):
calls.append(list(cmd))
if cmd[:2] == ["docker", "push"]:
return SimpleNamespace(
returncode=1, stdout="", stderr="x509: unknown authority"
)
assert cmd[0] == "/usr/bin/skopeo"
assert "--dest-tls-verify=true" in cmd
assert "--dest-cert-dir" in cmd
assert "--dest-tls-verify=false" not in cmd
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(env.subprocess, "run", fake_run)
ok = env._push_docker_image("myrddin.prole.org:5000/prole-db:18-118")
assert ok is True
assert len(calls) == 2

View File

@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
import pytest
from installer.ui.screens.docker import DockerScreenMixin
import installer.ui.screens.docker as docker_mod
class _DummyDockerScreen(DockerScreenMixin):
def __init__(self, tmp_path: Path):
self._tmp_path = tmp_path
self.prole_cfg_data = {
# Global deployment mode may remain k3d, while the UI selection is Service.
"Global": {"DEPLOYMENT_MODE": "k3d"},
"Docker Build": {},
}
self.local_registry_url = None
self.local_registry_internal = None
def _cluster_env_key(self, env_label=None) -> str:
return "service"
def _deployment_mode(self) -> str:
return "k3d"
def _k3s_registry_hostport(self) -> str:
return "myrddin.prole.org:5000"
def _registry_namespace(self) -> str:
return "knoe-system"
def check_docker_running(self) -> bool:
return True
def safe_after(self, fn):
# Execute inline for tests
try:
fn()
except Exception:
pass
def _save_prole_cfg(self):
return None
def _registry_log_path(self) -> Path:
return self._tmp_path / "local_registry.log"
def test_ensure_local_registry_available_prefers_selected_env_over_global_mode(monkeypatch, tmp_path):
"""Regression: Service (k3s) selection must not trigger localhost/k3d registry checks."""
def _unexpected_ping(*_args, **_kwargs):
raise AssertionError("_http_ping_registry should not be called for k3s branch")
def _unexpected_run(*args, **kwargs):
raise AssertionError(f"subprocess.run should not be used for k3s branch: {args}")
monkeypatch.setattr(docker_mod, "_http_ping_registry", _unexpected_ping)
monkeypatch.setattr(docker_mod.subprocess, "run", _unexpected_run)
screen = _DummyDockerScreen(tmp_path)
info = screen.ensure_local_registry_available()
assert info == (
"myrddin.prole.org:5000",
"registry.knoe-system.svc.cluster.local:5000",
)

View File

@ -0,0 +1,30 @@
from __future__ import annotations
from unittest.mock import MagicMock
def test_db_rescan_registry_status_after_init_schedules_multiple_checks():
from installer.ui.screens.database import DatabaseScreenMixin
class _Dummy(DatabaseScreenMixin):
def __init__(self):
self._scheduled: list[int] = []
self._ensure_called = 0
self._db_registry_status_label = 1
self.bg_canvas = MagicMock()
def safe_after(self, fn, delay: int = 0):
self._scheduled.append(delay)
if delay == 0:
return fn()
return None
def _ensure_db_build_registry_async(self):
self._ensure_called += 1
d = _Dummy()
d._db_rescan_registry_status_after_init()
# One immediate label update (delay 0), then a sequence of scheduled checks.
assert d._scheduled == [0, 0, 1000, 2000, 4000, 8000, 15000]
assert d._ensure_called == 1

View File

@ -0,0 +1,141 @@
from __future__ import annotations
import json
from installer.ui.screens.cluster import ClusterScreenMixin
class _Var:
def __init__(self, value: str = ""):
self._value = value
def get(self) -> str:
return self._value
def set(self, value: str) -> None:
self._value = value
class _Root:
def after(self, _delay_ms: int, fn):
fn()
class _Canvas:
def itemconfig(self, *_args, **_kwargs):
return None
class _Button:
def configure(self, **_kwargs):
return None
def test_cluster_status_refresh_is_observe_only(monkeypatch):
"""Regression: simply navigating/refreshing the Cluster Environment screen must not mutate the cluster."""
class _ImmediateThread:
def __init__(self, *, target=None, daemon=None):
self._target = target
def start(self):
# Execute synchronously so exceptions surface to the test.
if self._target:
self._target()
monkeypatch.setattr(
"installer.ui.screens.cluster.threading.Thread", _ImmediateThread, raising=True
)
class Dummy(ClusterScreenMixin):
def __init__(self):
self.root = _Root()
self.bg_canvas = _Canvas()
self.k3d_status_label = 1
self.common_services_status_label = 2
self._init_cluster_button = _Button()
self._kong_last_repair_at = 0.0
self._kong_repair_cooldown_s = 0.0
self._repair_inflight = False
self._repair_last_run_at = 0.0
self._repair_cooldown_s = 0.0
def _set_cluster_env_message(self, *_args, **_kwargs):
return None
def _cluster_status_snapshot(self) -> dict:
return {
"env": "service",
"cluster_ok": True,
"cluster_msg": "ok",
"cluster_fill": "#34c759",
"common_ok": True,
"common_msg": "ok",
"common_fill": "#34c759",
}
def _dashboard_kong_status(self, _env=None):
# Unhealthy, but observe-only behavior must not attempt repairs.
return {"ok": False, "reset_pods": ["pod-1"]}
def _authority_context_missing(self) -> bool:
return True
def _maybe_run_repair_pipeline(self, *_args, **_kwargs):
raise AssertionError("repair pipeline must not run during status refresh")
Dummy().check_cluster_status_async()
def test_service_namespace_infers_registry_namespace(monkeypatch):
"""If cluster is reachable and `registry:2` exists, infer that namespace instead of defaulting to `default`."""
class Dummy(ClusterScreenMixin):
def __init__(self):
self.service_namespace = _Var("")
self.cluster_env = _Var("prod")
self.prole_cfg_data = {"Global": {}}
def _ensure_k3s_kubeconfig_merged(self):
return None
def _deployment_mode(self) -> str:
return "k8s"
def _kubectl_base_cmd(self, _mode=None):
return ["kubectl"]
payload = {
"items": [
{
"metadata": {"namespace": "knoe-system"},
"spec": {
"template": {
"spec": {"containers": [{"image": "registry:2"}]}
}
},
}
]
}
def fake_run(cmd, capture_output, text, env, timeout):
assert cmd[:3] == ["kubectl", "get", "deploy"]
class _Res:
returncode = 0
stdout = json.dumps(payload)
stderr = ""
return _Res()
monkeypatch.setattr(
"installer.ui.screens.cluster.subprocess.run", fake_run, raising=True
)
# Avoid reliance on an actual kubeconfig in test environment.
monkeypatch.setattr(
"installer.ui.screens.cluster._find_kubeconfig_file", lambda *_a, **_k: "", raising=True
)
assert Dummy()._get_service_namespace() == "knoe-system"

View File

@ -0,0 +1,19 @@
from __future__ import annotations
from pathlib import Path
def test_init_kerberos_waits_for_ad_forwarder_ready_and_endpoints():
"""Regression: Kerberos test must not run against an AD forwarder Service with no endpoints.
Historically `init_kerberos.sh` would `kubectl apply` the AD forwarder Deployment/Service and then
proceed immediately (or ignore rollout failures), which could yield `kinit: Cannot contact any KDC`.
"""
text = Path("etc/init_kerberos.sh").read_text(encoding="utf-8")
assert "wait_for_ad_forwarder_ready" in text
assert "wait_for_ad_forwarder_ready \"$KRB5_AD_NAMESPACE\" \"$KRB5_AD_PROXY_NAME\" \"$KRB5_AD_SERVICE_NAME\"" in text
# Ensure we don't silently ignore readiness.
assert "rollout status deploy/${KRB5_AD_PROXY_NAME} --timeout=120s || true" not in text

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path
from installer.core.actions import ProleSilentInstaller
from installer.core.actions import ProleConsoleInstaller
from installer.core.controller import ProleController
@ -12,7 +12,7 @@ def test_silent_installer_service_namespace_defaults_to_knoe_system_for_k3s(tmp_
monkeypatch.delenv("SERVICE_NAMESPACE", raising=False)
c = ProleController(tmp_path)
installer = ProleSilentInstaller(c)
installer = ProleConsoleInstaller(c)
installer.inputs["init_cluster.cluster_env"] = "service"
installer.prole_cfg_data = {"Global": {}}

View File

@ -5,13 +5,13 @@ from types import SimpleNamespace
import pytest
import installer.core.actions as actions
from installer.core.actions import ProleSilentInstaller
from installer.core.actions import ProleConsoleInstaller
from installer.core.controller import ProleController
def _mk_installer(tmp_path) -> ProleSilentInstaller:
def _mk_installer(tmp_path) -> ProleConsoleInstaller:
c = ProleController(tmp_path)
return ProleSilentInstaller(c)
return ProleConsoleInstaller(c)
def test_repair_flow_classifies_blockers_attempts_reclaim_and_avoids_blind_health_wait(

View File

@ -5,7 +5,7 @@ import os
from types import SimpleNamespace
import installer.core.actions as actions
from installer.core.actions import ProleSilentInstaller
from installer.core.actions import ProleConsoleInstaller
from installer.core.controller import ProleController
@ -29,9 +29,9 @@ def _preserve_namespace_env():
os.environ[k] = v
def _mk_installer(tmp_path) -> ProleSilentInstaller:
def _mk_installer(tmp_path) -> ProleConsoleInstaller:
c = ProleController(tmp_path)
return ProleSilentInstaller(c)
return ProleConsoleInstaller(c)
def test_match_stale_released_pv_happy_path(tmp_path):