#!/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