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:50 UTC
(cloudstack-extensions) branch utm-extension created (now fe6decb)
This is an automated email from the ASF dual-hosted git repository.
DaanHoogland pushed a change to branch utm-extension
in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git
at fe6decb utm: add a script for UTM as extension
This branch includes the following new commits:
new fe6decb utm: add a script for UTM as 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: utm: add a script for UTM as extension
Posted by da...@apache.org.
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()