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/02 15:19:51 UTC
(cloudstack-extensions) 01/01: utm: add a script for UTM as extension
This is an automated email from the ASF dual-hosted git repository.
DaanHoogland pushed a commit to branch utm-extension
in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git
commit fe6decbab60e9b7134258de55f7c03c037fadce3
Author: Daan Hoogland <da...@apache.org>
AuthorDate: Fri Oct 2 17:18:26 2026 +0200
utm: add a script for UTM as extension
---
utm/README.txt | 130 +++++++++++++++++++
utm/utm.py | 398 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 528 insertions(+)
diff --git a/utm/README.txt b/utm/README.txt
new file mode 100644
index 0000000..fde8600
--- /dev/null
+++ b/utm/README.txt
@@ -0,0 +1,130 @@
+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.
+
+UTM Orchestrator Extension
+==========================
+
+Orchestrator extension that lets Apache CloudStack manage virtual machines
+in UTM (https://mac.getutm.app), the QEMU / Apple Virtualization front end
+for macOS. Each macOS machine running UTM is added to CloudStack as a host
+in a cluster mapped to this extension.
+
+The script runs on the management server and drives UTM on the Mac through
+SSH, using UTM's command line tool `utmctl` and its AppleScript interface
+(`osascript`). 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:
+ - UTM 4.x with a logged-in GUI session of the configured user; UTM is
+ controlled through Apple Events, which need that session
+ - Remote Login (SSH) enabled for that user
+ - The SSH session must be allowed to control UTM. If commands fail with
+ "OSStatus error -1743", grant the permission under System Settings >
+ Privacy & Security > Automation (run a utmctl command once from an SSH
+ session to get the prompt)
+ - One or more UTM virtual machines prepared as templates
+
+Supported operations
+--------------------
+
+ create Clone the template VM (APFS clone, so it is cheap), name it
+ after the CloudStack instance's internal name, set CPU
+ cores, memory and NIC MAC addresses, then start it. The
+ clone is removed again when any step fails.
+ start, stop, reboot, delete, status, statuses
+ getconsole Not supported, UTM has no VNC endpoint to hand out.
+
+Custom actions (register them with addCustomAction, no parameters):
+ Suspend Pause the VM in memory
+ Resume Resume a suspended VM
+ GetIpAddresses List IP addresses reported by the QEMU guest agent
+
+Configuration details
+---------------------
+
+Extension or host details (host details win):
+ url Mac hostname/IP, or "localhost" to run locally
+ username SSH user owning the UTM library; 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"
+ utmctl_path Optional; by default /Applications/UTM.app is tried,
+ then the UTM bundle is looked up with Spotlight
+ template_name Optional default UTM VM to clone
+ network_mode Optional: shared, bridged, host or emulated (QEMU
+ backend; Apple Virtualization VMs only support shared
+ and bridged). When set it is applied to every NIC,
+ otherwise template NICs keep their mode and additional
+ NICs use shared
+ bridge_interface macOS interface for bridged mode, e.g. en0
+ wait_timeout Optional, seconds to wait for state changes, default 120
+
+Template, service offering or instance details:
+ template_name UTM VM to clone, overrides the host/extension value
+
+Setup
+-----
+
+1. Copy utm.py to every management server:
+
+ mkdir -p /usr/share/cloudstack-management/extensions/UTM
+ cp utm.py /usr/share/cloudstack-management/extensions/UTM/utm.py
+ chmod 755 /usr/share/cloudstack-management/extensions/UTM/utm.py
+ chown -R cloud:cloud /usr/share/cloudstack-management/extensions/UTM
+
+ 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.
+
+2. Register the extension and its custom actions (CloudMonkey):
+
+ cmk create extension name=UTM type=Orchestrator path=utm.py
+ cmk add customaction extensionid=<id> name=Suspend resourcetype=VirtualMachine
+ cmk add customaction extensionid=<id> name=Resume resourcetype=VirtualMachine
+ cmk add customaction extensionid=<id> name=GetIpAddresses resourcetype=VirtualMachine
+
+3. 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.
+
+4. Register a template with hypervisor External and extension UTM, using a
+ dummy url, and set the external detail template_name to the name of the
+ UTM VM to clone.
+
+5. Deploy instances from that template.
+
+Limitations
+-----------
+
+ - No VLAN isolation: UTM cannot tag VLANs. Instances get the CloudStack
+ MAC addresses and the configured network mode only, so the guest network
+ must be reachable through the Mac's shared or bridged networking.
+ - No console access and no snapshots (neither is exposed by utmctl or
+ UTM's AppleScript dictionary).
+ - The UTM VM name equals the CloudStack internal instance name; renaming
+ the VM in UTM breaks the mapping.
+ - Tested against UTM 4.6.4 (clone, configure, status, delete). Starting
+ VMs through Apple Events was refused by the sandboxed UTM build used for
+ testing ("Operation not permitted"), so start/reboot/resume still need
+ verification on a UTM installation that allows scripted starts.
diff --git a/utm/utm.py b/utm/utm.py
new file mode 100755
index 0000000..4e07119
--- /dev/null
+++ b/utm/utm.py
@@ -0,0 +1,398 @@
+#!/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 UTM (https://mac.getutm.app) on macOS.
+
+The management server reaches the Mac over SSH (or runs locally when the
+management server itself runs on the Mac) and drives UTM through its
+command line tool `utmctl` and its AppleScript interface (`osascript`).
+
+Instances are created by cloning an existing UTM virtual machine that acts
+as the template, after which CPU cores, memory and NIC MAC addresses are
+set from the CloudStack instance. The UTM virtual machine 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 UTM library (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"
+ utmctl_path optional, by default /Applications/UTM.app is used,
+ falling back to a Spotlight lookup of the UTM bundle
+ template_name optional, default UTM VM to clone
+ network_mode optional, shared|bridged|host|emulated; when set it is
+ applied to every NIC, otherwise the template's mode is
+ kept and added NICs use "shared"
+ 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 UTM VM to clone, overrides the host/extension value
+"""
+
+import json
+import os
+import shlex
+import subprocess
+import sys
+import time
+
+DEFAULT_UTMCTL = "/Applications/UTM.app/Contents/MacOS/utmctl"
+UTM_BUNDLE_ID = "com.utmapp.UTM"
+NETWORK_MODES = ("shared", "bridged", "host", "emulated")
+LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
+
+POWER_ON_STATES = ("started", "starting", "paused", "pausing", "resuming")
+POWER_OFF_STATES = ("stopped",)
+
+CONFIGURE_SCRIPT = """
+on run argv
+ set vmName to item 1 of argv
+ set cpuCount to (item 2 of argv) as integer
+ set memMib to (item 3 of argv) as integer
+ set bridgeIf to item 4 of argv
+ set macs to {}
+ repeat with i from 5 to count of argv
+ set end of macs to item i of argv
+ end repeat
+ tell application "UTM"
+ set vm to virtual machine named vmName
+ set config to configuration of vm
+ set cpu cores of config to cpuCount
+ set memory of config to memMib
+ set nics to network interfaces of config
+ set newNics to {}
+ repeat with i from 1 to count of macs
+ if i <= (count of nics) then
+ set nic to item i of nics
+ set address of nic to item i of macs
+ %(set_mode)s
+ else
+ %(new_nic)s
+ end if
+ set end of newNics to nic
+ end repeat
+ set network interfaces of config to newNics
+ update configuration of vm with config
+ end tell
+end run
+"""
+
+
+def fail(message):
+ print(json.dumps({"status": "error", "error": message}))
+ sys.exit(1)
+
+
+def succeed(data):
+ print(json.dumps(data))
+ sys.exit(0)
+
+
+class UtmError(Exception):
+ pass
+
+
+class UtmManager:
+ 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
+
+ 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": str(detail("verify_host_key", "true")).lower() == "true",
+ "utmctl": detail("utmctl_path"),
+ "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)}")
+ if data["network_mode"] == "bridged" and not data["bridge_interface"]:
+ fail("Missing required field in JSON: bridge_interface (required for bridged network_mode)")
+ 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")]
+
+ data["parameters"] = json_data.get("parameters", {}) or {}
+ 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, input=stdin, capture_output=True, text=True,
+ timeout=self.data["wait_timeout"] + 30, env=env)
+ except FileNotFoundError as e:
+ raise UtmError(f"Command not found: {e.filename}")
+ except subprocess.TimeoutExpired:
+ raise UtmError(f"Timed out running: {' '.join(argv)}")
+ # utmctl exits with 0 when the Apple Event it sends fails, so check its error output as well
+ if r.returncode != 0 or "Error from event" in r.stderr:
+ raise UtmError((r.stderr or r.stdout).strip() or f"'{' '.join(argv)}' exited with {r.returncode}")
+ return r.stdout
+
+ def resolve_utmctl(self):
+ script = (f'p={shlex.quote(DEFAULT_UTMCTL)}; [ -x "$p" ] || '
+ f'p="$(mdfind "kMDItemCFBundleIdentifier == \'{UTM_BUNDLE_ID}\'" | head -n 1)/Contents/MacOS/utmctl"; '
+ '[ -x "$p" ] && echo "$p"')
+ try:
+ path = self.run(["sh", "-c", script]).strip()
+ except UtmError:
+ path = ""
+ if not path:
+ raise UtmError("UTM not found on the host, set the utmctl_path detail")
+ return path
+
+ def utmctl(self, *args):
+ if not self.data["utmctl"]:
+ self.data["utmctl"] = self.resolve_utmctl()
+ return self.run([self.data["utmctl"]] + list(args))
+
+ def osascript(self, script, *args):
+ return self.run(["osascript", "-"] + list(args), stdin=script)
+
+ def list_vms(self):
+ vms = {}
+ for line in self.utmctl("list").splitlines():
+ parts = line.split(None, 2)
+ if len(parts) < 3 or parts[0] == "UUID":
+ continue
+ vms[parts[2].strip()] = parts[1].strip().lower()
+ return vms
+
+ def vm_state(self, name=None):
+ return self.list_vms().get(name or self.data["vmname"])
+
+ def wait_for_state(self, states, name=None):
+ deadline = time.time() + self.data["wait_timeout"]
+ while True:
+ state = self.vm_state(name)
+ if state in states:
+ return state
+ if time.time() > deadline:
+ raise UtmError(f"Timed out waiting for {name or self.data['vmname']} to reach {'/'.join(states)}, "
+ f"current state: {state}")
+ time.sleep(2)
+
+ def require_vmname(self):
+ if not self.data["vmname"]:
+ fail("Missing required field in JSON: cloudstack.vm.details.name")
+
+ def configure_script(self):
+ mode = self.data["network_mode"]
+ set_mode = ""
+ if mode:
+ set_mode = f"set mode of nic to {mode}"
+ if mode == "bridged":
+ set_mode += "\n set host interface of nic to bridgeIf"
+ new_mode = mode or "shared"
+ host_interface = ", host interface:bridgeIf" if new_mode == "bridged" else ""
+ new_nic = f"set nic to {{mode:{new_mode}, address:item i of macs{host_interface}}}"
+ return CONFIGURE_SCRIPT % {"set_mode": set_mode, "new_nic": new_nic}
+
+ def stop_vm(self, name):
+ state = self.vm_state(name)
+ if state is None or state in POWER_OFF_STATES:
+ return
+ self.utmctl("stop", name)
+ self.wait_for_state(POWER_OFF_STATES, name)
+
+ 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")
+
+ vms = self.list_vms()
+ if template not in vms:
+ fail(f"Template VM '{template}' not found in UTM")
+ if vm_name in vms:
+ fail(f"A UTM VM named '{vm_name}' already exists")
+
+ cloned = False
+ try:
+ self.utmctl("clone", template, "--name", vm_name)
+ cloned = True
+ memory_mib = int(self.data["memory"]) // (1024 * 1024)
+ self.osascript(self.configure_script(), vm_name, str(self.data["cpus"]), str(memory_mib),
+ self.data["bridge_interface"], *self.data["macs"])
+ self.utmctl("start", vm_name)
+ succeed({"status": "success", "message": "Instance created"})
+ except UtmError as e:
+ if cloned:
+ try:
+ self.stop_vm(vm_name)
+ self.utmctl("delete", vm_name)
+ except UtmError:
+ pass
+ fail(str(e))
+
+ def start(self):
+ self.require_vmname()
+ self.utmctl("start", self.data["vmname"])
+ succeed({"status": "success", "message": "Instance started"})
+
+ def stop(self):
+ self.require_vmname()
+ self.stop_vm(self.data["vmname"])
+ succeed({"status": "success", "message": "Instance stopped"})
+
+ def reboot(self):
+ self.require_vmname()
+ self.stop_vm(self.data["vmname"])
+ self.utmctl("start", self.data["vmname"])
+ succeed({"status": "success", "message": "Instance rebooted"})
+
+ def delete(self):
+ self.require_vmname()
+ vm_name = self.data["vmname"]
+ if self.vm_state(vm_name) is not None:
+ self.stop_vm(vm_name)
+ self.utmctl("delete", vm_name)
+ succeed({"status": "success", "message": "Instance deleted"})
+
+ @staticmethod
+ def power_state(state):
+ if state in POWER_ON_STATES:
+ return "poweron"
+ if state in POWER_OFF_STATES:
+ return "poweroff"
+ return "unknown"
+
+ def status(self):
+ self.require_vmname()
+ succeed({"status": "success", "power_state": self.power_state(self.vm_state())})
+
+ def statuses(self):
+ power_state = {name: self.power_state(state) for name, state in self.list_vms().items()}
+ succeed({"status": "success", "power_state": power_state})
+
+ def get_console(self):
+ fail("Operation not supported")
+
+ def suspend(self):
+ self.require_vmname()
+ self.utmctl("suspend", self.data["vmname"])
+ succeed({"status": "success", "message": "Instance suspended"})
+
+ def resume(self):
+ self.require_vmname()
+ self.utmctl("start", self.data["vmname"])
+ succeed({"status": "success", "message": "Instance resumed"})
+
+ def get_ip_addresses(self):
+ self.require_vmname()
+ addresses = [a.strip() for a in self.utmctl("ip-address", self.data["vmname"]).splitlines() if a.strip()]
+ succeed({"status": "success", "printmessage": "true", "message": addresses})
+
+
+def main():
+ if len(sys.argv) < 3:
+ fail("Usage: utm.py <operation> '<json-file-path>'")
+
+ operation = sys.argv[1].lower()
+ json_file_path = sys.argv[2]
+
+ try:
+ manager = UtmManager(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,
+ "suspend": manager.suspend,
+ "resume": manager.resume,
+ "getipaddresses": manager.get_ip_addresses,
+ }
+
+ if operation not in operations:
+ fail("Invalid action")
+
+ try:
+ operations[operation]()
+ except UtmError as e:
+ fail(str(e))
+
+
+if __name__ == "__main__":
+ main()