@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/exposure.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The Exposure seam's pure core: the public endpoints the conductor declares,
|
|
2
|
+
// and one reconcile pass over an adapter that makes them exist.
|
|
3
|
+
//
|
|
4
|
+
// An Exposure adapter (optional, injected by the platform; the core never
|
|
5
|
+
// constructs one) publishes Mounts on a front door such as tailscale serve:
|
|
6
|
+
// Mount = { name: 'harness'|'base'|'pr', host, port, path, target }
|
|
7
|
+
// Drift = { mount: Mount, actual: string | null }
|
|
8
|
+
// Exposure = { ensure(mounts) -> Promise<{ added: Mount[], ok: Mount[] }>,
|
|
9
|
+
// check(mounts) -> Promise<{ ok: boolean, drift: Drift[] }> }
|
|
10
|
+
// `host` and `port` are the mount's public side, from its own origin;
|
|
11
|
+
// `target` is where the conductor listens, on cfg.host. A mount is the
|
|
12
|
+
// conductor's own (port, path): an adapter never touches any other handler.
|
|
13
|
+
//
|
|
14
|
+
// mountsFor derives every mount from the origins viewers open, so the URL a
|
|
15
|
+
// viewer sees and the mount behind it can't disagree. No I/O here: the
|
|
16
|
+
// adapter owns every effect, and runExpose (the qa-conductor-expose bin's
|
|
17
|
+
// core) prints through the log it is given.
|
|
18
|
+
|
|
19
|
+
import { defaultExposure, defaultHarnessOrigin, EXPOSURE_MODES, HARNESS_PATH } from './config.mjs'
|
|
20
|
+
import { hostPort, isLoopbackHost, portOf, webOrigin } from './net.mjs'
|
|
21
|
+
|
|
22
|
+
const ORIGINS = [
|
|
23
|
+
['harness', 'the harness origin (QA_HARNESS_ORIGIN)'],
|
|
24
|
+
['base', 'the base pane origin (QA_BASE_ORIGIN)'],
|
|
25
|
+
['pr', 'the PR pane origin (QA_PR_ORIGIN)'],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const shown = value => (typeof value === 'string' ? JSON.stringify(value) : String(value))
|
|
29
|
+
|
|
30
|
+
// The three mounts for `cfg`: the harness under HARNESS_PATH at
|
|
31
|
+
// cfg.harnessOrigin's port, each pane at / at its origin's port. `ports` are
|
|
32
|
+
// the listen ports the targets point at; pass the bound ones when cfg.ports
|
|
33
|
+
// holds 0. Throws on a layout no adapter can publish.
|
|
34
|
+
export function mountsFor(cfg, { ports = cfg.ports } = {}) {
|
|
35
|
+
if (!cfg.harnessOrigin) throw new Error('exposure: no harness origin to mount (set QA_HARNESS_ORIGIN)')
|
|
36
|
+
const bind = cfg.host ?? '127.0.0.1'
|
|
37
|
+
const mounts = ORIGINS.map(([name, what]) => {
|
|
38
|
+
const value = name === 'harness' ? cfg.harnessOrigin : cfg.paneOrigins?.[name]
|
|
39
|
+
if (value == null || value === '') throw new Error(`exposure: ${what} is missing`)
|
|
40
|
+
const origin = webOrigin(value, `exposure: ${what}`)
|
|
41
|
+
// tailscale serve publishes https only
|
|
42
|
+
if (!origin.startsWith('https:')) throw new Error(`exposure: ${what} ${origin} is not https, and only an https origin can be mounted`)
|
|
43
|
+
// The URL parser takes port 0, and no front door can listen there.
|
|
44
|
+
const port = portOf(origin)
|
|
45
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`exposure: ${what} ${origin} has no port a front door can listen on`)
|
|
46
|
+
const listen = ports?.[name]
|
|
47
|
+
if (!Number.isInteger(listen) || listen < 1 || listen > 65535) {
|
|
48
|
+
throw new Error(`exposure: the ${name} listen port must be an integer from 1 to 65535, got ${shown(listen)} (pass the bound ports)`)
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
host: new URL(origin).hostname,
|
|
53
|
+
port,
|
|
54
|
+
path: name === 'harness' ? HARNESS_PATH : '/',
|
|
55
|
+
target: `http://${hostPort(bind, listen)}`,
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
// `serve --https=<port>` publishes on this node's name whatever the host, so
|
|
59
|
+
// two mounts on one port would share an origin, and the harness and the
|
|
60
|
+
// two panes must be three different origins.
|
|
61
|
+
for (const [i, a] of mounts.entries()) {
|
|
62
|
+
for (const b of mounts.slice(i + 1)) {
|
|
63
|
+
if (a.port === b.port) throw new Error(`exposure: the ${a.name} and ${b.name} mounts are both on port ${a.port}: each needs a port of its own`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return mounts
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// One pass: ensure then check, or check alone with checkOnly. Never rejects:
|
|
70
|
+
// exposure trouble must not take the conductor down, so an error comes back
|
|
71
|
+
// as { ok: false, error }, keeping whatever ensure added: all of it when the
|
|
72
|
+
// check fails, and the error's own `added` list when ensure fails part way.
|
|
73
|
+
export async function reconcileExposure(exposure, mounts, { checkOnly = false, now = Date.now } = {}) {
|
|
74
|
+
let added = []
|
|
75
|
+
try {
|
|
76
|
+
if (!checkOnly) {
|
|
77
|
+
const ensured = await exposure.ensure(mounts)
|
|
78
|
+
if (!Array.isArray(ensured?.added)) throw new Error('the exposure adapter\'s ensure returned no added list')
|
|
79
|
+
added = ensured.added
|
|
80
|
+
}
|
|
81
|
+
const checked = await exposure.check(mounts)
|
|
82
|
+
if (!Array.isArray(checked?.drift)) throw new Error('the exposure adapter\'s check returned no drift list')
|
|
83
|
+
return { ok: checked.ok === true && checked.drift.length === 0, checkedAt: now(), drift: checked.drift, added, error: null }
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return { ok: false, checkedAt: now(), drift: [], added: addedOn(err) ?? added, error: messageOf(err) }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The adapter is platform code and may throw anything: a value with no
|
|
90
|
+
// primitive form (Object.create(null)), or one whose getters throw, must not
|
|
91
|
+
// turn the result into a rejection.
|
|
92
|
+
function messageOf(err) {
|
|
93
|
+
try {
|
|
94
|
+
return String(err?.message ?? err)
|
|
95
|
+
} catch {
|
|
96
|
+
return 'the exposure adapter threw a value that is not an Error'
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function addedOn(err) {
|
|
101
|
+
try {
|
|
102
|
+
return Array.isArray(err?.added) ? err.added : null
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The expose CLI's core: one pass for `cfg` over `exposure`, printed for an
|
|
109
|
+
// operator, as an exit code. It is the conductor's own pass (mountsFor, then
|
|
110
|
+
// reconcileExposure) on the configured ports, so the two can't disagree
|
|
111
|
+
// about what should be mounted. 0: nothing to do (none mode), or every mount
|
|
112
|
+
// in place; 1: drift remains, or the adapter failed; 2: a mode, bind host or
|
|
113
|
+
// mount layout the conductor would refuse or no front door can publish.
|
|
114
|
+
// Never rejects for anything the adapter does.
|
|
115
|
+
export async function runExpose({ cfg, exposure, checkOnly = false, log = console }) {
|
|
116
|
+
// A cfg with no exposure or harnessOrigin resolves both as startConductor
|
|
117
|
+
// does; loadConfig's always has both.
|
|
118
|
+
let host, harnessOrigin, mode, because
|
|
119
|
+
try {
|
|
120
|
+
host = cfg.host ?? '127.0.0.1'
|
|
121
|
+
// startConductor refuses this too: every target would be http://[[::1]]:port.
|
|
122
|
+
if (/^\[.*\]$/.test(host)) {
|
|
123
|
+
throw new Error(`the bind host (QA_BIND_HOST, cfg.host) takes an IPv6 literal without brackets, as the conductor requires: use ${JSON.stringify(host.slice(1, -1))}, not ${JSON.stringify(host)}`)
|
|
124
|
+
}
|
|
125
|
+
harnessOrigin = cfg.harnessOrigin ?? defaultHarnessOrigin({ publicHost: cfg.publicHost, host, port: cfg.ports?.harness })
|
|
126
|
+
// `because` names what made a defaulted mode tailscale, for the error below
|
|
127
|
+
const resolved = cfg.exposure != null ? { mode: cfg.exposure, because: null } : defaultExposure({
|
|
128
|
+
harnessOrigin, paneOrigins: cfg.paneOrigins, publicHost: cfg.publicHost, allowedHosts: cfg.allowedHosts ?? [], host,
|
|
129
|
+
})
|
|
130
|
+
mode = resolved.mode
|
|
131
|
+
because = resolved.because
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log.error(`qa exposure: ${err.message}`)
|
|
134
|
+
return 2
|
|
135
|
+
}
|
|
136
|
+
if (!EXPOSURE_MODES.includes(mode)) {
|
|
137
|
+
log.error(`qa exposure: QA_EXPOSURE must be none or tailscale, got ${shown(mode)}`)
|
|
138
|
+
return 2
|
|
139
|
+
}
|
|
140
|
+
// An ungated conductor must not publish itself, so there is nothing to do.
|
|
141
|
+
if (mode === 'none') {
|
|
142
|
+
log.log('qa exposure: QA_EXPOSURE=none, nothing to do')
|
|
143
|
+
return 0
|
|
144
|
+
}
|
|
145
|
+
// The identity gate trusts Tailscale-User-Login only on a loopback bind, so
|
|
146
|
+
// loadConfig and startConductor refuse tailscale mode anywhere else, and
|
|
147
|
+
// the CLI must not publish a layout the conductor won't run.
|
|
148
|
+
if (!isLoopbackHost(host)) {
|
|
149
|
+
const why = because ? `QA_EXPOSURE defaults to tailscale because ${because} is not loopback` : 'QA_EXPOSURE is tailscale'
|
|
150
|
+
log.error(`qa exposure: ${why}, and the conductor runs tailscale mode only on a loopback bind host (QA_BIND_HOST, cfg.host), got ${JSON.stringify(host)}`)
|
|
151
|
+
return 2
|
|
152
|
+
}
|
|
153
|
+
let mounts
|
|
154
|
+
try {
|
|
155
|
+
mounts = mountsFor({ ...cfg, harnessOrigin })
|
|
156
|
+
} catch (err) {
|
|
157
|
+
log.error(`qa exposure: ${String(err.message).replace(/^exposure: /, '')}`)
|
|
158
|
+
return 2
|
|
159
|
+
}
|
|
160
|
+
const result = await reconcileExposure(exposure, mounts, { checkOnly })
|
|
161
|
+
try {
|
|
162
|
+
report(result, log)
|
|
163
|
+
} catch {
|
|
164
|
+
// The adapter is platform code: a mount whose getters throw.
|
|
165
|
+
log.error('qa exposure: the adapter returned a result that cannot be printed')
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
if (result.ok) {
|
|
169
|
+
log.log(`qa exposure: ok (harness ${webOrigin(harnessOrigin)}${HARNESS_PATH}/)`)
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
return 1
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function report(result, log) {
|
|
176
|
+
for (const m of result.added) log.log(`qa exposure: mounted ${at(m)} -> ${field(m?.target)}`)
|
|
177
|
+
for (const { mount, actual } of result.drift) {
|
|
178
|
+
// A handler under the mount's path, which check names by its path: no
|
|
179
|
+
// ensure replaces it, since it isn't the conductor's.
|
|
180
|
+
if (typeof actual === 'string' && actual.startsWith('/')) {
|
|
181
|
+
log.error(`qa exposure: drift ${at(mount)}: the handler at ${actual} takes some of its requests; remove it`)
|
|
182
|
+
} else {
|
|
183
|
+
log.error(`qa exposure: drift ${at(mount)}: want ${field(mount?.target)}, have ${typeof actual === 'string' ? actual : 'nothing'}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (result.error !== null) log.error(`qa exposure: ${result.error}`)
|
|
187
|
+
else if (!result.ok && result.drift.length === 0) log.error('qa exposure: the adapter reported drift but named no mount')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A mount as the adapter returned it, printed: only the Mount type's own
|
|
191
|
+
// values (a string, an integer port) are shown, anything else as `?`.
|
|
192
|
+
const field = value => (typeof value === 'string' || Number.isInteger(value) ? String(value) : '?')
|
|
193
|
+
const at = m => `${field(m?.port)}${field(m?.path)}`
|
package/lib/github.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// GitHub API client for the QA conductor. All effects go through the injected
|
|
2
|
+
// fetchFn (and sleepFn for polling) so tests never touch the network.
|
|
3
|
+
|
|
4
|
+
const API = 'https://api.github.com'
|
|
5
|
+
|
|
6
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
7
|
+
|
|
8
|
+
// `packageName` is the GHCR container package the GHCR helpers (tag lookup,
|
|
9
|
+
// RC baseline, preview-image wait) read; only those helpers need it. The
|
|
10
|
+
// versions endpoint is the authed-user path (`/user/...`); GHCR-under-an-org
|
|
11
|
+
// would need `/orgs/{owner}/...` — a follow-up when a consumer needs it.
|
|
12
|
+
//
|
|
13
|
+
// `token` authorizes the repo calls (pulls, collaborators, workflow dispatch
|
|
14
|
+
// and runs, issue comments and labels). `packagesToken` authorizes only the
|
|
15
|
+
// package versions listing, which a fine-grained PAT cannot call: pass a
|
|
16
|
+
// classic PAT with read:packages there (cfg.ghcrToken). It defaults to
|
|
17
|
+
// `token`.
|
|
18
|
+
export function createGithub({
|
|
19
|
+
token,
|
|
20
|
+
packagesToken = token,
|
|
21
|
+
repo,
|
|
22
|
+
fetchFn = fetch,
|
|
23
|
+
packageName = null,
|
|
24
|
+
// which GHCR tags count as release candidates for latestRcTag
|
|
25
|
+
rcTagPattern = /-rc\.\d+$/,
|
|
26
|
+
// the workflow that builds PR preview images, and the ref it is dispatched on
|
|
27
|
+
previewWorkflow = 'pr-preview.yml',
|
|
28
|
+
previewRef = 'main',
|
|
29
|
+
// the exclusive verdict label pair: [accept, reject]
|
|
30
|
+
qaLabels = ['qa-approved', 'qa-changes-requested'],
|
|
31
|
+
}) {
|
|
32
|
+
const packageVersionsPath = () => {
|
|
33
|
+
if (!packageName) throw new Error('createGithub: packageName is required for GHCR lookups')
|
|
34
|
+
return `/user/packages/container/${packageName}/versions`
|
|
35
|
+
}
|
|
36
|
+
async function request(method, path, body, auth = token) {
|
|
37
|
+
return fetchFn(`${API}${path}`, {
|
|
38
|
+
method,
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${auth}`,
|
|
41
|
+
Accept: 'application/vnd.github+json',
|
|
42
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
43
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
44
|
+
},
|
|
45
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function requestJson(method, path, body, auth = token) {
|
|
50
|
+
const res = await request(method, path, body, auth)
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const text = await res.text()
|
|
53
|
+
throw new Error(`github ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`)
|
|
54
|
+
}
|
|
55
|
+
if (res.status === 204) return null
|
|
56
|
+
return res.json()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function* packageVersions() {
|
|
60
|
+
for (let page = 1; ; page++) {
|
|
61
|
+
const versions = await requestJson('GET', `${packageVersionsPath()}?per_page=100&page=${page}`, undefined, packagesToken)
|
|
62
|
+
for (const version of versions) yield version
|
|
63
|
+
if (versions.length < 100) return
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function versionTags(version) {
|
|
68
|
+
return version.metadata?.container?.tags ?? []
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Who opened a PR and where its head lives: the inputs a BuildConvention's
|
|
72
|
+
// trust gate judges. head.repo is null when the head repository was deleted.
|
|
73
|
+
function trustFields(pr) {
|
|
74
|
+
return {
|
|
75
|
+
authorAssociation: pr.author_association ?? null,
|
|
76
|
+
headRepo: pr.head.repo?.full_name ?? null,
|
|
77
|
+
headOwner: pr.head.repo?.owner?.login ?? null,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function listOpenPrs() {
|
|
82
|
+
const prs = await requestJson('GET', `/repos/${repo}/pulls?state=open&per_page=50`)
|
|
83
|
+
return prs.map((pr) => ({
|
|
84
|
+
number: pr.number,
|
|
85
|
+
title: pr.title,
|
|
86
|
+
headSha: pr.head.sha,
|
|
87
|
+
headRef: pr.head.ref,
|
|
88
|
+
author: pr.user.login,
|
|
89
|
+
...trustFields(pr),
|
|
90
|
+
}))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function prHead(num) {
|
|
94
|
+
const pr = await requestJson('GET', `/repos/${repo}/pulls/${num}`)
|
|
95
|
+
return pr.head.sha
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function prInfo(num) {
|
|
99
|
+
const pr = await requestJson('GET', `/repos/${repo}/pulls/${num}`)
|
|
100
|
+
const { authorAssociation, headRepo, headOwner } = trustFields(pr)
|
|
101
|
+
return {
|
|
102
|
+
number: pr.number,
|
|
103
|
+
headSha: pr.head.sha,
|
|
104
|
+
author: pr.user?.login ?? null,
|
|
105
|
+
authorAssociation,
|
|
106
|
+
isDraft: Boolean(pr.draft),
|
|
107
|
+
headRepo,
|
|
108
|
+
headOwner,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The login's effective permission on the repo: admin | write | read | none.
|
|
113
|
+
// author_association alone is no access check (a read-only outside
|
|
114
|
+
// collaborator is still COLLABORATOR), so trust gates ask this too.
|
|
115
|
+
async function authorPermission(login) {
|
|
116
|
+
const res = await requestJson('GET', `/repos/${repo}/collaborators/${encodeURIComponent(login)}/permission`)
|
|
117
|
+
return res.permission
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function ghcrTagExists(tag) {
|
|
121
|
+
for await (const version of packageVersions()) {
|
|
122
|
+
if (versionTags(version).includes(tag)) return true
|
|
123
|
+
}
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function latestRcTag() {
|
|
128
|
+
let bestTag = null
|
|
129
|
+
let bestUpdated = ''
|
|
130
|
+
for await (const version of packageVersions()) {
|
|
131
|
+
const tag = versionTags(version).find((t) => rcTagPattern.test(t))
|
|
132
|
+
if (tag && (!bestTag || version.updated_at > bestUpdated)) {
|
|
133
|
+
bestTag = tag
|
|
134
|
+
bestUpdated = version.updated_at
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return bestTag
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function dispatchPreviewBuild(num) {
|
|
141
|
+
await requestJson('POST', `/repos/${repo}/actions/workflows/${previewWorkflow}/dispatches`, {
|
|
142
|
+
ref: previewRef,
|
|
143
|
+
inputs: { pr: String(num) },
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function awaitPreviewImage(num, sha, { timeoutMs = 900000, pollMs = 15000, sleepFn = defaultSleep, signal } = {}) {
|
|
148
|
+
const tag = `pr-${num}-${sha.slice(0, 12)}`
|
|
149
|
+
let waited = 0
|
|
150
|
+
for (;;) {
|
|
151
|
+
// A cancelled boot (teardown/takeover) must stop polling promptly rather
|
|
152
|
+
// than hold its wait for up to timeoutMs.
|
|
153
|
+
if (signal?.aborted) throw Object.assign(new Error(`aborted waiting for GHCR tag ${tag}`), { name: 'AbortError' })
|
|
154
|
+
if (await ghcrTagExists(tag)) return tag
|
|
155
|
+
if (waited >= timeoutMs) throw new Error(`timed out after ${timeoutMs}ms waiting for GHCR tag ${tag}`)
|
|
156
|
+
await sleepFn(pollMs)
|
|
157
|
+
waited += pollMs
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function runReferencesPr(run, num) {
|
|
162
|
+
const hay = `${run.display_title ?? ''} ${run.head_branch ?? ''} ${run.name ?? ''}`
|
|
163
|
+
return new RegExp(`(^|[^0-9])${num}([^0-9]|$)`).test(hay)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function findPreviewRun(num) {
|
|
167
|
+
const data = await requestJson('GET', `/repos/${repo}/actions/workflows/${previewWorkflow}/runs?per_page=10`)
|
|
168
|
+
const runs = Array.isArray(data) ? data : (data?.workflow_runs ?? [])
|
|
169
|
+
if (runs.length === 0) return null
|
|
170
|
+
const byRecency = [...runs].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
|
|
171
|
+
const chosen = byRecency.find((run) => runReferencesPr(run, num)) ?? byRecency[0]
|
|
172
|
+
return {
|
|
173
|
+
url: chosen.html_url,
|
|
174
|
+
status: chosen.status,
|
|
175
|
+
conclusion: chosen.conclusion ?? null,
|
|
176
|
+
startedAt: chosen.run_started_at ?? chosen.created_at,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function listPrImageTags() {
|
|
181
|
+
const tags = new Set()
|
|
182
|
+
for await (const version of packageVersions()) {
|
|
183
|
+
for (const tag of versionTags(version)) tags.add(tag)
|
|
184
|
+
}
|
|
185
|
+
return [...tags]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function postComment(num, body) {
|
|
189
|
+
const comment = await requestJson('POST', `/repos/${repo}/issues/${num}/comments`, { body })
|
|
190
|
+
return comment.html_url
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function setQaLabel(num, label) {
|
|
194
|
+
if (!qaLabels.includes(label)) throw new Error(`unknown QA label: ${label}`)
|
|
195
|
+
const opposite = qaLabels.find((l) => l !== label)
|
|
196
|
+
await requestJson('POST', `/repos/${repo}/issues/${num}/labels`, { labels: [label] })
|
|
197
|
+
const res = await request('DELETE', `/repos/${repo}/issues/${num}/labels/${encodeURIComponent(opposite)}`)
|
|
198
|
+
if (!res.ok && res.status !== 404) {
|
|
199
|
+
const text = await res.text()
|
|
200
|
+
throw new Error(`github DELETE label ${opposite} -> ${res.status}: ${text.slice(0, 200)}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
listOpenPrs,
|
|
206
|
+
prHead,
|
|
207
|
+
prInfo,
|
|
208
|
+
authorPermission,
|
|
209
|
+
ghcrTagExists,
|
|
210
|
+
latestRcTag,
|
|
211
|
+
dispatchPreviewBuild,
|
|
212
|
+
awaitPreviewImage,
|
|
213
|
+
findPreviewRun,
|
|
214
|
+
listPrImageTags,
|
|
215
|
+
postComment,
|
|
216
|
+
setQaLabel,
|
|
217
|
+
}
|
|
218
|
+
}
|
package/lib/identity.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Tailscale identity gate for the harness and both pane proxies (#307).
|
|
2
|
+
//
|
|
3
|
+
// tailscale serve sets Tailscale-User-Login on the requests it proxies from a
|
|
4
|
+
// user's device, strips any copy the client sent, and sets none for tagged
|
|
5
|
+
// devices. The header can be trusted only because the conductor listens on
|
|
6
|
+
// loopback (cfg.host), so serve on the same host is the only way in.
|
|
7
|
+
//
|
|
8
|
+
// The allowlist is cfg.allowedLogins, from the comma-separated
|
|
9
|
+
// QA_ALLOWED_LOGINS. The names and refusal strings are homefree #329's, which
|
|
10
|
+
// its access-guard tests assert.
|
|
11
|
+
|
|
12
|
+
const LOGIN_HEADER = 'tailscale-user-login'
|
|
13
|
+
|
|
14
|
+
function headerValue(headers, name) {
|
|
15
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
16
|
+
if (key.toLowerCase() === name) return Array.isArray(value) ? value.join(', ') : String(value)
|
|
17
|
+
}
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeLogins(list) {
|
|
22
|
+
return (list ?? []).map(login => String(login).trim().toLowerCase()).filter(Boolean)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Why a request must be refused, or null to serve it. An empty allowlist
|
|
26
|
+
// refuses everyone.
|
|
27
|
+
export function refusalReason(headers, allowlist) {
|
|
28
|
+
const login = headerValue(headers, LOGIN_HEADER)?.trim()
|
|
29
|
+
if (!login) return 'no Tailscale identity: open this through tailscale serve from an allowed user\'s device'
|
|
30
|
+
if (!normalizeLogins(allowlist).includes(login.toLowerCase())) return `${login} is not in QA_ALLOWED_LOGINS`
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isAllowed(headers, allowlist) {
|
|
35
|
+
return refusalReason(headers, allowlist) === null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Wraps a request handler so that only allowed logins reach it; the rest get
|
|
39
|
+
// a short plain-text 403 that carries no conductor state.
|
|
40
|
+
export function identityGate(handler, allowlist) {
|
|
41
|
+
return (req, res) => {
|
|
42
|
+
const reason = refusalReason(req.headers, allowlist)
|
|
43
|
+
if (reason === null) return handler(req, res)
|
|
44
|
+
res.writeHead(403, {
|
|
45
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
46
|
+
'x-content-type-options': 'nosniff',
|
|
47
|
+
'cache-control': 'no-store',
|
|
48
|
+
})
|
|
49
|
+
res.end(`403: ${reason}\n`)
|
|
50
|
+
}
|
|
51
|
+
}
|
package/lib/net.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Address and origin helpers shared by the config loader, the servers and the
|
|
2
|
+
// request guards. Pure: no I/O.
|
|
3
|
+
|
|
4
|
+
import { isIPv4 } from 'node:net'
|
|
5
|
+
|
|
6
|
+
// A hostname a CSP source and a Host allowlist can both carry: DNS labels
|
|
7
|
+
// (letters, digits, `-`) or a bracketed IPv6 literal. The URL parser accepts
|
|
8
|
+
// more (`;`, `,`, `'`, `*`), and those would change a policy's meaning.
|
|
9
|
+
const SAFE_HOSTNAME = /^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*\.?|\[[0-9a-f:.]+\])$/
|
|
10
|
+
|
|
11
|
+
// host:port, with brackets around an IPv6 literal.
|
|
12
|
+
export const hostPort = (host, port) => (host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`)
|
|
13
|
+
|
|
14
|
+
// True for 127.0.0.0/8, ::1 and localhost. `[]` is stripped, since
|
|
15
|
+
// URL.hostname keeps it on an IPv6 literal.
|
|
16
|
+
export function isLoopbackHost(name) {
|
|
17
|
+
const h = String(name ?? '').toLowerCase().replace(/^\[(.*)\]$/, '$1')
|
|
18
|
+
return h === '::1' || h === 'localhost' || (isIPv4(h) && h.startsWith('127.'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A web origin, normalized: scheme and host lowercased, a default port, any
|
|
22
|
+
// path and a trailing slash dropped. Anything that isn't an http(s) URL with
|
|
23
|
+
// a plain hostname throws, naming `what` (a config key or a cfg field).
|
|
24
|
+
// `new URL('file:///x').origin` and a data: URL's are the string 'null'.
|
|
25
|
+
export function webOrigin(value, what = 'an origin') {
|
|
26
|
+
let url
|
|
27
|
+
try { url = new URL(value) } catch { url = null }
|
|
28
|
+
if ((url?.protocol !== 'https:' && url?.protocol !== 'http:') || !SAFE_HOSTNAME.test(url.hostname)) {
|
|
29
|
+
const shown = typeof value === 'string' ? JSON.stringify(value) : String(value)
|
|
30
|
+
throw new Error(`${what} must be an origin such as https://host:8444, got ${shown}`)
|
|
31
|
+
}
|
|
32
|
+
return url.origin
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The port an http(s) origin is served on: its own, else the scheme's default
|
|
36
|
+
// (443 for https, 80 for http), which URL.port leaves empty.
|
|
37
|
+
export function portOf(origin) {
|
|
38
|
+
const url = new URL(origin)
|
|
39
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error(`portOf needs an http(s) origin, got ${JSON.stringify(String(origin))}`)
|
|
40
|
+
return Number(url.port || (url.protocol === 'https:' ? 443 : 80))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Throws unless browsers send Fetch Metadata (Sec-Fetch-*) to `origin`, a
|
|
44
|
+
// webOrigin() result. They send it only to potentially trustworthy origins:
|
|
45
|
+
// https, and http on a loopback host. Without it, and with no Origin on a
|
|
46
|
+
// GET, another page's <img>, <iframe> or link looks like a non-browser
|
|
47
|
+
// client, so `guard` (the request guard that depends on it) would admit it.
|
|
48
|
+
// The message names `what` (a config key or a cfg field).
|
|
49
|
+
export function requireFetchMetadata(origin, what, guard) {
|
|
50
|
+
const url = new URL(origin)
|
|
51
|
+
if (url.protocol === 'https:' || isLoopbackHost(url.hostname)) return
|
|
52
|
+
throw new Error(
|
|
53
|
+
`${what} ${origin}: browsers send no Sec-Fetch-* headers to a plain-http origin off loopback, ` +
|
|
54
|
+
`so the ${guard} can't tell other pages apart; use https or a loopback address`,
|
|
55
|
+
)
|
|
56
|
+
}
|