#!/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 an XML file. PROG="init_port_forwards" PROLE_HOME="${PROLE_HOME:-/Users/chrisfu/dev/prole}" VERBOSE=0 CONFIG_FILE="$PROLE_HOME/conf/port-mappings.properties" usage() { cat < [component] Options: -c, --config-file=FILE Path to local-ports.properties (XML) -v, --verbose Verbose output Examples: $PROG -c ./port-mappings.properties start $PROG stop openbao $PROG --verbose status EOF } TARGET_ID="" 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; } # 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 ! "$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 } # ---- 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 } # 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 } pid_file_for() { printf '%s/%s.pid' "$PID_DIR" "$1"; } log_file_for() { printf '%s/%s.log' "$LOG_DIR" "$1"; } 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 } 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 } # ---- XML parsing (simple attribute extraction) ---- # We parse lines containing: # Attributes must use double quotes in the XML (as in the sample). get_attr() { # $1 = line, $2 = attribute name # outputs value or empty printf '%s\n' "$1" | sed -n "s/.*$2=\"\([^\"]*\)\".*/\1/p" } foreach_mapping() { # Calls a provided function with mapping fields: # callback id ns target address hostPort servicePort protocol description local callback="$1" [ -f "$CONFIG_FILE" ] || { err "Config file not found: $CONFIG_FILE"; exit 1; } # Support both single-line and multi-line self-closing mapping tags, e.g.: # OR lines spanning multiple lines until "/>". local in_mapping=0 in_comment=0 buffer="" line while IFS= read -r line; do # Handle XML comments: skip anything between if [ $in_comment -eq 1 ]; then case "$line" in *"-->"*) in_comment=0; continue ;; *) continue ;; esac fi case "$line" in *""*) # single-line comment; skip line continue ;; *) in_comment=1 continue ;; esac ;; esac if [ $in_mapping -eq 0 ]; then case "$line" in *"' buffer="$buffer $line" fi if [ $in_mapping -eq 1 ] && printf '%s' "$line" | grep -q "/>"; then # Normalize whitespace to make attribute extraction robust local merged merged=$(printf '%s\n' "$buffer" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g') local id ns target address hostPort servicePort protocol description id="$(get_attr "$merged" "id")" ns="$(get_attr "$merged" "namespace")" target="$(get_attr "$merged" "target")" address="$(get_attr "$merged" "address")" hostPort="$(get_attr "$merged" "hostPort")" servicePort="$(get_attr "$merged" "servicePort")" protocol="$(get_attr "$merged" "protocol")" description="$(get_attr "$merged" "description")" # Basic validation if [ -z "$id" ] || [ -z "$ns" ] || [ -z "$target" ] || [ -z "$hostPort" ] || [ -z "$servicePort" ]; then err "Invalid mapping (missing required attributes): $merged" exit 1 fi # Filter by TARGET_ID if set if [ -n "$TARGET_ID" ] && [ "$id" != "$TARGET_ID" ]; then in_mapping=0 buffer="" continue fi if [ -z "$address" ]; then address="127.0.0.1"; fi if [ -z "$protocol" ]; then protocol="TCP"; 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" # Reset accumulator in_mapping=0 buffer="" fi done <"$CONFIG_FILE" } 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 # Aggressively stop existing processes before starting to avoid port conflicts stop_port_forward "$id" "$ns" "$target" "$address" "$hostPort" "$servicePort" "$protocol" "$description" 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 } do_start() { validate_env # Preflight: require Docker daemon and k3d (if applicable) ensure_docker_running ensure_k3d_ready_if_applicable foreach_mapping start_port_forward } do_stop() { # stop doesn't require cluster access, but it does need state dirs ensure_dirs foreach_mapping stop_port_forward } do_restart() { validate_env # Preflight: require Docker daemon and k3d (if applicable) ensure_docker_running ensure_k3d_ready_if_applicable foreach_mapping stop_port_forward foreach_mapping start_port_forward } do_status() { 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 ==" foreach_mapping status_one 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 ;; -v|--verbose) VERBOSE=1 shift ;; -c) shift [ $# -gt 0 ] || { err "-c requires a file path"; usage; exit 2; } CONFIG_FILE="$1" shift ;; --config-file=*) CONFIG_FILE="${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 [ -n "$ACTION" ] || { usage; exit 2; } case "$ACTION" in start) do_start ;; stop) do_stop ;; restart) do_restart ;; status) do_status ;; esac