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
258bd581
cef1190c
+67
-68
2 changed files
expand all
collapse all
unified
split
src
utils.ts
utils_test.ts
+44
-45
src/utils.ts
···
66
66
export const isValidISOurl = (url?: string): boolean => {
67
67
return Boolean(
68
68
(url?.startsWith("http://") || url?.startsWith("https://")) &&
69
69
-
url?.endsWith(".iso")
69
69
+
url?.endsWith(".iso"),
70
70
);
71
71
};
72
72
···
92
92
});
93
93
94
94
export const validateImage = (
95
95
-
image: string
95
95
+
image: string,
96
96
): Effect.Effect<string, InvalidImageNameError, never> => {
97
97
const regex =
98
98
/^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/;
···
103
103
image,
104
104
cause:
105
105
"Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
106
106
-
})
106
106
+
}),
107
107
);
108
108
}
109
109
return Effect.succeed(image);
···
112
112
export const extractTag = (name: string) =>
113
113
pipe(
114
114
validateImage(name),
115
115
-
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest"))
115
115
+
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")),
116
116
);
117
117
118
118
export const failOnMissingImage = (
119
119
-
image: Image | undefined
119
119
+
image: Image | undefined,
120
120
): Effect.Effect<Image, Error, never> =>
121
121
image
122
122
? Effect.succeed(image)
123
123
: Effect.fail(new NoSuchImageError({ cause: "No such image" }));
124
124
125
125
export const du = (
126
126
-
path: string
126
126
+
path: string,
127
127
): Effect.Effect<number, LogCommandError, never> =>
128
128
Effect.tryPromise({
129
129
try: async () => {
···
155
155
exists
156
156
? Effect.succeed(true)
157
157
: du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB))
158
158
-
)
158
158
+
),
159
159
);
160
160
161
161
export const downloadIso = (url: string, options: Options) =>
···
177
177
if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
178
178
console.log(
179
179
chalk.yellowBright(
180
180
-
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`
181
181
-
)
180
180
+
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
181
181
+
),
182
182
);
183
183
return null;
184
184
}
···
196
196
if (outputExists) {
197
197
console.log(
198
198
chalk.yellowBright(
199
199
-
`File ${outputPath} already exists, skipping download.`
200
200
-
)
199
199
+
`File ${outputPath} already exists, skipping download.`,
200
200
+
),
201
201
);
202
202
return outputPath;
203
203
}
···
246
246
if (!success) {
247
247
console.error(
248
248
chalk.redBright(
249
249
-
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew."
250
250
-
)
249
249
+
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.",
250
250
+
),
251
251
);
252
252
Deno.exit(1);
253
253
}
···
260
260
try: () =>
261
261
Deno.copyFile(
262
262
`${brewPrefix}/share/qemu/edk2-arm-vars.fd`,
263
263
-
edk2VarsAarch64
263
263
+
edk2VarsAarch64,
264
264
),
265
265
catch: (error) => new LogCommandError({ cause: error }),
266
266
});
···
305
305
const configOK = yield* pipe(
306
306
fileExists("config.ign"),
307
307
Effect.flatMap(() => Effect.succeed(true)),
308
308
-
Effect.catchAll(() => Effect.succeed(false))
308
308
+
Effect.catchAll(() => Effect.succeed(false)),
309
309
);
310
310
if (!configOK) {
311
311
console.error(
312
312
chalk.redBright(
313
313
-
"CoreOS image requires a config.ign file in the current directory."
314
314
-
)
313
313
+
"CoreOS image requires a config.ign file in the current directory.",
314
314
+
),
315
315
);
316
316
Deno.exit(1);
317
317
}
···
346
346
imagePath &&
347
347
imagePath.endsWith(".qcow2") &&
348
348
imagePath.startsWith(
349
349
-
`di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`
349
349
+
`di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`,
350
350
)
351
351
) {
352
352
return ["-drive", `file=${imagePath},format=qcow2,if=virtio`];
···
359
359
Effect.gen(function* () {
360
360
const macAddress = yield* generateRandomMacAddress();
361
361
362
362
-
const qemu =
363
363
-
Deno.build.arch === "aarch64"
364
364
-
? "qemu-system-aarch64"
365
365
-
: "qemu-system-x86_64";
362
362
+
const qemu = Deno.build.arch === "aarch64"
363
363
+
? "qemu-system-aarch64"
364
364
+
: "qemu-system-x86_64";
366
365
367
366
const firmwareFiles = yield* setupFirmwareFilesIfNeeded();
368
367
let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image);
···
414
413
options.image && [
415
414
"-drive",
416
415
`file=${options.image},format=${options.diskFormat},if=virtio`,
417
417
-
]
416
416
+
],
418
417
),
419
418
];
420
419
···
429
428
const logPath = `${LOGS_DIR}/${name}.log`;
430
429
431
430
const fullCommand = options.bridge
432
432
-
? `sudo ${qemu} ${qemuArgs
431
431
+
? `sudo ${qemu} ${
432
432
+
qemuArgs
433
433
.slice(1)
434
434
-
.join(" ")} >> "${logPath}" 2>&1 & echo $!`
434
434
+
.join(" ")
435
435
+
} >> "${logPath}" 2>&1 & echo $!`
435
436
: `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
436
437
437
438
const { stdout } = yield* Effect.tryPromise({
···
457
458
cpus: options.cpus,
458
459
cpu: options.cpu,
459
460
diskSize: options.size || "20G",
460
460
-
diskFormat:
461
461
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
461
461
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
462
462
options.diskFormat ||
463
463
"raw",
464
464
portForward: options.portForward,
···
477
477
});
478
478
479
479
console.log(
480
480
-
`Virtual machine ${name} started in background (PID: ${qemuPid})`
480
480
+
`Virtual machine ${name} started in background (PID: ${qemuPid})`,
481
481
);
482
482
console.log(`Logs will be written to: ${logPath}`);
483
483
···
500
500
cpus: options.cpus,
501
501
cpu: options.cpu,
502
502
diskSize: options.size || "20G",
503
503
-
diskFormat:
504
504
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
503
503
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
505
504
options.diskFormat ||
506
505
"raw",
507
506
portForward: options.portForward,
···
609
608
if (pathExists) {
610
609
console.log(
611
610
chalk.yellowBright(
612
612
-
`Drive image ${path} already exists, skipping creation.`
613
613
-
)
611
611
+
`Drive image ${path} already exists, skipping creation.`,
612
612
+
),
614
613
);
615
614
return;
616
615
}
···
637
636
});
638
637
639
638
export const fileExists = (
640
640
-
path: string
639
639
+
path: string,
641
640
): Effect.Effect<void, NoSuchFileError, never> =>
642
641
Effect.try({
643
642
try: () => Deno.statSync(path),
···
645
644
});
646
645
647
646
export const constructCoreOSImageURL = (
648
648
-
image: string
647
647
+
image: string,
649
648
): Effect.Effect<string, InvalidImageNameError, never> => {
650
649
// detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version>
651
650
const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/;
···
653
652
if (match) {
654
653
const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION;
655
654
return Effect.succeed(
656
656
-
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version)
655
655
+
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version),
657
656
);
658
657
}
659
658
···
661
660
new InvalidImageNameError({
662
661
image,
663
662
cause: "Image name does not match CoreOS naming conventions.",
664
664
-
})
663
663
+
}),
665
664
);
666
665
};
667
666
···
690
689
});
691
690
692
691
export const constructNixOSImageURL = (
693
693
-
image: string
692
692
+
image: string,
694
693
): Effect.Effect<string, InvalidImageNameError, never> => {
695
694
// detect with regex if image matches NixOS pattern: nixos or nixos-<version>
696
695
const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/;
···
698
697
if (match) {
699
698
const version = match[3] || NIXOS_DEFAULT_VERSION;
700
699
return Effect.succeed(
701
701
-
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version)
700
700
+
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version),
702
701
);
703
702
}
704
703
···
706
705
new InvalidImageNameError({
707
706
image,
708
707
cause: "Image name does not match NixOS naming conventions.",
709
709
-
})
708
708
+
}),
710
709
);
711
710
};
712
711
713
712
export const constructFedoraImageURL = (
714
714
-
image: string
713
713
+
image: string,
715
714
): Effect.Effect<string, InvalidImageNameError, never> => {
716
715
// detect with regex if image matches Fedora pattern: fedora
717
716
const fedoraRegex = /^(fedora)$/;
···
724
723
new InvalidImageNameError({
725
724
image,
726
725
cause: "Image name does not match Fedora naming conventions.",
727
727
-
})
726
726
+
}),
728
727
);
729
728
};
730
729
731
730
export const constructGentooImageURL = (
732
732
-
image: string
731
731
+
image: string,
733
732
): Effect.Effect<string, InvalidImageNameError, never> => {
734
733
// detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo
735
734
const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/;
···
738
737
return Effect.succeed(
739
738
GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll(
740
739
"20251116T233105Z",
741
741
-
match[3]
742
742
-
)
740
740
+
match[3],
741
741
+
),
743
742
);
744
743
}
745
744
···
751
750
new InvalidImageNameError({
752
751
image,
753
752
cause: "Image name does not match Gentoo naming conventions.",
754
754
-
})
753
753
+
}),
755
754
);
756
755
};
+23
-23
src/utils_test.ts
···
15
15
const url = Effect.runSync(
16
16
pipe(
17
17
constructCoreOSImageURL("fedora-coreos"),
18
18
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
19
19
-
)
18
18
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
19
19
+
),
20
20
);
21
21
22
22
assertEquals(url, FEDORA_COREOS_IMG_URL);
···
26
26
const url = Effect.runSync(
27
27
pipe(
28
28
constructCoreOSImageURL("coreos"),
29
29
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
30
30
-
)
29
29
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
30
30
+
),
31
31
);
32
32
33
33
assertEquals(url, FEDORA_COREOS_IMG_URL);
···
37
37
const url = Effect.runSync(
38
38
pipe(
39
39
constructCoreOSImageURL("fedora-coreos-43.20251024.2.0"),
40
40
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
41
41
-
)
40
40
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
41
41
+
),
42
42
);
43
43
44
44
assertEquals(
45
45
url,
46
46
"https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/43.20251024.2.0/" +
47
47
-
`${Deno.build.arch}/fedora-coreos-43.20251024.2.0-qemu.${Deno.build.arch}.qcow2.xz`
47
47
+
`${Deno.build.arch}/fedora-coreos-43.20251024.2.0-qemu.${Deno.build.arch}.qcow2.xz`,
48
48
);
49
49
});
50
50
···
52
52
const url = Effect.runSync(
53
53
pipe(
54
54
constructCoreOSImageURL("fedora-coreos-latest"),
55
55
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
56
56
-
)
55
55
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
56
56
+
),
57
57
);
58
58
59
59
assertEquals(url, null);
···
63
63
const url = Effect.runSync(
64
64
pipe(
65
65
constructNixOSImageURL("nixos"),
66
66
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
67
67
-
)
66
66
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
67
67
+
),
68
68
);
69
69
70
70
assertEquals(url, NIXOS_ISO_URL);
···
74
74
const url = Effect.runSync(
75
75
pipe(
76
76
constructNixOSImageURL("nixos-24.05"),
77
77
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
78
78
-
)
77
77
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
78
78
+
),
79
79
);
80
80
81
81
assertEquals(
82
82
url,
83
83
-
`https://channels.nixos.org/nixos-24.05/latest-nixos-minimal-${Deno.build.arch}-linux.iso`
83
83
+
`https://channels.nixos.org/nixos-24.05/latest-nixos-minimal-${Deno.build.arch}-linux.iso`,
84
84
);
85
85
});
86
86
···
88
88
const url = Effect.runSync(
89
89
pipe(
90
90
constructNixOSImageURL("nixos-latest"),
91
91
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
92
92
-
)
91
91
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
92
92
+
),
93
93
);
94
94
95
95
assertEquals(url, null);
···
99
99
const url = Effect.runSync(
100
100
pipe(
101
101
constructGentooImageURL("gentoo-20251116T161545Z"),
102
102
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
103
103
-
)
102
102
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
103
103
+
),
104
104
);
105
105
106
106
const arch = Deno.build.arch === "aarch64" ? "arm64" : "amd64";
107
107
assertEquals(
108
108
url,
109
109
-
`https://distfiles.gentoo.org/releases/${arch}/autobuilds/20251116T161545Z/di-${arch}-console-20251116T161545Z.qcow2`
109
109
+
`https://distfiles.gentoo.org/releases/${arch}/autobuilds/20251116T161545Z/di-${arch}-console-20251116T161545Z.qcow2`,
110
110
);
111
111
});
112
112
···
114
114
const url = Effect.runSync(
115
115
pipe(
116
116
constructGentooImageURL("gentoo"),
117
117
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
118
118
-
)
117
117
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
118
118
+
),
119
119
);
120
120
121
121
assertEquals(url, GENTOO_IMG_URL);
···
125
125
const url = Effect.runSync(
126
126
pipe(
127
127
constructGentooImageURL("gentoo-latest"),
128
128
-
Effect.catchAll((_error) => Effect.succeed(null as string | null))
129
129
-
)
128
128
+
Effect.catchAll((_error) => Effect.succeed(null as string | null)),
129
129
+
),
130
130
);
131
131
132
132
assertEquals(url, null);