Skip to content

Commit 0992785

Browse files
Spencer Bryngelsonclaude
andcommitted
refactor: deduplicate runner scripts into misc/common/
Extract shared logic from frontier/phoenix into common scripts: - runner-lib.sh: add gh_list_runners(), has_slurm() (portable, grep PATH for slurm keyword), sweep_all_nodes() (exe-based, parallel SSH), CGROUP_LIMIT default - check-runners.sh: new common script (exe-based discovery, slurm column, conditional cgroup footer); both sites now show slurm status - list-runners.sh: new common script (parallel sweep, slurm column, stale runner.node detection, conditional cgroup footer) - rebalance-runners.sh: new common script (optional sync_runner_nodes hook, writes runner.node after start, checks slurm after start) All site scripts (frontier/, phoenix/) reduced to thin wrappers that source config.sh then the common implementation. phoenix/create-runner.sh fixed to write runner.node after successful start. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 958cce7 commit 0992785

13 files changed

Lines changed: 387 additions & 589 deletions

misc/common/check-runners.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env bash
2+
# Check runner health across all login nodes.
3+
#
4+
# Sourced by site wrappers (frontier/check-runners.sh, phoenix/check-runners.sh)
5+
# after config.sh is loaded. Shows Runner.Listener processes per node with
6+
# name, busy/idle status, slurm availability, and RSS memory.
7+
# If CGROUP_LIMIT > 0, also shows per-node total memory vs the cgroup limit.
8+
#
9+
# Usage: bash check-runners.sh
10+
set -euo pipefail
11+
12+
declare -f sync_runner_nodes > /dev/null 2>&1 && {
13+
echo "==> Syncing runner node locations..."
14+
sync_runner_nodes
15+
}
16+
17+
for node in "${NODES[@]}"; do
18+
echo "=== $node ==="
19+
ssh $SSH_OPTS "$node" '
20+
found=0
21+
for p in $(ps aux | grep Runner.Listener | grep -v grep | awk "{print \$2}"); do
22+
found=1
23+
exe=$(readlink -f /proc/$p/exe 2>/dev/null || echo "???")
24+
dir=$(dirname "$(dirname "$exe")" 2>/dev/null || echo "???")
25+
name=$(basename "$dir")
26+
worker=$(ps aux | grep "Runner.Worker" | grep "$dir" | grep -v grep | awk "{print \$2}" | head -1)
27+
[ -n "$worker" ] && status="BUSY" || status="idle"
28+
rss=$(ps -p $p -o rss= 2>/dev/null | awk "{printf \"%.0f\", \$1/1024}" || echo "?")
29+
slurm=$(tr "\0" "\n" < /proc/$p/environ 2>/dev/null | grep -c "^PATH=.*slurm" || echo 0)
30+
[ "$slurm" -gt 0 ] && slurm_ok="ok" || slurm_ok="MISSING"
31+
printf " %-30s %5s slurm=%-7s %s MB\n" "$name" "$status" "$slurm_ok" "$rss"
32+
done
33+
[ "$found" -eq 0 ] && echo " (no runners)"
34+
' 2>/dev/null || echo " (unreachable)"
35+
36+
if [ "${CGROUP_LIMIT:-0}" -gt 0 ]; then
37+
rss=$(ssh $SSH_OPTS "$node" \
38+
"ps -u \$(whoami) -o rss= 2>/dev/null | awk '{sum+=\$1} END {printf \"%.0f\", sum/1024}'" \
39+
2>/dev/null || echo "?")
40+
[[ "$rss" =~ ^[0-9]+$ ]] || rss=0
41+
echo " --- Total: ${rss} MB / ${CGROUP_LIMIT} MB ($(( CGROUP_LIMIT - rss )) MB free) ---"
42+
fi
43+
echo ""
44+
done

misc/common/list-runners.sh

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env bash
2+
# List all runners combining GitHub API status with live node process info.
3+
#
4+
# Sourced by site wrappers (frontier/list-runners.sh, phoenix/list-runners.sh)
5+
# after config.sh is loaded. Uses a parallel SSH sweep across all nodes
6+
# simultaneously (one SSH per node regardless of runner count).
7+
# Shows name, GitHub status, node, slurm availability, and RSS.
8+
# If CGROUP_LIMIT > 0, also shows a per-node memory summary.
9+
#
10+
# Usage: bash list-runners.sh
11+
set -euo pipefail
12+
13+
declare -f sync_runner_nodes > /dev/null 2>&1 && {
14+
echo "==> Syncing runner node locations..."
15+
sync_runner_nodes
16+
}
17+
18+
tmpdir=$(mktemp -d)
19+
trap 'rm -rf "$tmpdir"' EXIT
20+
21+
sweep_all_nodes "$tmpdir"
22+
23+
# Parse sweep results into associative arrays
24+
declare -A runner_node runner_rss runner_slurm
25+
for node in "${NODES[@]}"; do
26+
while IFS= read -r line; do
27+
read -r _s sweep_node dir rss slurm_ok <<< "$line"
28+
runner_node["$dir"]="$sweep_node"
29+
runner_rss["$dir"]="$rss"
30+
runner_slurm["$dir"]="$slurm_ok"
31+
done < <(grep '^RUNNER ' "$tmpdir/$node.out" 2>/dev/null || true)
32+
done
33+
34+
# Fetch GitHub API status
35+
declare -A gh_status gh_busy
36+
while read -r _id name status busy; do
37+
gh_status["$name"]="$status"
38+
gh_busy["$name"]="$busy"
39+
done < <(gh_list_runners)
40+
41+
# Print table
42+
printf "%-25s %-8s %-20s %-8s %s\n" "NAME" "GITHUB" "NODE" "SLURM" "RSS"
43+
printf "%s\n" "$(printf '%.0s-' {1..70})"
44+
45+
while IFS= read -r dir; do
46+
name=$(get_runner_name "$dir")
47+
[ -z "$name" ] && continue
48+
49+
[ "${gh_busy[$name]:-false}" = "true" ] && gh_col="BUSY" || gh_col="${gh_status[$name]:-unknown}"
50+
51+
actual_node="${runner_node[$dir]:-}"
52+
rss="${runner_rss[$dir]:-—}"
53+
slurm="${runner_slurm[$dir]:-—}"
54+
55+
if [ -z "$actual_node" ]; then
56+
printf "%-25s %-8s %-20s %-8s %s\n" "$name" "$gh_col" "offline" "" ""
57+
continue
58+
fi
59+
60+
# Flag stale runner.node entries
61+
node_col="$actual_node"
62+
if [ -f "$dir/runner.node" ]; then
63+
recorded=$(cat "$dir/runner.node")
64+
[ "$actual_node" != "$recorded" ] && node_col="${actual_node} *(stale: ${recorded})"
65+
fi
66+
67+
printf "%-25s %-8s %-20s %-8s %sMB\n" "$name" "$gh_col" "$node_col" "$slurm" "$rss"
68+
done < <(find_runner_dirs)
69+
70+
# Per-node memory summary (only when site has a cgroup limit)
71+
if [ "${CGROUP_LIMIT:-0}" -gt 0 ]; then
72+
echo ""
73+
echo "=== Per-node memory ==="
74+
for node in "${NODES[@]}"; do
75+
count=$(ssh $SSH_OPTS "$node" \
76+
"ps aux | grep Runner.Listener | grep -v grep | wc -l" 2>/dev/null || echo 0)
77+
rss=$(ssh $SSH_OPTS "$node" \
78+
"ps -u \$(whoami) -o rss= 2>/dev/null | awk '{sum+=\$1} END {printf \"%.0f\", sum/1024}'" \
79+
2>/dev/null || echo "?")
80+
[[ "$rss" =~ ^[0-9]+$ ]] || rss=0
81+
echo " $node: $count runners, ${rss} MB / ${CGROUP_LIMIT} MB ($(( CGROUP_LIMIT - rss )) MB free)"
82+
done
83+
fi

misc/common/rebalance-runners.sh

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env bash
2+
# Core rebalance algorithm for GitHub Actions runners.
3+
#
4+
# Sourced by site wrappers (frontier/rebalance-runners.sh,
5+
# phoenix/rebalance-runners.sh) after config.sh is loaded.
6+
# Discovers all runner directories, checks which node each is on,
7+
# computes the optimal distribution, and moves runners to balance.
8+
# Prefers moving idle runners over busy ones. Also places offline runners.
9+
#
10+
# Usage: bash rebalance-runners.sh # dry run
11+
# APPLY=1 bash rebalance-runners.sh # execute
12+
# APPLY=1 FORCE=1 bash rebalance-runners.sh # move busy runners too
13+
set -euo pipefail
14+
15+
declare -f sync_runner_nodes > /dev/null 2>&1 && {
16+
echo "==> Syncing runner node locations..."
17+
sync_runner_nodes
18+
}
19+
20+
# Discover runners
21+
declare -a dirs=() names=()
22+
while IFS= read -r dir; do
23+
name=$(get_runner_name "$dir")
24+
[ -z "$name" ] && continue
25+
dirs+=("$dir")
26+
names+=("$name")
27+
done < <(find_runner_dirs)
28+
29+
num_nodes=${#NODES[@]}
30+
num_runners=${#dirs[@]}
31+
target=$(( num_runners / num_nodes ))
32+
remainder=$(( num_runners % num_nodes ))
33+
34+
echo "=== Current state ==="
35+
echo "Runners: $num_runners across $num_nodes nodes"
36+
echo "Target: $target per node (+1 on first $remainder nodes)"
37+
echo ""
38+
39+
# Map runners to nodes and check busy status
40+
declare -A node_runners
41+
declare -A runner_node runner_busy
42+
43+
for node in "${NODES[@]}"; do node_runners[$node]=""; done
44+
45+
for i in "${!dirs[@]}"; do
46+
node=$(find_node "${dirs[$i]}")
47+
runner_node[$i]="$node"
48+
if [ "$node" != "offline" ]; then
49+
node_runners[$node]="${node_runners[$node]:-} $i"
50+
worker=$(ssh $SSH_OPTS "$node" "ps aux | grep Runner.Worker | grep '${dirs[$i]}' | grep -v grep" 2>/dev/null || true)
51+
[ -n "$worker" ] && runner_busy[$i]=1 || runner_busy[$i]=0
52+
else
53+
runner_busy[$i]=0
54+
fi
55+
done
56+
57+
# Show current distribution
58+
for node in "${NODES[@]}"; do
59+
indices=(${node_runners[$node]:-})
60+
echo "$node: ${#indices[@]} runners"
61+
for i in "${indices[@]}"; do
62+
busy=""
63+
[ "${runner_busy[$i]:-0}" = "1" ] && busy=" (BUSY)"
64+
echo " ${names[$i]}$busy"
65+
done
66+
done
67+
68+
offline=()
69+
for i in "${!dirs[@]}"; do
70+
[ "${runner_node[$i]}" = "offline" ] && offline+=("$i")
71+
done
72+
if [ ${#offline[@]} -gt 0 ]; then
73+
echo ""
74+
echo "OFFLINE:"
75+
for i in "${offline[@]}"; do echo " ${names[$i]}"; done
76+
fi
77+
echo ""
78+
79+
# Compute per-node targets
80+
declare -A node_target
81+
n=0
82+
for node in "${NODES[@]}"; do
83+
node_target[$node]=$target
84+
[ $n -lt $remainder ] && node_target[$node]=$(( target + 1 ))
85+
n=$((n + 1))
86+
done
87+
88+
# Plan moves: pull runners from overloaded nodes (idle first)
89+
to_place=()
90+
for node in "${NODES[@]}"; do
91+
indices=(${node_runners[$node]:-})
92+
excess=$(( ${#indices[@]} - ${node_target[$node]} ))
93+
[ $excess -le 0 ] && continue
94+
idle=() busy=()
95+
for i in "${indices[@]}"; do
96+
[ "${runner_busy[$i]:-0}" = "1" ] && busy+=("$i") || idle+=("$i")
97+
done
98+
moved=0
99+
for i in "${idle[@]}" "${busy[@]}"; do
100+
[ $moved -ge $excess ] && break
101+
to_place+=("$node $i")
102+
moved=$((moved + 1))
103+
done
104+
done
105+
106+
# Add offline runners to be placed
107+
for i in "${offline[@]}"; do to_place+=("offline $i"); done
108+
109+
# Assign to underloaded nodes
110+
moves=()
111+
for entry in "${to_place[@]}"; do
112+
read -r src idx <<< "$entry"
113+
best="" best_deficit=-999
114+
for node in "${NODES[@]}"; do
115+
cur=(${node_runners[$node]:-})
116+
deficit=$(( ${node_target[$node]} - ${#cur[@]} ))
117+
[ $deficit -gt $best_deficit ] && best_deficit=$deficit && best=$node
118+
done
119+
[ -z "$best" ] || [ "$best_deficit" -le 0 ] && continue
120+
moves+=("$src $best $idx")
121+
# Update bookkeeping so subsequent assignments reflect this move
122+
if [ "$src" != "offline" ]; then
123+
new=""
124+
for j in ${node_runners[$src]}; do [ "$j" != "$idx" ] && new="$new $j"; done
125+
node_runners[$src]="$new"
126+
fi
127+
node_runners[$best]="${node_runners[$best]:-} $idx"
128+
done
129+
130+
if [ ${#moves[@]} -eq 0 ]; then
131+
echo "Already balanced."
132+
exit 0
133+
fi
134+
135+
echo "=== Planned moves ==="
136+
has_busy=false
137+
for move in "${moves[@]}"; do
138+
read -r src dst idx <<< "$move"
139+
busy=""
140+
[ "${runner_busy[$idx]:-0}" = "1" ] && busy=" (BUSY!)" && has_busy=true
141+
echo " ${names[$idx]}: $src -> $dst$busy"
142+
done
143+
echo ""
144+
echo "=== Target ==="
145+
for node in "${NODES[@]}"; do
146+
cur=(${node_runners[$node]:-})
147+
echo " $node: ${#cur[@]} runners"
148+
done
149+
150+
[ "$has_busy" = true ] && [ "${FORCE:-0}" != "1" ] && echo "" && echo "Set FORCE=1 to move busy runners." && exit 1
151+
[ "${APPLY:-0}" != "1" ] && echo "" && echo "Dry run — set APPLY=1 to execute." && exit 0
152+
153+
echo ""
154+
echo "=== Executing ==="
155+
for move in "${moves[@]}"; do
156+
read -r src dst idx <<< "$move"
157+
echo "Moving ${names[$idx]}: $src -> $dst"
158+
[ "$src" != "offline" ] && stop_runner "$src" "${dirs[$idx]}"
159+
if start_runner "$dst" "${dirs[$idx]}"; then
160+
echo "$dst" > "${dirs[$idx]}/runner.node"
161+
pids=$(find_pids "$dst" "${dirs[$idx]}")
162+
pid=${pids%% *}
163+
if has_slurm "$dst" "$pid"; then
164+
echo " OK: ${names[$idx]} started on $dst (slurm ok)"
165+
else
166+
echo " WARNING: ${names[$idx]} started on $dst but slurm MISSING from PATH"
167+
fi
168+
else
169+
echo " ERROR: Failed to start ${names[$idx]} on $dst"
170+
fi
171+
done
172+
173+
echo ""
174+
bash "${SITE_SCRIPT_DIR}/check-runners.sh"

misc/common/runner-lib.sh

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,20 @@
55
# misc/phoenix/config.sh). Callers must define ORG, NODES, and SSH_OPTS
66
# before sourcing this file.
77

8+
# Default: no cgroup memory limit displayed. Override in site config (e.g. CGROUP_LIMIT=4096).
9+
CGROUP_LIMIT=${CGROUP_LIMIT:-0}
10+
811
# --- GitHub API ---
912

13+
# List runners from the GitHub API, filtered to this site's label.
14+
# Prints: id name status busy (one runner per line)
15+
gh_list_runners() {
16+
gh api "orgs/$ORG/actions/runners" --paginate \
17+
--jq ".runners[]
18+
| select(.labels | map(.name) | index(\"$RUNNER_LABEL\"))
19+
| \"\(.id) \(.name) \(.status) \(.busy)\""
20+
}
21+
1022
# Get a registration token for new runners.
1123
gh_registration_token() {
1224
gh api "orgs/$ORG/actions/runners/registration-token" --jq .token
@@ -62,6 +74,40 @@ find_node() {
6274
echo "offline"
6375
}
6476

77+
# Check if a runner process has a slurm directory in its PATH.
78+
# Works across sites regardless of the specific slurm installation path.
79+
# Args: $1 = node, $2 = PID (or "PID rest..." — uses first token only)
80+
has_slurm() {
81+
local node="$1" pid="${2%% *}"
82+
ssh $SSH_OPTS "$node" \
83+
"tr '\0' '\n' < /proc/$pid/environ 2>/dev/null | grep -q '^PATH=.*slurm'" \
84+
2>/dev/null
85+
}
86+
87+
# Sweep all nodes in parallel, writing per-node result files to tmpdir.
88+
# Each output line: RUNNER <node> <dir> <rss_mb> <slurm_ok>
89+
# dir = runner directory derived from the Runner.Listener exe path
90+
# slurm_ok = "ok" if slurm appears in the process PATH, "MISSING" otherwise
91+
# Caller must create tmpdir and parse the output files.
92+
# Args: $1 = tmpdir
93+
sweep_all_nodes() {
94+
local tmpdir="$1" node
95+
for node in "${NODES[@]}"; do
96+
ssh $SSH_OPTS "$node" '
97+
for p in $(ps aux | grep Runner.Listener | grep -v grep | awk "{print \$2}"); do
98+
exe=$(readlink -f /proc/$p/exe 2>/dev/null || true)
99+
[ -z "$exe" ] && continue
100+
dir=$(dirname "$(dirname "$exe")")
101+
rss=$(ps -p $p -o rss= 2>/dev/null | awk "{printf \"%.0f\", \$1/1024}" || echo 0)
102+
slurm=$(tr "\0" "\n" < /proc/$p/environ 2>/dev/null | grep -c "^PATH=.*slurm" || echo 0)
103+
[ "$slurm" -gt 0 ] && slurm_ok="ok" || slurm_ok="MISSING"
104+
echo "RUNNER '"$node"' $dir $rss $slurm_ok"
105+
done
106+
' 2>/dev/null > "$tmpdir/$node.out" &
107+
done
108+
wait
109+
}
110+
65111
# Start a runner on a node.
66112
# Uses a login shell (bash -lc) so site PATH (e.g. SLURM) is available.
67113
# Args: $1 = node, $2 = runner directory

0 commit comments

Comments
 (0)