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

GET /api/v1/keeper answers the release keeper's releases, holds and what blocks the next cut, as MCP `release_keeper` does #8071

Merged opened this

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

Merged

torque/forge:torque/8024-get-api-v1-keeper-answers-the-release-ke → main 3 commits fork 1 thread · 1 unresolved

  • Merged. torque/8024-get-api-v1-keeper-answers-the-release-ke was merged into main as 48bffd3.

Review

Sign in to review this change.
8 files changed +125 −10
README.md +1 −1
@@ -564,7 +564,7 @@ The release keeper (`src/keeper/`, #7557) cuts, promotes and verifies releases o
564564 - **Verify:** it records the known-good version and image before the tag, sets the Deployment's image, and has the new version spend ten minutes under page load probing `/healthz` each second.
565565 - **Roll back and hold:** three bad probes in a row hold further cuts with the numbers and set the recorded known-good image back by name, never "undo". A pull saying `release hold <N>` lets the fix through, and that release verifying lifts the hold.
566566 - **Owner's hold:** set and lifted from the card, the terminal (`h`) or MCP `release_hold`, by a person who may roll back, never an agent.
567 - **Where to see it:** the **Release keeper** card on `/releases`, `Release keeper` in the terminal's palette, the **Maintenance** board under `/boards`, and MCP `release_keeper`, each gated as `/releases` is: a guest sees none of it.
567 - **Where to see it:** the **Release keeper** card on `/releases`, `Release keeper` in the terminal's palette, the **Maintenance** board under `/boards`, MCP `release_keeper` and `GET /api/v1/keeper` (JSON; 403 for a caller who may not read releases), each gated as `/releases` is: a guest sees none of it.
568568
569569 How it works and how to turn it on: [`docs/features/release-keeper.md`](docs/features/release-keeper.md).
570570
src/contract/openapi/routes/getApiV1Keeper.ts added +11
@@ -0,0 +1,11 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/keeper",
6 summary: "The release keeper's releases, holds and what blocks the next cut, for the forge's administrators and its repository's readers",
7 auth: false,
8 refusals: {
9 forbidden: "the release history is for the forge's administrators and its repository's readers",
10 },
11 };
src/contract/openapi/routes/index.ts +1
@@ -69,6 +69,7 @@ export { route as getApiV1ForgesTemplates } from "./getApiV1ForgesTemplates.ts";
6969 export { route as getApiV1Front } from "./getApiV1Front.ts";
7070 export { route as getApiV1Health } from "./getApiV1Health.ts";
7171 export { route as getApiV1Here } from "./getApiV1Here.ts";
72 export { route as getApiV1Keeper } from "./getApiV1Keeper.ts";
7273 export { route as getApiV1Map } from "./getApiV1Map.ts";
7374 export { route as getApiV1MigrateJob } from "./getApiV1MigrateJob.ts";
7475 export { route as getApiV1MigrateJobStream } from "./getApiV1MigrateJobStream.ts";
src/http/api.ts +2
@@ -313,6 +313,7 @@ import { egressGrantRoutes } from "./api/egress_grants.ts";
313313 import { forgeRoutes } from "./api/forges.ts";
314314 import { releasesRoutes } from "./api/releases.ts";
315315 import { hookRoutes } from "./api/hooks.ts";
316 import { keeperRoutes } from "./api/keeper.ts";
316317 import { agentRoutes } from "./api/agents.ts";
317318 import { subscriptionRoutes } from "./api/subscriptions.ts";
318319 import { boardRoutes } from "./api/boards.ts";
@@ -2931,6 +2932,7 @@ export function apiRoutes(router: Router<Handler>): void {
29312932 });
29322933
29332934 hookRoutes(router);
2935 keeperRoutes(router);
29342936
29352937 // Sync one mirror, or let a fork take its parent's updates. A mirror only
29362938 // pulls, so any writer may ask. A fork fast-forwards, or writes a merge
src/http/api/keeper.ts added +21
@@ -0,0 +1,21 @@
1 /**
2 * `GET /api/v1/keeper`: the release keeper's releases, holds and what blocks
3 * the next cut, for whoever may read /releases — MCP `release_keeper` over
4 * HTTP. The answer is `keeper/landed.ts`'s `keeperAnswer`.
5 */
6
7 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
8 import { keeperAnswer } from "../../keeper/landed.ts";
9 import { mayRead } from "../../release/gate.ts";
10 import type { Handler } from "../context.ts";
11 import { apiError, json } from "../respond.ts";
12 import type { Router } from "../router.ts";
13
14 export function keeperRoutes(router: Router<Handler>): void {
15 router.add("GET", "/api/v1/keeper", (ctx) => {
16 notePhase(ctx.request, "session", takeSessionTime(ctx.request));

Fine as is. Please add this door to the README line that lists where the keeper shows ("Where to see it").

17 if (!mayRead(ctx.app, ctx.auth.user))
18 return apiError("forbidden", 403, "the release history is for the forge's administrators and its repository's readers");
19 return json(keeperAnswer(ctx.app.db));
20 });
21 }
src/http/mcp/tools/release_keeper.ts +2 −9
@@ -1,7 +1,5 @@
11 import { mayRead } from "../../../release/gate.ts";
2 import { blocking, listHolds, standingHolds } from "../../../keeper/hold.ts";
3 import { withLandedToLive } from "../../../keeper/landed.ts";
4 import { listKeeperReleases } from "../../../keeper/record.ts";
2 import { keeperAnswer } from "../../../keeper/landed.ts";
53 import { bad, good, type Tool } from "../shared.ts";
64
75 export const tool: Tool = {
@@ -13,11 +11,6 @@ export const tool: Tool = {
1311 run(ctx, user) {
1412 if (!mayRead(ctx.app, user))
1513 return bad("forbidden", "the release history is for the forge's administrators and its repository's readers");
16 const db = ctx.app.db;
17 return good({
18 blocked: blocking(standingHolds(db)) ?? null,
19 releases: withLandedToLive(db, listKeeperReleases(db)),
20 holds: listHolds(db),
21 });
14 return good(keeperAnswer(ctx.app.db));
2215 },
2316 };
src/keeper/landed.ts +10
@@ -7,6 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { blocking, listHolds, standingHolds, type Hold } from "./hold.ts";
1011 import { listKeeperReleases, type KeeperRelease } from "./record.ts";
1112
1213 /** When `sha` landed, epoch ms: its pull's merge, else the first time a landing checked it out. */
@@ -53,3 +54,12 @@ export type LandedRelease = KeeperRelease & { readonly commit: string; readonly
5354 export function withLandedToLive(db: Database, releases: readonly KeeperRelease[]): LandedRelease[] {
5455 return releases.map((r) => ({ ...r, commit: r.sha.slice(0, 7), landedToLiveSeconds: landedToLive(db, r) ?? null }));
5556 }
57
58 /** What the keeper did and what stops the next cut: one answer for MCP `release_keeper` and `GET /api/v1/keeper`. */
59 export function keeperAnswer(db: Database): { blocked: string | null; releases: LandedRelease[]; holds: Hold[] } {
60 return {
61 blocked: blocking(standingHolds(db)) ?? null,
62 releases: withLandedToLive(db, listKeeperReleases(db)),
63 holds: listHolds(db),
64 };
65 }
test/api-keeper.test.ts added +77
@@ -0,0 +1,77 @@
1 /** `GET /api/v1/keeper` is MCP `release_keeper` over HTTP: the same answer, the same gate. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { setHold } from "../src/keeper/hold.ts";
11
12 describe("GET /api/v1/keeper", () => {
13 let dataDir: string;
14 let app: App;
15 let servers: Servers;
16 let admin: string;
17 let bob: string;
18 const get = (token?: string) =>
19 fetch(`${servers.url}/api/v1/keeper`, token ? { headers: { authorization: `Bearer ${token}` } } : {});
20 const mcp = async (token: string) => {
21 const res = await fetch(`${servers.url}/mcp`, {
22 method: "POST",
23 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
24 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "release_keeper", arguments: {} } }),
25 });
26 return JSON.parse(((await res.json()) as any).result.content[0].text);
27 };
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-api-keeper-"));
31 app = createApp({ dataDir, port: 0 });
32 servers = await serve(app);
33 const mk = async (username: string, first = false) => {
34 const u = await createUser(app.db, {
35 origin: "seed", trusted: true, username,
36 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
37 });
38 if (!u.ok) throw new Error(u.error);
39 return u.value;
40 };
41 const alice = await mk("alice", true);
42 const b = await mk("bob");
43 admin = createToken(app.db, alice.id, "a", "admin").token;
44 bob = createToken(app.db, b.id, "b", "admin").token;
45 setHold(app.db, { kind: "owner", version: "v1", reason: "hold the line", at: Date.now() });
46 }, 30_000);
47
48 afterAll(async () => {
49 servers.stop();
50 app.close();
51 await rm(dataDir, { recursive: true, force: true });
52 });
53
54 test("an administrator gets what MCP release_keeper answers, the hold blocking", async () => {
55 const res = await get(admin);
56 expect(res.status).toBe(200);
57 const body: any = await res.json();
58 expect(typeof body.blocked).toBe("string");
59 expect(body.holds.map((h: any) => h.reason)).toEqual(["hold the line"]);
60 expect(body).toEqual(await mcp(admin));
61 });
62
63 test("a stranger and an anonymous caller are refused in words", async () => {
64 for (const token of [bob, undefined]) {
65 const res = await get(token);
66 expect(res.status).toBe(403);
67 const body: any = await res.json();
68 expect(body.error).toBe("forbidden");
69 expect(body.message).toContain("release history");
70 }
71 });
72
73 test("the OpenAPI document lists the route", async () => {
74 const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any;
75 expect(doc.paths["/api/v1/keeper"].get).toBeDefined();
76 });
77 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs