Files
kandev/scripts/kandev-instances
T

246 lines
9.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# kandev-instances — list every running kandev backend instance on this host.
#
# For each backend process (binary named `kandev` with a listening TCP socket)
# it prints, tab-separated:
# PID BACKEND_PORT WEB_PORT AGENTCTL_PORT HOME_DIR REPO_PATH
#
# Sources:
# - backend PID + listening port: `ss -tlnp` (falls back to `lsof`)
# - web port: the separate `next-server` listener pointing at this backend
# (matched via its KANDEV_API_BASE_URL / NEXT_PUBLIC_KANDEV_API_PORT, port
# read from `ss`); falls back to the backend's KANDEV_WEB_INTERNAL_URL
# (else `?`). The backend's own port is never the web port.
# - agentctl port: KANDEV_AGENT_STANDALONE_PORT / AGENTCTL_PORT in environ,
# else the `-port=` flag of a child `agentctl` process (else `?`)
# - HOME_DIR: KANDEV_HOME_DIR in environ, else HOME, else `?`
# - repo path: derived from /proc/<pid>/exe (…/apps/backend/bin/kandev →
# repo root) or /proc/<pid>/cwd (else `?`)
#
# This is the tool a debugger uses to tell "my isolated instance" apart from
# "the user's live instance" before touching anything. Unresolved fields print
# as `?` rather than failing the whole listing.
#
# Usage:
# scripts/kandev-instances # human-readable table
# scripts/kandev-instances --raw # tab-separated, no header (scriptable)
#
set -euo pipefail
RAW=0
if [[ "${1:-}" == "--raw" ]]; then
RAW=1
fi
# Read a single env var value from a process's environ. Prints empty on miss.
read_environ_var() {
local pid="$1" var="$2"
[[ -r "/proc/$pid/environ" ]] || return 0
tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null |
awk -F= -v k="$var" '$1==k {sub("^"k"=",""); print; exit}'
}
# Resolve the repo path from a kandev binary's exe path or cwd.
# …/apps/backend/bin/kandev → repo root (strip the trailing 4 segments).
resolve_repo() {
local pid="$1" exe cwd
exe="$(readlink "/proc/$pid/exe" 2>/dev/null || true)"
if [[ "$exe" == */apps/backend/bin/kandev ]]; then
printf '%s\n' "${exe%/apps/backend/bin/kandev}"
return 0
fi
cwd="$(readlink "/proc/$pid/cwd" 2>/dev/null || true)"
if [[ "$cwd" == */apps/backend/bin ]]; then
printf '%s\n' "${cwd%/apps/backend/bin}"
return 0
fi
if [[ -n "$cwd" ]]; then
printf '%s\n' "$cwd"
return 0
fi
printf '?\n'
}
# Resolve agentctl port: prefer the env var, fall back to a child agentctl's
# -port= flag.
resolve_agentctl_port() {
local pid="$1" port
port="$(read_environ_var "$pid" KANDEV_AGENT_STANDALONE_PORT)"
[[ -z "$port" ]] && port="$(read_environ_var "$pid" AGENTCTL_PORT)"
if [[ -z "$port" ]]; then
# Inspect child processes for an `agentctl -port=NNNN`.
local child cmd
for child in $(pgrep -P "$pid" 2>/dev/null || true); do
cmd="$(tr '\0' ' ' <"/proc/$child/cmdline" 2>/dev/null || true)"
case "$cmd" in
*agentctl*-port=*)
port="$(printf '%s' "$cmd" | grep -oE 'port=[0-9]+' | head -1 | cut -d= -f2)"
break
;;
esac
done
fi
printf '%s\n' "${port:-?}"
}
# True if something is currently listening on the given TCP port.
port_has_listener() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -tlnH "sport = :$port" 2>/dev/null | grep -q .
elif command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1
else
return 1
fi
}
# Resolve the web port for a backend.
#
# The web UI is served by a SEPARATE `next-server` process, not the backend, so
# the backend's own listening port is never the web port. We resolve in two
# steps, preferring the authoritative source:
#
# 1. The next-server process that points back at THIS backend. Each web
# process is launched with KANDEV_API_BASE_URL=http://host:<backend_port>
# (and/or NEXT_PUBLIC_KANDEV_API_PORT=<backend_port>) and its own
# PORT=<web_port>. We find the next-server whose backend-pointing env
# matches this backend's port, then read its ACTUAL listening port from ss
# (authoritative — the real socket, not an env hint).
# 2. Fallback: the backend's KANDEV_WEB_INTERNAL_URL=http://host:<web_port>,
# which the backend uses to reach the web server. Parse the trailing
# :<port> (the host:port pair, never the scheme's colon).
#
# If neither resolves, print `?` rather than guessing a wrong port (e.g. the
# backend port from PORT / NEXT_PUBLIC_KANDEV_API_PORT, which are the backend's).
resolve_web_port() {
local backend_pid="$1" backend_port="${2:-}" port
port="$(resolve_web_port_via_next_server "$backend_port")"
if [[ -z "$port" ]]; then
local url cand=""
url="$(read_environ_var "$backend_pid" KANDEV_WEB_INTERNAL_URL)"
# Match the LAST :<port> so an IPv6 host or path can't fool us; the trailing
# host:port is what we want, not the scheme separator (http:).
if [[ "$url" =~ :([0-9]+)([/?#].*)?$ ]]; then
cand="${BASH_REMATCH[1]}"
fi
# KANDEV_WEB_INTERNAL_URL is the web URL the backend was CONFIGURED with — it
# may name a web port that never started (e.g. a backend-only dev-isolated
# run). Only trust it if something is actually listening there; otherwise the
# web UI isn't reachable, so report `?` rather than a dead port.
if [[ -n "$cand" ]] && port_has_listener "$cand"; then
port="$cand"
fi
fi
printf '%s\n' "${port:-?}"
}
# Find the next-server process bound to this backend and return its listening
# port (the web port). Empty output means "not found".
resolve_web_port_via_next_server() {
local backend_port="$1"
[[ -n "$backend_port" ]] || return 0
command -v ss >/dev/null 2>&1 || return 0
# Enumerate listening next-server sockets as "pid port" pairs.
local pid web_port api_port api_url
while read -r _ _ _ local_addr _ procinfo; do
case "$procinfo" in
*'next-server'*) ;;
*) continue ;;
esac
# sed (not grep) so a no-match yields empty + exit 0 instead of tripping
# `set -o pipefail` when a socket line carries no pid= field.
pid="$(printf '%s\n' "$procinfo" | sed -nE 's/.*pid=([0-9]+).*/\1/p' | head -1)"
web_port="${local_addr##*:}"
[[ -n "$pid" && -n "$web_port" ]] || continue
# Does this web process point back at OUR backend port?
api_port="$(read_environ_var "$pid" NEXT_PUBLIC_KANDEV_API_PORT)"
if [[ -z "$api_port" ]]; then
api_url="$(read_environ_var "$pid" KANDEV_API_BASE_URL)"
[[ "$api_url" =~ :([0-9]+)([/?#].*)?$ ]] && api_port="${BASH_REMATCH[1]}"
fi
if [[ "$api_port" == "$backend_port" ]]; then
printf '%s\n' "$web_port"
return 0
fi
done < <(ss -tlnpH 2>/dev/null)
return 0
}
# Emit "PID PORT" pairs for every listening kandev backend socket.
# Primary: ss. Fallback: lsof. De-duplicated by PID (a backend may also have
# the agentctl child listening, but agentctl's comm is "agentctl", not
# "kandev", so it's excluded by the comm filter).
list_backend_sockets() {
if command -v ss >/dev/null 2>&1; then
# ss columns: State Recv-Q Send-Q Local:Port Peer:Port users:((...))
# The process info is the last field; the local address is the 4th.
ss -tlnpH 2>/dev/null | while read -r _ _ _ local_addr _ procinfo; do
case "$procinfo" in
*'"kandev"'*)
local pid port
# sed (not grep) so a no-match yields empty + exit 0 instead of tripping
# `set -o pipefail` when a socket line carries no pid= field.
pid="$(printf '%s\n' "$procinfo" | sed -nE 's/.*pid=([0-9]+).*/\1/p' | head -1)"
port="${local_addr##*:}"
[[ -n "$pid" && -n "$port" ]] && printf '%s %s\n' "$pid" "$port"
;;
esac
done
elif command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk '$1=="kandev" {
pid=$2; split($9,a,":"); port=a[length(a)]; print pid, port
}'
fi
}
# Build a unique "PID PORT" list, keeping the lowest port per PID (the backend
# HTTP server binds the configured KANDEV_SERVER_PORT; that is the canonical one
# we report).
declare -A SEEN_PORT
mapfile -t PAIRS < <(list_backend_sockets | sort -u)
for pair in "${PAIRS[@]:-}"; do
[[ -z "$pair" ]] && continue
pid="${pair%% *}"
port="${pair##* }"
cur="${SEEN_PORT[$pid]:-}"
if [[ -z "$cur" || "$port" -lt "$cur" ]]; then
SEEN_PORT[$pid]="$port"
fi
done
if [[ "$RAW" -eq 0 ]]; then
printf '%-7s %-12s %-8s %-13s %-28s %s\n' \
"PID" "BACKEND_PORT" "WEB_PORT" "AGENTCTL_PORT" "HOME_DIR" "REPO_PATH"
fi
# `set -u` treats an empty associative array as unbound when expanded, so
# collect the keys with -u temporarily relaxed.
set +u
pids=("${!SEEN_PORT[@]}")
set -u
if [[ ${#pids[@]} -eq 0 ]]; then
[[ "$RAW" -eq 0 ]] && echo "(no running kandev instances found)" >&2
exit 0
fi
for pid in "${pids[@]}"; do
backend_port="${SEEN_PORT[$pid]}"
web_port="$(resolve_web_port "$pid" "$backend_port")"
agentctl_port="$(resolve_agentctl_port "$pid")"
home_dir="$(read_environ_var "$pid" KANDEV_HOME_DIR)"
[[ -z "$home_dir" ]] && home_dir="$(read_environ_var "$pid" HOME)"
[[ -z "$home_dir" ]] && home_dir="?"
repo="$(resolve_repo "$pid")"
if [[ "$RAW" -eq 1 ]]; then
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$pid" "$backend_port" "$web_port" "$agentctl_port" "$home_dir" "$repo"
else
printf '%-7s %-12s %-8s %-13s %-28s %s\n' \
"$pid" "$backend_port" "$web_port" "$agentctl_port" "$home_dir" "$repo"
fi
done | sort -k2 -n