A simple, zero-configuration script to quickly boot FreeBSD ISO images using QEMU

Merge pull request #4 from tsirysndr/feat/config-file

Enhance VM configuration management and add new commands

authored by tsiry-sandratraina.com and committed by GitHub 5fb69235 a62fa2bf

+2 -1
.gitignore
··· 1 1 *.iso 2 - *.img 2 + *.img 3 + vmconfig.toml
+2
deno.json
··· 15 15 "@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4", 16 16 "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 17 17 "@std/assert": "jsr:@std/assert@1", 18 + "@std/toml": "jsr:@std/toml@^1.0.11", 19 + "@zod/zod": "jsr:@zod/zod@^4.1.12", 18 20 "chalk": "npm:chalk@^5.6.2", 19 21 "dayjs": "npm:dayjs@^1.11.19", 20 22 "effect": "npm:effect@^3.19.2",
+23 -1
deno.lock
··· 13 13 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", 14 14 "jsr:@std/assert@0.217": "0.217.0", 15 15 "jsr:@std/assert@1": "1.0.15", 16 + "jsr:@std/collections@^1.1.3": "1.1.3", 16 17 "jsr:@std/encoding@1": "1.0.10", 17 18 "jsr:@std/fmt@1": "1.0.8", 18 19 "jsr:@std/fmt@~1.0.2": "1.0.8", ··· 23 24 "jsr:@std/path@0.217": "0.217.0", 24 25 "jsr:@std/path@1": "1.1.2", 25 26 "jsr:@std/path@^1.1.1": "1.1.2", 26 - "jsr:@std/text@~1.0.7": "1.0.15", 27 + "jsr:@std/text@~1.0.7": "1.0.16", 28 + "jsr:@std/toml@*": "1.0.11", 29 + "jsr:@std/toml@^1.0.11": "1.0.11", 30 + "jsr:@zod/zod@*": "4.1.12", 31 + "jsr:@zod/zod@^4.1.12": "4.1.12", 27 32 "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", 28 33 "npm:chalk@^5.6.2": "5.6.2", 29 34 "npm:dayjs@^1.11.19": "1.11.19", ··· 92 97 "jsr:@std/internal@^1.0.12" 93 98 ] 94 99 }, 100 + "@std/collections@1.1.3": { 101 + "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" 102 + }, 95 103 "@std/encoding@1.0.10": { 96 104 "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 97 105 }, ··· 122 130 }, 123 131 "@std/text@1.0.15": { 124 132 "integrity": "91f5cc1e12779a3d95f1be34e763f9c28a75a078b7360e6fcaef0d8d9b1e3e7f" 133 + }, 134 + "@std/text@1.0.16": { 135 + "integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8" 136 + }, 137 + "@std/toml@1.0.11": { 138 + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", 139 + "dependencies": [ 140 + "jsr:@std/collections" 141 + ] 142 + }, 143 + "@zod/zod@4.1.12": { 144 + "integrity": "5876ed4c6d44673faf5120f0a461a2ada2eb6c735329d3ebaf5ba1fc08387695" 125 145 } 126 146 }, 127 147 "npm": { ··· 184 204 "jsr:@es-toolkit/es-toolkit@^1.41.0", 185 205 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 186 206 "jsr:@std/assert@1", 207 + "jsr:@std/toml@^1.0.11", 208 + "jsr:@zod/zod@^4.1.12", 187 209 "npm:@paralleldrive/cuid2@^3.0.4", 188 210 "npm:chalk@^5.6.2", 189 211 "npm:dayjs@^1.11.19",
+40 -5
main.ts
··· 1 1 #!/usr/bin/env -S deno run --allow-run --allow-read --allow-env 2 2 3 3 import { Command } from "@cliffy/command"; 4 - import { Effect } from "effect"; 4 + import chalk from "chalk"; 5 + import { Effect, pipe } from "effect"; 5 6 import pkg from "./deno.json" with { type: "json" }; 7 + import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; 8 + import { CONFIG_FILE_NAME } from "./src/constants.ts"; 6 9 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 7 10 import inspect from "./src/subcommands/inspect.ts"; 8 11 import logs from "./src/subcommands/logs.ts"; ··· 16 19 downloadIso, 17 20 emptyDiskImage, 18 21 handleInput, 22 + isValidISOurl, 19 23 type Options, 20 24 runQemu, 21 25 } from "./src/utils.ts"; ··· 68 72 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 69 73 ) 70 74 .example( 75 + "Create a default VM configuration file", 76 + "freebsd-up init", 77 + ) 78 + .example( 71 79 "Default usage", 72 80 "freebsd-up", 73 81 ) ··· 108 116 const resolvedInput = handleInput(input); 109 117 let isoPath: string | null = resolvedInput; 110 118 111 - if ( 112 - resolvedInput.startsWith("https://") || 113 - resolvedInput.startsWith("http://") 114 - ) { 119 + const config = yield* pipe( 120 + parseVmFile(CONFIG_FILE_NAME), 121 + Effect.tap(() => Effect.log("Parsed VM configuration file.")), 122 + Effect.catchAll(() => Effect.succeed(null)), 123 + ); 124 + 125 + if (!input && (isValidISOurl(config?.vm?.iso))) { 126 + isoPath = yield* downloadIso(config!.vm!.iso!, options); 127 + } 128 + 129 + options = yield* mergeConfig(config, options); 130 + 131 + if (input && isValidISOurl(resolvedInput)) { 115 132 isoPath = yield* downloadIso(resolvedInput, options); 116 133 } 117 134 ··· 128 145 129 146 if (options.bridge) { 130 147 yield* createBridgeNetworkIfNeeded(options.bridge); 148 + } 149 + 150 + if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) { 151 + isoPath = null; 131 152 } 132 153 133 154 yield* runQemu(isoPath, options); ··· 206 227 .arguments("<vm-name:string>") 207 228 .action(async (_options: unknown, vmName: string) => { 208 229 await restart(vmName); 230 + }) 231 + .command("init", "Initialize a default VM configuration file") 232 + .action(async () => { 233 + await Effect.runPromise(initVmFile(CONFIG_FILE_NAME)); 234 + console.log( 235 + `New VM configuration file created at ${ 236 + chalk.greenBright("./") + 237 + chalk.greenBright(CONFIG_FILE_NAME) 238 + }`, 239 + ); 240 + console.log( 241 + `You can edit this file to customize your VM settings and then start the VM with:`, 242 + ); 243 + console.log(` ${chalk.greenBright(`freebsd-up`)}`); 209 244 }) 210 245 .parse(Deno.args); 211 246 }
+115
src/config.ts
··· 1 + import { parseFlags } from "@cliffy/flags"; 2 + import _ from "@es-toolkit/es-toolkit/compat"; 3 + import * as toml from "@std/toml"; 4 + import z from "@zod/zod"; 5 + import { Data, Effect } from "effect"; 6 + import { 7 + constructDownloadUrl, 8 + DEFAULT_VERSION, 9 + type Options, 10 + } from "./utils.ts"; 11 + 12 + export const VmConfigSchema = z.object({ 13 + vm: z.object({ 14 + iso: z.string(), 15 + output: z.string(), 16 + cpu: z.string(), 17 + cpus: z.number(), 18 + memory: z.string(), 19 + image: z.string(), 20 + disk_format: z.enum(["qcow2", "raw"]), 21 + size: z.string(), 22 + }).partial(), 23 + network: z.object({ 24 + bridge: z.string(), 25 + port_forward: z.string(), 26 + }).partial(), 27 + options: z.object({ 28 + detach: z.boolean(), 29 + }).partial(), 30 + }); 31 + 32 + export type VmConfig = z.infer<typeof VmConfigSchema>; 33 + 34 + class VmConfigError extends Data.TaggedError("VmConfigError")<{ 35 + cause?: string; 36 + }> {} 37 + 38 + export const initVmFile = ( 39 + path: string, 40 + ): Effect.Effect<void, VmConfigError, never> => 41 + Effect.tryPromise({ 42 + try: async () => { 43 + const defaultConfig: VmConfig = { 44 + vm: { 45 + iso: constructDownloadUrl(DEFAULT_VERSION), 46 + cpu: "host", 47 + cpus: 2, 48 + memory: "2G", 49 + }, 50 + network: { 51 + port_forward: "2222:22", 52 + }, 53 + options: { 54 + detach: false, 55 + }, 56 + }; 57 + const tomlString = toml.stringify(defaultConfig); 58 + await Deno.writeTextFile(path, tomlString); 59 + }, 60 + catch: (error) => new VmConfigError({ cause: String(error) }), 61 + }); 62 + 63 + export const parseVmFile = ( 64 + path: string, 65 + ): Effect.Effect<VmConfig, VmConfigError, never> => 66 + Effect.tryPromise({ 67 + try: async () => { 68 + const fileContent = await Deno.readTextFile(path); 69 + const parsedToml = toml.parse(fileContent); 70 + return VmConfigSchema.parse(parsedToml); 71 + }, 72 + catch: (error) => new VmConfigError({ cause: String(error) }), 73 + }); 74 + 75 + export const mergeConfig = ( 76 + config: VmConfig | null, 77 + options: Options, 78 + ): Effect.Effect<Options, never, never> => { 79 + const { flags } = parseFlags(Deno.args); 80 + const defaultConfig: VmConfig = { 81 + vm: { 82 + iso: _.get(config, "vm.iso"), 83 + cpu: _.get(config, "vm.cpu", "host"), 84 + cpus: _.get(config, "vm.cpus", 2), 85 + memory: _.get(config, "vm.memory", "2G"), 86 + image: _.get(config, "vm.image", options.image), 87 + disk_format: _.get(config, "vm.disk_format", "raw"), 88 + size: _.get(config, "vm.size", "20G"), 89 + }, 90 + network: { 91 + port_forward: _.get(config, "network.port_forward", "2222:22"), 92 + }, 93 + options: { 94 + detach: _.get(config, "options.detach", false), 95 + }, 96 + }; 97 + return Effect.succeed({ 98 + memory: _.get(flags, "memory", defaultConfig.vm.memory!) as string, 99 + cpus: _.get(flags, "cpus", defaultConfig.vm.cpus!) as number, 100 + cpu: _.get(flags, "cpu", defaultConfig.vm.cpu!) as string, 101 + diskFormat: _.get( 102 + flags, 103 + "diskFormat", 104 + defaultConfig.vm.disk_format!, 105 + ) as string, 106 + portForward: _.get( 107 + flags, 108 + "portForward", 109 + defaultConfig.network.port_forward!, 110 + ) as string, 111 + image: _.get(flags, "image", defaultConfig.vm.image!) as string, 112 + bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string, 113 + size: _.get(flags, "size", defaultConfig.vm.size!) as string, 114 + }); 115 + };
+1
src/constants.ts
··· 2 2 export const DB_PATH: string = `${CONFIG_DIR}/state.sqlite`; 3 3 export const LOGS_DIR: string = `${CONFIG_DIR}/logs`; 4 4 export const EMPTY_DISK_THRESHOLD_KB: number = 100; 5 + export const CONFIG_FILE_NAME: string = "vmconfig.toml";
+9 -1
src/utils.ts
··· 7 7 import { generateRandomMacAddress } from "./network.ts"; 8 8 import { saveInstanceState, updateInstanceState } from "./state.ts"; 9 9 10 - const DEFAULT_VERSION = "14.3-RELEASE"; 10 + export const DEFAULT_VERSION = "14.3-RELEASE"; 11 11 12 12 export interface Options { 13 13 output?: string; ··· 25 25 class LogCommandError extends Data.TaggedError("LogCommandError")<{ 26 26 cause?: unknown; 27 27 }> {} 28 + 29 + export const isValidISOurl = (url?: string): boolean => { 30 + return Boolean( 31 + (url?.startsWith("http://") || url?.startsWith("https://")) && 32 + url?.endsWith(".iso"), 33 + ); 34 + }; 28 35 29 36 const du = (path: string) => 30 37 Effect.tryPromise({ ··· 104 111 105 112 yield* Effect.tryPromise({ 106 113 try: async () => { 114 + console.log(chalk.blueBright(`Downloading ISO from ${url}...`)); 107 115 const cmd = new Deno.Command("curl", { 108 116 args: ["-L", "-o", outputPath, url], 109 117 stdin: "inherit",