246 lines
9.2 KiB
Bash
Executable File
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
|