Repository navigation
Expand file tree
/
Copy pathtest-stack.mjs
More file actions
239 lines (222 loc) · 11.4 KB
/
Copy pathtest-stack.mjs
File metadata and controls
239 lines (222 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
// #region test-stack — docs: README.md#the-disposable-test-stack
// The one place a database-backed probe learns where its database is (2026-10-03 release
// review, iteration 1, findings 2 and 3).
//
// WHY THIS EXISTS. Until this module, about twenty probes ran `supabase status` at the
// repository root and took whatever it answered. That is the operator's LIVE stack — project
// `fabric`, API 54321, DB 54322 — the same database the desktop app keeps their real estates
// in. The probes wrote random-id estates, persons and projects into it and cleaned up only part
// of them (the live database held 768 estates, 3 541 projects and 996 persons in TOTAL;
// `scripts/residue-report.mjs` classes 716, 3 475 and 994 of them as probe RESIDUE it would
// remove — both counted 2026-10-03, release review iteration 3), one revoked
// SELECT on `memory_facts` there for the length of a call, and `ci.sh full` ran
// `supabase migration up` against it. An override could not help: the probes overwrote
// `SUPABASE_URL` with the root's answer.
//
// THE RULE NOW. A probe reads its connection from the environment the tier sets, and only from
// there: `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL`, and `FABRIC_TEST_STACK`
// naming the disposable project. Nothing falls back to `supabase status`. Before anything
// connects, the address is compared with the live stack's ports — read from the root
// `supabase/config.toml`, plus 54321/54322 whatever the file says — and a match is REFUSED.
// The disposable stack itself is made by `scripts/test-stack.mjs`.
import { readFileSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
/** The ports the live stack listens on whatever its config says today (README, external-contracts). */
export const LIVE_DEFAULT_PORTS = Object.freeze([54321, 54322])
/** A disposable project id. Anything else is never stopped with `--no-backup` by this tooling. */
export const DISPOSABLE_ID = /^fabric_test_[0-9a-f]{6,32}$/
// Where each port of a disposable stack sits inside its block of ten. Distinct by construction;
// the test pins that, because two services on one port fail as a start that "hangs".
export const PORT_OFFSETS = Object.freeze({
'db.shadow_port': 0,
'api.port': 1,
'db.port': 2,
'studio.port': 3,
'local_smtp.port': 4,
'edge_runtime.inspector_port': 6,
'analytics.port': 7,
'db.pooler.port': 9
})
export const PORT_BLOCK = 10
const SECTION = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/
const ASSIGN = /^(\s*)([A-Za-z0-9_]+)(\s*=\s*)(.+?)(\s*(?:#.*)?)$/
/**
* The project id and every port a Supabase `config.toml` declares, keyed `section.key`.
* A deliberately small reader: the file is generated by the CLI and the two things read here
* are plain scalars. Commented lines are ignored, so a commented-out port is not a port.
*/
export function parseStackConfig(text) {
let section = ''
let projectId = null
const ports = {}
for (const raw of String(text).split('\n')) {
if (/^\s*#/.test(raw)) continue
const s = raw.match(SECTION)
if (s) { section = s[1].trim(); continue }
const a = raw.match(ASSIGN)
if (!a) continue
const [, , key, , value] = a
if (!section && key === 'project_id') projectId = value.replace(/^"(.*)"$/, '$1')
if (/(^|_)port$/.test(key) && /^\d+$/.test(value)) ports[`${section}.${key}`] = Number(value)
}
return { projectId, ports }
}
/** The live stack: what the repository root declares, and the two ports it has always had. */
export function liveStack(root = REPO_ROOT) {
const file = path.join(root, 'supabase', 'config.toml')
const parsed = existsSync(file) ? parseStackConfig(readFileSync(file, 'utf8')) : { projectId: 'fabric', ports: {} }
const ports = new Set([...LIVE_DEFAULT_PORTS, ...Object.values(parsed.ports)])
return {
projectId: parsed.projectId ?? 'fabric',
apiPort: parsed.ports['api.port'] ?? 54321,
dbPort: parsed.ports['db.port'] ?? 54322,
ports
}
}
/**
* The root config rewritten for a disposable stack: its own project id, and every port moved into
* the block that starts at `base`. A port key this table does not know still moves — to the next
* unused offset — so a service added to the config later cannot keep a live port by accident.
*/
export function disposableConfig(text, { projectId, base }) {
if (!DISPOSABLE_ID.test(projectId)) throw new Error(`not a disposable project id: ${projectId}`)
if (!Number.isInteger(base) || base < 1024 || base + PORT_BLOCK > 65535) throw new Error(`bad base port: ${base}`)
const used = new Set(Object.values(PORT_OFFSETS))
const extra = new Map()
const offsetOf = key => {
if (key in PORT_OFFSETS) return PORT_OFFSETS[key]
if (!extra.has(key)) {
let o = 0
while (used.has(o)) o++
if (o >= PORT_BLOCK) throw new Error(`more ports than the block holds: ${key}`)
used.add(o); extra.set(key, o)
}
return extra.get(key)
}
let section = ''
let sawId = false
const out = String(text).split('\n').map(raw => {
if (/^\s*#/.test(raw)) return raw
const s = raw.match(SECTION)
if (s) { section = s[1].trim(); return raw }
const a = raw.match(ASSIGN)
if (!a) return raw
const [, lead, key, eq, value, tail] = a
if (!section && key === 'project_id') { sawId = true; return `${lead}${key}${eq}"${projectId}"${tail}` }
if (/(^|_)port$/.test(key) && /^\d+$/.test(value)) return `${lead}${key}${eq}${base + offsetOf(`${section}.${key}`)}${tail}`
return raw
})
if (!sawId) out.unshift(`project_id = "${projectId}"`)
return out.join('\n')
}
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]', '::1'])
function portOf(value, defaultPort) {
try {
const u = new URL(value)
return { host: u.hostname, port: u.port ? Number(u.port) : defaultPort }
} catch {
return null
}
}
/**
* pg's own connection-string parser, loaded from beside `pg` (a dependency of @fabric/schema): the guard
* must read DATABASE_URL exactly as the probes' client will, not as `new URL` does. Null when it cannot
* be loaded — the caller then refuses, because an address it cannot read the client's way is unverified.
*/
let pgParse
function pgConnectionParse() {
if (pgParse !== undefined) return pgParse
try {
const fromSchema = createRequire(path.join(REPO_ROOT, 'packages', 'schema', 'package.json'))
const fromPg = createRequire(fromSchema.resolve('pg/package.json'))
const mod = fromPg('pg-connection-string')
pgParse = mod.parse ?? mod
} catch {
pgParse = null
}
return pgParse
}
/**
* Query parameters through which libpq and pg move a connection somewhere the URL does not say. `service`
* (iteration 3): libpq then reads host and port from the connection service file, which can name anything.
*/
const ADDRESS_PARAMS = ['host', 'hostaddr', 'port', 'service']
/**
* libpq variables that move a psql-based probe's connection somewhere DATABASE_URL does not say (iteration
* 3, errors finding 11): a service read from the service file, the file itself, and a numeric address that
* outranks the host. `probeEnv` removes them; nothing in a probe needs them.
*/
export const REDIRECTING_PG_ENV = Object.freeze(['PGSERVICE', 'PGSERVICEFILE', 'PGHOSTADDR'])
/**
* Where `pg` will actually connect for this DATABASE_URL and environment (release review 2026-10-03,
* iteration 2): pg-connection-string's host and port — a `?host=` or `?port=` in the query overrides the
* URL's own — then PGHOST / PGPORT when the string names none, then localhost:5432. A host, port or
* service in the query string is refused outright: the address must be the one a person reads in the URL.
* `missingPort` is set when the URL names no port of its own (iteration 3: refused by `checkTestStack`,
* which still reports where pg would have gone).
*/
export function pgTarget(value, env = process.env) {
let url
try { url = new URL(value) } catch { return { error: 'is not a URL' } }
const inQuery = ADDRESS_PARAMS.filter((k) => url.searchParams.has(k))
if (inQuery.length) return { error: `carries ${inQuery.join(', ')} in its query string, which overrides the address the URL shows` }
const parse = pgConnectionParse()
if (!parse) return { error: 'cannot be read the way the pg client reads it (pg-connection-string did not load)' }
const parsed = parse(value)
const host = parsed.host || env.PGHOST || 'localhost'
const port = Number(parsed.port || env.PGPORT || 5432)
return { host: host.replace(/^\[(.*)\]$/, '$1'), port, missingPort: !parsed.port }
}
/**
* Every reason this environment must NOT be used by a database-backed probe. Empty means it names
* a disposable stack. Pure, so the refusals are tested one by one (scripts/test/test-stack.test.mjs).
*/
export function checkTestStack(env, live = liveStack()) {
const reasons = []
const need = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'DATABASE_URL', 'FABRIC_TEST_STACK']
for (const name of need) if (!env[name]) reasons.push(`${name} is not set — the tier sets it for a disposable stack`)
if (env.FABRIC_TEST_STACK && env.FABRIC_TEST_STACK === live.projectId)
reasons.push(`FABRIC_TEST_STACK names the live project "${live.projectId}"`)
if (env.FABRIC_TEST_STACK && !DISPOSABLE_ID.test(env.FABRIC_TEST_STACK))
reasons.push(`FABRIC_TEST_STACK "${env.FABRIC_TEST_STACK}" is not a disposable project id (fabric_test_<hex>)`)
const targets = [
['SUPABASE_URL', env.SUPABASE_URL, (v) => portOf(v, new URL(v).protocol === 'https:' ? 443 : 80)],
// Read the way the probes' pg client reads it, environment included — not the way `new URL` does.
['DATABASE_URL', env.DATABASE_URL, (v) => pgTarget(v, env)]
]
for (const [name, value, read] of targets) {
if (!value) continue
let at
try { at = read(value) } catch { at = null }
if (!at) { reasons.push(`${name} is not a URL`); continue }
if (at.error) { reasons.push(`${name} ${at.error}`); continue }
if (at.missingPort)
reasons.push(`${name} names no port; pg would connect to port ${at.port} (from PGPORT or its default) — an explicit port is required`)
if (live.ports.has(at.port))
reasons.push(`${name} points at port ${at.port}, which is the LIVE stack's (API ${live.apiPort}, DB ${live.dbPort}) — the operator's database`)
if (!LOOPBACK.has(at.host)) reasons.push(`${name} host ${at.host} is not loopback — a disposable stack is local by construction`)
}
return reasons
}
/**
* The environment a database-backed probe runs with, or a refusal and exit 1 BEFORE any client
* exists. A refusal is a FAIL, not a skip: a probe that asserts nothing must not read as green
* (M110). Returns a copy of `process.env`, ready to hand to a child, without the libpq variables that
* would redirect a psql child (`REDIRECTING_PG_ENV`) — removed from `env` itself too, so a probe that
* spawns from its own environment is covered as well.
*/
export function probeEnv({ env = process.env, live = liveStack(), exit = code => process.exit(code), log = console.log } = {}) {
const reasons = checkTestStack(env, live)
if (reasons.length) {
for (const r of reasons) log(` FAIL ${r}`)
log(' FAIL refusing to connect: database probes run only against a disposable stack — `bash scripts/ci.sh full`, or `node scripts/test-stack.mjs run -- <command>`')
exit(1)
return null
}
for (const k of REDIRECTING_PG_ENV) delete env[k]
return { ...env }
}
// #endregion test-stack