prole/etc/init_port_forwards.sh
chrisfu 906392d462 feat(installer): improve UI and add test coverage for core features
- Refactored installer UI with updated canvas rendering, sidebar navigation, and footer buttons.
- Enhanced styling for macOS compatibility and consistent design across controls.
- Added Pytest-based unit tests for `screen.py` and `config.py`.
- Expanded dependency catalog with new tools like `tshark` and `pyshark`.
- Improved error tolerance for background rendering and added placeholders for Kerberos configuration.
2026-01-08 21:51:30 -08:00

538 lines
15 KiB
Bash
Executable File

#!/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 <<EOF
Usage:
$PROG [-v|--verbose] [-c|--config-file=FILE] <start|stop|restart|status> [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 <ctx>"
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 <non-k3d-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: <mapping ... />
# 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.:
# <mapping id="x" ... /> 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 <!-- and -->
if [ $in_comment -eq 1 ]; then
case "$line" in
*"-->"*) in_comment=0; continue ;;
*) continue ;;
esac
fi
case "$line" in
*"<!--"*)
case "$line" in
*"-->"*)
# single-line comment; skip line
continue
;;
*)
in_comment=1
continue
;;
esac
;;
esac
if [ $in_mapping -eq 0 ]; then
case "$line" in
*"<mapping"*)
in_mapping=1
buffer="$line"
;;
*)
continue
;;
esac
else
# Accumulate lines until we see the closing '/>'
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 <ns> --address <addr> <target> <local>:<remote>
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 &
pid="$!"
write_pid "$pidfile" "$pid"
# Quick verification
sleep 0.2
if is_pid_running "$pid"; then
vlog "$id started (pid=$pid, log=$logfile)"
return 0
else
err "$id failed to start (see log: $logfile)"
remove_pidfile "$pidfile"
return 1
fi
}
stop_port_forward() {
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
log "Stopping: $id (pid=$pid)"
kill "$pid" >/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 <ns> ... <target> <hostPort>:<servicePort>
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 <target> -n <ns>
# 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