#!/usr/bin/env bash # Lifecycle for the Rust redirector host. # # redirector.sh start | stop | status | verify # # Mirrors sidecar.sh: refuses to start with an orphan present or the port busy, # and stop PROVES the process is gone and the port free rather than assuming a # signal worked. # # Additionally REFUSES TO START unless the binary's stamped commit equals HEAD # and the migration crates are clean — evidence from an unidentifiable binary is # not evidence. set -uo pipefail HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" ROOT="$(cd "$HERE/.." && pwd)" RUNDIR="${OPENFUT_REDIRECTOR_RUNDIR:-${TMPDIR:-/tmp}/openfut-redirector}" PIDFILE="$RUNDIR/redirector.pid" PORTFILE="$RUNDIR/redirector.port" # Commit the RUNNING process was started from. `verify` alone inspects the # on-disk binary, which a rebuild (even `cargo test`, which re-runs build.rs # when the branch ref moves) can silently advance past the live process. STAMPFILE="$RUNDIR/redirector.commit" LOGFILE="${OPENFUT_REDIRECTOR_LOG:-$RUNDIR/redirector.log}" # Which artifact is under test must never be ambiguous. Preferring debug and # falling back to release meant `cargo build --release` could produce a fresh # binary while this kept launching a stale debug one — the guard then compared # the WRONG artifact against HEAD and refused with a message naming a commit # nobody was trying to run. Both present and disagreeing is an error, not a # preference. Set OPENFUT_REDIRECTOR_BIN to choose deliberately. DEBUG_BIN="$ROOT/target/debug/openfut-redirector-host" RELEASE_BIN="$ROOT/target/release/openfut-redirector-host" BIN_ERR="" if [[ -n "${OPENFUT_REDIRECTOR_BIN:-}" ]]; then BIN="$OPENFUT_REDIRECTOR_BIN" [[ -x "$BIN" ]] || { echo "redirector: OPENFUT_REDIRECTOR_BIN is not executable: $BIN" >&2; exit 1; } elif [[ -x "$DEBUG_BIN" && -x "$RELEASE_BIN" ]]; then d="$("$DEBUG_BIN" --identity 2>&1 | sed -nE 's/.*commit=([0-9a-f]+).*/\1/p' | head -1)" r="$("$RELEASE_BIN" --identity 2>&1 | sed -nE 's/.*commit=([0-9a-f]+).*/\1/p' | head -1)" if [[ "$d" != "$r" ]]; then # Recorded, NOT fatal here: `stop` and `status` must keep working no matter # what state the build tree is in. Rolling back can never be blocked by a # question about which artifact would have been started. BIN_ERR="REFUSING — two binaries exist and disagree. debug $DEBUG_BIN commit=$d release $RELEASE_BIN commit=$r Rebuild both, delete one, or set OPENFUT_REDIRECTOR_BIN." fi BIN="$RELEASE_BIN" elif [[ -x "$DEBUG_BIN" ]]; then BIN="$DEBUG_BIN" else BIN="$RELEASE_BIN" fi die() { echo "redirector: $*" >&2; exit 1; } pid_alive() { kill -0 "$1" 2>/dev/null; } port_listening() { ss -ltn 2>/dev/null | grep -qE "[:.]${1}[[:space:]]"; } # Match the resolved executable, not the command line: `pgrep -f` matches any # shell whose arguments merely mention the name. list_procs() { local self=$$ pid exe for d in /proc/[0-9]*; do pid="${d#/proc/}"; [[ "$pid" == "$self" ]] && continue # readlink, NOT readlink -f: once the binary is rebuilt the link reads # " (deleted)", and -f resolves that to something that matches # nothing. The orphan check would then be blind to exactly the long-lived # processes it exists to find — verified: two orphans (a stale-cert # redirector and a Blaze sidecar) were both invisible to this until the # suffix was stripped. exe="$(readlink "$d/exe" 2>/dev/null)" || continue exe="${exe% (deleted)}" [[ "${exe##*/}" == "openfut-redirector-host" ]] && echo "$pid" done return 0 } # Only the commands that actually run a binary care which one it is. need_bin() { [[ -z "$BIN_ERR" ]] || die "$BIN_ERR"; } # The binary prints `commit=` in its banner; ask it rather than guessing. stamped_commit() { "$BIN" --identity 2>&1 | sed -nE 's/.*commit=([0-9a-f]+).*/\1/p' | head -1; } cmd_verify() { need_bin local c; c="$(stamped_commit)" [[ -n "$c" ]] || die "could not read the binary's commit stamp" "$ROOT/scripts/verify-build-identity.sh" "$c" } cmd_start() { need_bin [[ -x "$BIN" ]] || die "not built: cargo build -p openfut-redirector-host" : "${OPENFUT_REDIRECTOR_HOST_PORT:?set OPENFUT_REDIRECTOR_HOST_PORT (no default: runs beside Python)}" local strays; strays="$(list_procs)" [[ -z "$strays" ]] || die "orphan redirector process(es): $strays" port_listening "$OPENFUT_REDIRECTOR_HOST_PORT" && die "port $OPENFUT_REDIRECTOR_HOST_PORT in use" cmd_verify || die "build identity check failed — refusing to start" mkdir -p "$RUNDIR"; echo "$OPENFUT_REDIRECTOR_HOST_PORT" > "$PORTFILE" stamped_commit > "$STAMPFILE" "$BIN" >"$LOGFILE" 2>&1 & local pid=$!; echo "$pid" > "$PIDFILE" local w=0 while (( w < 100 )); do pid_alive "$pid" || { echo "died during startup:" >&2; tail -20 "$LOGFILE" >&2; rm -f "$PIDFILE"; return 1; } if port_listening "$OPENFUT_REDIRECTOR_HOST_PORT"; then echo "redirector started: pid $pid, port $OPENFUT_REDIRECTOR_HOST_PORT" grep -E 'SELF-TEST|openfut-redirector-host v' "$LOGFILE" | sed 's/^/ /' return 0 fi sleep 0.1; w=$((w+1)) done echo "did not listen within 10s:" >&2; tail -20 "$LOGFILE" >&2 kill "$pid" 2>/dev/null; rm -f "$PIDFILE"; return 1 } cmd_stop() { local rc=0 pid="" port="" [[ -f "$PIDFILE" ]] && pid="$(cat "$PIDFILE")" [[ -f "$PORTFILE" ]] && port="$(cat "$PORTFILE")" if [[ -n "$pid" ]] && pid_alive "$pid"; then kill "$pid" 2>/dev/null local w=0; while pid_alive "$pid" && (( w < 50 )); do sleep 0.1; w=$((w+1)); done pid_alive "$pid" && kill -9 "$pid" 2>/dev/null sleep 0.2 fi [[ -n "$pid" ]] && pid_alive "$pid" && { echo "FAILED to stop $pid" >&2; rc=1; } [[ -n "$port" ]] && port_listening "$port" && { echo "FAILED: port $port still listening" >&2; rc=1; } local strays; strays="$(list_procs)" [[ -n "$strays" ]] && { echo "FAILED: still running: $strays" >&2; rc=1; } rm -f "$PIDFILE" "$PORTFILE" [[ $rc -eq 0 ]] && echo "redirector stopped and verified gone${pid:+ (pid $pid)}" return $rc } cmd_status() { if [[ -f "$PIDFILE" ]] && pid_alive "$(cat "$PIDFILE")"; then echo "running: pid $(cat "$PIDFILE"), port $(cat "$PORTFILE" 2>/dev/null || echo '?')" else echo "not running" fi local strays; strays="$(list_procs)" [[ -n "$strays" ]] && echo "redirector processes: $strays" return 0 } cmd_verify_running() { # The stamp outlives the process it describes. Without this check the command # happily reports on a corpse — either "OK" or a REFUSAL naming a commit, # both implying something is running when nothing is. local pid="" [[ -f "$PIDFILE" ]] && pid="$(cat "$PIDFILE" 2>/dev/null)" if [[ -z "$pid" ]] || ! pid_alive "$pid"; then echo "REFUSING: nothing is running — the stamp describes a process that has exited." >&2 return 1 fi [[ -f "$STAMPFILE" ]] || die "no running-process stamp — was it started by this script?" local running head running="$(cat "$STAMPFILE")"; head="$(git -C "$ROOT" rev-parse --short=7 HEAD 2>/dev/null)" if [[ "$running" != "$head" ]]; then echo "REFUSING: the RUNNING process was started from $running but HEAD is $head" >&2 echo " Restart before treating this run as evidence." >&2 return 1 fi echo "running-process identity OK: started from $running == HEAD" } case "${1:-}" in start) cmd_start ;; stop) cmd_stop ;; status) cmd_status ;; verify) cmd_verify ;; verify-running) cmd_verify_running ;; *) sed -n '2,6p' "$0" | sed 's/^# \?//'; exit 2 ;; esac