@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/README.md
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# @volter/world-machine
|
|
2
|
+
|
|
3
|
+
The machine as a World service: a Firecracker microVM run as an ordinary `process` service, whose
|
|
4
|
+
memory-and-disk snapshots are entries in the World's ledger. `mark` and `branch --at machine@N` take
|
|
5
|
+
the machine back exactly as they take a twin back, with no change to the kernel or the runtime.
|
|
6
|
+
It is the machine half of *retake*: an agent's world (machine memory and disk, and its twins' state)
|
|
7
|
+
goes back to a mark while the agent's conversation, running outside the machine, continues.
|
|
8
|
+
|
|
9
|
+
## The shape
|
|
10
|
+
|
|
11
|
+
| Part | Where | What |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| the ledger | the World, state service `machine` | one entry per checkpoint (`snapshot:<id>`: label, snapshot directory, memory/disk/store bytes, pause, created), written through world-core's `applyTwinWrite`; the head a branch restores is the last snapshot in its view (`wholeLog`) |
|
|
14
|
+
| the machine host | Linux with KVM, or a Lima >= 2 VM with `nestedVirtualization: true` on a Mac | Firecracker in one **slot** (fixed tap, guest address and disk path), run in place: a restore replaces the slot's process, so the guest keeps its address and identity |
|
|
15
|
+
| the store | a directory on the machine host on XFS (reflink) or btrfs | `base-<hash>/` warm bases, `snapshots/<id>/{vmstate,mem,disk.ext4}`, `slots/<slot>/`; for the container backend also `tree-<hash>/` (a filled tree, below) and `slots/<slot>/ct-loaded` (what the slot's machine was restored from) |
|
|
16
|
+
| the guest agent | inside the guest, in every snapshot | steps the wall clock, resets the connections a restore left stale, writes `/etc/world.env` |
|
|
17
|
+
|
|
18
|
+
The service holds one persistent shell on the machine host (a new ssh per operation cost 75-125 ms on
|
|
19
|
+
a loaded Mac), so each operation is one round trip.
|
|
20
|
+
|
|
21
|
+
- **Checkpoint** flushes the disk's dirty pages, pauses the guest, writes the pages dirtied since the
|
|
22
|
+
last snapshot or load (a Firecracker `Diff` snapshot) and the device state to `/dev/shm`,
|
|
23
|
+
reflink-clones the disk, resumes it, then materializes one full
|
|
24
|
+
memory file for the entry (a reflink copy of the parent's with the diff applied by
|
|
25
|
+
`snapshot-editor edit-memory rebase`), so a restore always loads one file.
|
|
26
|
+
- **Restore** loads this branch's head (or the warm base when the ledger has none) with `mem_backend`
|
|
27
|
+
`File`, steps the guest's wall clock to the World's clock (`TWIN_WORLD_CLOCK_FILE` when set, else
|
|
28
|
+
the World host's now), resets every connection the restore left stale, and rebuilds the guest's route out.
|
|
29
|
+
- **Nothing runs until asked.** The service answers at once and boots nothing (`state: idle`). The first
|
|
30
|
+
`GET /machine` or `POST /machine/checkpoint` brings the machine up (the ledger's head, else the warm base);
|
|
31
|
+
`POST /machine/restore` loads the head itself. A branch's services start before `branch` forks their
|
|
32
|
+
ledgers, so a retake is `branch --at` followed by `POST /machine/restore`, and the new branch's machine is
|
|
33
|
+
loaded once, at its mark (it used to boot its head at start and be replaced by the restore a second later).
|
|
34
|
+
- **The warm base** is the declared guest image cold-booted once, provisioned by the declared script
|
|
35
|
+
and snapshotted whole, cached in the store under a hash of everything that shapes it. A World's
|
|
36
|
+
machine never cold-boots (20-60 s measured).
|
|
37
|
+
- **Copy-on-write** is a precondition: the store must reflink (`cp --reflink=always`). There is no
|
|
38
|
+
fallback to a full copy; a whole-image copy dominated every pause it was part of (1-13 s measured).
|
|
39
|
+
- **Egress**: the guest has no gateway and forwarding from its tap is dropped. Its only way out is
|
|
40
|
+
one forwarder per twin on the tap's host address, at a stable guest-visible port per vendor, pointed
|
|
41
|
+
at this World's `*_TWIN_URL` at every restore and start (so a checkout re-points it). The guest
|
|
42
|
+
reads the stable URLs from `/etc/world.env` (`STRIPE_TWIN_URL=http://172.16.0.1:29772`), with the rest of the World's
|
|
43
|
+
live env (`VOLTER_WORLD_ENV_FILE`: its fake credentials and app values; its proxy as a forwarder at a stable port,
|
|
44
|
+
which the twins' own addresses bypass; a certificate file it names (its CA and trust bundle) copied to
|
|
45
|
+
`/etc/volter-world-<name>`, all written at each start and restore; what only its host can use, a directory there
|
|
46
|
+
or a loopback URL of its other listeners, is left out and named in the file), and `NODE_OPTIONS` requiring world-core's redirect, written with
|
|
47
|
+
the files it loads to `/opt/volter-world` at each start and restore too: a real SDK calling its vendor's host reaches the twin, as under `volter world
|
|
48
|
+
run`. Every login shell exports the file (`/etc/profile.d/volter-world.sh`). A World variable whose value is empty
|
|
49
|
+
is left unset in the machine and named in the file. `world init --repo` writes a repository's `.env.example` keys
|
|
50
|
+
that way, and set, even to nothing, they hid the app's own `.env.local` (Next.js and dotenv never override a set
|
|
51
|
+
variable). A deliberately empty value therefore reads as unset in the machine, as it does on the host: `init` writes
|
|
52
|
+
no empty placeholders, and `up` leaves an empty World value unset.
|
|
53
|
+
|
|
54
|
+
## The container backend (`backend: "container"`, not yet run)
|
|
55
|
+
|
|
56
|
+
On a host where a microVM would be nested (a Mac, Firecracker inside a Lima VM), first-touch guest memory faults through
|
|
57
|
+
two hypervisors, so a real project's install ran 20–80× slower than one level up. The container backend (`container.ts`,
|
|
58
|
+
`ct.sh`) runs the machine as the host's own processes in a crun container instead, marked by CRIU:
|
|
59
|
+
|
|
60
|
+
| | firecracker | container |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| the machine | a microVM on KVM | a crun container in a network namespace holding the same guest address |
|
|
63
|
+
| its tree | a reflinked `disk.ext4` | one reflinked ext4 image holding the container's root (so NFS handles on an unchanged file stay valid across a restore) |
|
|
64
|
+
| a mark | a memory diff, the disk reflinked | the container paused, the image reflinked, a full CRIU dump (arm64 has no soft-dirty tracking), resumed; the dump's pages then composed against the parent's (`compose.py`) |
|
|
65
|
+
| a restore | the snapshot loaded, the clock stepped, stale sockets reset by the guest agent | the image reflinked back, CRIU restore, stale sockets reset from the host (`ss -K` in the namespace) |
|
|
66
|
+
| the clock | stepped to the World's | the host kernel's, which is now; a frozen World clock is not followed |
|
|
67
|
+
| the World's env, certificates, redirect | written by the guest agent | written into the tree |
|
|
68
|
+
|
|
69
|
+
Its config adds:
|
|
70
|
+
- `backend`;
|
|
71
|
+
- `image` (the OCI image the tree is made from) and `imageSize` (default 40G);
|
|
72
|
+
- `binds` (host directories bound read-only into the container);
|
|
73
|
+
- `export` (a path in the tree the host's NFS server v3 serves to one client, unexported and re-exported around every
|
|
74
|
+
swap of the image, with a fixed fsid);
|
|
75
|
+
- `publishPorts` (`{"ranges": [[3000, 3999]], "except": [...]}`, none by default): the machine's listening ports in those
|
|
76
|
+
ranges, published on the host's loopback at the same port as the machine's own `localhost` (`publish.py`).
|
|
77
|
+
|
|
78
|
+
The app in the machine registers `http://localhost:<port>` with a vendor, and a twin on the host delivered its webhooks
|
|
79
|
+
to the host's own loopback, where nothing listened. A Lima host also forwards those ports on to the Mac.
|
|
80
|
+
|
|
81
|
+
A port is published only when:
|
|
82
|
+
- it is allowed: the machine runs code the user did not write, and a port it held first would be taken from a host
|
|
83
|
+
service that binds it later;
|
|
84
|
+
- the machine listens on it on a wildcard or loopback address, and on no address of its own (how its doors bind);
|
|
85
|
+
- the host does not listen on it already.
|
|
86
|
+
|
|
87
|
+
Each connection enters the machine's namespace to its loopback. `setup` adds `skip-in-flight` to CRIU's default config
|
|
88
|
+
(`/etc/criu/default.conf`, which crun's CRIU reads). A connection the kernel had accepted but the paused process had
|
|
89
|
+
not made a mark fail (measured under requests to a published port); skipped, its client sees a reset and retries.
|
|
90
|
+
With it, 8 of 8 marks succeeded under steady requests. `setup` installs
|
|
91
|
+
crun, CRIU and podman and checks CRIU. `base` fills the tree, runs the provisioning once in the container, starts
|
|
92
|
+
`world-machine-init` (which runs each `/etc/world-machine/services.d/*.sh` to completion: a service script starts its
|
|
93
|
+
daemon and returns once it listens) and marks it.
|
|
94
|
+
|
|
95
|
+
Measured on the pieces, not on this code (Volter Harness `container/retake/README.md`):
|
|
96
|
+
- a `next dev` and Postgres froze 1.28 s for a CRIU dump and restored in place in 1.6 s, warm;
|
|
97
|
+
- consecutive marks share 71% of their pages, and zstd takes a mark 6×;
|
|
98
|
+
- a twin replaced across a restore cost one in-flight request.
|
|
99
|
+
|
|
100
|
+
The container's processes are told io_uring does not exist (seccomp, `ENOSYS`): CRIU cannot dump one, and Node's libuv
|
|
101
|
+
uses it where the kernel offers it. Both loops (the store's image and the tree's) use direct I/O: through the page cache
|
|
102
|
+
every page was cached once per layer, and a large dump waited on writeback. A store `setup --make-store` creates is
|
|
103
|
+
btrfs with `compress-force=zstd:1` where the host has btrfs (an XFS image made before is kept). After each mark,
|
|
104
|
+
`compose.py` rewrites its CRIU page files so that pages identical to the parent's are reflinked from it and only new ones
|
|
105
|
+
are data. The files stay CRIU's own, byte for byte, and a `next dev` mark of 1.3 GB kept 40–131 MB (mean 89 MB). Every
|
|
106
|
+
mark dumps: whether nothing ran since the parent cannot be told exactly (a freeze and thaw retry the processes'
|
|
107
|
+
interrupted syscalls, as a small real wake-up does, and no counter on a VZ guest tells the two apart: it has no PMU to
|
|
108
|
+
count user instructions; Volter Harness `container/retake/README.md`, "a turn in which nothing ran").
|
|
109
|
+
|
|
110
|
+
**The container base's identity** is the image by its id on the host (a tag rebuilt or pulled anew is a new image), its
|
|
111
|
+
size, the provisioning, the host functions that make it and its dump (`wm_ct_base_fill` with the init it installs,
|
|
112
|
+
`wm_ct_provision`, `wm_ct_start`, `wm_ct_bundle`, `wm_ct_net`, `wm_ct_mark`, as bash prints them, so a comment is no
|
|
113
|
+
input), the `binds` and the slot's addresses. A change to any other host function keeps the base (it hashed all of
|
|
114
|
+
`ct.sh`, and each such change rebuilt it: 49 s measured). With no image id on the host (podman failing, the image
|
|
115
|
+
removed) there is no base name: a machine with a head restores it anyway, one without reads `error` saying so, and a
|
|
116
|
+
collection stops before deleting anything.
|
|
117
|
+
|
|
118
|
+
**The filled tree** (`tree-<hash>`: the image's tree after the fill, keyed by the image id, the size, the fill and the
|
|
119
|
+
guest's address) is kept in the store. A base rebuilt for anything but those starts from a reflink of it: 1.5 s where
|
|
120
|
+
filling from the image took 12.6 s (a warm `podman export` of the 1.4 GB, 37,000-file image alone was 8.2 s). `base`
|
|
121
|
+
pulls an image from a registry in the background first, since the base is named by its id; the fill never pulls, so a
|
|
122
|
+
registry tag is refreshed only by a pull on the host.
|
|
123
|
+
|
|
124
|
+
`POST /machine/prepare {"position": N}` (container backend) is a retake's first half, run by the machine of the World
|
|
125
|
+
being left while the World branches. It restores the head a branch forked at `machine@N` will have, pauses it at once
|
|
126
|
+
with its way out closed, and marks the slot as handed over to that snapshot. When the World stops that machine's
|
|
127
|
+
service, the container is left as it is. The branch's machine, asked to restore, finds its own head waiting there
|
|
128
|
+
paused and adopts it: its egress is pointed at the branch's twins, the World's files are written, stale connections
|
|
129
|
+
are reset, and the processes resume, with no second restore. Anything else (no handover, another snapshot, a running
|
|
130
|
+
container) takes the whole restore as before. With `"live": true` nothing is restored or paused: the running machine
|
|
131
|
+
itself is handed over, and `N` must be this ledger's head (a mark just taken). A partial retake uses it (the twins go
|
|
132
|
+
back, the machine stays): its way out is closed and only its connections through the World's forwarders are reset, so
|
|
133
|
+
its processes and the connections into it (the harness's) run on; its calls to a twin are refused until the branch
|
|
134
|
+
adopts it (about a second). A failure opens the way out again. Measured through supercode's `restore_world`:
|
|
135
|
+
|
|
136
|
+
| | machine | whole retake |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| before | 0.57–0.81 s | 2.3–2.6 s |
|
|
139
|
+
| adopted, no dev server | 0.14–0.19 s | 1.17–1.25 s |
|
|
140
|
+
| adopted, `next dev` (1.3 GB) in the machine | 0.32 s | 2.4 s |
|
|
141
|
+
|
|
142
|
+
The branch itself grew under the concurrent restore (2.0 s against 1.0–1.7 s), which is why the dev-server case
|
|
143
|
+
gained less.
|
|
144
|
+
|
|
145
|
+
Two services operate the one slot during a retake, the machine being left and the branch's. So on the container
|
|
146
|
+
backend every host operation takes the slot's lock (`slot.lock`, `flock`, up to 120 s), except the store-wide gc. The
|
|
147
|
+
checks behind the handover are made inside the same locked operation as the swap:
|
|
148
|
+
- a prepare refuses a slot that is not its machine's;
|
|
149
|
+
- a prepare that fails once the swap began leaves the machine stopped with its way out closed, never the mark running
|
|
150
|
+
into the World being left;
|
|
151
|
+
- a failed adopt falls through to the whole restore.
|
|
152
|
+
|
|
153
|
+
Background processes the host starts (forwarders, the publisher, the composer, the base fill) do not inherit the lock's
|
|
154
|
+
descriptor.
|
|
155
|
+
|
|
156
|
+
Run end to end on 2026-09-25 (Volter Harness `container/retake/README.md`, "the container machine's first run"). A mark
|
|
157
|
+
paused the machine for 0.2–0.6 s, and a whole retake took 2.4–3.0 s, of which the machine took 0.85 s. Files, process
|
|
158
|
+
memory and a twin came back exactly, and a Mac's NFS handles stayed valid. With an `export`, the host's NFS server stops
|
|
159
|
+
its threads for a restore's swap, so a hard-mounted client waits instead of getting an error. That server is the host's,
|
|
160
|
+
so every export on it waits.
|
|
161
|
+
|
|
162
|
+
## Declaring it
|
|
163
|
+
|
|
164
|
+
The machine config (paths are on the machine host; `~/` is that host's home; `provision` is beside
|
|
165
|
+
the config on the World's host):
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"host": { "kind": "lima", "instance": "tm", "limaHome": "~/.lima-tm" },
|
|
170
|
+
"store": "~/.cache/volter-world-machine",
|
|
171
|
+
"kernel": "~/fc/vmlinux",
|
|
172
|
+
"rootfs": "~/fc/rootfs.ext4",
|
|
173
|
+
"sshKey": "~/fc/id_rsa",
|
|
174
|
+
"provision": "provision.sh",
|
|
175
|
+
"vcpus": 2,
|
|
176
|
+
"memMib": 1024
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Optional: `firecracker` and `snapshotEditor` (default `<store>/bin/…`, installed by `setup`),
|
|
181
|
+
`slot` (`default`), `tap` (`wm0`), `hostIp` (`172.16.0.1`), `guestIp` (`172.16.0.2`), `egressPorts`
|
|
182
|
+
(`{"STRIPE": 21000}` to fix a vendor's port) and `room` (`{"paths": [...], "reserveBytes": 2147483648}`). A mark is
|
|
183
|
+
refused with 507 unless the filesystems under the store have room for it: the store, the file backing it when it is a
|
|
184
|
+
loop image, and each of `paths`. Room means the last mark's growth, at least 1 GiB, plus `reserveBytes`. On a Mac, name
|
|
185
|
+
the home the Lima VM mounts: its free space is the Mac's, beneath the VM's own sparse disk. When the lowest layer
|
|
186
|
+
fills, a write fails as an I/O error in every layer above it, which can corrupt the store; a refused mark only goes
|
|
187
|
+
untaken. Not covered: the warm base's own mark, and what the machine's processes write between marks. On Linux
|
|
188
|
+
`host` is `{"kind":"linux"}` (the default).
|
|
189
|
+
The rootfs must authorize `sshKey`'s public half for root and have `python3` (the guest agent).
|
|
190
|
+
|
|
191
|
+
The service, declared after the twins it routes to (a service sees the `*_TWIN_URL`s of the
|
|
192
|
+
services started before it):
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"id": "machine", "type": "process",
|
|
197
|
+
"execution": { "process": { "command": "bun", "args": ["node_modules/@volter/world-machine/src/cli.ts", "serve", "--config", "machine.json"] }, "controlPlane": true },
|
|
198
|
+
"endpoint": { "port": "auto" },
|
|
199
|
+
"bindings": { "injectEnv": "MACHINE_URL" }
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Commands
|
|
204
|
+
|
|
205
|
+
Every command returns in well under 30 s; the one-time base build is a sequence of short steps.
|
|
206
|
+
|
|
207
|
+
| Command | Does |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `world-machine setup --config <file> [--make-store <size>]` | on the machine host: the `/dev/kvm` udev rule (udev resets the device's mode), socat, the tap, Firecracker v1.17.0 and `snapshot-editor` when absent, and with `--make-store`, where the store cannot reflink already, a loop-mounted XFS image at the store (in fstab). Idempotent |
|
|
210
|
+
| `world-machine base --config <file>` | advances the warm base one step (copy the rootfs, boot, install the agent and start the provisioning script, then the Full snapshot; for the container backend: pull a registry image, fill the tree or reflink the filled one, provision, mark) and says what it waits on; exit 3 until it prints `"ready": true`. It uses the slot, so a running machine is stopped |
|
|
211
|
+
| `world-machine gc --config <file>` | deletes the snapshots, warm bases and filled trees no registered World's branch names, then commits and trims the store's filesystem; prints what it kept, what it deleted and the bytes freed (the store's `df`, before and after) |
|
|
212
|
+
| `world-machine serve --config <file>` | the service; the World adds `--port` and `--root`. It registers its World in the store at once and collects after the machine's first bring-up (or 10 s after start when nothing brings it up), in the background: a first `GET` waited 1.5 s behind a collection deleting two bases |
|
|
213
|
+
|
|
214
|
+
## Doors
|
|
215
|
+
|
|
216
|
+
Every door needs the World's door key: `authorization: Bearer <key>`, the 64 hex digits `serve` writes once to
|
|
217
|
+
`.world-machine-token` beside the configuration it is given (`--config`), mode 0600. That file is on the machine's host
|
|
218
|
+
only (never under the tree the machine runs on or a bind of it), and a caller reads it there: supercode-retake from
|
|
219
|
+
the World's app folder, where its `machine.json` is. Loopback is not a boundary here, since the machine's processes
|
|
220
|
+
reach this host's loopback through the World's egress proxy, which forwards local destinations. Every door also refuses (403) a request carrying `Origin`, naming a `Host` other than this listener's loopback address, or
|
|
221
|
+
POSTing a body that is not `application/json`. The port is loopback, but a VM's loopback is forwarded to its host (Lima),
|
|
222
|
+
where a web page could otherwise send a cross-origin POST, or read an answer through a rebound DNS name.
|
|
223
|
+
|
|
224
|
+
| Door | Answers |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `GET /machine` | `{ state, head: {id,label}\|null, loaded, guest: {ip, sshPort, sshKey, user}, egress: {<VENDOR>: url}, message? }`; `state` is `running`, `displaced` (another World's machine took the slot; a restore takes it back), `no-base` or `error` (for the container backend also when its image has no id on the host), or `idle` before anything asked for it (a `GET` brings it up, and its answer carries `broughtUp` with the restore's facts when it did) |
|
|
227
|
+
| `POST /machine/checkpoint {label, keep?}` | `{ id, pauseMs, memBytes, diskBytes, storeBytes, memWriteMs, diskCloneMs, materializeMs }`; appends one ledger entry |
|
|
228
|
+
| `POST /machine/restore` | `{ id, label, from, loadMs, restoreMs, clockMs, clockErrorMs, clockSteps, staleReset, resetMs, … , totalMs }` |
|
|
229
|
+
| `POST /machine/present {positions}` | `[{ position, id, present }]`: whether each ledger position's snapshot is still in the store (complete, not `.pending`) |
|
|
230
|
+
| `GET /machine/clock` | `{ errorMs, guestRttMs, hostOffsetMs, hostRttMs }`: the guest's wall clock against the World's, now |
|
|
231
|
+
| `POST /machine/compare {position, path?}` | container backend, read only: what a restore to that position would change, `{ files: {back, gone, changed, counts, skipped, partial}, processes: {restarted, stopped, then, now} }`, from the mark's image mounted read-only (`path` defaults to the exported project; regular files and symlinks, bounded at 30,000 files and 15 s) |
|
|
232
|
+
| `POST /machine/run {argv, user?, timeoutMs?}` | container backend: a command run in the running machine (`crun exec`), as `user` (a name the machine's passwd resolves) or its root, bounded by `timeoutMs` (60 s, at most 300 s) with the machine's own `timeout`, which ends what it started there; `{ code, stdout, stderr, timedOut? }`, each output's last 16,000 bytes. A caller composing a service's restore (stop it, `files`, start it) uses it; an error starting `refused:` ran nothing |
|
|
233
|
+
| `POST /machine/files {position, paths}` | container backend: those paths (absolute in the machine, or relative to the exported project) back as they were at the position, the machine running on, `{ paths: [{path, done, changes, sample}] }`; `done` is `restored` (from the mark's image: a folder as it was, what was made in it since removed; a file by its content, never its time; owners, modes and times), `removed` (the mark did not have it) or `absent`. Every path is checked before any is touched (an error starting `refused:` changed nothing); with `noLinks`, a path holding a symlink anywhere under it, in the mark or now, is refused (a data directory whose tablespace or log lives elsewhere); the live tree is written component by component without following a symlink, and a path whose folders are a symlink in either tree is refused. Hard links, ACLs and extended attributes are not carried |
|
|
234
|
+
|
|
235
|
+
`memBytes` is the dirty memory written, `storeBytes` the store's `df` growth (read after a syncfs:
|
|
236
|
+
XFS frees an unlinked file's blocks in the background) since the last restore or checkpoint, and
|
|
237
|
+
`diskBytes` that growth less `memBytes` (the disk's copy-on-write blocks).
|
|
238
|
+
|
|
239
|
+
## A retake
|
|
240
|
+
|
|
241
|
+
At a mark: `POST /machine/checkpoint`, then the World's positions (`volter world log --json`, the
|
|
242
|
+
highest position per service). To go back: `volter world branch <name> --at machine@N,stripe@M`,
|
|
243
|
+
then `POST /machine/restore` on the new branch's machine (its URL is `MACHINE_URL` in the World's
|
|
244
|
+
env). With a frozen World clock, carry the parent's instant with `volter world clock set` first.
|
|
245
|
+
|
|
246
|
+
## The clock
|
|
247
|
+
|
|
248
|
+
A restored guest wakes at its snapshot's wall time. The restore moves it through the guest agent: the
|
|
249
|
+
machine host's clock is measured against the World host's (the shortest of five round trips over the
|
|
250
|
+
persistent shell); after one warm-up request (the first request after a load waits on the guest waking, its
|
|
251
|
+
pages faulted in from the snapshot file), three probes measure the guest's error against the target and the
|
|
252
|
+
agent moves its clock by that difference against its own time (`shift`), so the step does not depend on
|
|
253
|
+
when it arrives; probe and shift repeat (at most four times) until the guest reads within 3 ms. With a
|
|
254
|
+
frozen World clock the guest is moved to the frozen instant and runs on from it, so `GET /machine/clock`
|
|
255
|
+
then reads the time since the restore.
|
|
256
|
+
|
|
257
|
+
## Stale connections
|
|
258
|
+
|
|
259
|
+
Every established TCP connection between the guest and the host on its link is stale after a restore: its
|
|
260
|
+
far end belongs to the host as it was at the snapshot. A server listening in the guest (a harness's executor,
|
|
261
|
+
sshd) comes back holding the mark's client, which is gone, and refuses or waits on a new one that resumes the
|
|
262
|
+
same session; a client in the guest waits out its own timeout. Once the clock is moved, the guest agent resets
|
|
263
|
+
all of them except its own connection (the kernel's socket destroy over sock_diag netlink, what `ss -K`
|
|
264
|
+
does, in the agent's own process: the guest kernel needs `CONFIG_INET_DIAG_DESTROY`), so each peer sees a reset at once and reconnects. The restore's answer counts
|
|
265
|
+
them (`staleReset`, `resetMs`).
|
|
266
|
+
|
|
267
|
+
## Retention
|
|
268
|
+
|
|
269
|
+
A snapshot is kept while any existing branch's machine ledger names it, through the branch's whole view
|
|
270
|
+
(the parent's history up to the fork included); a purged branch's own snapshots go. Every snapshot
|
|
271
|
+
directory is self-contained (its memory and disk are reflink copies, never references into another
|
|
272
|
+
directory), so deleting one never breaks another. The store is shared by every World whose machine runs
|
|
273
|
+
on the host, so each `serve` registers its World (`<store>/roots.json`: the World's `.volter/worlds`
|
|
274
|
+
directory) and a collection reads every registered World's branches, dropping registrations whose
|
|
275
|
+
directory is gone. It deletes snapshot directories no ledger names, except one a checkpoint is still
|
|
276
|
+
writing (`.pending` until the ledger entry is written) and one a running slot's machine was loaded from
|
|
277
|
+
(`slots/<slot>/loaded`, read only while that slot's Firecracker runs; a container slot's `ct-loaded`, written before a
|
|
278
|
+
restore reads it and removed when its machine's service stops); and warm bases no kept snapshot was built on, except
|
|
279
|
+
this configuration's base, a loaded one and one being built. On the container backend a snapshot never reads its base,
|
|
280
|
+
so there only a branch's head keeps the base it was taken on, and a filled tree stays while it is this configuration's
|
|
281
|
+
or a kept base (or base build) was made from it. Then the store is committed and `fstrim` gives the space back to the
|
|
282
|
+
host (a trim before btrfs committed the deletes gave nothing back; on a Mac the Lima VM's root discards online, so the
|
|
283
|
+
space reaches the Mac's sparse disk file at the root's next commit). A ledger it cannot read stops it before anything is deleted. A checkpoint that
|
|
284
|
+
failed half-way leaves its `.pending` directory behind, which a collection keeps.
|
|
285
|
+
|
|
286
|
+
`retain` (a number, optional) bounds one World's own marks: a collection run by that World's `serve` keeps each
|
|
287
|
+
machine ledger's head snapshot, and of the World's other snapshots the newest `retain` by the host time in their
|
|
288
|
+
id; the rest go even though a ledger names them, and each is listed in `<store>/expired.list`. Other Worlds'
|
|
289
|
+
snapshots are left to their own `serve`. A snapshot another registration's ledger also names is kept, so a mark shared with a branch
|
|
290
|
+
still registered is not counted against `retain`. With `retain` set, `serve` collects at start and after every tenth
|
|
291
|
+
checkpoint. A snapshot being restored (`slots/<slot>/restoring`, written before its presence is checked and
|
|
292
|
+
removed once the restore returns) is kept like a loaded one. A restore or prepare of a snapshot that is gone is
|
|
293
|
+
refused before anything stops, naming whether it was let go past `retain` or is missing; `POST /machine/present`
|
|
294
|
+
tells a caller beforehand which positions can still be restored.
|
|
295
|
+
|
|
296
|
+
`thin` (true or false, optional, needs `retain`) thins with age instead of cutting at `retain`: past the newest
|
|
297
|
+
`retain`, one snapshot of each calendar hour stays for a day and one of each UTC day for a month (`src/thin.ts`). The
|
|
298
|
+
one kept is each bucket's oldest, so a newer mark never displaces it: a snapshot that stayed stays until its tier
|
|
299
|
+
ends, and an hour's keeper goes only as it crosses into the day tier, unless it is its day's oldest. A checkpoint taken
|
|
300
|
+
with `keep` (`POST /machine/checkpoint {label, keep: true}`: a mark the agent pinned) is never let go, the newest 20
|
|
301
|
+
such; they count among the newest `retain` like any other.
|
|
302
|
+
|
|
303
|
+
## Not done
|
|
304
|
+
|
|
305
|
+
- One slot per machine host: two Worlds' machines share it (the later restore wins; the other reads
|
|
306
|
+
`displaced`).
|
|
307
|
+
- `diskBytes` is derived from `df`, not from the disk's own changed extents.
|
|
308
|
+
|
|
309
|
+
## Measured (2026-09-24): on a loaded box, so readings, not results
|
|
310
|
+
|
|
311
|
+
Surface: this package's own doors (`POST /machine/checkpoint`, `POST /machine/restore`,
|
|
312
|
+
`GET /machine/clock`) and the product CLI (`volter world branch --at`, `world log --json`) on a World
|
|
313
|
+
of the Stripe twin and this service. Apple M3 Pro, macOS 26.4; Lima 2.2.0 VM (`vz`, nested
|
|
314
|
+
virtualization, 4 CPU / 6 GiB, Ubuntu, kernel 7.0); Firecracker v1.17.0; CI guest kernel 6.18 and
|
|
315
|
+
Ubuntu 24.04 rootfs (2 vCPU / 1 GiB, 2 GiB ext4 disk); the store a 12 GiB loop-mounted XFS image.
|
|
316
|
+
**The Mac was not quiet during any reading**: load average 33-90 and 80,000-240,000 page
|
|
317
|
+
compressions per 2 s (`vm_stat`), from other work on the box. Every timing below is therefore a
|
|
318
|
+
reading under that load, not a result; the quiet-box numbers the acceptance asks for were not taken.
|
|
319
|
+
|
|
320
|
+
| What | Target | Read (loaded) |
|
|
321
|
+
|---|---|---|
|
|
322
|
+
| checkpoint pause | < 200 ms | 61-134 ms over six checkpoints (memory write 23-68 ms, disk clone 20-56 ms); 202-604 ms before the diff went to `/dev/shm` |
|
|
323
|
+
| after the pause | | materialize 95-186 ms |
|
|
324
|
+
| storage per checkpoint | ≈ changed disk + dirty memory | 11-13 MB of dirty memory per turn-sized checkpoint, 1.7-2.2 MB after 5-10 s idle; disk 140-200 KiB (store growth after syncfs; `filefrag` counted 284 KiB of changed disk blocks in one) |
|
|
325
|
+
| restore load | < 100 ms | `snapshot/load` 17-69 ms; the whole in-place replacement (process, disk clone, load) 67-250 ms |
|
|
326
|
+
| restore, answered | | 0.72-1.6 s; the guest's first reply after the load (memory faulted in from the file, through nested virtualization, on a compressing host) takes 0.2-1.7 s of it |
|
|
327
|
+
| guest clock after restore | within ~50 ms | -1.0 to -2.9 ms (World host to machine host round trip 4-14 ms bounds the offset to half of it) |
|
|
328
|
+
| a whole retake (`world branch --at` + restore) | < 1 s | 6.3 s: branch 4.9 s (the machine's start in it 1.1 s), restore 1.4 s; 8.5-10 s before the restore was trimmed |
|
|
329
|
+
|
|
330
|
+
A dev server in the guest (pid 364) came back from every restore with the same pid and startup nonce
|
|
331
|
+
and its in-memory notes as of the mark (`['1', '2', 'b1']` after notes `b2`, `b3` were written and a
|
|
332
|
+
retake went to `b1`), and `/work/proof` held the mark's rows. The World branch held the Stripe
|
|
333
|
+
customers up to the mark. The guest reached the twin only through its forwarder (`200`); an outside
|
|
334
|
+
address did not answer and the guest has no default route.
|
|
335
|
+
|
|
336
|
+
The lab's guest clock came back a constant 6 s short. `ssh guest date -s @$(date)` reads the time
|
|
337
|
+
before the ssh login, so the guest lands behind by the login's duration: here the same command left
|
|
338
|
+
the guest 1,534 ms behind with a 1.5 s login under load, where this service's step had read -1.4 ms
|
|
339
|
+
a moment before. (That the lab's 6 s was its login time is inferred from this, not measured there.)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# world-machine-agent: runs inside the guest, started once when the warm base is built, so it is in
|
|
2
|
+
# every snapshot. It answers the machine host on the guest's one link: it steps the wall clock to the
|
|
3
|
+
# instant it is sent (a restored guest wakes at its snapshot's wall time), resets the connections a restore
|
|
4
|
+
# left stale and writes the World's env file. One JSON object per line each way.
|
|
5
|
+
import json, os, socket, socketserver, struct, time
|
|
6
|
+
|
|
7
|
+
# After a restore every established TCP connection to the host on this link is stale by construction: its far
|
|
8
|
+
# end belongs to the host as it was at the snapshot (a server listening here keeps the mark's client attached, a
|
|
9
|
+
# client here waits out its own timeout). reset_stale resets each (the kernel's sock_destroy, what `ss -K` does:
|
|
10
|
+
# CONFIG_INET_DIAG_DESTROY), the agent's own connection excepted (its port serves one connection at a time), over
|
|
11
|
+
# sock_diag netlink in this process: measured, spawning `ss` from a just-restored guest took 1.0-2.3 s.
|
|
12
|
+
NETLINK_SOCK_DIAG, SOCK_DIAG_BY_FAMILY, SOCK_DESTROY = 4, 20, 21
|
|
13
|
+
NLM_F_REQUEST, NLM_F_ACK, NLM_F_DUMP, NLMSG_ERROR, NLMSG_DONE = 1, 4, 0x300, 2, 3
|
|
14
|
+
TCP_ESTABLISHED = 1
|
|
15
|
+
|
|
16
|
+
def _diag(family, states, sockid=b"\0" * 48):
|
|
17
|
+
return struct.pack("=BBBBI", family, socket.IPPROTO_TCP, 0, 0, states) + sockid
|
|
18
|
+
|
|
19
|
+
def _talk(nl, kind, flags, payload, seq):
|
|
20
|
+
nl.send(struct.pack("=IHHII", 16 + len(payload), kind, flags, seq, 0) + payload)
|
|
21
|
+
|
|
22
|
+
def reset_stale(host, own_port):
|
|
23
|
+
peer = socket.inet_aton(host)
|
|
24
|
+
nl = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_SOCK_DIAG)
|
|
25
|
+
try:
|
|
26
|
+
_talk(nl, SOCK_DIAG_BY_FAMILY, NLM_F_REQUEST | NLM_F_DUMP, _diag(socket.AF_INET, 1 << TCP_ESTABLISHED), 1)
|
|
27
|
+
stale, done = [], False
|
|
28
|
+
while not done:
|
|
29
|
+
data, off = nl.recv(65536), 0
|
|
30
|
+
while off + 16 <= len(data):
|
|
31
|
+
length, kind = struct.unpack_from("=IH", data, off)
|
|
32
|
+
if kind == NLMSG_DONE: done = True; break
|
|
33
|
+
if kind == NLMSG_ERROR: raise OSError(-struct.unpack_from("=i", data, off + 16)[0], "sock_diag dump failed")
|
|
34
|
+
sockid = data[off + 20:off + 68] # inet_diag_msg: family, state, timer, retrans, then the id
|
|
35
|
+
sport = struct.unpack_from("!H", sockid, 0)[0]
|
|
36
|
+
if sockid[20:24] == peer and sport != own_port: stale.append(sockid)
|
|
37
|
+
off += (length + 3) & ~3
|
|
38
|
+
for i, sockid in enumerate(stale):
|
|
39
|
+
_talk(nl, SOCK_DESTROY, NLM_F_REQUEST | NLM_F_ACK, _diag(socket.AF_INET, 1 << TCP_ESTABLISHED, sockid), 2 + i)
|
|
40
|
+
data = nl.recv(65536)
|
|
41
|
+
err = -struct.unpack_from("=i", data, 16)[0]
|
|
42
|
+
if err and err != 2: raise OSError(err, "sock_destroy: %s" % os.strerror(err)) # ENOENT: it closed meanwhile
|
|
43
|
+
return len(stale)
|
|
44
|
+
finally:
|
|
45
|
+
nl.close()
|
|
46
|
+
|
|
47
|
+
class Handler(socketserver.StreamRequestHandler):
|
|
48
|
+
def handle(self):
|
|
49
|
+
accepted = time.perf_counter()
|
|
50
|
+
setup_ms = round((accepted - Server.accepted_at) * 1000, 2) # from accept() returning to this handler running
|
|
51
|
+
for line in self.rfile:
|
|
52
|
+
got = time.perf_counter()
|
|
53
|
+
try:
|
|
54
|
+
req = json.loads(line)
|
|
55
|
+
op = req.get("op")
|
|
56
|
+
if op == "clock":
|
|
57
|
+
time.clock_settime(time.CLOCK_REALTIME, float(req["t"]))
|
|
58
|
+
res = {"t": time.time()}
|
|
59
|
+
elif op == "shift":
|
|
60
|
+
# moved by an amount against its own clock, read here: the step does not depend on when it arrives
|
|
61
|
+
time.clock_settime(time.CLOCK_REALTIME, time.time() + float(req["by"]))
|
|
62
|
+
res = {"t": time.time()}
|
|
63
|
+
elif op == "reset":
|
|
64
|
+
res = {"reset": reset_stale(self.client_address[0], self.server.server_address[1])}
|
|
65
|
+
elif op == "now":
|
|
66
|
+
res = {"t": time.time()}
|
|
67
|
+
elif op == "write":
|
|
68
|
+
path = req["path"]; tmp = path + ".tmp"
|
|
69
|
+
os.makedirs(os.path.dirname(path) or "/", exist_ok=True)
|
|
70
|
+
with open(tmp, "w") as f: f.write(req["content"])
|
|
71
|
+
os.replace(tmp, path)
|
|
72
|
+
res = {"ok": True}
|
|
73
|
+
else:
|
|
74
|
+
res = {"error": "unknown op %r" % op}
|
|
75
|
+
except Exception as e:
|
|
76
|
+
res = {"error": str(e)}
|
|
77
|
+
# where a reply's time goes inside the guest: waiting for the line since the connection was accepted, and
|
|
78
|
+
# handling it (a first request after a load faults this process's pages in from the snapshot file)
|
|
79
|
+
res["setupMs"] = setup_ms; setup_ms = 0.0
|
|
80
|
+
res["waitMs"] = round((got - accepted) * 1000, 2); res["handleMs"] = round((time.perf_counter() - got) * 1000, 2)
|
|
81
|
+
accepted = time.perf_counter()
|
|
82
|
+
self.wfile.write((json.dumps(res) + "\n").encode()); self.wfile.flush()
|
|
83
|
+
|
|
84
|
+
# One connection at a time, on the serving thread: the first request after a load pays for every guest
|
|
85
|
+
# page it touches (faulted in from the snapshot file), and a thread per connection touched more.
|
|
86
|
+
class Server(socketserver.TCPServer):
|
|
87
|
+
allow_reuse_address = True
|
|
88
|
+
accepted_at = 0.0
|
|
89
|
+
def get_request(self):
|
|
90
|
+
r = super().get_request(); Server.accepted_at = time.perf_counter(); return r
|
|
91
|
+
|
|
92
|
+
Server(("0.0.0.0", int(os.environ.get("WM_AGENT_PORT", "7070"))), Handler).serve_forever()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# The machine host's client for the guest agent (agent.py). Clock arguments are seconds:
|
|
2
|
+
# OFFSET is this host's clock minus the World host's clock, DELTA the World clock minus the World
|
|
3
|
+
# host's clock (0 unless the World's clock is frozen). The target instant is therefore
|
|
4
|
+
# time.time() - OFFSET + DELTA, read the moment before it is sent.
|
|
5
|
+
import json, socket, sys, time
|
|
6
|
+
|
|
7
|
+
ip, port, op = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
|
8
|
+
started = time.time()
|
|
9
|
+
deadline = time.time() + 3
|
|
10
|
+
while True:
|
|
11
|
+
try:
|
|
12
|
+
s = socket.create_connection((ip, port), timeout=1); break
|
|
13
|
+
except OSError as e:
|
|
14
|
+
if time.time() > deadline: print('@' + json.dumps({"error": "guest agent unreachable at %s:%d: %s" % (ip, port, e)})); sys.exit(1)
|
|
15
|
+
time.sleep(0.005)
|
|
16
|
+
# the first request after a load can wait on guest memory faulted in from the file
|
|
17
|
+
s.settimeout(10); f = s.makefile("rwb")
|
|
18
|
+
connectMs = round((time.time() - started) * 1000, 1)
|
|
19
|
+
|
|
20
|
+
def call(req):
|
|
21
|
+
f.write((json.dumps(req) + "\n").encode()); f.flush()
|
|
22
|
+
return json.loads(f.readline())
|
|
23
|
+
|
|
24
|
+
def probe(offset, delta, n=5):
|
|
25
|
+
best = None
|
|
26
|
+
for _ in range(n):
|
|
27
|
+
h0 = time.time(); g = call({"op": "now"})["t"]; h1 = time.time()
|
|
28
|
+
err = g - ((h0 + h1) / 2 - offset + delta)
|
|
29
|
+
if best is None or h1 - h0 < best[1]: best = (err, h1 - h0)
|
|
30
|
+
return best
|
|
31
|
+
|
|
32
|
+
def write_files(files, out):
|
|
33
|
+
for path, content in files.items():
|
|
34
|
+
res = call({"op": "write", "path": path, "content": content})
|
|
35
|
+
if "error" in res: out["error"] = "guest agent: writing %s: %s" % (path, res["error"]); return False
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
if op in ("clock", "now"):
|
|
39
|
+
offset, delta = float(sys.argv[4]), float(sys.argv[5])
|
|
40
|
+
out = {}
|
|
41
|
+
# files to write on the same connection: one path, its content on stdin, or `-` and a JSON object of path ->
|
|
42
|
+
# content on stdin
|
|
43
|
+
files = (json.load(sys.stdin) if sys.argv[6] == "-" else {sys.argv[6]: sys.stdin.read()}) if len(sys.argv) > 6 else {}
|
|
44
|
+
# the restore id first, the first request after the load: a restored connection the guest drops while the clock
|
|
45
|
+
# is stepped (a second or more under load) must already find the new id
|
|
46
|
+
rid = [k for k in files if k == "/run/world-machine/restore-id"]
|
|
47
|
+
if rid and not write_files({rid[0]: files.pop(rid[0])}, out): print('@' + json.dumps(out)); sys.exit(0)
|
|
48
|
+
if op == "clock":
|
|
49
|
+
# The first request after a load waits on the guest waking (measured: its own handling 11 ms of a 288 ms
|
|
50
|
+
# reply); it is only the warm-up.
|
|
51
|
+
t0 = time.time(); first = call({"op": "now"}); before = first["t"]
|
|
52
|
+
out["connectMs"] = connectMs; out["firstReplyMs"] = round((time.time() - t0) * 1000, 1)
|
|
53
|
+
out["firstHandleMs"] = first.get("handleMs"); out["firstWaitMs"] = first.get("waitMs"); out["firstSetupMs"] = first.get("setupMs")
|
|
54
|
+
# Measure the guest's clock against the target over warm round trips, then move it by the difference,
|
|
55
|
+
# relative to its own time (op shift): unlike stepping to an instant sent ahead, the step does not land late
|
|
56
|
+
# by however long the request takes to be read (the first requests after a load wait on guest pages).
|
|
57
|
+
steps = 0
|
|
58
|
+
while True:
|
|
59
|
+
err, rtt = probe(offset, delta, 3)
|
|
60
|
+
if abs(err) <= 0.003 or steps == 4: break
|
|
61
|
+
call({"op": "shift", "by": -err}); steps += 1
|
|
62
|
+
out["clockMs"] = round((time.time() - t0) * 1000, 1)
|
|
63
|
+
out["steps"] = steps
|
|
64
|
+
out["steppedByMs"] = round(((time.time() - offset + delta) - before) * 1000)
|
|
65
|
+
# the files before the reset: a program whose connection the reset ends reads the new restore id (and the
|
|
66
|
+
# World's env) when it reconnects
|
|
67
|
+
if not write_files(files, out): print('@' + json.dumps(out)); sys.exit(0)
|
|
68
|
+
files = {}
|
|
69
|
+
# then the connections the restore left stale, once the clock reads now (a reset peer reconnects at once)
|
|
70
|
+
r0 = time.time(); res = call({"op": "reset"})
|
|
71
|
+
if "error" in res: print('@' + json.dumps({"error": "guest agent: resetting the stale connections: " + res["error"]})); sys.exit(1)
|
|
72
|
+
out["staleReset"] = res["reset"]; out["resetMs"] = round((time.time() - r0) * 1000, 1)
|
|
73
|
+
err, rtt = probe(offset, delta)
|
|
74
|
+
out.update({"errorMs": round(err * 1000, 2), "probeRttMs": round(rtt * 1000, 2)})
|
|
75
|
+
write_files(files, out)
|
|
76
|
+
print('@' + json.dumps(out))
|
|
77
|
+
elif op == "write":
|
|
78
|
+
res = call({"op": "write", "path": sys.argv[4], "content": sys.stdin.read()})
|
|
79
|
+
print('@' + json.dumps(res))
|
|
80
|
+
else:
|
|
81
|
+
print('@' + json.dumps({"error": "unknown op " + op})); sys.exit(1)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MachineConfig } from './config.js';
|
|
2
|
+
import { type HostSession } from './host.js';
|
|
3
|
+
export type BaseStep = {
|
|
4
|
+
base: string;
|
|
5
|
+
stage: string;
|
|
6
|
+
ready: boolean;
|
|
7
|
+
detail?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function stepBase(config: MachineConfig, session: HostSession): Promise<BaseStep>;
|
package/dist/src/base.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The warm base: the declared guest image, cold-booted once, provisioned by the declared script, and
|
|
2
|
+
// snapshotted whole, in a store-wide cache keyed by everything that shapes it. A World's machine never
|
|
3
|
+
// cold-boots (20-60 s measured); it loads this or a snapshot. The build is a sequence of short steps,
|
|
4
|
+
// each run by one `world-machine base` call that advances as far as it can without waiting and says
|
|
5
|
+
// what it is waiting on; the caller runs it again.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { GUEST_AGENT, GUEST_PROFILE } from "./host.js";
|
|
8
|
+
import { baseHash, bootArgs, hp } from "./machine.js";
|
|
9
|
+
import { q } from "./host.js";
|
|
10
|
+
export async function stepBase(config, session) {
|
|
11
|
+
await session.call('wm_reflink_check');
|
|
12
|
+
const hash = await baseHash(config, session);
|
|
13
|
+
const base = `base-${hash}`;
|
|
14
|
+
const B = `"$WM_STORE/${base}"`;
|
|
15
|
+
const W = `"$WM_STORE/${base}.building"`;
|
|
16
|
+
const has = async (test) => (await session.call(`if ${test}; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok;
|
|
17
|
+
const provision = config.provision ? readFileSync(config.provision, 'utf8') : '';
|
|
18
|
+
if (await has(`[ -f ${B}/vmstate ]`))
|
|
19
|
+
return { base, stage: 'ready', ready: true };
|
|
20
|
+
// 1. the rootfs, copied once into the store (it may not be on a reflink filesystem), in the background
|
|
21
|
+
if (!(await has(`[ -f ${W}/copied ]`))) {
|
|
22
|
+
// a copy that is no longer running (the host restarted, the store was remounted) is started again
|
|
23
|
+
if (!(await has(`[ ! -f ${W}/copying ] || kill -0 "$(cat ${W}/copy.pid 2>/dev/null)" 2>/dev/null`)))
|
|
24
|
+
await session.call(`rm -f ${W}/copying ${W}/disk.ext4; echo '@{"ok":true}'`);
|
|
25
|
+
if (!(await has(`[ -f ${W}/copying ]`))) {
|
|
26
|
+
// a slot builds one base at a time: another base left half-built by this slot (its inputs moved on) is abandoned,
|
|
27
|
+
// and goes before this one starts; one with no slot recorded predates the record and is taken as this slot's
|
|
28
|
+
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" ] && rm -rf "$d"; done; echo '@{"ok":true}'`);
|
|
29
|
+
await session.call(`mkdir -p ${W} && printf '%s' "$WM_SLOT" > ${W}/slot && : > ${W}/copying && (setsid bash -c "echo \\$\\$ > ${W.replace(/"/g, '\\"')}/copy.pid; cp --sparse=always ${hp(config.rootfs).replace(/"/g, '\\"')} ${W.replace(/"/g, '\\"')}/disk.ext4 && : > ${W.replace(/"/g, '\\"')}/copied" > ${W}/copy.log 2>&1 < /dev/null &) ; echo '@{"ok":true}'`);
|
|
30
|
+
return { base, stage: 'copying the rootfs into the store', ready: false };
|
|
31
|
+
}
|
|
32
|
+
return { base, stage: 'copying the rootfs into the store', ready: false, detail: 'run again' };
|
|
33
|
+
}
|
|
34
|
+
// 2. a cold boot in the slot (the snapshot records the slot's disk path and tap)
|
|
35
|
+
if (!(await has(`[ -f ${W}/booted ]`))) {
|
|
36
|
+
await session.call(`wm_net >/dev/null && wm_boot ${hp(config.kernel)} ${W}/disk.ext4 ${config.vcpus} ${config.memMib} ${q(bootArgs(config))} && wm_own '{"token":"base-build"}' && : > ${W}/booted`);
|
|
37
|
+
return { base, stage: 'booting the guest', ready: false, detail: 'run again once it answers ssh' };
|
|
38
|
+
}
|
|
39
|
+
// 3. the guest agent and the provisioning script, started in the guest
|
|
40
|
+
if (!(await has(`[ -f ${W}/provisioning ]`))) {
|
|
41
|
+
if (!(await has('WM_GSSH_TIMEOUT=6 wm_gssh true')))
|
|
42
|
+
return { base, stage: 'waiting for the guest to answer ssh', ready: false };
|
|
43
|
+
await session.call([
|
|
44
|
+
`wm_gssh 'mkdir -p /usr/local/sbin && cat > /usr/local/sbin/world-machine-agent' <<'__WM_AGENT__'\n${GUEST_AGENT}\n__WM_AGENT__`,
|
|
45
|
+
`wm_gssh 'cat > /etc/profile.d/volter-world.sh' <<'__WM_PROFILE__'\n${GUEST_PROFILE}\n__WM_PROFILE__`,
|
|
46
|
+
`wm_gssh 'cat > /root/.world-machine-provision.sh' <<'__WM_PROVISION__'\n${provision}\n__WM_PROVISION__`,
|
|
47
|
+
`wm_gssh 'setsid nohup python3 /usr/local/sbin/world-machine-agent > /var/log/world-machine-agent.log 2>&1 < /dev/null & setsid nohup bash -c "bash /root/.world-machine-provision.sh > /root/.world-machine-provision.log 2>&1; echo \\$? > /root/.world-machine-provision.done" > /dev/null 2>&1 < /dev/null &' || return 1`,
|
|
48
|
+
`: > ${W}/provisioning; echo '@{"ok":true}'`,
|
|
49
|
+
].join('\n'));
|
|
50
|
+
return { base, stage: 'provisioning', ready: false, detail: 'run again' };
|
|
51
|
+
}
|
|
52
|
+
// 4. provisioning finished, and finished well
|
|
53
|
+
const done = await session.call(`printf '@{"code":"%s","log":%s}\\n' "$(wm_gssh 'cat /root/.world-machine-provision.done 2>/dev/null')" "$(wm_gssh 'tail -c 600 /root/.world-machine-provision.log 2>/dev/null' | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"`);
|
|
54
|
+
if (done.code === '')
|
|
55
|
+
return { base, stage: 'provisioning', ready: false, detail: done.log.trim().split('\n').pop() };
|
|
56
|
+
if (done.code.trim() !== '0')
|
|
57
|
+
throw new Error(`world-machine base: the provisioning script exited ${done.code.trim()}:\n${done.log}\n(remove ${config.store}/${base}.building to start over)`);
|
|
58
|
+
// 5. the Full snapshot: memory, device state and disk, then the slot is freed
|
|
59
|
+
const snap = await session.call(`wm_agent now 0 0 >/dev/null || return 1\nwm_snapshot ${W} Full || return 1\nwm_fc_stop; wm_release\nrm -f ${W}/copying ${W}/copied ${W}/booted ${W}/provisioning ${W}/copy.log\nmv ${W} ${B}`);
|
|
60
|
+
return { base, stage: 'ready', ready: true, detail: `full snapshot in ${snap.pauseMs} ms, ${Math.round(snap.memBytes / 1048576)} MiB of memory` };
|
|
61
|
+
}
|