mandrel-platform 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # check-runner-env-drift.sh — report per-runner `.env` configuration drift
4
+ # across one host's runner pool (mandrel-platform runner kit).
5
+ #
6
+ # ── WHY THIS EXISTS ─────────────────────────────────────────────────────────
7
+ #
8
+ # Nothing else observes a runner's LOCAL configuration. The fleet monitor
9
+ # (`scripts/check-runner-health.mjs`) reaches runners through
10
+ # `GET /repos/{owner}/{repo}/actions/runners`, which reports a runner's name,
11
+ # labels and online status — the endpoint cannot see `<RUNNER_DIR>/.env`, so
12
+ # hook configuration is invisible to it.
13
+ #
14
+ # The consequence is that a partially-provisioned pool never presents as a
15
+ # configuration fault. It presents as an unattributable behavioural difference
16
+ # between two runs of the SAME job on the SAME repo. That is issue #343: on one
17
+ # host, 16 of 19 runners carried ACTIONS_RUNNER_HOOK_JOB_STARTED; two of the
18
+ # hooked ones sat 5m29s in `Set up runner` while the same job on an unhooked
19
+ # runner finished in 54 seconds. Attributing that took far longer than reading
20
+ # nineteen `.env` files would have — which is precisely what this script does.
21
+ #
22
+ # ── WHAT IT REPORTS ─────────────────────────────────────────────────────────
23
+ #
24
+ # PRESENCE — never value — of the four keys `.env.example` mandates:
25
+ # ACTIONS_RUNNER_HOOK_JOB_STARTED, RUNNER_TOOL_CACHE, AGENT_TOOLSDIRECTORY,
26
+ # LANG. Values are deliberately not compared: every one of them embeds the
27
+ # runner's own absolute root path, so they are SUPPOSED to differ per runner.
28
+ #
29
+ # The drift signal is a key set on SOME runners but not all — the 16-of-19
30
+ # shape. A key absent from EVERY runner is a uniform gap: reported as such, and
31
+ # not on its own a non-zero exit. A fleet that has deliberately not adopted a
32
+ # key must not be a standing alarm, or the operator learns to ignore the exit
33
+ # code and the signal is worth nothing when it does fire.
34
+ #
35
+ # ── OPERATOR CONTRACT ───────────────────────────────────────────────────────
36
+ #
37
+ # check-runner-env-drift.sh [--pool-root <dir>]
38
+ #
39
+ # --pool-root <dir> Directory holding one subdirectory per runner. Defaults
40
+ # to the PARENT of the directory containing this script:
41
+ # the kit installs it into <RUNNER_DIR>, and the runbook
42
+ # mandates one directory per runner under a common root,
43
+ # so the default is correct on any kit-provisioned host.
44
+ #
45
+ # A child directory counts as a runner iff it contains `config.sh`. That
46
+ # predicate keeps unrelated siblings (shared caches, scratch dirs) out of the
47
+ # report without inventing a naming convention.
48
+ #
49
+ # exit 0 — no drift: every mandated key is uniform across the pool (set
50
+ # everywhere, or unset everywhere).
51
+ # exit 1 — drift: at least one key is set on some runners but not all. The
52
+ # non-zero exit IS the alert channel, matching the posture
53
+ # `scripts/check-runner-health.mjs` already uses, so this can be
54
+ # scheduled.
55
+ # exit 2 — usage error: unknown flag, or a pool root that is not a directory
56
+ # or holds no runners. Deliberately distinct from 0: reporting "no
57
+ # drift" over an empty walk would read as evidence the fleet is
58
+ # uniform.
59
+ #
60
+ # READ-ONLY, and never fails soft on a broken runner. It writes nothing into a
61
+ # runner root and never touches a launchd service. A runner whose `.env` is
62
+ # missing or unreadable is recorded as all four keys unset and the walk
63
+ # continues — one broken runner must not shrink the sample the verdict is
64
+ # computed over.
65
+ #
66
+ # This is an OPERATOR-run tool, not a job hook. Do not wire it into
67
+ # ACTIONS_RUNNER_HOOK_JOB_STARTED: that hook runs inside the job's clock, where
68
+ # every read is billed to `Set up runner` and counts against the job's
69
+ # `timeout-minutes` (issue #343). A pool-wide walk belongs outside that clock.
70
+ #
71
+ # ── PORTABILITY ─────────────────────────────────────────────────────────────
72
+ #
73
+ # Runs on the host with no repo checkout and no Node runtime, and stays
74
+ # compatible with macOS's system bash 3.2 — the same constraint
75
+ # `.github/actions/gitleaks-scan/action.yml` documents for this fleet. That
76
+ # rules out `declare -A`, `mapfile`/`readarray`, and `${var,,}`; it does NOT
77
+ # rule out plain INDEXED arrays, which 3.2 supports and which the accumulators
78
+ # below use. Only the per-key tally needs a second pass, because keeping a
79
+ # key->count table is the one thing an indexed array cannot do.
80
+ #
81
+ # Accumulating into arrays rather than splitting a delimited string on a
82
+ # reassigned `IFS` is deliberate and load-bearing: reassigning IFS globally is
83
+ # flagged by the platform's own SAST ruleset (`bash.lang.security.ifs-tampering`)
84
+ # because it silently changes the splitting behaviour of every later unquoted
85
+ # expansion in the script. Arrays give the same grouping with no global state
86
+ # and no quoting hazard for a runner directory whose name contains whitespace.
87
+
88
+ set -u
89
+
90
+ MANDATED_KEYS=(
91
+ ACTIONS_RUNNER_HOOK_JOB_STARTED
92
+ RUNNER_TOOL_CACHE
93
+ AGENT_TOOLSDIRECTORY
94
+ LANG
95
+ )
96
+
97
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
98
+ POOL_ROOT=$(dirname "$SCRIPT_DIR")
99
+
100
+ usage() {
101
+ cat <<'USAGE'
102
+ Usage: check-runner-env-drift.sh [--pool-root <dir>]
103
+
104
+ Reports which runners in a pool are missing the `.env` keys the runner kit
105
+ mandates. Read-only.
106
+
107
+ --pool-root <dir> Pool root (default: the parent of this script's dir).
108
+ -h, --help Show this help.
109
+
110
+ Exit: 0 no drift · 1 drift · 2 usage error.
111
+ USAGE
112
+ }
113
+
114
+ while [ $# -gt 0 ]; do
115
+ case "$1" in
116
+ --pool-root)
117
+ if [ $# -lt 2 ]; then
118
+ printf 'check-runner-env-drift: --pool-root requires a directory\n' >&2
119
+ exit 2
120
+ fi
121
+ POOL_ROOT=$2
122
+ shift 2
123
+ ;;
124
+ --pool-root=*)
125
+ POOL_ROOT=${1#--pool-root=}
126
+ shift
127
+ ;;
128
+ -h | --help)
129
+ usage
130
+ exit 0
131
+ ;;
132
+ *)
133
+ printf 'check-runner-env-drift: unknown argument: %s\n' "$1" >&2
134
+ usage >&2
135
+ exit 2
136
+ ;;
137
+ esac
138
+ done
139
+
140
+ if [ ! -d "$POOL_ROOT" ]; then
141
+ printf 'check-runner-env-drift: pool root is not a directory: %s\n' "$POOL_ROOT" >&2
142
+ exit 2
143
+ fi
144
+ POOL_ROOT=$(cd "$POOL_ROOT" && pwd)
145
+
146
+ # Presence test for one key in one runner's `.env`.
147
+ #
148
+ # The assignment form is `^[[:space:]]*KEY=` on a non-comment line. A commented
149
+ # line cannot match (the `#` is not whitespace), which is the case that matters:
150
+ # `.env.example` ships every key inside a block of explanatory prose, so a
151
+ # half-applied copy where the operator never uncommented a line is the most
152
+ # likely real drift shape — and matching the key name anywhere in the file would
153
+ # report that runner as fully provisioned. The trailing `=` is equally
154
+ # load-bearing: without it `LANGUAGE=` would satisfy `LANG`.
155
+ #
156
+ # A missing, unreadable, or non-regular `.env` returns "unset" rather than
157
+ # aborting, so the caller records all keys unset and keeps walking.
158
+ env_has_key() {
159
+ env_file=$1
160
+ env_key=$2
161
+
162
+ [ -f "$env_file" ] || return 1
163
+ [ -r "$env_file" ] || return 1
164
+ grep -Eq "^[[:space:]]*${env_key}=" "$env_file" 2>/dev/null
165
+ }
166
+
167
+ key_total=${#MANDATED_KEYS[@]}
168
+
169
+ # Enumerate runners. A glob matching nothing stays literal and fails the `-d`
170
+ # test, so an empty pool root falls through to the exit-2 branch below.
171
+ runner_names=()
172
+ for candidate in "$POOL_ROOT"/*/; do
173
+ [ -d "$candidate" ] || continue
174
+ [ -f "${candidate}config.sh" ] || continue
175
+ runner_names+=("$(basename "$candidate")")
176
+ done
177
+ runner_count=${#runner_names[@]}
178
+
179
+ if [ "$runner_count" -eq 0 ]; then
180
+ printf 'check-runner-env-drift: no runner directories under %s\n' "$POOL_ROOT" >&2
181
+ printf 'check-runner-env-drift: a runner is a child directory containing config.sh — is this the pool root?\n' >&2
182
+ exit 2
183
+ fi
184
+
185
+ printf 'runner .env configuration drift report\n'
186
+ printf ' pool root: %s\n' "$POOL_ROOT"
187
+ printf ' runners: %d\n' "$runner_count"
188
+ printf '\n'
189
+
190
+ printf 'per-runner:\n'
191
+ for name in "${runner_names[@]}"; do
192
+ unset_keys=()
193
+ for key in "${MANDATED_KEYS[@]}"; do
194
+ if ! env_has_key "$POOL_ROOT/$name/.env" "$key"; then
195
+ unset_keys+=("$key")
196
+ fi
197
+ done
198
+
199
+ if [ "${#unset_keys[@]}" -eq 0 ]; then
200
+ printf ' %s: all %d mandated keys set\n' "$name" "$key_total"
201
+ else
202
+ # `${arr[*]}` joins on the first character of IFS — a space, since this
203
+ # script never reassigns it. Safe for key names, which carry no whitespace;
204
+ # runner names are printed one per line below for exactly that reason.
205
+ printf ' %s: unset %s\n' "$name" "${unset_keys[*]}"
206
+ fi
207
+ done
208
+ printf '\n'
209
+
210
+ printf 'per-key:\n'
211
+ drift_count=0
212
+ for key in "${MANDATED_KEYS[@]}"; do
213
+ set_count=0
214
+ unset_names=()
215
+ for name in "${runner_names[@]}"; do
216
+ if env_has_key "$POOL_ROOT/$name/.env" "$key"; then
217
+ set_count=$((set_count + 1))
218
+ else
219
+ unset_names+=("$name")
220
+ fi
221
+ done
222
+
223
+ if [ "$set_count" -eq "$runner_count" ]; then
224
+ printf ' %s: ok — set on %d of %d runners\n' "$key" "$set_count" "$runner_count"
225
+ elif [ "$set_count" -eq 0 ]; then
226
+ printf ' %s: uniformly unset — set on 0 of %d runners; a uniform gap, not drift\n' "$key" "$runner_count"
227
+ else
228
+ # Reached only when 0 < set_count < runner_count, so unset_names is
229
+ # non-empty here — one name per line, because a runner directory name may
230
+ # contain whitespace and a joined list would make it unactionable.
231
+ drift_count=$((drift_count + 1))
232
+ printf ' %s: DRIFT — set on %d of %d runners; unset on:\n' "$key" "$set_count" "$runner_count"
233
+ for name in "${unset_names[@]}"; do
234
+ printf ' %s\n' "$name"
235
+ done
236
+ fi
237
+ done
238
+ printf '\n'
239
+
240
+ if [ "$drift_count" -eq 0 ]; then
241
+ printf 'no drift: every mandated key is uniform across the pool.\n'
242
+ exit 0
243
+ fi
244
+
245
+ printf 'DRIFT: %d of %d mandated keys are set on some runners but not all.\n' "$drift_count" "$key_total"
246
+ printf 'Provision the runners named above from templates/runner/.env.example,\n'
247
+ printf 'then restart each one so it reloads .env: ./svc.sh stop && ./svc.sh start\n'
248
+ exit 1