@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/config.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Generic conductor configuration: parse `.env.qa` (plain KEY=value lines)
|
|
2
|
+
// into the settings the CORE needs. There are no app defaults here: a
|
|
3
|
+
// consuming platform passes its own `defaults` (and any keys it must insist
|
|
4
|
+
// on as `required`) and reads its app-specific keys (image repo, database
|
|
5
|
+
// identity, ...) from the returned raw `env` map. See the homefree platform's
|
|
6
|
+
// config-homefree.mjs for the reference reader.
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
|
|
10
|
+
import { normalizeLogins } from './identity.mjs'
|
|
11
|
+
import { hostPort, isLoopbackHost, requireFetchMetadata, webOrigin } from './net.mjs'
|
|
12
|
+
|
|
13
|
+
// Where the harness is served under its origin. Fixed: public/index.html and
|
|
14
|
+
// public/harness.js load and call /qa/... .
|
|
15
|
+
export const HARNESS_PATH = '/qa'
|
|
16
|
+
|
|
17
|
+
// QA_EXPOSURE / cfg.exposure. `tailscale`: fronted by tailscale serve on this
|
|
18
|
+
// host, so every server answers only QA_ALLOWED_LOGINS and must listen on
|
|
19
|
+
// loopback. `none`: no identity gate (0.2's behaviour).
|
|
20
|
+
export const EXPOSURE_MODES = ['none', 'tailscale']
|
|
21
|
+
|
|
22
|
+
// QA_EXPOSURE_INTERVAL_MINUTES / cfg.exposureIntervalMinutes: the minutes
|
|
23
|
+
// between the conductor's exposure reconcile passes. At most 35791, the
|
|
24
|
+
// largest whole number of minutes whose milliseconds fit setInterval's
|
|
25
|
+
// 2^31-1 limit: above it Node fires every 1 ms, and the loop would run the
|
|
26
|
+
// front door's CLI back to back. Fractions are fine.
|
|
27
|
+
export const MAX_EXPOSURE_INTERVAL_MINUTES = 35791
|
|
28
|
+
|
|
29
|
+
export function isExposureInterval(minutes) {
|
|
30
|
+
return typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0 && minutes <= MAX_EXPOSURE_INTERVAL_MINUTES
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The rule both loadConfig and startConductor state when they refuse one.
|
|
34
|
+
export const EXPOSURE_INTERVAL_RULE = `must be a number of minutes above 0 and at most ${MAX_EXPOSURE_INTERVAL_MINUTES} (the longest a timer can wait)`
|
|
35
|
+
|
|
36
|
+
// The hostname of an http(s) origin, or null for anything else.
|
|
37
|
+
function originHostname(value) {
|
|
38
|
+
try { return new URL(webOrigin(value)).hostname } catch { return null }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The exposure mode for a layout that names none: tailscale when anything
|
|
42
|
+
// the conductor answers to or listens on is off loopback, since it is then
|
|
43
|
+
// meant to be reached from another machine; else none. The inputs are the
|
|
44
|
+
// harness origin as resolved at start (null, on port 0 or off loopback,
|
|
45
|
+
// adds nothing: the bind host counts on its own), both pane origins (a
|
|
46
|
+
// missing or unparseable one counts), the public host and every allowed
|
|
47
|
+
// host, which widen the Host allowlist, and the bind host. `because` names
|
|
48
|
+
// the first one off loopback, for error messages, else null. The public
|
|
49
|
+
// host goes first, since the derived origins come from it.
|
|
50
|
+
export function defaultExposure({ harnessOrigin = null, paneOrigins, publicHost = null, allowedHosts = [], host = '127.0.0.1' } = {}) {
|
|
51
|
+
const inputs = []
|
|
52
|
+
if (publicHost) inputs.push([`QA_PUBLIC_HOST=${publicHost}`, publicHost])
|
|
53
|
+
if (harnessOrigin != null) inputs.push([`QA_HARNESS_ORIGIN=${harnessOrigin}`, originHostname(harnessOrigin)])
|
|
54
|
+
for (const [role, key] of [['base', 'QA_BASE_ORIGIN'], ['pr', 'QA_PR_ORIGIN']]) {
|
|
55
|
+
const value = paneOrigins?.[role]
|
|
56
|
+
const hostname = originHostname(value)
|
|
57
|
+
const shown = value == null ? `${key} (unset)` : hostname === null ? `${key}=${JSON.stringify(String(value))}` : `${key}=${value}`
|
|
58
|
+
inputs.push([shown, hostname])
|
|
59
|
+
}
|
|
60
|
+
for (const entry of allowedHosts ?? []) {
|
|
61
|
+
// a blank entry matches no Host, so it widens nothing
|
|
62
|
+
if (String(entry).trim()) inputs.push([`the QA_ALLOWED_HOSTS entry ${entry}`, String(entry).trim()])
|
|
63
|
+
}
|
|
64
|
+
inputs.push([`QA_BIND_HOST=${host}`, host])
|
|
65
|
+
const first = inputs.find(([, name]) => !isLoopbackHost(name))
|
|
66
|
+
return first ? { mode: 'tailscale', because: first[0] } : { mode: 'none', because: null }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The harness origin when none is configured: :8444 on the public host, else
|
|
70
|
+
// the loopback address the harness listens on, else null (on port 0 the
|
|
71
|
+
// conductor derives it from the bound port; off loopback it can't be known).
|
|
72
|
+
export function defaultHarnessOrigin({ publicHost, host, port }) {
|
|
73
|
+
if (publicHost) return webOrigin(`https://${publicHost}:8444`, 'the harness origin derived from QA_PUBLIC_HOST')
|
|
74
|
+
if (isLoopbackHost(host) && port > 0) return new URL(`http://${hostPort(host, port)}`).origin
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseEnvFile(envFilePath) {
|
|
79
|
+
const env = {}
|
|
80
|
+
for (const line of readFileSync(envFilePath, 'utf8').split('\n')) {
|
|
81
|
+
const trimmed = line.trim()
|
|
82
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
83
|
+
const eq = trimmed.indexOf('=')
|
|
84
|
+
if (eq === -1) continue
|
|
85
|
+
env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim()
|
|
86
|
+
}
|
|
87
|
+
return env
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const num = (value, fallback) => (value ? Number(value) : fallback)
|
|
91
|
+
|
|
92
|
+
const list = value => String(value ?? '').split(',').map(s => s.trim()).filter(Boolean)
|
|
93
|
+
|
|
94
|
+
// The harness and the panes each act for the operator, so none may be
|
|
95
|
+
// same-origin with another: a pane's page (PR code) would then drive the
|
|
96
|
+
// harness API or the other pane. Throws naming the first pair that collides.
|
|
97
|
+
function checkDistinct(origins, envFilePath) {
|
|
98
|
+
const seen = new Map()
|
|
99
|
+
for (const [name, origin] of Object.entries(origins)) {
|
|
100
|
+
if (origin === null) continue
|
|
101
|
+
if (seen.has(origin)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`the harness and the two panes must be three different origins, but the ${seen.get(origin)} and ${name} are both ${origin} ` +
|
|
104
|
+
`in ${envFilePath} (QA_HARNESS_ORIGIN, QA_BASE_ORIGIN, QA_PR_ORIGIN)`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
seen.set(origin, name)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// QA_EXPOSURE, else the default for this layout. `explicit` and `because`
|
|
112
|
+
// say why, for the errors below.
|
|
113
|
+
function exposureOf(value, layout, envFilePath) {
|
|
114
|
+
if (!value) return { ...defaultExposure(layout), explicit: false }
|
|
115
|
+
if (!EXPOSURE_MODES.includes(value)) {
|
|
116
|
+
throw new Error(`QA_EXPOSURE must be none or tailscale, got ${JSON.stringify(value)} in ${envFilePath}`)
|
|
117
|
+
}
|
|
118
|
+
return { mode: value, because: null, explicit: true }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The identity gate trusts Tailscale-User-Login only because nothing but
|
|
122
|
+
// tailscale serve on this host can reach a loopback listener; on any other
|
|
123
|
+
// address a client could send its own. And an empty allowlist would refuse
|
|
124
|
+
// everyone. The bind is checked first, so its error names the way out too.
|
|
125
|
+
function checkTailscale({ explicit, because }, host, allowedLogins, envFilePath) {
|
|
126
|
+
const avoid = 'set QA_EXPOSURE=none if another front door authenticates'
|
|
127
|
+
if (!isLoopbackHost(host)) {
|
|
128
|
+
const why = explicit ? 'QA_EXPOSURE=tailscale is set' : `QA_EXPOSURE defaults to tailscale because ${because} is not loopback`
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${why}, so QA_BIND_HOST must be a loopback address: tailscale serve on this host is the only supported front ` +
|
|
131
|
+
`(got ${JSON.stringify(host)} in ${envFilePath}; use 127.0.0.1, ::1 or localhost), or ${avoid}`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
if (allowedLogins.length === 0) {
|
|
135
|
+
const reason = explicit ? 'set' : `${because} is not loopback`
|
|
136
|
+
throw new Error(
|
|
137
|
+
`QA_ALLOWED_LOGINS is empty in ${envFilePath} and QA_EXPOSURE is tailscale (${reason}): the harness and both panes would refuse everyone. ` +
|
|
138
|
+
'Set it to the comma-separated Tailscale logins allowed in (to find one, run `tailscale whois <device tailnet ip>`), ' +
|
|
139
|
+
`or ${avoid}.`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Keys the core cannot run without. QA_PUBLIC_HOST joins them only when it is
|
|
145
|
+
// needed to derive a pane origin that wasn't given explicitly.
|
|
146
|
+
function requiredKeys(env, extra) {
|
|
147
|
+
const keys = new Set(['GITHUB_QA_TOKEN', 'QA_REPO', ...extra])
|
|
148
|
+
if (!(env.QA_BASE_ORIGIN && env.QA_PR_ORIGIN)) keys.add('QA_PUBLIC_HOST')
|
|
149
|
+
return keys
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function loadConfig(envFilePath, { defaults = {}, required = [] } = {}) {
|
|
153
|
+
const env = { ...defaults, ...parseEnvFile(envFilePath) }
|
|
154
|
+
for (const key of requiredKeys(env, required)) {
|
|
155
|
+
if (!env[key]) throw new Error(`${key} missing in ${envFilePath}`)
|
|
156
|
+
}
|
|
157
|
+
const publicHost = env.QA_PUBLIC_HOST || null
|
|
158
|
+
// listen() takes an IPv6 literal bare: `[::1]`, as a URL writes it, is ::1.
|
|
159
|
+
// Left bracketed, it would pass every loopback check and then fail to listen.
|
|
160
|
+
const host = (env.QA_BIND_HOST || '127.0.0.1').replace(/^\[(.*)\]$/, '$1')
|
|
161
|
+
const ports = {
|
|
162
|
+
harness: num(env.QA_HARNESS_PORT, 3100),
|
|
163
|
+
base: num(env.QA_BASE_PROXY_PORT, 3101),
|
|
164
|
+
pr: num(env.QA_PR_PROXY_PORT, 3102),
|
|
165
|
+
}
|
|
166
|
+
const paneOrigins = {
|
|
167
|
+
base: env.QA_BASE_ORIGIN
|
|
168
|
+
? webOrigin(env.QA_BASE_ORIGIN, 'QA_BASE_ORIGIN')
|
|
169
|
+
: webOrigin(`https://${publicHost}:8443`, 'QA_BASE_ORIGIN (derived from QA_PUBLIC_HOST)'),
|
|
170
|
+
pr: env.QA_PR_ORIGIN
|
|
171
|
+
? webOrigin(env.QA_PR_ORIGIN, 'QA_PR_ORIGIN')
|
|
172
|
+
: webOrigin(`https://${publicHost}:10000`, 'QA_PR_ORIGIN (derived from QA_PUBLIC_HOST)'),
|
|
173
|
+
}
|
|
174
|
+
const harnessOrigin = env.QA_HARNESS_ORIGIN
|
|
175
|
+
? webOrigin(env.QA_HARNESS_ORIGIN, 'QA_HARNESS_ORIGIN')
|
|
176
|
+
: defaultHarnessOrigin({ publicHost, host, port: ports.harness })
|
|
177
|
+
if (harnessOrigin === null && !(isLoopbackHost(host) && ports.harness === 0)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`QA_HARNESS_ORIGIN missing in ${envFilePath}: with QA_BIND_HOST=${host} and no QA_PUBLIC_HOST, ` +
|
|
180
|
+
'the origin viewers open the harness at cannot be derived (for example https://qa.example.com:8444)',
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
// The derived origins are https or loopback; an explicit one may be neither.
|
|
184
|
+
if (harnessOrigin !== null) requireFetchMetadata(harnessOrigin, 'QA_HARNESS_ORIGIN', 'harness API guard')
|
|
185
|
+
requireFetchMetadata(paneOrigins.base, 'QA_BASE_ORIGIN', 'pane request guard')
|
|
186
|
+
requireFetchMetadata(paneOrigins.pr, 'QA_PR_ORIGIN', 'pane request guard')
|
|
187
|
+
checkDistinct({ harness: harnessOrigin, base: paneOrigins.base, pr: paneOrigins.pr }, envFilePath)
|
|
188
|
+
const frameAncestors = list(env.QA_FRAME_ANCESTORS).map(v => webOrigin(v, 'QA_FRAME_ANCESTORS'))
|
|
189
|
+
const allowedHosts = list(env.QA_ALLOWED_HOSTS)
|
|
190
|
+
const exposure = exposureOf(env.QA_EXPOSURE, { harnessOrigin, paneOrigins, publicHost, allowedHosts, host }, envFilePath)
|
|
191
|
+
const allowedLogins = normalizeLogins(list(env.QA_ALLOWED_LOGINS))
|
|
192
|
+
if (exposure.mode === 'tailscale') checkTailscale(exposure, host, allowedLogins, envFilePath)
|
|
193
|
+
const exposureIntervalMinutes = num(env.QA_EXPOSURE_INTERVAL_MINUTES, 5)
|
|
194
|
+
if (!isExposureInterval(exposureIntervalMinutes)) {
|
|
195
|
+
throw new Error(`QA_EXPOSURE_INTERVAL_MINUTES ${EXPOSURE_INTERVAL_RULE}, got ${JSON.stringify(env.QA_EXPOSURE_INTERVAL_MINUTES)} in ${envFilePath}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
env,
|
|
200
|
+
githubToken: env.GITHUB_QA_TOKEN,
|
|
201
|
+
// The GHCR token, for createGithub({ packagesToken }) (package version
|
|
202
|
+
// listings) and a platform's registry login. A fine-grained
|
|
203
|
+
// GITHUB_QA_TOKEN cannot call the Packages API, so QA_GHCR_TOKEN (a
|
|
204
|
+
// classic PAT with read:packages) can take those over. Unset,
|
|
205
|
+
// GITHUB_QA_TOKEN does both.
|
|
206
|
+
ghcrToken: env.QA_GHCR_TOKEN || env.GITHUB_QA_TOKEN,
|
|
207
|
+
operatorEmail: env.QA_OPERATOR_EMAIL || null,
|
|
208
|
+
repo: env.QA_REPO,
|
|
209
|
+
publicHost,
|
|
210
|
+
idleMinutes: num(env.QA_IDLE_MINUTES, 30),
|
|
211
|
+
// The address all three servers listen on. Loopback by default, and
|
|
212
|
+
// always in tailscale mode: in none mode the API is unauthenticated, and
|
|
213
|
+
// a pane proxy is a signed-in pane session either way.
|
|
214
|
+
host,
|
|
215
|
+
// Extra Host-header hostnames the servers answer to, beyond loopback, the
|
|
216
|
+
// public host and the pane origins (DNS-rebinding defence).
|
|
217
|
+
allowedHosts,
|
|
218
|
+
// 'tailscale' (the identity gate is on) or 'none'; see EXPOSURE_MODES.
|
|
219
|
+
exposure: exposure.mode,
|
|
220
|
+
// Tailscale logins (as tailscale serve reports them, e.g. alice@github)
|
|
221
|
+
// allowed in when the identity gate is on. Empty refuses everyone, so
|
|
222
|
+
// tailscale mode requires some.
|
|
223
|
+
allowedLogins,
|
|
224
|
+
// Minutes between exposure reconcile passes, when the platform passes
|
|
225
|
+
// an Exposure adapter (adapters.exposure); see MAX_EXPOSURE_INTERVAL_MINUTES.
|
|
226
|
+
exposureIntervalMinutes,
|
|
227
|
+
// Listen ports: the harness UI/API and the two pane proxies.
|
|
228
|
+
ports,
|
|
229
|
+
// The external origin a viewer reaches each pane at: the TLS terminator in
|
|
230
|
+
// front of the pane proxies. Defaults match a tailscale-serve layout.
|
|
231
|
+
paneOrigins,
|
|
232
|
+
// The origin viewers open the harness at (its page is HARNESS_PATH/ under
|
|
233
|
+
// it). Only it, the panes themselves and frameAncestors may frame a pane,
|
|
234
|
+
// a navigation into a pane from another site must come from it, and the
|
|
235
|
+
// mirror bridge talks only to it. null: derived from the bound port.
|
|
236
|
+
harnessOrigin,
|
|
237
|
+
// Extra origins allowed to frame the panes, for a harness that is itself
|
|
238
|
+
// framed (self-QA's inner demos). CSP only: no Referer or bridge trust.
|
|
239
|
+
frameAncestors,
|
|
240
|
+
verdictLabels: {
|
|
241
|
+
accept: env.QA_LABEL_ACCEPT || 'qa-approved',
|
|
242
|
+
reject: env.QA_LABEL_REJECT || 'qa-changes-requested',
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
}
|
package/lib/docker.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Docker CLI wrappers for QA sessions. All effects go through the injected
|
|
2
|
+
// execFileFn (promisified child_process.execFile shape: resolves { stdout }),
|
|
3
|
+
// so tests assert exact argv without touching docker.
|
|
4
|
+
|
|
5
|
+
const SAFE_NAME = /^[a-z0-9_-]+$/i
|
|
6
|
+
const DEFAULT_LABEL = 'qa-conductor-session'
|
|
7
|
+
const DEFAULT_POSTGRES = { image: 'postgres:16', user: 'qa', password: 'qa', db: 'postgres' }
|
|
8
|
+
|
|
9
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
10
|
+
|
|
11
|
+
function assertSafe(value, what) {
|
|
12
|
+
if (!SAFE_NAME.test(value)) throw new Error(`unsafe ${what}: ${JSON.stringify(value)}`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// `label` marks every conductor-owned container/network (the startup sweep
|
|
16
|
+
// removes whatever carries it); `postgres` is the pane database identity.
|
|
17
|
+
// A consuming app that clones a real database passes its own superuser here.
|
|
18
|
+
export function createDocker({ execFileFn, label = DEFAULT_LABEL, postgres = DEFAULT_POSTGRES }) {
|
|
19
|
+
const pg = { ...DEFAULT_POSTGRES, ...postgres }
|
|
20
|
+
async function run(argv) {
|
|
21
|
+
const { stdout } = await execFileFn('docker', argv)
|
|
22
|
+
return stdout
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
run,
|
|
27
|
+
|
|
28
|
+
// Log the daemon into GHCR with the conductor's own token so pane image
|
|
29
|
+
// pulls never depend on a manually-seeded (and expiring) host credential.
|
|
30
|
+
// Token travels via the child env, never argv. Merge (not replace) the
|
|
31
|
+
// parent env so HOME survives — docker login writes ~/.docker/config.json,
|
|
32
|
+
// and docker pull/run must read the SAME HOME or the credential is lost.
|
|
33
|
+
async login(user, token) {
|
|
34
|
+
assertSafe(user, 'registry user')
|
|
35
|
+
// biome-ignore lint/nursery/noProcessEnv: must inherit HOME so docker login/pull share a config
|
|
36
|
+
const parentEnv = globalThis.process?.env ?? {}
|
|
37
|
+
await execFileFn('sh', ['-c', 'printf %s "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin'], {
|
|
38
|
+
env: { ...parentEnv, GHCR_USER: user, GHCR_TOKEN: token },
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async imagePresent(image) {
|
|
43
|
+
try {
|
|
44
|
+
await run(['image', 'inspect', image])
|
|
45
|
+
return true
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// Make an image local before it's run. No-op if already present; otherwise
|
|
52
|
+
// pull with retries, re-logging-in before each retry so a transient
|
|
53
|
+
// registry 'denied' (seen live — same token pulls fine seconds later)
|
|
54
|
+
// self-heals instead of killing the boot.
|
|
55
|
+
async ensureImage(image, { retries = 3, sleepFn = defaultSleep, relogin } = {}) {
|
|
56
|
+
if (await this.imagePresent(image)) return
|
|
57
|
+
let lastErr
|
|
58
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
59
|
+
try {
|
|
60
|
+
await run(['pull', image])
|
|
61
|
+
return
|
|
62
|
+
} catch (err) {
|
|
63
|
+
lastErr = err
|
|
64
|
+
if (relogin) await relogin().catch(() => {})
|
|
65
|
+
await sleepFn(3000)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`pull ${image} failed after ${retries} attempts: ${lastErr?.message ?? ''}`)
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async runPg(name, network) {
|
|
72
|
+
return run([
|
|
73
|
+
'run', '-d',
|
|
74
|
+
'--name', name,
|
|
75
|
+
'--network', network,
|
|
76
|
+
'--label', label,
|
|
77
|
+
'-e', `POSTGRES_USER=${pg.user}`,
|
|
78
|
+
'-e', `POSTGRES_PASSWORD=${pg.password}`,
|
|
79
|
+
'-e', `POSTGRES_DB=${pg.db}`,
|
|
80
|
+
pg.image,
|
|
81
|
+
])
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// postgres:16's entrypoint starts a TEMPORARY server during initdb, stops
|
|
85
|
+
// it, then starts the real one. pg_isready can pass against the temporary
|
|
86
|
+
// server, so require two consecutive successful probes (with a real query)
|
|
87
|
+
// separated by a beat — the shutdown gap fails one of them and resets.
|
|
88
|
+
async waitHealthyPg(name, { retries = 30, sleepFn = defaultSleep } = {}) {
|
|
89
|
+
let consecutive = 0
|
|
90
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
91
|
+
try {
|
|
92
|
+
await run(['exec', name, 'pg_isready', '-U', pg.user])
|
|
93
|
+
await run(['exec', name, 'psql', '-U', pg.user, '-d', pg.db, '-c', 'SELECT 1'])
|
|
94
|
+
consecutive += 1
|
|
95
|
+
if (consecutive >= 2) return
|
|
96
|
+
} catch {
|
|
97
|
+
consecutive = 0
|
|
98
|
+
if (attempt === retries) break
|
|
99
|
+
}
|
|
100
|
+
await sleepFn(1000)
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`postgres ${name} not ready after ${retries} attempts`)
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// CREATE DATABASE in a pane pg container, tolerating already-exists and
|
|
106
|
+
// retrying through the postgres-init restart window. Extracted from cloneDb
|
|
107
|
+
// so the Provisioner seam can own DB creation while the Seed seam owns data
|
|
108
|
+
// movement (extraction Stage 1). cloneDb keeps its behavior by composing the
|
|
109
|
+
// two.
|
|
110
|
+
async createDatabase(toContainer, db, { retries = 10, sleepFn = defaultSleep } = {}) {
|
|
111
|
+
assertSafe(toContainer, 'container name')
|
|
112
|
+
assertSafe(db, 'database name')
|
|
113
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
await run(['exec', toContainer, 'psql', '-U', pg.user, '-d', pg.db, '-c', `CREATE DATABASE ${db}`])
|
|
116
|
+
return
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const detail = `${err.message ?? ''}\n${err.stderr ?? ''}`
|
|
119
|
+
if (detail.includes('already exists')) return
|
|
120
|
+
// Connection refusals can still occur in the entrypoint's restart
|
|
121
|
+
// window; retry those instead of failing the whole boot.
|
|
122
|
+
const transient = detail.includes('connection to server') || detail.includes('the database system is starting up')
|
|
123
|
+
if (!transient || attempt === retries) throw err
|
|
124
|
+
await sleepFn(1000)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
// Copy one database from a source container into a pane container via a
|
|
130
|
+
// host-side pg_dump|psql pipe. This is a docker-topology detail the Seed
|
|
131
|
+
// adapter owns; kept here as the shared movement primitive.
|
|
132
|
+
async pipeDump(fromContainer, toContainer, db) {
|
|
133
|
+
assertSafe(fromContainer, 'container name')
|
|
134
|
+
assertSafe(toContainer, 'container name')
|
|
135
|
+
assertSafe(db, 'database name')
|
|
136
|
+
const pipeline =
|
|
137
|
+
`docker exec ${fromContainer} pg_dump -U ${pg.user} --clean --if-exists ${db}` +
|
|
138
|
+
` | docker exec -i ${toContainer} psql -q -U ${pg.user} -d ${db}`
|
|
139
|
+
await execFileFn('sh', ['-c', pipeline])
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async cloneDb(fromContainer, toContainer, db, opts = {}) {
|
|
143
|
+
// Validate all three up front so a bad source is rejected before any exec.
|
|
144
|
+
assertSafe(fromContainer, 'container name')
|
|
145
|
+
assertSafe(toContainer, 'container name')
|
|
146
|
+
assertSafe(db, 'database name')
|
|
147
|
+
await this.createDatabase(toContainer, db, opts)
|
|
148
|
+
await this.pipeDump(fromContainer, toContainer, db)
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async runMigrate(image, network, envFile) {
|
|
152
|
+
return run([
|
|
153
|
+
'run', '--rm',
|
|
154
|
+
'--network', network,
|
|
155
|
+
'--label', label,
|
|
156
|
+
'--env-file', envFile,
|
|
157
|
+
image,
|
|
158
|
+
])
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async runApp(name, image, network, envFile, hostPort) {
|
|
162
|
+
return run([
|
|
163
|
+
'run', '-d',
|
|
164
|
+
'--name', name,
|
|
165
|
+
'--network', network,
|
|
166
|
+
'--label', label,
|
|
167
|
+
'--env-file', envFile,
|
|
168
|
+
'-p', `127.0.0.1:${hostPort}:3000`,
|
|
169
|
+
image,
|
|
170
|
+
])
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async waitHealthyApp(hostPort, { retries = 60, sleepFn = defaultSleep, fetchFn = fetch } = {}) {
|
|
174
|
+
const url = `http://127.0.0.1:${hostPort}/api/health`
|
|
175
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetchFn(url)
|
|
178
|
+
if (res.status === 200) return
|
|
179
|
+
} catch {
|
|
180
|
+
// app not listening yet
|
|
181
|
+
}
|
|
182
|
+
if (attempt < retries) await sleepFn(1000)
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`app on port ${hostPort} not healthy after ${retries} attempts`)
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async psql(container, db, sql) {
|
|
188
|
+
return run(['exec', container, 'psql', '-U', pg.user, '-d', db, '-t', '-A', '-c', sql])
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async rmForce(names) {
|
|
192
|
+
if (!names.length) return
|
|
193
|
+
try {
|
|
194
|
+
await run(['rm', '-f', '-v', ...names])
|
|
195
|
+
} catch {
|
|
196
|
+
// teardown is idempotent: already-gone containers are fine
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
async createNetwork(name) {
|
|
201
|
+
return run(['network', 'create', '--label', label, name])
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async rmNetwork(name) {
|
|
205
|
+
try {
|
|
206
|
+
await run(['network', 'rm', name])
|
|
207
|
+
} catch {
|
|
208
|
+
// already gone
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
async sweepQaContainers() {
|
|
213
|
+
const out = await run(['ps', '-aq', '--filter', `label=${label}`])
|
|
214
|
+
const ids = out.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
215
|
+
if (ids.length) await this.rmForce(ids)
|
|
216
|
+
return ids
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
async inspectImageOf(container) {
|
|
220
|
+
const out = await run(['inspect', '--format', '{{.Config.Image}}', container])
|
|
221
|
+
return out.trim()
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// Tail a container's recent log lines for triage. docker logs writes to
|
|
225
|
+
// both stdout and stderr, but the run() wrapper only surfaces stdout;
|
|
226
|
+
// that's acceptable here — return whatever stdout the exec yields.
|
|
227
|
+
async logsTail(name, n = 40) {
|
|
228
|
+
assertSafe(name, 'container name')
|
|
229
|
+
const result = await execFileFn('docker', ['logs', '--tail', String(n), name])
|
|
230
|
+
return result.stdout || ''
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}
|
package/lib/exec.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Promisified execFile with the { stdout } resolution shape the docker module
|
|
2
|
+
// depends on (createDocker destructures { stdout } from every call). Kept as
|
|
3
|
+
// its own module so the shape is unit-tested rather than assumed by wiring.
|
|
4
|
+
//
|
|
5
|
+
// A failure rejects with an Error whose message is "<cmd> <first arg>:
|
|
6
|
+
// <execFile message>\n<stderr, first 2000 chars>" and which also carries the
|
|
7
|
+
// full `stdout`, `stderr` and exit `code`, so a caller can build its own log
|
|
8
|
+
// tail (an installer's output usually lands on stdout).
|
|
9
|
+
import { execFile } from 'node:child_process'
|
|
10
|
+
|
|
11
|
+
export function makeExecFileFn({ maxBuffer = 64 * 1024 * 1024 } = {}) {
|
|
12
|
+
return (cmd, args, opts) => new Promise((resolve, reject) => {
|
|
13
|
+
execFile(cmd, args, { maxBuffer, ...opts }, (err, stdout, stderr) => {
|
|
14
|
+
if (err) reject(execError(cmd, args, err, stdout, stderr))
|
|
15
|
+
else resolve({ stdout: String(stdout) })
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function execError(cmd, args, err, stdout, stderr) {
|
|
21
|
+
const out = new Error(`${cmd} ${args?.[0] ?? ''}: ${err.message}\n${String(stderr).slice(0, 2000)}`, { cause: err })
|
|
22
|
+
return Object.assign(out, { stdout: String(stdout ?? ''), stderr: String(stderr ?? ''), code: err.code })
|
|
23
|
+
}
|