@volter/world-machine 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ import type { MachineConfig } from './config.js';
2
+ import { type HostSession } from './host.js';
3
+ /** What `serve` registers for a service root: the World's branch directory when the root is a branch's
4
+ * service data directory (`<app>/.volter/worlds/<branch>/data/<service>`), else the root itself. */
5
+ export declare function registration(root: string): string;
6
+ export declare function register(session: HostSession, root: string): Promise<void>;
7
+ export type GcReport = {
8
+ roots: string[];
9
+ dropped: string[];
10
+ branches: number;
11
+ live: number;
12
+ deletedSnapshots: string[];
13
+ deletedBases: string[];
14
+ deletedTrees: string[];
15
+ expired: string[];
16
+ kept: {
17
+ pending: string[];
18
+ loaded: string[];
19
+ };
20
+ usedBefore: number;
21
+ usedAfter: number;
22
+ freedBytes: number;
23
+ trimmed: string;
24
+ };
25
+ export declare function collect(config: MachineConfig, session: HostSession, own?: string): Promise<GcReport>;
package/dist/src/gc.js ADDED
@@ -0,0 +1,131 @@
1
+ // Snapshot retention: a snapshot is kept while any existing branch's machine ledger (its whole view,
2
+ // the parent's history included) names it; everything else in the store goes. Each snapshot directory
3
+ // is self-contained (its memory and disk are reflink copies, never references to another directory), so
4
+ // deleting one never breaks another.
5
+ //
6
+ // The store is shared by every World whose machine runs on this host, so each `serve` registers its
7
+ // World in the store (`roots.json`: the World's branch directory, `<app>/.volter/worlds`, or the
8
+ // service root itself when it is not laid out that way). A collection reads every registered World's
9
+ // branches (a registration whose directory is gone is dropped), and deletes:
10
+ // - snapshot directories no ledger names, unless a checkpoint is still writing them (`.pending`) or a
11
+ // slot's machine was loaded from them;
12
+ // - warm bases no kept snapshot was built on (on the container backend, none a branch's head was not taken on: its
13
+ // snapshots never read their base), unless it is this configuration's base, a slot's machine was loaded from it,
14
+ // or it is still being built;
15
+ // - the container backend's filled trees (tree-<hash>) but this configuration's and those a kept base was made from;
16
+ // then trims the store's filesystem so the space goes back to the host. A ledger that cannot be read
17
+ // stops the collection: nothing is deleted on a partial view.
18
+ import { existsSync, readdirSync, statSync } from 'node:fs';
19
+ import { basename, join } from 'node:path';
20
+ import { wholeLog } from '@volter/world-core';
21
+ import { q } from "./host.js";
22
+ import { headSnapshot, LEDGER } from "./ledger.js";
23
+ import { containerHashes } from "./container.js";
24
+ import { baseHash } from "./machine.js";
25
+ import { thinned } from "./thin.js";
26
+ /** What `serve` registers for a service root: the World's branch directory when the root is a branch's
27
+ * service data directory (`<app>/.volter/worlds/<branch>/data/<service>`), else the root itself. */
28
+ export function registration(root) {
29
+ const m = /^(.*\/\.volter\/worlds)\/[^/]+\/data\/[^/]+\/?$/.exec(root);
30
+ return m ? m[1] : root;
31
+ }
32
+ /** Every machine service root a registration covers. */
33
+ function machineRoots(registered) {
34
+ if (basename(registered) === 'worlds') {
35
+ return readdirSync(registered)
36
+ .filter((b) => !b.startsWith('.'))
37
+ .map((b) => join(registered, b, 'data', LEDGER))
38
+ .filter((r) => existsSync(r) && statSync(r).isDirectory());
39
+ }
40
+ return [registered];
41
+ }
42
+ export async function register(session, root) {
43
+ await session.call(`wm_register ${q(registration(root))}`);
44
+ }
45
+ export async function collect(config, session, own) {
46
+ const store = await session.call('wm_store_list');
47
+ const roots = store.roots.filter((r) => existsSync(r));
48
+ const dropped = store.roots.filter((r) => !roots.includes(r));
49
+ const liveDirs = new Set();
50
+ const liveBases = new Set();
51
+ const heads = new Set();
52
+ const ownDirs = new Set();
53
+ const othersDirs = new Set();
54
+ const keepDirs = new Set();
55
+ let branches = 0;
56
+ for (const registered of roots) {
57
+ const mine = own !== undefined && registered === registration(own);
58
+ for (const r of machineRoots(registered)) {
59
+ branches++;
60
+ let log;
61
+ try {
62
+ log = wholeLog(LEDGER, r);
63
+ }
64
+ catch (error) {
65
+ throw new Error(`world-machine gc: cannot read the machine ledger at ${r} (${error.message}); nothing was deleted`);
66
+ }
67
+ for (const e of log) {
68
+ if (e.op !== 'set' || e.subject.type !== 'snapshot')
69
+ continue;
70
+ const f = e.fields;
71
+ if (f.dir) {
72
+ liveDirs.add(basename(f.dir));
73
+ (mine ? ownDirs : othersDirs).add(basename(f.dir));
74
+ }
75
+ if (f.dir && e.fields.keep)
76
+ keepDirs.add(basename(f.dir));
77
+ // a Firecracker snapshot is kept with the base it was built on; a container snapshot never reads its base (a
78
+ // restore reads the snapshot's own tree and dump, and a machine with no head the configured base), so there only
79
+ // the configured base, heads' bases (below) and a running machine's stay
80
+ if (f.base && config.backend !== 'container')
81
+ liveBases.add(f.base);
82
+ }
83
+ const head = headSnapshot(r);
84
+ if (head)
85
+ heads.add(basename(head.dir));
86
+ // on the container backend the base a branch's head was taken on stays (another World on this store, configured
87
+ // otherwise, starts a branch with no head from it); a base only older heads named goes once each moves on
88
+ if (head?.base && config.backend === 'container')
89
+ liveBases.add(head.base);
90
+ }
91
+ }
92
+ // Retention (config `retain`): every branch's head stays (a World resumes at it), and the newest `retain` others (with
93
+ // `thin`, also the newest of each hour for a day and of each day for a month, and every snapshot taken with `keep`); the
94
+ // rest are let go though a ledger names them, and recorded so that a restore of one says it expired
95
+ // Only the calling World's own snapshots are counted: `retain` is its configuration, and another World on the store
96
+ // keeps its own, including a snapshot both name (a World's ledgers copied from another). They are ranked by when the host took them (the time in the snapshot's id), never by the World's
97
+ // clock, which a World can freeze (every mark would tie).
98
+ const expired = [];
99
+ if (config.retain && own) {
100
+ const present = new Set(store.snapshots);
101
+ const taken = (d) => { const m = /^snap-([0-9a-z]+)-/.exec(d); return m ? parseInt(m[1], 36) : 0; };
102
+ const others = [...ownDirs].filter((d) => !heads.has(d) && !othersDirs.has(d) && present.has(d)).sort((a, b) => taken(b) - taken(a) || b.localeCompare(a));
103
+ // thinned with age (thin.ts), or the newest `retain` only
104
+ const stay = config.thin ? thinned(others.map((d) => ({ dir: d, takenMs: taken(d), keep: keepDirs.has(d) })), Date.now(), { retain: config.retain }) : new Set(others.slice(0, config.retain));
105
+ for (const d of others)
106
+ if (!stay.has(d)) {
107
+ liveDirs.delete(d);
108
+ expired.push(d);
109
+ }
110
+ }
111
+ const loaded = new Set(store.loaded.map((p) => basename(p.replace(/\/+$/, ''))));
112
+ const pending = new Set(store.pending);
113
+ const hashes = config.backend === 'container' ? await containerHashes(config, session) : { base: await baseHash(config, session), tree: '' };
114
+ const configured = `base-${hashes.base}`;
115
+ const deletedSnapshots = store.snapshots.filter((s) => !liveDirs.has(s) && !pending.has(s) && !loaded.has(s));
116
+ const deletedBases = store.bases.filter((b) => !liveBases.has(b) && b !== configured && !loaded.has(b) && !b.endsWith('.building'));
117
+ // the container backend's filled trees (tree-<hash>): only this configuration's is kept (a base's own root.img shares
118
+ // its blocks, so a tree whose image or fill moved on holds only what nothing else does)
119
+ // (bases being built included: each names its tree from its first step; a tree being saved is tree-<hash>.part-<slot>)
120
+ const keptTrees = new Set(store.bases.filter((b) => !deletedBases.includes(b)).map((b) => store.baseTrees[b]).filter(Boolean));
121
+ const treeOf = (t) => t.replace(/\.part-[a-z0-9-]+$/, '');
122
+ const deletedTrees = store.trees.filter((t) => treeOf(t) !== `tree-${hashes.tree}` && !keptTrees.has(treeOf(t)));
123
+ const paths = [...deletedSnapshots.map((s) => `snapshots/${s}`), ...deletedBases, ...deletedTrees];
124
+ const expiring = expired.filter((d) => deletedSnapshots.includes(d));
125
+ const done = await session.call(`${expiring.length ? `printf '%s\\n' ${expiring.map(q).join(' ')} >> "$WM_STORE/expired.list"\n` : ''}wm_gc_delete ${q(JSON.stringify(dropped))} ${paths.map(q).join(' ')}`);
126
+ return {
127
+ roots, dropped, branches, live: liveDirs.size, deletedSnapshots, deletedBases, deletedTrees, expired: expiring,
128
+ kept: { pending: [...pending], loaded: [...loaded] },
129
+ usedBefore: done.usedBefore, usedAfter: done.usedAfter, freedBytes: Math.max(0, done.usedBefore - done.usedAfter), trimmed: done.trimmed,
130
+ };
131
+ }
@@ -0,0 +1,49 @@
1
+ import type { MachineConfig } from './config.js';
2
+ export declare const GUEST_AGENT: string;
3
+ /** world-core's zero-edit redirect (a real SDK's vendor host to its twin, by the *_TWIN_URL variables) and the files it
4
+ * loads, written into the guest at GUEST_INJECT_DIR at every start and restore: an app in the machine reaches the twins
5
+ * as `volter world run` does. */
6
+ export declare const GUEST_INJECT_DIR = "/opt/volter-world";
7
+ export declare const GUEST_INJECT: Record<string, string>;
8
+ /** Every login shell in the guest exports the World's env file (KEY=value lines, written at each start and restore). */
9
+ export declare const GUEST_PROFILE = "[ -r /etc/world.env ] && while IFS= read -r l; do case \"$l\" in ''|'#'*) ;; *=*) export \"$l\" ;; esac; done < /etc/world.env";
10
+ /** A sub-millisecond wall clock on the World's host. */
11
+ export declare const hostNowMs: () => number;
12
+ /** Single-quote a string for bash. */
13
+ export declare const q: (s: string) => string;
14
+ export declare class HostSession {
15
+ private readonly config;
16
+ private child;
17
+ private buffer;
18
+ private waiter;
19
+ private queue;
20
+ /** The machine host's clock minus this host's, in seconds; measured at open and before each clock step. */
21
+ offsetS: number;
22
+ constructor(config: MachineConfig);
23
+ private spawnShell;
24
+ private open;
25
+ private drain;
26
+ /** Set while the store-wide collection runs (gc): it is no operation on the slot, and must not wait on one. */
27
+ unlocked: boolean;
28
+ private rawRun;
29
+ /** Run a script on the machine host, one at a time; resolves with its status and output. */
30
+ run(script: string): Promise<{
31
+ status: number;
32
+ out: string;
33
+ }>;
34
+ /** Run host.sh calls, each printing an `@{json}` result line; the results merge in order. An
35
+ * `error` field, or a nonzero status, is thrown with the output. */
36
+ call<T = Record<string, unknown>>(script: string): Promise<T>;
37
+ /** The machine host's clock against this host's: the exchange with the shortest round trip of
38
+ * five bounds the error by half that round trip. */
39
+ measureOffset(): Promise<{
40
+ offsetMs: number;
41
+ rttMs: number;
42
+ }>;
43
+ /** measureOffset, serialized with every other operation. */
44
+ offset(): Promise<{
45
+ offsetMs: number;
46
+ rttMs: number;
47
+ }>;
48
+ close(): void;
49
+ }
@@ -0,0 +1,163 @@
1
+ // The machine host: where Firecracker runs. On Linux it is this box; on a Mac it is a Lima (>= 2) VM
2
+ // with nested virtualization, reached over Lima's own ssh config. Either way the service holds ONE
3
+ // persistent bash session on it (a new ssh or bash per operation cost 75-125 ms on a loaded Mac) with
4
+ // host.sh sourced, and every operation is one round trip over that session.
5
+ import { spawn } from 'node:child_process';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ const HOST_LIB = readFileSync(new URL('./host.sh', import.meta.url), 'utf8');
11
+ /** The container backend's functions, sourced after HOST_LIB when the config names that backend. */
12
+ const CT_LIB = readFileSync(new URL('./ct.sh', import.meta.url), 'utf8');
13
+ const AGENT_CLIENT = readFileSync(new URL('./agentc.py', import.meta.url), 'utf8');
14
+ /** The container backend's page composer (compose.py), run detached on the host after each mark. */
15
+ const COMPOSER = readFileSync(new URL('./compose.py', import.meta.url), 'utf8');
16
+ /** The container backend's port publisher (publish.py), run detached on the host while the machine runs. */
17
+ const PUBLISHER = readFileSync(new URL('./publish.py', import.meta.url), 'utf8');
18
+ export const GUEST_AGENT = readFileSync(new URL('./agent.py', import.meta.url), 'utf8');
19
+ /** world-core's zero-edit redirect (a real SDK's vendor host to its twin, by the *_TWIN_URL variables) and the files it
20
+ * loads, written into the guest at GUEST_INJECT_DIR at every start and restore: an app in the machine reaches the twins
21
+ * as `volter world run` does. */
22
+ export const GUEST_INJECT_DIR = '/opt/volter-world';
23
+ const core = (spec) => readFileSync(fileURLToPath(import.meta.resolve(`@volter/world-core/${spec}`)), 'utf8');
24
+ export const GUEST_INJECT = {
25
+ 'inject.cjs': core('inject'), 'network-policy.cjs': core('network-policy'), 'app-route.cjs': core('app-route'),
26
+ 'vendor-hosts.cjs': core('vendor-hosts'),
27
+ 'generated/pack-facts.json': core('generated/pack-facts.json'),
28
+ };
29
+ /** Every login shell in the guest exports the World's env file (KEY=value lines, written at each start and restore). */
30
+ export const GUEST_PROFILE = `[ -r /etc/world.env ] && while IFS= read -r l; do case "$l" in ''|'#'*) ;; *=*) export "$l" ;; esac; done < /etc/world.env`;
31
+ /** A sub-millisecond wall clock on the World's host. */
32
+ export const hostNowMs = () => performance.timeOrigin + performance.now();
33
+ /** Single-quote a string for bash. */
34
+ export const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
35
+ export class HostSession {
36
+ config;
37
+ child;
38
+ buffer = '';
39
+ waiter;
40
+ queue = Promise.resolve();
41
+ /** The machine host's clock minus this host's, in seconds; measured at open and before each clock step. */
42
+ offsetS = 0;
43
+ constructor(config) {
44
+ this.config = config;
45
+ }
46
+ // Its own process group: the World stops a service by signalling its group, and the session must
47
+ // outlive that signal long enough to release the slot (it was cut mid-script, leaving the slot
48
+ // claimed). It still ends with this process: its stdin closes and the shell reads EOF.
49
+ spawnShell() {
50
+ const h = this.config.host;
51
+ if (h.kind === 'linux')
52
+ return spawn('bash', ['--noprofile', '--norc'], { stdio: 'pipe', detached: true });
53
+ const cfg = join(h.limaHome, h.instance, 'ssh.config');
54
+ return spawn('ssh', ['-F', cfg, '-T', '-o', 'ServerAliveInterval=10', `lima-${h.instance}`, 'bash --noprofile --norc'], { stdio: 'pipe', detached: true });
55
+ }
56
+ async open() {
57
+ const child = this.spawnShell();
58
+ this.child = child;
59
+ child.stdout.setEncoding('utf8');
60
+ child.stderr.setEncoding('utf8');
61
+ child.stdout.on('data', (chunk) => { this.buffer += chunk; this.drain(); });
62
+ child.stderr.on('data', (chunk) => { this.buffer += chunk; });
63
+ child.on('exit', (code) => {
64
+ if (this.child === child)
65
+ this.child = undefined;
66
+ const w = this.waiter;
67
+ this.waiter = undefined;
68
+ w?.reject(new Error(`the machine host session ended (exit ${code}): ${this.buffer.slice(-400)}`));
69
+ });
70
+ const c = this.config;
71
+ const vars = { WM_STORE: c.store, WM_SLOT: c.slot, WM_FC: c.firecracker, WM_SE: c.snapshotEditor, WM_KEY: c.sshKey, WM_TAP: c.tap, WM_HOSTIP: c.hostIp, WM_GUESTIP: c.guestIp };
72
+ if (c.backend === 'container') {
73
+ vars.WM_BINDS = JSON.stringify(c.binds);
74
+ if (c.export)
75
+ vars.WM_EXPORT = JSON.stringify(c.export);
76
+ }
77
+ // `~/` in a configured path is the machine host's home, expanded there.
78
+ const assign = Object.entries(vars).map(([k, v]) => `${k}=${v.startsWith('~/') ? `"$HOME"/${q(v.slice(2))}` : q(v)}`).join('\n');
79
+ await this.rawRun(`${assign}\n${HOST_LIB}\n${c.backend === 'container' ? `${CT_LIB}\n` : ''}wm_init && mkdir -p "$WM_SLOTDIR" && ${c.backend === 'container' ? 'exec 9>>"$WM_SLOTDIR/slot.lock" && WM_LOCKED=1 && ' : ''}cat > "$WM_SLOTDIR/agentc.py" <<'__WM_AGENTC__'\n${AGENT_CLIENT}\n__WM_AGENTC__\n${c.backend === 'container' ? `cat > "$WM_SLOTDIR/compose.py.$$" <<'__WM_COMPOSE__' && mv -f "$WM_SLOTDIR/compose.py.$$" "$WM_SLOTDIR/compose.py"\n${COMPOSER}\n__WM_COMPOSE__\ncat > "$WM_SLOTDIR/publish.py.$$" <<'__WM_PUBLISH__' && mv -f "$WM_SLOTDIR/publish.py.$$" "$WM_SLOTDIR/publish.py"\n${PUBLISHER}\n__WM_PUBLISH__\n` : ''}`);
80
+ await this.measureOffset();
81
+ }
82
+ drain() {
83
+ const w = this.waiter;
84
+ if (!w)
85
+ return;
86
+ const at = this.buffer.indexOf(w.marker);
87
+ if (at < 0)
88
+ return;
89
+ const rest = this.buffer.slice(at + w.marker.length);
90
+ const nl = rest.indexOf('\n');
91
+ if (nl < 0)
92
+ return;
93
+ const status = Number(rest.slice(0, nl).trim());
94
+ const out = this.buffer.slice(0, at);
95
+ this.buffer = rest.slice(nl + 1);
96
+ this.waiter = undefined;
97
+ w.resolve({ status, out });
98
+ }
99
+ /** Set while the store-wide collection runs (gc): it is no operation on the slot, and must not wait on one. */
100
+ unlocked = false;
101
+ rawRun(script) {
102
+ const child = this.child;
103
+ if (!child)
104
+ return Promise.reject(new Error('no machine host session'));
105
+ const marker = `__WM_DONE_${randomBytes(6).toString('hex')}__`;
106
+ return new Promise((resolve, reject) => {
107
+ this.waiter = { marker, resolve, reject };
108
+ this.buffer = '';
109
+ // a function, so a script may `return` its status
110
+ // With the slot's lock open (the container backend, from init on), one operation at a time across every service on
111
+ // the slot: a World's branch runs the machine being left and the branch's own machine side by side (a retake's
112
+ // prepare, then adopt), and each must see the slot as the other left it. The lock is taken for the operation and
113
+ // released after it, however it ends; a background process that inherited it does not keep it.
114
+ child.stdin.write(`__wm_op() {\n${script}\n}\nif [ -n "\${WM_LOCKED:-}" ] && [ -z "${this.unlocked ? '1' : ''}" ]; then flock -w 120 9 || { echo '@{"error":"the machine slot stayed busy for 120 s (another World operating it)"}'; false; }; fi && __wm_op 2>&1 < /dev/null; __wm_rc=$?; [ -n "\${WM_LOCKED:-}" ] && flock -u 9; printf '\\n%s %d\\n' ${marker} $__wm_rc\n`);
115
+ });
116
+ }
117
+ /** Run a script on the machine host, one at a time; resolves with its status and output. */
118
+ run(script) {
119
+ const next = this.queue.then(async () => {
120
+ if (!this.child)
121
+ await this.open();
122
+ return this.rawRun(script);
123
+ });
124
+ this.queue = next.catch(() => undefined);
125
+ return next;
126
+ }
127
+ /** Run host.sh calls, each printing an `@{json}` result line; the results merge in order. An
128
+ * `error` field, or a nonzero status, is thrown with the output. */
129
+ async call(script) {
130
+ const { status, out } = await this.run(script);
131
+ const results = out.split('\n').filter((l) => l.startsWith('@{')).map((l) => JSON.parse(l.slice(1)));
132
+ const failed = results.find((r) => typeof r.error === 'string');
133
+ if (failed)
134
+ throw new Error(failed.error);
135
+ if (status !== 0 || !results.length)
136
+ throw new Error(`machine host: \`${script.split('\n')[0]}\` exited ${status}: ${out.trim().slice(-600)}`);
137
+ return Object.assign({}, ...results);
138
+ }
139
+ /** The machine host's clock against this host's: the exchange with the shortest round trip of
140
+ * five bounds the error by half that round trip. */
141
+ async measureOffset() {
142
+ let best;
143
+ for (let i = 0; i < 5; i++) {
144
+ const t0 = hostNowMs();
145
+ const { out } = await this.rawRun('date +%s%N');
146
+ const t1 = hostNowMs();
147
+ const remote = Number(out.trim().split('\n').pop()) / 1e6;
148
+ const sample = { offset: remote - (t0 + t1) / 2, rtt: t1 - t0 };
149
+ if (!best || sample.rtt < best.rtt)
150
+ best = sample;
151
+ }
152
+ this.offsetS = best.offset / 1000;
153
+ return { offsetMs: Math.round(best.offset * 100) / 100, rttMs: Math.round(best.rtt * 100) / 100 };
154
+ }
155
+ /** measureOffset, serialized with every other operation. */
156
+ offset() {
157
+ const next = this.queue.then(async () => { if (!this.child)
158
+ await this.open(); return this.measureOffset(); });
159
+ this.queue = next.catch(() => undefined);
160
+ return next;
161
+ }
162
+ close() { this.child?.stdin.end(); this.child?.kill(); this.child = undefined; }
163
+ }