A Docker-like CLI and HTTP API for managing headless VMs
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 408 lines 11 kB view raw
1import { parseFlags } from "@cliffy/flags"; 2import _ from "@es-toolkit/es-toolkit/compat"; 3import { Effect, pipe } from "effect"; 4import { LOGS_DIR } from "../constants.ts"; 5import type { VirtualMachine, Volume } from "../db.ts"; 6import { 7 CommandError, 8 VmAlreadyRunningError, 9 VmNotFoundError, 10} from "../errors.ts"; 11import { getImage } from "../images.ts"; 12import { getInstanceState, updateInstanceState } from "../state.ts"; 13import { 14 setupAlmaLinuxArgs, 15 setupAlpineArgs, 16 setupCoreOSArgs, 17 setupDebianArgs, 18 setupFedoraArgs, 19 setupFirmwareFilesIfNeeded, 20 setupGentooArgs, 21 setupNATNetworkArgs, 22 setupRockyLinuxArgs, 23 setupUbuntuArgs, 24} from "../utils.ts"; 25import { createVolume, getVolume } from "../volumes.ts"; 26 27const findVm = (name: string) => 28 pipe( 29 getInstanceState(name), 30 Effect.flatMap((vm) => 31 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 32 ), 33 ); 34 35const logStarting = (vm: VirtualMachine) => 36 Effect.sync(() => { 37 console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 38 }); 39 40const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm)); 41 42export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 43 44export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 45 const qemu = Deno.build.arch === "aarch64" 46 ? "qemu-system-aarch64" 47 : "qemu-system-x86_64"; 48 49 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 50 let alpineArgs: string[] = Effect.runSync( 51 setupAlpineArgs(vm.isoPath, vm.seed), 52 ); 53 let debianArgs: string[] = Effect.runSync( 54 setupDebianArgs(vm.isoPath, vm.seed), 55 ); 56 let ubuntuArgs: string[] = Effect.runSync( 57 setupUbuntuArgs(vm.isoPath, vm.seed), 58 ); 59 let almalinuxArgs: string[] = Effect.runSync( 60 setupAlmaLinuxArgs(vm.isoPath, vm.seed), 61 ); 62 let rockylinuxArgs: string[] = Effect.runSync( 63 setupRockyLinuxArgs(vm.isoPath, vm.seed), 64 ); 65 let gentooArgs: string[] = Effect.runSync( 66 setupGentooArgs(vm.isoPath, vm.seed), 67 ); 68 let fedoraArgs: string[] = Effect.runSync( 69 setupFedoraArgs(vm.isoPath, vm.seed), 70 ); 71 72 if (coreosArgs.length > 0) { 73 coreosArgs = coreosArgs.slice(2); 74 } 75 76 if (alpineArgs.length > 2) { 77 alpineArgs = alpineArgs.slice(2); 78 } 79 80 if (debianArgs.length > 2) { 81 debianArgs = debianArgs.slice(2); 82 } 83 84 if (ubuntuArgs.length > 2) { 85 ubuntuArgs = ubuntuArgs.slice(2); 86 } 87 88 if (almalinuxArgs.length > 2) { 89 almalinuxArgs = almalinuxArgs.slice(2); 90 } 91 92 if (rockylinuxArgs.length > 2) { 93 rockylinuxArgs = rockylinuxArgs.slice(2); 94 } 95 96 if (gentooArgs.length > 2) { 97 gentooArgs = gentooArgs.slice(2); 98 } 99 100 if (fedoraArgs.length > 2) { 101 fedoraArgs = fedoraArgs.slice(2); 102 } 103 104 return Effect.succeed([ 105 ..._.compact([vm.bridge && qemu]), 106 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), 107 ...(Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : []), 108 "-cpu", 109 vm.cpu, 110 "-m", 111 vm.memory, 112 "-smp", 113 vm.cpus.toString(), 114 ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 115 "-netdev", 116 vm.bridge 117 ? `bridge,id=net0,br=${vm.bridge}` 118 : setupNATNetworkArgs(vm.portForward), 119 "-device", 120 `e1000,netdev=net0,mac=${vm.macAddress}`, 121 "-nographic", 122 "-monitor", 123 "none", 124 "-chardev", 125 "stdio,id=con0,signal=off", 126 "-serial", 127 "chardev:con0", 128 ...firmwareArgs, 129 ..._.compact( 130 vm.drivePath && [ 131 "-drive", 132 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 133 ], 134 ), 135 ...coreosArgs, 136 ...alpineArgs, 137 ...debianArgs, 138 ...ubuntuArgs, 139 ...almalinuxArgs, 140 ...rockylinuxArgs, 141 ...gentooArgs, 142 ...fedoraArgs, 143 ...(vm.seed ? ["-drive", `if=virtio,file=${vm.seed},media=cdrom`] : []), 144 ...(vm.volume ? [] : ["-snapshot"]), 145 ]); 146}; 147 148export const createLogsDir = () => 149 Effect.tryPromise({ 150 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 151 catch: (error) => new CommandError({ cause: error }), 152 }); 153 154export const startDetachedQemu = ( 155 name: string, 156 vm: VirtualMachine, 157 qemuArgs: string[], 158) => { 159 const qemu = Deno.build.arch === "aarch64" 160 ? "qemu-system-aarch64" 161 : "qemu-system-x86_64"; 162 163 const logPath = `${LOGS_DIR}/${vm.name}.log`; 164 165 const fullCommand = vm.bridge 166 ? `sudo ${qemu} ${ 167 qemuArgs 168 .slice(1) 169 .join(" ") 170 } >> "${logPath}" 2>&1 & echo $!` 171 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 172 173 return Effect.tryPromise({ 174 try: async () => { 175 const cmd = new Deno.Command("sh", { 176 args: ["-c", fullCommand], 177 stdin: "piped", 178 stdout: "piped", 179 }).spawn(); 180 181 // Wait 2 seconds and send "1" to boot normally 182 setTimeout(async () => { 183 try { 184 const writer = cmd.stdin.getWriter(); 185 await writer.write(new TextEncoder().encode("1\n")); 186 await writer.close(); 187 } catch { 188 // Ignore errors if stdin is already closed 189 } 190 }, 2000); 191 192 const { stdout } = await cmd.output(); 193 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 194 return { qemuPid, logPath }; 195 }, 196 catch: (error) => new CommandError({ cause: error }), 197 }).pipe( 198 Effect.flatMap(({ qemuPid, logPath }) => 199 pipe( 200 updateInstanceState(name, "RUNNING", qemuPid), 201 Effect.map(() => ({ vm, qemuPid, logPath })), 202 ) 203 ), 204 ); 205}; 206 207const logDetachedSuccess = ({ 208 vm, 209 qemuPid, 210 logPath, 211}: { 212 vm: VirtualMachine; 213 qemuPid: number; 214 logPath: string; 215}) => 216 Effect.sync(() => { 217 console.log( 218 `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 219 ); 220 console.log(`Logs will be written to: ${logPath}`); 221 }); 222 223const startInteractiveQemu = ( 224 name: string, 225 vm: VirtualMachine, 226 qemuArgs: string[], 227) => { 228 const qemu = Deno.build.arch === "aarch64" 229 ? "qemu-system-aarch64" 230 : "qemu-system-x86_64"; 231 232 return Effect.tryPromise({ 233 try: async () => { 234 const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, { 235 args: qemuArgs, 236 stdin: "inherit", 237 stdout: "inherit", 238 stderr: "inherit", 239 }); 240 241 const child = cmd.spawn(); 242 243 await Effect.runPromise(updateInstanceState(name, "RUNNING", child.pid)); 244 245 const status = await child.status; 246 247 await Effect.runPromise(updateInstanceState(name, "STOPPED", child.pid)); 248 249 return status; 250 }, 251 catch: (error) => new CommandError({ cause: error }), 252 }); 253}; 254 255const handleError = (error: VmNotFoundError | CommandError | Error) => 256 Effect.sync(() => { 257 if (error instanceof VmNotFoundError) { 258 console.error(`Virtual machine with name or ID ${error.name} not found.`); 259 } else { 260 console.error(`An error occurred: ${error}`); 261 } 262 Deno.exit(1); 263 }); 264 265export const createVolumeIfNeeded = ( 266 vm: VirtualMachine, 267): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 268 Effect.gen(function* () { 269 const { flags } = parseFlags(Deno.args); 270 if (!flags.volume) { 271 console.log("No volume flag provided, proceeding without volume."); 272 return [vm]; 273 } 274 const volume = yield* getVolume(flags.volume as string); 275 if (volume) { 276 return [vm, volume]; 277 } 278 279 if (!vm.drivePath) { 280 throw new Error( 281 `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 282 ); 283 } 284 285 let image = yield* getImage(vm.drivePath); 286 287 if (!image) { 288 const volume = yield* getVolume(vm.drivePath); 289 if (volume) { 290 image = yield* getImage(volume.baseImageId); 291 } 292 } 293 294 const newVolume = yield* createVolume(flags.volume as string, image!); 295 return [vm, newVolume]; 296 }); 297 298export const failIfVMRunning = (vm: VirtualMachine) => 299 Effect.gen(function* () { 300 if (vm.status === "RUNNING") { 301 return yield* Effect.fail(new VmAlreadyRunningError({ name: vm.name })); 302 } 303 return vm; 304 }); 305 306const startDetachedEffect = (name: string) => 307 pipe( 308 findVm(name), 309 Effect.flatMap(failIfVMRunning), 310 Effect.tap(logStarting), 311 Effect.flatMap(applyFlags), 312 Effect.flatMap(createVolumeIfNeeded), 313 Effect.flatMap(([vm, volume]) => 314 pipe( 315 setupFirmware(), 316 Effect.flatMap((firmwareArgs) => 317 buildQemuArgs( 318 { 319 ...vm, 320 drivePath: volume ? volume.path : vm.drivePath, 321 diskFormat: volume ? "qcow2" : vm.diskFormat, 322 volume: volume?.path, 323 }, 324 firmwareArgs, 325 ) 326 ), 327 Effect.flatMap((qemuArgs) => 328 pipe( 329 createLogsDir(), 330 Effect.flatMap(() => 331 startDetachedQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 332 ), 333 Effect.tap(logDetachedSuccess), 334 Effect.map(() => 0), // Exit code 0 335 ) 336 ), 337 ) 338 ), 339 Effect.catchAll(handleError), 340 ); 341 342const startInteractiveEffect = (name: string) => 343 pipe( 344 findVm(name), 345 Effect.flatMap(failIfVMRunning), 346 Effect.tap(logStarting), 347 Effect.flatMap(applyFlags), 348 Effect.flatMap(createVolumeIfNeeded), 349 Effect.flatMap(([vm, volume]) => 350 pipe( 351 setupFirmware(), 352 Effect.flatMap((firmwareArgs) => 353 buildQemuArgs( 354 { 355 ...vm, 356 drivePath: volume ? volume.path : vm.drivePath, 357 diskFormat: volume ? "qcow2" : vm.diskFormat, 358 volume: volume?.path, 359 }, 360 firmwareArgs, 361 ) 362 ), 363 Effect.flatMap((qemuArgs) => 364 startInteractiveQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 365 ), 366 Effect.map((status) => (status.success ? 0 : status.code || 1)), 367 ) 368 ), 369 Effect.catchAll(handleError), 370 ); 371 372export default async function (name: string, detach: boolean = false) { 373 const exitCode = await Effect.runPromise( 374 detach ? startDetachedEffect(name) : startInteractiveEffect(name), 375 ); 376 377 if (detach) { 378 Deno.exit(exitCode); 379 } else if (exitCode !== 0) { 380 Deno.exit(exitCode); 381 } 382} 383 384function mergeFlags(vm: VirtualMachine): VirtualMachine { 385 const { flags } = parseFlags(Deno.args); 386 return { 387 ...vm, 388 memory: flags.memory || flags.m 389 ? String(flags.memory || flags.m) 390 : vm.memory, 391 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 392 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 393 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 394 portForward: flags.portForward || flags.p 395 ? String(flags.portForward || flags.p) 396 : vm.portForward, 397 drivePath: flags.image || flags.i 398 ? String(flags.image || flags.i) 399 : vm.drivePath, 400 bridge: flags.bridge || flags.b 401 ? String(flags.bridge || flags.b) 402 : vm.bridge, 403 diskSize: flags.size || flags.s 404 ? String(flags.size || flags.s) 405 : vm.diskSize, 406 seed: flags.seed ? String(flags.seed) : vm.seed, 407 }; 408}