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

POST /api/v1/admin/releases/:sha/rollback plans, then rolls the forge back to a kept release, with the releases page's rules #7227

Merged opened this

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

Merged

mason/forge:mason/7217-post-api-v1-admin-releases-sha-rollback → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/7217-post-api-v1-admin-releases-sha-rollback was merged into main as ddfb315.

Review

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

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

View file
src/contract/openapi/routes/index.ts +1
@@ -171,6 +171,7 @@ export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1Admin
171171 export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1AdminAccountsUsernameUnsuspend.ts";
172172 export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts";
173173 export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts";
174 export { route as postApiV1AdminReleasesShaRollback } from "./postApiV1AdminReleasesShaRollback.ts";
174175 export { route as postApiV1Agents } from "./postApiV1Agents.ts";
175176 export { route as postApiV1Apps } from "./postApiV1Apps.ts";
176177 export { route as postApiV1AsksBulk } from "./postApiV1AsksBulk.ts";
src/contract/openapi/routes/postApiV1AdminReleasesShaRollback.ts added +19
@@ -0,0 +1,19 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/releases/{sha}/rollback",
6 summary:
7 "Plan, then roll the forge back to a kept release (admin scope, a person who may write the forge's repository); `dryRun` true (the default) answers { from?, to, allowed, message? } and changes nothing, false swaps and answers { from?, to, rolledBack } before this process drains; `from` is the live commit, left out when it is not known",
8 auth: true,
9 body: { dryRun: "boolean" },
10 reply: { dryRun: "boolean", from: "string", to: "string", allowed: "boolean", message: "string", rolledBack: "boolean" },

from is not always a string: see rollback.ts:37. Declare it as nullable, or leave it out when the live commit is unknown.

11 refusals: {
12 not_admin: "the caller's role may not administer the forge (403)",
13 forbidden: "the token lacks admin scope, or the caller may not roll back: an agent, or no writer of the forge's repository (403)",
14 not_found: "no kept release has this sha (404)",
15 invalid: "`dryRun` is not a boolean (422)",
16 not_rollable: "the release is the live one, its preflight failed, or its schema is behind the live database's (409)",
17 not_rolled_back: "the swap was refused or did not read healthy; the line says why (409)",
18 },
19 };
src/http/api.ts +2
@@ -414,6 +414,7 @@ import { boardRoutes } from "./api/boards.ts";
414414 import { followingRoutes } from "./api/following.ts";
415415 import { labelsRoutes } from "./api/labels.ts";
416416 import { reportRoutes } from "./api/reports.ts";
417 import { rollbackRoutes } from "./api/rollback.ts";
417418 import { takeBackRoutes } from "./api/take_back.ts";
418419 import { orgRoutes } from "./api/orgs.ts";
419420 import { promptRoutes } from "./api/prompts.ts";
@@ -2683,6 +2684,7 @@ export function apiRoutes(router: Router<Handler>): void {
26832684 });
26842685
26852686 reportRoutes(router);
2687 rollbackRoutes(router);
26862688 takeBackRoutes(router);
26872689
26882690 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number", (ctx) => {
src/http/api/rollback.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * Rolling the forge back over the API (#7217):
3 * `POST /api/v1/admin/releases/:sha/rollback`. A plan by default — from
4 * which release to which, and whether the releases page would offer it —
5 * and only `{ "dryRun": false }` swaps. The rules are `release/gate.ts`'s,
6 * the same the page and the terminal run: `mayRollBack`, `canRollBackTo`,
7 * then `rollBackTo` unchanged. `admin_gate.ts` has already refused a person
8 * who may not administer; this adds the admin token scope.
9 */
10
11 import { existsSync } from "node:fs";
12 import { running } from "../../kernel/version.ts";
13 import { releaseDir } from "../../release/checkout.ts";
14 import { canRollBackTo, mayRollBack, rollBackTo } from "../../release/gate.ts";
15 import { listReleases } from "../../release/record.ts";
16 import { ALREADY_LIVE, NOT_ROLLABLE } from "../../release/words.ts";
17 import type { Handler } from "../context.ts";
18 import { apiError, body, forbidden, json, notFound, requireScope } from "../respond.ts";
19 import type { Router } from "../router.ts";
20
21 export function rollbackRoutes(router: Router<Handler>): void {
22 router.add("POST", "/api/v1/admin/releases/:sha/rollback", async (ctx) => {
23 const denied = requireScope(ctx, "admin");
24 if (denied) return denied;
25 const user = ctx.auth.user!;
26 if (!mayRollBack(ctx.app, user)) return forbidden();

No test fails if this line is removed: alice writes the forge repository, and bob is stopped earlier by admin_gate. This line is the only thing between an admin-scope agent token (or a site admin who does not write FORGE_REPO) and a production swap. Pin it: both callers get 403 forbidden on the plan and on the apply, and drained stays 0.

27 const input = await body(ctx.request);
28 if (input.dryRun !== undefined && typeof input.dryRun !== "boolean")
29 return apiError("invalid", 422, "dryRun is true (the default: plan only) or false (roll back)");
30 const dryRun = input.dryRun !== false;
31 const target = ctx.params.sha ?? "";
32 // Kept means the page lists it and its checkout is still on disk.
33 const release = /^[0-9a-f]{40}$/.test(target)
34 ? listReleases(ctx.app.db).find((r) => r.sha === target)
35 : undefined;
36 if (release === undefined || !existsSync(releaseDir(ctx.app.config.dataDir, target))) return notFound();
37 const from = running().commit;

running().commit is null when FORGE_COMMIT is unset or not 40-hex, so the plan answers from: null while the contract says string. Declare it nullable (and test it), or leave it out.

38 const refusal = from === target ? ALREADY_LIVE : (await canRollBackTo(ctx.app, release)) ? undefined : NOT_ROLLABLE;
39 // `from` is left out when nothing says which commit is live.
40 const route = { dryRun, ...(from === null ? {} : { from }), to: target };
41 if (dryRun) return json({ ...route, allowed: refusal === undefined, ...(refusal ? { message: refusal } : {}) });
42 if (refusal !== undefined) return apiError("not_rollable", 409, refusal);
43 const done = await rollBackTo(ctx.app, user, target);
44 if (!done.ok) return apiError("not_rolled_back", 409, done.line);
45 return json({ ...route, rolledBack: true });
46 });
47 }
src/release/words.ts +6
@@ -20,3 +20,9 @@ export function liveSince<T, V>(tag: Tag<T, V>, sha: V, since: V): T {
2020
2121 /** What the empty releases view says will fill it. */
2222 export const NO_RELEASES_YET = "A pull landed on the forge's own repository is recorded here.";
23
24 /** Why a kept release offers no rollback: it is the one serving. */
25 export const ALREADY_LIVE = "This release is the one serving; there is nothing to roll back to.";
26
27 /** Why a kept release offers no rollback: its preflight failed or its schema is behind the live database's. */
28 export const NOT_ROLLABLE = "This release cannot be rolled back to: its preflight failed or its schema is behind the live database's.";
test/api-rollback.test.ts added +206
@@ -0,0 +1,206 @@
1 /**
2 * Rolling back over the API (#7217): a plan by default that moves nothing,
3 * an apply that goes through `rollBackTo` and its refusals, and the doors:
4 * a person who may not administer is `not_admin`, a token without admin
5 * scope, an agent, or anyone at all when FORGE_REPO names no repository is
6 * `forbidden`, an unknown or not-kept sha is `not_found`.
7 */
8 import { afterAll, beforeAll, beforeEach, describe, expect, setDefaultTimeout, 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 { migrations } from "../src/core/schema.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { markVerified } from "../src/core/verify.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { releaseDir } from "../src/release/checkout.ts";
19 import { recordRelease } from "../src/release/record.ts";
20 import { handover } from "../src/release/swap.ts";
21 import { ALREADY_LIVE, NOT_ROLLABLE } from "../src/release/words.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 quietLogs();
25 // The plan imports a kept release's schema module: room on a loaded machine.
26 setDefaultTimeout(30_000);
27
28 const LIVE = "a".repeat(40);
29 const KEPT = "b".repeat(40);
30 const PRUNED = "c".repeat(40);
31 const BEHIND = "d".repeat(40);
32
33 describe("POST /api/v1/admin/releases/:sha/rollback", () => {
34 let root = "";
35 let app: App;
36 let servers: Servers;
37 let admin = "";
38 const was = process.env["FORGE_COMMIT"];
39
40 const addUser = (username: string, isAdmin: boolean): string => {
41 const id = `u-${username}`;
42 app.db
43 .query("INSERT INTO user (id, username, email, password_hash, is_admin) VALUES (?1, ?2, ?3, 'x', ?4)")
44 .run(id, username, `${username}@forge.local`, isAdmin ? 1 : 0);
45 markVerified(app.db, id, new Date().toISOString());
46 return id;
47 };
48 const keep = (sha: string, schemaLength: number): void => {
49 const dir = releaseDir(root, sha);
50 mkdirSync(join(dir, "src", "core"), { recursive: true });
51 writeFileSync(
52 join(dir, "src", "core", "schema.ts"),
53 "export const migrations = " + JSON.stringify(Array(schemaLength).fill("SELECT 1")) + ";\n",
54 );
55 };
56 const roll = (sha: string, token: string, input?: unknown) =>
57 fetch(`${servers.url}/api/v1/admin/releases/${sha}/rollback`, {
58 method: "POST",
59 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
60 ...(input === undefined ? {} : { body: JSON.stringify(input) }),
61 });
62 // Nothing moves: this process is never told to drain.
63 let drained = 0;
64 const hadDrain = handover.drain;
65
66 beforeAll(async () => {
67 root = mkdtempSync(join(tmpdir(), "forge-api-rollback-"));
68 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
69 addUser("alice", true);
70 addUser("bob", false);
71 const made = await createRepo(app, { id: "u-alice", username: "alice" } as never, { name: "forge" });
72 if (!made.ok) throw new Error(made.error);
73 const repoId = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name='forge'").get()!.id;
74 for (const sha of [PRUNED, BEHIND, KEPT, LIVE])
75 recordRelease(app.db, { sha, repoId, pullNumber: null, ok: true, line: "ok" });
76 keep(LIVE, migrations.length);
77 keep(KEPT, migrations.length + 1);
78 keep(BEHIND, 1);
79 servers = await serve(app);
80 admin = createToken(app.db, "u-alice", "t", "admin").token;
81 handover.drain = () => drained++;
82 }, 60_000);
83 beforeEach(() => {
84 process.env["FORGE_COMMIT"] = LIVE;
85 });
86 afterAll(() => {
87 if (hadDrain === undefined) delete handover.drain;
88 else handover.drain = hadDrain;
89 if (was === undefined) delete process.env["FORGE_COMMIT"];
90 else process.env["FORGE_COMMIT"] = was;
91 servers.stop();
92 app.close();
93 rmSync(root, { recursive: true, force: true });
94 });
95
96 test("the plan is the default: from the live release to the kept one, allowed, and nothing moves", async () => {
97 const before = app.db.query("SELECT COUNT(*) AS n FROM release").get();
98 for (const input of [undefined, {}, { dryRun: true }]) {
99 const res = await roll(KEPT, admin, input);
100 expect(res.status).toBe(200);
101 expect(await res.json()).toEqual({ dryRun: true, from: LIVE, to: KEPT, allowed: true });
102 }
103 expect(app.db.query("SELECT COUNT(*) AS n FROM release").get()).toEqual(before);
104 expect(drained).toBe(0);
105 });
106
107 test("the plan says why the page offers no rollback: the live one, a schema behind", async () => {
108 expect(await (await roll(LIVE, admin)).json()).toEqual({
109 dryRun: true, from: LIVE, to: LIVE, allowed: false, message: ALREADY_LIVE,
110 });
111 expect(await (await roll(BEHIND, admin)).json()).toEqual({
112 dryRun: true, from: LIVE, to: BEHIND, allowed: false, message: NOT_ROLLABLE,
113 });
114 });
115
116 test("applying a release the live schema forbids is 409 with the page's words", async () => {
117 const res = await roll(BEHIND, admin, { dryRun: false });
118 expect(res.status).toBe(409);
119 expect(await res.json()).toEqual({ error: "not_rollable", message: NOT_ROLLABLE });
120 });
121
122 test("applying goes through rollBackTo: with self-swap off it refuses and nothing moves", async () => {
123 const res = await roll(KEPT, admin, { dryRun: false });
124 expect(res.status).toBe(409);
125 const out = (await res.json()) as { error: string; message: string };
126 expect(out.error).toBe("not_rolled_back");
127 expect(out.message).toContain("FORGE_SELF_SWAP");
128 expect(drained).toBe(0);
129 });
130
131 test("a member is not_admin and an rw token of an admin is forbidden", async () => {
132 const member = createToken(app.db, "u-bob", "t", "admin").token;
133 const asMember = await roll(KEPT, member);
134 expect(asMember.status).toBe(403);
135 expect(((await asMember.json()) as { error: string }).error).toBe("not_admin");
136 for (const scope of ["rw", "ro"] as const) {
137 const res = await roll(KEPT, createToken(app.db, "u-alice", "t", scope).token, { dryRun: false });
138 expect(res.status).toBe(403);
139 expect(((await res.json()) as { error: string }).error).toBe("forbidden");
140 }
141 });
142
143 // `mayRollBack` is this route's own door past the admin gate and the scope:
144 // an agent never rolls back, and nobody does when FORGE_REPO names no
145 // repository here. (A person past the gate is a site admin, who writes
146 // every repository, so there is no admin who does not write FORGE_REPO.)
147 test("an admin-scope agent is forbidden on the plan and the apply, and nothing drains", async () => {
148 const agent = addUser("crew1", true);
149 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?").run(agent);
150 const token = createToken(app.db, agent, "t", "admin").token;
151 for (const input of [undefined, { dryRun: false }]) {
152 const res = await roll(KEPT, token, input);
153 expect(res.status).toBe(403);
154 expect(((await res.json()) as { error: string }).error).toBe("forbidden");
155 }
156 expect(drained).toBe(0);
157 });
158
159 test("an admin is forbidden when FORGE_REPO names no repository here, and nothing drains", async () => {
160 const config = app.config as { forgeRepo: string | undefined };
161 const named = config.forgeRepo;
162 config.forgeRepo = "nobody/missing";
163 try {
164 for (const input of [undefined, { dryRun: false }]) {
165 const res = await roll(KEPT, admin, input);
166 expect(res.status).toBe(403);
167 expect(((await res.json()) as { error: string }).error).toBe("forbidden");
168 }
169 } finally {
170 config.forgeRepo = named;
171 }
172 expect(drained).toBe(0);
173 });
174
175 test("the plan leaves out from when nothing says which commit is live", async () => {
176 const app = process.env["FORGE_APP_COMMIT"];
177 delete process.env["FORGE_COMMIT"];
178 delete process.env["FORGE_APP_COMMIT"];
179 try {
180 const res = await roll(KEPT, admin);
181 expect(res.status).toBe(200);
182 expect(await res.json()).toEqual({ dryRun: true, to: KEPT, allowed: true });
183 } finally {
184 if (app !== undefined) process.env["FORGE_APP_COMMIT"] = app;
185 }
186 });
187
188 test("no token at all is unauthorized", async () => {
189 const res = await fetch(`${servers.url}/api/v1/admin/releases/${KEPT}/rollback`, { method: "POST" });
190 expect(res.status).toBe(401);
191 });
192
193 test("an unknown, malformed or no-longer-kept sha is not_found", async () => {
194 for (const sha of ["e".repeat(40), "not-a-sha", PRUNED]) {
195 const res = await roll(sha, admin);
196 expect(res.status).toBe(404);
197 expect(((await res.json()) as { error: string }).error).toBe("not_found");
198 }
199 });
200
201 test("a dryRun that is not a boolean is refused", async () => {
202 const res = await roll(KEPT, admin, { dryRun: "no" });
203 expect(res.status).toBe(422);
204 expect(((await res.json()) as { error: string }).error).toBe("invalid");
205 });
206 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs