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


The following commit(s) were added to refs/heads/virtualos-extension by this 
push:
     new 7bf39ad  virtualos: regard clients shut-down from within the os as 
stopped
7bf39ad is described below

commit 7bf39ad48ea517dfb7782c68f29a5434a5ac4e53
Author: Daan Hoogland <[email protected]>
AuthorDate: Wed Oct 7 15:27:27 2026 +0200

    virtualos: regard clients shut-down from within the os as stopped
---
 virtualos/README.txt  |  34 +++++++++------
 virtualos/src/main.rs | 114 ++++++++++++++++++++++++++++++++++++++++----------
 2 files changed, 115 insertions(+), 33 deletions(-)

diff --git a/virtualos/README.txt b/virtualos/README.txt
index 0d1bb4e..48b4818 100644
--- a/virtualos/README.txt
+++ b/virtualos/README.txt
@@ -65,9 +65,15 @@ Supported operations
                   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
+                  start and reboot first write the instance's current CPU
+                  count, memory and MAC address to the VM, so a changed
+                  service offering takes effect;
+                  start succeeds once virtualOS logs "vm started" for the
+                  VM and fails (stopping the process again) when it logs
+                  anything else or exits; stop terminates the virtualOS
+                  process, which powers the guest off without a shutdown,
+                  and waits until no process holds the bundle's files, so
+                  reboot can start it again right away
   getconsole      Not supported, virtualOS has no VNC endpoint to hand out.
 
 Custom actions (register them with addCustomAction):
@@ -97,6 +103,9 @@ Extension or host details (host details win):
   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
+  start_in_background
+                    Optional, "true" to launch virtualOS without bringing
+                    its window to the front, default "false"
 
 Template, service offering or instance details:
   template_name     VM bundle to clone, overrides the host/extension value
@@ -150,16 +159,17 @@ Limitations
   - 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.
+  - Status is derived from the virtualOS process together with the files
+    of the VM bundle being in use, so a shutdown from inside the guest is
+    reported as stopped even though its virtualOS window stays open; a
+    later start closes that window first. VMs started from a virtualOS
+    window instead of from CloudStack are not seen as running.
   - 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.
+  - Tested on macOS 26.6 with virtualOS 3.0 and a macOS 26.6.2 guest
+    through a CloudStack 24.0.0 management server: deploy (clone with new
+    machine identifier, CPU, memory and MAC), GetIpAddresses, stop, start,
+    reboot and destroy with expunge. Templates must be registered with
+    format EXTERNAL.
diff --git a/virtualos/src/main.rs b/virtualos/src/main.rs
index 9be120d..f058c5d 100644
--- a/virtualos/src/main.rs
+++ b/virtualos/src/main.rs
@@ -49,6 +49,8 @@
 //!                         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)
+//!   start_in_background   optional, "true" to launch virtualOS without
+//!                         bringing its window to the front (default "false")
 //!
 //! Instance/template details:
 //!   template_name         VM bundle to clone, overrides the host/extension 
value
@@ -69,7 +71,8 @@ const AUTOSTART_ARGUMENT: &str = "-autostartVMBundlePath";
 const LOCAL_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
 const DHCP_LEASES: &str = "/var/db/dhcpd_leases";
 const REQUIRED_BUNDLE_FILES: [&str; 3] = ["HardwareModel", "AuxiliaryStorage", 
"Parameters.txt"];
-const START_CHECK_DELAY: Duration = Duration::from_secs(5);
+const VM_STARTED_MESSAGE: &str = "vm started";
+const START_CHECK_TIMEOUT: Duration = Duration::from_secs(60);
 
 type Result<T> = std::result::Result<T, String>;
 
@@ -188,6 +191,7 @@ struct Config {
     network_mode: String,
     bridge_interface: String,
     wait_timeout: u64,
+    start_in_background: bool,
     template_name: String,
     local: bool,
     vmname: String,
@@ -251,6 +255,7 @@ impl Config {
             ssh_key: detail("ssh_key", ""),
             ssh_port: detail("ssh_port", "22"),
             verify_host_key: flag("verify_host_key", "true"),
+            start_in_background: flag("start_in_background", "false"),
             app_path: detail("app_path", ""),
             vm_directory: detail("vm_directory", 
&default_vm_directory()).trim_end_matches('/').to_string(),
             network_mode,
@@ -495,26 +500,58 @@ impl VirtualOSManager {
         self.write_file(&format!("{bundle}/Parameters.txt"), 
content.as_bytes())
     }
 
-    fn start_errors(&self, pid: &str) -> Result<Vec<String>> {
+    /// Messages virtualOS logged for the given process, oldest first.
+    fn virtualos_messages(&self, pid: &str) -> Result<Vec<String>> {
         let predicate = format!("subsystem == \"{VIRTUALOS_LOG_SUBSYSTEM}\" 
AND processID == {pid}");
-        let output = self.run(&["/usr/bin/log", "show", "--last", "2m", 
"--style", "compact", "--predicate", &predicate])?;
-        let mut errors: Vec<String> = Vec::new();
-        for line in output.lines().filter(|l| l.contains("] Error")) {
-            let error = line.rsplit_once("] ").map(|(_, e)| 
e.to_string()).unwrap_or_default();
-            if !errors.contains(&error) {
-                errors.push(error);
+        let output = self.run(&["/usr/bin/log", "show", "--last", "5m", 
"--style", "compact", "--predicate", &predicate])?;
+        let mut messages: Vec<String> = Vec::new();
+        for line in output.lines().filter(|l| 
l.contains(&format!("[{VIRTUALOS_LOG_SUBSYSTEM}:"))) {
+            let message = line.rsplit_once("] ").map(|(_, m)| 
m.trim().to_string()).unwrap_or_default();
+            if !messages.contains(&message) {
+                messages.push(message);
             }
         }
-        Ok(errors)
+        Ok(messages)
+    }
+
+    /// Wait until virtualOS logs that the VM started; the failure reason is 
often redacted as <private>.
+    fn wait_for_vm_started(&mut self, pid: &str) -> Result<()> {
+        let vmname = self.config.vmname.clone();
+        let deadline = Instant::now() + START_CHECK_TIMEOUT;
+        let mut messages = Vec::new();
+        loop {
+            if let Ok(logged) = self.virtualos_messages(pid) {
+                messages = logged;
+            }
+            if messages.iter().any(|m| m == VM_STARTED_MESSAGE) {
+                return Ok(());
+            }
+            let errors: Vec<&String> = messages.iter().filter(|m| 
m.starts_with("Error")).collect();
+            if !errors.is_empty() || 
!self.vm_pids(None)?.contains(&pid.to_string()) || Instant::now() > deadline {
+                let reason = if errors.is_empty() { messages.join("; ") } else 
{ errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join("; ") };
+                self.stop_vm(None)?;
+                return Err(format!("virtualOS did not start {vmname}: 
{reason}"));
+            }
+            thread::sleep(Duration::from_secs(2));
+        }
     }
 
     fn start_vm(&mut self) -> Result<()> {
+        let bundle = self.bundle_path(None)?;
         if !self.vm_pids(None)?.is_empty() {
-            return Ok(());
+            if !self.bundle_holders(&bundle)?.is_empty() {
+                return Ok(());
+            }
+            // the guest shut itself down, the virtualOS window stays open: 
close it before starting again
+            self.stop_vm(None)?;
         }
-        let bundle = self.bundle_path(None)?;
         let app = self.app_path()?;
-        self.run(&["open", "-n", "-g", "-a", &app, "--args", 
AUTOSTART_ARGUMENT, &bundle])?;
+        let mut open = vec!["open", "-n"];
+        if self.config.start_in_background {
+            open.push("-g");
+        }
+        open.extend(["-a", &app, "--args", AUTOSTART_ARGUMENT, &bundle]);
+        self.run(&open)?;
         let vmname = self.config.vmname.clone();
         let deadline = Instant::now() + 
Duration::from_secs(self.config.wait_timeout);
         while self.vm_pids(None)?.is_empty() {
@@ -523,17 +560,12 @@ impl VirtualOSManager {
             }
             thread::sleep(Duration::from_secs(1));
         }
-        thread::sleep(START_CHECK_DELAY);
         let pids = self.vm_pids(None)?;
         let Some(pid) = pids.first() else {
             return Err(format!("virtualOS exited while starting {vmname}"));
         };
-        let errors = self.start_errors(pid).unwrap_or_default();
-        if !errors.is_empty() {
-            self.stop_vm(None)?;
-            return Err(format!("virtualOS failed to start {vmname}: {}", 
errors.join("; ")));
-        }
-        Ok(())
+        let pid = pid.clone();
+        self.wait_for_vm_started(&pid)
     }
 
     fn kill(&self, signal: &str, pids: &[String]) -> Result<()> {
@@ -564,7 +596,33 @@ impl VirtualOSManager {
         if !self.vm_pids(name)?.is_empty() {
             return Err(format!("Failed to stop {}", 
name.unwrap_or(&self.config.vmname)));
         }
-        Ok(())
+        self.wait_for_bundle_release(name)
+    }
+
+    /// The Virtualization framework's VM process can keep the disk open after 
virtualOS exits;
+    /// starting the VM again before it lets go fails.
+    fn wait_for_bundle_release(&mut self, name: Option<&str>) -> Result<()> {
+        let bundle = self.bundle_path(name)?;
+        let deadline = Instant::now() + 
Duration::from_secs(self.config.wait_timeout);
+        loop {
+            let holders = self.bundle_holders(&bundle)?;
+            if holders.is_empty() {
+                return Ok(());
+            }
+            if Instant::now() > deadline {
+                return Err(format!("{bundle} is still in use by process(es) 
{}", holders.join(", ")));
+            }
+            thread::sleep(Duration::from_secs(1));
+        }
+    }
+
+    /// Pids of the processes holding files of the bundle open; while the 
guest runs, that is the
+    /// Virtualization framework's VM process.
+    fn bundle_holders(&self, bundle: &str) -> Result<Vec<String>> {
+        let output = self.sh("lsof -t +d \"$1\" 2>/dev/null; exit 0", 
&[bundle])?;
+        let mut pids: Vec<String> = 
output.split_whitespace().map(String::from).collect();
+        pids.dedup();
+        Ok(pids)
     }
 
     fn remove_bundle(&mut self) -> Result<()> {
@@ -627,10 +685,21 @@ impl VirtualOSManager {
         if !self.bundle_exists()? {
             return Err(self.bundle_not_found()?);
         }
+        self.apply_offering()?;
         self.start_vm()?;
         Ok(success_message("Instance started"))
     }
 
+    /// Write the instance's current CPU count, memory and MAC address to a 
stopped VM,
+    /// so changed service offerings take effect on the next start.
+    fn apply_offering(&mut self) -> Result<()> {
+        if self.config.cpus.is_none() || self.config.memory.is_none() || 
!self.vm_pids(None)?.is_empty() {
+            return Ok(());
+        }
+        let bundle = self.bundle_path(None)?;
+        self.configure(&bundle)
+    }
+
     fn stop(&mut self) -> Result<Value> {
         self.require_vmname()?;
         self.stop_vm(None)?;
@@ -640,6 +709,7 @@ impl VirtualOSManager {
     fn reboot(&mut self) -> Result<Value> {
         self.require_vmname()?;
         self.stop_vm(None)?;
+        self.apply_offering()?;
         self.start_vm()?;
         Ok(success_message("Instance rebooted"))
     }
@@ -653,9 +723,11 @@ impl VirtualOSManager {
         Ok(success_message("Instance deleted"))
     }
 
+    /// Running means a virtualOS process was started for the bundle and its 
guest still holds the
+    /// bundle's files; after a shutdown from inside the guest the virtualOS 
window stays open.
     fn power_state(&mut self, name: &str, running: &BTreeMap<String, 
Vec<String>>) -> Result<&'static str> {
         let bundle = self.bundle_path(Some(name))?;
-        Ok(if running.contains_key(&bundle) { "poweron" } else { "poweroff" })
+        Ok(if running.contains_key(&bundle) && 
!self.bundle_holders(&bundle)?.is_empty() { "poweron" } else { "poweroff" })
     }
 
     fn status(&mut self) -> Result<Value> {

Reply via email to