From e8456436958c5e2efb6d632dcdc92ca2689df705 Mon Sep 17 00:00:00 2001 From: sudheer-monta Date: Tue, 21 Jul 2026 05:59:23 +0200 Subject: [PATCH] fix(sonar): wait for tailnet path to SonarQube before scanning `tailscale up` returns before the DERP/route path has settled, so the scanner's first call (GET /api/v2/analysis/version) can hit a half-open path and fail fast with "Failed to query server version ... failed: null". The scanner's default connect timeout is only 5s, so this surfaces as a flaky Sonar failure requiring several CI retries across Kotlin services (ocpp, notifications, data-broker, alerts, ...). Port the hardening Jonas landed in infra-portal#1179 into the two shared reusable workflows so every consumer (@main) picks it up at once: add a curl probe that polls the exact health endpoint until reachable before the scan runs. This both waits out the race and warms the relay path. Guarded with the same conditions as the scan step and `continue-on-error: sonar-non-blocking`, so it composes with the existing non-blocking behaviour (#320/#321) and the SonarCloud path when Tailscale is skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pull-request-kotlin.yml | 44 +++++++++++++++++++++++ .github/workflows/sonar-cloud.yml | 44 +++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/.github/workflows/pull-request-kotlin.yml b/.github/workflows/pull-request-kotlin.yml index 776a32d..e0b46f0 100644 --- a/.github/workflows/pull-request-kotlin.yml +++ b/.github/workflows/pull-request-kotlin.yml @@ -203,6 +203,50 @@ jobs: authkey: ${{ env.TAILSCALE_AUTHKEY }} hostname: "github-${{ github.run_id }}" args: "--login-server https://headscale.monta.com --accept-routes" + # Wait for the tailnet path to SonarQube to actually be usable before + # scanning. `tailscale up` returns before DERP/route programming has + # settled, so the scanner's first call (GET /api/v2/analysis/version) + # can hit a half-open path and fail fast with "failed: null" — the + # scanner's default connect timeout is only 5s. Polling that exact + # endpoint until it answers both waits out the race AND warms the + # relay path, so the scan starts on a connection we know works. + - name: Wait for SonarQube to be reachable over the tailnet + if: ${{ !inputs.skip-sonar && steps.tailscale.outcome != 'failure' }} + continue-on-error: ${{ inputs.sonar-non-blocking }} + shell: bash + env: + SONAR_HOST_URL: https://sonarqube.vpn.internal.monta.app/ + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + BASE="${SONAR_HOST_URL%/}" # strip trailing slash -> no /api//... + HEALTH_URL="${BASE}/api/v2/analysis/version" + + MAX_ATTEMPTS=18 # exits early on first success; ~3-4 min worst case + CONNECT_TIMEOUT=10 # seconds for the TCP/TLS connect phase per attempt + SLEEP_BETWEEN=5 # seconds between attempts + + echo "Probing ${HEALTH_URL} until reachable…" + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + # --fail -> non-2xx counts as a miss (retry) + # --connect-timeout -> bounds the connect phase (the thing that times out) + # -u "${TOKEN}:" -> token as basic-auth user; harmless if the endpoint + # is anonymous, required if "Force user authentication" + # is enabled. + if curl --silent --show-error --fail \ + --connect-timeout "$CONNECT_TIMEOUT" \ + --max-time "$(( CONNECT_TIMEOUT + 5 ))" \ + -u "${SONAR_TOKEN}:" \ + -o /dev/null \ + "$HEALTH_URL"; then + echo "✅ Reachable on attempt ${attempt}/${MAX_ATTEMPTS}" + exit 0 + fi + echo "… attempt ${attempt}/${MAX_ATTEMPTS} missed; retry in ${SLEEP_BETWEEN}s" + sleep "$SLEEP_BETWEEN" + done + + echo "::error::SonarQube unreachable over the tailnet after ${MAX_ATTEMPTS} attempts — likely DERP/route health. Check the 'tailscale status' / 'tailscale ping' output above." + exit 1 # != 'failure' (not == 'success') keeps the SonarCloud path when Tailscale is skipped. - name: Upload results to SonarQube if: ${{ !inputs.skip-sonar && steps.tailscale.outcome != 'failure' }} diff --git a/.github/workflows/sonar-cloud.yml b/.github/workflows/sonar-cloud.yml index d48af03..97a644d 100644 --- a/.github/workflows/sonar-cloud.yml +++ b/.github/workflows/sonar-cloud.yml @@ -118,6 +118,50 @@ jobs: gradle-module: ${{ inputs.gradle-module }} gradle-tasks: 'test koverXmlReport' gradle-args: ${{ inputs.gradle-args }} + # Wait for the tailnet path to SonarQube to actually be usable before + # scanning. `tailscale up` returns before DERP/route programming has + # settled, so the scanner's first call (GET /api/v2/analysis/version) + # can hit a half-open path and fail fast with "failed: null" — the + # scanner's default connect timeout is only 5s. Polling that exact + # endpoint until it answers both waits out the race AND warms the + # relay path, so the scan starts on a connection we know works. + - name: Wait for SonarQube to be reachable over the tailnet + if: ${{ steps.tailscale.outcome != 'failure' && env.TAILSCALE_AUTHKEY != '' }} + continue-on-error: ${{ inputs.sonar-non-blocking }} + shell: bash + env: + SONAR_HOST_URL: https://sonarqube.vpn.internal.monta.app/ + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + BASE="${SONAR_HOST_URL%/}" # strip trailing slash -> no /api//... + HEALTH_URL="${BASE}/api/v2/analysis/version" + + MAX_ATTEMPTS=18 # exits early on first success; ~3-4 min worst case + CONNECT_TIMEOUT=10 # seconds for the TCP/TLS connect phase per attempt + SLEEP_BETWEEN=5 # seconds between attempts + + echo "Probing ${HEALTH_URL} until reachable…" + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + # --fail -> non-2xx counts as a miss (retry) + # --connect-timeout -> bounds the connect phase (the thing that times out) + # -u "${TOKEN}:" -> token as basic-auth user; harmless if the endpoint + # is anonymous, required if "Force user authentication" + # is enabled. + if curl --silent --show-error --fail \ + --connect-timeout "$CONNECT_TIMEOUT" \ + --max-time "$(( CONNECT_TIMEOUT + 5 ))" \ + -u "${SONAR_TOKEN}:" \ + -o /dev/null \ + "$HEALTH_URL"; then + echo "✅ Reachable on attempt ${attempt}/${MAX_ATTEMPTS}" + exit 0 + fi + echo "… attempt ${attempt}/${MAX_ATTEMPTS} missed; retry in ${SLEEP_BETWEEN}s" + sleep "$SLEEP_BETWEEN" + done + + echo "::error::SonarQube unreachable over the tailnet after ${MAX_ATTEMPTS} attempts — likely DERP/route health. Check the 'tailscale status' / 'tailscale ping' output above." + exit 1 # != 'failure' (not == 'success') keeps the SonarCloud path when Tailscale is skipped. - name: Analyze with SonarQube if: ${{ steps.tailscale.outcome != 'failure' }}