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
19b70a6b
fe9a754a
+94
-91
3 changed files
expand all
collapse all
unified
split
src
constants.ts
subcommands
start.ts
utils.ts
+9
-7
src/constants.ts
···
6
6
export const IMAGE_DIR: string = `${CONFIG_DIR}/images`;
7
7
export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`;
8
8
9
9
-
export const UBUNTU_ISO_URL: string =
10
10
-
Deno.build.arch === "aarch64"
11
11
-
? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso"
12
12
-
: "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-live-server-amd64.iso";
9
9
+
export const UBUNTU_ISO_URL: string = Deno.build.arch === "aarch64"
10
10
+
? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso"
11
11
+
: "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-live-server-amd64.iso";
13
12
14
13
export const FEDORA_COREOS_DEFAULT_VERSION: string = "43.20251024.3.0";
15
15
-
export const FEDORA_COREOS_IMG_URL: string = `https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/${FEDORA_COREOS_DEFAULT_VERSION}/${Deno.build.arch}/fedora-coreos-${FEDORA_COREOS_DEFAULT_VERSION}-qemu.${Deno.build.arch}.qcow2.xz`;
14
14
+
export const FEDORA_COREOS_IMG_URL: string =
15
15
+
`https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/${FEDORA_COREOS_DEFAULT_VERSION}/${Deno.build.arch}/fedora-coreos-${FEDORA_COREOS_DEFAULT_VERSION}-qemu.${Deno.build.arch}.qcow2.xz`;
16
16
17
17
export const NIXOS_DEFAULT_VERSION: string = "25.05";
18
18
-
export const NIXOS_ISO_URL: string = `https://channels.nixos.org/nixos-${NIXOS_DEFAULT_VERSION}/latest-nixos-minimal-${Deno.build.arch}-linux.iso`;
18
18
+
export const NIXOS_ISO_URL: string =
19
19
+
`https://channels.nixos.org/nixos-${NIXOS_DEFAULT_VERSION}/latest-nixos-minimal-${Deno.build.arch}-linux.iso`;
19
20
20
20
-
export const FEDORA_IMG_URL: string = `https://download.fedoraproject.org/pub/fedora/linux/releases/43/Server/${Deno.build.arch}/images/Fedora-Server-Guest-Generic-43-1.6.${Deno.build.arch}.qcow2`;
21
21
+
export const FEDORA_IMG_URL: string =
22
22
+
`https://download.fedoraproject.org/pub/fedora/linux/releases/43/Server/${Deno.build.arch}/images/Fedora-Server-Guest-Generic-43-1.6.${Deno.build.arch}.qcow2`;
+46
-44
src/subcommands/start.ts
···
17
17
}> {}
18
18
19
19
export class VmAlreadyRunningError extends Data.TaggedError(
20
20
-
"VmAlreadyRunningError"
20
20
+
"VmAlreadyRunningError",
21
21
)<{
22
22
name: string;
23
23
}> {}
···
31
31
getInstanceState(name),
32
32
Effect.flatMap((vm) =>
33
33
vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name }))
34
34
-
)
34
34
+
),
35
35
);
36
36
37
37
const logStarting = (vm: VirtualMachine) =>
···
44
44
export const setupFirmware = () => setupFirmwareFilesIfNeeded();
45
45
46
46
export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => {
47
47
-
const qemu =
48
48
-
Deno.build.arch === "aarch64"
49
49
-
? "qemu-system-aarch64"
50
50
-
: "qemu-system-x86_64";
47
47
+
const qemu = Deno.build.arch === "aarch64"
48
48
+
? "qemu-system-aarch64"
49
49
+
: "qemu-system-x86_64";
51
50
52
51
let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath));
53
52
···
84
83
vm.drivePath && [
85
84
"-drive",
86
85
`file=${vm.drivePath},format=${vm.diskFormat},if=virtio`,
87
87
-
]
86
86
+
],
88
87
),
89
88
...coreosArgs,
90
89
...(vm.volume ? [] : ["-snapshot"]),
···
100
99
export const startDetachedQemu = (
101
100
name: string,
102
101
vm: VirtualMachine,
103
103
-
qemuArgs: string[]
102
102
+
qemuArgs: string[],
104
103
) => {
105
105
-
const qemu =
106
106
-
Deno.build.arch === "aarch64"
107
107
-
? "qemu-system-aarch64"
108
108
-
: "qemu-system-x86_64";
104
104
+
const qemu = Deno.build.arch === "aarch64"
105
105
+
? "qemu-system-aarch64"
106
106
+
: "qemu-system-x86_64";
109
107
110
108
const logPath = `${LOGS_DIR}/${vm.name}.log`;
111
109
112
110
const fullCommand = vm.bridge
113
113
-
? `sudo ${qemu} ${qemuArgs
111
111
+
? `sudo ${qemu} ${
112
112
+
qemuArgs
114
113
.slice(1)
115
115
-
.join(" ")} >> "${logPath}" 2>&1 & echo $!`
114
114
+
.join(" ")
115
115
+
} >> "${logPath}" 2>&1 & echo $!`
116
116
: `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
117
117
118
118
return Effect.tryPromise({
···
143
143
Effect.flatMap(({ qemuPid, logPath }) =>
144
144
pipe(
145
145
updateInstanceState(name, "RUNNING", qemuPid),
146
146
-
Effect.map(() => ({ vm, qemuPid, logPath }))
146
146
+
Effect.map(() => ({ vm, qemuPid, logPath })),
147
147
)
148
148
-
)
148
148
+
),
149
149
);
150
150
};
151
151
···
160
160
}) =>
161
161
Effect.sync(() => {
162
162
console.log(
163
163
-
`Virtual machine ${vm.name} started in background (PID: ${qemuPid})`
163
163
+
`Virtual machine ${vm.name} started in background (PID: ${qemuPid})`,
164
164
);
165
165
console.log(`Logs will be written to: ${logPath}`);
166
166
});
···
168
168
const startInteractiveQemu = (
169
169
name: string,
170
170
vm: VirtualMachine,
171
171
-
qemuArgs: string[]
171
171
+
qemuArgs: string[],
172
172
) => {
173
173
-
const qemu =
174
174
-
Deno.build.arch === "aarch64"
175
175
-
? "qemu-system-aarch64"
176
176
-
: "qemu-system-x86_64";
173
173
+
const qemu = Deno.build.arch === "aarch64"
174
174
+
? "qemu-system-aarch64"
175
175
+
: "qemu-system-x86_64";
177
176
178
177
return Effect.tryPromise({
179
178
try: async () => {
···
209
208
});
210
209
211
210
export const createVolumeIfNeeded = (
212
212
-
vm: VirtualMachine
211
211
+
vm: VirtualMachine,
213
212
): Effect.Effect<[VirtualMachine, Volume?], Error, never> =>
214
213
Effect.gen(function* () {
215
214
const { flags } = parseFlags(Deno.args);
···
224
223
225
224
if (!vm.drivePath) {
226
225
throw new Error(
227
227
-
`Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`
226
226
+
`Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`,
228
227
);
229
228
}
230
229
···
267
266
diskFormat: volume ? "qcow2" : vm.diskFormat,
268
267
volume: volume?.path,
269
268
},
270
270
-
firmwareArgs
269
269
+
firmwareArgs,
271
270
)
272
271
),
273
272
Effect.flatMap((qemuArgs) =>
···
277
276
startDetachedQemu(name, { ...vm, volume: volume?.path }, qemuArgs)
278
277
),
279
278
Effect.tap(logDetachedSuccess),
280
280
-
Effect.map(() => 0) // Exit code 0
279
279
+
Effect.map(() => 0), // Exit code 0
281
280
)
282
282
-
)
281
281
+
),
283
282
)
284
283
),
285
285
-
Effect.catchAll(handleError)
284
284
+
Effect.catchAll(handleError),
286
285
);
287
286
288
287
const startInteractiveEffect = (name: string) =>
···
303
302
diskFormat: volume ? "qcow2" : vm.diskFormat,
304
303
volume: volume?.path,
305
304
},
306
306
-
firmwareArgs
305
305
+
firmwareArgs,
307
306
)
308
307
),
309
308
Effect.flatMap((qemuArgs) =>
310
309
startInteractiveQemu(name, { ...vm, volume: volume?.path }, qemuArgs)
311
310
),
312
312
-
Effect.map((status) => (status.success ? 0 : status.code || 1))
311
311
+
Effect.map((status) => (status.success ? 0 : status.code || 1)),
313
312
)
314
313
),
315
315
-
Effect.catchAll(handleError)
314
314
+
Effect.catchAll(handleError),
316
315
);
317
316
318
317
export default async function (name: string, detach: boolean = false) {
319
318
const exitCode = await Effect.runPromise(
320
320
-
detach ? startDetachedEffect(name) : startInteractiveEffect(name)
319
319
+
detach ? startDetachedEffect(name) : startInteractiveEffect(name),
321
320
);
322
321
323
322
if (detach) {
···
331
330
const { flags } = parseFlags(Deno.args);
332
331
return {
333
332
...vm,
334
334
-
memory:
335
335
-
flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory,
333
333
+
memory: flags.memory || flags.m
334
334
+
? String(flags.memory || flags.m)
335
335
+
: vm.memory,
336
336
cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus,
337
337
cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu,
338
338
diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat,
339
339
-
portForward:
340
340
-
flags.portForward || flags.p
341
341
-
? String(flags.portForward || flags.p)
342
342
-
: vm.portForward,
343
343
-
drivePath:
344
344
-
flags.image || flags.i ? String(flags.image || flags.i) : vm.drivePath,
345
345
-
bridge:
346
346
-
flags.bridge || flags.b ? String(flags.bridge || flags.b) : vm.bridge,
347
347
-
diskSize:
348
348
-
flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize,
339
339
+
portForward: flags.portForward || flags.p
340
340
+
? String(flags.portForward || flags.p)
341
341
+
: vm.portForward,
342
342
+
drivePath: flags.image || flags.i
343
343
+
? String(flags.image || flags.i)
344
344
+
: vm.drivePath,
345
345
+
bridge: flags.bridge || flags.b
346
346
+
? String(flags.bridge || flags.b)
347
347
+
: vm.bridge,
348
348
+
diskSize: flags.size || flags.s
349
349
+
? String(flags.size || flags.s)
350
350
+
: vm.diskSize,
349
351
};
350
352
}
+39
-40
src/utils.ts
···
65
65
export const isValidISOurl = (url?: string): boolean => {
66
66
return Boolean(
67
67
(url?.startsWith("http://") || url?.startsWith("https://")) &&
68
68
-
url?.endsWith(".iso")
68
68
+
url?.endsWith(".iso"),
69
69
);
70
70
};
71
71
···
91
91
});
92
92
93
93
export const validateImage = (
94
94
-
image: string
94
94
+
image: string,
95
95
): Effect.Effect<string, InvalidImageNameError, never> => {
96
96
const regex =
97
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
102
image,
103
103
cause:
104
104
"Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
105
105
-
})
105
105
+
}),
106
106
);
107
107
}
108
108
return Effect.succeed(image);
···
111
111
export const extractTag = (name: string) =>
112
112
pipe(
113
113
validateImage(name),
114
114
-
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest"))
114
114
+
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")),
115
115
);
116
116
117
117
export const failOnMissingImage = (
118
118
-
image: Image | undefined
118
118
+
image: Image | undefined,
119
119
): Effect.Effect<Image, Error, never> =>
120
120
image
121
121
? Effect.succeed(image)
122
122
: Effect.fail(new NoSuchImageError({ cause: "No such image" }));
123
123
124
124
export const du = (
125
125
-
path: string
125
125
+
path: string,
126
126
): Effect.Effect<number, LogCommandError, never> =>
127
127
Effect.tryPromise({
128
128
try: async () => {
···
154
154
exists
155
155
? Effect.succeed(true)
156
156
: du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB))
157
157
-
)
157
157
+
),
158
158
);
159
159
160
160
export const downloadIso = (url: string, options: Options) =>
···
176
176
if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
177
177
console.log(
178
178
chalk.yellowBright(
179
179
-
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`
180
180
-
)
179
179
+
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
180
180
+
),
181
181
);
182
182
return null;
183
183
}
···
195
195
if (outputExists) {
196
196
console.log(
197
197
chalk.yellowBright(
198
198
-
`File ${outputPath} already exists, skipping download.`
199
199
-
)
198
198
+
`File ${outputPath} already exists, skipping download.`,
199
199
+
),
200
200
);
201
201
return outputPath;
202
202
}
···
245
245
if (!success) {
246
246
console.error(
247
247
chalk.redBright(
248
248
-
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew."
249
249
-
)
248
248
+
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.",
249
249
+
),
250
250
);
251
251
Deno.exit(1);
252
252
}
···
259
259
try: () =>
260
260
Deno.copyFile(
261
261
`${brewPrefix}/share/qemu/edk2-arm-vars.fd`,
262
262
-
edk2VarsAarch64
262
262
+
edk2VarsAarch64,
263
263
),
264
264
catch: (error) => new LogCommandError({ cause: error }),
265
265
});
···
304
304
const configOK = yield* pipe(
305
305
fileExists("config.ign"),
306
306
Effect.flatMap(() => Effect.succeed(true)),
307
307
-
Effect.catchAll(() => Effect.succeed(false))
307
307
+
Effect.catchAll(() => Effect.succeed(false)),
308
308
);
309
309
if (!configOK) {
310
310
console.error(
311
311
chalk.redBright(
312
312
-
"CoreOS image requires a config.ign file in the current directory."
313
313
-
)
312
312
+
"CoreOS image requires a config.ign file in the current directory.",
313
313
+
),
314
314
);
315
315
Deno.exit(1);
316
316
}
···
343
343
Effect.gen(function* () {
344
344
const macAddress = yield* generateRandomMacAddress();
345
345
346
346
-
const qemu =
347
347
-
Deno.build.arch === "aarch64"
348
348
-
? "qemu-system-aarch64"
349
349
-
: "qemu-system-x86_64";
346
346
+
const qemu = Deno.build.arch === "aarch64"
347
347
+
? "qemu-system-aarch64"
348
348
+
: "qemu-system-x86_64";
350
349
351
350
const firmwareFiles = yield* setupFirmwareFilesIfNeeded();
352
351
let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image);
···
392
391
options.image && [
393
392
"-drive",
394
393
`file=${options.image},format=${options.diskFormat},if=virtio`,
395
395
-
]
394
394
+
],
396
395
),
397
396
];
398
397
···
407
406
const logPath = `${LOGS_DIR}/${name}.log`;
408
407
409
408
const fullCommand = options.bridge
410
410
-
? `sudo ${qemu} ${qemuArgs
409
409
+
? `sudo ${qemu} ${
410
410
+
qemuArgs
411
411
.slice(1)
412
412
-
.join(" ")} >> "${logPath}" 2>&1 & echo $!`
412
412
+
.join(" ")
413
413
+
} >> "${logPath}" 2>&1 & echo $!`
413
414
: `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
414
415
415
416
const { stdout } = yield* Effect.tryPromise({
···
435
436
cpus: options.cpus,
436
437
cpu: options.cpu,
437
438
diskSize: options.size || "20G",
438
438
-
diskFormat:
439
439
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
439
439
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
440
440
options.diskFormat ||
441
441
"raw",
442
442
portForward: options.portForward,
···
455
455
});
456
456
457
457
console.log(
458
458
-
`Virtual machine ${name} started in background (PID: ${qemuPid})`
458
458
+
`Virtual machine ${name} started in background (PID: ${qemuPid})`,
459
459
);
460
460
console.log(`Logs will be written to: ${logPath}`);
461
461
···
478
478
cpus: options.cpus,
479
479
cpu: options.cpu,
480
480
diskSize: options.size || "20G",
481
481
-
diskFormat:
482
482
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
481
481
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
483
482
options.diskFormat ||
484
483
"raw",
485
484
portForward: options.portForward,
···
587
586
if (pathExists) {
588
587
console.log(
589
588
chalk.yellowBright(
590
590
-
`Drive image ${path} already exists, skipping creation.`
591
591
-
)
589
589
+
`Drive image ${path} already exists, skipping creation.`,
590
590
+
),
592
591
);
593
592
return;
594
593
}
···
615
614
});
616
615
617
616
export const fileExists = (
618
618
-
path: string
617
617
+
path: string,
619
618
): Effect.Effect<void, NoSuchFileError, never> =>
620
619
Effect.try({
621
620
try: () => Deno.statSync(path),
···
623
622
});
624
623
625
624
export const constructCoreOSImageURL = (
626
626
-
image: string
625
625
+
image: string,
627
626
): Effect.Effect<string, InvalidImageNameError, never> => {
628
627
// detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version>
629
628
const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/;
···
631
630
if (match) {
632
631
const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION;
633
632
return Effect.succeed(
634
634
-
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version)
633
633
+
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version),
635
634
);
636
635
}
637
636
···
639
638
new InvalidImageNameError({
640
639
image,
641
640
cause: "Image name does not match CoreOS naming conventions.",
642
642
-
})
641
641
+
}),
643
642
);
644
643
};
645
644
···
668
667
});
669
668
670
669
export const constructNixOSImageURL = (
671
671
-
image: string
670
670
+
image: string,
672
671
): Effect.Effect<string, InvalidImageNameError, never> => {
673
672
// detect with regex if image matches NixOS pattern: nixos or nixos-<version>
674
673
const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/;
···
676
675
if (match) {
677
676
const version = match[3] || NIXOS_DEFAULT_VERSION;
678
677
return Effect.succeed(
679
679
-
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version)
678
678
+
NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version),
680
679
);
681
680
}
682
681
···
684
683
new InvalidImageNameError({
685
684
image,
686
685
cause: "Image name does not match NixOS naming conventions.",
687
687
-
})
686
686
+
}),
688
687
);
689
688
};
690
689
691
690
export const constructFedoraImageURL = (
692
692
-
image: string
691
691
+
image: string,
693
692
): Effect.Effect<string, InvalidImageNameError, never> => {
694
693
// detect with regex if image matches Fedora pattern: fedora
695
694
const fedoraRegex = /^(fedora)$/;
···
702
701
new InvalidImageNameError({
703
702
image,
704
703
cause: "Image name does not match Fedora naming conventions.",
705
705
-
})
704
704
+
}),
706
705
);
707
706
};