Skip to content

fix(ci): harden GKE readiness via /healthcheck probes - #5211

Open
zdrapela wants to merge 2 commits into
redhat-developer:release-1.10from
zdrapela:fix/rhdhbugs-3508-gke-readiness-1.10
Open

fix(ci): harden GKE readiness via /healthcheck probes#5211
zdrapela wants to merge 2 commits into
redhat-developer:release-1.10from
zdrapela:fix/rhdhbugs-3508-gke-readiness-1.10

Conversation

@zdrapela

@zdrapela zdrapela commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

  • Probe GET /healthcheck (not HEAD /) in testing::check_backstage_running so CI readiness matches what Playwright uses and retries through transient TLS/connect failures.
  • Re-probe /healthcheck for up to ~2 minutes immediately before yarn playwright test after yarn install.
  • Harden instance-health-check.spec.ts with expect.poll so a single TLS socket disconnect does not fail the test (RHDHBUGS-3508).

5 runs in a row succeeded: https://prow.ci.openshift.org/job-history/gs/test-platform-results/pr-logs/directory/pull-ci-redhat-developer-rhdh-release-1.10-e2e-gke-helm-nightly

Test plan

  • bash -n .ci/pipelines/lib/testing.sh
  • pre-commit: shellcheck + e2e lint/tsc/prettier
  • Wait for GKE Helm nightly / rehearse on this PR if available
  • Confirm nightlies no longer fail solely on early /healthcheck TLS disconnects

Made with Cursor

Probe GET /healthcheck (not HEAD /) during deploy readiness and again
right before Playwright so transient TLS disconnects retry within the
existing budget. Also poll the instance health-check spec through
transport failures (RHDHBUGS-3508).
@openshift-ci
openshift-ci Bot requested review from josephca and sanketpathak August 4, 2026 12:33
@rhdh-qodo-merge

Copy link
Copy Markdown

PR Summary by Qodo

Harden CI/GKE readiness by probing GET /healthcheck and polling Playwright health test

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Switch CI readiness checks to GET /healthcheck JSON liveness instead of HEAD /
• Re-check /healthcheck immediately before Playwright to absorb transient GKE TLS disconnects
• Poll the Playwright instance health test so transport flakiness doesn’t fail the suite
Diagram

graph TD
  CI["CI e2e job"] --> SH["testing.sh"] --> WAIT["wait_for_healthcheck"] --> HC["GET /healthcheck"] --> APP["RHDH instance"]
  SH --> PW["Playwright run"] --> SPEC["health-check spec"] --> POLL["expect.poll"] --> HC
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use curl built-in retry flags
  • ➕ Less custom shell logic (e.g., curl --retry/--retry-connrefused)
  • ➕ Standardized behavior and potentially better handling of specific network errors
  • ➖ Retry semantics can be harder to reason about for mixed failure modes (HTTP body vs TLS/connect)
  • ➖ May still need response-body validation logic on top
2. Introduce a Playwright globalSetup readiness gate
  • ➕ Single source of truth for readiness immediately before tests
  • ➕ Keeps readiness logic closer to the test harness
  • ➖ Not available/standard on the release-1.10 branch per PR context
  • ➖ Requires wider harness changes and may complicate backporting
3. Rely on Kubernetes/Ingress readiness (no extra probes)
  • ➕ Avoids duplicating readiness logic in CI scripts/tests
  • ➕ Uses platform-native readiness signals
  • ➖ Doesn’t guarantee the app-level /healthcheck JSON is stable
  • ➖ Still vulnerable to transient client-side TLS/socket disconnects at test start

Recommendation: The PR’s approach is appropriate for release-1.10: it aligns CI readiness with the same /healthcheck contract Playwright validates, and it explicitly treats transport/TLS failures as transient within a bounded retry budget. Curl-retry flags were a plausible simplification, but the current implementation’s explicit JSON-body check and consistent behavior across readiness + test make it easier to reason about and debug in CI logs.

Files changed (2) +101 / -18

Bug fix (1) +78 / -8
testing.shProbe and re-probe GET /healthcheck with retry to harden readiness +78/-8

Probe and re-probe GET /healthcheck with retry to harden readiness

• Adds helper functions to GET /healthcheck and validate a JSON "status":"ok" response while treating TLS/connect errors as not-ready. Updates the deploy readiness check to use /healthcheck instead of HEAD /, and re-validates /healthcheck right before launching Playwright to avoid failures from transient GKE TLS blips.

.ci/pipelines/lib/testing.sh

Tests (1) +23 / -10
instance-health-check.spec.tsPoll /healthcheck in Playwright to tolerate transient TLS/socket failures +23/-10

Poll /healthcheck in Playwright to tolerate transient TLS/socket failures

• Switches the test import to the coverage-wrapped Playwright test helper. Replaces the single-shot /healthcheck request with an expect.poll loop (up to 120s) so intermittent transport failures do not fail the suite on the first attempt.

e2e-tests/playwright/e2e/instance-health-check.spec.ts

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 69.60%. Comparing base (c7baf11) to head (7946a80).

Additional details and impacted files
@@                Coverage Diff                @@
##           release-1.10    #5211       +/-   ##
=================================================
+ Coverage         41.03%   69.60%   +28.57%     
=================================================
  Files               121      111       -10     
  Lines              2220     4702     +2482     
  Branches            563      537       -26     
=================================================
+ Hits                911     3273     +2362     
- Misses             1304     1428      +124     
+ Partials              5        1        -4     
Flag Coverage Δ
install-dynamic-plugins 92.44% <ø> (?)
rhdh 38.81% <ø> (-2.23%) ⬇️

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update c7baf11...7946a80. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rhdh-qodo-merge

rhdh-qodo-merge Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Context used
⚠️ Tickets: not configured — ticket URL found in PR but could not be fetched — check ticket provider credentials
✅ Compliance rules (platform): 46 rules
✅ Cross-repo context
  Not relevant to this PR: redhat-developer/rhdh-plugins
  Not relevant to this PR: redhat-developer/rhdh-chart
  Not relevant to this PR: redhat-developer/rhdh-operator
  Not relevant to this PR: redhat-developer/rhdh-local

Grey Divider


Action required

1. Healthcheck budget unbounded 🐞 Bug ☼ Reliability
Description
testing::wait_for_rhdh_healthcheck advertises a fixed retry budget, but
testing::probe_rhdh_healthcheck calls curl with no per-request timeout, so a stalled
DNS/connect/TLS/response can exceed the budget and hang CI. This is newly on the test critical path
because run_tests now calls the wait immediately before Playwright.
Code

.ci/pipelines/lib/testing.sh[R161-163]

+  # Append http_code on its own line so connect/TLS failures can be retried.
+  response=$(curl --insecure -sS -w "\n%{http_code}" "${health_url}" 2> /dev/null || true)
+  if [[ -z "${response}" ]]; then
Relevance

●●● Strong

CI reliability hardening; team has accepted adding explicit time bounds to avoid hangs.

PR-#5036
PR-#3307

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The wait function computes and logs a total wait budget based on attempts and sleep, but the probe
it calls uses curl without any timeouts, so each probe can take arbitrarily long and violate the
advertised bound.

.ci/pipelines/lib/testing.sh[156-173]
.ci/pipelines/lib/testing.sh[184-208]
.ci/pipelines/lib/testing.sh[76-83]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`testing::wait_for_rhdh_healthcheck` logs a bounded wait time, but the underlying `curl` in `testing::probe_rhdh_healthcheck` has no `--connect-timeout` / `--max-time`. A hung request can therefore stall longer than the intended retry budget and block CI.

### Issue Context
This new probe is now used both in `check_backstage_running` and (newly) right before `yarn playwright test`.

### Fix Focus Areas
- .ci/pipelines/lib/testing.sh[156-209]

### Implementation notes
- Add `--connect-timeout <n>` and `--max-time <m>` to the `curl` call in `testing::probe_rhdh_healthcheck`.
- Consider parameterizing these values (or deriving them from `wait_seconds`) so the total worst-case time is truly bounded.
- Optionally avoid sleeping after the final attempt (sleep only when `i < max_attempts`) and update the “up to …s” log message to reflect the true worst-case bound (including per-probe timeout).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Healthcheck failures lack detail 🐞 Bug ◔ Observability
Description
Readiness failures now log only a generic message and the probe discards curl stderr, so CI output
no longer distinguishes HTTP failures (e.g., 503) from transport/TLS/connect errors. This regression
makes intermittent readiness failures harder to diagnose and triage.
Code

.ci/pipelines/lib/testing.sh[R244-245]

    else
-      log::warn "Attempt ${i} of ${max_attempts}: Backstage not yet available (HTTP Status: ${http_status})"
+      log::warn "Attempt ${i} of ${max_attempts}: Backstage /healthcheck not yet available"
Relevance

●●● Strong

Team tends to keep stderr/context for CI diagnosis; they’ve accepted not silencing error output.

PR-#5036
PR-#2920

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The probe explicitly redirects curl stderr to /dev/null, and the readiness loop only emits a generic
per-attempt warning, so failures lose the status/error context needed to debug why /healthcheck
isn’t ready.

.ci/pipelines/lib/testing.sh[156-173]
.ci/pipelines/lib/testing.sh[236-247]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The new `/healthcheck` probe suppresses curl stderr (`2> /dev/null`) and the caller logs only a generic warning, removing useful diagnostics (HTTP status code, curl failure reason).

### Issue Context
These checks run in CI loops, so the logs are the primary debugging surface when readiness flakes.

### Fix Focus Areas
- .ci/pipelines/lib/testing.sh[156-173]
- .ci/pipelines/lib/testing.sh[236-247]

### Implementation notes
- Preserve and log the HTTP status code returned by the probe (and optionally a short, sanitized snippet of the body) when the response is non-200.
- When curl fails to connect/TLS/DNS, capture the curl exit code and a concise error string (log it at least on the final attempt to avoid noise).
- If you keep stderr suppressed for transient retries, consider logging a summarized reason on the last failure before returning 1.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Revert coverage import; shorten pre-Playwright re-probe to warn-only ~30s;
drop wait_for_rhdh_healthcheck helper; reduce spec poll to 30s.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@zdrapela

zdrapela commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

/test e2e-gke-helm-nightly

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Image was built and published successfully. It is available at:

@zdrapela

zdrapela commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

/test e2e-gke-helm-nightly

3 similar comments
@zdrapela

zdrapela commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

/test e2e-gke-helm-nightly

@zdrapela

zdrapela commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

/test e2e-gke-helm-nightly

@zdrapela

zdrapela commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

/test e2e-gke-helm-nightly

@zdrapela zdrapela changed the title fix(ci): harden GKE readiness via /healthcheck probes (RHDHBUGS-3508) fix(ci): harden GKE readiness via /healthcheck probes Aug 4, 2026
# Returns:
# 0 - HTTP 200 and body contains "status":"ok"
# 1 - Not ready
testing::probe_rhdh_healthcheck() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probe needs to land on main too. origin/main:.ci/pipelines/lib/testing.sh still does curl -I for readiness, and per RHDHBUGS-3508 the flake last hit main on Aug 1.

The ticket says main is waiting on #5083, but that PR has been open since Jul 10, is currently CONFLICTING, and touches 90 files — not a realistic vehicle for a CI flake fix. Could we land the slim extract on main directly, as the ticket comment suggests as the alternative? Otherwise 1.11 branches off main and this fix disappears with it.

local response http_status body

# Append http_code on its own line so connect/TLS failures can be retried.
response=$(curl --insecure -sS -w "\n%{http_code}" "${health_url}" 2> /dev/null || true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No --connect-timeout / --max-time here. If the LB accepts the connection but the backend never answers, curl blocks with no ceiling — that turns the GKE budget of 50 x 30s into unbounded wall clock inside a job with a hard Prow timeout, and makes the pre-Playwright loop open-ended too.

response=$(curl --insecure -sS --connect-timeout 5 --max-time 15 -w "\n%{http_code}" "${health_url}" 2> /dev/null || true)


# Append http_code on its own line so connect/TLS failures can be retried.
response=$(curl --insecure -sS -w "\n%{http_code}" "${health_url}" 2> /dev/null || true)
if [[ -z "${response}" ]]; then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this branch is effectively unreachable. With -w set, curl still writes the format string on a connect failure, so response comes back as \n000 rather than empty (I checked against a dead port). The retry actually comes from http_status != 200 below, not from here — worth either dropping the guard or rewording the comment above it so the next reader doesn't think it's load-bearing.

Same line above: -S doesn't buy anything with stderr going to /dev/null.

http_status=$(printf '%s' "${response}" | tail -n 1)
body=$(printf '%s' "${response}" | sed '$d')

if [[ "${http_status}" == "200" ]] && [[ "${body}" =~ \"status\"[[:space:]]*:[[:space:]]*\"ok\" ]]; then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This substring match passes on a nested hit — {"db":{"status":"ok"},"status":"error"} reads as healthy. jq is already in the CI image (.ci/images/Dockerfile:39), so we can be exact and fail closed on malformed JSON at the same time:

if [[ "${http_status}" == "200" ]] && printf '%s' "${body}" | jq -e '.status == "ok"' > /dev/null 2>&1; then


# Quick re-check after yarn install; do not fail the job — deploy readiness
# already waited. Warn-only so a brief TLS blip does not abort the suite.
if [[ -n "${url}" ]]; then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use common::retry (.ci/pipelines/lib/common.sh:182) rather than a hand-rolled loop?

if [[ -n "${url}" ]]; then
  if common::retry 6 5 testing::probe_rhdh_healthcheck "${url}"; then
    log::success "Pre-Playwright /healthcheck OK"
  else
    log::warn "Pre-Playwright /healthcheck still flaky; continuing to tests"
  fi
fi

Same behavior, ~15 fewer lines, and consistent logging with the rest of .ci/. It also drops the wasted final sleep 5 — the loop as written sleeps after the 6th failed probe before falling through.

if [[ -n "${url}" ]]; then
local ready=false
local i
for ((i = 1; i <= 6; i++)); do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: 6 and 5 are bare literals, and the resulting "~30s" lives only in the warn string below — they'll drift apart the first time someone tunes one. A named local (local health_retries=6 health_backoff_seconds=5) or a one-line comment on why 30s is the right window would keep them honest.

Related: the PR description still says "up to ~2 minutes" while this does ~30s. Looks stale after the slim GKE healthcheck harden commit.

return 0
else
log::warn "Attempt ${i} of ${max_attempts}: Backstage not yet available (HTTP Status: ${http_status})"
log::warn "Attempt ${i} of ${max_attempts}: Backstage /healthcheck not yet available"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We lost the HTTP status here — this used to print (HTTP Status: 503). That's the one number that separates a TLS disconnect (000) from a real 503 or a 200 whose body isn't ok yet, which is exactly the distinction RHDHBUGS-3508 is about, so dropping it makes the next occurrence harder to triage than the code being replaced.

#5083 keeps that signal on the TS side via its detail string. Could the probe echo the status (or stash it in a namespaced global like _TESTING_LAST_HEALTH_STATUS) so this warn can include it?

expect(response.status()).toBe(200);

expect(responseBody).toHaveProperty("status", "ok");
// Short poll: one TLS blip should not fail; deploy readiness already waited.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up: this spec doesn't run in showcase-rbac-k8s. That project uses testMatch: ["**/playwright/e2e/**/*-rbac.spec.ts"] (playwright.config.ts:151), and instance-health-check.spec.ts doesn't match it — so for one of the two projects the ticket names as affected, this change is a no-op and only the shell probe covers it.

Was the failure there coming through the smoke-test dependency instead? If so, is retries: 10 on smoke-test already absorbing it, or is something still left to fix on that path?


expect(responseBody).toHaveProperty("status", "ok");
// Short poll: one TLS blip should not fail; deploy readiness already waited.
await expect

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about this poll.

.toBe(true) means a genuine regression reports Expected: true / Received: false after 30s — no status, no body. The ticket already notes there are no screenshots because nothing ran, so the failure string is the only evidence the next occurrence produces.

And #5083 already solves this on main with probeHealthcheck returning {ok, detail} and waitForRhdhReady rethrowing RHDH not ready within ${timeoutMs}ms: ${lastDetail} — so we'd land two different shapes of the same fix and have to reconcile them at cherry-pick time. Could we mirror it here?

let lastDetail = "no response yet";
await expect
  .poll(
    async () => {
      try {
        const response = await request.get("/healthcheck");
        if (response.status() !== 200) {
          lastDetail = `HTTP ${response.status()}`;
          return false;
        }
        const body: unknown = await response.json();
        const ok =
          typeof body === "object" &&
          body !== null &&
          Reflect.get(body, "status") === "ok";
        lastDetail = ok ? "status ok" : "HTTP 200 unexpected body";
        return ok;
      } catch (error) {
        lastDetail = `request failed: ${error instanceof Error ? error.message : String(error)}`;
        return false;
      }
    },
    { timeout: 30_000, intervals: [2_000] },
  )
  .toBe(true);

(The try wrapping the whole request is the right call — expect.poll runs await actual() outside its own try/catch, so a transport throw aborts the poll instead of retrying. That's the actual root cause in the ticket.)

async () => {
try {
const response = await request.get("/healthcheck");
if (response.status() !== 200) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: main's wait-for-rhdh-ready.ts — and #5083's rewrite of it — also checks that content-type contains json before parsing, which guards against a proxy returning an HTML error page with a 200. Worth matching so the 1.10 and main versions don't drift further apart.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants