This is an automated email from the ASF dual-hosted git repository. DaanHoogland pushed a commit to branch virtualos-extension in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git
commit 26a33f25ae6db856ad1b63d49e69f9e3cea6dde8 Author: Daan Hoogland <[email protected]> AuthorDate: Mon Oct 5 18:17:41 2026 +0200 macos virtualos as hypervisor extension --- virtualos/README.txt | 154 ++++++++++++++++ virtualos/virtualos.py | 479 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 633 insertions(+) diff --git a/virtualos/README.txt b/virtualos/README.txt new file mode 100644 index 0000000..78a0cf0 --- /dev/null +++ b/virtualos/README.txt @@ -0,0 +1,154 @@ +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. + +virtualOS Orchestrator Extension +================================ + +Orchestrator extension that lets Apache CloudStack manage macOS virtual +machines in virtualOS (https://github.com/yep/virtualOS) on Apple silicon +Macs. Each Mac running virtualOS is added to CloudStack as a host in a +cluster mapped to this extension. + +virtualOS has no command line interface or scripting dictionary. The script +therefore works on the virtualOS VM bundles directly and starts a VM by +launching a separate virtualOS process with the autostartVMBundlePath user +default passed as launch argument: + + open -n -g -a virtualOS --args -autostartVMBundlePath <dir>/<name>.bundle + +Each running instance is one virtualOS process; stopping an instance +terminates that process. The script runs on the management server and +reaches the Mac through SSH. When the management server itself runs on the +Mac (e.g. a development setup), the host url can be "localhost" and no SSH +is used. + +Requirements +------------ + +Management server: + - python3 (standard library only) + - ssh client; sshpass only when password authentication is used + +Mac: + - Apple silicon, virtualOS 3.0 or later (the autostart setting is used) + - The SSH user logged in to the GUI session, virtualOS is a GUI app + - Remote Login (SSH) enabled, with "Allow full disk access for remote + users" when vm_directory is inside the virtualOS sandbox container + (the default), as macOS protects other apps' containers + - One or more installed macOS VMs in virtualOS to clone from, stopped + - An APFS volume for vm_directory, so clones are copy-on-write + +Supported operations +-------------------- + + create Clone the template VM bundle, name it after the CloudStack + instance's internal name, give it a new machine + identifier, set CPU count, memory (rounded up to whole + GiB), MAC address and network mode, then start it. The + clone is removed again when any step fails. + start, stop, reboot, delete, status, statuses + start waits a few seconds and fails when virtualOS logs + an error for the VM; stop terminates the virtualOS + process, which powers the guest off without a shutdown + getconsole Not supported, virtualOS has no VNC endpoint to hand out. + +Custom actions (register them with addCustomAction): + GetIpAddresses List the IP addresses the Mac's DHCP server leased to the + instance's MAC address (NAT network mode only) + +Configuration details +--------------------- + +Extension or host details (host details win): + url Mac hostname/IP, or "localhost" to run locally + username SSH user owning the virtualOS VMs; leave empty for local + password Optional SSH password (needs sshpass); keys preferred + ssh_key Optional private key path on the management server + ssh_port Optional, default 22 + verify_host_key Optional, "true" (default) or "false" + app_path Optional, default /Applications/virtualOS.app, falling + back to a Spotlight lookup of the app bundle + vm_directory Optional, directory holding the <name>.bundle VMs. It + must be the "VM files" directory configured in virtualOS + as the sandboxed app can not open bundles elsewhere. + Default: ~/Library/Containers/com.github.yep.ios.virtualOS + /Data/Documents + template_name Optional default VM bundle to clone, without .bundle + network_mode Optional: nat or bridged. When set it is applied to the + instance, otherwise the template's mode is kept + bridge_interface macOS interface for bridged mode, e.g. en0, or its + virtualOS name, e.g. "Wi-Fi (en0)" + wait_timeout Optional, seconds to wait for state changes, default 120 + +Template, service offering or instance details: + template_name VM bundle to clone, overrides the host/extension value + +Setup +----- + +1. In virtualOS, install a macOS VM to use as template, e.g. "macOS-15". + Set it up as wanted (user account, Remote Login, ...) and shut it down. + +2. Copy virtualos.py to every management server: + + mkdir -p /usr/share/cloudstack-management/extensions/virtualOS + cp virtualos.py /usr/share/cloudstack-management/extensions/virtualOS/virtualos.py + chmod 755 /usr/share/cloudstack-management/extensions/virtualOS/virtualos.py + chown -R cloud:cloud /usr/share/cloudstack-management/extensions/virtualOS + + For SSH key authentication, create a key for the cloud user and add the + public key to ~/.ssh/authorized_keys of the user on the Mac. + +3. Register the extension and its custom action (CloudMonkey): + + cmk create extension name=virtualOS type=Orchestrator path=virtualos.py + cmk add customaction extensionid=<id> name=GetIpAddresses resourcetype=VirtualMachine + +4. Create a cluster with hypervisor External, register the extension to it, + and add each Mac as a host with url, username and (optionally) ssh_key, + network_mode and bridge_interface as details. + +5. Register a template with hypervisor External and extension virtualOS, + using a dummy url, and set the external detail template_name to the name + of the virtualOS VM to clone. + +6. Deploy instances from that template, using a service offering with at + least the CPU count and memory macOS needs (e.g. 2 CPUs, 4 GiB). + +Limitations +----------- + + - One NIC per instance and no VLAN isolation: virtualOS configures a single + NAT or bridged network device. Instances get the CloudStack MAC address + only, so the guest network must be reachable through the Mac's NAT or + bridged networking. + - Apple's macOS license and the Virtualization framework allow at most + two macOS VMs running at the same time per Mac. + - No graceful shutdown: stop and reboot power the guest off. + - Status is derived from the virtualOS process. When the guest shuts + itself down, its virtualOS process keeps running and the instance is + still reported as running until it is stopped from CloudStack. VMs + started from the virtualOS window are not seen at all. + - No console access, no root disk resizing and no snapshot support; + virtualOS 3.0 only offers snapshots through its window. + - The bundle name equals the CloudStack internal instance name; renaming + the VM in virtualOS breaks the mapping. + - Tested on macOS 26.6 with virtualOS 3.0 without an installed macOS + guest: launching through the autostart argument, the start error + detection with an invalid bundle, cloning, machine identifier and + Parameters.txt generation, DHCP lease parsing, status, statuses and + delete. Running a real macOS guest still needs verification. diff --git a/virtualos/virtualos.py b/virtualos/virtualos.py new file mode 100755 index 0000000..2154dea --- /dev/null +++ b/virtualos/virtualos.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +CloudStack orchestrator extension for virtualOS on macOS. + +virtualOS (https://github.com/yep/virtualOS) runs macOS guests on Apple +silicon through Apple's Virtualization framework. It has no command line +interface, so this script manages its VM bundles directly and starts a VM by +launching a separate virtualOS process with the autostartVMBundlePath user +default set as a launch argument. Stopping a VM terminates that process. + +The management server reaches the Mac over SSH (or runs locally when the +management server itself runs on the Mac). Instances are created by cloning +an existing virtualOS VM bundle, which is a copy-on-write copy on APFS, after +which the clone gets a new machine identifier and the CPU count, memory and +MAC address of the CloudStack instance. The bundle is named after the +CloudStack instance's internal name. + +Details (host details override extension details): + url Mac hostname/IP, or "localhost" to run locally + username SSH user owning the virtualOS VMs (logged in to the GUI) + password optional, SSH password (requires sshpass on the + management server; key authentication is preferred) + ssh_key optional, private key path on the management server + ssh_port optional, defaults to 22 + verify_host_key optional, "true" (default) or "false" + app_path optional, defaults to /Applications/virtualOS.app, + falling back to a Spotlight lookup of the app bundle + vm_directory optional, directory holding the VM bundles; must be + the "VM files" directory configured in virtualOS, + defaults to its sandbox container's Documents folder + template_name optional, default VM bundle to clone (without .bundle) + network_mode optional, nat|bridged; when set it is applied to the + instance, otherwise the template's mode is kept + bridge_interface optional, macOS interface for bridged mode, e.g. en0 + wait_timeout optional, seconds to wait for state changes (120) + +Instance/template details: + template_name VM bundle to clone, overrides the host/extension value +""" + +import base64 +import json +import math +import os +import plistlib +import re +import secrets +import shlex +import subprocess +import sys +import time + +DEFAULT_APP_PATH = "/Applications/virtualOS.app" +VIRTUALOS_BUNDLE_ID = "com.github.yep.ios.virtualOS" +VIRTUALOS_LOG_SUBSYSTEM = "com.github.virtualOS" +DEFAULT_VM_DIRECTORY = f"~/Library/Containers/{VIRTUALOS_BUNDLE_ID}/Data/Documents" +AUTOSTART_ARGUMENT = "-autostartVMBundlePath" +NETWORK_MODES = {"nat": "NAT", "bridged": "Bridge"} +LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1") +DHCP_LEASES = "/var/db/dhcpd_leases" +REQUIRED_BUNDLE_FILES = ("HardwareModel", "AuxiliaryStorage", "Parameters.txt") +START_CHECK_DELAY = 5 + + +def fail(message): + print(json.dumps({"status": "error", "error": message})) + sys.exit(1) + + +def succeed(data): + print(json.dumps(data)) + sys.exit(0) + + +def normalize_mac(mac): + return ":".join(f"{int(octet, 16):02x}" for octet in mac.split(":")) + + +class VirtualOSError(Exception): + pass + + +class VirtualOSManager: + def __init__(self, config_path): + self.data = self.parse_json(config_path) + + def parse_json(self, config_path): + with open(config_path, 'r') as f: + json_data = json.load(f) + + external = json_data.get("externaldetails", {}) + extension = external.get("extension", {}) or {} + host = external.get("host", {}) or {} + vm = external.get("virtualmachine", {}) or {} + + def detail(name, default=""): + return host.get(name) or extension.get(name) or default + + def flag(name, default="false"): + return str(detail(name, default)).lower() == "true" + + data = { + "url": detail("url"), + "username": detail("username"), + "password": detail("password"), + "ssh_key": detail("ssh_key"), + "ssh_port": str(detail("ssh_port", "22")), + "verify_host_key": flag("verify_host_key", "true"), + "app_path": detail("app_path"), + "vm_directory": detail("vm_directory", DEFAULT_VM_DIRECTORY).rstrip("/"), + "network_mode": detail("network_mode").lower(), + "bridge_interface": detail("bridge_interface"), + "wait_timeout": int(detail("wait_timeout", "120")), + "template_name": vm.get("template_name") or detail("template_name"), + } + if not data["url"]: + fail("Missing required field in JSON: url") + if data["network_mode"] and data["network_mode"] not in NETWORK_MODES: + fail(f"Invalid network_mode '{data['network_mode']}', expected one of {', '.join(NETWORK_MODES)}") + data["local"] = data["url"].lower() in LOCAL_HOSTS and not data["username"] + if not data["local"] and not data["username"]: + fail("Missing required field in JSON: username") + + vm_details = json_data.get("cloudstack.vm.details", {}) or {} + data["vmname"] = vm_details.get("name", "") + data["cpus"] = vm_details.get("cpus") + data["memory"] = vm_details.get("minRam") + nics = sorted(vm_details.get("nics", []) or [], key=lambda n: n.get("deviceId", 0)) + data["macs"] = [nic["mac"] for nic in nics if nic.get("mac")] + return data + + def ssh_command(self, remote_argv): + cmd = [ + "ssh", + "-o", "ConnectTimeout=15", + "-o", "StrictHostKeyChecking=" + ("yes" if self.data["verify_host_key"] else "no"), + "-p", self.data["ssh_port"], + ] + if not self.data["verify_host_key"]: + cmd += ["-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"] + if self.data["ssh_key"]: + cmd += ["-i", self.data["ssh_key"]] + if self.data["password"]: + cmd = ["sshpass", "-e"] + cmd + ["-o", "BatchMode=no"] + else: + cmd += ["-o", "BatchMode=yes"] + cmd += [f"{self.data['username']}@{self.data['url']}", "--", + " ".join(shlex.quote(a) for a in remote_argv)] + return cmd + + def run(self, argv, stdin=None): + cmd = argv if self.data["local"] else self.ssh_command(argv) + env = None + if not self.data["local"] and self.data["password"]: + env = dict(os.environ, SSHPASS=self.data["password"]) + try: + r = subprocess.run(cmd, capture_output=True, text=True, input=stdin, + timeout=self.data["wait_timeout"] + 30, env=env) + except FileNotFoundError as e: + raise VirtualOSError(f"Command not found: {e.filename}") + except subprocess.TimeoutExpired: + raise VirtualOSError(f"Timed out running: {' '.join(argv)}") + if r.returncode != 0: + raise VirtualOSError((r.stderr or r.stdout).strip() or f"'{' '.join(argv)}' exited with {r.returncode}") + return r.stdout + + def sh(self, script, *args, stdin=None): + return self.run(["sh", "-c", script, "sh"] + list(args), stdin=stdin) + + def vm_directory(self): + directory = self.data["vm_directory"] + if directory == "~" or directory.startswith("~/"): + home = self.sh('printf %s "$HOME"').strip() + directory = home + directory[1:] + self.data["vm_directory"] = directory + return directory + + def bundle_path(self, name=None): + return f"{self.vm_directory()}/{name or self.data['vmname']}.bundle" + + def resolve_app(self): + script = ('[ -d "$1" ] && echo "$1" && exit 0; ' + f'mdfind "kMDItemCFBundleIdentifier == \'{VIRTUALOS_BUNDLE_ID}\'" | head -n 1') + try: + path = self.sh(script, DEFAULT_APP_PATH).strip() + except VirtualOSError: + path = "" + if not path: + raise VirtualOSError("virtualOS not found on the host, set the app_path detail") + return path + + def app_path(self): + if not self.data["app_path"]: + self.data["app_path"] = self.resolve_app() + return self.data["app_path"] + + def list_bundles(self): + script = 'cd "$1" 2>/dev/null || exit 0; for b in *.bundle; do [ -d "$b" ] && echo "${b%.bundle}"; done; exit 0' + return [name for name in self.sh(script, self.vm_directory()).splitlines() if name] + + def bundle_exists(self, name=None): + return (name or self.data["vmname"]) in self.list_bundles() + + def running_vms(self): + """Map bundle path to the pids of the virtualOS processes autostarting it.""" + executable = "/Contents/MacOS/virtualOS " + marker = f" {AUTOSTART_ARGUMENT} " + running = {} + for line in self.run(["ps", "-axww", "-o", "pid=,command="]).splitlines(): + pid, _, command = line.strip().partition(" ") + if executable in command and marker in command: + running.setdefault(command.split(marker, 1)[1].strip(), []).append(pid) + return running + + def vm_pids(self, name=None): + return self.running_vms().get(self.bundle_path(name), []) + + def require_vmname(self): + if not self.data["vmname"]: + fail("Missing required field in JSON: cloudstack.vm.details.name") + if "/" in self.data["vmname"] or self.data["vmname"].startswith("."): + fail(f"Invalid instance name '{self.data['vmname']}'") + + def read_parameters(self, bundle): + output = self.run(["cat", f"{bundle}/Parameters.txt"]) + try: + return json.loads(output) + except json.JSONDecodeError: + raise VirtualOSError(f"Failed to parse {bundle}/Parameters.txt") + + def write_file(self, path, content): + self.sh('cat > "$1"', path, stdin=content) + + def write_machine_identifier(self, bundle): + # VZMacMachineIdentifier.dataRepresentation is a binary plist holding a random ECID + identifier = plistlib.dumps({"ECID": secrets.randbits(63)}, fmt=plistlib.FMT_BINARY) + self.sh('base64 -D > "$1"', f"{bundle}/MachineIdentifier", + stdin=base64.b64encode(identifier).decode()) + + def bridge_description(self): + # virtualOS matches the bridge by its display name, e.g. "Wi-Fi (en0)" + interface = self.data["bridge_interface"] + if not interface or "(" in interface: + return interface + port = None + for line in self.run(["networksetup", "-listallhardwareports"]).splitlines(): + if line.startswith("Hardware Port: "): + port = line[len("Hardware Port: "):].strip() + elif line.startswith("Device: ") and line[len("Device: "):].strip() == interface and port: + return f"{port} ({interface})" + return interface + + def configure(self, bundle): + parameters = self.read_parameters(bundle) + cpus = int(self.data["cpus"]) + memory_gb = max(1, math.ceil(int(self.data["memory"]) / (1024 ** 3))) + parameters["cpuCount"] = cpus + parameters["cpuCountMax"] = max(cpus, int(parameters.get("cpuCountMax", cpus))) + parameters["cpuCountMin"] = min(cpus, int(parameters.get("cpuCountMin", cpus))) + parameters["memorySizeInGB"] = memory_gb + parameters["memorySizeInGBMax"] = max(memory_gb, int(parameters.get("memorySizeInGBMax", memory_gb))) + parameters["memorySizeInGBMin"] = min(memory_gb, int(parameters.get("memorySizeInGBMin", memory_gb))) + parameters["installFinished"] = True + if self.data["macs"]: + parameters["macAddress"] = normalize_mac(self.data["macs"][0]) + mode = self.data["network_mode"] + if mode: + parameters["networkType"] = NETWORK_MODES[mode] + if mode == "bridged": + parameters["networkBridge"] = self.bridge_description() + self.write_file(f"{bundle}/Parameters.txt", json.dumps(parameters, indent=2)) + + def start_errors(self, pid): + output = self.run(["/usr/bin/log", "show", "--last", "2m", "--style", "compact", "--predicate", + f'subsystem == "{VIRTUALOS_LOG_SUBSYSTEM}" AND processID == {pid}']) + errors = [line.rsplit("] ", 1)[1] for line in output.splitlines() if "] Error" in line] + return list(dict.fromkeys(errors)) + + def start_vm(self): + if self.vm_pids(): + return + bundle = self.bundle_path() + self.run(["open", "-n", "-g", "-a", self.app_path(), "--args", AUTOSTART_ARGUMENT, bundle]) + deadline = time.time() + self.data["wait_timeout"] + while not self.vm_pids(): + if time.time() > deadline: + raise VirtualOSError(f"Timed out waiting for virtualOS to start {self.data['vmname']}") + time.sleep(1) + time.sleep(START_CHECK_DELAY) + pids = self.vm_pids() + if not pids: + raise VirtualOSError(f"virtualOS exited while starting {self.data['vmname']}") + try: + errors = self.start_errors(pids[0]) + except VirtualOSError: + errors = [] + if errors: + self.stop_vm() + raise VirtualOSError(f"virtualOS failed to start {self.data['vmname']}: {'; '.join(errors)}") + + def stop_vm(self, name=None): + pids = self.vm_pids(name) + if not pids: + return + self.run(["kill", "-TERM"] + pids) + deadline = time.time() + self.data["wait_timeout"] + while self.vm_pids(name): + if time.time() > deadline: + self.run(["kill", "-KILL"] + self.vm_pids(name)) + time.sleep(2) + break + time.sleep(1) + if self.vm_pids(name): + raise VirtualOSError(f"Failed to stop {name or self.data['vmname']}") + + def remove_bundle(self, name=None): + bundle = self.bundle_path(name) + if not bundle.endswith(".bundle") or not (name or self.data["vmname"]): + raise VirtualOSError(f"Refusing to remove '{bundle}'") + self.run(["rm", "-rf", bundle]) + + def create(self): + self.require_vmname() + vm_name = self.data["vmname"] + template = self.data["template_name"] + if not template: + fail("Missing required field in JSON: template_name") + if self.data["cpus"] is None or self.data["memory"] is None: + fail("Missing CPU or memory in cloudstack.vm.details") + if len(self.data["macs"]) > 1: + fail("virtualOS supports a single network interface per VM") + + bundles = self.list_bundles() + if template not in bundles: + fail(f"Template VM bundle '{template}' not found in {self.vm_directory()}") + if vm_name in bundles: + fail(f"A virtualOS VM named '{vm_name}' already exists") + template_bundle = self.bundle_path(template) + missing = self.sh('cd "$1" && shift && for f; do [ -e "$f" ] || echo "$f"; done; exit 0', + template_bundle, *REQUIRED_BUNDLE_FILES).split() + if missing: + fail(f"Template VM bundle '{template}' is incomplete, missing: {', '.join(missing)}") + if self.vm_pids(template): + fail(f"Template VM '{template}' is running, stop it before cloning") + + bundle = self.bundle_path() + cloned = False + try: + # -c clones the files on APFS, plain copy elsewhere + self.sh('cp -cR "$1" "$2" 2>/dev/null || { rm -rf "$2"; cp -R "$1" "$2"; }', template_bundle, bundle) + cloned = True + self.write_machine_identifier(bundle) + self.configure(bundle) + self.start_vm() + succeed({"status": "success", "message": "Instance created"}) + except VirtualOSError as e: + if cloned: + try: + self.stop_vm() + self.remove_bundle() + except VirtualOSError: + pass + fail(str(e)) + + def start(self): + self.require_vmname() + if not self.bundle_exists(): + fail(f"VM bundle '{self.data['vmname']}' not found in {self.vm_directory()}") + self.start_vm() + succeed({"status": "success", "message": "Instance started"}) + + def stop(self): + self.require_vmname() + self.stop_vm() + succeed({"status": "success", "message": "Instance stopped"}) + + def reboot(self): + self.require_vmname() + self.stop_vm() + self.start_vm() + succeed({"status": "success", "message": "Instance rebooted"}) + + def delete(self): + self.require_vmname() + self.stop_vm() + if self.bundle_exists(): + self.remove_bundle() + succeed({"status": "success", "message": "Instance deleted"}) + + def power_state(self, name, running): + if self.bundle_path(name) in running: + return "poweron" + return "poweroff" + + def status(self): + self.require_vmname() + if not self.bundle_exists(): + succeed({"status": "success", "power_state": "unknown"}) + succeed({"status": "success", "power_state": self.power_state(self.data["vmname"], self.running_vms())}) + + def statuses(self): + running = self.running_vms() + power_state = {name: self.power_state(name, running) for name in self.list_bundles()} + succeed({"status": "success", "power_state": power_state}) + + def get_console(self): + fail("Operation not supported") + + def get_ip_addresses(self): + self.require_vmname() + if not self.bundle_exists(): + fail(f"VM bundle '{self.data['vmname']}' not found in {self.vm_directory()}") + mac = normalize_mac(self.read_parameters(self.bundle_path()).get("macAddress", "0")) + leases = self.sh('[ -r "$1" ] && cat "$1"; exit 0', DHCP_LEASES) + addresses = [] + for lease in re.findall(r"\{(.*?)\}", leases, re.S): + fields = dict(line.strip().split("=", 1) for line in lease.splitlines() if "=" in line) + hw_address = fields.get("hw_address", "").split(",", 1)[-1] + if hw_address and normalize_mac(hw_address) == mac and fields.get("ip_address"): + addresses.append(fields["ip_address"]) + succeed({"status": "success", "printmessage": "true", "message": addresses}) + + +def main(): + if len(sys.argv) < 3: + fail("Usage: virtualos.py <operation> '<json-file-path>'") + + operation = sys.argv[1].lower() + json_file_path = sys.argv[2] + + try: + manager = VirtualOSManager(json_file_path) + except FileNotFoundError: + fail(f"JSON file not found: {json_file_path}") + except json.JSONDecodeError: + fail("Invalid JSON in file") + except (KeyError, ValueError) as e: + fail(f"Error parsing JSON: {str(e)}") + + operations = { + "create": manager.create, + "start": manager.start, + "stop": manager.stop, + "reboot": manager.reboot, + "delete": manager.delete, + "status": manager.status, + "statuses": manager.statuses, + "getconsole": manager.get_console, + "getipaddresses": manager.get_ip_addresses, + } + + if operation not in operations: + fail("Invalid action") + + try: + operations[operation]() + except VirtualOSError as e: + fail(str(e)) + + +if __name__ == "__main__": + main()
