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

Add support for CoreOS image handling and extraction in QEMU setup

+111 -1
+5 -1
.gitignore
··· 4 4 .env 5 5 *.fd 6 6 vmlinux 7 - *.tar.gz 7 + *.tar.gz 8 + *.qcow2 9 + *.xz 10 + *.bu 11 + *.ign
+47
main.ts
··· 3 3 import { Command } from "@cliffy/command"; 4 4 import { Secret } from "@cliffy/prompt"; 5 5 import { readAll } from "@std/io"; 6 + import { basename } from "@std/path"; 6 7 import chalk from "chalk"; 7 8 import { Effect, pipe } from "effect"; 8 9 import pkg from "./deno.json" with { type: "json" }; 9 10 import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; 10 11 import { CONFIG_FILE_NAME } from "./src/constants.ts"; 11 12 import { getImage } from "./src/images.ts"; 13 + import { constructCoreOSImageURL } from "./src/mod.ts"; 12 14 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 13 15 import { getImageArchivePath } from "./src/oras.ts"; 14 16 import images from "./src/subcommands/images.ts"; ··· 32 34 createDriveImageIfNeeded, 33 35 downloadIso, 34 36 emptyDiskImage, 37 + extractXz, 35 38 fileExists, 36 39 isValidISOurl, 37 40 NoSuchFileError, ··· 161 164 } 162 165 } 163 166 167 + const coreOSImageURL = yield* pipe( 168 + constructCoreOSImageURL(input), 169 + Effect.catchAll(() => Effect.succeed(null)) 170 + ); 171 + 172 + if (coreOSImageURL) { 173 + const cached = yield* pipe( 174 + basename(coreOSImageURL).replace(".xz", ""), 175 + fileExists, 176 + Effect.flatMap(() => Effect.succeed(true)), 177 + Effect.catchAll(() => Effect.succeed(false)) 178 + ); 179 + if (!cached) { 180 + isoPath = yield* pipe( 181 + downloadIso(coreOSImageURL, options), 182 + Effect.flatMap((xz) => extractXz(xz)), 183 + ); 184 + } else { 185 + isoPath = basename(coreOSImageURL).replace(".xz", ""); 186 + } 187 + } 188 + 164 189 } 165 190 166 191 ··· 182 207 183 208 if (!input && (isValidISOurl(config?.vm?.iso))) { 184 209 isoPath = yield* downloadIso(config!.vm!.iso!, options); 210 + } 211 + 212 + if (!input && config?.vm?.iso) { 213 + const coreOSImageURL = yield* pipe( 214 + constructCoreOSImageURL(config.vm.iso), 215 + Effect.catchAll(() => Effect.succeed(null)) 216 + ); 217 + 218 + if (coreOSImageURL) { 219 + const cached = yield* pipe( 220 + basename(coreOSImageURL).replace(".xz", ""), 221 + fileExists, 222 + Effect.flatMap(() => Effect.succeed(true)), 223 + Effect.catchAll(() => Effect.succeed(false)) 224 + ); 225 + if (!cached) { 226 + const xz = yield* downloadIso(coreOSImageURL, options); 227 + isoPath = yield* extractXz(xz); 228 + } else { 229 + isoPath = basename(coreOSImageURL).replace(".xz", ""); 230 + } 231 + } 185 232 } 186 233 187 234 options = yield* mergeConfig(config, options);
+59
src/utils.ts
··· 1 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 2 import { createId } from "@paralleldrive/cuid2"; 3 + import { dirname } from "@std/path"; 3 4 import chalk from "chalk"; 4 5 import { Data, Effect, pipe } from "effect"; 5 6 import Moniker from "moniker"; ··· 290 291 return `user,id=net0,${portForwarding}`; 291 292 } 292 293 294 + export const setupCoreOSArgs = (imagePath: string | null) => 295 + Effect.gen(function* () { 296 + if ( 297 + imagePath && 298 + imagePath.endsWith(".qcow2") && 299 + imagePath.includes("coreos") 300 + ) { 301 + const configOK = yield* pipe( 302 + fileExists("config.ign"), 303 + Effect.flatMap(() => Effect.succeed(true)), 304 + Effect.catchAll(() => Effect.succeed(false)) 305 + ); 306 + if (!configOK) { 307 + console.error( 308 + chalk.redBright( 309 + "CoreOS image requires a config.ign file in the current directory." 310 + ) 311 + ); 312 + Deno.exit(1); 313 + } 314 + 315 + return [ 316 + "-drive", 317 + `file=${imagePath},format=qcow2,if=virtio`, 318 + "-fw_cfg", 319 + "name=opt/com.coreos/config,file=config.ign", 320 + ]; 321 + } 322 + 323 + return []; 324 + }); 325 + 293 326 export const runQemu = (isoPath: string | null, options: Options) => 294 327 Effect.gen(function* () { 295 328 const macAddress = yield* generateRandomMacAddress(); ··· 300 333 : "qemu-system-x86_64"; 301 334 302 335 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 336 + const coreosArgs: string[] = yield* setupCoreOSArgs(isoPath); 303 337 304 338 const qemuArgs = [ 305 339 ..._.compact([options.bridge && qemu]), ··· 327 361 "-serial", 328 362 "chardev:con0", 329 363 ...firmwareFiles, 364 + ...coreosArgs, 330 365 ..._.compact( 331 366 options.image && [ 332 367 "-drive", ··· 561 596 }) 562 597 ); 563 598 }; 599 + 600 + export const extractXz = (path: string | null) => 601 + Effect.tryPromise({ 602 + try: async () => { 603 + if (!path) { 604 + return null; 605 + } 606 + const cmd = new Deno.Command("xz", { 607 + args: ["-d", path], 608 + stdin: "inherit", 609 + stdout: "inherit", 610 + stderr: "inherit", 611 + cwd: dirname(path), 612 + }).spawn(); 613 + 614 + const status = await cmd.status; 615 + if (!status.success) { 616 + console.error(chalk.redBright("Failed to extract xz file.")); 617 + Deno.exit(status.code); 618 + } 619 + return path.replace(/\.xz$/, ""); 620 + }, 621 + catch: (error) => new LogCommandError({ cause: error }), 622 + });