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

/maintenance says whether the standby page is answering before the admin powers off #9663

Merged opened this

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

Merged

scout/forge:scout/9656-maintenance-says-whether-the-standby-pag → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/9656-maintenance-says-whether-the-standby-pag was merged into main as 30f04bd.

Review

Sign in to review this change.
8 files changed +251 −14
docs/features/maintenance.md +8 −1
@@ -37,6 +37,13 @@ No Kubernetes is asked anything; the state is one file.
3737 Cloudflare's origin at it for the move, and back after. A Cloudflare Worker can
3838 return the same HTML with status 503 and `Retry-After`.
3939
40 **Is the standby up?** Set `MAINTENANCE_STANDBY_URL` (an http(s) URL, e.g. `http://127.0.0.1:8080/`) in
41 the forge's environment. `/maintenance` and `GET /api/v1/admin/maintenance` (field `standby`) then fetch
42 that one URL server-side (2 s timeout, no redirects) and say whether it answers 503 with the maintenance
43 page. The URL is never taken from a request. When ready, the line reads "Ready, standby answering",
44 "Ready, standby not answering" or "Ready, no standby". Unset, the page says how to start one:
45 `bun scripts/standby.ts 8080` on the droplet, then point the tunnel or Cloudflare origin at it.
46
4047 **Size of a standby.** For reads or a whole forge: disk for the data (38 GB now,
4148 apps 13 GB) plus one snapshot and headroom, so 64 GB; 4 GB of memory free for the
4249 forge alone, 8 GB with apps running. The 16 GB droplet cannot hold it.
@@ -66,6 +73,6 @@ Test: `test/standby-restore.test.ts`.
6673 snapshot, so anything written on the main forge after it is not there, and
6774 nothing carries standby-time changes back (there are none; it takes no writes).
6875 Nothing replicates continuously; re-restore into a new directory to catch up.
69 - The page is not pushed to Cloudflare automatically; `standby.ts` is started by hand.
76 - The page is not pushed to Cloudflare automatically; `standby.ts` is started by hand (the forge only checks it).
7077 - The admin is told on the page, event and status, and by the notification address they saved (`/account`); no mail.
7178 - Where the kernel gives no boot id, any restart ends maintenance.
src/http/api/maintenance.ts +3 −2
@@ -1,5 +1,6 @@
11 /** `/api/v1/admin/maintenance`: what the Maintenance page and MCP `maintenance` do, for a forge admin's admin-scope credential. */
22
3 import { statusWithStandby } from "../../maintenance/standby.ts";
34 import { prepare, resumeNow, status } from "../../maintenance/service.ts";
45 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
56 import { crossOrigin, type Handler } from "../context.ts";
@@ -10,13 +11,13 @@ import type { Router } from "../router.ts";
1011 const BODY_MAX = 4096;
1112
1213 export function maintenanceRoutes(router: Router<Handler>): void {
13 router.add("GET", "/api/v1/admin/maintenance", (ctx) => {
14 router.add("GET", "/api/v1/admin/maintenance", async (ctx) => {
1415 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1516 if (!ctx.auth.user) return unauthorized();
1617 if (!ctx.auth.user.isAdmin) return notFound();
1718 const denied = requireScope(ctx, "ro");
1819 if (denied) return denied;
19 return json(status(ctx.app));
20 return json(await statusWithStandby(status(ctx.app)));
2021 });
2122 router.add("POST", "/api/v1/admin/maintenance/prepare", async (ctx) => {
2223 if (!ctx.auth.user) return unauthorized();
src/http/mcp/tools/maintenance.ts +3 −2
@@ -1,10 +1,11 @@
11 import { prepare, resumeNow, status } from "../../../maintenance/service.ts";
2 import { statusWithStandby } from "../../../maintenance/standby.ts";
23 import { scopeAllows } from "../../../core/tokens.ts";
34 import { good, bad, type Tool } from "../shared.ts";
45
56 export const tool: Tool = {
67 name: "maintenance",
7 doc: "`action` is `status` (default), `prepare` or `resume`; `reason` (prepare only, at most 200 characters) is shown to visitors; a forge admin, and prepare and resume need an admin-scope token; answers the status `{ phase, since?, reason?, backup?, working, says }`",
8 doc: "`action` is `status` (default), `prepare` or `resume`; `reason` (prepare only, at most 200 characters) is shown to visitors; a forge admin, and prepare and resume need an admin-scope token; answers the status `{ phase, since?, reason?, backup?, working, says, standby: { state, says } }`",
89 description: "Prepare the forge for a power-off, check the drain, or end maintenance (admin).",
910 inputSchema: {
1011 type: "object",
@@ -14,7 +15,7 @@ export const tool: Tool = {
1415 async run(ctx, user, args) {
1516 if (!user.isAdmin) return bad("not_found", "no such tool here");
1617 const action = args["action"] ?? "status";
17 if (action === "status") return good(status(ctx.app));
18 if (action === "status") return good(await statusWithStandby(status(ctx.app)));
1819 if (action !== "prepare" && action !== "resume") return bad("invalid", "action is status, prepare or resume");
1920 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "maintenance needs an admin-scope token");
2021 if (action === "resume") {
src/maintenance/standby.ts added +89
@@ -0,0 +1,89 @@
1 /**
2 * Is the standby page answering? Before the admin powers off, the forge asks
3 * the one URL in MAINTENANCE_STANDBY_URL (never one taken from a request)
4 * whether it answers 503 with the maintenance page. Plain data out.
5 */
6
7 import type { Status } from "./service.ts";
8
9 export const STANDBY_START = "No standby configured: on the droplet run `bun scripts/standby.ts 8080`, point the tunnel or Cloudflare origin at it, and set MAINTENANCE_STANDBY_URL to check it here.";
10 const TIMEOUT_MS = 2000;
11 const READ_MAX = 65536;
12
13 export interface Standby {
14 readonly state: "unset" | "answering" | "not_answering";
15 readonly says: string;
16 }
17
18 /** The configured URL when it is a plain http(s) URL, else undefined. */
19 export function standbyUrl(env: Record<string, string | undefined> = process.env): string | undefined {
20 const raw = env["MAINTENANCE_STANDBY_URL"]?.trim();
21 if (!raw) return undefined;
22 try {
23 const u = new URL(raw);
24 return u.protocol === "http:" || u.protocol === "https:" ? u.href : undefined;
25 } catch {
26 return undefined;
27 }
28 }
29
30 /** The body's first READ_MAX bytes: the stream is read until then and cancelled, never read whole. */
31 export async function readCapped(res: Response, max: number = READ_MAX): Promise<string> {
32 if (!res.body) return "";
33 const reader = res.body.getReader();
34 const chunks: Uint8Array[] = [];
outdated

Reads the entire body before slicing; a hostile or misbehaving standby can stream unbounded data in 2 s. Read at most READ_MAX bytes from the stream and cancel.

35 let have = 0;
36 try {
37 while (have < max) {
38 const { done, value } = await reader.read();
39 if (done) break;
40 const part = value.length > max - have ? value.subarray(0, max - have) : value;
41 chunks.push(part);
42 have += part.length;
43 }
outdated

No caching: each page view/API call does a live fetch, up to 2 s. Cache for a few seconds.

44 } finally {
45 await reader.cancel().catch(() => undefined);
46 }
47 return new TextDecoder().decode(Buffer.concat(chunks));
48 }
49
50 async function probeNow(url: string): Promise<Standby> {
51 try {
52 const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: "text/html" } });
53 const text = await readCapped(res);
54 if (res.status !== 503) return { state: "not_answering", says: `Standby not answering: ${url} replied ${res.status}, not 503.` };
55 if (!text.includes("Back soon")) return { state: "not_answering", says: `Standby not answering: ${url} replied 503 but not with the maintenance page.` };
56 return { state: "answering", says: `Standby answering: ${url} serves the maintenance page with 503.` };
57 } catch {
58 return { state: "not_answering", says: `Standby not answering: ${url} did not reply within ${TIMEOUT_MS / 1000} s.` };
59 }
60 }
61
62 const CACHE_MS = 5000;
63 let cached: { readonly url: string; readonly at: number; readonly result: Promise<Standby> } | undefined;
64
65 /** Forget the last answer (tests). */
66 export function forgetStandby(): void {
67 cached = undefined;
68 }
69
70 /** The standby's state; one live probe per URL every few seconds, so views cannot hammer it. */
71 export function probeStandby(url: string | undefined = standbyUrl()): Promise<Standby> {
72 if (url === undefined) return Promise.resolve({ state: "unset", says: STANDBY_START });
73 const now = Date.now();
74 if (cached && cached.url === url && now - cached.at < CACHE_MS) return cached.result;
75 const result = probeNow(url);
76 cached = { url, at: now, result };
77 return result;
78 }
79
80 /** A status with a known standby beside it; "ready" says whether visitors will see the page. */
81 export function withStandby(s: Status, standby: Standby): Status & { readonly standby: Standby } {
82 if (s.phase !== "ready") return { ...s, standby };
83 const lead = standby.state === "answering" ? "Ready, standby answering" : standby.state === "unset" ? "Ready, no standby" : "Ready, standby not answering";
84 return { ...s, standby, says: `${lead}: ${s.says.replace(/^Ready: /, "")}` };
85 }
86
87 export async function statusWithStandby(s: Status, probe: () => Promise<Standby> = probeStandby): Promise<Status & { readonly standby: Standby }> {
88 return withStandby(s, await probe());
89 }
src/tui/screens/maintenance.ts +15 −4
@@ -8,6 +8,7 @@
88 */
99
1010 import { ABOUT_TO_STOP_ROWS, prepare, resumeNow, status } from "../../maintenance/service.ts";
11 import { probeStandby, withStandby, type Standby } from "../../maintenance/standby.ts";
1112 import type { Ctx, Screen } from "../app/app.ts";
1213 import { binding } from "../app/keys.ts";
1314 import type { Session } from "../session.ts";
@@ -19,12 +20,15 @@ import type { PaletteEntry } from "./entries.ts";
1920 const WIDTH = 72;
2021 const INNER = WIDTH - 6;
2122
22 export function maintenanceLines(f: Session): string[] {
23 const s = status(f.app);
23 export function maintenanceLines(f: Session, standby?: Standby): string[] {
24 const s = standby ? withStandby(status(f.app), standby) : status(f.app);
25 // Said once: the lead sentence carries it when ready; otherwise its own line.
26 const standbyLine = standby && s.phase !== "ready" ? wrap(standby.says, INNER) : [];
2427 const facts =
2528 s.phase === "idle"
26 ? ABOUT_TO_STOP_ROWS.flatMap((r) => wrap(`${r.what}: ${r.stops}`, INNER))
29 ? [...standbyLine, ...ABOUT_TO_STOP_ROWS.flatMap((r) => wrap(`${r.what}: ${r.stops}`, INNER))]
2730 : [
31 ...standbyLine,
2832 ...wrap(`Since ${s.since ?? ""}${s.reason ? `: ${s.reason}` : ""}`, INNER),
2933 ...wrap(`Backup: ${s.backup?.says ?? ""}`, INNER),
3034 `Still working: ${s.working}`,
@@ -44,6 +48,13 @@ export function maintenanceLines(f: Session): string[] {
4448 export function maintenanceScreen(f: Session, ctx: Ctx): Screen {
4549 const mayAct = (): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
4650 let busy = false;
51 let standby: Standby | undefined;
52 const learnStandby = (): void =>
53 runAsync(ctx, async () => {
54 standby = await probeStandby();
55 ctx.invalidate();
56 });
57 learnStandby();
4758 let note = "";
4859 const say = (text: string): void => {
4960 note = text;
@@ -117,7 +128,7 @@ export function maintenanceScreen(f: Session, ctx: Ctx): Screen {
117128 render(area, s) {
118129 const buttons = dialog(s, area, ctx.theme.c, {
119130 title: "Maintenance",
120 body: [...(note === "" ? [] : [...wrap(note, INNER), ""]), ...maintenanceLines(f)],
131 body: [...(note === "" ? [] : [...wrap(note, INNER), ""]), ...maintenanceLines(f, standby)],
121132 actions: [{ label: "Close" }],
122133 selected: 0,
123134 width: WIDTH,
src/web/pages/maintenance.ts +5 −2
@@ -5,6 +5,7 @@
55 */
66
77 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
8 import { statusWithStandby } from "../../maintenance/standby.ts";
89 import { ABOUT_TO_STOP_ROWS, prepare, resumeNow, status } from "../../maintenance/service.ts";
910 import type { Ctx } from "../../http/context.ts";
1011 import { notFoundPage, signInFirst } from "../chrome.ts";
@@ -22,14 +23,15 @@ function admin(ctx: Ctx): Response | undefined {
2223 /** One fact of the page: what it is and how it stands, as a `dl` row. */
2324 const fact = (label: string, value: Html | string): Html => html`<div class="status-row"><dt>${label}</dt><dd>${value}</dd></div>`;
2425
25 export function maintenancePage(ctx: Ctx): Response {
26 export async function maintenancePage(ctx: Ctx): Promise<Response> {
2627 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2728 const denied = admin(ctx);
2829 if (denied) return denied;
2930 const rendering = performance.now();
30 const s = status(ctx.app);
31 const s = await statusWithStandby(status(ctx.app));
3132 const body = html`<div class="page-head"><h1>Maintenance</h1></div>
3233 <p id="maintenance-says">${s.says}</p>
34 ${s.phase === "idle" ? html`<p id="maintenance-standby" class="muted">${s.standby.says}</p>` : null}
3335 ${s.phase === "idle"
3436 ? html`<p class="muted">Get the forge ready for a planned power-off. What stops:</p>
3537 <dl class="status-rows">${ABOUT_TO_STOP_ROWS.map((r) => fact(r.what, r.stops))}</dl>
@@ -49,6 +51,7 @@ export function maintenancePage(ctx: Ctx): Response {
4951 : html`<ul id="maintenance-checks" class="muted">${s.last.verified.map((v) => html`<li>${v}</li>`)}</ul>`}`
5052 : null}`
5153 : html`<dl class="status-rows">
54 ${s.phase === "ready" ? null : fact("Standby", s.standby.says)}
5255 ${fact("Since", s.since ?? "")} ${s.reason ? fact("Reason", s.reason) : null}
5356 ${fact("Backup", s.backup?.says ?? "")} ${fact("Still working", String(s.working))}
5457 </dl>
test/maintenance-standby.test.ts added +100
@@ -0,0 +1,100 @@
1 import { afterAll, expect, test } from "bun:test";
2 import { maintenanceHtml, MAINTENANCE_HEADERS } from "../src/maintenance/page.ts";
3 import type { Status } from "../src/maintenance/service.ts";
4 import { forgetStandby, probeStandby, readCapped, withStandby, standbyUrl, statusWithStandby, STANDBY_START } from "../src/maintenance/standby.ts";
5
6 const standby = Bun.serve({ port: 0, fetch: () => new Response(maintenanceHtml(), { status: 503, headers: MAINTENANCE_HEADERS }) });
7 const wrong = Bun.serve({ port: 0, fetch: () => new Response("<h1>forge</h1>", { status: 200 }) });
8 afterAll(() => {
9 standby.stop(true);
10 wrong.stop(true);
11 });
12
13 const ready: Status = { phase: "ready", working: 0, says: "Ready: releases are held. The host can power off." };
14
15 test("unset: says how to start one", async () => {
16 expect(standbyUrl({})).toBeUndefined();
17 expect(standbyUrl({ MAINTENANCE_STANDBY_URL: "file:///etc/passwd" })).toBeUndefined();
18 const p = await probeStandby(undefined);
19 expect(p.state).toBe("unset");
20 expect(p.says).toBe(STANDBY_START);
21 expect(p.says).toContain("bun scripts/standby.ts 8080");
22 expect((await statusWithStandby(ready, async () => p)).says).toStartWith("Ready, no standby");
23 });
24
25 test("answering: 503 with the maintenance page", async () => {
26 const p = await probeStandby(`http://127.0.0.1:${standby.port}/`);
27 expect(p.state).toBe("answering");
28 const s = await statusWithStandby(ready, async () => p);
29 expect(s.says).toStartWith("Ready, standby answering");
30 expect(s.standby.state).toBe("answering");
31 });
32
33 test("not answering: wrong status, nothing listening", async () => {
34 expect((await probeStandby(`http://127.0.0.1:${wrong.port}/`)).state).toBe("not_answering");
35 const dead = Bun.serve({ port: 0, fetch: () => new Response("") });
36 const port = dead.port;
37 dead.stop(true);
38 const p = await probeStandby(`http://127.0.0.1:${port}/`);
39 expect(p.state).toBe("not_answering");
40 expect((await statusWithStandby(ready, async () => p)).says).toStartWith("Ready, standby not answering");
41 });
42
43 test("not ready: says is untouched", async () => {
44 const draining: Status = { phase: "draining", working: 2, says: "Draining: 2" };
45 expect((await statusWithStandby(draining, async () => ({ state: "unset", says: "x" }))).says).toBe("Draining: 2");
46 });
47
48 test("a redirect is not followed: it is not answering", async () => {
49 const redir = Bun.serve({ port: 0, fetch: () => new Response("", { status: 302, headers: { location: `http://127.0.0.1:${standby.port}/` } }) });
50 forgetStandby();
51 const p = await probeStandby(`http://127.0.0.1:${redir.port}/`);
52 redir.stop(true);
53 expect(p.state).toBe("not_answering");
54 expect(p.says).toContain("302");
55 });
56
57 test("a standby that never answers times out as not answering", async () => {
58 const hang = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => undefined) });
59 forgetStandby();
60 const t = performance.now();
61 const p = await probeStandby(`http://127.0.0.1:${hang.port}/`);
62 hang.stop(true);
63 expect(p.state).toBe("not_answering");
64 expect(performance.now() - t).toBeLessThan(4000);
65 });
66
67 test("the body is read up to the cap and the stream cancelled", async () => {
68 let cancelled = false;
69 let sent = 0;
70 const body = new ReadableStream<Uint8Array>({
71 pull(c) {
72 sent += 16384;
73 c.enqueue(new Uint8Array(16384).fill(97));
74 },
75 cancel() {
76 cancelled = true;
77 },
78 });
79 const text = await readCapped(new Response(body), 40000);
80 expect(text.length).toBe(40000);
81 expect(cancelled).toBe(true);
82 expect(sent).toBeLessThan(100000);
83 });
84
85 test("the probe is cached for a few seconds", async () => {
86 let hits = 0;
87 const counted = Bun.serve({ port: 0, fetch: () => (hits++, new Response("no", { status: 200 })) });
88 forgetStandby();
89 const url = `http://127.0.0.1:${counted.port}/`;
90 await probeStandby(url);
91 await probeStandby(url);
92 counted.stop(true);
93 expect(hits).toBe(1);
94 });
95
96 test("said once: ready carries it in the lead, withStandby leaves other phases alone", () => {
97 const s = withStandby(ready, { state: "answering", says: "Standby answering: x" });
98 expect(s.says).toStartWith("Ready, standby answering");
99 expect(s.standby.says).toBe("Standby answering: x");
100 });
test/maintenance.test.ts +28 −3
@@ -9,14 +9,14 @@ import { createUser, type User } from "../src/core/users.ts";
99 import { serve, type Servers } from "../src/http/server.ts";
1010 import { standingHolds } from "../src/keeper/hold.ts";
1111 import { maintenanceHtml, MAINTENANCE_HEADERS } from "../src/maintenance/page.ts";
12 import { forgetStandby } from "../src/maintenance/standby.ts";
1213 import { prepare, resume, resumeAtBoot, resumeNow, status } from "../src/maintenance/service.ts";
1314 import { inMaintenance, readState, writeState } from "../src/maintenance/state.ts";
1415 import { HOLD_WORDS } from "../src/runs/held.ts";
1516 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
1617 import { dirStore } from "../src/snapshot/store.ts";
1718 import { runnerCheck, tunnelCheck } from "../src/maintenance/checks.ts";
18 import { maintenanceLines } from "../src/tui/screens/maintenance.ts";
19 import { entry, maintenanceScreen } from "../src/tui/screens/maintenance.ts";
19 import { entry, maintenanceLines, maintenanceScreen } from "../src/tui/screens/maintenance.ts";
2020 import { quietLogs } from "./helpers/globals.ts";
2121
2222 quietLogs();
@@ -148,6 +148,31 @@ describe("prepare and come back", () => {
148148 expect(inMaintenance(dataDir)).toBe(false);
149149 });
150150
151 test("the standby is said on the page, the API and MCP; idle shows its line, once", async () => {
152 const standby = Bun.serve({ port: 0, fetch: () => new Response(maintenanceHtml(), { status: 503, headers: MAINTENANCE_HEADERS }) });
153 const before = process.env["MAINTENANCE_STANDBY_URL"];
154 process.env["MAINTENANCE_STANDBY_URL"] = `http://127.0.0.1:${standby.port}/`;
155 forgetStandby();
156 try {
157 const cookie = await login("alice");
158 const html = await (await fetch(`${servers.url}/maintenance`, { headers: { cookie } })).text();
159 expect(html).toContain('id="maintenance-standby"');
160 expect(html).toContain("Standby answering");
161 const api = (await (await fetch(`${servers.url}/api/v1/admin/maintenance`, { headers: { authorization: `Bearer ${adminRo}` } })).json()) as { standby: { state: string } };
162 expect(api.standby.state).toBe("answering");
163 const mcp = await (await post("/mcp", { authorization: `Bearer ${adminAdmin}`, accept: "application/json, text/event-stream" },
164 JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "maintenance", arguments: { action: "status" } } }))).text();
165 expect(mcp).toContain("answering");
166 const lines = maintenanceLines({ user: admin, app, db: app.db } as never, api.standby as never).join("\n");
167 expect(lines).toContain("Standby answering");
168 } finally {
169 if (before === undefined) delete process.env["MAINTENANCE_STANDBY_URL"];
170 else process.env["MAINTENANCE_STANDBY_URL"] = before;
171 forgetStandby();
172 standby.stop(true);
173 }
174 });
175
151176 test("page: the confirm box is required to prepare", async () => {
152177 const cookie = await login("alice");
153178 const idle = await (await fetch(`${servers.url}/maintenance`, { headers: { cookie } })).text();
@@ -173,7 +198,7 @@ describe("prepare and come back", () => {
173198 const session = (user: User) => ({ user, app, db: app.db }) as never;
174199 expect(entry.offered(session(admin))).toBe(true);
175200 expect(entry.offered(session(bob))).toBe(false);
176 const screen = maintenanceScreen(session(admin), {} as never);
201 const screen = maintenanceScreen(session(admin), { track: (p: Promise<unknown>) => p, invalidate: () => undefined } as never);
177202 expect(screen.title).toBe("Maintenance");
178203 expect(screen.keys.map((k) => k.help).join(" ")).toContain("end maintenance");
179204 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs