From 96adb9e51b5b24ffa72c92ee45ee9f8dc81c6838 Mon Sep 17 00:00:00 2001 From: Delan Azabani Date: Thu, 30 Oct 2025 17:41:06 +0800 Subject: [PATCH 1/6] nix: add support for aarch64-darwin --- book/src/monitor/hacking.md | 2 +- flake.nix | 41 ++++++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/book/src/monitor/hacking.md b/book/src/monitor/hacking.md index 74b78a48..3a079a7a 100644 --- a/book/src/monitor/hacking.md +++ b/book/src/monitor/hacking.md @@ -11,5 +11,5 @@ Harder but faster way: ``` $ export RUSTFLAGS=-Clink-arg=-fuse-ld=mold $ cargo build -$ sudo [RUST_BACKTRACE=1] IMAGE_DEPS_DIR=$(nix eval --raw .\#image-deps) LIB_MONITOR_DIR=. target/debug/monitor +$ sudo [RUST_BACKTRACE=1] IMAGE_DEPS_DIR=$(nix build --print-out-paths .\#image-deps) LIB_MONITOR_DIR=. target/debug/monitor ``` diff --git a/flake.nix b/flake.nix index 84cde429..4ec43c37 100644 --- a/flake.nix +++ b/flake.nix @@ -7,10 +7,16 @@ outputs = inputs@{ self, unstable, ... }: let - pkgsUnstable = import unstable { - system = "x86_64-linux"; - config = { allowUnfree = true; }; - }; + forEachSystem = fn: + unstable.lib.genAttrs [ + "x86_64-linux" + "aarch64-darwin" + ] (system: fn system ( + import unstable { + inherit system; + config.allowUnfree = true; + } + )); monitor = inputs.crate2nix.tools.x86_64-linux.appliedCargoNix { name = "monitor"; src = ./.; @@ -71,15 +77,22 @@ monitor = self.packages.x86_64-linux.monitor; }) ]; }; - packages.x86_64-linux.monitor = pkgsUnstable.callPackage server/nixos/monitor.nix { - monitorCrate = monitor.workspaceMembers.monitor.build; - image-deps = self.packages.x86_64-linux.image-deps; - }; - packages.x86_64-linux.image-deps = pkgsUnstable.callPackage server/nixos/image-deps.nix {}; - devShells.x86_64-linux.default = import ./shell.nix { - inherit (pkgsUnstable) pkgs; - image-deps = self.packages.x86_64-linux.image-deps; - monitor = self.packages.x86_64-linux.monitor; - }; + packages = forEachSystem ( + system: pkgs: { + monitor = pkgs.callPackage server/nixos/monitor.nix { + monitorCrate = monitor.workspaceMembers.monitor.build; + image-deps = self.packages.${system}.image-deps; + }; + image-deps = pkgs.callPackage server/nixos/image-deps.nix {}; + } + ); + devShells = forEachSystem ( + system: pkgs: { + default = import ./shell.nix { + inherit pkgs; + inherit (self.packages.${system}) image-deps monitor; + }; + } + ); }; } From 5b0a2a682cef184de4298a280d7b33da0b773cf1 Mon Sep 17 00:00:00 2001 From: Delan Azabani Date: Thu, 30 Oct 2025 18:31:18 +0800 Subject: [PATCH 2/6] ci: cargo check for macOS too --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6cee9216..65542c35 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,5 +12,8 @@ jobs: cd monitor && cargo fmt --all -- --check - run: | cd monitor && cargo test --workspace + - run: | + rustup target add aarch64-apple-darwin + cargo check --target aarch64-apple-darwin # TODO: `cargo clippy --workspace -- --deny warnings` # TODO: Check nix derivations From cf50ef3f228753c351e9522a3eeb7c9f613fe81d Mon Sep 17 00:00:00 2001 From: Delan Azabani Date: Mon, 10 Nov 2025 20:03:10 +0800 Subject: [PATCH 3/6] README: add procedure for clean image --- book/src/SUMMARY.md | 1 + book/src/servo/images/macos-arm64.md | 96 ++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 book/src/servo/images/macos-arm64.md diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 9b93f757..06e03f99 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -24,6 +24,7 @@ - [Windows 10 images](servo/images/windows10.md) - [Ubuntu 22.04 images](servo/images/ubuntu2204.md) - [macOS 13/14/15 x64 images](servo/images/macos-x64.md) + - [macOS 15 arm64 images](servo/images/macos-arm64.md) # Meta diff --git a/book/src/servo/images/macos-arm64.md b/book/src/servo/images/macos-arm64.md new file mode 100644 index 00000000..e51e28db --- /dev/null +++ b/book/src/servo/images/macos-arm64.md @@ -0,0 +1,96 @@ +# macOS 15 arm64 images + +Runners created from these images preinstall all dependencies (including those specified in the main repo, like mach bootstrap deps), preload the main repo, and prebuild Servo in the release profile. + +This is a **UTM**-based image, compatible with macOS arm64 servers only: + +- `servo-macos15-arm` + +To prepare a macOS server for macOS guests and build a clean image, replacing “15” with the macOS version as needed: + +- Install [Rust](https://rustup.rs) +- Install [Nix](https://nixos.org/download/#nix-install-macos) (the package manager) +- Install [UTM for macOS](https://mac.getutm.app) — the GitHub download version is free of charge +- Install [Homebrew](https://brew.sh) and libvirt: + - `brew install libvirt` + - `brew services start libvirt` + - TODO: make libvirt optional and remove this? +- Clone and enter this repo: + - `git clone https://github.com/servo/ci-runners.git ~/ci-runners` + - `cd ~/ci-runners` +- Configure the monitor: + - `cp .env.example .env` + - `cp monitor.toml.example monitor.toml` + - `vim .env` + - **LIBVIRT_DEFAULT_URI** = `qemu:///session` + - `vim monitor.toml.example` + - **available_1g_hugepages** = **0** (it’s Linux only) + - **available_normal_memory** = RAM minus 8G + - Zero out the **target_count** of any Linux-only profiles +- Download the IPSW list for macOS 15: + - macOS 15: `curl -fsSo com_apple_macOSIPSW_20250825041725.xml --compressed https://web.archive.org/web/20250825041725id_/https://mesu.apple.com/assets/macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml` +- Download the IPSW image for macOS 15: + - macOS 15: `curl -O $(< com_apple_macOSIPSW_20250825041725.xml sed '/>VirtualMac2,1https:/!d' | tail -1 | egrep -o 'https://[^<]+')` +- In UTM: + - **Settings** > **Network** > **Regenerate MAC addresses on clone** = on + - Close the settings window + - **Create a New Virtual Machine** > **Virtualize** > **macOS 12+** + - **Memory** = **8192** MiB (TODO: update for prod environment) + - **CPU Cores** = **5** (TODO: update for prod environment) + - On the **macOS** page: **Import IPSW** > **Browse...** > `~/ci-runners/UniversalMac_15.6.1_24G90_Restore.ipsw` + - On the **Storage** page: **Size** = **90** GiB (TODO: update for prod environment) + - On the **Summary** page: + - **Name** = **servo-macos15-arm-clean** + - **Open VM Settings** = on + - In the VM settings: + - **Virtualization** > **Enable Sound** = off + - **Virtualization** > **Enable Clipboard Sharing** = off + - **Display** > **Resolution** = 1280 × 800 (this should be enough for a [WPT reftest](https://web-platform-tests.org/reviewing-tests/checklist.html#reftests-only)) + - **Display** > **Dynamic Resolution** = off + - Start **servo-macos15-arm-clean** using the GUI + - There seems to be a bug in UTM 4.7.4 (115) that breaks the VM if you start it with AppleScript + - At the **Confirmation** prompt: **Would you like to install macOS?** > **OK** +- Once the clean vm boots: + - **Language** = **English** + - If latency is high: + - FIXME: this seems to be busted in Remmina + UTM + macOS 15 (Command+Option+F5 just enables VoiceOver) + - Press **Command**+**Option**+**F5**, then click **Full Keyboard Access**, then press **Enter** + - You can now press **Shift**+**Tab** to get to the buttons at the bottom of the wizard + - **Select Your Country or Region** = United States + - macOS 15: **Transfer Your Data to This Mac** = Set up as new + - If latency is high, **Accessibility** > **Vision** then: + - \> **Reduce Transparency** = Reduce Transparency + - \> **Reduce Motion** = Reduce Motion + - **Full name** = `servo` + - **Account name** = `servo` + - **Password** = `servo2024!` + - macOS 15: **Allow computer account password to be reset with your Apple Account** = off + - macOS 15: **Sign In to Your Apple Account** = Set Up Later + - **Enable Location Services** = Continue, Don’t Use + - **Select Your Time Zone** > **Closest City:** = UTC - United Kingdom + - Uncheck **Share Mac Analytics with Apple** + - **Screen Time** = Set Up Later + - macOS 15: **Update Mac Automatically** = Only Download Automatically + - TODO: can we prevent the download too? + - Quit the **Keyboard Setup Assistant** + - If latency is high: + - Press **Cmd**+**Space**, type `full keyboard access`, turn it on, then press **Cmd**+**Q** + - On macOS 15, this may make some steps *harder* to do with keyboard navigation for some reason + - Once installed, shut down macOS: + - **Apple logo** > **Shut Down…** + - **Reopen windows when logging back in** = off + - **Shut Down** +- When the guest shuts down, clone it: `osascript -e 'tell application "UTM"' -e 'set vm to virtual machine named "servo-macos15-arm-clean"' -e 'duplicate vm with properties {configuration: {name: "servo-macos15-arm-clean@oobe"}}' -e 'end tell'` + - You may be prompted for permission in the GUI +- Start the clean vm (the original, not the clone): `osascript -e 'tell application "UTM"' -e 'set vm to virtual machine named "servo-macos15-arm-clean"' -e 'start vm' -e 'end tell'` +- Log in with the password above +- In the UTM title bar, click **Capture input devices** +- Press **Cmd**+**Space**, type `full disk access`, press **Enter** + - On macOS 14/15, you may have to explicitly select **Allow applications to access all user files** +- Click the plus, type the password above, type `/System/Applications/Utilities/Terminal.app`, press **Enter** twice, press **Cmd**+**Q** +- Shut down macOS: **Apple logo** > **Shut down…** +- When the guest shuts down, make another clone: `osascript -e 'tell application "UTM"' -e 'set vm to virtual machine named "servo-macos15-arm-clean"' -e 'duplicate vm with properties {configuration: {name: "servo-macos15-arm-clean@preautomated"}}' -e 'end tell'` +- Start the clean vm (the original, not the clone): `osascript -e 'tell application "UTM"' -e 'set vm to virtual machine named "servo-macos15-arm-clean"' -e 'start vm' -e 'end tell'` +- Press **Cmd**+**Space**, type `terminal`, press **Enter** +- Type `curl https://ci0.servo.org/static/macos13.sh | sed -E 's/100([.]1:8000)/64\1/' | sudo sh`, press **Enter**, type the password above, press **Enter** +- When the guest shuts down, make another clone: `osascript -e 'tell application "UTM"' -e 'set vm to virtual machine named "servo-macos15-arm-clean"' -e 'duplicate vm with properties {configuration: {name: "servo-macos15-arm-clean@automated"}}' -e 'end tell'` From 937b9663c28f6f5c20d263c63a8b6c254f6a8087 Mon Sep 17 00:00:00 2001 From: Delan Azabani Date: Thu, 30 Oct 2025 15:02:52 +0800 Subject: [PATCH 4/6] monitor: initial utm backend --- Cargo.lock | 97 ++++++++++ Cargo.toml | 1 + monitor/Cargo.toml | 2 +- monitor/hypervisor/Cargo.toml | 4 + monitor/hypervisor/src/impl_libvirt.rs | 10 + monitor/hypervisor/src/impl_utm.rs | 215 +++++++++++++++++++++ monitor/hypervisor/src/impl_utm_backend.rs | 152 +++++++++++++++ monitor/hypervisor/src/lib.rs | 10 + monitor/hypervisor/src/utm.rs | 21 ++ monitor/src/main.rs | 54 ++++-- 10 files changed, 546 insertions(+), 20 deletions(-) create mode 100644 monitor/hypervisor/src/impl_utm.rs create mode 100644 monitor/hypervisor/src/impl_utm_backend.rs create mode 100644 monitor/hypervisor/src/utm.rs diff --git a/Cargo.lock b/Cargo.lock index 5703008c..64d466bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,15 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -438,6 +447,16 @@ dependencies = [ "syn", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.6.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -900,7 +919,9 @@ name = "hypervisor" version = "0.1.0" dependencies = [ "cmd_lib", + "crossbeam-channel", "jane-eyre", + "osakit", "settings", "shell", "tracing", @@ -1327,6 +1348,68 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.6.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.6.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.6.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.6.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "object" version = "0.32.2" @@ -1352,6 +1435,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "overload" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index b0a6bd88..7248a5ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ bytesize = "2.0.1" chrono = { version = "0.4.39", features = ["serde"] } cli = { path = "monitor/cli" } cmd_lib = "1.9.5" +crossbeam-channel = "0.5.13" dotenv = "0.15.0" hypervisor = { path = "monitor/hypervisor" } jane-eyre = "0.3.0" diff --git a/monitor/Cargo.toml b/monitor/Cargo.toml index 553ca0d3..1c7c76d1 100644 --- a/monitor/Cargo.toml +++ b/monitor/Cargo.toml @@ -12,7 +12,7 @@ cfg-if = "1.0.1" chrono = { workspace = true } cli = { workspace = true } cmd_lib = { workspace = true } -crossbeam-channel = "0.5.13" +crossbeam-channel = { workspace = true } dotenv = { workspace = true } http = "0.2" hypervisor = { workspace = true } diff --git a/monitor/hypervisor/Cargo.toml b/monitor/hypervisor/Cargo.toml index 6a289abc..5630b0e8 100644 --- a/monitor/hypervisor/Cargo.toml +++ b/monitor/hypervisor/Cargo.toml @@ -5,7 +5,11 @@ edition = "2024" [dependencies] cmd_lib.workspace = true +crossbeam-channel.workspace = true jane-eyre.workspace = true settings.workspace = true shell.workspace = true tracing.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +osakit = { version = "0.3.1", features = ["full"] } diff --git a/monitor/hypervisor/src/impl_libvirt.rs b/monitor/hypervisor/src/impl_libvirt.rs index 91de6234..b6c1dd8b 100644 --- a/monitor/hypervisor/src/impl_libvirt.rs +++ b/monitor/hypervisor/src/impl_libvirt.rs @@ -15,6 +15,16 @@ use tracing::{debug, info}; use crate::libvirt::{delete_template_or_rebuild_image_file, template_or_rebuild_images_path}; +pub fn initialise() -> eyre::Result<()> { + // Do nothing (not applicable to libvirt) + Ok(()) +} + +pub fn handle_main_thread_request() -> eyre::Result<()> { + // Do nothing (not applicable to libvirt) + Ok(()) +} + pub fn list_template_guests() -> eyre::Result> { // Output is not filtered by prefix, so we must filter it ourselves. let prefix = format!("{}-", TOML.libvirt_template_guest_prefix()); diff --git a/monitor/hypervisor/src/impl_utm.rs b/monitor/hypervisor/src/impl_utm.rs new file mode 100644 index 00000000..08678d12 --- /dev/null +++ b/monitor/hypervisor/src/impl_utm.rs @@ -0,0 +1,215 @@ +#[path = "impl_utm_backend.rs"] +mod backend; + +use std::{ + collections::BTreeSet, + net::Ipv4Addr, + path::Path, + sync::LazyLock, + time::{Duration, Instant}, +}; + +use crossbeam_channel::{Receiver, Sender}; +use jane_eyre::eyre::{self, bail}; +use settings::TOML; +use settings::profile::Profile; +use tracing::info; + +pub(crate) struct Channel { + pub sender: Sender, + pub receiver: Receiver, +} +pub(crate) static UTM_REQUEST: LazyLock> = LazyLock::new(|| { + let (sender, receiver) = crossbeam_channel::bounded(0); + Channel { sender, receiver } +}); + +#[derive(Debug)] +pub(crate) enum UtmRequest { + ListGuests { + result: Sender>>, + }, + GuestStatus { + result: Sender>, + guest_name: String, + }, + StartGuest { + result: Sender>, + guest_name: String, + }, + DeleteGuest { + result: Sender>, + guest_name: String, + }, + CloneGuest { + result: Sender>, + original_guest_name: String, + new_guest_name: String, + }, + RenameGuest { + result: Sender>, + old_guest_name: String, + new_guest_name: String, + }, +} + +pub fn initialise() -> eyre::Result<()> { + self::backend::request_automation_permission() +} + +pub fn handle_main_thread_request() -> eyre::Result<()> { + if let Ok(request) = UTM_REQUEST.receiver.recv_timeout(Duration::from_secs(1)) { + match request { + UtmRequest::ListGuests { result } => result.send(self::backend::list_guests())?, + UtmRequest::GuestStatus { result, guest_name } => { + result.send(self::backend::guest_status(&guest_name))? + } + UtmRequest::StartGuest { result, guest_name } => { + result.send(self::backend::start_guest(&guest_name))? + } + UtmRequest::DeleteGuest { result, guest_name } => { + result.send(self::backend::delete_guest(&guest_name))? + } + UtmRequest::CloneGuest { + result, + original_guest_name, + new_guest_name, + } => result.send(self::backend::clone_guest( + &original_guest_name, + &new_guest_name, + ))?, + UtmRequest::RenameGuest { + result, + old_guest_name, + new_guest_name, + } => result.send(self::backend::rename_guest( + &old_guest_name, + &new_guest_name, + ))?, + } + } + Ok(()) +} + +pub fn list_template_guests() -> eyre::Result> { + // Output is not filtered by prefix, so we must filter it ourselves. + let prefix = format!("{}-", TOML.libvirt_template_guest_prefix()); + let (tx, rx) = crossbeam_channel::bounded(0); + UTM_REQUEST + .sender + .send(UtmRequest::ListGuests { result: tx })?; + let result = rx + .recv()?? + .into_iter() + .filter(|name| name.starts_with(&prefix)); + + Ok(result.collect()) +} + +pub fn list_rebuild_guests() -> eyre::Result> { + // Output is not filtered by prefix, so we must filter it ourselves. + let prefix = format!("{}-", TOML.libvirt_rebuild_guest_prefix()); + let (tx, rx) = crossbeam_channel::bounded(0); + UTM_REQUEST + .sender + .send(UtmRequest::ListGuests { result: tx })?; + let result = rx + .recv()?? + .into_iter() + .filter(|name| name.starts_with(&prefix)); + + Ok(result.collect()) +} + +pub fn list_runner_guests() -> eyre::Result> { + // Output is not filtered by prefix, so we must filter it ourselves. + let prefix = format!("{}-", TOML.libvirt_runner_guest_prefix()); + let (tx, rx) = crossbeam_channel::bounded(0); + UTM_REQUEST + .sender + .send(UtmRequest::ListGuests { result: tx })?; + let result = rx + .recv()?? + .into_iter() + .filter(|name| name.starts_with(&prefix)); + + Ok(result.collect()) +} + +#[expect(unused)] +pub fn update_screenshot(guest_name: &str, output_dir: &Path) -> eyre::Result<()> { + bail!("TODO") +} + +#[expect(unused)] +pub fn take_screenshot(guest_name: &str, output_path: &Path) -> eyre::Result<()> { + bail!("TODO") +} + +#[expect(unused)] +pub fn get_ipv4_address(guest_name: &str) -> Option { + // TODO + None +} + +pub fn start_guest(guest_name: &str) -> eyre::Result<()> { + let (tx, rx) = crossbeam_channel::bounded(0); + UTM_REQUEST.sender.send(UtmRequest::StartGuest { + result: tx, + guest_name: guest_name.to_owned(), + })?; + Ok(rx.recv()??) +} + +pub fn wait_for_guest(guest_name: &str, timeout: Duration) -> eyre::Result<()> { + let timeout_for_log = timeout.as_secs(); + info!("Waiting for guest to shut down (max {timeout_for_log} seconds)"); + let start_time = Instant::now(); + while Instant::now() + .checked_duration_since(start_time) + .is_none_or(|d| d < timeout) + { + if guest_status(guest_name)? == "stopped" { + return Ok(()); + } + } + + bail!("Waiting for guest timed out!") +} + +pub fn rename_guest(old_guest_name: &str, new_guest_name: &str) -> eyre::Result<()> { + let (tx, rx) = crossbeam_channel::bounded(0); + UTM_REQUEST.sender.send(UtmRequest::RenameGuest { + result: tx, + old_guest_name: old_guest_name.to_owned(), + new_guest_name: new_guest_name.to_owned(), + })?; + Ok(rx.recv()??) +} + +pub fn delete_guest(guest_name: &str) -> eyre::Result<()> { + let (tx, rx) = crossbeam_channel::bounded(0); + UTM_REQUEST.sender.send(UtmRequest::DeleteGuest { + result: tx, + guest_name: guest_name.to_owned(), + })?; + Ok(rx.recv()??) +} + +#[expect(unused)] +pub fn prune_base_image_files( + profile: &Profile, + keep_snapshots: BTreeSet, +) -> eyre::Result<()> { + // Do nothing (not applicable to UTM) + Ok(()) +} + +fn guest_status(guest_name: &str) -> eyre::Result { + let (tx, rx) = crossbeam_channel::bounded(0); + UTM_REQUEST.sender.send(UtmRequest::GuestStatus { + result: tx, + guest_name: guest_name.to_owned(), + })?; + Ok(rx.recv()??) +} diff --git a/monitor/hypervisor/src/impl_utm_backend.rs b/monitor/hypervisor/src/impl_utm_backend.rs new file mode 100644 index 00000000..ca7eb0d6 --- /dev/null +++ b/monitor/hypervisor/src/impl_utm_backend.rs @@ -0,0 +1,152 @@ +use std::{process::Command, thread::sleep, time::Duration}; + +use jane_eyre::eyre; +use osakit::{self, declare_script}; +use settings::TOML; +use tracing::{error, warn}; + +declare_script! { + #[language(JavaScript)] + #[source(r#" + // Like `Array.from()`, but works on array-like objects created by applications + // (which would otherwise throw “Error: Error: Can't get object.”). + function array(xs) { + const result = []; + for (var i in xs) { + result.push(xs[i]); + } + return result; + } + function list_guests() { + const utm = Application("UTM"); + const vms = array(utm.virtualMachines); + return vms.map(vm => vm.name()); + } + function start_guest(guest_name) { + const utm = Application("UTM"); + const vm = array(utm.virtualMachines).find(vm => vm.name() == guest_name); + vm.start(); + } + function delete_guest(guest_name) { + const utm = Application("UTM"); + const vm = array(utm.virtualMachines).find(vm => vm.name() == guest_name); + if (vm) { + vm.delete(); + } + } + function guest_status(guest_name) { + const utm = Application("UTM"); + const vm = array(utm.virtualMachines).find(vm => vm.name() == guest_name); + return vm.status(); + } + "#)] + Script { + fn list_guests() -> Vec; + fn start_guest(guest_name: &str); + fn delete_guest(guest_name: &str); + fn guest_status(guest_name: &str) -> String; + } +} + +/// Trigger an automation permission prompt for UTM, on behalf of whatever context the monitor +/// is running in (sshd-keygen-wrapper, Terminal, etc). +/// +/// Panics if UTM is not installed or someone chose to deny permission. +pub fn request_automation_permission() -> eyre::Result<()> { + // Not sure why the osakit crate can’t do this. + let mut child = Command::new("osascript") + .args([ + "-e", + r#"tell application "UTM""#, + "-e", + "set vms to virtual machines", + "-e", + "end tell", + ]) + .spawn()?; + sleep(Duration::from_millis(250)); + let mut warned = false; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if !warned { + warn!("Waiting for permission prompt; please check the macOS UI"); + warned = true; + } + sleep(Duration::from_millis(250)); + }; + if !status.success() { + error!("Failed to acquire automation permission for UTM!"); + error!("Either UTM is not installed, someone chose to deny the permission,"); + error!("or you are running the monitor with sudo (try without sudo)."); + error!("If UTM is installed, try clearing the automation permissions list:"); + // + error!("$ tccutil reset AppleEvents"); + panic!("Failed to acquire permission"); + } + Ok(()) +} + +pub fn list_guests() -> eyre::Result> { + // Output is not filtered by prefix, so we must filter it ourselves. + let prefix = format!("{}-", TOML.libvirt_runner_guest_prefix()); + let result = Script::new()? + .list_guests()? + .into_iter() + .filter(|name| name.starts_with(&prefix)); + + Ok(result.collect()) +} + +pub fn guest_status(guest_name: &str) -> eyre::Result { + Ok(Script::new()?.guest_status(guest_name)?) +} + +pub fn start_guest(guest_name: &str) -> eyre::Result<()> { + Ok(Script::new()?.start_guest(guest_name)?) +} + +pub fn delete_guest(guest_name: &str) -> eyre::Result<()> { + Ok(Script::new()?.delete_guest(guest_name)?) +} + +pub fn clone_guest(original_guest_name: &str, new_guest_name: &str) -> eyre::Result<()> { + declare_script! { + #[language(AppleScript)] + #[source(r#" + on clone_guest(original_guest_name, new_guest_name) + tell application "UTM" + set vm to virtual machine named original_guest_name + duplicate vm with properties {configuration: {name: new_guest_name}} + end tell + end clone_guest + "#)] + Script { + fn clone_guest(original_guest_name: &str, new_guest_name: &str); + } + } + Script::new()?.clone_guest(original_guest_name, new_guest_name)?; + Ok(()) +} + +pub fn rename_guest(old_guest_name: &str, new_guest_name: &str) -> eyre::Result<()> { + declare_script! { + #[language(AppleScript)] + #[source(r#" + on rename_guest(old_guest_name, new_guest_name) + tell application "UTM" + set vm to virtual machine named old_guest_name + set config to configuration of vm + set name of config to new_guest_name + update configuration vm with config + end tell + end rename_guest + "#)] + Script { + fn rename_guest(old_guest_name: &str, new_guest_name: &str); + } + } + Script::new()?.rename_guest(old_guest_name, new_guest_name)?; + Ok(()) +} diff --git a/monitor/hypervisor/src/lib.rs b/monitor/hypervisor/src/lib.rs index aa682198..add17cea 100644 --- a/monitor/hypervisor/src/lib.rs +++ b/monitor/hypervisor/src/lib.rs @@ -1,6 +1,8 @@ pub mod libvirt; +pub mod utm; #[cfg_attr(target_os = "linux", path = "impl_libvirt.rs")] +#[cfg_attr(target_os = "macos", path = "impl_utm.rs")] mod platform; use std::{collections::BTreeSet, net::Ipv4Addr, path::Path, time::Duration}; @@ -8,6 +10,14 @@ use std::{collections::BTreeSet, net::Ipv4Addr, path::Path, time::Duration}; use jane_eyre::eyre; use settings::profile::Profile; +pub fn initialise() -> eyre::Result<()> { + self::platform::initialise() +} + +pub fn handle_main_thread_request() -> eyre::Result<()> { + self::platform::handle_main_thread_request() +} + pub fn list_template_guests() -> eyre::Result> { self::platform::list_template_guests() } diff --git a/monitor/hypervisor/src/utm.rs b/monitor/hypervisor/src/utm.rs new file mode 100644 index 00000000..4463769c --- /dev/null +++ b/monitor/hypervisor/src/utm.rs @@ -0,0 +1,21 @@ +use jane_eyre::eyre; + +#[cfg(target_os = "macos")] +use crate::platform::{UTM_REQUEST, UtmRequest}; + +#[cfg(not(target_os = "macos"))] +#[expect(unused)] +pub fn clone_guest(original_guest_name: &str, new_guest_name: &str) -> eyre::Result<()> { + unimplemented!() +} + +#[cfg(target_os = "macos")] +pub fn clone_guest(original_guest_name: &str, new_guest_name: &str) -> eyre::Result<()> { + let (tx, rx) = crossbeam_channel::bounded(0); + UTM_REQUEST.sender.send(UtmRequest::CloneGuest { + result: tx, + original_guest_name: original_guest_name.to_owned(), + new_guest_name: new_guest_name.to_owned(), + })?; + Ok(rx.recv()??) +} diff --git a/monitor/src/main.rs b/monitor/src/main.rs index dc2a5d3e..3bfcb1c8 100644 --- a/monitor/src/main.rs +++ b/monitor/src/main.rs @@ -13,13 +13,14 @@ use std::{ path::Path, process::exit, sync::{LazyLock, RwLock}, - thread::{self}, + thread, time::Duration, }; use askama::Template; use askama_web::WebTemplate; use crossbeam_channel::{Receiver, Sender}; +use hypervisor::handle_main_thread_request; use hypervisor::list_runner_guests; use hypervisor::start_guest; use jane_eyre::eyre::{self, eyre, Context, OptionExt}; @@ -378,8 +379,7 @@ fn boot_script_route(remote_addr: web::auth::RemoteAddr) -> rocket_eyre::Result< Ok(RawText(result)) } -#[rocket::main] -async fn main() -> eyre::Result<()> { +fn main() -> eyre::Result<()> { if env::var_os("RUST_LOG").is_none() { // EnvFilter Builder::with_default_directive doesn’t support multiple directives, // so we need to apply defaults ourselves. @@ -388,24 +388,38 @@ async fn main() -> eyre::Result<()> { cli::init()?; run_migrations()?; - tokio::task::spawn(async move { - let thread = thread::spawn(monitor_thread); - loop { - if thread.is_finished() { - match thread.join() { - Ok(Ok(())) => { - info!("Monitor thread exited"); - exit(0); - } - Ok(Err(report)) => error!(%report, "Monitor thread error"), - Err(panic) => error!(?panic, "Monitor thread panic"), - }; - exit(1); - } - tokio::time::sleep(Duration::from_secs(1)).await; + let web_server_thread = thread::spawn(web_server_thread); + let monitor_thread = thread::spawn(monitor_thread); + + loop { + if monitor_thread.is_finished() { + match monitor_thread.join() { + Ok(Ok(())) => { + info!("Monitor thread exited"); + exit(0); + } + Ok(Err(report)) => error!(?report, "Monitor thread error"), + Err(panic) => error!(?panic, "Monitor thread panic"), + }; + exit(1); } - }); + if web_server_thread.is_finished() { + match web_server_thread.join() { + Ok(Ok(())) => { + info!("Web server thread exited"); + exit(0); + } + Ok(Err(report)) => error!(?report, "Web server thread error"), + Err(panic) => error!(?panic, "Web server thread panic"), + }; + exit(1); + } + handle_main_thread_request()?; + } +} +#[rocket::main] +async fn web_server_thread() -> eyre::Result<()> { let rocket = |listen_addr: &str| { rocket::custom( rocket::Config::figment() @@ -461,6 +475,8 @@ async fn main() -> eyre::Result<()> { /// It handles one [`Request`] at a time, polling for updated resources before /// each request, then sends one response to the API server for each request. fn monitor_thread() -> eyre::Result<()> { + hypervisor::initialise()?; + let mut id_gen = IdGen::new_load().unwrap_or_else(|error| { warn!(?error, "Failed to read last-runner-id: {error}"); IdGen::new_empty() From be8b5e36878081fdac93fda2673d4dea486672b0 Mon Sep 17 00:00:00 2001 From: Delan Azabani Date: Tue, 11 Nov 2025 14:33:45 +0800 Subject: [PATCH 5/6] monitor: print full error reports thrown by worker threads --- monitor/src/image.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monitor/src/image.rs b/monitor/src/image.rs index 3cdcef59..da0de496 100644 --- a/monitor/src/image.rs +++ b/monitor/src/image.rs @@ -149,7 +149,7 @@ impl Rebuilds { info!("Servo update thread exited"); cached_servo_repo_was_just_updated = true; } - Ok(Err(report)) => error!(%report, "Servo update thread error"), + Ok(Err(report)) => error!(?report, "Servo update thread error"), Err(panic) => error!(?panic, "Servo update thread panic"), }; } else { @@ -229,7 +229,7 @@ impl Rebuilds { info!(profile_key, "Image rebuild thread exited"); policy.set_base_image_snapshot(&profile_key, &rebuild.snapshot_name)?; } - Ok(Err(report)) => error!(profile_key, %report, "Image rebuild thread error"), + Ok(Err(report)) => error!(profile_key, ?report, "Image rebuild thread error"), Err(panic) => error!(profile_key, ?panic, "Image rebuild thread panic"), }; } else { From 05de633679522178ca38785533a3b73adc3f5678 Mon Sep 17 00:00:00 2001 From: Delan Azabani Date: Tue, 25 Nov 2025 21:20:06 +0800 Subject: [PATCH 6/6] servo-macos15-arm: initial --- monitor/src/image.rs | 10 +- monitor/src/image/macos13.rs | 53 ++++++ profiles/servo-macos15-arm/boot-script | 114 +++++++++++++ profiles/servo-macos15-arm/guest.xml | 218 +++++++++++++++++++++++++ 4 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 profiles/servo-macos15-arm/boot-script create mode 100644 profiles/servo-macos15-arm/guest.xml diff --git a/monitor/src/image.rs b/monitor/src/image.rs index da0de496..561dab32 100644 --- a/monitor/src/image.rs +++ b/monitor/src/image.rs @@ -28,7 +28,11 @@ use shell::{log_output_as_info, reflink_or_copy_with_warning}; use tracing::{error, info, warn}; use crate::{ - image::{macos13::Macos13, ubuntu2204::Ubuntu2204, windows10::Windows10}, + image::{ + macos13::{Macos13, MacosUtm}, + ubuntu2204::Ubuntu2204, + windows10::Windows10, + }, policy::Policy, }; @@ -46,6 +50,10 @@ static IMAGES: LazyLock>> = LazyLo "servo-macos15".to_owned(), Box::new(Macos13::new(ByteSize::gib(90), Duration::from_secs(2000))), ); + result.insert( + "servo-macos15-arm".to_owned(), + Box::new(MacosUtm::new(Duration::from_secs(2000))), + ); result.insert( "servo-ubuntu2204".to_owned(), Box::new(Ubuntu2204::new( diff --git a/monitor/src/image/macos13.rs b/monitor/src/image/macos13.rs index 98c4e48b..9eb093cc 100644 --- a/monitor/src/image/macos13.rs +++ b/monitor/src/image/macos13.rs @@ -1,5 +1,6 @@ use std::ffi::OsStr; use std::fs::copy; +use std::fs::create_dir_all; use std::fs::remove_file; use std::path::Path; use std::time::Duration; @@ -74,6 +75,57 @@ impl Image for Macos13 { } } +pub struct MacosUtm { + wait_duration: Duration, +} + +impl MacosUtm { + pub const fn new(wait_duration: Duration) -> Self { + Self { wait_duration } + } +} + +#[expect(unused_variables)] +impl Image for MacosUtm { + fn rebuild(&self, profile: &Profile, snapshot_name: &str) -> eyre::Result<()> { + let profile_name = &profile.profile_name; + let rebuild_guest_name = &profile.rebuild_guest_name(snapshot_name); + hypervisor::utm::clone_guest(&format!("{profile_name}-clean"), rebuild_guest_name)?; + + start_guest(rebuild_guest_name)?; + wait_for_guest(rebuild_guest_name, self.wait_duration)?; + + let template_guest_name = &profile.template_guest_name(snapshot_name); + rename_guest(rebuild_guest_name, template_guest_name)?; + create_dir_all(get_profile_data_path(&profile.profile_name, None)?)?; + let snapshot_symlink_path = + get_profile_data_path(&profile.profile_name, Path::new("snapshot"))?; + atomic_symlink(snapshot_name, snapshot_symlink_path)?; + + Ok(()) + } + fn delete_template(&self, profile: &Profile, snapshot_name: &str) -> eyre::Result<()> { + delete_guest(&profile.template_guest_name(snapshot_name)) + } + fn register_runner(&self, profile: &Profile, runner_guest_name: &str) -> eyre::Result { + register_runner(profile, runner_guest_name) + } + fn create_runner( + &self, + profile: &Profile, + snapshot_name: &str, + runner_guest_name: &str, + runner_id: usize, + ) -> eyre::Result { + let template_guest_name = &profile.template_guest_name(snapshot_name); + hypervisor::utm::clone_guest(template_guest_name, runner_guest_name)?; + Ok(runner_guest_name.to_owned()) + } + fn destroy_runner(&self, runner_guest_name: &str, _runner_id: usize) -> eyre::Result<()> { + delete_guest(runner_guest_name) + } +} + pub(super) fn rebuild( profile: &Profile, snapshot_name: &str, @@ -104,6 +156,7 @@ pub(super) fn rebuild( let template_guest_name = &profile.template_guest_name(snapshot_name); rename_guest(rebuild_guest_name, template_guest_name)?; + create_dir_all(get_profile_data_path(&profile.profile_name, None)?)?; let snapshot_symlink_path = get_profile_data_path(&profile.profile_name, Path::new("snapshot"))?; atomic_symlink(snapshot_name, snapshot_symlink_path)?; diff --git a/profiles/servo-macos15-arm/boot-script b/profiles/servo-macos15-arm/boot-script new file mode 100644 index 00000000..0921e86b --- /dev/null +++ b/profiles/servo-macos15-arm/boot-script @@ -0,0 +1,114 @@ +#!/usr/bin/env zsh +set -euxo pipefail -o bsdecho + +download() { + curl -fsSLO "http://192.168.100.1:8000/image-deps/macos13/$1" +} + +install_github_actions_runner() { + if ! [ -e actions-runner-osx-x64.tar.gz ]; then + download actions-runner-osx-x64.tar.gz + rm -Rf actions-runner + mkdir -p actions-runner + ( cd actions-runner; tar xf ../actions-runner-osx-x64.tar.gz ) + fi +} + +bake_servo_repo() ( + # Note the parentheses around this block, so we only cd for this function + cd ~/a/servo/servo + + # Fix the remote url, since it’s still set to our cache + git remote set-url origin https://github.com/servo/servo.git + + # Install the Rust toolchain, for checkouts without servo#35795 + rustup show active-toolchain || rustup toolchain install + + ./mach bootstrap --force + # Build the same way as a typical macOS libservo job, to allow for incremental builds. + # FIXME: `cargo build -p libservo` is busted on most platforms + # FIXME: `cargo build -p libservo` is untested in CI + # cargo build -p libservo --all-targets --release --target-dir target/libservo + # Build the same way as a typical macOS build job, to allow for incremental builds. + ./mach build --use-crown --locked --release +) + +start_github_actions_runner() { + curl -fsS --max-time 5 --retry 99 --retry-all-errors http://192.168.100.1:8000/github-jitconfig | jq -er . > jitconfig + actions-runner/run.sh --jitconfig $(cat jitconfig) +} + +mkdir -p /Users/servo/ci +cd /Users/servo/ci + +# Resize the window to occupy more of the 1280x800 display +# - Method based on +# - Another method for exclusive fullscreen +# - Another method with unclear automation +osascript -e 'tell application "Terminal"' -e 'activate' -e 'set the bounds of the first window to {0,0,1280,600}' -e 'end tell' + +# Disable sleep and display sleep +# +sudo pmset sleep 0 +sudo pmset displaysleep 0 + +# ~/.cargo/env requires HOME to be set +export HOME=/Users/servo + +# Ensure uv is on PATH +export PATH=$HOME/.local/bin:$PATH + +if ! [ -e image-built ]; then + # Install Xcode CLT (Command Line Tools) non-interactively + # + download install-xcode-clt.sh + chmod +x install-xcode-clt.sh + sudo -i mkdir -p /var/root/utils + sudo -i touch /var/root/utils/utils.sh + sudo -i $PWD/install-xcode-clt.sh + + # Install Homebrew + if ! [ -e /usr/local/bin/brew ]; then + download install-homebrew.sh + chmod +x install-homebrew.sh + NONINTERACTIVE=1 ./install-homebrew.sh + fi + + set -- gnu-tar # Install gtar(1) + set -- "$@" jq # Used by start_github_actions_runner() + + brew install "$@" + + # Install rustup and the latest Rust + if ! [ -e ~/.rustup ]; then + download rustup-init + chmod +x rustup-init + ./rustup-init -y --quiet + mkdir -p ~/.cargo + curl -fsSLo ~/.cargo/config.toml http://192.168.100.1:8000/image-deps/cargo-config.toml + fi + + # Install uv + if ! [ -e ~/.local/bin/uv ]; then + download uv-installer.sh + chmod +x uv-installer.sh + ./uv-installer.sh + fi +fi + +# Set up Cargo +. ~/.cargo/env + +if ! [ -e image-built ]; then + # Clone and bake the Servo repo + mkdir -p ~/a/servo + git clone http://192.168.100.1:8000/cache/servo/.git ~/a/servo/servo + bake_servo_repo + + install_github_actions_runner + touch image-built + sudo shutdown -h now + exit # `shutdown` does not exit +else + start_github_actions_runner +fi diff --git a/profiles/servo-macos15-arm/guest.xml b/profiles/servo-macos15-arm/guest.xml new file mode 100644 index 00000000..2adccb42 --- /dev/null +++ b/profiles/servo-macos15-arm/guest.xml @@ -0,0 +1,218 @@ + + + + + servo-macos15.init + 25cb2b90-081e-44b2-a9ac-5cee8370ae44 + 25165824 + 25165824 + + + + 16 + + hvm + + /var/lib/libvirt/images/OSX-KVM/OVMF_CODE.fd + /var/lib/libvirt/images/OSX-KVM/OVMF_VARS.fd + + + + + + + + + + + destroy + restart + restart + + /run/libvirt/nix-emulators/qemu-system-x86_64 + + + + + +
+ + + + + + + + + +
+ + + + + + +
+ + +
+ + + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + +
+ + +
+ + + +
+ + + +
+ + + +
+ + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +