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 .env 5 *.fd 6 vmlinux 7 - *.tar.gz
··· 4 .env 5 *.fd 6 vmlinux 7 + *.tar.gz 8 + *.qcow2 9 + *.xz 10 + *.bu 11 + *.ign
+47
main.ts
··· 3 import { Command } from "@cliffy/command"; 4 import { Secret } from "@cliffy/prompt"; 5 import { readAll } from "@std/io"; 6 import chalk from "chalk"; 7 import { Effect, pipe } from "effect"; 8 import pkg from "./deno.json" with { type: "json" }; 9 import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; 10 import { CONFIG_FILE_NAME } from "./src/constants.ts"; 11 import { getImage } from "./src/images.ts"; 12 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 13 import { getImageArchivePath } from "./src/oras.ts"; 14 import images from "./src/subcommands/images.ts"; ··· 32 createDriveImageIfNeeded, 33 downloadIso, 34 emptyDiskImage, 35 fileExists, 36 isValidISOurl, 37 NoSuchFileError, ··· 161 } 162 } 163 164 } 165 166 ··· 182 183 if (!input && (isValidISOurl(config?.vm?.iso))) { 184 isoPath = yield* downloadIso(config!.vm!.iso!, options); 185 } 186 187 options = yield* mergeConfig(config, options);
··· 3 import { Command } from "@cliffy/command"; 4 import { Secret } from "@cliffy/prompt"; 5 import { readAll } from "@std/io"; 6 + import { basename } from "@std/path"; 7 import chalk from "chalk"; 8 import { Effect, pipe } from "effect"; 9 import pkg from "./deno.json" with { type: "json" }; 10 import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; 11 import { CONFIG_FILE_NAME } from "./src/constants.ts"; 12 import { getImage } from "./src/images.ts"; 13 + import { constructCoreOSImageURL } from "./src/mod.ts"; 14 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 15 import { getImageArchivePath } from "./src/oras.ts"; 16 import images from "./src/subcommands/images.ts"; ··· 34 createDriveImageIfNeeded, 35 downloadIso, 36 emptyDiskImage, 37 + extractXz, 38 fileExists, 39 isValidISOurl, 40 NoSuchFileError, ··· 164 } 165 } 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 + 189 } 190 191 ··· 207 208 if (!input && (isValidISOurl(config?.vm?.iso))) { 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 + } 232 } 233 234 options = yield* mergeConfig(config, options);
+59
src/utils.ts
··· 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 import { createId } from "@paralleldrive/cuid2"; 3 import chalk from "chalk"; 4 import { Data, Effect, pipe } from "effect"; 5 import Moniker from "moniker"; ··· 290 return `user,id=net0,${portForwarding}`; 291 } 292 293 export const runQemu = (isoPath: string | null, options: Options) => 294 Effect.gen(function* () { 295 const macAddress = yield* generateRandomMacAddress(); ··· 300 : "qemu-system-x86_64"; 301 302 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 303 304 const qemuArgs = [ 305 ..._.compact([options.bridge && qemu]), ··· 327 "-serial", 328 "chardev:con0", 329 ...firmwareFiles, 330 ..._.compact( 331 options.image && [ 332 "-drive", ··· 561 }) 562 ); 563 };
··· 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 import { createId } from "@paralleldrive/cuid2"; 3 + import { dirname } from "@std/path"; 4 import chalk from "chalk"; 5 import { Data, Effect, pipe } from "effect"; 6 import Moniker from "moniker"; ··· 291 return `user,id=net0,${portForwarding}`; 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 + 326 export const runQemu = (isoPath: string | null, options: Options) => 327 Effect.gen(function* () { 328 const macAddress = yield* generateRandomMacAddress(); ··· 333 : "qemu-system-x86_64"; 334 335 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 336 + const coreosArgs: string[] = yield* setupCoreOSArgs(isoPath); 337 338 const qemuArgs = [ 339 ..._.compact([options.bridge && qemu]), ··· 361 "-serial", 362 "chardev:con0", 363 ...firmwareFiles, 364 + ...coreosArgs, 365 ..._.compact( 366 options.image && [ 367 "-drive", ··· 596 }) 597 ); 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 + });