@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.
@@ -0,0 +1,136 @@
1
+ // tailscale serve Exposure adapter: publishes the conductor's mounts on this
2
+ // host's tailscaled through the tailscale CLI, and reports drift.
3
+ //
4
+ // It only ever adds or replaces handlers at the (port, path) pairs it is
5
+ // given. `serve --bg --https=P --set-path=/qa T` sits beside an existing /
6
+ // handler on P, and re-running a / handler keeps /qa (tailscaled 1.98.5), so
7
+ // another app's 8444 / and the harness's /qa can share a port. It never runs
8
+ // `serve reset` or `off`, so it never removes a handler: one an old origin
9
+ // left stays until the operator removes it, and one that shadows a mount is
10
+ // reported as drift, never rewritten.
11
+ //
12
+ // Every command goes through the injected execFileFn (makeExecFileFn from
13
+ // ./exec in production), so it is tested against recorded status JSON with
14
+ // no tailscaled. The platform constructs it; the core never does.
15
+
16
+ const SERVE_STATUS = ['serve', 'status', '--json']
17
+
18
+ // A Mount this adapter can pass to the CLI: an argument that can't be read
19
+ // as a flag, and a port and path serve accepts.
20
+ function assertMounts(mounts) {
21
+ if (!Array.isArray(mounts)) throw new Error('tailscale exposure: mounts must be an array')
22
+ for (const m of mounts) {
23
+ const ok = m != null &&
24
+ Number.isInteger(m.port) && m.port >= 1 && m.port <= 65535 &&
25
+ typeof m.path === 'string' && /^\/\S*$/.test(m.path) &&
26
+ typeof m.target === 'string' && /^https?:\/\/\S+$/.test(m.target)
27
+ if (!ok) throw new Error(`tailscale exposure: not a mount: ${JSON.stringify(m)}`)
28
+ }
29
+ }
30
+
31
+ const sameTarget = (a, b) => a.replace(/\/$/, '') === b.replace(/\/$/, '')
32
+ const proxyOf = handler => (typeof handler?.Proxy === 'string' ? handler.Proxy : null)
33
+ const snippet = text => JSON.stringify(text.length > 200 ? `${text.slice(0, 200)}…` : text)
34
+
35
+ export function createTailscaleExposure({ execFileFn, bin = 'tailscale', socket = null, timeoutMs = 30_000 } = {}) {
36
+ if (typeof execFileFn !== 'function') throw new TypeError('createTailscaleExposure needs execFileFn (makeExecFileFn() from ./exec)')
37
+ if (typeof bin !== 'string' || !bin) throw new TypeError(`createTailscaleExposure: bin must be the tailscale CLI's path or name, got ${JSON.stringify(bin)}`)
38
+ if (socket !== null && (typeof socket !== 'string' || !socket)) throw new TypeError(`createTailscaleExposure: socket must be a path or null, got ${JSON.stringify(socket)}`)
39
+ if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
40
+ throw new TypeError(`createTailscaleExposure: timeoutMs must be a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`)
41
+ }
42
+ // --socket is a global flag, so it goes before the subcommand. The timeout
43
+ // keeps a hung CLI from holding a reconcile pass forever.
44
+ const run = async args => {
45
+ const result = await execFileFn(bin, [...(socket ? [`--socket=${socket}`] : []), ...args], { timeout: timeoutMs })
46
+ return String(result?.stdout ?? '')
47
+ }
48
+
49
+ // The serve config. With none, the CLI prints {} (1.98.5); empty output and
50
+ // a JSON null mean the same.
51
+ async function status() {
52
+ const out = (await run(SERVE_STATUS)).trim()
53
+ if (!out) return {}
54
+ let st
55
+ try { st = JSON.parse(out) } catch {
56
+ throw new Error(`tailscale serve status --json printed something that is not JSON: ${snippet(out)}`)
57
+ }
58
+ if (st === null) return {}
59
+ if (typeof st !== 'object' || Array.isArray(st)) throw new Error(`tailscale serve status --json printed JSON that is not a JSON object: ${snippet(out)}`)
60
+ return st
61
+ }
62
+
63
+ // What tailscaled serves for a mount, from the status JSON:
64
+ // - `actual`: the proxy target served over https at the mount's own
65
+ // (port, path), or null;
66
+ // - `shadows`: the other handlers on that Web key that take some of the
67
+ // mount's requests and don't proxy to its target, each as
68
+ // `<path> -> <proxy>` or `<path> (not a proxy)`. tailscaled hands a
69
+ // request to the deepest handler path that holds it, trying `<dir>/`
70
+ // before `<dir>` (getServeHandler in ipn/ipnlocal/serve.go). So `/qa/`
71
+ // or `/qa/api` beside the harness's `/qa` takes harness requests, and
72
+ // any other path on a pane's port takes pane requests.
73
+ // The Web key is the mount's own host:port, else the first key on that port
74
+ // (a mount host that is a short MagicDNS name, say).
75
+ function served(st, { host, port, path, target }) {
76
+ if (st.TCP?.[String(port)]?.HTTPS !== true) return { actual: null, shadows: [] }
77
+ const keys = Object.keys(st.Web ?? {})
78
+ const exact = `${host}:${port}`.toLowerCase()
79
+ const key = keys.find(k => k.toLowerCase() === exact) ?? keys.find(k => k.endsWith(`:${port}`))
80
+ const handlers = (key === undefined ? undefined : st.Web[key]?.Handlers) ?? {}
81
+ const under = path.endsWith('/') ? path : `${path}/`
82
+ const shadows = []
83
+ for (const [p, handler] of Object.entries(handlers)) {
84
+ if (p === path || !p.startsWith(under)) continue
85
+ const proxy = proxyOf(handler)
86
+ if (proxy !== null && sameTarget(proxy, target)) continue
87
+ shadows.push(proxy === null ? `${p} (not a proxy)` : `${p} -> ${proxy}`)
88
+ }
89
+ return { actual: Object.hasOwn(handlers, path) ? proxyOf(handlers[path]) : null, shadows }
90
+ }
91
+
92
+ const inPlace = (actual, mount) => actual !== null && sameTarget(actual, mount.target)
93
+
94
+ // A mount drifts when its own handler is missing or points elsewhere
95
+ // (`actual` is that handler's target, or null), and once more for each
96
+ // handler that shadows it (`actual` names that handler).
97
+ async function check(mounts) {
98
+ assertMounts(mounts)
99
+ const st = await status()
100
+ const drift = []
101
+ for (const mount of mounts) {
102
+ const { actual, shadows } = served(st, mount)
103
+ if (!inPlace(actual, mount)) drift.push({ mount, actual })
104
+ for (const shadow of shadows) drift.push({ mount, actual: shadow })
105
+ }
106
+ return { ok: drift.length === 0, drift }
107
+ }
108
+
109
+ // Writes each mount whose own handler drifted, and only those: a handler
110
+ // that shadows a mount isn't the conductor's, so it is reported and left
111
+ // alone. One that fails doesn't stop the rest, so a port another listener
112
+ // holds can't keep the others down. Then it rejects, naming each failure,
113
+ // and the error's `added` lists the mounts that were written.
114
+ async function ensure(mounts) {
115
+ assertMounts(mounts)
116
+ const st = await status()
117
+ const added = []
118
+ const failed = []
119
+ for (const mount of mounts.filter(m => !inPlace(served(st, m).actual, m))) {
120
+ const args = ['serve', '--bg', `--https=${mount.port}`, ...(mount.path === '/' ? [] : [`--set-path=${mount.path}`]), mount.target]
121
+ try {
122
+ await run(args)
123
+ added.push(mount)
124
+ } catch (err) {
125
+ failed.push({ mount, err })
126
+ }
127
+ }
128
+ if (failed.length) {
129
+ const lines = failed.map(({ mount, err }) => `could not mount ${mount.port}${mount.path} -> ${mount.target}: ${err?.message ?? err}`)
130
+ throw Object.assign(new AggregateError(failed.map(f => f.err), lines.join('\n')), { added })
131
+ }
132
+ return { added, ok: mounts.filter(m => !added.includes(m)) }
133
+ }
134
+
135
+ return { check, ensure }
136
+ }
@@ -0,0 +1,116 @@
1
+ // Docker Provisioner adapter: the built-in Provisioner for docker-sibling
2
+ // deployments.
3
+ //
4
+ // It owns everything docker: the pane pg containers, the shared network, the
5
+ // app containers, registry auth, the one-shot migrate run, the pane env files
6
+ // (mode 0600, under workDir), the startup orphan sweep and the failure log
7
+ // tails. The conductor core never touches docker. A managed-Postgres or
8
+ // process-based deployment provides a peer provisioner; the core and the other
9
+ // four seams are identical either way.
10
+ //
11
+ // Single-service per pane: each pane runs its services as `qa-app-<role>`
12
+ // with one env file; multi-service docker panes are a follow-up when a
13
+ // consumer needs them.
14
+ //
15
+ // Effects are injected (docker wrappers, fsx) so this is unit-tested against a
16
+ // recording docker mock with no real containers.
17
+
18
+ import { renderEnv } from '../session.mjs'
19
+
20
+ export function createDockerProvisioner({
21
+ docker,
22
+ fsx,
23
+ // directory the pane env files are written to (must be readable by the
24
+ // docker daemon's `--env-file` path resolution, i.e. the conductor's cwd view)
25
+ workDir,
26
+ network = 'qa-session',
27
+ postgres = { user: 'qa', password: 'qa', db: 'postgres' },
28
+ // per-role loopback host ports the app container publishes on
29
+ hostPorts = { base: 3111, pr: 3112 },
30
+ // registry credentials: log in before pulls, relogin on pull retry
31
+ registry = null,
32
+ }) {
33
+ const ensureOpts = registry ? { relogin: () => docker.login(registry.user, registry.token) } : {}
34
+ const login = async () => {
35
+ if (registry) await docker.login(registry.user, registry.token)
36
+ }
37
+ const pgName = role => `qa-pg-${role}`
38
+ const appName = role => `qa-app-${role}`
39
+ const envFile = role => `${workDir}/.env.qa-${role}`
40
+ // The env carries prod-derived secrets: owner-only, and scrubbed on teardown.
41
+ const writeEnv = async (role, env) => {
42
+ const path = envFile(role)
43
+ await fsx.writeFile(path, renderEnv(env ?? {}), { mode: 0o600 })
44
+ return path
45
+ }
46
+
47
+ return {
48
+ network,
49
+
50
+ async provisionDatabase({ paneRef, databases }) {
51
+ await docker.createNetwork(network).catch(() => {}) // idempotent; shared across panes
52
+ const pg = pgName(paneRef.role)
53
+ await docker.runPg(pg, network)
54
+ await docker.waitHealthyPg(pg, {})
55
+ for (const d of databases) await docker.createDatabase(pg, d)
56
+ const dsn = `postgresql://${postgres.user}:${postgres.password}@${pg}:5432`
57
+ // query handle = docker-exec psql; the topology stays inside this adapter.
58
+ // query takes an optional {database} so adapters can target a specific
59
+ // logical DB (homefree's magic_links lives in `idp`, not the admin DB).
60
+ const db = { dsn, query: (sql, { database } = {}) => docker.psql(pg, database ?? postgres.db, sql) }
61
+ return { dsn, db }
62
+ },
63
+
64
+ async reserveServices({ paneRef, services }) {
65
+ // Docker host ports are deterministic per role, so reservation is pure:
66
+ // assign the loopback url/port without starting anything.
67
+ const port = hostPorts[paneRef.role]
68
+ const out = {}
69
+ for (const name of Object.keys(services)) out[name] = { url: `http://127.0.0.1:${port}`, port }
70
+ return out
71
+ },
72
+
73
+ // One-shot migration: run the migrate image on the pane network with the
74
+ // same env the app will get. Pulls explicitly (retry + relogin) so a
75
+ // transient registry hiccup can't half-fail a docker run.
76
+ async runMigrate({ paneRef, migrate, env }) {
77
+ await login()
78
+ await docker.ensureImage(migrate.image, ensureOpts)
79
+ const file = await writeEnv(paneRef.role, env.app ?? Object.values(env)[0])
80
+ await docker.runMigrate(migrate.image, network, file)
81
+ },
82
+
83
+ async launchServices({ paneRef, services, env, reserved }) {
84
+ await login()
85
+ for (const [name, image] of Object.entries(services)) {
86
+ await docker.ensureImage(image, ensureOpts)
87
+ const file = await writeEnv(paneRef.role, env[name])
88
+ await docker.runApp(appName(paneRef.role), image, network, file, reserved[name].port)
89
+ }
90
+ },
91
+
92
+ async waitHealthy({ services }) {
93
+ for (const svc of Object.values(services)) if (svc.port) await docker.waitHealthyApp(svc.port, {})
94
+ },
95
+
96
+ // Failure triage: the app container once it exists, the pg container before.
97
+ async logs({ paneRef, stage, lines = 40 }) {
98
+ const container = stage === 'starting' ? appName(paneRef.role) : pgName(paneRef.role)
99
+ return docker.logsTail(container, lines)
100
+ },
101
+
102
+ // Startup: remove labelled orphans (and the network) from a previous run.
103
+ async sweep() {
104
+ await docker.sweepQaContainers()
105
+ await docker.rmNetwork(network)
106
+ },
107
+
108
+ async teardown({ paneRef }) {
109
+ await docker.rmForce([appName(paneRef.role), pgName(paneRef.role)])
110
+ // Shared across panes; removal succeeds once the last pane is gone and
111
+ // is tolerated (rmNetwork swallows already-gone/in-use) otherwise.
112
+ await docker.rmNetwork(network)
113
+ await fsx.unlink(envFile(paneRef.role)).catch(() => {})
114
+ },
115
+ }
116
+ }