@supermachine/core 0.5.3 → 0.7.1

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 CHANGED
@@ -182,6 +182,78 @@ const image = await Image.build({
182
182
 
183
183
  Host file contents are folded into the snapshot's input-hash, so editing the host file invalidates the cache and rebakes automatically.
184
184
 
185
+ ### Pattern 5: Live host directory mount (virtio-fs DAX)
186
+
187
+ For dev loops, code-mounted-from-host workflows, and "run my project" sandboxes, you want a *live* host directory inside the guest — edits on the host show up in the guest immediately, and the guest can read/write back. This is what `mounts` does: a virtio-fs share with DAX (zero-copy reads through the host page cache, kqueue-driven cache invalidation when host files change).
188
+
189
+ ```ts
190
+ const image = await Image.build({
191
+ ref: "node:20-alpine",
192
+ mounts: [
193
+ {
194
+ hostPath: "/Users/me/my-app", // host source tree
195
+ guestTag: "workspace", // guest mounts via this tag
196
+ symlinks: "opaque", // see below
197
+ },
198
+ ],
199
+ });
200
+
201
+ const vm = await (await image.pool()).acquire();
202
+ // Inside the guest:
203
+ // mount -t virtiofs workspace /work
204
+ // cd /work && npm install
205
+ ```
206
+
207
+ **`symlinks` policy** picks the trust posture for the mount. Workspace tools (npm workspaces, pnpm, yarn berry) symlink prolifically — `node_modules/<member> → ../packages/<member>` — so symlink creation has to work. But symlinks are also the classic escape vector if you're running untrusted code. The policy lets integrators pick:
208
+
209
+ | Value | Guest can create symlinks? | External host symlinks visible/traversable? | When |
210
+ |---|---|---|---|
211
+ | `"deny"` | no (`EPERM`) | no (`EACCES` at LOOKUP) | Paranoid mounts: pure file content, no metadata surprises |
212
+ | `"opaque"` *(default)* | yes — targets stored verbatim, host never resolves them | no | **Safe multi-tenant default**: npm/pnpm/yarn all work; a hostile guest can plant `escape → /etc/passwd` but the host never follows it |
213
+ | `"follow"` | yes | yes | Trusted single-tenant: dev trees that symlink into Homebrew/`~/.cache`/etc. |
214
+
215
+ The `"opaque"` default exists because of how `symlink(2)` works: the target is opaque bytes stored on the symlink inode — POSIX never validates it. Resolution happens *in the guest's kernel* against the guest's own namespace, so a target like `/etc/passwd` resolves to the guest's `/etc/passwd`, not the host's. The host serves `readlink()` (returns those bytes) and refuses to follow them. That's the security model: guest can store arbitrary bytes, host never canonicalizes guest-supplied paths.
216
+
217
+ A runnable example is in [`examples/06-mount-workspace.mjs`](./examples/06-mount-workspace.mjs).
218
+
219
+ #### Performance: don't put dependency caches on the mount
220
+
221
+ A mount is great for source code (host-editable, DAX-fast reads). It is **the wrong place** for `node_modules`, `__pycache__`, `target/`, `.next/`, or anything else that gets thousands of small files written by a build/install step.
222
+
223
+ Why: every guest file operation on a virtio-fs mount becomes a FUSE message round-trip over vsock — open, write, close, fsync each cost ~µs of in-guest overhead plus ~µs of host-side handling. For one big file (a tarball, an image), that overhead is invisible. For an `npm install` that creates ~10–30 k tiny files, you're paying milliseconds × tens of thousands of round-trips. We've measured the same `npm install` at **3–6 minutes** on a virtio-fs mount vs **10–30 seconds** on a virtio-blk volume backing `node_modules`. Same workload, same data, same VM — the difference is purely FUSE round-trip cost × small-file count.
224
+
225
+ Block devices (`virtio-blk`, exposed via `volumes`) bypass FUSE entirely — the guest's own filesystem driver writes to a backing file on the host with no per-syscall protocol overhead. Same speed as a "normal" disk inside the guest.
226
+
227
+ ### Pattern 6: npm / pnpm / cargo workflow (mount for source, volume for cache)
228
+
229
+ The shape that gets you both live host edits and fast installs:
230
+
231
+ ```ts
232
+ const image = await Image.build({
233
+ ref: "node:20-alpine",
234
+ // Source tree — live host edits, DAX reads
235
+ mounts: [{ hostPath: "/Users/me/my-app", guestTag: "src", symlinks: "opaque" }],
236
+ // Dependency cache — fast writes, persists between bakes via the snapshot
237
+ volumes: [
238
+ { name: "node_modules", sizeMib: 1024, mountPoint: "/work/node_modules" },
239
+ ],
240
+ // One-time install at bake. Once snapshotted, every restore has
241
+ // node_modules already populated — no install on the hot path.
242
+ warmup: async (vm) => {
243
+ await vm.exec({ argv: ["mount", "-t", "virtiofs", "src", "/work"] });
244
+ await vm.exec({ argv: ["cp", "-r", "/work/package*.json", "/work/node_modules/.."] });
245
+ await vm.exec({ argv: ["sh", "-c", "cd /work && npm install"], timeoutMs: 600_000 });
246
+ },
247
+ });
248
+ ```
249
+
250
+ Three layers, each playing to their strengths:
251
+ - **`mounts`** for source: read-only-ish on the hot path, DAX zero-copy, host edits show up immediately (kqueue invalidation).
252
+ - **`volumes`** for write-heavy caches: native-speed writes, no FUSE overhead, persists across restores.
253
+ - **`warmup`** + bake to do the install once: every restore reuses the baked node_modules; cold install is amortized across all future runs of this snapshot.
254
+
255
+ A runnable example is in [`examples/07-npm-workspace-fast.mjs`](./examples/07-npm-workspace-fast.mjs).
256
+
185
257
  ---
186
258
 
187
259
  ## Full API
@@ -217,6 +289,7 @@ class Image {
217
289
  | `guestPort` | `number` | 80 | Listener port (used with `listenerRequired`) |
218
290
  | `listenerRequired` | `boolean` | `false` | Wait for workload's listener before snapshotting |
219
291
  | `extraFiles` | `ExtraFile[]` | — | Stage host files into guest at fixed paths |
292
+ | `mounts` | `MountSpec[]` | — | Live host dir mounts via virtio-fs DAX. See Pattern 5. |
220
293
  | `warmupTag` | `string` | `"default"` | Cache key for the warmup; bump to invalidate |
221
294
  | `warmup` | `(vm: WarmupVm) => Promise<void>` | — | Post-bake state pre-population |
222
295
  | `snapshotsDir` | `string` | `~/.local/supermachine-snapshots` | Where to write the snapshot |
@@ -0,0 +1,97 @@
1
+ // 06-mount-workspace.mjs — mount a host directory into the guest via
2
+ // virtio-fs DAX, then run an `npm install`-style symlink workflow
3
+ // from inside the VM.
4
+ //
5
+ // Demonstrates:
6
+ // - `mounts: [{ hostPath, guestTag, symlinks }]` on Image.build
7
+ // - The per-mount `symlinks` policy: "deny" | "opaque" | "follow"
8
+ // - The npm-workspace symlink pattern (node_modules/<name> →
9
+ // ../packages/<name>) working end-to-end through virtio-fs
10
+ //
11
+ // The mount surfaces host files in the guest with zero-copy DAX
12
+ // reads and kqueue-driven cache invalidation when the host file
13
+ // changes — edit a file on the host, the guest sees the new bytes
14
+ // on the next read with no manual re-mount.
15
+ //
16
+ // `symlinks: "opaque"` is the safe default for guest-writable mounts
17
+ // in a multi-tenant setting: guest can create symlinks (workspace
18
+ // tools need this), targets are stored verbatim (POSIX symlink(2)),
19
+ // and the host never resolves them. A guest planting
20
+ // `escape -> /etc/passwd` gets a symlink whose readlink returns
21
+ // "/etc/passwd" bytes — but the host won't follow it. Path
22
+ // resolution happens in the guest's kernel against the guest's
23
+ // own namespace.
24
+ //
25
+ // Run:
26
+ // node examples/06-mount-workspace.mjs
27
+
28
+ import { Image } from "@supermachine/core";
29
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readlinkSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { join } from "node:path";
32
+
33
+ // 1. Build a host-side workspace tree that mimics what npm creates:
34
+ // project/
35
+ // packages/alpha/index.js "ALPHA"
36
+ // packages/beta/index.js "BETA"
37
+ // Inside the guest we'll add node_modules/<name> symlinks
38
+ // pointing at ../packages/<name>.
39
+ const project = mkdtempSync(join(tmpdir(), "sm-workspace-"));
40
+ mkdirSync(join(project, "packages", "alpha"), { recursive: true });
41
+ mkdirSync(join(project, "packages", "beta"), { recursive: true });
42
+ writeFileSync(join(project, "packages", "alpha", "index.js"), "ALPHA");
43
+ writeFileSync(join(project, "packages", "beta", "index.js"), "BETA");
44
+
45
+ // 2. Bake an image with the project mounted at guest tag "workspace".
46
+ // `symlinks: "opaque"` is the default but spelled out here for
47
+ // documentation value.
48
+ const img = await Image.build({
49
+ ref: "alpine:latest",
50
+ name: "mount-workspace-demo",
51
+ memoryMib: 256,
52
+ cmd: ["/bin/sh", "-c", "sleep infinity"],
53
+ mounts: [
54
+ {
55
+ hostPath: project,
56
+ guestTag: "workspace",
57
+ symlinks: "opaque",
58
+ },
59
+ ],
60
+ });
61
+
62
+ const pool = await img.pool({ min: 1, max: 1, restoreOnRelease: false });
63
+ const vm = await pool.acquire();
64
+
65
+ // 3. Inside the guest: mount the virtio-fs share, create the
66
+ // workspace symlinks, resolve through them.
67
+ const result = await vm.exec({
68
+ argv: [
69
+ "sh",
70
+ "-c",
71
+ [
72
+ "mkdir -p /work && mount -t virtiofs workspace /work",
73
+ "mkdir -p /work/node_modules",
74
+ "ln -s ../packages/alpha /work/node_modules/alpha",
75
+ "ln -s ../packages/beta /work/node_modules/beta",
76
+ "echo === readlink ===",
77
+ "readlink /work/node_modules/alpha",
78
+ "readlink /work/node_modules/beta",
79
+ "echo === resolve through ===",
80
+ "cat /work/node_modules/alpha/index.js",
81
+ "cat /work/node_modules/beta/index.js",
82
+ ].join(" && "),
83
+ ],
84
+ timeoutMs: 10_000,
85
+ });
86
+ console.log(result.stdout.toString());
87
+
88
+ // 4. Host-side: confirm the symlinks landed on disk as real
89
+ // symlinks (not regular files), with the bytes the guest asked
90
+ // for. This is the "store-target-verbatim" guarantee in action.
91
+ console.log("=== host-side symlink targets ===");
92
+ console.log("alpha →", readlinkSync(join(project, "node_modules", "alpha")));
93
+ console.log("beta →", readlinkSync(join(project, "node_modules", "beta")));
94
+
95
+ await vm.release();
96
+ await pool.shutdown();
97
+ rmSync(project, { recursive: true, force: true });
@@ -0,0 +1,115 @@
1
+ // 07-npm-workspace-fast.mjs — the integrator-recommended pattern
2
+ // for "edit on host, run in VM, install dependencies once."
3
+ //
4
+ // Three layers, each playing to its strength:
5
+ //
6
+ // 1. virtio-fs MOUNT for the source tree — host edits are live
7
+ // in the guest (DAX-fast reads, kqueue-driven invalidation),
8
+ // `symlinks: 'opaque'` so npm workspace symlinks work without
9
+ // letting the host follow guest-supplied targets.
10
+ //
11
+ // 2. virtio-blk VOLUME for `node_modules` — block I/O is
12
+ // ~native disk speed; bypasses the FUSE per-file round-trip
13
+ // cost that makes `npm install` 3-6 min on a virtio-fs mount.
14
+ // Same install on a volume: ~10-30 s.
15
+ //
16
+ // 3. WARMUP runs `npm install` once at bake time. The result
17
+ // (node_modules populated on the volume) persists across
18
+ // restores — every subsequent restore has dependencies ready,
19
+ // no install on the hot path.
20
+ //
21
+ // Run:
22
+ // node examples/07-npm-workspace-fast.mjs
23
+
24
+ import { Image } from "@supermachine/core";
25
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+
29
+ // 1. Build a host-side project that mimics a tiny npm workspace.
30
+ const project = mkdtempSync(join(tmpdir(), "sm-fast-"));
31
+ mkdirSync(project, { recursive: true });
32
+ writeFileSync(
33
+ join(project, "package.json"),
34
+ JSON.stringify(
35
+ {
36
+ name: "fast-demo",
37
+ version: "0.0.0",
38
+ dependencies: { "lodash.chunk": "^4.2.0" },
39
+ },
40
+ null,
41
+ 2,
42
+ ),
43
+ );
44
+
45
+ // 2. Volume backing file — sparse, 1 GiB cap. Sits alongside the
46
+ // project for the demo; in production you'd put it under
47
+ // ~/.local/supermachine/volumes/ or wherever your tenancy boundary
48
+ // lives.
49
+ const volumePath = join(project, "node_modules.img");
50
+
51
+ // 3. Bake with a warmup that runs `npm install` once on the volume.
52
+ // The snapshot captures the *post-install* state; every restore
53
+ // has node_modules ready instantly.
54
+ const t0 = Date.now();
55
+ const img = await Image.build({
56
+ ref: "node:20-alpine",
57
+ name: "npm-workspace-fast-demo",
58
+ memoryMib: 512,
59
+ cmd: ["/bin/sh", "-c", "sleep infinity"],
60
+ mounts: [
61
+ { hostPath: project, guestTag: "src", symlinks: "opaque" },
62
+ ],
63
+ volumes: [
64
+ { hostPath: volumePath, guestPath: "/work/node_modules", sizeMib: 1024 },
65
+ ],
66
+ warmup: async (vm) => {
67
+ // Mount source RO-ish + format and mount the node_modules volume.
68
+ await vm.exec({
69
+ argv: [
70
+ "sh",
71
+ "-c",
72
+ [
73
+ "mkdir -p /src /work/node_modules",
74
+ "mount -t virtiofs src /src",
75
+ // The volume comes up as the next /dev/vdN; on first bake
76
+ // it's blank, format ext4. Idempotent: existing fs is left
77
+ // alone by mke2fs -F? No — use `blkid` to skip if already
78
+ // formatted. For a demo, the bake-time guest always sees
79
+ // an empty volume.
80
+ "mkfs.ext4 -F -q /dev/vdb",
81
+ "mount /dev/vdb /work/node_modules",
82
+ // Copy package*.json out of the read-mount into the
83
+ // volume-backed dir so `npm install` writes locally.
84
+ "cp /src/package.json /work/package.json",
85
+ "cd /work && npm install --silent --no-fund --no-audit",
86
+ ].join(" && "),
87
+ ],
88
+ timeoutMs: 120_000,
89
+ });
90
+ },
91
+ warmupTag: "v1",
92
+ });
93
+ console.log(`[fast] bake total: ${Date.now() - t0} ms`);
94
+
95
+ // 4. Run a workload: every restore lands with node_modules already
96
+ // populated. Re-mount mounts and continue.
97
+ const pool = await img.pool({ min: 1, max: 1, restoreOnRelease: false });
98
+ const vm = await pool.acquire();
99
+ const r = await vm.exec({
100
+ argv: [
101
+ "sh",
102
+ "-c",
103
+ [
104
+ "mount -t virtiofs src /src 2>/dev/null || true",
105
+ "mount /dev/vdb /work/node_modules 2>/dev/null || true",
106
+ "cd /work && node -e \"console.log(require('lodash.chunk')([1,2,3,4,5], 2))\"",
107
+ ].join("; "),
108
+ ],
109
+ timeoutMs: 10_000,
110
+ });
111
+ console.log(r.stdout.toString());
112
+
113
+ await vm.release();
114
+ await pool.shutdown();
115
+ rmSync(project, { recursive: true, force: true });
@@ -11,6 +11,8 @@ with the package installed.
11
11
  | `03-snapshot-then-restore.mjs` | `vm.writeFile` / `readFile`, mid-flight `vm.snapshot()`, `Image.fromSnapshot` |
12
12
  | `04-expose-tcp.mjs` | `listenerRequired: true` bakes, `vm.exposeTcp()`, `TcpForwarder` |
13
13
  | `05-spawn-streaming.mjs` | `vm.spawn()` → `ExecChild` (writeStdin / readStdout / wait) |
14
+ | `06-mount-workspace.mjs` | `mounts` + `symlinks` policy: live host dir into guest, npm-workspace symlink pattern |
15
+ | `07-npm-workspace-fast.mjs` | The recommended npm/pnpm/cargo pattern: virtio-fs mount for source + virtio-blk volume for node_modules + warmup-bake for one-time install |
14
16
 
15
17
  ## Requirements
16
18
 
@@ -33,7 +35,7 @@ node --input-type=module -e \
33
35
  "$(sed 's|@supermachine/core|./index.js|' examples/01-hello.mjs)"
34
36
  ```
35
37
 
36
- For a quick smoke test that all five work end-to-end:
38
+ For a quick smoke test that all examples work end-to-end:
37
39
 
38
40
  ```sh
39
41
  for f in examples/*.mjs; do
package/index.d.ts CHANGED
@@ -39,6 +39,49 @@ export interface MountSpec {
39
39
  hostPath: string
40
40
  /** virtiofs tag the guest mounts by. Max 35 bytes. */
41
41
  guestTag: string
42
+ /**
43
+ * Symlink policy: `"deny"` | `"opaque"` | `"follow"`. Default
44
+ * `"opaque"` — guest may create symlinks (npm/pnpm/yarn rely on
45
+ * them) but host-side symlinks pointing outside the mount root
46
+ * are rejected. Use `"deny"` for paranoid mounts (no symlinks or
47
+ * hard links at all) or `"follow"` for trusted single-tenant
48
+ * workloads (pre-0.5.5 `allowExternalSymlinks: true` behaviour).
49
+ */
50
+ symlinks?: string
51
+ }
52
+
53
+ /**
54
+ * A writable virtio-blk volume backed by a host file. Use for
55
+ * dependency caches (`node_modules`, `target/`, `__pycache__`,
56
+ * `.next/`) and other write-heavy paths — block I/O is ~native
57
+ * disk speed, no FUSE round-trip per file. Contrast with
58
+ * [`MountSpec`] which exposes a host directory via virtio-fs
59
+ * (right for source trees you want live-editable from the host,
60
+ * wrong for thousands-of-small-files write workloads).
61
+ *
62
+ * The host file is created sparse at `hostPath` if missing
63
+ * (actual disk usage tracks what the guest writes). Snapshots
64
+ * capture the mapping, not the volume contents — same backing
65
+ * file is reused across all restores of this snapshot.
66
+ */
67
+ export interface VolumeSpec {
68
+ /**
69
+ * Host file backing this volume. Created sparse at `sizeMib`
70
+ * MiB if missing. Persists across restores; delete it to
71
+ * reset state.
72
+ */
73
+ hostPath: string
74
+ /**
75
+ * Mount point inside the guest, e.g. `"/var/lib/postgres"`
76
+ * or `"/work/node_modules"`. The guest needs to actually
77
+ * mount it (init can do this, or your warmup callback).
78
+ */
79
+ guestPath: string
80
+ /**
81
+ * Size cap in MiB. Default 1024 (1 GiB). Sparse — actual
82
+ * disk usage matches what the guest writes.
83
+ */
84
+ sizeMib?: number
42
85
  }
43
86
 
44
87
  /**
@@ -105,6 +148,25 @@ export interface BuildOptions {
105
148
  * re-bakes.
106
149
  */
107
150
  mounts?: Array<MountSpec>
151
+ /**
152
+ * Writable virtio-blk volumes. Each entry maps a sparse host
153
+ * file to a guest mount point. Use for dependency caches
154
+ * (`node_modules`, `target/`) — block I/O is ~native disk
155
+ * speed and bypasses the FUSE round-trip cost that makes
156
+ * `mounts` slow for thousands-of-small-files workloads.
157
+ * Volume bytes persist across restores; only the mapping is
158
+ * captured in the snapshot.
159
+ */
160
+ volumes?: Array<VolumeSpec>
161
+ /**
162
+ * Extra kernel cmdline tokens, appended after supermachine's
163
+ * own defaults (earlycon, console, tsi_hijack, etc.). Power-
164
+ * user / testing escape hatch — e.g. `["panic=1"]` to force
165
+ * an immediate panic on warning, or `["init=/nope"]` to
166
+ * deliberately trigger an early-init kernel panic in a test.
167
+ * Folded into the bake's input hash so changing this re-bakes.
168
+ */
169
+ extraKernelArgs?: Array<string>
108
170
  /**
109
171
  * Override where to fetch the image bytes. Default behavior
110
172
  * (when omitted) treats `ref` as a registry reference and pulls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supermachine/core",
3
- "version": "0.5.3",
3
+ "version": "0.7.1",
4
4
  "description": "Run any OCI/Docker image as a hardware-isolated microVM. Node/Bun/Deno binding for the supermachine Rust crate.",
5
5
  "license": "Apache-2.0",
6
6
  "main": "index.js",
@@ -22,7 +22,7 @@
22
22
  "arm64"
23
23
  ],
24
24
  "optionalDependencies": {
25
- "@supermachine/core-darwin-arm64": "0.5.3"
25
+ "@supermachine/core-darwin-arm64": "0.7.1"
26
26
  },
27
27
  "scripts": {
28
28
  "build:rust": "TYPE_DEF_TMP_PATH=$(pwd)/scripts/.napi_type_defs.tmp cargo build --release -p supermachine-napi && cargo build --release --bin supermachine-worker -p supermachine",