diff --git a/ci/ios/create-vm/README.md b/ci/ios/create-vm/README.md new file mode 100644 index 000000000000..3699f727672d --- /dev/null +++ b/ci/ios/create-vm/README.md @@ -0,0 +1,66 @@ +# Creating new macOS VMs to build MullvadVPN iOS in a CI environment +This guide assumes you are running on macOS. +## Prerequisites +In order to create VMs on the fly, we decided to use [tart](https://tart.run/) and [packer](https://developer.hashicorp.com/packer). + +The various scripts that run in the VM are written in bash with the help of [shellcheck](shellcheck.net). + +## VM requirements +- You will need at least 60GB of available space on your VM host +- You will need at least 8GB of available RAM on your VM host +- You will need at least 4 CPU cores available on your VM host + +## How to install Tart +- brew install `cirruslabs/cli/tart` + +## How to install Packer +- brew tap `hashicorp/tap` +- brew install `hashicorp/tap/packer` + +## How to install shellcheck +- brew install `shellcheck` + +> [!IMPORTANT] +> # Prerequisite setup before running packer +> - Get a copy of the Xcode version you want to install on the VM in a xip format +> - Copy that file into the folder named `vm_shared_folder` +> - Open the file named `variables.pkrvars.hcl` +> - Edit the variables named `xcode_version` and `xcode_xip_name` + +Here is an example of what to expect +```bash +% ls vm_shared_folder +Xcode_15.0.1.xip +% head -2 variables.pkrvars.hcl +xcode_version = "15.1" +xcode_xip_name = "Xcode_15.1.xip" +``` + +### Sanity checks before running packer +It is a good idea to keep logs, the `logs` folder is provided to that effect. +Enable packer logs by setting the following environment variables (assuming your are running with `zsh`) +- export `PACKER_LOG=1` +- export `PACKER_LOG_PATH="logs/packer_logs.txt"` + +> [!NOTE] +> The logs will be overwritten with each packer command you issue. + +You can then check that the templates are valid before running `packer` +- packer inspect `-var-file="variables.pkrvars.hcl" install-build-dependencies.pkr.hcl` +- packer validate `-var-file="variables.pkrvars.hcl" install-build-dependencies.pkr.hcl` + +You can make sure you are not missing any dependencies with the `init` command +- packer init `install-vanilla-ventura.pkr.hcl` + +### Create the VM image via Packer +Once your setup is ready, you just need one command to create a VM. And one more to install Xcode on it. +- packer build `-var-file="variables.pkrvars.hcl" install-vanilla-ventura.pkr.hcl` + +### Install Xcode on the VM image via Packer +- packer build `-var-file="variables.pkrvars.hcl" install-build-dependencies.pkr.hcl` + +> [!IMPORTANT] +> At the time of writing this, `tart` does not support VM snapshotting. This means that any action taken by packer will be **permanent** on the VM. + +Make sure to properly clean up the VM before running packer commands again if something went wrong. +You can look at the `cleanup.sh` script in the `scripts` folder to see what type of cleanup is ran in case things go wrong. \ No newline at end of file diff --git a/ci/ios/create-vm/install-build-dependencies.pkr.hcl b/ci/ios/create-vm/install-build-dependencies.pkr.hcl new file mode 100644 index 000000000000..deeacf609f23 --- /dev/null +++ b/ci/ios/create-vm/install-build-dependencies.pkr.hcl @@ -0,0 +1,113 @@ +packer { + required_plugins { + tart = { + version = ">= 1.2.0" + source = "github.com/cirruslabs/tart" + } + } +} + +variable "shared_folder_path" { type = string } + +variable "xcode_version" { + type = string + + validation { + condition = can(regex("(\\d)+(\\.)?((\\d)+)?(\\.)?((\\d)+)?", var.xcode_version)) + error_message = "Invalid Xcode version number. Example of a valid number: '15.0.1'." + } +} + +variable "vm_name" { type = string } + +variable "user_name" { type = string } + +variable "xcode_xip_name" { + type = string + + validation { + condition = can(regex("Xcode_(\\d)+(\\.)?((\\d)+)?(\\.)?((\\d)+)?\\.xip", var.xcode_xip_name)) + error_message = "Invalid Xcode file name. Example of a valid file name: 'Xcode_15.0.1.xip'." + } +} + +source "tart-cli" "tart" { + vm_name = "${var.vm_name}" + ssh_password = "admin" + ssh_username = "admin" + ssh_timeout = "120s" + disk_size_gb = 80 +} + +build { + sources = ["source.tart-cli.tart"] + + + // Create a symlink for bash compatibility + provisioner "shell" { + script = "scripts/link-zprofile.sh" + } + + // Install brew + provisioner "shell" { + environment_vars = [ + "USER=${var.user_name}" + ] + script = "scripts/install-brew.sh" + } + + + // Install required brew dependencies + provisioner "shell" { + script = "scripts/install-brew-dependencies.sh" + } + + // Install rustup + provisioner "shell" { + script = "scripts/install-rustup.sh" + } + + // Install go + provisioner "shell" { + script = "scripts/install-go.sh" + } + + // Copy the local Xcode xip file to the VM + provisioner "file" { + source = "${var.shared_folder_path}/${var.xcode_xip_name}" + destination = "/tmp/${var.xcode_xip_name}" + } + + // Install Xcode via xcodes.app + provisioner "shell" { + + environment_vars = [ + "XCODE_VERSION=${var.xcode_version}", + "XCODE_XIP_NAME=${var.xcode_xip_name}", + "XCODE_SHARED_PATH=/tmp", + ] + script = "scripts/install-xcode.sh" + } + + // Delete the Xcode xip file to save some space + provisioner "shell" { + inline = [ + "rm -f /tmp/${var.xcode_xip_name}" + ] + } + + // Run the xcodebuild first launch prompt to automatically accept terms and conditions, and download the iOS runtime simulator + provisioner "shell" { + script = "scripts/run-xcode-first-launch.sh" + } + + // Add Apple root certs + provisioner "shell" { + script = "scripts/add-apple-certs.sh" + } + + // Remove everything in case of error + error-cleanup-provisioner "shell" { + script = "scripts/cleanup.sh" + } +} diff --git a/ci/ios/create-vm/install-vanilla-ventura.pkr.hcl b/ci/ios/create-vm/install-vanilla-ventura.pkr.hcl new file mode 100644 index 000000000000..9629a4645e92 --- /dev/null +++ b/ci/ios/create-vm/install-vanilla-ventura.pkr.hcl @@ -0,0 +1,129 @@ +packer { + required_plugins { + tart = { + version = ">= 1.2.0" + source = "github.com/cirruslabs/tart" + } + } +} + +variable "vm_name" { type = string } + +source "tart-cli" "tart" { + # You can find macOS IPSW URLs on various websites like https://ipsw.me/ + # and https://www.theiphonewiki.com/wiki/Beta_Firmware/Mac/13.x + from_ipsw = "https://updates.cdn-apple.com/2023SummerFCS/fullrestores/042-43686/945D434B-DA5D-48DB-A558-F6D18D11AD69/UniversalMac_13.5.2_22G91_Restore.ipsw" + vm_name = "${var.vm_name}" + cpu_count = 4 + memory_gb = 8 + disk_size_gb = 60 + ssh_password = "admin" + ssh_username = "admin" + ssh_timeout = "120s" + boot_command = [ + # hello, hola, bonjour, etc. + "", + # Language: most of the times we have a list of "English"[1], "English (UK)", etc. with + # "English" language already selected. If we type "english", it'll cause us to switch + # to the "English (UK)", which is not what we want. To solve this, we switch to some other + # language first, e.g. "Italiano" and then switch back to "English". We'll then jump to the + # first entry in a list of "english"-prefixed items, which will be "English". + # + # [1]: should be named "English (US)", but oh well 🤷 + "italianoenglish", + # Select Your Country and Region + "united states", + # Written and Spoken Languages + "", + # Accessibility + "", + # Data & Privacy + "", + # Migration Assistant + "", + # Sign In with Your Apple ID + "", + # Are you sure you want to skip signing in with an Apple ID? + "", + # Terms and Conditions + "", + # I have read and agree to the macOS Software License Agreement + "", + # Create a Computer Account + "adminadminadmin", + # Enable Location Services + "", + # Are you sure you don't want to use Location Services? + "", + # Select Your Time Zone + "UTC", + # Analytics + "", + # Screen Time + "", + # Siri + "", + # Choose Your Look + "", + # Enable Voice Over + "", + # Now that the installation is done, open "System Settings" + "System Settings", + # Navigate to "Sharing" + "fscreen sharing", + # Navigate to "Screen Sharing" and enable it + "", + # Navigate to "Remote Login" and enable it + "", + # Open "Remote Login" details + "", + # Enable "Full Disk Access" + "", + # Click "Done" + "", + # Disable Voice Over + "", + ] + + // A (hopefully) temporary workaround for Virtualization.Framework's + // installation process not fully finishing in a timely manner + create_grace_time = "30s" +} + +build { + sources = ["source.tart-cli.tart"] + + provisioner "shell" { + inline = [ + // Enable passwordless sudo + "echo admin | sudo -S sh -c \"mkdir -p /etc/sudoers.d/; echo 'admin ALL=(ALL) NOPASSWD: ALL' | EDITOR=tee visudo /etc/sudoers.d/admin-nopasswd\"", + // Enable auto-login + // + // See https://github.com/xfreebird/kcpassword for details. + "echo '00000000: 1ced 3f4a bcbc ba2c caca 4e82' | sudo xxd -r - /etc/kcpassword", + "sudo defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser admin", + // Disable screensaver at login screen + "sudo defaults write /Library/Preferences/com.apple.screensaver loginWindowIdleTime 0", + // Disable screensaver for admin user + "defaults -currentHost write com.apple.screensaver idleTime 0", + // Prevent the VM from sleeping + "sudo systemsetup -setdisplaysleep Off", + "sudo systemsetup -setsleep Off", + "sudo systemsetup -setcomputersleep Off", + // Launch Safari to populate the defaults + "/Applications/Safari.app/Contents/MacOS/Safari &", + "sleep 30", + "kill -9 %1", + // Enable Safari's remote automation and "Develop" menu + "sudo safaridriver --enable", + "defaults write com.apple.Safari.SandboxBroker ShowDevelopMenu -bool true", + "defaults write com.apple.Safari IncludeDevelopMenu -bool true", + // Disable screen lock + // + // Note that this only works if the user is logged-in, + // i.e. not on login screen. + "sysadminctl -screenLock off -password admin", + "defaults -currentHost write com.apple.screensaver idleTime 0" + ] + } +} diff --git a/ci/ios/create-vm/scripts/add-apple-certs.sh b/ci/ios/create-vm/scripts/add-apple-certs.sh new file mode 100644 index 000000000000..be56458eb890 --- /dev/null +++ b/ci/ios/create-vm/scripts/add-apple-certs.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# inspired by https://github.com/actions/runner-images/blob/fb3b6fd69957772c1596848e2daaec69eabca1bb/images/macos/provision/configuration/configure-machine.sh#L33-L61 + +sudo security delete-certificate -Z FF6797793A3CD798DC5B2ABEF56F73EDC9F83A64 /Library/Keychains/System.keychain + +curl -o AppleWWDRCAG3.cer https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer +curl -o DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer +sudo security add-certificates AppleWWDRCAG3.cer +sudo security add-certificates DeveloperIDG2CA.cer diff --git a/ci/ios/create-vm/scripts/cleanup.sh b/ci/ios/create-vm/scripts/cleanup.sh new file mode 100644 index 000000000000..159d2e8e39e0 --- /dev/null +++ b/ci/ios/create-vm/scripts/cleanup.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -euo pipefail + +# shellcheck source=/dev/null +source ~/.bash_profile + + +# Uninstall rust +# shellcheck source=/dev/null +if [[ -f "${HOME}/.cargo/env" ]] +then + source "${HOME}/.cargo/env" + yes | rustup self uninstall +fi + +# Uninstall brew (This should also delete all dependencies) +NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)" +# Clean up folders that were not automatically removed +sudo rm -rf /opt/homebrew + +# Remove the custom profiles +rm -f ~/.zprofile ~/.profile ~/.bash_profile \ No newline at end of file diff --git a/ci/ios/create-vm/scripts/install-brew-dependencies.sh b/ci/ios/create-vm/scripts/install-brew-dependencies.sh new file mode 100644 index 000000000000..abae2923b841 --- /dev/null +++ b/ci/ios/create-vm/scripts/install-brew-dependencies.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail + +# shellcheck source=/dev/null +source ~/.bash_profile + +if command -v brew &>/dev/null +then + echo "Installing xcodes" + brew install xcodesorg/made/xcodes + echo "Installing xcodes" + brew install bash +fi diff --git a/ci/ios/create-vm/scripts/install-brew.sh b/ci/ios/create-vm/scripts/install-brew.sh new file mode 100644 index 000000000000..130d0576da74 --- /dev/null +++ b/ci/ios/create-vm/scripts/install-brew.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -euo pipefail + +if command -v brew &>/dev/null +then + echo >&1 "brew is already installed, nothing to do here" + exit 0 +fi + +echo >&1 "installing brew" +NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +# This is intentionally in single quotes for echo to append properly +# shellcheck disable=SC2016 +echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.bash_profile +eval "$(/opt/homebrew/bin/brew shellenv)" + +# shellcheck source=/dev/null +source ~/.bash_profile +brew update diff --git a/ci/ios/create-vm/scripts/install-go.sh b/ci/ios/create-vm/scripts/install-go.sh new file mode 100644 index 000000000000..9576dc257620 --- /dev/null +++ b/ci/ios/create-vm/scripts/install-go.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -euo pipefail + +# shellcheck source=/dev/null +source ~/.bash_profile + +if command -v brew &>/dev/null +then + echo >&1 "Installing go@1.20" + brew install go@1.20 + echo "export PATH='/opt/homebrew/opt/go@1.20/bin:$PATH'" >> ~/.bash_profile +fi \ No newline at end of file diff --git a/ci/ios/create-vm/scripts/install-rustup.sh b/ci/ios/create-vm/scripts/install-rustup.sh new file mode 100644 index 000000000000..e8f6a9f941b0 --- /dev/null +++ b/ci/ios/create-vm/scripts/install-rustup.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +# shellcheck source=/dev/null +source ~/.bash_profile + +if ! command -v rustup &>/dev/null +then + echo >&1 "Installing rustup" + # Install rustup and automatically accept the prompt + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | bash -s -- -y + + # shellcheck source=/dev/null + source "${HOME}/.cargo/env" + echo "source ~/.cargo/env" >> ~/.bash_profile + + echo >&1 "Installing rustup targets" + rustup target add aarch64-apple-ios-sim aarch64-apple-ios +fi \ No newline at end of file diff --git a/ci/ios/create-vm/scripts/install-xcode.sh b/ci/ios/create-vm/scripts/install-xcode.sh new file mode 100644 index 000000000000..26102f532804 --- /dev/null +++ b/ci/ios/create-vm/scripts/install-xcode.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail + +# shellcheck source=/dev/null +source ~/.bash_profile + +echo >&1 "Installing xcode" +xcodes install "${XCODE_VERSION}" --path "${XCODE_SHARED_PATH}/${XCODE_XIP_NAME}" --experimental-unxip +xcodes select "${XCODE_VERSION}" diff --git a/ci/ios/create-vm/scripts/link-zprofile.sh b/ci/ios/create-vm/scripts/link-zprofile.sh new file mode 100644 index 000000000000..af085137ab90 --- /dev/null +++ b/ci/ios/create-vm/scripts/link-zprofile.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -eu + +# The profile link already exists, skip this step +if [[ -f "$HOME/.profile" ]] +then + exit 0 +fi + +touch ~/.zprofile +ln -s ~/.zprofile ~/.profile \ No newline at end of file diff --git a/ci/ios/create-vm/scripts/run-xcode-first-launch.sh b/ci/ios/create-vm/scripts/run-xcode-first-launch.sh new file mode 100644 index 000000000000..87c50603cfd4 --- /dev/null +++ b/ci/ios/create-vm/scripts/run-xcode-first-launch.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -euo pipefail + +# shellcheck source=/dev/null +source ~/.bash_profile + +if command -v xcodebuild &>/dev/null +then + echo >&1 "Running xcodebuild -runFirstLaunch" + xcodebuild -runFirstLaunch + echo >&1 "Downloading iOS Simulator runtime, this might take a while" + xcodebuild -downloadPlatform iOS +fi + +# Xcode is needed in order to run swiftformat or swiftlint +brew install swiftformat swiftlint \ No newline at end of file diff --git a/ci/ios/create-vm/variables.pkrvars.hcl b/ci/ios/create-vm/variables.pkrvars.hcl new file mode 100644 index 000000000000..19d5e50eafb8 --- /dev/null +++ b/ci/ios/create-vm/variables.pkrvars.hcl @@ -0,0 +1,5 @@ +xcode_version = "15.0.1" +xcode_xip_name = "Xcode_15.0.1.xip" +vm_name = "app-build" +user_name = "admin" +shared_folder_path = "vm_shared_folder"