diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 8ecb1d9..8e88045 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -2,14 +2,31 @@ name: Integration Test on: workflow_dispatch: + inputs: + flavor: + description: 'Server flavor (e.g. CSO2A4, CSO4A8)' + required: false + default: CSO2A4 + image: + description: 'Boot image (e.g. LU22-001, LU24-001)' + required: false + default: LU22-001 + +# Prevent overlapping integration runs — each run creates real cloud resources. +# Do not cancel in-progress runs; let them finish and clean up first. +concurrency: + group: integration-test + cancel-in-progress: false permissions: contents: read jobs: + # ── Job 1: provision the ephemeral runner ─────────────────────────────────── start-runner: name: Start ephemeral runner runs-on: ubuntu-latest + timeout-minutes: 20 outputs: label: ${{ steps.runner.outputs.label }} server_id: ${{ steps.runner.outputs.server_id }} @@ -30,27 +47,57 @@ jobs: subnet_uri: ${{ secrets.ACLOUD_SUBNET_URI }} security_group_uri: ${{ secrets.ACLOUD_SECURITY_GROUP_URI }} keypair_uri: ${{ secrets.ACLOUD_KEYPAIR_URI }} - flavor: CSO2A4 - image: LU22-001 + name: test-${{ github.run_id }}-${{ github.run_attempt }} + flavor: ${{ inputs.flavor }} + image: ${{ inputs.image }} + # ── Job 2: the actual workload (runs on the ephemeral runner) ─────────────── test: name: Run on ephemeral runner needs: start-runner runs-on: ${{ needs.start-runner.outputs.label }} + timeout-minutes: 10 steps: - - name: Verify runner environment + - name: System info + run: | + echo "=== OS ===" + uname -a + cat /etc/os-release + echo "=== User ===" + whoami + echo "=== Runner name: $RUNNER_NAME ===" + echo "=== Disk ===" + df -h / + + - name: Verify required tools installed by cloud-init run: | - echo "Running on: $(uname -a)" - echo "User: $(whoami)" - echo "Runner name: $RUNNER_NAME" + for cmd in curl git jq tar; do + if command -v "$cmd" >/dev/null 2>&1; then + echo "OK: $cmd found at $(command -v "$cmd")" + else + echo "FAIL: $cmd not found" && exit 1 + fi + done - - name: Verify acloud-cli is not required on ephemeral runner - run: echo "Job completed successfully on Aruba Cloud runner." + - name: Verify internet connectivity + run: | + curl -fsSL --max-time 15 https://github.com > /dev/null + echo "OK: GitHub reachable" + + - name: Verify runner is ephemeral + run: | + if [[ "$RUNNER_EPHEMERAL" != "1" ]]; then + echo "FAIL: runner is not ephemeral (RUNNER_EPHEMERAL='$RUNNER_EPHEMERAL')" + exit 1 + fi + echo "OK: runner is ephemeral" + # ── Job 3: tear down the runner and cloud resources ───────────────────────── stop-runner: name: Stop ephemeral runner needs: [start-runner, test] runs-on: ubuntu-latest + timeout-minutes: 15 if: always() steps: - uses: actions/checkout@v4 @@ -65,3 +112,31 @@ jobs: server_id: ${{ needs.start-runner.outputs.server_id }} boot_disk_id: ${{ needs.start-runner.outputs.boot_disk_id }} name: ${{ needs.start-runner.outputs.label }} + + # Confirm the server is no longer reachable via the API. + # We treat "not-found" and "Deleting" as both acceptable final states. + - name: Verify server is deleted + if: needs.start-runner.outputs.server_id != '' + env: + ACLOUD_CLIENT_ID: ${{ secrets.ACLOUD_CLIENT_ID }} + ACLOUD_CLIENT_SECRET: ${{ secrets.ACLOUD_CLIENT_SECRET }} + ACLOUD_PROJECT_ID: ${{ needs.start-runner.outputs.project_id }} + SERVER_ID: ${{ needs.start-runner.outputs.server_id }} + run: | + acloud config set \ + --client-id "$ACLOUD_CLIENT_ID" \ + --client-secret "$ACLOUD_CLIENT_SECRET" + acloud context set default --project-id "$ACLOUD_PROJECT_ID" + + STATUS=$(acloud compute cloudserver get "$SERVER_ID" \ + --output json 2>/dev/null \ + | jq -r '.status // empty' || echo "not-found") + + echo "Post-delete server status: '${STATUS}'" + + case "$STATUS" in + not-found|Deleting) + echo "OK: server is being removed." ;; + *) + echo "WARNING: unexpected server status '$STATUS' — verify manually." ;; + esac diff --git a/.gitignore b/.gitignore index 995785d..41ff21e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ # Example/reference projects - not tracked in this repo others/ + +# Local test config (may contain credentials) +test diff --git a/README.md b/README.md index 8ca3846..4448e1b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ -# acloud-github-runner +# Self-Hosted GitHub Actions Runner on Aruba Cloud On-demand self-hosted GitHub Actions runners on [Aruba Cloud](https://www.arubacloud.com). +

+ ArubaCloud Logo +

+ Each workflow gets a **fresh, ephemeral cloud server**. The server is created at the start of the job and deleted at the end — no idle costs, no shared state between runs. --- diff --git a/action.sh b/action.sh index ad9056e..d1e499a 100644 --- a/action.sh +++ b/action.sh @@ -16,12 +16,17 @@ function _cleanup_on_exit() { local code=$? [[ $code -eq 0 ]] && return + echo >&2 "--- Cleanup triggered (exit code: $code) ---" + if [[ -n "$_CREATED_SERVER_ID" ]]; then echo >&2 "Create failed — deleting orphan server '$_CREATED_SERVER_ID'..." acloud compute cloudserver delete "$_CREATED_SERVER_ID" \ --project-id "$MY_ACLOUD_PROJECT_ID" \ --yes 2>/dev/null \ + && echo >&2 "Server '$_CREATED_SERVER_ID' deleted." \ || echo >&2 "Warning: could not auto-delete server '$_CREATED_SERVER_ID'. Remove it manually." + # Wait briefly for the server to release the boot disk before deleting it + sleep 10 fi if [[ -n "$_CREATED_BOOT_DISK_ID" ]]; then @@ -29,6 +34,7 @@ function _cleanup_on_exit() { acloud storage blockstorage delete "$_CREATED_BOOT_DISK_ID" \ --project-id "$MY_ACLOUD_PROJECT_ID" \ --yes 2>/dev/null \ + && echo >&2 "Boot disk '$_CREATED_BOOT_DISK_ID' deleted." \ || echo >&2 "Warning: could not auto-delete boot disk '$_CREATED_BOOT_DISK_ID'. Remove it manually." fi } @@ -124,22 +130,26 @@ acloud context set default --project-id "$MY_ACLOUD_PROJECT_ID" # ─── DELETE ─────────────────────────────────────────────────────────────────── if [[ "$MY_MODE" == "delete" ]]; then - [[ -n "$MY_SERVER_ID" ]] || exit_with_failure "server_id is required for delete mode." - - echo "Deleting Aruba Cloud server '$MY_SERVER_ID' (project: $MY_ACLOUD_PROJECT_ID)..." - acloud compute cloudserver delete "$MY_SERVER_ID" \ - --project-id "$MY_ACLOUD_PROJECT_ID" \ - --yes \ - || exit_with_failure "Failed to delete server '$MY_SERVER_ID'." - echo "Server deleted." + if [[ -n "$MY_SERVER_ID" ]]; then + echo "Deleting Aruba Cloud server '$MY_SERVER_ID' (project: $MY_ACLOUD_PROJECT_ID)..." + acloud compute cloudserver delete "$MY_SERVER_ID" \ + --project-id "$MY_ACLOUD_PROJECT_ID" \ + --yes \ + && echo "Server deleted." \ + || echo "Warning: could not delete server '$MY_SERVER_ID' (may already be gone)." + # Wait for the server to release the boot disk before deleting it + sleep 10 + else + echo "No server_id provided — skipping server deletion." + fi if [[ -n "$MY_BOOT_DISK_ID" ]]; then echo "Deleting boot disk '$MY_BOOT_DISK_ID'..." acloud storage blockstorage delete "$MY_BOOT_DISK_ID" \ --project-id "$MY_ACLOUD_PROJECT_ID" \ --yes \ - || exit_with_failure "Failed to delete boot disk '$MY_BOOT_DISK_ID'." - echo "Boot disk deleted." + && echo "Boot disk deleted." \ + || echo "Warning: could not delete boot disk '$MY_BOOT_DISK_ID' (may already be gone)." fi # Best-effort cleanup of the GitHub runner entry. Ephemeral runners @@ -171,7 +181,11 @@ if [[ "$MY_MODE" == "delete" ]]; then fi echo "Cleanup complete." - echo "Aruba Cloud server and boot disk deleted successfully. 🗑️" >> "$GITHUB_STEP_SUMMARY" + if [[ -n "$MY_SERVER_ID" || -n "$MY_BOOT_DISK_ID" ]]; then + echo "Aruba Cloud resources deleted successfully. 🗑️" >> "$GITHUB_STEP_SUMMARY" + else + echo "No Aruba Cloud resources to delete." >> "$GITHUB_STEP_SUMMARY" + fi exit 0 fi @@ -230,12 +244,22 @@ acloud storage blockstorage create \ --size "$MY_BOOT_DISK_SIZE" \ --type "$MY_BOOT_DISK_TYPE" \ --image "$MY_IMAGE" \ - --output json > boot-disk-create.json \ + --project-id "$MY_ACLOUD_PROJECT_ID" \ + > boot-disk-create.txt \ || exit_with_failure "Failed to create boot disk." -MY_BOOT_DISK_ID=$(jq -er '.id' < boot-disk-create.json) +cat boot-disk-create.txt + +MY_BOOT_DISK_ID=$(grep -E '^ID:' boot-disk-create.txt | awk '{print $NF}') [[ -n "$MY_BOOT_DISK_ID" ]] || exit_with_failure "Could not parse boot disk ID from create response." -_CREATED_BOOT_DISK_ID="$MY_BOOT_DISK_ID" # arm cleanup trap +_CREATED_BOOT_DISK_ID="$MY_BOOT_DISK_ID" # arm cleanup trap — must be set before any subsequent exit + +# Write boot_disk_id and project_id to GITHUB_OUTPUT immediately so stop-runner +# can delete the disk even if subsequent steps fail. +{ + echo "boot_disk_id=$MY_BOOT_DISK_ID" + echo "project_id=$MY_ACLOUD_PROJECT_ID" +} >> "$GITHUB_OUTPUT" echo "Boot disk created (ID: $MY_BOOT_DISK_ID). Waiting for 'NotUsed' status..." @@ -244,10 +268,10 @@ echo "Boot disk created (ID: $MY_BOOT_DISK_ID). Waiting for 'NotUsed' status..." MY_BOOT_DISK_STATUS="" RETRY_COUNT=0 while [[ $RETRY_COUNT -lt $MY_BOOT_DISK_WAIT ]]; do - acloud storage blockstorage get "$MY_BOOT_DISK_ID" --output json \ - > boot-disk-status.json 2>/dev/null || true + acloud storage blockstorage get "$MY_BOOT_DISK_ID" --project-id "$MY_ACLOUD_PROJECT_ID" \ + > boot-disk-status.txt 2>/dev/null || true - MY_BOOT_DISK_STATUS=$(jq -er '.status // empty' < boot-disk-status.json 2>/dev/null || true) + MY_BOOT_DISK_STATUS=$(grep -E '^Status:' boot-disk-status.txt | awk '{print $NF}' || true) if [[ "$MY_BOOT_DISK_STATUS" == "NotUsed" ]]; then echo "Boot disk is ready (NotUsed)." @@ -263,39 +287,74 @@ done exit_with_failure "Boot disk did not reach 'NotUsed' in time. Check the Aruba Cloud console." # Get the boot disk URI (required to attach it to the cloudserver) -MY_BOOT_DISK_URI=$(jq -er '.uri // .URI // empty' < boot-disk-status.json) +MY_BOOT_DISK_URI=$(grep -E '^URI:' boot-disk-status.txt | awk '{print $NF}') [[ -n "$MY_BOOT_DISK_URI" ]] || exit_with_failure "Could not parse boot disk URI from status response." echo "Boot disk URI: $MY_BOOT_DISK_URI" # ── Step 3: Create the cloudserver ─────────────────────────────────────────── +SERVER_CREATE_MAX_ATTEMPTS=3 +SERVER_CREATE_RETRY_WAIT=15 + +_run_cloudserver_create() { + local extra_flags=("$@") + acloud compute cloudserver create \ + "${extra_flags[@]}" \ + --name "$MY_NAME" \ + --region "$MY_REGION" \ + --zone "$MY_ZONE" \ + --flavor "$MY_FLAVOR" \ + --boot-disk-uri "$MY_BOOT_DISK_URI" \ + --vpc-uri "$MY_VPC_URI" \ + --subnet-uri "$MY_SUBNET_URI" \ + --security-group-uri "$MY_SECURITY_GROUP_URI" \ + --keypair-uri "$MY_KEYPAIR_URI" \ + --user-data-file cloud-init.yml \ + --project-id "$MY_ACLOUD_PROJECT_ID" +} + echo "Creating Aruba Cloud server '$MY_NAME'..." -acloud compute cloudserver create \ - --name "$MY_NAME" \ - --region "$MY_REGION" \ - --zone "$MY_ZONE" \ - --flavor "$MY_FLAVOR" \ - --boot-disk-uri "$MY_BOOT_DISK_URI" \ - --vpc-uri "$MY_VPC_URI" \ - --subnet-uri "$MY_SUBNET_URI" \ - --security-group-uri "$MY_SECURITY_GROUP_URI" \ - --keypair-uri "$MY_KEYPAIR_URI" \ - --user-data-file cloud-init.yml \ - --output json > server-create.json \ - || exit_with_failure "Failed to create Aruba Cloud server." - -MY_ACLOUD_SERVER_ID=$(jq -er '.id' < server-create.json) +_server_create_attempt=0 +while true; do + _server_create_attempt=$(( _server_create_attempt + 1 )) + echo "Server create attempt ${_server_create_attempt}/${SERVER_CREATE_MAX_ATTEMPTS}..." + if _run_cloudserver_create > server-create.txt 2>&1; then + break + fi + + _server_create_err=$(cat server-create.txt) + echo >&2 "Attempt ${_server_create_attempt} failed: ${_server_create_err}" + + # On any failure, print debug output to aid diagnosis + echo >&2 "--- Debug output for failed attempt ${_server_create_attempt} ---" + _run_cloudserver_create -d || true + if echo "$_server_create_err" | grep -qE 'status 5[0-9]{2}'; then + if [[ $_server_create_attempt -lt $SERVER_CREATE_MAX_ATTEMPTS ]]; then + echo "Transient server error — retrying in ${SERVER_CREATE_RETRY_WAIT}s..." + sleep "$SERVER_CREATE_RETRY_WAIT" + continue + fi + fi + + exit_with_failure "Failed to create Aruba Cloud server." +done + +cat server-create.txt + +MY_ACLOUD_SERVER_ID=$(awk 'NR==2 {print $1}' server-create.txt) [[ -n "$MY_ACLOUD_SERVER_ID" ]] || exit_with_failure "Could not parse server ID from create response." _CREATED_SERVER_ID="$MY_ACLOUD_SERVER_ID" # arm cleanup trap +# Write server_id to GITHUB_OUTPUT immediately so stop-runner can delete it +# even if the runner polling timeout or any subsequent step fails. +echo "server_id=$MY_ACLOUD_SERVER_ID" >> "$GITHUB_OUTPUT" + echo "Server created (ID: $MY_ACLOUD_SERVER_ID)." -# Publish outputs immediately so the delete step has all IDs even if -# later polling fails. Server + project + boot disk are all needed for cleanup. -echo "label=$MY_NAME" >> "$GITHUB_OUTPUT" -echo "server_id=$MY_ACLOUD_SERVER_ID" >> "$GITHUB_OUTPUT" -echo "project_id=$MY_ACLOUD_PROJECT_ID" >> "$GITHUB_OUTPUT" -echo "boot_disk_id=$MY_BOOT_DISK_ID" >> "$GITHUB_OUTPUT" +# Write remaining outputs (label already known from the start). +{ + echo "label=$MY_NAME" +} >> "$GITHUB_OUTPUT" # ── Step 4: Poll server until Active ───────────────────────────────────────── # Aruba Cloud resources must be "Active" before they can be used. @@ -304,10 +363,10 @@ echo "Waiting for server to become Active..." MY_SERVER_STATUS="" RETRY_COUNT=0 while [[ $RETRY_COUNT -lt $MY_SERVER_WAIT ]]; do - acloud compute cloudserver get "$MY_ACLOUD_SERVER_ID" --output json \ - > server-status.json 2>/dev/null || true + acloud compute cloudserver get "$MY_ACLOUD_SERVER_ID" --project-id "$MY_ACLOUD_PROJECT_ID" \ + > server-status.txt 2>/dev/null || true - MY_SERVER_STATUS=$(jq -er '.status // empty' < server-status.json 2>/dev/null || true) + MY_SERVER_STATUS=$(grep -E '^Status:' server-status.txt | awk '{print $NF}' || true) if [[ "$MY_SERVER_STATUS" == "Active" ]]; then echo "Server is Active." diff --git a/action.yml b/action.yml index 1f8ab88..3b1e6e3 100644 --- a/action.yml +++ b/action.yml @@ -1,8 +1,5 @@ -name: Self-Hosted GitHub Actions Runner on Aruba Cloud -description: >- - Automatically create Aruba Cloud servers and register them as ephemeral - self-hosted GitHub Actions runners. Each workflow gets a fresh server; - the server is deleted after the job completes. +name: "Aruba Cloud GitHub Runner" +description: "Create ephemeral self-hosted GitHub Actions runners on Aruba Cloud." author: Aruba Cloud branding: @@ -186,9 +183,13 @@ runs: aarch64|arm64) ACLOUD_ARCH="arm64" ;; *) ACLOUD_ARCH="amd64" ;; esac - curl -fsSL \ - "https://github.com/Arubacloud/acloud-cli/releases/latest/download/acloud-linux-${ACLOUD_ARCH}" \ - -o /tmp/acloud + URL="https://github.com/Arubacloud/acloud-cli/releases/latest/download/acloud-linux-${ACLOUD_ARCH}" + for attempt in 1 2 3; do + echo "Downloading acloud-cli (attempt ${attempt}/3)..." + curl -fsSL "$URL" -o /tmp/acloud && break + [[ $attempt -lt 3 ]] && sleep 10 + done + [[ -f /tmp/acloud ]] || { echo "Failed to download acloud-cli after 3 attempts."; exit 1; } chmod +x /tmp/acloud sudo mv /tmp/acloud /usr/local/bin/acloud fi diff --git a/cloud-init.yml.tpl b/cloud-init.yml.tpl index 3daae10..74c5e6d 100644 --- a/cloud-init.yml.tpl +++ b/cloud-init.yml.tpl @@ -11,7 +11,7 @@ runcmd: - export RUNNER_ALLOW_RUNASROOT=1 - bash $MY_RUNNER_DIR/pre_runner_script.sh - bash $MY_RUNNER_DIR/runner-install.sh -v "$MY_RUNNER_VERSION" -d "$MY_RUNNER_DIR" - - $MY_RUNNER_DIR/config.sh --url "https://github.com/$MY_GITHUB_REPOSITORY" --token "$MY_GITHUB_RUNNER_REGISTRATION_TOKEN" --name "$MY_NAME" --labels "$MY_RUNNER_LABELS" --no-default-labels --ephemeral --disableupdate --unattended + - $MY_RUNNER_DIR/config.sh --url "https://github.com/$MY_GITHUB_REPOSITORY" --token "$MY_GITHUB_RUNNER_REGISTRATION_TOKEN" --name "$MY_NAME" --labels "$MY_NAME,$MY_RUNNER_LABELS" --no-default-labels --ephemeral --disableupdate --unattended - $MY_RUNNER_DIR/run.sh write_files: - path: $MY_RUNNER_DIR/pre_runner_script.sh diff --git a/examples/basic.yml b/examples/basic.yml new file mode 100644 index 0000000..59f3161 --- /dev/null +++ b/examples/basic.yml @@ -0,0 +1,60 @@ +name: CI on Aruba Cloud + +on: [push] + +jobs: + # ── Provision the ephemeral runner ────────────────────────────────────────── + start-runner: + name: Start ephemeral runner + runs-on: ubuntu-latest + outputs: + label: ${{ steps.runner.outputs.label }} + server_id: ${{ steps.runner.outputs.server_id }} + project_id: ${{ steps.runner.outputs.project_id }} + boot_disk_id: ${{ steps.runner.outputs.boot_disk_id }} + steps: + - uses: actions/checkout@v4 + + - uses: Arubacloud/acloud-github-runner@v1 + id: runner + with: + mode: create + github_token: ${{ secrets.GH_PAT }} + acloud_client_id: ${{ secrets.ACLOUD_CLIENT_ID }} + acloud_client_secret: ${{ secrets.ACLOUD_CLIENT_SECRET }} + acloud_project_id: ${{ secrets.ACLOUD_PROJECT_ID }} + vpc_uri: ${{ secrets.ACLOUD_VPC_URI }} + subnet_uri: ${{ secrets.ACLOUD_SUBNET_URI }} + security_group_uri: ${{ secrets.ACLOUD_SECURITY_GROUP_URI }} + keypair_uri: ${{ secrets.ACLOUD_KEYPAIR_URI }} + flavor: CSO2A4 # 2 vCPU / 4 GB RAM + image: LU22-001 # Ubuntu 22.04 LTS + + # ── Your actual workload ───────────────────────────────────────────────────── + build: + name: Build + needs: start-runner + runs-on: ${{ needs.start-runner.outputs.label }} + steps: + - uses: actions/checkout@v4 + - run: echo "Running on a fresh Aruba Cloud server!" + + # ── Tear down — runs even if build fails ──────────────────────────────────── + stop-runner: + name: Stop ephemeral runner + needs: [start-runner, build] + runs-on: ubuntu-latest + if: always() + steps: + - uses: actions/checkout@v4 + + - uses: Arubacloud/acloud-github-runner@v1 + with: + mode: delete + github_token: ${{ secrets.GH_PAT }} + acloud_client_id: ${{ secrets.ACLOUD_CLIENT_ID }} + acloud_client_secret: ${{ secrets.ACLOUD_CLIENT_SECRET }} + acloud_project_id: ${{ needs.start-runner.outputs.project_id }} + server_id: ${{ needs.start-runner.outputs.server_id }} + boot_disk_id: ${{ needs.start-runner.outputs.boot_disk_id }} + name: ${{ needs.start-runner.outputs.label }} diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..1cc6164 Binary files /dev/null and b/logo.png differ