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

Self-deploy rollback, part 2: a releases page lists each kept release with sha, landed pull, when and healthy-since, and a writer can roll back from it #1867

Merged opened this

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

Merged

scout/forge:scout/1824-self-deploy-rollback-part-2-a-releases-p → main 4 commits fork 6 threads · 6 unresolved

  • Merged. scout/1824-self-deploy-rollback-part-2-a-releases-p was merged into main as 046e6cd.

Review

Sign in to review this change.
7 files changed +314 −2
README.md +1 −1

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

View file
scripts/gate.ts +1
@@ -32,6 +32,7 @@ export const GATE: readonly string[] = [
3232 "test/lands-once.test.ts",
3333 "test/conflicts-tui.test.ts",
3434 "test/accounts-retire.test.ts",
35 "test/releases-page.test.ts",
3536 "test/client-home.test.ts",
3637 "test/cloud-use.test.ts",
3738 "test/accounts.test.ts",
src/core/reserved.ts +1
@@ -95,6 +95,7 @@ export const SYSTEM_NAMES: readonly string[] = [
9595 "signin",
9696 "signup",
9797 "sitemap.xml",
98 "releases",
9899 "releases.json",
99100 "sw.js",
100101 "tell",
src/http/server.ts +3
@@ -125,6 +125,7 @@ import {
125125 backupsPage,
126126 backupsTest,
127127 } from "../web/pages/backups.ts";
128 import { releasesPage, rollbackSubmit } from "../web/pages/releases.ts";
128129 import {
129130 cloudAdd,
130131 cloudAudit,
@@ -633,6 +634,8 @@ export function buildRouter(): Router<Handler> {
633634 router.add("GET", "/-/confirm", confirmPage);
634635 router.add("GET", "/new", newRepoPage);
635636 router.add("GET", "/audit", auditPage);
637 router.add("GET", "/releases", releasesPage);
638 router.add("POST", "/releases/:sha/rollback", rollbackSubmit);
636639 router.add("GET", "/audit.csv", auditCsv);
637640 router.add("POST", "/audit/:owner/:name/probe", auditProbeSubmit);
638641 router.add("POST", "/new", newRepoSubmit);
src/web/pages/releases.ts added +134
@@ -0,0 +1,134 @@
1 /**
2 * Releases: each kept release of the forge's own code with its short sha, the
3 * pull that landed it, when, and — for the one serving — since when it has
4 * been healthy. A writer of the forge's repository gets a Roll back button on
5 * a kept release whose schema is not behind the live database's; the POST
6 * goes through the same refusals and the same health-read swap as
7 * `--rollback`, then this process drains.
8 */
9
10 import { existsSync } from "node:fs";
11 import { canRead, canWrite, getRepo, getRepoById, type Repo } from "../../core/repos.ts";
12 import type { User } from "../../core/users.ts";
13 import { running } from "../../kernel/version.ts";
14 import type { Ctx } from "../../http/context.ts";
15 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";
19 import { notFoundPage, signInFirst } from "../chrome.ts";
20 import { readForm } from "../form.ts";
21 import { html, when, type Html } from "../html.ts";
22 import { flashTo, page } from "../layout.ts";
23 import { badge, btn, card, empty, sha, time } from "../ui.ts";
24
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 {
outdated

The live commit goes on every signed-in home. Apply the same reader check before rendering it.

High: an agent that writes FORGE_REPO passes this check, so a crew token can roll back production and drain the forge. Refuse user.kind === "agent" here, the way actions/revert.ts:43 does, and add a test for it.

45 // Undoing a deployment is a person's decision, as a revert is (#1824).
46 if (user === undefined || user.kind === "agent") return false;

Good. This matches the person-only rule in actions/revert.ts, and the agent test covers it.

47 const repo = forgeRepoOf(ctx);
48 return repo !== undefined && canWrite(ctx.app.db, repo, user);
49 }
50
51 /** "live: <sha> since <time>" for the home, or nothing when no release is known to be live. */
52 export function liveLine(ctx: Ctx): Html | undefined {
53 const live = running().commit;
54 if (live === null || !mayRead(ctx, ctx.auth.user)) return undefined;
55 return html`<p class="small muted" id="live-release">
56 <a href="/releases">live: <span class="sha">${live.slice(0, 7)}</span> since ${time(BOOTED)}</a>
57 </p>`;
58 }
59
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 function pullCell(ctx: Ctx, r: Release): Html {
69 if (r.pullNumber === null) return html`—`;
outdated

High: this only checks sign-in. Gate on canRead of the FORGE_REPO repository (or isAdmin) and return notFoundPage otherwise. Right now any account sees the private forge repo's landed shas and its owner/name and pull links.

70 const repo = getRepoById(ctx.app.db, r.repoId);
71 return repo === undefined
72 ? html`#${r.pullNumber}`
73 : html`<a href="/${repo.ownerName}/${repo.name}/pulls/${r.pullNumber}">#${r.pullNumber}</a>`;
74 }
75
76 export async function releasesPage(ctx: Ctx): Promise<Response> {
77 const user = ctx.auth.user;
78 if (user === undefined) return signInFirst(ctx);
79 if (!mayRead(ctx, user)) return notFoundPage(ctx);
80 const releases = listReleases(ctx.app.db);
81 const live = running().commit;
82 const writer = mayRollBack(ctx, user);
83 const rows: Html[] = [];
84 for (const r of releases) {
85 const isLive = live !== null && r.sha === live;
86 const button = writer && !isLive && (await canRollBackTo(ctx, r));
87 rows.push(html`<tr>
88 <td>${sha(r.sha)} ${when(isLive, () => badge("live", { tone: "success" }))}
89 ${when(!r.ok, () => badge("preflight failed", { tone: "danger" }))}</td>
90 <td>${pullCell(ctx, r)}</td>
91 <td>${time(r.createdAt)}</td>
92 <td>${isLive ? html`since ${time(BOOTED)}` : html`—`}</td>
93 <td>${when(button, () =>
94 html`<form method="post" action="/releases/${r.sha}/rollback">${btn("Roll back", { type: "submit" })}</form>`,
95 )}</td>
96 </tr>`);
97 }
98 const body =
99 rows.length === 0
100 ? empty({ icon: "clock", title: "No releases yet", note: "A pull landed on the forge's own repository is recorded here." })
101 : card(html`<table class="table">
102 <thead><tr><th>Release</th><th>Pull</th><th>Landed</th><th>Healthy</th><th></th></tr></thead>
103 <tbody>${rows}</tbody>
104 </table>`);
105 return page({
106 ctx,
107 title: "Releases",
108 body: html`<div class="page-head"><h1>Releases</h1></div>${body}`,
109 });
110 }
111
112 export async function rollbackSubmit(ctx: Ctx): Promise<Response> {
113 const user = ctx.auth.user;
114 if (user === undefined) return new Response("forbidden", { status: 403 });
115 const form = await readForm(ctx, "rw");
116 if (form instanceof Response) return form;
outdated

This is the only authorisation check before rollback() and drain(). Once mayRollBack refuses agents, an agent's token gets a 403 here.

117 if (!mayRollBack(ctx, user)) return new Response("forbidden", { status: 403 });
118 const target = ctx.params["sha"] ?? "";
119 const refuse = (message: string): Response =>
120 flashTo(ctx, "/releases", { tone: "danger", message });
121 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();
133 return flashTo(ctx, "/releases", { message: `Rolled back to ${target.slice(0, 7)}; it reads healthy.` });
134 }
src/web/studio/home.ts +2 −1
@@ -6,6 +6,7 @@
66 * best of what is public (`actions/front.ts`).
77 */
88
9 import { liveLine } from "../pages/releases.ts";
910 import {
1011 frontPage,
1112 frontPicksJson,
@@ -214,7 +215,7 @@ export async function studioHome(
214215 });
215216 }
216217 const body = html`<h1 class="sr-only">Home</h1>
217 ${firstSteps ?? everydayCard(ctx, user)} ${address}
218 ${firstSteps ?? everydayCard(ctx, user)} ${address} ${liveLine(ctx)}
218219 ${when(
219220 steps,
220221 () => html`<p class="small muted" id="lean-home">
test/releases-page.test.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * The Releases page (#1824): it lists each kept release, escaped, with the
3 * live one marked and the pull linked; a writer of the forge's repository
4 * gets a Roll back button on a kept, schema-compatible release and anyone
5 * else gets none and a 403; a cross-origin POST is refused; the home links
6 * the live release.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { releaseDir } from "../src/release/checkout.ts";
18 import { recordRelease } from "../src/release/record.ts";
19 import { proveAll } from "./helpers/verified.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 const LIVE = "a".repeat(40);
25 const KEPT = "b".repeat(40);
26 const PRUNED = "c".repeat(40);
27
28 describe("the releases page", () => {
29 let root = "";
30 let app: App;
31 let servers: Servers;
32 const was = process.env["FORGE_COMMIT"];
33
34 const login = async (username: string, password: string) => {
35 const r = await fetch(`${servers.url}/signin`, {
36 method: "POST",
37 headers: { origin: servers.url },
38 body: new URLSearchParams({ username, password }),
39 redirect: "manual",
40 });
41 return (r.headers.get("set-cookie") ?? "").split(";")[0]!;
42 };
43 const post = (path: string, cookie: string, origin: string) =>
44 fetch(`${servers.url}${path}`, {
45 method: "POST",
46 headers: { cookie, origin },
47 redirect: "manual",
48 });
49
50 beforeAll(async () => {
51 process.env["FORGE_COMMIT"] = LIVE;
52 root = mkdtempSync(join(tmpdir(), "forge-releases-"));
53 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
54 for (const [username, password, adminIfFirst] of [
55 ["alice", "a-good-passphrase", true],
56 ["bob", "another-good-passphrase", false],
57 ] as const) {
58 const u = await createUser(app.db, {
59 trusted: true,
60 username,
61 email: `${username}@forge.local`,
62 password,
63 adminIfFirst,
64 });
65 if (!u.ok) throw new Error(u.error);
66 }
67 proveAll(app);
68 const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username='alice'").get()!;
69 const made = await createRepo(app, { id: alice.id, username: "alice" } as never, { name: "forge" });
70 if (!made.ok) throw new Error(made.error);
71 const repoId = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name='forge'").get()!.id;
72 for (const [s, pull, line] of [
73 [LIVE, 7, "ok"],
74 [KEPT, 6, "<script>alert(1)</script>"],
75 [PRUNED, null, "ok"],
76 ] as const)
77 recordRelease(app.db, { sha: s, repoId, pullNumber: pull, ok: true, line });
78 const dir = releaseDir(root, KEPT);
79 mkdirSync(join(dir, "src", "core"), { recursive: true });
80 writeFileSync(join(dir, "src", "core", "schema.ts"), "export const migrations = " + JSON.stringify(Array(10_000).fill("SELECT 1")) + ";\n");
81 servers = await serve(app);
82 });
83 afterAll(() => {
84 if (was === undefined) delete process.env["FORGE_COMMIT"];
85 else process.env["FORGE_COMMIT"] = was;
86 servers.stop();
87 app.close();
88 rmSync(root, { recursive: true, force: true });
89 });
90
91 test("it lists the releases, marks the live one and links the pull", async () => {
92 const cookie = await login("alice", "a-good-passphrase");
93 const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie } })).text();
94 expect(body).toContain("aaaaaaa");
95 expect(body).toContain("bbbbbbb");
96 expect(body).toContain("live");
97 expect(body).toContain('href="/alice/forge/pulls/7"');
98 expect(body).toContain('href="/alice/forge/pulls/6"');
99 });
100
101 test("a writer gets a button on a kept release, not on the live or a pruned one", async () => {
102 const cookie = await login("alice", "a-good-passphrase");
103 const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie } })).text();
104 expect(body).toContain(`/releases/${KEPT}/rollback`);
105 expect(body).not.toContain(`/releases/${LIVE}/rollback`);
106 expect(body).not.toContain(`/releases/${PRUNED}/rollback`);
107 });
108
109 test("a non-reader gets a 404 and no live line; a reader who cannot write sees the list with no button", async () => {
110 const cookie = await login("bob", "another-good-passphrase");
outdated

This asserts the leak: a non-writer (and non-reader) is shown the release list. Expect a 404 for a non-reader, and add a reader-but-not-writer case.

111 const get = (path: string) => fetch(`${servers.url}${path}`, { headers: { cookie } });
112 expect((await get("/releases")).status).toBe(404);
113 const home = await (await get("/")).text();
114 expect(home).not.toContain("live-release");
115 expect(home).not.toContain("aaaaaaa");
116 expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403);
117
118 app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run();
119 const body = await (await get("/releases")).text();
120 expect(body).toContain("bbbbbbb");
121 expect(body).not.toContain("/rollback");
122 expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403);
123 expect((await post(`/releases/${KEPT}/rollback`, "", servers.url)).status).toBe(403);
124 expect(await (await get("/")).text()).toContain("live-release");
125 app.db.query("UPDATE repo SET visibility = 'private' WHERE name = 'forge'").run();
outdated

A successful rollback also redirects to /releases, so this would pass either way. Assert the refusal flash or that no swap started.

126 });
127
128 test("an agent that can write the repository gets no button and a 403", async () => {
129 const u = await createUser(app.db, {
130 trusted: true,
131 username: "crew1",
132 email: "[email protected]",
133 password: "crew-good-passphrase",
134 adminIfFirst: false,
135 });
136 if (!u.ok) throw new Error(u.error);
137 app.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE username = 'crew1'").run();
138 const cookie = `forge_session=${createSession(app.db, u.value.id).token}`;
139 const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie } })).text();
140 expect(body).toContain("bbbbbbb");
141 expect(body).not.toContain("/rollback");
142 expect((await post(`/releases/${KEPT}/rollback`, cookie, servers.url)).status).toBe(403);
143 });
144
145 test("a cross-origin POST is refused", async () => {
146 const cookie = await login("alice", "a-good-passphrase");
147 expect((await post(`/releases/${KEPT}/rollback`, cookie, "https://evil.example")).status).toBe(403);
148 });
149
150 test("a writer's POST with swapping off is told so and nothing swaps", async () => {
151 const cookie = await login("alice", "a-good-passphrase");
152 const res = await post(`/releases/${KEPT}/rollback`, cookie, servers.url);
153 expect(res.status).toBe(303);
154 expect(res.headers.get("location")).toBe("/releases");
155 const flash = (res.headers.getSetCookie() ?? []).map((c) => c.split(";")[0]).join("; ");
156 const shown = await (await fetch(`${servers.url}/releases`, { headers: { cookie: `${cookie}; ${flash}` } })).text();
157 expect(shown).toContain("FORGE_SELF_SWAP is off");
158 });
159
160 test("the home links the live release", async () => {
161 const cookie = await login("alice", "a-good-passphrase");
162 const body = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
163 expect(body).toContain('href="/releases"');
164 expect(body).toContain("live:");
165 expect(body).toContain("aaaaaaa");
166 });
167
168 test("a signed-out visitor is sent to sign in", async () => {
169 const res = await fetch(`${servers.url}/releases`, { redirect: "manual" });
170 expect(res.status).toBe(303);
171 });
172 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs