Public: anyone can read this; the crew can change it.
/maintenance says whether the standby page is answering before the admin powers off #9663
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9656-maintenance-says-whether-the-standby-pag → main 5 commits fork 3 threads · 3 unresolved
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. | ||
| 37 | 37 | Cloudflare's origin at it for the move, and back after. A Cloudflare Worker can |
| 38 | 38 | return the same HTML with status 503 and `Retry-After`. |
| 39 | 39 | |
| 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 | ||
| 40 | 47 | **Size of a standby.** For reads or a whole forge: disk for the data (38 GB now, |
| 41 | 48 | apps 13 GB) plus one snapshot and headroom, so 64 GB; 4 GB of memory free for the |
| 42 | 49 | forge alone, 8 GB with apps running. The 16 GB droplet cannot hold it. |
| @@ -66,6 +73,6 @@ Test: `test/standby-restore.test.ts`. | ||
| 66 | 73 | snapshot, so anything written on the main forge after it is not there, and |
| 67 | 74 | nothing carries standby-time changes back (there are none; it takes no writes). |
| 68 | 75 | 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). | |
| 70 | 77 | - The admin is told on the page, event and status, and by the notification address they saved (`/account`); no mail. |
| 71 | 78 | - Where the kernel gives no boot id, any restart ends maintenance. |
src/http/api/maintenance.ts +3 −2
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | /** `/api/v1/admin/maintenance`: what the Maintenance page and MCP `maintenance` do, for a forge admin's admin-scope credential. */ |
| 2 | 2 | |
| 3 | import { statusWithStandby } from "../../maintenance/standby.ts"; | |
| 3 | 4 | import { prepare, resumeNow, status } from "../../maintenance/service.ts"; |
| 4 | 5 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 5 | 6 | import { crossOrigin, type Handler } from "../context.ts"; |
| @@ -10,13 +11,13 @@ import type { Router } from "../router.ts"; | ||
| 10 | 11 | const BODY_MAX = 4096; |
| 11 | 12 | |
| 12 | 13 | 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) => { | |
| 14 | 15 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 15 | 16 | if (!ctx.auth.user) return unauthorized(); |
| 16 | 17 | if (!ctx.auth.user.isAdmin) return notFound(); |
| 17 | 18 | const denied = requireScope(ctx, "ro"); |
| 18 | 19 | if (denied) return denied; |
| 19 | return json(status(ctx.app)); | |
| 20 | return json(await statusWithStandby(status(ctx.app))); | |
| 20 | 21 | }); |
| 21 | 22 | router.add("POST", "/api/v1/admin/maintenance/prepare", async (ctx) => { |
| 22 | 23 | if (!ctx.auth.user) return unauthorized(); |
src/http/mcp/tools/maintenance.ts +3 −2
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { prepare, resumeNow, status } from "../../../maintenance/service.ts"; |
| 2 | import { statusWithStandby } from "../../../maintenance/standby.ts"; | |
| 2 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 3 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | 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 } }`", | |
| 8 | 9 | description: "Prepare the forge for a power-off, check the drain, or end maintenance (admin).", |
| 9 | 10 | inputSchema: { |
| 10 | 11 | type: "object", |
| @@ -14,7 +15,7 @@ export const tool: Tool = { | ||
| 14 | 15 | async run(ctx, user, args) { |
| 15 | 16 | if (!user.isAdmin) return bad("not_found", "no such tool here"); |
| 16 | 17 | 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))); | |
| 18 | 19 | if (action !== "prepare" && action !== "resume") return bad("invalid", "action is status, prepare or resume"); |
| 19 | 20 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "maintenance needs an admin-scope token"); |
| 20 | 21 | 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[] = []; | |
| 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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { ABOUT_TO_STOP_ROWS, prepare, resumeNow, status } from "../../maintenance/service.ts"; |
| 11 | import { probeStandby, withStandby, type Standby } from "../../maintenance/standby.ts"; | |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 13 | import { binding } from "../app/keys.ts"; |
| 13 | 14 | import type { Session } from "../session.ts"; |
| @@ -19,12 +20,15 @@ import type { PaletteEntry } from "./entries.ts"; | ||
| 19 | 20 | const WIDTH = 72; |
| 20 | 21 | const INNER = WIDTH - 6; |
| 21 | 22 | |
| 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) : []; | |
| 24 | 27 | const facts = |
| 25 | 28 | 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))] | |
| 27 | 30 | : [ |
| 31 | ...standbyLine, | |
| 28 | 32 | ...wrap(`Since ${s.since ?? ""}${s.reason ? `: ${s.reason}` : ""}`, INNER), |
| 29 | 33 | ...wrap(`Backup: ${s.backup?.says ?? ""}`, INNER), |
| 30 | 34 | `Still working: ${s.working}`, |
| @@ -44,6 +48,13 @@ export function maintenanceLines(f: Session): string[] { | ||
| 44 | 48 | export function maintenanceScreen(f: Session, ctx: Ctx): Screen { |
| 45 | 49 | const mayAct = (): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; |
| 46 | 50 | 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(); | |
| 47 | 58 | let note = ""; |
| 48 | 59 | const say = (text: string): void => { |
| 49 | 60 | note = text; |
| @@ -117,7 +128,7 @@ export function maintenanceScreen(f: Session, ctx: Ctx): Screen { | ||
| 117 | 128 | render(area, s) { |
| 118 | 129 | const buttons = dialog(s, area, ctx.theme.c, { |
| 119 | 130 | title: "Maintenance", |
| 120 | body: [...(note === "" ? [] : [...wrap(note, INNER), ""]), ...maintenanceLines(f)], | |
| 131 | body: [...(note === "" ? [] : [...wrap(note, INNER), ""]), ...maintenanceLines(f, standby)], | |
| 121 | 132 | actions: [{ label: "Close" }], |
| 122 | 133 | selected: 0, |
| 123 | 134 | width: WIDTH, |
src/web/pages/maintenance.ts +5 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 8 | import { statusWithStandby } from "../../maintenance/standby.ts"; | |
| 8 | 9 | import { ABOUT_TO_STOP_ROWS, prepare, resumeNow, status } from "../../maintenance/service.ts"; |
| 9 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 10 | 11 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| @@ -22,14 +23,15 @@ function admin(ctx: Ctx): Response | undefined { | ||
| 22 | 23 | /** One fact of the page: what it is and how it stands, as a `dl` row. */ |
| 23 | 24 | const fact = (label: string, value: Html | string): Html => html`<div class="status-row"><dt>${label}</dt><dd>${value}</dd></div>`; |
| 24 | 25 | |
| 25 | export function maintenancePage(ctx: Ctx): Response { | |
| 26 | export async function maintenancePage(ctx: Ctx): Promise<Response> { | |
| 26 | 27 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 27 | 28 | const denied = admin(ctx); |
| 28 | 29 | if (denied) return denied; |
| 29 | 30 | const rendering = performance.now(); |
| 30 | const s = status(ctx.app); | |
| 31 | const s = await statusWithStandby(status(ctx.app)); | |
| 31 | 32 | const body = html`<div class="page-head"><h1>Maintenance</h1></div> |
| 32 | 33 | <p id="maintenance-says">${s.says}</p> |
| 34 | ${s.phase === "idle" ? html`<p id="maintenance-standby" class="muted">${s.standby.says}</p>` : null} | |
| 33 | 35 | ${s.phase === "idle" |
| 34 | 36 | ? html`<p class="muted">Get the forge ready for a planned power-off. What stops:</p> |
| 35 | 37 | <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 { | ||
| 49 | 51 | : html`<ul id="maintenance-checks" class="muted">${s.last.verified.map((v) => html`<li>${v}</li>`)}</ul>`}` |
| 50 | 52 | : null}` |
| 51 | 53 | : html`<dl class="status-rows"> |
| 54 | ${s.phase === "ready" ? null : fact("Standby", s.standby.says)} | |
| 52 | 55 | ${fact("Since", s.since ?? "")} ${s.reason ? fact("Reason", s.reason) : null} |
| 53 | 56 | ${fact("Backup", s.backup?.says ?? "")} ${fact("Still working", String(s.working))} |
| 54 | 57 | </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"; | ||
| 9 | 9 | import { serve, type Servers } from "../src/http/server.ts"; |
| 10 | 10 | import { standingHolds } from "../src/keeper/hold.ts"; |
| 11 | 11 | import { maintenanceHtml, MAINTENANCE_HEADERS } from "../src/maintenance/page.ts"; |
| 12 | import { forgetStandby } from "../src/maintenance/standby.ts"; | |
| 12 | 13 | import { prepare, resume, resumeAtBoot, resumeNow, status } from "../src/maintenance/service.ts"; |
| 13 | 14 | import { inMaintenance, readState, writeState } from "../src/maintenance/state.ts"; |
| 14 | 15 | import { HOLD_WORDS } from "../src/runs/held.ts"; |
| 15 | 16 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; |
| 16 | 17 | import { dirStore } from "../src/snapshot/store.ts"; |
| 17 | 18 | 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"; | |
| 20 | 20 | import { quietLogs } from "./helpers/globals.ts"; |
| 21 | 21 | |
| 22 | 22 | quietLogs(); |
| @@ -148,6 +148,31 @@ describe("prepare and come back", () => { | ||
| 148 | 148 | expect(inMaintenance(dataDir)).toBe(false); |
| 149 | 149 | }); |
| 150 | 150 | |
| 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 | ||
| 151 | 176 | test("page: the confirm box is required to prepare", async () => { |
| 152 | 177 | const cookie = await login("alice"); |
| 153 | 178 | const idle = await (await fetch(`${servers.url}/maintenance`, { headers: { cookie } })).text(); |
| @@ -173,7 +198,7 @@ describe("prepare and come back", () => { | ||
| 173 | 198 | const session = (user: User) => ({ user, app, db: app.db }) as never; |
| 174 | 199 | expect(entry.offered(session(admin))).toBe(true); |
| 175 | 200 | 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); | |
| 177 | 202 | expect(screen.title).toBe("Maintenance"); |
| 178 | 203 | expect(screen.keys.map((k) => k.help).join(" ")).toContain("end maintenance"); |
| 179 | 204 | }); |
new above
offline
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.