@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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// qa-conductor-expose: ensure the conductor's tailscale serve mounts, or with
|
|
3
|
+
// --check only report drift. An operator and debug tool: the conductor's own
|
|
4
|
+
// reconcile loop owns the mounts, and no deploy runs this.
|
|
5
|
+
//
|
|
6
|
+
// It loads cfg as the conductor does (loadConfig, or the platform's own
|
|
7
|
+
// loader named by --config, so a .env.qa that leans on a platform's defaults
|
|
8
|
+
// loads too), builds the built-in tailscale adapter, and runs runExpose:
|
|
9
|
+
// mountsFor then reconcileExposure, the conductor loop's own pass. The pass
|
|
10
|
+
// and what it prints are in lib/exposure.mjs; this file parses flags, loads
|
|
11
|
+
// cfg and sets the exit code.
|
|
12
|
+
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { pathToFileURL } from 'node:url'
|
|
15
|
+
import { parseArgs } from 'node:util'
|
|
16
|
+
|
|
17
|
+
import { createTailscaleExposure } from '../lib/adapters/exposure-tailscale.mjs'
|
|
18
|
+
import { loadConfig } from '../lib/config.mjs'
|
|
19
|
+
import { makeExecFileFn } from '../lib/exec.mjs'
|
|
20
|
+
import { runExpose } from '../lib/exposure.mjs'
|
|
21
|
+
|
|
22
|
+
const USAGE = `Usage: qa-conductor-expose [--check] [--env FILE] [--config MODULE[#export]] [--tailscale BIN] [--socket PATH] [--help|-h]
|
|
23
|
+
|
|
24
|
+
Ensures the conductor's tailscale serve mounts (the harness under /qa at the
|
|
25
|
+
harness origin's port, each pane at / at its own origin's port), or with
|
|
26
|
+
--check only reports drift. It never removes a handler. The conductor's own
|
|
27
|
+
reconcile loop owns these mounts: this is for operators and debugging, and no
|
|
28
|
+
deploy needs to run it.
|
|
29
|
+
|
|
30
|
+
Run it without --check only while the conductor is running. This CLI never
|
|
31
|
+
removes a mount, and self-QA removes its own only as it stops, so a mount
|
|
32
|
+
written with no conductor behind it stays, and publishes whatever listens on
|
|
33
|
+
its loopback port next with no identity gate. Otherwise use --check.
|
|
34
|
+
|
|
35
|
+
--check report drift; change nothing
|
|
36
|
+
--env FILE the conductor's env file
|
|
37
|
+
(default: $QA_ENV_FILE, else ./.env.qa)
|
|
38
|
+
--config MODULE[#export] load the config by calling MODULE's export (default:
|
|
39
|
+
its default export) with the env file's path. MODULE
|
|
40
|
+
is a file path, from the working directory. Without
|
|
41
|
+
it, the core loadConfig reads the env file, which then
|
|
42
|
+
needs GITHUB_QA_TOKEN and QA_REPO like the conductor's
|
|
43
|
+
--tailscale BIN the tailscale CLI (default: QA_TAILSCALE_BIN in the env
|
|
44
|
+
file, else tailscale on PATH). On macOS, use the app's
|
|
45
|
+
/Applications/Tailscale.app/Contents/MacOS/Tailscale
|
|
46
|
+
when the one on PATH is older than the daemon
|
|
47
|
+
--socket PATH tailscaled's socket, passed as --socket=PATH
|
|
48
|
+
-h, --help print this and exit
|
|
49
|
+
|
|
50
|
+
Exit status: 0 every mount is in place (or QA_EXPOSURE=none), 1 drift remains
|
|
51
|
+
or tailscale failed, 2 a usage, config or mount layout error.
|
|
52
|
+
`
|
|
53
|
+
|
|
54
|
+
const OPTIONS = {
|
|
55
|
+
check: { type: 'boolean' },
|
|
56
|
+
env: { type: 'string' },
|
|
57
|
+
config: { type: 'string' },
|
|
58
|
+
tailscale: { type: 'string' },
|
|
59
|
+
socket: { type: 'string' },
|
|
60
|
+
help: { type: 'boolean', short: 'h' },
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A usage or config error: what went wrong, then the usage, on stderr.
|
|
64
|
+
function refuse(message) {
|
|
65
|
+
process.stderr.write(`qa-conductor-expose: ${message}\n\n${USAGE}`)
|
|
66
|
+
return 2
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// MODULE[#export]: the export is what follows the last #, when that is a name.
|
|
70
|
+
async function loadCfg(spec, envFile) {
|
|
71
|
+
if (spec === undefined) return loadConfig(envFile)
|
|
72
|
+
const [, file, name] = /^(.*)#([A-Za-z_$][\w$]*)$/.exec(spec) ?? [spec, spec, 'default']
|
|
73
|
+
const mod = await import(pathToFileURL(path.resolve(file)).href)
|
|
74
|
+
if (typeof mod[name] !== 'function') throw new Error(`--config ${spec}: ${file} has no function export named ${name}`)
|
|
75
|
+
const cfg = await mod[name](envFile)
|
|
76
|
+
if (cfg === null || typeof cfg !== 'object') throw new Error(`--config ${spec}: ${name}(${JSON.stringify(envFile)}) returned no config object`)
|
|
77
|
+
return cfg
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main(argv) {
|
|
81
|
+
let opts
|
|
82
|
+
try {
|
|
83
|
+
opts = parseArgs({ args: argv, options: OPTIONS, strict: true, allowPositionals: false }).values
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return refuse(err.message)
|
|
86
|
+
}
|
|
87
|
+
if (opts.help) {
|
|
88
|
+
process.stdout.write(USAGE)
|
|
89
|
+
return 0
|
|
90
|
+
}
|
|
91
|
+
const envFile = opts.env ?? (process.env.QA_ENV_FILE || '.env.qa')
|
|
92
|
+
let cfg, exposure
|
|
93
|
+
try {
|
|
94
|
+
cfg = await loadCfg(opts.config, envFile)
|
|
95
|
+
exposure = createTailscaleExposure({
|
|
96
|
+
execFileFn: makeExecFileFn(),
|
|
97
|
+
bin: opts.tailscale ?? (cfg.env?.QA_TAILSCALE_BIN || 'tailscale'),
|
|
98
|
+
socket: opts.socket ?? null,
|
|
99
|
+
})
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return refuse(err?.message ?? String(err))
|
|
102
|
+
}
|
|
103
|
+
return runExpose({ cfg, exposure, checkOnly: opts.check === true })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// exitCode, not exit(): stdout and stderr drain first.
|
|
107
|
+
main(process.argv.slice(2)).then(
|
|
108
|
+
code => { process.exitCode = code },
|
|
109
|
+
err => {
|
|
110
|
+
process.stderr.write(`qa-conductor-expose: ${err?.stack ?? err}\n`)
|
|
111
|
+
process.exitCode = 1
|
|
112
|
+
},
|
|
113
|
+
)
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
// git-worktree BuildConvention: the built-in "build" for consumers that run a
|
|
2
|
+
// PR straight from its source instead of from CI-built images.
|
|
3
|
+
//
|
|
4
|
+
// For a PR it fetches the base branch and the PR head into a bare repository
|
|
5
|
+
// under `cacheDir`, checks each SHA out into its own worktree
|
|
6
|
+
// (`worktrees/<sha>`), optionally installs dependencies there, and records a
|
|
7
|
+
// marker outside the checkout (`built/<sha>.json`) so the next boot of the same
|
|
8
|
+
// SHA is instant. `servicesFor(dir, { role, sha })` turns a worktree into the
|
|
9
|
+
// Provisioner's service refs.
|
|
10
|
+
//
|
|
11
|
+
// This checks out and installs a PR's code on the reviewer's machine, so the
|
|
12
|
+
// trust gate (`trustDecision`) is the only real boundary: ensureBuilt refuses
|
|
13
|
+
// an untrusted PR before any git call, and only the exact SHA that passed the
|
|
14
|
+
// gate is ever checked out or installed. The installer's scrubbed env
|
|
15
|
+
// (PATH, HOME and install.env only) is defence in depth, not a sandbox.
|
|
16
|
+
// A marker vouches for a checkout, so the cache must be the reviewer's alone:
|
|
17
|
+
// a build refuses a cache that is a symlink or that others could write to.
|
|
18
|
+
//
|
|
19
|
+
// Builds run one at a time per instance. Effects (execFileFn, fsx, baseEnv,
|
|
20
|
+
// nowFn) are injected, so it is unit-tested with no real git or processes.
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import fsp from 'node:fs/promises'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
|
|
26
|
+
import { makeExecFileFn } from '../exec.mjs'
|
|
27
|
+
|
|
28
|
+
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/
|
|
29
|
+
const REPO_RE = /^[\w.-]+\/[\w.-]+$/
|
|
30
|
+
const DEFAULT_TRUST = { logins: [], associations: ['OWNER', 'MEMBER', 'COLLABORATOR'], requirePush: true, allowForks: true }
|
|
31
|
+
const PUSH_PERMISSIONS = ['admin', 'write']
|
|
32
|
+
const PERMISSION_TTL_MS = 5 * 60_000
|
|
33
|
+
const TAIL_LINES = 40
|
|
34
|
+
const TAIL_BYTES = 8192
|
|
35
|
+
|
|
36
|
+
const lower = s => String(s ?? '').toLowerCase()
|
|
37
|
+
const sha7 = sha => sha.slice(0, 7)
|
|
38
|
+
const abortError = () => Object.assign(new Error('build aborted'), { name: 'AbortError' })
|
|
39
|
+
const throwIfAborted = signal => { if (signal?.aborted) throw abortError() }
|
|
40
|
+
|
|
41
|
+
// A string is iterable, so `logins: 'alice'` would spread into one-character
|
|
42
|
+
// logins and widen the allowlist. Anything but a list of non-empty strings
|
|
43
|
+
// throws: at construction, and inside trustDecision it fails closed.
|
|
44
|
+
function stringList(value, key, what) {
|
|
45
|
+
if (value == null) return []
|
|
46
|
+
const list = value instanceof Set ? [...value] : value
|
|
47
|
+
if (!Array.isArray(list) || !list.every(s => typeof s === 'string' && s.trim() !== '')) {
|
|
48
|
+
throw new Error(`trust.${key} must be an array of ${what}`)
|
|
49
|
+
}
|
|
50
|
+
return list.map(s => s.trim())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function flag(value, key) {
|
|
54
|
+
if (value == null) return DEFAULT_TRUST[key]
|
|
55
|
+
if (typeof value !== 'boolean') throw new Error(`trust.${key} must be true or false`)
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A partial `trust` must never switch a check off by omission: missing keys
|
|
60
|
+
// take the defaults, and only an explicit `false` disables a check.
|
|
61
|
+
function normalizeTrust(trust) {
|
|
62
|
+
if (trust != null && (typeof trust !== 'object' || Array.isArray(trust))) throw new Error('trust must be an object')
|
|
63
|
+
const t = { ...DEFAULT_TRUST, ...trust }
|
|
64
|
+
return {
|
|
65
|
+
logins: stringList(t.logins, 'logins', 'GitHub logins').map(lower),
|
|
66
|
+
associations: stringList(t.associations, 'associations', 'author associations').map(s => s.toUpperCase()),
|
|
67
|
+
requirePush: flag(t.requirePush, 'requirePush'),
|
|
68
|
+
allowForks: flag(t.allowForks, 'allowForks'),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isAllowlisted(info, t) {
|
|
73
|
+
return !!info?.author && t.logins.includes(lower(info.author))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Whether trustDecision will need the author's repo permission.
|
|
77
|
+
function permissionNeeded(info, t) {
|
|
78
|
+
return t.requirePush && !isAllowlisted(info, t) && t.associations.includes(String(info?.authorAssociation).toUpperCase())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Is this PR's code safe to check out and run on the reviewer's machine?
|
|
82
|
+
// `info` is a github.prInfo result (or a listOpenPrs item). `permission` is the
|
|
83
|
+
// author's repo permission, needed only when an association has to be backed
|
|
84
|
+
// by push access: author_association alone is not an access check, since
|
|
85
|
+
// COLLABORATOR includes read-only outside collaborators and MEMBER includes
|
|
86
|
+
// org members with no push access. Any error fails closed.
|
|
87
|
+
//
|
|
88
|
+
// It vouches for the author and where the head lives, not for every commit on
|
|
89
|
+
// the head branch: anyone who can push to the author's fork can move it.
|
|
90
|
+
// `allowForks: false` requires a head in the repo itself.
|
|
91
|
+
export function trustDecision(info, { repo, trust, permission } = {}) {
|
|
92
|
+
try {
|
|
93
|
+
const t = normalizeTrust(trust)
|
|
94
|
+
const author = info.author
|
|
95
|
+
if (!isAllowlisted(info, t)) {
|
|
96
|
+
const association = String(info.authorAssociation ?? 'NONE').toUpperCase()
|
|
97
|
+
if (!t.associations.includes(association)) return { ok: false, reason: `author association ${association} is not trusted` }
|
|
98
|
+
if (t.requirePush && !PUSH_PERMISSIONS.includes(permission)) {
|
|
99
|
+
const has = permission === 'none' ? 'no' : permission || 'unknown'
|
|
100
|
+
return { ok: false, reason: `@${author} has ${has} access; push access is required` }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (info.headRepo == null) return { ok: false, reason: 'head repository was deleted' }
|
|
104
|
+
if (lower(info.headRepo) !== lower(repo)) {
|
|
105
|
+
const authorsFork = !!author && lower(info.headOwner) === lower(author)
|
|
106
|
+
if (!authorsFork) return { ok: false, reason: `head branch is in @${info.headOwner}'s fork (${info.headRepo}), not the author's` }
|
|
107
|
+
if (!t.allowForks) return { ok: false, reason: `head branch is in a fork (${info.headRepo}) and trust.allowForks is off` }
|
|
108
|
+
}
|
|
109
|
+
return { ok: true }
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { ok: false, reason: `trust check failed: ${err?.message ?? err}` }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validSha(value, what) {
|
|
116
|
+
const sha = typeof value === 'string' ? value.trim() : ''
|
|
117
|
+
if (!SHA_RE.test(sha)) throw new Error(`invalid SHA for ${what}: ${JSON.stringify(String(value)).slice(0, 80)}`)
|
|
118
|
+
return sha
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function assertPrNumber(pr) {
|
|
122
|
+
if (!Number.isSafeInteger(pr) || pr <= 0) throw new Error(`invalid PR number: ${typeof pr === 'string' ? JSON.stringify(pr) : String(pr)}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Tokens belong in a git credential helper, never in the URL: the URL lands in
|
|
126
|
+
// argv (visible in `ps`) and in git's own error messages. The message never
|
|
127
|
+
// echoes the URL.
|
|
128
|
+
function assertCloneUrl(url) {
|
|
129
|
+
if (typeof url !== 'string' || !url.trim() || url.startsWith('-')) throw new Error('cloneUrl must be a URL or a path')
|
|
130
|
+
const refuse = () => { throw new Error('cloneUrl must not contain credentials; use a git credential helper') }
|
|
131
|
+
let parsed = null
|
|
132
|
+
try { parsed = new URL(url) } catch { /* scp-like (git@host:path) or a local path */ }
|
|
133
|
+
if (parsed && url.slice(parsed.protocol.length).startsWith('//')) {
|
|
134
|
+
const sshUser = parsed.protocol === 'ssh:' && !parsed.password
|
|
135
|
+
if (parsed.password || (parsed.username && !sshUser)) refuse()
|
|
136
|
+
} else if (/^[^/@:]+:[^/@]*@/.test(url)) {
|
|
137
|
+
refuse() // user:secret@host:path
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertBaseRef(ref) {
|
|
142
|
+
const ok = typeof ref === 'string' && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) &&
|
|
143
|
+
!ref.includes('..') && !ref.includes('//') && !ref.endsWith('/') && !ref.endsWith('.lock')
|
|
144
|
+
if (!ok) throw new Error(`invalid baseRef: ${JSON.stringify(ref)}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertInstall(install) {
|
|
148
|
+
if (install === null) return
|
|
149
|
+
if (typeof install?.cmd !== 'string' || !install.cmd) throw new Error('install.cmd must be a non-empty string')
|
|
150
|
+
if (install.args !== undefined && !(Array.isArray(install.args) && install.args.every(a => typeof a === 'string'))) {
|
|
151
|
+
throw new Error('install.args must be an array of strings')
|
|
152
|
+
}
|
|
153
|
+
if (install.env !== undefined && (typeof install.env !== 'object' || install.env === null)) throw new Error('install.env must be an object')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Markers are keyed on what shaped the install. Env values are left out so a
|
|
157
|
+
// rotated secret in install.env doesn't invalidate every build.
|
|
158
|
+
function installFingerprint(install) {
|
|
159
|
+
const shape = install ? { cmd: install.cmd, args: install.args ?? [], envKeys: Object.keys(install.env ?? {}).sort() } : null
|
|
160
|
+
return createHash('sha256').update(JSON.stringify(shape)).digest('hex')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The last TAIL_LINES lines, at most TAIL_BYTES, of a failed exec's output.
|
|
164
|
+
// U1's exec errors carry stdout/stderr; older ones only a message.
|
|
165
|
+
//
|
|
166
|
+
// Output can run to tens of megabytes, so it is cut to a window before any
|
|
167
|
+
// line work, and trimmed with trimEnd: a /\s+$/ regex backtracks
|
|
168
|
+
// quadratically over a long run of inner whitespace, blocking the event loop.
|
|
169
|
+
// The window (TAIL_BYTES * 8 UTF-16 units) always encodes to more than
|
|
170
|
+
// TAIL_BYTES, so its possibly split first character never survives the cap.
|
|
171
|
+
function logTailOf(err) {
|
|
172
|
+
const stdout = err?.stdout == null ? '' : String(err.stdout)
|
|
173
|
+
const stderr = err?.stderr == null ? '' : String(err.stderr)
|
|
174
|
+
let text = (stdout && stderr && !stdout.endsWith('\n') ? `${stdout}\n${stderr}` : stdout + stderr).trimEnd()
|
|
175
|
+
if (!text) text = String(err?.message ?? err ?? '').trimEnd()
|
|
176
|
+
const tail = text.slice(-TAIL_BYTES * 8).split('\n').slice(-TAIL_LINES).join('\n')
|
|
177
|
+
let buf = Buffer.from(tail, 'utf8')
|
|
178
|
+
if (buf.length <= TAIL_BYTES) return tail
|
|
179
|
+
buf = buf.subarray(buf.length - TAIL_BYTES)
|
|
180
|
+
let i = 0
|
|
181
|
+
while (i < buf.length && (buf[i] & 0xc0) === 0x80) i++ // don't start mid-character
|
|
182
|
+
return buf.subarray(i).toString('utf8')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createWorktreeBuild({
|
|
186
|
+
repo,
|
|
187
|
+
cloneUrl = `https://github.com/${repo}.git`,
|
|
188
|
+
cacheDir,
|
|
189
|
+
github,
|
|
190
|
+
servicesFor,
|
|
191
|
+
install = null,
|
|
192
|
+
baseRef = 'main',
|
|
193
|
+
trust = DEFAULT_TRUST,
|
|
194
|
+
keep = 6,
|
|
195
|
+
migrationStrategy = 'on-boot',
|
|
196
|
+
execFileFn = makeExecFileFn(),
|
|
197
|
+
fsx = fsp,
|
|
198
|
+
baseEnv = process.env,
|
|
199
|
+
nowFn = Date.now,
|
|
200
|
+
} = {}) {
|
|
201
|
+
if (typeof repo !== 'string' || !REPO_RE.test(repo)) throw new Error(`repo must be 'owner/name', got ${JSON.stringify(repo)}`)
|
|
202
|
+
if (typeof cacheDir !== 'string' || !cacheDir) throw new Error('cacheDir is required')
|
|
203
|
+
if (typeof github?.prInfo !== 'function') throw new Error('github must provide prInfo(num)')
|
|
204
|
+
if (typeof github?.authorPermission !== 'function') throw new Error('github must provide authorPermission(login)')
|
|
205
|
+
if (typeof servicesFor !== 'function') throw new Error('servicesFor(worktreeDir, { role, sha }) is required')
|
|
206
|
+
if (!Number.isSafeInteger(keep) || keep < 0) throw new Error('keep must be a non-negative integer')
|
|
207
|
+
assertCloneUrl(cloneUrl)
|
|
208
|
+
assertBaseRef(baseRef)
|
|
209
|
+
assertInstall(install)
|
|
210
|
+
|
|
211
|
+
const trustCfg = normalizeTrust(trust)
|
|
212
|
+
const root = path.resolve(cacheDir)
|
|
213
|
+
const bare = path.join(root, 'repo.git')
|
|
214
|
+
const worktreesRoot = path.join(root, 'worktrees')
|
|
215
|
+
const builtDir = path.join(root, 'built')
|
|
216
|
+
const fingerprint = installFingerprint(install)
|
|
217
|
+
const worktreeDir = sha => path.join(worktreesRoot, sha)
|
|
218
|
+
const markerPath = sha => path.join(builtDir, `${sha}.json`)
|
|
219
|
+
|
|
220
|
+
// The SHAs the last live ensureBuilt resolved, for resolve*.
|
|
221
|
+
const resolved = { base: null, prs: new Map() }
|
|
222
|
+
// SHAs whose build holds the lock (describePrs reports them as building).
|
|
223
|
+
let building = new Set()
|
|
224
|
+
let listener = null
|
|
225
|
+
const permissionCache = new Map()
|
|
226
|
+
|
|
227
|
+
// Progress belongs to the call that made it. The core resubscribes on every
|
|
228
|
+
// boot, so a call keeps the subscriber it started with, and an aborted
|
|
229
|
+
// call's late messages (a failing prune, say) never reach the next boot.
|
|
230
|
+
function progressFor(signal) {
|
|
231
|
+
const cb = listener
|
|
232
|
+
return message => {
|
|
233
|
+
if (!cb || signal?.aborted) return
|
|
234
|
+
try {
|
|
235
|
+
const out = cb({ message })
|
|
236
|
+
if (typeof out?.then === 'function') out.then(undefined, () => {})
|
|
237
|
+
} catch { /* a broken subscriber must never fail a build */ }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- the lock: one build at a time -----------------------------------------
|
|
242
|
+
// Callers queue on a promise chain that never rejects. A waiter never
|
|
243
|
+
// shares the holder's promise, because an aborted holder's AbortError must
|
|
244
|
+
// not reach a live boot: it waits, then runs the whole build itself.
|
|
245
|
+
let lockTail = Promise.resolve()
|
|
246
|
+
let lockUsers = 0
|
|
247
|
+
async function withLock(fn, progress) {
|
|
248
|
+
const prev = lockTail
|
|
249
|
+
let release
|
|
250
|
+
lockTail = new Promise(resolve => { release = resolve })
|
|
251
|
+
if (lockUsers++ > 0) progress('waiting for another build to finish…')
|
|
252
|
+
try {
|
|
253
|
+
await prev
|
|
254
|
+
return await fn()
|
|
255
|
+
} finally {
|
|
256
|
+
lockUsers--
|
|
257
|
+
release()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- effects ---------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
// No prompts (a missing credential fails instead of hanging) and no hooks:
|
|
264
|
+
// `worktree add` runs post-checkout and ref updates run
|
|
265
|
+
// reference-transaction, so a user-level core.hooksPath or init.templateDir
|
|
266
|
+
// hook would otherwise run over PR code with this full env. Passed as env
|
|
267
|
+
// config (GIT_CONFIG_COUNT), appended to any the operator already set.
|
|
268
|
+
function gitEnv() {
|
|
269
|
+
const env = { ...baseEnv, GIT_TERMINAL_PROMPT: '0' }
|
|
270
|
+
const n = Number.parseInt(env.GIT_CONFIG_COUNT ?? '0', 10) || 0
|
|
271
|
+
env.GIT_CONFIG_COUNT = String(n + 1)
|
|
272
|
+
env[`GIT_CONFIG_KEY_${n}`] = 'core.hooksPath'
|
|
273
|
+
env[`GIT_CONFIG_VALUE_${n}`] = '/dev/null'
|
|
274
|
+
return env
|
|
275
|
+
}
|
|
276
|
+
const git = (args, signal) => execFileFn('git', ['--git-dir', bare, ...args], { signal, env: gitEnv() })
|
|
277
|
+
const exists = p => fsx.lstat(p).then(() => true, () => false)
|
|
278
|
+
|
|
279
|
+
// The only recursive delete: refuses anything outside worktrees/.
|
|
280
|
+
async function rmrf(target) {
|
|
281
|
+
const abs = path.resolve(target)
|
|
282
|
+
if (!abs.startsWith(worktreesRoot + path.sep)) throw new Error(`refusing to delete ${abs}: outside ${worktreesRoot}`)
|
|
283
|
+
await fsx.rm(abs, { recursive: true, force: true })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function revParse(ref, signal) {
|
|
287
|
+
const out = await git(['rev-parse', '--verify', ref], signal)
|
|
288
|
+
return validSha(String(out?.stdout ?? ''), ref)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Whoever can write to the cache can plant a marker and a tree for the
|
|
292
|
+
// public, predictable base SHA, which the next boot would run as the
|
|
293
|
+
// reviewer. So the cache and its marker and checkout directories must each
|
|
294
|
+
// be a real directory (not a symlink), ours, and not group- or
|
|
295
|
+
// world-writable. Owners and modes are skipped where there are none (Windows).
|
|
296
|
+
async function assertPrivateDir(dir) {
|
|
297
|
+
const st = await fsx.lstat(dir)
|
|
298
|
+
const refuse = why => { throw new Error(`refusing to build in ${dir}: ${why}`) }
|
|
299
|
+
if (st.isSymbolicLink()) refuse('it is a symlink; pass the real path as cacheDir')
|
|
300
|
+
if (!st.isDirectory()) refuse('it is not a directory')
|
|
301
|
+
const uid = process.getuid?.()
|
|
302
|
+
if (uid === undefined) return
|
|
303
|
+
if (st.uid !== uid) refuse(`it is owned by uid ${st.uid}, not ${uid}`)
|
|
304
|
+
if (st.mode & 0o022) refuse(`it is writable by group or others (mode ${(st.mode & 0o777).toString(8)}); chmod 700 it`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function ensureBareRepo(signal) {
|
|
308
|
+
await fsx.mkdir(root, { recursive: true, mode: 0o700 })
|
|
309
|
+
await assertPrivateDir(root)
|
|
310
|
+
for (const dir of [worktreesRoot, builtDir]) {
|
|
311
|
+
await fsx.mkdir(dir, { recursive: true, mode: 0o700 })
|
|
312
|
+
await assertPrivateDir(dir)
|
|
313
|
+
}
|
|
314
|
+
if (!(await exists(path.join(bare, 'HEAD')))) {
|
|
315
|
+
await execFileFn('git', ['init', '--bare', bare], { signal, env: gitEnv() })
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function readMarker(sha) {
|
|
320
|
+
return JSON.parse(String(await fsx.readFile(markerPath(sha), 'utf8')))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Built = a marker for this SHA and install shape, and its checkout present.
|
|
324
|
+
async function isBuilt(sha) {
|
|
325
|
+
try {
|
|
326
|
+
const m = await readMarker(sha)
|
|
327
|
+
return m?.sha === sha && m.installFingerprint === fingerprint && (await exists(worktreeDir(sha)))
|
|
328
|
+
} catch {
|
|
329
|
+
return false
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- trust ---------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
async function gate(info, getPermission) {
|
|
336
|
+
let permission = null
|
|
337
|
+
try {
|
|
338
|
+
if (permissionNeeded(info, trustCfg)) permission = await getPermission(info.author)
|
|
339
|
+
} catch (err) {
|
|
340
|
+
return { ok: false, reason: `could not check @${info?.author}'s permission: ${err?.message ?? err}` }
|
|
341
|
+
}
|
|
342
|
+
return trustDecision(info, { repo, trust: trustCfg, permission })
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const freshPermission = login => github.authorPermission(login)
|
|
346
|
+
|
|
347
|
+
// The picker polls describePrs, so permission lookups are memoized per login
|
|
348
|
+
// for five minutes. Failures aren't cached (they fail closed until retried).
|
|
349
|
+
function cachedPermission(login) {
|
|
350
|
+
const key = lower(login)
|
|
351
|
+
const hit = permissionCache.get(key)
|
|
352
|
+
if (hit && nowFn() - hit.at < PERMISSION_TTL_MS) return hit.promise
|
|
353
|
+
const promise = Promise.resolve().then(() => github.authorPermission(login))
|
|
354
|
+
permissionCache.set(key, { at: nowFn(), promise })
|
|
355
|
+
promise.catch(() => { if (permissionCache.get(key)?.promise === promise) permissionCache.delete(key) })
|
|
356
|
+
return promise
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The fetched head differs from the one we gated: accept it only if GitHub
|
|
360
|
+
// now reports exactly that SHA and it passes the gate again.
|
|
361
|
+
async function regate(pr, fetchedSha) {
|
|
362
|
+
const moved = cause => new Error(`PR #${pr} head moved during fetch; retry`, cause ? { cause } : undefined)
|
|
363
|
+
let again
|
|
364
|
+
try { again = await github.prInfo(pr) } catch (err) { throw moved(err) }
|
|
365
|
+
let sha = null
|
|
366
|
+
try { sha = validSha(again?.headSha, `PR #${pr} head`) } catch { /* treated as moved */ }
|
|
367
|
+
if (sha !== fetchedSha || !(await gate(again, freshPermission)).ok) throw moved()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- building ----------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
// `run` is one ensureBuilt call: { signal, progress }.
|
|
373
|
+
async function runInstall(dir, tag, { signal, progress }) {
|
|
374
|
+
if (!install) return
|
|
375
|
+
progress(`installing dependencies for ${tag}…`)
|
|
376
|
+
// Nothing else from the conductor's env reaches PR code (no tokens).
|
|
377
|
+
const env = {}
|
|
378
|
+
for (const key of ['PATH', 'HOME']) if (baseEnv[key] !== undefined) env[key] = baseEnv[key]
|
|
379
|
+
try {
|
|
380
|
+
await execFileFn(install.cmd, install.args ?? [], { cwd: dir, signal, env: { ...env, ...install.env } })
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (signal?.aborted || err?.name === 'AbortError') throw err
|
|
383
|
+
const status = err?.code ?? err?.signal ?? 'abnormally'
|
|
384
|
+
throw Object.assign(new Error(`install failed for ${tag}: ${install.cmd} exited ${status}`, { cause: err }), { logTail: logTailOf(err) })
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function buildTree(sha, who, run) {
|
|
389
|
+
const { signal, progress } = run
|
|
390
|
+
const tag = `${who} (${sha7(sha)})`
|
|
391
|
+
if (await isBuilt(sha)) {
|
|
392
|
+
progress(`${tag} already built`)
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
throwIfAborted(signal)
|
|
396
|
+
// A leftover marker (install shape changed, or its checkout was deleted)
|
|
397
|
+
// must not outlive the rebuild: drop it before touching the directory.
|
|
398
|
+
await fsx.rm(markerPath(sha), { force: true })
|
|
399
|
+
const dir = worktreeDir(sha)
|
|
400
|
+
await git(['worktree', 'prune'], signal) // drop registrations whose directory is gone
|
|
401
|
+
if (await exists(dir)) {
|
|
402
|
+
// A previous build died part-way: start from a clean checkout.
|
|
403
|
+
progress(`cleaning up a partial checkout of ${tag}…`)
|
|
404
|
+
await git(['worktree', 'remove', '-f', '-f', dir], signal).catch(() => {})
|
|
405
|
+
await rmrf(dir).catch(() => {})
|
|
406
|
+
await git(['worktree', 'prune'], signal).catch(() => {})
|
|
407
|
+
}
|
|
408
|
+
progress(`checking out ${tag}…`)
|
|
409
|
+
await git(['worktree', 'add', '-f', '-f', '--detach', dir, sha], signal)
|
|
410
|
+
await runInstall(dir, tag, run)
|
|
411
|
+
// Last, so a marker always means a complete checkout and install.
|
|
412
|
+
await fsx.writeFile(markerPath(sha), `${JSON.stringify({ sha, builtAt: nowFn(), installFingerprint: fingerprint })}\n`)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Best-effort: keep the newest `keep` markers and the SHAs just built, and
|
|
416
|
+
// clear everything else, including checkouts whose build never finished.
|
|
417
|
+
async function prune(current, { signal, progress }) {
|
|
418
|
+
const report = (what, err) => progress(`could not prune ${what}: ${err?.message ?? err}`)
|
|
419
|
+
try {
|
|
420
|
+
const markers = []
|
|
421
|
+
for (const name of await fsx.readdir(builtDir)) {
|
|
422
|
+
const m = /^(.+)\.json$/.exec(name)
|
|
423
|
+
if (!m || !SHA_RE.test(m[1])) continue
|
|
424
|
+
let builtAt = 0
|
|
425
|
+
try { builtAt = Number((await readMarker(m[1])).builtAt) || 0 } catch { /* unreadable: oldest */ }
|
|
426
|
+
markers.push({ sha: m[1], builtAt })
|
|
427
|
+
}
|
|
428
|
+
markers.sort((a, b) => b.builtAt - a.builtAt || a.sha.localeCompare(b.sha))
|
|
429
|
+
const retain = new Set([...markers.slice(0, keep).map(m => m.sha), ...current])
|
|
430
|
+
const candidates = new Set(markers.map(m => m.sha))
|
|
431
|
+
for (const name of await fsx.readdir(worktreesRoot).catch(() => [])) if (SHA_RE.test(name)) candidates.add(name)
|
|
432
|
+
|
|
433
|
+
const stale = [...candidates].filter(sha => !retain.has(sha))
|
|
434
|
+
for (const sha of stale) {
|
|
435
|
+
try {
|
|
436
|
+
// Marker first, so a half-removed tree can never count as built.
|
|
437
|
+
await fsx.rm(markerPath(sha), { force: true })
|
|
438
|
+
const dir = worktreeDir(sha)
|
|
439
|
+
await git(['worktree', 'remove', '-f', '-f', dir], signal).catch(() => rmrf(dir))
|
|
440
|
+
} catch (err) {
|
|
441
|
+
report(sha7(sha), err)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (stale.length) await git(['worktree', 'prune'], signal).catch(err => report('stale worktrees', err))
|
|
445
|
+
} catch (err) {
|
|
446
|
+
report('the build cache', err)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function buildLocked(pr, headSha, run) {
|
|
451
|
+
const { signal, progress } = run
|
|
452
|
+
throwIfAborted(signal)
|
|
453
|
+
building = new Set([headSha])
|
|
454
|
+
try {
|
|
455
|
+
await ensureBareRepo(signal)
|
|
456
|
+
progress(`fetching ${repo}…`)
|
|
457
|
+
await git([
|
|
458
|
+
'fetch', '--no-tags', cloneUrl,
|
|
459
|
+
`+refs/heads/${baseRef}:refs/qa/base`,
|
|
460
|
+
`+refs/pull/${pr}/head:refs/qa/pr-${pr}-incoming`,
|
|
461
|
+
], signal)
|
|
462
|
+
const baseSha = await revParse('refs/qa/base', signal)
|
|
463
|
+
const prSha = await revParse(`refs/qa/pr-${pr}-incoming`, signal)
|
|
464
|
+
if (prSha !== headSha) await regate(pr, prSha)
|
|
465
|
+
// From here on prSha is the gated SHA: it is the only PR code that gets
|
|
466
|
+
// pinned, checked out or installed.
|
|
467
|
+
await git(['update-ref', `refs/qa/pr-${pr}`, prSha], signal)
|
|
468
|
+
if (!signal?.aborted) {
|
|
469
|
+
resolved.base = baseSha
|
|
470
|
+
resolved.prs.set(pr, prSha)
|
|
471
|
+
}
|
|
472
|
+
building = new Set([baseSha, prSha])
|
|
473
|
+
await buildTree(baseSha, 'base', run)
|
|
474
|
+
await buildTree(prSha, `#${pr}`, run)
|
|
475
|
+
// An aborted signal fails every further exec, so there's nothing to gain.
|
|
476
|
+
if (!signal?.aborted) await prune([baseSha, prSha], run)
|
|
477
|
+
} finally {
|
|
478
|
+
building = new Set()
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
migrationStrategy,
|
|
484
|
+
|
|
485
|
+
// One subscriber, the latest: the core subscribes on every boot, just
|
|
486
|
+
// before its ensureBuilt, and runs one session at a time.
|
|
487
|
+
subscribeBuild(cb) { listener = cb },
|
|
488
|
+
|
|
489
|
+
async ensureBuilt(pr, { signal } = {}) {
|
|
490
|
+
// Bound before the first await, while it is still this call's subscriber.
|
|
491
|
+
const run = { signal, progress: progressFor(signal) }
|
|
492
|
+
assertPrNumber(pr)
|
|
493
|
+
// The gate runs outside the lock and before any git call.
|
|
494
|
+
const info = await github.prInfo(pr)
|
|
495
|
+
const decision = await gate(info, freshPermission)
|
|
496
|
+
if (!decision.ok) throw new Error(`PR #${pr} by @${info?.author ?? 'unknown'} is not from a trusted source: ${decision.reason}`)
|
|
497
|
+
const headSha = validSha(info.headSha, `PR #${pr} head`)
|
|
498
|
+
await withLock(() => buildLocked(pr, headSha, run), run.progress)
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
async resolvePrImages(pr) {
|
|
502
|
+
assertPrNumber(pr)
|
|
503
|
+
const sha = resolved.prs.get(pr)
|
|
504
|
+
if (!sha) throw new Error(`PR #${pr} has not been built; call ensureBuilt first`)
|
|
505
|
+
return { services: await servicesFor(worktreeDir(sha), { role: 'pr', sha }), migrate: null, label: `#${pr}@${sha7(sha)}` }
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
async resolveBaseImages() {
|
|
509
|
+
const sha = resolved.base
|
|
510
|
+
if (!sha) throw new Error('base has not been built; call ensureBuilt first')
|
|
511
|
+
return { services: await servicesFor(worktreeDir(sha), { role: 'base', sha }), migrate: null, label: `${baseRef}@${sha7(sha)}` }
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
// Picker readiness for listOpenPrs items. Items without authorAssociation
|
|
515
|
+
// (a bare { number, headSha }) aren't judged on trust; ensureBuilt still is.
|
|
516
|
+
async describePrs(prs) {
|
|
517
|
+
return Promise.all((prs ?? []).map(async item => {
|
|
518
|
+
const row = { number: item?.number, status: 'none', runUrl: null }
|
|
519
|
+
try {
|
|
520
|
+
if (item.authorAssociation != null) {
|
|
521
|
+
const decision = await gate(item, cachedPermission)
|
|
522
|
+
if (!decision.ok) return { ...row, status: 'blocked', reason: decision.reason }
|
|
523
|
+
}
|
|
524
|
+
const sha = validSha(item.headSha, `PR #${item.number} head`)
|
|
525
|
+
if (building.has(sha)) row.status = 'building'
|
|
526
|
+
else if (await isBuilt(sha)) row.status = 'built'
|
|
527
|
+
} catch { /* unknown readiness shows as none */ }
|
|
528
|
+
return row
|
|
529
|
+
}))
|
|
530
|
+
},
|
|
531
|
+
}
|
|
532
|
+
}
|