@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.
- package/README.md +339 -0
- package/dist/src/agent.py +92 -0
- package/dist/src/agentc.py +81 -0
- package/dist/src/base.d.ts +9 -0
- package/dist/src/base.js +61 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.js +319 -0
- package/dist/src/compose.py +82 -0
- package/dist/src/config.d.ts +74 -0
- package/dist/src/config.js +120 -0
- package/dist/src/container.d.ts +73 -0
- package/dist/src/container.js +436 -0
- package/dist/src/ct.sh +675 -0
- package/dist/src/gc.d.ts +25 -0
- package/dist/src/gc.js +131 -0
- package/dist/src/host.d.ts +49 -0
- package/dist/src/host.js +163 -0
- package/dist/src/host.sh +302 -0
- package/dist/src/ledger.d.ts +25 -0
- package/dist/src/ledger.js +38 -0
- package/dist/src/machine.d.ts +82 -0
- package/dist/src/machine.js +367 -0
- package/dist/src/publish.py +134 -0
- package/dist/src/thin.d.ts +20 -0
- package/dist/src/thin.js +30 -0
- package/package.json +33 -0
- package/src/agent.py +92 -0
- package/src/agentc.py +81 -0
- package/src/base.ts +67 -0
- package/src/cli.ts +283 -0
- package/src/compose.py +82 -0
- package/src/config.ts +165 -0
- package/src/container.ts +411 -0
- package/src/ct.sh +675 -0
- package/src/gc.ts +129 -0
- package/src/guest-inject.test.ts +21 -0
- package/src/host.sh +302 -0
- package/src/host.ts +161 -0
- package/src/ledger.ts +61 -0
- package/src/machine.ts +314 -0
- package/src/publish.py +134 -0
- package/src/thin.test.ts +62 -0
- package/src/thin.ts +48 -0
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The machine's ledger: one World state service, `machine`, one entry per checkpoint, written through
|
|
2
|
+
// the kernel's own write path (applyTwinWrite, as every twin records its writes). The World's `mark`
|
|
3
|
+
// finds it like any twin's log and `branch --at machine@N` forks it at N; the head a branch restores is
|
|
4
|
+
// the last snapshot in the branch's view, its parent's history up to the fork position and its own
|
|
5
|
+
// entries after it.
|
|
6
|
+
import { applyTwinWrite, wholeLog, worldNow } from '@volter/world-core';
|
|
7
|
+
|
|
8
|
+
export const LEDGER = 'machine';
|
|
9
|
+
|
|
10
|
+
export type SnapshotRecord = {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
type: 'Full' | 'Diff';
|
|
14
|
+
/** the entry this one's memory diff was taken against, or the warm base */
|
|
15
|
+
parent: string;
|
|
16
|
+
base: string;
|
|
17
|
+
/** directory of vmstate, mem and disk.ext4, relative to the store */
|
|
18
|
+
dir: string;
|
|
19
|
+
memBytes: number;
|
|
20
|
+
diskBytes: number;
|
|
21
|
+
storeBytes: number;
|
|
22
|
+
pauseMs: number;
|
|
23
|
+
created: string;
|
|
24
|
+
/** taken to be kept (a mark the agent pinned): retention (`thin`) never lets it go */
|
|
25
|
+
keep?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function appendSnapshot(root: string, record: SnapshotRecord): Promise<void> {
|
|
29
|
+
const { id, ...fields } = record;
|
|
30
|
+
await applyTwinWrite(LEDGER, {
|
|
31
|
+
operation: 'snapshot.create',
|
|
32
|
+
subjectType: 'snapshot',
|
|
33
|
+
subjectId: id,
|
|
34
|
+
fields,
|
|
35
|
+
occurredAt: record.created,
|
|
36
|
+
actor: { kind: 'system', id: 'world-machine' },
|
|
37
|
+
}, root);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The last snapshot in this branch's view of the ledger, or null. */
|
|
41
|
+
export function headSnapshot(root: string): SnapshotRecord | null {
|
|
42
|
+
return headOf(wholeLog(LEDGER, root));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The head a branch forked from this one at `position` (entries of this branch's whole log) starts with: what
|
|
46
|
+
* `world branch --at machine@<position>` gives the branch's machine to restore. */
|
|
47
|
+
export function headAt(root: string, position: number): SnapshotRecord | null {
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
return headOf(wholeLog(LEDGER, root).filter((e) => (seen.has(e.id) ? false : (seen.add(e.id), true))).slice(0, position));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function headOf(log: ReturnType<typeof wholeLog>): SnapshotRecord | null {
|
|
53
|
+
const reverted = new Set(log.filter((e) => e.op === 'revert' && e.revertsActionId).map((e) => e.revertsActionId!));
|
|
54
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
55
|
+
const e = log[i]!;
|
|
56
|
+
if (e.op === 'set' && e.subject.type === 'snapshot' && !reverted.has(e.id)) return { id: e.subject.id, ...(e.fields as Omit<SnapshotRecord, 'id'>) };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const worldInstant = (): string => worldNow();
|
package/src/machine.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// The machine: one Firecracker microVM in a slot on the machine host, run in place. A checkpoint
|
|
2
|
+
// pauses it, writes its memory (a diff against what it was loaded from) and clones its disk, resumes
|
|
3
|
+
// it, then materializes a full memory file for the entry and appends the entry to the ledger. A
|
|
4
|
+
// restore replaces the slot's process with this branch's head (or the warm base), steps the guest's
|
|
5
|
+
// wall clock to the World's, and points its one route out at this World's twins.
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
8
|
+
import { basename } from 'node:path';
|
|
9
|
+
import type { MachineConfig } from './config.ts';
|
|
10
|
+
import { GUEST_AGENT, GUEST_INJECT, GUEST_INJECT_DIR, GUEST_PROFILE, HostSession, hostNowMs, q } from './host.ts';
|
|
11
|
+
import { appendSnapshot, headAt, headSnapshot, type SnapshotRecord, worldInstant } from './ledger.ts';
|
|
12
|
+
|
|
13
|
+
type Loaded = { id: string; label: string; dir: string } | null;
|
|
14
|
+
/** Rewritten with a new id at each restore, before stale connections are reset (see guestFiles). */
|
|
15
|
+
export const RESTORE_ID_FILE = '/run/world-machine/restore-id';
|
|
16
|
+
const IDLE = 'nothing is running yet: the first GET /machine, POST /machine/checkpoint or POST /machine/restore brings the machine up';
|
|
17
|
+
export type MachineState = 'idle' | 'running' | 'displaced' | 'no-base' | 'error';
|
|
18
|
+
|
|
19
|
+
/** A machine-host path as one shell word: `~/x` expands on that host. */
|
|
20
|
+
export const hp = (path: string): string => (path.startsWith('~/') ? `"$HOME"/${q(path.slice(2))}` : q(path));
|
|
21
|
+
|
|
22
|
+
/** Every `<VENDOR>_TWIN_URL` the World handed this service. */
|
|
23
|
+
export function twinUrls(env: Record<string, string | undefined>): Record<string, URL> {
|
|
24
|
+
const out: Record<string, URL> = {};
|
|
25
|
+
for (const [k, v] of Object.entries(env)) {
|
|
26
|
+
const m = /^([A-Z0-9_]+)_TWIN_URL$/.exec(k);
|
|
27
|
+
if (m && v) { try { out[m[1]!] = new URL(v); } catch { /* not a URL: not a route */ } }
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A stable guest-visible port per vendor: derived from the name (FNV-1a into 20000-29999), a
|
|
33
|
+
* collision moving the later name in sorted order up by one; a configured port wins. */
|
|
34
|
+
export function egressPorts(vendors: string[], fixed: Record<string, number>): Record<string, number> {
|
|
35
|
+
const taken = new Set(Object.values(fixed));
|
|
36
|
+
const out: Record<string, number> = {};
|
|
37
|
+
for (const v of [...vendors].sort()) {
|
|
38
|
+
if (fixed[v] !== undefined) { out[v] = fixed[v]!; continue; }
|
|
39
|
+
let h = 0x811c9dc5;
|
|
40
|
+
for (const c of v) { h ^= c.charCodeAt(0); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
41
|
+
let port = 20000 + (h % 10000);
|
|
42
|
+
while (taken.has(port)) port = port === 29999 ? 20000 : port + 1;
|
|
43
|
+
taken.add(port); out[v] = port;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The guest kernel's command line. The guest's address is on-link with the tap's host end and it
|
|
49
|
+
* has no gateway: no route leads anywhere but the machine host. */
|
|
50
|
+
export const bootArgs = (config: MachineConfig): string => `console=ttyS0 reboot=k panic=1 ip=${config.guestIp}:::255.255.255.252::eth0:off`;
|
|
51
|
+
|
|
52
|
+
/** The egress port name of the World's proxy (never a vendor's: vendors come from *_TWIN_URL names). */
|
|
53
|
+
const WORLD_PROXY = 'VOLTER_WORLD_PROXY';
|
|
54
|
+
|
|
55
|
+
export async function baseHash(config: MachineConfig, session: HostSession): Promise<string> {
|
|
56
|
+
const facts = await session.call<{ kernel: string; rootfs: string; fc: string }>(
|
|
57
|
+
`printf '@{"kernel":"%s","rootfs":"%s","fc":"%s"}\\n' "$(stat -c %s-%Y ${hp(config.kernel)})" "$(stat -c %s-%Y ${hp(config.rootfs)})" "$(${hp(config.firecracker)} --version 2>&1 | head -1)"`);
|
|
58
|
+
const provision = config.provision ? readFileSync(config.provision, 'utf8') : '';
|
|
59
|
+
const inputs = { ...facts, bootArgs: bootArgs(config), provision, agent: GUEST_AGENT, profile: GUEST_PROFILE, vcpus: config.vcpus, memMib: config.memMib, slot: config.slot, tap: config.tap, hostIp: config.hostIp, guestIp: config.guestIp, store: config.store };
|
|
60
|
+
return createHash('sha256').update(JSON.stringify(inputs)).digest('hex').slice(0, 16);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class Machine {
|
|
64
|
+
state: MachineState = 'idle';
|
|
65
|
+
message: string | undefined = IDLE;
|
|
66
|
+
loaded: Loaded = null;
|
|
67
|
+
protected base: string | undefined;
|
|
68
|
+
protected lastUsed: Promise<number> = Promise.resolve(0);
|
|
69
|
+
protected readonly token = randomBytes(8).toString('hex');
|
|
70
|
+
/** This load's restore id (RESTORE_ID_FILE); a backend that can write it before the machine runs sets it first. */
|
|
71
|
+
protected restoreId = '';
|
|
72
|
+
readonly session: HostSession;
|
|
73
|
+
protected readonly ports: Record<string, number>;
|
|
74
|
+
protected readonly twins: Record<string, URL>;
|
|
75
|
+
|
|
76
|
+
constructor(readonly config: MachineConfig, readonly root: string, readonly env: Record<string, string | undefined>, readonly port: number) {
|
|
77
|
+
this.session = new HostSession(config);
|
|
78
|
+
this.twins = twinUrls(env);
|
|
79
|
+
// the World's egress proxy (its CONNECT door to the twins and, as its network policy allows, beyond) has a
|
|
80
|
+
// stable guest-visible port like a twin's
|
|
81
|
+
this.ports = egressPorts([...Object.keys(this.twins), WORLD_PROXY], config.egressPorts);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What the guest sees: vendor → its stable URL on the tap's host address. */
|
|
85
|
+
egress(): Record<string, string> {
|
|
86
|
+
return Object.fromEntries(Object.entries(this.twins).map(([v, u]) => [v, `http://${this.config.hostIp}:${this.ports[v]}${u.pathname === '/' ? '' : u.pathname}`]));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The World's live env (VOLTER_WORLD_ENV_FILE), `export K='v'` lines. */
|
|
90
|
+
protected worldEnv(): Record<string, string> {
|
|
91
|
+
const file = this.env.VOLTER_WORLD_ENV_FILE;
|
|
92
|
+
const out: Record<string, string> = {};
|
|
93
|
+
if (!file || !existsSync(file)) return out;
|
|
94
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
95
|
+
const m = /^export ([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
96
|
+
if (m) out[m[1]!] = m[2]!.startsWith("'") ? m[2]!.slice(1, -1).replaceAll("'\\''", "'") : m[2]!;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The World's proxy as the machine reaches it, read from its live env at each load: its guests' listener
|
|
102
|
+
* (`VOLTER_WORLD_GUEST_PROXY`, where every connection is kept off this host's own network, whatever NAT makes its
|
|
103
|
+
* source look like; world-runtime redirect-proxy.ts), else, from an older World, its one listener. */
|
|
104
|
+
protected proxy(): URL | null {
|
|
105
|
+
const env = this.worldEnv();
|
|
106
|
+
try { return new URL(env.VOLTER_WORLD_GUEST_PROXY || env.VOLTER_WORLD_PROXY || ''); } catch { return null; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What the guest is given at each start and restore: /etc/world.env, the World's live env as the guest sees it,
|
|
110
|
+
* and the files it names. Twin URLs become the stable egress ones, the World's proxy its forwarder (the twins'
|
|
111
|
+
* own addresses bypass it), NODE_OPTIONS the guest's copy of the redirect, and a value naming a certificate file on the
|
|
112
|
+
* World's host (its CA and trust bundle, PEM) a copy of it under /etc; what only that host can use (a
|
|
113
|
+
* directory there, a loopback URL of its other listeners) is left out and named, and an empty value is left unset. */
|
|
114
|
+
protected guestFiles(): Record<string, string> {
|
|
115
|
+
const out = Object.entries(this.egress()).map(([v, u]) => `${v}_TWIN_URL=${u}`);
|
|
116
|
+
// a new id at every load, written before the connections the load left stale are reset: a program in the machine
|
|
117
|
+
// that loses a connection can tell a restore (the id changed since it connected) from its peer going away
|
|
118
|
+
const files: Record<string, string> = { [RESTORE_ID_FILE]: `${this.restoreId || randomBytes(8).toString('hex')}\n` };
|
|
119
|
+
const env = this.worldEnv();
|
|
120
|
+
const proxy = this.proxy();
|
|
121
|
+
const guestProxy = proxy ? `http://${this.config.hostIp}:${this.ports[WORLD_PROXY]}` : null;
|
|
122
|
+
// a value naming either of the proxy's listeners (HTTPS_PROXY names the World's own) is the machine's forwarder
|
|
123
|
+
const proxyPorts = new Set([proxy?.port, ...['VOLTER_WORLD_PROXY', 'VOLTER_WORLD_GUEST_PROXY'].map((k) => { try { return new URL(env[k] ?? '').port; } catch { return ''; } })].filter(Boolean));
|
|
124
|
+
const hostPorts = new Set([...Object.values(this.twins).map((u) => u.port), String(this.port), ...proxyPorts, ...['MACHINE_URL'].map((k) => { try { return new URL(env[k] ?? '').port; } catch { return ''; } })].filter(Boolean));
|
|
125
|
+
const left: string[] = [];
|
|
126
|
+
const unset: string[] = [];
|
|
127
|
+
for (const [k, v] of Object.entries(env)) {
|
|
128
|
+
if (k.endsWith('_TWIN_URL') || v.includes('\n')) continue;
|
|
129
|
+
// an empty value (a repository's .env.example keys, detected with no value) is left unset: set, even to nothing,
|
|
130
|
+
// it would win over the app's own env files, which Next.js and dotenv never let override a set variable (measured:
|
|
131
|
+
// the app's plan ids in .env.local were hidden, and the agent had to unset them on every start)
|
|
132
|
+
if (v === '') { unset.push(k); continue; }
|
|
133
|
+
if (k === 'NODE_OPTIONS') { out.push(`NODE_OPTIONS=--require ${GUEST_INJECT_DIR}/inject.cjs`); continue; }
|
|
134
|
+
if (/^(no_proxy|NO_PROXY)$/.test(k)) { out.push(`${k}=${[v, this.config.hostIp].filter(Boolean).join(',')}`); continue; }
|
|
135
|
+
const loopback = /^[a-z]+:\/\/(?:[^@/]*@)?(?:127\.0\.0\.1|localhost):(\d+)/.exec(v);
|
|
136
|
+
if (loopback && proxy && proxyPorts.has(loopback[1]!) && guestProxy) { out.push(`${k}=${guestProxy}`); continue; }
|
|
137
|
+
if (v.startsWith('/')) {
|
|
138
|
+
let size = -1;
|
|
139
|
+
try { const s = statSync(v); if (s.isFile()) size = s.size; } catch { /* absent */ }
|
|
140
|
+
const pem = size >= 0 && size <= 1 << 20 ? readFileSync(v, 'utf8') : '';
|
|
141
|
+
if (pem.startsWith('-----BEGIN ')) {
|
|
142
|
+
const guest = `/etc/volter-world-${basename(v)}`;
|
|
143
|
+
files[guest] = pem;
|
|
144
|
+
out.push(`${k}=${guest}`);
|
|
145
|
+
} else left.push(k);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (loopback && hostPorts.has(loopback[1]!)) { left.push(k); continue; }
|
|
149
|
+
out.push(`${k}=${v}`);
|
|
150
|
+
}
|
|
151
|
+
// Node's own fetch (corepack, npm's newer paths) uses HTTP(S)_PROXY only when asked: without it corepack could not
|
|
152
|
+
// reach the registry from the machine (measured), where the proxy is the only way out
|
|
153
|
+
if (guestProxy && !('NODE_USE_ENV_PROXY' in env)) out.push('NODE_USE_ENV_PROXY=1');
|
|
154
|
+
if (left.length) out.unshift(`# not carried (the World's host only): ${left.join(' ')}`);
|
|
155
|
+
if (unset.length) out.unshift(`# empty in the World, so left unset (the app's own env files can set them): ${unset.join(' ')}`);
|
|
156
|
+
// world-core's redirect with the files it loads: written at every load rather than kept in the warm base, whose
|
|
157
|
+
// hash would otherwise move with every change to the pack facts (and rebuild the base each time twin moves)
|
|
158
|
+
for (const [f, body] of Object.entries(GUEST_INJECT)) files[`${GUEST_INJECT_DIR}/${f}`] = body;
|
|
159
|
+
return { ...files, '/etc/world.env': `${out.join('\n')}\n` };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected forwarders(): string {
|
|
163
|
+
const proxy = this.proxy();
|
|
164
|
+
return Object.entries(this.twins).map(([v, u]) => {
|
|
165
|
+
const host = u.hostname === '127.0.0.1' || u.hostname === 'localhost' ? this.config.twinHost : u.hostname;
|
|
166
|
+
return q(`${this.ports[v]}=${host}:${u.port || (u.protocol === 'https:' ? '443' : '80')}`);
|
|
167
|
+
}).concat(proxy ? [q(`${this.ports[WORLD_PROXY]}=${proxy.hostname === '127.0.0.1' || proxy.hostname === 'localhost' ? this.config.twinHost : proxy.hostname}:${proxy.port || '80'}`)] : []).join(' ');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
protected owner(): string {
|
|
171
|
+
return JSON.stringify({ token: this.token, world: this.env.VOLTER_WORLD_NAME ?? null, root: this.root, pid: process.pid, port: this.port });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
protected baseDir(): string { return `$WM_STORE/base-${this.base}`; }
|
|
175
|
+
|
|
176
|
+
/** The machine, brought up when first asked for: nothing runs until a door needs it, so a new branch's
|
|
177
|
+
* machine is loaded once, by the restore that retakes to its mark, instead of booting its head at start and
|
|
178
|
+
* being replaced a moment later. `GET /machine` and `checkpoint` bring up the head if the ledger has one,
|
|
179
|
+
* else the warm base; `restore` loads the head itself. Returns the restore's facts when it brought it up. */
|
|
180
|
+
async ensure(): Promise<Record<string, unknown> | null> {
|
|
181
|
+
if (this.state === 'running' || this.state === 'displaced') return null;
|
|
182
|
+
try {
|
|
183
|
+
this.base = await baseHash(this.config, this.session);
|
|
184
|
+
const { ready } = await this.session.call<{ ready: boolean }>(`wm_reflink_check >/dev/null || return 1\n[ -f "${this.baseDir()}/vmstate" ] && echo '@{"ready":true}' || echo '@{"ready":false}'`);
|
|
185
|
+
if (!headSnapshot(this.root) && !ready) {
|
|
186
|
+
this.state = 'no-base';
|
|
187
|
+
this.message = `no warm base base-${this.base} in the store; build it with \`world-machine base --config <file>\` (rerun until it prints ready)`;
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
return await this.restore();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.state = 'error';
|
|
193
|
+
this.message = (error as Error).message;
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** This branch's head, loaded in place; with no head, the warm base. */
|
|
199
|
+
async restore(): Promise<Record<string, unknown>> {
|
|
200
|
+
if (!this.base) { this.base = await baseHash(this.config, this.session); }
|
|
201
|
+
const head = headSnapshot(this.root);
|
|
202
|
+
const dir = head ? `$WM_STORE/${head.dir}` : this.baseDir();
|
|
203
|
+
const { offsetMs, rttMs } = await this.session.offset();
|
|
204
|
+
const clockFile = this.env.TWIN_WORLD_CLOCK_FILE;
|
|
205
|
+
const deltaS = clockFile && existsSync(clockFile) ? (Date.parse(worldInstant()) - hostNowMs()) / 1000 : 0;
|
|
206
|
+
const t0 = hostNowMs();
|
|
207
|
+
const r = await this.session.call<Record<string, number>>([
|
|
208
|
+
'wm_net >/dev/null || return 1',
|
|
209
|
+
`wm_timed egress wm_egress ${this.forwarders()} || return 1`,
|
|
210
|
+
`wm_restore "${dir}" || return 1`,
|
|
211
|
+
`wm_own ${q(this.owner())}`,
|
|
212
|
+
`printf '%s' ${q(JSON.stringify(this.guestFiles()))} | wm_timed agent wm_agent clock ${this.session.offsetS} ${deltaS} - || return 1`,
|
|
213
|
+
].join('\n'));
|
|
214
|
+
// the store's baseline for the next checkpoint's growth, read after the answer (a syncfs is slow)
|
|
215
|
+
this.lastUsed = this.session.call<{ used: number }>('wm_used').then((u) => u.used, () => Number.NaN);
|
|
216
|
+
this.loaded = head ? { id: head.id, label: head.label, dir: head.dir } : null;
|
|
217
|
+
this.state = 'running';
|
|
218
|
+
this.message = undefined;
|
|
219
|
+
return {
|
|
220
|
+
id: head?.id ?? null, label: head?.label ?? null, from: head ? 'head' : `base-${this.base}`,
|
|
221
|
+
loadMs: r.loadMs, restoreMs: r.restoreMs, clockMs: r.clockMs, clockErrorMs: r.errorMs, clockSteps: r.steps, agentConnectMs: r.connectMs, agentFirstReplyMs: r.firstReplyMs, agentFirstHandleMs: r.firstHandleMs, agentFirstWaitMs: r.firstWaitMs, agentFirstSetupMs: r.firstSetupMs, staleReset: r.staleReset, resetMs: r.resetMs, agentMs: r.agentMs, egressMs: r.egressMs,
|
|
222
|
+
hostOffsetMs: offsetMs, hostRttMs: rttMs, totalMs: Math.round(hostNowMs() - t0),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Is the slot still this service's? A later restore by another World's machine takes it. */
|
|
227
|
+
async refresh(): Promise<void> {
|
|
228
|
+
if (this.state !== 'running' && this.state !== 'displaced') return;
|
|
229
|
+
const r = await this.session.call<{ fc: string; owner: { token?: string; world?: string } | null }>('wm_running');
|
|
230
|
+
if (r.owner?.token !== this.token) { this.state = 'displaced'; this.message = `the slot now runs ${r.owner?.world ?? 'nothing'}'s machine; POST /machine/restore takes it back`; }
|
|
231
|
+
else if (r.fc !== 'Running') { this.state = 'error'; this.message = `firecracker is ${r.fc}`; }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async checkpoint(label: string, keep = false): Promise<Record<string, unknown>> {
|
|
235
|
+
await this.refresh();
|
|
236
|
+
if (this.state !== 'running') throw Object.assign(new Error(`cannot checkpoint: the machine is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
|
|
237
|
+
await this.room();
|
|
238
|
+
const id = `snap-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
|
|
239
|
+
const dir = `snapshots/${id}`;
|
|
240
|
+
const parentMem = this.loaded ? `$WM_STORE/${this.loaded.dir}/mem` : `${this.baseDir()}/mem`;
|
|
241
|
+
// `.pending` until the ledger names it: a collection running meanwhile (another World's serve) keeps it
|
|
242
|
+
const r = await this.session.call<Record<string, number>>([
|
|
243
|
+
`mkdir -p "$WM_STORE/${dir}" && : > "$WM_STORE/${dir}/.pending" || return 1`,
|
|
244
|
+
`wm_snapshot "$WM_STORE/${dir}" Diff || return 1`,
|
|
245
|
+
`wm_materialize "${parentMem}" "$WM_STORE/${dir}" || return 1`,
|
|
246
|
+
// the machine now runs on from this entry: its memory is the next checkpoint's parent
|
|
247
|
+
`printf '%s' "$WM_STORE/${dir}" > "$WM_SLOTDIR/loaded"`,
|
|
248
|
+
'wm_used',
|
|
249
|
+
].join('\n'));
|
|
250
|
+
const measured = await this.lastUsed; const before = Number.isNaN(measured) ? (r.used ?? 0) : measured;
|
|
251
|
+
const storeBytes = Math.max(0, (r.used ?? 0) - before);
|
|
252
|
+
this.lastUsed = Promise.resolve(r.used ?? before);
|
|
253
|
+
const record: SnapshotRecord = {
|
|
254
|
+
id, label, type: 'Diff', parent: this.loaded?.id ?? `base-${this.base}`, base: `base-${this.base}`, dir,
|
|
255
|
+
memBytes: r.memBytes ?? 0, diskBytes: Math.max(0, storeBytes - (r.memBytes ?? 0)), storeBytes,
|
|
256
|
+
pauseMs: r.pauseMs ?? 0, created: worldInstant(), ...(keep ? { keep: true } : {}),
|
|
257
|
+
};
|
|
258
|
+
await appendSnapshot(this.root, record);
|
|
259
|
+
await this.session.call(`rm -f "$WM_STORE/${dir}/.pending"; echo '@{"recorded":true}'`);
|
|
260
|
+
this.loaded = { id, label, dir };
|
|
261
|
+
return { id, pauseMs: r.pauseMs, memBytes: record.memBytes, diskBytes: record.diskBytes, storeBytes, memWriteMs: r.memWriteMs, diskCloneMs: r.diskCloneMs, materializeMs: r.materializeMs };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Refused (507) unless every filesystem beneath the store has room for a mark: the last mark's store growth, at
|
|
265
|
+
* least `firstMarkBytes` when there is none, plus the configured reserve. Checked before anything is written. */
|
|
266
|
+
protected async room(firstMarkBytes = 2 ** 30): Promise<void> {
|
|
267
|
+
const last = headSnapshot(this.root)?.storeBytes ?? 0;
|
|
268
|
+
const need = Math.max(last, firstMarkBytes) + this.config.room.reserveBytes;
|
|
269
|
+
try {
|
|
270
|
+
await this.session.call(`wm_room ${need} ${this.config.room.paths.map(hp).join(' ')}`);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// 507 only for the refusal itself; a host that did not answer is the error it was
|
|
273
|
+
throw /^no room for a mark/.test((error as Error).message) ? Object.assign(error as Error, { status: 507 }) : error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** For each position of this machine's ledger (a mark's `machine@N`), the snapshot a branch forked there would restore
|
|
278
|
+
* and whether the store still holds it: gc with `retain` lets old ones go. */
|
|
279
|
+
async present(positions: number[]): Promise<Array<{ position: number; id: string | null; present: boolean }>> {
|
|
280
|
+
const at = positions.map((position) => ({ position, snap: headAt(this.root, position) }));
|
|
281
|
+
const dirs = [...new Set(at.flatMap(({ snap }) => (snap ? [snap.dir] : [])))];
|
|
282
|
+
const r = dirs.length ? await this.session.call<{ present: string[] }>(`printf '@{"present":['; s=''; for d in ${dirs.map(q).join(' ')}; do [ -d "$WM_STORE/$d" ] && [ ! -e "$WM_STORE/$d/.pending" ] && { printf '%s"%s"' "$s" "$d"; s=,; }; done; printf ']}\n'`) : { present: [] };
|
|
283
|
+
const held = new Set(r.present);
|
|
284
|
+
return at.map(({ position, snap }) => ({ position, id: snap?.id ?? null, present: snap ? held.has(snap.dir) : false }));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The guest's wall clock against the World's, now. */
|
|
288
|
+
async clock(): Promise<Record<string, unknown>> {
|
|
289
|
+
const { offsetMs, rttMs } = await this.session.offset();
|
|
290
|
+
const clockFile = this.env.TWIN_WORLD_CLOCK_FILE;
|
|
291
|
+
const deltaS = clockFile && existsSync(clockFile) ? (Date.parse(worldInstant()) - hostNowMs()) / 1000 : 0;
|
|
292
|
+
const r = await this.session.call<{ errorMs: number; probeRttMs: number }>(`wm_agent now ${this.session.offsetS} ${deltaS}`);
|
|
293
|
+
return { errorMs: r.errorMs, guestRttMs: r.probeRttMs, hostOffsetMs: offsetMs, hostRttMs: rttMs };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
status(): Record<string, unknown> {
|
|
297
|
+
const head = headSnapshot(this.root);
|
|
298
|
+
return {
|
|
299
|
+
state: this.state,
|
|
300
|
+
head: head ? { id: head.id, label: head.label } : null,
|
|
301
|
+
loaded: this.state === 'running' ? (this.loaded ? { id: this.loaded.id, label: this.loaded.label } : { base: `base-${this.base}` }) : null,
|
|
302
|
+
guest: { ip: this.config.guestIp, sshPort: 22, sshKey: this.config.sshKey, user: 'root' },
|
|
303
|
+
egress: this.egress(),
|
|
304
|
+
...(this.message ? { message: this.message } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** On service stop: the slot is released only if it is still this service's. */
|
|
309
|
+
async stop(): Promise<void> {
|
|
310
|
+
try {
|
|
311
|
+
await this.session.call(`if [ "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("token",""))' "$WM_SLOTDIR/owner.json" 2>/dev/null)" = ${q(this.token)} ]; then wm_fc_stop; wm_egress; wm_release; fi; echo '@{"stopped":true}'`);
|
|
312
|
+
} finally { this.session.close(); }
|
|
313
|
+
}
|
|
314
|
+
}
|
package/src/publish.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""publish.py <slot dir> <netns> <container> <ports JSON> <sum>: the container machine's listening TCP ports on this host's
|
|
3
|
+
loopback, as the machine's own `localhost`. The machine is where the app runs, and what the app is told is its address
|
|
4
|
+
(`http://localhost:3000`) is what it registers with a vendor: a twin delivering a webhook there posts from this host,
|
|
5
|
+
and reached nothing (a Stripe twin's deliveries were dropped). Published here, the twin's delivery reaches the app, and
|
|
6
|
+
a Lima VM forwards this host's loopback ports to the Mac, so the user's own browser opens the same URL.
|
|
7
|
+
|
|
8
|
+
Only the ports the config allows (<ports JSON>: {"ranges": [[from, to], ...], "except": [port, ...]}) are published:
|
|
9
|
+
the machine runs code the user did not write, and a port it held first would be taken from a service of this host that
|
|
10
|
+
binds it later (the retake stack's own doors, a twin restarting). Every half second the machine's listeners are read
|
|
11
|
+
from its network namespace (/proc/<init>/net/tcp and tcp6); an allowed port is published on 127.0.0.1 when
|
|
12
|
+
- the machine listens on it on a wildcard or loopback address, and not also on an address of its own (a port the
|
|
13
|
+
machine's doors bind, the job server and a harness's executor, is never published, however else it is bound), and
|
|
14
|
+
- nothing on this host listens on it.
|
|
15
|
+
Each connection enters the machine's namespace and reaches its loopback, 127.0.0.1 for an IPv4 listener and ::1 for an
|
|
16
|
+
IPv6 one, so it arrives at exactly the listener that was classified. A port the machine stops listening on stops being
|
|
17
|
+
published. Runs as root (entering the namespace needs it), one per slot (a lock in the slot), its pid in
|
|
18
|
+
<slot>/publish.pid; its socat children die with it."""
|
|
19
|
+
import ctypes, fcntl, json, os, signal, subprocess, sys, time
|
|
20
|
+
|
|
21
|
+
SLOT, NETNS, CONTAINER, PORTS, SUM = sys.argv[1], sys.argv[2], sys.argv[3], json.loads(sys.argv[4]), sys.argv[5]
|
|
22
|
+
RANGES = [tuple(r) for r in PORTS.get('ranges', [])]
|
|
23
|
+
EXCEPT = set(PORTS.get('except', []))
|
|
24
|
+
children = {} # port -> [socat processes]: one on 127.0.0.1, and one on ::1 where that is free
|
|
25
|
+
PR_SET_PDEATHSIG = 1
|
|
26
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
27
|
+
LOOPBACK = ('0100007F', '00000000000000000000000001000000', '0000000000000000FFFF00000100007F')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def allowed(port):
|
|
31
|
+
return port not in EXCEPT and any(lo <= port <= hi for lo, hi in RANGES)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def listeners(path, family):
|
|
35
|
+
"""[(port, kind, family)] for the LISTEN sockets in a /proc/net/tcp{,6} file; kind is any | loopback | addr."""
|
|
36
|
+
try:
|
|
37
|
+
rows = open(path).read().splitlines()[1:]
|
|
38
|
+
except OSError:
|
|
39
|
+
return []
|
|
40
|
+
out = []
|
|
41
|
+
for row in rows:
|
|
42
|
+
f = row.split()
|
|
43
|
+
if len(f) < 4 or f[3] != '0A':
|
|
44
|
+
continue
|
|
45
|
+
addr, port = f[1].rsplit(':', 1)
|
|
46
|
+
kind = 'any' if set(addr) == {'0'} else 'loopback' if addr in LOOPBACK else 'addr'
|
|
47
|
+
out.append((int(port, 16), kind, family))
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def machine(previous):
|
|
52
|
+
"""{port: family} the machine publishes now. A failed read of the container keeps the previous set (a busy
|
|
53
|
+
`crun state` during a mark must not drop live connections); it is empty only when the container is gone."""
|
|
54
|
+
r = subprocess.run(['crun', 'state', CONTAINER], capture_output=True, timeout=5)
|
|
55
|
+
if r.returncode != 0:
|
|
56
|
+
gone = b'does not exist' in r.stderr or b'no such' in r.stderr.lower()
|
|
57
|
+
return {} if gone else previous
|
|
58
|
+
try:
|
|
59
|
+
pid = json.loads(r.stdout)['pid']
|
|
60
|
+
except (ValueError, KeyError):
|
|
61
|
+
return previous
|
|
62
|
+
kinds = {}
|
|
63
|
+
for name, family in (('tcp', 4), ('tcp6', 6)):
|
|
64
|
+
for port, kind, fam in listeners(f'/proc/{pid}/net/{name}', family):
|
|
65
|
+
kinds.setdefault(port, []).append((kind, fam))
|
|
66
|
+
out = {}
|
|
67
|
+
for port, found in kinds.items():
|
|
68
|
+
if not allowed(port) or any(kind == 'addr' for kind, _ in found):
|
|
69
|
+
continue
|
|
70
|
+
out[port] = 4 if any(fam == 4 for _, fam in found) else 6
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def host_busy():
|
|
75
|
+
return {port for name in ('tcp', 'tcp6') for port, _, _ in listeners(f'/proc/net/{name}', 0)}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def die_with_parent():
|
|
79
|
+
libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def stop(port):
|
|
83
|
+
"""The port's listeners and every connection they forked (their own process groups: a fork does not keep the
|
|
84
|
+
parent-death signal, so an open connection would otherwise outlive the publisher)."""
|
|
85
|
+
for p in children.pop(port, []):
|
|
86
|
+
try:
|
|
87
|
+
os.killpg(p.pid, signal.SIGTERM)
|
|
88
|
+
except ProcessLookupError:
|
|
89
|
+
pass
|
|
90
|
+
try:
|
|
91
|
+
p.wait(2)
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
try:
|
|
94
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
95
|
+
except ProcessLookupError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def shutdown(*_):
|
|
100
|
+
for port in list(children):
|
|
101
|
+
stop(port)
|
|
102
|
+
sys.exit(0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
lock = open(os.path.join(SLOT, 'publish.lock'), 'w')
|
|
106
|
+
try:
|
|
107
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
108
|
+
except OSError:
|
|
109
|
+
sys.exit(0) # this slot's publisher is running
|
|
110
|
+
for name, value in (('publish.pid', str(os.getpid())), ('publish.sum', SUM)):
|
|
111
|
+
with open(os.path.join(SLOT, name), 'w') as f:
|
|
112
|
+
f.write(value)
|
|
113
|
+
signal.signal(signal.SIGTERM, shutdown)
|
|
114
|
+
signal.signal(signal.SIGINT, shutdown)
|
|
115
|
+
wanted = {}
|
|
116
|
+
while True:
|
|
117
|
+
try:
|
|
118
|
+
wanted = machine(wanted)
|
|
119
|
+
for port in list(children):
|
|
120
|
+
if port not in wanted or children[port][0].poll() is not None:
|
|
121
|
+
stop(port)
|
|
122
|
+
busy = host_busy()
|
|
123
|
+
for port, family in wanted.items():
|
|
124
|
+
if port in children or port in busy:
|
|
125
|
+
continue
|
|
126
|
+
inside = f'TCP4\\:127.0.0.1\\:{port}' if family == 4 else f'TCP6\\:[\\:\\:1]\\:{port}'
|
|
127
|
+
target = f'EXEC:ip netns exec {NETNS} socat STDIO {inside}'
|
|
128
|
+
# 127.0.0.1, and ::1 as well (a client resolving localhost to ::1 first); errors to the log
|
|
129
|
+
children[port] = [subprocess.Popen(['socat', listen, target], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
130
|
+
stderr=sys.stdout, preexec_fn=die_with_parent, start_new_session=True)
|
|
131
|
+
for listen in (f'TCP4-LISTEN:{port},bind=127.0.0.1,reuseaddr,fork', f'TCP6-LISTEN:{port},bind=[::1],ipv6only=1,reuseaddr,fork')]
|
|
132
|
+
except Exception as error: # one bad pass is not the publisher's end
|
|
133
|
+
print(f'publish: {error}', flush=True)
|
|
134
|
+
time.sleep(0.5)
|
package/src/thin.test.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { thinned } from './thin.ts';
|
|
3
|
+
|
|
4
|
+
const HOUR = 3_600_000;
|
|
5
|
+
const now = Date.UTC(2026, 8, 27, 12, 0, 0);
|
|
6
|
+
const snap = (dir: string, agoMs: number, keep?: boolean) => ({ dir, takenMs: now - agoMs, ...(keep ? { keep } : {}) });
|
|
7
|
+
|
|
8
|
+
describe('thinned', () => {
|
|
9
|
+
test('the newest `retain` stay whatever their age', () => {
|
|
10
|
+
const s = [snap('a', 1000), snap('b', 40 * 24 * HOUR), snap('c', 50 * 24 * HOUR)];
|
|
11
|
+
expect([...thinned(s, now, { retain: 2 })].sort()).toEqual(['a', 'b']);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('past retain, the oldest of each hour for a day', () => {
|
|
15
|
+
// three in the hour 3 h ago, one 5 h ago
|
|
16
|
+
const s = [snap('new', 0), snap('h3a', 3 * HOUR + 60_000), snap('h3b', 3 * HOUR + 120_000), snap('h3c', 3 * HOUR + 180_000), snap('h5', 5 * HOUR + 1000)];
|
|
17
|
+
expect([...thinned(s, now, { retain: 1 })].sort()).toEqual(['h3c', 'h5', 'new']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('past the hours, the oldest of each day for a month; older let go', () => {
|
|
21
|
+
const s = [snap('new', 0), snap('d2a', 2 * 24 * HOUR + HOUR), snap('d2b', 2 * 24 * HOUR + 2 * HOUR), snap('old', 45 * 24 * HOUR)];
|
|
22
|
+
expect([...thinned(s, now, { retain: 1 })].sort()).toEqual(['d2b', 'new']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a snapshot taken to be kept is never thinned', () => {
|
|
26
|
+
const s = [snap('new', 0), snap('pin', 90 * 24 * HOUR, true), snap('old', 91 * 24 * HOUR)];
|
|
27
|
+
expect([...thinned(s, now, { retain: 1 })].sort()).toEqual(['new', 'pin']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a keeper is never displaced: new marks leaving retain, and crossing into the day tier', () => {
|
|
31
|
+
// one snapshot every 10 minutes for three days, thinned at each step of the way with retain 5: a snapshot that
|
|
32
|
+
// stayed past retain goes only at the step it crosses from the hour tier into the day tier (an hour's keeper that
|
|
33
|
+
// is not its day's oldest), never because a newer one arrived; a day's keeper never goes within the month
|
|
34
|
+
const step = 10 * 60_000;
|
|
35
|
+
const all: { dir: string; takenMs: number }[] = [];
|
|
36
|
+
const start = now - 3 * 24 * HOUR;
|
|
37
|
+
let stayed: { dir: string; takenMs: number }[] = [];
|
|
38
|
+
for (let t = start, i = 0; t <= now; t += step, i++) {
|
|
39
|
+
all.push({ dir: `s${String(i).padStart(4, '0')}`, takenMs: t });
|
|
40
|
+
const kept = thinned(all, t, { retain: 5 });
|
|
41
|
+
const newest = new Set(all.slice(-5).map((x) => x.dir));
|
|
42
|
+
for (const s of stayed) {
|
|
43
|
+
if (newest.has(s.dir) || kept.has(s.dir)) continue;
|
|
44
|
+
const crossedNow = t - s.takenMs >= 24 * HOUR && t - step - s.takenMs < 24 * HOUR;
|
|
45
|
+
expect({ dir: s.dir, crossedNow }).toEqual({ dir: s.dir, crossedNow: true });
|
|
46
|
+
}
|
|
47
|
+
stayed = all.filter((x) => kept.has(x.dir) && !newest.has(x.dir));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('pins past keepMax thin like any other', () => {
|
|
52
|
+
const s = [snap('new', 0), snap('p1', 60 * 24 * HOUR, true), snap('p2', 61 * 24 * HOUR, true)];
|
|
53
|
+
expect([...thinned(s, now, { retain: 1, keepMax: 1 })].sort()).toEqual(['new', 'p1']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a bucket does not move as time passes: what stayed an hour ago stays', () => {
|
|
57
|
+
const s = [snap('new', 0), snap('x', 2 * HOUR + 10_000), snap('y', 2 * HOUR + 20_000)];
|
|
58
|
+
const before = thinned(s, now, { retain: 1 });
|
|
59
|
+
const later = thinned(s, now + 30 * 60_000, { retain: 1 });
|
|
60
|
+
expect([...later].sort()).toEqual([...before].sort());
|
|
61
|
+
});
|
|
62
|
+
});
|
package/src/thin.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Retention thinned with age (config `thin`), as a backup's history is: every snapshot of the last `retain`, then one
|
|
2
|
+
// of each calendar hour for a day, then one of each UTC day for a month; older ones are let go. The one kept in a
|
|
3
|
+
// bucket is its OLDEST: nothing older can join a bucket later, so a keeper is never displaced by a newer mark leaving
|
|
4
|
+
// `retain` or crossing into the day tier (the day's oldest crosses first), and what a listing showed kept stays kept
|
|
5
|
+
// until its tier ends. A snapshot taken to be kept (`keep`: a mark the agent pinned) is never thinned, the newest
|
|
6
|
+
// `keepMax` of them (a pin in a loop must not fill the store). Pure: gc.ts ranks the calling World's own snapshots
|
|
7
|
+
// and asks which of them stay.
|
|
8
|
+
|
|
9
|
+
export interface Thinnable {
|
|
10
|
+
/** The snapshot's directory name (its id). */
|
|
11
|
+
dir: string;
|
|
12
|
+
/** When the host took it (ms since the epoch). */
|
|
13
|
+
takenMs: number;
|
|
14
|
+
/** Taken to be kept: never thinned. */
|
|
15
|
+
keep?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ThinRule {
|
|
19
|
+
/** How many of the newest stay whatever their age. */
|
|
20
|
+
retain: number;
|
|
21
|
+
/** Hours for which one of each hour stays (after the newest `retain`). */
|
|
22
|
+
hours?: number;
|
|
23
|
+
/** Days for which one of each day stays (after the hours). */
|
|
24
|
+
days?: number;
|
|
25
|
+
/** How many `keep` snapshots are never thinned (the newest); older ones thin like any other. */
|
|
26
|
+
keepMax?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HOUR = 3_600_000;
|
|
30
|
+
const DAY = 24 * HOUR;
|
|
31
|
+
|
|
32
|
+
/** The dirs of `snapshots` that stay at `nowMs`. */
|
|
33
|
+
export function thinned(snapshots: Thinnable[], nowMs: number, rule: ThinRule): Set<string> {
|
|
34
|
+
const hours = rule.hours ?? 24;
|
|
35
|
+
const days = rule.days ?? 30;
|
|
36
|
+
const newestFirst = [...snapshots].sort((a, b) => b.takenMs - a.takenMs || b.dir.localeCompare(a.dir));
|
|
37
|
+
const kept = new Set<string>();
|
|
38
|
+
const pinned = new Set(newestFirst.filter((s) => s.keep).slice(0, rule.keepMax ?? 20).map((s) => s.dir));
|
|
39
|
+
newestFirst.forEach((s, i) => { if (pinned.has(s.dir) || i < rule.retain) kept.add(s.dir); });
|
|
40
|
+
// each bucket's oldest (walked oldest first): by the calendar hour or UTC day it was taken in
|
|
41
|
+
const buckets = new Set<string>();
|
|
42
|
+
for (const s of [...newestFirst].reverse()) {
|
|
43
|
+
const age = nowMs - s.takenMs;
|
|
44
|
+
const bucket = age < hours * HOUR ? `h${Math.floor(s.takenMs / HOUR)}` : age < hours * HOUR + days * DAY ? `d${Math.floor(s.takenMs / DAY)}` : null;
|
|
45
|
+
if (bucket && !buckets.has(bucket)) { buckets.add(bucket); kept.add(s.dir); }
|
|
46
|
+
}
|
|
47
|
+
return kept;
|
|
48
|
+
}
|