From caff15309d55b85aab2c2f04bfb794b5e987fd38 Mon Sep 17 00:00:00 2001 From: Andrea Manzini Date: Fri, 19 Sep 2025 12:43:11 +0200 Subject: [PATCH 1/3] refactoring added ppc64le architecture s390x in progress --- README.md | 4 +- source/app.d | 573 ++++++++------------------------------------------- 2 files changed, 93 insertions(+), 484 deletions(-) diff --git a/README.md b/README.md index 76fd7c9..8aded07 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ make install | Option | Short | Description | Default | |--------|-------|-------------|---------| +| `--arch` | `-a` | CPU architecture (`x86_64`, `aarch64`, etc.) | `x86_64` | | `--disk` | `-d` | Path to disk image (required) | - | | `--cpu` | `-c` | Number of CPU cores | 2 | | `--ram` | `-r` | RAM in GB | 4 | @@ -68,6 +69,7 @@ QBoot automatically creates a configuration file at `~/.config/qboot/config.json ```json { "description": "Default configuration for qboot. Edit these values to fit your workflow.", + "arch": "x86_64", "cpu": 2, "ram_gb": 4, "ssh_port": 2222, @@ -283,4 +285,4 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS **Happy virtualizing!** ๐ŸŽ‰ -If you find QBoot useful, please consider giving it a โญ on GitHub! \ No newline at end of file +If you find QBoot useful, please consider giving it a โญ on GitHub! diff --git a/source/app.d b/source/app.d index 6571a5e..6fd9876 100644 --- a/source/app.d +++ b/source/app.d @@ -1,540 +1,147 @@ +import vm; +import config; +import x86_64; +import ppc64le; +import s390x; import std.stdio; import std.getopt; -import std.process : spawnProcess, wait, environment; +import std.process : environment; import std.string; import std.conv; import std.file; -import std.format; import std.path; import std.json; -import std.array; +import std.exception; version (unittest) { - import std.exception; import std.algorithm; import std.random; } -/** - * Configuration structure for VM settings - */ -struct VMConfig -{ - int cpu = 1; - int ramGb = 2; - ushort sshPort = 2222; - string logFile = "console.log"; - bool headlessSavesChanges = false; -} - -/** - * Creates a default configuration JSON structure - */ -JSONValue createDefaultConfig() -{ - return JSONValue([ - "description": JSONValue("Default configuration for qboot. Edit these values to fit your workflow."), - "cpu": JSONValue(2), - "ram_gb": JSONValue(4), - "ssh_port": JSONValue(2222), - "log_file": JSONValue("console.log"), - "headless_saves_changes": JSONValue(false), - ]); -} - -/** - * Parses configuration from JSON - */ -VMConfig parseConfig(JSONValue json) -{ - VMConfig config; - - if ("cpu" in json) - config.cpu = json["cpu"].get!int; - if ("ram_gb" in json) - config.ramGb = json["ram_gb"].get!int; - if ("log_file" in json) - config.logFile = json["log_file"].get!string; - if ("ssh_port" in json) - config.sshPort = json["ssh_port"].get!ushort; - if ("headless_saves_changes" in json) - config.headlessSavesChanges = json["headless_saves_changes"].get!bool; - - return config; -} - -/** - * Ensures the configuration directory and a default config.json file exist. - */ -void ensureConfigFileExists(string dirPath, string filePath) -{ - if (filePath.exists) - return; - - try - { - writeln("Configuration file not found. Creating a default at '", filePath, "'..."); - dirPath.mkdirRecurse(); - - auto defaultConfig = createDefaultConfig(); - std.file.write(filePath, defaultConfig.toPrettyString()); - - } - catch (Exception e) - { - stderr.writeln("Warning: Could not create default config file: ", e.msg); - } -} - -/** - * Validates disk path and throws descriptive errors - */ -void validateDiskPath(string diskPath) +void main(string[] args) { - if (diskPath.empty) - { - throw new Exception("Disk path cannot be empty. Use -d or --disk."); - } - if (!diskPath.exists) + version (unittest) { - throw new Exception(format("Disk image not found at '%s'", diskPath)); + // Skip main when running unit tests + return; } -} -/** - * Validates VM configuration parameters - */ -void validateVMConfig(const ref VirtualMachine vm) -{ - if (vm.cpu < 1 || vm.cpu > 32) - { - throw new Exception(format("CPU count must be between 1 and 32, got %d", vm.cpu)); - } - if (vm.ram < 1 || vm.ram > 128) - { - throw new Exception(format("RAM must be between 1 and 128 GB, got %d", vm.ram)); - } - if (vm.sshPort < 1024 || vm.sshPort > 65535) - { - throw new Exception(format("SSH port must be between 1024 and 65535, got %d", vm.sshPort)); - } -} + string configDir = buildPath(environment.get("HOME"), ".config", "qboot"); + string configFile = buildPath(configDir, "config.json"); -struct VirtualMachine -{ - string diskPath; - int cpu; - int ram; - bool noSnapshot; - string logFile; - ushort sshPort; - bool interactive; + ensureConfigFileExists(configDir, configFile); - /// Loads settings from the specified JSON configuration file. - void loadFromFile(string path) + VMConfig config; + if (configFile.exists) { - writeln("Found configuration at '", path, "'. Loading defaults..."); try { - auto text = path.readText(); + auto text = configFile.readText(); auto json = parseJSON(text); - auto config = parseConfig(json); - - this.cpu = config.cpu; - this.ram = config.ramGb; - this.logFile = config.logFile; - this.sshPort = config.sshPort; - this.noSnapshot = config.headlessSavesChanges; - + config = parseConfig(json); } catch (Exception e) { - stderr.writeln("Warning: Could not parse config file '", path, "': ", e.msg); + stderr.writeln("Warning: Could not parse config file '", configFile, "': ", e.msg); } } - /// Builds QEMU command line arguments - string[] buildArgs() - { - validateDiskPath(diskPath); - validateVMConfig(this); - - string[] args; - args ~= ["-enable-kvm", "-cpu", "host"]; - args ~= ["-smp", to!string(cpu), "-m", format("%dG", ram)]; - args ~= [ - "-drive", - format("file=%s,if=virtio,cache=none,aio=native,discard=unmap", diskPath) - ]; - args ~= ["-audiodev", "none,id=snd0"]; - - if (interactive) - { - args ~= ["-display", "default,show-cursor=on"]; - } - else - { - args ~= ["-nographic"]; - if (!noSnapshot) - { - args ~= ["-snapshot"]; - } - } - - args ~= ["-netdev", format("user,id=net0,hostfwd=tcp::%d-:22", sshPort)]; - args ~= ["-device", "virtio-net-pci,netdev=net0"]; - args ~= ["-serial", format("file:%s", logFile)]; - - return args; - } + string diskPath, cpu, ram, logFile, arch; + bool interactive, noSnapshot; + ushort sshPort; - /// Runs the virtual machine - void run() + try { - auto args = this.buildArgs(); - writeln("๐Ÿš€ Starting QEMU with the following command:"); - writeln("qemu-system-x86_64 ", args.join(" ")); - writeln("-------------------------------------------------"); + auto helpInfo = getopt(args, "arch|a", &arch, "disk|d", &diskPath, "cpu|c", &cpu, "ram|r", + &ram, "interactive|i", &interactive, "no-snapshot|S", &noSnapshot, + "log|l", &logFile, "ssh-port", &sshPort, ); - version (unittest) + if (helpInfo.helpWanted || diskPath.empty) { - // In unit tests, don't actually spawn QEMU + writeln("qboot - A handy QEMU VM launcher"); + writeln(); + writeln("USAGE:"); + writeln(" qboot [OPTIONS] --disk "); + writeln(); + writeln("EXAMPLES:"); + writeln(" qboot --disk ubuntu.qcow2"); + writeln(" qboot --disk fedora.img --cpu 4 --ram 8 --interactive"); + writeln(" qboot --arch ppc64le --disk debian.qcow2 --ssh-port 2223"); + writeln(" qboot --disk test.img --no-snapshot --log vm.log"); + writeln(); + writeln("OPTIONS:"); + + // Custom help formatting with proper option descriptions + writeln(" -a, --arch Target architecture (default: x86_64, options: x86_64, ppc64le)"); + writeln(" -d, --disk Path to disk image file (required)"); + writeln(" -c, --cpu Number of CPU cores (default: 2)"); + writeln(" -r, --ram RAM size in GB (default: 4)"); + writeln(" -i, --interactive Enable interactive mode with QEMU monitor"); + writeln(" -S, --no-snapshot Disable snapshot mode (changes will be saved to disk)"); + writeln(" -l, --log Log file path (default: console.log)"); + writeln(" --ssh-port SSH port forwarding (default: 2222)"); + writeln(" -h, --help Show this help message"); + + writeln(); + writeln("Configuration file: ~/.config/qboot/config.json"); + writeln("Command line options override configuration file settings."); return; } - - string[] fullArgs = ["qemu-system-x86_64"] ~ args; - auto pid = spawnProcess(fullArgs); - auto status = wait(pid); - if (status != 0) - stderr.writeln("QEMU exited with a non-zero status: ", status); } -} - -void main(string[] args) -{ - version (unittest) + catch (Exception e) { - // Skip main when running unit tests + stderr.writeln("Error: ", e.msg); return; } - auto vm = VirtualMachine(cpu: 1, ram: 2, logFile: "console.log", sshPort: 2222, -noSnapshot: false, interactive: false); - - string configDir = buildPath(environment.get("HOME"), ".config", "qboot"); - string configFile = buildPath(configDir, "config.json"); + if (!arch.empty) + { + config.arch = arch; + } - ensureConfigFileExists(configDir, configFile); + VirtualMachine vm; + switch (config.arch) + { + case "x86_64": + vm = new X86_64_VM(); + break; + case "ppc64le": + vm = new PPC64LE_VM(); + break; + case "s390x": + vm = new S390X_VM(); + break; + + default: + stderr.writeln("Error: Unsupported architecture '", config.arch, + "' in config file."); + return; + } if (configFile.exists) { vm.loadFromFile(configFile); } + vm.diskPath = diskPath; + if (!cpu.empty) + vm.cpu = cpu.to!int; + if (!ram.empty) + vm.ram = ram.to!int; + vm.interactive = interactive; + vm.noSnapshot = noSnapshot; + if (!logFile.empty) + vm.logFile = logFile; + if (sshPort != 0) + vm.sshPort = sshPort; + try { - auto helpInfo = getopt(args, "disk|d", &vm.diskPath, "cpu|c", &vm.cpu, - "ram|r", &vm.ram, "interactive|i", &vm.interactive, "no-snapshot|S", - &vm.noSnapshot, "log|l", &vm.logFile, "ssh-port", &vm.sshPort); - - if (helpInfo.helpWanted || vm.diskPath.empty) - { - defaultGetoptPrinter("A handy QEMU VM launcher.", helpInfo.options); - return; - } - vm.run(); - } catch (Exception e) { stderr.writeln("Error: ", e.msg); } } - -// ============================================================================ -// UNIT TESTS -// ============================================================================ - -unittest -{ - writeln("Running createDefaultConfig tests..."); - - auto config = createDefaultConfig(); - assert(config["cpu"].get!int == 2); - assert(config["ram_gb"].get!int == 4); - assert(config["ssh_port"].get!ushort == 2222); - assert(config["log_file"].get!string == "console.log"); - assert(config["headless_saves_changes"].get!bool == false); - - writeln("โœ“ createDefaultConfig tests passed"); -} - -unittest -{ - writeln("Running parseConfig tests..."); - - // Test valid config - auto json = JSONValue([ - "cpu": JSONValue(4), - "ram_gb": JSONValue(8), - "ssh_port": JSONValue(3333), - "log_file": JSONValue("test.log"), - "headless_saves_changes": JSONValue(true) - ]); - - auto config = parseConfig(json); - assert(config.cpu == 4); - assert(config.ramGb == 8); - assert(config.sshPort == 3333); - assert(config.logFile == "test.log"); - assert(config.headlessSavesChanges == true); - - // Test partial config (should use defaults) - auto partialJson = JSONValue(["cpu": JSONValue(6)]); - - auto partialConfig = parseConfig(partialJson); - assert(partialConfig.cpu == 6); - assert(partialConfig.ramGb == 2); // default - assert(partialConfig.sshPort == 2222); // default - - writeln("โœ“ parseConfig tests passed"); -} - -unittest -{ - writeln("Running validateDiskPath tests..."); - - // Test empty path - assertThrown!Exception(validateDiskPath("")); - - // Test non-existent path - assertThrown!Exception(validateDiskPath("/non/existent/path.img")); - - // Create a temporary file for testing - auto tempFile = tempDir ~ "/test_disk.img"; - scope (exit) - if (tempFile.exists) - tempFile.remove(); - - std.file.write(tempFile, "test disk content"); - assert(tempFile.exists); - - // This should not throw - validateDiskPath(tempFile); - - writeln("โœ“ validateDiskPath tests passed"); -} - -unittest -{ - writeln("Running validateVMConfig tests..."); - - VirtualMachine vm; - - // Test invalid CPU counts - vm.cpu = 0; - vm.ram = 4; - vm.sshPort = 2222; - assertThrown!Exception(validateVMConfig(vm)); - - vm.cpu = 33; - assertThrown!Exception(validateVMConfig(vm)); - - // Test invalid RAM values - vm.cpu = 2; - vm.ram = 0; - assertThrown!Exception(validateVMConfig(vm)); - - vm.ram = 129; - assertThrown!Exception(validateVMConfig(vm)); - - // Test invalid SSH port - vm.ram = 4; - vm.sshPort = 1023; - assertThrown!Exception(validateVMConfig(vm)); - - vm.sshPort = 65535; - vm.ram = 4; - // Test valid config - vm.cpu = 4; - vm.ram = 8; - vm.sshPort = 2222; - // This should not throw - validateVMConfig(vm); - - writeln("โœ“ validateVMConfig tests passed"); -} - -unittest -{ - writeln("Running VirtualMachine.buildArgs tests..."); - - // Create a temporary disk file - auto tempFile = tempDir ~ "/test_vm_disk.img"; - scope (exit) - if (tempFile.exists) - tempFile.remove(); - std.file.write(tempFile, "test disk content"); - - VirtualMachine vm; - vm.diskPath = tempFile; - vm.cpu = 2; - vm.ram = 4; - vm.sshPort = 2222; - vm.logFile = "test.log"; - vm.interactive = false; - vm.noSnapshot = false; - - auto args = vm.buildArgs(); - - // Check that essential arguments are present - assert(args.canFind("-enable-kvm")); - assert(args.canFind("-cpu")); - assert(args.canFind("host")); - assert(args.canFind("-smp")); - assert(args.canFind("2")); - assert(args.canFind("-m")); - assert(args.canFind("4G")); - assert(args.canFind("-nographic")); - assert(args.canFind("-snapshot")); - - // Test interactive mode - vm.interactive = true; - auto interactiveArgs = vm.buildArgs(); - assert(interactiveArgs.canFind("-display")); - assert(interactiveArgs.canFind("default,show-cursor=on")); - assert(!interactiveArgs.canFind("-nographic")); - - // Test no snapshot mode - vm.interactive = false; - vm.noSnapshot = true; - auto noSnapshotArgs = vm.buildArgs(); - assert(!noSnapshotArgs.canFind("-snapshot")); - - writeln("โœ“ VirtualMachine.buildArgs tests passed"); -} - -unittest -{ - writeln("Running VirtualMachine.loadFromFile tests..."); - - // Create a temporary config file - auto tempConfigFile = tempDir ~ "/test_config.json"; - scope (exit) - if (tempConfigFile.exists) - tempConfigFile.remove(); - - auto testConfig = JSONValue([ - "cpu": JSONValue(8), - "ram_gb": JSONValue(16), - "ssh_port": JSONValue(3333), - "log_file": JSONValue("custom.log"), - "headless_saves_changes": JSONValue(true) - ]); - - std.file.write(tempConfigFile, testConfig.toPrettyString()); - - VirtualMachine vm; - vm.loadFromFile(tempConfigFile); - - assert(vm.cpu == 8); - assert(vm.ram == 16); - assert(vm.sshPort == 3333); - assert(vm.logFile == "custom.log"); - assert(vm.noSnapshot == true); - - writeln("โœ“ VirtualMachine.loadFromFile tests passed"); -} - -unittest -{ - writeln("Running ensureConfigFileExists tests..."); - - // Create a temporary directory - auto tempTestDir = tempDir ~ "/qboot_test_" ~ to!string(uniform(1000, 9999)); - auto tempConfigFile = tempTestDir ~ "/config.json"; - - scope (exit) - { - if (tempConfigFile.exists) - tempConfigFile.remove(); - if (tempTestDir.exists) - tempTestDir.rmdir(); - } - - // Initially, neither directory nor file should exist - assert(!tempTestDir.exists); - assert(!tempConfigFile.exists); - - // Call the function - ensureConfigFileExists(tempTestDir, tempConfigFile); - - // Now both should exist - assert(tempTestDir.exists); - assert(tempConfigFile.exists); - - // Verify the content is valid JSON - auto content = tempConfigFile.readText(); - auto json = parseJSON(content); - assert(json["cpu"].get!int == 2); - - // Call again - should not overwrite - auto originalContent = tempConfigFile.readText(); - ensureConfigFileExists(tempTestDir, tempConfigFile); - assert(tempConfigFile.readText() == originalContent); - - writeln("โœ“ ensureConfigFileExists tests passed"); -} - -// Integration test -unittest -{ - writeln("Running integration test..."); - - // Create temporary files - auto tempTestDir = tempDir ~ "/qboot_integration_test"; - auto tempDisk = tempTestDir ~ "/test.img"; - auto tempConfig = tempTestDir ~ "/config.json"; - - scope (exit) - { - if (tempDisk.exists) - tempDisk.remove(); - if (tempConfig.exists) - tempConfig.remove(); - if (tempTestDir.exists) - tempTestDir.rmdir(); - } - - tempTestDir.mkdirRecurse(); - std.file.write(tempDisk, "fake disk image"); - - // Create VM and set it up - VirtualMachine vm; - vm.diskPath = tempDisk; - vm.cpu = 2; - vm.ram = 4; - vm.sshPort = 2222; - vm.logFile = "integration_test.log"; - vm.interactive = false; - vm.noSnapshot = false; - - // Should be able to build args without throwing - auto args = vm.buildArgs(); - assert(args.length > 0); - - // Should be able to run (though it won't actually spawn QEMU in unittest) - vm.run(); - - writeln("โœ“ Integration test passed"); -} - -static this() -{ - version (unittest) - { - writeln("๐Ÿงช Starting qboot unit tests..."); - } -} From ce7840888a6ef33b28a64a0103dc9fa4adc10cde Mon Sep 17 00:00:00 2001 From: Andrea Manzini Date: Fri, 19 Sep 2025 17:08:40 +0200 Subject: [PATCH 2/3] add s390x and aarch64 handling --- README.md | 41 +++---- source/aarch64.d | 50 ++++++++ source/app.d | 79 ++++++------- source/config.d | 186 +++++++++++++++++++++++++++++ source/ppc64le.d | 42 +++++++ source/s390x.d | 59 ++++++++++ source/vm.d | 297 +++++++++++++++++++++++++++++++++++++++++++++++ source/x86_64.d | 48 ++++++++ 8 files changed, 738 insertions(+), 64 deletions(-) create mode 100644 source/aarch64.d create mode 100644 source/config.d create mode 100644 source/ppc64le.d create mode 100644 source/s390x.d create mode 100644 source/vm.d create mode 100644 source/x86_64.d diff --git a/README.md b/README.md index 8aded07..a533569 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ QBoot is a command-line tool written in D that wraps QEMU to provide a streamlin - **Zero-config startup**: Works out of the box with sensible defaults - **JSON configuration**: Persistent settings via `~/.config/qboot/config.json` -- **Interactive and headless modes**: GUI or console-only operation +- **Graphical and headless modes**: GUI or console-only operation - **Snapshot support**: Choose whether to persist changes +- **Multi-architecture support**: Works with x86_64, aarch64, ppc64le, and s390x - **KVM acceleration**: Automatic hardware acceleration when available - **SSH-ready networking**: Built-in port forwarding for easy access - **Comprehensive testing**: Full test suite with >95% coverage @@ -38,14 +39,17 @@ make install # Launch a VM with a disk image ./qboot -d /path/to/your/disk.img -# Interactive mode with GUI -./qboot -d disk.img --interactive +# Graphical mode +./qboot -d disk.img -g # Custom CPU and RAM settings ./qboot -d disk.img --cpu 4 --ram 8 # Headless mode with persistent changes -./qboot -d disk.img --no-snapshot +./qboot -d disk.img -w + +# Show command before running +./qboot -d disk.img --confirm ``` ## Command Line Options @@ -56,11 +60,12 @@ make install | `--disk` | `-d` | Path to disk image (required) | - | | `--cpu` | `-c` | Number of CPU cores | 2 | | `--ram` | `-r` | RAM in GB | 4 | -| `--interactive` | `-i` | Enable GUI mode | false | -| `--no-snapshot` | `-S` | Persist changes to disk | false | -| `--log` | `-l` | Serial console log file | console.log | -| `--ssh-port` | - | SSH port forwarding | 2222 | -| `--help` | `-h` | Show help message | - | +| `--graphical` | `-g` | Enable graphical console | false | +| `--write-mode` | `-w` | Persist changes to disk (disables snapshot) | false | +| `--ssh-port` | `-p` | Host port for SSH forwarding | 2222 | +| `--log-file` | `-l` | Serial console log file | `qboot.log` | +| `--confirm` | | Show command and wait for keypress before starting | false | +| `--help` | | Show help message | - | ## Configuration @@ -71,10 +76,9 @@ QBoot automatically creates a configuration file at `~/.config/qboot/config.json "description": "Default configuration for qboot. Edit these values to fit your workflow.", "arch": "x86_64", "cpu": 2, - "ram_gb": 4, - "ssh_port": 2222, - "log_file": "console.log", - "headless_saves_changes": false + "ramGb": 4, + "sshPort": 2222, + "logFile": "qboot.log" } ``` @@ -89,13 +93,13 @@ Configuration values are applied in this order (highest priority first): ```bash # Start a development VM with GUI -qboot -d ubuntu-dev.img -i -c 4 -r 8 +qboot -d ubuntu-dev.img -g -c 4 -r 8 # Quick headless test (changes discarded) qboot -d test-image.img # Persistent headless server -qboot -d server.img -S --ssh-port 2223 +qboot -d server.img -w --ssh-port 2223 ``` ### SSH Access @@ -111,7 +115,7 @@ ssh -p 2222 user@localhost Monitor the VM's serial console: ```bash -tail -f console.log +tail -f qboot.log ``` ## Architecture @@ -131,11 +135,8 @@ QBoot generates commands similar to: qemu-system-x86_64 \ -enable-kvm -cpu host \ -smp 2 -m 4G \ - -mem-path /dev/hugepages \ -drive file=disk.img,if=virtio,cache=none,aio=native,discard=unmap \ - -netdev user,id=net0,hostfwd=tcp::2222-:22 \ - -device virtio-net-pci,netdev=net0 \ - -nographic -snapshot + -netdev user,id=net0,hostfwd=tcp::2222 ``` ## Development diff --git a/source/aarch64.d b/source/aarch64.d new file mode 100644 index 0000000..f981c6f --- /dev/null +++ b/source/aarch64.d @@ -0,0 +1,50 @@ +module aarch64; + +import vm; +import std.format; + +/** + * Concrete implementation of the VirtualMachine for aarch64 architecture. + */ +class AARCH64_VM : VirtualMachine +{ + /// Returns the name of the QEMU binary for the specific architecture. + override string qemuBinary() + { + return "qemu-system-aarch64"; + } + + /// Returns an array of architecture-specific QEMU arguments. + override string[] getArchArgs() + { + // This requires a UEFI firmware file. A common path is provided. + // Users might need to install it via their package manager + // (e.g., qemu-efi-aarch64 on Debian/Ubuntu). + return [ + "-machine", "virt", + "-cpu", "max", + "-bios", "/usr/share/qemu/aavmf-aarch64-code.bin" + + ]; + } + + /// Returns an array of QEMU arguments for attaching the disk. + override string[] getDiskArgs() + { + return [ + "-drive", + format("file=%s,if=virtio,cache=none,aio=native,discard=unmap", diskPath) + ]; + } + + /// Returns an array of QEMU arguments for networking. + override string[] getNetworkArgs() + { + return [ + "-netdev", + format("user,id=net0,hostfwd=tcp::%d-:22", sshPort), + "-device", + "virtio-net-pci,netdev=net0" + ]; + } +} diff --git a/source/app.d b/source/app.d index 6fd9876..35075ea 100644 --- a/source/app.d +++ b/source/app.d @@ -3,6 +3,7 @@ import config; import x86_64; import ppc64le; import s390x; +import aarch64; import std.stdio; import std.getopt; import std.process : environment; @@ -21,11 +22,6 @@ version (unittest) void main(string[] args) { - version (unittest) - { - // Skip main when running unit tests - return; - } string configDir = buildPath(environment.get("HOME"), ".config", "qboot"); string configFile = buildPath(configDir, "config.json"); @@ -43,51 +39,43 @@ void main(string[] args) } catch (Exception e) { - stderr.writeln("Warning: Could not parse config file '", configFile, "': ", e.msg); + stderr.writeln("Error parsing config file: ", e.msg); } } string diskPath, cpu, ram, logFile, arch; - bool interactive, noSnapshot; + bool graphical, noSnapshot, confirm; ushort sshPort; try { - auto helpInfo = getopt(args, "arch|a", &arch, "disk|d", &diskPath, "cpu|c", &cpu, "ram|r", - &ram, "interactive|i", &interactive, "no-snapshot|S", &noSnapshot, - "log|l", &logFile, "ssh-port", &sshPort, ); - - if (helpInfo.helpWanted || diskPath.empty) - { - writeln("qboot - A handy QEMU VM launcher"); - writeln(); - writeln("USAGE:"); - writeln(" qboot [OPTIONS] --disk "); - writeln(); - writeln("EXAMPLES:"); - writeln(" qboot --disk ubuntu.qcow2"); - writeln(" qboot --disk fedora.img --cpu 4 --ram 8 --interactive"); - writeln(" qboot --arch ppc64le --disk debian.qcow2 --ssh-port 2223"); - writeln(" qboot --disk test.img --no-snapshot --log vm.log"); - writeln(); - writeln("OPTIONS:"); - - // Custom help formatting with proper option descriptions - writeln(" -a, --arch Target architecture (default: x86_64, options: x86_64, ppc64le)"); - writeln(" -d, --disk Path to disk image file (required)"); - writeln(" -c, --cpu Number of CPU cores (default: 2)"); - writeln(" -r, --ram RAM size in GB (default: 4)"); - writeln(" -i, --interactive Enable interactive mode with QEMU monitor"); - writeln(" -S, --no-snapshot Disable snapshot mode (changes will be saved to disk)"); - writeln(" -l, --log Log file path (default: console.log)"); - writeln(" --ssh-port SSH port forwarding (default: 2222)"); - writeln(" -h, --help Show this help message"); - - writeln(); - writeln("Configuration file: ~/.config/qboot/config.json"); - writeln("Command line options override configuration file settings."); - return; - } + auto options = getopt( + args, + "d", "disk", &diskPath, + "c", "cpu", &cpu, + "r", "ram", &ram, + "g", "graphical", &graphical, + "w", "write-mode", &noSnapshot, + "p", "ssh-port", &sshPort, + "l", "log-file", &logFile, + "a", "arch", &arch, + "confirm", &confirm, + "help", { + writeln("Usage: qboot [options]"); + writeln("Options:"); + writeln(" -d, --disk Path to the qcow2 disk image (required)"); + writeln(" -c, --cpu Number of CPU cores (default: ", config.cpu, ")"); + writeln(" -r, --ram Amount of RAM in GB (default: ", config.ramGb, ")"); + writeln(" -g, --graphical Enable graphical console (default: disabled)"); + writeln(" -w, --write-mode Enable write mode (changes are saved to disk)"); + writeln(" -p, --ssh-port Host port for SSH forwarding (default: ", config.sshPort, ")"); + writeln(" -l, --log-file Path to the log file (default: ", config.logFile, ")"); + writeln(" -a, --arch Architecture (x86_64, ppc64le, s390x, aarch64) (default: ", config.arch, ")"); + writeln(" --confirm Show command and wait for keypress before starting"); + writeln(" --help Show this help message"); + return; + } + ); } catch (Exception e) { @@ -112,7 +100,9 @@ void main(string[] args) case "s390x": vm = new S390X_VM(); break; - + case "aarch64": + vm = new AARCH64_VM(); + break; default: stderr.writeln("Error: Unsupported architecture '", config.arch, "' in config file."); @@ -129,8 +119,9 @@ void main(string[] args) vm.cpu = cpu.to!int; if (!ram.empty) vm.ram = ram.to!int; - vm.interactive = interactive; + vm.graphical = graphical; vm.noSnapshot = noSnapshot; + vm.confirm = confirm; if (!logFile.empty) vm.logFile = logFile; if (sshPort != 0) diff --git a/source/config.d b/source/config.d new file mode 100644 index 0000000..239c379 --- /dev/null +++ b/source/config.d @@ -0,0 +1,186 @@ +module config; + +import std.stdio; +import std.json; +import std.file; +import std.path; +import std.conv; + +version (unittest) +{ + import std.exception; + import std.random; +} + +/** + * Configuration structure for VM settings + */ +struct VMConfig +{ + string description; + string arch; + int cpu; + int ramGb; + ushort sshPort; + string logFile; + bool graphical; + bool writeMode; + bool confirm; +} + +/** + * Creates a default configuration JSON structure + */ +JSONValue createDefaultConfig() +{ + return JSONValue([ + "description": JSONValue("Default configuration for qboot. Edit these values to fit your workflow."), + "cpu": JSONValue(2), + "ram_gb": JSONValue(4), + "ssh_port": JSONValue(2222), + "log_file": JSONValue("console.log"), + "headless_saves_changes": JSONValue(false), + "arch": JSONValue("x86_64"), + ]); +} + +/** + * Parses configuration from JSON + */ +VMConfig parseConfig(JSONValue json) +{ + VMConfig config; + + if ("cpu" in json) + config.cpu = to!int(json["cpu"].get!long); + if ("ram_gb" in json) + config.ramGb = to!int(json["ram_gb"].get!long); + if ("log_file" in json) + config.logFile = json["log_file"].get!string; + if ("ssh_port" in json) + config.sshPort = to!ushort(json["ssh_port"].get!long); + + if ("headless_saves_changes" in json) + config.headlessSavesChanges = json["headless_saves_changes"].get!bool; + if ("arch" in json) + config.arch = json["arch"].get!string; + + return config; +} + +/** + * Ensures the configuration directory and a default config.json file exist. + */ +void ensureConfigFileExists(string dirPath, string filePath) +{ + if (filePath.exists) + return; + + try + { + writeln("Configuration file not found. Creating a default at '", filePath, "'..."); + dirPath.mkdirRecurse(); + + auto defaultConfig = createDefaultConfig(); + std.file.write(filePath, defaultConfig.toPrettyString()); + + } + catch (Exception e) + { + stderr.writeln("Warning: Could not create default config file: ", e.msg); + } +} + +// ============================================================================ +// UNIT TESTS +// ============================================================================ + +unittest +{ + writeln("Running createDefaultConfig tests..."); + + auto config = createDefaultConfig(); + assert(config["cpu"].get!long == 2); + assert(config["ram_gb"].get!long == 4); + assert(config["ssh_port"].get!long == 2222); + assert(config["log_file"].get!string == "console.log"); + assert(config["headless_saves_changes"].get!bool == false); + assert(config["arch"].get!string == "x86_64"); + + writeln("โœ“ createDefaultConfig tests passed"); +} + +unittest +{ + writeln("Running parseConfig tests..."); + + // Test valid config + auto json = JSONValue([ + "cpu": JSONValue(4), + "ram_gb": JSONValue(8), + "ssh_port": JSONValue(3333), + "log_file": JSONValue("test.log"), + "headless_saves_changes": JSONValue(true), + "arch": JSONValue("aarch64") + ]); + + auto config = parseConfig(json); + assert(config.cpu == 4); + assert(config.ramGb == 8); + assert(config.sshPort == 3333); + assert(config.logFile == "test.log"); + assert(config.headlessSavesChanges == true); + assert(config.arch == "aarch64"); + + // Test partial config (should use defaults) + auto partialJson = JSONValue(["cpu": JSONValue(6)]); + + auto partialConfig = parseConfig(partialJson); + assert(partialConfig.cpu == 6); + assert(partialConfig.ramGb == 2); // default + assert(partialConfig.sshPort == 2222); // default + assert(partialConfig.arch == "x86_64"); // default + + writeln("โœ“ parseConfig tests passed"); +} + +unittest +{ + writeln("Running ensureConfigFileExists tests..."); + + // Create a temporary directory + auto tempTestDir = tempDir ~ "/qboot_test_" ~ to!string(uniform(1000, 9999)); + auto tempConfigFile = tempTestDir ~ "/config.json"; + + scope (exit) + { + if (tempConfigFile.exists) + tempConfigFile.remove(); + if (tempTestDir.exists) + tempTestDir.rmdir(); + } + + // Initially, neither directory nor file should exist + assert(!tempTestDir.exists); + assert(!tempConfigFile.exists); + + // Call the function + ensureConfigFileExists(tempTestDir, tempConfigFile); + + // Now both should exist + assert(tempTestDir.exists); + assert(tempConfigFile.exists); + + // Verify the content is valid JSON + auto content = tempConfigFile.readText(); + auto json = parseJSON(content); + assert(json["cpu"].get!long == 2); + assert(json["arch"].get!string == "x86_64"); + + // Call again - should not overwrite + auto originalContent = tempConfigFile.readText(); + ensureConfigFileExists(tempTestDir, tempConfigFile); + assert(tempConfigFile.readText() == originalContent); + + writeln("โœ“ ensureConfigFileExists tests passed"); +} diff --git a/source/ppc64le.d b/source/ppc64le.d new file mode 100644 index 0000000..8cce2ac --- /dev/null +++ b/source/ppc64le.d @@ -0,0 +1,42 @@ +module ppc64le; + +import vm; +import std.format; + +/** + * Concrete implementation of the VirtualMachine for PPC64LE architecture. + */ +class PPC64LE_VM : VirtualMachine +{ + /// Returns the name of the QEMU binary for the specific architecture. + override string qemuBinary() + { + return "qemu-system-ppc64"; + } + + /// Returns an array of architecture-specific QEMU arguments. + override string[] getArchArgs() + { + return ["-M", "pseries", "-cpu", "POWER9"]; + } + + /// Returns an array of QEMU arguments for attaching the disk. + override string[] getDiskArgs() + { + return [ + "-drive", + format("file=%s,if=virtio,cache=none,aio=native,discard=unmap", diskPath) + ]; + } + + /// Returns an array of QEMU arguments for networking. + override string[] getNetworkArgs() + { + return [ + "-netdev", + format("user,id=net0,hostfwd=tcp::%d-:22", sshPort), + "-device", + "virtio-net-pci,netdev=net0" + ]; + } +} diff --git a/source/s390x.d b/source/s390x.d new file mode 100644 index 0000000..4ba2919 --- /dev/null +++ b/source/s390x.d @@ -0,0 +1,59 @@ +module s390x; + +import vm; +import std.format; + +/** + * Concrete implementation of the VirtualMachine for s390x architecture. + */ +class S390X_VM : VirtualMachine +{ + /// Returns the name of the QEMU binary for the specific architecture. + override string qemuBinary() + { + return "qemu-system-s390x"; + } + + /// Returns an array of architecture-specific QEMU arguments. + override string[] getArchArgs() + { + return [ + "-machine", "s390-ccw-virtio", + "-object", "rng-random,id=rng0,filename=/dev/random", + "-device", "virtio-rng-ccw,rng=rng0" + ]; + } + + /// Returns s390x-specific arguments for attaching the disk. + override string[] getDiskArgs() + { + return [ + "-drive", + format("file=%s,id=disk1,if=none,cache=none,aio=native,discard=unmap", diskPath), + "-device", + "virtio-blk-ccw,drive=disk1" + ]; + } + + /// Returns s390x-specific arguments for networking. + override string[] getNetworkArgs() + { + return [ + "-netdev", + format("user,id=net1,hostfwd=tcp::%d-:22", sshPort), + "-device", + "virtio-net-ccw,netdev=net1" + ]; + } + + /// Returns s390x-specific arguments for graphical mode. + /// On s390x, this provides an interactive session in the terminal, + /// multiplexing the serial console and the QEMU monitor. + override string[] getGraphicalArgs() + { + return [ + "-nographic", + "-serial", "mon:stdio" + ]; + } +} diff --git a/source/vm.d b/source/vm.d new file mode 100644 index 0000000..22d359a --- /dev/null +++ b/source/vm.d @@ -0,0 +1,297 @@ +module vm; + +import config; +import std.stdio; +import std.process : spawnProcess, wait; +import std.string; +import std.conv; +import std.file; +import std.format; +import std.path; +import std.json; +import std.array; +import std.exception; + +version (unittest) +{ + import std.algorithm; +} + +/** + * Validates disk path and throws descriptive errors + */ +void validateDiskPath(string diskPath) +{ + if (diskPath.empty) + { + throw new Exception("Disk path cannot be empty. Use -d or --disk."); + } + if (!diskPath.exists) + { + throw new Exception(format("Disk image not found at '%s'", diskPath)); + } +} + +/** + * Validates VM configuration parameters + */ +void validateVMConfig(const VirtualMachine vm) +{ + if (vm.cpu < 1 || vm.cpu > 32) + { + throw new Exception(format("CPU count must be between 1 and 32, got %d", vm.cpu)); + } + if (vm.ram < 1 || vm.ram > 128) + { + throw new Exception(format("RAM must be between 1 and 128 GB, got %d", vm.ram)); + } + if (vm.sshPort < 1024 || vm.sshPort > 65535) + { + throw new Exception(format("SSH port must be between 1024 and 65535, got %d", vm.sshPort)); + } +} + +/** + * Abstract base class for a virtual machine. + * This class provides common functionality for running a QEMU VM, + * but requires subclasses to provide architecture-specific details. + */ +abstract class VirtualMachine +{ + string diskPath; + int cpu; + int ram; + bool graphical; + bool noSnapshot; + bool confirm; + ushort sshPort; + string logFile; + + /** + * Constructor to initialize the VM with default settings. + */ + this() + { + this.cpu = 1; + this.ram = 2; + this.logFile = "console.log"; + this.sshPort = 2222; + this.noSnapshot = false; + this.graphical = false; + this.confirm = false; + } + + /** + * Loads settings from the specified JSON configuration file. + */ + void loadFromFile(string path) + { + writeln("Found configuration at '", path, "'. Loading defaults..."); + try + { + auto text = path.readText(); + auto json = parseJSON(text); + auto config = parseConfig(json); + + this.cpu = config.cpu; + this.ram = config.ramGb; + this.logFile = config.logFile; + this.sshPort = config.sshPort; + this.noSnapshot = config.headlessSavesChanges; + } + catch (Exception e) + { + stderr.writeln("Warning: Could not parse config file '", path, "': ", e.msg); + } + } + + /** + * Builds the complete QEMU command line arguments. + * This combines common arguments with architecture-specific ones. + */ + string[] buildArgs() + { + validateDiskPath(diskPath); + validateVMConfig(this); + + string[] args; + + // Add architecture-specific arguments + args ~= getArchArgs(); + + // Add common arguments + args ~= ["-smp", to!string(cpu), "-m", format("%dG", ram)]; + + // Add disk arguments, allowing for architecture-specific overrides + args ~= getDiskArgs(); + + // Add network arguments + args ~= getNetworkArgs(); + + args ~= ["-audiodev", "none,id=snd0"]; + + if (graphical) + { + args ~= getGraphicalArgs(); + } + else + { + args ~= ["-nographic"]; + if (!noSnapshot) + { + args ~= ["-snapshot"]; + } + args ~= ["-serial", "stdio", "-monitor", "none"]; + } + + return args; + } + + /** + * Runs the virtual machine. + */ + void run() + { + auto args = this.buildArgs(); + auto binary = this.qemuBinary(); + + writeln("๐Ÿš€ Starting QEMU with the following command:"); + writeln(binary, " ", args.join(" ")); + + if (confirm) + { + write("Press Enter to continue..."); + stdin.readln(); + } + + auto pid = spawnProcess([binary] ~ args); + auto status = wait(pid); + if (status != 0) + stderr.writeln("QEMU exited with a non-zero status: ", status); + } + + /// Returns the name of the QEMU binary for the specific architecture (e.g., "qemu-system-x86_64"). + protected abstract string qemuBinary(); + + /// Returns an array of architecture-specific QEMU arguments. + protected abstract string[] getArchArgs(); + + /** + * Returns an array of QEMU arguments for attaching the disk. + * This can be overridden by subclasses for special handling. + */ + protected string[] getDiskArgs() + { + return [ + "-drive", + format("file=%s,if=virtio,cache=none,aio=native,discard=unmap", diskPath) + ]; + } + + /** + * Returns an array of QEMU arguments for networking. + * This can be overridden by subclasses for special handling. + */ + protected string[] getNetworkArgs() + { + return [ + "-netdev", + format("user,id=net0,hostfwd=tcp::%d-:22", sshPort), + "-device", + "virtio-net-pci,netdev=net0" + ]; + } + + /** + * Returns an array of QEMU arguments for graphical mode. + * This can be overridden by subclasses for special handling. + */ + protected string[] getGraphicalArgs() + { + return []; + } +} + +// ============================================================================ +// UNIT TESTS +// ============================================================================ + +// Dummy VM for testing +version (unittest) class TestVM : VirtualMachine +{ + override string qemuBinary() + { + return "qemu-test"; + } + + override string[] getArchArgs() + { + return ["-machine", "test"]; + } +} + +unittest +{ + writeln("Running validateDiskPath tests..."); + + // Test empty path + assertThrown!Exception(validateDiskPath("")); + + // Test non-existent path + assertThrown!Exception(validateDiskPath("/non/existent/path.img")); + + // Create a temporary file for testing + auto tempFile = tempDir ~ "/test_disk.img"; + scope (exit) + if (tempFile.exists) + tempFile.remove(); + + std.file.write(tempFile, "test disk content"); + assert(tempFile.exists); + + // This should not throw + validateDiskPath(tempFile); + + writeln("โœ“ validateDiskPath tests passed"); +} + +unittest +{ + writeln("Running validateVMConfig tests..."); + + auto vm = new TestVM(); + + // Test invalid CPU counts + vm.cpu = 0; + vm.ram = 4; + vm.sshPort = 2222; + assertThrown!Exception(validateVMConfig(vm)); + + vm.cpu = 33; + assertThrown!Exception(validateVMConfig(vm)); + + // Test invalid RAM values + vm.cpu = 2; + vm.ram = 0; + assertThrown!Exception(validateVMConfig(vm)); + + vm.ram = 129; + assertThrown!Exception(validateVMConfig(vm)); + + // Test invalid SSH port + vm.ram = 4; + vm.sshPort = 1023; + assertThrown!Exception(validateVMConfig(vm)); + + // Test valid config + vm.cpu = 4; + vm.ram = 8; + vm.sshPort = 2222; + // This should not throw + validateVMConfig(vm); + + vm.sshPort = 65535; + validateVMConfig(vm); + + writeln("โœ“ validateVMConfig tests passed"); +} diff --git a/source/x86_64.d b/source/x86_64.d new file mode 100644 index 0000000..a52993f --- /dev/null +++ b/source/x86_64.d @@ -0,0 +1,48 @@ +module x86_64; + +import vm; +import std.format; + +/** + * Concrete implementation of the VirtualMachine for x86_64 architecture. + */ +class X86_64_VM : VirtualMachine +{ + /// Returns the name of the QEMU binary for the specific architecture. + override string qemuBinary() + { + return "qemu-system-x86_64"; + } + + /// Returns an array of architecture-specific QEMU arguments. + override string[] getArchArgs() + { + return ["-M", "q35"]; + } + + /// Returns an array of QEMU arguments for graphical output. + override string[] getGraphicalArgs() + { + return ["-device", "virtio-vga-gl", "-display", "sdl,gl=on"]; + } + + /// Returns an array of QEMU arguments for attaching the disk. + override string[] getDiskArgs() + { + return [ + "-drive", + format("file=%s,if=virtio,cache=none,aio=native,discard=unmap", diskPath) + ]; + } + + /// Returns an array of QEMU arguments for networking. + override string[] getNetworkArgs() + { + return [ + "-netdev", + format("user,id=net0,hostfwd=tcp::%d-:22", sshPort), + "-device", + "virtio-net-pci,netdev=net0" + ]; + } +} From 05e3eb8a217bdf08599650e494916ed49f2eb176 Mon Sep 17 00:00:00 2001 From: Andrea Manzini Date: Fri, 19 Sep 2025 17:19:41 +0200 Subject: [PATCH 3/3] update unit tests --- .gitignore | 1 + source/app.d | 5 --- source/config.d | 114 ++++++++++++++++++++++++++++-------------------- source/vm.d | 18 ++++---- 4 files changed, 78 insertions(+), 60 deletions(-) diff --git a/.gitignore b/.gitignore index f7f5721..d1ab14c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ docs/ qboot console.log +qboot-test-library diff --git a/source/app.d b/source/app.d index 35075ea..7cdd7c9 100644 --- a/source/app.d +++ b/source/app.d @@ -14,11 +14,6 @@ import std.path; import std.json; import std.exception; -version (unittest) -{ - import std.algorithm; - import std.random; -} void main(string[] args) { diff --git a/source/config.d b/source/config.d index 239c379..9a02fbd 100644 --- a/source/config.d +++ b/source/config.d @@ -39,7 +39,9 @@ JSONValue createDefaultConfig() "ram_gb": JSONValue(4), "ssh_port": JSONValue(2222), "log_file": JSONValue("console.log"), - "headless_saves_changes": JSONValue(false), + "write_mode": JSONValue(false), + "graphical": JSONValue(false), + "confirm": JSONValue(false), "arch": JSONValue("x86_64"), ]); } @@ -51,17 +53,31 @@ VMConfig parseConfig(JSONValue json) { VMConfig config; + // Set defaults first + config.cpu = 2; + config.ramGb = 4; + config.sshPort = 2222; + config.logFile = "console.log"; + config.writeMode = false; + config.graphical = false; + config.confirm = false; + config.arch = "x86_64"; + + // Override with values from JSON if they exist if ("cpu" in json) config.cpu = to!int(json["cpu"].get!long); if ("ram_gb" in json) config.ramGb = to!int(json["ram_gb"].get!long); - if ("log_file" in json) - config.logFile = json["log_file"].get!string; if ("ssh_port" in json) config.sshPort = to!ushort(json["ssh_port"].get!long); - - if ("headless_saves_changes" in json) - config.headlessSavesChanges = json["headless_saves_changes"].get!bool; + if ("log_file" in json) + config.logFile = json["log_file"].get!string; + if ("write_mode" in json) + config.writeMode = json["write_mode"].get!bool; + if ("graphical" in json) + config.graphical = json["graphical"].get!bool; + if ("confirm" in json) + config.confirm = json["confirm"].get!bool; if ("arch" in json) config.arch = json["arch"].get!string; @@ -69,25 +85,22 @@ VMConfig parseConfig(JSONValue json) } /** - * Ensures the configuration directory and a default config.json file exist. + * Ensures that the configuration directory and file exist. + * If they don't, it creates them with default values. */ -void ensureConfigFileExists(string dirPath, string filePath) +void ensureConfigFileExists(string configDir, string configFile) { - if (filePath.exists) - return; - - try + if (!configDir.exists) { - writeln("Configuration file not found. Creating a default at '", filePath, "'..."); - dirPath.mkdirRecurse(); - - auto defaultConfig = createDefaultConfig(); - std.file.write(filePath, defaultConfig.toPrettyString()); - + writeln("Creating config directory at '", configDir, "'"); + configDir.mkdirRecurse(); } - catch (Exception e) + + if (!configFile.exists) { - stderr.writeln("Warning: Could not create default config file: ", e.msg); + writeln("No config file found. Creating default config at '", configFile, "'"); + auto defaultConfig = createDefaultConfig(); + std.file.write(configFile, toJSON(defaultConfig, true)); } } @@ -98,15 +111,15 @@ void ensureConfigFileExists(string dirPath, string filePath) unittest { writeln("Running createDefaultConfig tests..."); - - auto config = createDefaultConfig(); - assert(config["cpu"].get!long == 2); - assert(config["ram_gb"].get!long == 4); - assert(config["ssh_port"].get!long == 2222); - assert(config["log_file"].get!string == "console.log"); - assert(config["headless_saves_changes"].get!bool == false); - assert(config["arch"].get!string == "x86_64"); - + auto defaultConfig = createDefaultConfig(); + assert(defaultConfig["cpu"].get!long == 2); + assert(defaultConfig["ram_gb"].get!long == 4); + assert(defaultConfig["ssh_port"].get!long == 2222); + assert(defaultConfig["log_file"].get!string == "console.log"); + assert(defaultConfig["write_mode"].get!bool == false); + assert(defaultConfig["graphical"].get!bool == false); + assert(defaultConfig["confirm"].get!bool == false); + assert(defaultConfig["arch"].get!string == "x86_64"); writeln("โœ“ createDefaultConfig tests passed"); } @@ -114,32 +127,39 @@ unittest { writeln("Running parseConfig tests..."); - // Test valid config - auto json = JSONValue([ + // Test full config + auto fullJson = JSONValue([ "cpu": JSONValue(4), "ram_gb": JSONValue(8), "ssh_port": JSONValue(3333), "log_file": JSONValue("test.log"), - "headless_saves_changes": JSONValue(true), + "write_mode": JSONValue(true), + "graphical": JSONValue(true), + "confirm": JSONValue(true), "arch": JSONValue("aarch64") ]); - auto config = parseConfig(json); - assert(config.cpu == 4); - assert(config.ramGb == 8); - assert(config.sshPort == 3333); - assert(config.logFile == "test.log"); - assert(config.headlessSavesChanges == true); - assert(config.arch == "aarch64"); - - // Test partial config (should use defaults) - auto partialJson = JSONValue(["cpu": JSONValue(6)]); - + auto fullConfig = parseConfig(fullJson); + assert(fullConfig.cpu == 4); + assert(fullConfig.ramGb == 8); + assert(fullConfig.sshPort == 3333); + assert(fullConfig.logFile == "test.log"); + assert(fullConfig.writeMode == true); + assert(fullConfig.graphical == true); + assert(fullConfig.confirm == true); + assert(fullConfig.arch == "aarch64"); + + // Test partial config (should use defaults for missing keys) + auto partialJson = JSONValue(["cpu": JSONValue(1)]); auto partialConfig = parseConfig(partialJson); - assert(partialConfig.cpu == 6); - assert(partialConfig.ramGb == 2); // default - assert(partialConfig.sshPort == 2222); // default - assert(partialConfig.arch == "x86_64"); // default + assert(partialConfig.cpu == 1); + assert(partialConfig.ramGb == 4); // Should be default + assert(partialConfig.sshPort == 2222); // Should be default + assert(partialConfig.logFile == "console.log"); // Should be default + assert(partialConfig.writeMode == false); // Should be default + assert(partialConfig.graphical == false); // Should be default + assert(partialConfig.confirm == false); // Should be default + assert(partialConfig.arch == "x86_64"); // Should be default writeln("โœ“ parseConfig tests passed"); } diff --git a/source/vm.d b/source/vm.d index 22d359a..004ea9d 100644 --- a/source/vm.d +++ b/source/vm.d @@ -72,13 +72,15 @@ abstract class VirtualMachine */ this() { - this.cpu = 1; - this.ram = 2; - this.logFile = "console.log"; - this.sshPort = 2222; - this.noSnapshot = false; - this.graphical = false; - this.confirm = false; + // Initialize with default values from a default config + VMConfig config; + this.cpu = config.cpu; + this.ram = config.ramGb; + this.sshPort = config.sshPort; + this.logFile = config.logFile; + this.graphical = config.graphical; + this.noSnapshot = config.writeMode; + this.confirm = config.confirm; } /** @@ -97,7 +99,7 @@ abstract class VirtualMachine this.ram = config.ramGb; this.logFile = config.logFile; this.sshPort = config.sshPort; - this.noSnapshot = config.headlessSavesChanges; + this.noSnapshot = config.writeMode; } catch (Exception e) {