tangled
alpha
login
or
join now
tsiry-sandratraina.com
/
vmx
1
fork
atom
A Docker-like CLI and HTTP API for managing headless VMs
1
fork
atom
overview
issues
pulls
pipelines
run format
tsiry-sandratraina.com
3 months ago
98dd6862
c94b8cdc
2/2
fmt.yml
success
2s
test.yml
success
8s
+149
-133
4 changed files
expand all
collapse all
unified
split
deno.json
src
api
machines.ts
migrations.ts
utils.ts
+1
-1
deno.json
···
29
"kysely": "npm:kysely@0.27.6",
30
"moniker": "npm:moniker@^0.1.2"
31
}
32
-
}
···
29
"kysely": "npm:kysely@0.27.6",
30
"moniker": "npm:moniker@^0.1.2"
31
}
32
+
}
+26
-33
src/api/machines.ts
···
43
Effect.flatMap((params) =>
44
listInstances(params.all === "true" || params.all === "1")
45
),
46
-
presentation(c)
47
-
)
48
-
)
49
-
);
50
51
app.post("/", (c) =>
52
Effect.runPromise(
···
57
const image = yield* getImage(params.image);
58
if (!image) {
59
return yield* Effect.fail(
60
-
new ImageNotFoundError({ id: params.image })
61
);
62
}
63
···
91
sshPwauth: false,
92
},
93
},
94
-
tempDir
95
);
96
}
97
···
116
seed: _.get(
117
params,
118
"seed",
119
-
params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined
120
),
121
pid: 0,
122
});
···
126
})
127
),
128
presentation(c),
129
-
Effect.catchAll((error) => handleError(error, c))
130
-
)
131
-
)
132
-
);
133
134
app.get("/:id", (c) =>
135
Effect.runPromise(
136
pipe(
137
parseParams(c),
138
Effect.flatMap(({ id }) => getInstanceState(id)),
139
-
presentation(c)
140
-
)
141
-
)
142
-
);
143
144
app.delete("/:id", (c) =>
145
Effect.runPromise(
···
158
})
159
),
160
presentation(c),
161
-
Effect.catchAll((error) => handleError(error, c))
162
-
)
163
-
)
164
-
);
165
166
app.post("/:id/start", (c) =>
167
Effect.runPromise(
···
186
? startRequest.portForward.join(",")
187
: vm.portForward,
188
},
189
-
firmwareArgs
190
);
191
yield* createLogsDir();
192
yield* startDetachedQemu(vm.id, vm, qemuArgs);
···
194
})
195
),
196
presentation(c),
197
-
Effect.catchAll((error) => handleError(error, c))
198
-
)
199
-
)
200
-
);
201
202
app.post("/:id/stop", (c) =>
203
Effect.runPromise(
···
207
Effect.flatMap(killProcess),
208
Effect.flatMap(updateToStopped),
209
presentation(c),
210
-
Effect.catchAll((error) => handleError(error, c))
211
-
)
212
-
)
213
-
);
214
215
app.post("/:id/restart", (c) =>
216
Effect.runPromise(
···
237
? startRequest.portForward.join(",")
238
: vm.portForward,
239
},
240
-
firmwareArgs
241
);
242
yield* createLogsDir();
243
yield* startDetachedQemu(vm.id, vm, qemuArgs);
···
245
})
246
),
247
presentation(c),
248
-
Effect.catchAll((error) => handleError(error, c))
249
-
)
250
-
)
251
-
);
252
253
export default app;
···
43
Effect.flatMap((params) =>
44
listInstances(params.all === "true" || params.all === "1")
45
),
46
+
presentation(c),
47
+
),
48
+
));
0
49
50
app.post("/", (c) =>
51
Effect.runPromise(
···
56
const image = yield* getImage(params.image);
57
if (!image) {
58
return yield* Effect.fail(
59
+
new ImageNotFoundError({ id: params.image }),
60
);
61
}
62
···
90
sshPwauth: false,
91
},
92
},
93
+
tempDir,
94
);
95
}
96
···
115
seed: _.get(
116
params,
117
"seed",
118
+
params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined,
119
),
120
pid: 0,
121
});
···
125
})
126
),
127
presentation(c),
128
+
Effect.catchAll((error) => handleError(error, c)),
129
+
),
130
+
));
0
131
132
app.get("/:id", (c) =>
133
Effect.runPromise(
134
pipe(
135
parseParams(c),
136
Effect.flatMap(({ id }) => getInstanceState(id)),
137
+
presentation(c),
138
+
),
139
+
));
0
140
141
app.delete("/:id", (c) =>
142
Effect.runPromise(
···
155
})
156
),
157
presentation(c),
158
+
Effect.catchAll((error) => handleError(error, c)),
159
+
),
160
+
));
0
161
162
app.post("/:id/start", (c) =>
163
Effect.runPromise(
···
182
? startRequest.portForward.join(",")
183
: vm.portForward,
184
},
185
+
firmwareArgs,
186
);
187
yield* createLogsDir();
188
yield* startDetachedQemu(vm.id, vm, qemuArgs);
···
190
})
191
),
192
presentation(c),
193
+
Effect.catchAll((error) => handleError(error, c)),
194
+
),
195
+
));
0
196
197
app.post("/:id/stop", (c) =>
198
Effect.runPromise(
···
202
Effect.flatMap(killProcess),
203
Effect.flatMap(updateToStopped),
204
presentation(c),
205
+
Effect.catchAll((error) => handleError(error, c)),
206
+
),
207
+
));
0
208
209
app.post("/:id/restart", (c) =>
210
Effect.runPromise(
···
231
? startRequest.portForward.join(",")
232
: vm.portForward,
233
},
234
+
firmwareArgs,
235
);
236
yield* createLogsDir();
237
yield* startDetachedQemu(vm.id, vm, qemuArgs);
···
239
})
240
),
241
presentation(c),
242
+
Effect.catchAll((error) => handleError(error, c)),
243
+
),
244
+
));
0
245
246
export default app;
+52
-28
src/migrations.ts
···
34
.addColumn("isoPath", "varchar")
35
.addColumn("status", "varchar", (col) => col.notNull())
36
.addColumn("pid", "integer")
37
-
.addColumn("createdAt", "varchar", (col) =>
38
-
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
0
0
39
)
40
-
.addColumn("updatedAt", "varchar", (col) =>
41
-
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
0
0
42
)
43
.execute();
44
},
···
153
.addColumn("size", "integer", (col) => col.notNull())
154
.addColumn("path", "varchar", (col) => col.notNull())
155
.addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2"))
156
-
.addColumn("createdAt", "varchar", (col) =>
157
-
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
0
0
158
)
159
.addUniqueConstraint("images_repository_tag_unique", [
160
"repository",
···
212
.createTable("volumes")
213
.addColumn("id", "varchar", (col) => col.primaryKey())
214
.addColumn("name", "varchar", (col) => col.notNull().unique())
215
-
.addColumn("baseImageId", "varchar", (col) =>
216
-
col.notNull().references("images.id").onDelete("cascade")
0
0
217
)
218
.addColumn("path", "varchar", (col) => col.notNull())
219
-
.addColumn("createdAt", "varchar", (col) =>
220
-
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
0
0
221
)
222
.execute();
223
},
···
233
.createTable("volumes_new")
234
.addColumn("id", "varchar", (col) => col.primaryKey())
235
.addColumn("name", "varchar", (col) => col.notNull().unique())
236
-
.addColumn("baseImageId", "varchar", (col) =>
237
-
col.notNull().references("images.id").onDelete("cascade")
0
0
238
)
239
.addColumn("path", "varchar", (col) => col.notNull())
240
-
.addColumn("createdAt", "varchar", (col) =>
241
-
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
0
0
242
)
243
.execute();
244
···
307
.addColumn("pid", "integer")
308
.addColumn("volume", "varchar")
309
.addColumn("seed", "varchar")
310
-
.addColumn("createdAt", "varchar", (col) =>
311
-
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
0
0
312
)
313
-
.addColumn("updatedAt", "varchar", (col) =>
314
-
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
0
0
315
)
316
.execute();
317
···
321
`.execute(db);
322
323
await db.schema.dropTable("virtual_machines").execute();
324
-
await sql`ALTER TABLE virtual_machines_new RENAME TO virtual_machines`.execute(
325
-
db
326
-
);
0
327
},
328
329
async down(db: Kysely<unknown>): Promise<void> {
···
347
.addColumn("pid", "integer")
348
.addColumn("volume", "varchar")
349
.addColumn("seed", "varchar")
350
-
.addColumn("createdAt", "varchar", (col) =>
351
-
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
0
0
352
)
353
-
.addColumn("updatedAt", "varchar", (col) =>
354
-
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)
0
0
355
)
356
.execute();
357
···
361
`.execute(db);
362
363
await db.schema.dropTable("virtual_machines").execute();
364
-
await sql`ALTER TABLE virtual_machines_old RENAME TO virtual_machines`.execute(
365
-
db
366
-
);
0
367
},
368
};
369
···
34
.addColumn("isoPath", "varchar")
35
.addColumn("status", "varchar", (col) => col.notNull())
36
.addColumn("pid", "integer")
37
+
.addColumn(
38
+
"createdAt",
39
+
"varchar",
40
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
41
)
42
+
.addColumn(
43
+
"updatedAt",
44
+
"varchar",
45
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
46
)
47
.execute();
48
},
···
157
.addColumn("size", "integer", (col) => col.notNull())
158
.addColumn("path", "varchar", (col) => col.notNull())
159
.addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2"))
160
+
.addColumn(
161
+
"createdAt",
162
+
"varchar",
163
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
164
)
165
.addUniqueConstraint("images_repository_tag_unique", [
166
"repository",
···
218
.createTable("volumes")
219
.addColumn("id", "varchar", (col) => col.primaryKey())
220
.addColumn("name", "varchar", (col) => col.notNull().unique())
221
+
.addColumn(
222
+
"baseImageId",
223
+
"varchar",
224
+
(col) => col.notNull().references("images.id").onDelete("cascade"),
225
)
226
.addColumn("path", "varchar", (col) => col.notNull())
227
+
.addColumn(
228
+
"createdAt",
229
+
"varchar",
230
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
231
)
232
.execute();
233
},
···
243
.createTable("volumes_new")
244
.addColumn("id", "varchar", (col) => col.primaryKey())
245
.addColumn("name", "varchar", (col) => col.notNull().unique())
246
+
.addColumn(
247
+
"baseImageId",
248
+
"varchar",
249
+
(col) => col.notNull().references("images.id").onDelete("cascade"),
250
)
251
.addColumn("path", "varchar", (col) => col.notNull())
252
+
.addColumn(
253
+
"createdAt",
254
+
"varchar",
255
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
256
)
257
.execute();
258
···
321
.addColumn("pid", "integer")
322
.addColumn("volume", "varchar")
323
.addColumn("seed", "varchar")
324
+
.addColumn(
325
+
"createdAt",
326
+
"varchar",
327
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
328
)
329
+
.addColumn(
330
+
"updatedAt",
331
+
"varchar",
332
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
333
)
334
.execute();
335
···
339
`.execute(db);
340
341
await db.schema.dropTable("virtual_machines").execute();
342
+
await sql`ALTER TABLE virtual_machines_new RENAME TO virtual_machines`
343
+
.execute(
344
+
db,
345
+
);
346
},
347
348
async down(db: Kysely<unknown>): Promise<void> {
···
366
.addColumn("pid", "integer")
367
.addColumn("volume", "varchar")
368
.addColumn("seed", "varchar")
369
+
.addColumn(
370
+
"createdAt",
371
+
"varchar",
372
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
373
)
374
+
.addColumn(
375
+
"updatedAt",
376
+
"varchar",
377
+
(col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
378
)
379
.execute();
380
···
384
`.execute(db);
385
386
await db.schema.dropTable("virtual_machines").execute();
387
+
await sql`ALTER TABLE virtual_machines_old RENAME TO virtual_machines`
388
+
.execute(
389
+
db,
390
+
);
391
},
392
};
393
+70
-71
src/utils.ts
···
65
export const isValidISOurl = (url?: string): boolean => {
66
return Boolean(
67
(url?.startsWith("http://") || url?.startsWith("https://")) &&
68
-
url?.endsWith(".iso")
69
);
70
};
71
···
91
});
92
93
export const validateImage = (
94
-
image: string
95
): Effect.Effect<string, InvalidImageNameError, never> => {
96
const regex =
97
/^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/;
···
102
image,
103
cause:
104
"Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
105
-
})
106
);
107
}
108
return Effect.succeed(image);
···
111
export const extractTag = (name: string) =>
112
pipe(
113
validateImage(name),
114
-
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest"))
115
);
116
117
export const failOnMissingImage = (
118
-
image: Image | undefined
119
): Effect.Effect<Image, Error, never> =>
120
image
121
? Effect.succeed(image)
122
: Effect.fail(new NoSuchImageError({ cause: "No such image" }));
123
124
export const du = (
125
-
path: string
126
): Effect.Effect<number, LogCommandError, never> =>
127
Effect.tryPromise({
128
try: async () => {
···
154
exists
155
? Effect.succeed(true)
156
: du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB))
157
-
)
158
);
159
160
export const downloadIso = (url: string, options: Options) =>
···
176
if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
177
console.log(
178
chalk.yellowBright(
179
-
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`
180
-
)
181
);
182
return null;
183
}
···
195
if (outputExists) {
196
console.log(
197
chalk.yellowBright(
198
-
`File ${outputPath} already exists, skipping download.`
199
-
)
200
);
201
return outputPath;
202
}
···
207
chalk.blueBright(
208
`Downloading ${
209
url.endsWith(".iso") ? "ISO" : "image"
210
-
} from ${url}...`
211
-
)
212
);
213
const cmd = new Deno.Command("curl", {
214
args: ["-L", "-o", outputPath, url],
···
251
if (!success) {
252
console.error(
253
chalk.redBright(
254
-
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew."
255
-
)
256
);
257
Deno.exit(1);
258
}
···
265
try: () =>
266
Deno.copyFile(
267
`${brewPrefix}/share/qemu/edk2-arm-vars.fd`,
268
-
edk2VarsAarch64
269
),
270
catch: (error) => new LogCommandError({ cause: error }),
271
});
···
310
const configOK = yield* pipe(
311
fileExists("config.ign"),
312
Effect.flatMap(() => Effect.succeed(true)),
313
-
Effect.catchAll(() => Effect.succeed(false))
314
);
315
if (!configOK) {
316
console.error(
317
chalk.redBright(
318
-
"CoreOS image requires a config.ign file in the current directory."
319
-
)
320
);
321
Deno.exit(1);
322
}
···
356
imagePath &&
357
imagePath.endsWith(".qcow2") &&
358
imagePath.startsWith(
359
-
`di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`
360
)
361
) {
362
return [
···
371
372
export const setupAlpineArgs = (
373
imagePath?: string | null,
374
-
seed: string = "seed.iso"
375
) =>
376
Effect.sync(() => {
377
if (
···
392
393
export const setupDebianArgs = (
394
imagePath?: string | null,
395
-
seed: string = "seed.iso"
396
) =>
397
Effect.sync(() => {
398
if (
···
413
414
export const setupUbuntuArgs = (
415
imagePath?: string | null,
416
-
seed: string = "seed.iso"
417
) =>
418
Effect.sync(() => {
419
if (
···
434
435
export const setupAlmaLinuxArgs = (
436
imagePath?: string | null,
437
-
seed: string = "seed.iso"
438
) =>
439
Effect.sync(() => {
440
if (
···
455
456
export const setupRockyLinuxArgs = (
457
imagePath?: string | null,
458
-
seed: string = "seed.iso"
459
) =>
460
Effect.sync(() => {
461
if (
···
478
Effect.gen(function* () {
479
const macAddress = yield* generateRandomMacAddress();
480
481
-
const qemu =
482
-
Deno.build.arch === "aarch64"
483
-
? "qemu-system-aarch64"
484
-
: "qemu-system-x86_64";
485
486
const firmwareFiles = yield* setupFirmwareFilesIfNeeded();
487
let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image);
488
let fedoraArgs: string[] = yield* setupFedoraArgs(
489
isoPath || options.image,
490
-
options.seed
491
);
492
let gentooArgs: string[] = yield* setupGentooArgs(
493
isoPath || options.image,
494
-
options.seed
495
);
496
let alpineArgs: string[] = yield* setupAlpineArgs(
497
isoPath || options.image,
498
-
options.seed
499
);
500
let debianArgs: string[] = yield* setupDebianArgs(
501
isoPath || options.image,
502
-
options.seed
503
);
504
let ubuntuArgs: string[] = yield* setupUbuntuArgs(
505
isoPath || options.image,
506
-
options.seed
507
);
508
let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs(
509
isoPath || options.image,
510
-
options.seed
511
);
512
let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs(
513
isoPath || options.image,
514
-
options.seed
515
);
516
517
if (coreosArgs.length > 0 && !isoPath) {
···
584
options.image && [
585
"-drive",
586
`file=${options.image},format=${options.diskFormat},if=virtio`,
587
-
]
588
),
589
];
590
···
599
const logPath = `${LOGS_DIR}/${name}.log`;
600
601
const fullCommand = options.bridge
602
-
? `sudo ${qemu} ${qemuArgs
0
603
.slice(1)
604
-
.join(" ")} >> "${logPath}" 2>&1 & echo $!`
0
605
: `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
606
607
const { stdout } = yield* Effect.tryPromise({
···
627
cpus: options.cpus,
628
cpu: options.cpu,
629
diskSize: options.size || "20G",
630
-
diskFormat:
631
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
632
options.diskFormat ||
633
"raw",
634
portForward: options.portForward,
···
647
});
648
649
console.log(
650
-
`Virtual machine ${name} started in background (PID: ${qemuPid})`
651
);
652
console.log(`Logs will be written to: ${logPath}`);
653
···
670
cpus: options.cpus,
671
cpu: options.cpu,
672
diskSize: options.size || "20G",
673
-
diskFormat:
674
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
675
options.diskFormat ||
676
"raw",
677
portForward: options.portForward,
···
779
if (pathExists) {
780
console.log(
781
chalk.yellowBright(
782
-
`Drive image ${path} already exists, skipping creation.`
783
-
)
784
);
785
return;
786
}
···
807
});
808
809
export const fileExists = (
810
-
path: string
811
): Effect.Effect<void, NoSuchFileError, never> =>
812
Effect.try({
813
try: () => Deno.statSync(path),
···
815
});
816
817
export const constructCoreOSImageURL = (
818
-
image: string
819
): Effect.Effect<string, InvalidImageNameError, never> => {
820
// detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version>
821
const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/;
···
823
if (match) {
824
const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION;
825
return Effect.succeed(
826
-
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version)
827
);
828
}
829
···
831
new InvalidImageNameError({
832
image,
833
cause: "Image name does not match CoreOS naming conventions.",
834
-
})
835
);
836
};
837
···
860
});
861
862
export const constructNixOSImageURL = (
863
-
image: string
864
): Effect.Effect<string, InvalidImageNameError, never> => {
865
// detect with regex if image matches NixOS pattern: nixos or nixos-<version>
866
const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/;
···
868
if (match) {
869
const version = match[3] || NIXOS_DEFAULT_VERSION;
870
return Effect.succeed(
871
-
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version)
872
);
873
}
874
···
876
new InvalidImageNameError({
877
image,
878
cause: "Image name does not match NixOS naming conventions.",
879
-
})
880
);
881
};
882
883
export const constructFedoraImageURL = (
884
image: string,
885
-
cloud: boolean = false
886
): Effect.Effect<string, InvalidImageNameError, never> => {
887
// detect with regex if image matches Fedora pattern: fedora
888
const fedoraRegex = /^(fedora)$/;
···
895
new InvalidImageNameError({
896
image,
897
cause: "Image name does not match Fedora naming conventions.",
898
-
})
899
);
900
};
901
902
export const constructGentooImageURL = (
903
-
image: string
904
): Effect.Effect<string, InvalidImageNameError, never> => {
905
// detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo
906
const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/;
···
909
return Effect.succeed(
910
GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll(
911
"20251116T233105Z",
912
-
match[3]
913
-
)
914
);
915
}
916
···
922
new InvalidImageNameError({
923
image,
924
cause: "Image name does not match Gentoo naming conventions.",
925
-
})
926
);
927
};
928
929
export const constructDebianImageURL = (
930
image: string,
931
-
cloud: boolean = false
932
): Effect.Effect<string, InvalidImageNameError, never> => {
933
if (cloud && image === "debian") {
934
return Effect.succeed(DEBIAN_CLOUD_IMG_URL);
···
939
const match = image.match(debianRegex);
940
if (match?.[3]) {
941
return Effect.succeed(
942
-
DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3])
943
);
944
}
945
···
951
new InvalidImageNameError({
952
image,
953
cause: "Image name does not match Debian naming conventions.",
954
-
})
955
);
956
};
957
958
export const constructAlpineImageURL = (
959
-
image: string
960
): Effect.Effect<string, InvalidImageNameError, never> => {
961
// detect with regex if image matches alpine pattern: alpine-<version> or alpine
962
const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/;
963
const match = image.match(alpineRegex);
964
if (match?.[3]) {
965
return Effect.succeed(
966
-
ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3])
967
);
968
}
969
···
975
new InvalidImageNameError({
976
image,
977
cause: "Image name does not match Alpine naming conventions.",
978
-
})
979
);
980
};
981
982
export const constructUbuntuImageURL = (
983
image: string,
984
-
cloud: boolean = false
985
): Effect.Effect<string, InvalidImageNameError, never> => {
986
// detect with regex if image matches ubuntu pattern: ubuntu
987
const ubuntuRegex = /^(ubuntu)$/;
···
997
new InvalidImageNameError({
998
image,
999
cause: "Image name does not match Ubuntu naming conventions.",
1000
-
})
1001
);
1002
};
1003
1004
export const constructAlmaLinuxImageURL = (
1005
image: string,
1006
-
cloud: boolean = false
1007
): Effect.Effect<string, InvalidImageNameError, never> => {
1008
// detect with regex if image matches almalinux pattern: almalinux, almalinux
1009
const almaLinuxRegex = /^(almalinux|alma)$/;
···
1019
new InvalidImageNameError({
1020
image,
1021
cause: "Image name does not match AlmaLinux naming conventions.",
1022
-
})
1023
);
1024
};
1025
1026
export const constructRockyLinuxImageURL = (
1027
image: string,
1028
-
cloud: boolean = false
1029
): Effect.Effect<string, InvalidImageNameError, never> => {
1030
// detect with regex if image matches rockylinux pattern: rocky. rockylinux
1031
const rockyLinuxRegex = /^(rockylinux|rocky)$/;
···
1041
new InvalidImageNameError({
1042
image,
1043
cause: "Image name does not match RockyLinux naming conventions.",
1044
-
})
1045
);
1046
};
···
65
export const isValidISOurl = (url?: string): boolean => {
66
return Boolean(
67
(url?.startsWith("http://") || url?.startsWith("https://")) &&
68
+
url?.endsWith(".iso"),
69
);
70
};
71
···
91
});
92
93
export const validateImage = (
94
+
image: string,
95
): Effect.Effect<string, InvalidImageNameError, never> => {
96
const regex =
97
/^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/;
···
102
image,
103
cause:
104
"Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
105
+
}),
106
);
107
}
108
return Effect.succeed(image);
···
111
export const extractTag = (name: string) =>
112
pipe(
113
validateImage(name),
114
+
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")),
115
);
116
117
export const failOnMissingImage = (
118
+
image: Image | undefined,
119
): Effect.Effect<Image, Error, never> =>
120
image
121
? Effect.succeed(image)
122
: Effect.fail(new NoSuchImageError({ cause: "No such image" }));
123
124
export const du = (
125
+
path: string,
126
): Effect.Effect<number, LogCommandError, never> =>
127
Effect.tryPromise({
128
try: async () => {
···
154
exists
155
? Effect.succeed(true)
156
: du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB))
157
+
),
158
);
159
160
export const downloadIso = (url: string, options: Options) =>
···
176
if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
177
console.log(
178
chalk.yellowBright(
179
+
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
180
+
),
181
);
182
return null;
183
}
···
195
if (outputExists) {
196
console.log(
197
chalk.yellowBright(
198
+
`File ${outputPath} already exists, skipping download.`,
199
+
),
200
);
201
return outputPath;
202
}
···
207
chalk.blueBright(
208
`Downloading ${
209
url.endsWith(".iso") ? "ISO" : "image"
210
+
} from ${url}...`,
211
+
),
212
);
213
const cmd = new Deno.Command("curl", {
214
args: ["-L", "-o", outputPath, url],
···
251
if (!success) {
252
console.error(
253
chalk.redBright(
254
+
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.",
255
+
),
256
);
257
Deno.exit(1);
258
}
···
265
try: () =>
266
Deno.copyFile(
267
`${brewPrefix}/share/qemu/edk2-arm-vars.fd`,
268
+
edk2VarsAarch64,
269
),
270
catch: (error) => new LogCommandError({ cause: error }),
271
});
···
310
const configOK = yield* pipe(
311
fileExists("config.ign"),
312
Effect.flatMap(() => Effect.succeed(true)),
313
+
Effect.catchAll(() => Effect.succeed(false)),
314
);
315
if (!configOK) {
316
console.error(
317
chalk.redBright(
318
+
"CoreOS image requires a config.ign file in the current directory.",
319
+
),
320
);
321
Deno.exit(1);
322
}
···
356
imagePath &&
357
imagePath.endsWith(".qcow2") &&
358
imagePath.startsWith(
359
+
`di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`,
360
)
361
) {
362
return [
···
371
372
export const setupAlpineArgs = (
373
imagePath?: string | null,
374
+
seed: string = "seed.iso",
375
) =>
376
Effect.sync(() => {
377
if (
···
392
393
export const setupDebianArgs = (
394
imagePath?: string | null,
395
+
seed: string = "seed.iso",
396
) =>
397
Effect.sync(() => {
398
if (
···
413
414
export const setupUbuntuArgs = (
415
imagePath?: string | null,
416
+
seed: string = "seed.iso",
417
) =>
418
Effect.sync(() => {
419
if (
···
434
435
export const setupAlmaLinuxArgs = (
436
imagePath?: string | null,
437
+
seed: string = "seed.iso",
438
) =>
439
Effect.sync(() => {
440
if (
···
455
456
export const setupRockyLinuxArgs = (
457
imagePath?: string | null,
458
+
seed: string = "seed.iso",
459
) =>
460
Effect.sync(() => {
461
if (
···
478
Effect.gen(function* () {
479
const macAddress = yield* generateRandomMacAddress();
480
481
+
const qemu = Deno.build.arch === "aarch64"
482
+
? "qemu-system-aarch64"
483
+
: "qemu-system-x86_64";
0
484
485
const firmwareFiles = yield* setupFirmwareFilesIfNeeded();
486
let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image);
487
let fedoraArgs: string[] = yield* setupFedoraArgs(
488
isoPath || options.image,
489
+
options.seed,
490
);
491
let gentooArgs: string[] = yield* setupGentooArgs(
492
isoPath || options.image,
493
+
options.seed,
494
);
495
let alpineArgs: string[] = yield* setupAlpineArgs(
496
isoPath || options.image,
497
+
options.seed,
498
);
499
let debianArgs: string[] = yield* setupDebianArgs(
500
isoPath || options.image,
501
+
options.seed,
502
);
503
let ubuntuArgs: string[] = yield* setupUbuntuArgs(
504
isoPath || options.image,
505
+
options.seed,
506
);
507
let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs(
508
isoPath || options.image,
509
+
options.seed,
510
);
511
let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs(
512
isoPath || options.image,
513
+
options.seed,
514
);
515
516
if (coreosArgs.length > 0 && !isoPath) {
···
583
options.image && [
584
"-drive",
585
`file=${options.image},format=${options.diskFormat},if=virtio`,
586
+
],
587
),
588
];
589
···
598
const logPath = `${LOGS_DIR}/${name}.log`;
599
600
const fullCommand = options.bridge
601
+
? `sudo ${qemu} ${
602
+
qemuArgs
603
.slice(1)
604
+
.join(" ")
605
+
} >> "${logPath}" 2>&1 & echo $!`
606
: `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
607
608
const { stdout } = yield* Effect.tryPromise({
···
628
cpus: options.cpus,
629
cpu: options.cpu,
630
diskSize: options.size || "20G",
631
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
0
632
options.diskFormat ||
633
"raw",
634
portForward: options.portForward,
···
647
});
648
649
console.log(
650
+
`Virtual machine ${name} started in background (PID: ${qemuPid})`,
651
);
652
console.log(`Logs will be written to: ${logPath}`);
653
···
670
cpus: options.cpus,
671
cpu: options.cpu,
672
diskSize: options.size || "20G",
673
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
0
674
options.diskFormat ||
675
"raw",
676
portForward: options.portForward,
···
778
if (pathExists) {
779
console.log(
780
chalk.yellowBright(
781
+
`Drive image ${path} already exists, skipping creation.`,
782
+
),
783
);
784
return;
785
}
···
806
});
807
808
export const fileExists = (
809
+
path: string,
810
): Effect.Effect<void, NoSuchFileError, never> =>
811
Effect.try({
812
try: () => Deno.statSync(path),
···
814
});
815
816
export const constructCoreOSImageURL = (
817
+
image: string,
818
): Effect.Effect<string, InvalidImageNameError, never> => {
819
// detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version>
820
const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/;
···
822
if (match) {
823
const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION;
824
return Effect.succeed(
825
+
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version),
826
);
827
}
828
···
830
new InvalidImageNameError({
831
image,
832
cause: "Image name does not match CoreOS naming conventions.",
833
+
}),
834
);
835
};
836
···
859
});
860
861
export const constructNixOSImageURL = (
862
+
image: string,
863
): Effect.Effect<string, InvalidImageNameError, never> => {
864
// detect with regex if image matches NixOS pattern: nixos or nixos-<version>
865
const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/;
···
867
if (match) {
868
const version = match[3] || NIXOS_DEFAULT_VERSION;
869
return Effect.succeed(
870
+
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version),
871
);
872
}
873
···
875
new InvalidImageNameError({
876
image,
877
cause: "Image name does not match NixOS naming conventions.",
878
+
}),
879
);
880
};
881
882
export const constructFedoraImageURL = (
883
image: string,
884
+
cloud: boolean = false,
885
): Effect.Effect<string, InvalidImageNameError, never> => {
886
// detect with regex if image matches Fedora pattern: fedora
887
const fedoraRegex = /^(fedora)$/;
···
894
new InvalidImageNameError({
895
image,
896
cause: "Image name does not match Fedora naming conventions.",
897
+
}),
898
);
899
};
900
901
export const constructGentooImageURL = (
902
+
image: string,
903
): Effect.Effect<string, InvalidImageNameError, never> => {
904
// detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo
905
const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/;
···
908
return Effect.succeed(
909
GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll(
910
"20251116T233105Z",
911
+
match[3],
912
+
),
913
);
914
}
915
···
921
new InvalidImageNameError({
922
image,
923
cause: "Image name does not match Gentoo naming conventions.",
924
+
}),
925
);
926
};
927
928
export const constructDebianImageURL = (
929
image: string,
930
+
cloud: boolean = false,
931
): Effect.Effect<string, InvalidImageNameError, never> => {
932
if (cloud && image === "debian") {
933
return Effect.succeed(DEBIAN_CLOUD_IMG_URL);
···
938
const match = image.match(debianRegex);
939
if (match?.[3]) {
940
return Effect.succeed(
941
+
DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]),
942
);
943
}
944
···
950
new InvalidImageNameError({
951
image,
952
cause: "Image name does not match Debian naming conventions.",
953
+
}),
954
);
955
};
956
957
export const constructAlpineImageURL = (
958
+
image: string,
959
): Effect.Effect<string, InvalidImageNameError, never> => {
960
// detect with regex if image matches alpine pattern: alpine-<version> or alpine
961
const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/;
962
const match = image.match(alpineRegex);
963
if (match?.[3]) {
964
return Effect.succeed(
965
+
ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]),
966
);
967
}
968
···
974
new InvalidImageNameError({
975
image,
976
cause: "Image name does not match Alpine naming conventions.",
977
+
}),
978
);
979
};
980
981
export const constructUbuntuImageURL = (
982
image: string,
983
+
cloud: boolean = false,
984
): Effect.Effect<string, InvalidImageNameError, never> => {
985
// detect with regex if image matches ubuntu pattern: ubuntu
986
const ubuntuRegex = /^(ubuntu)$/;
···
996
new InvalidImageNameError({
997
image,
998
cause: "Image name does not match Ubuntu naming conventions.",
999
+
}),
1000
);
1001
};
1002
1003
export const constructAlmaLinuxImageURL = (
1004
image: string,
1005
+
cloud: boolean = false,
1006
): Effect.Effect<string, InvalidImageNameError, never> => {
1007
// detect with regex if image matches almalinux pattern: almalinux, almalinux
1008
const almaLinuxRegex = /^(almalinux|alma)$/;
···
1018
new InvalidImageNameError({
1019
image,
1020
cause: "Image name does not match AlmaLinux naming conventions.",
1021
+
}),
1022
);
1023
};
1024
1025
export const constructRockyLinuxImageURL = (
1026
image: string,
1027
+
cloud: boolean = false,
1028
): Effect.Effect<string, InvalidImageNameError, never> => {
1029
// detect with regex if image matches rockylinux pattern: rocky. rockylinux
1030
const rockyLinuxRegex = /^(rockylinux|rocky)$/;
···
1040
new InvalidImageNameError({
1041
image,
1042
cause: "Image name does not match RockyLinux naming conventions.",
1043
+
}),
1044
);
1045
};