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
format
tsiry-sandratraina.com
3 months ago
55b04fb0
5ea3303c
+108
-103
3 changed files
expand all
collapse all
unified
split
src
subcommands
restart.ts
start.ts
utils.ts
+28
-24
src/subcommands/restart.ts
···
28
28
getInstanceState(name),
29
29
Effect.flatMap((vm) =>
30
30
vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name }))
31
31
-
)
31
31
+
),
32
32
);
33
33
34
34
const killQemu = (vm: VirtualMachine) =>
···
37
37
success
38
38
? Effect.succeed(vm)
39
39
: Effect.fail(new KillQemuError({ vmName: vm.name }))
40
40
-
)
40
40
+
),
41
41
);
42
42
43
43
const sleep = (ms: number) =>
···
55
55
const setupFirmware = () => setupFirmwareFilesIfNeeded();
56
56
57
57
const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => {
58
58
-
const qemu =
59
59
-
Deno.build.arch === "aarch64"
60
60
-
? "qemu-system-aarch64"
61
61
-
: "qemu-system-x86_64";
58
58
+
const qemu = Deno.build.arch === "aarch64"
59
59
+
? "qemu-system-aarch64"
60
60
+
: "qemu-system-x86_64";
62
61
63
62
let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath));
64
63
···
95
94
vm.drivePath && [
96
95
"-drive",
97
96
`file=${vm.drivePath},format=${vm.diskFormat},if=virtio`,
98
98
-
]
97
97
+
],
99
98
),
100
99
...coreosArgs,
101
100
]);
102
101
};
103
102
104
103
const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => {
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({
···
134
134
const logSuccess = (vm: VirtualMachine, qemuPid: number, logPath: string) =>
135
135
Effect.sync(() => {
136
136
console.log(
137
137
-
`${chalk.greenBright(vm.name)} restarted with PID ${chalk.greenBright(
138
138
-
qemuPid
139
139
-
)}.`
137
137
+
`${chalk.greenBright(vm.name)} restarted with PID ${
138
138
+
chalk.greenBright(
139
139
+
qemuPid,
140
140
+
)
141
141
+
}.`,
140
142
);
141
143
console.log(`Logs are being written to ${chalk.blueBright(logPath)}`);
142
144
});
143
145
144
146
const handleError = (
145
145
-
error: VmNotFoundError | KillQemuError | CommandError | Error
147
147
+
error: VmNotFoundError | KillQemuError | CommandError | Error,
146
148
) =>
147
149
Effect.sync(() => {
148
150
if (error instanceof VmNotFoundError) {
149
151
console.error(
150
150
-
`Virtual machine with name or ID ${chalk.greenBright(
151
151
-
error.name
152
152
-
)} not found.`
152
152
+
`Virtual machine with name or ID ${
153
153
+
chalk.greenBright(
154
154
+
error.name,
155
155
+
)
156
156
+
} not found.`,
153
157
);
154
158
} else if (error instanceof KillQemuError) {
155
159
console.error(
156
156
-
`Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`
160
160
+
`Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`,
157
161
);
158
162
} else {
159
163
console.error(`An error occurred: ${error}`);
···
179
183
pipe(
180
184
updateInstanceState(vm.id, "RUNNING", qemuPid),
181
185
Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)),
182
182
-
Effect.flatMap(() => sleep(2000))
186
186
+
Effect.flatMap(() => sleep(2000)),
183
187
)
184
184
-
)
188
188
+
),
185
189
)
186
190
),
187
187
-
Effect.catchAll(handleError)
191
191
+
Effect.catchAll(handleError),
188
192
);
189
193
190
194
export default async function (name: string) {
+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
]);
···
99
98
export const startDetachedQemu = (
100
99
name: string,
101
100
vm: VirtualMachine,
102
102
-
qemuArgs: string[]
101
101
+
qemuArgs: string[],
103
102
) => {
104
104
-
const qemu =
105
105
-
Deno.build.arch === "aarch64"
106
106
-
? "qemu-system-aarch64"
107
107
-
: "qemu-system-x86_64";
103
103
+
const qemu = Deno.build.arch === "aarch64"
104
104
+
? "qemu-system-aarch64"
105
105
+
: "qemu-system-x86_64";
108
106
109
107
const logPath = `${LOGS_DIR}/${vm.name}.log`;
110
108
111
109
const fullCommand = vm.bridge
112
112
-
? `sudo ${qemu} ${qemuArgs
110
110
+
? `sudo ${qemu} ${
111
111
+
qemuArgs
113
112
.slice(1)
114
114
-
.join(" ")} >> "${logPath}" 2>&1 & echo $!`
113
113
+
.join(" ")
114
114
+
} >> "${logPath}" 2>&1 & echo $!`
115
115
: `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
116
116
117
117
return Effect.tryPromise({
···
142
142
Effect.flatMap(({ qemuPid, logPath }) =>
143
143
pipe(
144
144
updateInstanceState(name, "RUNNING", qemuPid),
145
145
-
Effect.map(() => ({ vm, qemuPid, logPath }))
145
145
+
Effect.map(() => ({ vm, qemuPid, logPath })),
146
146
)
147
147
-
)
147
147
+
),
148
148
);
149
149
};
150
150
···
159
159
}) =>
160
160
Effect.sync(() => {
161
161
console.log(
162
162
-
`Virtual machine ${vm.name} started in background (PID: ${qemuPid})`
162
162
+
`Virtual machine ${vm.name} started in background (PID: ${qemuPid})`,
163
163
);
164
164
console.log(`Logs will be written to: ${logPath}`);
165
165
});
···
167
167
const startInteractiveQemu = (
168
168
name: string,
169
169
vm: VirtualMachine,
170
170
-
qemuArgs: string[]
170
170
+
qemuArgs: string[],
171
171
) => {
172
172
-
const qemu =
173
173
-
Deno.build.arch === "aarch64"
174
174
-
? "qemu-system-aarch64"
175
175
-
: "qemu-system-x86_64";
172
172
+
const qemu = Deno.build.arch === "aarch64"
173
173
+
? "qemu-system-aarch64"
174
174
+
: "qemu-system-x86_64";
176
175
177
176
return Effect.tryPromise({
178
177
try: async () => {
···
208
207
});
209
208
210
209
export const createVolumeIfNeeded = (
211
211
-
vm: VirtualMachine
210
210
+
vm: VirtualMachine,
212
211
): Effect.Effect<[VirtualMachine, Volume?], Error, never> =>
213
212
Effect.gen(function* () {
214
213
const { flags } = parseFlags(Deno.args);
···
222
221
223
222
if (!vm.drivePath) {
224
223
throw new Error(
225
225
-
`Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`
224
224
+
`Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`,
226
225
);
227
226
}
228
227
···
264
263
drivePath: volume ? volume.path : vm.drivePath,
265
264
diskFormat: volume ? "qcow2" : vm.diskFormat,
266
265
},
267
267
-
firmwareArgs
266
266
+
firmwareArgs,
268
267
)
269
268
),
270
269
Effect.flatMap((qemuArgs) =>
···
272
271
createLogsDir(),
273
272
Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)),
274
273
Effect.tap(logDetachedSuccess),
275
275
-
Effect.map(() => 0) // Exit code 0
274
274
+
Effect.map(() => 0), // Exit code 0
276
275
)
277
277
-
)
276
276
+
),
278
277
)
279
278
),
280
280
-
Effect.catchAll(handleError)
279
279
+
Effect.catchAll(handleError),
281
280
);
282
281
283
282
const startInteractiveEffect = (name: string) =>
···
297
296
drivePath: volume ? volume.path : vm.drivePath,
298
297
diskFormat: volume ? "qcow2" : vm.diskFormat,
299
298
},
300
300
-
firmwareArgs
299
299
+
firmwareArgs,
301
300
)
302
301
),
303
302
Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)),
304
304
-
Effect.map((status) => (status.success ? 0 : status.code || 1))
303
303
+
Effect.map((status) => (status.success ? 0 : status.code || 1)),
305
304
)
306
305
),
307
307
-
Effect.catchAll(handleError)
306
306
+
Effect.catchAll(handleError),
308
307
);
309
308
310
309
export default async function (name: string, detach: boolean = false) {
311
310
const exitCode = await Effect.runPromise(
312
312
-
detach ? startDetachedEffect(name) : startInteractiveEffect(name)
311
311
+
detach ? startDetachedEffect(name) : startInteractiveEffect(name),
313
312
);
314
313
315
314
if (detach) {
···
323
322
const { flags } = parseFlags(Deno.args);
324
323
return {
325
324
...vm,
326
326
-
memory:
327
327
-
flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory,
325
325
+
memory: flags.memory || flags.m
326
326
+
? String(flags.memory || flags.m)
327
327
+
: vm.memory,
328
328
cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus,
329
329
cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu,
330
330
diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat,
331
331
-
portForward:
332
332
-
flags.portForward || flags.p
333
333
-
? String(flags.portForward || flags.p)
334
334
-
: vm.portForward,
335
335
-
drivePath:
336
336
-
flags.image || flags.i ? String(flags.image || flags.i) : vm.drivePath,
337
337
-
bridge:
338
338
-
flags.bridge || flags.b ? String(flags.bridge || flags.b) : vm.bridge,
339
339
-
diskSize:
340
340
-
flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize,
331
331
+
portForward: flags.portForward || flags.p
332
332
+
? String(flags.portForward || flags.p)
333
333
+
: vm.portForward,
334
334
+
drivePath: flags.image || flags.i
335
335
+
? String(flags.image || flags.i)
336
336
+
: vm.drivePath,
337
337
+
bridge: flags.bridge || flags.b
338
338
+
? String(flags.bridge || flags.b)
339
339
+
: vm.bridge,
340
340
+
diskSize: flags.size || flags.s
341
341
+
? String(flags.size || flags.s)
342
342
+
: vm.diskSize,
341
343
};
342
344
}
+34
-35
src/utils.ts
···
62
62
export const isValidISOurl = (url?: string): boolean => {
63
63
return Boolean(
64
64
(url?.startsWith("http://") || url?.startsWith("https://")) &&
65
65
-
url?.endsWith(".iso")
65
65
+
url?.endsWith(".iso"),
66
66
);
67
67
};
68
68
···
88
88
});
89
89
90
90
export const validateImage = (
91
91
-
image: string
91
91
+
image: string,
92
92
): Effect.Effect<string, InvalidImageNameError, never> => {
93
93
const regex =
94
94
/^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/;
···
99
99
image,
100
100
cause:
101
101
"Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
102
102
-
})
102
102
+
}),
103
103
);
104
104
}
105
105
return Effect.succeed(image);
···
108
108
export const extractTag = (name: string) =>
109
109
pipe(
110
110
validateImage(name),
111
111
-
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest"))
111
111
+
Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")),
112
112
);
113
113
114
114
export const failOnMissingImage = (
115
115
-
image: Image | undefined
115
115
+
image: Image | undefined,
116
116
): Effect.Effect<Image, Error, never> =>
117
117
image
118
118
? Effect.succeed(image)
119
119
: Effect.fail(new NoSuchImageError({ cause: "No such image" }));
120
120
121
121
export const du = (
122
122
-
path: string
122
122
+
path: string,
123
123
): Effect.Effect<number, LogCommandError, never> =>
124
124
Effect.tryPromise({
125
125
try: async () => {
···
151
151
exists
152
152
? Effect.succeed(true)
153
153
: du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB))
154
154
-
)
154
154
+
),
155
155
);
156
156
157
157
export const downloadIso = (url: string, options: Options) =>
···
173
173
if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
174
174
console.log(
175
175
chalk.yellowBright(
176
176
-
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`
177
177
-
)
176
176
+
`Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
177
177
+
),
178
178
);
179
179
return null;
180
180
}
···
192
192
if (outputExists) {
193
193
console.log(
194
194
chalk.yellowBright(
195
195
-
`File ${outputPath} already exists, skipping download.`
196
196
-
)
195
195
+
`File ${outputPath} already exists, skipping download.`,
196
196
+
),
197
197
);
198
198
return outputPath;
199
199
}
···
242
242
if (!success) {
243
243
console.error(
244
244
chalk.redBright(
245
245
-
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew."
246
246
-
)
245
245
+
"Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.",
246
246
+
),
247
247
);
248
248
Deno.exit(1);
249
249
}
···
256
256
try: () =>
257
257
Deno.copyFile(
258
258
`${brewPrefix}/share/qemu/edk2-arm-vars.fd`,
259
259
-
edk2VarsAarch64
259
259
+
edk2VarsAarch64,
260
260
),
261
261
catch: (error) => new LogCommandError({ cause: error }),
262
262
});
···
301
301
const configOK = yield* pipe(
302
302
fileExists("config.ign"),
303
303
Effect.flatMap(() => Effect.succeed(true)),
304
304
-
Effect.catchAll(() => Effect.succeed(false))
304
304
+
Effect.catchAll(() => Effect.succeed(false)),
305
305
);
306
306
if (!configOK) {
307
307
console.error(
308
308
chalk.redBright(
309
309
-
"CoreOS image requires a config.ign file in the current directory."
310
310
-
)
309
309
+
"CoreOS image requires a config.ign file in the current directory.",
310
310
+
),
311
311
);
312
312
Deno.exit(1);
313
313
}
···
327
327
Effect.gen(function* () {
328
328
const macAddress = yield* generateRandomMacAddress();
329
329
330
330
-
const qemu =
331
331
-
Deno.build.arch === "aarch64"
332
332
-
? "qemu-system-aarch64"
333
333
-
: "qemu-system-x86_64";
330
330
+
const qemu = Deno.build.arch === "aarch64"
331
331
+
? "qemu-system-aarch64"
332
332
+
: "qemu-system-x86_64";
334
333
335
334
const firmwareFiles = yield* setupFirmwareFilesIfNeeded();
336
335
const coreosArgs: string[] = yield* setupCoreOSArgs(isoPath);
···
366
365
options.image && [
367
366
"-drive",
368
367
`file=${options.image},format=${options.diskFormat},if=virtio`,
369
369
-
]
368
368
+
],
370
369
),
371
370
];
372
371
···
381
380
const logPath = `${LOGS_DIR}/${name}.log`;
382
381
383
382
const fullCommand = options.bridge
384
384
-
? `sudo ${qemu} ${qemuArgs
383
383
+
? `sudo ${qemu} ${
384
384
+
qemuArgs
385
385
.slice(1)
386
386
-
.join(" ")} >> "${logPath}" 2>&1 & echo $!`
386
386
+
.join(" ")
387
387
+
} >> "${logPath}" 2>&1 & echo $!`
387
388
: `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
388
389
389
390
const { stdout } = yield* Effect.tryPromise({
···
409
410
cpus: options.cpus,
410
411
cpu: options.cpu,
411
412
diskSize: options.size || "20G",
412
412
-
diskFormat:
413
413
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
413
413
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
414
414
options.diskFormat ||
415
415
"raw",
416
416
portForward: options.portForward,
···
429
429
});
430
430
431
431
console.log(
432
432
-
`Virtual machine ${name} started in background (PID: ${qemuPid})`
432
432
+
`Virtual machine ${name} started in background (PID: ${qemuPid})`,
433
433
);
434
434
console.log(`Logs will be written to: ${logPath}`);
435
435
···
452
452
cpus: options.cpus,
453
453
cpu: options.cpu,
454
454
diskSize: options.size || "20G",
455
455
-
diskFormat:
456
456
-
(isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
455
455
+
diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) ||
457
456
options.diskFormat ||
458
457
"raw",
459
458
portForward: options.portForward,
···
561
560
if (pathExists) {
562
561
console.log(
563
562
chalk.yellowBright(
564
564
-
`Drive image ${path} already exists, skipping creation.`
565
565
-
)
563
563
+
`Drive image ${path} already exists, skipping creation.`,
564
564
+
),
566
565
);
567
566
return;
568
567
}
···
589
588
});
590
589
591
590
export const fileExists = (
592
592
-
path: string
591
591
+
path: string,
593
592
): Effect.Effect<void, NoSuchFileError, never> =>
594
593
Effect.try({
595
594
try: () => Deno.statSync(path),
···
597
596
});
598
597
599
598
export const constructCoreOSImageURL = (
600
600
-
image: string
599
599
+
image: string,
601
600
): Effect.Effect<string, InvalidImageNameError, never> => {
602
601
// detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version>
603
602
const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/;
···
605
604
if (match) {
606
605
const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION;
607
606
return Effect.succeed(
608
608
-
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version)
607
607
+
FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version),
609
608
);
610
609
}
611
610
···
613
612
new InvalidImageNameError({
614
613
image,
615
614
cause: "Image name does not match CoreOS naming conventions.",
616
616
-
})
615
615
+
}),
617
616
);
618
617
};
619
618