From 24eb3275338b78ff4dfb5fbe7c181e9397136ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:29:51 +0200 Subject: [PATCH 1/2] Add e2e tests on ci + commands to trigger it stack-info: PR: https://github.com/Elenpay/NodeGuard/pull/528, branch: Jossec101/stack/14 --- .github/workflows/docker.yaml | 62 ----------- .github/workflows/dotnet.yml | 162 ++++++++++++++++++++++++++--- .justfile | 11 +- Tiltfile | 15 ++- docker-compose.yml | 1 + docker/e2e/Dockerfile.runner | 12 +++ docker/e2e/README.md | 53 ++++++++++ docker/e2e/docker-compose.yml | 118 +++++++++++++++++++++ docker/e2e/extract-env.sh | 39 +++++++ docker/e2e/nodeguard-entrypoint.sh | 19 ++++ docker/e2e/setup-e2e.sh | 79 ++++++++++++++ 11 files changed, 493 insertions(+), 78 deletions(-) delete mode 100644 .github/workflows/docker.yaml create mode 100644 docker/e2e/Dockerfile.runner create mode 100644 docker/e2e/README.md create mode 100644 docker/e2e/docker-compose.yml create mode 100755 docker/e2e/extract-env.sh create mode 100755 docker/e2e/nodeguard-entrypoint.sh create mode 100755 docker/e2e/setup-e2e.sh diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml deleted file mode 100644 index cc8fc231..00000000 --- a/.github/workflows/docker.yaml +++ /dev/null @@ -1,62 +0,0 @@ -name: Docker image build -on: - workflow_dispatch: - push: - tags: - - '*' - branches: - - main - - master - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-docker: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Log into the Container registry - uses: docker/login-action@v2 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Docker meta - id: meta - uses: docker/metadata-action@v4 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - labels: | - commit=${{ github.sha }} - actions_run=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - - name: Build and push the Docker image - uses: docker/build-push-action@v4 - with: - context: . - file: src/Dockerfile - push: true - platforms: linux/amd64 - # sbom: true - provenance: false - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - - name: Scan SBOM - uses: anchore/scan-action@v3 - with: - image: ${{ steps.meta.outputs.tags }} - add-cpes-if-none: true - output-format: table - severity-cutoff: critical - # fail-build: true - fail-build: false \ No newline at end of file diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 37d8d47a..22ec1094 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,25 +1,161 @@ -name: Unit testing +name: CI +# Single pipeline: detect relevant changes → unit tests + e2e tests → build & push the image. +# The image build `needs` BOTH test jobs, so an image is only published when unit AND e2e pass. +# +# "Required only on code changes": the test/build jobs are gated on a paths filter via job-level +# `if` (NOT `on.paths`). On a docs-only PR the jobs are skipped, and GitHub treats a skipped +# required status check as passing — so unrelated PRs aren't blocked, while C#/version/dependency +# PRs must pass unit + e2e. on: pull_request: branches: [ main ] - types: [ opened, synchronize ] + types: [ opened, synchronize, reopened ] + push: + branches: [ main, master ] + tags: + - 'v[0-9]+.[0-9]+.[0-9]+*' # semver release tags, v-prefixed (v1.2.3, v1.2.3-rc1) + - '[0-9]+.[0-9]+.[0-9]+*' # semver release tags, bare (1.2.3) + workflow_dispatch: + +permissions: + contents: read + pull-requests: read # dorny/paths-filter uses the REST API on pull_request events + packages: write # push the image to ghcr + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: - build-and-test: + # Detects whether C# code, versions, or dependencies changed. Everything else gates on this. + changes: + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history so the filter can diff push events too + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 + id: filter + with: + filters: | + code: + # C# / generated code + - '**/*.cs' + - '**/*.razor' + - '**/*.cshtml' + - '**/*.proto' + # versions & dependencies + - '**/*.csproj' + - '*.sln' + - 'Directory.Build.props' + - 'Directory.Packages.props' + - '**/packages.lock.json' + - 'global.json' + - 'nuget.config' + - 'NuGet.config' + # build/e2e harness that exercises the above + - 'src/Dockerfile' + - 'docker-compose.yml' + - 'docker/**' + - '.github/workflows/dotnet.yml' + + unit-test: + needs: changes + # Run for code/version/dependency changes, and always on manual dispatch and tag (release) pushes. + if: ${{ needs.changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Restore + run: dotnet restore + - name: Unit tests (excludes e2e) + run: dotnet test --filter "Category!=E2E" + + e2e-test: + needs: changes + if: ${{ needs.changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: E2E (open channel via gRPC + circular rebalance against a live NodeGuard) + # Clean slate first (DbInitializer only funds the dev wallets on an empty DB), then `run` + # brings up the chain (bitcoind → alice/bob/carol → setup-e2e + extract-env → nodeguard → + # e2e-runner). The runner's exit code becomes the job result. + run: | + COMPOSE_PROFILES=polar,e2e docker compose -f docker-compose.yml down -v --remove-orphans || true + COMPOSE_PROFILES=polar,e2e docker compose -f docker-compose.yml run --rm --build e2e-runner + - name: Tear down + if: always() + run: COMPOSE_PROFILES=polar,e2e docker compose -f docker-compose.yml down -v --remove-orphans + # Build + push the image ONLY after unit + e2e pass, on: pushes to main/master (with relevant + # changes) and semver tag pushes (releases). Never on PRs or manual dispatch — those only test. + docker-build: + needs: [ changes, unit-test, e2e-test ] + # Test success is asserted explicitly so a future change to the test jobs' triggers can't + # silently let an image build (or silently skip it). + if: >- + ${{ !cancelled() + && needs.unit-test.result == 'success' + && needs.e2e-test.result == 'success' + && github.event_name == 'push' + && ( startsWith(github.ref, 'refs/tags/') + || ((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + && needs.changes.outputs.code == 'true') ) }} + runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: '8.0.x' + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # Branch pushes → branch name (+ `latest` on the default branch). Semver tag pushes → + # full version + major.minor + major (the `v` prefix is stripped by type=semver). + tags: | + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + labels: | + commit=${{ github.sha }} + actions_run=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - name: Restore dependencies - run: dotnet restore + - name: Build and push the Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: src/Dockerfile + push: true + platforms: linux/amd64 + provenance: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} - - name: Build and test - run: dotnet test + - name: Scan SBOM + uses: anchore/scan-action@3343887d815d7b07465f6fdcd395bd66508d486a # v3.6.4 + with: + image: ${{ steps.meta.outputs.tags }} + add-cpes-if-none: true + output-format: table + severity-cutoff: critical + fail-build: false diff --git a/.justfile b/.justfile index 0cf3ae06..3d6f7716 100644 --- a/.justfile +++ b/.justfile @@ -106,7 +106,16 @@ docker-down: # Stops the development docker containers and removes the volumes, add DOCKER_COMPOSE_FILE to override the default file docker-rm: - docker compose --profile polar --profile loop --profile 40swap -f {{DOCKER_COMPOSE_FILE}} down -v + docker compose --profile polar --profile loop --profile 40swap --profile e2e --profile mempool -f {{DOCKER_COMPOSE_FILE}} down -v + +# Runs the option-B end-to-end rebalance test in containers: brings up the regtest stack + a live +# NodeGuard, then the runner opens a channel via gRPC and rebalances. Exit code = test result. +# Starts from a CLEAN slate (down -v first) — DbInitializer only funds the dev wallets when the DB +# has none, so a stale postgres volume would leave the wallet unfunded ("no UTXOs" on OpenChannel). +test-e2e: + -docker compose --profile polar --profile e2e -f {{DOCKER_COMPOSE_FILE}} down -v --remove-orphans + docker compose --profile polar --profile e2e -f {{DOCKER_COMPOSE_FILE}} run --rm --build e2e-runner + docker compose --profile polar --profile e2e -f {{DOCKER_COMPOSE_FILE}} down -v --remove-orphans ########## # Dapr # diff --git a/Tiltfile b/Tiltfile index 64a44181..de63605a 100644 --- a/Tiltfile +++ b/Tiltfile @@ -2,7 +2,7 @@ ci_settings(readiness_timeout = '10m') docker_compose([ "./docker-compose.yml", -], profiles = ["polar", "loop", "mempool", "40swap"]) +], profiles = ["polar", "loop", "mempool", "40swap", "e2e"]) # Labels are used to group containers on the UI. labels = { @@ -45,16 +45,27 @@ labels = { 'grafana': [ 'grafana', ], + + # Option-B end-to-end stack (live NodeGuard + test runner). Disabled by default — manual + # trigger only, consistent with NodeGuard itself not being Tilt-managed. Run via `just test-e2e`. + 'e2e': [ + 'setup-e2e', + 'extract-env', + 'nodeguard', + 'e2e-runner', + ], } for (label, services) in labels.items(): for s in services: - # Mempool and 40swap-frontend services are included but stopped by default (auto_init=False) + # Mempool, 40swap, grafana and e2e services are included but stopped by default (auto_init=False) if label == 'mempool': dc_resource(s, auto_init=False, labels = [label]) elif label == '40swap': dc_resource(s, auto_init=False, labels = [label]) elif label == 'grafana': dc_resource(s, auto_init=False, labels = [label]) + elif label == 'e2e': + dc_resource(s, auto_init=False, labels = [label]) else: dc_resource(s, labels = [label]) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 91cbac56..0b2e3ba3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,3 +7,4 @@ include: - ./docker/mempool/docker-compose.yml - ./docker/40swap/docker-compose.yml - ./docker/grafana/docker-compose.yml + - ./docker/e2e/docker-compose.yml diff --git a/docker/e2e/Dockerfile.runner b/docker/e2e/Dockerfile.runner new file mode 100644 index 00000000..af26fec3 --- /dev/null +++ b/docker/e2e/Dockerfile.runner @@ -0,0 +1,12 @@ +# E2E test runner image. Runs the [Category=E2E] tests, which drive a live NodeGuard purely +# over gRPC and mine via NBitcoin's RPCClient — so the runner needs nothing but the .NET SDK +# and the repo (no grpcurl/curl). Build context = repo root. +FROM mcr.microsoft.com/dotnet/sdk:10.0 +WORKDIR /src +COPY . . +RUN dotnet restore test/NodeGuard.Tests/NodeGuard.Tests.csproj + +# Forces [E2EFact] on regardless of the reachability probe (the runner targets nodeguard:50051). +ENV RUN_E2E_TESTS=1 +ENTRYPOINT ["dotnet", "test", "test/NodeGuard.Tests/NodeGuard.Tests.csproj", \ + "--filter", "Category=E2E", "--logger", "console;verbosity=detailed"] diff --git a/docker/e2e/README.md b/docker/e2e/README.md new file mode 100644 index 00000000..bcbe5c69 --- /dev/null +++ b/docker/e2e/README.md @@ -0,0 +1,53 @@ +# Containerized rebalance e2e (option B) + +A true end-to-end test of Lightning rebalancing: a .NET test runner drives a **live NodeGuard** +entirely over gRPC — it **opens the source channel via NodeGuard's `OpenChannel` API** (so the e2e +also covers channel opening), mines via NBitcoin's `RPCClient`, then performs a circular rebalance +Alice→Bob→Carol→Alice and asserts success. + +## Run it + +```bash +just test-e2e # or: COMPOSE_PROFILES=polar,e2e docker compose run --rm --build e2e-runner +``` + +Everything is profile-gated (`e2e`), so a normal `tilt up` / `docker compose up` ignores it. In the +Tilt UI the e2e services appear under the `e2e` label, disabled (manual trigger only). + +## Pieces + +| File | Role | +|------|------| +| `setup-e2e.sh` | Loads the bitcoind wallet, funds the LND nodes, opens **only** Bob→Carol + Carol→Alice (NodeGuard opens Alice→Bob). docker.sock pattern, like the polar `setup` service. | +| `extract-env.sh` | Writes `nodeguard-macaroons.env` (LND host/macaroon/pubkey) from the mounted LND data volumes + a network `lncli getinfo`. The LND certs include the service-name SANs (`alice`/`bob`/`carol`), so TLS verifies. | +| `nodeguard-entrypoint.sh` | Sources that env file, then launches NodeGuard. | +| `Dockerfile.runner` | .NET SDK image that runs `dotnet test --filter Category=E2E`. The test does the gRPC + mining itself — no grpcurl/curl. | +| `docker-compose.yml` | Wires `setup-e2e` + `extract-env` → `nodeguard` → `e2e-runner` with the right `depends_on` ordering. | + +The test itself is `test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs`, gated by `[E2EFact]` (runs when a +NodeGuard gRPC is reachable on `NODEGUARD_GRPC_ENDPOINT`, or `RUN_E2E_TESTS=1`). + +## Status / shakeout notes + +Every **runtime step** was validated manually against a live NodeGuard (open channel via gRPC → +`ONCHAIN_CONFIRMED` → rebalance `Succeeded`, 500 sat fee). The **compose wiring** has not yet been run +as a full stack in CI; watch these on the first CI run: + +- **App config env (fixed):** the published image (`dotnet NodeGuard.dll`) does NOT read + `launchSettings.json`, so `Constants`' required vars (`MINIMUM_CHANNEL_CAPACITY_SATS`, + `TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS`, `ANCHOR_CLOSINGS_MINIMUM_SATS`, `DEFAULT_DERIVATION_PATH`, + `NBXPLORER_URI`, …) are set explicitly on the `nodeguard` service. Keep them in sync with the dev + launch profile. +- **Clean slate required (fixed):** `DbInitializer` only funds the dev wallets when the DB has none + (`!Wallets.Any()`). A stale postgres volume from an interrupted run leaves the wallet rows present + but unfunded against the fresh chain → `OpenChannel` fails with "Error generating template PSBT" + (no UTXOs). `just test-e2e` and the CI job now `down -v` before running. +- **Critical ordering:** `nodeguard` must start only after `setup-e2e` completes — NodeGuard funds its + hot wallet via bitcoind RPC, which fails ("No wallet is loaded") if the bitcoind wallet isn't loaded + yet. The `depends_on: service_completed_successfully` enforces this. +- **Shared volumes** (`alice_lnd_data`/`bob_lnd_data`/`carol_lnd_data`) are declared in the polar + compose; they're referenced here by name within the same merged project. +- **`nonroot` entrypoint:** the NodeGuard image runs as uid 65532; the entrypoint wrapper + `/shared` + mount must be readable by it. +- **docker.sock** must be available to `setup-e2e` (it is on GitHub-hosted runners). +- **Hot wallet id** is assumed to be `3` (the dev single-sig wallet); override via `E2E_HOT_WALLET_ID`. diff --git a/docker/e2e/docker-compose.yml b/docker/e2e/docker-compose.yml new file mode 100644 index 00000000..23f0547c --- /dev/null +++ b/docker/e2e/docker-compose.yml @@ -0,0 +1,118 @@ +# Option-B end-to-end stack: a LIVE NodeGuard whose gRPC API is driven by a .NET test runner. +# All services are profile-gated ([e2e]) so a normal `tilt up` / `docker compose up` ignores them. +# +# Flow (depends_on order): bitcoind + alice/bob/carol (from the polar stack) → +# setup-e2e (loads bitcoind wallet, funds nodes, opens Bob→Carol + Carol→Alice — NOT Alice→Bob) + +# extract-env (writes the LND macaroon/host/pubkey env from the mounted data volumes) → +# nodeguard (seeds nodes, funds its hot wallet) → +# e2e-runner (opens Alice→Bob via gRPC, mines, then rebalances and asserts). +# +# NOTE: built but not yet runtime-verified as a stack (see docker/e2e/README.md) — every runtime +# STEP was validated manually against a live NodeGuard; the compose wiring needs a CI shakeout. + +services: + # Loads the bitcoind wallet + funds LND + opens the two non-source channels. docker.sock pattern + # mirrors the polar `setup` service. Must complete before nodeguard boots (NodeGuard funds its + # hot wallet via bitcoind RPC, which needs a loaded wallet). + setup-e2e: + profiles: [e2e] + image: alpine:latest + depends_on: + bitcoind: { condition: service_started } + alice: { condition: service_healthy } + bob: { condition: service_healthy } + carol: { condition: service_healthy } + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./setup-e2e.sh:/setup-e2e.sh:ro + command: sh -c "apk add --no-cache docker-cli jq && sh /setup-e2e.sh" + restart: "no" + + # Writes /shared/nodeguard-macaroons.env (LND hosts/macaroons/pubkeys) from the mounted LND data + # volumes + a network `lncli getinfo` (the LND certs include the service-name SANs). + extract-env: + profiles: [e2e] + image: lightninglabs/lndinit:v0.1.34-beta-lnd-v0.20.1-beta + depends_on: + alice: { condition: service_healthy } + bob: { condition: service_healthy } + carol: { condition: service_healthy } + entrypoint: ["sh", "/scripts/extract-env.sh"] + volumes: + - alice_lnd_data:/lnd/alice:ro + - bob_lnd_data:/lnd/bob:ro + - carol_lnd_data:/lnd/carol:ro + - ./extract-env.sh:/scripts/extract-env.sh:ro + - e2e_env:/shared + restart: "no" + + # The app under test. Built from the production src/Dockerfile; an entrypoint wrapper sources the + # LND env file before launching. Reaches LND by service name (TLS ignored by LightningClientService). + nodeguard: + profiles: [e2e] + build: + context: ../.. + dockerfile: src/Dockerfile + depends_on: + postgres: { condition: service_healthy } + nbxplorer: { condition: service_healthy } + setup-e2e: { condition: service_completed_successfully } + extract-env: { condition: service_completed_successfully } + # The published image (dotnet NodeGuard.dll) does NOT read launchSettings.json, so every env + # var the dev profile normally provides must be set here, container-adjusted (service names). + # ALICE/BOB/CAROL_HOST + *_MACAROON + *_PUBKEY are sourced from extract-env (overrides any here). + environment: + IS_DEV_ENVIRONMENT: "true" + ASPNETCORE_ENVIRONMENT: "Development" + HTTP1_LISTEN_PORT: "8080" + BITCOIN_NETWORK: "REGTEST" + POSTGRES_CONNECTIONSTRING: "Host=postgres;Port=5432;Database=nodeguard;User ID=postgres;" + NBXPLORER_URI: "http://nbxplorer:32838" + NBXPLORER_ENABLE_CUSTOM_BACKEND: "true" + NBXPLORER_BTCRPCURL: "http://bitcoind:18443" + NBXPLORER_BTCRPCUSER: "polaruser" + NBXPLORER_BTCRPCPASSWORD: "polarpass" + NBXPLORER_BTCNODEENDPOINT: "bitcoind:18444" + MINIMUM_CHANNEL_CAPACITY_SATS: "20000" + MINIMUM_WITHDRAWAL_BTC_AMOUNT: "0.001" + MAXIMUM_WITHDRAWAL_BTC_AMOUNT: "21000000" + ANCHOR_CLOSINGS_MINIMUM_SATS: "100000" + TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS: "6" + DEFAULT_DERIVATION_PATH: "m/48'/1'" + ENABLE_HW_SUPPORT: "false" + ENABLE_REMOTE_SIGNER: "false" + PUSH_NOTIFICATIONS_ONESIGNAL_ENABLED: "false" + # GenerateTemplatePSBT always calls GetMempoolRecommendedFeesAsync (even for CUSTOM_FEE, whose + # result is then discarded), which throws if MEMPOOL_ENDPOINT is unset. Match the dev profile. + MEMPOOL_ENDPOINT: "https://mempool.space" + AMBOSS_ENDPOINT: "https://amboss.space" + COINGECKO_ENDPOINT: "https://pro-api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=bitcoin" + API_TOKEN_SALT: "H/fCx1+maAFMcdi6idIYEg==" + FUNDSMANAGER_ENDPOINT: "http://nodeguard:8080" + entrypoint: ["/bin/sh", "/scripts/nodeguard-entrypoint.sh"] + volumes: + - ./nodeguard-entrypoint.sh:/scripts/nodeguard-entrypoint.sh:ro + - e2e_env:/shared:ro + restart: "no" + + # Drives NodeGuard's gRPC: OpenChannel(Alice→Bob) → mine + poll → RequestRebalance → assert. + e2e-runner: + profiles: [e2e] + build: + context: ../.. + dockerfile: docker/e2e/Dockerfile.runner + depends_on: + nodeguard: { condition: service_started } + environment: + RUN_E2E_TESTS: "1" + NODEGUARD_GRPC_ENDPOINT: "http://nodeguard:50051" + NODEGUARD_API_TOKEN: "8rvSsUGeyXXdDQrHctcTey/xtHdZQEn945KHwccKp9Q=" + BITCOIND_RPC_URL: "http://bitcoind:18443" + BITCOIND_RPC_USER: "polaruser" + BITCOIND_RPC_PASS: "polarpass" + BITCOIND_RPC_WALLET: "default" + E2E_HOT_WALLET_ID: "3" + restart: "no" + +volumes: + e2e_env: diff --git a/docker/e2e/extract-env.sh b/docker/e2e/extract-env.sh new file mode 100755 index 00000000..a5046ff6 --- /dev/null +++ b/docker/e2e/extract-env.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# +# Writes nodeguard-macaroons.env for the e2e NodeGuard container, using the LND data volumes +# mounted read-only (creds) + the LND gRPC over the compose network (for the node pubkey). +# No docker.sock. Runs in the LND image (has lncli + busybox od). +# +# Output (consumed by the nodeguard entrypoint via `set -a; . file`): +# _HOST internal compose service name :10009 (e.g. alice:10009) +# _MACAROON admin.macaroon hex +# _PUBKEY identity pubkey (from `lncli getinfo` over the network) +set -e + +OUT=/shared/nodeguard-macaroons.env +: > "$OUT" + +emit() { # emit + name=$1; svc=$2; dir=$3 + mac="$dir/data/chain/bitcoin/regtest/admin.macaroon" + tls="$dir/tls.cert" + # Wait for LND to answer (it may still be starting). + until lncli -n regtest --rpcserver="$svc:10009" --macaroonpath "$mac" --tlscertpath "$tls" getinfo >/tmp/info 2>/dev/null; do + echo "waiting for $svc getinfo..."; sleep 2 + done + pubkey=$(grep identity_pubkey /tmp/info | head -1 | cut -d'"' -f4) + hex=$(od -A n -v -t x1 "$mac" | tr -d ' \n') + { + echo "${name}_HOST=\"$svc:10009\"" + echo "${name}_MACAROON=\"$hex\"" + echo "${name}_PUBKEY=\"$pubkey\"" + } >> "$OUT" + echo "wrote $name (pubkey=$pubkey)" +} + +emit ALICE alice /lnd/alice +emit BOB bob /lnd/bob +emit CAROL carol /lnd/carol + +echo "=== $OUT ===" +cat "$OUT" diff --git a/docker/e2e/nodeguard-entrypoint.sh b/docker/e2e/nodeguard-entrypoint.sh new file mode 100755 index 00000000..5e5a8c0a --- /dev/null +++ b/docker/e2e/nodeguard-entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# +# Entrypoint wrapper for the e2e NodeGuard container. Sources the env file produced by the +# extract-env service (LND hosts/macaroons/pubkeys from the mounted data volumes) so NodeGuard's +# Constants pick them up as real environment variables, then launches the app. +set -e + +ENV_FILE=/shared/nodeguard-macaroons.env +echo "Waiting for $ENV_FILE from extract-env..." +i=0 +until [ -s "$ENV_FILE" ] || [ $i -ge 60 ]; do i=$((i+1)); sleep 2; done +if [ -s "$ENV_FILE" ]; then + set -a; . "$ENV_FILE"; set +a + echo "Loaded LND connection env for ALICE/BOB/CAROL." +else + echo "WARNING: $ENV_FILE missing/empty; NodeGuard may fail to reach LND." >&2 +fi + +exec dotnet NodeGuard.dll diff --git a/docker/e2e/setup-e2e.sh b/docker/e2e/setup-e2e.sh new file mode 100755 index 00000000..eb65b7e7 --- /dev/null +++ b/docker/e2e/setup-e2e.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# +# E2E variant of docker/bitcoin/setup.sh for the OPTION-B rebalance e2e. +# +# Difference from setup.sh: it does NOT open the Alice->Bob channel — that channel is opened +# by NodeGuard itself via the gRPC OpenChannel API (so the e2e also covers channel opening). +# It still loads the bitcoind wallet, funds the LND nodes, opens Bob->Carol and Carol->Alice +# (the rest of the rebalance cycle), sets fee policies, and mines for confirmation + gossip. +# +# Runs in a container that has docker.sock + docker-cli (same pattern as the polar `setup` +# service), so it can `docker exec` into the polar containers. + +ALICE=polar-n1-alice +BOB=polar-n1-bob +CAROL=polar-n1-carol +BACKEND=polar-n1-backend + +lncli() { + node=$1; shift; args=$@ + while true; do + r=$(docker exec $node lncli -n regtest --tlscertpath /root/.lnd/tls.cert --macaroonpath /root/.lnd/data/chain/bitcoin/regtest/admin.macaroon $args) + e=$? + [ $e -eq 0 ] && echo $r && return 0 + >&2 echo "Command failed retrying..." + sleep 1 + done +} +node_pubkey() { lncli $1 getinfo | jq -r .identity_pubkey; } +new_lnd_address() { lncli $1 newaddress p2wkh | jq -r .address; } +bitcoin_cli() { docker exec $BACKEND bitcoin-cli -regtest -rpcuser=polaruser -rpcpassword=polarpass -rpcwallet=default $@; } + +echo "Ensuring a single loaded 'default' wallet on bitcoind" +docker exec $BACKEND bitcoin-cli -regtest -rpcuser=polaruser -rpcpassword=polarpass unloadwallet "" || true +WALLETS=$(bitcoin_cli listwallets | jq -r '.[] | select(. != "")') +for wallet in $WALLETS; do bitcoin_cli unloadwallet "$wallet"; done +bitcoin_cli createwallet default || true +bitcoin_cli loadwallet default || true + +echo "Funding Alice, Bob and Carol" +bitcoin_cli generatetoaddress 5 $(new_lnd_address $ALICE) +bitcoin_cli generatetoaddress 5 $(new_lnd_address $BOB) +bitcoin_cli generatetoaddress 5 $(new_lnd_address $CAROL) +echo "Maturing blocks" +bitcoin_cli -generate 100 > /dev/null + +ALICE_PUBKEY=$(node_pubkey $ALICE) +BOB_PUBKEY=$(node_pubkey $BOB) +CAROL_PUBKEY=$(node_pubkey $CAROL) + +# Same fee-policy topology as setup.sh; only the Alice->Bob open is omitted (NodeGuard opens it). +BOB_TO_CAROL_LOCAL=16000000 +BOB_TO_CAROL_PUSH=6000000 +CAROL_TO_ALICE_LOCAL=16000000 +CAROL_TO_ALICE_PUSH=10000000 + +echo "Opening Bob -> Carol (middle hop)" +lncli $BOB openchannel --connect $CAROL:9735 $CAROL_PUBKEY --local_amt $BOB_TO_CAROL_LOCAL --push_amt $BOB_TO_CAROL_PUSH + +echo "Opening Carol -> Alice (return path)" +lncli $CAROL openchannel --connect $ALICE:9735 $ALICE_PUBKEY --local_amt $CAROL_TO_ALICE_LOCAL --push_amt $CAROL_TO_ALICE_PUSH + +# NodeGuard opens the Alice->Bob channel itself (option B), so it never ran an `openchannel --connect` +# to learn Bob's address. Bob's node_announcement (with address) doesn't reach Alice over gossip when +# they share no channel, so pre-connect them here — NodeGuard's ConnectToPeer then sees them connected. +echo "Connecting Alice -> Bob as peers" +lncli $ALICE connect $BOB_PUBKEY@bob:9735 || true + +echo "Confirming channels" +bitcoin_cli -generate 6 > /dev/null + +# Distinct outbound fees so routing cost is visible: Bob 400 ppm + Carol 600 ppm = ~1000 ppm. +echo "Setting outbound fee policies" +lncli $ALICE updatechanpolicy --base_fee_msat 0 --fee_rate_ppm 1000 --time_lock_delta 40 +lncli $BOB updatechanpolicy --base_fee_msat 0 --fee_rate_ppm 400 --time_lock_delta 40 +lncli $CAROL updatechanpolicy --base_fee_msat 0 --fee_rate_ppm 600 --time_lock_delta 40 + +echo "Mining gossip blocks" +bitcoin_cli -generate 3 > /dev/null +echo "setup-e2e done (Alice->Bob is opened later by NodeGuard via gRPC)" From 673d2e510f850e2c233a86839db677bfda508262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:15:01 +0200 Subject: [PATCH 2/2] Update CI workflow to enhance e2e test triggering and concurrency management --- .github/workflows/dotnet.yml | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 22ec1094..9de51eb5 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -6,11 +6,16 @@ name: CI # "Required only on code changes": the test/build jobs are gated on a paths filter via job-level # `if` (NOT `on.paths`). On a docs-only PR the jobs are skipped, and GitHub treats a skipped # required status check as passing — so unrelated PRs aren't blocked, while C#/version/dependency -# PRs must pass unit + e2e. +# PRs must pass unit tests. +# +# e2e is opt-in per PR via the `run-e2e-tests` label: it runs only while that label is present. +# `labeled`/`unlabeled` are in the trigger list so adding the label starts a run and removing it +# starts a fresh run where e2e is skipped; the `concurrency` block cancels the in-flight run so a +# still-running e2e is stopped when the label is removed. on: pull_request: branches: [ main ] - types: [ opened, synchronize, reopened ] + types: [ opened, synchronize, reopened, labeled, unlabeled ] push: branches: [ main, master ] tags: @@ -27,6 +32,12 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} +# One in-flight run per PR (or per ref for pushes). Removing the `run-e2e-tests` label triggers a +# new run that cancels the previous one, stopping an e2e job that was still executing. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: # Detects whether C# code, versions, or dependencies changed. Everything else gates on this. changes: @@ -79,11 +90,19 @@ jobs: e2e-test: needs: changes - if: ${{ needs.changes.outputs.code == 'true' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }} + # Same code/dispatch/tag gate as unit tests, but on PRs it additionally requires the + # `run-e2e-tests` label. Adding the label starts an e2e run; removing it re-runs the workflow + # with e2e skipped (a skipped required check counts as passing, so the PR isn't blocked). + if: >- + ${{ ( needs.changes.outputs.code == 'true' + && ( github.event_name != 'pull_request' + || contains(github.event.pull_request.labels.*.name, 'run-e2e-tests') ) ) + || github.event_name == 'workflow_dispatch' + || startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: E2E (open channel via gRPC + circular rebalance against a live NodeGuard) + - name: Run e2e tests # Clean slate first (DbInitializer only funds the dev wallets on an empty DB), then `run` # brings up the chain (bitcoind → alice/bob/carol → setup-e2e + extract-env → nodeguard → # e2e-runner). The runner's exit code becomes the job result.