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