A simple command-line tool to start NetBSD virtual machines using QEMU with sensible defaults.
at main 189 lines 5.3 kB view raw
1import _ from "@es-toolkit/es-toolkit/compat"; 2import chalk from "chalk"; 3import { Data, Effect, pipe } from "effect"; 4import { LOGS_DIR } from "../constants.ts"; 5import type { VirtualMachine } from "../db.ts"; 6import { getInstanceState, updateInstanceState } from "../state.ts"; 7import { 8 safeKillQemu, 9 setupFirmwareFilesIfNeeded, 10 setupNATNetworkArgs, 11} from "../utils.ts"; 12 13class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 14 name: string; 15}> {} 16 17class KillQemuError extends Data.TaggedError("KillQemuError")<{ 18 vmName: string; 19}> {} 20 21class CommandError extends Data.TaggedError("CommandError")<{ 22 cause?: unknown; 23}> {} 24 25const findVm = (name: string) => 26 pipe( 27 getInstanceState(name), 28 Effect.flatMap((vm) => 29 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 30 ), 31 ); 32 33const killQemu = (vm: VirtualMachine) => 34 safeKillQemu(vm.pid, Boolean(vm.bridge)).pipe( 35 Effect.flatMap((success) => 36 success 37 ? Effect.succeed(vm) 38 : Effect.fail(new KillQemuError({ vmName: vm.name })) 39 ), 40 ); 41 42const sleep = (ms: number) => 43 Effect.tryPromise({ 44 try: () => new Promise((resolve) => setTimeout(resolve, ms)), 45 catch: (error) => new CommandError({ cause: error }), 46 }); 47 48const createLogsDir = () => 49 Effect.tryPromise({ 50 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 51 catch: (error) => new CommandError({ cause: error }), 52 }); 53 54const setupFirmware = () => setupFirmwareFilesIfNeeded(); 55 56const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 57 const qemu = Deno.build.arch === "aarch64" 58 ? "qemu-system-aarch64" 59 : "qemu-system-x86_64"; 60 61 return Effect.succeed([ 62 ..._.compact([vm.bridge && qemu]), 63 ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 64 ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 65 "-cpu", 66 vm.cpu, 67 "-m", 68 vm.memory, 69 "-smp", 70 vm.cpus.toString(), 71 ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 72 "-netdev", 73 vm.bridge 74 ? `bridge,id=net0,br=${vm.bridge}` 75 : setupNATNetworkArgs(vm.portForward), 76 "-device", 77 `e1000,netdev=net0,mac=${vm.macAddress}`, 78 "-nographic", 79 "-monitor", 80 "none", 81 "-chardev", 82 "stdio,id=con0,signal=off", 83 "-serial", 84 "chardev:con0", 85 ...firmwareArgs, 86 ..._.compact( 87 vm.drivePath && [ 88 "-drive", 89 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 90 ], 91 ), 92 "-object", 93 "rng-random,filename=/dev/urandom,id=rng0", 94 "-device", 95 "virtio-rng-pci,rng=rng0", 96 ]); 97}; 98 99const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => { 100 const qemu = Deno.build.arch === "aarch64" 101 ? "qemu-system-aarch64" 102 : "qemu-system-x86_64"; 103 104 const logPath = `${LOGS_DIR}/${vm.name}.log`; 105 106 const fullCommand = vm.bridge 107 ? `sudo ${qemu} ${ 108 qemuArgs.slice(1).join(" ") 109 } >> "${logPath}" 2>&1 & echo $!` 110 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 111 112 return Effect.tryPromise({ 113 try: async () => { 114 const cmd = new Deno.Command("sh", { 115 args: ["-c", fullCommand], 116 stdin: "null", 117 stdout: "piped", 118 }); 119 120 const { stdout } = await cmd.spawn().output(); 121 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 122 return { qemuPid, logPath }; 123 }, 124 catch: (error) => new CommandError({ cause: error }), 125 }); 126}; 127 128const logSuccess = (vm: VirtualMachine, qemuPid: number, logPath: string) => 129 Effect.sync(() => { 130 console.log( 131 `${chalk.greenBright(vm.name)} restarted with PID ${ 132 chalk.greenBright(qemuPid) 133 }.`, 134 ); 135 console.log( 136 `Logs are being written to ${chalk.blueBright(logPath)}`, 137 ); 138 }); 139 140const handleError = ( 141 error: VmNotFoundError | KillQemuError | CommandError | Error, 142) => 143 Effect.sync(() => { 144 if (error instanceof VmNotFoundError) { 145 console.error( 146 `Virtual machine with name or ID ${ 147 chalk.greenBright(error.name) 148 } not found.`, 149 ); 150 } else if (error instanceof KillQemuError) { 151 console.error( 152 `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 153 ); 154 } else { 155 console.error(`An error occurred: ${error}`); 156 } 157 Deno.exit(1); 158 }); 159 160const restartEffect = (name: string) => 161 pipe( 162 findVm(name), 163 Effect.tap((vm) => Effect.log(`Found VM: ${vm.name}`)), 164 Effect.flatMap(killQemu), 165 Effect.tap((vm) => updateInstanceState(vm.id, "STOPPED")), 166 Effect.flatMap((vm) => 167 pipe( 168 sleep(2000), 169 Effect.flatMap(() => createLogsDir()), 170 Effect.flatMap(() => setupFirmware()), 171 Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 172 Effect.flatMap((qemuArgs) => startQemu(vm, qemuArgs)), 173 Effect.tap(() => sleep(2000)), 174 Effect.flatMap(({ qemuPid, logPath }) => 175 pipe( 176 updateInstanceState(vm.id, "RUNNING", qemuPid), 177 Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)), 178 Effect.flatMap(() => sleep(2000)), 179 ) 180 ), 181 ) 182 ), 183 Effect.catchAll(handleError), 184 ); 185 186export default async function (name: string) { 187 await Effect.runPromise(restartEffect(name)); 188 Deno.exit(0); 189}