You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cloudstack.apache.org by da...@apache.org on 2026/10/05 16:17:58 UTC

(cloudstack-extensions) branch virtualos-extension created (now 26a33f2)

This is an automated email from the ASF dual-hosted git repository.

DaanHoogland pushed a change to branch virtualos-extension
in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git


      at 26a33f2  macos virtualos as hypervisor extension

This branch includes the following new commits:

     new 26a33f2  macos virtualos as hypervisor extension

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(cloudstack-extensions) 01/01: macos virtualos as hypervisor extension

Posted by da...@apache.org.
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 <da...@apache.org>
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()