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

Merge pull request #1 from tsirysndr/feat/fedora-coreos

feat: add support for Fedora CoreOS

authored by tsiry-sandratraina.com and committed by

GitHub 548d55e9 d1660bdb

+351 -111
+26
.github/workflows/ci.yml
··· 1 + name: ci 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + branches: [main] 8 + 9 + jobs: 10 + test: 11 + runs-on: ubuntu-latest 12 + 13 + steps: 14 + - name: Checkout code 15 + uses: actions/checkout@v4 16 + 17 + - name: Setup Deno 18 + uses: denoland/setup-deno@v2 19 + with: 20 + deno-version: v2.x 21 + 22 + - name: Run tests 23 + run: deno test -A 24 + 25 + - name: Check formatting 26 + run: deno fmt --check
+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
+23 -10
README.md
··· 1 1 # vmx 2 2 3 - A powerful command-line tool and HTTP API for managing and running headless virtual machines using QEMU. Built with Deno and TypeScript, vmx provides a Docker-like experience for VM management with OCI registry support. 3 + [![ci](https://github.com/tsirysndr/vmx/actions/workflows/ci.yml/badge.svg)](https://github.com/tsirysndr/vmx/actions/workflows/ci.yml) 4 + 5 + A powerful command-line tool and HTTP API for managing and running headless 6 + virtual machines using QEMU. Built with Deno and TypeScript, vmx provides a 7 + Docker-like experience for VM management with OCI registry support. 4 8 5 9 ## Features 6 10 7 11 ### 🚀 Core Functionality 8 12 9 13 - **Headless VM Management** - Run VMs in the background without GUI overhead 10 - - **QEMU Integration** - Leverages QEMU for robust virtualization on both x86_64 and ARM64 architectures 11 - - **Docker-like CLI** - Familiar commands for VM lifecycle management (run, start, stop, ps, rm, etc.) 14 + - **QEMU Integration** - Leverages QEMU for robust virtualization on both x86_64 15 + and ARM64 architectures 16 + - **Docker-like CLI** - Familiar commands for VM lifecycle management (run, 17 + start, stop, ps, rm, etc.) 12 18 - **Configuration Files** - TOML-based configuration for reproducible VM setups 13 - - **Multiple Input Sources** - Boot from local ISOs, remote URLs, or OCI registry images 19 + - **Multiple Input Sources** - Boot from local ISOs, remote URLs, or OCI 20 + registry images 14 21 15 22 ### 📦 OCI Registry Support 16 23 17 - - **Pull & Push** - Store and retrieve VM images from OCI-compliant registries (GitHub Container Registry, Docker Hub, etc.) 24 + - **Pull & Push** - Store and retrieve VM images from OCI-compliant registries 25 + (GitHub Container Registry, Docker Hub, etc.) 18 26 - **Image Management** - List, tag, and remove local VM images 19 27 - **Authentication** - Secure login/logout for private registries 20 - - **Cross-platform** - Automatic architecture detection and handling (amd64/arm64) 28 + - **Cross-platform** - Automatic architecture detection and handling 29 + (amd64/arm64) 21 30 22 31 ### 🌐 Networking 23 32 24 33 - **Bridge Networking** - Create and manage network bridges for VM connectivity 25 34 - **Port Forwarding** - Easy SSH and service access with flexible port mapping 26 - - **Multiple Network Modes** - Support for various QEMU networking configurations 35 + - **Multiple Network Modes** - Support for various QEMU networking 36 + configurations 27 37 28 38 ### 💾 Storage & Volumes 29 39 30 40 - **Volume Management** - Create, list, inspect, and delete persistent volumes 31 41 - **Multiple Disk Formats** - Support for qcow2 and raw disk images 32 - - **Automatic Provisioning** - Volumes are created automatically from base images 42 + - **Automatic Provisioning** - Volumes are created automatically from base 43 + images 33 44 - **Flexible Sizing** - Configurable disk sizes for different workloads 34 45 35 46 ### 🔧 Advanced Features ··· 38 49 - **Live Logs** - Stream VM output and follow logs in real-time 39 50 - **VM Inspection** - Detailed information about running and stopped VMs 40 51 - **Resource Configuration** - Customizable CPU, memory, and disk settings 41 - - **ARM64 & x86_64 Support** - Native support for both architectures with UEFI firmware 52 + - **ARM64 & x86_64 Support** - Native support for both architectures with UEFI 53 + firmware 42 54 43 55 ### 🌍 HTTP API 44 56 ··· 304 316 vmx automatically detects and adapts to your system architecture: 305 317 306 318 - **x86_64 / amd64** - Full QEMU system emulation 307 - - **ARM64 / aarch64** - Native Apple Silicon and ARM server support with UEFI firmware 319 + - **ARM64 / aarch64** - Native Apple Silicon and ARM server support with UEFI 320 + firmware 308 321 309 322 ## Examples 310 323
+1 -1
deno.json
··· 28 28 "kysely": "npm:kysely@0.27.6", 29 29 "moniker": "npm:moniker@^0.1.2" 30 30 } 31 - } 31 + }
+6 -1
deno.lock
··· 15 15 "jsr:@es-toolkit/es-toolkit@^1.41.0": "1.41.0", 16 16 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", 17 17 "jsr:@std/assert@0.217": "0.217.0", 18 + "jsr:@std/assert@1": "1.0.15", 18 19 "jsr:@std/assert@~1.0.6": "1.0.15", 19 20 "jsr:@std/bytes@^1.0.5": "1.0.6", 20 21 "jsr:@std/collections@^1.1.3": "1.1.3", ··· 24 25 "jsr:@std/fmt@~1.0.2": "1.0.8", 25 26 "jsr:@std/fs@1": "1.0.19", 26 27 "jsr:@std/internal@^1.0.10": "1.0.12", 28 + "jsr:@std/internal@^1.0.12": "1.0.12", 27 29 "jsr:@std/internal@^1.0.9": "1.0.12", 28 30 "jsr:@std/io@~0.225.2": "0.225.2", 29 31 "jsr:@std/path@0.217": "0.217.0", ··· 121 123 "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" 122 124 }, 123 125 "@std/assert@1.0.15": { 124 - "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b" 126 + "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", 127 + "dependencies": [ 128 + "jsr:@std/internal@^1.0.12" 129 + ] 125 130 }, 126 131 "@std/bytes@1.0.6": { 127 132 "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
+54 -7
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, ··· 151 154 isoPath = yield* downloadIso(input, options); 152 155 } 153 156 154 - if (yield* pipe( 157 + if ( 158 + yield* pipe( 155 159 fileExists(input), 156 160 Effect.map(() => true), 157 - Effect.catchAll(() => Effect.succeed(false))) 158 - ) { 159 - if (input.endsWith(".iso")) { 160 - isoPath = input; 161 - } 161 + Effect.catchAll(() => Effect.succeed(false)), 162 + ) 163 + ) { 164 + if (input.endsWith(".iso")) { 165 + isoPath = input; 162 166 } 167 + } 163 168 164 - } 169 + const coreOSImageURL = yield* pipe( 170 + constructCoreOSImageURL(input), 171 + Effect.catchAll(() => Effect.succeed(null)), 172 + ); 165 173 174 + if (coreOSImageURL) { 175 + const cached = yield* pipe( 176 + basename(coreOSImageURL).replace(".xz", ""), 177 + fileExists, 178 + Effect.flatMap(() => Effect.succeed(true)), 179 + Effect.catchAll(() => Effect.succeed(false)), 180 + ); 181 + if (!cached) { 182 + isoPath = yield* pipe( 183 + downloadIso(coreOSImageURL, options), 184 + Effect.flatMap((xz) => extractXz(xz)), 185 + ); 186 + } else { 187 + isoPath = basename(coreOSImageURL).replace(".xz", ""); 188 + } 189 + } 190 + } 166 191 167 192 const config = yield* pipe( 168 193 fileExists(CONFIG_FILE_NAME), ··· 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);
+2 -2
src/api/mod.ts
··· 17 17 console.log(`Using API token: ${token}`); 18 18 } else { 19 19 console.log( 20 - `Using provided API token from environment variable VMX_API_TOKEN` 20 + `Using provided API token from environment variable VMX_API_TOKEN`, 21 21 ); 22 22 } 23 23 ··· 39 39 flags.p || 40 40 (Deno.env.get("VMX_API_PORT") 41 41 ? Number(Deno.env.get("VMX_API_PORT")) 42 - : 8889) 42 + : 8889), 43 43 ); 44 44 45 45 Deno.serve({ port }, app.fetch);
+5 -5
src/config.ts
··· 39 39 }> {} 40 40 41 41 export const initVmFile = ( 42 - path: string 42 + path: string, 43 43 ): Effect.Effect<void, VmConfigError, never> => 44 44 Effect.tryPromise({ 45 45 try: async () => { ··· 64 64 }); 65 65 66 66 export const parseVmFile = ( 67 - path: string 67 + path: string, 68 68 ): Effect.Effect<VmConfig, VmConfigError, never> => 69 69 Effect.tryPromise({ 70 70 try: async () => { ··· 77 77 78 78 export const mergeConfig = ( 79 79 config: VmConfig | null, 80 - options: Options 80 + options: Options, 81 81 ): Effect.Effect<Options, never, never> => { 82 82 const { flags } = parseFlags(Deno.args); 83 83 flags.image = flags.i || flags.image; ··· 113 113 diskFormat: _.get( 114 114 flags, 115 115 "diskFormat", 116 - defaultConfig.vm.disk_format! 116 + defaultConfig.vm.disk_format!, 117 117 ) as string, 118 118 portForward: _.get( 119 119 flags, 120 120 "portForward", 121 - defaultConfig.network.port_forward! 121 + defaultConfig.network.port_forward!, 122 122 ) as string, 123 123 image: _.get(flags, "image", defaultConfig.vm.image!) as string, 124 124 bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string,
+3
src/constants.ts
··· 8 8 export const UBUNTU_ISO_URL: string = Deno.build.arch === "aarch64" 9 9 ? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso" 10 10 : "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-live-server-amd64.iso"; 11 + export const FEDORA_COREOS_DEFAULT_VERSION: string = "43.20251024.3.0"; 12 + export const FEDORA_COREOS_IMG_URL: string = 13 + `https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/${FEDORA_COREOS_DEFAULT_VERSION}/${Deno.build.arch}/fedora-coreos-${FEDORA_COREOS_DEFAULT_VERSION}-qemu.${Deno.build.arch}.qcow2.xz`;
+15 -13
src/oras.ts
··· 18 18 }> {} 19 19 20 20 export class CreateDirectoryError extends Data.TaggedError( 21 - "CreateDirectoryError" 21 + "CreateDirectoryError", 22 22 )<{ 23 23 cause?: unknown; 24 24 }> {} 25 25 26 26 export class ImageAlreadyPulledError extends Data.TaggedError( 27 - "ImageAlreadyPulledError" 27 + "ImageAlreadyPulledError", 28 28 )<{ 29 29 name: string; 30 30 }> {} ··· 60 60 } 61 61 62 62 // https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_darwin_amd64.tar.gz 63 - const downloadUrl = `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; 63 + const downloadUrl = 64 + `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; 64 65 65 66 console.log(`Downloading ORAS from ${chalk.greenBright(downloadUrl)}`); 66 67 ··· 100 101 await Deno.chmod(`${CONFIG_DIR}/bin/oras`, 0o755); 101 102 102 103 console.log( 103 - `ORAS binary installed at ${chalk.greenBright(`${CONFIG_DIR}/bin/oras`)}` 104 + `ORAS binary installed at ${chalk.greenBright(`${CONFIG_DIR}/bin/oras`)}`, 104 105 ); 105 106 } 106 107 ··· 199 200 return Effect.fail(new ImageAlreadyPulledError({ name: image })); 200 201 } 201 202 return Effect.succeed(void 0); 202 - }) 203 + }), 203 204 ); 204 205 205 206 export const pullFromRegistry = (image: string) => ··· 211 212 const tag = image.split(":")[1] || "latest"; 212 213 console.log( 213 214 "pull", 214 - `${formatRepository(repository)}:${tag}-${getCurrentArch()}` 215 + `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 215 216 ); 216 217 217 218 const process = new Deno.Command("oras", { ··· 234 235 new PullImageError({ 235 236 cause: error instanceof Error ? error.message : String(error), 236 237 }), 237 - }) 238 + }), 238 239 ); 239 240 240 241 export const getImageArchivePath = (image: string) => ··· 268 269 !layers[0].annotations["org.opencontainers.image.title"] 269 270 ) { 270 271 throw new Error( 271 - `No title annotation found for layer in image ${image}` 272 + `No title annotation found for layer in image ${image}`, 272 273 ); 273 274 } 274 275 275 - const path = `${IMAGE_DIR}/${layers[0].annotations["org.opencontainers.image.title"]}`; 276 + const path = `${IMAGE_DIR}/${ 277 + layers[0].annotations["org.opencontainers.image.title"] 278 + }`; 276 279 277 280 if (!(await Deno.stat(path).catch(() => false))) { 278 281 throw new Error(`Image archive not found at expected path ${path}`); ··· 368 371 return Effect.succeed(void 0); 369 372 }), 370 373 Effect.flatMap(() => pushToRegistry(img)), 371 - Effect.flatMap(cleanup) 374 + Effect.flatMap(cleanup), 372 375 ) 373 - ) 376 + ), 374 377 ); 375 378 376 379 export const pullImage = (image: string) => ··· 391 394 ), 392 395 Effect.flatMap(cleanup), 393 396 Effect.catchTag("ImageAlreadyPulledError", () => 394 - Effect.sync(() => console.log(`Image ${image} is already pulled.`)) 395 - ) 397 + Effect.sync(() => console.log(`Image ${image} is already pulled.`))), 396 398 );
+46 -44
src/subcommands/start.ts
··· 13 13 }> {} 14 14 15 15 export class VmAlreadyRunningError extends Data.TaggedError( 16 - "VmAlreadyRunningError" 16 + "VmAlreadyRunningError", 17 17 )<{ 18 18 name: string; 19 19 }> {} ··· 27 27 getInstanceState(name), 28 28 Effect.flatMap((vm) => 29 29 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 30 - ) 30 + ), 31 31 ); 32 32 33 33 const logStarting = (vm: VirtualMachine) => ··· 40 40 export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 41 41 42 42 export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 43 - const qemu = 44 - Deno.build.arch === "aarch64" 45 - ? "qemu-system-aarch64" 46 - : "qemu-system-x86_64"; 43 + const qemu = Deno.build.arch === "aarch64" 44 + ? "qemu-system-aarch64" 45 + : "qemu-system-x86_64"; 47 46 48 47 return Effect.succeed([ 49 48 ..._.compact([vm.bridge && qemu]), ··· 74 73 vm.drivePath && [ 75 74 "-drive", 76 75 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 77 - ] 76 + ], 78 77 ), 79 78 ]); 80 79 }; ··· 88 87 export const startDetachedQemu = ( 89 88 name: string, 90 89 vm: VirtualMachine, 91 - qemuArgs: string[] 90 + qemuArgs: string[], 92 91 ) => { 93 - const qemu = 94 - Deno.build.arch === "aarch64" 95 - ? "qemu-system-aarch64" 96 - : "qemu-system-x86_64"; 92 + const qemu = Deno.build.arch === "aarch64" 93 + ? "qemu-system-aarch64" 94 + : "qemu-system-x86_64"; 97 95 98 96 const logPath = `${LOGS_DIR}/${vm.name}.log`; 99 97 100 98 const fullCommand = vm.bridge 101 - ? `sudo ${qemu} ${qemuArgs 99 + ? `sudo ${qemu} ${ 100 + qemuArgs 102 101 .slice(1) 103 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 102 + .join(" ") 103 + } >> "${logPath}" 2>&1 & echo $!` 104 104 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 105 105 106 106 return Effect.tryPromise({ ··· 131 131 Effect.flatMap(({ qemuPid, logPath }) => 132 132 pipe( 133 133 updateInstanceState(name, "RUNNING", qemuPid), 134 - Effect.map(() => ({ vm, qemuPid, logPath })) 134 + Effect.map(() => ({ vm, qemuPid, logPath })), 135 135 ) 136 - ) 136 + ), 137 137 ); 138 138 }; 139 139 ··· 148 148 }) => 149 149 Effect.sync(() => { 150 150 console.log( 151 - `Virtual machine ${vm.name} started in background (PID: ${qemuPid})` 151 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 152 152 ); 153 153 console.log(`Logs will be written to: ${logPath}`); 154 154 }); ··· 156 156 const startInteractiveQemu = ( 157 157 name: string, 158 158 vm: VirtualMachine, 159 - qemuArgs: string[] 159 + qemuArgs: string[], 160 160 ) => { 161 - const qemu = 162 - Deno.build.arch === "aarch64" 163 - ? "qemu-system-aarch64" 164 - : "qemu-system-x86_64"; 161 + const qemu = Deno.build.arch === "aarch64" 162 + ? "qemu-system-aarch64" 163 + : "qemu-system-x86_64"; 165 164 166 165 return Effect.tryPromise({ 167 166 try: async () => { ··· 197 196 }); 198 197 199 198 export const createVolumeIfNeeded = ( 200 - vm: VirtualMachine 199 + vm: VirtualMachine, 201 200 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 202 201 Effect.gen(function* () { 203 202 const { flags } = parseFlags(Deno.args); ··· 211 210 212 211 if (!vm.drivePath) { 213 212 throw new Error( 214 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 213 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 215 214 ); 216 215 } 217 216 ··· 253 252 drivePath: volume ? volume.path : vm.drivePath, 254 253 diskFormat: volume ? "qcow2" : vm.diskFormat, 255 254 }, 256 - firmwareArgs 255 + firmwareArgs, 257 256 ) 258 257 ), 259 258 Effect.flatMap((qemuArgs) => ··· 261 260 createLogsDir(), 262 261 Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 263 262 Effect.tap(logDetachedSuccess), 264 - Effect.map(() => 0) // Exit code 0 263 + Effect.map(() => 0), // Exit code 0 265 264 ) 266 - ) 265 + ), 267 266 ) 268 267 ), 269 - Effect.catchAll(handleError) 268 + Effect.catchAll(handleError), 270 269 ); 271 270 272 271 const startInteractiveEffect = (name: string) => ··· 286 285 drivePath: volume ? volume.path : vm.drivePath, 287 286 diskFormat: volume ? "qcow2" : vm.diskFormat, 288 287 }, 289 - firmwareArgs 288 + firmwareArgs, 290 289 ) 291 290 ), 292 291 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 293 - Effect.map((status) => (status.success ? 0 : status.code || 1)) 292 + Effect.map((status) => (status.success ? 0 : status.code || 1)), 294 293 ) 295 294 ), 296 - Effect.catchAll(handleError) 295 + Effect.catchAll(handleError), 297 296 ); 298 297 299 298 export default async function (name: string, detach: boolean = false) { 300 299 const exitCode = await Effect.runPromise( 301 - detach ? startDetachedEffect(name) : startInteractiveEffect(name) 300 + detach ? startDetachedEffect(name) : startInteractiveEffect(name), 302 301 ); 303 302 304 303 if (detach) { ··· 312 311 const { flags } = parseFlags(Deno.args); 313 312 return { 314 313 ...vm, 315 - memory: 316 - flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory, 314 + memory: flags.memory || flags.m 315 + ? String(flags.memory || flags.m) 316 + : vm.memory, 317 317 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 318 318 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 319 319 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 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, 320 + portForward: flags.portForward || flags.p 321 + ? String(flags.portForward || flags.p) 322 + : vm.portForward, 323 + drivePath: flags.image || flags.i 324 + ? String(flags.image || flags.i) 325 + : vm.drivePath, 326 + bridge: flags.bridge || flags.b 327 + ? String(flags.bridge || flags.b) 328 + : vm.bridge, 329 + diskSize: flags.size || flags.s 330 + ? String(flags.size || flags.s) 331 + : vm.diskSize, 330 332 }; 331 333 }
+113 -27
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"; 6 - import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts"; 7 + import { 8 + EMPTY_DISK_THRESHOLD_KB, 9 + FEDORA_COREOS_DEFAULT_VERSION, 10 + FEDORA_COREOS_IMG_URL, 11 + LOGS_DIR, 12 + } from "./constants.ts"; 7 13 import type { Image } from "./db.ts"; 8 14 import { generateRandomMacAddress } from "./network.ts"; 9 15 import { saveInstanceState, updateInstanceState } from "./state.ts"; ··· 56 62 export const isValidISOurl = (url?: string): boolean => { 57 63 return Boolean( 58 64 (url?.startsWith("http://") || url?.startsWith("https://")) && 59 - url?.endsWith(".iso") 65 + url?.endsWith(".iso"), 60 66 ); 61 67 }; 62 68 ··· 82 88 }); 83 89 84 90 export const validateImage = ( 85 - image: string 91 + image: string, 86 92 ): Effect.Effect<string, InvalidImageNameError, never> => { 87 93 const regex = 88 94 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 93 99 image, 94 100 cause: 95 101 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 96 - }) 102 + }), 97 103 ); 98 104 } 99 105 return Effect.succeed(image); ··· 102 108 export const extractTag = (name: string) => 103 109 pipe( 104 110 validateImage(name), 105 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 111 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 106 112 ); 107 113 108 114 export const failOnMissingImage = ( 109 - image: Image | undefined 115 + image: Image | undefined, 110 116 ): Effect.Effect<Image, Error, never> => 111 117 image 112 118 ? Effect.succeed(image) 113 119 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 114 120 115 121 export const du = ( 116 - path: string 122 + path: string, 117 123 ): Effect.Effect<number, LogCommandError, never> => 118 124 Effect.tryPromise({ 119 125 try: async () => { ··· 145 151 exists 146 152 ? Effect.succeed(true) 147 153 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 148 - ) 154 + ), 149 155 ); 150 156 151 157 export const downloadIso = (url: string, options: Options) => ··· 167 173 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 168 174 console.log( 169 175 chalk.yellowBright( 170 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 171 - ) 176 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 177 + ), 172 178 ); 173 179 return null; 174 180 } ··· 186 192 if (outputExists) { 187 193 console.log( 188 194 chalk.yellowBright( 189 - `File ${outputPath} already exists, skipping download.` 190 - ) 195 + `File ${outputPath} already exists, skipping download.`, 196 + ), 191 197 ); 192 198 return outputPath; 193 199 } ··· 236 242 if (!success) { 237 243 console.error( 238 244 chalk.redBright( 239 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 240 - ) 245 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 246 + ), 241 247 ); 242 248 Deno.exit(1); 243 249 } ··· 250 256 try: () => 251 257 Deno.copyFile( 252 258 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 253 - edk2VarsAarch64 259 + edk2VarsAarch64, 254 260 ), 255 261 catch: (error) => new LogCommandError({ cause: error }), 256 262 }); ··· 285 291 return `user,id=net0,${portForwarding}`; 286 292 } 287 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 + 288 326 export const runQemu = (isoPath: string | null, options: Options) => 289 327 Effect.gen(function* () { 290 328 const macAddress = yield* generateRandomMacAddress(); 291 329 292 - const qemu = 293 - Deno.build.arch === "aarch64" 294 - ? "qemu-system-aarch64" 295 - : "qemu-system-x86_64"; 330 + const qemu = Deno.build.arch === "aarch64" 331 + ? "qemu-system-aarch64" 332 + : "qemu-system-x86_64"; 296 333 297 334 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 335 + const coreosArgs: string[] = yield* setupCoreOSArgs(isoPath); 298 336 299 337 const qemuArgs = [ 300 338 ..._.compact([options.bridge && qemu]), ··· 306 344 options.memory, 307 345 "-smp", 308 346 options.cpus.toString(), 309 - ..._.compact([isoPath && "-cdrom", isoPath]), 347 + ...(isoPath && isoPath.endsWith(".iso") ? ["-cdrom", isoPath] : []), 310 348 "-netdev", 311 349 options.bridge 312 350 ? `bridge,id=net0,br=${options.bridge}` ··· 322 360 "-serial", 323 361 "chardev:con0", 324 362 ...firmwareFiles, 363 + ...coreosArgs, 325 364 ..._.compact( 326 365 options.image && [ 327 366 "-drive", 328 367 `file=${options.image},format=${options.diskFormat},if=virtio`, 329 - ] 368 + ], 330 369 ), 331 370 ]; 332 371 ··· 341 380 const logPath = `${LOGS_DIR}/${name}.log`; 342 381 343 382 const fullCommand = options.bridge 344 - ? `sudo ${qemu} ${qemuArgs 383 + ? `sudo ${qemu} ${ 384 + qemuArgs 345 385 .slice(1) 346 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 386 + .join(" ") 387 + } >> "${logPath}" 2>&1 & echo $!` 347 388 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 348 389 349 390 const { stdout } = yield* Effect.tryPromise({ ··· 379 420 }); 380 421 381 422 console.log( 382 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 423 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 383 424 ); 384 425 console.log(`Logs will be written to: ${logPath}`); 385 426 ··· 501 542 if (pathExists) { 502 543 console.log( 503 544 chalk.yellowBright( 504 - `Drive image ${path} already exists, skipping creation.` 505 - ) 545 + `Drive image ${path} already exists, skipping creation.`, 546 + ), 506 547 ); 507 548 return; 508 549 } ··· 529 570 }); 530 571 531 572 export const fileExists = ( 532 - path: string 573 + path: string, 533 574 ): Effect.Effect<void, NoSuchFileError, never> => 534 575 Effect.tryPromise({ 535 576 try: () => Deno.stat(path), 536 577 catch: (error) => new NoSuchFileError({ cause: String(error) }), 537 578 }); 579 + 580 + export const constructCoreOSImageURL = ( 581 + image: string, 582 + ): Effect.Effect<string, InvalidImageNameError, never> => { 583 + // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 584 + const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; 585 + const match = image.match(coreosRegex); 586 + if (match) { 587 + const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 588 + return Effect.succeed( 589 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 590 + ); 591 + } 592 + 593 + return Effect.fail( 594 + new InvalidImageNameError({ 595 + image, 596 + cause: "Image name does not match CoreOS naming conventions.", 597 + }), 598 + ); 599 + }; 600 + 601 + export const extractXz = (path: string | null) => 602 + Effect.tryPromise({ 603 + try: async () => { 604 + if (!path) { 605 + return null; 606 + } 607 + const cmd = new Deno.Command("xz", { 608 + args: ["-d", path], 609 + stdin: "inherit", 610 + stdout: "inherit", 611 + stderr: "inherit", 612 + cwd: dirname(path), 613 + }).spawn(); 614 + 615 + const status = await cmd.status; 616 + if (!status.success) { 617 + console.error(chalk.redBright("Failed to extract xz file.")); 618 + Deno.exit(status.code); 619 + } 620 + return path.replace(/\.xz$/, ""); 621 + }, 622 + catch: (error) => new LogCommandError({ cause: error }), 623 + });
+52
src/utils_test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { Effect, pipe } from "effect"; 3 + import { FEDORA_COREOS_IMG_URL } from "./constants.ts"; 4 + import { constructCoreOSImageURL } from "./utils.ts"; 5 + 6 + Deno.test("Test Default Fedora CoreOS Image URL", () => { 7 + const url = Effect.runSync( 8 + pipe( 9 + constructCoreOSImageURL("fedora-coreos"), 10 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 11 + ), 12 + ); 13 + 14 + assertEquals(url, FEDORA_COREOS_IMG_URL); 15 + }); 16 + 17 + Deno.test("Test Default Fedora CoreOS Image URL", () => { 18 + const url = Effect.runSync( 19 + pipe( 20 + constructCoreOSImageURL("coreos"), 21 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 22 + ), 23 + ); 24 + 25 + assertEquals(url, FEDORA_COREOS_IMG_URL); 26 + }); 27 + 28 + Deno.test("Test Specific Fedora CoreOS Version", () => { 29 + const url = Effect.runSync( 30 + pipe( 31 + constructCoreOSImageURL("fedora-coreos-43.20251024.2.0"), 32 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 33 + ), 34 + ); 35 + 36 + assertEquals( 37 + url, 38 + "https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/43.20251024.2.0/" + 39 + `${Deno.build.arch}/fedora-coreos-43.20251024.2.0-qemu.${Deno.build.arch}.qcow2.xz`, 40 + ); 41 + }); 42 + 43 + Deno.test("Test invalid Fedora CoreOS Image Name", () => { 44 + const url = Effect.runSync( 45 + pipe( 46 + constructCoreOSImageURL("fedora-coreos-latest"), 47 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 48 + ), 49 + ); 50 + 51 + assertEquals(url, null); 52 + });