diff --git a/etc/init-port-fowards.sh b/etc/init-port-fowards.sh new file mode 100755 index 0000000..d14d927 --- /dev/null +++ b/etc/init-port-fowards.sh @@ -0,0 +1,417 @@ +#!/usr/bin/env bash +set -u + +# init-kpf.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="/Users/chrisfu/dev/prole" +VERBOSE=0 +CONFIG_FILE="$PROLE_HOME/conf/port-mappings.properties" + +usage() { + cat < + +Options: + -c, --config-file=FILE Path to local-ports.properties (XML) + -v, --verbose Verbose output + +Examples: + $PROG -c ./port-mappings.properties start + $PROG --verbose status +EOF +} + +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 +} + +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 + 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() { + echo "start_port_forward()" + local id="$1" ns="$2" target="$3" address="$4" hostPort="$5" servicePort="$6" protocol="$7" description="$8" + local pf pidfile logfile cmd pid + + pidfile="$(pid_file_for "$id")" + logfile="$(log_file_for "$id")" + cmd="$(build_port_forward_cmd "$ns" "$target" "$address" "$hostPort" "$servicePort")" + echo $cmd + # If pidfile exists and process is alive, skip + if [ -f "$pidfile" ]; then + pid="$(read_pid "$pidfile" || true)" + if [ -n "${pid:-}" ] && is_pid_running "$pid"; then + vlog "$id already running (pid=$pid)" + return 0 + else + # stale pidfile + remove_pidfile "$pidfile" + fi + fi + + 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 + vlog "$id stale pidfile (pid=$pid not running)" + fi + + remove_pidfile "$pidfile" +} + +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 + 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 + foreach_mapping stop_port_forward + foreach_mapping start_port_forward +} + +do_status() { + validate_env + + 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) + ACTION="$1" + 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 + ;; + *) + err "Unknown arg: $1" + usage + exit 2 + ;; + esac +done + +[ -n "$ACTION" ] || { usage; exit 2; } + +case "$ACTION" in + start) do_start ;; + stop) do_stop ;; + restart) do_restart ;; + status) do_status ;; +esac