@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/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
+ }