Provisioning on-demand macOS virtual machines with Jenkins and Anka Build for iOS CI

Guest Blog Post By Peter Wiesner, Senior Software Engineer @Skyscanner

Every year Apple releases a new version of Xcode. CI systems for iOS application development need to adopt it, so developers can take advantage of the new iOS features. CI systems using Anka Build have a head-start here. For folks not familiar with Anka Build, read more details here.

You only need to create a new tag for the current Anka macOS virtual machine with the new Xcode installed. Anka ships with features helping to automate this process.

In this blog post, I will show you how to do this. We will use the following solutions:
  • anka create to create the macOS virtual machine from a script
  • Jenkins pipeline to run the jobs on demand
  • anka run to execute batch commands on the virtual machines
About the prerequisites:
  • Jenkins with pipeline plugin installed
  • a mac with anka-create label connected to Jenkins. We will use this native node to create and provision the virtual machine
  • Anka Build package version 1.4 current release installed on the mac
Jenkins pipelineLet’s create a Jenkins pipeline project with following script. The key tricks are:
  • use Jenkins parameters to provide configuration (Xcode version to install)
  • use a separate repo for the actual provisioning files
  • always save the state of the VM on pipeline failure so next iteration is faster
pipeline {

    agent none

    options {

        timeout(time: 240, unit: 'MINUTES')

    }

    environment {

        // Provide credentials here (anka username/pwd, keychain passwords,
        credentials
        for tool packages etc.)

}

parameters {

    //Provide mutable configuration for scripts (anka VM name, anka
    tag,
    Xcode version)

string(name: 'ANKA_VM_NAME', defaultValue: 'anka_VM_node',
    description: 'Name of the Anka VM')

//...
}

stages {

    stage("anka-create") {

        agent {

            node {

                // There is a native mac connected to Jenkins with this
                label
                label 'anka-create'

            }

        }
        steps {

            create_task()

        }

        post {

            always {

                // Create a vm that stores the state of the VM on exiting. When fixing
                failures,
                this can speed up the process a lot

                sh ""
                "#!/bin/bash

                if [\$(anka list | grep current_state_vm | wc - l) == 1]

                then

                anka delete--yes current_state_vm

                fi

                anka stop\ $ANKA_VM_NAME

                anka clone - c\ $ANKA_VM_NAME current_state_vm

                if [\$(anka list | grep\ $ANKA_VM_NAME | wc - l) == 1]

                then

                echo "Deleting Anka VM: \$ANKA_VM_NAME"

                anka delete--yes\ $ANKA_VM_NAME

                fi

                ""
                "

                cleanWs()

            }

        }
    }
}
}

def checkout_provisioning_scripts() {

    // It is useful to decouple the actual provisioning script from this
    pipeline script

    // Easy to do updates and helps reading the pipeline script

    // Clone the provisioning script from a GitHub repo

    timeout(15) {

        checkout([$class: 'GitSCM', branches: [
                [name: 'master']
            ], ,

            extensions: [
                [$class: 'CloneOption', depth: 1, noTags: false,
                    shallow: true, timeout: 30
                ]
            ],
            userRemoteConfigs: [
                [name: 'origin', refspec:
                    '+refs/heads/*:refs/remotes/origin/*', url: 'SSH_GIT_URL'
                ]
            ]
        ])
    }

}

def create_task() {

    cleanWs()

    credentials.withAllCredentials {

        checkout_provisioning_scripts()

        // Once we cloned the repository, print the environment variables
        to see
        if job parameters and variables are correctly passed to this task

        // provision.sh comes from the repo

        sh ""
        "#!/bin/bash

        printenv

        sh. / provision.sh

        ""
        "

    }
}


Creating the VM

The Jenkins pipeline calls out to a provision script. This is responsible to utilize anka for provisioning. The key tricks are:
  • collecting all necessary packages beforehand
  • allowing the script to use previously saved virtual machine as starting point (`anka create` can take up to 20 mins)
#!/bin/bash

# Let's fail on error

set -e

# Retrieve macOS installer and necessary packages

# It's beneficial to store them on a NAS that can be connected to the mac
node

# We can start the process with previous state of vm if we would like to

# This saves a lot of time

if [ "$START_FROM_BEGINNING" -eq 0 ]

then

#Create VM from the Installer on NAS

ANKA_DEFAULT_USER=$USER_NAME anka --debug create --ram-size $VM_RAM
--cpu-count $VM_CPU --disk-size $VM_DISK --app "$PATH_TO_OS_INSTALLER"
$ANKA_VM_NAME

anka modify $ANKA_VM_NAME add port-forwarding --host-port 0 --guest-port
22 --protocol tcp ssh

else

#Clone VM

anka clone current_state_vm $ANKA_VM_NAME

fi

# Iterate on the setup/install scripts

for file in $files_to_execute

do

# Run script as root

anka run --env $ANKA_VM_NAME sudo -E bash $file

# Run script as created anka user

#anka run --env $ANKA_VM_NAME sudo -E -H -u $USER_NAME bash $file

done

# Stop the VM after we are done, start it up and suspend it, so CI can use
the super fast start-up feature of Anka VMs.

anka stop -f $ANKA_VM_NAME

anka run $ANKA_VM_NAME ls

anka suspend $ANKA_VM_NAME

# Save the new baseline virtual machine

anka registry -a $REGISTRY_IP_ADDRESS push $ANKA_VM_NAME -t $ANKA_TAG
Installing necessary tools

The actual provisioning happens in the `for loop`, where we use `anka run`. These scripts install the tools like Xcode, Xcode CLI, Ruby, git or any packages. The content and order of the scripts are company/case specific, but let me list some general tricks. Keep in mind, when running the `anka run` command, the current working directory will be mounted to the VM and the files can be accessed with a relative path (sweet!).

Installing certificates

$P12_PATH contains the path to the exported p12 files location.
echo $USER_PWD | sudo -S security import $P12_PATH -k
"/Library/Keychains/System.keychain" -P "$P12_PASSWORD" -A
Installing Xcode CLI

$XCODE_CLI_PATH contains the path to the Xcode Command Line Tools package location.
MOUNTDIR=$(echo `hdiutil mount $XCODE_CLI_PATH | tail -1 | awk '{$1=$2="";
print $0}'` | xargs -0 echo)

echo $USER_PWD | sudo -S installer -pkg "${MOUNTDIR}/"*.pkg -target /
hdiutil unmount "${MOUNTDIR}"
Installing Xcode

You need to install the xcode-install gem for this. $XCODE_APP_VERSION contains the version number of the Xcode you want to install (like 10.0) $XCODE_REMOTE_URL is an URL from where the Xcode xip can be downloaded (it is worth to download it and upload to a private remote server to have bigger download speed).
xcversion install $XCODE_APP_VERSION --url="$XCODE_REMOTE_URL" --verbose