diff --git a/contrib/providers.d/azure/cloudconfig/install_runner.tpl b/contrib/providers.d/azure/cloudconfig/install_runner.tpl new file mode 100644 index 00000000..0453fe8c --- /dev/null +++ b/contrib/providers.d/azure/cloudconfig/install_runner.tpl @@ -0,0 +1,61 @@ +#!/bin/bash + +set -ex +set -o pipefail + +CALLBACK_URL="GARM_CALLBACK_URL" +BEARER_TOKEN="GARM_CALLBACK_TOKEN" +DOWNLOAD_URL="GH_DOWNLOAD_URL" +FILENAME="GH_FILENAME" +TARGET_URL="GH_TARGET_URL" +RUNNER_TOKEN="GH_RUNNER_TOKEN" +RUNNER_NAME="GH_RUNNER_NAME" +RUNNER_LABELS="GH_RUNNER_LABELS" + +function call() { + PAYLOAD="$1" + curl -s -X POST -d "${PAYLOAD}" -H 'Accept: application/json' -H "Authorization: Bearer ${BEARER_TOKEN}" "${CALLBACK_URL}" || echo "failed to call home: exit code ($?)" +} + +function sendStatus() { + MSG="$1" + call "{\"status\": \"installing\", \"message\": \"$MSG\"}" +} + +function success() { + MSG="$1" + call "{\"status\": \"idle\", \"message\": \"$MSG\"}" +} + +function fail() { + MSG="$1" + call "{\"status\": \"failed\", \"message\": \"$MSG\"}" + exit 1 +} + + + +sendStatus "downloading tools from ${DOWNLOAD_URL}" +curl -L -o "/home/runner/${FILENAME}" "${DOWNLOAD_URL}" || fail "failed to download tools" + +mkdir -p /home/runner/actions-runner || fail "failed to create actions-runner folder" + +sendStatus "extracting runner" +tar xf "/home/runner/${FILENAME}" -C /home/runner/actions-runner/ || fail "failed to extract runner" +chown runner:runner -R /home/runner/actions-runner/ || fail "failed to change owner" + +sendStatus "installing dependencies" +cd /home/runner/actions-runner +sudo ./bin/installdependencies.sh || fail "failed to install dependencies" + +sendStatus "configuring runner" +sudo -u runner -- ./config.sh --unattended --url "${TARGET_URL}" --token "${RUNNER_TOKEN}" --name "${RUNNER_NAME}" --labels "${RUNNER_LABELS}" --ephemeral || fail "failed to configure runner" + +sendStatus "installing runner service" +./svc.sh install runner || fail "failed to install service" + +sendStatus "starting service" +./svc.sh start || fail "failed to start service" + +success "runner successfully installed" + diff --git a/contrib/providers.d/azure/cloudconfig/userdata.tpl b/contrib/providers.d/azure/cloudconfig/userdata.tpl new file mode 100644 index 00000000..cb9fe6ad --- /dev/null +++ b/contrib/providers.d/azure/cloudconfig/userdata.tpl @@ -0,0 +1,29 @@ +#cloud-config +package_upgrade: true +packages: + - curl + - tar +system_info: + default_user: + name: runner + home: /home/runner + shell: /bin/bash + groups: + - sudo + - adm + - cdrom + - dialout + - dip + - video + - plugdev + - netdev + sudo: ALL=(ALL) NOPASSWD:ALL +runcmd: + - /install_runner.sh + - rm -f /install_runner.sh +write_files: + - encoding: b64 + content: RUNNER_INSTALL_B64 + owner: root:root + path: /install_runner.sh + permissions: "755" diff --git a/contrib/providers.d/azure/config.sh b/contrib/providers.d/azure/config.sh new file mode 100644 index 00000000..f99f42ac --- /dev/null +++ b/contrib/providers.d/azure/config.sh @@ -0,0 +1,8 @@ +# Azure service principal credentials +export AZURE_SUBSCRIPTION_ID="" +export AZURE_TENANT_ID="" +export AZURE_CLIENT_ID="" +export AZURE_CLIENT_SECRET="" + +# GARM config +export LOCATION="westeurope" diff --git a/contrib/providers.d/azure/garm-external-provider b/contrib/providers.d/azure/garm-external-provider new file mode 100755 index 00000000..98d6435a --- /dev/null +++ b/contrib/providers.d/azure/garm-external-provider @@ -0,0 +1,332 @@ +#!/bin/bash + +set -e +set -o pipefail + +if [ ! -t 0 ] +then + INPUT=$(cat -) +fi +MYPATH=$(realpath ${BASH_SOURCE[0]}) +MYDIR=$(dirname "${MYPATH}") +TEMPLATES="$MYDIR/cloudconfig" + +# Defaults +LOCATION=${LOCATION:"westeurope"} + +# END Defaults + +if [ -z "$GARM_PROVIDER_CONFIG_FILE" ] +then + echo "no config file specified in env" + exit 1 +fi + +source "$GARM_PROVIDER_CONFIG_FILE" + +declare -A GARM_TO_GH_ARCH_MAP +GARM_TO_GH_ARCH_MAP["amd64"]="x64" +GARM_TO_GH_ARCH_MAP["arm"]="arm" +GARM_TO_GH_ARCH_MAP["arm64"]="arm64" + +declare -A AZURE_OS_TO_GH_OS_MAP +AZURE_OS_TO_GH_OS_MAP["Linux"]="linux" +AZURE_OS_TO_GH_OS_MAP["Windows"]="win" + +# https://docs.microsoft.com/en-us/azure/virtual-machines/states-billing#power-states-and-billing +declare -A AZURE_POWER_STATE_MAP +AZURE_POWER_STATE_MAP["VM starting"]="pending_create" +AZURE_POWER_STATE_MAP["VM running"]="running" +AZURE_POWER_STATE_MAP["VM stopping"]="stopped" +AZURE_POWER_STATE_MAP["VM stopped"]="stopped" +AZURE_POWER_STATE_MAP["VM deallocating"]="stopped" +AZURE_POWER_STATE_MAP["VM deallocated"]="stopped" + +# https://docs.microsoft.com/en-us/azure/virtual-machines/states-billing#provisioning-states +declare -A AZURE_PROVISION_STATE_MAP +AZURE_PROVISION_STATE_MAP["Creating"]="pending_create" +AZURE_PROVISION_STATE_MAP["Updating"]="pending_create" +AZURE_PROVISION_STATE_MAP["Migrating"]="pending_create" +AZURE_PROVISION_STATE_MAP["Failed"]="error" +AZURE_PROVISION_STATE_MAP["Succeeded"]="running" +AZURE_PROVISION_STATE_MAP["Deleting"]="pending_delete" + +function checkValNotNull() { + if [ -z "$1" -o "$1" == "null" ]; then + echo "failed to fetch value $2" + return 1 + fi + return 0 +} + +function requestedArch() { + ARCH=$(echo "$INPUT" | jq -c -r '.arch') + checkValNotNull "${ARCH}" "arch" || return $? + echo "${ARCH}" +} + +function downloadURL() { + [ -z "$1" -o -z "$2" ] && return 1 + GH_OS="${AZURE_OS_TO_GH_OS_MAP[$1]}" + GH_ARCH="${GARM_TO_GH_ARCH_MAP[$2]}" + URL=$(echo "$INPUT" | jq -c -r --arg OS "$GH_OS" --arg ARCH "$GH_ARCH" '(.tools[] | select( .os == $OS and .architecture == $ARCH)).download_url') + checkValNotNull "${URL}" "download URL" || return $? + echo "${URL}" +} + +function downloadFilename() { + [ -z "$1" -o -z "$2" ] && return 1 + GH_OS="${AZURE_OS_TO_GH_OS_MAP[$1]}" + GH_ARCH="${GARM_TO_GH_ARCH_MAP[$2]}" + FN=$(echo "$INPUT" | jq -c -r --arg OS "$GH_OS" --arg ARCH "$GH_ARCH" '(.tools[] | select( .os == $OS and .architecture == $ARCH)).filename') + checkValNotNull "${FN}" "download filename" || return $? + echo "${FN}" +} + +function poolID() { + POOL_ID=$(echo "$INPUT" | jq -c -r '.pool_id') + checkValNotNull "${POOL_ID}" "pool_id" || return $? + echo "${POOL_ID}" +} + +function vmSize() { + VM_SIZE=$(echo "$INPUT" | jq -c -r '.flavor') + checkValNotNull "${VM_SIZE}" "flavor" || return $? + echo "${VM_SIZE}" +} + +function imageUrn() { + IMG=$(echo "$INPUT" | jq -c -r '.image') + checkValNotNull "${IMG}" "image" || return $? + echo "${IMG}" +} + +function getOSImageDetails() { + IMAGE=$(echo "$INPUT" | jq -r -c '.image') + IMAGE_DETAILS=$(az vm image show --urn "$IMAGE" -o json --only-show-errors) + echo "$IMAGE_DETAILS" +} + +function repoURL() { + REPO=$(echo "$INPUT" | jq -c -r '.repo_url') + checkValNotNull "${REPO}" "repo_url" || return $? + echo "${REPO}" +} + +function ghAccessToken() { + TOKEN=$(echo "$INPUT" | jq -c -r '.github_runner_access_token') + checkValNotNull "${TOKEN}" "github_runner_access_token" || return $? + echo "${TOKEN}" +} + +function callbackURL() { + CB_URL=$(echo "$INPUT" | jq -c -r '."callback-url"') + checkValNotNull "${CB_URL}" "callback-url" || return $? + echo "${CB_URL}" +} + +function callbackToken() { + CB_TK=$(echo "$INPUT" | jq -c -r '."instance-token"') + checkValNotNull "${CB_TK}" "instance-token" || return $? + echo "${CB_TK}" +} + +function instanceName() { + NAME=$(echo "$INPUT" | jq -c -r '.name') + checkValNotNull "${NAME}" "name" || return $? + echo "${NAME}" +} + +function labels() { + LBL=$(echo "$INPUT" | jq -c -r '.labels | join(",")') + checkValNotNull "${LBL}" "labels" || return $? + echo "${LBL}" +} + +function vmStatus() { + [ -z "$1" -o -z "$2" ] && return 1 + + RG_DETAILS=$(az group show -n "$1" -o json --only-show-errors) + RG_STATE=$(echo "$RG_DETAILS" | jq -r '.properties.provisioningState') + STATUS="${AZURE_PROVISION_STATE_MAP[$RG_STATE]}" + if [[ "$STATUS" != "running" ]]; then + echo "$STATUS" + return 0 + fi + VM_DETAILS=$(az vm show -g "$1" -n "$2" --show-details -o json --only-show-errors) + VM_STATE=$(echo "$VM_DETAILS" | jq -r '.provisioningState') + STATUS="${AZURE_PROVISION_STATE_MAP[$VM_STATE]}" + if [[ "$STATUS" != "running" ]]; then + echo "$STATUS" + return 0 + fi + VM_POWER_STATE=$(echo "$VM_DETAILS" | jq -r '.powerState') + VM_STATUS="${AZURE_POWER_STATE_MAP[$VM_POWER_STATE]}" + if [[ -z "${VM_STATUS}" ]]; then + echo "unknown" + return 0 + fi + echo "${VM_STATUS}" +} + +function getCloudConfig() { + IMAGE_DETAILS=$(getOSImageDetails) + + OS_TYPE=$(echo "${IMAGE_DETAILS}" | jq -c -r '.osDiskImage.operatingSystem') + checkValNotNull "${OS_TYPE}" "operatingSystem" || return $? + + ARCH=$(requestedArch) + DW_URL=$(downloadURL "${OS_TYPE}" "${ARCH}") + DW_FILENAME=$(downloadFilename "${OS_TYPE}" "${ARCH}") + LABELS=$(labels) + + TMP_SCRIPT=$(mktemp) + TMP_CC=$(mktemp) + + INSTALL_TPL=$(cat "${TEMPLATES}/install_runner.tpl") + CC_TPL=$(cat "${TEMPLATES}/userdata.tpl") + echo "$INSTALL_TPL" | sed -e "s|GARM_CALLBACK_URL|$(callbackURL)|g" \ + -e "s|GARM_CALLBACK_TOKEN|$(callbackToken)|g" \ + -e "s|GH_DOWNLOAD_URL|${DW_URL}|g" \ + -e "s|GH_FILENAME|${DW_FILENAME}|g" \ + -e "s|GH_TARGET_URL|$(repoURL)|g" \ + -e "s|GH_RUNNER_TOKEN|$(ghAccessToken)|g" \ + -e "s|GH_RUNNER_NAME|$(instanceName)|g" \ + -e "s|GH_RUNNER_LABELS|${LABELS}|g" > ${TMP_SCRIPT} + + AS_B64=$(base64 -w0 ${TMP_SCRIPT}) + echo "${CC_TPL}" | sed "s|RUNNER_INSTALL_B64|${AS_B64}|g" > ${TMP_CC} + echo "${TMP_CC}" +} + +function CreateInstance() { + if [ -z "$INPUT" ]; then + echo "expected build params in stdin" + exit 1 + fi + + CC_FILE=$(getCloudConfig) + VM_SIZE=$(vmSize) + INSTANCE_NAME=$(instanceName) + IMAGE_URN=$(imageUrn) + IMAGE_DETAILS=$(getOSImageDetails) + + OS_TYPE=$(echo "${IMAGE_DETAILS}" | jq -c -r '.osDiskImage.operatingSystem' | tr '[:upper:]' '[:lower:]') + checkValNotNull "${OS_TYPE}" "os_type" || return $? + OS_NAME=$(echo "${IMAGE_URN}" | cut -d ':' -f2) + OS_VERSION=$(echo "${IMAGE_URN}" | cut -d ':' -f3) + ARCH="amd64" + + TAGS="garm-controller-id=${GARM_CONTROLLER_ID} garm-pool-id=${GARM_POOL_ID}" + + set +e + + az group create -n $INSTANCE_NAME -l $LOCATION --tags $TAGS --only-show-errors -o none + az vm create -g $INSTANCE_NAME -n $INSTANCE_NAME -l $LOCATION --size $VM_SIZE --image $IMAGE_URN --tags $TAGS --nsg-rule none --public-ip-address "" --user-data "${CC_FILE}" -o none --only-show-errors + if [[ $? -ne 0 ]]; then + az group delete -n $INSTANCE_NAME --no-wait --y -o none --only-show-errors + echo "Failed to create Azure VM" + exit 1 + fi + rm -f "${CC_FILE}" + + set -e + + STATUS=$(vmStatus $INSTANCE_NAME $INSTANCE_NAME) + FAULT_VAL="" + + jq -rnc \ + --arg PROVIDER_ID "${INSTANCE_NAME}" \ + --arg NAME "${INSTANCE_NAME}" \ + --arg OS_TYPE "${OS_TYPE}" \ + --arg OS_NAME "${OS_NAME}" \ + --arg OS_VERSION "${OS_VERSION}" \ + --arg ARCH "${ARCH}" \ + --arg STATUS "${STATUS}" \ + --arg POOL_ID "${GARM_POOL_ID}" \ + --arg FAULT "${FAULT_VAL}" \ + '{"provider_id": $PROVIDER_ID, "name": $NAME, "os_type": $OS_TYPE, "os_name": $OS_NAME, "os_version": $OS_VERSION, "os_arch": $ARCH, "status": $STATUS, "pool_id": $POOL_ID, "provider_fault": $FAULT}' +} + +function DeleteInstance() { + local instance_id="${GARM_INSTANCE_ID}" + if [ -z "${instance_id}" ]; then + echo "missing instance ID in env" + return 1 + fi + + set +e + rg_info=$(az group show -n "${instance_id}" -o json --only-show-errors 2>&1) + if [ $? -ne 0 ]; then + CODE=$? + set -e + if echo "${rg_info}" | grep -q "ResourceGroupNotFound"; then + return 0 + fi + return $CODE + fi + set -e + az group delete -n "${instance_id}" --no-wait --y --only-show-errors +} + +function StartInstance() { + local instance_id="${GARM_INSTANCE_ID}" + if [ -z "${instance_id}" ]; then + echo "missing instance ID in env" + return 1 + fi + + az vm start -g "${instance_id}" -n "${instance_id}" -o none --only-show-errors +} + +function StopServer() { + local instance_id="${GARM_INSTANCE_ID}" + if [ -z "${instance_id}" ]; then + echo "missing instance ID in env" + return 1 + fi + + az vm deallocate -g "${instance_id}" -n "${instance_id}" -o none --only-show-errors +} + +# Login to Azure +checkValNotNull "${AZURE_SUBSCRIPTION_ID}" "AZURE_SUBSCRIPTION_ID" +checkValNotNull "${AZURE_TENANT_ID}" "AZURE_TENANT_ID" +checkValNotNull "${AZURE_CLIENT_ID}" "AZURE_CLIENT_ID" +checkValNotNull "${AZURE_CLIENT_SECRET}" "AZURE_CLIENT_SECRET" + +export AZURE_CONFIG_DIR="${MYDIR}/.azure" + +az login --service-principal -u $AZURE_CLIENT_ID -p $AZURE_CLIENT_SECRET --tenant $AZURE_TENANT_ID -o none --only-show-errors +az account set -s $AZURE_SUBSCRIPTION_ID -o none --only-show-errors + +case "$GARM_COMMAND" in + "CreateInstance") + CreateInstance + ;; + "DeleteInstance") + DeleteInstance + ;; + "GetInstance") + echo "GetInstance not implemented" + exit 1 + ;; + "ListInstances") + echo "ListInstances not implemented" + exit 1 + ;; + "StartInstance") + StartInstance + ;; + "StopInstance") + StopServer + ;; + "RemoveAllInstances") + echo "RemoveAllInstances not implemented" + exit 1 + ;; + *) + echo "Invalid GARM provider command: \"$GARM_COMMAND\"" + exit 1 + ;; +esac diff --git a/testdata/config.toml b/testdata/config.toml index 1656934b..fcd5ec92 100644 --- a/testdata/config.toml +++ b/testdata/config.toml @@ -136,7 +136,7 @@ time_to_live = "8760h" protocol = "simplestreams" skip_verify = false -# This is an example external provider. External providers are executables that +# These are examples of external providers. External providers are executables that # implement the needed interface to create/delete/list compute systems that are used # by garm to create runners. [[provider]] @@ -150,6 +150,17 @@ provider_type = "external" # can be anything (bash, a binary, python, etc) provider_dir = "/home/ubuntu/garm/providers.d/openstack" +[[provider]] +name = "azure_external" +description = "external azure provider" +provider_type = "external" + [provider.external] + # config file passed to the executable via GARM_PROVIDER_CONFIG_FILE environment variable + config_file = "/home/ubuntu/garm/providers.d/azure/config.sh" + # path on disk to a folder that contains a "garm-external-provider" executable. The executable + # can be anything (bash, a binary, python, etc) + provider_dir = "/home/ubuntu/garm/providers.d/azure" + # This is a list of credentials that you can define as part of the repository # or organization definitions. They are not saved inside the database, as there # is no Vault integration (yet). This will change in the future.