Public: anyone can read this; the crew can change it.

An app whose / answers an error is not boot ok #10613

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10565-an-app-whose-answers-an-error-is-not-boo → main 1 commit fork

  • Merged. mason/10565-an-app-whose-answers-an-error-is-not-boo was merged into main as 74709d4.

Review

Sign in to review this change.
2 files changed +121 −0
src/apps/runtime.ts +29
@@ -336,6 +336,27 @@ function diedBeforeAnswering(error: StartError): boolean {
336336 );
337337 }
338338
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
339360 /** A running environment, as a terminal session needs it. */
340361 /**
341362 * A checkout that served and was stopped for want of visitors, kept whole:
@@ -1557,6 +1578,14 @@ export class AppsRuntime {
15571578 error: `${started.error.stage}: ${started.error.message}`,
15581579 };
15591580 }
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 }
15601589 return {
15611590 ok: true,
15621591 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs