Skip to content

fix(infra): gate deployment checks and GCP uptime on /health body status - #1023

Merged
cristim merged 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-649
Oct 17, 2026
Merged

cristim merged 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-649

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Root cause

Since #488 / PR #646, /health is the liveness contract: it answers HTTP 200 even when every dependency is down. The verdict is in the body's top-level "status" ("healthy" / "degraded"). Two verification layers still treated that 200 as the signal, so both passed on a fully degraded deployment:

  • terraform/modules/deployment-checks: check "health" asserted only status_code == 200. The warmup provisioner also returned on any status other than 000/502/503, so the checks could run against a replica that was answering but had not finished initializing.
  • terraform/modules/monitoring/gcp: the uptime check did have a content matcher, but it was CONTAINS_STRING "healthy". That string is a substring of "unhealthy", and every nested check result is {"status":"healthy"}. A degraded body with the DB unreachable ({"checks":{"auth_store":{"status":"healthy"},"config_store":{"status":"unhealthy",...}},"status":"degraded"}) matched, so the matcher passed on degraded replicas. (The issue describes this check as "HTTP 200 only". In effect it was, but the reason is the substring match.)

Fix

  • check "health" keeps the 200 assert and adds a second one: try(jsondecode(response_body).status, "") == "healthy". The error message names the reported status, or says unparseable.
  • warmup now waits for an HTTP 200 whose body's top-level status is "healthy". It parses the body with python3, the same tool scripts/test-deployment.sh already uses for JSON. If python3 is missing it falls back to accepting a 200, and as before it gives up after 10 attempts and continues (on_failure = continue).
  • The GCP uptime check now uses MATCHES_JSON_PATH with $.status and EXACT_MATCH "healthy". It stays on /health, not /ready, as the issue says (pointing it at /ready would flap the alert on deploys). service_unavailable already needs 180s of failures before it fires.

/health's status-code semantics are unchanged. Nothing here touches the endpoint or its body, which stays generic per #941.

Why gate on the body rather than a per-platform /ready path

The issue offered two options: a per-platform path variable (/ready on containers, /health plus body parsing on Lambda), or a Lambda /ready. Gating on the body status covers every platform with one rule and adds no new module input:

  • On Lambda, HandleLambdaEvent runs ensureDB before routing, so a DB failure already surfaces as a 5xx. A connected but degraded handler (for example the dev credential key) reports degraded in the body.
  • On containers, the body is healthy only when every check is healthy. That is stricter than /ready, which stays 200 after a failed migration run, and strictness is what a post-deploy gate wants.

How #648 / #647 can build on this

The two layers now have separate jobs. Post-deploy verification and uptime monitoring read the body status of /health. Traffic admission (GKE/Cloud Run readiness in #648, the Fargate ALB target group in #647) should use the status code of /ready, which answers 503 until initialization is done. Neither of those needs a body matcher. TestHealthBodyStatusIsTheVerdict pins the body half of the contract. If a later change wants the deployment check on /ready for container platforms, adding a path variable to this module keeps the body assert.

Tests (fail before, pass after)

  • terraform/modules/deployment-checks/tests/health.tftest.hcl (new): plan-only runs with a mocked http provider, no network access, and provisioners are never run.

    • healthy_body_passes
    • degraded_body_with_200_fails: the DB-unreachable body from the issue, with HTTP 200
    • non_json_body_fails: an SPA index containing the word "healthy"
    • non_200_fails

    On the old main.tf, degraded_body_with_200_fails fails, because check.health passed when it was expected to fail.

  • terraform/modules/monitoring/gcp/tests/uptime_check.tftest.hcl (new): mocked google provider. Asserts the path stays /health and that there is exactly one matcher: MATCHES_JSON_PATH / $.status / EXACT_MATCH "healthy". It fails on the old config.

  • internal/server/health_contract_test.go (new): decodes only the top-level status, the way the gates read it, on both transports:

    • the real CreateHTTPServer mux, healthy and degraded
    • handleLambdaHTTPEvent on /health, degraded

    It also asserts that a degraded body still contains "healthy", which is why a substring match cannot be the verdict.

  • CI: a new Test /health gate modules step in terraform-validate (gcp matrix leg) runs init -backend=false -lockfile=readonly, fmt -check and test for both modules. Until now CI ran terraform test only under compute/aws/* and iac/federation/azure-target.

Mutation probes, each caught:

  • body assert weakened to != "": the degraded run fails
  • GCP json_path changed to $.checks: the uptime test fails
  • HealthStatus.Status JSON tag renamed to state: the Go contract test fails

Verification (static only; nothing deployed, no plan/apply against real accounts)

  • terraform test passes for both modules on Terraform 1.10.5, the CI pin, and on 1.15.8, both with a temporary TF_DATA_DIR. terraform fmt -check -recursive terraform/ and terraform validate pass for both modules and for terraform/environments/{gcp,aws} (init -backend=false).
  • I rendered the warmup heredoc to a POSIX sh script. shellcheck -s sh is clean. I ran it against local stub servers:
    • degraded, then healthy: returns at attempt 3
    • always degraded: retries all 10 attempts, then continues
    • HTML body with 200: not accepted
  • go test ./internal/server/ -count=1, ./internal/mocks/, and ./internal/api/ -run Health pass (with AWS_CONFIG_FILE and AWS_SHARED_CREDENTIALS_FILE set to /dev/null). go vet passes, and golangci-lint v2.10.1 reports 0 issues on ./internal/server/.
  • actionlint .github/workflows/ci.yml is clean. git diff --check is clean. git-secrets --scan on the changed files (temp copy, isolated HOME, patterns from scripts/setup-git-secrets.sh) is clean, and scripts/test-git-secrets-allowlist.sh gives 42 PASS, 0 FAIL.
  • Not run locally: tflint, which is not installed. Real-account behavior of the GCP JSON-path matcher is unverified; the google ~> 5.0 provider (lock file 5.45.2) accepts the schema, but I have not seen it against live Cloud Monitoring.

Behaviour change to be aware of

  • A deploy that comes up degraded now produces a check.health warning. Check blocks never fail the apply. Warmup waits up to about 50s longer for "healthy" before the checks run.
  • On GCP, a replica that stays degraded for more than 180s now fires service_unavailable. That includes the all-zero dev credential key, which checkCredentialStore says monitoring should alert on in any deployed environment.

Out of scope

Closes #649

🤖 Generated with Claude Code

/health is the liveness contract since LeanerCloud#488/LeanerCloud#646: it answers HTTP 200 even
when every dependency is down and reports the verdict in the body's
top-level "status" ("healthy" / "degraded"). Two verification layers still
treated that 200 as meaningful and passed on a fully degraded deployment:

- deployment-checks: check "health" asserted only status_code == 200, and
  the warmup provisioner returned on any non-000/502/503 status. The check
  now also requires jsondecode(body).status == "healthy", and warmup waits
  for a 200 whose body reports "healthy" (falling back to 200-only when
  python3 is unavailable). Gating on the body works unchanged on Lambda,
  where /ready does not exist.
- monitoring/gcp: the uptime check's CONTAINS_STRING "healthy" matcher also
  matches "unhealthy" and every nested check's {"status":"healthy"}, so it
  passed on degraded replicas. It now exact-matches "healthy" at JSON path
  $.status. It stays on /health, not /ready.

/health's status-code semantics are unchanged.

Tests: terraform tests for both modules (mocked providers, plan only; they
fail on the old config) wired into the terraform-validate CI job, plus a Go
test pinning the top-level "status" contract on the server mux and the
Lambda router.

Closes LeanerCloud#649

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/many Affects most users effort/m Days type/bug Defect triaged Item has been triaged labels Oct 16, 2026
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 7 billable files and costs up to $1.75.

Or wait 38 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ef3c1621-417a-4c5b-b70e-d5858bc99be4
📥 Commits

Reviewing files that changed from the base of the PR and between 110a496 and 66cbbf0.

📒 Files selected for processing: 7
  • .github/workflows/ci.yml
  • internal/server/health_contract_test.go
  • terraform/modules/deployment-checks/.terraform.lock.hcl
  • terraform/modules/deployment-checks/main.tf
  • terraform/modules/deployment-checks/tests/health.tftest.hcl
  • terraform/modules/monitoring/gcp/main.tf
  • terraform/modules/monitoring/gcp/tests/uptime_check.tftest.hcl
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member

Independent review verdict: CLEAN at 7fe7114

Reviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief. Read-only, with no cloud credentials and no plan or apply against a real backend. The PR body was treated as a claim. Coordinator: claude-cc-reviewer.

Blocking findings: none.

Health body vs matchers:

  • The top-level status is only "healthy" or "degraded" (internal/server/health.go:17-27,90-126; Lambda router internal/api/health.go:15-51). "unhealthy" and "pending" appear only in nested checks.
  • deployment-checks: try(jsondecode(body).status,"") == "healthy" parses the JSON, so it is whitespace-insensitive and reads the top-level field only. The warmup reads the same field via python3, and waits for a 200 if python3 is missing.
  • GCP uptime: MATCHES_JSON_PATH $.status with EXACT_MATCH "healthy". This cannot match a nested or "unhealthy" value. The old CONTAINS_STRING "healthy" was the real hole.
  • Degraded: both gates now fail on degraded, which is what fix(infra): deployment checks and GCP uptime monitor gate on tautological /health 200 #649 asks for. The monitor stays on /health, not /ready.

Contract and CI:

  • health_contract_test.go decodes the raw map, so renaming the JSON field fails it. It also covers the Lambda router and degraded-200.
  • ci.yml adds a terraform-validate step for gcp: init -backend=false, fmt, then terraform test with mock providers. It is part of the ci-success needs, and nothing is weakened.

Deploy impact (merge held for an explicit owner OK):

  • deploy-gcp runs on every push to main, and deployment-checks is used by the aws, azure and gcp environments' checks.tf. So the stricter gate applies on every deploy right away.
  • The warmup retries for about 50s and then continues. The check block warns if a replica is still degraded, so a slow first initialization may show a warning.
  • terraform/modules/monitoring/gcp is not instantiated by any environment, so no live uptime resource changes.

Note (out of scope): the Azure monitor still uses a substring "healthy" match, the same hole this PR fixes for GCP.

What was attacked and how:

  • terraform fmt -check, init and validate pass in both modules.
  • terraform test: deployment-checks 4/4, monitoring/gcp 1/1.
  • go test -run Health ./internal/server/... passes.
  • Mutation probes, all killed:
    • expected status "ok" in deployment-checks (tftest fails);
    • GCP content "ok" (tftest fails);
    • JSON tag renamed (contract test fails).

Gate:

…k file

The lock file held a single h1 hash for hashicorp/http 3.5.0 (one
platform), so `terraform init -lockfile=readonly` on the linux_amd64 CI
runner could not verify the downloaded package and `terraform test`
failed in the new "Test /health gate modules" step. Regenerated with
`terraform providers lock -platform=linux_amd64 -platform=darwin_arm64
-platform=darwin_amd64`; the provider version is unchanged.

Refs LeanerCloud#649

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI fix for "Validate Terraform (gcp)", new head 66cbbf0e.

Root cause: terraform/modules/deployment-checks/.terraform.lock.hcl recorded only one h1: hash for hashicorp/http 3.5.0, and it was for a non-linux platform. Until now no CI step ran init -lockfile=readonly in this module. In the new Test /health gate modules step, init could not record the linux_amd64 hash, and terraform test then rejected the cached package: "does not match any of the checksums recorded in the dependency lock file". The workflow step itself mirrors the existing AWS compute test step: same flags, no shared data dir.

Fix: I regenerated the lock file with terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=darwin_amd64. The provider version is unchanged; the fix adds two h1: hashes and the constraints line. The workflow is unchanged. The monitoring/gcp lock already had multi-platform hashes, so it needed no change.

Evidence:

  • Re-running providers lock -platform=linux_amd64 reports "no need for changes".
  • Fresh TF_DATA_DIR with init -backend=false -lockfile=readonly: no warnings for either module.
  • terraform test: deployment-checks 4 passed, monitoring/gcp 1 passed.
  • actionlint is clean.

🤖 Generated with Claude Code

@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member

Head moved to 66cbbf0. The reviewed 7fe7114 is unchanged (range-diff =). The one added commit, "build(terraform): record linux/darwin hashes in deployment-checks lock file", changes only terraform/modules/deployment-checks/.terraform.lock.hcl: it adds two h1: hashes and the ~> 3.4 constraint for the same hashicorp/http 3.5.0. The version and the existing zh: hashes are unchanged. I reviewed it myself; it carries no behaviour change. The CLEAN verdict carries to 66cbbf0, and the merge is still held for @cristim's OK. (claude-cc-reviewer)

@IAmJSD
IAmJSD marked this pull request as ready for review October 16, 2026 23:34
@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member

Merge driver status (claude-cc-pr-merge, 2026-10-17T15:25:27Z): code gate met, merge BLOCKED on @cristim's explicit OK for the dev deploy. Merging to main runs the existing deploy workflows (Terraform apply to dev), and merge-driver sessions are not permitted to trigger that, so a maintainer must merge or approve it (stricter /health gate on every deploy).

@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member

Independent delta review of 66cbbf0 (previous: 7fe7114): clean

Reviewer: fresh non-author agent, dispatched by claude-cc-pr-merge-2. The earlier CLEAN verdict at 7fe7114 stands for the rest of the PR.

  • OLD is an ancestor of NEW. The delta is terraform/modules/deployment-checks/.terraform.lock.hcl (+4/-1). The provider is unchanged (hashicorp/http 3.5.0). The added constraints = "~> 3.4" matches required_providers (deployment-checks/main.tf:21). The zh: hashes are unchanged.
  • Supply chain: 8bUo... is the same http 3.5.0 hash as in iac/federation/azure-target/terraform/.terraform.lock.hcl. Ksgl... was verified by regenerating with terraform providers lock in scratch (pinned = 3.5.0, linux/darwin/windows; no credentials). The regenerated set contains all three PR hashes, plus two for platforms the PR does not record (harmless).
  • git merge-tree with main is clean. Findings: none.

@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member

Refreshing CI (claude-cc-pr-merge-2): main changed directories this PR also touches after its CI ran. The merge-tree with main is clean (see the delta review), but CI must re-run on the current merge ref. Reopening now.

@cristim cristim closed this Oct 17, 2026
@cristim cristim reopened this Oct 17, 2026
@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member

Refreshing CI (claude-cc-pr-merge-2, round 1): main changed directories this PR touches after its CI ran. Reopening now.

@cristim cristim closed this Oct 17, 2026
@cristim cristim reopened this Oct 17, 2026
@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member

Gate record for 66cbbf0 (claude-cc-pr-merge-2):

  • Independent adversarial review: CLEAN at 7fe7114 (fix(infra): gate deployment checks and GCP uptime on /health body status #1023 (comment)), plus a clean independent delta review of this exact SHA (fix(infra): gate deployment checks and GCP uptime on /health body status #1023 (comment)). The local verification evidence and its limits are in those comments: fixture or mock level, no plan or apply against a real backend.
  • Owner hold lifted 2026-10-17 (~/.claude/decisions/2026-10-17-platform-holds-lifted.md). Stricter /health body gate for every env (merge applies to dev).
  • CI: the latest 'CI - Build & Test' run on this SHA is completed and green, and the required checks pass. Both were re-checked at merge time, including the guard against main changing the same packages since CI ran.
  • Labels mirror the linked issue. No unresolved threads. CodeRabbit: skipped (quota).
  • Merging with squash and --match-head-commit; no --admin.

@cristim
cristim merged commit 333b13f into LeanerCloud:main Oct 17, 2026
69 checks passed
IAmJSD added a commit that referenced this pull request Oct 24, 2026
#1317)

* feat(frontend): add analytics trends API client and series shaping

GET /api/analytics/trends has served the savings-snapshot history since
the backend work for #1023/#1033, but the frontend had no client for it.
Add a typed getAnalyticsTrends() mirroring TrendsResponse, plus pure
helpers that bucket the (month, account, provider, service) rows into one
point per calendar month, narrow rows to the provider chip, and keep
missing months and missing coverage as null rather than a fabricated 0.

Refs #30

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(frontend): add a Trends sub-tab to Inventory & Coverage

Users could not see savings or coverage over time. The new
/inventory/trends sub-tab renders the monthly savings run-rate as a
single-series bar chart with a matching table (coverage per month), stat
tiles, and the by-provider and by-service breakdowns. The account chip is
forwarded as account_id so the backend's allowed_accounts scope applies,
and a chip change reloads the sub-tab like the other Inventory views.
Everything is built with textContent (no innerHTML) and the period and
refresh controls are bound once per element. 503 and the scoped-user
"account_id is required" refusal get actionable inline copy.

Refs #30

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(frontend): cover the Trends view in jest and Playwright

Unit tests pin the month bucketing, null gaps, provider narrowing, XSS
safety, stale-response handling and listener dedup. The Playwright spec
checks the sub-tab routing, that the chart canvas is actually laid out,
and the 503 message; /inventory/trends joins the page-overflow sweep.

Refs #30

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(infra): deployment checks and GCP uptime monitor gate on tautological /health 200

2 participants