diff --git a/.github/actions/regtest-vm-down/action.yml b/.github/actions/regtest-vm-down/action.yml new file mode 100644 index 0000000..d2279c2 --- /dev/null +++ b/.github/actions/regtest-vm-down/action.yml @@ -0,0 +1,69 @@ +name: Destroy regtest VM +description: "Deletes the ephemeral VM and its firewall rule. Always call with `if: always()`." + +# Composite actions cannot declare `post:` steps (actions/runner#1478), so teardown +# cannot be automatic. This must be an explicit step, and it is best-effort — a hard +# cancellation can skip it entirely. The scheduled reaper is the actual safety net. + +inputs: + gcp-project: + required: true + gcp-zone: + default: europe-west3-a + instance: + description: From regtest-vm-up outputs. + required: true + firewall: + description: From regtest-vm-up outputs. + required: true + workload-identity-provider: + required: true + service-account: + required: true + dump-logs: + description: Pull container logs off the VM before deleting it. + default: 'false' + +runs: + using: composite + steps: + - uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ inputs.workload-identity-provider }} + service_account: ${{ inputs.service-account }} + + - uses: google-github-actions/setup-gcloud@v3 + + - name: Dump stack logs + if: inputs.dump-logs == 'true' + shell: bash + continue-on-error: true + run: | + gcloud compute ssh "${{ inputs.instance }}" \ + --project="${{ inputs.gcp-project }}" \ + --zone="${{ inputs.gcp-zone }}" \ + --command="cd /opt/regtest && docker compose logs --no-color --tail=500" \ + > regtest-stack.log 2>&1 || true + + - uses: actions/upload-artifact@v7 + if: inputs.dump-logs == 'true' + continue-on-error: true + with: + name: regtest-stack-log-${{ inputs.instance }} + path: regtest-stack.log + + - name: Delete instance and firewall rule + shell: bash + run: | + # Never fail the job on teardown. A leaked resource is the reaper's problem; + # a red build from a cleanup step hides the real test result. + set +e + gcloud compute instances delete "${{ inputs.instance }}" \ + --project="${{ inputs.gcp-project }}" \ + --zone="${{ inputs.gcp-zone }}" --quiet + echo "instance delete exited $?" + + gcloud compute firewall-rules delete "${{ inputs.firewall }}" \ + --project="${{ inputs.gcp-project }}" --quiet + echo "firewall delete exited $?" + exit 0 diff --git a/.github/actions/regtest-vm-up/action.yml b/.github/actions/regtest-vm-up/action.yml new file mode 100644 index 0000000..c5d4270 --- /dev/null +++ b/.github/actions/regtest-vm-up/action.yml @@ -0,0 +1,181 @@ +name: Provision regtest VM +description: Creates an ephemeral GCP VM running the bitkit regtest stack and waits until it is reachable. + +inputs: + gcp-project: + required: true + gcp-zone: + default: europe-west3-a + machine-type: + default: e2-standard-4 + image-family: + description: Stock Ubuntu. startup.sh installs Docker and pulls the stack itself. + default: ubuntu-2404-lts-amd64 + image-project: + description: Project holding image-family. + default: ubuntu-os-cloud + stack-dir: + description: > + Path on the runner to the compose directory, sent to the VM as metadata. It + comes from whatever this workflow checked out, so the stack always matches + the tests without a second ref to keep in sync. + default: docker + workload-identity-provider: + required: true + service-account: + required: true + name-suffix: + description: Disambiguator when several VMs exist per run, e.g. the shard name. + default: '' + creds-port: + description: Port the VM serves LND's tls.cert and admin.macaroon on. + default: '8081' + ttl-minutes: + description: > + GCE deletes the VM this long after creation, enforced by the platform rather + than by anything running on the guest. Must exceed the longest e2e run or the + stack disappears mid-test — the single-shard iOS suite currently takes up to + ~130 minutes. + default: '240' + ready-timeout-seconds: + default: '420' + +outputs: + host: + description: Public IP of the VM. + value: ${{ steps.create.outputs.host }} + instance: + description: Instance name — pass to regtest-vm-down. + value: ${{ steps.names.outputs.instance }} + firewall: + description: Firewall rule name — pass to regtest-vm-down. + value: ${{ steps.names.outputs.firewall }} + creds-url: + description: > + Base URL to fetch tls.cert and admin.macaroon from. LND generates both on + first start, so they exist only on the VM. + value: ${{ steps.create.outputs.creds-url }} + +runs: + using: composite + steps: + - id: names + shell: bash + run: | + set -euo pipefail + # GCP resource names are RFC1035: lowercase, alphanumeric + hyphen, <=63 chars. + # Shard names carry underscores, so they cannot be used verbatim. + suffix=$(printf '%s' "${{ inputs.name-suffix }}" | tr '[:upper:]_' '[:lower:]-' | tr -cd 'a-z0-9-') + base="rt-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}${suffix:+-$suffix}" + base=$(printf '%s' "$base" | cut -c1-55) + echo "instance=$base" >> "$GITHUB_OUTPUT" + echo "firewall=$base-fw" >> "$GITHUB_OUTPUT" + + - id: bundle + shell: bash + run: | + set -euo pipefail + # Runtime state is excluded: it is gitignored, regenerated on the VM, and + # lnd/ in particular is root-owned once a stack has run locally. + bundle=$(mktemp) + tar czf - -C "${{ inputs.stack-dir }}" \ + --exclude=lnd --exclude=lnurl-server-data --exclude=.trezor-user-env \ + . | base64 -w0 > "$bundle" + size=$(wc -c < "$bundle") + echo "bundle: ${size} bytes" + # GCE allows 256KB per metadata value. + if [ "$size" -gt 250000 ]; then + echo "::error::stack bundle is ${size} bytes, over the metadata limit" + exit 1 + fi + echo "path=$bundle" >> "$GITHUB_OUTPUT" + + - uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ inputs.workload-identity-provider }} + service_account: ${{ inputs.service-account }} + + - uses: google-github-actions/setup-gcloud@v3 + + - id: create + shell: bash + env: + PROJECT: ${{ inputs.gcp-project }} + ZONE: ${{ inputs.gcp-zone }} + INSTANCE: ${{ steps.names.outputs.instance }} + FIREWALL: ${{ steps.names.outputs.firewall }} + run: | + set -euo pipefail + + # Scope ingress to this runner only. GitHub-hosted runner egress IPs are not + # stable, so the rule is created per run and deleted by regtest-vm-down. + runner_ip=$(curl -fsS --max-time 10 https://api.ipify.org) + echo "runner egress IP: $runner_ip" + + # Random path segment for the credential server, so reaching the port is + # not sufficient. Masked so it never lands in the log. + creds_token=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n') + echo "::add-mask::$creds_token" + + gcloud compute firewall-rules create "$FIREWALL" \ + --project="$PROJECT" \ + --network=default \ + --direction=INGRESS \ + --source-ranges="$runner_ip/32" \ + --allow=tcp:60001,tcp:9735,tcp:3003,tcp:43782,tcp:8080,tcp:10009,tcp:${{ inputs.creds-port }} \ + --target-tags="$INSTANCE" \ + --description="ephemeral e2e regtest, run ${GITHUB_RUN_ID}" + + gcloud compute instances create "$INSTANCE" \ + --project="$PROJECT" \ + --zone="$ZONE" \ + --machine-type="${{ inputs.machine-type }}" \ + --image-family="${{ inputs.image-family }}" \ + --image-project="${{ inputs.image-project }}" \ + --tags="$INSTANCE" \ + --labels="ci=e2e,run-id=${GITHUB_RUN_ID},repo=${GITHUB_REPOSITORY##*/}" \ + --metadata="creds-token=${creds_token},creds-port=${{ inputs.creds-port }}" \ + --metadata-from-file="startup-script=${{ github.action_path }}/startup.sh,stack-bundle=${{ steps.bundle.outputs.path }}" \ + --max-run-duration="${{ inputs.ttl-minutes }}m" \ + --instance-termination-action=DELETE \ + --no-restart-on-failure + + host=$(gcloud compute instances describe "$INSTANCE" \ + --project="$PROJECT" --zone="$ZONE" \ + --format='get(networkInterfaces[0].accessConfigs[0].natIP)') + echo "host=$host" >> "$GITHUB_OUTPUT" + echo "creds-url=http://${host}:${{ inputs.creds-port }}/${creds_token}" >> "$GITHUB_OUTPUT" + echo "VM $INSTANCE at $host" + + - name: Wait for stack + shell: bash + env: + HOST: ${{ steps.create.outputs.host }} + run: | + set -euo pipefail + deadline=$(( SECONDS + ${{ inputs.ready-timeout-seconds }} )) + # electrs, LND P2P, LND REST, bitcoind RPC — the four the tests and the app + # need — plus the credential server, which is useless if it is not reachable. + for port in 60001 9735 8080 43782 ${{ inputs.creds-port }}; do + until nc -z -w 5 "$HOST" "$port" 2>/dev/null; do + if (( SECONDS >= deadline )); then + echo "::error::timed out waiting for $HOST:$port" + exit 1 + fi + sleep 5 + done + echo "✓ $port" + done + + # LND listens on 8080 well before it can serve, so an open port is not + # readiness. It answers "the RPC server is in the process of starting up" + # until the wallet is unlocked and the RPC server is live. + until curl -sk --max-time 10 "https://${HOST}:8080/v1/getinfo" 2>/dev/null \ + | grep -qv "process of starting up"; do + if (( SECONDS >= deadline )); then + echo "::error::LND never finished starting" + exit 1 + fi + sleep 5 + done + echo "✓ lnd rpc ready" diff --git a/.github/actions/regtest-vm-up/startup.sh b/.github/actions/regtest-vm-up/startup.sh new file mode 100755 index 0000000..7d5dd13 --- /dev/null +++ b/.github/actions/regtest-vm-up/startup.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +# GCE startup script for the ephemeral regtest VM. Runs as root on every boot. +# Output lands in the serial console and /var/log/syslog. +# +# Reads from instance metadata: +# stack-bundle base64 tar.gz of the compose directory, written by regtest-vm-up +# ttl-minutes self-destruct timer, backstop for a skipped CI teardown +# creds-token random path segment the VM serves tls.cert / admin.macaroon under +# creds-port port for that server +# +# The stack files arrive in metadata rather than being cloned, so the VM needs no +# repository access, no token, and no network path to GitHub. + +WORKDIR="${WORKDIR:-/opt/regtest}" +READY_MARKER="${READY_MARKER:-/var/run/regtest-ready}" + +meta() { + curl -fsS -H "Metadata-Flavor: Google" \ + "http://metadata.google.internal/computeMetadata/v1/$1" 2>/dev/null || true +} + +log() { echo "[regtest-startup] $*"; } + +# Each value falls back to metadata, so the script can be exercised outside GCE by +# exporting them. +STACK_BUNDLE="${STACK_BUNDLE:-$(meta instance/attributes/stack-bundle)}" +CREDS_TOKEN="${CREDS_TOKEN:-$(meta instance/attributes/creds-token)}" +CREDS_PORT="${CREDS_PORT:-$(meta instance/attributes/creds-port)}" +CREDS_PORT="${CREDS_PORT:-8081}" +EXTERNAL_IP="${EXTERNAL_IP:-$(meta instance/network-interfaces/0/access-configs/0/external-ip)}" + +: "${STACK_BUNDLE:?stack-bundle metadata is required}" +: "${EXTERNAL_IP:?instance has no external IP}" + +# Nothing here schedules the VM's own destruction: regtest-vm-up sets +# --max-run-duration with --instance-termination-action=DELETE, so GCE deletes it +# whatever happens in here — including if this script never runs at all. + +if ! command -v docker >/dev/null 2>&1; then + log "installing docker" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl netcat-openbsd + curl -fsSL https://get.docker.com | sh + systemctl enable --now docker +fi + +log "unpacking stack bundle" +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR" +printf '%s' "$STACK_BUNDLE" | base64 -d | tar xz -C "$WORKDIR" + +cd "$WORKDIR" + +# LND writes tls.cert and the macaroons here on first start; the container runs as a +# different uid, so the directory has to be world-writable before it comes up. +mkdir -p lnd +chmod 777 lnd + +# LND advertises this address to peers. Left at the compose default of 127.0.0.1 the +# app would dial itself instead of the VM. +export LND_EXTERNAL_IP="$EXTERNAL_IP" + +# Not used by the default profile, but set so the adhoc lnurl-server hands out +# reachable URLs if that profile is ever enabled on a VM. +export LNURL_DOMAIN="http://${EXTERNAL_IP}:${LNURL_SERVER_PORT:-30001}" + +log "external ip $EXTERNAL_IP" + +# Only the default profile is started, matching what the e2e workflows run today: +# bitcoind, lnd, bitcoinsetup, darkhttpd, electrs, ldk-backup-server. The adhoc, +# homegate and trezor profiles stay off. +docker compose pull --quiet +docker compose up -d + +log "waiting for electrs" +until nc -z 127.0.0.1 60001; do sleep 2; done + +log "waiting for lnd macaroon" +until [ -f lnd/data/chain/bitcoin/regtest/admin.macaroon ]; do sleep 2; done +chmod -R 777 lnd + +if [ -n "$CREDS_TOKEN" ]; then + # LND generates tls.cert and admin.macaroon on first start, so they exist only + # here — but the tests need them as files on the runner, which cannot SSH in. + # Serving them over a random path means reaching the port is not enough; the + # firewall already limits that port to the runner's own IP. + creds_dir="/opt/creds/${CREDS_TOKEN}" + mkdir -p "$creds_dir" + cp lnd/tls.cert "$creds_dir/tls.cert" + cp lnd/data/chain/bitcoin/regtest/admin.macaroon "$creds_dir/admin.macaroon" + # Fetch target for proving a client reached this VM. The access log is the + # evidence, so the contents do not matter. + echo ok > "$creds_dir/ping.txt" + chmod -R a+r /opt/creds + + # systemd-run so the server outlives this startup script, which runs as a unit + # whose children are killed when it exits. Absolute path because the transient + # unit does not inherit this shell's PATH. + # StandardOutput=journal+console puts the access log on the serial port, so a + # request can be confirmed with gcloud instead of needing SSH onto the VM. + systemd-run --unit=regtest-creds --collect \ + --property=StandardOutput=journal+console \ + --property=StandardError=journal+console \ + /usr/bin/python3 -m http.server "$CREDS_PORT" --bind 0.0.0.0 --directory /opt/creds \ + || log "WARNING: systemd-run failed" + + for _ in $(seq 1 15); do + nc -z 127.0.0.1 "$CREDS_PORT" && break + sleep 1 + done + if nc -z 127.0.0.1 "$CREDS_PORT"; then + log "serving credentials on :$CREDS_PORT" + else + log "ERROR: credential server not listening on :$CREDS_PORT" + systemctl status regtest-creds --no-pager --lines=20 || true + fi +fi + +touch "$READY_MARKER" +log "stack ready" diff --git a/.github/workflows/regtest-reaper.yml b/.github/workflows/regtest-reaper.yml new file mode 100644 index 0000000..1cad5be --- /dev/null +++ b/.github/workflows/regtest-reaper.yml @@ -0,0 +1,95 @@ +name: Regtest Reaper + +# Deletes firewall rules left behind by regtest-vm-up. +# +# Instances are NOT handled here. regtest-vm-up creates them with +# --max-run-duration and --instance-termination-action=DELETE, so GCE removes them +# on its own even if the job is hard-cancelled or the VM never boots. Firewall +# rules have no equivalent TTL, so they are the only thing that can accumulate. +# +# An orphaned rule targets a network tag no instance carries any more, so it is +# inert and free — this is tidiness and quota hygiene, not a leak. + +on: + schedule: + - cron: '0 4 * * *' + workflow_dispatch: + inputs: + max_age_minutes: + description: Delete rules older than this. Must exceed the longest e2e run. + required: true + default: '360' + dry_run: + description: List what would be deleted without deleting it + required: true + default: 'true' + +permissions: + id-token: write + contents: read + +concurrency: + group: regtest-reaper + cancel-in-progress: false + +env: + MAX_AGE_MINUTES: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.max_age_minutes || '360' }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run || 'false' }} + GCP_PROJECT: ${{ vars.REGTEST_GCP_PROJECT }} + +jobs: + reap: + runs-on: ubuntu-latest + steps: + - name: Validate params + run: | + if ! [[ "${MAX_AGE_MINUTES}" =~ ^[0-9]+$ ]]; then + echo "MAX_AGE_MINUTES must be a positive integer: ${MAX_AGE_MINUTES}" >&2 + exit 1 + fi + # Must not delete a rule belonging to a running job. + if [[ "${MAX_AGE_MINUTES}" -lt 240 ]]; then + echo "MAX_AGE_MINUTES below 240 risks cutting off an in-flight e2e run" >&2 + exit 1 + fi + if [[ -z "${GCP_PROJECT}" ]]; then + echo "vars.REGTEST_GCP_PROJECT is not set" >&2 + exit 1 + fi + + - uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ secrets.REGTEST_WIF_PROVIDER }} + service_account: ${{ secrets.REGTEST_SERVICE_ACCOUNT }} + + - uses: google-github-actions/setup-gcloud@v3 + + - name: Delete orphaned firewall rules + run: | + set -euo pipefail + cutoff=$(date -u -d "-${MAX_AGE_MINUTES} minutes" +%Y-%m-%dT%H:%M:%SZ) + echo "cutoff: $cutoff (age > ${MAX_AGE_MINUTES}m)" + + # Firewall rules cannot carry labels, so the name prefix regtest-vm-up + # assigns is the only boundary available. Nothing else may use it. + mapfile -t rules < <(gcloud compute firewall-rules list \ + --project="${GCP_PROJECT}" \ + --filter="name~'^rt-.*-fw$' AND creationTimestamp<'${cutoff}'" \ + --format='value(name)') + + if [[ ${#rules[@]} -eq 0 ]]; then + echo "nothing to reap" + exit 0 + fi + + echo "found ${#rules[@]} orphaned rule(s)" + for rule in "${rules[@]}"; do + if [[ "${DRY_RUN}" == "true" ]]; then + echo "DRY RUN: would delete $rule" + continue + fi + echo "deleting $rule" + gcloud compute firewall-rules delete "$rule" \ + --project="${GCP_PROJECT}" --quiet || \ + echo "::warning::failed to delete $rule" + done diff --git a/.github/workflows/regtest-vm-smoke.yml b/.github/workflows/regtest-vm-smoke.yml new file mode 100644 index 0000000..af84212 --- /dev/null +++ b/.github/workflows/regtest-vm-smoke.yml @@ -0,0 +1,149 @@ +name: Regtest VM Smoke Test + +# Manual verification that the regtest VM lifecycle works end to end: provision, +# reach the stack over the network, tear down. Deliberately runs on ubuntu-latest — +# nothing here needs macOS, and this proves auth, firewall, boot and teardown in +# isolation from anything iOS. +# +# The actions are referenced by local path, so this runs on a branch without the +# actions having to be merged first. + +on: + workflow_dispatch: + inputs: + keep_vm: + description: Skip teardown, to test the reaper or debug by hand. Remember to delete it. + required: true + default: 'false' + zone: + description: GCP zone + required: true + default: europe-west3-a + +permissions: + id-token: write + contents: read + +concurrency: + group: regtest-vm-smoke + cancel-in-progress: false + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Provision + id: regtest + uses: ./.github/actions/regtest-vm-up + with: + gcp-project: ${{ vars.REGTEST_GCP_PROJECT }} + gcp-zone: ${{ github.event.inputs.zone }} + workload-identity-provider: ${{ secrets.REGTEST_WIF_PROVIDER }} + service-account: ${{ secrets.REGTEST_SERVICE_ACCOUNT }} + name-suffix: smoke + ttl-minutes: '30' + + - name: Check every port the tests and the app need + env: + HOST: ${{ steps.regtest.outputs.host }} + run: | + set -euo pipefail + echo "host: $HOST" + # app: electrs, LND P2P, ldk-backup-server + # tests: bitcoind RPC, LND REST, LND gRPC + for port in 60001 9735 3003 43782 8080 10009; do + if nc -z -w 5 "$HOST" "$port"; then + echo "✓ $port" + else + echo "::error::$HOST:$port unreachable" + exit 1 + fi + done + + - name: Check bitcoind is on regtest and mining works + env: + HOST: ${{ steps.regtest.outputs.host }} + run: | + set -euo pipefail + rpc() { + curl -fsS --max-time 15 --user polaruser:polarpass \ + -H 'content-type: text/plain;' \ + --data-binary "{\"jsonrpc\":\"1.0\",\"method\":\"$1\",\"params\":${2:-[]}}" \ + "http://${HOST}:43782/" + } + chain=$(rpc getblockchaininfo | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["chain"])') + echo "chain: $chain" + [ "$chain" = "regtest" ] || { echo "::error::expected regtest, got $chain"; exit 1; } + + before=$(rpc getblockcount | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"])') + addr=$(rpc getnewaddress | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"])') + rpc generatetoaddress "[1, \"$addr\"]" >/dev/null + after=$(rpc getblockcount | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"])') + echo "height $before -> $after" + [ "$after" -gt "$before" ] || { echo "::error::mining did not advance the chain"; exit 1; } + + - name: Check LND is up and rejecting unauthenticated calls + env: + HOST: ${{ steps.regtest.outputs.host }} + run: | + set -euo pipefail + # LND's REST gateway answers every error with HTTP 500, because grpc-gateway + # maps gRPC code 2 (UNKNOWN) onto it. The status therefore says nothing — + # the message is what distinguishes a ready node from a starting one. + body=$(curl -sk --max-time 15 "https://${HOST}:8080/v1/getinfo" || echo '') + echo "$body" + case "$body" in + *"expected 1 macaroon"*) + echo "✓ LND ready and rejecting unauthenticated calls" ;; + *"process of starting up"*) + echo "::error::LND still starting — the readiness gate let this through"; exit 1 ;; + '') + echo "::error::no response from ${HOST}:8080"; exit 1 ;; + *) + echo "::warning::unrecognised response" ;; + esac + + - name: Fetch credentials and verify LND advertises the VM address + env: + HOST: ${{ steps.regtest.outputs.host }} + CREDS_URL: ${{ steps.regtest.outputs.creds-url }} + run: | + set -euo pipefail + curl -fsS --max-time 20 -o tls.cert "${CREDS_URL}/tls.cert" + curl -fsS --max-time 20 -o admin.macaroon "${CREDS_URL}/admin.macaroon" + echo "fetched $(wc -c < tls.cert) byte cert, $(wc -c < admin.macaroon) byte macaroon" + + mac=$(xxd -p -c 2000 admin.macaroon) + uris=$(curl -fsS --max-time 20 --cacert tls.cert \ + -H "Grpc-Metadata-macaroon: ${mac}" \ + "https://${HOST}:8080/v1/getinfo" \ + | python3 -c 'import json,sys; print(",".join(json.load(sys.stdin).get("uris",[])))') + echo "uris: ${uris:-}" + + # The one assertion the port checks cannot make: without LND_EXTERNAL_IP + # reaching the container, the app would be told to dial 127.0.0.1. + case "$uris" in + *"$HOST"*) echo "✓ LND advertises $HOST" ;; + *127.0.0.1*) echo "::error::LND advertises 127.0.0.1 — LND_EXTERNAL_IP did not take"; exit 1 ;; + *) echo "::error::unexpected uris"; exit 1 ;; + esac + + - name: Destroy + if: always() && github.event.inputs.keep_vm != 'true' + uses: ./.github/actions/regtest-vm-down + with: + gcp-project: ${{ vars.REGTEST_GCP_PROJECT }} + gcp-zone: ${{ github.event.inputs.zone }} + instance: ${{ steps.regtest.outputs.instance }} + firewall: ${{ steps.regtest.outputs.firewall }} + workload-identity-provider: ${{ secrets.REGTEST_WIF_PROVIDER }} + service-account: ${{ secrets.REGTEST_SERVICE_ACCOUNT }} + dump-logs: 'true' + + - name: Warn if VM was kept + if: always() && github.event.inputs.keep_vm == 'true' + run: | + echo "::warning::VM ${{ steps.regtest.outputs.instance }} was kept and will self-destruct in 30m. Firewall rule ${{ steps.regtest.outputs.firewall }} is NOT removed by that — delete it or wait for the reaper." diff --git a/ci/regtest-vm/setup-wif.sh b/ci/regtest-vm/setup-wif.sh new file mode 100755 index 0000000..c30d9a6 --- /dev/null +++ b/ci/regtest-vm/setup-wif.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +# One-off setup of Workload Identity Federation so a GitHub Actions workflow can +# provision and delete regtest VMs without a long-lived service-account key. +# +# Run once per (GCP project, GitHub repo) pair. Safe to re-run: every create is +# guarded by a lookup. +# +# PROJECT_ID=your-gcp-project REPO=owner/name ./setup-wif.sh +# +# Prints the three values the workflows expect at the end. + +PROJECT_ID="${PROJECT_ID:?set PROJECT_ID}" +REPO="${REPO:?set REPO as owner/name}" + +POOL="${POOL:-github-actions}" +PROVIDER="${PROVIDER:-github}" +SA_NAME="${SA_NAME:-regtest-ci}" +SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" + +log() { echo "[setup-wif] $*"; } +exists() { "$@" >/dev/null 2>&1; } + +PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)') +log "project $PROJECT_ID ($PROJECT_NUMBER), repo $REPO" + +log "enabling APIs" +gcloud services enable \ + iamcredentials.googleapis.com \ + sts.googleapis.com \ + compute.googleapis.com \ + --project="$PROJECT_ID" + +if ! exists gcloud iam service-accounts describe "$SA_EMAIL" --project="$PROJECT_ID"; then + log "creating service account $SA_NAME" + gcloud iam service-accounts create "$SA_NAME" \ + --project="$PROJECT_ID" \ + --display-name="Ephemeral regtest VM lifecycle for CI" +fi + +log "granting roles" +# instanceAdmin: create/delete VMs. securityAdmin: the per-run firewall rule. +# Both are broader than strictly needed — a custom role limited to +# compute.instances.* and compute.firewalls.* is worth doing for a shared project. +for role in roles/compute.instanceAdmin.v1 roles/compute.securityAdmin; do + gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA_EMAIL}" --role="$role" --condition=None >/dev/null +done + +# Creating an instance that runs as the default compute SA requires actAs on it. +# The VM itself needs no GCP access, so --no-service-account on the instance would +# remove this binding — worth doing if the project is ever shared. +gcloud iam service-accounts add-iam-policy-binding \ + "${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \ + --project="$PROJECT_ID" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role=roles/iam.serviceAccountUser >/dev/null + +if ! exists gcloud iam workload-identity-pools describe "$POOL" \ + --project="$PROJECT_ID" --location=global; then + log "creating workload identity pool $POOL" + gcloud iam workload-identity-pools create "$POOL" \ + --project="$PROJECT_ID" --location=global \ + --display-name="GitHub Actions" +fi + +if ! exists gcloud iam workload-identity-pools providers describe "$PROVIDER" \ + --project="$PROJECT_ID" --location=global --workload-identity-pool="$POOL"; then + log "creating OIDC provider $PROVIDER" + # The attribute-condition is the security boundary. Without it ANY GitHub repo + # in the world could mint tokens for this pool. + gcloud iam workload-identity-pools providers create-oidc "$PROVIDER" \ + --project="$PROJECT_ID" --location=global \ + --workload-identity-pool="$POOL" \ + --display-name="GitHub" \ + --issuer-uri="https://token.actions.githubusercontent.com" \ + --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \ + --attribute-condition="assertion.repository=='${REPO}'" +fi + +log "binding $REPO to $SA_EMAIL" +gcloud iam service-accounts add-iam-policy-binding "$SA_EMAIL" \ + --project="$PROJECT_ID" \ + --role=roles/iam.workloadIdentityUser \ + --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL}/attribute.repository/${REPO}" >/dev/null + +cat <