From 1a836168895cd34061ca67c614eb8195b3ad64f1 Mon Sep 17 00:00:00 2001 From: Maxim Dozhdev Date: Fri, 21 Aug 2026 18:04:11 +0200 Subject: [PATCH] test: run the regtest stack on an ephemeral VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS e2e suite needs the regtest stack and the iOS Simulator on one machine, because the app reaches Electrum and homegate on 127.0.0.1. GitHub-hosted macOS runners cannot run Docker — Apple's Virtualization framework has no nested virtualization for macOS guests — so the suite runs on a self-hosted Mac, serially, at ~2h per run against ~20m for the same tests on Linux. Add tooling to run the stack on a short-lived GCP VM instead, so the Mac only has to do the one thing only a Mac can do. regtest-vm-up provisions a VM, opens a firewall scoped to the runner's egress /32, waits until the stack actually serves rather than merely listens startup.sh runs on the VM: installs Docker, unpacks the stack from instance metadata, starts it, publishes LND's tls.cert and admin.macaroon on a random path regtest-vm-down deletes the VM and the rule regtest-reaper sweeps orphaned firewall rules, which have no TTL regtest-vm-smoke exercises the above without running the suite setup-wif.sh one-off Workload Identity Federation setup The stack travels as a base64 tarball in instance metadata rather than being cloned, so the VM needs no repository access and no token, and the stack always matches the checkout the tests run from. Instances carry --max-run-duration with --instance-termination-action=DELETE, so GCE removes them even if teardown never runs. Four changes let the tests and the app address a non-local stack, all keeping their current defaults: docker-compose.yml LND advertises LND_EXTERNAL_IP, not 127.0.0.1 constants.ts lndConfig host, ports and credential paths from env lnd.ts connectToLND uses the configured host wdio.conf.ts forwards E2E_LOCAL_HOST into the app's launch environment, which is how a build made before the VM existed learns its address Verified on a GitHub-hosted macOS runner: VM provisioned, stack reached, credentials fetched, and a request from inside a booted Simulator recorded in the VM's own access log. Co-Authored-By: Claude Opus 5 --- .github/actions/regtest-vm-down/action.yml | 69 ++++++++ .github/actions/regtest-vm-up/action.yml | 181 +++++++++++++++++++++ .github/actions/regtest-vm-up/startup.sh | 123 ++++++++++++++ .github/workflows/regtest-reaper.yml | 95 +++++++++++ .github/workflows/regtest-vm-smoke.yml | 149 +++++++++++++++++ ci/regtest-vm/setup-wif.sh | 102 ++++++++++++ docker/docker-compose.yml | 4 +- test/helpers/constants.ts | 14 +- test/helpers/lnd.ts | 6 +- wdio.conf.ts | 7 + 10 files changed, 740 insertions(+), 10 deletions(-) create mode 100644 .github/actions/regtest-vm-down/action.yml create mode 100644 .github/actions/regtest-vm-up/action.yml create mode 100755 .github/actions/regtest-vm-up/startup.sh create mode 100644 .github/workflows/regtest-reaper.yml create mode 100644 .github/workflows/regtest-vm-smoke.yml create mode 100755 ci/regtest-vm/setup-wif.sh 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 <