diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 0ef90b2..8b85155 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -193,10 +193,12 @@ deploy for the throwaway project `automated-test-miedro` to the runner's `/deployCustomClasses`, so it exercises workspace init, the compile gate and the DSL push path without writing anything to a real project. It fails (non-zero) on any non-200 response, on `success:false`, or if the runner rejects the -`flutter_web_plugins` SDK package, and also reports the runner's `/healthz` +`flutter_web_plugins` SDK package, and also reports the runner's health route versions when that endpoint is deployed - without failing when it is not yet. -`GET /healthz` answers `{sha, flutterflowCli, minFlutterflowCli}`. The deploy +`GET /runnerHealth` answers `{sha, flutterflowCli, minFlutterflowCli}`. It is **not** +`/healthz`: Cloud Run's front end answers that path itself, so a route there never +reaches the container and is dead in production however well it works locally. The deploy script passes the checkout's short SHA as `RUNNER_GIT_SHA`, so comparing `sha` against `git log -1 --format=%h` tells you whether a merged runner fix is actually live. That question is what a months-long stall in a merged-but-never- diff --git a/cloud-run/ffai-runner/bin/server.dart b/cloud-run/ffai-runner/bin/server.dart index b2a8683..5a97754 100644 --- a/cloud-run/ffai-runner/bin/server.dart +++ b/cloud-run/ffai-runner/bin/server.dart @@ -8,9 +8,14 @@ import 'package:ccc_ffai_runner/sha256.dart'; // The lowest flutterflow_cli the vendored FlutterFlow AI snapshot accepts. The // image pins FLUTTERFLOW_CLI_VERSION to this (see Dockerfile); it must be kept -// in lockstep there, and it is what /healthz reports as the required minimum. +// in lockstep there, and it is what the health route reports as the required minimum. const minCliVersion = '0.0.41'; +/// The liveness/version route the container actually serves. Must not be a +/// path Google's front end reserves ('/healthz'), or Cloud Run answers it and +/// this handler is unreachable. +const healthPath = '/runnerHealth'; + Future main() async { final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080; final server = await HttpServer.bind(InternetAddress.anyIPv4, port); @@ -37,7 +42,10 @@ Future _handle(HttpRequest request) async { return; } - if (request.method == 'GET' && request.uri.path == '/healthz') { + // NOT /healthz: Cloud Run's front end answers that path itself and the + // request never reaches this container, so a route there is dead code in + // production however well it works locally. + if (request.method == 'GET' && request.uri.path == healthPath) { await _writeJsonBody(request.response, await _healthz()); return; } diff --git a/scripts/canary-deploy.mjs b/scripts/canary-deploy.mjs index 6be1644..a9dd216 100644 --- a/scripts/canary-deploy.mjs +++ b/scripts/canary-deploy.mjs @@ -8,8 +8,8 @@ // (workspace init, compile gate, DSL validation) but never write to the // project, so this is safe to run unattended. // -// It also curls the runner's /healthz when present and reports its versions, -// but never fails on a missing /healthz - another change is adding it in +// It also curls the runner's /runnerHealth when present and reports its versions, +// but never fails on a missing /runnerHealth - another change is adding it in // parallel and it may not be deployed yet. const RUNNER_DEFAULT_URL = "https://ccc-ffai-runner-y5cyj3473a-uw.a.run.app"; @@ -145,27 +145,27 @@ async function main() { clearTimeout(timer); } - // The /healthz route is being added alongside this canary; do not fail the + // The /runnerHealth route is being added alongside this canary; do not fail the // canary when it is not deployed yet. try { - const health = await fetch(`${baseUrl}/healthz`, { + const health = await fetch(`${baseUrl}/runnerHealth`, { signal: AbortSignal.timeout(10000), }); if (health.ok) { const healthBody = await health.json().catch(() => ({})); console.log( - `[canary] healthz: HTTP ${health.status}` + + `[canary] health: HTTP ${health.status}` + (healthBody.versions ? ` versions=${JSON.stringify(healthBody.versions)}` : ` ${JSON.stringify(healthBody)}`), ); } else { console.log( - `[canary] healthz: HTTP ${health.status} (not reported as a failure)`, + `[canary] health: HTTP ${health.status} (not reported as a failure)`, ); } } catch { - console.log("[canary] healthz: unavailable (not reported as a failure)"); + console.log("[canary] health: unavailable (not reported as a failure)"); } } diff --git a/src/cloudRunRunner.test.js b/src/cloudRunRunner.test.js index bd36c37..b604334 100644 --- a/src/cloudRunRunner.test.js +++ b/src/cloudRunRunner.test.js @@ -210,10 +210,10 @@ test("the image copies every directory the entrypoint imports", () => { } }); -// The runner reports minFlutterflowCli at /healthz so a version drift is visible. -// If the two ever disagree, /healthz reports a minimum the image does not +// The runner reports minFlutterflowCli at its health route so a version drift is +// visible. If the two ever disagree, it reports a minimum the image does not // actually satisfy, which is worse than not reporting one at all. -test("/healthz reports the same required CLI version the image pins", () => { +test("the health route reports the same required CLI version the image pins", () => { const pinned = dockerfile.match( /^ARG FLUTTERFLOW_CLI_VERSION=(\d+\.\d+\.\d+)$/m, )?.[1]; @@ -231,7 +231,7 @@ test("/healthz reports the same required CLI version the image pins", () => { ); }); -// A merged runner fix that never shipped is the failure this guards: /healthz +// A merged runner fix that never shipped is the failure this guards: the health route // has to be able to answer which revision is live, so the deploy must actually // pass the SHA through. test("the deploy passes the built revision to the runner for /healthz", () => { @@ -243,6 +243,25 @@ test("the deploy passes the built revision to the runner for /healthz", () => { assert.match( deployScript, /--set-env-vars "[^"]*RUNNER_GIT_SHA=\$RUNNER_GIT_SHA/, - "RUNNER_GIT_SHA must reach the service, or /healthz reports an empty SHA", + "RUNNER_GIT_SHA must reach the service, or the health route reports an empty SHA", + ); +}); + +// Cloud Run's front end answers /healthz itself, so the request never reaches +// the container. That shipped once: the endpoint was dead in production +// however well it worked locally. The path is therefore checked, not assumed. +test("the health route is not a path Cloud Run reserves", () => { + const path = runnerSource.match(/const healthPath = '([^']+)'/)?.[1]; + + assert.ok(path, "the runner must declare the health route it serves"); + assert.notEqual( + path, + "/healthz", + "Cloud Run's front end answers /healthz, so the container never sees it", + ); + assert.match( + runnerSource, + /request\.uri\.path == healthPath/, + "the route must be matched through the shared constant, not a literal", ); });