@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/served-world.js
CHANGED
|
@@ -156,7 +156,8 @@ export async function mountWorld(name, opts = {}) {
|
|
|
156
156
|
const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
|
|
157
157
|
const keep = () => { writeSecret(join(worldRoot, stateDirName(), 'token'), world.token); writeSecret(join(worldRoot, stateDirName(), 'token.read'), world.readToken); };
|
|
158
158
|
let timers = [];
|
|
159
|
-
|
|
159
|
+
let stopping = null;
|
|
160
|
+
const mounted = {
|
|
160
161
|
name, served, root: worldRoot,
|
|
161
162
|
get token() { return world.token; }, get readToken() { return world.readToken; },
|
|
162
163
|
twins: () => world.twins(),
|
|
@@ -193,18 +194,40 @@ export async function mountWorld(name, opts = {}) {
|
|
|
193
194
|
throw error;
|
|
194
195
|
}
|
|
195
196
|
},
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
rmSync(record, { force: true });
|
|
201
|
-
}
|
|
202
|
-
catch { /* already gone */ }
|
|
203
|
-
const result = await downWorld(name, worldRoot);
|
|
204
|
-
if (result.externalErrors.length)
|
|
205
|
-
throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
|
|
197
|
+
stop() {
|
|
198
|
+
// one stop at a time: a second caller (the view shutting down while a switch stops this World) shares the first's
|
|
199
|
+
stopping ??= stopOnce().finally(() => { stopping = null; });
|
|
200
|
+
return stopping;
|
|
206
201
|
},
|
|
207
202
|
};
|
|
203
|
+
async function stopOnce() {
|
|
204
|
+
// refreshes stop first, so none writes into the World while its twins stop; the serve record goes only once they are
|
|
205
|
+
// down. A stop that fails keeps the record (a checkout under the serve still refuses) and re-arms the refreshes:
|
|
206
|
+
// refused before anything stopped, the World is served as it was; failed part way, its record names what remains
|
|
207
|
+
for (const t of timers)
|
|
208
|
+
clearInterval(t);
|
|
209
|
+
timers = [];
|
|
210
|
+
let result;
|
|
211
|
+
try {
|
|
212
|
+
result = await downWorld(name, worldRoot);
|
|
213
|
+
if (result.externalErrors.length)
|
|
214
|
+
throw new Error(`World "${name}" shutdown incomplete; instance and reservation retained: ${result.externalErrors.join('; ')}`);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (instance) {
|
|
218
|
+
try {
|
|
219
|
+
timers = await scheduleRefreshes(name, worldRoot, instance);
|
|
220
|
+
}
|
|
221
|
+
catch { /* the stop's error is the one reported */ }
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
rmSync(record, { force: true });
|
|
227
|
+
}
|
|
228
|
+
catch { /* already gone */ }
|
|
229
|
+
}
|
|
230
|
+
return mounted;
|
|
208
231
|
}
|
|
209
232
|
/** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
|
|
210
233
|
const localMirrors = new Map();
|
|
@@ -1564,8 +1587,10 @@ export class WorldDoors {
|
|
|
1564
1587
|
const state = this.stateOf(vendor);
|
|
1565
1588
|
const controlRoot = this.controlRoot(vendor);
|
|
1566
1589
|
const counts = new Map();
|
|
1590
|
+
// what the World holds as the vendor's own resources: the twin's bookkeeping (a `_` type, as a screens sign-in's token)
|
|
1591
|
+
// is never a resource a person reads, as it is in no log or diff
|
|
1567
1592
|
for (const r of readTree(state, controlRoot))
|
|
1568
|
-
if (r.deleted !== true)
|
|
1593
|
+
if (r.deleted !== true && !r.type.startsWith('_'))
|
|
1569
1594
|
counts.set(r.type, (counts.get(r.type) ?? 0) + 1);
|
|
1570
1595
|
twins.push({
|
|
1571
1596
|
twin: vendor, position: wholeLog(state, controlRoot).length,
|
package/dist/src/world-view.js
CHANGED
|
@@ -55,17 +55,8 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
55
55
|
switching = true;
|
|
56
56
|
const from = world.name;
|
|
57
57
|
try {
|
|
58
|
-
// a stop that
|
|
59
|
-
|
|
60
|
-
await world.stop();
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
try {
|
|
64
|
-
await world.boot(url);
|
|
65
|
-
}
|
|
66
|
-
catch { /* the stop's own error is the one reported */ }
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
58
|
+
// a stop that is refused or fails leaves this branch served as it was (served-world.ts stop): the switch just fails
|
|
59
|
+
await world.stop();
|
|
69
60
|
let next;
|
|
70
61
|
try {
|
|
71
62
|
next = await mountWorld(branch, mountOpts);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.17",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
"node": ">=22.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@volter/world-core": "2.0.
|
|
70
|
+
"@volter/world-core": "2.0.10",
|
|
71
71
|
"@volter/world-console": "2.0.9"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
|
75
75
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
76
|
"@volter/world-access": "2.0.1",
|
|
77
|
-
"@volter/world-core": "2.0.
|
|
77
|
+
"@volter/world-core": "2.0.10",
|
|
78
78
|
"pg-gateway": "0.3.0-beta.4",
|
|
79
79
|
"smol-toml": "^1.8.0"
|
|
80
80
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
86
|
"optionalDependencies": {
|
|
87
|
-
"@volter/twin-mongodb": "0.1.
|
|
87
|
+
"@volter/twin-mongodb": "0.1.10"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/infra-cli.ts
CHANGED
|
@@ -1,177 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
// this declared service; application agents never operate or diagnose its backing
|
|
4
|
-
import {
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import { spawnSync } from 'node:child_process';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
|
|
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.ts';
|
|
9
5
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
process.stderr.write(
|
|
13
|
-
process.exit(2);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const worldConfig = process.env.VOLTER_WORLD_CONFIG;
|
|
17
|
-
const worldData = process.env.VOLTER_WORLD_DATA;
|
|
18
|
-
if (!worldConfig || !worldData) {
|
|
19
|
-
process.stderr.write('managed infrastructure: missing World lifecycle context\n');
|
|
20
|
-
process.exit(2);
|
|
21
|
-
}
|
|
22
|
-
const definition = join(dirname(worldConfig), 'world.infrastructure.yml');
|
|
23
|
-
if (!existsSync(definition)) {
|
|
24
|
-
process.stderr.write('managed infrastructure: declared definition is missing\n');
|
|
25
|
-
process.exit(2);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const base = ['compose', '-f', definition];
|
|
29
|
-
const run = (args: string[]) => spawnSync('docker', [...base, ...args], { windowsHide: true,
|
|
30
|
-
encoding: 'utf8',
|
|
31
|
-
env: { ...process.env, VOLTER_WORLD_DATA: worldData },
|
|
32
|
-
timeout: 120_000,
|
|
6
|
+
const code = await runInfrastructure(process.argv[2] ?? '', process.env, {
|
|
7
|
+
out: (text) => { process.stdout.write(text); },
|
|
8
|
+
err: (text) => { process.stderr.write(text); },
|
|
33
9
|
});
|
|
34
|
-
|
|
35
|
-
? 'insufficient writable storage'
|
|
36
|
-
: /out of memory|cannot allocate memory|killed/iu.test(raw)
|
|
37
|
-
? 'insufficient memory'
|
|
38
|
-
: /cannot connect|not running|daemon/iu.test(raw)
|
|
39
|
-
? 'local execution capacity is unavailable'
|
|
40
|
-
: 'managed infrastructure operation failed';
|
|
41
|
-
const fail = (result: ReturnType<typeof run>): never => {
|
|
42
|
-
const raw = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
43
|
-
process.stderr.write(`managed infrastructure ${phase} failed: ${classify(raw)}\n`);
|
|
44
|
-
process.exit(result.status && result.status > 0 ? result.status : 1);
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
// ---- backing selection ------------------------------------------------------
|
|
48
|
-
// The declared service contract (up/status/down) is the boundary; WHICH
|
|
49
|
-
// runtime answers it is private and chosen here, per machine, at each phase:
|
|
50
|
-
// 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
|
|
51
|
-
// 2. a working container runtime — the compose path, byte-identical to before;
|
|
52
|
-
// 3. no container runtime + a definition of postgres and/or mongodb services —
|
|
53
|
-
// the containerless backing (pglite-backing.ts: PGlite for postgres, the
|
|
54
|
-
// MongoDB twin for mongodb), announced loudly;
|
|
55
|
-
// 4. otherwise the honest refusal naming what this machine cannot serve.
|
|
56
|
-
function selectBacking(): 'docker' | 'pglite' {
|
|
57
|
-
const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
|
|
58
|
-
if (forced === 'docker' || forced === 'pglite') return forced;
|
|
59
|
-
if (forced !== undefined) {
|
|
60
|
-
process.stderr.write(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
|
|
61
|
-
process.exit(2);
|
|
62
|
-
}
|
|
63
|
-
const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { windowsHide: true, encoding: 'utf8', timeout: 10_000 });
|
|
64
|
-
if (probe.status === 0) return 'docker';
|
|
65
|
-
// Absence of a container runtime (no binary, no daemon) is a capability
|
|
66
|
-
// difference: swap backings. A PRESENT runtime failing on resources stays
|
|
67
|
-
// on the docker path so the real capacity problem is classified and
|
|
68
|
-
// surfaced — a full disk starves every backing equally.
|
|
69
|
-
if (probe.error !== undefined) return 'pglite';
|
|
70
|
-
const reason = classify(`${probe.stdout ?? ''}\n${probe.stderr ?? ''}`);
|
|
71
|
-
return reason === 'local execution capacity is unavailable' ? 'pglite' : 'docker';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function runPglite(): Promise<never> {
|
|
75
|
-
const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
|
|
76
|
-
const services = parseInfraDefinition(readFileSync(definition, 'utf8')).filter((s) => composed === null || composed.includes(s.kind));
|
|
77
|
-
if (services.length === 0) {
|
|
78
|
-
process.stderr.write('managed infrastructure: the declared definition names no services\n');
|
|
79
|
-
process.exit(1);
|
|
80
|
-
}
|
|
81
|
-
const unsupported = unsupportedKinds(services);
|
|
82
|
-
if (unsupported.length > 0) {
|
|
83
|
-
process.stderr.write(`managed infrastructure ${phase} failed: local execution capacity is unavailable (no container runtime, and the containerless backing cannot serve: ${unsupported.join(', ')})\n`);
|
|
84
|
-
process.exit(1);
|
|
85
|
-
}
|
|
86
|
-
if (phase === 'up') {
|
|
87
|
-
process.stdout.write('managed infrastructure backing: containerless (no container runtime)\n');
|
|
88
|
-
try {
|
|
89
|
-
await pgliteUp(services, worldData!);
|
|
90
|
-
} catch (error) {
|
|
91
|
-
process.stderr.write(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
|
|
92
|
-
process.exit(1);
|
|
93
|
-
}
|
|
94
|
-
process.stdout.write('managed infrastructure ready\n');
|
|
95
|
-
process.exit(0);
|
|
96
|
-
}
|
|
97
|
-
if (phase === 'status') {
|
|
98
|
-
const status = await pgliteStatus(services, worldData!);
|
|
99
|
-
if (!status.ok) {
|
|
100
|
-
process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
|
|
101
|
-
process.exit(1);
|
|
102
|
-
}
|
|
103
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
104
|
-
process.exit(0);
|
|
105
|
-
}
|
|
106
|
-
await pgliteDown(services, worldData!);
|
|
107
|
-
process.stdout.write('managed infrastructure stopped\n');
|
|
108
|
-
process.exit(0);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ---- redis: the twin, containerless ----------------------------------------
|
|
112
|
-
// A redis service is served by the redis twin (redis-backing.ts) on every machine, container runtime or not,
|
|
113
|
-
// unless the operator forces the container backing; the other services take the backing chosen above. Where the
|
|
114
|
-
// twin is not installed (@volter/twin-redis is not this runtime's dependency), redis goes to that backing too:
|
|
115
|
-
// the container where there is one, else the backing's refusal naming redis.
|
|
116
|
-
const declared = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
117
|
-
const { redisTwinCli } = await import('./redis-backing.ts');
|
|
118
|
-
const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
|
|
119
|
-
const twinned = process.env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
|
|
120
|
-
/** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
|
|
121
|
-
const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
|
|
122
|
-
if (twinned.length > 0) await runRedisTwin();
|
|
123
|
-
|
|
124
|
-
async function runRedisTwin(): Promise<void> {
|
|
125
|
-
const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import('./redis-backing.ts');
|
|
126
|
-
const cli = twinCli!;
|
|
127
|
-
if (phase === 'up') {
|
|
128
|
-
process.stdout.write('managed infrastructure backing: redis twin (containerless)\n');
|
|
129
|
-
try {
|
|
130
|
-
await redisTwinUp(twinned, worldData!, cli);
|
|
131
|
-
} catch (error) {
|
|
132
|
-
process.stderr.write(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
|
|
133
|
-
process.exit(1);
|
|
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
|
-
} else {
|
|
142
|
-
await redisTwinDown(twinned, worldData!);
|
|
143
|
-
}
|
|
144
|
-
if (composed!.length === 0) {
|
|
145
|
-
if (phase === 'up') process.stdout.write('managed infrastructure ready\n');
|
|
146
|
-
else if (phase === 'status') process.stdout.write(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
147
|
-
else process.stdout.write('managed infrastructure stopped\n');
|
|
148
|
-
process.exit(0);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (selectBacking() === 'pglite') {
|
|
153
|
-
await runPglite();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const only = composed ?? [];
|
|
157
|
-
if (phase === 'up') {
|
|
158
|
-
const result = run(['up', '-d', '--wait', ...only]);
|
|
159
|
-
if (result.status !== 0) fail(result);
|
|
160
|
-
process.stdout.write('managed infrastructure ready\n');
|
|
161
|
-
} else if (phase === 'status') {
|
|
162
|
-
const expected = run(['config', '--services']);
|
|
163
|
-
if (expected.status !== 0) fail(expected);
|
|
164
|
-
const running = run(['ps', '--status', 'running', '--services']);
|
|
165
|
-
if (running.status !== 0) fail(running);
|
|
166
|
-
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
167
|
-
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
168
|
-
if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
|
|
169
|
-
process.stderr.write(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
|
|
170
|
-
process.exit(1);
|
|
171
|
-
}
|
|
172
|
-
process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, process.env) })}\n`);
|
|
173
|
-
} else {
|
|
174
|
-
const result = run(['down', '--remove-orphans']);
|
|
175
|
-
if (result.status !== 0) fail(result);
|
|
176
|
-
process.stdout.write('managed infrastructure stopped\n');
|
|
177
|
-
}
|
|
10
|
+
process.exit(code);
|
package/src/infra.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
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.ts';
|
|
15
|
+
|
|
16
|
+
export interface InfrastructureIo { out: (text: string) => void; err: (text: string) => void }
|
|
17
|
+
|
|
18
|
+
interface Ran { status: number | null; stdout: string; stderr: string; error?: Error }
|
|
19
|
+
|
|
20
|
+
/** A command run to its end without blocking this process: in the World's own `up` the phase shares the event loop
|
|
21
|
+
* with the boot's signal handling, where the tool's spawnSync blocked only the tool. Ended by `timeout` (SIGTERM,
|
|
22
|
+
* as spawnSync's timeout) or by the boot's abort. */
|
|
23
|
+
function runCommand(command: string, args: string[], options: { env: NodeJS.ProcessEnv; timeout: number; signal?: AbortSignal }): Promise<Ran> {
|
|
24
|
+
return new Promise((resolveRan) => {
|
|
25
|
+
let stdout = ''; let stderr = ''; let settled = false;
|
|
26
|
+
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
27
|
+
const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], ...(options.signal ? { signal: options.signal } : {}) });
|
|
28
|
+
const settle = (ran: Ran) => {
|
|
29
|
+
if (settled) return;
|
|
30
|
+
settled = true; clearTimeout(timer); if (grace) clearTimeout(grace);
|
|
31
|
+
// a grandchild that still holds the pipes does not hold this answer
|
|
32
|
+
child.stdout?.destroy(); child.stderr?.destroy();
|
|
33
|
+
resolveRan(ran);
|
|
34
|
+
};
|
|
35
|
+
// the timeout is a hard limit and answers as spawnSync's did: the command is killed and the result carries an
|
|
36
|
+
// ETIMEDOUT error (a hung `docker info`, a daemon that never answers, is then no container runtime, as before)
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
child.kill('SIGTERM');
|
|
39
|
+
settle({ status: null, stdout, stderr, error: Object.assign(new Error(`spawn ${command} ETIMEDOUT`), { code: 'ETIMEDOUT' }) });
|
|
40
|
+
}, options.timeout);
|
|
41
|
+
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
|
|
42
|
+
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
|
|
43
|
+
// a spawn that fails (no such binary, an abort) answers as spawnSync did: no status, the error
|
|
44
|
+
child.on('error', (error) => settle({ status: null, stdout, stderr, error }));
|
|
45
|
+
// the output is whole at `close`; once the command has exited, a descendant holding its pipes open delays the
|
|
46
|
+
// answer by at most a moment, never to the timeout
|
|
47
|
+
child.on('exit', (code) => { grace = setTimeout(() => settle({ status: code, stdout, stderr }), 500); });
|
|
48
|
+
child.on('close', (code) => settle({ status: code, stdout, stderr }));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One phase of the World-managed infrastructure. `env` is the infrastructure service's environment, which the
|
|
53
|
+
* services this starts run in; `signal` is the boot's abort (the World's own up passes it; the tool has none). */
|
|
54
|
+
export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, io: InfrastructureIo, signal?: AbortSignal): Promise<number> {
|
|
55
|
+
if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
|
|
56
|
+
io.err('managed infrastructure: expected up, status, or down\n');
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
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
|
+
|
|
72
|
+
const base = ['compose', '-f', definition];
|
|
73
|
+
const run = async (args: string[]): Promise<Ran> => {
|
|
74
|
+
const ran = await runCommand('docker', [...base, ...args], { env: { ...env, VOLTER_WORLD_DATA: worldData }, timeout: 120_000, ...(signal ? { signal } : {}) });
|
|
75
|
+
signal?.throwIfAborted();
|
|
76
|
+
return ran;
|
|
77
|
+
};
|
|
78
|
+
const launch = { env, ...(signal ? { signal } : {}) };
|
|
79
|
+
const classify = (raw: string): string => /no space left on device|enospc/iu.test(raw)
|
|
80
|
+
? 'insufficient writable storage'
|
|
81
|
+
: /out of memory|cannot allocate memory|killed/iu.test(raw)
|
|
82
|
+
? 'insufficient memory'
|
|
83
|
+
: /cannot connect|not running|daemon/iu.test(raw)
|
|
84
|
+
? 'local execution capacity is unavailable'
|
|
85
|
+
: 'managed infrastructure operation failed';
|
|
86
|
+
const fail = (result: Ran): number => {
|
|
87
|
+
const raw = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
88
|
+
io.err(`managed infrastructure ${phase} failed: ${classify(raw)}\n`);
|
|
89
|
+
return result.status && result.status > 0 ? result.status : 1;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ---- backing selection ------------------------------------------------------
|
|
93
|
+
// The declared service contract (up/status/down) is the boundary; WHICH
|
|
94
|
+
// runtime answers it is private and chosen here, per machine, at each phase:
|
|
95
|
+
// 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
|
|
96
|
+
// 2. a working container runtime — the compose path, byte-identical to before;
|
|
97
|
+
// 3. no container runtime + a definition of postgres and/or mongodb services —
|
|
98
|
+
// the containerless backing (pglite-backing.ts: PGlite for postgres, the
|
|
99
|
+
// MongoDB twin for mongodb), announced loudly;
|
|
100
|
+
// 4. otherwise the honest refusal naming what this machine cannot serve.
|
|
101
|
+
async function selectBacking(): Promise<'docker' | 'pglite' | number> {
|
|
102
|
+
const forced = env.VOLTER_WORLD_INFRA_BACKING;
|
|
103
|
+
if (forced === 'docker' || forced === 'pglite') return forced;
|
|
104
|
+
if (forced !== undefined) {
|
|
105
|
+
io.err(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
const probe = await runCommand('docker', ['info', '--format', '{{.ServerVersion}}'], { env, timeout: 10_000, ...(signal ? { signal } : {}) });
|
|
109
|
+
signal?.throwIfAborted();
|
|
110
|
+
if (probe.status === 0) 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) return 'pglite';
|
|
118
|
+
const reason = classify(`${probe.stdout ?? ''}\n${probe.stderr ?? ''}`);
|
|
119
|
+
return reason === 'local execution capacity is unavailable' ? 'pglite' : 'docker';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function runPglite(): Promise<number> {
|
|
123
|
+
const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
|
|
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
|
+
} catch (error) {
|
|
139
|
+
io.err(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
io.out('managed infrastructure ready\n');
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
if (phase === 'status') {
|
|
146
|
+
const status = await pgliteStatus(services, worldData!);
|
|
147
|
+
if (!status.ok) {
|
|
148
|
+
io.err(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
await pgliteDown(services, worldData!);
|
|
155
|
+
io.out('managed infrastructure stopped\n');
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
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.ts');
|
|
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.ts');
|
|
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
|
+
} catch (error) {
|
|
178
|
+
io.err(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
} else if (phase === 'status') {
|
|
182
|
+
const ready = await redisTwinStatus(twinned, worldData!);
|
|
183
|
+
if (ready !== twinned.length) {
|
|
184
|
+
io.err(`managed infrastructure status failed: ${ready}/${declared.length} declared services are ready\n`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
await redisTwinDown(twinned, worldData!);
|
|
189
|
+
}
|
|
190
|
+
if (composed!.length === 0) {
|
|
191
|
+
if (phase === 'up') io.out('managed infrastructure ready\n');
|
|
192
|
+
else if (phase === 'status') io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
|
|
193
|
+
else io.out('managed infrastructure stopped\n');
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const backing = await selectBacking();
|
|
199
|
+
if (typeof backing === 'number') return backing;
|
|
200
|
+
if (backing === 'pglite') return runPglite();
|
|
201
|
+
|
|
202
|
+
const only = composed ?? [];
|
|
203
|
+
if (phase === 'up') {
|
|
204
|
+
const result = await run(['up', '-d', '--wait', ...only]);
|
|
205
|
+
if (result.status !== 0) return fail(result);
|
|
206
|
+
io.out('managed infrastructure ready\n');
|
|
207
|
+
} else if (phase === 'status') {
|
|
208
|
+
const expected = await run(['config', '--services']);
|
|
209
|
+
if (expected.status !== 0) return fail(expected);
|
|
210
|
+
const running = await run(['ps', '--status', 'running', '--services']);
|
|
211
|
+
if (running.status !== 0) return fail(running);
|
|
212
|
+
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
213
|
+
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
|
|
214
|
+
if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
|
|
215
|
+
io.err(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
io.out(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, env) })}\n`);
|
|
219
|
+
} else {
|
|
220
|
+
const result = await run(['down', '--remove-orphans']);
|
|
221
|
+
if (result.status !== 0) return fail(result);
|
|
222
|
+
io.out('managed infrastructure stopped\n');
|
|
223
|
+
}
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
package/src/pglite-backing.ts
CHANGED
|
@@ -97,11 +97,19 @@ export function twinRunner(cli: string, bunOnPath: () => boolean = () => spawnSy
|
|
|
97
97
|
throw new Error(`a checkout's MongoDB twin runs under bun, which is not on PATH (${cli} is TypeScript that Node cannot run)`);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** How a backing starts its hosts: the environment they run in (the World's service environment, which
|
|
101
|
+
* the runtime computes for the infrastructure service: admitted host env, the World's env, the service's own, less
|
|
102
|
+
* the control plane's NODE_OPTIONS and proxies), and the boot's abort. The `volter-world-infra` process passes
|
|
103
|
+
* nothing and its hosts inherit its own environment, which is that same service environment. The World's `up`,
|
|
104
|
+
* running the up phase in its own process, passes it: its own process.env is the operator's, not the service's. */
|
|
105
|
+
export interface HostLaunch { env?: NodeJS.ProcessEnv; signal?: AbortSignal }
|
|
106
|
+
|
|
100
107
|
/** The argv that serves one service on its declared loopback port, with its bytes in `serviceData`. */
|
|
101
|
-
function hostArgv(service: InfraService, serviceData: string): string[] {
|
|
108
|
+
function hostArgv(service: InfraService, serviceData: string, env: NodeJS.ProcessEnv): string[] {
|
|
102
109
|
if (service.kind === 'mongodb') {
|
|
103
|
-
const cli = mongodbTwinCli(
|
|
104
|
-
|
|
110
|
+
const cli = mongodbTwinCli(env.VOLTER_WORLD_CONFIG);
|
|
111
|
+
// blocking (spawnSync), as it was in the tool: reached only for a checkout's TypeScript cli under Node
|
|
112
|
+
return [twinRunner(cli, () => spawnSync('bun', ['--version'], { stdio: 'ignore', timeout: 10_000, windowsHide: true, env }).status === 0), cli, 'serve', '--port', String(service.hostPort), '--root', serviceData];
|
|
105
113
|
}
|
|
106
114
|
return ['node', hostScript(), '--port', String(service.hostPort), '--data', serviceData];
|
|
107
115
|
}
|
|
@@ -138,7 +146,8 @@ function logSince(log: string, offset: number): string {
|
|
|
138
146
|
try { return readFileSync(log).subarray(offset).toString('utf8'); } catch { return ''; }
|
|
139
147
|
}
|
|
140
148
|
|
|
141
|
-
export async function pgliteUp(services: InfraService[], dataDir: string): Promise<void> {
|
|
149
|
+
export async function pgliteUp(services: InfraService[], dataDir: string, launch: HostLaunch = {}): Promise<void> {
|
|
150
|
+
const env = launch.env ?? process.env;
|
|
142
151
|
// Every check before any start: a refused up starts nothing, so it never leaves the World half-booted.
|
|
143
152
|
const plan: Array<{ service: InfraService; name: string; serviceData: string; log: string; pidFile: string; argv: string[] }> = [];
|
|
144
153
|
for (const service of services) {
|
|
@@ -150,7 +159,7 @@ export async function pgliteUp(services: InfraService[], dataDir: string): Promi
|
|
|
150
159
|
if (await listening(service.hostPort)) {
|
|
151
160
|
throw new Error(`${name} cannot serve port ${service.hostPort}: another process is already listening on it`);
|
|
152
161
|
}
|
|
153
|
-
plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData) });
|
|
162
|
+
plan.push({ service, name, serviceData, log, pidFile, argv: hostArgv(service, serviceData, env) });
|
|
154
163
|
}
|
|
155
164
|
const started: Array<{ pid: number; pidFile: string }> = [];
|
|
156
165
|
try {
|
|
@@ -163,6 +172,7 @@ export async function pgliteUp(services: InfraService[], dataDir: string): Promi
|
|
|
163
172
|
const logFd = openSync(log, 'a');
|
|
164
173
|
const child = spawn(command!, args, {
|
|
165
174
|
windowsHide: true,
|
|
175
|
+
env,
|
|
166
176
|
detached: true,
|
|
167
177
|
stdio: ['ignore', logFd, logFd],
|
|
168
178
|
});
|
|
@@ -177,6 +187,8 @@ export async function pgliteUp(services: InfraService[], dataDir: string): Promi
|
|
|
177
187
|
while (!(alive(child.pid!) && logSince(log, offset).includes(line) && await listening(service.hostPort))) {
|
|
178
188
|
if (!alive(child.pid!)) throw new Error(`${name} exited before serving — see ${log}`);
|
|
179
189
|
if (Date.now() > deadline) throw new Error(`${name} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
190
|
+
// an aborted boot (Ctrl-C during up) stops waiting; the catch below ends what this up started
|
|
191
|
+
launch.signal?.throwIfAborted();
|
|
180
192
|
await sleep(200);
|
|
181
193
|
}
|
|
182
194
|
}
|
package/src/redis-backing.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { connect } from 'node:net';
|
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
15
|
import { findInstalledPackage, packCli } from './catalog.ts';
|
|
16
|
-
import type { InfraService } from './pglite-backing.ts';
|
|
16
|
+
import type { HostLaunch, InfraService } from './pglite-backing.ts';
|
|
17
17
|
|
|
18
18
|
const PACKAGE = '@volter/twin-redis';
|
|
19
19
|
const READY_TIMEOUT_MS = 60_000;
|
|
@@ -49,7 +49,7 @@ function listening(port: number): Promise<boolean> {
|
|
|
49
49
|
|
|
50
50
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
51
|
|
|
52
|
-
export async function redisTwinUp(services: InfraService[], dataDir: string, cli: string): Promise<void> {
|
|
52
|
+
export async function redisTwinUp(services: InfraService[], dataDir: string, cli: string, launch: HostLaunch = {}): Promise<void> {
|
|
53
53
|
for (const service of services) {
|
|
54
54
|
const pidFile = pidPath(dataDir, service);
|
|
55
55
|
if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort)) continue;
|
|
@@ -66,6 +66,7 @@ export async function redisTwinUp(services: InfraService[], dataDir: string, cli
|
|
|
66
66
|
// type transformation, not only stripping (the kernel declares constructor parameter properties)
|
|
67
67
|
const runtime = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
|
|
68
68
|
const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root], {
|
|
69
|
+
env: launch.env ?? process.env,
|
|
69
70
|
detached: true,
|
|
70
71
|
stdio: ['ignore', logFd, logFd],
|
|
71
72
|
});
|
|
@@ -75,6 +76,7 @@ export async function redisTwinUp(services: InfraService[], dataDir: string, cli
|
|
|
75
76
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
76
77
|
while (!(await listening(service.hostPort)) || !alive(child.pid!)) {
|
|
77
78
|
if (!alive(child.pid!)) throw new Error(`redis exited before serving — see ${log}`);
|
|
79
|
+
launch.signal?.throwIfAborted();
|
|
78
80
|
if (Date.now() > deadline) throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
79
81
|
await sleep(200);
|
|
80
82
|
}
|