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

Merged opened this

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

Merged

scout/forge:scout/2079-terminal-live-releases-and-a-person-s-ro → main 1 commit fork

  • Merged. scout/2079-terminal-live-releases-and-a-person-s-ro was merged into main as 820d31c.

Review

Sign in to review this change.
6 files changed +361 −56
README.md +1 −1

Diff not shown — a line 4,279 characters wide (limit 2,000). +1 −1

View file
src/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";
4040 import { accessScreen } from "./screens/access.ts";
4141 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
4242 import { flowLines, flowScreen } from "./screens/flow.ts";
43 import { releasesScreen } from "./screens/releases.ts";
44 import { mayRead } from "../release/gate.ts";
4345 import { stalledScreen } from "./screens/stalled.ts";
4446 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
4547 import { limbsScreen } from "./screens/limbs.ts";
@@ -241,6 +243,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
241243 hint: "each protection green, red or untested",
242244 run: () => ctx.push(resilienceScreen(ctx, resilienceLines(session.app.config.dataDir))),
243245 });
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 });
244254 // Stalled flow steps the viewer can unblock, as the home's cards show them (#1807).
245255 if (session.signedIn)
246256 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 @@
77 * `--rollback`, then this process drains.
88 */
99
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";
1211 import type { User } from "../../core/users.ts";
1312 import { running } from "../../kernel/version.ts";
1413 import type { Ctx } from "../../http/context.ts";
1514 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";
1916 import { notFoundPage, signInFirst } from "../chrome.ts";
2017 import { readForm } from "../form.ts";
2118 import { html, when, type Html } from "../html.ts";
2219 import { flashTo, page } from "../layout.ts";
2320 import { badge, btn, card, empty, sha, time } from "../ui.ts";
2421
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
5122 /** "live: <sha> since <time>" for the home, or nothing when no release is known to be live. */
5223 export function liveLine(ctx: Ctx): Html | undefined {
5324 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;
5526 return html`<p class="small muted" id="live-release">
5627 <a href="/releases">live: <span class="sha">${live.slice(0, 7)}</span> since ${time(BOOTED)}</a>
5728 </p>`;
5829 }
5930
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
6831 function pullCell(ctx: Ctx, r: Release): Html {
6932 if (r.pullNumber === null) return html`—`;
7033 const repo = getRepoById(ctx.app.db, r.repoId);
@@ -76,14 +39,14 @@ function pullCell(ctx: Ctx, r: Release): Html {
7639 export async function releasesPage(ctx: Ctx): Promise<Response> {
7740 const user = ctx.auth.user;
7841 if (user === undefined) return signInFirst(ctx);
79 if (!mayRead(ctx, user)) return notFoundPage(ctx);
42 if (!mayRead(ctx.app, user)) return notFoundPage(ctx);
8043 const releases = listReleases(ctx.app.db);
8144 const live = running().commit;
82 const writer = mayRollBack(ctx, user);
45 const writer = mayRollBack(ctx.app, user);
8346 const rows: Html[] = [];
8447 for (const r of releases) {
8548 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));
8750 rows.push(html`<tr>
8851 <td>${sha(r.sha)} ${when(isLive, () => badge("live", { tone: "success" }))}
8952 ${when(!r.ok, () => badge("preflight failed", { tone: "danger" }))}</td>
@@ -114,21 +77,12 @@ export async function rollbackSubmit(ctx: Ctx): Promise<Response> {
11477 if (user === undefined) return new Response("forbidden", { status: 403 });
11578 const form = await readForm(ctx, "rw");
11679 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 });
11881 const target = ctx.params["sha"] ?? "";
11982 const refuse = (message: string): Response =>
12083 flashTo(ctx, "/releases", { tone: "danger", message });
12184 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);
13387 return flashTo(ctx, "/releases", { message: `Rolled back to ${target.slice(0, 7)}; it reads healthy.` });
13488 }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs