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

Refactor error classes and streamline code formatting in oras.ts and start.ts

+118 -140
+2 -1
.gitignore
··· 2 2 *.img 3 3 vmconfig.toml 4 4 .env 5 - *.fd 5 + *.fd 6 + vmlinux
+37 -64
src/oras.ts
··· 17 17 cause?: unknown; 18 18 }> {} 19 19 20 - export class CreateDirectoryError 21 - extends Data.TaggedError("CreateDirectoryError")<{ 22 - cause?: unknown; 23 - }> {} 20 + export class CreateDirectoryError extends Data.TaggedError( 21 + "CreateDirectoryError" 22 + )<{ 23 + cause?: unknown; 24 + }> {} 24 25 25 - export class ImageAlreadyPulledError 26 - extends Data.TaggedError("ImageAlreadyPulledError")<{ 27 - name: string; 28 - }> {} 26 + export class ImageAlreadyPulledError extends Data.TaggedError( 27 + "ImageAlreadyPulledError" 28 + )<{ 29 + name: string; 30 + }> {} 29 31 30 32 export async function setupOrasBinary(): Promise<void> { 31 - Deno.env.set( 32 - "PATH", 33 - `${CONFIG_DIR}/bin:${Deno.env.get("PATH")}`, 34 - ); 33 + Deno.env.set("PATH", `${CONFIG_DIR}/bin:${Deno.env.get("PATH")}`); 35 34 36 35 const oras = new Deno.Command("which", { 37 36 args: ["oras"], 38 37 stdout: "null", 39 38 stderr: "null", 40 - }) 41 - .spawn(); 39 + }).spawn(); 42 40 43 41 const orasStatus = await oras.status; 44 42 if (orasStatus.success) { ··· 62 60 } 63 61 64 62 // https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_darwin_amd64.tar.gz 65 - const downloadUrl = 66 - `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; 63 + const downloadUrl = `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; 67 64 68 65 console.log(`Downloading ORAS from ${chalk.greenBright(downloadUrl)}`); 69 66 ··· 72 69 stdout: "inherit", 73 70 stderr: "inherit", 74 71 cwd: "/tmp", 75 - }) 76 - .spawn(); 72 + }).spawn(); 77 73 78 74 const status = await downloadProcess.status; 79 75 if (!status.success) { ··· 84 80 console.log("Extracting ORAS binary..."); 85 81 86 82 const extractProcess = new Deno.Command("tar", { 87 - args: [ 88 - "-xzf", 89 - `oras_${version}_${os}_${arch}.tar.gz`, 90 - "-C", 91 - "./", 92 - ], 83 + args: ["-xzf", `oras_${version}_${os}_${arch}.tar.gz`, "-C", "./"], 93 84 stdout: "inherit", 94 85 stderr: "inherit", 95 86 cwd: "/tmp", 96 - }) 97 - .spawn(); 87 + }).spawn(); 98 88 99 89 const extractStatus = await extractProcess.status; 100 90 if (!extractStatus.success) { ··· 106 96 107 97 await Deno.mkdir(`${CONFIG_DIR}/bin`, { recursive: true }); 108 98 109 - await Deno.rename( 110 - `/tmp/oras`, 111 - `${CONFIG_DIR}/bin/oras`, 112 - ); 99 + await Deno.rename(`/tmp/oras`, `${CONFIG_DIR}/bin/oras`); 113 100 await Deno.chmod(`${CONFIG_DIR}/bin/oras`, 0o755); 114 101 115 102 console.log( 116 - `ORAS binary installed at ${ 117 - chalk.greenBright( 118 - `${CONFIG_DIR}/bin/oras`, 119 - ) 120 - }`, 103 + `ORAS binary installed at ${chalk.greenBright(`${CONFIG_DIR}/bin/oras`)}` 121 104 ); 122 105 } 123 106 ··· 155 138 ? repository 156 139 : `docker.io/${repository}`; 157 140 158 - const pushToRegistry = ( 159 - img: { repository: string; tag: string; path: string }, 160 - ) => 141 + const pushToRegistry = (img: { 142 + repository: string; 143 + tag: string; 144 + path: string; 145 + }) => 161 146 Effect.tryPromise({ 162 147 try: async () => { 163 148 console.log(`Pushing image ${formatRepository(img.repository)}...`); ··· 170 155 "--annotation", 171 156 `org.opencontainers.image.architecture=${getCurrentArch()}`, 172 157 "--annotation", 173 - "org.opencontainers.image.os=freebsd", 158 + "org.opencontainers.image.os=linux", 174 159 "--annotation", 175 - "org.opencontainers.image.description=QEMU raw disk image of FreeBSD", 160 + "org.opencontainers.image.description=QEMU raw disk image", 176 161 basename(img.path), 177 162 ], 178 163 stdout: "inherit", ··· 211 196 Effect.flatMap(getImage), 212 197 Effect.flatMap((img) => { 213 198 if (img) { 214 - return Effect.fail( 215 - new ImageAlreadyPulledError({ name: image }), 216 - ); 199 + return Effect.fail(new ImageAlreadyPulledError({ name: image })); 217 200 } 218 201 return Effect.succeed(void 0); 219 - }), 202 + }) 220 203 ); 221 204 222 205 export const pullFromRegistry = (image: string) => ··· 228 211 const tag = image.split(":")[1] || "latest"; 229 212 console.log( 230 213 "pull", 231 - `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 214 + `${formatRepository(repository)}:${tag}-${getCurrentArch()}` 232 215 ); 233 216 234 217 const process = new Deno.Command("oras", { ··· 251 234 new PullImageError({ 252 235 cause: error instanceof Error ? error.message : String(error), 253 236 }), 254 - }), 237 + }) 255 238 ); 256 239 257 240 export const getImageArchivePath = (image: string) => ··· 285 268 !layers[0].annotations["org.opencontainers.image.title"] 286 269 ) { 287 270 throw new Error( 288 - `No title annotation found for layer in image ${image}`, 271 + `No title annotation found for layer in image ${image}` 289 272 ); 290 273 } 291 274 292 - const path = `${IMAGE_DIR}/${ 293 - layers[0].annotations["org.opencontainers.image.title"] 294 - }`; 275 + const path = `${IMAGE_DIR}/${layers[0].annotations["org.opencontainers.image.title"]}`; 295 276 296 277 if (!(await Deno.stat(path).catch(() => false))) { 297 278 throw new Error(`Image archive not found at expected path ${path}`); ··· 343 324 try: async () => { 344 325 console.log("Extracting image archive..."); 345 326 const tarProcess = new Deno.Command("tar", { 346 - args: [ 347 - "-xSzf", 348 - path, 349 - "-C", 350 - dirname(path), 351 - ], 327 + args: ["-xSzf", path, "-C", dirname(path)], 352 328 stdout: "inherit", 353 329 stderr: "inherit", 354 330 cwd: IMAGE_DIR, ··· 366 342 }), 367 343 }); 368 344 369 - const savePulledImage = ( 370 - imagePath: string, 371 - digest: string, 372 - name: string, 373 - ) => 345 + const savePulledImage = (imagePath: string, digest: string, name: string) => 374 346 Effect.gen(function* () { 375 347 yield* saveImage({ 376 348 id: createId(), ··· 396 368 return Effect.succeed(void 0); 397 369 }), 398 370 Effect.flatMap(() => pushToRegistry(img)), 399 - Effect.flatMap(cleanup), 371 + Effect.flatMap(cleanup) 400 372 ) 401 - ), 373 + ) 402 374 ); 403 375 404 376 export const pullImage = (image: string) => ··· 419 391 ), 420 392 Effect.flatMap(cleanup), 421 393 Effect.catchTag("ImageAlreadyPulledError", () => 422 - Effect.sync(() => console.log(`Image ${image} is already pulled.`))), 394 + Effect.sync(() => console.log(`Image ${image} is already pulled.`)) 395 + ) 423 396 );
+79 -75
src/subcommands/start.ts
··· 12 12 name: string; 13 13 }> {} 14 14 15 - export class VmAlreadyRunningError 16 - extends Data.TaggedError("VmAlreadyRunningError")<{ 17 - name: string; 18 - }> {} 15 + export class VmAlreadyRunningError extends Data.TaggedError( 16 + "VmAlreadyRunningError" 17 + )<{ 18 + name: string; 19 + }> {} 19 20 20 21 export class CommandError extends Data.TaggedError("CommandError")<{ 21 22 cause?: unknown; ··· 26 27 getInstanceState(name), 27 28 Effect.flatMap((vm) => 28 29 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 29 - ), 30 + ) 30 31 ); 31 32 32 33 const logStarting = (vm: VirtualMachine) => ··· 39 40 export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 40 41 41 42 export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 42 - const qemu = Deno.build.arch === "aarch64" 43 - ? "qemu-system-aarch64" 44 - : "qemu-system-x86_64"; 43 + const qemu = 44 + Deno.build.arch === "aarch64" 45 + ? "qemu-system-aarch64" 46 + : "qemu-system-x86_64"; 45 47 46 48 return Effect.succeed([ 47 49 ..._.compact([vm.bridge && qemu]), 48 - ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 49 - ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 50 + ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), 51 + ...(Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : []), 50 52 "-cpu", 51 53 vm.cpu, 52 54 "-m", ··· 72 74 vm.drivePath && [ 73 75 "-drive", 74 76 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 75 - ], 77 + ] 76 78 ), 77 79 ]); 78 80 }; ··· 86 88 export const startDetachedQemu = ( 87 89 name: string, 88 90 vm: VirtualMachine, 89 - qemuArgs: string[], 91 + qemuArgs: string[] 90 92 ) => { 91 - const qemu = Deno.build.arch === "aarch64" 92 - ? "qemu-system-aarch64" 93 - : "qemu-system-x86_64"; 93 + const qemu = 94 + Deno.build.arch === "aarch64" 95 + ? "qemu-system-aarch64" 96 + : "qemu-system-x86_64"; 94 97 95 98 const logPath = `${LOGS_DIR}/${vm.name}.log`; 96 99 97 100 const fullCommand = vm.bridge 98 - ? `sudo ${qemu} ${ 99 - qemuArgs.slice(1).join(" ") 100 - } >> "${logPath}" 2>&1 & echo $!` 101 + ? `sudo ${qemu} ${qemuArgs 102 + .slice(1) 103 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 101 104 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 102 105 103 106 return Effect.tryPromise({ ··· 106 109 args: ["-c", fullCommand], 107 110 stdin: "piped", 108 111 stdout: "piped", 109 - }) 110 - .spawn(); 112 + }).spawn(); 111 113 112 114 // Wait 2 seconds and send "1" to boot normally 113 115 setTimeout(async () => { ··· 129 131 Effect.flatMap(({ qemuPid, logPath }) => 130 132 pipe( 131 133 updateInstanceState(name, "RUNNING", qemuPid), 132 - Effect.map(() => ({ vm, qemuPid, logPath })), 134 + Effect.map(() => ({ vm, qemuPid, logPath })) 133 135 ) 134 - ), 136 + ) 135 137 ); 136 138 }; 137 139 138 - const logDetachedSuccess = ( 139 - { vm, qemuPid, logPath }: { 140 - vm: VirtualMachine; 141 - qemuPid: number; 142 - logPath: string; 143 - }, 144 - ) => 140 + const logDetachedSuccess = ({ 141 + vm, 142 + qemuPid, 143 + logPath, 144 + }: { 145 + vm: VirtualMachine; 146 + qemuPid: number; 147 + logPath: string; 148 + }) => 145 149 Effect.sync(() => { 146 150 console.log( 147 - `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 151 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})` 148 152 ); 149 153 console.log(`Logs will be written to: ${logPath}`); 150 154 }); ··· 152 156 const startInteractiveQemu = ( 153 157 name: string, 154 158 vm: VirtualMachine, 155 - qemuArgs: string[], 159 + qemuArgs: string[] 156 160 ) => { 157 - const qemu = Deno.build.arch === "aarch64" 158 - ? "qemu-system-aarch64" 159 - : "qemu-system-x86_64"; 161 + const qemu = 162 + Deno.build.arch === "aarch64" 163 + ? "qemu-system-aarch64" 164 + : "qemu-system-x86_64"; 160 165 161 166 return Effect.tryPromise({ 162 167 try: async () => { ··· 184 189 const handleError = (error: VmNotFoundError | CommandError | Error) => 185 190 Effect.sync(() => { 186 191 if (error instanceof VmNotFoundError) { 187 - console.error( 188 - `Virtual machine with name or ID ${error.name} not found.`, 189 - ); 192 + console.error(`Virtual machine with name or ID ${error.name} not found.`); 190 193 } else { 191 194 console.error(`An error occurred: ${error}`); 192 195 } ··· 194 197 }); 195 198 196 199 export const createVolumeIfNeeded = ( 197 - vm: VirtualMachine, 200 + vm: VirtualMachine 198 201 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 199 202 Effect.gen(function* () { 200 203 const { flags } = parseFlags(Deno.args); ··· 208 211 209 212 if (!vm.drivePath) { 210 213 throw new Error( 211 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 214 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 212 215 ); 213 216 } 214 217 ··· 228 231 export const failIfVMRunning = (vm: VirtualMachine) => 229 232 Effect.gen(function* () { 230 233 if (vm.status === "RUNNING") { 231 - return yield* Effect.fail( 232 - new VmAlreadyRunningError({ name: vm.name }), 233 - ); 234 + return yield* Effect.fail(new VmAlreadyRunningError({ name: vm.name })); 234 235 } 235 236 return vm; 236 237 }); ··· 246 247 pipe( 247 248 setupFirmware(), 248 249 Effect.flatMap((firmwareArgs) => 249 - buildQemuArgs({ 250 - ...vm, 251 - drivePath: volume ? volume.path : vm.drivePath, 252 - diskFormat: volume ? "qcow2" : vm.diskFormat, 253 - }, firmwareArgs) 250 + buildQemuArgs( 251 + { 252 + ...vm, 253 + drivePath: volume ? volume.path : vm.drivePath, 254 + diskFormat: volume ? "qcow2" : vm.diskFormat, 255 + }, 256 + firmwareArgs 257 + ) 254 258 ), 255 259 Effect.flatMap((qemuArgs) => 256 260 pipe( 257 261 createLogsDir(), 258 262 Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 259 263 Effect.tap(logDetachedSuccess), 260 - Effect.map(() => 0), // Exit code 0 264 + Effect.map(() => 0) // Exit code 0 261 265 ) 262 - ), 266 + ) 263 267 ) 264 268 ), 265 - Effect.catchAll(handleError), 269 + Effect.catchAll(handleError) 266 270 ); 267 271 268 272 const startInteractiveEffect = (name: string) => ··· 276 280 pipe( 277 281 setupFirmware(), 278 282 Effect.flatMap((firmwareArgs) => 279 - buildQemuArgs({ 280 - ...vm, 281 - drivePath: volume ? volume.path : vm.drivePath, 282 - diskFormat: volume ? "qcow2" : vm.diskFormat, 283 - }, firmwareArgs) 283 + buildQemuArgs( 284 + { 285 + ...vm, 286 + drivePath: volume ? volume.path : vm.drivePath, 287 + diskFormat: volume ? "qcow2" : vm.diskFormat, 288 + }, 289 + firmwareArgs 290 + ) 284 291 ), 285 292 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 286 - Effect.map((status) => status.success ? 0 : (status.code || 1)), 293 + Effect.map((status) => (status.success ? 0 : status.code || 1)) 287 294 ) 288 295 ), 289 - Effect.catchAll(handleError), 296 + Effect.catchAll(handleError) 290 297 ); 291 298 292 299 export default async function (name: string, detach: boolean = false) { 293 300 const exitCode = await Effect.runPromise( 294 - detach ? startDetachedEffect(name) : startInteractiveEffect(name), 301 + detach ? startDetachedEffect(name) : startInteractiveEffect(name) 295 302 ); 296 303 297 304 if (detach) { ··· 305 312 const { flags } = parseFlags(Deno.args); 306 313 return { 307 314 ...vm, 308 - memory: (flags.memory || flags.m) 309 - ? String(flags.memory || flags.m) 310 - : vm.memory, 311 - cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus, 312 - cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu, 315 + memory: 316 + flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory, 317 + cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 318 + cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 313 319 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 314 - portForward: (flags.portForward || flags.p) 315 - ? String(flags.portForward || flags.p) 316 - : vm.portForward, 317 - drivePath: (flags.image || flags.i) 318 - ? String(flags.image || flags.i) 319 - : vm.drivePath, 320 - bridge: (flags.bridge || flags.b) 321 - ? String(flags.bridge || flags.b) 322 - : vm.bridge, 323 - diskSize: (flags.size || flags.s) 324 - ? String(flags.size || flags.s) 325 - : vm.diskSize, 320 + portForward: 321 + flags.portForward || flags.p 322 + ? String(flags.portForward || flags.p) 323 + : vm.portForward, 324 + drivePath: 325 + flags.image || flags.i ? String(flags.image || flags.i) : vm.drivePath, 326 + bridge: 327 + flags.bridge || flags.b ? String(flags.bridge || flags.b) : vm.bridge, 328 + diskSize: 329 + flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize, 326 330 }; 327 331 }