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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7217-post-api-v1-admin-releases-sha-rollback → main 3 commits fork 3 threads · 3 unresolved
README.md +1 −1
Diff not shown — a line 4,561 characters wide (limit 2,000). +1 −1
View filesrc/contract/openapi/routes/index.ts +1
| @@ -171,6 +171,7 @@ export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1Admin | ||
| 171 | 171 | export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1AdminAccountsUsernameUnsuspend.ts"; |
| 172 | 172 | export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts"; |
| 173 | 173 | export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts"; |
| 174 | export { route as postApiV1AdminReleasesShaRollback } from "./postApiV1AdminReleasesShaRollback.ts"; | |
| 174 | 175 | export { route as postApiV1Agents } from "./postApiV1Agents.ts"; |
| 175 | 176 | export { route as postApiV1Apps } from "./postApiV1Apps.ts"; |
| 176 | 177 | 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" }, | |
| 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"; | ||
| 414 | 414 | import { followingRoutes } from "./api/following.ts"; |
| 415 | 415 | import { labelsRoutes } from "./api/labels.ts"; |
| 416 | 416 | import { reportRoutes } from "./api/reports.ts"; |
| 417 | import { rollbackRoutes } from "./api/rollback.ts"; | |
| 417 | 418 | import { takeBackRoutes } from "./api/take_back.ts"; |
| 418 | 419 | import { orgRoutes } from "./api/orgs.ts"; |
| 419 | 420 | import { promptRoutes } from "./api/prompts.ts"; |
| @@ -2683,6 +2684,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2683 | 2684 | }); |
| 2684 | 2685 | |
| 2685 | 2686 | reportRoutes(router); |
| 2687 | rollbackRoutes(router); | |
| 2686 | 2688 | takeBackRoutes(router); |
| 2687 | 2689 | |
| 2688 | 2690 | 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 |
||
| 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; | |
|
||
| 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 { | ||
| 20 | 20 | |
| 21 | 21 | /** What the empty releases view says will fill it. */ |
| 22 | 22 | 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 | }); |
fromis not always a string: see rollback.ts:37. Declare it as nullable, or leave it out when the live commit is unknown.