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

Add CI workflow for testing and formatting with Deno

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