@critical-labs/qa-conductor 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +445 -2
- package/bin/qa-conductor-expose.mjs +113 -0
- package/lib/adapters/build-worktree.mjs +532 -0
- package/lib/adapters/exposure-tailscale.mjs +136 -0
- package/lib/adapters/provisioner-docker.mjs +116 -0
- package/lib/adapters/provisioner-process.mjs +653 -0
- package/lib/config.mjs +245 -0
- package/lib/docker.mjs +233 -0
- package/lib/exec.mjs +23 -0
- package/lib/exposure.mjs +193 -0
- package/lib/github.mjs +218 -0
- package/lib/identity.mjs +51 -0
- package/lib/net.mjs +56 -0
- package/lib/proxy.mjs +327 -0
- package/lib/request-guard.mjs +95 -0
- package/lib/server.mjs +721 -0
- package/lib/session.mjs +250 -0
- package/lib/verdict.mjs +33 -0
- package/package.json +47 -4
- package/public/bridge.js +324 -0
- package/public/harness.js +542 -0
- package/public/index.html +284 -0
package/lib/server.mjs
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
// PR-QA conductor core: harness UI/API + pane proxies, composed over the five
|
|
2
|
+
// adapter seams. This module owns NO app, infra or registry policy: a
|
|
3
|
+
// consuming platform constructs {cfg, github, fsx, adapters, readBaseEnv} and
|
|
4
|
+
// calls startConductor. Containers, env files and log tails belong to the
|
|
5
|
+
// Provisioner; PR readiness belongs to the BuildConvention (describePrs);
|
|
6
|
+
// origins, ports and verdict labels are config. `github` is used for the PR
|
|
7
|
+
// list, PR head/trust lookups and the verdict comment + label.
|
|
8
|
+
//
|
|
9
|
+
// Browser-facing hardening (defence in depth, not a trust boundary):
|
|
10
|
+
// - in tailscale mode (cfg.exposure, else derived as loadConfig does), all
|
|
11
|
+
// three servers answer only Tailscale-User-Login values in
|
|
12
|
+
// cfg.allowedLogins (403 before anything else runs) and must listen on
|
|
13
|
+
// loopback, so tailscale serve on this host is the only way in;
|
|
14
|
+
// - all three servers listen on cfg.host (loopback by default) and answer 421
|
|
15
|
+
// to a Host header outside the allowlist, so a DNS-rebound page can't reach
|
|
16
|
+
// them;
|
|
17
|
+
// - every /api/* request must come from the harness page itself, at the
|
|
18
|
+
// harness origin's host:port (403), and POST bodies must be JSON (415), so
|
|
19
|
+
// another page in the reviewer's browser can't drive or read the API;
|
|
20
|
+
// - no other page may frame the harness, only the harness origin (plus
|
|
21
|
+
// cfg.frameAncestors) may frame a pane, and the pane proxies refuse other
|
|
22
|
+
// pages' requests (lib/request-guard.mjs).
|
|
23
|
+
//
|
|
24
|
+
// The harness origin is cfg.harnessOrigin, else derived as loadConfig does:
|
|
25
|
+
// :8444 on cfg.publicHost, else the loopback address the harness listens on
|
|
26
|
+
// (per request, so a harness on port 0 uses its bound port).
|
|
27
|
+
//
|
|
28
|
+
// An optional sixth adapter, adapters.exposure (lib/exposure.mjs), lets the
|
|
29
|
+
// conductor publish itself: a reconcile loop keeps its three mounts on the
|
|
30
|
+
// front door, and GET /api/exposure reports the last pass. Only a gated
|
|
31
|
+
// conductor may have one.
|
|
32
|
+
import http from 'node:http'
|
|
33
|
+
import { fileURLToPath } from 'node:url'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
defaultExposure, defaultHarnessOrigin, EXPOSURE_INTERVAL_RULE, EXPOSURE_MODES, HARNESS_PATH, isExposureInterval,
|
|
38
|
+
} from './config.mjs'
|
|
39
|
+
import { mountsFor, reconcileExposure } from './exposure.mjs'
|
|
40
|
+
import { identityGate, normalizeLogins } from './identity.mjs'
|
|
41
|
+
import { hostPort, isLoopbackHost, requireFetchMetadata, webOrigin } from './net.mjs'
|
|
42
|
+
import { createPaneProxy, isAllowedHost, misdirected, panePolicy } from './proxy.mjs'
|
|
43
|
+
import { isHarnessHost, isSameOriginRequest } from './request-guard.mjs'
|
|
44
|
+
import { formatVerdict } from './verdict.mjs'
|
|
45
|
+
import {
|
|
46
|
+
createSession, reduce, touch, isIdle, bootSession, teardownSession, PANE_STAGES,
|
|
47
|
+
} from './session.mjs'
|
|
48
|
+
|
|
49
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
50
|
+
const DEFAULT_PUBLIC_DIR = path.join(HERE, '..', 'public')
|
|
51
|
+
const DEFAULT_HOST = '127.0.0.1'
|
|
52
|
+
// Harness POST routes whose body must be JSON. A cross-site HTML form can't
|
|
53
|
+
// send that type, and a cross-site fetch() with it needs a CORS preflight.
|
|
54
|
+
const JSON_ROUTES = new Set(['/api/session', '/api/verdict', '/api/teardown'])
|
|
55
|
+
// The harness page starts sessions and posts verdicts in one click, so no
|
|
56
|
+
// other page may frame it (clickjacking); a browser that ignores
|
|
57
|
+
// frame-ancestors still honours X-Frame-Options. The Referrer-Policy pins
|
|
58
|
+
// what the panes' Referer check relies on: the harness origin, sent on its
|
|
59
|
+
// iframe loads and new tabs whatever the browser's default.
|
|
60
|
+
const HARNESS_FRAME_HEADERS = {
|
|
61
|
+
'content-security-policy': "frame-ancestors 'self'",
|
|
62
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
63
|
+
'referrer-policy': 'strict-origin-when-cross-origin',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isJsonRequest(headers) {
|
|
67
|
+
return String(headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() === 'application/json'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// cfg.host, the address all three servers listen on. listen() takes an IPv6
|
|
71
|
+
// literal bare (::1); in brackets it would pass the loopback check below,
|
|
72
|
+
// then fail to listen and take the process down. loadConfig unwraps it.
|
|
73
|
+
function startHost(cfg) {
|
|
74
|
+
const host = cfg.host ?? DEFAULT_HOST
|
|
75
|
+
if (/^\[.*\]$/.test(host)) {
|
|
76
|
+
throw new Error(`startConductor: cfg.host is a listen address, which takes an IPv6 literal without brackets: use ${JSON.stringify(host.slice(1, -1))}, not ${JSON.stringify(host)}`)
|
|
77
|
+
}
|
|
78
|
+
return host
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The harness origin as configured, or as derivable at start, after checking
|
|
82
|
+
// the origins in cfg. Throws on what would otherwise fail open: a harness
|
|
83
|
+
// origin or extra frame ancestor that isn't an http(s) origin, a harness or
|
|
84
|
+
// pane origin set at start that browsers send no Fetch Metadata to (plain
|
|
85
|
+
// http off loopback: the request guards would take other pages for curl),
|
|
86
|
+
// and a harness origin equal to a pane origin (PR code would be same-origin
|
|
87
|
+
// with the harness). Equal pane origins are left to loadConfig: the demo's
|
|
88
|
+
// placeholders are equal until its proxies listen.
|
|
89
|
+
function startHarnessOrigin(cfg, host) {
|
|
90
|
+
const configured = cfg.harnessOrigin == null ? null : webOrigin(cfg.harnessOrigin, 'cfg.harnessOrigin')
|
|
91
|
+
for (const value of [].concat(cfg.frameAncestors ?? [])) webOrigin(value, 'cfg.frameAncestors')
|
|
92
|
+
// A derived origin is https or loopback.
|
|
93
|
+
if (configured !== null) requireFetchMetadata(configured, 'cfg.harnessOrigin', 'harness API guard')
|
|
94
|
+
const origin = configured ?? defaultHarnessOrigin({ publicHost: cfg.publicHost, host, port: cfg.ports.harness })
|
|
95
|
+
for (const [role, value] of Object.entries(cfg.paneOrigins ?? {})) {
|
|
96
|
+
let pane
|
|
97
|
+
try { pane = webOrigin(value) } catch { continue } // not an origin yet: nothing to check
|
|
98
|
+
requireFetchMetadata(pane, `cfg.paneOrigins.${role}`, 'pane request guard')
|
|
99
|
+
if (pane === origin) {
|
|
100
|
+
throw new Error(`startConductor: the harness origin ${origin} is also the ${role} pane's origin; the harness and each pane need an origin of their own`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return origin
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// cfg.exposure, else the default loadConfig would give this layout, fixed at
|
|
107
|
+
// start: a cfg whose non-loopback origins arrive later must set it. Throws on
|
|
108
|
+
// an unknown mode, and on tailscale mode off loopback, where anything that
|
|
109
|
+
// reaches the port could send its own Tailscale-User-Login. `because` names
|
|
110
|
+
// what made a defaulted mode tailscale, for the startup log; else null.
|
|
111
|
+
function startExposure(cfg, host, startOrigin) {
|
|
112
|
+
const fallback = defaultExposure({
|
|
113
|
+
harnessOrigin: startOrigin, paneOrigins: cfg.paneOrigins,
|
|
114
|
+
publicHost: cfg.publicHost, allowedHosts: cfg.allowedHosts ?? [], host,
|
|
115
|
+
})
|
|
116
|
+
const mode = cfg.exposure ?? fallback.mode
|
|
117
|
+
if (!EXPOSURE_MODES.includes(mode)) {
|
|
118
|
+
throw new Error(`startConductor: cfg.exposure must be 'none' or 'tailscale', got ${JSON.stringify(mode)}`)
|
|
119
|
+
}
|
|
120
|
+
if (mode === 'tailscale' && !isLoopbackHost(host)) {
|
|
121
|
+
const why = cfg.exposure == null ? `exposure defaults to tailscale because ${fallback.because} is not loopback` : "cfg.exposure is 'tailscale'"
|
|
122
|
+
throw new Error(
|
|
123
|
+
`startConductor: ${why}, so cfg.host must be a loopback address: tailscale serve on this host is the only supported front ` +
|
|
124
|
+
`(got ${JSON.stringify(host)}), or set exposure: 'none' if another front door authenticates`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return { mode, because: cfg.exposure == null ? fallback.because : null }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// adapters.exposure, if any, and the milliseconds between its passes. Only a
|
|
131
|
+
// gated conductor may publish itself: in none mode, anyone a mount reaches
|
|
132
|
+
// would reach the API and the panes. The interval is held to loadConfig's
|
|
133
|
+
// rule, so a code-built cfg can't make the loop fire every 1 ms (above
|
|
134
|
+
// 2^31-1 ms, setInterval does).
|
|
135
|
+
function startExposureAdapter(adapters, mode, cfg) {
|
|
136
|
+
const adapter = adapters.exposure ?? null
|
|
137
|
+
if (adapter !== null && mode !== 'tailscale') {
|
|
138
|
+
throw new Error('adapters.exposure needs QA_EXPOSURE=tailscale: an ungated conductor must not publish itself')
|
|
139
|
+
}
|
|
140
|
+
const minutes = cfg.exposureIntervalMinutes ?? 5
|
|
141
|
+
if (!isExposureInterval(minutes)) {
|
|
142
|
+
const shown = typeof minutes === 'string' ? JSON.stringify(minutes) : String(minutes)
|
|
143
|
+
throw new Error(`startConductor: cfg.exposureIntervalMinutes ${EXPOSURE_INTERVAL_RULE}, got ${shown}`)
|
|
144
|
+
}
|
|
145
|
+
return { adapter, intervalMs: minutes * 60_000 }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A Mount as plain data. The adapter is platform code, so what it reports is
|
|
149
|
+
// copied before /api/exposure serializes it or state() clones it, keeping
|
|
150
|
+
// only the Mount type's own values: a string, or an integer port. Anything
|
|
151
|
+
// else (a URL object as the target, a function, a Symbol) becomes null, since
|
|
152
|
+
// structuredClone throws on some of those and JSON drops or garbles others.
|
|
153
|
+
const stringOrNull = v => (typeof v === 'string' ? v : null)
|
|
154
|
+
const plainMount = m => ({
|
|
155
|
+
name: stringOrNull(m?.name), host: stringOrNull(m?.host), port: Number.isInteger(m?.port) ? m.port : null,
|
|
156
|
+
path: stringOrNull(m?.path), target: stringOrNull(m?.target),
|
|
157
|
+
})
|
|
158
|
+
const mountKey = m => `${m.port}${m.path} -> ${m.target}`
|
|
159
|
+
|
|
160
|
+
export function startConductor({ cfg, github, fsx, adapters, readBaseEnv, publicDir = DEFAULT_PUBLIC_DIR, log = console }) {
|
|
161
|
+
const host = startHost(cfg)
|
|
162
|
+
const startOrigin = startHarnessOrigin(cfg, host)
|
|
163
|
+
const { mode: exposure, because } = startExposure(cfg, host, startOrigin)
|
|
164
|
+
const { adapter: exposureAdapter, intervalMs: exposureIntervalMs } = startExposureAdapter(adapters, exposure, cfg)
|
|
165
|
+
// In tailscale mode every server answers only these logins
|
|
166
|
+
// (lib/identity.mjs); an empty list refuses everyone. The gate wraps each
|
|
167
|
+
// whole server handler, so no route runs for an unidentified request.
|
|
168
|
+
// Nothing registers an 'upgrade' listener, so upgrades reach it too.
|
|
169
|
+
const allowedLogins = normalizeLogins(cfg.allowedLogins)
|
|
170
|
+
const gate = exposure === 'tailscale' ? handler => identityGate(handler, allowedLogins) : handler => handler
|
|
171
|
+
if (exposure === 'tailscale') {
|
|
172
|
+
// A cfg that names no mode says what gated it: a pane origin left unset
|
|
173
|
+
// at start counts as off loopback, which a code-built cfg may not expect.
|
|
174
|
+
const why = because ? ` (exposure defaults to tailscale because ${because} is not loopback)` : ''
|
|
175
|
+
const n = allowedLogins.length
|
|
176
|
+
log.log(`[qa] identity gate on${why}: ${n} allowed login${n === 1 ? '' : 's'}`)
|
|
177
|
+
if (n === 0) {
|
|
178
|
+
log.error("[qa] QA_ALLOWED_LOGINS is empty: every request will be refused; set cfg.allowedLogins, or exposure: 'none' if another front door authenticates")
|
|
179
|
+
}
|
|
180
|
+
// Gated, but published by someone else (homefree's apply script, say).
|
|
181
|
+
if (exposureAdapter === null) log.log('[qa] exposure: tailscale serve mounts are managed outside the conductor')
|
|
182
|
+
} else {
|
|
183
|
+
log.log('[qa] identity gate off (QA_EXPOSURE=none)')
|
|
184
|
+
}
|
|
185
|
+
let session = createSession()
|
|
186
|
+
let buildRun = null
|
|
187
|
+
// AbortController for the in-flight boot. Teardown/takeover abort it so a
|
|
188
|
+
// stale boot can never write state or tear down a newer session's panes.
|
|
189
|
+
let bootAbort = null
|
|
190
|
+
// Each pane's reserved primary-service port for the ready session; the pane
|
|
191
|
+
// proxies route to it and answer 503 while it is null.
|
|
192
|
+
let upstreams = { base: null, pr: null }
|
|
193
|
+
const sseClients = new Set()
|
|
194
|
+
const progressLog = []
|
|
195
|
+
// Set by shutdown(): writes are refused, no boot starts, and a server whose
|
|
196
|
+
// listen completes afterwards closes at once.
|
|
197
|
+
let closing = false
|
|
198
|
+
|
|
199
|
+
// Remove orphans a previous conductor run left behind (e.g. a deploy restarted
|
|
200
|
+
// the conductor mid-session), if the Provisioner can. Boots wait for it: a
|
|
201
|
+
// session started while the sweep is still running would otherwise collide
|
|
202
|
+
// with the orphans' fixed names, or have its fresh containers and network
|
|
203
|
+
// removed by the sweep. Never rejects (a sweep that throws synchronously, or
|
|
204
|
+
// rejects with a non-Error, included), so a failed sweep doesn't block boots.
|
|
205
|
+
// No timeout.
|
|
206
|
+
let sweeping = typeof adapters.provisioner.sweep === 'function'
|
|
207
|
+
const startupSweep = sweeping
|
|
208
|
+
? (async () => adapters.provisioner.sweep())()
|
|
209
|
+
.then(() => log.log('[qa] startup sweep complete'))
|
|
210
|
+
.catch(err => log.error('[qa] startup sweep failed:', err?.message ?? String(err)))
|
|
211
|
+
.finally(() => { sweeping = false })
|
|
212
|
+
: Promise.resolve()
|
|
213
|
+
|
|
214
|
+
function broadcast(event) {
|
|
215
|
+
const stamped = { at: Date.now(), ...event }
|
|
216
|
+
progressLog.push(stamped)
|
|
217
|
+
const line = `data: ${JSON.stringify(stamped)}\n\n`
|
|
218
|
+
for (const res of sseClients) res.write(line)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The origin viewers open the harness at. Without one at start, a loopback
|
|
222
|
+
// harness derives it from its bound port once listening; until then, and off
|
|
223
|
+
// loopback with no public host, it is null.
|
|
224
|
+
function harnessOrigin() {
|
|
225
|
+
if (startOrigin !== null) return startOrigin
|
|
226
|
+
return defaultHarnessOrigin({ publicHost: null, host, port: harness.listening ? harness.address().port : 0 })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Hostnames (beyond loopback) the servers answer to. Read per request: a
|
|
230
|
+
// platform may assign cfg.paneOrigins after the proxies are listening.
|
|
231
|
+
function allowedHostnames() {
|
|
232
|
+
const out = []
|
|
233
|
+
if (cfg.publicHost) out.push(cfg.publicHost)
|
|
234
|
+
const harnessAt = harnessOrigin()
|
|
235
|
+
if (harnessAt) out.push(new URL(harnessAt).hostname)
|
|
236
|
+
for (const origin of Object.values(cfg.paneOrigins ?? {})) {
|
|
237
|
+
try { out.push(new URL(origin).hostname) } catch { /* not a URL: contributes nothing */ }
|
|
238
|
+
}
|
|
239
|
+
return out.concat(cfg.allowedHosts ?? [])
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// loginUrls are per-pane SessionResults from the AuthBootstrap adapter; the
|
|
243
|
+
// landingUrl carries the magic-link token (homefree's degenerate case — no
|
|
244
|
+
// cookies/replay needed by the proxy yet).
|
|
245
|
+
function paneUrls(loginUrls) {
|
|
246
|
+
return {
|
|
247
|
+
base: loginUrls.base.landingUrl,
|
|
248
|
+
pr: loginUrls.pr.landingUrl,
|
|
249
|
+
baseOrigin: cfg.paneOrigins.base,
|
|
250
|
+
prOrigin: cfg.paneOrigins.pr,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// `lastStep` is the step this boot already broadcast (startBoot sends
|
|
255
|
+
// ensuring-image before the sweep wait). A repeat isn't sent again: the
|
|
256
|
+
// harness times each step, and the boot, from its step event's `at`.
|
|
257
|
+
function sessionDeps(signal, lastStep = null) {
|
|
258
|
+
return {
|
|
259
|
+
signal,
|
|
260
|
+
adapters,
|
|
261
|
+
readBaseEnv,
|
|
262
|
+
env: { operatorEmail: cfg.operatorEmail, paneOrigins: cfg.paneOrigins },
|
|
263
|
+
onProgress: step => {
|
|
264
|
+
if (signal.aborted) return
|
|
265
|
+
session = reduce(session, { type: 'step', step })
|
|
266
|
+
if (step === lastStep) return
|
|
267
|
+
lastStep = step
|
|
268
|
+
broadcast({ kind: 'step', step })
|
|
269
|
+
},
|
|
270
|
+
// Build progress: a CI run (runUrl/runStatus) and/or a plain-text message.
|
|
271
|
+
onBuild: ({ runUrl = null, runStatus = null, message = null } = {}) => {
|
|
272
|
+
if (signal.aborted) return
|
|
273
|
+
buildRun = { url: runUrl, status: runStatus, message }
|
|
274
|
+
broadcast({ kind: 'build', runUrl, runStatus, message })
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function startBoot(pr) {
|
|
280
|
+
if (closing) return
|
|
281
|
+
bootAbort?.abort()
|
|
282
|
+
const ac = new AbortController()
|
|
283
|
+
bootAbort = ac
|
|
284
|
+
progressLog.length = 0
|
|
285
|
+
buildRun = null
|
|
286
|
+
broadcast({ kind: 'step', step: 'ensuring-image' })
|
|
287
|
+
const deps = sessionDeps(ac.signal, 'ensuring-image')
|
|
288
|
+
try {
|
|
289
|
+
// Say so while the startup sweep holds the boot back, and once it's done,
|
|
290
|
+
// so the message isn't left standing while the build runs.
|
|
291
|
+
const waited = sweeping
|
|
292
|
+
if (waited) deps.onBuild({ message: 'waiting for startup cleanup…' })
|
|
293
|
+
await startupSweep
|
|
294
|
+
if (ac.signal.aborted) return // torn down or taken over while waiting
|
|
295
|
+
if (waited) deps.onBuild({ message: 'startup cleanup done' })
|
|
296
|
+
const out = await bootSession(deps, pr)
|
|
297
|
+
if (ac.signal.aborted) return // superseded: result belongs to no session
|
|
298
|
+
session = reduce(session, { type: 'tags', baseTag: out.baseTag, prTag: out.prTag })
|
|
299
|
+
session = reduce(session, { type: 'ready', now: Date.now(), tokens: out.loginUrls })
|
|
300
|
+
upstreams = { base: out.upstreams?.base ?? null, pr: out.upstreams?.pr ?? null }
|
|
301
|
+
broadcast({ kind: 'ready', pr, panes: paneUrls(out.loginUrls), baseTag: out.baseTag, prTag: out.prTag })
|
|
302
|
+
} catch (err) {
|
|
303
|
+
if (ac.signal.aborted) return // superseded: must not touch the current session
|
|
304
|
+
const step = session.status
|
|
305
|
+
session = reduce(session, { type: 'error', step, message: String(err.message ?? err) })
|
|
306
|
+
// bootSession read the failing pane's tail before tearing down; a
|
|
307
|
+
// BuildConvention may attach one too. Else ask the Provisioner now.
|
|
308
|
+
const logTail = typeof err?.logTail === 'string' ? err.logTail : await tailForStep(step).catch(() => '')
|
|
309
|
+
broadcast({ kind: 'error', step, message: session.error?.message, logTail, runUrl: buildRun?.url ?? null })
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Best-effort log tail for a failed pane stage, if the Provisioner offers one.
|
|
314
|
+
// async so a synchronous logs() (return or throw) still yields a promise.
|
|
315
|
+
async function tailForStep(step) {
|
|
316
|
+
if (!PANE_STAGES.includes(step) || typeof adapters.provisioner.logs !== 'function') return ''
|
|
317
|
+
const tail = await adapters.provisioner.logs({ paneRef: { role: 'pr' }, stage: step, lines: 40 })
|
|
318
|
+
return typeof tail === 'string' ? tail : ''
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function doTeardown() {
|
|
322
|
+
bootAbort?.abort()
|
|
323
|
+
bootAbort = null
|
|
324
|
+
upstreams = { base: null, pr: null }
|
|
325
|
+
session = reduce(session, { type: 'teardown' })
|
|
326
|
+
await teardownSession({ provisioner: adapters.provisioner }).catch(() => {})
|
|
327
|
+
session = reduce(session, { type: 'torn-down' })
|
|
328
|
+
buildRun = null
|
|
329
|
+
broadcast({ kind: 'torn-down' })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function sessionSummary() {
|
|
333
|
+
return { pr: session.pr, status: session.status, startedAt: session.startedAt, lastActivity: session.lastActivity }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function readBody(req) {
|
|
337
|
+
const chunks = []
|
|
338
|
+
for await (const c of req) chunks.push(c)
|
|
339
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } catch { return {} }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function json(res, status, body) {
|
|
343
|
+
const buf = JSON.stringify(body)
|
|
344
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(buf) })
|
|
345
|
+
res.end(buf)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function serveStatic(res, file, type) {
|
|
349
|
+
try {
|
|
350
|
+
const buf = await fsx.readFile(path.join(publicDir, file))
|
|
351
|
+
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' })
|
|
352
|
+
res.end(buf)
|
|
353
|
+
} catch {
|
|
354
|
+
res.writeHead(404).end('not found')
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Build readiness per PR from the BuildConvention, if it can describe it.
|
|
359
|
+
// Failures and a missing describePrs degrade to 'none' (the picker still works).
|
|
360
|
+
async function describePrs(prs) {
|
|
361
|
+
if (typeof adapters.build.describePrs !== 'function') return new Map()
|
|
362
|
+
try {
|
|
363
|
+
return new Map((await adapters.build.describePrs(prs)).map(d => [d.number, d]))
|
|
364
|
+
} catch {
|
|
365
|
+
return new Map()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The open-PR list, enriched with build readiness. `reason` explains a
|
|
370
|
+
// 'blocked' status (e.g. an untrusted head) in plain text.
|
|
371
|
+
async function enrichedPrs() {
|
|
372
|
+
const prs = await github.listOpenPrs()
|
|
373
|
+
const readiness = await describePrs(prs)
|
|
374
|
+
return prs.map(pr => ({
|
|
375
|
+
number: pr.number, title: pr.title, headRef: pr.headRef, author: pr.author,
|
|
376
|
+
imageStatus: readiness.get(pr.number)?.status ?? 'none',
|
|
377
|
+
runUrl: readiness.get(pr.number)?.runUrl ?? null,
|
|
378
|
+
reason: readiness.get(pr.number)?.reason ?? null,
|
|
379
|
+
}))
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The describePrs item for one PR: with trust data when github can give it.
|
|
383
|
+
async function prForDescribe(pr) {
|
|
384
|
+
if (typeof github.prInfo !== 'function') return { number: pr, headSha: await github.prHead(pr) }
|
|
385
|
+
const { headSha, author, authorAssociation, headRepo, headOwner } = await github.prInfo(pr)
|
|
386
|
+
return { number: pr, headSha, author, authorAssociation, headRepo, headOwner }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Refusals that apply before any harness route runs (the Host check runs
|
|
390
|
+
// earlier still, before the URL is parsed). Returns true when the request was
|
|
391
|
+
// answered.
|
|
392
|
+
function refused(req, res, p) {
|
|
393
|
+
// Every API request, reads and the event stream too, must come from the
|
|
394
|
+
// harness page itself: another page can't read the answers, but its writes
|
|
395
|
+
// would run, and each /api/prs or /api/build-status spends GitHub API calls
|
|
396
|
+
// on the conductor's token. The page itself (/ and /harness.js) stays open
|
|
397
|
+
// to any navigation.
|
|
398
|
+
if (p.startsWith('/api/')) {
|
|
399
|
+
if (!isSameOriginRequest(req.headers)) {
|
|
400
|
+
json(res, 403, { error: 'cross-site request refused' })
|
|
401
|
+
return true
|
|
402
|
+
}
|
|
403
|
+
// The harness page shows its banner from harnessOrigin. No other page can
|
|
404
|
+
// read the answer, and the origin is no secret: every pane sends it.
|
|
405
|
+
if (!isHarnessHost(req.headers, harnessOrigin())) {
|
|
406
|
+
json(res, 403, { error: 'not the harness origin', harnessOrigin: harnessOrigin() })
|
|
407
|
+
return true
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const write = req.method !== 'GET' && req.method !== 'HEAD'
|
|
411
|
+
// A write accepted during shutdown could start a boot nothing tears down.
|
|
412
|
+
if (write && closing) {
|
|
413
|
+
json(res, 503, { error: 'shutting down' })
|
|
414
|
+
return true
|
|
415
|
+
}
|
|
416
|
+
if (req.method === 'POST' && JSON_ROUTES.has(p) && !isJsonRequest(req.headers)) {
|
|
417
|
+
json(res, 415, { error: 'content-type must be application/json' })
|
|
418
|
+
return true
|
|
419
|
+
}
|
|
420
|
+
return false
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Set before any handler runs, so every harness response carries them: the
|
|
424
|
+
// identity gate's 403s, the 421s and other 403s, errors and the event stream.
|
|
425
|
+
// No harness response sets its own Content-Security-Policy to merge with.
|
|
426
|
+
const harnessHeaders = handler => (req, res) => {
|
|
427
|
+
for (const [name, value] of Object.entries(HARNESS_FRAME_HEADERS)) res.setHeader(name, value)
|
|
428
|
+
return handler(req, res)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Likewise for each pane, so a response the proxy doesn't make itself (the
|
|
432
|
+
// identity gate's 403) still carries the pane's frame policy. The proxy's own
|
|
433
|
+
// responses send the policy through writeHead, whose headers take precedence.
|
|
434
|
+
const paneHeaders = handler => (req, res) => {
|
|
435
|
+
res.setHeader('content-security-policy', panePolicy(harnessOrigin(), cfg.frameAncestors ?? []))
|
|
436
|
+
res.setHeader('x-content-type-options', 'nosniff')
|
|
437
|
+
return handler(req, res)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const harness = http.createServer(harnessHeaders(gate(async (req, res) => {
|
|
441
|
+
if (!isAllowedHost(req.headers.host, allowedHostnames())) return misdirected(res)
|
|
442
|
+
// A request target like `//x:99999` is not a parseable URL; left uncaught it
|
|
443
|
+
// rejects this async handler and takes the process down.
|
|
444
|
+
let url
|
|
445
|
+
try { url = new URL(req.url, 'http://x') } catch { return json(res, 400, { error: 'bad request target' }) }
|
|
446
|
+
// tailscale serve --set-path may or may not strip the /qa prefix; accept both.
|
|
447
|
+
const p = url.pathname.replace(/^\/qa(?=\/|$)/, '') || '/'
|
|
448
|
+
try {
|
|
449
|
+
if (refused(req, res, p)) return
|
|
450
|
+
if (req.method === 'GET' && p === '/') return await serveStatic(res, 'index.html', 'text/html; charset=utf-8')
|
|
451
|
+
if (req.method === 'GET' && p === '/harness.js') return await serveStatic(res, 'harness.js', 'text/javascript')
|
|
452
|
+
if (req.method === 'GET' && p === '/api/state') {
|
|
453
|
+
return json(res, 200, {
|
|
454
|
+
status: session.status, pr: session.pr, error: session.error,
|
|
455
|
+
baseTag: session.baseTag, prTag: session.prTag,
|
|
456
|
+
startedAt: session.startedAt, lastActivity: session.lastActivity, idleMinutes: cfg.idleMinutes,
|
|
457
|
+
buildRun,
|
|
458
|
+
panes: session.status === 'ready' && session.tokens ? paneUrls(session.tokens) : null,
|
|
459
|
+
harnessOrigin: harnessOrigin(),
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
if (req.method === 'GET' && p === '/api/prs') {
|
|
463
|
+
const prs = await enrichedPrs()
|
|
464
|
+
return json(res, 200, { prs, session: sessionSummary() })
|
|
465
|
+
}
|
|
466
|
+
// The last reconcile pass, as recorded: it never calls the front door.
|
|
467
|
+
if (req.method === 'GET' && p === '/api/exposure') return json(res, 200, exposureView())
|
|
468
|
+
if (req.method === 'GET' && p === '/api/build-status') {
|
|
469
|
+
const pr = Number(url.searchParams.get('pr'))
|
|
470
|
+
if (!Number.isInteger(pr)) return json(res, 400, { error: 'pr required' })
|
|
471
|
+
let d = null
|
|
472
|
+
try {
|
|
473
|
+
d = (await describePrs([await prForDescribe(pr)])).get(pr) ?? null
|
|
474
|
+
} catch { /* return what we have */ }
|
|
475
|
+
const status = d?.status ?? 'none'
|
|
476
|
+
const body = { pr, status, exists: status === 'built', runUrl: d?.runUrl ?? null }
|
|
477
|
+
// Only a blocked status adds a field, so the shape is otherwise unchanged.
|
|
478
|
+
if (status === 'blocked') body.reason = String(d.reason ?? '')
|
|
479
|
+
return json(res, 200, body)
|
|
480
|
+
}
|
|
481
|
+
if (req.method === 'GET' && p === '/api/progress') {
|
|
482
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' })
|
|
483
|
+
for (const e of progressLog) res.write(`data: ${JSON.stringify(e)}\n\n`)
|
|
484
|
+
sseClients.add(res)
|
|
485
|
+
req.on('close', () => sseClients.delete(res))
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
if (req.method === 'POST' && p === '/api/session') {
|
|
489
|
+
const { pr, takeover } = await readBody(req)
|
|
490
|
+
if (!Number.isInteger(pr)) return json(res, 400, { error: 'pr (number) required' })
|
|
491
|
+
const active = session.status !== 'idle' && session.status !== 'error'
|
|
492
|
+
if (active && session.pr !== pr && !takeover) {
|
|
493
|
+
return json(res, 409, { error: `session already active (pr #${session.pr}, ${session.status})`, session: sessionSummary() })
|
|
494
|
+
}
|
|
495
|
+
if (active && takeover) await doTeardown()
|
|
496
|
+
// shutdown() may have begun while the takeover teardown ran
|
|
497
|
+
if (closing) return json(res, 503, { error: 'shutting down' })
|
|
498
|
+
session = reduce(session, { type: 'open', pr })
|
|
499
|
+
startBoot(pr)
|
|
500
|
+
return json(res, 202, { ok: true })
|
|
501
|
+
}
|
|
502
|
+
if (req.method === 'GET' && p === '/api/verdict/preview') {
|
|
503
|
+
if (session.pr == null) return json(res, 409, { error: 'no session' })
|
|
504
|
+
const verdict = url.searchParams.get('verdict')
|
|
505
|
+
if (verdict !== 'accept' && verdict !== 'reject') return json(res, 400, { error: 'verdict must be accept|reject' })
|
|
506
|
+
const notes = url.searchParams.get('notes') ?? ''
|
|
507
|
+
const durationMin = session.startedAt ? Math.round((Date.now() - session.startedAt) / 60000) : 0
|
|
508
|
+
const body = formatVerdict({ verdict, notes, pr: session.pr, baseTag: session.baseTag, prTag: session.prTag, durationMin })
|
|
509
|
+
const { accept, reject } = cfg.verdictLabels
|
|
510
|
+
const applies = verdict === 'accept' ? accept : reject
|
|
511
|
+
const removes = verdict === 'accept' ? reject : accept
|
|
512
|
+
return json(res, 200, { body, applies, removes })
|
|
513
|
+
}
|
|
514
|
+
if (req.method === 'POST' && p === '/api/verdict') {
|
|
515
|
+
const { verdict, notes } = await readBody(req)
|
|
516
|
+
if (session.pr == null) return json(res, 409, { error: 'no session' })
|
|
517
|
+
if (verdict !== 'accept' && verdict !== 'reject') return json(res, 400, { error: 'verdict must be accept|reject' })
|
|
518
|
+
const durationMin = session.startedAt ? Math.round((Date.now() - session.startedAt) / 60000) : 0
|
|
519
|
+
const body = formatVerdict({ verdict, notes: notes ?? '', pr: session.pr, baseTag: session.baseTag, prTag: session.prTag, durationMin })
|
|
520
|
+
const url2 = await github.postComment(session.pr, body)
|
|
521
|
+
await github.setQaLabel(session.pr, verdict === 'accept' ? cfg.verdictLabels.accept : cfg.verdictLabels.reject)
|
|
522
|
+
return json(res, 200, { url: url2 })
|
|
523
|
+
}
|
|
524
|
+
if (req.method === 'POST' && p === '/api/teardown') {
|
|
525
|
+
await doTeardown()
|
|
526
|
+
return json(res, 200, { ok: true })
|
|
527
|
+
}
|
|
528
|
+
res.writeHead(404).end('not found')
|
|
529
|
+
} catch (err) {
|
|
530
|
+
json(res, 500, { error: String(err.message ?? err) })
|
|
531
|
+
}
|
|
532
|
+
})))
|
|
533
|
+
|
|
534
|
+
const onActivity = () => { session = touch(session, Date.now()) }
|
|
535
|
+
const bridgePath = path.join(publicDir, 'bridge.js')
|
|
536
|
+
const paneProxy = role => http.createServer(paneHeaders(gate(createPaneProxy({
|
|
537
|
+
upstreamPort: () => upstreams[role],
|
|
538
|
+
bridgePath,
|
|
539
|
+
onActivity,
|
|
540
|
+
httpMod: http,
|
|
541
|
+
allowedHosts: allowedHostnames,
|
|
542
|
+
harnessOrigin,
|
|
543
|
+
frameAncestors: cfg.frameAncestors ?? [],
|
|
544
|
+
}))))
|
|
545
|
+
const baseProxy = paneProxy('base')
|
|
546
|
+
const prProxy = paneProxy('pr')
|
|
547
|
+
const servers = { harness, baseProxy, prProxy }
|
|
548
|
+
|
|
549
|
+
const reaper = setInterval(() => {
|
|
550
|
+
if (isIdle(session, Date.now(), cfg.idleMinutes * 60_000)) {
|
|
551
|
+
log.log('[qa] idle session — tearing down')
|
|
552
|
+
doTeardown().catch(err => log.error('[qa] teardown failed:', err.message))
|
|
553
|
+
}
|
|
554
|
+
}, 60_000)
|
|
555
|
+
|
|
556
|
+
// --- Exposure: the reconcile loop -------------------------------------------
|
|
557
|
+
// With an adapter, the conductor owns its front-door mounts. It reconciles
|
|
558
|
+
// once all three servers listen, so the targets are the bound ports, then
|
|
559
|
+
// every exposureIntervalMinutes on an unref()ed timer. One pass at a time,
|
|
560
|
+
// so a hung CLI never stacks processes; none once stop() or shutdown()
|
|
561
|
+
// begins, and neither waits for one in flight. Nothing ever removes a
|
|
562
|
+
// mount. A pass never throws: its result is recorded for /api/exposure and
|
|
563
|
+
// state(), and logged only when it changes.
|
|
564
|
+
let exposureState = { mode: exposure, managed: exposureAdapter !== null, ok: null, checkedAt: null, drift: [], added: [], error: null }
|
|
565
|
+
const exposureView = () => structuredClone(exposureState)
|
|
566
|
+
let exposureStarted = false // all three servers are listening
|
|
567
|
+
let exposureEnded = false // stop() or shutdown() began
|
|
568
|
+
let exposureTimer = null
|
|
569
|
+
let reconciling = null // the pass in flight
|
|
570
|
+
let exposureStatus = null // the last ok / drift / failed line
|
|
571
|
+
// Mounts this conductor has had in place, so a rewrite of one is a restore.
|
|
572
|
+
const mountsInPlace = new Set()
|
|
573
|
+
let resolveExposureReady
|
|
574
|
+
const exposureReady = new Promise(resolve => { resolveExposureReady = resolve })
|
|
575
|
+
if (exposureAdapter === null) resolveExposureReady(exposureView())
|
|
576
|
+
|
|
577
|
+
// mountsFor runs every pass: a platform may assign cfg.paneOrigins after
|
|
578
|
+
// start. Its throw is a layout no front door can publish, recorded like an
|
|
579
|
+
// adapter error (reconcileExposure never rejects).
|
|
580
|
+
async function exposurePass() {
|
|
581
|
+
const ports = { harness: harness.address()?.port, base: baseProxy.address()?.port, pr: prProxy.address()?.port }
|
|
582
|
+
let mounts = []
|
|
583
|
+
let result
|
|
584
|
+
try {
|
|
585
|
+
mounts = mountsFor({ ...cfg, harnessOrigin: harnessOrigin() }, { ports })
|
|
586
|
+
} catch (err) {
|
|
587
|
+
result = { ok: false, checkedAt: Date.now(), drift: [], added: [], error: String(err.message).replace(/^exposure: /, '') }
|
|
588
|
+
}
|
|
589
|
+
result ??= await reconcileExposure(exposureAdapter, mounts)
|
|
590
|
+
recordExposure(mounts, result)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function recordExposure(mounts, result) {
|
|
594
|
+
const added = result.added.map(plainMount)
|
|
595
|
+
const drift = result.drift.map(d => ({ mount: plainMount(d?.mount), actual: typeof d?.actual === 'string' ? d.actual : null }))
|
|
596
|
+
exposureState = { mode: exposure, managed: true, ok: result.ok === true, checkedAt: result.checkedAt, drift, added, error: result.error }
|
|
597
|
+
for (const m of added) {
|
|
598
|
+
log.log(`[qa] exposure ${mountsInPlace.has(mountKey(m)) ? 'restored' : 'mounted'} ${mountKey(m)}`)
|
|
599
|
+
mountsInPlace.add(mountKey(m))
|
|
600
|
+
}
|
|
601
|
+
// ensure wrote every mount that wasn't in place, or failed: after an
|
|
602
|
+
// error-free pass, all of them were.
|
|
603
|
+
if (result.error === null) for (const m of mounts) mountsInPlace.add(mountKey(m))
|
|
604
|
+
const status = result.error !== null ? `failed: ${result.error}`
|
|
605
|
+
: exposureState.ok ? 'ok'
|
|
606
|
+
: `drift remains: ${[...new Set(drift.map(d => `${d.mount.port}${d.mount.path}`))].join(', ') || '(the adapter named no mount)'}`
|
|
607
|
+
if (status === exposureStatus) return
|
|
608
|
+
exposureStatus = status
|
|
609
|
+
if (status === 'ok') log.log('[qa] exposure ok')
|
|
610
|
+
else log.error(`[qa] exposure ${status}`)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// One pass, or the one in flight. Before the servers listen there are no
|
|
614
|
+
// targets yet: that is the first pass, so it returns `ready`, which waits
|
|
615
|
+
// for it (or settles when stop() or shutdown() begins first).
|
|
616
|
+
function reconcileNow() {
|
|
617
|
+
if (exposureAdapter === null || exposureEnded) return Promise.resolve(exposureView())
|
|
618
|
+
if (!exposureStarted) return exposureReady
|
|
619
|
+
// A pass rejects only when the platform's logger throws, and then there
|
|
620
|
+
// is nowhere to report it; the next tick tries again.
|
|
621
|
+
reconciling ??= exposurePass().catch(() => {}).then(() => {
|
|
622
|
+
reconciling = null
|
|
623
|
+
return exposureView()
|
|
624
|
+
})
|
|
625
|
+
return reconciling
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// The loop's own calls end their chains, so nothing a pass does can become
|
|
629
|
+
// an unhandled rejection, which would take the process down.
|
|
630
|
+
function beginReconcileLoop() {
|
|
631
|
+
exposureStarted = true
|
|
632
|
+
if (exposureAdapter === null || exposureEnded) return
|
|
633
|
+
reconcileNow().then(resolveExposureReady, () => resolveExposureReady(exposureState))
|
|
634
|
+
exposureTimer = setInterval(() => { reconcileNow().catch(() => {}) }, exposureIntervalMs)
|
|
635
|
+
exposureTimer.unref()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// `ready` settles here when no pass has: before the servers listen, or
|
|
639
|
+
// with the first pass still in flight.
|
|
640
|
+
function endExposure() {
|
|
641
|
+
exposureEnded = true
|
|
642
|
+
clearInterval(exposureTimer)
|
|
643
|
+
resolveExposureReady(exposureView())
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
let listening = 0
|
|
647
|
+
function listen(server, port, label) {
|
|
648
|
+
server.listen(port, host, () => {
|
|
649
|
+
if (closing) return server.close()
|
|
650
|
+
log.log(`[qa] ${label} on ${hostPort(host, server.address().port)}`)
|
|
651
|
+
if (server === harness) logHarnessOrigin()
|
|
652
|
+
if (++listening === Object.keys(servers).length) beginReconcileLoop()
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function logHarnessOrigin() {
|
|
657
|
+
const origin = harnessOrigin()
|
|
658
|
+
if (origin) log.log(`[qa] harness at ${origin}${HARNESS_PATH}/`)
|
|
659
|
+
else log.error('[qa] no harness origin (set QA_HARNESS_ORIGIN): only a pane can frame itself and the mirror is off')
|
|
660
|
+
// panePolicy leaves an IPv6 literal out (a CSP source can't name one), so
|
|
661
|
+
// no harness could frame the panes. A `::1` bind derives one.
|
|
662
|
+
const hostname = origin ? new URL(origin).hostname : ''
|
|
663
|
+
if (hostname.startsWith('[')) {
|
|
664
|
+
log.error(
|
|
665
|
+
`[qa] the harness origin ${origin} is an IPv6 literal, which frame-ancestors cannot name: ` +
|
|
666
|
+
`the panes will stay blank; set QA_HARNESS_ORIGIN=${origin.replace(hostname, 'localhost')}`,
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
listen(harness, cfg.ports.harness, 'harness')
|
|
671
|
+
listen(baseProxy, cfg.ports.base, 'base pane proxy')
|
|
672
|
+
listen(prProxy, cfg.ports.pr, 'pr pane proxy')
|
|
673
|
+
|
|
674
|
+
// Stop accepting, drop keep-alive and SSE sockets, resolve once closed.
|
|
675
|
+
function closeServer(server) {
|
|
676
|
+
return new Promise(resolve => {
|
|
677
|
+
server.close(() => resolve())
|
|
678
|
+
server.closeAllConnections()
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let shuttingDown = null
|
|
683
|
+
async function shutdown() {
|
|
684
|
+
closing = true
|
|
685
|
+
clearInterval(reaper)
|
|
686
|
+
endExposure()
|
|
687
|
+
await doTeardown()
|
|
688
|
+
// closing keeps new boots out; this catches any that slipped in anyway.
|
|
689
|
+
if (bootAbort) await doTeardown()
|
|
690
|
+
for (const res of sseClients) res.end()
|
|
691
|
+
sseClients.clear()
|
|
692
|
+
await Promise.all(Object.values(servers).map(closeServer))
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
servers,
|
|
697
|
+
stop() {
|
|
698
|
+
clearInterval(reaper)
|
|
699
|
+
endExposure()
|
|
700
|
+
harness.close()
|
|
701
|
+
baseProxy.close()
|
|
702
|
+
prProxy.close()
|
|
703
|
+
},
|
|
704
|
+
// Graceful exit for CLIs (call it on SIGINT/SIGTERM): tears the session
|
|
705
|
+
// down (aborting an in-flight boot) and closes all three servers.
|
|
706
|
+
shutdown() {
|
|
707
|
+
shuttingDown ??= shutdown()
|
|
708
|
+
return shuttingDown
|
|
709
|
+
},
|
|
710
|
+
// The reconcile loop. `ready` resolves with the state after the first
|
|
711
|
+
// pass, or as it stands once stop() or shutdown() begins first (at once
|
|
712
|
+
// with no adapter); state() is the last pass, as GET /api/exposure
|
|
713
|
+
// reports it; reconcile() runs a pass now, or joins the one in flight,
|
|
714
|
+
// and before the servers listen returns `ready`.
|
|
715
|
+
exposure: {
|
|
716
|
+
ready: exposureReady,
|
|
717
|
+
state: exposureView,
|
|
718
|
+
reconcile: reconcileNow,
|
|
719
|
+
},
|
|
720
|
+
}
|
|
721
|
+
}
|