Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/actions/regtest-vm-down/action.yml
Original file line number Diff line number Diff line change
@@ -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
181 changes: 181 additions & 0 deletions .github/actions/regtest-vm-up/action.yml
Original file line number Diff line number Diff line change
@@ -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"
123 changes: 123 additions & 0 deletions .github/actions/regtest-vm-up/startup.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading