Public: anyone can read this; the crew can change it.
Terminal: `live`, `releases` and a person's `rollback` in the forge's SSH terminal, gated as /releases is #2103
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2079-terminal-live-releases-and-a-person-s-ro → main 1 commit fork
6 files
changed +361
−56
README.md +1 −1
Diff not shown — a line 4,279 characters wide (limit 2,000). +1 −1
View filesrc/release/gate.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * Who may see the kept releases and roll back to one, and the one rollback | |
| 3 | * both clients (the /releases page and the SSH terminal) run: the same | |
| 4 | * refusals, `beginSwap`/`endSwap`, the health-read swap, then this process | |
| 5 | * drains. Plain data in, plain data out; neither client re-states a rule. | |
| 6 | */ | |
| 7 | ||
| 8 | import { existsSync } from "node:fs"; | |
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { canRead, canWrite, getRepo, type Repo } from "../core/repos.ts"; | |
| 11 | import type { User } from "../core/users.ts"; | |
| 12 | import { releaseDir } from "./checkout.ts"; | |
| 13 | import type { Release } from "./record.ts"; | |
| 14 | import { liveSchema, releaseSchema, rollback } from "./rollback.ts"; | |
| 15 | import { beginSwap, endSwap, handover } from "./swap.ts"; | |
| 16 | ||
| 17 | /** When this process began serving: "healthy since" for the release it runs. */ | |
| 18 | export const BOOTED = new Date().toISOString(); | |
| 19 | ||
| 20 | /** The forge's own code repository, when this forge names one. */ | |
| 21 | function forgeRepoOf(app: App): Repo | undefined { | |
| 22 | const named = app.config.forgeRepo; | |
| 23 | if (named === undefined) return undefined; | |
| 24 | const [owner, name] = named.split("/"); | |
| 25 | return owner === undefined || name === undefined ? undefined : getRepo(app.db, owner, name); | |
| 26 | } | |
| 27 | ||
| 28 | /** Who may see the release history: the forge repository's readers and a site admin. */ | |
| 29 | export function mayRead(app: App, user: User | undefined): boolean { | |
| 30 | if (user === undefined) return false; | |
| 31 | if (user.isAdmin) return true; | |
| 32 | const repo = forgeRepoOf(app); | |
| 33 | return repo !== undefined && canRead(app.db, repo, user); | |
| 34 | } | |
| 35 | ||
| 36 | export function mayRollBack(app: App, user: User | undefined): boolean { | |
| 37 | // Undoing a deployment is a person's decision, as a revert is (#1824). | |
| 38 | if (user === undefined || user.kind === "agent") return false; | |
| 39 | const repo = forgeRepoOf(app); | |
| 40 | return repo !== undefined && canWrite(app.db, repo, user); | |
| 41 | } | |
| 42 | ||
| 43 | /** Is this kept release one the live database can be rolled back to? */ | |
| 44 | export async function canRollBackTo(app: App, r: Release): Promise<boolean> { | |
| 45 | const dataDir = app.config.dataDir; | |
| 46 | const dir = releaseDir(dataDir, r.sha); | |
| 47 | if (!r.ok || !/^[0-9a-f]{40}$/.test(r.sha) || !existsSync(dir)) return false; | |
| 48 | const known = await releaseSchema(dir); | |
| 49 | return known !== undefined && known >= liveSchema(dataDir); | |
| 50 | } | |
| 51 | ||
| 52 | export type RolledBack = { ok: true } | { ok: false; line: string }; | |
| 53 | ||
| 54 | /** Swap back to a kept release and drain this process; the caller has checked `mayRollBack`. */ | |
| 55 | export async function rollBackTo(app: App, user: User, target: string): Promise<RolledBack> { | |
| 56 | const drain = handover.drain; | |
| 57 | const port = app.config.port ?? 0; | |
| 58 | if (app.config.selfSwap !== true || drain === undefined || port <= 0) | |
| 59 | return { ok: false, line: "This forge does not swap releases itself (FORGE_SELF_SWAP is off)." }; | |
| 60 | const turn = beginSwap(); | |
| 61 | if (!turn.ok) return { ok: false, line: `Not rolled back: ${turn.line}.` }; | |
| 62 | const result = await rollback({ dataDir: app.config.dataDir, sha: target, port, env: process.env }); | |
| 63 | endSwap(result.ok); | |
| 64 | if (!result.ok) return { ok: false, line: `Not rolled back: ${result.line}.` }; | |
| 65 | app.log.info("rolled back; draining this process", { sha: target.slice(0, 12), by: user.username }); | |
| 66 | drain(); | |
| 67 | return { ok: true }; | |
| 68 | } |
src/tui/forge.ts +10
| @@ -40,6 +40,8 @@ import { dashboardScreen } from "./screens/dashboard.ts"; | ||
| 40 | 40 | import { accessScreen } from "./screens/access.ts"; |
| 41 | 41 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; |
| 42 | 42 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| 43 | import { releasesScreen } from "./screens/releases.ts"; | |
| 44 | import { mayRead } from "../release/gate.ts"; | |
| 43 | 45 | import { stalledScreen } from "./screens/stalled.ts"; |
| 44 | 46 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 45 | 47 | import { limbsScreen } from "./screens/limbs.ts"; |
| @@ -241,6 +243,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 241 | 243 | hint: "each protection green, red or untested", |
| 242 | 244 | run: () => ctx.push(resilienceScreen(ctx, resilienceLines(session.app.config.dataDir))), |
| 243 | 245 | }); |
| 246 | // The live release and the kept ones, as /releases gates them: a site admin or a reader of FORGE_REPO. | |
| 247 | if (mayRead(session.app, session.user)) | |
| 248 | commands.push({ | |
| 249 | id: "releases", | |
| 250 | title: "Releases", | |
| 251 | hint: "the live release and the kept ones", | |
| 252 | run: () => ctx.push(releasesScreen(session, ctx)), | |
| 253 | }); | |
| 244 | 254 | // Stalled flow steps the viewer can unblock, as the home's cards show them (#1807). |
| 245 | 255 | if (session.signedIn) |
| 246 | 256 | commands.push({ |
src/tui/screens/releases.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** | |
| 2 | * Releases, the terminal's /releases: the live line, then each kept release | |
| 3 | * (short sha, the pull that landed it, when, healthy since). Gated by | |
| 4 | * `release/gate.ts` as the page is: a site admin or a reader of FORGE_REPO | |
| 5 | * sees it; Enter rolls back only for a person who writes that repository, on | |
| 6 | * a kept release the live schema allows, through the page's own rollback. | |
| 7 | */ | |
| 8 | ||
| 9 | import { getRepoById } from "../../core/repos.ts"; | |
| 10 | import { running } from "../../kernel/version.ts"; | |
| 11 | import { BOOTED, canRollBackTo, mayRead, mayRollBack, rollBackTo } from "../../release/gate.ts"; | |
| 12 | import { listReleases, type Release } from "../../release/record.ts"; | |
| 13 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 14 | import { binding } from "../app/keys.ts"; | |
| 15 | import type { Session } from "../session.ts"; | |
| 16 | import { style } from "../term/style.ts"; | |
| 17 | import { truncate } from "../term/width.ts"; | |
| 18 | import { fill, len, split } from "../ui/layout.ts"; | |
| 19 | import { emptyState } from "../ui/widgets.ts"; | |
| 20 | import { ListState, confirmScreen, drawList, hitList, ago, metaOf, runAsync, type ListRow } from "./common.ts"; | |
| 21 | ||
| 22 | export interface ReleaseLine { | |
| 23 | readonly release: Release; | |
| 24 | readonly live: boolean; | |
| 25 | /** Whether Enter may roll back to it, for this person. */ | |
| 26 | readonly rollable: boolean; | |
| 27 | } | |
| 28 | ||
| 29 | /** "live: <sha> since <time>", or nothing when no release is known to be live. */ | |
| 30 | export function liveText(f: Session): string | undefined { | |
| 31 | const live = running().commit; | |
| 32 | return live === null || !mayRead(f.app, f.user) ? undefined : `live: ${live.slice(0, 7)} since ${ago(BOOTED)}`; | |
| 33 | } | |
| 34 | ||
| 35 | /** The kept releases this person may see; none at all for anyone the page would 404. */ | |
| 36 | export async function readReleases(f: Session): Promise<ReleaseLine[]> { | |
| 37 | if (!mayRead(f.app, f.user)) return []; | |
| 38 | const live = running().commit; | |
| 39 | const writer = mayRollBack(f.app, f.user); | |
| 40 | const lines: ReleaseLine[] = []; | |
| 41 | for (const release of listReleases(f.db)) { | |
| 42 | const isLive = live !== null && release.sha === live; | |
| 43 | lines.push({ | |
| 44 | release, | |
| 45 | live: isLive, | |
| 46 | rollable: writer && !isLive && (await canRollBackTo(f.app, release)), | |
| 47 | }); | |
| 48 | } | |
| 49 | return lines; | |
| 50 | } | |
| 51 | ||
| 52 | export function releaseRows(f: Session, lines: readonly ReleaseLine[]): ListRow[] { | |
| 53 | return lines.map(({ release: r, live }) => { | |
| 54 | const repo = getRepoById(f.db, r.repoId); | |
| 55 | const pull = r.pullNumber === null ? "no pull" : `${repo ? `${repo.ownerName}/${repo.name}` : "pull"} #${r.pullNumber}`; | |
| 56 | return { | |
| 57 | title: r.sha.slice(0, 7), | |
| 58 | tone: live ? "success" : r.ok ? "default" : "danger", | |
| 59 | meta: metaOf( | |
| 60 | pull, | |
| 61 | `landed ${ago(r.createdAt)}`, | |
| 62 | live ? `healthy since ${ago(BOOTED)}` : "", | |
| 63 | r.ok ? "" : "preflight failed", | |
| 64 | ), | |
| 65 | } satisfies ListRow; | |
| 66 | }); | |
| 67 | } | |
| 68 | ||
| 69 | export function releasesScreen(f: Session, ctx: Ctx): Screen { | |
| 70 | const state = new ListState(); | |
| 71 | let lines: ReleaseLine[] = []; | |
| 72 | let loaded = false; | |
| 73 | const read = async (): Promise<void> => { | |
| 74 | lines = await readReleases(f); | |
| 75 | state.clamp(lines.length); | |
| 76 | loaded = true; | |
| 77 | ctx.invalidate(); | |
| 78 | }; | |
| 79 | const self: Screen = { | |
| 80 | title: "Releases", | |
| 81 | keys: [binding(["j", "k"], "move"), binding(["enter"], "roll back"), binding(["esc", "q"], "close")], | |
| 82 | load: read, | |
| 83 | render(area, s) { | |
| 84 | const c = ctx.theme.c; | |
| 85 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 86 | s.text(head!.x, head!.y, truncate(liveText(f) ?? "", head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 87 | if (lines.length === 0) { | |
| 88 | emptyState(s, body!, c, { | |
| 89 | title: loaded ? "No releases yet" : "Reading…", | |
| 90 | body: "A pull landed on the forge's own repository is recorded here.", | |
| 91 | }); | |
| 92 | return; | |
| 93 | } | |
| 94 | drawList(s, body!, c, state, releaseRows(f, lines)); | |
| 95 | hitList(ctx, body!, state, lines.length, { open: (i) => ask(i) }); | |
| 96 | }, | |
| 97 | key(e) { | |
| 98 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 99 | else if (e.key === "enter") ask(state.index); | |
| 100 | else state.key(e, lines.length); | |
| 101 | return true; | |
| 102 | }, | |
| 103 | }; | |
| 104 | const ask = (index: number): void => { | |
| 105 | const line = lines[index]; | |
| 106 | const user = f.user; | |
| 107 | if (line === undefined || user === undefined || !line.rollable) return; | |
| 108 | // Re-checked at the act: the list is a snapshot, the gate is not. | |
| 109 | if (!mayRollBack(f.app, user)) return; | |
| 110 | const sha = line.release.sha; | |
| 111 | ctx.push( | |
| 112 | confirmScreen(ctx, self, { | |
| 113 | title: "Roll back", | |
| 114 | body: [`Swap to ${sha.slice(0, 7)} and stop this process?`], | |
| 115 | actions: [{ label: "Roll back", variant: "danger" }, { label: "Cancel" }], | |
| 116 | selected: 1, | |
| 117 | onChoose: (choice) => { | |
| 118 | if (choice !== 0) return; | |
| 119 | runAsync(ctx, async () => { | |
| 120 | const done = await rollBackTo(f.app, user, sha); | |
| 121 | ctx.toast(done.ok ? `Rolled back to ${sha.slice(0, 7)}; it reads healthy.` : done.line, done.ok ? "default" : "danger"); | |
| 122 | }); | |
| 123 | }, | |
| 124 | }), | |
| 125 | ); | |
| 126 | }; | |
| 127 | return self; | |
| 128 | } |
src/web/pages/releases.ts +9 −55
| @@ -7,64 +7,27 @@ | ||
| 7 | 7 | * `--rollback`, then this process drains. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { existsSync } from "node:fs"; | |
| 11 | import { canRead, canWrite, getRepo, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 10 | import { getRepoById } from "../../core/repos.ts"; | |
| 12 | 11 | import type { User } from "../../core/users.ts"; |
| 13 | 12 | import { running } from "../../kernel/version.ts"; |
| 14 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 15 | 14 | import { listReleases, type Release } from "../../release/record.ts"; |
| 16 | import { releaseDir } from "../../release/checkout.ts"; | |
| 17 | import { liveSchema, releaseSchema, rollback } from "../../release/rollback.ts"; | |
| 18 | import { beginSwap, endSwap, handover } from "../../release/swap.ts"; | |
| 15 | import { BOOTED, canRollBackTo, mayRead, mayRollBack, rollBackTo } from "../../release/gate.ts"; | |
| 19 | 16 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 20 | 17 | import { readForm } from "../form.ts"; |
| 21 | 18 | import { html, when, type Html } from "../html.ts"; |
| 22 | 19 | import { flashTo, page } from "../layout.ts"; |
| 23 | 20 | import { badge, btn, card, empty, sha, time } from "../ui.ts"; |
| 24 | 21 | |
| 25 | /** When this process began serving: "healthy since" for the release it runs. */ | |
| 26 | const BOOTED = new Date().toISOString(); | |
| 27 | ||
| 28 | /** The forge's own code repository, when this forge names one. */ | |
| 29 | function forgeRepoOf(ctx: Ctx): Repo | undefined { | |
| 30 | const named = ctx.app.config.forgeRepo; | |
| 31 | if (named === undefined) return undefined; | |
| 32 | const [owner, name] = named.split("/"); | |
| 33 | return owner === undefined || name === undefined ? undefined : getRepo(ctx.app.db, owner, name); | |
| 34 | } | |
| 35 | ||
| 36 | /** Who may see the release history: the forge repository's readers and a site admin. */ | |
| 37 | function mayRead(ctx: Ctx, user: User | undefined): boolean { | |
| 38 | if (user === undefined) return false; | |
| 39 | if (user.isAdmin) return true; | |
| 40 | const repo = forgeRepoOf(ctx); | |
| 41 | return repo !== undefined && canRead(ctx.app.db, repo, user); | |
| 42 | } | |
| 43 | ||
| 44 | function mayRollBack(ctx: Ctx, user: User | undefined): boolean { | |
| 45 | // Undoing a deployment is a person's decision, as a revert is (#1824). | |
| 46 | if (user === undefined || user.kind === "agent") return false; | |
| 47 | const repo = forgeRepoOf(ctx); | |
| 48 | return repo !== undefined && canWrite(ctx.app.db, repo, user); | |
| 49 | } | |
| 50 | ||
| 51 | 22 | /** "live: <sha> since <time>" for the home, or nothing when no release is known to be live. */ |
| 52 | 23 | export function liveLine(ctx: Ctx): Html | undefined { |
| 53 | 24 | const live = running().commit; |
| 54 | if (live === null || !mayRead(ctx, ctx.auth.user)) return undefined; | |
| 25 | if (live === null || !mayRead(ctx.app, ctx.auth.user)) return undefined; | |
| 55 | 26 | return html`<p class="small muted" id="live-release"> |
| 56 | 27 | <a href="/releases">live: <span class="sha">${live.slice(0, 7)}</span> since ${time(BOOTED)}</a> |
| 57 | 28 | </p>`; |
| 58 | 29 | } |
| 59 | 30 | |
| 60 | async function canRollBackTo(ctx: Ctx, r: Release): Promise<boolean> { | |
| 61 | const dataDir = ctx.app.config.dataDir; | |
| 62 | const dir = releaseDir(dataDir, r.sha); | |
| 63 | if (!r.ok || !/^[0-9a-f]{40}$/.test(r.sha) || !existsSync(dir)) return false; | |
| 64 | const known = await releaseSchema(dir); | |
| 65 | return known !== undefined && known >= liveSchema(dataDir); | |
| 66 | } | |
| 67 | ||
| 68 | 31 | function pullCell(ctx: Ctx, r: Release): Html { |
| 69 | 32 | if (r.pullNumber === null) return html`—`; |
| 70 | 33 | const repo = getRepoById(ctx.app.db, r.repoId); |
| @@ -76,14 +39,14 @@ function pullCell(ctx: Ctx, r: Release): Html { | ||
| 76 | 39 | export async function releasesPage(ctx: Ctx): Promise<Response> { |
| 77 | 40 | const user = ctx.auth.user; |
| 78 | 41 | if (user === undefined) return signInFirst(ctx); |
| 79 | if (!mayRead(ctx, user)) return notFoundPage(ctx); | |
| 42 | if (!mayRead(ctx.app, user)) return notFoundPage(ctx); | |
| 80 | 43 | const releases = listReleases(ctx.app.db); |
| 81 | 44 | const live = running().commit; |
| 82 | const writer = mayRollBack(ctx, user); | |
| 45 | const writer = mayRollBack(ctx.app, user); | |
| 83 | 46 | const rows: Html[] = []; |
| 84 | 47 | for (const r of releases) { |
| 85 | 48 | const isLive = live !== null && r.sha === live; |
| 86 | const button = writer && !isLive && (await canRollBackTo(ctx, r)); | |
| 49 | const button = writer && !isLive && (await canRollBackTo(ctx.app, r)); | |
| 87 | 50 | rows.push(html`<tr> |
| 88 | 51 | <td>${sha(r.sha)} ${when(isLive, () => badge("live", { tone: "success" }))} |
| 89 | 52 | ${when(!r.ok, () => badge("preflight failed", { tone: "danger" }))}</td> |
| @@ -114,21 +77,12 @@ export async function rollbackSubmit(ctx: Ctx): Promise<Response> { | ||
| 114 | 77 | if (user === undefined) return new Response("forbidden", { status: 403 }); |
| 115 | 78 | const form = await readForm(ctx, "rw"); |
| 116 | 79 | if (form instanceof Response) return form; |
| 117 | if (!mayRollBack(ctx, user)) return new Response("forbidden", { status: 403 }); | |
| 80 | if (!mayRollBack(ctx.app, user)) return new Response("forbidden", { status: 403 }); | |
| 118 | 81 | const target = ctx.params["sha"] ?? ""; |
| 119 | 82 | const refuse = (message: string): Response => |
| 120 | 83 | flashTo(ctx, "/releases", { tone: "danger", message }); |
| 121 | 84 | if (!/^[0-9a-f]{40}$/.test(target)) return notFoundPage(ctx); |
| 122 | const drain = handover.drain; | |
| 123 | const port = ctx.app.config.port ?? 0; | |
| 124 | if (ctx.app.config.selfSwap !== true || drain === undefined || port <= 0) | |
| 125 | return refuse("This forge does not swap releases itself (FORGE_SELF_SWAP is off)."); | |
| 126 | const turn = beginSwap(); | |
| 127 | if (!turn.ok) return refuse(`Not rolled back: ${turn.line}.`); | |
| 128 | const result = await rollback({ dataDir: ctx.app.config.dataDir, sha: target, port, env: process.env }); | |
| 129 | endSwap(result.ok); | |
| 130 | if (!result.ok) return refuse(`Not rolled back: ${result.line}.`); | |
| 131 | ctx.app.log.info("rolled back; draining this process", { sha: target.slice(0, 12), by: user.username }); | |
| 132 | drain(); | |
| 85 | const result = await rollBackTo(ctx.app, user, target); | |
| 86 | if (!result.ok) return refuse(result.line); | |
| 133 | 87 | return flashTo(ctx, "/releases", { message: `Rolled back to ${target.slice(0, 7)}; it reads healthy.` }); |
| 134 | 88 | } |
test/tui-releases.test.ts added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | /** | |
| 2 | * Releases in the terminal (#2079): the palette offers it, and the screen | |
| 3 | * shows the live line and the kept releases, only to a site admin or a reader | |
| 4 | * of FORGE_REPO; Enter rolls back only for a person who writes that | |
| 5 | * repository, and says so when the forge does not swap itself. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { releaseDir } from "../src/release/checkout.ts"; | |
| 15 | import { mayRollBack } from "../src/release/gate.ts"; | |
| 16 | import { recordRelease } from "../src/release/record.ts"; | |
| 17 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 18 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 19 | import { readReleases, releasesScreen } from "../src/tui/screens/releases.ts"; | |
| 20 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const LIVE = "a".repeat(40); | |
| 27 | const KEPT = "b".repeat(40); | |
| 28 | ||
| 29 | describe("the releases screen", () => { | |
| 30 | let root = ""; | |
| 31 | let app: App; | |
| 32 | let alice: User; | |
| 33 | let bob: User; | |
| 34 | let carol: User; | |
| 35 | const was = process.env["FORGE_COMMIT"]; | |
| 36 | ||
| 37 | const identity = (user?: User) => ({ | |
| 38 | sshUsername: user?.username ?? "git", | |
| 39 | remoteAddress: "127.0.0.1", | |
| 40 | fingerprint: "SHA256:test", | |
| 41 | user, | |
| 42 | }); | |
| 43 | const offered = (user?: User) => | |
| 44 | forgeCommands(new Session(app, identity(user)))({} as Ctx).some((c) => c.id === "releases"); | |
| 45 | ||
| 46 | async function open(user: User, keys = ""): Promise<string> { | |
| 47 | const terminal = headlessTerminal(120, 30); | |
| 48 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false }); | |
| 49 | tui.start(); | |
| 50 | tui.ctx.push(releasesScreen(session, tui.ctx)); | |
| 51 | await settleApp(tui); | |
| 52 | if (keys !== "") { | |
| 53 | terminal.feed(keys); | |
| 54 | await settleApp(tui); | |
| 55 | } | |
| 56 | const text = tui.screen.toText(); | |
| 57 | tui.stop?.(); | |
| 58 | return text; | |
| 59 | } | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | process.env["FORGE_COMMIT"] = LIVE; | |
| 63 | root = mkdtempSync(join(tmpdir(), "forge-tui-releases-")); | |
| 64 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 65 | const mk = async (username: string, adminIfFirst: boolean) => { | |
| 66 | const r = await createUser(app.db, { | |
| 67 | trusted: true, | |
| 68 | username, | |
| 69 | email: `${username}@forge.local`, | |
| 70 | password: "a-good-passphrase", | |
| 71 | adminIfFirst, | |
| 72 | }); | |
| 73 | if (!r.ok) throw new Error(r.error); | |
| 74 | return r.value; | |
| 75 | }; | |
| 76 | alice = await mk("alice", true); | |
| 77 | bob = await mk("bob", false); | |
| 78 | carol = await mk("carol", false); | |
| 79 | const made = await createRepo(app, alice, { name: "forge", visibility: "private" }); | |
| 80 | if (!made.ok) throw new Error(String(made.error)); | |
| 81 | const repoId = made.value.id; | |
| 82 | recordRelease(app.db, { sha: LIVE, repoId, pullNumber: 7, ok: true, line: "ok" }); | |
| 83 | recordRelease(app.db, { sha: KEPT, repoId, pullNumber: 6, ok: true, line: "ok" }); | |
| 84 | const dir = releaseDir(root, KEPT); | |
| 85 | mkdirSync(join(dir, "src", "core"), { recursive: true }); | |
| 86 | writeFileSync( | |
| 87 | join(dir, "src", "core", "schema.ts"), | |
| 88 | "export const migrations = " + JSON.stringify(Array(10_000).fill("SELECT 1")) + ";\n", | |
| 89 | ); | |
| 90 | }); | |
| 91 | afterAll(() => { | |
| 92 | if (was === undefined) delete process.env["FORGE_COMMIT"]; | |
| 93 | else process.env["FORGE_COMMIT"] = was; | |
| 94 | app.db.close(); | |
| 95 | rmSync(root, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a non-reader is offered nothing and sees nothing", async () => { | |
| 99 | expect(offered(bob)).toBe(false); | |
| 100 | expect(offered(undefined)).toBe(false); | |
| 101 | expect(await readReleases(new Session(app, identity(bob)))).toEqual([]); | |
| 102 | const text = await open(bob); | |
| 103 | expect(text).not.toContain("bbbbbbb"); | |
| 104 | expect(text).not.toContain("aaaaaaa"); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a site admin sees the live line and the kept releases", async () => { | |
| 108 | expect(offered(alice)).toBe(true); | |
| 109 | const text = await open(alice); | |
| 110 | expect(text).toContain("live: aaaaaaa since"); | |
| 111 | expect(text).toContain("bbbbbbb"); | |
| 112 | expect(text).toContain("alice/forge #6"); | |
| 113 | expect(text).toContain("healthy since"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a reader sees the releases but gets no rollback", async () => { | |
| 117 | app.db.run("UPDATE repo SET visibility='public' WHERE name='forge'"); | |
| 118 | expect(offered(carol)).toBe(true); | |
| 119 | const lines = await readReleases(new Session(app, identity(carol))); | |
| 120 | expect(lines.map((l) => l.release.sha.slice(0, 1))).toEqual(["b", "a"]); | |
| 121 | expect(lines.every((l) => !l.rollable)).toBe(true); | |
| 122 | // Enter on the kept release asks nothing. | |
| 123 | const text = await open(carol, "j\r"); | |
| 124 | expect(text).not.toContain("Swap to"); | |
| 125 | expect(text).toContain("bbbbbbb"); | |
| 126 | }); | |
| 127 | ||
| 128 | test("an agent is refused a rollback, whatever it can read", async () => { | |
| 129 | const agent = { ...alice, kind: "agent" } as User; | |
| 130 | expect(mayRollBack(app, agent)).toBe(false); | |
| 131 | const lines = await readReleases(new Session(app, identity(agent))); | |
| 132 | expect(lines.length).toBe(2); | |
| 133 | expect(lines.every((l) => !l.rollable)).toBe(true); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a writer is asked to confirm, and is told when the forge does not swap itself", async () => { | |
| 137 | const lines = await readReleases(new Session(app, identity(alice))); | |
| 138 | expect(lines.find((l) => l.release.sha === KEPT)?.rollable).toBe(true); | |
| 139 | expect(lines.find((l) => l.release.sha === LIVE)?.rollable).toBe(false); | |
| 140 | const asking = await open(alice, "\r"); | |
| 141 | expect(asking).toContain("Swap to bbbbbbb"); | |
| 142 | const told = await open(alice, "\r\x1b[D\r"); | |
| 143 | expect(told).toContain("FORGE_SELF_SWAP is off"); | |
| 144 | }); | |
| 145 | }); |
new above
offline