@volter/world-runtime 3.0.100 → 3.0.102
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/dist/src/cli-commands.js +1 -1
- package/dist/src/configs.js +1 -1
- package/dist/src/mongodb-backing.d.ts +3 -2
- package/dist/src/mongodb-backing.js +16 -11
- package/dist/src/pglite-backing.js +57 -16
- package/dist/src/pglite-host.mjs +12 -1
- package/dist/src/process-signal.mjs +13 -4
- package/dist/src/redis-backing.js +31 -3
- package/dist/src/runtime.js +77 -112
- package/dist/src/schema.d.ts +4 -6
- package/dist/src/schema.js +4 -10
- package/dist/src/service-exit.d.ts +0 -3
- package/dist/src/service-exit.js +0 -5
- package/dist/src/up-cli.js +11 -0
- package/package.json +4 -4
- package/src/cli-commands.ts +1 -1
- package/src/configs.ts +1 -1
- package/src/mongodb-backing.ts +10 -8
- package/src/pglite-backing.ts +48 -16
- package/src/pglite-host.mjs +12 -1
- package/src/process-signal.mjs +13 -4
- package/src/redis-backing.ts +25 -3
- package/src/runtime.ts +72 -98
- package/src/schema.ts +8 -15
- package/src/service-exit.ts +0 -6
- package/src/up-cli.ts +10 -0
package/dist/src/cli-commands.js
CHANGED
|
@@ -57,7 +57,7 @@ function printHelp() {
|
|
|
57
57
|
# emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
|
|
58
58
|
# Deterministic: same repo + same pack catalog => identical bytes.
|
|
59
59
|
# init DIAGNOSES; it never boots — it prints the exact next commands
|
|
60
|
-
volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--json] [--root <repo>]
|
|
60
|
+
volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--app-url <url>] [--json] [--root <repo>]
|
|
61
61
|
volter-world outdated <world> [--json] — each mounted twin: pinned / mounted / catalog-current version and protocol standing (R17)
|
|
62
62
|
volter-world audit <world> [--json] — each mounted twin's grade, as the catalog records it (R17)
|
|
63
63
|
volter-world down <name> [--grace-ms <ms>] [--purge] [--if-instance <token>] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
|
package/dist/src/configs.js
CHANGED
|
@@ -34,7 +34,7 @@ const LEGACY_NESTED = {
|
|
|
34
34
|
bare: new Set(['name']), resources: new Set(['memoryMiB', 'writableStorageMiB']), catalog: new Set(['sha', 'protocol']),
|
|
35
35
|
root: new Set(['url', 'deploy', 'refresh', 'scope']), refresh: new Set(['every', 'webhook']),
|
|
36
36
|
external: new Set(['up', 'status', 'down', 'discover', 'readyWhen']), discover: new Set(['as', 'source', 'jsonPath', 'pattern']),
|
|
37
|
-
ready: new Set(['command', 'args', 'httpUrl', '
|
|
37
|
+
ready: new Set(['command', 'args', 'httpUrl', 'timeoutMs', 'intervalMs']), colocate: new Set(['module', 'export', 'scenarioPath']),
|
|
38
38
|
share: new Set(['provider', 'command', 'args', 'ephemeral', 'services']), shareService: new Set(['id', 'verifyPath']),
|
|
39
39
|
};
|
|
40
40
|
function unknownKeys(value, known, allowComments = false) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { HostLaunch, InfraService } from './pglite-backing.js';
|
|
2
2
|
/** PATH is the binary contract, as for the native PostgreSQL example and LiveKit. No acquisition at boot. */
|
|
3
3
|
export declare function mongodbArgv(service: InfraService, data: string): string[];
|
|
4
|
-
/** MongoDB's
|
|
5
|
-
|
|
4
|
+
/** Whether the server answering MongoDB's protocol on the port is this child: its own serverStatus names its pid. A
|
|
5
|
+
* listener that is not answering yet, or another process's server on the port, is not. */
|
|
6
|
+
export declare function mongodbServes(service: InfraService, pid: number): Promise<boolean>;
|
|
6
7
|
/** Ping the server; initialize only an explicitly declared one-member set and await its primary. */
|
|
7
8
|
export declare function mongodbReady(service: InfraService, launch: HostLaunch, deadline: number, initialize?: boolean): Promise<void>;
|
|
@@ -3,17 +3,22 @@ export function mongodbArgv(service, data) {
|
|
|
3
3
|
return ['mongod', '--bind_ip', '127.0.0.1', '--port', String(service.hostPort), '--dbpath', data,
|
|
4
4
|
...(service.replicaSet ? ['--replSet', service.replicaSet] : [])];
|
|
5
5
|
}
|
|
6
|
-
/** MongoDB's
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
6
|
+
/** Whether the server answering MongoDB's protocol on the port is this child: its own serverStatus names its pid. A
|
|
7
|
+
* listener that is not answering yet, or another process's server on the port, is not. */
|
|
8
|
+
export async function mongodbServes(service, pid) {
|
|
9
|
+
const { MongoClient } = await import('mongodb');
|
|
10
|
+
const client = new MongoClient(`mongodb://127.0.0.1:${service.hostPort}`, { directConnection: true, timeoutMS: 1000, serverSelectionTimeoutMS: 1000 });
|
|
11
|
+
try {
|
|
12
|
+
await client.connect();
|
|
13
|
+
const status = await client.db('admin').command({ serverStatus: 1 });
|
|
14
|
+
return Number(status.pid) === pid;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
await client.close().catch(() => undefined);
|
|
21
|
+
}
|
|
17
22
|
}
|
|
18
23
|
/** Ping the server; initialize only an explicitly declared one-member set and await its primary. */
|
|
19
24
|
export async function mongodbReady(service, launch, deadline, initialize = true) {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// services only. A definition declaring mysql without a container runtime is
|
|
13
13
|
// refused with the kinds named, never half-booted. Redis never reaches it: every
|
|
14
14
|
// redis service is the redis twin's (redis-backing.ts).
|
|
15
|
-
import { appendFileSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync,
|
|
15
|
+
import { appendFileSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
16
17
|
import { connect } from 'node:net';
|
|
17
18
|
import { dirname, join } from 'node:path';
|
|
18
19
|
import { spawn } from 'node:child_process';
|
|
@@ -22,7 +23,7 @@ import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnRecei
|
|
|
22
23
|
import { errorMessage } from "./error-message.js";
|
|
23
24
|
import { fileURLToPath } from 'node:url';
|
|
24
25
|
import { parse } from 'yaml';
|
|
25
|
-
import { mongodbArgv,
|
|
26
|
+
import { mongodbArgv, mongodbReady, mongodbServes } from "./mongodb-backing.js";
|
|
26
27
|
/** Read the generated definition's loopback port mappings. MongoDB may explicitly request
|
|
27
28
|
* one replica-set member through its Compose-compatible command; other mongod options are refused. */
|
|
28
29
|
export function parseInfraDefinition(text) {
|
|
@@ -149,13 +150,52 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
149
150
|
export function unsupportedKinds(services) {
|
|
150
151
|
return [...new Set(services.filter((s) => CONTAINERLESS[s.kind] === undefined).map((s) => s.kind))];
|
|
151
152
|
}
|
|
152
|
-
/**
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
/**
|
|
154
|
+
* What `up` means for a managed database (architecture, managed infrastructure): it answers its own protocol on its
|
|
155
|
+
* port, as pg_isready and a compose healthcheck ask, and the answer is this host's. Readiness never reads a log line.
|
|
156
|
+
* The PGlite host writes its own listener receipt once its socket is bound ({version, pid, port, boot}, pglite-host.mjs
|
|
157
|
+
* --listening-file); MongoDB's server names its own pid in serverStatus. The log is for people.
|
|
158
|
+
*/
|
|
159
|
+
function listeningReceiptPath(pidFile) {
|
|
160
|
+
return pidFile.replace(/\.pid$/, '.listening.json');
|
|
161
|
+
}
|
|
162
|
+
function listeningReceipt(path) {
|
|
163
|
+
let text;
|
|
164
|
+
try {
|
|
165
|
+
text = readFileSync(path, 'utf8');
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const value = JSON.parse(text);
|
|
172
|
+
if (value.version !== 1 || !Number.isSafeInteger(value.pid) || !Number.isSafeInteger(value.port))
|
|
173
|
+
return undefined;
|
|
174
|
+
return { pid: value.pid, port: value.port, boot: typeof value.boot === 'string' ? value.boot : null };
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Postgres's own first exchange: an SSLRequest answered with one byte, 'N' or 'S' (pg_isready's question). */
|
|
181
|
+
function postgresAnswers(port) {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
|
|
184
|
+
socket.once('connect', () => socket.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f])));
|
|
185
|
+
socket.once('data', (chunk) => { socket.destroy(); resolve(chunk[0] === 0x4e || chunk[0] === 0x53); });
|
|
186
|
+
socket.on('error', () => resolve(false));
|
|
187
|
+
socket.on('timeout', () => { socket.destroy(); resolve(false); });
|
|
188
|
+
});
|
|
156
189
|
}
|
|
157
|
-
|
|
158
|
-
|
|
190
|
+
/** Whether this host serves: its own receipt names it on the port (for this boot, when one is given), and the port
|
|
191
|
+
* answers the service's protocol. */
|
|
192
|
+
async function hostServes(service, pidFile, pid, boot) {
|
|
193
|
+
if (service.kind === 'mongodb')
|
|
194
|
+
return mongodbServes(service, pid);
|
|
195
|
+
const receipt = listeningReceipt(listeningReceiptPath(pidFile));
|
|
196
|
+
if (!receipt || receipt.pid !== pid || receipt.port !== service.hostPort || (boot !== undefined && receipt.boot !== boot))
|
|
197
|
+
return false;
|
|
198
|
+
return postgresAnswers(service.hostPort);
|
|
159
199
|
}
|
|
160
200
|
/**
|
|
161
201
|
* A World's PostgreSQL cluster prepared before its first boot (architecture, managed infrastructure; ADR 0018).
|
|
@@ -274,8 +314,10 @@ export async function pgliteUp(services, dataDir, launch = {}) {
|
|
|
274
314
|
appendFileSync(log, `${placed.note}\n`);
|
|
275
315
|
launch.note?.(placed.note);
|
|
276
316
|
}
|
|
277
|
-
|
|
278
|
-
const
|
|
317
|
+
// This boot's token, which the host writes into its listener receipt; an earlier receipt is never this one's.
|
|
318
|
+
const boot = randomUUID();
|
|
319
|
+
rmSync(listeningReceiptPath(pidFile), { force: true });
|
|
320
|
+
const [command, ...args] = [...(placed.placed ? [...argv, '--prepared'] : argv), ...(service.kind === 'postgres' ? ['--listening-file', listeningReceiptPath(pidFile), '--boot', boot] : [])];
|
|
279
321
|
// a real fd, not a pipe: the host must outlive this process untethered
|
|
280
322
|
const logFd = openSync(log, 'a');
|
|
281
323
|
const child = spawn(command, args, {
|
|
@@ -305,10 +347,10 @@ export async function pgliteUp(services, dataDir, launch = {}) {
|
|
|
305
347
|
publishProcessReceipt(pidFile, `${child.pid}\n`);
|
|
306
348
|
launch.note?.(`${name} spawned`);
|
|
307
349
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
while (!(alive(child.pid) && (service.kind === '
|
|
350
|
+
// Ready when the port answers the service's protocol and the answer is this child's: its own receipt (PGlite)
|
|
351
|
+
// or its own serverStatus (MongoDB). A squatter that took the port after the check above makes the child fail
|
|
352
|
+
// its bind and exit, and never answers as this child.
|
|
353
|
+
while (!(alive(child.pid) && await hostServes(service, pidFile, child.pid, service.kind === 'postgres' ? boot : undefined))) {
|
|
312
354
|
if (spawnError)
|
|
313
355
|
throw spawnError;
|
|
314
356
|
if (!alive(child.pid))
|
|
@@ -317,7 +359,6 @@ export async function pgliteUp(services, dataDir, launch = {}) {
|
|
|
317
359
|
throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
318
360
|
// an aborted boot (Ctrl-C during up) stops waiting; the catch below ends what this up started
|
|
319
361
|
launch.signal?.throwIfAborted();
|
|
320
|
-
// the ready line is a small log read, and the port is asked only once it is there
|
|
321
362
|
await sleep(25);
|
|
322
363
|
}
|
|
323
364
|
launch.note?.(`${name} serving on 127.0.0.1:${service.hostPort}`);
|
|
@@ -349,7 +390,7 @@ export async function pgliteStatus(services, dataDir) {
|
|
|
349
390
|
const ownership = hostOwnership(pidFile);
|
|
350
391
|
if (ownership.errors.length)
|
|
351
392
|
throw new AggregateError(ownership.errors, 'Managed infrastructure ownership uncertain; retry down');
|
|
352
|
-
if (!ownership.readyReceipt || !ownership.pid || !alive(ownership.pid) || !(await
|
|
393
|
+
if (!ownership.readyReceipt || !ownership.pid || !alive(ownership.pid) || !(await hostServes(service, pidFile, ownership.pid)))
|
|
353
394
|
continue;
|
|
354
395
|
if (ownership.identity)
|
|
355
396
|
await verifySpawnIdentity(ownership.identity);
|
package/dist/src/pglite-host.mjs
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
// Randomness remains the host's own; no database schema objects are installed.
|
|
64
64
|
import { errorMessage } from './error-message.mjs';
|
|
65
65
|
import net from 'node:net';
|
|
66
|
-
import { mkdirSync, readFileSync } from 'node:fs';
|
|
66
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
67
67
|
import clockForm from '@volter/world-core/world-clock';
|
|
68
68
|
import { fromNodeSocket } from 'pg-gateway/node';
|
|
69
69
|
import { BackendError } from 'pg-gateway';
|
|
@@ -122,6 +122,12 @@ function arg(name) {
|
|
|
122
122
|
// path a serving host takes, close it cleanly and exit without listening. The World's image build runs it on the
|
|
123
123
|
// host so the tab opens an existing cluster (pglite-backing.ts, pglitePrepare).
|
|
124
124
|
const preparing = process.argv.includes('--prepare');
|
|
125
|
+
// --listening-file <path> --boot <token>: once the listener is bound, the host writes its own receipt there
|
|
126
|
+
// ({version, pid, port, boot}); the backing reads it as this host's ownership of the port (pglite-backing.ts). Readiness
|
|
127
|
+
// is the port answering Postgres's own protocol; the stdout line is for people only.
|
|
128
|
+
const optional = (name) => { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; };
|
|
129
|
+
const listeningFile = optional('--listening-file');
|
|
130
|
+
const bootToken = optional('--boot');
|
|
125
131
|
// --prepared: the data directory is a cluster `--prepare` made for this declared identity (pglite-backing.ts checks
|
|
126
132
|
// its stamp before passing this), so the declared database and role exist and the maintenance open is skipped.
|
|
127
133
|
const prepared = process.argv.includes('--prepared');
|
|
@@ -649,6 +655,11 @@ if (preparing) void dbReady.then(async (db) => {
|
|
|
649
655
|
else void dbReady.then(() => {
|
|
650
656
|
clearInterval(initKeepAlive);
|
|
651
657
|
server.listen(port, '127.0.0.1', () => {
|
|
658
|
+
if (listeningFile) {
|
|
659
|
+
const temporary = `${listeningFile}.${process.pid}.tmp`;
|
|
660
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, pid: process.pid, port, boot: bootToken ?? null })}\n`, { mode: 0o600 });
|
|
661
|
+
renameSync(temporary, listeningFile);
|
|
662
|
+
}
|
|
652
663
|
process.stdout.write(`pglite-host serving postgres wire protocol on 127.0.0.1:${port}\n`);
|
|
653
664
|
});
|
|
654
665
|
});
|
|
@@ -20,10 +20,19 @@ export async function processBirth(pid) {
|
|
|
20
20
|
} catch { return undefined; }
|
|
21
21
|
}
|
|
22
22
|
return new Promise(resolve => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
// A probe that cannot run is a birth unknown, never a lifecycle failure: spawn throws synchronously where the
|
|
24
|
+
// process may not exec `ps` at all (setuid root on macOS, which a sandbox-exec profile refuses: EPERM), and reports
|
|
25
|
+
// other failures as 'error' (below). Either way the identity is the pid without a start time.
|
|
26
|
+
let child;
|
|
27
|
+
try {
|
|
28
|
+
child = spawn('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], {
|
|
29
|
+
windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' },
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
resolve(undefined);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
27
36
|
let output = '', settled = false;
|
|
28
37
|
const finish = value => { if (!settled) { settled = true; clearTimeout(timer); child.stdout.destroy(); resolve(value); } };
|
|
29
38
|
// This is our still-held inspector, not a PID from a receipt.
|
|
@@ -55,6 +55,33 @@ function listening(port) {
|
|
|
55
55
|
socket.on('timeout', () => { socket.destroy(); resolve(false); });
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
+
/** Whether the Redis answering on the port is this child (architecture D9): its own INFO server names its process_id, as
|
|
59
|
+
* Redis's does. A port that answers as another process, or does not answer yet, is not this child's. */
|
|
60
|
+
function redisServes(port, pid) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
|
|
63
|
+
let reply = '';
|
|
64
|
+
socket.once('connect', () => socket.write('*2\r\n$4\r\nINFO\r\n$6\r\nserver\r\n'));
|
|
65
|
+
socket.on('data', (chunk) => {
|
|
66
|
+
reply += chunk.toString('latin1');
|
|
67
|
+
// a bulk string: $<length>\r\n<body>\r\n
|
|
68
|
+
const head = /^\$(\d+)\r\n/.exec(reply);
|
|
69
|
+
if (!head) {
|
|
70
|
+
if (!reply.startsWith('$') || reply.includes('\r\n')) {
|
|
71
|
+
socket.destroy();
|
|
72
|
+
resolve(false);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (Buffer.byteLength(reply, 'latin1') < head[0].length + Number(head[1]) + 2)
|
|
77
|
+
return;
|
|
78
|
+
socket.destroy();
|
|
79
|
+
resolve(Number(/(?:^|\r\n)process_id:(\d+)\r\n/.exec(reply.slice(head[0].length))?.[1]) === pid);
|
|
80
|
+
});
|
|
81
|
+
socket.on('error', () => resolve(false));
|
|
82
|
+
socket.on('timeout', () => { socket.destroy(); resolve(false); });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
58
85
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
86
|
export async function redisTwinUp(services, dataDir, cli, launch = {}) {
|
|
60
87
|
for (const service of services) {
|
|
@@ -99,13 +126,13 @@ export async function redisTwinUp(services, dataDir, cli, launch = {}) {
|
|
|
99
126
|
publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(identity) + '\n');
|
|
100
127
|
publishProcessReceipt(pidFile, `${held.child.pid}\n`);
|
|
101
128
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
102
|
-
while (!(await
|
|
129
|
+
while (!alive(held.child.pid) || !(await redisServes(service.hostPort, held.child.pid))) {
|
|
103
130
|
if (!alive(held.child.pid))
|
|
104
131
|
throw new Error(`redis exited before serving — see ${log}`);
|
|
105
132
|
launch.signal?.throwIfAborted();
|
|
106
133
|
if (Date.now() > deadline)
|
|
107
134
|
throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
108
|
-
await sleep(
|
|
135
|
+
await sleep(25);
|
|
109
136
|
}
|
|
110
137
|
launch.signal?.throwIfAborted();
|
|
111
138
|
held.child.unref();
|
|
@@ -129,7 +156,8 @@ export async function redisTwinStatus(services, dataDir) {
|
|
|
129
156
|
const pidFile = pidPath(dataDir, service);
|
|
130
157
|
if (!existsSync(pidFile))
|
|
131
158
|
continue;
|
|
132
|
-
|
|
159
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
160
|
+
if (alive(pid) && await redisServes(service.hostPort, pid))
|
|
133
161
|
ready += 1;
|
|
134
162
|
}
|
|
135
163
|
return ready;
|