automify 0.2.0 → 0.3.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 +239 -36
- package/examples/browser-with-safety.js +7 -10
- package/examples/cli-qemu.js +28 -0
- package/examples/desktop-qemu.js +41 -0
- package/package.json +5 -2
- package/scripts/generate-argument-reference.js +3 -1
- package/scripts/qemu-image.js +154 -0
- package/src/index.d.ts +368 -10
- package/src/index.js +18 -38
- package/src/lib/adapter-toolkit.js +8 -4
- package/src/lib/anthropic-model-adapter.js +24 -13
- package/src/lib/argument-reference.js +60 -8
- package/src/lib/automify.js +96 -0
- package/src/lib/cli-automify.js +41 -2
- package/src/lib/computer-automify.js +45 -26
- package/src/lib/docker-cli-automify.js +2 -6
- package/src/lib/docker-desktop-computer.js +7 -13
- package/src/lib/file-data.js +6 -6
- package/src/lib/init.js +14 -3
- package/src/lib/local-desktop-computer.js +2 -1
- package/src/lib/openai-responses-client.js +10 -3
- package/src/lib/presets.js +50 -2
- package/src/lib/qemu-cli-automify.js +568 -0
- package/src/lib/qemu-desktop-computer.js +681 -0
- package/src/lib/qemu-runtime.js +654 -0
- package/src/lib/runtime.js +23 -2
- package/src/lib/screen-recording.js +184 -0
- package/src/lib/task.js +564 -0
- package/src/lib/virtual-shared-folder.js +3 -1
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { createWriteStream, existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { createServer as createHttpServer } from "node:http";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
+
import { createServer as createNetServer } from "node:net";
|
|
9
|
+
import { Readable } from "node:stream";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
|
|
13
|
+
import { AutomifyError } from "./errors.js";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_QEMU_MEMORY = "2g";
|
|
16
|
+
export const DEFAULT_QEMU_CPUS = 2;
|
|
17
|
+
export const DEFAULT_QEMU_SSH_HOST = "127.0.0.1";
|
|
18
|
+
export const DEFAULT_QEMU_SSH_USER = "root";
|
|
19
|
+
export const DEFAULT_QEMU_DEBIAN_RELEASE = "trixie";
|
|
20
|
+
export const DEFAULT_QEMU_DEBIAN_VERSION = "13";
|
|
21
|
+
export const DEFAULT_QEMU_PREPARED_IMAGE_VERSION = "v2";
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFileCallback);
|
|
24
|
+
|
|
25
|
+
export function defaultQemuCommand() {
|
|
26
|
+
switch (process.arch) {
|
|
27
|
+
case "arm64":
|
|
28
|
+
return "qemu-system-aarch64";
|
|
29
|
+
case "x64":
|
|
30
|
+
return "qemu-system-x86_64";
|
|
31
|
+
default:
|
|
32
|
+
return `qemu-system-${process.arch}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function defaultQemuAccel() {
|
|
37
|
+
switch (process.platform) {
|
|
38
|
+
case "darwin":
|
|
39
|
+
return "hvf";
|
|
40
|
+
case "linux":
|
|
41
|
+
return "kvm";
|
|
42
|
+
case "win32":
|
|
43
|
+
return "whpx";
|
|
44
|
+
default:
|
|
45
|
+
return "tcg";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function defaultQemuFirmware(env = process.env) {
|
|
50
|
+
if (env.AUTOMIFY_QEMU_FIRMWARE) return env.AUTOMIFY_QEMU_FIRMWARE;
|
|
51
|
+
if (process.arch !== "arm64") return null;
|
|
52
|
+
|
|
53
|
+
const homebrewPrefix = env.HOMEBREW_PREFIX;
|
|
54
|
+
const candidates = [
|
|
55
|
+
homebrewPrefix ? join(homebrewPrefix, "share", "qemu", "edk2-aarch64-code.fd") : null,
|
|
56
|
+
"/opt/homebrew/share/qemu/edk2-aarch64-code.fd",
|
|
57
|
+
"/usr/local/share/qemu/edk2-aarch64-code.fd",
|
|
58
|
+
"/usr/share/qemu-efi-aarch64/QEMU_EFI.fd",
|
|
59
|
+
"/usr/share/AAVMF/AAVMF_CODE.fd",
|
|
60
|
+
"/usr/share/edk2/aarch64/QEMU_EFI.fd",
|
|
61
|
+
"/usr/share/qemu/edk2-aarch64-code.fd"
|
|
62
|
+
].filter(Boolean);
|
|
63
|
+
|
|
64
|
+
return candidates.find((path) => existsSync(path)) ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function defaultQemuCpu(options = {}) {
|
|
68
|
+
if (process.arch !== "arm64") return null;
|
|
69
|
+
const accel = options.accel ?? defaultQemuAccel();
|
|
70
|
+
if (accel === "hvf" || accel === "kvm") return "host";
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function getAvailablePort() {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const server = createNetServer();
|
|
77
|
+
server.unref();
|
|
78
|
+
server.on("error", reject);
|
|
79
|
+
server.listen(0, "127.0.0.1", () => {
|
|
80
|
+
const port = server.address().port;
|
|
81
|
+
server.close(() => resolve(port));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function defaultQemuImageUrl(env = process.env) {
|
|
87
|
+
if (env.AUTOMIFY_QEMU_DEFAULT_IMAGE_URL) return env.AUTOMIFY_QEMU_DEFAULT_IMAGE_URL;
|
|
88
|
+
const arch = debianCloudImageArch();
|
|
89
|
+
return `https://cloud.debian.org/images/cloud/${DEFAULT_QEMU_DEBIAN_RELEASE}/latest/debian-${DEFAULT_QEMU_DEBIAN_VERSION}-genericcloud-${arch}.qcow2`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function defaultQemuImageCacheRoot(env = process.env) {
|
|
93
|
+
if (env.AUTOMIFY_QEMU_IMAGE_CACHE_DIR) return resolve(env.AUTOMIFY_QEMU_IMAGE_CACHE_DIR);
|
|
94
|
+
if (process.platform === "win32") {
|
|
95
|
+
return join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "automify", "qemu-images");
|
|
96
|
+
}
|
|
97
|
+
if (process.platform === "darwin") {
|
|
98
|
+
return join(homedir(), "Library", "Caches", "automify", "qemu-images");
|
|
99
|
+
}
|
|
100
|
+
return join(env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "automify", "qemu-images");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function defaultQemuPreparedImageCacheRoot(env = process.env) {
|
|
104
|
+
if (env.AUTOMIFY_QEMU_PREPARED_IMAGE_CACHE_DIR) return resolve(env.AUTOMIFY_QEMU_PREPARED_IMAGE_CACHE_DIR);
|
|
105
|
+
return join(defaultQemuImageCacheRoot(env), "prepared");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function defaultQemuBaseImagePath(env = process.env) {
|
|
109
|
+
return join(defaultQemuImageCacheRoot(env), basename(new URL(defaultQemuImageUrl(env)).pathname));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function defaultQemuPreparedImagePath(options = {}) {
|
|
113
|
+
const imageUrl = options.imageUrl ?? process.env.AUTOMIFY_QEMU_DEFAULT_IMAGE_URL ?? defaultQemuImageUrl();
|
|
114
|
+
const preparedDir = options.preparedDir ?? defaultQemuPreparedImageCacheRoot();
|
|
115
|
+
const sourceName = basename(new URL(imageUrl).pathname).replace(/\.(qcow2|img|raw)$/i, "");
|
|
116
|
+
const profile = options.profile ?? "base";
|
|
117
|
+
const packages = uniquePackages(options.packages ?? []);
|
|
118
|
+
const setupKey = options.setupKey ?? packages.join("\n");
|
|
119
|
+
const key = createHash("sha256")
|
|
120
|
+
.update([DEFAULT_QEMU_PREPARED_IMAGE_VERSION, imageUrl, process.arch, profile, setupKey].join("\n"))
|
|
121
|
+
.digest("hex")
|
|
122
|
+
.slice(0, 12);
|
|
123
|
+
return join(preparedDir, `${sourceName}-${profile}-${key}.automify-prepared.qcow2`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function prepareDefaultQemuImage(options = {}) {
|
|
127
|
+
const execFile = options.execFile ?? execFileAsync;
|
|
128
|
+
const cache = await ensureDefaultQemuImageCache(options);
|
|
129
|
+
const backingImage = cache.preparedImage ?? cache.baseImage;
|
|
130
|
+
const workDir = await mkdtemp(join(tmpdir(), "automify-qemu-debian-"));
|
|
131
|
+
let cloudInitServer;
|
|
132
|
+
let keyPath = cache.sshKeyPath;
|
|
133
|
+
let extraQemuArgs = [...(cache.extraQemuArgs ?? [])];
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
if (!cache.preparedImage) {
|
|
137
|
+
keyPath = join(workDir, "id_ed25519");
|
|
138
|
+
await execFile(options.sshKeygenCommand ?? "ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", keyPath]);
|
|
139
|
+
const publicKey = (await readFile(`${keyPath}.pub`, "utf8")).trim();
|
|
140
|
+
const createCloudInitServer = options.createCloudInitServer ?? createNoCloudServer;
|
|
141
|
+
cloudInitServer = await createCloudInitServer({
|
|
142
|
+
publicKey,
|
|
143
|
+
hostname: sanitizeHostname(options.vmName ?? `automify-${randomUUID()}`)
|
|
144
|
+
});
|
|
145
|
+
extraQemuArgs = ["-smbios", `type=1,serial=ds=nocloud-net;s=http://10.0.2.2:${cloudInitServer.port}/`];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const overlayPath = join(workDir, "disk.qcow2");
|
|
149
|
+
await execFile(options.qemuImgCommand ?? "qemu-img", [
|
|
150
|
+
"create",
|
|
151
|
+
"-f",
|
|
152
|
+
"qcow2",
|
|
153
|
+
"-F",
|
|
154
|
+
"qcow2",
|
|
155
|
+
"-b",
|
|
156
|
+
backingImage,
|
|
157
|
+
overlayPath
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
image: overlayPath,
|
|
162
|
+
diskFormat: "qcow2",
|
|
163
|
+
sshUser: "automify",
|
|
164
|
+
sshKeyPath: keyPath,
|
|
165
|
+
sudo: true,
|
|
166
|
+
extraQemuArgs,
|
|
167
|
+
baseImage: cache.baseImage,
|
|
168
|
+
preparedImage: cache.preparedImage,
|
|
169
|
+
preparedPackages: cache.preparedPackages ?? [],
|
|
170
|
+
imageUrl: cache.imageUrl,
|
|
171
|
+
workDir,
|
|
172
|
+
async close() {
|
|
173
|
+
await cloudInitServer?.close();
|
|
174
|
+
await rm(workDir, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
} catch (error) {
|
|
178
|
+
await cloudInitServer?.close();
|
|
179
|
+
await rm(workDir, { recursive: true, force: true });
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function ensureDefaultQemuImageCache(options = {}) {
|
|
185
|
+
const execFile = options.execFile ?? execFileAsync;
|
|
186
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
187
|
+
const imageUrl = options.imageUrl ?? process.env.AUTOMIFY_QEMU_DEFAULT_IMAGE_URL ?? defaultQemuImageUrl();
|
|
188
|
+
const cacheOptions = normalizeDefaultImageCache(options.defaultImageCache, options);
|
|
189
|
+
const baseImage = options.baseImage ?? join(cacheOptions.imageCacheDir, basename(new URL(imageUrl).pathname));
|
|
190
|
+
|
|
191
|
+
await ensureDefaultQemuBaseImage(baseImage, imageUrl, {
|
|
192
|
+
fetchImpl,
|
|
193
|
+
forceDownload: cacheOptions.forceDownload
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!cacheOptions.prepared) {
|
|
197
|
+
return {
|
|
198
|
+
baseImage,
|
|
199
|
+
preparedImage: null,
|
|
200
|
+
preparedPackages: [],
|
|
201
|
+
imageUrl
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const prepared = await ensurePreparedQemuImage({
|
|
206
|
+
...options,
|
|
207
|
+
execFile,
|
|
208
|
+
imageUrl,
|
|
209
|
+
baseImage,
|
|
210
|
+
preparedImage:
|
|
211
|
+
options.preparedImage ??
|
|
212
|
+
defaultQemuPreparedImagePath({
|
|
213
|
+
imageUrl,
|
|
214
|
+
preparedDir: cacheOptions.preparedDir,
|
|
215
|
+
profile: options.preparedImageProfile,
|
|
216
|
+
packages: options.preparedPackages,
|
|
217
|
+
setupKey: options.preparedSetupKey
|
|
218
|
+
}),
|
|
219
|
+
keyPath: options.preparedSshKeyPath,
|
|
220
|
+
packages: options.preparedPackages,
|
|
221
|
+
setupCommands: options.preparedSetupCommands,
|
|
222
|
+
forcePrepare: cacheOptions.forcePrepare || cacheOptions.forceDownload
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
baseImage,
|
|
227
|
+
preparedImage: prepared.image,
|
|
228
|
+
sshKeyPath: prepared.keyPath,
|
|
229
|
+
preparedPackages: prepared.packages ?? [],
|
|
230
|
+
imageUrl
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function buildQemuArgs(options = {}) {
|
|
235
|
+
if (!options.image && !options.existingVM) {
|
|
236
|
+
throw new AutomifyError("QEMU virtual adapter requires image or vm.image with a bootable disk image.");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const args = [];
|
|
240
|
+
appendNonEmptyArg(args, "-name", options.name);
|
|
241
|
+
appendNonEmptyArg(args, "-m", options.memory ?? DEFAULT_QEMU_MEMORY);
|
|
242
|
+
const cpus = positiveInteger(options.cpus) ?? DEFAULT_QEMU_CPUS;
|
|
243
|
+
appendNonEmptyArg(args, "-smp", cpus);
|
|
244
|
+
|
|
245
|
+
if (options.machine) {
|
|
246
|
+
appendNonEmptyArg(args, "-machine", options.machine);
|
|
247
|
+
} else if (process.arch === "arm64") {
|
|
248
|
+
appendNonEmptyArg(args, "-machine", "virt");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
appendNonEmptyArg(args, "-accel", options.accel ?? defaultQemuAccel());
|
|
252
|
+
appendNonEmptyArg(args, "-cpu", options.cpu ?? defaultQemuCpu(options));
|
|
253
|
+
appendNonEmptyArg(args, "-bios", options.firmware ?? defaultQemuFirmware());
|
|
254
|
+
|
|
255
|
+
args.push("-display", "none", "-no-reboot");
|
|
256
|
+
|
|
257
|
+
if (options.image) {
|
|
258
|
+
args.push("-drive", `file=${options.image},if=virtio,format=${options.diskFormat ?? "qcow2"}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (options.network !== false) {
|
|
262
|
+
const netdev = [`user`, `id=net0`];
|
|
263
|
+
if (options.sshPort) {
|
|
264
|
+
netdev.push(`hostfwd=tcp:${options.sshHost ?? DEFAULT_QEMU_SSH_HOST}:${options.sshPort}-:22`);
|
|
265
|
+
}
|
|
266
|
+
args.push("-netdev", netdev.join(","));
|
|
267
|
+
args.push("-device", options.networkDevice ?? "virtio-net-pci,netdev=net0");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (options.sharedFolder && options.sharedMode !== "none") {
|
|
271
|
+
args.push(
|
|
272
|
+
"-virtfs",
|
|
273
|
+
[
|
|
274
|
+
"local",
|
|
275
|
+
`path=${options.sharedFolder.hostPath}`,
|
|
276
|
+
`mount_tag=${options.sharedTag ?? "automify_shared"}`,
|
|
277
|
+
`security_model=${options.sharedSecurityModel ?? "none"}`
|
|
278
|
+
].join(",")
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const arg of options.extraQemuArgs ?? []) {
|
|
283
|
+
args.push(String(arg));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return args;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function sshArgs(options = {}, command) {
|
|
290
|
+
const args = [
|
|
291
|
+
"-p",
|
|
292
|
+
String(options.sshPort),
|
|
293
|
+
"-o",
|
|
294
|
+
"BatchMode=yes",
|
|
295
|
+
"-o",
|
|
296
|
+
"StrictHostKeyChecking=no",
|
|
297
|
+
"-o",
|
|
298
|
+
"UserKnownHostsFile=/dev/null"
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
for (const option of options.sshOptions ?? []) {
|
|
302
|
+
args.push(String(option));
|
|
303
|
+
}
|
|
304
|
+
if (options.sshKeyPath) {
|
|
305
|
+
args.push("-i", options.sshKeyPath);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
args.push(`${options.sshUser ?? DEFAULT_QEMU_SSH_USER}@${options.sshHost ?? DEFAULT_QEMU_SSH_HOST}`);
|
|
309
|
+
if (command != null) args.push(String(command));
|
|
310
|
+
return args;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function waitForSsh(execFile, sshCommand, options = {}) {
|
|
314
|
+
const timeoutMs = positiveInteger(options.startupTimeoutMs) ?? 60_000;
|
|
315
|
+
const startedAt = Date.now();
|
|
316
|
+
let lastError;
|
|
317
|
+
|
|
318
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
319
|
+
try {
|
|
320
|
+
await execFile(sshCommand, sshArgs(options, "true"), {
|
|
321
|
+
timeout: positiveInteger(options.sshTimeoutMs) ?? 5_000
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
lastError = error;
|
|
326
|
+
await sleep(250);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
throw lastError ?? new AutomifyError("QEMU SSH readiness check timed out.");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function stopQemuProcess(child, timeoutMs = 1500) {
|
|
334
|
+
if (!child || child.killed) return;
|
|
335
|
+
const exited = new Promise((resolve) => {
|
|
336
|
+
child.once?.("exit", resolve);
|
|
337
|
+
child.once?.("close", resolve);
|
|
338
|
+
});
|
|
339
|
+
child.kill?.("SIGTERM");
|
|
340
|
+
await Promise.race([exited, sleep(timeoutMs)]);
|
|
341
|
+
if (!child.killed) child.kill?.("SIGKILL");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function installCommand(packages, options = {}) {
|
|
345
|
+
if (options.installDependencies === false || packages.length === 0) return ":";
|
|
346
|
+
const apt = [
|
|
347
|
+
"export DEBIAN_FRONTEND=noninteractive",
|
|
348
|
+
`${sudo(options)}apt-get update`,
|
|
349
|
+
`${sudo(options)}apt-get install -y --no-install-recommends ${packages.map(shellQuote).join(" ")}`,
|
|
350
|
+
`${sudo(options)}rm -rf /var/lib/apt/lists/*`
|
|
351
|
+
];
|
|
352
|
+
return apt.join(" && ");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function mountSharedFolderCommand(sharedFolder, options = {}) {
|
|
356
|
+
if (!sharedFolder || options.sharedMode === "none") return ":";
|
|
357
|
+
const target = normalizeGuestPath(sharedFolder.containerPath);
|
|
358
|
+
const tag = options.sharedTag ?? "automify_shared";
|
|
359
|
+
return [
|
|
360
|
+
`${sudo(options)}mkdir -p ${shellQuote(target)}`,
|
|
361
|
+
`${sudo(options)}mount -t 9p -o trans=virtio,version=9p2000.L ${shellQuote(tag)} ${shellQuote(target)} || true`
|
|
362
|
+
].join(" && ");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function shellQuote(value) {
|
|
366
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function uniquePackages(packages) {
|
|
370
|
+
return [...new Set(packages.map((pkg) => String(pkg).trim()).filter(Boolean))];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function normalizeGuestPath(value, fallback = "/workspace") {
|
|
374
|
+
const path = String(value || fallback).trim();
|
|
375
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function positiveInteger(value) {
|
|
379
|
+
const number = Number(value);
|
|
380
|
+
if (!Number.isFinite(number) || number <= 0) return null;
|
|
381
|
+
return Math.floor(number);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function sleep(ms) {
|
|
385
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function appendNonEmptyArg(args, flag, value) {
|
|
389
|
+
if (value == null || value === false || value === "") return;
|
|
390
|
+
args.push(flag, String(value));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function sudo(options) {
|
|
394
|
+
return options.sudo ? "sudo -n " : "";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function ensureDefaultQemuBaseImage(path, url, options = {}) {
|
|
398
|
+
try {
|
|
399
|
+
const info = await stat(path);
|
|
400
|
+
if (!options.forceDownload && info.size > 0) return;
|
|
401
|
+
} catch {
|
|
402
|
+
// The image is downloaded below.
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
await mkdir(dirname(path), { recursive: true });
|
|
406
|
+
const tempPath = `${path}.download-${process.pid}-${Date.now()}`;
|
|
407
|
+
try {
|
|
408
|
+
await downloadFile(url, tempPath, options);
|
|
409
|
+
await rename(tempPath, path);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
await rm(tempPath, { force: true });
|
|
412
|
+
throw new AutomifyError(`Failed to download the default QEMU Debian image from ${url}.`, { cause: error });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function ensurePreparedQemuImage(options = {}) {
|
|
417
|
+
const execFile = options.execFile ?? execFileAsync;
|
|
418
|
+
const spawn = options.spawn;
|
|
419
|
+
const qemu = options.qemuCommand ?? defaultQemuCommand();
|
|
420
|
+
const qemuImg = options.qemuImgCommand ?? "qemu-img";
|
|
421
|
+
const preparedImage = options.preparedImage;
|
|
422
|
+
const keyPath = options.keyPath ?? `${preparedImage}.id_ed25519`;
|
|
423
|
+
const packages = uniquePackages(options.packages ?? []);
|
|
424
|
+
|
|
425
|
+
if (!options.forcePrepare && (await existingNonEmptyFile(preparedImage)) && (await existingNonEmptyFile(keyPath))) {
|
|
426
|
+
return {
|
|
427
|
+
image: preparedImage,
|
|
428
|
+
keyPath,
|
|
429
|
+
packages
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (typeof spawn !== "function") {
|
|
434
|
+
throw new AutomifyError("Preparing the default QEMU image requires a spawn implementation.");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
await mkdir(dirname(preparedImage), { recursive: true });
|
|
438
|
+
await rm(preparedImage, { force: true });
|
|
439
|
+
await rm(keyPath, { force: true });
|
|
440
|
+
await rm(`${keyPath}.pub`, { force: true });
|
|
441
|
+
|
|
442
|
+
const workDir = await mkdtemp(join(tmpdir(), "automify-qemu-prepare-"));
|
|
443
|
+
const tempImage = join(workDir, "prepared.qcow2");
|
|
444
|
+
let child;
|
|
445
|
+
let cloudInitServer;
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
await execFile(qemuImg, ["create", "-f", "qcow2", "-F", "qcow2", "-b", options.baseImage, tempImage]);
|
|
449
|
+
await execFile(options.sshKeygenCommand ?? "ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", keyPath]);
|
|
450
|
+
const publicKey = (await readFile(`${keyPath}.pub`, "utf8")).trim();
|
|
451
|
+
const createCloudInitServer = options.createCloudInitServer ?? createNoCloudServer;
|
|
452
|
+
cloudInitServer = await createCloudInitServer({
|
|
453
|
+
publicKey,
|
|
454
|
+
hostname: sanitizeHostname(options.vmName ?? `automify-prepared-${randomUUID()}`)
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const sshPort = positiveInteger(options.sshPort) ?? (await getAvailablePort());
|
|
458
|
+
const qemuArgs = buildQemuArgs({
|
|
459
|
+
...options,
|
|
460
|
+
name: sanitizeHostname(`${options.vmName ?? "automify-prepared"}-cache`),
|
|
461
|
+
image: tempImage,
|
|
462
|
+
diskFormat: "qcow2",
|
|
463
|
+
sshPort,
|
|
464
|
+
extraQemuArgs: ["-smbios", `type=1,serial=ds=nocloud-net;s=http://10.0.2.2:${cloudInitServer.port}/`]
|
|
465
|
+
});
|
|
466
|
+
child = spawn(qemu, qemuArgs, { stdio: "ignore" });
|
|
467
|
+
child.unref?.();
|
|
468
|
+
const sshOptions = {
|
|
469
|
+
...options,
|
|
470
|
+
sshPort,
|
|
471
|
+
sshUser: "automify",
|
|
472
|
+
sshKeyPath: keyPath,
|
|
473
|
+
sudo: true
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
await waitForSsh(execFile, options.sshCommand ?? "ssh", sshOptions);
|
|
477
|
+
await execFile(options.sshCommand ?? "ssh", sshArgs(sshOptions, preparedImageSetupCommand(options)), {
|
|
478
|
+
timeout: positiveInteger(options.timeoutMs) ?? 60_000
|
|
479
|
+
});
|
|
480
|
+
await stopQemuProcess(child, positiveInteger(options.qemuTimeoutMs) ?? 1500);
|
|
481
|
+
child = null;
|
|
482
|
+
await rename(tempImage, preparedImage);
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
image: preparedImage,
|
|
486
|
+
keyPath,
|
|
487
|
+
packages
|
|
488
|
+
};
|
|
489
|
+
} catch (error) {
|
|
490
|
+
await stopQemuProcess(child, positiveInteger(options.qemuTimeoutMs) ?? 1500);
|
|
491
|
+
await rm(preparedImage, { force: true });
|
|
492
|
+
await rm(keyPath, { force: true });
|
|
493
|
+
await rm(`${keyPath}.pub`, { force: true });
|
|
494
|
+
throw new AutomifyError("Failed to prepare the cached default QEMU Debian image.", { cause: error });
|
|
495
|
+
} finally {
|
|
496
|
+
await cloudInitServer?.close();
|
|
497
|
+
await rm(workDir, { recursive: true, force: true });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function existingNonEmptyFile(path) {
|
|
502
|
+
try {
|
|
503
|
+
return (await stat(path)).size > 0;
|
|
504
|
+
} catch {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function normalizeDefaultImageCache(value, options = {}) {
|
|
510
|
+
const imageCacheDir =
|
|
511
|
+
options.cacheDir ?? options.imageCacheDir ?? options.qemuImageCacheDir ?? defaultQemuImageCacheRoot();
|
|
512
|
+
const preparedDir = options.preparedImageCacheDir ?? join(imageCacheDir, "prepared");
|
|
513
|
+
if (value === false) {
|
|
514
|
+
return {
|
|
515
|
+
imageCacheDir,
|
|
516
|
+
preparedDir,
|
|
517
|
+
prepared: false,
|
|
518
|
+
forceDownload: false,
|
|
519
|
+
forcePrepare: false
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (value == null || value === true) {
|
|
523
|
+
return {
|
|
524
|
+
imageCacheDir,
|
|
525
|
+
preparedDir,
|
|
526
|
+
prepared: true,
|
|
527
|
+
forceDownload: false,
|
|
528
|
+
forcePrepare: false
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
532
|
+
throw new AutomifyError("defaultImageCache must be a boolean or an object.");
|
|
533
|
+
}
|
|
534
|
+
const dir = value.dir ? resolve(value.dir) : imageCacheDir;
|
|
535
|
+
const normalized = {
|
|
536
|
+
imageCacheDir: value.imageCacheDir ? resolve(value.imageCacheDir) : dir,
|
|
537
|
+
preparedDir: value.preparedDir ? resolve(value.preparedDir) : join(dir, "prepared"),
|
|
538
|
+
prepared: value.prepared !== false,
|
|
539
|
+
forceDownload: value.forceDownload === true,
|
|
540
|
+
forcePrepare: value.forcePrepare === true
|
|
541
|
+
};
|
|
542
|
+
if (normalized.forceDownload) normalized.forcePrepare = true;
|
|
543
|
+
return normalized;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function preparedImageSetupCommand(options = {}) {
|
|
547
|
+
const packages = uniquePackages(options.packages ?? []);
|
|
548
|
+
const setupCommands = [installCommand(packages, { sudo: true }), ...(options.setupCommands ?? [])].filter(
|
|
549
|
+
(command) => command && command !== ":"
|
|
550
|
+
);
|
|
551
|
+
return [
|
|
552
|
+
"set -eu",
|
|
553
|
+
...setupCommands,
|
|
554
|
+
"sudo -n install -d -m 700 -o automify -g automify /home/automify/.ssh",
|
|
555
|
+
"sudo -n touch /etc/cloud/cloud-init.disabled || true",
|
|
556
|
+
"sudo -n cloud-init clean --logs || true",
|
|
557
|
+
"sudo -n rm -f /var/lib/cloud/instance /var/lib/cloud/data/result.json || true",
|
|
558
|
+
"sudo -n sync"
|
|
559
|
+
].join(" && ");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function downloadFile(url, targetPath, options = {}) {
|
|
563
|
+
const fetchImpl = options.fetchImpl;
|
|
564
|
+
if (typeof fetchImpl !== "function") {
|
|
565
|
+
throw new AutomifyError("Default QEMU Debian image download requires a fetch implementation.");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const response = await fetchImpl(url);
|
|
569
|
+
if (!response?.ok) {
|
|
570
|
+
throw new AutomifyError(`Default QEMU Debian image download failed with HTTP ${response?.status ?? "error"}.`);
|
|
571
|
+
}
|
|
572
|
+
if (!response.body) {
|
|
573
|
+
throw new AutomifyError("Default QEMU Debian image download returned an empty response body.");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const body = typeof response.body.getReader === "function" ? Readable.fromWeb(response.body) : response.body;
|
|
577
|
+
await pipeline(body, createWriteStream(targetPath));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function createNoCloudServer(options = {}) {
|
|
581
|
+
const userData = [
|
|
582
|
+
"#cloud-config",
|
|
583
|
+
"users:",
|
|
584
|
+
" - default",
|
|
585
|
+
" - name: automify",
|
|
586
|
+
" groups: sudo",
|
|
587
|
+
" shell: /bin/bash",
|
|
588
|
+
" sudo: ALL=(ALL) NOPASSWD:ALL",
|
|
589
|
+
" ssh_authorized_keys:",
|
|
590
|
+
` - ${options.publicKey}`,
|
|
591
|
+
"ssh_pwauth: false",
|
|
592
|
+
"disable_root: false",
|
|
593
|
+
"package_update: false",
|
|
594
|
+
""
|
|
595
|
+
].join("\n");
|
|
596
|
+
const metaData = [
|
|
597
|
+
`instance-id: automify-${randomUUID()}`,
|
|
598
|
+
`local-hostname: ${sanitizeHostname(options.hostname ?? "automify-qemu")}`,
|
|
599
|
+
""
|
|
600
|
+
].join("\n");
|
|
601
|
+
|
|
602
|
+
const routes = new Map([
|
|
603
|
+
["/user-data", userData],
|
|
604
|
+
["/meta-data", metaData],
|
|
605
|
+
["/vendor-data", ""],
|
|
606
|
+
["/network-config", ""]
|
|
607
|
+
]);
|
|
608
|
+
|
|
609
|
+
const server = createHttpServer((request, response) => {
|
|
610
|
+
const path = request.url?.split("?")[0] ?? "/";
|
|
611
|
+
if (!routes.has(path)) {
|
|
612
|
+
response.writeHead(404);
|
|
613
|
+
response.end("not found");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
617
|
+
response.end(routes.get(path));
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
await new Promise((resolve, reject) => {
|
|
621
|
+
server.once("error", reject);
|
|
622
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
623
|
+
});
|
|
624
|
+
server.unref?.();
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
port: server.address().port,
|
|
628
|
+
close() {
|
|
629
|
+
return new Promise((resolve, reject) => {
|
|
630
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function debianCloudImageArch() {
|
|
637
|
+
switch (process.arch) {
|
|
638
|
+
case "x64":
|
|
639
|
+
return "amd64";
|
|
640
|
+
case "arm64":
|
|
641
|
+
return "arm64";
|
|
642
|
+
default:
|
|
643
|
+
throw new AutomifyError(`Default QEMU Debian image is not available for Node architecture ${process.arch}.`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function sanitizeHostname(value) {
|
|
648
|
+
const normalized = String(value ?? "automify-qemu")
|
|
649
|
+
.toLowerCase()
|
|
650
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
651
|
+
.replace(/^-+|-+$/g, "")
|
|
652
|
+
.slice(0, 63);
|
|
653
|
+
return normalized || "automify-qemu";
|
|
654
|
+
}
|