@volter/world-runtime 2.0.15 → 2.0.17
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/infra-cli.js +7 -183
- package/dist/src/infra.d.ts +7 -0
- package/dist/src/infra.js +237 -0
- package/dist/src/pglite-backing.d.ts +10 -1
- package/dist/src/pglite-backing.js +10 -5
- package/dist/src/redis-backing.d.ts +2 -2
- package/dist/src/redis-backing.js +3 -1
- package/dist/src/runtime.js +51 -1
- package/dist/src/served-world.js +37 -12
- package/dist/src/world-view.js +2 -11
- package/package.json +4 -4
- package/src/infra-cli.ts +7 -174
- package/src/infra.ts +225 -0
- package/src/pglite-backing.ts +17 -5
- package/src/redis-backing.ts +4 -2
- package/src/runtime.ts +45 -1
- package/src/served-world.ts +26 -7
- package/src/world-view.ts +2 -5
package/dist/src/infra-cli.js
CHANGED
|
@@ -1,185 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
// this declared service; application agents never operate or diagnose its backing
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
|
|
9
|
-
const phase = process.argv[2];
|
|
10
|
-
if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
|
|
11
|
-
process.stderr.write('managed infrastructure: expected up, status, or down\n');
|
|
12
|
-
process.exit(2);
|
|
13
|
-
}
|
|
14
|
-
const worldConfig = process.env.VOLTER_WORLD_CONFIG;
|
|
15
|
-
const worldData = process.env.VOLTER_WORLD_DATA;
|
|
16
|
-
if (!worldConfig || !worldData) {
|
|
17
|
-
process.stderr.write('managed infrastructure: missing World lifecycle context\n');
|
|
18
|
-
process.exit(2);
|
|
19
|
-
}
|
|
20
|
-
const definition = join(dirname(worldConfig), 'world.infrastructure.yml');
|
|
21
|
-
if (!existsSync(definition)) {
|
|
22
|
-
process.stderr.write('managed infrastructure: declared definition is missing\n');
|
|
23
|
-
process.exit(2);
|
|
24
|
-
}
|
|
25
|
-
const base = ['compose', '-f', definition];
|
|
26
|
-
const run = (args) => spawnSync('docker', [...base, ...args], { windowsHide: true,
|
|
27
|
-
encoding: 'utf8',
|
|
28
|
-
env: { ...process.env, VOLTER_WORLD_DATA: worldData },
|
|
29
|
-
timeout: 120_000,
|
|
2
|
+
// `volter-world-infra up|status|down`: the World-managed infrastructure's declared lifecycle commands (infra.ts), as
|
|
3
|
+
// a process. World configs invoke this declared service; application agents never operate or diagnose its backing.
|
|
4
|
+
import { runInfrastructure } from "./infra.js";
|
|
5
|
+
const code = await runInfrastructure(process.argv[2] ?? '', process.env, {
|
|
6
|
+
out: (text) => { process.stdout.write(text); },
|
|
7
|
+
err: (text) => { process.stderr.write(text); },
|
|
30
8
|
});
|
|
31
|
-
|
|
32
|
-
? 'insufficient writable storage'
|
|
33
|
-
: /out of memory|cannot allocate memory|killed/iu.test(raw)
|
|
34
|
-
? 'insufficient memory'
|
|
35
|
-
: /cannot connect|not running|daemon/iu.test(raw)
|
|
36
|
-
? 'local execution capacity is unavailable'
|
|
37
|
-
: 'managed infrastructure operation failed';
|
|
38
|
-
const fail = (result) => {
|
|
39
|
-
const raw = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
40
|
-
process.stderr.write(`managed infrastructure ${phase} failed: ${classify(raw)}\n`);
|
|
41
|
-
process.exit(result.status && result.status > 0 ? result.status : 1);
|
|
42
|
-
};
|
|
43
|
-
// ---- backing selection ------------------------------------------------------
|
|
44
|
-
// The declared service contract (up/status/down) is the boundary; WHICH
|
|
45
|
-
// runtime answers it is private and chosen here, per machine, at each phase:
|
|
46
|
-
// 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
|
|
47
|
-
// 2. a working container runtime — the compose path, byte-identical to before;
|
|
48
|
-
// 3. no container runtime + a definition of postgres and/or mongodb services —
|
|
49
|
-
// the containerless backing (pglite-backing.ts: PGlite for postgres, the
|
|
50
|
-
// MongoDB twin for mongodb), announced loudly;
|
|
51
|
-
// 4. otherwise the honest refusal naming what this machine cannot serve.
|
|
52
|
-
function selectBacking() {
|
|
53
|
-
const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
|
|
54
|
-
if (forced === 'docker' || forced === 'pglite')
|
|
55
|
-
return forced;
|
|
56
|
-
if (forced !== undefined) {
|
|
57
|
-
process.stderr.write(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
|
|
58
|
-
process.exit(2);
|
|
59
|
-
}
|
|
60
|
-
const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { windowsHide: true, encoding: 'utf8', timeout: 10_000 });
|
|
61
|
-
if (probe.status === 0)
|
|
62
|
-
return 'docker';
|
|
63
|
-
// Absence of a container runtime (no binary, no daemon) is a capability
|
|
64
|
-
// difference: swap backings. A PRESENT runtime failing on resources stays
|
|
65
|
-
// on the docker path so the real capacity problem is classified and
|
|
66
|
-
// surfaced — a full disk starves every backing equally.
|
|
67
|
-
if (probe.error !== undefined)
|
|
68
|
-
return 'pglite';
|
|
69
|
-
const reason = classify(`${probe.stdout ?? ''}\n${probe.stderr ?? ''}`);
|
|
70
|
-
return reason === 'local execution capacity is unavailable' ? 'pglite' : 'docker';
|
|
71
|
-
}
|
|
72
|
-
async function runPglite() {
|
|
73
|
-
const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import("./pglite-backing.js");
|
|
74
|
-
const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
|
|
75
|
-
if (services.length === 0) {
|
|
76
|
-
process.stderr.write('managed infrastructure: the declared definition names no services\n');
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
const unsupported = unsupportedKinds(services);
|
|
80
|
-
if (unsupported.length > 0) {
|
|
81
|
-
process.stderr.write(`managed infrastructure ${phase} failed: local execution capacity is unavailable (no container runtime, and the containerless backing cannot serve: ${unsupported.join(', ')})\n`);
|
|
82
|
-
process.exit(1);
|
|
83
|
-
}
|
|
84
|
-
if (phase === 'up') {
|
|
85
|
-
process.stdout.write('managed infrastructure backing: containerless (no container runtime)\n');
|
|
86
|
-
try {
|
|
87
|
-
await pgliteUp(services, worldData);
|
|
88
|
-
}
|
|
89
|
-
catch (error) {
|
|
90
|
-
process.stderr.write(`managed infrastructure up failed: ${String(error.message ?? error)}\n`);
|
|
91
|
-
process.exit(1);
|
|
92
|
-
}
|
|
93
|
-
process.stdout.write('managed infrastructure ready\n');
|
|
94
|
-
process.exit(0);
|
|
95
|
-
}
|
|
96
|
-
if (phase === 'status') {
|
|
97
|
-
const status = await pgliteStatus(services, worldData);
|
|
98
|
-
if (!status.ok) {
|
|
99
|
-
process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
|
|
100
|
-
process.exit(1);
|
|
101
|
-
}
|
|
102
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
103
|
-
process.exit(0);
|
|
104
|
-
}
|
|
105
|
-
await pgliteDown(services, worldData);
|
|
106
|
-
process.stdout.write('managed infrastructure stopped\n');
|
|
107
|
-
process.exit(0);
|
|
108
|
-
}
|
|
109
|
-
// ---- redis: the twin, containerless ----------------------------------------
|
|
110
|
-
// A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
|
|
111
|
-
// unless the operator forces the container backing; the other services take the backing chosen above. Where the
|
|
112
|
-
// twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
|
|
113
|
-
// the container where there is one, else the backing's refusal naming redis.
|
|
114
|
-
const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
115
|
-
const { redisTwinCli } = await import("./redis-backing.js");
|
|
116
|
-
const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
|
|
117
|
-
const twinned = process.env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
|
|
118
|
-
/** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
|
|
119
|
-
const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
|
|
120
|
-
if (twinned.length > 0)
|
|
121
|
-
await runRedisTwin();
|
|
122
|
-
async function runRedisTwin() {
|
|
123
|
-
const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import("./redis-backing.js");
|
|
124
|
-
const cli = twinCli;
|
|
125
|
-
if (phase === 'up') {
|
|
126
|
-
process.stdout.write('managed infrastructure backing: redis twin (containerless)\n');
|
|
127
|
-
try {
|
|
128
|
-
await redisTwinUp(twinned, worldData, cli);
|
|
129
|
-
}
|
|
130
|
-
catch (error) {
|
|
131
|
-
process.stderr.write(`managed infrastructure up failed: ${String(error.message ?? error)}\n`);
|
|
132
|
-
process.exit(1);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
else if (phase === 'status') {
|
|
136
|
-
const ready = await redisTwinStatus(twinned, worldData);
|
|
137
|
-
if (ready !== twinned.length) {
|
|
138
|
-
process.stderr.write(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
|
|
139
|
-
process.exit(1);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
else {
|
|
143
|
-
await redisTwinDown(twinned, worldData);
|
|
144
|
-
}
|
|
145
|
-
if (composed.length === 0) {
|
|
146
|
-
if (phase === 'up')
|
|
147
|
-
process.stdout.write('managed infrastructure ready\n');
|
|
148
|
-
else if (phase === 'status')
|
|
149
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
150
|
-
else
|
|
151
|
-
process.stdout.write('managed infrastructure stopped\n');
|
|
152
|
-
process.exit(0);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
if (selectBacking() === 'pglite') {
|
|
156
|
-
await runPglite();
|
|
157
|
-
}
|
|
158
|
-
const only = composed ?? [];
|
|
159
|
-
if (phase === 'up') {
|
|
160
|
-
const result = run(['up', '-d', '--wait', ...only]);
|
|
161
|
-
if (result.status !== 0)
|
|
162
|
-
fail(result);
|
|
163
|
-
process.stdout.write('managed infrastructure ready\n');
|
|
164
|
-
}
|
|
165
|
-
else if (phase === 'status') {
|
|
166
|
-
const expected = run(['config', '--services']);
|
|
167
|
-
if (expected.status !== 0)
|
|
168
|
-
fail(expected);
|
|
169
|
-
const running = run(['ps', '--status', 'running', '--services']);
|
|
170
|
-
if (running.status !== 0)
|
|
171
|
-
fail(running);
|
|
172
|
-
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
173
|
-
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
174
|
-
if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
|
|
175
|
-
process.stderr.write(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
|
|
176
|
-
process.exit(1);
|
|
177
|
-
}
|
|
178
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
179
|
-
}
|
|
180
|
-
else {
|
|
181
|
-
const result = run(['down', '--remove-orphans']);
|
|
182
|
-
if (result.status !== 0)
|
|
183
|
-
fail(result);
|
|
184
|
-
process.stdout.write('managed infrastructure stopped\n');
|
|
185
|
-
}
|
|
9
|
+
process.exit(code);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface InfrastructureIo {
|
|
2
|
+
out: (text: string) => void;
|
|
3
|
+
err: (text: string) => void;
|
|
4
|
+
}
|
|
5
|
+
/** One phase of the World-managed infrastructure. `env` is the infrastructure service's environment, which the
|
|
6
|
+
* services this starts run in; `signal` is the boot's abort (the World's own up passes it; the tool has none). */
|
|
7
|
+
export declare function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, io: InfrastructureIo, signal?: AbortSignal): Promise<number>;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Private implementation for infrastructure emitted by `volter-world init`. World configs invoke
|
|
2
|
+
// this declared service; application agents never operate or diagnose its backing mechanism.
|
|
3
|
+
//
|
|
4
|
+
// One phase (up, status, down) per call, answering its exit code, with its output written through `io`. The
|
|
5
|
+
// `volter-world-infra` bin (infra-cli.ts) runs it as a process; the World's own `up` runs the up phase in its own
|
|
6
|
+
// process (runtime.ts, `ownInfrastructureUp`), so the containerless services it starts are children of the process
|
|
7
|
+
// that owns the World's lifetime, as every other service's recorder is, and not of a tool that exits once they
|
|
8
|
+
// serve. A process layer where a child does not outlive its parent (a browser tab's) otherwise ended the PGlite host
|
|
9
|
+
// the moment `volter-world-infra up` returned, and the World's readiness probe never saw it.
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
|
|
15
|
+
/** A command run to its end without blocking this process: in the World's own `up` the phase shares the event loop
|
|
16
|
+
* with the boot's signal handling, where the tool's spawnSync blocked only the tool. Ended by `timeout` (SIGTERM,
|
|
17
|
+
* as spawnSync's timeout) or by the boot's abort. */
|
|
18
|
+
function runCommand(command, args, options) {
|
|
19
|
+
return new Promise((resolveRan) => {
|
|
20
|
+
let stdout = '';
|
|
21
|
+
let stderr = '';
|
|
22
|
+
let settled = false;
|
|
23
|
+
let grace;
|
|
24
|
+
const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], ...(options.signal ? { signal: options.signal } : {}) });
|
|
25
|
+
const settle = (ran) => {
|
|
26
|
+
if (settled)
|
|
27
|
+
return;
|
|
28
|
+
settled = true;
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
if (grace)
|
|
31
|
+
clearTimeout(grace);
|
|
32
|
+
// a grandchild that still holds the pipes does not hold this answer
|
|
33
|
+
child.stdout?.destroy();
|
|
34
|
+
child.stderr?.destroy();
|
|
35
|
+
resolveRan(ran);
|
|
36
|
+
};
|
|
37
|
+
// the timeout is a hard limit and answers as spawnSync's did: the command is killed and the result carries an
|
|
38
|
+
// ETIMEDOUT error (a hung `docker info`, a daemon that never answers, is then no container runtime, as before)
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
child.kill('SIGTERM');
|
|
41
|
+
settle({ status: null, stdout, stderr, error: Object.assign(new Error(`spawn ${command} ETIMEDOUT`), { code: 'ETIMEDOUT' }) });
|
|
42
|
+
}, options.timeout);
|
|
43
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
|
44
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
|
45
|
+
// a spawn that fails (no such binary, an abort) answers as spawnSync did: no status, the error
|
|
46
|
+
child.on('error', (error) => settle({ status: null, stdout, stderr, error }));
|
|
47
|
+
// the output is whole at `close`; once the command has exited, a descendant holding its pipes open delays the
|
|
48
|
+
// answer by at most a moment, never to the timeout
|
|
49
|
+
child.on('exit', (code) => { grace = setTimeout(() => settle({ status: code, stdout, stderr }), 500); });
|
|
50
|
+
child.on('close', (code) => settle({ status: code, stdout, stderr }));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** One phase of the World-managed infrastructure. `env` is the infrastructure service's environment, which the
|
|
54
|
+
* services this starts run in; `signal` is the boot's abort (the World's own up passes it; the tool has none). */
|
|
55
|
+
export async function runInfrastructure(phase, env, io, signal) {
|
|
56
|
+
if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
|
|
57
|
+
io.err('managed infrastructure: expected up, status, or down\n');
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
const worldConfig = env.VOLTER_WORLD_CONFIG;
|
|
61
|
+
const worldData = env.VOLTER_WORLD_DATA;
|
|
62
|
+
if (!worldConfig || !worldData) {
|
|
63
|
+
io.err('managed infrastructure: missing World lifecycle context\n');
|
|
64
|
+
return 2;
|
|
65
|
+
}
|
|
66
|
+
const definition = join(dirname(worldConfig), 'world.infrastructure.yml');
|
|
67
|
+
if (!existsSync(definition)) {
|
|
68
|
+
io.err('managed infrastructure: declared definition is missing\n');
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
const base = ['compose', '-f', definition];
|
|
72
|
+
const run = async (args) => {
|
|
73
|
+
const ran = await runCommand('docker', [...base, ...args], { env: { ...env, VOLTER_WORLD_DATA: worldData }, timeout: 120_000, ...(signal ? { signal } : {}) });
|
|
74
|
+
signal?.throwIfAborted();
|
|
75
|
+
return ran;
|
|
76
|
+
};
|
|
77
|
+
const launch = { env, ...(signal ? { signal } : {}) };
|
|
78
|
+
const classify = (raw) => /no space left on device|enospc/iu.test(raw)
|
|
79
|
+
? 'insufficient writable storage'
|
|
80
|
+
: /out of memory|cannot allocate memory|killed/iu.test(raw)
|
|
81
|
+
? 'insufficient memory'
|
|
82
|
+
: /cannot connect|not running|daemon/iu.test(raw)
|
|
83
|
+
? 'local execution capacity is unavailable'
|
|
84
|
+
: 'managed infrastructure operation failed';
|
|
85
|
+
const fail = (result) => {
|
|
86
|
+
const raw = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
87
|
+
io.err(`managed infrastructure ${phase} failed: ${classify(raw)}\n`);
|
|
88
|
+
return result.status && result.status > 0 ? result.status : 1;
|
|
89
|
+
};
|
|
90
|
+
// ---- backing selection ------------------------------------------------------
|
|
91
|
+
// The declared service contract (up/status/down) is the boundary; WHICH
|
|
92
|
+
// runtime answers it is private and chosen here, per machine, at each phase:
|
|
93
|
+
// 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
|
|
94
|
+
// 2. a working container runtime — the compose path, byte-identical to before;
|
|
95
|
+
// 3. no container runtime + a definition of postgres and/or mongodb services —
|
|
96
|
+
// the containerless backing (pglite-backing.ts: PGlite for postgres, the
|
|
97
|
+
// MongoDB twin for mongodb), announced loudly;
|
|
98
|
+
// 4. otherwise the honest refusal naming what this machine cannot serve.
|
|
99
|
+
async function selectBacking() {
|
|
100
|
+
const forced = env.VOLTER_WORLD_INFRA_BACKING;
|
|
101
|
+
if (forced === 'docker' || forced === 'pglite')
|
|
102
|
+
return forced;
|
|
103
|
+
if (forced !== undefined) {
|
|
104
|
+
io.err(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
const probe = await runCommand('docker', ['info', '--format', '{{.ServerVersion}}'], { env, timeout: 10_000, ...(signal ? { signal } : {}) });
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
if (probe.status === 0)
|
|
110
|
+
return 'docker';
|
|
111
|
+
// Absence of a container runtime (no binary, no daemon) is a capability
|
|
112
|
+
// difference: swap backings. A PRESENT runtime failing on resources stays
|
|
113
|
+
// on the docker path so the real capacity problem is classified and
|
|
114
|
+
// surfaced — a full disk starves every backing equally.
|
|
115
|
+
// A shell's "command not found" (127) is the same absence: a process layer that runs commands by name answers
|
|
116
|
+
// an unknown one so, where spawnSync answered ENOSYS (a browser tab's).
|
|
117
|
+
if (probe.error !== undefined || probe.status === 127)
|
|
118
|
+
return 'pglite';
|
|
119
|
+
const reason = classify(`${probe.stdout ?? ''}\n${probe.stderr ?? ''}`);
|
|
120
|
+
return reason === 'local execution capacity is unavailable' ? 'pglite' : 'docker';
|
|
121
|
+
}
|
|
122
|
+
async function runPglite() {
|
|
123
|
+
const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import("./pglite-backing.js");
|
|
124
|
+
const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
|
|
125
|
+
if (services.length === 0) {
|
|
126
|
+
io.err('managed infrastructure: the declared definition names no services\n');
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
const unsupported = unsupportedKinds(services);
|
|
130
|
+
if (unsupported.length > 0) {
|
|
131
|
+
io.err(`managed infrastructure ${phase} failed: local execution capacity is unavailable (no container runtime, and the containerless backing cannot serve: ${unsupported.join(', ')})\n`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
if (phase === 'up') {
|
|
135
|
+
io.out('managed infrastructure backing: containerless (no container runtime)\n');
|
|
136
|
+
try {
|
|
137
|
+
await pgliteUp(services, worldData, launch);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
io.err(`managed infrastructure up failed: ${String(error.message ?? error)}\n`);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
io.out('managed infrastructure ready\n');
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
if (phase === 'status') {
|
|
147
|
+
const status = await pgliteStatus(services, worldData);
|
|
148
|
+
if (!status.ok) {
|
|
149
|
+
io.err(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
await pgliteDown(services, worldData);
|
|
156
|
+
io.out('managed infrastructure stopped\n');
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
// ---- redis: the twin, containerless ----------------------------------------
|
|
160
|
+
// A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
|
|
161
|
+
// unless the operator forces the container backing; the other services take the backing chosen above. Where the
|
|
162
|
+
// twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
|
|
163
|
+
// the container where there is one, else the backing's refusal naming redis.
|
|
164
|
+
const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
165
|
+
const { redisTwinCli } = await import("./redis-backing.js");
|
|
166
|
+
const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
|
|
167
|
+
const twinned = env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
|
|
168
|
+
/** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
|
|
169
|
+
const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
|
|
170
|
+
if (twinned.length > 0) {
|
|
171
|
+
const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import("./redis-backing.js");
|
|
172
|
+
const cli = twinCli;
|
|
173
|
+
if (phase === 'up') {
|
|
174
|
+
io.out('managed infrastructure backing: redis twin (containerless)\n');
|
|
175
|
+
try {
|
|
176
|
+
await redisTwinUp(twinned, worldData, cli, launch);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
io.err(`managed infrastructure up failed: ${String(error.message ?? error)}\n`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (phase === 'status') {
|
|
184
|
+
const ready = await redisTwinStatus(twinned, worldData);
|
|
185
|
+
if (ready !== twinned.length) {
|
|
186
|
+
io.err(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
await redisTwinDown(twinned, worldData);
|
|
192
|
+
}
|
|
193
|
+
if (composed.length === 0) {
|
|
194
|
+
if (phase === 'up')
|
|
195
|
+
io.out('managed infrastructure ready\n');
|
|
196
|
+
else if (phase === 'status')
|
|
197
|
+
io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
|
|
198
|
+
else
|
|
199
|
+
io.out('managed infrastructure stopped\n');
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const backing = await selectBacking();
|
|
204
|
+
if (typeof backing === 'number')
|
|
205
|
+
return backing;
|
|
206
|
+
if (backing === 'pglite')
|
|
207
|
+
return runPglite();
|
|
208
|
+
const only = composed ?? [];
|
|
209
|
+
if (phase === 'up') {
|
|
210
|
+
const result = await run(['up', '-d', '--wait', ...only]);
|
|
211
|
+
if (result.status !== 0)
|
|
212
|
+
return fail(result);
|
|
213
|
+
io.out('managed infrastructure ready\n');
|
|
214
|
+
}
|
|
215
|
+
else if (phase === 'status') {
|
|
216
|
+
const expected = await run(['config', '--services']);
|
|
217
|
+
if (expected.status !== 0)
|
|
218
|
+
return fail(expected);
|
|
219
|
+
const running = await run(['ps', '--status', 'running', '--services']);
|
|
220
|
+
if (running.status !== 0)
|
|
221
|
+
return fail(running);
|
|
222
|
+
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
223
|
+
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
224
|
+
if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
|
|
225
|
+
io.err(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
io.out(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, env) })}\n`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const result = await run(['down', '--remove-orphans']);
|
|
232
|
+
if (result.status !== 0)
|
|
233
|
+
return fail(result);
|
|
234
|
+
io.out('managed infrastructure stopped\n');
|
|
235
|
+
}
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
@@ -19,9 +19,18 @@ export declare function mongodbTwinCli(worldConfig?: string, froms?: string[]):
|
|
|
19
19
|
* stripping, so it runs under Bun (this process's, or the one on PATH) — the repository's toolchain —
|
|
20
20
|
* and without Bun it is refused by name rather than started to fail. */
|
|
21
21
|
export declare function twinRunner(cli: string, bunOnPath?: () => boolean, underBun?: boolean): string;
|
|
22
|
+
/** How a backing starts its hosts: the environment they run in (the World's service environment, which
|
|
23
|
+
* the runtime computes for the infrastructure service: admitted host env, the World's env, the service's own, less
|
|
24
|
+
* the control plane's NODE_OPTIONS and proxies), and the boot's abort. The `volter-world-infra` process passes
|
|
25
|
+
* nothing and its hosts inherit its own environment, which is that same service environment. The World's `up`,
|
|
26
|
+
* running the up phase in its own process, passes it: its own process.env is the operator's, not the service's. */
|
|
27
|
+
export interface HostLaunch {
|
|
28
|
+
env?: NodeJS.ProcessEnv;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}
|
|
22
31
|
/** The kinds this backing cannot serve, or [] when it can serve the world. */
|
|
23
32
|
export declare function unsupportedKinds(services: InfraService[]): string[];
|
|
24
|
-
export declare function pgliteUp(services: InfraService[], dataDir: string): Promise<void>;
|
|
33
|
+
export declare function pgliteUp(services: InfraService[], dataDir: string, launch?: HostLaunch): Promise<void>;
|
|
25
34
|
export declare function pgliteStatus(services: InfraService[], dataDir: string): Promise<{
|
|
26
35
|
ok: boolean;
|
|
27
36
|
ready: number;
|
|
@@ -102,10 +102,11 @@ export function twinRunner(cli, bunOnPath = () => spawnSync('bun', ['--version']
|
|
|
102
102
|
throw new Error(`a checkout's MongoDB twin runs under bun, which is not on PATH (${cli} is TypeScript that Node cannot run)`);
|
|
103
103
|
}
|
|
104
104
|
/** The argv that serves one service on its declared loopback port, with its bytes in `serviceData`. */
|
|
105
|
-
function hostArgv(service, serviceData) {
|
|
105
|
+
function hostArgv(service, serviceData, env) {
|
|
106
106
|
if (service.kind === 'mongodb') {
|
|
107
|
-
const cli = mongodbTwinCli(
|
|
108
|
-
|
|
107
|
+
const cli = mongodbTwinCli(env.VOLTER_WORLD_CONFIG);
|
|
108
|
+
// blocking (spawnSync), as it was in the tool: reached only for a checkout's TypeScript cli under Node
|
|
109
|
+
return [twinRunner(cli, () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000, windowsHide: true, env }).status === 0), cli, 'serve', '--port', String(service.hostPort), '--root', serviceData];
|
|
109
110
|
}
|
|
110
111
|
return ['node', hostScript(), '--port', String(service.hostPort), '--data', serviceData];
|
|
111
112
|
}
|
|
@@ -146,7 +147,8 @@ function logSince(log, offset) {
|
|
|
146
147
|
return '';
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
|
-
export async function pgliteUp(services, dataDir) {
|
|
150
|
+
export async function pgliteUp(services, dataDir, launch = {}) {
|
|
151
|
+
const env = launch.env ?? process.env;
|
|
150
152
|
// Every check before any start: a refused up starts nothing, so it never leaves the World half-booted.
|
|
151
153
|
const plan = [];
|
|
152
154
|
for (const service of services) {
|
|
@@ -159,7 +161,7 @@ export async function pgliteUp(services, dataDir) {
|
|
|
159
161
|
if (await listening(service.hostPort)) {
|
|
160
162
|
throw new Error(`${name} cannot serve port ${service.hostPort}: another process is already listening on it`);
|
|
161
163
|
}
|
|
162
|
-
plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData) });
|
|
164
|
+
plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData, env) });
|
|
163
165
|
}
|
|
164
166
|
const started = [];
|
|
165
167
|
try {
|
|
@@ -172,6 +174,7 @@ export async function pgliteUp(services, dataDir) {
|
|
|
172
174
|
const logFd = openSync(log, 'a');
|
|
173
175
|
const child = spawn(command, args, {
|
|
174
176
|
windowsHide: true,
|
|
177
|
+
env,
|
|
175
178
|
detached: true,
|
|
176
179
|
stdio: ['ignore', logFd, logFd],
|
|
177
180
|
});
|
|
@@ -188,6 +191,8 @@ export async function pgliteUp(services, dataDir) {
|
|
|
188
191
|
throw new Error(`${name} exited before serving — see ${log}`);
|
|
189
192
|
if (Date.now() > deadline)
|
|
190
193
|
throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
194
|
+
// an aborted boot (Ctrl-C during up) stops waiting; the catch below ends what this up started
|
|
195
|
+
launch.signal?.throwIfAborted();
|
|
191
196
|
await sleep(200);
|
|
192
197
|
}
|
|
193
198
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { InfraService } from './pglite-backing.js';
|
|
1
|
+
import type { HostLaunch, InfraService } from './pglite-backing.js';
|
|
2
2
|
/** The redis twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
|
|
3
3
|
export declare function redisTwinCli(from: string[]): string | undefined;
|
|
4
4
|
/** Where the service's keys live: the World's tree for the `redis` service, under the World's data. */
|
|
5
5
|
export declare const redisTwinRoot: (dataDir: string) => string;
|
|
6
|
-
export declare function redisTwinUp(services: InfraService[], dataDir: string, cli: string): Promise<void>;
|
|
6
|
+
export declare function redisTwinUp(services: InfraService[], dataDir: string, cli: string, launch?: HostLaunch): Promise<void>;
|
|
7
7
|
export declare function redisTwinStatus(services: InfraService[], dataDir: string): Promise<number>;
|
|
8
8
|
export declare function redisTwinDown(services: InfraService[], dataDir: string): Promise<void>;
|
|
@@ -48,7 +48,7 @@ function listening(port) {
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
|
-
export async function redisTwinUp(services, dataDir, cli) {
|
|
51
|
+
export async function redisTwinUp(services, dataDir, cli, launch = {}) {
|
|
52
52
|
for (const service of services) {
|
|
53
53
|
const pidFile = pidPath(dataDir, service);
|
|
54
54
|
if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort))
|
|
@@ -67,6 +67,7 @@ export async function redisTwinUp(services, dataDir, cli) {
|
|
|
67
67
|
// type transformation, not only stripping (the kernel declares constructor parameter properties)
|
|
68
68
|
const runtime = typeof globalThis.Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
|
|
69
69
|
const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root], {
|
|
70
|
+
env: launch.env ?? process.env,
|
|
70
71
|
detached: true,
|
|
71
72
|
stdio: ['ignore', logFd, logFd],
|
|
72
73
|
});
|
|
@@ -77,6 +78,7 @@ export async function redisTwinUp(services, dataDir, cli) {
|
|
|
77
78
|
while (!(await listening(service.hostPort)) || !alive(child.pid)) {
|
|
78
79
|
if (!alive(child.pid))
|
|
79
80
|
throw new Error(`redis exited before serving — see ${log}`);
|
|
81
|
+
launch.signal?.throwIfAborted();
|
|
80
82
|
if (Date.now() > deadline)
|
|
81
83
|
throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
82
84
|
await sleep(200);
|
package/dist/src/runtime.js
CHANGED
|
@@ -12,7 +12,7 @@ import { createRequire } from 'node:module';
|
|
|
12
12
|
import { isIP } from 'node:net';
|
|
13
13
|
import { allocatePort } from "./tcp-port.js";
|
|
14
14
|
import { constants as osConstants, hostname as osHostname } from 'node:os';
|
|
15
|
-
import { appendFileSync, chmodSync, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync, writeSync, existsSync } from 'node:fs';
|
|
15
|
+
import { accessSync, appendFileSync, chmodSync, closeSync, constants as fsConstants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync, existsSync } from 'node:fs';
|
|
16
16
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
17
17
|
import { packFacts } from "./pack-facts.js";
|
|
18
18
|
import { describeServiceEnd, readServiceEnd, serviceOutput } from "./service-exit.js";
|
|
@@ -803,6 +803,8 @@ async function runExternalCommand(command, cwd, serviceId, phase, env = process.
|
|
|
803
803
|
* (instead of buffering through spawnSync's 1MB cap, which a large DB restore would overflow). Returns
|
|
804
804
|
* the combined output so `discover` can read it. Throws with a bounded tail on nonzero exit. */
|
|
805
805
|
async function runExternalUp(command, cwd, serviceId, env, logPath, abort) {
|
|
806
|
+
if (ownInfrastructureUp(command))
|
|
807
|
+
return runOwnInfrastructureUp(command, serviceId, env, logPath, abort);
|
|
806
808
|
const [bin, ...args] = ownCommand(command);
|
|
807
809
|
if (!bin)
|
|
808
810
|
throw new Error(`External service "${serviceId}": up command is empty`);
|
|
@@ -846,6 +848,40 @@ async function runExternalUp(command, cwd, serviceId, env, logPath, abort) {
|
|
|
846
848
|
}
|
|
847
849
|
return chunks.join('');
|
|
848
850
|
}
|
|
851
|
+
/** Whether an external `up` is this runtime's own managed-infrastructure tool (`volter-world-infra up`, which `init`
|
|
852
|
+
* names), with nothing on PATH answering the name: the phase then runs in this process (infra.ts) instead of in a
|
|
853
|
+
* child. The services it starts (the PGlite host, the redis and MongoDB twins) are then children of the process that
|
|
854
|
+
* owns the World's lifetime, as every service recorder is, not of a tool that exits once they serve. Where a child
|
|
855
|
+
* does not outlive its parent (a browser tab's process layer, measured 2026-09-28: a detached, unref'd listener
|
|
856
|
+
* ended with the process that spawned it), the PGlite host ended the moment `volter-world-infra up` returned and
|
|
857
|
+
* the readiness probe timed out at "0/1 declared services are ready". status and down still run as the tool.
|
|
858
|
+
* The phase gets what the tool's process got: the infrastructure service's environment (`env`, which the hosts it
|
|
859
|
+
* starts are spawned with, never this process's own) and the boot's abort; its commands (docker info, compose)
|
|
860
|
+
* run without blocking this process's event loop. */
|
|
861
|
+
function ownInfrastructureUp(command) {
|
|
862
|
+
return command[0] === 'volter-world-infra' && command[1] === 'up' && ownCommand(command)[0] === process.execPath;
|
|
863
|
+
}
|
|
864
|
+
async function runOwnInfrastructureUp(command, serviceId, env, logPath, abort) {
|
|
865
|
+
writeFileSync(logPath, `$ ${command.join(' ')}\n`, { mode: 0o600 });
|
|
866
|
+
chmodSync(logPath, 0o600);
|
|
867
|
+
const out = openPrivateLog(logPath, 'a');
|
|
868
|
+
const chunks = [];
|
|
869
|
+
try {
|
|
870
|
+
abort?.throwIfAborted();
|
|
871
|
+
const { runInfrastructure } = await import("./infra.js");
|
|
872
|
+
const write = (text) => { chunks.push(text); writeSync(out, text); };
|
|
873
|
+
const code = await runInfrastructure(command[1], env, { out: write, err: write }, abort);
|
|
874
|
+
abort?.throwIfAborted();
|
|
875
|
+
if (code !== 0) {
|
|
876
|
+
const detail = chunks.join('').trim().split('\n').slice(-40).join('\n');
|
|
877
|
+
throw new Error(`External service "${serviceId}": up command exited ${code}\n${detail}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
finally {
|
|
881
|
+
closeSync(out);
|
|
882
|
+
}
|
|
883
|
+
return chunks.join('');
|
|
884
|
+
}
|
|
849
885
|
/** Resolve `${url}`/`${host}`/`${port}` in a readiness probe's httpUrl against an owned loopback
|
|
850
886
|
* service's allocated port (process/twin readiness — the port is auto-assigned, so it can't be a
|
|
851
887
|
* literal in the config). Other probe kinds (command/stdoutMatch) are returned unchanged. */
|
|
@@ -2236,7 +2272,21 @@ export function ownCommand(command) {
|
|
|
2236
2272
|
return command;
|
|
2237
2273
|
return [process.execPath, siblingScript(import.meta.url, stem), ...args];
|
|
2238
2274
|
}
|
|
2275
|
+
/** A command answers when it names an executable: a path is checked as a file (what `which` does with a path),
|
|
2276
|
+
* never by spawning `which`. `ownCommand` hands this the running executable's own path (process.execPath), and a
|
|
2277
|
+
* process layer without synchronous spawn (a browser tab's, where spawnSync is ENOSYS) answered "not found on PATH"
|
|
2278
|
+
* for the very node running the World: the managed infrastructure never started there. A bare name is still looked
|
|
2279
|
+
* up on PATH. */
|
|
2239
2280
|
function onPath(command) {
|
|
2281
|
+
if (command.includes(sep) || command.includes('/')) {
|
|
2282
|
+
try {
|
|
2283
|
+
accessSync(command, fsConstants.X_OK);
|
|
2284
|
+
return statSync(command).isFile();
|
|
2285
|
+
}
|
|
2286
|
+
catch {
|
|
2287
|
+
return false;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2240
2290
|
return spawnSync('which', [command], { windowsHide: true, stdio: 'ignore', env: process.env }).status === 0;
|
|
2241
2291
|
}
|
|
2242
2292
|
function resolveWithPublicDns(hostname) {
|