Public: anyone can read this; the crew can change it.
An app whose / answers an error is not boot ok #10613
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10565-an-app-whose-answers-an-error-is-not-boo → main 1 commit fork
2 files
changed +121
−0
src/apps/runtime.ts +29
| @@ -336,6 +336,27 @@ function diedBeforeAnswering(error: StartError): boolean { | ||
| 336 | 336 | ); |
| 337 | 337 | } |
| 338 | 338 | |
| 339 | /** | |
| 340 | * What is wrong with an app's `/`, if anything: the line naming the status | |
| 341 | * when it answers 4xx or 5xx, `undefined` when it answers 200 or redirects. | |
| 342 | * It asks the process on its own port, so a private app's access door never | |
| 343 | * answers in its place: whatever status comes back is the app's own. | |
| 344 | */ | |
| 345 | export async function rootFault(port: number): Promise<string | undefined> { | |
| 346 | try { | |
| 347 | const response = await fetch(`http://127.0.0.1:${port}/`, { | |
| 348 | signal: AbortSignal.timeout(5_000), | |
| 349 | redirect: "manual", | |
| 350 | }); | |
| 351 | await response.body?.cancel(); | |
| 352 | if (response.status >= 400) | |
| 353 | return `/ answered ${response.status}; the app is up but has no page at its root`; | |
| 354 | } catch { | |
| 355 | // No answer is the health path's business, not this check's. | |
| 356 | } | |
| 357 | return undefined; | |
| 358 | } | |
| 359 | ||
| 339 | 360 | /** A running environment, as a terminal session needs it. */ |
| 340 | 361 | /** |
| 341 | 362 | * A checkout that served and was stopped for want of visitors, kept whole: |
| @@ -1557,6 +1578,14 @@ export class AppsRuntime { | ||
| 1557 | 1578 | error: `${started.error.stage}: ${started.error.message}`, |
| 1558 | 1579 | }; |
| 1559 | 1580 | } |
| 1581 | // The health path answered; the page people open must too. | |
| 1582 | const fault = await rootFault(started.value.handle.port); | |
| 1583 | if (fault !== undefined) { | |
| 1584 | await started.value.handle.stop(); | |
| 1585 | revokeAppToken(this.app.db, token.id); | |
| 1586 | rmSync(dir, { recursive: true, force: true }); | |
| 1587 | return { ok: false, error: `ready: ${fault}` }; | |
| 1588 | } | |
| 1560 | 1589 | return { |
| 1561 | 1590 | ok: true, |
| 1562 | 1591 | value: { |
test/app-runtime-root.test.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * An app whose `/` answers an error is not "ready": the deploy's proof | |
| 3 | * fetches `/` as well as the health path (#10565). | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { enableApp } from "../src/actions/apps.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { rootFault } from "../src/apps/runtime.ts"; | |
| 9 | import { | |
| 10 | deploySettled, | |
| 11 | startAppsForge, | |
| 12 | stopAppsForge, | |
| 13 | type AppsForge, | |
| 14 | } from "./helpers/apps-forge.ts"; | |
| 15 | ||
| 16 | const serveRoot = (status: number, headers: Record<string, string> = {}) => | |
| 17 | Bun.serve({ | |
| 18 | port: 0, | |
| 19 | hostname: "127.0.0.1", | |
| 20 | fetch: (req) => | |
| 21 | new URL(req.url).pathname === "/health" | |
| 22 | ? new Response("ok") | |
| 23 | : new Response("x", { status, headers }), | |
| 24 | }); | |
| 25 | ||
| 26 | describe("rootFault", () => { | |
| 27 | test.each([404, 500])("%d on / is named", async (status) => { | |
| 28 | const server = serveRoot(status); | |
| 29 | try { | |
| 30 | expect(await rootFault(server.port!)).toContain(`/ answered ${status}`); | |
| 31 | } finally { | |
| 32 | server.stop(true); | |
| 33 | } | |
| 34 | }); | |
| 35 | ||
| 36 | test("200 and a redirect pass", async () => { | |
| 37 | const ok = serveRoot(200); | |
| 38 | const moved = serveRoot(302, { location: "/login" }); | |
| 39 | try { | |
| 40 | expect(await rootFault(ok.port!)).toBeUndefined(); | |
| 41 | expect(await rootFault(moved.port!)).toBeUndefined(); | |
| 42 | } finally { | |
| 43 | ok.stop(true); | |
| 44 | moved.stop(true); | |
| 45 | } | |
| 46 | }); | |
| 47 | }); | |
| 48 | ||
| 49 | describe("a deploy proves /", () => { | |
| 50 | let forge: AppsForge; | |
| 51 | beforeAll(async () => { | |
| 52 | forge = await startAppsForge(); | |
| 53 | }, 60_000); | |
| 54 | afterAll(async () => { | |
| 55 | await forge.runtime.stop(); | |
| 56 | await stopAppsForge(forge); | |
| 57 | }); | |
| 58 | ||
| 59 | const deployWith = async (name: string, rootStatus: number) => { | |
| 60 | const made = await createRepo(forge.app, forge.alice, { name, visibility: "public" }); | |
| 61 | if (!made.ok) throw new Error(String(made.error)); | |
| 62 | const repo = made.value; | |
| 63 | const run = `exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch:(r)=>new URL(r.url).pathname==='/health'?new Response('ok'):new Response('x',{status:${rootStatus}})})"\n`; | |
| 64 | const committed = await commitToBranch(forge.app, forge.alice, repo, { | |
| 65 | message: "An app", | |
| 66 | files: [ | |
| 67 | { | |
| 68 | path: "forge.json", | |
| 69 | content: JSON.stringify({ | |
| 70 | app: { run: "sh run.sh", install: "", health: "/health", ready: 10 }, | |
| 71 | }), | |
| 72 | }, | |
| 73 | { path: "run.sh", content: run }, | |
| 74 | ], | |
| 75 | }); | |
| 76 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 77 | const enabled = enableApp(forge.app, forge.alice, repo, {}); | |
| 78 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 79 | return deploySettled(forge.app, repo.id, (r) => r.env === "production"); | |
| 80 | }; | |
| 81 | ||
| 82 | test("a healthy health route with a 404 / fails, naming the status", async () => { | |
| 83 | const done = await deployWith("rootless", 404); | |
| 84 | expect(done.state).toBe("failed"); | |
| 85 | expect(done.summary).toContain("/ answered 404"); | |
| 86 | }, 60_000); | |
| 87 | ||
| 88 | test("one answering 200 passes", async () => { | |
| 89 | const done = await deployWith("rooted", 200); | |
| 90 | expect(done.state).toBe("ok"); | |
| 91 | }, 60_000); | |
| 92 | }); |