@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { MachineConfig } from './config.js';
|
|
2
|
+
import { type HostSession } from './host.js';
|
|
3
|
+
import { Machine } from './machine.js';
|
|
4
|
+
/** A container base's identity, and its filled tree's (`tree-<hash>`, kept in the store: a base rebuilt for anything
|
|
5
|
+
* but its image or fill starts from a reflink of it instead of filling again, the 8-25 s of a rebuild). The tree is
|
|
6
|
+
* the image (by its id on this host: a tag rebuilt or pulled anew is a new image), its size, the fill and the init it
|
|
7
|
+
* installs, and the guest's address (its /etc/hosts); the base adds the provisioning, the host functions that make it,
|
|
8
|
+
* the directories bound into it and the slot. Functions as bash prints them, so a comment is no input. Refused when the
|
|
9
|
+
* image has no id here (podman failing, or the image removed): a hash without it names no base that exists, and a
|
|
10
|
+
* collection working from it would delete the one that does. */
|
|
11
|
+
export declare function containerHashes(config: MachineConfig, session: HostSession): Promise<{
|
|
12
|
+
base: string;
|
|
13
|
+
tree: string;
|
|
14
|
+
}>;
|
|
15
|
+
export declare const containerBaseHash: (config: MachineConfig, session: HostSession) => Promise<string>;
|
|
16
|
+
export type ContainerBaseStep = {
|
|
17
|
+
base: string;
|
|
18
|
+
stage: string;
|
|
19
|
+
ready: boolean;
|
|
20
|
+
detail?: string;
|
|
21
|
+
};
|
|
22
|
+
/** One step of building the container base, advancing as far as it can without waiting (the Firecracker base's
|
|
23
|
+
* contract: run it again until ready). The base is a mark like any other: the tree and the freshly started init with
|
|
24
|
+
* its services. */
|
|
25
|
+
export declare function stepContainerBase(config: MachineConfig, session: HostSession): Promise<ContainerBaseStep>;
|
|
26
|
+
export declare class ContainerMachine extends Machine {
|
|
27
|
+
/** What going back to the snapshot at `position` would change in the machine, read only (ct.sh wm_ct_compare): the
|
|
28
|
+
* files under `path` (the config's export by default: the project a harness works in) and the processes. */
|
|
29
|
+
compare(position: number, path?: string): Promise<Record<string, unknown>>;
|
|
30
|
+
/** A command run in the running machine (world-machine POST /machine/run; ct.sh wm_ct_run): as `user`, a name the
|
|
31
|
+
* machine's passwd resolves, or its root; bounded by `timeoutMs` (at most 300 s). `{code, stdout, stderr}`, tails. */
|
|
32
|
+
run(argv: string[], user?: string, timeoutMs?: number): Promise<Record<string, unknown>>;
|
|
33
|
+
/** Part of the machine's disk back at `position` (world-machine POST /machine/files), the machine running on: each
|
|
34
|
+
* path, absolute in the machine or relative to its exported project, copied from the mark's image into the live
|
|
35
|
+
* tree (ct.sh wm_ct_files). A partial retake's files: no restore, no branch; the next mark records the result. */
|
|
36
|
+
restoreFiles(position: number, paths: string[], { noLinks, dryRun }?: {
|
|
37
|
+
noLinks?: boolean | undefined;
|
|
38
|
+
dryRun?: boolean | undefined;
|
|
39
|
+
}): Promise<Record<string, unknown>>;
|
|
40
|
+
protected baseDir(): string;
|
|
41
|
+
/** This configuration's base. A machine with a head restores that and reads no base, so a hash that cannot be made
|
|
42
|
+
* (the image has no id on the host) is no failure there: the name stays what it was. */
|
|
43
|
+
private baseName;
|
|
44
|
+
/** A machine with a head needs its base's name only for the records its marks write: learned after the restore
|
|
45
|
+
* (queued behind it on the session), never before it. */
|
|
46
|
+
private basePending;
|
|
47
|
+
private learnBase;
|
|
48
|
+
ensure(): Promise<Record<string, unknown> | null>;
|
|
49
|
+
/** A retake's first half, run by the machine being left while the World branches: the mark a branch forked at
|
|
50
|
+
* `position` of this machine's ledger will have as its head, restored into the slot now and paused at once, its way
|
|
51
|
+
* out closed, the slot marked as handed over to that snapshot. The branch's machine then adopts it (restore) instead
|
|
52
|
+
* of restoring it again, and this one, when the World stops it, leaves the container alone (stop). While paused the
|
|
53
|
+
* machine runs nothing, and until adopted it reaches no twin, so it cannot write into the World being left. */
|
|
54
|
+
prepare(position: number, live?: boolean): Promise<Record<string, unknown>>;
|
|
55
|
+
/** A handover with no restore: the running machine itself handed to the branch that forks at `position`, which must
|
|
56
|
+
* be this ledger's head (a mark just taken). A partial retake uses it: the twins go back, the machine stays. It is not
|
|
57
|
+
* paused (the harness's connection into it would go silent): its way out is closed and its connections through the
|
|
58
|
+
* World's forwarders reset, so from here it reaches no twin of the World being left, while its processes and every
|
|
59
|
+
* other connection run on. The branch adopts it as it adopts a prepared one, the connections into it kept; a new
|
|
60
|
+
* restore id tells a program whose twin connection was reset that the World moved. */
|
|
61
|
+
private prepareLive;
|
|
62
|
+
/** This branch's head, restored in place; with no head, the base. A slot a retake's prepare left holding this head,
|
|
63
|
+
* paused, is adopted instead: its way out pointed at this World's twins, the World's files written, the connections
|
|
64
|
+
* the restore left stale reset, and the processes resumed, with no second restore. */
|
|
65
|
+
restore(): Promise<Record<string, unknown>>;
|
|
66
|
+
private adopt;
|
|
67
|
+
refresh(): Promise<void>;
|
|
68
|
+
checkpoint(label: string, keep?: boolean): Promise<Record<string, unknown>>;
|
|
69
|
+
/** The container reads the host's kernel clock: there is no guest clock to be off. */
|
|
70
|
+
clock(): Promise<Record<string, unknown>>;
|
|
71
|
+
status(): Record<string, unknown>;
|
|
72
|
+
stop(): Promise<void>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// The container backend (config `backend: "container"`, host functions in ct.sh): the machine is the host's own
|
|
2
|
+
// processes in a container run by crun, marked by CRIU with their tree in one reflinked ext4 image. It is for a host
|
|
3
|
+
// where a microVM would be nested (a Mac): there first-touch memory faults through two hypervisors, and a real
|
|
4
|
+
// project's install ran 20–80x slower than one level up (Volter Harness container/retake/README.md). The doors are the
|
|
5
|
+
// Firecracker backend's: the same guest address on the same bridge name, the same egress forwarders, the same
|
|
6
|
+
// ledger entries. What differs:
|
|
7
|
+
// the clock the container reads the host's kernel clock, which is now; a World whose clock is frozen is not
|
|
8
|
+
// followed (reported in the restore's facts)
|
|
9
|
+
// a mark a full CRIU dump of the processes and a reflink of the tree (no memory diff: arm64 has no
|
|
10
|
+
// soft-dirty tracking), recorded as a Full entry; its pages composed against the parent's
|
|
11
|
+
// afterwards (compose.py), so a compressing store keeps only the new ones
|
|
12
|
+
// stale sockets reset from the host in the container's network namespace (ss -K), as the guest agent resets them
|
|
13
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { posix } from 'node:path';
|
|
16
|
+
import { GUEST_PROFILE, hostNowMs, q } from "./host.js";
|
|
17
|
+
import { appendSnapshot, headAt, headSnapshot, worldInstant } from "./ledger.js";
|
|
18
|
+
import { Machine } from "./machine.js";
|
|
19
|
+
/** The host functions a base is made by: the tree's fill (with the init it installs), then the provisioning run, the
|
|
20
|
+
* init's start and the bundle every process in it runs under. The rest (marks, restores, the export, publishing) act on
|
|
21
|
+
* a base without shaping it. Hashing all of ct.sh rebuilt the base at every change to the host functions (49 s
|
|
22
|
+
* measured). */
|
|
23
|
+
const FILL_FUNCTIONS = ['wm_ct_base_fill'];
|
|
24
|
+
// the base's dump too: its CRIU options (wm_ct_mark) and the namespace its sockets live in (wm_ct_net), which a restore
|
|
25
|
+
// must match
|
|
26
|
+
const BASE_FUNCTIONS = ['wm_ct_provision', 'wm_ct_start', 'wm_ct_bundle', 'wm_ct_net', 'wm_ct_mark'];
|
|
27
|
+
const sha = (inputs) => createHash('sha256').update(JSON.stringify(inputs)).digest('hex').slice(0, 16);
|
|
28
|
+
/** A container base's identity, and its filled tree's (`tree-<hash>`, kept in the store: a base rebuilt for anything
|
|
29
|
+
* but its image or fill starts from a reflink of it instead of filling again, the 8-25 s of a rebuild). The tree is
|
|
30
|
+
* the image (by its id on this host: a tag rebuilt or pulled anew is a new image), its size, the fill and the init it
|
|
31
|
+
* installs, and the guest's address (its /etc/hosts); the base adds the provisioning, the host functions that make it,
|
|
32
|
+
* the directories bound into it and the slot. Functions as bash prints them, so a comment is no input. Refused when the
|
|
33
|
+
* image has no id here (podman failing, or the image removed): a hash without it names no base that exists, and a
|
|
34
|
+
* collection working from it would delete the one that does. */
|
|
35
|
+
export async function containerHashes(config, session) {
|
|
36
|
+
const json = (names, extra = '') => `"$({ declare -f ${names.join(' ')}; ${extra} } | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"`;
|
|
37
|
+
const r = await session.call(`printf '@{"imageId":"%s","fill":%s,"functions":%s}\n' "$(sudo -n podman image inspect --format '{{.Id}}' ${q(config.image)} 2>/dev/null)" ${json(FILL_FUNCTIONS, `printf '%s' "$WM_CT_INIT";`)} ${json(BASE_FUNCTIONS)}`);
|
|
38
|
+
if (!/^[0-9a-f]{64}$/.test(r.imageId))
|
|
39
|
+
throw new Error(`world-machine: the image ${config.image} has no id on the machine host (podman image inspect gave ${JSON.stringify(r.imageId)}): build or pull it`);
|
|
40
|
+
const tree = sha({ image: config.image, imageId: r.imageId, imageSize: config.imageSize, fill: r.fill, hostIp: config.hostIp, guestIp: config.guestIp });
|
|
41
|
+
const provision = config.provision ? readFileSync(config.provision, 'utf8') : '';
|
|
42
|
+
const base = sha({ backend: 'container', tree, provision, functions: r.functions, binds: config.binds, slot: config.slot, tap: config.tap, hostIp: config.hostIp, guestIp: config.guestIp });
|
|
43
|
+
return { base, tree };
|
|
44
|
+
}
|
|
45
|
+
export const containerBaseHash = async (config, session) => (await containerHashes(config, session)).base;
|
|
46
|
+
/** One step of building the container base, advancing as far as it can without waiting (the Firecracker base's
|
|
47
|
+
* contract: run it again until ready). The base is a mark like any other: the tree and the freshly started init with
|
|
48
|
+
* its services. */
|
|
49
|
+
export async function stepContainerBase(config, session) {
|
|
50
|
+
await session.call('wm_reflink_check');
|
|
51
|
+
// an image from a registry is pulled first (in the background): the base is named by the image's id on this host
|
|
52
|
+
if (!config.image.startsWith('localhost/') && !(await session.call(`if sudo -n podman image exists ${q(config.image)}; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok) {
|
|
53
|
+
const P = `"$WM_STORE/.pull-$WM_SLOT"`;
|
|
54
|
+
if (!(await session.call(`if kill -0 "$(cat ${P}.pid 2>/dev/null)" 2>/dev/null; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok) {
|
|
55
|
+
if ((await session.call(`if [ -f ${P}.log ]; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok) {
|
|
56
|
+
const log = await session.call(`printf '@{"log":%s}\n' "$(tail -c 400 ${P}.log | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"; rm -f ${P}.log`);
|
|
57
|
+
throw new Error(`world-machine base: pulling ${config.image} failed:\n${log.log}`);
|
|
58
|
+
}
|
|
59
|
+
await session.call(`{ wm_closefds; sudo -n podman pull -q ${q(config.image)} && rm -f ${P}.log; } </dev/null >${P}.log 2>&1 9>&- &\necho $! > ${P}.pid; echo '@{"ok":true}'`);
|
|
60
|
+
}
|
|
61
|
+
return { base: '', stage: `pulling ${config.image}`, ready: false, detail: 'run again' };
|
|
62
|
+
}
|
|
63
|
+
const hashes = await containerHashes(config, session);
|
|
64
|
+
const base = `base-${hashes.base}`;
|
|
65
|
+
const T = `"$WM_STORE/tree-${hashes.tree}"`;
|
|
66
|
+
const B = `"$WM_STORE/${base}"`;
|
|
67
|
+
const W = `"$WM_STORE/${base}.building"`;
|
|
68
|
+
const has = async (test) => (await session.call(`if ${test}; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok;
|
|
69
|
+
if (await has(`[ -f ${B}/criu/inventory.img ]`))
|
|
70
|
+
return { base, stage: 'ready', ready: true };
|
|
71
|
+
// 1. the tree: a reflink of the one filled from the same image before, else made from the image in the background
|
|
72
|
+
// (an export takes 8-25 s)
|
|
73
|
+
if (!(await has(`[ -f ${W}/filled ]`)) && (await has(`[ -f ${T}/root.img ] && [ ! -f ${W}/filling ]`))) {
|
|
74
|
+
await session.call(`for d in "$WM_STORE"/base-*.building; do [ -d "$d" ] && [ "$d" != ${W} ] || continue; s=$(cat "$d/slot" 2>/dev/null || echo "$WM_SLOT"); [ "$s" = "$WM_SLOT" ] && sudo -n rm -rf "$d"; done
|
|
75
|
+
# a tree another store's collection deleted meanwhile is filled again below
|
|
76
|
+
mkdir -p ${W} && printf '%s' "$WM_SLOT" > ${W}/slot && printf '%s' tree-${hashes.tree} > ${W}/tree && { cp --reflink=always ${T}/root.img ${W}/root.img && : > ${W}/filled || rm -f ${W}/root.img; }; echo '@{"ok":true}'`);
|
|
77
|
+
}
|
|
78
|
+
if (!(await has(`[ -f ${W}/filled ]`))) {
|
|
79
|
+
if (await has(`[ -f ${W}/filling ] && kill -0 "$(cat ${W}/fill.pid 2>/dev/null)" 2>/dev/null`))
|
|
80
|
+
return { base, stage: `filling the tree from ${config.image}`, ready: false, detail: 'run again' };
|
|
81
|
+
if (await has(`[ -f ${W}/filling ]`)) {
|
|
82
|
+
const log = await session.call(`printf '@{"log":%s}\\n' "$(tail -c 400 ${W}/fill.log 2>/dev/null | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"`);
|
|
83
|
+
throw new Error(`world-machine base: filling the tree from ${config.image} failed:\n${log.log}\n(remove ${config.store}/${base}.building to start over)`);
|
|
84
|
+
}
|
|
85
|
+
await session.call(`for d in "$WM_STORE"/base-*.building; do [ -d "$d" ] && [ "$d" != ${W} ] || continue; s=$(cat "$d/slot" 2>/dev/null || echo "$WM_SLOT"); [ "$s" = "$WM_SLOT" ] && sudo -n rm -rf "$d"; done
|
|
86
|
+
mkdir -p ${W} && printf '%s' "$WM_SLOT" > ${W}/slot && printf '%s' tree-${hashes.tree} > ${W}/tree && : > ${W}/filling && { wm_closefds; wm_ct_base_fill ${q(config.image)} ${q(config.imageSize)} ${W} > ${W}/fill.log 2>&1 && : > ${W}/filled; } </dev/null >/dev/null 2>&1 9>&- &
|
|
87
|
+
echo $! > ${W}/fill.pid; echo '@{"ok":true}'`);
|
|
88
|
+
return { base, stage: `filling the tree from ${config.image}`, ready: false };
|
|
89
|
+
}
|
|
90
|
+
// 2. the provisioning, run once in the tree placed in the slot; the tree as filled is kept first, for the next base
|
|
91
|
+
// made from it
|
|
92
|
+
if (!(await has(`[ -f ${W}/provisioned ]`))) {
|
|
93
|
+
const P = `${T}.part-"$WM_SLOT"`;
|
|
94
|
+
await session.call(`[ -f ${T}/root.img ] || { rm -rf ${P} && mkdir -p ${P} && cp --reflink=always ${W}/root.img ${P}/root.img && mv -T ${P} ${T}; } || rm -rf ${P}; echo '@{"ok":true}'`);
|
|
95
|
+
const provision = config.provision ? readFileSync(config.provision, 'utf8') : '';
|
|
96
|
+
const r = await session.call([
|
|
97
|
+
'wm_ct_stop; wm_ct_umount || return 1',
|
|
98
|
+
// the bundle names the slot's network namespace, which the host's boot does not make
|
|
99
|
+
'wm_ct_net >/dev/null || return 1',
|
|
100
|
+
`cp --reflink=always ${W}/root.img "$WM_ROOTIMG" || { wm_fail "cannot place the tree in the slot"; return 1; }`,
|
|
101
|
+
'wm_ct_mount || return 1',
|
|
102
|
+
`cat > "$WM_SLOTDIR/provision.sh" <<'__WM_PROVISION__'\n${provision || 'true'}\n__WM_PROVISION__`,
|
|
103
|
+
'wm_ct_provision "$WM_SLOTDIR/provision.sh"',
|
|
104
|
+
].join('\n'));
|
|
105
|
+
if (r.code !== 0)
|
|
106
|
+
throw new Error(`world-machine base: the provisioning script exited ${r.code}:\n${r.log}\n(remove ${config.store}/${base}.building to start over)`);
|
|
107
|
+
await session.call(`: > ${W}/provisioned; echo '@{"ok":true}'`);
|
|
108
|
+
return { base, stage: 'provisioned', ready: false, detail: 'run again' };
|
|
109
|
+
}
|
|
110
|
+
// 3. the init started with its services, marked, and the base put in place
|
|
111
|
+
const r = await session.call([
|
|
112
|
+
'wm_ct_net >/dev/null || return 1',
|
|
113
|
+
'wm_ct_mount || return 1',
|
|
114
|
+
'wm_ct_start >/dev/null || return 1',
|
|
115
|
+
`wm_ct_mark ${W} || return 1`,
|
|
116
|
+
// W/tree: the tree it was made from, kept by gc while this base (or its build) is
|
|
117
|
+
`wm_ct_stop; rm -f ${W}/filling ${W}/filled ${W}/provisioned ${W}/fill.pid ${W}/fill.log; printf '%s' tree-${hashes.tree} > ${W}/tree; mv ${W} ${B}`,
|
|
118
|
+
].join('\n'));
|
|
119
|
+
return { base, stage: 'ready', ready: true, detail: `marked in ${r.pauseMs} ms, ${Math.round((r.memBytes ?? 0) / 1e6)} MB of processes` };
|
|
120
|
+
}
|
|
121
|
+
export class ContainerMachine extends Machine {
|
|
122
|
+
/** What going back to the snapshot at `position` would change in the machine, read only (ct.sh wm_ct_compare): the
|
|
123
|
+
* files under `path` (the config's export by default: the project a harness works in) and the processes. */
|
|
124
|
+
async compare(position, path) {
|
|
125
|
+
const snap = headAt(this.root, position);
|
|
126
|
+
if (!snap)
|
|
127
|
+
throw new Error(`no snapshot at machine position ${position}`);
|
|
128
|
+
// a relative path is the exported project's, as POST /machine/files reads it
|
|
129
|
+
const base = this.config.export?.path;
|
|
130
|
+
const where = path === undefined ? base : path.startsWith('/') ? path : base ? `${base}/${path}` : undefined;
|
|
131
|
+
if (!where)
|
|
132
|
+
throw new Error(path === undefined ? 'compare needs a path (the machine exports none)' : `compare: ${path} is relative and the machine exports no project to resolve it against`);
|
|
133
|
+
return this.session.call(`[ -d "$WM_STORE/${snap.dir}" ] || { wm_fail "the snapshot ${snap.id} is no longer kept"; return 1; }; wm_ct_compare "$WM_STORE/${snap.dir}" ${q(where)}${path === undefined ? '' : ' exact'}`);
|
|
134
|
+
}
|
|
135
|
+
/** A command run in the running machine (world-machine POST /machine/run; ct.sh wm_ct_run): as `user`, a name the
|
|
136
|
+
* machine's passwd resolves, or its root; bounded by `timeoutMs` (at most 300 s). `{code, stdout, stderr}`, tails. */
|
|
137
|
+
async run(argv, user, timeoutMs = 60_000) {
|
|
138
|
+
await this.refresh();
|
|
139
|
+
if (this.state !== 'running')
|
|
140
|
+
throw Object.assign(new Error(`refused: cannot run a command: the machine is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
|
|
141
|
+
const secs = Math.max(1, Math.min(300, Math.ceil(timeoutMs / 1000)));
|
|
142
|
+
return this.session.call(`wm_ct_run ${q(user ?? '')} ${secs} ${q(JSON.stringify(argv))}`);
|
|
143
|
+
}
|
|
144
|
+
/** Part of the machine's disk back at `position` (world-machine POST /machine/files), the machine running on: each
|
|
145
|
+
* path, absolute in the machine or relative to its exported project, copied from the mark's image into the live
|
|
146
|
+
* tree (ct.sh wm_ct_files). A partial retake's files: no restore, no branch; the next mark records the result. */
|
|
147
|
+
async restoreFiles(position, paths, { noLinks = false, dryRun = false } = {}) {
|
|
148
|
+
const snap = headAt(this.root, position);
|
|
149
|
+
if (!snap)
|
|
150
|
+
throw Object.assign(new Error(`no snapshot at machine position ${position}`), { status: 409 });
|
|
151
|
+
await this.refresh();
|
|
152
|
+
if (this.state !== 'running')
|
|
153
|
+
throw Object.assign(new Error(`cannot restore files: the machine is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
|
|
154
|
+
const base = this.config.export?.path;
|
|
155
|
+
const rels = paths.map((p) => {
|
|
156
|
+
if (!p.startsWith('/') && !base)
|
|
157
|
+
throw Object.assign(new Error(`refused: files: ${p} is relative and the machine exports no project to resolve it against`), { status: 400 });
|
|
158
|
+
const abs = posix.normalize(p.startsWith('/') ? p : `${base}/${p}`);
|
|
159
|
+
// a relative path stays inside the exported project
|
|
160
|
+
if (!p.startsWith('/') && abs !== base && !abs.startsWith(`${base}/`))
|
|
161
|
+
throw Object.assign(new Error(`refused: files: ${p} climbs out of ${base}`), { status: 400 });
|
|
162
|
+
return abs.replace(/^\/+|\/+$/g, '');
|
|
163
|
+
});
|
|
164
|
+
return this.session.call(`[ -d "$WM_STORE/${snap.dir}" ] || { wm_fail "the snapshot ${snap.id} is no longer kept"; return 1; }; wm_ct_files "$WM_STORE/${snap.dir}" ${q(JSON.stringify(rels))}${noLinks ? ' nolinks' : ' -'}${dryRun ? ' dry' : ''}`);
|
|
165
|
+
}
|
|
166
|
+
baseDir() { return `$WM_STORE/base-${this.base}`; }
|
|
167
|
+
/** This configuration's base. A machine with a head restores that and reads no base, so a hash that cannot be made
|
|
168
|
+
* (the image has no id on the host) is no failure there: the name stays what it was. */
|
|
169
|
+
async baseName() {
|
|
170
|
+
try {
|
|
171
|
+
return await containerBaseHash(this.config, this.session);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (!headSnapshot(this.root))
|
|
175
|
+
throw error;
|
|
176
|
+
return this.base;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** A machine with a head needs its base's name only for the records its marks write: learned after the restore
|
|
180
|
+
* (queued behind it on the session), never before it. */
|
|
181
|
+
basePending;
|
|
182
|
+
learnBase() {
|
|
183
|
+
if (this.base || this.basePending)
|
|
184
|
+
return;
|
|
185
|
+
this.basePending = this.baseName().then((b) => { this.base = b ?? this.base; }, () => undefined).finally(() => { this.basePending = undefined; });
|
|
186
|
+
}
|
|
187
|
+
async ensure() {
|
|
188
|
+
if (this.state === 'running' || this.state === 'displaced')
|
|
189
|
+
return null;
|
|
190
|
+
try {
|
|
191
|
+
// the store checked (and its loop put on direct I/O: a host boot mounts it without) before any restore
|
|
192
|
+
if (headSnapshot(this.root)) {
|
|
193
|
+
await this.session.call('wm_reflink_check');
|
|
194
|
+
return await this.restore();
|
|
195
|
+
}
|
|
196
|
+
this.base = await this.baseName();
|
|
197
|
+
const { ready } = await this.session.call(`wm_reflink_check >/dev/null || return 1\n[ -f "${this.baseDir()}/criu/inventory.img" ] && echo '@{"ready":true}' || echo '@{"ready":false}'`);
|
|
198
|
+
if (!headSnapshot(this.root) && !ready) {
|
|
199
|
+
this.state = 'no-base';
|
|
200
|
+
this.message = `no container base base-${this.base} in the store; build it with \`world-machine base --config <file>\` (rerun until it prints ready)`;
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return await this.restore();
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
this.state = 'error';
|
|
207
|
+
this.message = error.message;
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** A retake's first half, run by the machine being left while the World branches: the mark a branch forked at
|
|
212
|
+
* `position` of this machine's ledger will have as its head, restored into the slot now and paused at once, its way
|
|
213
|
+
* out closed, the slot marked as handed over to that snapshot. The branch's machine then adopts it (restore) instead
|
|
214
|
+
* of restoring it again, and this one, when the World stops it, leaves the container alone (stop). While paused the
|
|
215
|
+
* machine runs nothing, and until adopted it reaches no twin, so it cannot write into the World being left. */
|
|
216
|
+
async prepare(position, live = false) {
|
|
217
|
+
const snap = headAt(this.root, position);
|
|
218
|
+
if (!snap)
|
|
219
|
+
throw Object.assign(new Error(`no snapshot at machine@${position} of this ledger`), { status: 409 });
|
|
220
|
+
if (live)
|
|
221
|
+
return this.prepareLive(snap, position);
|
|
222
|
+
const t0 = hostNowMs();
|
|
223
|
+
const restoreId = randomBytes(8).toString('hex');
|
|
224
|
+
// One locked op: the slot must still be this machine's; then its way out is closed and the mark swapped in. A
|
|
225
|
+
// failure once the swap began (wm_ct_restore stops this machine first) leaves the container stopped and its way
|
|
226
|
+
// out closed, never the mark running into this World's twins; the error says the slot was swapped. The slot's owner
|
|
227
|
+
// is then no machine's, only the handover (a status read here must not take it back, and this machine's stop leaves
|
|
228
|
+
// it alone).
|
|
229
|
+
const handover = { handover: snap.dir, restoreId, from: this.env.VOLTER_WORLD_NAME ?? null, position };
|
|
230
|
+
let r;
|
|
231
|
+
try {
|
|
232
|
+
r = await this.session.call([
|
|
233
|
+
`[ "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("token",""))' "$WM_SLOTDIR/owner.json" 2>/dev/null)" = ${q(this.token)} ] || { wm_fail "cannot prepare: the slot is not this machine's"; return 1; }`,
|
|
234
|
+
// held against a collection from here until the handover names it (gc reads both), before its presence is checked
|
|
235
|
+
`printf '%s' "$WM_STORE/${snap.dir}" > "$WM_SLOTDIR/preparing"`,
|
|
236
|
+
`[ -d "$WM_STORE/${snap.dir}" ] || { rm -f "$WM_SLOTDIR/preparing"; if grep -qx ${q(snap.dir.replace(/^snapshots\//, ''))} "$WM_STORE/expired.list" 2>/dev/null; then wm_fail "cannot prepare: the snapshot ${snap.id} was let go (past retain)"; else wm_fail "cannot prepare: the snapshot ${snap.id} is missing from the store"; fi; return 1; }`,
|
|
237
|
+
'wm_ct_net >/dev/null || { rm -f "$WM_SLOTDIR/preparing"; return 1; }',
|
|
238
|
+
'wm_egress >/dev/null',
|
|
239
|
+
`wm_ct_restore "$WM_STORE/${snap.dir}" ${q(restoreId)} || { rm -f "$WM_SLOTDIR/preparing"; wm_ct_stop; wm_fail "prepare failed after the slot was swapped (the machine is stopped): $(tail -2 "$WM_SLOTDIR/restore.out" 2>/dev/null)"; return 1; }`,
|
|
240
|
+
'sudo -n crun pause "$WM_CT" || { rm -f "$WM_SLOTDIR/preparing"; wm_ct_stop; wm_fail "prepare failed after the slot was swapped (the machine is stopped): cannot pause it"; return 1; }',
|
|
241
|
+
`wm_own ${q(JSON.stringify(handover))} || { rm -f "$WM_SLOTDIR/preparing"; wm_ct_stop; wm_fail "prepare failed after the slot was swapped (the machine is stopped)"; return 1; }`,
|
|
242
|
+
'rm -f "$WM_SLOTDIR/preparing"',
|
|
243
|
+
].join('\n'));
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
const swapped = /after the slot was swapped/.test(error.message);
|
|
247
|
+
if (swapped) {
|
|
248
|
+
this.state = 'error';
|
|
249
|
+
this.message = error.message;
|
|
250
|
+
}
|
|
251
|
+
// before the swap, nothing changed but the closed way out, put back while the slot is still this machine's
|
|
252
|
+
else
|
|
253
|
+
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_egress ${this.forwarders()} >/dev/null; fi; echo '@{"egress":true}'`).catch(() => undefined);
|
|
254
|
+
throw Object.assign(error, { status: 409, swapped });
|
|
255
|
+
}
|
|
256
|
+
this.state = 'displaced';
|
|
257
|
+
this.message = `handed over: the slot holds ${snap.id} for the branch that forks at machine@${position}`;
|
|
258
|
+
return { id: snap.id, dir: snap.dir, loadMs: r.loadMs, restoreMs: r.restoreMs, totalMs: Math.round(hostNowMs() - t0) };
|
|
259
|
+
}
|
|
260
|
+
/** A handover with no restore: the running machine itself handed to the branch that forks at `position`, which must
|
|
261
|
+
* be this ledger's head (a mark just taken). A partial retake uses it: the twins go back, the machine stays. It is not
|
|
262
|
+
* paused (the harness's connection into it would go silent): its way out is closed and its connections through the
|
|
263
|
+
* World's forwarders reset, so from here it reaches no twin of the World being left, while its processes and every
|
|
264
|
+
* other connection run on. The branch adopts it as it adopts a prepared one, the connections into it kept; a new
|
|
265
|
+
* restore id tells a program whose twin connection was reset that the World moved. */
|
|
266
|
+
async prepareLive(snap, position) {
|
|
267
|
+
const head = headSnapshot(this.root);
|
|
268
|
+
if (head?.dir !== snap.dir)
|
|
269
|
+
throw Object.assign(new Error(`cannot hand over the live machine: machine@${position} (${snap.id}) is not this ledger's head (${head?.id ?? 'none'})`), { status: 409 });
|
|
270
|
+
await this.refresh();
|
|
271
|
+
if (this.state !== 'running')
|
|
272
|
+
throw Object.assign(new Error(`cannot hand over the live machine: it is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
|
|
273
|
+
const t0 = hostNowMs();
|
|
274
|
+
const handover = { handover: snap.dir, restoreId: randomBytes(8).toString('hex'), from: this.env.VOLTER_WORLD_NAME ?? null, position, live: true };
|
|
275
|
+
const spec = this.forwarders();
|
|
276
|
+
// One locked op: the slot still this machine's and running, its way out closed and those connections reset, the
|
|
277
|
+
// owner the handover. Nothing is restored or paused: a failure opens the way out again and the machine runs on.
|
|
278
|
+
try {
|
|
279
|
+
await this.session.call([
|
|
280
|
+
`[ "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("token",""))' "$WM_SLOTDIR/owner.json" 2>/dev/null)" = ${q(this.token)} ] || { wm_fail "cannot hand over: the slot is not this machine's"; return 1; }`,
|
|
281
|
+
`[ "$(wm_ct_state)" = running ] || { wm_fail "cannot hand over: the container is $(wm_ct_state)"; return 1; }`,
|
|
282
|
+
'wm_egress >/dev/null',
|
|
283
|
+
`wm_ct_reset_egress ${spec} >/dev/null`,
|
|
284
|
+
`wm_own ${q(JSON.stringify(handover))} || { wm_fail "cannot hand over the slot"; return 1; }`,
|
|
285
|
+
`printf '%s' "$WM_STORE/${snap.dir}" > "$WM_SLOTDIR/ct-loaded"`,
|
|
286
|
+
`echo '@{"handedOver":true}'`,
|
|
287
|
+
].join('\n'));
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
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_egress ${spec} >/dev/null; fi; echo '@{"egress":true}'`).catch(() => undefined);
|
|
291
|
+
throw Object.assign(error, { status: 409, swapped: false });
|
|
292
|
+
}
|
|
293
|
+
this.state = 'displaced';
|
|
294
|
+
this.message = `handed over live: the slot's running machine (at ${snap.id}) is the branch's that forks at machine@${position}; POST /machine/restore takes it back`;
|
|
295
|
+
return { id: snap.id, dir: snap.dir, live: true, totalMs: Math.round(hostNowMs() - t0) };
|
|
296
|
+
}
|
|
297
|
+
/** This branch's head, restored in place; with no head, the base. A slot a retake's prepare left holding this head,
|
|
298
|
+
* paused, is adopted instead: its way out pointed at this World's twins, the World's files written, the connections
|
|
299
|
+
* the restore left stale reset, and the processes resumed, with no second restore. */
|
|
300
|
+
async restore() {
|
|
301
|
+
const head = headSnapshot(this.root);
|
|
302
|
+
if (head)
|
|
303
|
+
this.learnBase();
|
|
304
|
+
else if (!this.base)
|
|
305
|
+
this.base = await this.baseName();
|
|
306
|
+
if (head) {
|
|
307
|
+
// an adopt that fails part way leaves the slot to the whole restore below, in this same call
|
|
308
|
+
const adopted = await this.adopt(head).catch((error) => { process.stderr.write(`world-machine: adopting the prepared slot failed (${error.message}); restoring\n`); return null; });
|
|
309
|
+
if (adopted)
|
|
310
|
+
return adopted;
|
|
311
|
+
}
|
|
312
|
+
const dir = head ? `$WM_STORE/${head.dir}` : this.baseDir();
|
|
313
|
+
const clockFile = this.env.TWIN_WORLD_CLOCK_FILE;
|
|
314
|
+
const frozenClock = Boolean(clockFile && existsSync(clockFile));
|
|
315
|
+
const t0 = hostNowMs();
|
|
316
|
+
this.restoreId = randomBytes(8).toString('hex');
|
|
317
|
+
const r = await this.session.call([
|
|
318
|
+
// a snapshot let go past retain is named before anything is stopped
|
|
319
|
+
`[ -d "${dir}" ] || { if grep -qx ${q((head?.dir ?? '').replace(/^snapshots\//, ''))} "$WM_STORE/expired.list" 2>/dev/null; then wm_fail "the snapshot ${head?.id ?? 'base'} was let go (past retain)"; else wm_fail "the snapshot ${head?.id ?? 'base'} is missing from the store"; fi; return 1; }`,
|
|
320
|
+
// what the collector keeps while this machine runs (a base another World's configuration does not name), from
|
|
321
|
+
// before the restore reads it
|
|
322
|
+
`printf '%s' "${dir}" > "$WM_SLOTDIR/ct-loaded"`,
|
|
323
|
+
'wm_ct_net >/dev/null || return 1',
|
|
324
|
+
`wm_timed egress wm_egress ${this.forwarders()} || return 1`,
|
|
325
|
+
// the restore id is in the tree before the processes run: a restored connection is reset by its peer at once
|
|
326
|
+
`wm_ct_restore "${dir}" ${q(this.restoreId)} || return 1`,
|
|
327
|
+
`wm_own ${q(this.owner())}`, 'rm -f "$WM_SLOTDIR/preparing"',
|
|
328
|
+
// the World's env, certificates and redirect, and the login-shell profile that exports the env (the Firecracker base
|
|
329
|
+
// installs that once; a container's tree gets it with the rest)
|
|
330
|
+
`printf '%s' ${q(JSON.stringify({ ...this.guestFiles(), '/etc/profile.d/volter-world.sh': `${GUEST_PROFILE}\n` }))} | wm_ct_write >/dev/null || return 1`,
|
|
331
|
+
'wm_ct_reset',
|
|
332
|
+
this.config.publishPorts ? `wm_ct_publish ${q(JSON.stringify(this.config.publishPorts))}` : 'wm_ct_unpublish',
|
|
333
|
+
].join('\n'));
|
|
334
|
+
this.lastUsed = this.session.call('wm_used').then((u) => u.used, () => Number.NaN);
|
|
335
|
+
this.loaded = head ? { id: head.id, label: head.label, dir: head.dir } : null;
|
|
336
|
+
this.state = 'running';
|
|
337
|
+
this.message = undefined;
|
|
338
|
+
return {
|
|
339
|
+
id: head?.id ?? null, label: head?.label ?? null, from: head ? 'head' : `base-${this.base}`, backend: 'container',
|
|
340
|
+
loadMs: r.loadMs, restoreMs: r.restoreMs, resetMs: r.resetMs, egressMs: r.egressMs,
|
|
341
|
+
...(frozenClock ? { clock: 'the World\'s clock is frozen; the container reads the host\'s, which is now' } : {}),
|
|
342
|
+
totalMs: Math.round(hostNowMs() - t0),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
async adopt(head) {
|
|
346
|
+
const slot = await this.session.call('wm_ct_running');
|
|
347
|
+
// a prepared slot is paused; a live handover's machine runs on (prepareLive)
|
|
348
|
+
const live = slot.owner?.live === true;
|
|
349
|
+
if (slot.container !== (live ? 'running' : 'paused') || slot.owner?.handover !== head.dir || !slot.owner.restoreId)
|
|
350
|
+
return null;
|
|
351
|
+
const t0 = hostNowMs();
|
|
352
|
+
this.restoreId = slot.owner.restoreId;
|
|
353
|
+
const r = await this.session.call([
|
|
354
|
+
`wm_timed egress wm_egress ${this.forwarders()} || return 1`,
|
|
355
|
+
`wm_own ${q(this.owner())}`, 'rm -f "$WM_SLOTDIR/preparing"',
|
|
356
|
+
`printf '%s' "$WM_STORE/${head.dir}" > "$WM_SLOTDIR/ct-loaded"`,
|
|
357
|
+
`printf '%s' ${q(JSON.stringify({ ...this.guestFiles(), '/etc/profile.d/volter-world.sh': `${GUEST_PROFILE}\n` }))} | wm_ct_write >/dev/null || return 1`,
|
|
358
|
+
// a live machine was never paused and its connections through the forwarders were reset at the handover: the
|
|
359
|
+
// connections into it (the harness's) are kept
|
|
360
|
+
live ? 'echo \'@{"resetMs":0}\'' : 'sudo -n crun resume "$WM_CT" || { wm_fail "cannot resume the adopted machine"; return 1; }',
|
|
361
|
+
live ? '' : 'wm_ct_reset',
|
|
362
|
+
this.config.publishPorts ? `wm_ct_publish ${q(JSON.stringify(this.config.publishPorts))}` : 'wm_ct_unpublish',
|
|
363
|
+
].join('\n'));
|
|
364
|
+
this.lastUsed = this.session.call('wm_used').then((u) => u.used, () => Number.NaN);
|
|
365
|
+
this.loaded = { id: head.id, label: head.label, dir: head.dir };
|
|
366
|
+
this.state = 'running';
|
|
367
|
+
this.message = undefined;
|
|
368
|
+
return { id: head.id, label: head.label, from: 'head', backend: 'container', adopted: true, ...(live ? { live: true } : {}), egressMs: r.egressMs, resetMs: r.resetMs, totalMs: Math.round(hostNowMs() - t0) };
|
|
369
|
+
}
|
|
370
|
+
async refresh() {
|
|
371
|
+
if (this.state !== 'running' && this.state !== 'displaced')
|
|
372
|
+
return;
|
|
373
|
+
const r = await this.session.call('wm_ct_running');
|
|
374
|
+
if (r.owner?.handover) {
|
|
375
|
+
this.state = 'displaced';
|
|
376
|
+
this.message = `the slot is handed over to a branch (${r.owner.handover}); POST /machine/restore takes it back`;
|
|
377
|
+
}
|
|
378
|
+
else if (r.owner?.token !== this.token) {
|
|
379
|
+
this.state = 'displaced';
|
|
380
|
+
this.message = `the slot now runs ${r.owner?.world ?? 'nothing'}'s machine; POST /machine/restore takes it back`;
|
|
381
|
+
}
|
|
382
|
+
else if (r.container !== 'running') {
|
|
383
|
+
this.state = 'error';
|
|
384
|
+
this.message = `the container is ${r.container || 'gone'}`;
|
|
385
|
+
}
|
|
386
|
+
// this machine's publisher started again if it died (idempotent while it runs): refresh runs at every mark and status read
|
|
387
|
+
else if (this.config.publishPorts)
|
|
388
|
+
await this.session.call(`wm_ct_publish ${q(JSON.stringify(this.config.publishPorts))} >/dev/null 2>&1; echo '@{"published":true}'`);
|
|
389
|
+
}
|
|
390
|
+
async checkpoint(label, keep = false) {
|
|
391
|
+
await this.refresh();
|
|
392
|
+
if (this.state !== 'running')
|
|
393
|
+
throw Object.assign(new Error(`cannot checkpoint: the machine is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
|
|
394
|
+
await this.room();
|
|
395
|
+
await this.basePending;
|
|
396
|
+
const id = `snap-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
|
|
397
|
+
const dir = `snapshots/${id}`;
|
|
398
|
+
const r = await this.session.call([
|
|
399
|
+
`mkdir -p "$WM_STORE/${dir}" && : > "$WM_STORE/${dir}/.pending" || return 1`,
|
|
400
|
+
// the mark it descends from: the dump's pages are composed against its
|
|
401
|
+
`wm_ct_mark "$WM_STORE/${dir}" "${this.loaded ? `$WM_STORE/${this.loaded.dir}` : this.baseDir()}" || return 1`,
|
|
402
|
+
'wm_used',
|
|
403
|
+
].join('\n'));
|
|
404
|
+
const measured = await this.lastUsed;
|
|
405
|
+
const before = Number.isNaN(measured) ? (r.used ?? 0) : measured;
|
|
406
|
+
const storeBytes = Math.max(0, (r.used ?? 0) - before);
|
|
407
|
+
this.lastUsed = Promise.resolve(r.used ?? before);
|
|
408
|
+
const record = {
|
|
409
|
+
// no base named when it could not be learned (the image has no id on the host)
|
|
410
|
+
id, label, type: 'Full', parent: this.loaded?.id ?? `base-${this.base}`, base: this.base ? `base-${this.base}` : '', dir,
|
|
411
|
+
memBytes: r.memBytes ?? 0, diskBytes: Math.max(0, storeBytes - (r.memBytes ?? 0)), storeBytes,
|
|
412
|
+
pauseMs: r.pauseMs ?? 0, created: worldInstant(), ...(keep ? { keep: true } : {}),
|
|
413
|
+
};
|
|
414
|
+
await appendSnapshot(this.root, record);
|
|
415
|
+
await this.session.call(`rm -f "$WM_STORE/${dir}/.pending"; echo '@{"recorded":true}'`);
|
|
416
|
+
this.loaded = { id, label, dir };
|
|
417
|
+
return { id, pauseMs: r.pauseMs, treeMs: r.treeMs, dumpMs: r.dumpMs, memBytes: record.memBytes, diskBytes: record.diskBytes, storeBytes };
|
|
418
|
+
}
|
|
419
|
+
/** The container reads the host's kernel clock: there is no guest clock to be off. */
|
|
420
|
+
async clock() {
|
|
421
|
+
return { errorMs: 0, clock: 'the host kernel\'s (the container shares it)' };
|
|
422
|
+
}
|
|
423
|
+
status() {
|
|
424
|
+
const s = super.status();
|
|
425
|
+
return { ...s, backend: 'container', guest: { ip: this.config.guestIp, container: `wm-${this.config.slot}` } };
|
|
426
|
+
}
|
|
427
|
+
async stop() {
|
|
428
|
+
try {
|
|
429
|
+
// a slot this machine handed over (prepare) holds no token of its own: it is the branch's to adopt, left as it is
|
|
430
|
+
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_ct_unpublish; wm_ct_stop; wm_egress; wm_release; rm -f "$WM_SLOTDIR/ct-loaded"; fi; echo '@{"stopped":true}'`);
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
this.session.close();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|