Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down
12 changes: 10 additions & 2 deletions cloud-run/ffai-runner/bin/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> main() async {
final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080;
final server = await HttpServer.bind(InternetAddress.anyIPv4, port);
Expand All @@ -37,7 +42,10 @@ Future<void> _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;
}
Expand Down
14 changes: 7 additions & 7 deletions scripts/canary-deploy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)");
}
}

Expand Down
29 changes: 24 additions & 5 deletions src/cloudRunRunner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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", () => {
Expand All @@ -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",
);
});
Loading