mandrel-platform 1.0.1 → 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
@@ -10,8 +10,8 @@
10
10
  #
11
11
  # - an orphaned `pnpm`/`node` process (e.g. a hung install or lint) still
12
12
  # mutating the pnpm shim install, corrupting the pnpm CLI for the next job;
13
- # - leftover `gitleaks.tmp` / `gitleaks-*` artifacts in the shared $TMPDIR
14
- # blocking the next gitleaks download.
13
+ # - leftover tool-download temp dirs (gitleaks, OSV-scanner, the semgrep
14
+ # venv) accumulating in the runner's own job temp.
15
15
  #
16
16
  # Running this before every job gives each job a clean slate ("fresh per job"
17
17
  # without the cost of re-registering an ephemeral runner).
@@ -35,9 +35,36 @@
35
35
  # own work tree (`<RUNNER_DIR>/_work/...`). Every path below derives
36
36
  # from RUNNER_DIR, which is unique per runner, so a co-resident
37
37
  # runner's processes and files are never matched.
38
- # 3. Age-gates cleanup of the genuinely shared $TMPDIR gitleaks artifacts,
39
- # so a fresh (in-flight) download owned by a concurrent job is never
40
- # deleted only stale leftovers are.
38
+ # 3. NEVER READS the shared OS temp root, either. Reading is not free: the
39
+ # hook runs inside the JOB's clock, so any cost here is charged to
40
+ # `Set up runner` and counts against the job's own `timeout-minutes`.
41
+ # $TMPDIR is unbounded and shared with every other process on the host,
42
+ # so a sweep rooted there costs a function of how much UNRELATED junk
43
+ # the host has accumulated — see the incident note below.
44
+ #
45
+ # ── WHY THE SHARED-$TMPDIR SWEEP IS GONE (issue #343) ───────────────────────
46
+ #
47
+ # This hook used to age-gate two `find "$TMPDIR" -maxdepth 1 -name …` sweeps
48
+ # for `gitleaks.tmp` / `gitleaks-*`. `-maxdepth 1 -name <literal>` is a FULL
49
+ # directory enumeration for what is really an existence check, so its cost
50
+ # scaled with host churn. On the swarm-os runner host $TMPDIR reached 841,690
51
+ # entries; one scan measured 42s, the hook ran two of them, and up to 16
52
+ # co-resident runners ran it concurrently. `Set up runner` reached 5m29s, and
53
+ # every job whose `timeout-minutes` sat at or below that was killed before its
54
+ # first real step — surfacing as `cancelled` on an innocent diff.
55
+ #
56
+ # It was also a no-op: the platform's actions extract via `mktemp -d`, so
57
+ # nothing ever created `gitleaks.tmp` or `gitleaks-*`. The sweep paid an
58
+ # unbounded cost hunting names that never existed, while the dirs the actions
59
+ # DID leave went unswept.
60
+ #
61
+ # The fix is ownership, not tuning: every platform action now extracts into
62
+ # `${RUNNER_TEMP}` (== RUNNER_TMP below), which is unique per runner. A
63
+ # co-resident runner's in-flight download is therefore unreachable from here
64
+ # by construction — which is what retired the age gate outright (along with
65
+ # the stale-minutes env knob that tuned it), rather than merely shrinking its
66
+ # blast radius. Keep it that way: a sweep added here MUST be rooted at
67
+ # RUNNER_TMP.
41
68
  #
42
69
  # ── PARAMETERIZATION ────────────────────────────────────────────────────────
43
70
  #
@@ -48,11 +75,8 @@
48
75
  # runner root, next to config.sh / run.sh). Override via env
49
76
  # only if you install the hook elsewhere.
50
77
  # RUNNER_TMP — the runner's per-runner job temp (`runner.temp`), always
51
- # `${RUNNER_DIR}/_work/_temp`.
52
- # JOB_CLEANUP_STALE_MINUTES
53
- # — age threshold (minutes) for the shared-$TMPDIR gitleaks
54
- # sweep. Default 60. Artifacts younger than this are assumed
55
- # in-flight and left alone.
78
+ # `${RUNNER_DIR}/_work/_temp`. Every path this hook touches
79
+ # lives under it.
56
80
  #
57
81
  # Configured via `ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh`
58
82
  # in the runner's `.env` (see .env.example in this directory).
@@ -64,8 +88,6 @@ set +e
64
88
  RUNNER_DIR="${RUNNER_DIR:-$(cd "$(dirname "$0")" && pwd)}"
65
89
  RUNNER_WORK="${RUNNER_DIR}/_work"
66
90
  RUNNER_TMP="${RUNNER_WORK}/_temp"
67
- TMP="${TMPDIR:-/tmp}"
68
- STALE_MINUTES="${JOB_CLEANUP_STALE_MINUTES:-60}"
69
91
 
70
92
  # 1) Reap orphaned pnpm/node processes from prior jobs — scoped to THIS
71
93
  # runner's work tree only. The patterns target executable paths INSIDE the
@@ -85,13 +107,20 @@ pkill -9 -f "${RUNNER_WORK}/_tool/[^ ]*node_modules" 2>/dev/null
85
107
  rm -rf "${RUNNER_TMP}/pnpm" 2>/dev/null
86
108
  rm -rf "${RUNNER_TMP}/setup-pnpm" 2>/dev/null
87
109
 
88
- # 3) Sweep stale gitleaks artifacts from the SHARED $TMPDIR. Because this
89
- # location is shared by every runner on the host, deletion is age-gated:
90
- # only artifacts older than STALE_MINUTES are removed, so a concurrent
91
- # runner's in-flight download is never deleted mid-job.
92
- find "${TMP}" -maxdepth 1 -name 'gitleaks.tmp' -mmin "+${STALE_MINUTES}" \
93
- -exec rm -f {} + 2>/dev/null
94
- find "${TMP}" -maxdepth 1 -name 'gitleaks-*' -mmin "+${STALE_MINUTES}" \
95
- -exec rm -rf {} + 2>/dev/null
110
+ # 3) Remove this runner's own leftover tool-download temp dirs. The platform's
111
+ # composite actions and workflows create these via
112
+ # `mktemp -d "${RUNNER_TEMP}/<tool>.XXXXXX"`, so every one of them is
113
+ # runner-scoped and a co-resident runner's in-flight download is
114
+ # unreachable here no age gate is needed (see the issue #343 note above).
115
+ #
116
+ # Globbing is what keeps this bounded: the shell expands these against
117
+ # RUNNER_TMP alone, so the cost is a function of THIS runner's leftovers,
118
+ # never of host-wide churn. Do not replace it with a `find` over a parent.
119
+ # A glob that matches nothing stays literal, and `rm -rf` on a nonexistent
120
+ # path is silent — hence the nullglob-free form plus 2>/dev/null.
121
+ rm -rf "${RUNNER_TMP}"/gitleaks.* 2>/dev/null
122
+ rm -rf "${RUNNER_TMP}"/osv-scanner.* 2>/dev/null
123
+ rm -rf "${RUNNER_TMP}"/semgrep.* 2>/dev/null
124
+ rm -f "${RUNNER_TMP}"/gh-api-err.* 2>/dev/null
96
125
 
97
126
  exit 0