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.
- package/package.json +1 -1
- package/scripts/audit-check.mjs +112 -12
- package/scripts/audit-check.test.mjs +195 -0
- package/scripts/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-cancelled-provenance.test.mjs +493 -1
- package/scripts/check-docs-staleness.mjs +15 -2
- package/scripts/check-docs-staleness.test.mjs +114 -9
- package/scripts/check-first-party-pin-freshness.mjs +532 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +489 -0
- package/scripts/job-cleanup-hook.test.mjs +234 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/templates/runbooks/runner-provisioning.md +62 -9
- package/templates/runner/.env.example +8 -2
- package/templates/runner/check-runner-env-drift.sh +248 -0
- package/templates/runner/job-cleanup.sh +49 -20
|
@@ -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
|
|
14
|
-
#
|
|
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.
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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
|
-
#
|
|
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)
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# runner's in-flight download is
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|