diff --git a/.github/workflows/runner_input_validation.yml b/.github/workflows/runner_input_validation.yml new file mode 100644 index 0000000..7bfaf06 --- /dev/null +++ b/.github/workflows/runner_input_validation.yml @@ -0,0 +1,24 @@ +name: Runner input validation + +on: + pull_request: + paths: + - action.sh + - action.yml + - tests/runner_input_validation_test.sh + - .github/workflows/runner_input_validation.yml + push: + branches: + - main + paths: + - action.sh + - action.yml + - tests/runner_input_validation_test.sh + - .github/workflows/runner_input_validation.yml + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: tests/runner_input_validation_test.sh diff --git a/action.sh b/action.sh index ea30192..743f804 100755 --- a/action.sh +++ b/action.sh @@ -164,6 +164,22 @@ do esac done +function is_exact_runner_version { + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +function validate_inputs { + if [[ "${runner_ver}" != "latest" ]] && ! is_exact_runner_version "${runner_ver}"; then + echo "Invalid runner_ver: expected 'latest' or a version in X.Y.Z format" >&2 + exit 1 + fi + + if [[ ! "${shutdown_timeout}" =~ ^[0-9]+$ || ${#shutdown_timeout} -gt 5 ]] || (( 10#${shutdown_timeout} > 86400 )); then + echo "Invalid shutdown_timeout: expected an integer from 0 through 86400" >&2 + exit 1 + fi +} + function gcloud_auth { # NOTE: when --project is specified, it updates the config echo ${service_account_key} | gcloud --project ${project_id} --quiet auth activate-service-account --key-file - &>/dev/null @@ -179,6 +195,16 @@ function start_vm { gcloud_auth fi + if [[ "${actions_preinstalled}" != "true" && "${runner_ver}" == "latest" ]]; then + latest_ver=$(curl -sL https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name' | sed -e 's/^v//') + if ! is_exact_runner_version "$latest_ver"; then + echo "Invalid resolved runner version: expected a version in X.Y.Z format" >&2 + exit 2 + fi + runner_ver="$latest_ver" + echo "✅ runner_ver=latest is specified. v$latest_ver is detected as the latest version." + fi + RUNNER_TOKEN=$(curl -S -s -XPOST \ -H "authorization: Bearer ${token}" \ https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/runners/registration-token |\ @@ -258,15 +284,6 @@ function start_vm { cd /actions-runner $startup_script" else - if [[ "$runner_ver" = "latest" ]]; then - latest_ver=$(curl -sL https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name' | sed -e 's/^v//') - runner_ver="$latest_ver" - echo "✅ runner_ver=latest is specified. v$latest_ver is detected as the latest version." - if [[ -z "$latest_ver" || "null" == "$latest_ver" ]]; then - echo "❌ could not retrieve the latest version of a runner" - exit 2 - fi - fi echo "✅ Startup script will install GitHub Actions v$runner_ver" if $arm ; then startup_script="${startup_prelude} @@ -352,6 +369,7 @@ function start_vm { } safety_on +validate_inputs case "$command" in start) start_vm diff --git a/action.yml b/action.yml index 83e7811..e8b82e9 100644 --- a/action.yml +++ b/action.yml @@ -21,7 +21,7 @@ inputs: This key should be created and stored as a secret. Should be JSON key. required: false runner_ver: - description: Version of the GitHub Runner. "latest" will resolve the latest version. + description: Version of the GitHub Runner in X.Y.Z format. "latest" will resolve the latest version. default: "latest" required: true vm_name_prefix: @@ -87,7 +87,7 @@ inputs: default: cloud-platform required: true shutdown_timeout: - description: "Shutdown grace period (in seconds)." + description: "Shutdown grace period in seconds, from 0 through 86400." default: 30 required: true actions_preinstalled: @@ -117,13 +117,16 @@ runs: using: "composite" steps: - id: gce-github-runner-script + env: + INPUT_RUNNER_VER: ${{ inputs.runner_ver }} + INPUT_SHUTDOWN_TIMEOUT: ${{ inputs.shutdown_timeout }} run: > ${{ github.action_path }}/action.sh --command=start --token=${{ inputs.token }} --project_id=${{ inputs.project_id }} --service_account_key='${{ inputs.service_account_key }}' - --runner_ver=${{ inputs.runner_ver }} + --runner_ver="${INPUT_RUNNER_VER}" --vm_name_prefix=${{ inputs.vm_name_prefix }} --machine_zone=${{ inputs.machine_zone }} --machine_type=${{ inputs.machine_type }} @@ -132,7 +135,7 @@ runs: --accelerator=${{ inputs.accelerator }} --disk_size=${{ inputs.disk_size }} --scopes=${{ inputs.scopes }} - --shutdown_timeout=${{ inputs.shutdown_timeout }} + --shutdown_timeout="${INPUT_SHUTDOWN_TIMEOUT}" --runner_service_account=${{ inputs.runner_service_account }} --image_project=${{ inputs.image_project }} --image=${{ inputs.image }} diff --git a/tests/runner_input_validation_test.sh b/tests/runner_input_validation_test.sh new file mode 100755 index 0000000..03c57bd --- /dev/null +++ b/tests/runner_input_validation_test.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/bin" + +cat > "$tmp_dir/bin/curl" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == *"/actions/runner/releases/latest"* ]]; then + printf '{"tag_name":"%s"}\n' "${MOCK_LATEST_TAG:-v2.321.0}" +else + printf '{"token":"runner-token"}\n' +fi +EOF + +cat > "$tmp_dir/bin/gcloud" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${GCLOUD_LOG}" +if [[ "$*" == *"compute instances describe"* ]]; then + printf '{"labels":{"gh_ready":"1"}}\n' +fi +EOF +chmod +x "$tmp_dir/bin/curl" "$tmp_dir/bin/gcloud" + +run_action() { + local runner_ver=$1 + local shutdown_timeout=$2 + local latest_tag=${3:-v2.321.0} + : > "$tmp_dir/gcloud.log" + set +e + output=$( + PATH="$tmp_dir/bin:$PATH" \ + GCLOUD_LOG="$tmp_dir/gcloud.log" \ + MOCK_LATEST_TAG="$latest_tag" \ + GITHUB_REPOSITORY=example/repo \ + GITHUB_REPOSITORY_OWNER=example \ + GITHUB_RUN_ID=123 \ + GITHUB_RUN_ATTEMPT=1 \ + GITHUB_OUTPUT="$tmp_dir/output" \ + "$repo_root/action.sh" \ + --command=start \ + --token=test-token \ + --project_id= \ + --service_account_key= \ + --runner_ver="$runner_ver" \ + --vm_name_prefix=test-runner \ + --machine_zone=us-east1-c \ + --machine_type=n1-standard-1 \ + --scopes=cloud-platform \ + --shutdown_timeout="$shutdown_timeout" \ + --preemptible=false \ + --ephemeral=false \ + --no_external_address=false \ + --actions_preinstalled=false \ + --arm=false \ + 2>&1 + ) + status=$? + set -e +} + +assert_rejected_before_gcloud() { + local expected_message=$1 + if [[ $status -eq 0 ]]; then + printf 'expected failure, got success\n%s\n' "$output" >&2 + exit 1 + fi + if [[ "$output" != *"$expected_message"* ]]; then + printf 'missing error %q in output:\n%s\n' "$expected_message" "$output" >&2 + exit 1 + fi + if [[ -s "$tmp_dir/gcloud.log" ]]; then + printf 'gcloud ran for rejected input:\n' >&2 + cat "$tmp_dir/gcloud.log" >&2 + exit 1 + fi +} + +run_action '2.321.0; touch /tmp/runner-version-marker' 30 +assert_rejected_before_gcloud 'Invalid runner_ver' + +run_action '2.321' 30 +assert_rejected_before_gcloud 'Invalid runner_ver' + +run_action '2.321.0' '30; touch /tmp/shutdown-timeout-marker' +assert_rejected_before_gcloud 'Invalid shutdown_timeout' + +run_action '2.321.0' 86401 +assert_rejected_before_gcloud 'Invalid shutdown_timeout' + +run_action '2.321.0' 999999999999999999999999 +assert_rejected_before_gcloud 'Invalid shutdown_timeout' + +run_action latest 30 'v2.321.0; touch /tmp/resolved-version-marker' +assert_rejected_before_gcloud 'Invalid resolved runner version' + +run_action '2.321.0' 0 +if [[ $status -ne 0 ]]; then + printf 'valid explicit version failed:\n%s\n' "$output" >&2 + exit 1 +fi + +run_action latest 86400 +if [[ $status -ne 0 ]]; then + printf 'valid latest version failed:\n%s\n' "$output" >&2 + exit 1 +fi + +printf 'runner input validation tests passed\n'