@agent-flow-board/bridge 0.2.0 → 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/README.md +16 -0
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +3 -2
- package/dist/checks/bind.js +3 -2
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +137 -32
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/command.js +75 -88
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connections.js +4 -3
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +649 -0
- package/dist/checks/envPolicy.js +50 -1
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +28 -4
- package/dist/checks/login.js +203 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +24 -0
- package/dist/checks/preview.js +418 -191
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +1 -1
- package/dist/checks/recipe.js +19 -5
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +243 -53
- package/dist/checks/sandbox.js +32 -10
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +119 -30
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +452 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +7 -1
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +190 -63
- package/dist/checks/wire.js +87 -0
- package/package.json +1 -1
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
// THE POOLED THROWAWAY DATABASES OF THIS MACHINE: ONE POSTGRES CONTAINER, A TEMPLATE DATABASE WARMED ONCE, A CLONE (WITH ITS OWN ROLES) PER RUN.
|
|
2
|
+
// A container per run costs seconds of start-up and a migration each; here the container is made once (afb-checks-pool-<id>), the schema is migrated and
|
|
3
|
+
// seeded once into a template database keyed by recipe + migration hash, and every run gets `CREATE DATABASE clone TEMPLATE template` (well under a second).
|
|
4
|
+
//
|
|
5
|
+
// SAFETY, in the same terms as provision.ts: every docker command goes through checkDockerArgs, so only containers named afb-checks-... are ever touched, the
|
|
6
|
+
// port is bound to 127.0.0.1 and is never 5433 or another forbidden port, and the person's own database container is never named. A pooled run records only
|
|
7
|
+
// { dbName, roleName, poolId } in its marker (never a container name), so a sweep of a dead run drops that run's database and roles and cannot remove the pool.
|
|
8
|
+
//
|
|
9
|
+
// ISOLATION: CONNECT is revoked from PUBLIC on the template, the clone, `postgres` and `template1`; each clone grants CONNECT to its own login role only. The
|
|
10
|
+
// recipe's runtime role (for example app_runtime) exists once as a NOLOGIN group, so the migrations can grant to it; each run's login role is a member of it.
|
|
11
|
+
// Another run's role has the group's table rights in the other clone but cannot connect to it. An owner role owns the clone's database and objects (ownerUrl).
|
|
12
|
+
//
|
|
13
|
+
// FILE LAYOUT (poolDir()):
|
|
14
|
+
// db-<poolId>.json { kind:'db', poolId, containerName, port, ownerPid, ownerStartedAt, image, maxConnections, slots, password, createdAt }
|
|
15
|
+
// written BEFORE the container starts (a crash leaves a recorded name); mode 0600; the superuser password lives only here.
|
|
16
|
+
// db-<poolId>.runs.json { runs: { <runId>: { pid, at, dbName, roleName } } }, changed only under the lock "dbruns-<poolId>"; its live entries are the live-run count.
|
|
17
|
+
// The container is removed by its owner process when no run is live (removeWhenIdle) or by sweepPool() (doctor) when the owner is dead, no run is live and the
|
|
18
|
+
// marker is old enough. Nothing else removes it.
|
|
19
|
+
// -- DEP-MODULES -- //
|
|
20
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
21
|
+
import { readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
// -- MODULES -- //
|
|
24
|
+
import { FORBIDDEN_PORTS } from './guardrails.js';
|
|
25
|
+
import { acquireLock, pidAlive, withLockSync } from './lock.js';
|
|
26
|
+
import { NAME, ensureDir, poolDir } from './paths.js';
|
|
27
|
+
import { renameRetry } from './fsretry.js';
|
|
28
|
+
import { CONTAINER_PREFIX, checkDockerArgs, freePort, makeThrowawayDatabase, realDocker, removeContainer } from './provision.js';
|
|
29
|
+
// -- CONSTANTS -- //
|
|
30
|
+
export const POOL_PREFIX = `${CONTAINER_PREFIX}pool-`;
|
|
31
|
+
const DEFAULT_IMAGE = 'postgres:17';
|
|
32
|
+
/** Connections one run may hold (its app, its migrations and a check), and the base for the superuser and the tool itself. */
|
|
33
|
+
const CONNECTIONS_PER_RUN = 12;
|
|
34
|
+
const BASE_CONNECTIONS = 20;
|
|
35
|
+
const READY_COMMENT = 'afb-ready';
|
|
36
|
+
const POOL_ID = /^[a-f0-9]{6,16}$/;
|
|
37
|
+
const ROLE = /^[a-z_][a-z0-9_]{0,40}$/;
|
|
38
|
+
const RESERVED_ROLE = /^(pg_|postgres$|afb[dro]_)/;
|
|
39
|
+
/** The names a pooled run owns. Only names of this exact shape are ever dropped. */
|
|
40
|
+
const DB_NAME = /^afbd_[a-z0-9_]{1,50}$/;
|
|
41
|
+
const RUN_ROLE = /^afbr_[a-z0-9_]{1,50}$/;
|
|
42
|
+
/** A marker younger than this is never swept (its owner may be only starting). */
|
|
43
|
+
const MIN_SWEEP_AGE_MS = 2 * 60_000;
|
|
44
|
+
// -- FUNCTIONS -- //
|
|
45
|
+
export class PoolUnavailableError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'PoolUnavailableError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
52
|
+
/** The rename of this module. A test replaces it to simulate the errors Windows gives while another process holds the file. */
|
|
53
|
+
export const poolIo = {
|
|
54
|
+
rename: (from, to) => renameSync(from, to)
|
|
55
|
+
};
|
|
56
|
+
function writeWhole(file, text) {
|
|
57
|
+
const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
|
|
58
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
59
|
+
try {
|
|
60
|
+
renameRetry(tmp, file, poolIo.rename);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
rmSync(tmp, { force: true });
|
|
64
|
+
throw e;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const markerFile = (poolId) => join(poolDir(), `db-${poolId}.json`);
|
|
68
|
+
const runsFile = (poolId) => join(poolDir(), `db-${poolId}.runs.json`);
|
|
69
|
+
const runsLock = (poolId) => `dbruns-${poolId}`;
|
|
70
|
+
/** The numeric start time of this process in ms (rounded to the second). */
|
|
71
|
+
const ownStart = () => Math.round((Date.now() - process.uptime() * 1000) / 1000) * 1000;
|
|
72
|
+
/** The names of one run: lower case, dash to underscore, shortened with a hash when long. */
|
|
73
|
+
export function pooledNames(runId) {
|
|
74
|
+
if (!NAME.test(runId))
|
|
75
|
+
throw new Error('The run id is not a plain name.');
|
|
76
|
+
let slug = runId.toLowerCase().replace(/-/g, '_');
|
|
77
|
+
if (slug.length > 36)
|
|
78
|
+
slug = `${slug.slice(0, 24)}_${createHash('sha256').update(runId).digest('hex').slice(0, 10)}`;
|
|
79
|
+
return {
|
|
80
|
+
dbName: `afbd_${slug}`,
|
|
81
|
+
roleName: `afbr_${slug}`,
|
|
82
|
+
ownerRole: `afbo_${slug}`,
|
|
83
|
+
poolId: ''
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** The owner role that belongs to a recorded run role, or null when the recorded names are not a matched pair of ours. */
|
|
87
|
+
function ownerRoleOf(info) {
|
|
88
|
+
if (!DB_NAME.test(info.dbName) || !RUN_ROLE.test(info.roleName))
|
|
89
|
+
return null;
|
|
90
|
+
const slug = info.roleName.slice('afbr_'.length);
|
|
91
|
+
if (info.dbName !== `afbd_${slug}`)
|
|
92
|
+
return null;
|
|
93
|
+
return `afbo_${slug}`;
|
|
94
|
+
}
|
|
95
|
+
/** The template database name for a key. */
|
|
96
|
+
export function templateName(template, runtimeRole = '') {
|
|
97
|
+
const h = createHash('sha256')
|
|
98
|
+
.update(`${template.recipeHash}\n${template.migrationHash}\n${runtimeRole}`)
|
|
99
|
+
.digest('hex');
|
|
100
|
+
return `afbt_${h.slice(0, 20)}`;
|
|
101
|
+
}
|
|
102
|
+
function parseMarker(text) {
|
|
103
|
+
try {
|
|
104
|
+
const v = JSON.parse(text);
|
|
105
|
+
if (!v || v.kind !== 'db')
|
|
106
|
+
return null;
|
|
107
|
+
if (typeof v.poolId !== 'string' || !POOL_ID.test(v.poolId))
|
|
108
|
+
return null;
|
|
109
|
+
// A marker that names any container but its own pool is damaged or planted: it is never acted on
|
|
110
|
+
if (v.containerName !== `${POOL_PREFIX}${v.poolId}`)
|
|
111
|
+
return null;
|
|
112
|
+
if (!Number.isInteger(v.port) || v.port < 1024 || v.port > 65535)
|
|
113
|
+
return null;
|
|
114
|
+
if (FORBIDDEN_PORTS.has(v.port))
|
|
115
|
+
return null;
|
|
116
|
+
if (!Number.isInteger(v.ownerPid) || typeof v.password !== 'string')
|
|
117
|
+
return null;
|
|
118
|
+
return v;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function readMarker(poolId) {
|
|
125
|
+
if (!POOL_ID.test(poolId))
|
|
126
|
+
return null;
|
|
127
|
+
try {
|
|
128
|
+
return parseMarker(readFileSync(markerFile(poolId), 'utf8'));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Every pool marker on this machine. For doctor and the sweep. */
|
|
135
|
+
export function listPools() {
|
|
136
|
+
let names;
|
|
137
|
+
try {
|
|
138
|
+
names = readdirSync(poolDir());
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const n of names) {
|
|
145
|
+
const m = /^db-([a-f0-9]{6,16})\.json$/.exec(n);
|
|
146
|
+
if (!m)
|
|
147
|
+
continue;
|
|
148
|
+
const marker = readMarker(m[1]);
|
|
149
|
+
if (marker)
|
|
150
|
+
out.push({
|
|
151
|
+
marker,
|
|
152
|
+
ownerAlive: pidAlive(marker.ownerPid),
|
|
153
|
+
liveRuns: liveRunCount(marker.poolId)
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
function readRuns(poolId) {
|
|
159
|
+
try {
|
|
160
|
+
const v = JSON.parse(readFileSync(runsFile(poolId), 'utf8'));
|
|
161
|
+
if (v && typeof v.runs === 'object' && v.runs)
|
|
162
|
+
return { runs: v.runs };
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
/* No file yet */
|
|
166
|
+
}
|
|
167
|
+
return { runs: {} };
|
|
168
|
+
}
|
|
169
|
+
/** Changes the runs file under its lock; dead entries (a process that is gone) are dropped first. */
|
|
170
|
+
function mutateRuns(poolId, fn) {
|
|
171
|
+
return withLockSync(runsLock(poolId), () => {
|
|
172
|
+
const f = readRuns(poolId);
|
|
173
|
+
for (const [id, r] of Object.entries(f.runs))
|
|
174
|
+
if (!pidAlive(r.pid))
|
|
175
|
+
delete f.runs[id];
|
|
176
|
+
const result = fn(f);
|
|
177
|
+
ensureDir(poolDir());
|
|
178
|
+
writeWhole(runsFile(poolId), JSON.stringify(f) + '\n');
|
|
179
|
+
return result;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** The number of runs of this pool whose process is alive. */
|
|
183
|
+
export function liveRunCount(poolId) {
|
|
184
|
+
return Object.values(readRuns(poolId).runs).filter((r) => pidAlive(r.pid)).length;
|
|
185
|
+
}
|
|
186
|
+
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
187
|
+
/** Waits for a lock without blocking the event loop. Null when it could not be had in time. */
|
|
188
|
+
async function takeLock(name, staleMs, waitMs, pause = 150) {
|
|
189
|
+
const until = Date.now() + waitMs;
|
|
190
|
+
for (;;) {
|
|
191
|
+
const lock = acquireLock(name, { staleMs });
|
|
192
|
+
if (lock)
|
|
193
|
+
return lock;
|
|
194
|
+
if (Date.now() > until)
|
|
195
|
+
return null;
|
|
196
|
+
await sleep(pause);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export class DatabasePool {
|
|
200
|
+
docker;
|
|
201
|
+
pickPort;
|
|
202
|
+
slots;
|
|
203
|
+
image;
|
|
204
|
+
log;
|
|
205
|
+
removeWhenIdle;
|
|
206
|
+
readyTimeoutMs;
|
|
207
|
+
templateWaitMs;
|
|
208
|
+
retryMs;
|
|
209
|
+
pool = null;
|
|
210
|
+
/** The names recorded for each run this process made, so a release drops exactly those. */
|
|
211
|
+
records = new Map();
|
|
212
|
+
constructor(deps = {}) {
|
|
213
|
+
this.docker = deps.docker ?? realDocker;
|
|
214
|
+
this.pickPort = deps.pickPort ?? freePort;
|
|
215
|
+
this.slots = Math.max(1, Math.floor(deps.slots ?? 4));
|
|
216
|
+
this.image = deps.image ?? DEFAULT_IMAGE;
|
|
217
|
+
this.log = deps.log ?? (() => { });
|
|
218
|
+
this.removeWhenIdle = deps.removeWhenIdle ?? true;
|
|
219
|
+
this.readyTimeoutMs = deps.readyTimeoutMs ?? 90_000;
|
|
220
|
+
this.templateWaitMs = deps.templateWaitMs ?? 20 * 60_000;
|
|
221
|
+
this.retryMs = deps.retryMs ?? 250;
|
|
222
|
+
}
|
|
223
|
+
/** Runs docker after the same argument check provision.ts applies. */
|
|
224
|
+
async dock(args) {
|
|
225
|
+
checkDockerArgs(args);
|
|
226
|
+
return this.docker(args);
|
|
227
|
+
}
|
|
228
|
+
/** One SQL statement as the superuser inside the pool container (over its socket), on a database. */
|
|
229
|
+
async psql(pool, db, sql) {
|
|
230
|
+
if (!pool.containerName.startsWith(POOL_PREFIX))
|
|
231
|
+
throw new Error('That is not a pool container of the check tool.');
|
|
232
|
+
return this.dock([
|
|
233
|
+
'exec',
|
|
234
|
+
pool.containerName,
|
|
235
|
+
'psql',
|
|
236
|
+
'-U',
|
|
237
|
+
'postgres',
|
|
238
|
+
'-d',
|
|
239
|
+
db,
|
|
240
|
+
'-v',
|
|
241
|
+
'ON_ERROR_STOP=1',
|
|
242
|
+
'-qtAc',
|
|
243
|
+
sql
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
async psqlOk(pool, db, sql, what) {
|
|
247
|
+
const r = await this.psql(pool, db, sql);
|
|
248
|
+
if (r.status !== 0)
|
|
249
|
+
throw new Error(`The pooled database step "${what}" failed: ${r.stderr.trim().split('\n').pop()?.slice(0, 200) ?? ''}`);
|
|
250
|
+
return r.stdout.trim();
|
|
251
|
+
}
|
|
252
|
+
async isRunning(pool) {
|
|
253
|
+
const r = await this.dock(['inspect', pool.containerName]);
|
|
254
|
+
if (r.status !== 0)
|
|
255
|
+
return false;
|
|
256
|
+
try {
|
|
257
|
+
const v = JSON.parse(r.stdout);
|
|
258
|
+
const first = Array.isArray(v)
|
|
259
|
+
? v[0]
|
|
260
|
+
: undefined;
|
|
261
|
+
return first?.State?.Running === true;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async waitReady(pool) {
|
|
268
|
+
const until = Date.now() + this.readyTimeoutMs;
|
|
269
|
+
while (Date.now() < until) {
|
|
270
|
+
// With -h the answer only comes from the real server, not the temporary one of the image's first start
|
|
271
|
+
const r = await this.dock([
|
|
272
|
+
'exec',
|
|
273
|
+
pool.containerName,
|
|
274
|
+
'pg_isready',
|
|
275
|
+
'-U',
|
|
276
|
+
'postgres',
|
|
277
|
+
'-h',
|
|
278
|
+
'127.0.0.1'
|
|
279
|
+
]);
|
|
280
|
+
if (r.status === 0)
|
|
281
|
+
return;
|
|
282
|
+
await sleep(400);
|
|
283
|
+
}
|
|
284
|
+
throw new PoolUnavailableError('The pooled database did not accept connections in time.');
|
|
285
|
+
}
|
|
286
|
+
/** Finds the live pool of this machine or starts one. Throws PoolUnavailableError when Docker or the container does not work. */
|
|
287
|
+
async ensurePool() {
|
|
288
|
+
if (this.pool && readMarker(this.pool.poolId))
|
|
289
|
+
return this.pool;
|
|
290
|
+
this.pool = null;
|
|
291
|
+
const version = await this.dock(['version']).catch(() => ({
|
|
292
|
+
status: -1,
|
|
293
|
+
stdout: '',
|
|
294
|
+
stderr: ''
|
|
295
|
+
}));
|
|
296
|
+
if (version.status !== 0)
|
|
297
|
+
throw new PoolUnavailableError('The pooled database needs Docker, which is not running.');
|
|
298
|
+
const need = this.slots * CONNECTIONS_PER_RUN + BASE_CONNECTIONS;
|
|
299
|
+
const lock = await takeLock('dbpool-create', 120_000, 120_000);
|
|
300
|
+
if (!lock)
|
|
301
|
+
throw new PoolUnavailableError('Another process kept the pool start lock for too long.');
|
|
302
|
+
try {
|
|
303
|
+
for (const p of listPools()) {
|
|
304
|
+
// A pool whose owner ended is still used while a run is live in another process (the last run removes it)
|
|
305
|
+
if ((!p.ownerAlive && p.liveRuns === 0) || p.marker.maxConnections < need)
|
|
306
|
+
continue;
|
|
307
|
+
if (await this.isRunning(p.marker)) {
|
|
308
|
+
this.pool = p.marker;
|
|
309
|
+
return p.marker;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
this.pool = await this.startPool(need);
|
|
313
|
+
return this.pool;
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
lock.release();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async startPool(maxConnections) {
|
|
320
|
+
ensureDir(poolDir());
|
|
321
|
+
const poolId = randomBytes(4).toString('hex');
|
|
322
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
323
|
+
const port = await this.pickPort();
|
|
324
|
+
if (port === 5433 || FORBIDDEN_PORTS.has(port))
|
|
325
|
+
throw new Error(`Port ${port} is not one the check tool may use for a database.`);
|
|
326
|
+
const marker = {
|
|
327
|
+
kind: 'db',
|
|
328
|
+
poolId,
|
|
329
|
+
containerName: `${POOL_PREFIX}${poolId}`,
|
|
330
|
+
port,
|
|
331
|
+
ownerPid: process.pid,
|
|
332
|
+
ownerStartedAt: ownStart(),
|
|
333
|
+
image: this.image,
|
|
334
|
+
maxConnections,
|
|
335
|
+
slots: this.slots,
|
|
336
|
+
password: randomBytes(18).toString('hex'),
|
|
337
|
+
createdAt: new Date().toISOString()
|
|
338
|
+
};
|
|
339
|
+
// Recorded before the container exists: whatever happens next, a sweep knows the exact name
|
|
340
|
+
writeWhole(markerFile(poolId), JSON.stringify(marker) + '\n');
|
|
341
|
+
let started;
|
|
342
|
+
try {
|
|
343
|
+
started = await this.dock([
|
|
344
|
+
'run',
|
|
345
|
+
'-d',
|
|
346
|
+
'--name',
|
|
347
|
+
marker.containerName,
|
|
348
|
+
'--label',
|
|
349
|
+
'afb-checks=pool',
|
|
350
|
+
'-e',
|
|
351
|
+
`POSTGRES_PASSWORD=${marker.password}`,
|
|
352
|
+
'-p',
|
|
353
|
+
`127.0.0.1:${port}:5432`,
|
|
354
|
+
this.image,
|
|
355
|
+
'postgres',
|
|
356
|
+
'-c',
|
|
357
|
+
`max_connections=${maxConnections}`,
|
|
358
|
+
// The data is thrown away with the container, so it is never flushed for safety
|
|
359
|
+
'-c',
|
|
360
|
+
'fsync=off',
|
|
361
|
+
'-c',
|
|
362
|
+
'synchronous_commit=off',
|
|
363
|
+
'-c',
|
|
364
|
+
'full_page_writes=off'
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
rmSync(markerFile(poolId), { force: true });
|
|
369
|
+
throw e;
|
|
370
|
+
}
|
|
371
|
+
if (started.status !== 0) {
|
|
372
|
+
await removeContainer(marker.containerName, this.docker);
|
|
373
|
+
if (attempt < 2 &&
|
|
374
|
+
/port is already allocated|address already in use|bind for/i.test(started.stderr))
|
|
375
|
+
continue;
|
|
376
|
+
rmSync(markerFile(poolId), { force: true });
|
|
377
|
+
throw new PoolUnavailableError('The pooled database container could not be started.');
|
|
378
|
+
}
|
|
379
|
+
this.log(`Pooled database ${marker.containerName} started on port ${port}.`);
|
|
380
|
+
try {
|
|
381
|
+
await this.waitReady(marker);
|
|
382
|
+
// Nobody but a run's own role may connect to a database; the default databases are closed to PUBLIC too
|
|
383
|
+
await this.psqlOk(marker, 'postgres', 'REVOKE CONNECT ON DATABASE postgres FROM PUBLIC', 'close postgres');
|
|
384
|
+
await this.psqlOk(marker, 'postgres', 'REVOKE CONNECT ON DATABASE template1 FROM PUBLIC', 'close template1');
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
await removeContainer(marker.containerName, this.docker);
|
|
388
|
+
rmSync(markerFile(poolId), { force: true });
|
|
389
|
+
throw e instanceof PoolUnavailableError
|
|
390
|
+
? e
|
|
391
|
+
: new PoolUnavailableError(e instanceof Error ? e.message : String(e));
|
|
392
|
+
}
|
|
393
|
+
return marker;
|
|
394
|
+
}
|
|
395
|
+
throw new PoolUnavailableError('No port could be used for the pooled database.');
|
|
396
|
+
}
|
|
397
|
+
async templateReady(pool, tpl) {
|
|
398
|
+
const out = await this.psqlOk(pool, 'postgres', `SELECT coalesce(shobj_description(oid,'pg_database'),'') FROM pg_database WHERE datname='${tpl}'`, 'look for the template');
|
|
399
|
+
return out === READY_COMMENT;
|
|
400
|
+
}
|
|
401
|
+
/** Makes the template once: whoever gets the lock migrates and seeds it, the others wait and then find it ready. */
|
|
402
|
+
async ensureTemplate(pool, template, runtimeRole) {
|
|
403
|
+
const tpl = templateName(template, runtimeRole);
|
|
404
|
+
if (await this.templateReady(pool, tpl))
|
|
405
|
+
return tpl;
|
|
406
|
+
const lockName = `dbtpl-${pool.poolId}-${tpl.slice(5, 17)}`;
|
|
407
|
+
// A warm-up can take minutes; a holder is only stale when its process is gone or after this long
|
|
408
|
+
const lock = await takeLock(lockName, 45 * 60_000, this.templateWaitMs, 200);
|
|
409
|
+
if (!lock)
|
|
410
|
+
throw new Error('Another run kept the template warm-up for too long.');
|
|
411
|
+
try {
|
|
412
|
+
if (await this.templateReady(pool, tpl))
|
|
413
|
+
return tpl;
|
|
414
|
+
// A half-built template of a run that died: dropped, then made again
|
|
415
|
+
await this.psqlOk(pool, 'postgres', `DROP DATABASE IF EXISTS ${quote(tpl)} WITH (FORCE)`, 'drop a half-built template');
|
|
416
|
+
// The runtime role must exist before the migrations grant to it
|
|
417
|
+
if (runtimeRole)
|
|
418
|
+
await this.psqlOk(pool, 'postgres', groupSql(runtimeRole), 'make the runtime group');
|
|
419
|
+
await this.psqlOk(pool, 'postgres', `CREATE DATABASE ${quote(tpl)}`, 'create the template');
|
|
420
|
+
this.log(`Warming the template database ${tpl} (once)...`);
|
|
421
|
+
try {
|
|
422
|
+
await template.warm(`postgresql://postgres:${pool.password}@127.0.0.1:${pool.port}/${tpl}`);
|
|
423
|
+
await this.psqlOk(pool, 'postgres', `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${tpl}'`, 'close the template connections');
|
|
424
|
+
await this.psqlOk(pool, 'postgres', `REVOKE ALL ON DATABASE ${quote(tpl)} FROM PUBLIC`, 'close the template');
|
|
425
|
+
await this.psqlOk(pool, 'postgres', `ALTER DATABASE ${quote(tpl)} ALLOW_CONNECTIONS false`, 'lock the template');
|
|
426
|
+
await this.psqlOk(pool, 'postgres', `COMMENT ON DATABASE ${quote(tpl)} IS '${READY_COMMENT}'`, 'mark the template ready');
|
|
427
|
+
}
|
|
428
|
+
catch (e) {
|
|
429
|
+
await this.psql(pool, 'postgres', `DROP DATABASE IF EXISTS ${quote(tpl)} WITH (FORCE)`).catch(() => undefined);
|
|
430
|
+
throw e;
|
|
431
|
+
}
|
|
432
|
+
return tpl;
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
lock.release();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Gives the run its own database: a clone of the template with its own login role, owner role and connection limit. */
|
|
439
|
+
async acquireDatabase(runId, opts) {
|
|
440
|
+
const group = opts.runtimeRole;
|
|
441
|
+
if (group !== undefined && (!ROLE.test(group) || RESERVED_ROLE.test(group)))
|
|
442
|
+
throw new Error('The runtime role is not a plain role name.');
|
|
443
|
+
const names = pooledNames(runId);
|
|
444
|
+
let pool = await this.ensurePool();
|
|
445
|
+
const info = {
|
|
446
|
+
dbName: names.dbName,
|
|
447
|
+
roleName: names.roleName,
|
|
448
|
+
poolId: pool.poolId
|
|
449
|
+
};
|
|
450
|
+
// Recorded first (the file and the marker of the run): a crash after this point still leaves the exact names to drop
|
|
451
|
+
const register = () => mutateRuns(pool.poolId, (f) => {
|
|
452
|
+
f.runs[runId] = {
|
|
453
|
+
pid: process.pid,
|
|
454
|
+
at: new Date().toISOString(),
|
|
455
|
+
dbName: info.dbName,
|
|
456
|
+
roleName: info.roleName
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
register();
|
|
460
|
+
// A pool whose last run ended a moment ago may have been removed by its owner between the look and the registration
|
|
461
|
+
if (!readMarker(pool.poolId)) {
|
|
462
|
+
this.pool = null;
|
|
463
|
+
pool = await this.ensurePool();
|
|
464
|
+
info.poolId = pool.poolId;
|
|
465
|
+
register();
|
|
466
|
+
}
|
|
467
|
+
this.records.set(runId, info);
|
|
468
|
+
opts.scope?.setInfo?.(info);
|
|
469
|
+
opts.scope?.add(`pooled database ${info.dbName}`, () => this.releaseDatabase(runId));
|
|
470
|
+
try {
|
|
471
|
+
const tpl = await this.ensureTemplate(pool, opts.template, group);
|
|
472
|
+
const runPassword = randomBytes(18).toString('hex');
|
|
473
|
+
const ownerPassword = randomBytes(18).toString('hex');
|
|
474
|
+
// One statement batch for the roles (the group too, idempotent), one for the clone: each docker exec costs a round trip
|
|
475
|
+
const roles = [
|
|
476
|
+
...(group ? [groupSql(group)] : []),
|
|
477
|
+
// The owner seeds and migrates under FORCE ROW LEVEL SECURITY, as the cluster superuser did in the container per run; the runtime role never bypasses
|
|
478
|
+
`CREATE ROLE ${quote(names.ownerRole)} LOGIN BYPASSRLS PASSWORD '${ownerPassword}'`,
|
|
479
|
+
`CREATE ROLE ${quote(info.roleName)} LOGIN PASSWORD '${runPassword}'${group ? ` IN ROLE ${quote(group)}` : ''}`
|
|
480
|
+
].join('; ');
|
|
481
|
+
await this.psqlOk(pool, 'postgres', roles, 'make the run roles');
|
|
482
|
+
await this.cloneTemplate(pool, tpl, info.dbName, names.ownerRole);
|
|
483
|
+
// Run inside the clone: database-level statements work from any connection, and the owner hand-over needs this one
|
|
484
|
+
await this.psqlOk(pool, info.dbName, [
|
|
485
|
+
`REVOKE ALL ON DATABASE ${quote(info.dbName)} FROM PUBLIC`,
|
|
486
|
+
`GRANT CONNECT ON DATABASE ${quote(info.dbName)} TO ${quote(info.roleName)}`,
|
|
487
|
+
`ALTER DATABASE ${quote(info.dbName)} CONNECTION LIMIT ${CONNECTIONS_PER_RUN}`,
|
|
488
|
+
ownerTransferSql(names.ownerRole)
|
|
489
|
+
].join('; '), 'close the clone and give the owner role its objects');
|
|
490
|
+
const at = (role, pw) => `postgresql://${role}:${pw}@127.0.0.1:${pool.port}/${info.dbName}`;
|
|
491
|
+
const ownerUrl = at(names.ownerRole, ownerPassword);
|
|
492
|
+
return {
|
|
493
|
+
databaseUrl: group ? at(info.roleName, runPassword) : ownerUrl,
|
|
494
|
+
ownerUrl,
|
|
495
|
+
// Always the recorded run role (the one a sweep drops), even when the url of a run with no runtime group is the owner role
|
|
496
|
+
roleName: info.roleName,
|
|
497
|
+
dbName: info.dbName,
|
|
498
|
+
poolId: pool.poolId
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
catch (e) {
|
|
502
|
+
await this.releaseDatabase(runId).catch(() => undefined);
|
|
503
|
+
throw e;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** CREATE DATABASE ... TEMPLATE, tried again while the template is busy (a connection of the warm-up still closing). */
|
|
507
|
+
async cloneTemplate(pool, tpl, dbName, ownerRole) {
|
|
508
|
+
const sql = `CREATE DATABASE ${quote(dbName)} TEMPLATE ${quote(tpl)} OWNER ${quote(ownerRole)} ALLOW_CONNECTIONS true`;
|
|
509
|
+
for (let attempt = 0;; attempt++) {
|
|
510
|
+
const r = await this.psql(pool, 'postgres', sql);
|
|
511
|
+
if (r.status === 0)
|
|
512
|
+
return;
|
|
513
|
+
if (attempt < 40 &&
|
|
514
|
+
/being accessed by other users|is being used by|source database/i.test(r.stderr)) {
|
|
515
|
+
await sleep(this.retryMs);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
throw new Error(`The pooled database could not be cloned: ${r.stderr.trim().split('\n').pop()?.slice(0, 200) ?? ''}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/** Drops the run's database and roles by the names recorded for it, forgets the run and, when it was the last one, lets the owner remove the pool. */
|
|
522
|
+
async releaseDatabase(runId) {
|
|
523
|
+
const info = this.records.get(runId);
|
|
524
|
+
this.records.delete(runId);
|
|
525
|
+
if (!info)
|
|
526
|
+
return;
|
|
527
|
+
await dropPooledDatabase(info, { docker: this.docker });
|
|
528
|
+
if (this.removeWhenIdle)
|
|
529
|
+
await this.closePoolIfIdle();
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Removes the pool container when no run of any process is live. Any process may do it, not only the owner: the owner may end first while other
|
|
533
|
+
* processes still use the pool, so whichever finishes last removes it. Returns true when it was removed.
|
|
534
|
+
*/
|
|
535
|
+
async closePoolIfIdle() {
|
|
536
|
+
const pool = this.pool;
|
|
537
|
+
// Only a pool this process knows by its recorded name, and only an afb-checks-pool- container
|
|
538
|
+
if (!pool || !pool.containerName.startsWith(POOL_PREFIX))
|
|
539
|
+
return false;
|
|
540
|
+
const rmLock = await takeLock(`dbpoolrm-${pool.poolId}`, 120_000, 30_000, 50);
|
|
541
|
+
if (!rmLock)
|
|
542
|
+
return false;
|
|
543
|
+
try {
|
|
544
|
+
const current = readMarker(pool.poolId);
|
|
545
|
+
if (!current || current.containerName !== pool.containerName) {
|
|
546
|
+
// Another process already removed it
|
|
547
|
+
this.pool = null;
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
// Counted again inside the lock, under the runs lock, so a run that registers at this moment is counted or finds the marker gone and starts again
|
|
551
|
+
const idle = mutateRuns(pool.poolId, (f) => {
|
|
552
|
+
if (Object.keys(f.runs).length > 0)
|
|
553
|
+
return false;
|
|
554
|
+
rmSync(markerFile(pool.poolId), { force: true });
|
|
555
|
+
return true;
|
|
556
|
+
});
|
|
557
|
+
if (!idle)
|
|
558
|
+
return false;
|
|
559
|
+
await removeContainer(pool.containerName, this.docker);
|
|
560
|
+
rmSync(runsFile(pool.poolId), { force: true });
|
|
561
|
+
this.pool = null;
|
|
562
|
+
this.log(`Pooled database ${pool.containerName} removed.`);
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
finally {
|
|
566
|
+
rmLock.release();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Makes the runtime group role once (NOLOGIN, no rights of its own); a second call is harmless. */
|
|
571
|
+
const groupSql = (group) => `DO $$ BEGIN CREATE ROLE ${quote(group)} NOLOGIN; EXCEPTION WHEN duplicate_object OR unique_violation THEN NULL; END $$`;
|
|
572
|
+
/** The statement that hands the objects of a clone (owned by the superuser after the template was migrated) to the clone's owner role. */
|
|
573
|
+
function ownerTransferSql(ownerRole) {
|
|
574
|
+
const owner = ownerRole.replace(/'/g, "''");
|
|
575
|
+
return `DO $$ DECLARE r record; own text := '${owner}'; su oid := (SELECT oid FROM pg_roles WHERE rolname='postgres'); BEGIN
|
|
576
|
+
FOR r IN SELECT n.nspname s, c.relname o, c.relkind k FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname NOT IN ('pg_catalog','information_schema','pg_toast') AND c.relkind IN ('r','p','v','m','f','S') AND c.relowner=su AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid=c.oid AND d.deptype='e') LOOP
|
|
577
|
+
BEGIN EXECUTE format('ALTER %s %I.%I OWNER TO %I', CASE r.k WHEN 'S' THEN 'SEQUENCE' WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED VIEW' WHEN 'f' THEN 'FOREIGN TABLE' ELSE 'TABLE' END, r.s, r.o, own); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
578
|
+
END LOOP;
|
|
579
|
+
FOR r IN SELECT n.nspname s, p.proname o, pg_get_function_identity_arguments(p.oid) a, p.prokind k FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname NOT IN ('pg_catalog','information_schema') AND p.proowner=su AND p.prokind IN ('f','p') AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid=p.oid AND d.deptype='e') LOOP
|
|
580
|
+
BEGIN EXECUTE format('ALTER %s %I.%I(%s) OWNER TO %I', CASE r.k WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END, r.s, r.o, r.a, own); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
581
|
+
END LOOP;
|
|
582
|
+
FOR r IN SELECT nspname s FROM pg_namespace WHERE nspowner=su AND nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema' LOOP
|
|
583
|
+
BEGIN EXECUTE format('ALTER SCHEMA %I OWNER TO %I', r.s, own); EXCEPTION WHEN OTHERS THEN NULL; END;
|
|
584
|
+
END LOOP; END $$`;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Drops one pooled run's database and its two roles by the recorded names, and forgets the run. Only names of the exact shape afbd_/afbr_ that belong
|
|
588
|
+
* together are ever dropped; the container is never touched. Returns false when the pool or the names are not known. Never throws.
|
|
589
|
+
*/
|
|
590
|
+
export async function dropPooledDatabase(info, deps = {}) {
|
|
591
|
+
const docker = deps.docker ?? realDocker;
|
|
592
|
+
const ownerRole = ownerRoleOf(info);
|
|
593
|
+
const pool = readMarker(info.poolId);
|
|
594
|
+
if (!ownerRole || !pool)
|
|
595
|
+
return false;
|
|
596
|
+
const psql = (sql) => {
|
|
597
|
+
const args = [
|
|
598
|
+
'exec',
|
|
599
|
+
pool.containerName,
|
|
600
|
+
'psql',
|
|
601
|
+
'-U',
|
|
602
|
+
'postgres',
|
|
603
|
+
'-d',
|
|
604
|
+
'postgres',
|
|
605
|
+
'-v',
|
|
606
|
+
'ON_ERROR_STOP=1',
|
|
607
|
+
'-qtAc',
|
|
608
|
+
sql
|
|
609
|
+
];
|
|
610
|
+
checkDockerArgs(args);
|
|
611
|
+
return docker(args);
|
|
612
|
+
};
|
|
613
|
+
let dropped = false;
|
|
614
|
+
try {
|
|
615
|
+
// psql answers with an exit status: a drop that failed (the database is busy, docker hiccuped) keeps the run's entry so a later sweep tries again
|
|
616
|
+
const db = await psql(`DROP DATABASE IF EXISTS ${quote(info.dbName)} WITH (FORCE)`);
|
|
617
|
+
if (db.status !== 0)
|
|
618
|
+
return false;
|
|
619
|
+
const roles = await psql(`DROP ROLE IF EXISTS ${quote(info.roleName)}; DROP ROLE IF EXISTS ${quote(ownerRole)}`);
|
|
620
|
+
if (roles.status !== 0)
|
|
621
|
+
return false;
|
|
622
|
+
dropped = true;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
finally {
|
|
628
|
+
if (dropped) {
|
|
629
|
+
try {
|
|
630
|
+
mutateRuns(pool.poolId, (f) => {
|
|
631
|
+
for (const [id, r] of Object.entries(f.runs))
|
|
632
|
+
if (r.dbName === info.dbName)
|
|
633
|
+
delete f.runs[id];
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
/* The lock could not be had: the dead entry is pruned by the next change */
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Doctor: removes pool containers whose owner process is gone, that no live run uses and whose marker is older than `minAgeMs` (default 2 minutes).
|
|
645
|
+
* Removes by the recorded name only. Returns the names removed.
|
|
646
|
+
*/
|
|
647
|
+
export async function sweepPool(opts = {}) {
|
|
648
|
+
const removed = [];
|
|
649
|
+
for (const p of listPools()) {
|
|
650
|
+
if (p.ownerAlive || p.liveRuns > 0)
|
|
651
|
+
continue;
|
|
652
|
+
if (Date.now() - Date.parse(p.marker.createdAt) < (opts.minAgeMs ?? MIN_SWEEP_AGE_MS))
|
|
653
|
+
continue;
|
|
654
|
+
await removeContainer(p.marker.containerName, opts.docker ?? realDocker);
|
|
655
|
+
rmSync(markerFile(p.marker.poolId), { force: true });
|
|
656
|
+
rmSync(runsFile(p.marker.poolId), { force: true });
|
|
657
|
+
removed.push(p.marker.containerName);
|
|
658
|
+
opts.log?.(`Removed the leftover pooled database ${p.marker.containerName}.`);
|
|
659
|
+
}
|
|
660
|
+
return removed;
|
|
661
|
+
}
|
|
662
|
+
let shared = null;
|
|
663
|
+
let sharedDeps = {};
|
|
664
|
+
/** Sets how this process's pool behaves (slots, keep-warm). Call before the first acquireDatabase. */
|
|
665
|
+
export function configureDatabasePool(deps) {
|
|
666
|
+
sharedDeps = deps;
|
|
667
|
+
shared = null;
|
|
668
|
+
}
|
|
669
|
+
/** The pool of this process. */
|
|
670
|
+
export function databasePool() {
|
|
671
|
+
shared ??= new DatabasePool(sharedDeps);
|
|
672
|
+
return shared;
|
|
673
|
+
}
|
|
674
|
+
export const acquireDatabase = (runId, opts) => databasePool().acquireDatabase(runId, opts);
|
|
675
|
+
export const releaseDatabase = (runId) => databasePool().releaseDatabase(runId);
|
|
676
|
+
export const closePoolIfIdle = () => databasePool().closePoolIfIdle();
|
|
677
|
+
/**
|
|
678
|
+
* A database for one run: from the pool, or, when the pool is unavailable (Docker down, the container could not start), its own container as before.
|
|
679
|
+
* The template's warm-up runs on the fresh container in that case, so the caller sees the same result either way.
|
|
680
|
+
*/
|
|
681
|
+
export async function provisionDatabase(runId, scope, log, opts) {
|
|
682
|
+
const pool = opts.pool ?? databasePool();
|
|
683
|
+
try {
|
|
684
|
+
const db = await pool.acquireDatabase(runId, {
|
|
685
|
+
runtimeRole: opts.runtimeRole,
|
|
686
|
+
template: opts.template,
|
|
687
|
+
scope
|
|
688
|
+
});
|
|
689
|
+
return { ...db, pooled: true };
|
|
690
|
+
}
|
|
691
|
+
catch (e) {
|
|
692
|
+
if (!(e instanceof PoolUnavailableError))
|
|
693
|
+
throw e;
|
|
694
|
+
log(`The database pool is not available (${e.message}). Using a container for this run.`);
|
|
695
|
+
}
|
|
696
|
+
const own = await makeThrowawayDatabase(runId, scope, log, {
|
|
697
|
+
runtimeRole: opts.runtimeRole,
|
|
698
|
+
docker: opts.docker,
|
|
699
|
+
pickPort: opts.pickPort
|
|
700
|
+
});
|
|
701
|
+
await opts.template.warm(own.ownerUrl);
|
|
702
|
+
return {
|
|
703
|
+
databaseUrl: own.databaseUrl,
|
|
704
|
+
ownerUrl: own.ownerUrl,
|
|
705
|
+
roleName: own.roleName ?? 'postgres',
|
|
706
|
+
dbName: 'app',
|
|
707
|
+
poolId: '',
|
|
708
|
+
pooled: false
|
|
709
|
+
};
|
|
710
|
+
}
|