@openstrap/utm 0.0.0-stage → 0.1.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.
Files changed (3) hide show
  1. package/README.md +38 -2
  2. package/dist/index.js +823 -0
  3. package/package.json +30 -4
package/README.md CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # @openstrap/utm
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ UTM provider plugin for OpenStrap. Creates and drives the lifecycle of virtual machines on macOS.
4
+
5
+ ## Status
6
+
7
+ Working. `provider-contract.live.ts` creates an Ubuntu 24.04 machine on real UTM, boots it and reaches sshd through a forwarded port. The provider was written against what UTM does rather than what its dictionary declares — the dictionary is accurate about what exists and silent about three things that decide whether any of it works.
8
+
9
+ ```bash
10
+ npm run test:live
11
+ ```
12
+
13
+ Needs UTM installed; downloads about 600 MB the first time. There is no fake provider and there will not be one.
14
+
15
+ ## What UTM actually supports
16
+
17
+ Verified by hand against UTM 4.7.5 (`com.utmapp.UTM`, direct download, sandboxed) on Apple Silicon. Full record: `docs/wiki/grill/utm-scripting-probe.md` in the openstrap repository.
18
+
19
+ The provider interface the milestone describes stands: disks, network and port forwarding are all configurable through scripting.
20
+
21
+ - `utmctl` has no `create`. Machines are created with the AppleScript `make` command.
22
+ - `make` accepts the whole scalar configuration in one call: `name`, `architecture`, `memory`, `cpu cores`, `uefi`, `hypervisor`.
23
+ - `update configuration` sets collections too — both `drives` and `network interfaces`.
24
+ - Attaching a disk works. UTM imports the file into the `.utm` package and converts it to qcow2.
25
+ - Port forwarding works, in `mode: emulated`. It does not exist in `shared`, which is the default after `make`.
26
+
27
+ ## Three constraints that only an experiment reveals
28
+
29
+ 1. **Enumerators do not resolve by name.** `protocol:TCP` fails with `-1700`; only the raw code works — `«constant ****TcPp»` for TCP, `«constant ****UdPp»` for UDP. JXA does not help. The provider must build AppleScript with raw constants taken from `UTM.sdef`.
30
+
31
+ 2. **The image file must live inside the UTM sandbox container.** A source under `$HOME` or `/private/tmp` fails with "the file couldn't be opened because it doesn't exist" on a file that plainly exists. From `~/Library/Containers/com.utmapp.UTM/Data/Documents/` it attaches. The image cache therefore lives inside that container.
32
+
33
+ 3. **No mutating command works while the application has no open window** — scripting answers `UTM is not ready to accept commands (-2700)` even though reads succeed. `open -a UTM` clears it.
34
+
35
+ `qemu additional arguments` is accepted silently and applied not at all, then errors on read. Nothing may be built on it — but nothing needs to be.
36
+
37
+ ## Consequences for the design
38
+
39
+ The guest is reached through a forwarded port on `127.0.0.1`, so the endpoint is stable and openstrap reserves the host port itself. See ADR 0006 in the openstrap repository. No QEMU guest agent is required for access, so waiting means waiting for sshd, not for an agent.
package/dist/index.js ADDED
@@ -0,0 +1,823 @@
1
+ import { createRequire as __openstrapRequire } from 'node:module';
2
+ import { fileURLToPath as __openstrapPath } from 'node:url';
3
+ import { dirname as __openstrapDirname } from 'node:path';
4
+ const require = __openstrapRequire(import.meta.url);
5
+ const __filename = __openstrapPath(import.meta.url);
6
+ const __dirname = __openstrapDirname(__filename);
7
+
8
+ // src/UTM.ts
9
+ import { homedir } from "node:os";
10
+ import { join as join4 } from "node:path";
11
+
12
+ // src/images/HostFiles.ts
13
+ import { createHash } from "node:crypto";
14
+ import { createWriteStream } from "node:fs";
15
+ import { mkdir, rename, rm, stat } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { Readable } from "node:stream";
18
+ import { pipeline } from "node:stream/promises";
19
+ var HostFiles = class {
20
+ joinPath(...parts) {
21
+ return join(...parts);
22
+ }
23
+ async createDirectory(path) {
24
+ await mkdir(path, { recursive: true });
25
+ }
26
+ async movePath(from, to) {
27
+ await rename(from, to);
28
+ }
29
+ /** The permissions of a file, or null when there is no such file — which is how presence is asked. */
30
+ async mode(path) {
31
+ try {
32
+ return (await stat(path)).mode & 511;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ async removePath(path, options) {
38
+ await rm(path, { force: options?.force ?? false, recursive: true });
39
+ }
40
+ };
41
+ var HostNetwork = class {
42
+ async download(url, destination) {
43
+ const response = await fetch(url);
44
+ if (!response.ok || !response.body) {
45
+ throw new Error(`Download of ${url} failed with status ${response.status}`);
46
+ }
47
+ const digest = createHash("sha256");
48
+ let bytes = 0;
49
+ const counted = Readable.fromWeb(response.body);
50
+ counted.on("data", (chunk) => {
51
+ bytes += chunk.length;
52
+ digest.update(chunk);
53
+ });
54
+ await pipeline(counted, createWriteStream(destination));
55
+ return { bytes, sha256: digest.digest("hex") };
56
+ }
57
+ };
58
+
59
+ // src/images/BoxArchive.ts
60
+ import { execFile } from "node:child_process";
61
+ import { mkdir as mkdir2, open, readdir, rename as rename2, rm as rm2 } from "node:fs/promises";
62
+ import { join as join2 } from "node:path";
63
+ import { promisify } from "node:util";
64
+ var run = promisify(execFile);
65
+ var qcow2 = Buffer.from([81, 70, 73, 251]);
66
+ var BoxDiskMissingError = class extends Error {
67
+ constructor(box, contents) {
68
+ super(
69
+ `${box} holds no disk image UTM can boot. It contains: ${contents.join(", ")}. A box openstrap can use has a qcow2 disk in it.`
70
+ );
71
+ this.name = "BoxDiskMissingError";
72
+ }
73
+ };
74
+ var BoxArchive = class _BoxArchive {
75
+ constructor(tar = "tar") {
76
+ this.tar = tar;
77
+ }
78
+ tar;
79
+ /** Puts the box's disk at `destination`, leaving nothing else of the archive behind. */
80
+ async diskFrom(box, destination) {
81
+ const unpacked = `${destination}.unpacking`;
82
+ await rm2(unpacked, { recursive: true, force: true });
83
+ await mkdir2(unpacked, { recursive: true });
84
+ try {
85
+ await run(this.tar, ["-xf", box, "-C", unpacked]);
86
+ const disk = await _BoxArchive.diskIn(unpacked);
87
+ if (!disk) {
88
+ throw new BoxDiskMissingError(box, await readdir(unpacked));
89
+ }
90
+ await rename2(join2(unpacked, disk), destination);
91
+ } finally {
92
+ await rm2(unpacked, { recursive: true, force: true });
93
+ }
94
+ }
95
+ static async diskIn(directory) {
96
+ for (const name of await readdir(directory)) {
97
+ const handle = await open(join2(directory, name), "r");
98
+ try {
99
+ const head = Buffer.alloc(qcow2.length);
100
+ const { bytesRead } = await handle.read(head, 0, head.length, 0);
101
+ if (bytesRead === head.length && head.equals(qcow2)) {
102
+ return name;
103
+ }
104
+ } finally {
105
+ await handle.close();
106
+ }
107
+ }
108
+ return void 0;
109
+ }
110
+ };
111
+
112
+ // src/images/ImageCache.ts
113
+ import { createHash as createHash2 } from "node:crypto";
114
+ var ImageChecksumError = class extends Error {
115
+ constructor(url, expected, actual) {
116
+ super(`${url} does not match the checksum it was published with. Expected ${expected}, got ${actual}`);
117
+ this.name = "ImageChecksumError";
118
+ }
119
+ };
120
+ var ImageCache = class _ImageCache {
121
+ constructor(directory, fileSystem, network, boxes = new BoxArchive()) {
122
+ this.directory = directory;
123
+ this.fileSystem = fileSystem;
124
+ this.network = network;
125
+ this.boxes = boxes;
126
+ }
127
+ directory;
128
+ fileSystem;
129
+ network;
130
+ boxes;
131
+ /**
132
+ * The disk on disk, downloaded once and checked against what it was published as.
133
+ *
134
+ * The checksum is openstrap's — the pin, when this target has one — and a file that does not
135
+ * match it is deleted rather than booted. No signature is checked: what is being trusted is that
136
+ * these are the bytes published under this name, which is what a checksum served beside them says.
137
+ */
138
+ async fetch(image) {
139
+ const held = image.sha256 ?? _ImageCache.digestOf(image.url);
140
+ const disk = this.fileSystem.joinPath(this.directory, `${held}.qcow2`);
141
+ if (await this.fileSystem.mode(disk) !== null) {
142
+ return { path: disk, sha256: image.sha256, bytes: 0, reused: true };
143
+ }
144
+ await this.fileSystem.createDirectory(this.directory);
145
+ const downloaded = this.fileSystem.joinPath(this.directory, `${held}.download`);
146
+ const download = await this.network.download(image.url, downloaded);
147
+ if (image.sha256 !== void 0 && download.sha256 !== image.sha256) {
148
+ await this.fileSystem.removePath(downloaded, { force: true });
149
+ throw new ImageChecksumError(image.url, image.sha256, download.sha256);
150
+ }
151
+ try {
152
+ await this.unpack(image.format, downloaded, disk);
153
+ } finally {
154
+ await this.fileSystem.removePath(downloaded, { force: true });
155
+ }
156
+ return { path: disk, sha256: download.sha256, bytes: download.bytes, reused: false };
157
+ }
158
+ /** A name for a file nobody published a checksum for, made from where it came from. */
159
+ static digestOf(url) {
160
+ return createHash2("sha256").update(url).digest("hex");
161
+ }
162
+ /**
163
+ * What was downloaded, as a disk.
164
+ *
165
+ * A box is an archive with the disk inside it; anything else is taken to be the disk, because a
166
+ * format this does not recognise is either already one or something UTM will refuse by itself,
167
+ * and refusing here as well would only be a second opinion about a file nobody has looked at.
168
+ */
169
+ async unpack(format, downloaded, disk) {
170
+ if (format === "vagrant-box") {
171
+ await this.boxes.diskFrom(downloaded, disk);
172
+ return;
173
+ }
174
+ await this.fileSystem.movePath(downloaded, disk);
175
+ }
176
+ };
177
+
178
+ // src/seed/CloudInitSeed.ts
179
+ import { execFile as execFile2 } from "node:child_process";
180
+ import { mkdtemp, rm as rm3, writeFile } from "node:fs/promises";
181
+ import { tmpdir } from "node:os";
182
+ import { join as join3 } from "node:path";
183
+ import { promisify as promisify2 } from "node:util";
184
+ var run2 = promisify2(execFile2);
185
+ var CloudInitSeed = class {
186
+ async build(request) {
187
+ if (request.publicKey.includes("PRIVATE KEY")) {
188
+ throw new Error("Refusing to write a private key into a cloud-init seed");
189
+ }
190
+ const staging = await mkdtemp(join3(tmpdir(), "openstrap-seed-"));
191
+ try {
192
+ await writeFile(join3(staging, "meta-data"), this.metaData(request), "utf8");
193
+ await writeFile(join3(staging, "user-data"), this.userData(request), "utf8");
194
+ await rm3(request.destination, { force: true });
195
+ await run2("hdiutil", [
196
+ "makehybrid",
197
+ "-iso",
198
+ "-joliet",
199
+ "-default-volume-name",
200
+ "cidata",
201
+ "-o",
202
+ request.destination,
203
+ staging
204
+ ]);
205
+ return request.destination;
206
+ } finally {
207
+ await rm3(staging, { force: true, recursive: true });
208
+ }
209
+ }
210
+ metaData(request) {
211
+ return [
212
+ `instance-id: ${request.name}`,
213
+ `local-hostname: ${request.name}`,
214
+ ""
215
+ ].join("\n");
216
+ }
217
+ userData(request) {
218
+ return [
219
+ "#cloud-config",
220
+ "users:",
221
+ ` - name: ${request.user}`,
222
+ " sudo: 'ALL=(ALL) NOPASSWD:ALL'",
223
+ " shell: /bin/bash",
224
+ " lock_passwd: true",
225
+ " ssh_authorized_keys:",
226
+ ` - ${request.publicKey.trim()}`,
227
+ "disable_root: true",
228
+ "ssh_pwauth: false",
229
+ "write_files:",
230
+ " - path: /etc/ssh/sshd_config.d/60-openstrap.conf",
231
+ " permissions: '0644'",
232
+ " content: |",
233
+ " PasswordAuthentication no",
234
+ " KbdInteractiveAuthentication no",
235
+ " PermitRootLogin no",
236
+ ""
237
+ ].join("\n");
238
+ }
239
+ };
240
+
241
+ // src/machine/MachineDidNotStartError.ts
242
+ var MachineDidNotStartError = class extends Error {
243
+ constructor(name, status) {
244
+ super(
245
+ `${name} was told to start and is ${status}. UTM refused to launch it; its own window or \`utmctl start ${name}\` says why.`
246
+ );
247
+ this.name = "MachineDidNotStartError";
248
+ }
249
+ };
250
+
251
+ // src/machine/MachineDisk.ts
252
+ import { copyFile, rm as rm4, stat as stat2 } from "node:fs/promises";
253
+ import { execFile as execFile3 } from "node:child_process";
254
+ import { promisify as promisify3 } from "node:util";
255
+ var run3 = promisify3(execFile3);
256
+ var MachineDisk = class {
257
+ constructor(qemuImg = "qemu-img") {
258
+ this.qemuImg = qemuImg;
259
+ }
260
+ qemuImg;
261
+ /**
262
+ * A disk for this machine, `bytes` in size, made from the image.
263
+ *
264
+ * Returns the image itself when no size was asked for or the image is already at least that big:
265
+ * qcow2 cannot shrink, and a machine asking for less than its image holds is asking for a machine
266
+ * that cannot boot.
267
+ */
268
+ async of(image, destination, bytes) {
269
+ if (bytes <= 0 || bytes <= await virtualSize(this.qemuImg, image)) {
270
+ return image;
271
+ }
272
+ await copyFile(image, destination);
273
+ try {
274
+ await run3(this.qemuImg, ["resize", destination, String(bytes)]);
275
+ } catch (error) {
276
+ await rm4(destination, { force: true });
277
+ throw new Error(`Could not size the disk for this machine: ${reasonFor(error)}`);
278
+ }
279
+ return destination;
280
+ }
281
+ /** The copy, once UTM has taken its own. Two of a forty gigabyte disk is not a thing to leave. */
282
+ async discard(path) {
283
+ await rm4(path, { force: true });
284
+ }
285
+ /**
286
+ * Whether the tool that resizes a disk is here.
287
+ *
288
+ * Asked before it is needed rather than after: a machine that has been created and then cannot be
289
+ * sized is a machine somebody has to go and delete.
290
+ */
291
+ async available() {
292
+ try {
293
+ await run3(this.qemuImg, ["--version"]);
294
+ return true;
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+ };
300
+ async function virtualSize(qemuImg, image) {
301
+ try {
302
+ const { stdout } = await run3(qemuImg, ["info", "--output=json", image]);
303
+ return Number(JSON.parse(stdout)["virtual-size"] ?? 0);
304
+ } catch {
305
+ return (await stat2(image)).size;
306
+ }
307
+ }
308
+ function reasonFor(error) {
309
+ const message = error instanceof Error ? error.message : String(error);
310
+ return message.includes("ENOENT") ? "qemu-img is not installed. It comes with QEMU: brew install qemu" : message;
311
+ }
312
+
313
+ // src/machine/UTMMachines.ts
314
+ import { execFile as execFile6 } from "node:child_process";
315
+ import { promisify as promisify6 } from "node:util";
316
+
317
+ // src/applescript/AppleScript.ts
318
+ import { execFile as execFile4 } from "node:child_process";
319
+ import { promisify as promisify4 } from "node:util";
320
+ var run4 = promisify4(execFile4);
321
+ var AppleScriptError = class extends Error {
322
+ constructor(message, script) {
323
+ super(message);
324
+ this.script = script;
325
+ this.name = "AppleScriptError";
326
+ }
327
+ script;
328
+ };
329
+ var rawConstant = /* @__PURE__ */ Symbol("rawConstant");
330
+ var posixFile = /* @__PURE__ */ Symbol("posixFile");
331
+ function constant(code) {
332
+ return { [rawConstant]: code };
333
+ }
334
+ function file(path) {
335
+ return { [posixFile]: path };
336
+ }
337
+ function literal(value) {
338
+ if (isRawConstant(value)) {
339
+ return `\xABconstant ****${value[rawConstant]}\xBB`;
340
+ }
341
+ if (isPosixFile(value)) {
342
+ return `POSIX file ${quote(value[posixFile])}`;
343
+ }
344
+ if (Array.isArray(value)) {
345
+ return `{${value.map(literal).join(", ")}}`;
346
+ }
347
+ if (typeof value === "string") {
348
+ return quote(value);
349
+ }
350
+ if (typeof value === "number" || typeof value === "boolean") {
351
+ return String(value);
352
+ }
353
+ const fields = Object.entries(value).filter((entry) => entry[1] !== void 0).map(([key, field]) => `${key}:${literal(field)}`);
354
+ return `{${fields.join(", ")}}`;
355
+ }
356
+ function quote(value) {
357
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
358
+ }
359
+ function isRawConstant(value) {
360
+ return typeof value === "object" && value !== null && rawConstant in value;
361
+ }
362
+ function isPosixFile(value) {
363
+ return typeof value === "object" && value !== null && posixFile in value;
364
+ }
365
+ var AppleScript = class {
366
+ constructor(timeoutSeconds = 120) {
367
+ this.timeoutSeconds = timeoutSeconds;
368
+ }
369
+ timeoutSeconds;
370
+ async run(body) {
371
+ const script = `with timeout of ${this.timeoutSeconds} seconds
372
+ ${body}
373
+ end timeout`;
374
+ try {
375
+ const { stdout } = await run4("osascript", ["-e", script], {
376
+ maxBuffer: 16 * 1024 * 1024,
377
+ timeout: (this.timeoutSeconds + 15) * 1e3
378
+ });
379
+ return stdout.trim();
380
+ } catch (error) {
381
+ throw new AppleScriptError(messageOf(error), script);
382
+ }
383
+ }
384
+ };
385
+ function messageOf(error) {
386
+ if (error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string" && error.stderr.trim()) {
387
+ return error.stderr.trim();
388
+ }
389
+ return error instanceof Error ? error.message : String(error);
390
+ }
391
+
392
+ // src/applescript/Automation.ts
393
+ import { execFile as execFile5 } from "node:child_process";
394
+ import { promisify as promisify5 } from "node:util";
395
+ var run5 = promisify5(execFile5);
396
+ var Automation = class {
397
+ constructor(timeoutSeconds = 60) {
398
+ this.timeoutSeconds = timeoutSeconds;
399
+ }
400
+ timeoutSeconds;
401
+ async read(expression) {
402
+ const script = `const utm = Application("UTM"); JSON.stringify(${expression});`;
403
+ try {
404
+ const { stdout } = await run5("osascript", ["-l", "JavaScript", "-e", script], {
405
+ maxBuffer: 16 * 1024 * 1024,
406
+ timeout: this.timeoutSeconds * 1e3
407
+ });
408
+ return JSON.parse(stdout.trim());
409
+ } catch (error) {
410
+ throw new AppleScriptError(messageOf2(error), script);
411
+ }
412
+ }
413
+ };
414
+ function messageOf2(error) {
415
+ if (error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string" && error.stderr.trim()) {
416
+ return error.stderr.trim();
417
+ }
418
+ return error instanceof Error ? error.message : String(error);
419
+ }
420
+
421
+ // src/machine/UTMMachines.ts
422
+ var run6 = promisify6(execFile6);
423
+ var utmctlPath = "/Applications/UTM.app/Contents/MacOS/utmctl";
424
+ var codes = {
425
+ tcp: "TcPp",
426
+ emulated: "EmUd",
427
+ virtio: "QdIv",
428
+ usb: "QdIu"
429
+ };
430
+ var UTMMachines = class {
431
+ constructor(appleScript = new AppleScript(), automation = new Automation()) {
432
+ this.appleScript = appleScript;
433
+ this.automation = automation;
434
+ }
435
+ appleScript;
436
+ automation;
437
+ async installed() {
438
+ try {
439
+ await run6(utmctlPath, ["version"]);
440
+ return true;
441
+ } catch {
442
+ return false;
443
+ }
444
+ }
445
+ async version() {
446
+ try {
447
+ const { stdout } = await run6(utmctlPath, ["version"]);
448
+ return stdout.trim();
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+ /**
454
+ * Makes UTM ready to accept commands.
455
+ *
456
+ * While the application has no open window, scripting answers
457
+ * "UTM is not ready to accept commands (-2700)" even though reads succeed.
458
+ */
459
+ async ready() {
460
+ await run6("open", ["-a", "UTM"]);
461
+ for (let attempt = 0; attempt < 40; attempt += 1) {
462
+ const windows = await this.appleScript.run('tell application "UTM" to return (count of windows) as text').catch(() => "0");
463
+ if (Number(windows) > 0) {
464
+ return;
465
+ }
466
+ await delay(250);
467
+ }
468
+ throw new Error("UTM did not open a window and will not accept commands");
469
+ }
470
+ async create(spec) {
471
+ const configuration = literal({
472
+ name: spec.name,
473
+ architecture: spec.architecture,
474
+ memory: spec.memoryMiB,
475
+ "cpu cores": spec.cpuCores,
476
+ uefi: true,
477
+ hypervisor: true,
478
+ // A machine with no screen is what UTM makes by default, and what everything openstrap is
479
+ // normally asked for wants. `virtio-ramfb-gl` is what UTM itself picks for an ARM guest.
480
+ ...spec.display ? { displays: [{ hardware: "virtio-ramfb-gl" }] } : {}
481
+ });
482
+ return this.appleScript.run(
483
+ `tell application "UTM" to return (id of (make new virtual machine with properties {backend:qemu, configuration:${configuration}})) as text`
484
+ );
485
+ }
486
+ async drives(id) {
487
+ return this.automation.read(
488
+ `utm.virtualMachines.byId(${JSON.stringify(id)}).configuration().drives`
489
+ );
490
+ }
491
+ async portForwards(id) {
492
+ return (await this.networkInterfaces(id)).flatMap((entry) => entry.portForwards);
493
+ }
494
+ async networkInterfaces(id) {
495
+ const interfaces = await this.automation.read(
496
+ `utm.virtualMachines.byId(${JSON.stringify(id)}).configuration().networkInterfaces`
497
+ );
498
+ return interfaces.map((entry, index) => ({
499
+ index: entry.index ?? index,
500
+ hardware: entry.hardware ?? "",
501
+ address: entry.address ?? "",
502
+ mode: entry.mode ?? "",
503
+ portForwards: (entry.portForwards ?? []).map((forward) => ({ hostPort: forward.hostPort, guestPort: forward.guestPort }))
504
+ }));
505
+ }
506
+ /**
507
+ * Replaces the drives of a machine with ones backed by the given images.
508
+ *
509
+ * UTM imports each source into the machine package and converts it, so the
510
+ * cached base image is read and never modified.
511
+ *
512
+ * The sources must live inside UTM's sandbox container. UTM reports a source
513
+ * it cannot reach as a file that does not exist, even when it plainly does.
514
+ */
515
+ async attachDrives(id, drives) {
516
+ const value = literal({
517
+ drives: drives.map((drive) => ({
518
+ id: drive.id,
519
+ source: file(drive.source),
520
+ interface: constant(drive.removable ? codes.usb : codes.virtio)
521
+ }))
522
+ });
523
+ await this.appleScript.run(`tell application "UTM" to update configuration virtual machine id "${id}" with ${value}`);
524
+ }
525
+ /**
526
+ * Port forwarding requires the emulated network mode; shared does not have it.
527
+ *
528
+ * Without `keep` this describes a new card, which is right for a machine being made. For a machine
529
+ * that exists the card must stay the same one: UTM would otherwise make a new card with a new MAC
530
+ * address, and a guest that configured its network by that address comes back with none.
531
+ */
532
+ async forwardPorts(id, forwards, keep) {
533
+ const value = literal({
534
+ "network interfaces": [{
535
+ ...keep === void 0 ? {} : {
536
+ index: keep.index,
537
+ address: keep.address,
538
+ ...keep.hardware === "" ? {} : { hardware: keep.hardware }
539
+ },
540
+ mode: constant(codes.emulated),
541
+ "port forwards": forwards.map((forward) => ({
542
+ protocol: constant(codes.tcp),
543
+ "host port": forward.hostPort,
544
+ "guest port": forward.guestPort
545
+ }))
546
+ }]
547
+ });
548
+ await this.appleScript.run(`tell application "UTM" to update configuration virtual machine id "${id}" with ${value}`);
549
+ }
550
+ async start(id) {
551
+ await run6(utmctlPath, ["start", id]);
552
+ }
553
+ async stop(id) {
554
+ await run6(utmctlPath, ["stop", id]);
555
+ }
556
+ /** The guest is asked to power down, the way a keypress on the case would, rather than switched off. */
557
+ async requestShutdown(id) {
558
+ await run6(utmctlPath, ["stop", "--request", id]);
559
+ }
560
+ async status(id) {
561
+ const { stdout } = await run6(utmctlPath, ["status", id]);
562
+ return stdout.trim();
563
+ }
564
+ async delete(id) {
565
+ await run6(utmctlPath, ["delete", id]);
566
+ }
567
+ async list() {
568
+ const { stdout } = await run6(utmctlPath, ["list"]);
569
+ return stdout.split("\n").slice(1).map((line) => line.trim()).filter(Boolean).flatMap((line) => {
570
+ const match = line.match(/^(\S+)\s+(\S+)\s+(.+)$/);
571
+ return match ? [{ id: match[1], status: match[2], name: match[3].trim() }] : [];
572
+ });
573
+ }
574
+ async find(name) {
575
+ return (await this.list()).find((machine) => machine.name === name) ?? null;
576
+ }
577
+ };
578
+ function delay(milliseconds) {
579
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
580
+ }
581
+
582
+ // src/UTM.ts
583
+ var utmCacheDirectory = join4(
584
+ homedir(),
585
+ "Library/Containers/com.utmapp.UTM/Data/Documents/openstrap-cache"
586
+ );
587
+ var startAttempts = 40;
588
+ var startInterval = 250;
589
+ var shutdownAttempts = 120;
590
+ var shutdownInterval = 500;
591
+ var statuses = {
592
+ started: "running",
593
+ running: "running",
594
+ stopped: "stopped",
595
+ paused: "suspended",
596
+ suspended: "suspended"
597
+ };
598
+ var UTM = class {
599
+ constructor(machines = new UTMMachines(), cacheDirectory = utmCacheDirectory, files = new HostFiles(), network = new HostNetwork()) {
600
+ this.machines = machines;
601
+ this.images = new ImageCache(cacheDirectory, files, network, this.boxes);
602
+ this.cacheDirectory = cacheDirectory;
603
+ }
604
+ machines;
605
+ id = "utm";
606
+ displayName = "UTM";
607
+ capabilities = {
608
+ scopes: ["guest"],
609
+ types: ["vm"],
610
+ resize: false,
611
+ portForward: true
612
+ };
613
+ seed = new CloudInitSeed();
614
+ disk = new MachineDisk();
615
+ boxes = new BoxArchive();
616
+ images;
617
+ cacheDirectory;
618
+ async detect() {
619
+ const version = await this.machines.version();
620
+ if (version === null) {
621
+ return {
622
+ available: false,
623
+ reason: "UTM is not installed",
624
+ install: {
625
+ description: "UTM is the hypervisor that runs the machine. openstrap does not install it for you.",
626
+ command: "brew install --cask utm"
627
+ }
628
+ };
629
+ }
630
+ if (!await this.disk.available()) {
631
+ return {
632
+ available: false,
633
+ reason: "qemu-img is missing, so a machine cannot be given the disk its blueprint asks for",
634
+ install: {
635
+ description: "qemu-img resizes the disk image. It comes with QEMU, which UTM does not install.",
636
+ command: "brew install qemu"
637
+ }
638
+ };
639
+ }
640
+ return { available: true, version };
641
+ }
642
+ /**
643
+ * Creates a stopped machine with a disk of the size that was asked for, and the seed attached.
644
+ *
645
+ * The cached image is never modified: UTM copies each source into the machine's own package, and
646
+ * the cache is shared by everything built from that image and addressed by its digest. A machine
647
+ * that wants a bigger disk gets a copy of its own, grown before it is handed over — the partition
648
+ * inside it is grown by cloud-init on first boot.
649
+ */
650
+ async create(request) {
651
+ const hostPort = request.hostPort;
652
+ if (hostPort === void 0) {
653
+ throw new Error(`No host port was given to forward to SSH on machine "${request.name}"`);
654
+ }
655
+ await this.machines.ready();
656
+ const cached = await this.images.fetch(request.image);
657
+ const id = await this.machines.create({
658
+ name: request.name,
659
+ architecture: architectureFor(request.image.architecture),
660
+ memoryMiB: Math.round(request.resources.memoryBytes / (1024 * 1024)),
661
+ cpuCores: request.resources.cpuCores,
662
+ ...request.display === void 0 ? {} : { display: request.display }
663
+ });
664
+ const disk = await this.disk.of(
665
+ cached.path,
666
+ join4(this.cacheDirectory, `${request.name}-disk.qcow2`),
667
+ request.resources.diskBytes
668
+ );
669
+ const seedPath = await this.seed.build({
670
+ name: request.name,
671
+ user: request.user,
672
+ publicKey: request.publicKey,
673
+ destination: join4(this.cacheDirectory, `${request.name}-seed.iso`)
674
+ });
675
+ await this.machines.attachDrives(id, [
676
+ { source: disk },
677
+ { source: seedPath }
678
+ ]);
679
+ if (disk !== cached.path) {
680
+ await this.disk.discard(disk);
681
+ }
682
+ await this.machines.forwardPorts(id, [
683
+ { hostPort, guestPort: request.guestPort },
684
+ ...(request.publish ?? []).map((port) => ({ hostPort: port, guestPort: port }))
685
+ ]);
686
+ return { id, name: request.name };
687
+ }
688
+ /**
689
+ * Starts the machine and waits until it is running, because being told is not the same as being.
690
+ *
691
+ * UTM reports a refusal on its own output and exits zero, so a machine that never launched came
692
+ * back as started. What openstrap does next — reach it, read it, deliver itself to it — is all
693
+ * built on the machine being there, and each of those fails further from the reason.
694
+ */
695
+ async start(machine) {
696
+ await this.machines.start(machine.id);
697
+ for (let attempt = 0; attempt < startAttempts; attempt += 1) {
698
+ const status = await this.machines.status(machine.id);
699
+ if (statuses[status] === "running") {
700
+ return;
701
+ }
702
+ await new Promise((wake) => setTimeout(wake, startInterval));
703
+ }
704
+ throw new MachineDidNotStartError(machine.name, await this.machines.status(machine.id));
705
+ }
706
+ async stop(machine) {
707
+ await this.machines.stop(machine.id);
708
+ }
709
+ async restart(machine) {
710
+ await this.machines.stop(machine.id);
711
+ await this.machines.start(machine.id);
712
+ }
713
+ /**
714
+ * Deletes a machine, stopping it first.
715
+ *
716
+ * UTM refuses to delete a running machine, and it keeps the name reserved
717
+ * until the deletion lands — so creating the same target again would fail
718
+ * with "a virtual machine already exists with this name".
719
+ */
720
+ async delete(machine) {
721
+ if ((await this.inspect(machine)).status === "running") {
722
+ await this.machines.stop(machine.id).catch(() => void 0);
723
+ }
724
+ await this.machines.delete(machine.id);
725
+ for (let attempt = 0; attempt < 40; attempt += 1) {
726
+ if (!await this.machines.find(machine.name)) {
727
+ return;
728
+ }
729
+ await delay2(250);
730
+ }
731
+ throw new Error(`UTM still lists a machine named "${machine.name}" after deleting it`);
732
+ }
733
+ /**
734
+ * Adds forwards for ports not yet forwarded, on the same number. QEMU's user-mode network is
735
+ * fixed while it runs, so a running machine is shut down, changed and started again.
736
+ */
737
+ async publish(machine, ports) {
738
+ const card = (await this.machines.networkInterfaces(machine.id))[0];
739
+ if (card === void 0) {
740
+ throw new Error(`Machine "${machine.name}" has no network card to forward ports on`);
741
+ }
742
+ const added = ports.filter((port) => !card.portForwards.some((forward) => forward.guestPort === port));
743
+ if (added.length === 0) {
744
+ return { added, restarted: false };
745
+ }
746
+ const running = (await this.inspect(machine)).status === "running";
747
+ if (running) {
748
+ await this.shutDown(machine);
749
+ }
750
+ await this.machines.forwardPorts(machine.id, [
751
+ ...card.portForwards,
752
+ ...added.map((port) => ({ hostPort: port, guestPort: port }))
753
+ ], { index: card.index, address: card.address, hardware: card.hardware });
754
+ if (running) {
755
+ await this.start(machine);
756
+ }
757
+ return { added, restarted: running };
758
+ }
759
+ /** Asks the guest to power down and waits; pulls the plug only when it has not by the deadline. */
760
+ async shutDown(machine) {
761
+ await this.machines.requestShutdown(machine.id);
762
+ for (let attempt = 0; attempt < shutdownAttempts; attempt += 1) {
763
+ if ((await this.inspect(machine)).status === "stopped") {
764
+ return;
765
+ }
766
+ await delay2(shutdownInterval);
767
+ }
768
+ await this.machines.stop(machine.id);
769
+ for (let attempt = 0; attempt < startAttempts; attempt += 1) {
770
+ if ((await this.inspect(machine)).status === "stopped") {
771
+ return;
772
+ }
773
+ await delay2(startInterval);
774
+ }
775
+ throw new Error(`UTM did not stop "${machine.name}" when asked to`);
776
+ }
777
+ async inspect(machine) {
778
+ const status = await this.machines.status(machine.id);
779
+ return { status: statuses[status.toLowerCase()] ?? "unknown" };
780
+ }
781
+ async access(machine) {
782
+ const forwards = await this.machines.portForwards(machine.id);
783
+ const ssh = forwards.find((forward) => forward.guestPort === 22);
784
+ if (!ssh) {
785
+ throw new Error(`Machine "${machine.name}" has no forwarded port for SSH`);
786
+ }
787
+ return {
788
+ transport: "ssh",
789
+ endpoint: { host: "127.0.0.1", port: ssh.hostPort, user: "openstrap" }
790
+ };
791
+ }
792
+ async find(name) {
793
+ const machine = await this.machines.find(name);
794
+ return machine ? { id: machine.id, name: machine.name } : null;
795
+ }
796
+ };
797
+ function architectureFor(architecture) {
798
+ return architecture === "arm64" ? "aarch64" : "x86_64";
799
+ }
800
+ function delay2(milliseconds) {
801
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
802
+ }
803
+
804
+ // src/index.ts
805
+ function openstrapUtm() {
806
+ return {
807
+ name: "openstrap:utm",
808
+ setup(api) {
809
+ api.registerProvider(new UTM());
810
+ }
811
+ };
812
+ }
813
+ export {
814
+ BoxArchive,
815
+ BoxDiskMissingError,
816
+ CloudInitSeed,
817
+ ImageCache,
818
+ ImageChecksumError,
819
+ MachineDidNotStartError,
820
+ UTM,
821
+ openstrapUtm as default,
822
+ utmCacheDirectory
823
+ };
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@openstrap/utm",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "OpenStrap UTM provider plugin",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "build": "rm -rf dist && node scripts/bundle.mjs",
19
+ "test": "vitest run",
20
+ "typecheck": "tsc --noEmit",
21
+ "test:live": "tsx src/test/provider-contract.live.ts",
22
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
23
+ },
24
+ "devDependencies": {
25
+ "@openstrap/plugin-contract": "^0.1.1",
26
+ "@types/node": "^24.10.1",
27
+ "esbuild": "0.28.1",
28
+ "tsx": "^4.23.1",
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^4.0.14"
31
+ }
32
+ }