Public: anyone can read this; the crew can change it.
A restore drill script restores a snapshot onto the forge box from the bucket alone and compares counts with production #9058
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9046-a-restore-drill-script-restores-a-snapsh → main 1 commit fork 1 thread · 1 unresolved
5 files
changed +404
−2
docs/RESTORE.md +18
| @@ -37,3 +37,21 @@ sealed values to set again. To pick an older snapshot, put its name | ||
| 37 | 37 | On a box that stays: `scripts/box-install.sh`, then lines 3–6 as the `forge` |
| 38 | 38 | user with `<data-dir>` `/var/lib/forge`, then `systemctl enable --now forge` |
| 39 | 39 | in place of line 7 ([](EXTRACT.md), _The box beside production_). |
| 40 | ||
| 41 | ## The drill on the box | |
| 42 | ||
| 43 | `scripts/restore-drill.ts` runs lines 3–7 and checks them, from the bucket | |
| 44 | alone: with the card's `S3_*` lines and the passphrase exported, as the | |
| 45 | `forge` user, | |
| 46 | ||
| 47 | bun scripts/restore-drill.ts <empty-data-dir> | |
| 48 | ||
| 49 | restores the newest snapshot into that directory, boots it on a free side | |
| 50 | port, checks `/` answers, and prints the repositories and apps anyone can | |
| 51 | see beside production's public API (`--production <url>`, default | |
| 52 | `https://forge.open-platform.sh`); the restored forge boots as a preview, | |
| 53 | so the sample its preview admin seeds is left out. It exits 1 when a step fails or a count | |
| 54 | differs. It writes only into the directory it is given, which must be empty, | |
| 55 | refuses `/data`, and leaves the restored copy in place. | |
| 56 | `test/restore-drill-script.test.ts` runs it against a tiny snapshot and a | |
| 57 | stub bucket. |
scripts/restore-drill.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** | |
| 2 | * The restore drill on the box beside production (#9046): the newest | |
| 3 | * snapshot restored from the bucket alone, booted, and set beside | |
| 4 | * production. It reads only what the restore card names — the `S3_*` | |
| 5 | * variables (or `FORGE_SNAPSHOT_DIR`) and `FORGE_SNAPSHOT_PASSPHRASE` — | |
| 6 | * writes only into the empty data directory it is given, and reads | |
| 7 | * production through its public API, nothing else of the platform. | |
| 8 | * | |
| 9 | * bun scripts/restore-drill.ts <data-dir> [--production https://forge.open-platform.sh] | |
| 10 | * | |
| 11 | * It restores, boots the restored forge on a free side port, checks `/` | |
| 12 | * answers, counts the repositories and apps anyone can see there and on | |
| 13 | * production, prints both, and exits 1 when any step fails or a count | |
| 14 | * differs. The restored directory stays: it is the box's own copy. | |
| 15 | */ | |
| 16 | ||
| 17 | import { resolve } from "node:path"; | |
| 18 | import { bootRestored } from "../src/snapshot/boot.ts"; | |
| 19 | import { resolveDestination } from "../src/snapshot/destination.ts"; | |
| 20 | import { publicCounts, type PublicCounts } from "../src/snapshot/public_counts.ts"; | |
| 21 | import { restoreSnapshot } from "../src/snapshot/restore.ts"; | |
| 22 | import { listSnapshots } from "../src/snapshot/snapshot.ts"; | |
| 23 | import { PREVIEW_ADMIN } from "../src/spawn/preview-looks.ts"; | |
| 24 | ||
| 25 | export const PRODUCTION = "https://forge.open-platform.sh"; | |
| 26 | /** Production's own data directory on the platform; the drill never writes there. */ | |
| 27 | const PRODUCTION_DATA = "/data"; | |
| 28 | const BOOT_MS = 60_000; | |
| 29 | ||
| 30 | /** The arguments as plain data, or the reason they are not usable. */ | |
| 31 | export function parseArgs(args: readonly string[]): { dataDir: string; production: string } | string { | |
| 32 | const rest = [...args]; | |
| 33 | let production = PRODUCTION; | |
| 34 | const at = rest.indexOf("--production"); | |
| 35 | if (at >= 0) { | |
| 36 | production = rest[at + 1] ?? ""; | |
| 37 | rest.splice(at, 2); | |
| 38 | } | |
| 39 | let url: URL; | |
| 40 | try { | |
| 41 | url = new URL(production); | |
| 42 | } catch { | |
| 43 | return `--production takes a URL, not ${JSON.stringify(production)}`; | |
| 44 | } | |
| 45 | const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost"; | |
| 46 | if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) | |
| 47 | return "--production takes an https URL"; | |
| 48 | if (rest.length !== 1 || !rest[0] || rest[0].startsWith("-")) | |
| 49 | return "usage: bun scripts/restore-drill.ts <data-dir> [--production URL]"; | |
| 50 | const dataDir = resolve(rest[0]); | |
| 51 | if (dataDir === PRODUCTION_DATA) return `${dataDir} is production's data directory; restore into the box's own`; | |
| 52 | return { dataDir, production: url.origin }; | |
| 53 | } | |
| 54 | ||
| 55 | /** The two sides' counts as lines a person reads, and which differ. */ | |
| 56 | export function compareLines(restored: PublicCounts, production: PublicCounts): { lines: string[]; differ: string[] } { | |
| 57 | const keys = Object.keys(restored) as (keyof PublicCounts)[]; | |
| 58 | const differ = keys.filter((k) => restored[k] !== production[k]); | |
| 59 | const lines = keys.map( | |
| 60 | (k) => `${k}: restored ${restored[k]}, production ${production[k]}${differ.includes(k) ? " DIFFERS" : ""}`, | |
| 61 | ); | |
| 62 | return { lines, differ }; | |
| 63 | } | |
| 64 | ||
| 65 | async function main(): Promise<number> { | |
| 66 | const args = parseArgs(process.argv.slice(2)); | |
| 67 | // CLI output below is for the person at the box, and is posted on the issue. | |
| 68 | if (typeof args === "string") { | |
| 69 | console.error(args); | |
| 70 | return 2; | |
| 71 | } | |
| 72 | const store = resolveDestination()?.store; | |
| 73 | if (store === undefined) { | |
| 74 | console.error("no store: export the S3_* lines of the restore card, or FORGE_SNAPSHOT_DIR"); | |
| 75 | return 2; | |
| 76 | } | |
| 77 | const newest = (await listSnapshots(store))[0]; | |
| 78 | if (newest === undefined) { | |
| 79 | console.error(`no snapshot in ${store.where}`); | |
| 80 | return 1; | |
| 81 | } | |
| 82 | const began = performance.now(); | |
| 83 | const seconds = (): string => ((performance.now() - began) / 1000).toFixed(1); | |
| 84 | console.log(`restore-drill: ${newest.name} from ${store.where} into ${args.dataDir}`); | |
| 85 | const done = await restoreSnapshot(store, newest.name, args.dataDir, { | |
| 86 | passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined, | |
| 87 | }); | |
| 88 | console.log(`restored ${done.files} files and ${done.repos} repositories, key ${done.key}, ${seconds()}s`); | |
| 89 | if (done.sealedMissing.length > 0) console.log(`sealed values to set again: ${done.sealedMissing.length}`); | |
| 90 | ||
| 91 | let restored: PublicCounts | undefined; | |
| 92 | const booted = await bootRestored(args.dataDir, BOOT_MS, undefined, async (base) => { | |
| 93 | const home = await fetch(`${base}/`, { redirect: "manual", signal: AbortSignal.timeout(15_000) }); | |
| 94 | if (home.status >= 400) throw new Error(`/ answered ${home.status}`); | |
| 95 | console.log(`booted: / answered ${home.status}, ${seconds()}s`); | |
| 96 | // The boot is a preview, which seeds the preview admin's sample on top. | |
| 97 | restored = await publicCounts(base, { except: PREVIEW_ADMIN }); | |
| 98 | }); | |
| 99 | if (!booted.ok || restored === undefined) { | |
| 100 | console.error(`boot failed: ${booted.reason || "no counts"}`); | |
| 101 | return 1; | |
| 102 | } | |
| 103 | let production: PublicCounts; | |
| 104 | try { | |
| 105 | production = await publicCounts(args.production); | |
| 106 | } catch (error) { | |
| 107 | console.error(`production ${args.production} did not answer: ${error instanceof Error ? error.message : String(error)}`); | |
| 108 | return 1; | |
| 109 | } | |
| 110 | const { lines, differ } = compareLines(restored, production); | |
| 111 | for (const line of lines) console.log(line); | |
| 112 | console.log(differ.length === 0 ? `restore-drill: green, ${seconds()}s` : `restore-drill: red, ${differ.join(" and ")} differ`); | |
| 113 | return differ.length === 0 ? 0 : 1; | |
| 114 | } | |
| 115 | ||
| 116 | if (import.meta.main) process.exit(await main()); |
src/snapshot/boot.ts +18 −2
| @@ -17,12 +17,18 @@ export interface Booted { | ||
| 17 | 17 | |
| 18 | 18 | const TREE = join(import.meta.dir, "../.."); |
| 19 | 19 | |
| 20 | /** | |
| 21 | * `whileUp`, when given, is called with the restored forge's base URL once | |
| 22 | * it reads healthy and before it is killed; what it throws is the reason | |
| 23 | * the boot is not ok (the restore drill script reads `/` and counts there). | |
| 24 | */ | |
| 20 | 25 | export async function bootRestored( |
| 21 | 26 | dataDir: string, |
| 22 | 27 | budgetMs: number, |
| 23 | 28 | tree: string = TREE, |
| 29 | whileUp?: (base: string) => Promise<void>, | |
| 24 | 30 | ): Promise<Booted> { |
| 25 | return onFreshPort((port) => bootOnPort(dataDir, budgetMs, port, tree)); | |
| 31 | return onFreshPort((port) => bootOnPort(dataDir, budgetMs, port, tree, whileUp)); | |
| 26 | 32 | } |
| 27 | 33 | |
| 28 | 34 | async function bootOnPort( |
| @@ -30,6 +36,7 @@ async function bootOnPort( | ||
| 30 | 36 | budgetMs: number, |
| 31 | 37 | port: number, |
| 32 | 38 | tree: string, |
| 39 | whileUp: ((base: string) => Promise<void>) | undefined, | |
| 33 | 40 | ): Promise<{ result: Booted; portLost: boolean }> { |
| 34 | 41 | const child = Bun.spawn([process.execPath, join(tree, "src/main.ts")], { |
| 35 | 42 | cwd: tree, |
| @@ -66,7 +73,16 @@ async function bootOnPort( | ||
| 66 | 73 | signal: AbortSignal.timeout(2_000), |
| 67 | 74 | }); |
| 68 | 75 | const body = (await res.json().catch(() => null)) as { ok?: unknown } | null; |
| 69 | if (res.ok && body?.ok === true) return { result: { ok: true, reason: "" }, portLost: false }; | |
| 76 | if (res.ok && body?.ok === true) { | |
| 77 | if (whileUp !== undefined) { | |
| 78 | try { | |
| 79 | await whileUp(`http://127.0.0.1:${port}`); | |
| 80 | } catch (error) { | |
| 81 | return no(error instanceof Error ? error.message : String(error)); | |
| 82 | } | |
| 83 | } | |
| 84 | return { result: { ok: true, reason: "" }, portLost: false }; | |
| 85 | } | |
| 70 | 86 | last = `health answered ${res.status}${body?.ok === false ? " not ok" : ""}`; |
| 71 | 87 | } catch { |
| 72 | 88 | last = "no answer yet"; |
src/snapshot/public_counts.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * What a forge shows anyone, counted through its public API: repositories | |
| 3 | * from `/api/v1/repos` page by page, apps from `/api/v1/apps`, asked with | |
| 4 | * no session. The restore drill script asks a restored forge and | |
| 5 | * production the same way, so the two numbers cover the same rows. | |
| 6 | * | |
| 7 | * `except` leaves out one owner's rows: a restored forge boots as a | |
| 8 | * preview, which seeds the preview admin's sample repository on top of | |
| 9 | * what was restored, and that is not production's. | |
| 10 | */ | |
| 11 | ||
| 12 | export interface PublicCounts { | |
| 13 | readonly repositories: number; | |
| 14 | readonly apps: number; | |
| 15 | } | |
| 16 | ||
| 17 | /** A forge answers more pages than this only if it is lying about its cursor. */ | |
| 18 | const MAX_PAGES = 1_000; | |
| 19 | ||
| 20 | type Fetch = (url: string, init?: RequestInit) => Promise<Response>; | |
| 21 | ||
| 22 | async function page(fetcher: Fetch, url: string): Promise<{ items: unknown[]; next: string | null }> { | |
| 23 | const res = await fetcher(url, { signal: AbortSignal.timeout(15_000), redirect: "error" }); | |
| 24 | if (!res.ok) throw new Error(`${url} answered ${res.status}`); | |
| 25 | const body = (await res.json().catch(() => null)) as { items?: unknown; next_cursor?: unknown } | null; | |
| 26 | if (body === null || !Array.isArray(body.items)) throw new Error(`${url} answered no items`); | |
| 27 | const next = body.next_cursor; | |
| 28 | return { | |
| 29 | items: body.items, | |
| 30 | next: typeof next === "string" || typeof next === "number" ? String(next) : null, | |
| 31 | }; | |
| 32 | } | |
| 33 | ||
| 34 | /** The owner of a repository item (`ownerName`) or an app item (`repo.owner`). */ | |
| 35 | function ownerOf(item: unknown): string | undefined { | |
| 36 | if (typeof item !== "object" || item === null) return undefined; | |
| 37 | const o = item as { ownerName?: unknown; repo?: { owner?: unknown } }; | |
| 38 | const name = typeof o.ownerName === "string" ? o.ownerName : o.repo?.owner; | |
| 39 | return typeof name === "string" ? name : undefined; | |
| 40 | } | |
| 41 | ||
| 42 | async function countAll(fetcher: Fetch, base: string, path: string, except: string | undefined): Promise<number> { | |
| 43 | let n = 0; | |
| 44 | let cursor: string | null = null; | |
| 45 | for (let i = 0; i < MAX_PAGES; i++) { | |
| 46 | const url = new URL(path, base); | |
| 47 | url.searchParams.set("limit", "100"); | |
| 48 | if (cursor !== null) url.searchParams.set("cursor", cursor); | |
| 49 | const got = await page(fetcher, url.href); | |
| 50 | n += got.items.filter((item) => except === undefined || ownerOf(item) !== except).length; | |
| 51 | if (got.next === null || got.items.length === 0) return n; | |
| 52 | cursor = got.next; | |
| 53 | } | |
| 54 | throw new Error(`${path} on ${base} pages past ${MAX_PAGES}`); | |
| 55 | } | |
| 56 | ||
| 57 | export async function publicCounts( | |
| 58 | base: string, | |
| 59 | options: { readonly except?: string | undefined; readonly fetcher?: Fetch | undefined } = {}, | |
| 60 | ): Promise<PublicCounts> { | |
| 61 | const fetcher = options.fetcher ?? fetch; | |
| 62 | return { | |
| 63 | repositories: await countAll(fetcher, base, "/api/v1/repos", options.except), | |
| 64 | apps: await countAll(fetcher, base, "/api/v1/apps", options.except), | |
| 65 | }; | |
| 66 | } |
test/restore-drill-script.test.ts added +186
| @@ -0,0 +1,186 @@ | ||
| 1 | /** | |
| 2 | * `scripts/restore-drill.ts` (#9046) run end to end: a tiny snapshot taken | |
| 3 | * here, served from a stub bucket that speaks the S3 the store asks for, | |
| 4 | * restored with only the card's variables, booted, and its public counts | |
| 5 | * set beside a stub production's. Matching counts are green; the | |
| 6 | * arguments and the comparison are checked as plain data. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { existsSync, mkdtempSync, readdirSync, readFileSync, statSync } from "node:fs"; | |
| 10 | import { rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join, relative } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 17 | import { takeSnapshot } from "../src/snapshot/snapshot.ts"; | |
| 18 | import { compareLines, parseArgs, PRODUCTION } from "../scripts/restore-drill.ts"; | |
| 19 | import { publicCounts } from "../src/snapshot/public_counts.ts"; | |
| 20 | ||
| 21 | const PASS = "correct horse battery staple"; | |
| 22 | const BUCKET = "drill"; | |
| 23 | const ESC: Record<string, string> = { "&": "&", "<": "<", ">": ">" }; | |
| 24 | const xml = (s: string): string => s.replace(/[&<>]/g, (c) => ESC[c]!); | |
| 25 | ||
| 26 | let root: string; | |
| 27 | let app: App; | |
| 28 | let bucket: ReturnType<typeof Bun.serve>; | |
| 29 | let production: ReturnType<typeof Bun.serve>; | |
| 30 | ||
| 31 | function keysUnder(dir: string, out: string[] = []): string[] { | |
| 32 | for (const name of readdirSync(dir)) { | |
| 33 | const path = join(dir, name); | |
| 34 | if (statSync(path).isDirectory()) keysUnder(path, out); | |
| 35 | else out.push(path); | |
| 36 | } | |
| 37 | return out; | |
| 38 | } | |
| 39 | ||
| 40 | /** Path-style S3 over a directory: HEAD and GET of a key, and ListObjectsV2 with a delimiter. */ | |
| 41 | function stubBucket(dir: string): ReturnType<typeof Bun.serve> { | |
| 42 | return Bun.serve({ | |
| 43 | port: 0, | |
| 44 | hostname: "127.0.0.1", | |
| 45 | fetch(req) { | |
| 46 | const url = new URL(req.url); | |
| 47 | const path = decodeURIComponent(url.pathname); | |
| 48 | if (path === `/${BUCKET}` || path === `/${BUCKET}/`) { | |
| 49 | const prefix = url.searchParams.get("prefix") ?? ""; | |
| 50 | const delimiter = url.searchParams.get("delimiter"); | |
| 51 | const keys = keysUnder(dir).map((p) => relative(dir, p)).filter((k) => k.startsWith(prefix)).sort(); | |
| 52 | const contents: string[] = []; | |
| 53 | const prefixes = new Set<string>(); | |
| 54 | for (const key of keys) { | |
| 55 | const cut = delimiter ? key.indexOf(delimiter, prefix.length) : -1; | |
| 56 | if (cut >= 0) prefixes.add(key.slice(0, cut + 1)); | |
| 57 | else contents.push(key); | |
| 58 | } | |
| 59 | const body = | |
| 60 | `<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Name>${BUCKET}</Name><Prefix>${xml(prefix)}</Prefix>` + | |
| 61 | `<KeyCount>${contents.length + prefixes.size}</KeyCount><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated>` + | |
| 62 | contents.map((k) => `<Contents><Key>${xml(k)}</Key><Size>${statSync(join(dir, k)).size}</Size></Contents>`).join("") + | |
| 63 | [...prefixes].map((p) => `<CommonPrefixes><Prefix>${xml(p)}</Prefix></CommonPrefixes>`).join("") + | |
| 64 | `</ListBucketResult>`; | |
| 65 | return new Response(body, { headers: { "content-type": "application/xml" } }); | |
| 66 | } | |
| 67 | const key = path.slice(`/${BUCKET}/`.length); | |
| 68 | if (!path.startsWith(`/${BUCKET}/`) || key.split("/").includes("..")) return new Response(null, { status: 400 }); | |
| 69 | const file = join(dir, key); | |
| 70 | if (!existsSync(file)) return new Response(null, { status: 404 }); | |
| 71 | return new Response(req.method === "HEAD" ? null : readFileSync(file), { | |
| 72 | headers: { "content-length": String(statSync(file).size) }, | |
| 73 | }); | |
| 74 | }, | |
| 75 | }); | |
| 76 | } | |
| 77 | ||
| 78 | beforeAll(async () => { | |
| 79 | root = mkdtempSync(join(tmpdir(), "forge-restore-drill-")); | |
| 80 | app = createApp({ dataDir: join(root, "live"), port: 0 }); | |
| 81 | const u = await createUser(app.db, { | |
| 82 | origin: "seed", | |
| 83 | trusted: true, | |
| 84 | username: "alice", | |
| 85 | email: "[email protected]", | |
| 86 | password: "a-good-passphrase", | |
| 87 | adminIfFirst: true, | |
| 88 | }); | |
| 89 | if (!u.ok) throw new Error(u.error); | |
| 90 | for (const [name, visibility] of [["notes", "public"], ["diary", "private"]] as const) { | |
| 91 | const repo = await createRepo(app, u.value, { name, visibility }); | |
| 92 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 93 | const c = await commitToBranch(app, u.value, repo.value, { | |
| 94 | message: "first", | |
| 95 | files: [{ path: "README.md", content: `# ${name}\n` }], | |
| 96 | }); | |
| 97 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 98 | } | |
| 99 | await takeSnapshot( | |
| 100 | { db: app.db, dataDir: app.config.dataDir, reposRoot: app.reposRoot, version: "t", passphrase: PASS }, | |
| 101 | dirStore(join(root, "store")), | |
| 102 | new Date("2026-11-01T08:00:00Z"), | |
| 103 | ); | |
| 104 | bucket = stubBucket(join(root, "store")); | |
| 105 | // Production as anyone sees it: the one public repository, no apps. | |
| 106 | production = Bun.serve({ | |
| 107 | port: 0, | |
| 108 | hostname: "127.0.0.1", | |
| 109 | fetch(req) { | |
| 110 | const path = new URL(req.url).pathname; | |
| 111 | if (path === "/api/v1/repos") return Response.json({ items: [{ id: 1 }], next_cursor: null }); | |
| 112 | if (path === "/api/v1/apps") return Response.json({ items: [], next_cursor: null }); | |
| 113 | return new Response(null, { status: 404 }); | |
| 114 | }, | |
| 115 | }); | |
| 116 | }); | |
| 117 | ||
| 118 | afterAll(async () => { | |
| 119 | app.close(); | |
| 120 | bucket.stop(true); | |
| 121 | production.stop(true); | |
| 122 | await rm(root, { recursive: true, force: true }); | |
| 123 | }); | |
| 124 | ||
| 125 | test("the drill restores from the bucket alone, boots, and matches production's public counts", async () => { | |
| 126 | const data = join(root, "box"); | |
| 127 | const run = Bun.spawn( | |
| 128 | [process.execPath, "scripts/restore-drill.ts", data, "--production", `http://127.0.0.1:${production.port}`], | |
| 129 | { | |
| 130 | cwd: join(import.meta.dir, ".."), | |
| 131 | env: { | |
| 132 | PATH: process.env["PATH"] ?? "", | |
| 133 | HOME: root, | |
| 134 | S3_ENDPOINT: `http://127.0.0.1:${bucket.port}`, | |
| 135 | S3_BUCKET: BUCKET, | |
| 136 | S3_ACCESS_KEY: "stub", | |
| 137 | S3_SECRET_KEY: "stub", | |
| 138 | S3_REGION: "us-east-1", | |
| 139 | FORGE_SNAPSHOT_PASSPHRASE: PASS, | |
| 140 | ...(process.env["FORGE_TEST_SOCKET"] ? { FORGE_TEST_SOCKET: process.env["FORGE_TEST_SOCKET"] } : {}), | |
| 141 | }, | |
| 142 | stdout: "pipe", | |
| 143 | stderr: "pipe", | |
| 144 | }, | |
| 145 | ); | |
| 146 | const [out, err, code] = await Promise.all([new Response(run.stdout).text(), new Response(run.stderr).text(), run.exited]); | |
| 147 | expect(err + out).toContain("restore-drill: 2026-11-01T08-00-00Z from 127.0.0.1:"); | |
| 148 | expect(out).toContain("and 2 repositories"); | |
| 149 | expect(out).toMatch(/booted: \/ answered [23]\d\d/); | |
| 150 | expect(out).toContain("repositories: restored 1, production 1"); | |
| 151 | expect(out).toContain("apps: restored 0, production 0"); | |
| 152 | expect(out).toContain("restore-drill: green"); | |
| 153 | expect(code).toBe(0); | |
| 154 | expect(existsSync(join(data, "forge.db"))).toBe(true); | |
| 155 | }, 120_000); | |
| 156 | ||
| 157 | test("the arguments: a data directory, production by default, never production's own directory", () => { | |
| 158 | expect(parseArgs(["/var/lib/forge"])).toEqual({ dataDir: "/var/lib/forge", production: PRODUCTION }); | |
| 159 | expect(parseArgs(["/data"])).toContain("production's data directory"); | |
| 160 | expect(parseArgs([])).toContain("usage"); | |
| 161 | expect(parseArgs(["/x", "--production", "http://forge.example"])).toContain("https"); | |
| 162 | expect(parseArgs(["/x", "--production", "not a url"])).toContain("URL"); | |
| 163 | }); | |
| 164 | ||
| 165 | test("a count that differs is named", () => { | |
| 166 | const same = compareLines({ repositories: 3, apps: 1 }, { repositories: 3, apps: 1 }); | |
| 167 | expect(same.differ).toEqual([]); | |
| 168 | const off = compareLines({ repositories: 3, apps: 1 }, { repositories: 4, apps: 1 }); | |
| 169 | expect(off.differ).toEqual(["repositories"]); | |
| 170 | expect(off.lines[0]).toBe("repositories: restored 3, production 4 DIFFERS"); | |
| 171 | }); | |
| 172 | ||
| 173 | test("public counts follow the cursor and leave out the preview admin's seeded rows", async () => { | |
| 174 | const pages: Record<string, unknown> = { | |
| 175 | "/api/v1/repos?limit=100": { items: [{ ownerName: "alice" }, { ownerName: "preview-admin" }], next_cursor: "b" }, | |
| 176 | "/api/v1/repos?limit=100&cursor=b": { items: [{ ownerName: "bob" }], next_cursor: null }, | |
| 177 | "/api/v1/apps?limit=100": { items: [{ repo: { owner: "alice" } }, { repo: { owner: "preview-admin" } }], next_cursor: null }, | |
| 178 | }; | |
| 179 | const fetcher = async (url: string): Promise<Response> => { | |
| 180 | const u = new URL(url); | |
| 181 | const body = pages[u.pathname + u.search]; | |
| 182 | return body === undefined ? new Response(null, { status: 404 }) : Response.json(body); | |
| 183 | }; | |
| 184 | expect(await publicCounts("http://forge.test", { fetcher })).toEqual({ repositories: 3, apps: 2 }); | |
| 185 | expect(await publicCounts("http://forge.test", { fetcher, except: "preview-admin" })).toEqual({ repositories: 2, apps: 1 }); | |
| 186 | }); |
new above
offline
This line runs long against the rest of the paragraph; rewrap it.