#!/usr/bin/env bash set -u # init_port_forwards.sh # Portable-ish (macOS, Ubuntu, Raspberry Pi OS, Alpine) bash init-style script # Manages kubectl port-forward daemons defined in port-mapping.cfg. PROG="init_port_forwards" SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck disable=SC1090 source "$SCRIPT_DIR/prole_cfg.sh" PROLE_HOME="${PROLE_HOME:-/Users/chrisfu/dev/prole}" VERBOSE=0 PROLE_CFG_OVERRIDE="" PORT_MAPPING_FILE_PATH="" PORT_FORWARD_ENABLED="${PORT_FORWARD_ENABLED:-}" PORT_FORWARD_K3D_ENABLED="${PORT_FORWARD_K3D_ENABLED:-}" PORT_FORWARD_K3S_ENABLED="${PORT_FORWARD_K3S_ENABLED:-}" PORT_FORWARD_HOSTS="${PORT_FORWARD_HOSTS:-}" PORT_FORWARD_K3D_HOSTS="${PORT_FORWARD_K3D_HOSTS:-}" PORT_FORWARD_K3S_HOSTS="${PORT_FORWARD_K3S_HOSTS:-}" PORT_MAPPING_FILE="${PORT_MAPPING_FILE:-}" PORT_FORWARD_SKIP_VALIDATE="${PORT_FORWARD_SKIP_VALIDATE:-}" PORT_FORWARD_SKIP_WAIT="${PORT_FORWARD_SKIP_WAIT:-}" usage() { cat < [component] Options: -c, --config-file=FILE Path to a prole.cfg file to source when generating port-mapping.cfg -v, --verbose Verbose output -f, --force Force: kill existing processes blocking ports Examples: $PROG -c ./prole.cfg start $PROG stop openbao $PROG --verbose status Config (prole.cfg or port-mapping.cfg): PORT_FORWARD_K3D_MAPPING_1 = id=dashboard;namespace=kubernetes-dashboard;target=svc/kubernetes-dashboard-kong-proxy;address=127.0.0.1;hostPort=8443;servicePort=443;protocol=TCP;description=Kubernetes Dashboard PORT_FORWARD_K3S_MAPPING_1 = id=opentofu;namespace=\${NAMESPACE};target=svc/opentofu;address=0.0.0.0;hostPort=8080;servicePort=8080;protocol=TCP;description=OpenTofu Legacy port-mapping.cfg: dashboard: local=8000 remote=80 ns=kubernetes-dashboard svc=kubernetes-dashboard-web address=0.0.0.0 EOF } TARGET_ID="" FORCE=0 log() { printf '%s\n' "$*"; } vlog() { [ "$VERBOSE" -eq 1 ] && printf '[verbose] %s\n' "$*" || true; } err() { printf '[error] %s\n' "$*" >&2; } have() { command -v "$1" >/dev/null 2>&1; } # ---- Config helpers / derived defaults ---- cfg_file() { if [ -n "${PROLE_CONF:-}" ] && [ -f "$PROLE_CONF/prole.cfg" ]; then printf '%s' "$PROLE_CONF/prole.cfg" return 0 fi if [ -n "${PROLE_HOME:-}" ] && [ -f "$PROLE_HOME/conf/prole.cfg" ]; then printf '%s' "$PROLE_HOME/conf/prole.cfg" return 0 fi return 1 } CFG_FILE="$(cfg_file || true)" cfg_value() { local key="$1" [ -n "${CFG_FILE:-}" ] || return 0 awk -F= -v k="$key" ' /^[[:space:]]*;/ {next} /^[[:space:]]*#/ {next} /^[[:space:]]*\\[/ {next} { kk=$1 sub(/^[[:space:]]+/, "", kk) sub(/[[:space:]]+$/, "", kk) } kk == k { v=$2 sub(/^[[:space:]]+/, "", v) sub(/[[:space:]]+$/, "", v) val=v } END { print val } ' "$CFG_FILE" 2>/dev/null } is_truthy() { case "${1:-}" in 1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;; esac return 1 } current_hostname() { local host="" if have hostname; then host="$(hostname -f 2>/dev/null || hostname 2>/dev/null || true)" fi if [ -z "$host" ] && [ -n "${HOSTNAME:-}" ]; then host="$HOSTNAME" fi printf '%s' "$host" } host_in_allowlist() { local list="$1" local host="$2" local short="${host%%.*}" list="${list//,/ }" for item in $list; do [ -z "$item" ] && continue if [ "$item" = "$host" ] || [ "$item" = "$short" ]; then return 0 fi done return 1 } port_forward_allowed_on_host() { local allow="" case "${PROLE_MODE:-}" in k3s) allow="${PORT_FORWARD_K3S_HOSTS:-${PORT_FORWARD_HOSTS:-}}" ;; k3d|"") allow="${PORT_FORWARD_K3D_HOSTS:-${PORT_FORWARD_HOSTS:-}}" ;; *) allow="${PORT_FORWARD_HOSTS:-}" ;; esac if [ -z "$allow" ]; then return 0 fi local host host="$(current_hostname)" if [ -z "$host" ]; then return 0 fi host_in_allowlist "$allow" "$host" } trim() { local s="$1" s="${s#"${s%%[![:space:]]*}"}" s="${s%"${s##*[![:space:]]}"}" printf '%s' "$s" } resolve_port_mapping_file() { if [ -n "${PORT_MAPPING_FILE:-}" ]; then printf '%s' "$PORT_MAPPING_FILE" return 0 fi if [ -n "${PROLE_CONF:-}" ]; then printf '%s/port-mapping.cfg' "$PROLE_CONF" return 0 fi if [ -n "${PROLE_HOME:-}" ]; then printf '%s/conf/port-mapping.cfg' "$PROLE_HOME" return 0 fi return 1 } mapping_prefix_for_mode() { case "${PROLE_MODE:-}" in k3s) printf '%s' "PORT_FORWARD_K3S_MAPPING_" ;; k3d|"") printf '%s' "PORT_FORWARD_K3D_MAPPING_" ;; *) printf '%s' "PORT_FORWARD_MAPPING_" ;; esac } collect_cfg_mappings() { local prefix="$1" local file="$2" [ -f "$file" ] || return 1 awk -v p="$prefix" ' /^[[:space:]]*[#;]/ {next} /^[[:space:]]*\[/ {next} { line=$0 pos=index(line, "=") if (pos == 0) next key=substr(line, 1, pos-1) val=substr(line, pos+1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) gsub(/^[[:space:]]+|[[:space:]]+$/, "", val) if (key ~ ("^" p "[0-9]+$")) { num=substr(key, length(p)+1)+0 print num "\t" val count++ } } END { if (count == 0) exit 1 } ' "$file" | sort -n } collect_cfg_mappings_for_mode() { local file="$1" local prefix prefix="$(mapping_prefix_for_mode)" if collect_cfg_mappings "$prefix" "$file"; then return 0 fi # Fallback to generic mappings if mode-specific ones are absent if collect_cfg_mappings "PORT_FORWARD_MAPPING_" "$file"; then return 0 fi return 1 } has_ini_mappings() { local file="$1" [ -f "$file" ] || return 1 grep -Eq '^[[:space:]]*PORT_FORWARD_.*_MAPPING_[0-9]+' "$file" } parse_cfg_mapping() { local line="$1" local id="" ns="" target="" address="" hostPort="" servicePort="" protocol="" description="" local token key value IFS=';' read -ra parts <<<"$line" for token in "${parts[@]}"; do token="$(trim "$token")" [ -z "$token" ] && continue key="$(trim "${token%%=*}")" value="$(trim "${token#*=}")" case "$key" in id) id="$value" ;; namespace|ns) ns="$value" ;; target) target="$value" ;; address|addr) address="$value" ;; hostPort|host_port|local|host) hostPort="$value" ;; servicePort|service_port|remote) servicePort="$value" ;; protocol) protocol="$value" ;; description|desc) description="$value" ;; esac done if [ -n "${PROLE_NAMESPACE:-}" ]; then ns="${ns//\$\{NAMESPACE\}/$PROLE_NAMESPACE}" ns="${ns//\$\{PROLE_NAMESPACE\}/$PROLE_NAMESPACE}" fi if [ -n "${PF_MONITORING_NAMESPACE:-}" ]; then ns="${ns//\$\{MONITORING_NAMESPACE\}/$PF_MONITORING_NAMESPACE}" ns="${ns//\$\{PROLE_MONITORING_NAMESPACE\}/$PF_MONITORING_NAMESPACE}" fi if [ -n "${PF_MANAGEMENT_NAMESPACE:-}" ]; then ns="${ns//\$\{MANAGEMENT_NAMESPACE\}/$PF_MANAGEMENT_NAMESPACE}" ns="${ns//\$\{PROLE_MANAGEMENT_NAMESPACE\}/$PF_MANAGEMENT_NAMESPACE}" fi if [ "$id" = "postgres" ]; then if [ -n "${DB_HOST_PORT:-}" ]; then hostPort="$DB_HOST_PORT" fi if [ "$SUPABASE_ENABLED_EFFECTIVE" -eq 1 ]; then ns="$SUPABASE_NAMESPACE" target="$SUPABASE_DB_TARGET" fi fi if [ -z "$address" ]; then address="127.0.0.1"; fi if [ -z "$protocol" ]; then protocol="TCP"; fi printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ "$id" "$ns" "$target" "$address" "$hostPort" "$servicePort" "$protocol" "$description" } generate_port_mapping_file() { local src_cfg="$1" local dst_cfg="$2" [ -f "$src_cfg" ] || { err "Prole config file not found: $src_cfg"; return 1; } local dst_dir dst_dir="$(dirname "$dst_cfg")" mkdir -p "$dst_dir" >/dev/null 2>&1 || true local tmp="${dst_cfg}.tmp.$$" { printf '; Port mappings generated by %s\n' "$PROG" printf '; Source: %s\n' "$src_cfg" awk ' /^[[:space:]]*[#;]/ {next} /^[[:space:]]*\[/ {next} { line=$0 pos=index(line, "=") if (pos == 0) next key=substr(line, 1, pos-1) val=substr(line, pos+1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) gsub(/^[[:space:]]+|[[:space:]]+$/, "", val) if (key ~ /^PORT_FORWARD_.*_MAPPING_[0-9]+$/) { print key " = " val } } ' "$src_cfg" } >"$tmp" if ! grep -q "^PORT_FORWARD_.*_MAPPING_[0-9][0-9]*[[:space:]]*=" "$tmp"; then rm -f "$tmp" >/dev/null 2>&1 || true err "No port-forward mappings found in $src_cfg" return 1 fi mv "$tmp" "$dst_cfg" return 0 } ensure_port_mapping_file() { local src_cfg="$1" local dst_cfg="$2" if [ -f "$dst_cfg" ]; then return 0 fi if [ -z "$src_cfg" ] || [ ! -f "$src_cfg" ]; then err "Port mapping file not found: $dst_cfg" err "No prole.cfg available to generate it." return 1 fi log "Port mapping file not found; generating $dst_cfg from $src_cfg" generate_port_mapping_file "$src_cfg" "$dst_cfg" } port_forward_enabled_for_mode() { local val="" case "${PROLE_MODE:-}" in k3s) val="${PORT_FORWARD_K3S_ENABLED:-${PORT_FORWARD_ENABLED:-}}" ;; k3d|"") val="${PORT_FORWARD_K3D_ENABLED:-${PORT_FORWARD_ENABLED:-}}" ;; *) val="${PORT_FORWARD_ENABLED:-}" ;; esac if [ -z "$val" ]; then return 0 fi is_truthy "$val" } port_in_use() { local port="$1" if have lsof; then lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 && return 0 || return 1 fi if have ss; then ss -lnt 2>/dev/null | awk '{print $4}' | grep -E "[:.]$port$" >/dev/null 2>&1 && return 0 || return 1 fi if have netstat; then netstat -an 2>/dev/null | grep -E "[.:]$port[[:space:]]" | grep -qi listen && return 0 || return 1 fi return 1 } find_available_port() { local base="$1" local max="${2:-50}" local port="$base" local i=0 while [ "$i" -lt "$max" ]; do if ! port_in_use "$port"; then printf '%s' "$port" return 0 fi port=$((port + 1)) i=$((i + 1)) done printf '%s' "$base" } cfg_set_default() { local key="$1" local val val="$(cfg_value "$key")" if [ -n "$val" ]; then if [ -z "${!key:-}" ] || [ -n "${PROLE_CFG_OVERRIDE:-}" ]; then printf -v "$key" '%s' "$val" export "$key" fi fi } init_cfg_context() { if [ -n "${CFG_FILE:-}" ] && [ -f "$CFG_FILE" ]; then cfg_set_default "NAMESPACE" cfg_set_default "MONITORING_NAMESPACE" cfg_set_default "MANAGEMENT_NAMESPACE" cfg_set_default "PROLE_MONITORING_NAMESPACE" cfg_set_default "PROLE_MANAGEMENT_NAMESPACE" cfg_set_default "SUPABASE_ENABLED" cfg_set_default "SUPABASE_NAMESPACE" cfg_set_default "SUPABASE_DB_SERVICE" cfg_set_default "DB_HOST_PORT" cfg_set_default "PROLE_DB_ALT_PORT" fi CFG_NAMESPACE="$(cfg_value "NAMESPACE")" CFG_MONITORING_NAMESPACE="$(cfg_value "MONITORING_NAMESPACE")" CFG_MANAGEMENT_NAMESPACE="$(cfg_value "MANAGEMENT_NAMESPACE")" CFG_PROLE_MONITORING_NAMESPACE="$(cfg_value "PROLE_MONITORING_NAMESPACE")" CFG_PROLE_MANAGEMENT_NAMESPACE="$(cfg_value "PROLE_MANAGEMENT_NAMESPACE")" PF_MONITORING_NAMESPACE="${MONITORING_NAMESPACE:-${PROLE_MONITORING_NAMESPACE:-${CFG_MONITORING_NAMESPACE:-${CFG_PROLE_MONITORING_NAMESPACE:-${CFG_NAMESPACE:-}}}}}" PF_MANAGEMENT_NAMESPACE="${MANAGEMENT_NAMESPACE:-${PROLE_MANAGEMENT_NAMESPACE:-${CFG_MANAGEMENT_NAMESPACE:-${CFG_PROLE_MANAGEMENT_NAMESPACE:-${CFG_NAMESPACE:-}}}}}" if [ -z "${PF_MONITORING_NAMESPACE:-}" ] && [ -n "${NAMESPACE:-}" ]; then PF_MONITORING_NAMESPACE="$NAMESPACE" fi if [ -z "${PF_MANAGEMENT_NAMESPACE:-}" ] && [ -n "${NAMESPACE:-}" ]; then PF_MANAGEMENT_NAMESPACE="$NAMESPACE" fi SUPABASE_NAMESPACE="${SUPABASE_NAMESPACE:-supabase}" SUPABASE_DB_SERVICE="${SUPABASE_DB_SERVICE:-db}" SUPABASE_DB_TARGET="${SUPABASE_DB_TARGET:-svc/$SUPABASE_DB_SERVICE}" SUPABASE_ENABLED_EFFECTIVE=0 if is_truthy "${SUPABASE_ENABLED:-}"; then SUPABASE_ENABLED_EFFECTIVE=1 elif is_truthy "${init_cluster_supabase_enabled:-}"; then SUPABASE_ENABLED_EFFECTIVE=1 fi PROLE_DB_ALT_PORT_BASE="${PROLE_DB_ALT_PORT_BASE:-15432}" PROLE_DB_ALT_PORT_EFFECTIVE="" if [ "$SUPABASE_ENABLED_EFFECTIVE" -eq 1 ]; then if [ -n "${PROLE_DB_ALT_PORT:-}" ]; then PROLE_DB_ALT_PORT_EFFECTIVE="$PROLE_DB_ALT_PORT" else PROLE_DB_ALT_PORT_EFFECTIVE="$(find_available_port "$PROLE_DB_ALT_PORT_BASE")" fi fi } # Choose a writable state dir for pid/log files: # - Prefer XDG_RUNTIME_DIR if set and writable # - Else /var/run if writable (rare without root) # - Else ~/.local/state # - Else /tmp state_dir() { if [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -w "${XDG_RUNTIME_DIR:-}" ]; then printf '%s/%s' "$XDG_RUNTIME_DIR" "$PROG" return fi if [ -d "/var/run" ] && [ -w "/var/run" ]; then printf '/var/run/%s' "$PROG" return fi if [ -n "${HOME:-}" ]; then mkdir -p "$HOME/.local/state" >/dev/null 2>&1 || true if [ -d "$HOME/.local/state" ] && [ -w "$HOME/.local/state" ]; then printf '%s/.local/state/%s' "$HOME" "$PROG" return fi fi printf '/tmp/%s' "$PROG" } STATE_DIR="$(state_dir)" PID_DIR="$STATE_DIR/pids" LOG_DIR="$STATE_DIR/logs" ensure_dirs() { mkdir -p "$PID_DIR" "$LOG_DIR" 2>/dev/null || true if [ ! -d "$PID_DIR" ] || [ ! -d "$LOG_DIR" ]; then err "Unable to create state directories under: $STATE_DIR" exit 1 fi } # Use kubectl for actions; kubecolor is used only for prettier status output. KUBECTL="kubectl" detect_kubectl() { if have kubectl; then KUBECTL="kubectl" else err "kubectl not found in PATH" exit 1 fi } # Basic environment validation validate_env() { detect_kubectl ensure_dirs if is_truthy "${PORT_FORWARD_SKIP_VALIDATE:-}"; then return 0 fi if ! "$KUBECTL" version --client >/dev/null 2>&1; then err "kubectl seems broken or not executable" exit 1 fi # Can we reach the cluster? if ! "$KUBECTL" cluster-info >/dev/null 2>&1; then err "kubectl cannot reach a cluster (check KUBECONFIG/context)" err "Try: kubectl config get-contexts && kubectl config use-context " exit 1 fi } ensure_supabase_ports() { [ "$SUPABASE_ENABLED_EFFECTIVE" -eq 1 ] || return 0 local wire_script="$SCRIPT_DIR/init_supabase_ports.sh" if [ ! -x "$wire_script" ]; then warn "Supabase port wiring script not found/executable: $wire_script" return 0 fi log "Ensuring Supabase DB wiring (db -> prole-db-rw, supabase-postgres -> 15432) ..." SUPABASE_NAMESPACE="$SUPABASE_NAMESPACE" \ "$wire_script" -n "$NAMESPACE" --supabase-namespace "$SUPABASE_NAMESPACE" } # ---- Preflight: Docker + k3d awareness ---- # Return 0 when Docker CLI can talk to a running daemon. docker_is_running() { if ! have docker; then return 1 fi docker info >/dev/null 2>&1 } docker_port_owner() { local port="$1" have docker || return 1 docker ps --format '{{.ID}}\t{{.Names}}\t{{.Ports}}' 2>/dev/null | \ awk -v p=":"$port"->" 'index($0, p) {print $1 "\t" $2; exit 0}' } should_skip_mapping() { local id="$1" hostPort="$2" case "$id" in registry|openbao) local owner owner="$(docker_port_owner "$hostPort")" if [ -n "$owner" ]; then log "Skipping $id port-forward; docker is already publishing port $hostPort ($owner)." return 0 fi ;; esac return 1 } # Hard-fail with clean guidance when Docker is not up (used for start/restart). ensure_docker_running() { if docker_is_running; then return 0 fi if ! have docker; then err "Docker CLI not found. Please install Docker Desktop or docker CLI." else err "Docker is not running. Start Docker Desktop and wait until it is ready." fi err "Tip (macOS): open -a Docker" err "Then re-run: $PROG start" exit 3 } # Detect k3d cluster name from env or current kubectl context. # - If K3D_CLUSTER is set, use it. # - Else, if current-context starts with 'k3d-', strip prefix to get name. detect_k3d_context() { if [ -n "${K3D_CLUSTER:-}" ]; then printf '%s' "$K3D_CLUSTER" return 0 fi local ctx ctx="$($KUBECTL config current-context 2>/dev/null || echo)" case "$ctx" in k3d-*) printf '%s' "${ctx#k3d-}"; return 0 ;; *) return 1 ;; esac } # Return 0 if the given k3d cluster exists and is running. k3d_cluster_is_running() { local name="$1" have k3d || return 1 # Use json output when available; fall back to grep otherwise if k3d cluster list -o json >/dev/null 2>&1; then k3d cluster list -o json 2>/dev/null | grep -q '"name"\s*:\s*"'"$name"'"' && \ k3d cluster list -o json 2>/dev/null | sed -n 's/.*"name"\s*:\s*"\([^"]\+\)".*"serversRunning"\s*:\s*\([0-9]\+\).*/\1 \2/p' | awk -v n="$name" '$1==n {exit ($2>0)?0:1}' return $? else k3d cluster list 2>/dev/null | grep -E "^$name\s" | grep -q running return $? fi } # If current context indicates k3d, ensure the cluster is up; otherwise no-op. ensure_k3d_ready_if_applicable() { local k3d_name if ! k3d_name="$(detect_k3d_context)"; then return 0 fi if ! have k3d; then err "k3d is not installed but kubectl context suggests k3d (context=$("$KUBECTL" config current-context))." err "Install k3d: brew install k3d (macOS)" err "Or switch context: kubectl config use-context " exit 4 fi if ! k3d_cluster_is_running "$k3d_name"; then err "k3d cluster '$k3d_name' is not running." err "Start it: k3d cluster start $k3d_name" err "Then re-run: $PROG start" exit 4 fi } is_local_mode() { case "${PROLE_MODE:-}" in ""|k3d) return 0 ;; esac return 1 } pid_file_for() { printf '%s/%s.pid' "$PID_DIR" "$1"; } log_file_for() { printf '%s/%s.log' "$LOG_DIR" "$1"; } wait_for_service_endpoints() { local ns="$1" svc="$2" local timeout="${PORT_FORWARD_WAIT_TIMEOUT:-120}" local interval="${PORT_FORWARD_WAIT_INTERVAL:-3}" local start start=$(date +%s) while true; do if "$KUBECTL" -n "$ns" get endpoints "$svc" -o jsonpath='{.subsets[*].addresses[*].ip}' 2>/dev/null | grep -q .; then return 0 fi if (( $(date +%s) - start > timeout )); then err "WARN: Endpoints for service '$svc' in namespace '$ns' not ready after ${timeout}s; continuing." return 1 fi sleep "$interval" done } is_pid_running() { # Return 0 if pid exists and running, else 1 # kill -0 is portable. local pid="$1" [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1 } is_matching_port_forward() { local pid="$1" target="$2" hostPort="$3" servicePort="$4" local cmd cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)" if [ -z "$cmd" ]; then return 1 fi case "$cmd" in *port-forward*"$target"*"$hostPort:$servicePort"*) return 0 ;; esac return 1 } read_pid() { local pf="$1" [ -f "$pf" ] || return 1 # shellcheck disable=SC2162 read pid <"$pf" || return 1 printf '%s' "$pid" } write_pid() { local pf="$1" pid="$2" printf '%s\n' "$pid" >"$pf" } remove_pidfile() { local pf="$1" rm -f "$pf" >/dev/null 2>&1 || true } handle_mapping_parsed() { local callback="$1" local parsed="$2" local id ns target address hostPort servicePort protocol description IFS=$'\t' read -r id ns target address hostPort servicePort protocol description <<<"$parsed" if [ -z "$id" ] || [ -z "$ns" ] || [ -z "$target" ] || [ -z "$hostPort" ] || [ -z "$servicePort" ]; then err "Invalid mapping (missing required fields): $parsed" exit 1 fi if [ -n "$TARGET_ID" ] && [ "$id" != "$TARGET_ID" ]; then return 0 fi vlog "mapping: id=$id ns=$ns target=$target address=$address hostPort=$hostPort servicePort=$servicePort protocol=$protocol" "$callback" "$id" "$ns" "$target" "$address" "$hostPort" "$servicePort" "$protocol" "$description" return 0 } foreach_mapping_cfg() { local callback="$1" local file="${2:-$PORT_MAPPING_FILE_PATH}" local mappings line parsed count=0 [ -n "$file" ] || return 1 if ! mappings="$(collect_cfg_mappings_for_mode "$file")"; then return 1 fi while IFS= read -r line; do [ -z "$line" ] && continue parsed="$(parse_cfg_mapping "${line#*$'\t'}")" if handle_mapping_parsed "$callback" "$parsed"; then count=$((count + 1)) fi done <<<"$mappings" [ "$count" -gt 0 ] || return 1 return 0 } foreach_mapping_legacy() { local callback="$1" local file="${2:-$PORT_MAPPING_FILE_PATH}" local line id rest token key value local hostPort servicePort ns target address protocol description local count=0 [ -f "$file" ] || return 1 while IFS= read -r line; do line="$(trim "$line")" [ -z "$line" ] && continue case "$line" in \#*|\;*) continue ;; esac id="${line%%:*}" rest="${line#*:}" if [ "$id" = "$line" ]; then continue fi id="$(trim "$id")" rest="$(trim "$rest")" hostPort="" servicePort="" ns="" target="" address="" protocol="" description="" for token in $rest; do key="$(trim "${token%%=*}")" value="$(trim "${token#*=}")" case "$key" in local|host|hostPort|host_port) hostPort="$value" ;; remote|servicePort|service_port) servicePort="$value" ;; ns|namespace) ns="$value" ;; svc|service) target="svc/$value" ;; target) target="$value" ;; address|addr) address="$value" ;; protocol|proto) protocol="$value" ;; desc|description) description="$value" ;; esac done if [ -z "$address" ]; then address="127.0.0.1"; fi if [ -z "$protocol" ]; then protocol="TCP"; fi line="id=$id;namespace=$ns;target=$target;address=$address;hostPort=$hostPort;servicePort=$servicePort;protocol=$protocol;description=$description" parsed="$(parse_cfg_mapping "$line")" if handle_mapping_parsed "$callback" "$parsed"; then count=$((count + 1)) fi done <"$file" [ "$count" -gt 0 ] || return 1 return 0 } foreach_mapping() { local callback="$1" if [ -n "${CFG_FILE:-}" ] && [ -f "$CFG_FILE" ]; then if foreach_mapping_cfg "$callback" "$CFG_FILE"; then return 0 fi fi if [ -n "${PORT_MAPPING_FILE_PATH:-}" ] && [ -f "$PORT_MAPPING_FILE_PATH" ]; then if has_ini_mappings "$PORT_MAPPING_FILE_PATH"; then if foreach_mapping_cfg "$callback" "$PORT_MAPPING_FILE_PATH"; then return 0 fi else if foreach_mapping_legacy "$callback" "$PORT_MAPPING_FILE_PATH"; then return 0 fi fi fi return 1 } build_port_forward_cmd() { # echo a command string local ns="$1" target="$2" address="$3" hostPort="$4" servicePort="$5" # kubectl port-forward -n --address : printf '%s port-forward -n %s --address %s %s %s:%s' \ "$KUBECTL" "$ns" "$address" "$target" "$hostPort" "$servicePort" } start_port_forward() { local id="$1" ns="$2" target="$3" address="$4" hostPort="$5" servicePort="$6" protocol="$7" description="$8" local pidfile logfile cmd pid if should_skip_mapping "$id" "$hostPort"; then stop_port_forward "$id" "$ns" "$target" "$address" "$hostPort" "$servicePort" "$protocol" "$description" return 0 fi # Aggressively stop existing processes before starting to avoid port conflicts stop_port_forward "$id" "$ns" "$target" "$address" "$hostPort" "$servicePort" "$protocol" "$description" # If target is a service, wait briefly for endpoints to be ready to avoid immediate port-forward failures. if [[ "$target" == svc/* ]] && ! is_truthy "${PORT_FORWARD_SKIP_WAIT:-}"; then wait_for_service_endpoints "$ns" "${target#svc/}" || true fi pidfile="$(pid_file_for "$id")" logfile="$(log_file_for "$id")" cmd="$(build_port_forward_cmd "$ns" "$target" "$address" "$hostPort" "$servicePort")" log "Starting: $id $address:$hostPort -> $target:$servicePort ($ns) ${description:-}" vlog "Command: $cmd" # Start in background, keep output in log. # nohup is available on macOS/Linux; redirect stdin from /dev/null to detach. nohup sh -c "$cmd" >>"$logfile" 2>&1 /dev/null 2>&1 || true # wait a moment, then SIGKILL if needed local i for i in 1 2 3 4 5; do if ! is_pid_running "$pid"; then break; fi sleep 0.2 done if is_pid_running "$pid"; then err "$id did not stop gracefully; sending SIGKILL" kill -9 "$pid" >/dev/null 2>&1 || true fi else if [ -f "$pidfile" ]; then vlog "$id stale pidfile (pid=$pid not running)" fi fi remove_pidfile "$pidfile" # Aggressively remove any other matching kubectl port-forward processes # Search for processes that match: kubectl port-forward -n ... : local extra_pids extra_pids=$(ps -ef | grep "port-forward" | grep "\-n" | grep "$ns" | grep "$target" | grep "$hostPort:$servicePort" | grep -v grep | awk '{print $2}') for epid in $extra_pids; do if [ "$epid" != "$pid" ]; then log "Cleaning up orphan process for $id (pid=$epid)" kill -9 "$epid" >/dev/null 2>&1 || true fi done } status_one() { local id="$1" ns="$2" target="$3" address="$4" hostPort="$5" servicePort="$6" protocol="$7" description="$8" local pidfile pid pidfile="$(pid_file_for "$id")" pid="" if [ -f "$pidfile" ]; then pid="$(read_pid "$pidfile" || true)" fi if [ -n "${pid:-}" ] && is_pid_running "$pid"; then printf 'RUNNING %-12s pid=%-7s %s:%s -> %s:%s ns=%s %s\n' \ "$id" "$pid" "$address" "$hostPort" "$target" "$servicePort" "$ns" "${description:-}" # ps details (portable flags vary; use a conservative format) ps -p "$pid" -o pid=,ppid=,etime=,command= 2>/dev/null | sed 's/^/ /' || true else printf 'STOPPED %-12s (no live pid) %s:%s -> %s:%s ns=%s %s\n' \ "$id" "$address" "$hostPort" "$target" "$servicePort" "$ns" "${description:-}" fi } scan_for_collisions() { local id="$1" ns="$2" target="$3" address="$4" hostPort="$5" servicePort="$6" protocol="$7" description="$8" if should_skip_mapping "$id" "$hostPort"; then return 0 fi if port_in_use "$hostPort"; then # find which process is using it local pid_info="" if have lsof; then pid_info=$(lsof -nP -iTCP:"$hostPort" -sTCP:LISTEN -t 2>/dev/null | head -n 1) fi if [ -n "$pid_info" ]; then if is_matching_port_forward "$pid_info" "$target" "$hostPort" "$servicePort"; then vlog "Port $hostPort already forwarded by kubectl for $id (pid=$pid_info)." return 0 fi # Check if this PID is already managed by us local pidfile pid_managed pidfile="$(pid_file_for "$id")" pid_managed="$(read_pid "$pidfile" 2>/dev/null || true)" if [ "$pid_info" = "$pid_managed" ]; then vlog "Port $hostPort is in use by our own process ($id, pid=$pid_info). This is fine for start/restart." return 0 fi local proc_details proc_details=$(ps -p "$pid_info" -o pid=,command= 2>/dev/null | sed 's/[[:space:]]\+/ /g' || echo "$pid_info") if [ "$FORCE" -eq 1 ]; then log "Port $hostPort is in use by: $proc_details" log "Force enabled. Killing process $pid_info..." if ! kill -9 "$pid_info" 2>/dev/null; then err "Failed to kill process $pid_info. Permission denied?" exit 1 fi sleep 0.5 else err "Port collision detected: Port $hostPort is already in use by another process." err "Process details: $proc_details" err "Use -f or --force to kill the offending process, or stop it manually." exit 1 fi else # Port in use but we can't find PID (maybe another user's process) err "Port collision detected: Port $hostPort is in use, but could not determine PID (check with sudo lsof -i :$hostPort)." exit 1 fi fi } do_start() { if ! port_forward_enabled_for_mode; then log "Port forwards disabled for mode '${PROLE_MODE:-auto}'." return 0 fi if ! port_forward_allowed_on_host; then log "Port forwards disabled on this host ($(current_hostname)); not listed in PORT_FORWARD_*_HOSTS." return 0 fi validate_env if is_local_mode; then # Preflight: require Docker daemon and k3d (if applicable) ensure_docker_running ensure_k3d_ready_if_applicable fi ensure_supabase_ports vlog "Scanning for port collisions..." if ! foreach_mapping scan_for_collisions; then err "No port-forward mappings found. Check ${CFG_FILE:-prole.cfg} or ${PORT_MAPPING_FILE_PATH:-port-mapping.cfg}." exit 1 fi if ! foreach_mapping start_port_forward; then err "No port-forward mappings started. Check ${CFG_FILE:-prole.cfg} or ${PORT_MAPPING_FILE_PATH:-port-mapping.cfg}." exit 1 fi } do_stop() { # stop doesn't require cluster access, but it does need state dirs ensure_dirs if ! foreach_mapping stop_port_forward; then log "No port-forward mappings found to stop." fi } do_restart() { if ! port_forward_enabled_for_mode; then log "Port forwards disabled for mode '${PROLE_MODE:-auto}'." return 0 fi if ! port_forward_allowed_on_host; then log "Port forwards disabled on this host ($(current_hostname)); not listed in PORT_FORWARD_*_HOSTS." return 0 fi validate_env if is_local_mode; then # Preflight: require Docker daemon and k3d (if applicable) ensure_docker_running ensure_k3d_ready_if_applicable fi ensure_supabase_ports vlog "Scanning for port collisions (excluding our own)..." # During restart, we'll stop them first anyway, but let's be safe. if ! foreach_mapping stop_port_forward; then err "No port-forward mappings found. Check ${CFG_FILE:-prole.cfg} or ${PORT_MAPPING_FILE_PATH:-port-mapping.cfg}." exit 1 fi if ! foreach_mapping scan_for_collisions; then err "No port-forward mappings found. Check ${CFG_FILE:-prole.cfg} or ${PORT_MAPPING_FILE_PATH:-port-mapping.cfg}." exit 1 fi if ! foreach_mapping start_port_forward; then err "No port-forward mappings started. Check ${CFG_FILE:-prole.cfg} or ${PORT_MAPPING_FILE_PATH:-port-mapping.cfg}." exit 1 fi } do_status() { if ! port_forward_allowed_on_host; then log "Port forwards disabled on this host ($(current_hostname)); not listed in PORT_FORWARD_*_HOSTS." return 0 fi validate_env # Non-fatal awareness messages if docker_is_running; then log "[OK] Docker daemon is running" else if have docker; then log "[WARN] Docker is not running" else log "[WARN] Docker CLI not found" fi fi local _k3d_name if _k3d_name="$(detect_k3d_context)"; then if have k3d; then if k3d_cluster_is_running "$_k3d_name"; then log "[OK] k3d cluster '$_k3d_name' is running" else log "[WARN] k3d cluster '$_k3d_name' is not running" fi else log "[WARN] k3d not installed but context suggests k3d (cluster='$_k3d_name')" fi fi log "== Context / Cluster ==" "$KUBECTL" config current-context 2>/dev/null | sed 's/^/ context: /' || true "$KUBECTL" cluster-info 2>/dev/null | sed 's/^/ /' || true log "" log "== Port-forward processes ==" if ! foreach_mapping status_one; then log " (no port-forward mappings found)" fi log "" log "== Quick k3d awareness checks (best-effort) ==" # If user is on k3d, current-context often includes k3d-... but not guaranteed. # Show nodes + a few namespaces/services related to mappings (best effort). "$KUBECTL" get nodes -o wide 2>/dev/null | sed 's/^/ /' || true log "" "$KUBECTL" get ns 2>/dev/null | sed 's/^/ /' || true log "" # For each mapping, try to show target existence log "== Target existence (best-effort) ==" _target_check() { local id="$1" ns="$2" target="$3" address="$4" hostPort="$5" servicePort="$6" protocol="$7" description="$8" # kubectl get -n # If target is like "svc/name", "deploy/name", etc. if "$KUBECTL" get -n "$ns" "$target" >/dev/null 2>&1; then printf 'OK %-12s %s (ns=%s)\n' "$id" "$target" "$ns" else printf 'MISSING %-12s %s (ns=%s)\n' "$id" "$target" "$ns" fi } foreach_mapping _target_check } # ---- arg parsing ---- ACTION="" while [ $# -gt 0 ]; do case "$1" in start|stop|restart|status) if [ -z "$ACTION" ]; then ACTION="$1" else TARGET_ID="$1" fi shift ;; -m|--mode) shift prole_set_mode "${1:-}" shift ;; -m=*|--mode=*) prole_set_mode "${1#*=}" shift ;; -v|--verbose) VERBOSE=1 shift ;; -f|--force) FORCE=1 shift ;; -c) shift [ $# -gt 0 ] || { err "-c requires a file path"; usage; exit 2; } PROLE_CFG_OVERRIDE="$1" shift ;; --config-file=*) PROLE_CFG_OVERRIDE="${1#*=}" shift ;; -h|--help) usage exit 0 ;; *) if [ -z "$ACTION" ]; then err "Unknown arg: $1" usage exit 2 fi TARGET_ID="$1" shift ;; esac done if [ -n "$PROLE_CFG_OVERRIDE" ]; then CFG_FILE="$PROLE_CFG_OVERRIDE" if [ -f "$CFG_FILE" ]; then PROLE_CONF="$(cd "$(dirname "$CFG_FILE")" && pwd)" export PROLE_CONF fi fi if [ -z "${CFG_FILE:-}" ]; then CFG_FILE="$(cfg_file || true)" fi [ -n "$ACTION" ] || { usage; exit 2; } PORT_MAPPING_FILE_PATH="$(resolve_port_mapping_file || true)" init_cfg_context if ! ensure_port_mapping_file "$CFG_FILE" "$PORT_MAPPING_FILE_PATH"; then exit 1 fi case "$ACTION" in start) do_start ;; stop) do_stop ;; restart) do_restart ;; status) do_status ;; esac