@evident-ai/runner-cdk 0.1.1-dev.da70cd4 → 3.4.1-dev.0ef5061
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/README.md +78 -20
- package/dist/controller-lambda/handler.js +50529 -0
- package/dist/evident-scale-to-zero-construct.d.ts +16 -4
- package/dist/evident-scale-to-zero-construct.js +19 -14
- package/dist/image-version-reporter-lambda/handler.js +129 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -1
- package/dist/microvm/constants.d.ts +7 -0
- package/dist/microvm/constants.js +34 -0
- package/dist/microvm/construct.d.ts +105 -0
- package/dist/microvm/construct.js +283 -0
- package/dist/microvm/controller/doorbell.d.ts +73 -0
- package/dist/microvm/controller/doorbell.js +107 -0
- package/dist/microvm/controller/handle-doorbell.d.ts +27 -0
- package/dist/microvm/controller/handle-doorbell.js +483 -0
- package/dist/microvm/controller/microvm-client.d.ts +81 -0
- package/dist/microvm/controller/microvm-client.js +7 -0
- package/dist/microvm/controller/shape-catalogue.d.ts +64 -0
- package/dist/microvm/controller/shape-catalogue.js +108 -0
- package/dist/microvm/controller/throttle-retry.d.ts +11 -0
- package/dist/microvm/controller/throttle-retry.js +27 -0
- package/dist/microvm/image/stage-context.d.ts +33 -0
- package/dist/microvm/image/stage-context.js +148 -0
- package/dist/microvm/image-version-reporter/construct.d.ts +35 -0
- package/dist/microvm/image-version-reporter/construct.js +91 -0
- package/dist/microvm/image-version-reporter/handler.d.ts +26 -0
- package/dist/microvm/image-version-reporter/handler.js +104 -0
- package/dist/microvm/shapes.d.ts +72 -0
- package/dist/microvm/shapes.js +93 -0
- package/dist/microvm-image-context/Dockerfile +227 -0
- package/dist/microvm-image-context/hook-server.js +286 -0
- package/dist/microvm-image-context/hooks/common.sh +957 -0
- package/dist/microvm-image-context/hooks/resume +78 -0
- package/dist/microvm-image-context/hooks/run +94 -0
- package/dist/microvm-image-context/hooks/suspend +22 -0
- package/dist/microvm-image-context/hooks/terminate +37 -0
- package/dist/waker/construct.js +1 -1
- package/package.json +15 -7
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sourced by every hook script. Nothing here runs at image build time.
|
|
3
|
+
#
|
|
4
|
+
# These hooks are the SECOND shell speaking the runner-synchroniser CLI contract
|
|
5
|
+
# (runner/docker-images/fargate/entrypoint.sh is the first), so both are held to it by
|
|
6
|
+
# runner/synchroniser/src/shell-contract.test.ts — which derives what
|
|
7
|
+
# `run_synchroniser` below must handle from the subcommands this shell calls.
|
|
8
|
+
|
|
9
|
+
# Installed from npm by the image (docker/Dockerfile's ARG
|
|
10
|
+
# RUNNER_SYNCHRONISER_VERSION) and resolved off PATH here. The Dockerfile's
|
|
11
|
+
# required-binary assertion is the loud failure if it is missing.
|
|
12
|
+
SYNCHRONISER="runner-synchroniser"
|
|
13
|
+
OPENCODE_PORT="${OPENCODE_PORT:-4096}"
|
|
14
|
+
|
|
15
|
+
# A hook script keeps no memory between invocations, but /resume must re-dial
|
|
16
|
+
# with the runner key /run was given, so /run leaves it here. Deliberate, on two
|
|
17
|
+
# axes: tmpfs means it never reaches the block device, and the SHARED image
|
|
18
|
+
# snapshot is taken at build time — long before /run — so the key cannot enter
|
|
19
|
+
# the artefact every VM boots from. And at 0600 owned by uid 10001 it is
|
|
20
|
+
# readable by nobody who could not already read it out of the tunnel process's
|
|
21
|
+
# /proc/<pid>/environ, which runs as that same uid. /terminate removes it.
|
|
22
|
+
# shellcheck disable=SC2034 # read by the scripts that source this file
|
|
23
|
+
CONTEXT_FILE="/dev/shm/evident-run-context"
|
|
24
|
+
TUNNEL_PID_FILE="/dev/shm/evident-tunnel.pid"
|
|
25
|
+
OPENCODE_PID_FILE="/dev/shm/evident-opencode.pid"
|
|
26
|
+
LITESTREAM_PID_FILE="/dev/shm/evident-litestream.pid"
|
|
27
|
+
CREDS_SYNC_PID_FILE="/dev/shm/evident-creds-sync.pid"
|
|
28
|
+
CREDS_SYNC_LAST_ERROR_FILE="/dev/shm/evident-creds-sync.last-error"
|
|
29
|
+
|
|
30
|
+
# Where the runner's credential store lives inside the durable-state bucket.
|
|
31
|
+
# The BUCKET is the same for every VM from an image version, so the stack bakes
|
|
32
|
+
# it into the image environment; the PREFIX selects one runner's store, so it is
|
|
33
|
+
# per-VM and can only arrive in the /run payload. /run leaves it here for the
|
|
34
|
+
# hooks that flush back to it. A file of its own rather than a line of
|
|
35
|
+
# CONTEXT_FILE: it is not a secret, so /suspend never has to read the runner key
|
|
36
|
+
# to find out where to write.
|
|
37
|
+
STATE_PREFIX_FILE="/dev/shm/evident-state-prefix"
|
|
38
|
+
|
|
39
|
+
# The generated litestream.yml (#812). tmpfs for the same two reasons as the
|
|
40
|
+
# files above: it must never reach the block device (Q6, `docs`), and it
|
|
41
|
+
# survives suspend/resume, which is what lets `/resume` start litestream again
|
|
42
|
+
# with no regeneration cost. The CLI regenerates it only when absent or empty.
|
|
43
|
+
LITESTREAM_CONFIG_FILE="/dev/shm/evident-litestream.yml"
|
|
44
|
+
|
|
45
|
+
# Set by the CLI when session-DB restore or verification could not prove this
|
|
46
|
+
# boot safe to replicate. The teardown hooks read it before flushing, and
|
|
47
|
+
# `/terminate` removes it with the rest of the per-VM state.
|
|
48
|
+
SESSION_DB_NO_REPLICATE_MARKER="/dev/shm/evident-session-db-no-replicate"
|
|
49
|
+
|
|
50
|
+
hook_name() { printf '%s' "${0##*/}"; }
|
|
51
|
+
log() { echo "[hook:$(hook_name)] $*"; }
|
|
52
|
+
warn() { echo "[hook:$(hook_name)] $*" >&2; }
|
|
53
|
+
error() { echo "[hook:$(hook_name)] ERROR: $*" >&2; }
|
|
54
|
+
|
|
55
|
+
# Returns the CLI's own exit code. Domain outcomes (nothing persisted yet, a
|
|
56
|
+
# corrupt object) are LOGGED and exit 0, the predicates answer "no" with 10, and
|
|
57
|
+
# `session-db-classify`'s three typed answers are 30 (fatal)/31 (replica
|
|
58
|
+
# unusable)/32 (retry), extended by `session-db-verify`'s 33 (integrity
|
|
59
|
+
# exhausted, replica separated and local disposed) / 34 (could not prove
|
|
60
|
+
# separation or disposal) — see `runner/synchroniser/src/cli.ts`'s own
|
|
61
|
+
# comment for what each means, not restated here. Any OTHER non-zero status
|
|
62
|
+
# means the tool itself broke, which is the only case worth an ERROR here.
|
|
63
|
+
run_synchroniser() {
|
|
64
|
+
local rc=0
|
|
65
|
+
"${SYNCHRONISER}" "$@" || rc=$?
|
|
66
|
+
case "${rc}" in
|
|
67
|
+
0 | 10 | 30 | 31 | 32 | 33 | 34) ;;
|
|
68
|
+
*) error "synchroniser '$*' exited ${rc}; the '${SYNCHRONISER}' command is missing from PATH, corrupt, or it threw" ;;
|
|
69
|
+
esac
|
|
70
|
+
return "${rc}"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Wall-clock milliseconds. ${EPOCHREALTIME} is a bash 5 builtin (the image is
|
|
74
|
+
# node:22-bookworm-slim → bash 5.2) rather than `date +%s%3N`, which has to
|
|
75
|
+
# exec /bin/date inside the caller's command substitution: measured on a loaded
|
|
76
|
+
# box, that exec put ~190ms of its own cost INSIDE the window being measured,
|
|
77
|
+
# against ~23ms for this — overhead charged to the very number this exists to
|
|
78
|
+
# learn. The remaining ~23ms is the command substitution's subshell, kept
|
|
79
|
+
# because removing it means an out-parameter global, and ~2% of a ~1.2s call
|
|
80
|
+
# biases the reading generous, which is the safe direction for sizing a
|
|
81
|
+
# deadline.
|
|
82
|
+
#
|
|
83
|
+
# The `[.,]` is not paranoia: ${EPOCHREALTIME} renders its decimal separator
|
|
84
|
+
# from LC_NUMERIC, so a comma locale would otherwise silently produce garbage
|
|
85
|
+
# here. Stripping either turns it into whole microseconds, which /1000 makes
|
|
86
|
+
# milliseconds.
|
|
87
|
+
#
|
|
88
|
+
# Never fails its caller: a timing line is diagnostics, and hardening a
|
|
89
|
+
# currently-working path is worse than the gap it closes (#931). The `date`
|
|
90
|
+
# fallback covers a pre-5.0 bash; a 0 means "could not read the clock", which
|
|
91
|
+
# `log_elapsed_since` turns into `elapsed_ms=unknown` rather than aborting a
|
|
92
|
+
# hook under `set -e` — and rather than a 0 that would read as a fast healthy call.
|
|
93
|
+
now_ms() {
|
|
94
|
+
local now="${EPOCHREALTIME:-}"
|
|
95
|
+
if [ -n "${now}" ]; then
|
|
96
|
+
now="${now/[.,]/}"
|
|
97
|
+
echo "$((now / 1000))"
|
|
98
|
+
return 0
|
|
99
|
+
fi
|
|
100
|
+
date +%s%3N 2>/dev/null || echo 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Reports what one pre-warm COST, in a deliberately machine-greppable line
|
|
104
|
+
# (`op=`, `elapsed_ms=`, `rc=`) so "how long does this actually take in the
|
|
105
|
+
# fleet?" is a log query rather than another spike. `at_s` is where the step
|
|
106
|
+
# landed on the hook's own SECONDS clock.
|
|
107
|
+
#
|
|
108
|
+
# `unknown`, never a number, when either end failed to read the clock (now_ms's
|
|
109
|
+
# 0). Subtracting them would report `elapsed_ms=0` — indistinguishable from a
|
|
110
|
+
# very fast healthy call, which would quietly bias the fleet-wide sizing data
|
|
111
|
+
# this line exists to produce, in the DANGEROUS direction (a deadline sized too
|
|
112
|
+
# tight). A non-numeric value drops out of an aggregate instead of poisoning it.
|
|
113
|
+
log_elapsed_since() {
|
|
114
|
+
local op="$1" started_ms="$2" rc="$3"
|
|
115
|
+
|
|
116
|
+
local finished_ms elapsed_ms="unknown"
|
|
117
|
+
finished_ms="$(now_ms)"
|
|
118
|
+
if [ "${started_ms}" -gt 0 ] && [ "${finished_ms}" -gt 0 ]; then
|
|
119
|
+
elapsed_ms="$((finished_ms - started_ms))"
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
log "SYNCHRONISER-TIMING op=${op} elapsed_ms=${elapsed_ms} rc=${rc} at_s=${SECONDS}"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Exports what `runner-synchroniser` resolves its object-store location from
|
|
126
|
+
# (runner/synchroniser/src/config.ts). It treats either being empty as
|
|
127
|
+
# "persistence disabled" and then reports every restore as a WARNING it still
|
|
128
|
+
# exits 0 for — so an unset value has to be caught HERE, where it can still be
|
|
129
|
+
# told apart from "the store is simply empty".
|
|
130
|
+
load_state_config() {
|
|
131
|
+
if [ -z "${LITESTREAM_BUCKET:-}" ]; then
|
|
132
|
+
error "LITESTREAM_BUCKET is unset; the image is missing the durable-state bucket the stack bakes in"
|
|
133
|
+
return 1
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
LITESTREAM_PREFIX="$(cat "${STATE_PREFIX_FILE}" 2>/dev/null || true)"
|
|
137
|
+
if [ -z "${LITESTREAM_PREFIX}" ]; then
|
|
138
|
+
error "no durable-state prefix at ${STATE_PREFIX_FILE}; /run records the payload's state_prefix there before anything can be restored or flushed"
|
|
139
|
+
return 1
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
export LITESTREAM_BUCKET LITESTREAM_PREFIX
|
|
143
|
+
|
|
144
|
+
# Mirrors runner-synchroniser's own persistenceEnabled predicate (config.ts):
|
|
145
|
+
# both non-empty, already checked above, so by this point PERSISTENCE_BUCKET
|
|
146
|
+
# is just LITESTREAM_BUCKET. The CLI's replicator and flush_session_db (#812
|
|
147
|
+
# WI-3/WI-4) read ONLY this var, never LITESTREAM_BUCKET/LITESTREAM_PREFIX
|
|
148
|
+
# directly, so every entry point agrees on one predicate regardless of which
|
|
149
|
+
# earlier step in THIS hook process set it; /resume, /suspend and /terminate
|
|
150
|
+
# all use this same function before any teardown decision.
|
|
151
|
+
export PERSISTENCE_BUCKET="${LITESTREAM_BUCKET}"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Best-effort by design: the teardown hooks must still complete even when a
|
|
155
|
+
# credential flush cannot happen.
|
|
156
|
+
sync_credentials() {
|
|
157
|
+
load_state_config || return 0
|
|
158
|
+
run_synchroniser sync-once claude || true
|
|
159
|
+
run_synchroniser sync-once opencode || true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# --- boot pre-warms ----------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
# Reads the ~30 MB litestream binary into the page cache, in the background, so
|
|
165
|
+
# the FIRST exec of it does not pay that read on the critical path.
|
|
166
|
+
#
|
|
167
|
+
# The pre-warm overlaps the CLI's node startup and auth round trip, so the first
|
|
168
|
+
# litestream operation does not also pay the binary's cold page-cache read.
|
|
169
|
+
# Boot measurements put ~6.6-7.3s before the litestream version line, of which
|
|
170
|
+
# only ~0.2s is accounted for by the two node calls in between — the remainder
|
|
171
|
+
# is INFERRED to be this read, never measured.
|
|
172
|
+
# The `litestream-prewarm` and `litestream-version` timings are what settle it
|
|
173
|
+
# on the next boot.
|
|
174
|
+
#
|
|
175
|
+
# Nothing waits on it and nothing reads its output, so if that inference is
|
|
176
|
+
# wrong this costs one backgrounded `cat`. Deleting the `litestream version`
|
|
177
|
+
# diagnostic instead would save nothing: `litestream restore` two calls later
|
|
178
|
+
# pays the identical read.
|
|
179
|
+
prewarm_litestream() {
|
|
180
|
+
local binary
|
|
181
|
+
binary="$(command -v litestream 2>/dev/null || true)"
|
|
182
|
+
if [ -z "${binary}" ]; then
|
|
183
|
+
warn "litestream is not on PATH; skipping the boot pre-warm"
|
|
184
|
+
return 0
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
# `cat` rather than a throwaway `litestream version`: a sequential read gets
|
|
188
|
+
# the whole file with readahead, where an exec demand-pages it.
|
|
189
|
+
(
|
|
190
|
+
local started_ms rc=0
|
|
191
|
+
started_ms="$(now_ms)"
|
|
192
|
+
cat "${binary}" >/dev/null 2>&1 || rc=$?
|
|
193
|
+
log_elapsed_since litestream-prewarm "${started_ms}" "${rc}"
|
|
194
|
+
) &
|
|
195
|
+
log "pre-warming ${binary} in the background"
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Reads the aws CLI v2 install tree into the page cache, in the background, so
|
|
199
|
+
# the CLI's first runner-secret fetch does not pay first-touch I/O. The read
|
|
200
|
+
# overlaps `evident run`'s node startup and auth round trip rather than a hook
|
|
201
|
+
# deadline.
|
|
202
|
+
#
|
|
203
|
+
# A single `cat` of the `aws` entrypoint (litestream's pattern above) is NOT
|
|
204
|
+
# enough here: v2 ships as a real Python distribution (~7,500 files under the
|
|
205
|
+
# resolved binary's own directory), and a cold invocation demand-pages a
|
|
206
|
+
# scattered set of them (botocore's endpoints.json/partitions.json, service
|
|
207
|
+
# model JSON, shared libs) — one boot measured #1997's fetch at 7,243ms cold
|
|
208
|
+
# vs ~400ms warm, an ~18x gap the litestream read-ahead trick alone cannot
|
|
209
|
+
# close. `find -exec cat` walks that whole tree instead of one file.
|
|
210
|
+
prewarm_aws_cli() {
|
|
211
|
+
local binary tree
|
|
212
|
+
binary="$(command -v aws 2>/dev/null || true)"
|
|
213
|
+
if [ -z "${binary}" ]; then
|
|
214
|
+
warn "aws CLI is not on PATH; skipping the boot pre-warm"
|
|
215
|
+
return 0
|
|
216
|
+
fi
|
|
217
|
+
tree="$(dirname "$(readlink -f "${binary}")")"
|
|
218
|
+
|
|
219
|
+
(
|
|
220
|
+
local started_ms rc=0
|
|
221
|
+
started_ms="$(now_ms)"
|
|
222
|
+
find "${tree}" -type f -exec cat {} + >/dev/null 2>&1 || rc=$?
|
|
223
|
+
log_elapsed_since aws-cli-prewarm "${started_ms}" "${rc}"
|
|
224
|
+
) &
|
|
225
|
+
log "pre-warming ${tree} in the background"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# `kill -0` answers "does this pid exist", which is not the question any caller
|
|
229
|
+
# here is asking. A process that has exited but has not been reaped — a zombie —
|
|
230
|
+
# still exists, so `kill -0` reports a corpse as ALIVE. That condition is the
|
|
231
|
+
# normal case for everything these hooks start: `start_tunnel` and its CLI child
|
|
232
|
+
# background a process that outlives the hook, the hook shell must return so AWS
|
|
233
|
+
# gets its 200, and PID 1 in this image is a bare node hook server with no init
|
|
234
|
+
# (Dockerfile) — so nothing ever wait()s for the orphan. It stayed a zombie for
|
|
235
|
+
# the rest of the VM's life, which made `stop_tunnel` burn its whole budget
|
|
236
|
+
# SIGKILLing a corpse and made a stale pid file suppress the next
|
|
237
|
+
# `start_tunnel`, leaving a resumed VM permanently offline (#718).
|
|
238
|
+
#
|
|
239
|
+
# This is zombie-safe, NOT identity-safe: a recycled pid reads R/S and is still
|
|
240
|
+
# called alive, so the SIGKILL backstop could in principle hit an unrelated
|
|
241
|
+
# process. Pre-existing limitation of the pid-file approach, out of scope here —
|
|
242
|
+
# named so nobody over-trusts the helper (src/image/hook-scripts.test.ts).
|
|
243
|
+
#
|
|
244
|
+
# Also sets PROC_DEAD_REASON on every return path — "gone" for a `kill -0`
|
|
245
|
+
# miss, "zombie" for an unreaped `Z`, reset to empty on the alive path so a
|
|
246
|
+
# stale value from a previous call can never be read — for the caller that
|
|
247
|
+
# needs to say WHICH death it was (stop_tunnel's outcome line), never for this
|
|
248
|
+
# function itself: see the silence note below. File-scope init (below) is what
|
|
249
|
+
# keeps a read of it safe under `set -u` before this has ever run.
|
|
250
|
+
PROC_DEAD_REASON=""
|
|
251
|
+
|
|
252
|
+
process_is_alive() {
|
|
253
|
+
if ! kill -0 "$1" 2>/dev/null; then
|
|
254
|
+
PROC_DEAD_REASON="gone"
|
|
255
|
+
return 1
|
|
256
|
+
fi
|
|
257
|
+
|
|
258
|
+
# `/proc/<pid>/status` rather than `/proc/<pid>/stat`, whose fields cannot be
|
|
259
|
+
# split safely when a comm contains a space or a paren. If the state cannot be
|
|
260
|
+
# read at all, keep the `kill -0` answer: absent evidence is not "dead", and
|
|
261
|
+
# reading it as dead would SIGTERM-and-forget a live, still-draining CLI.
|
|
262
|
+
# Three fields, not two: `read` hands the whole remainder to its LAST variable,
|
|
263
|
+
# so a trailing `_` is what keeps the state letter out of `Z (zombie)`.
|
|
264
|
+
#
|
|
265
|
+
# That deferral is silent HERE on purpose: `stop_tunnel` polls this 10x/s, so a
|
|
266
|
+
# warning inside it would bury the log. The flag lets `is_running` say it once
|
|
267
|
+
# per guard instead — absent evidence must warn as well as defer
|
|
268
|
+
# (development-workflow.mdc).
|
|
269
|
+
PROC_STATE_UNREADABLE=0
|
|
270
|
+
local state
|
|
271
|
+
read -r _ state _ < <(grep -m1 '^State:' "/proc/$1/status" 2>/dev/null) || {
|
|
272
|
+
PROC_STATE_UNREADABLE=1
|
|
273
|
+
PROC_DEAD_REASON="" # unreadable /proc still means "alive" below, not a dead reason
|
|
274
|
+
return 0
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if [ "${state}" = "Z" ]; then
|
|
278
|
+
PROC_DEAD_REASON="zombie"
|
|
279
|
+
return 1
|
|
280
|
+
fi
|
|
281
|
+
|
|
282
|
+
PROC_DEAD_REASON=""
|
|
283
|
+
return 0
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
is_running() {
|
|
287
|
+
[ -s "$1" ] || return 1
|
|
288
|
+
|
|
289
|
+
local pid
|
|
290
|
+
pid="$(cat "$1")"
|
|
291
|
+
process_is_alive "${pid}" || return 1
|
|
292
|
+
|
|
293
|
+
# The one state in which this whole helper is back to being a bare `kill -0`
|
|
294
|
+
# (a `hidepid=` mount, a `/proc` that is not mounted): every liveness answer
|
|
295
|
+
# silently reverts to #718 — a corpse reads as alive, so suspend SIGKILLs it
|
|
296
|
+
# after the full wait and the next start is suppressed for the VM's life.
|
|
297
|
+
[ "${PROC_STATE_UNREADABLE}" = 0 ] \
|
|
298
|
+
|| warn "could not read /proc/${pid}/status; falling back to 'kill -0', which reports an exited-but-unreaped process as alive (#718)"
|
|
299
|
+
|
|
300
|
+
return 0
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# The snapshot bakes one machine id into every VM launched from this image
|
|
304
|
+
# version, so /run replaces it. Best-effort per file: the hook runs as uid 10001
|
|
305
|
+
# and these live in root-owned directories, so a VM that kept the snapshot's id
|
|
306
|
+
# must still boot — but it says so, because it is a state worth being able to see.
|
|
307
|
+
regenerate_machine_id() {
|
|
308
|
+
local machine_id
|
|
309
|
+
machine_id="$(tr -d '-' </proc/sys/kernel/random/uuid)"
|
|
310
|
+
|
|
311
|
+
for path in /etc/machine-id /var/lib/dbus/machine-id; do
|
|
312
|
+
printf '%s\n' "${machine_id}" >"${path}" 2>/dev/null \
|
|
313
|
+
|| warn "could not rewrite ${path}; this VM keeps the snapshot's machine id"
|
|
314
|
+
done
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
tunnel_is_running() { is_running "${TUNNEL_PID_FILE}"; }
|
|
318
|
+
opencode_is_running() { is_running "${OPENCODE_PID_FILE}"; }
|
|
319
|
+
litestream_is_running() { is_running "${LITESTREAM_PID_FILE}"; }
|
|
320
|
+
creds_sync_is_running() { is_running "${CREDS_SYNC_PID_FILE}"; }
|
|
321
|
+
|
|
322
|
+
# `jq -e` alone is not enough: its exit status reflects the LAST OUTPUT VALUE,
|
|
323
|
+
# and an interpolation of a missing field is still a non-empty string, so a
|
|
324
|
+
# payload with no runner_key would sail through as the literal "null".
|
|
325
|
+
payload_is_complete() {
|
|
326
|
+
jq -e '
|
|
327
|
+
(.runner_key | type == "string" and length > 0)
|
|
328
|
+
and (.endpoints.api | type == "string" and length > 0)
|
|
329
|
+
and (.endpoints.tunnel | type == "string" and length > 0)
|
|
330
|
+
and (.state_prefix | type == "string" and length > 0)
|
|
331
|
+
' >/dev/null 2>&1
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
stop_opencode() {
|
|
335
|
+
if ! opencode_is_running; then
|
|
336
|
+
# Clear the file here too, for the same reason stop_tunnel does: opencode
|
|
337
|
+
# has exit paths that never reach this function (a crash, an OOM kill), so
|
|
338
|
+
# the pid file routinely outlives the process it names, and leaving it is
|
|
339
|
+
# #718 again the moment anything starts reaping orphans and the pid is
|
|
340
|
+
# recycled.
|
|
341
|
+
rm -f "${OPENCODE_PID_FILE}"
|
|
342
|
+
log "no opencode to stop"
|
|
343
|
+
return 0
|
|
344
|
+
fi
|
|
345
|
+
|
|
346
|
+
kill -TERM "$(cat "${OPENCODE_PID_FILE}")" 2>/dev/null || true
|
|
347
|
+
rm -f "${OPENCODE_PID_FILE}"
|
|
348
|
+
log "opencode stopped"
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
# The ceiling stop_opencode_and_wait (below) waits before its SIGKILL backstop.
|
|
352
|
+
# /terminate can afford a real drain — the VM is being torn down either way —
|
|
353
|
+
# unlike /run's cleanup, which cannot (stop_opencode's own cheapness above is
|
|
354
|
+
# load-bearing for that budget, §4, and must not grow a wait).
|
|
355
|
+
OPENCODE_STOP_WAIT_SECONDS="${EVIDENT_OPENCODE_STOP_WAIT_SECONDS:-5}"
|
|
356
|
+
|
|
357
|
+
# The graceful stop /terminate needs (#812 WI-4): SIGTERM, poll for exit,
|
|
358
|
+
# SIGKILL backstop, clear the pid file. A NEW function rather than growing
|
|
359
|
+
# stop_opencode itself, for the reason above — mirrors stop_tunnel's and
|
|
360
|
+
# stop_litestream's shape, including PROC_DEAD_REASON's one deciding read
|
|
361
|
+
# after the poll loop (never inside it, which would bury the log at 10x/s).
|
|
362
|
+
# This is half of the ordered-shutdown invariant (plan §6): /terminate must
|
|
363
|
+
# stop opencode BEFORE flush_session_db's final sync, or litestream could
|
|
364
|
+
# snapshot while opencode is still writing its WAL and the last session
|
|
365
|
+
# writes would be missing from S3.
|
|
366
|
+
stop_opencode_and_wait() {
|
|
367
|
+
if ! opencode_is_running; then
|
|
368
|
+
rm -f "${OPENCODE_PID_FILE}"
|
|
369
|
+
log "no opencode to stop"
|
|
370
|
+
return 0
|
|
371
|
+
fi
|
|
372
|
+
|
|
373
|
+
local pid
|
|
374
|
+
pid="$(cat "${OPENCODE_PID_FILE}")"
|
|
375
|
+
kill -TERM "${pid}" 2>/dev/null || true
|
|
376
|
+
|
|
377
|
+
local tenths=0
|
|
378
|
+
local deadline_tenths=$(( OPENCODE_STOP_WAIT_SECONDS * 10 ))
|
|
379
|
+
while [ "${tenths}" -lt "${deadline_tenths}" ]; do
|
|
380
|
+
process_is_alive "${pid}" || break
|
|
381
|
+
sleep 0.1
|
|
382
|
+
tenths=$(( tenths + 1 ))
|
|
383
|
+
done
|
|
384
|
+
|
|
385
|
+
local outcome
|
|
386
|
+
if process_is_alive "${pid}"; then
|
|
387
|
+
warn "opencode ${pid} ignored SIGTERM; killing"
|
|
388
|
+
kill -KILL "${pid}" 2>/dev/null || true
|
|
389
|
+
outcome="after SIGKILL (ignored SIGTERM for ${OPENCODE_STOP_WAIT_SECONDS}s)"
|
|
390
|
+
else
|
|
391
|
+
outcome="cleanly in $(( tenths / 10 )).$(( tenths % 10 ))s (${PROC_DEAD_REASON})"
|
|
392
|
+
fi
|
|
393
|
+
|
|
394
|
+
rm -f "${OPENCODE_PID_FILE}"
|
|
395
|
+
log "opencode stopped ${outcome}"
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
# The graceful wait before the SIGKILL backstop in stop_litestream, below.
|
|
399
|
+
# litestream's own sync is normally sub-second, so 10s is generous headroom —
|
|
400
|
+
# chosen the same way TUNNEL_STOP_WAIT_SECONDS was, not a measured worst case.
|
|
401
|
+
LITESTREAM_STOP_WAIT_SECONDS="${EVIDENT_LITESTREAM_STOP_WAIT_SECONDS:-10}"
|
|
402
|
+
|
|
403
|
+
# The graceful stop: mirrors stop_tunnel exactly, including PROC_DEAD_REASON's
|
|
404
|
+
# one deciding read AFTER the poll loop (never inside it, which polls 10x/s and
|
|
405
|
+
# would bury the log) — gone / zombie / SIGKILLed are the three outcomes an
|
|
406
|
+
# operator needs told apart, not a flatter clean/killed binary. /suspend and
|
|
407
|
+
# /terminate call this: the SIGTERM it sends IS the checked flush they need.
|
|
408
|
+
stop_litestream() {
|
|
409
|
+
if ! litestream_is_running; then
|
|
410
|
+
rm -f "${LITESTREAM_PID_FILE}"
|
|
411
|
+
log "no litestream to stop"
|
|
412
|
+
return 0
|
|
413
|
+
fi
|
|
414
|
+
|
|
415
|
+
local pid
|
|
416
|
+
pid="$(cat "${LITESTREAM_PID_FILE}")"
|
|
417
|
+
kill -TERM "${pid}" 2>/dev/null || true
|
|
418
|
+
|
|
419
|
+
local tenths=0
|
|
420
|
+
local deadline_tenths=$(( LITESTREAM_STOP_WAIT_SECONDS * 10 ))
|
|
421
|
+
while [ "${tenths}" -lt "${deadline_tenths}" ]; do
|
|
422
|
+
process_is_alive "${pid}" || break
|
|
423
|
+
sleep 0.1
|
|
424
|
+
tenths=$(( tenths + 1 ))
|
|
425
|
+
done
|
|
426
|
+
|
|
427
|
+
local outcome
|
|
428
|
+
if process_is_alive "${pid}"; then
|
|
429
|
+
warn "litestream ${pid} ignored SIGTERM; killing"
|
|
430
|
+
kill -KILL "${pid}" 2>/dev/null || true
|
|
431
|
+
outcome="after SIGKILL (ignored SIGTERM for ${LITESTREAM_STOP_WAIT_SECONDS}s)"
|
|
432
|
+
else
|
|
433
|
+
outcome="cleanly in $(( tenths / 10 )).$(( tenths % 10 ))s (${PROC_DEAD_REASON})"
|
|
434
|
+
fi
|
|
435
|
+
|
|
436
|
+
rm -f "${LITESTREAM_PID_FILE}"
|
|
437
|
+
log "litestream stopped ${outcome}"
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
# The cheap stop: one SIGTERM, one rm, one log line, NO poll loop — `/run`'s
|
|
441
|
+
# own cleanup calls this, never stop_litestream, because that path only runs
|
|
442
|
+
# after /run has already FAILED: no session happened yet, so a graceful final
|
|
443
|
+
# sync is worth nothing against a SIGTERM budget that cannot afford another
|
|
444
|
+
# wait on top of stop_tunnel's own. A distinct function rather than a mode
|
|
445
|
+
# argument on stop_litestream:
|
|
446
|
+
# stop_tunnel's own comment argues against an argument a future caller can get
|
|
447
|
+
# wrong. litestream_is_running is the same O(1) check stop_litestream's own
|
|
448
|
+
# no-op branch uses, not a poll — /run's cleanup trap fires this before
|
|
449
|
+
# the replicator start is ever reached whenever an earlier step failed, and an
|
|
450
|
+
# operator reading that log must not be told a stop signal went to a process
|
|
451
|
+
# that never started.
|
|
452
|
+
kill_litestream() {
|
|
453
|
+
if ! litestream_is_running; then
|
|
454
|
+
rm -f "${LITESTREAM_PID_FILE}"
|
|
455
|
+
log "no litestream to stop"
|
|
456
|
+
return 0
|
|
457
|
+
fi
|
|
458
|
+
|
|
459
|
+
kill -TERM "$(cat "${LITESTREAM_PID_FILE}")" 2>/dev/null || true
|
|
460
|
+
rm -f "${LITESTREAM_PID_FILE}"
|
|
461
|
+
log "litestream stop signalled (no wait)"
|
|
462
|
+
}
|
|
463
|
+
# --- litestream replicate (end) ----------------------------------------------
|
|
464
|
+
|
|
465
|
+
# --- credential sync loop (#1868 WI-3, ECS parity) ---------------------------
|
|
466
|
+
#
|
|
467
|
+
# sync_credentials (above) covers the three boundary flushes /run's restore,
|
|
468
|
+
# /suspend and /terminate already call. What it does NOT cover is a VM that
|
|
469
|
+
# runs for a long time between those boundaries: a provider re-authenticated
|
|
470
|
+
# through the proxied UI hours into a run would sit unflushed until the next
|
|
471
|
+
# suspend/terminate, and a VM that dies without one (a crash, an OOM kill)
|
|
472
|
+
# loses everything since boot. runner/docker-images/fargate/entrypoint.sh's
|
|
473
|
+
# own sync_credentials_loop is the ECS side of the identical gap; this is the
|
|
474
|
+
# same fix, backgrounded like the other long-lived services so it
|
|
475
|
+
# outlives this hook process, `( … ) &` rather than `setsid`: a plain
|
|
476
|
+
# backgrounded subshell is reparented to init and keeps running once its
|
|
477
|
+
# parent hook script exits (verified: PPID=1, still alive, with no controlling
|
|
478
|
+
# terminal in this image to send it a stray SIGHUP), and it inherits every
|
|
479
|
+
# function this file defines, so it can call run_synchroniser directly with no
|
|
480
|
+
# re-exec.
|
|
481
|
+
|
|
482
|
+
# Bounded confirmation window `stop_credential_sync` polls after signalling the
|
|
483
|
+
# loop. The loop's current child is one fast `run_synchroniser sync-once` call,
|
|
484
|
+
# so this stays a short backstop rather than a graceful drain.
|
|
485
|
+
CREDS_SYNC_STOP_WAIT_SECONDS=2
|
|
486
|
+
|
|
487
|
+
# Best-effort per tick, exactly like sync_credentials above: a failed tick
|
|
488
|
+
# must never end the loop, or a single transient S3 error would silently
|
|
489
|
+
# disable sync for the rest of the VM's life.
|
|
490
|
+
start_credential_sync() {
|
|
491
|
+
if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
|
|
492
|
+
warn "CREDS-SYNC-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; no interval credential sync this boot."
|
|
493
|
+
return 0
|
|
494
|
+
fi
|
|
495
|
+
|
|
496
|
+
if creds_sync_is_running; then
|
|
497
|
+
warn "credential sync loop already running (pid $(cat "${CREDS_SYNC_PID_FILE}")); reusing it"
|
|
498
|
+
return 0
|
|
499
|
+
fi
|
|
500
|
+
|
|
501
|
+
# This is the same environment input runner-synchroniser validates. The
|
|
502
|
+
# variable is normally absent, so that ordinary case uses the documented
|
|
503
|
+
# default without a warning; a present invalid value is named and rejected.
|
|
504
|
+
local interval="${CREDS_SYNC_INTERVAL:-60}"
|
|
505
|
+
if [ -n "${CREDS_SYNC_INTERVAL+x}" ] && [[ ! "${CREDS_SYNC_INTERVAL}" =~ ^[1-9][0-9]*$ ]]; then
|
|
506
|
+
warn "CREDS-SYNC-INTERVAL-INVALID: CREDS_SYNC_INTERVAL='${CREDS_SYNC_INTERVAL}' is not a positive integer; using 60s"
|
|
507
|
+
interval=60
|
|
508
|
+
fi
|
|
509
|
+
|
|
510
|
+
rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
511
|
+
|
|
512
|
+
(
|
|
513
|
+
# Releases the fds this subshell inherited from the hook process before
|
|
514
|
+
# settling in for the VM's whole remaining life: nothing here writes to
|
|
515
|
+
# them (every synchroniser call already redirects its own), so there is
|
|
516
|
+
# no reason to keep holding the hook's original stdout/stderr open. A
|
|
517
|
+
# long-lived process that instead inherited a pipe's write end (a test
|
|
518
|
+
# harness reading the hook's own output, for one) would keep that pipe
|
|
519
|
+
# from ever reporting EOF — testing-guide.mdc's own lesson, and the same
|
|
520
|
+
# reason the long-lived services never inherit stdio either. That
|
|
521
|
+
# redirect also means `warn`/`log`/`error` calls in here go nowhere, so a
|
|
522
|
+
# failed sync-once is instead recorded to CREDS_SYNC_LAST_ERROR_FILE and
|
|
523
|
+
# surfaced by stop_credential_sync, which DOES have live stdio.
|
|
524
|
+
exec >/dev/null 2>&1 </dev/null
|
|
525
|
+
|
|
526
|
+
# A TERM this subshell receives (from stop_credential_sync, below) only
|
|
527
|
+
# kills THIS wrapper by default — its currently-running child (`sleep`,
|
|
528
|
+
# or a `run_synchroniser sync-once` call) is a separate process that
|
|
529
|
+
# would otherwise be orphaned and keep running, free to upload STALE
|
|
530
|
+
# credentials to S3 after the boundary flush that /suspend and
|
|
531
|
+
# /terminate perform immediately following the stop. Tracking the
|
|
532
|
+
# current child explicitly and forwarding the signal closes that race.
|
|
533
|
+
creds_sync_child_pid=""
|
|
534
|
+
trap 'trap - TERM; [ -n "${creds_sync_child_pid}" ] && kill -TERM "${creds_sync_child_pid}" 2>/dev/null; exit 0' TERM
|
|
535
|
+
|
|
536
|
+
while true; do
|
|
537
|
+
sleep "${interval}" &
|
|
538
|
+
creds_sync_child_pid=$!
|
|
539
|
+
wait "${creds_sync_child_pid}" 2>/dev/null
|
|
540
|
+
creds_sync_child_pid=""
|
|
541
|
+
|
|
542
|
+
run_synchroniser sync-once claude &
|
|
543
|
+
creds_sync_child_pid=$!
|
|
544
|
+
wait "${creds_sync_child_pid}" 2>/dev/null || echo "claude" >"${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
545
|
+
creds_sync_child_pid=""
|
|
546
|
+
|
|
547
|
+
run_synchroniser sync-once opencode &
|
|
548
|
+
creds_sync_child_pid=$!
|
|
549
|
+
wait "${creds_sync_child_pid}" 2>/dev/null || echo "opencode" >"${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
550
|
+
creds_sync_child_pid=""
|
|
551
|
+
done
|
|
552
|
+
) &
|
|
553
|
+
|
|
554
|
+
echo $! >"${CREDS_SYNC_PID_FILE}"
|
|
555
|
+
log "CREDS-SYNC-STARTED: pid=$! interval=${interval}s"
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
# Signals the loop, then confirms (bounded — see CREDS_SYNC_STOP_WAIT_SECONDS)
|
|
559
|
+
# that it and its current child are actually gone before returning: /suspend
|
|
560
|
+
# and /terminate start their own boundary flush immediately after this call,
|
|
561
|
+
# and an orphaned in-flight sync-once surviving past that point can overwrite
|
|
562
|
+
# fresher credentials with stale ones. The TERM trap inside the loop (above)
|
|
563
|
+
# forwards the signal to its current child almost instantly — this poll is a
|
|
564
|
+
# defensive confirmation, not the primary mechanism, so it stays short; a
|
|
565
|
+
# SIGKILL backstop covers a child that ignores TERM entirely.
|
|
566
|
+
#
|
|
567
|
+
# The DIED branch is a liveness report, not a no-op: every recovery/no-op path
|
|
568
|
+
# must say what it found (development-workflow.mdc) — a stopped-before-called
|
|
569
|
+
# loop and a died-on-its-own loop are different facts an operator needs told
|
|
570
|
+
# apart, not the same "nothing to stop" line.
|
|
571
|
+
stop_credential_sync() {
|
|
572
|
+
if [ ! -s "${CREDS_SYNC_PID_FILE}" ]; then
|
|
573
|
+
log "CREDS-SYNC-NOT-RUNNING: no credential sync loop to stop"
|
|
574
|
+
return 0
|
|
575
|
+
fi
|
|
576
|
+
|
|
577
|
+
local pid
|
|
578
|
+
pid="$(cat "${CREDS_SYNC_PID_FILE}")"
|
|
579
|
+
if ! process_is_alive "${pid}"; then
|
|
580
|
+
rm -f "${CREDS_SYNC_PID_FILE}"
|
|
581
|
+
warn "CREDS-SYNC-DIED: credential sync loop (pid=${pid}) had already exited before this stop"
|
|
582
|
+
return 0
|
|
583
|
+
fi
|
|
584
|
+
|
|
585
|
+
kill -TERM "${pid}" 2>/dev/null || true
|
|
586
|
+
rm -f "${CREDS_SYNC_PID_FILE}"
|
|
587
|
+
|
|
588
|
+
local waited_ms=0
|
|
589
|
+
while process_is_alive "${pid}" && [ "${waited_ms}" -lt $((CREDS_SYNC_STOP_WAIT_SECONDS * 1000)) ]; do
|
|
590
|
+
sleep 0.1
|
|
591
|
+
waited_ms=$((waited_ms + 100))
|
|
592
|
+
done
|
|
593
|
+
|
|
594
|
+
if process_is_alive "${pid}"; then
|
|
595
|
+
kill -KILL "${pid}" 2>/dev/null || true
|
|
596
|
+
warn "CREDS-SYNC-STOP-TIMEOUT: pid=${pid} still alive after ${CREDS_SYNC_STOP_WAIT_SECONDS}s; sent SIGKILL"
|
|
597
|
+
fi
|
|
598
|
+
|
|
599
|
+
if [ -s "${CREDS_SYNC_LAST_ERROR_FILE}" ]; then
|
|
600
|
+
warn "CREDS-SYNC-HAD-FAILURES: sync-once failed at least once for: $(tr '\n' ' ' <"${CREDS_SYNC_LAST_ERROR_FILE}")"
|
|
601
|
+
rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
602
|
+
fi
|
|
603
|
+
|
|
604
|
+
log "CREDS-SYNC-STOPPED: pid=${pid}"
|
|
605
|
+
}
|
|
606
|
+
# --- credential sync loop (end) -----------------------------------------------
|
|
607
|
+
|
|
608
|
+
# --- flush_session_db (#812 WI-4) -------------------------------------------
|
|
609
|
+
#
|
|
610
|
+
# The checked, synchronous flush /suspend and /terminate need before they
|
|
611
|
+
# finish tearing down. Never fatal, for the same reason every session-DB
|
|
612
|
+
# function in this file is: /suspend and /terminate must complete their own
|
|
613
|
+
# teardown regardless of whether S3 could be reached.
|
|
614
|
+
|
|
615
|
+
# Ceiling for the one-shot `litestream replicate -once` flush below. litestream's
|
|
616
|
+
# own sync is normally sub-second (M5, the plan's grounding); 10s is generous
|
|
617
|
+
# headroom, chosen the same way LITESTREAM_STOP_WAIT_SECONDS was above — not a
|
|
618
|
+
# measured worst case. The SIGKILL backstop gives the flush its own two-second
|
|
619
|
+
# grace after `timeout` sends SIGTERM.
|
|
620
|
+
SESSION_DB_FLUSH_DEADLINE_SECONDS="${EVIDENT_SESSION_DB_FLUSH_DEADLINE_SECONDS:-10}"
|
|
621
|
+
SESSION_DB_FLUSH_KILL_GRACE_SECONDS=2
|
|
622
|
+
|
|
623
|
+
# Guards mirror the CLI replicator's first three exactly (disabled -> marker ->
|
|
624
|
+
# config) — a flush must never attempt work those guards would have refused
|
|
625
|
+
# to start in the first place. PERSISTENCE_BUCKET is the SAME variable
|
|
626
|
+
# the CLI reads, so both agree regardless of which earlier step in
|
|
627
|
+
# THIS process set it (load_state_config above).
|
|
628
|
+
#
|
|
629
|
+
# Then: stop the daemon FIRST — its SIGTERM IS litestream's own final-sync
|
|
630
|
+
# trigger — and only once it is confirmed stopped does the synchronous -once
|
|
631
|
+
# flush run, as a second, checked pass. Running -once while the daemon is
|
|
632
|
+
# still alive would put two writers on one prefix, the single-writer
|
|
633
|
+
# invariant this whole feature exists to protect (Q8) — this ordering is not
|
|
634
|
+
# negotiable.
|
|
635
|
+
#
|
|
636
|
+
# If the daemon has already died (a missing or stale pid file), that IS the
|
|
637
|
+
# detection this needs: SESSION-DB-REPLICATOR-DIED names it loudly instead of
|
|
638
|
+
# silently skipping straight to the flush (development-workflow.mdc: every
|
|
639
|
+
# recovery branch emits a server-visible signal). The flush still runs either
|
|
640
|
+
# way — it is what actually gets any unreplicated writes to S3 before the
|
|
641
|
+
# caller's next step.
|
|
642
|
+
flush_session_db() {
|
|
643
|
+
if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
|
|
644
|
+
warn "SESSION-DB-PERSISTENCE-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; nothing to flush."
|
|
645
|
+
return 0
|
|
646
|
+
fi
|
|
647
|
+
|
|
648
|
+
if [ -e "${SESSION_DB_NO_REPLICATE_MARKER}" ]; then
|
|
649
|
+
log "skipping session-DB flush: this boot's session DB was not proven safe to replicate (see the SESSION-DB-* warning above)"
|
|
650
|
+
return 0
|
|
651
|
+
fi
|
|
652
|
+
|
|
653
|
+
if [ ! -s "${LITESTREAM_CONFIG_FILE}" ]; then
|
|
654
|
+
error "no usable ${LITESTREAM_CONFIG_FILE}; cannot flush the session DB"
|
|
655
|
+
return 0
|
|
656
|
+
fi
|
|
657
|
+
|
|
658
|
+
if litestream_is_running; then
|
|
659
|
+
stop_litestream
|
|
660
|
+
else
|
|
661
|
+
warn "SESSION-DB-REPLICATOR-DIED: no live litestream at ${LITESTREAM_PID_FILE}; flushing one-shot anyway"
|
|
662
|
+
fi
|
|
663
|
+
|
|
664
|
+
# Only reached with the daemon confirmed stopped, or never running — never
|
|
665
|
+
# concurrently with it (see above).
|
|
666
|
+
local flush_rc=0
|
|
667
|
+
timeout -k "${SESSION_DB_FLUSH_KILL_GRACE_SECONDS}" "${SESSION_DB_FLUSH_DEADLINE_SECONDS}" \
|
|
668
|
+
litestream replicate -once -config "${LITESTREAM_CONFIG_FILE}" || flush_rc=$?
|
|
669
|
+
|
|
670
|
+
if [ "${flush_rc}" -eq 0 ]; then
|
|
671
|
+
log "SESSION-DB-FINAL-FLUSH: succeeded, ${SECONDS}s into the hook"
|
|
672
|
+
else
|
|
673
|
+
warn "SESSION-DB-FINAL-FLUSH: litestream replicate -once exited ${flush_rc}, ${SECONDS}s into the hook; some writes since the last sync may not have reached S3"
|
|
674
|
+
fi
|
|
675
|
+
}
|
|
676
|
+
# --- flush_session_db (end) --------------------------------------------------
|
|
677
|
+
|
|
678
|
+
# How long the guest CLI runs with no activity before it exits itself
|
|
679
|
+
# (`evident run --idle-timeout`, apps/cli/src/commands/run.ts), which is what
|
|
680
|
+
# turns a truly-abandoned VM into the clean-offline POST that lets Evident
|
|
681
|
+
# suspend it (#732). Sized from the measured cost of guessing wrong rather than
|
|
682
|
+
# the saving: suspend reaches SUSPENDED in ~7 s and a resume is RUNNING in
|
|
683
|
+
# ~0.6 s with the tunnel back ~2 s later (README, "Measured, end to end"), so
|
|
684
|
+
# napping a VM whose user comes straight back costs ~10 s — against ~$0.30/h
|
|
685
|
+
# that is cheap enough that the balance sits far nearer the floor than the
|
|
686
|
+
# ceiling. Not AT the floor, though: the CLI's idle detector needs 2 clear poll
|
|
687
|
+
# cycles (`run.ts`'s `idlePolls >= 2`, ≥4 s of real time), so a value near that
|
|
688
|
+
# would spend more time suspending/resuming than idle.
|
|
689
|
+
# ECS's waker uses 900 s instead only because *its* cold start is far slower
|
|
690
|
+
# than this VM's ~2 s resume — not evidence this default should match it.
|
|
691
|
+
#
|
|
692
|
+
# NOT part of the hook's own teardown budget (TUNNEL_STOP_WAIT_SECONDS et al.,
|
|
693
|
+
# below): it governs the CLI's lifetime long after the hook has already
|
|
694
|
+
# returned its 200, so it is deliberately outside that arithmetic.
|
|
695
|
+
#
|
|
696
|
+
# EVIDENT_IDLE_TIMEOUT_SECONDS, deliberately the SAME env var name
|
|
697
|
+
# runner/docker-images/fargate/entrypoint.sh uses for the ECS runner's own
|
|
698
|
+
# idle-timeout flag, so an operator who knows one knows the other. A
|
|
699
|
+
# non-numeric override must never silently DROP the flag — that degrades to
|
|
700
|
+
# an always-on VM burning ~$2.40/day, exactly the bug this closes — so it
|
|
701
|
+
# warns and falls back to the default instead.
|
|
702
|
+
#
|
|
703
|
+
# Deliberately NOT in the /run payload yet: doing so would touch the doorbell
|
|
704
|
+
# contract in four coordinated places for no capability today. #716 shipped
|
|
705
|
+
# the swarm/per-user provisioner but explicitly scoped control-plane
|
|
706
|
+
# idle/suspend policy OUT (its own "Phase 3" — see the issue's "Out of
|
|
707
|
+
# Scope"), so a per-pool idle policy is still a clean follow-up, not
|
|
708
|
+
# something #716 already provides a home for.
|
|
709
|
+
IDLE_TIMEOUT_SECONDS=120
|
|
710
|
+
if [ -n "${EVIDENT_IDLE_TIMEOUT_SECONDS:-}" ]; then
|
|
711
|
+
if [[ "${EVIDENT_IDLE_TIMEOUT_SECONDS}" =~ ^[0-9]+$ ]]; then
|
|
712
|
+
IDLE_TIMEOUT_SECONDS="${EVIDENT_IDLE_TIMEOUT_SECONDS}"
|
|
713
|
+
else
|
|
714
|
+
warn "EVIDENT_IDLE_TIMEOUT_SECONDS='${EVIDENT_IDLE_TIMEOUT_SECONDS}' is not numeric; using the default ${IDLE_TIMEOUT_SECONDS}s instead of leaving the VM always-on"
|
|
715
|
+
fi
|
|
716
|
+
fi
|
|
717
|
+
|
|
718
|
+
# `setsid` so the tunnel outlives this hook: the script must return so AWS gets
|
|
719
|
+
# its 200, while the tunnel keeps serving.
|
|
720
|
+
start_tunnel() {
|
|
721
|
+
local runner_key="$1" api_url="$2" tunnel_url="$3"
|
|
722
|
+
local restore_runner_credentials="${4:-false}" restore_history="${5:-false}"
|
|
723
|
+
local -a credential_flags=()
|
|
724
|
+
local -a session_db_flags=()
|
|
725
|
+
local -a opencode_config_flags=()
|
|
726
|
+
|
|
727
|
+
# The explicit fourth argument is set only by /run; /resume uses the default
|
|
728
|
+
# so a resumed VM never restores credentials over stores it already has.
|
|
729
|
+
if [ "${restore_runner_credentials}" = true ]; then
|
|
730
|
+
credential_flags=(--restore-runner-credentials)
|
|
731
|
+
fi
|
|
732
|
+
|
|
733
|
+
# Only a fresh /run asks the CLI to restore session history. /resume keeps
|
|
734
|
+
# the database from the snapshot and must not restore over it.
|
|
735
|
+
if [ "${restore_history}" = true ]; then
|
|
736
|
+
session_db_flags=(--restore-session-db)
|
|
737
|
+
fi
|
|
738
|
+
|
|
739
|
+
if [ -n "${RUNNER_OPENCODE_CONFIG:-}" ]; then
|
|
740
|
+
opencode_config_flags=(--opencode-config-overlay "${RUNNER_OPENCODE_CONFIG}")
|
|
741
|
+
fi
|
|
742
|
+
|
|
743
|
+
if tunnel_is_running; then
|
|
744
|
+
warn "tunnel already running (pid $(cat "${TUNNEL_PID_FILE}")); not starting a second one"
|
|
745
|
+
return 0
|
|
746
|
+
fi
|
|
747
|
+
|
|
748
|
+
# The key travels in the child's environment ONLY: argv is world-readable
|
|
749
|
+
# through /proc.
|
|
750
|
+
# --enable-file-sync-to is what makes the UI's "connect a provider account"
|
|
751
|
+
# flow reach this VM: without it the CLI declines every queued file and the
|
|
752
|
+
# page can only say "this runner isn't accepting files". There is no shell
|
|
753
|
+
# into a MicroVM, so it is the ONLY way to seed model credentials on a runner
|
|
754
|
+
# that is already up — the S3 credential store is read at /run and /resume,
|
|
755
|
+
# never mid-life. The allow-list stays a single directory, as on ECS
|
|
756
|
+
# (runner/docker-images/fargate/entrypoint.sh): the flag is repeatable, but every
|
|
757
|
+
# extra entry widens what Evident can write into a VM that executes agent
|
|
758
|
+
# code. ${HOME} is set by the image (ENV HOME=/home/runner) and `set -u` makes
|
|
759
|
+
# an unset one abort rather than silently allow-list "/.claude".
|
|
760
|
+
EVIDENT_RUNNER_KEY="${runner_key}" setsid evident run \
|
|
761
|
+
--port "${OPENCODE_PORT}" \
|
|
762
|
+
--endpoint "${api_url}" \
|
|
763
|
+
--tunnel "${tunnel_url}" \
|
|
764
|
+
--opencode-pid-file "${OPENCODE_PID_FILE}" \
|
|
765
|
+
--litestream-config "${LITESTREAM_CONFIG_FILE}" \
|
|
766
|
+
--litestream-pid-file "${LITESTREAM_PID_FILE}" \
|
|
767
|
+
--session-db-no-replicate-marker "${SESSION_DB_NO_REPLICATE_MARKER}" \
|
|
768
|
+
"${credential_flags[@]}" \
|
|
769
|
+
"${session_db_flags[@]}" \
|
|
770
|
+
"${opencode_config_flags[@]}" \
|
|
771
|
+
--idle-timeout "${IDLE_TIMEOUT_SECONDS}" \
|
|
772
|
+
--enable-file-sync-to "${HOME}/.claude" &
|
|
773
|
+
|
|
774
|
+
echo $! >"${TUNNEL_PID_FILE}"
|
|
775
|
+
log "tunnel started (pid $(cat "${TUNNEL_PID_FILE}"))"
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
# The worst case `evident run` can take to shut down gracefully on SIGTERM, in
|
|
779
|
+
# whole seconds: 25 s in-flight drain (SHUTDOWN_DRAIN_TIMEOUT_MS,
|
|
780
|
+
# apps/cli/src/commands/run.ts) + 2 s offline POST (notifyAgentDisconnected,
|
|
781
|
+
# apps/cli/src/commands/agent-lookup.ts) + 5 s telemetry flush
|
|
782
|
+
# (TELEMETRY_SHUTDOWN_TIMEOUT_MS, run.ts). Each of the three is bounded there, so
|
|
783
|
+
# this is a ceiling rather than a typical cost — an idle suspend finishes in a
|
|
784
|
+
# couple of seconds. This is the ONE place the budget is written down; the CLI
|
|
785
|
+
# only points back here, because restating it in three places produced #657.
|
|
786
|
+
#
|
|
787
|
+
# DOCUMENTATION ONLY — nothing is derived from this any more. The wait below
|
|
788
|
+
# used to be pinned above it, which is the reasoning that talked #699 into 40 s;
|
|
789
|
+
# since #718 an exited CLI is noticed on the first poll, so the wait is a
|
|
790
|
+
# backstop chosen on its own merits and the two numbers are unrelated. Nothing
|
|
791
|
+
# cross-checks the 32 against apps/cli either: it is a hand-maintained sum of the
|
|
792
|
+
# three bounds cited above, so if one of them moves, update it here.
|
|
793
|
+
# shellcheck disable=SC2034 # documentation; deliberately read by nothing
|
|
794
|
+
CLI_SHUTDOWN_CEILING_SECONDS=32
|
|
795
|
+
|
|
796
|
+
# How long stop_tunnel waits for that shutdown before the SIGKILL backstop.
|
|
797
|
+
# Since #718 this binds ONLY for a CLI that is still draining: one that has
|
|
798
|
+
# already exited is detected on the first 0.1 s poll whatever this says, because
|
|
799
|
+
# process_is_alive no longer mistakes its unreaped corpse for a live process.
|
|
800
|
+
# That is what takes a routine suspend from ~41 s back to the ~2 s it measures.
|
|
801
|
+
#
|
|
802
|
+
# So 10 is chosen, not derived, and it sits in the middle of a real range. The
|
|
803
|
+
# measured shutdown was 1255 ms of drain inside a 2322 ms total (#697): 5 s is
|
|
804
|
+
# only ~2.2x that and re-arms #657's complaint of a kill landing mid-drain,
|
|
805
|
+
# while 30 s never truncates a legitimate drain but rebuilds the headroom
|
|
806
|
+
# problem #699 created — 41 s against a 60 s lifecycleTimeout leaves 19 s for
|
|
807
|
+
# everything else in the hook. 10 s is ~4x the measured shutdown and gives a
|
|
808
|
+
# provable worst case of 10 + the 10 s `suspend` spends in sync_credentials
|
|
809
|
+
# before us = 20 s, inside the ~55 s the in-VM server allows this script
|
|
810
|
+
# (DEFAULT_TIMEOUT_SECONDS, aws/lambda-microvm-runtime/src/runtime.ts),
|
|
811
|
+
# itself inside AWS's 60 s (HOOK_TIMEOUT_SECONDS, src/constants.ts) —
|
|
812
|
+
# overrunning that fails the whole lifecycle transition, which is worse than the
|
|
813
|
+
# kill this replaces. The residual cost is a drain longer than 10 s being
|
|
814
|
+
# truncated; that work stays `processing` server-side and is re-adopted on the
|
|
815
|
+
# next start (ADR-0046).
|
|
816
|
+
#
|
|
817
|
+
# src/image/hook-scripts.test.ts holds both that this stays small and that it
|
|
818
|
+
# fits the hook timeout, and times a real SIGKILL against the override to prove
|
|
819
|
+
# the loop HONOURS it rather than a hardcoded deadline. The env override is for
|
|
820
|
+
# tests only, so they need not burn 10 s of wall clock.
|
|
821
|
+
TUNNEL_STOP_WAIT_SECONDS="${EVIDENT_TUNNEL_STOP_WAIT_SECONDS:-10}"
|
|
822
|
+
|
|
823
|
+
stop_tunnel() {
|
|
824
|
+
if ! tunnel_is_running; then
|
|
825
|
+
# Clear the file here too, not only on the path below: `evident run` has
|
|
826
|
+
# self-exit paths that never reach this function (auth expired, idle
|
|
827
|
+
# timeout, a crash), so the pid file routinely outlives the process it
|
|
828
|
+
# names. Leaving it means every later guard re-reads a dead pid — harmless
|
|
829
|
+
# while process_is_alive agrees it is dead, and #718 again the moment
|
|
830
|
+
# anything starts reaping orphans and the pid gets recycled.
|
|
831
|
+
rm -f "${TUNNEL_PID_FILE}"
|
|
832
|
+
log "no tunnel to stop"
|
|
833
|
+
return 0
|
|
834
|
+
fi
|
|
835
|
+
|
|
836
|
+
local pid
|
|
837
|
+
pid="$(cat "${TUNNEL_PID_FILE}")"
|
|
838
|
+
# One drain length for every caller: /suspend and /terminate always run over
|
|
839
|
+
# a connected tunnel, and /run's and /resume's own cleanup traps only reach a
|
|
840
|
+
# live tunnel here when it was never started (the cheap no-op branch above) —
|
|
841
|
+
# there is no path left where a just-spawned, not-yet-connected tunnel is the
|
|
842
|
+
# one being stopped, so there is nothing left to special-case.
|
|
843
|
+
kill -TERM "${pid}" 2>/dev/null || true
|
|
844
|
+
|
|
845
|
+
local tenths=0
|
|
846
|
+
local deadline_tenths=$(( TUNNEL_STOP_WAIT_SECONDS * 10 ))
|
|
847
|
+
while [ "${tenths}" -lt "${deadline_tenths}" ]; do
|
|
848
|
+
process_is_alive "${pid}" || break
|
|
849
|
+
sleep 0.1
|
|
850
|
+
tenths=$(( tenths + 1 ))
|
|
851
|
+
done
|
|
852
|
+
|
|
853
|
+
# This call, not the loop's, is the deciding read: it is what the `if` below
|
|
854
|
+
# branches on, so PROC_DEAD_REASON is fresh by construction. A corpse can
|
|
855
|
+
# still be reaped between the loop's last poll and this call, so the reason
|
|
856
|
+
# named below is what THIS call saw, not necessarily what the loop saw.
|
|
857
|
+
local outcome
|
|
858
|
+
if process_is_alive "${pid}"; then
|
|
859
|
+
warn "tunnel ${pid} ignored SIGTERM; killing"
|
|
860
|
+
kill -KILL "${pid}" 2>/dev/null || true
|
|
861
|
+
outcome="after SIGKILL (ignored SIGTERM for ${TUNNEL_STOP_WAIT_SECONDS}s)"
|
|
862
|
+
else
|
|
863
|
+
outcome="cleanly in $(( tenths / 10 )).$(( tenths % 10 ))s (${PROC_DEAD_REASON})"
|
|
864
|
+
fi
|
|
865
|
+
|
|
866
|
+
rm -f "${TUNNEL_PID_FILE}"
|
|
867
|
+
log "tunnel stopped ${outcome}"
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
# --- check_runner_key (#1172) ------------------------------------------------
|
|
871
|
+
#
|
|
872
|
+
# Answers exactly one question before opencode/the tunnel start spending this
|
|
873
|
+
# boot's SIGTERM budget on a key that cannot work: "does the runner key in this
|
|
874
|
+
# payload authenticate against Evident?" Delegates entirely to the CLI's own
|
|
875
|
+
# `evident status --json` (apps/cli/src/commands/status.ts) rather than
|
|
876
|
+
# reimplementing its auth logic here — that command's `reason` field is the
|
|
877
|
+
# published contract this function reads, and its own header states the
|
|
878
|
+
# absent-vs-contrary distinction this function must honour.
|
|
879
|
+
#
|
|
880
|
+
# Keyed on the JSON `reason`, NEVER on the exit code: an older CLI without a
|
|
881
|
+
# `status` subcommand exits 1 from Commander's own "unknown command" handling,
|
|
882
|
+
# which under an exit-code mapping would read as "the key was rejected" and
|
|
883
|
+
# destroy every VM on a hook/CLI version skew — precisely the hazard this
|
|
884
|
+
# image's README's "version skew" section flags. So no parseable JSON line
|
|
885
|
+
# means no verdict, whatever the exit code says.
|
|
886
|
+
#
|
|
887
|
+
# Returns 0 for positive AND absent evidence (network/timeout/5xx/404/old-CLI/
|
|
888
|
+
# crash) — the caller continues either way — and 1 ONLY for contrary evidence
|
|
889
|
+
# (401, another 4xx, or no credentials resolved at all): the key is actually
|
|
890
|
+
# wrong, not merely untested (development-workflow.mdc's "never fail a gate on
|
|
891
|
+
# absent evidence"). Every branch logs a named, greppable RUNNER-KEY-* signal
|
|
892
|
+
# so a caller that treats the return value as fatal (or doesn't) still leaves
|
|
893
|
+
# a trace of which branch fired.
|
|
894
|
+
#
|
|
895
|
+
# A 404 is deliberately NOT contrary: it means the endpoint has no /me route,
|
|
896
|
+
# so the key was never tested. Treating it as a rejection destroyed every VM
|
|
897
|
+
# for a day when the run payload shipped a bare origin instead of the CLI's
|
|
898
|
+
# /v1-prefixed one.
|
|
899
|
+
check_runner_key() {
|
|
900
|
+
local runner_key="$1" api_url="$2"
|
|
901
|
+
|
|
902
|
+
# status.ts's own exit-code contract (its header comment) means this exits
|
|
903
|
+
# non-zero on EVERY branch except `ok`; guard the capture so an expected
|
|
904
|
+
# non-zero does not abort this function under the caller's `set -e` before
|
|
905
|
+
# the case below ever runs. Only stdout is
|
|
906
|
+
# captured: status.ts's own contract is one parseable JSON line and nothing
|
|
907
|
+
# else there, and — like litestream's stderr elsewhere in this file — its
|
|
908
|
+
# stderr is left to reach CloudWatch directly rather than being folded in,
|
|
909
|
+
# since a stray stderr line ahead of the JSON would otherwise break `jq`.
|
|
910
|
+
local response rc=0
|
|
911
|
+
response="$(EVIDENT_API_URL="${api_url}" EVIDENT_RUNNER_KEY="${runner_key}" evident status --json)" || rc=$?
|
|
912
|
+
|
|
913
|
+
# `-z` as well as jq's own exit code, and not as belt-and-braces: on EMPTY
|
|
914
|
+
# input jq has no value to report on, so `jq -e` exits 0 with empty output
|
|
915
|
+
# rather than its documented 4 (verified on jq 1.6, the image's). Empty
|
|
916
|
+
# stdout is exactly the old-CLI-skew and missing-binary shape this branch
|
|
917
|
+
# exists for, so keying only on the exit code sent precisely those cases to
|
|
918
|
+
# the unrecognised-reason arm below — same safe return, but a log line
|
|
919
|
+
# blaming the API for an odd answer instead of naming the CLI skew, which is
|
|
920
|
+
# the one thing an operator needs told here.
|
|
921
|
+
local reason
|
|
922
|
+
reason="$(printf '%s' "${response}" | jq -er '.reason' 2>/dev/null)" || reason=""
|
|
923
|
+
if [ -z "${reason}" ]; then
|
|
924
|
+
warn "RUNNER-KEY-UNVERIFIABLE: 'evident status --json' exited ${rc} with no parseable JSON on stdout (an old CLI without this subcommand, a missing binary, or a crash — see its stderr above, if any); the key was NOT validated, continuing anyway"
|
|
925
|
+
return 0
|
|
926
|
+
fi
|
|
927
|
+
local detail
|
|
928
|
+
detail="$(printf '%s' "${response}" | jq -er '.error // empty' 2>/dev/null)" || detail=""
|
|
929
|
+
|
|
930
|
+
case "${reason}" in
|
|
931
|
+
ok)
|
|
932
|
+
log "RUNNER-KEY-OK: evident status confirmed the runner key against ${api_url}"
|
|
933
|
+
return 0
|
|
934
|
+
;;
|
|
935
|
+
unauthorized | no_credentials | http_error)
|
|
936
|
+
error "RUNNER-KEY-REJECTED: evident status reason=${reason} against ${api_url}${detail:+: ${detail}}"
|
|
937
|
+
return 1
|
|
938
|
+
;;
|
|
939
|
+
unreachable)
|
|
940
|
+
warn "RUNNER-KEY-UNREACHABLE: could not reach ${api_url} to validate the runner key; the key was NOT validated, continuing anyway"
|
|
941
|
+
return 0
|
|
942
|
+
;;
|
|
943
|
+
endpoint_not_found)
|
|
944
|
+
# Absent evidence, NOT contrary: a 404 means this endpoint has no /me
|
|
945
|
+
# route (typically a bare origin where the CLI wants the /v1-prefixed
|
|
946
|
+
# one), which says nothing about the key. Loud, because the runner will
|
|
947
|
+
# keep failing every API call until the endpoint is fixed.
|
|
948
|
+
warn "RUNNER-KEY-ENDPOINT-NOT-FOUND: ${api_url} has no /me route${detail:+: ${detail}}; the key was NOT validated, continuing anyway"
|
|
949
|
+
return 0
|
|
950
|
+
;;
|
|
951
|
+
*)
|
|
952
|
+
warn "RUNNER-KEY-UNKNOWN-REASON: evident status returned an unrecognised reason='${reason}'; the key was NOT validated, continuing anyway"
|
|
953
|
+
return 0
|
|
954
|
+
;;
|
|
955
|
+
esac
|
|
956
|
+
}
|
|
957
|
+
# --- check_runner_key (end) ---------------------------------------------------
|