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

The pinned forges page updates, rolls back and checks a forge, each after a confirm #10176

Merged opened this

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

Merged

scout/forge:scout/10107-the-pinned-forges-page-updates-rolls-bac → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/10107-the-pinned-forges-page-updates-rolls-bac was merged into main as ffb66dc.

Review

Sign in to review this change.
5 files changed +153 −3
docs/features/pinned-forge.md +1 −1
@@ -7,7 +7,7 @@ A pinned forge is a forge the owner put on their own cloud. It has **no crew** a
77 - HTTP: `GET /api/v1/admin/pinned` (forges, their changes, the targets), `POST /api/v1/admin/pinned` (deploy), `POST /api/v1/admin/pinned/:name/update`, `POST /api/v1/admin/pinned/:name/rollback`.
88 - Terminal: palette → **Pinned forges** (forge admin): `d` deploy, `u` update, `r` roll back; it follows the bus, so a change made at any door shows at once (`src/tui/screens/pinned.ts`).
99 - Web: `/deploy-to` (forge admin; linked from Cloud accounts) is a server-rendered form for cloud account, app, source, version and name. The link `/deploy-to?app=owner/name&source=https://…&version=v1.2.3` (`deployToHref` in `src/web/pinned.ts`) lands on it with those filled in after one sign-in; nothing deploys until the button is pressed. The POST is same-origin checked and calls the same `deployPinned` (`src/http/pinned_routes.ts`).
10 - Web list: `/pinned` (forge admin; 404 otherwise) is server-rendered, one block of plain rows per forge: name, app, version, address (a link), health (the state), when the last check ran (the newest `pinned.check` event; `never checked` before the first), and the last change with who and when, from the same `pinnedForges` read as the API (`src/web/pages/pinned.ts`). The intro links `/deploy-to`; an empty list says so.
10 - Web list: `/pinned` (forge admin; 404 otherwise) is server-rendered, with actions; one block of plain rows per forge: name, app, version, address (a link), health (the state), when the last check ran (the newest `pinned.check` event; `never checked` before the first), and the last change with who and when, from the same `pinnedForges` read as the API (`src/web/pages/pinned.ts`). The intro links `/deploy-to`; an empty list says so. A person can update, roll back and check a forge from `/pinned`, as the terminal's `u`, `r` and `c` do: each row has three buttons (Update, Roll back, Check), and each goes through the confirm page before it runs (Update also asks for the version). They post to `POST /pinned/:name/:act` (`act` is `update`, `rollback` or `check`; admin only, same-origin, 404 for non-admins and unknown acts); the result, or the sentence that refuses it, is shown as a flash on `/pinned` (`src/http/pinned_routes.ts`).
1111 - MCP: `pinned_forge` with `verb` `list`, `deploy`, `update`, `rollback` (and `check`) — the same words and the same checks (`src/actions/pinned.ts`).
1212
1313 A deploy names a cloud account (Settings → Cloud accounts, **writes allowed**), the app (`owner/name`), a git `source` (https) and a `version` (a 40-character commit id or a tag like `v1.2.3` — never a branch, so nothing moves under it).
src/http/pinned_routes.ts +27 −1
@@ -5,7 +5,7 @@
55 * `deployPinned` the API, MCP and terminal call, admin only and same-origin.
66 */
77
8 import { deployPinned, pinnedForges } from "../actions/pinned.ts";
8 import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../actions/pinned.ts";
99 import { listCloudAccounts } from "../core/cloud_account.ts";
1010 import { lastCheckOf } from "../core/pinned.ts";
1111 import { getUsersByIds } from "../core/users.ts";
@@ -71,8 +71,34 @@ function listPage(ctx: Ctx): Response {
7171 return page({ ctx, title: "Pinned forges", body: pinnedListPage(forges.value, { names, checks }) });
7272 }
7373
74 /** Update, Roll back and Check: the same actions the terminal's `u`, `r` and `c` call; admin only, same-origin, each refusal shown as its sentence. */
75 async function pinnedAct(ctx: Ctx): Promise<Response> {
76 if (!ctx.auth.user) return signInFirst(ctx);
77 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
78 const form = await readForm(ctx, "admin");
79 if (form instanceof Response) return form;
80 const name = ctx.params.name ?? "";
81 const act = ctx.params.act ?? "";
82 const user = ctx.auth.user;
83 let done;
84 if (act === "update") done = await updatePinned(ctx.app, user, name, { version: field(form, "version").trim().slice(0, 40) });
85 else if (act === "rollback") done = await rollbackPinned(ctx.app, user, name);
86 else if (act === "check") done = await checkPinned(ctx.app, user, name);
87 else return notFoundPage(ctx);
88 if (!done.ok) return flashTo(ctx, "/pinned", { tone: "danger", message: done.error });
89 const at = done.value.version.slice(0, 12);
90 const message =
91 act === "check"
92 ? `${name} ${"healthy" in done.value && done.value.healthy ? "answers" : "did not answer"}; it is ${done.value.state}.`
93 : act === "update"
94 ? `Updated ${name} to ${at}.`
95 : `Rolled ${name} back to ${at}.`;
96 return flashTo(ctx, "/pinned", { tone: "success", message });
97 }
98

Add this route and the three row buttons to the 'Web list' paragraph of docs/features/pinned-forge.md. The how-to must say a person can update, roll back and check from /pinned, as the terminal's u, r and c do. The docs are the only miss in this pull.

7499 export function pinnedRoutes(router: Router<Handler>): void {
75100 router.add("GET", "/pinned", listPage);
101 router.add("POST", "/pinned/:name/:act", pinnedAct);
76102 router.add("GET", "/deploy-to", deployToPage);
77103 router.add("POST", "/deploy-to", deployToSubmit);
78104 }
src/web/pages/pinned.ts +22 −1
@@ -6,7 +6,7 @@
66
77 import type { PinnedView } from "../../actions/pinned.ts";
88 import { html, type Html } from "../html.ts";
9 import { badge, time, type Tone } from "../ui.ts";
9 import { badge, btn, time, type Tone } from "../ui.ts";
1010
1111 const TONE: Record<string, Tone> = { running: "success", failed: "danger" };
1212
@@ -19,6 +19,26 @@ export interface PinnedFacts {
1919 const fact = (label: string, value: Html): Html => html`<div class="status-row"><dt>${label}</dt><dd>${value}</dd></div>`;
2020 const none = (text: string): Html => html`<span class="muted">${text}</span>`;
2121
22 /** Update asks for the version, Roll back and Check for nothing; each goes through the confirm page, which posts to `/pinned/<name>/<act>`. */
23 function actions(p: PinnedView): Html {
24 const to = (act: string): string => `/pinned/${p.name}/${act}`;
25 return html`<div class="form-actions">
26 <form method="get" action="/-/confirm">
27 <input type="hidden" name="to" value="${to("update")}" />
28 <input class="mono" name="version" value="${p.version}" maxlength="40" required spellcheck="false" autocomplete="off" aria-label="Version for ${p.name}" />
29 ${btn("Update", { variant: "secondary" })}
30 </form>
31 <form method="get" action="/-/confirm">
32 <input type="hidden" name="to" value="${to("rollback")}" />
33 ${btn("Roll back", { variant: "secondary", label: `Roll back ${p.name}` })}
34 </form>
35 <form method="get" action="/-/confirm">
36 <input type="hidden" name="to" value="${to("check")}" />
37 ${btn("Check", { variant: "ghost", label: `Check ${p.name}` })}
38 </form>
39 </div>`;
40 }
41
2242 function forge(p: PinnedView, f: PinnedFacts): Html {
2343 const last = p.changes[0];
2444 const checked = f.checks.get(p.name);
@@ -33,6 +53,7 @@ function forge(p: PinnedView, f: PinnedFacts): Html {
3353 ${fact("Last change", last ? html`${last.kind} to <span class="mono">${last.toVersion.slice(0, 12)}</span>` : none("none"))}
3454 ${last ? fact("Changed by", html`${f.names.get(last.by) ?? last.by} ${time(last.at)}`) : null}
3555 </dl>
56 ${actions(p)}
3657 </section>`;
3758 }
3859
src/web/pages/search.ts +27
@@ -866,6 +866,33 @@ const DESTRUCTIVE: {
866866 };
867867 },
868868 },
869 {
870 pattern: /^\/pinned\/([a-z][a-z0-9-]*)\/(update|rollback|check)$/,
871 describe: (m) => {
872 const name = m[1]!;
873 if (m[2] === "update")
874 return {
875 verb: "Update",
876 title: `Update ${name}?`,
877 detail: "The forge is replaced with the version you named. It changes only when you press this, and the change is recorded with your name.",
878 phrase: "",
879 carries: "version",
880 };
881 if (m[2] === "rollback")
882 return {
883 verb: "Roll back",
884 title: `Roll ${name} back?`,
885 detail: "The forge is replaced with the version the latest change replaced. The change is recorded with your name.",
886 phrase: "",
887 };
888 return {
889 verb: "Check",
890 title: `Check ${name}?`,
891 detail: "The forge reads its droplet's address back and asks the forge there for its front page. Nothing is changed but what it says about itself.",
892 phrase: "",
893 };
894 },
895 },
869896 {
870897 pattern: /^\/admin\/invites\/([^/]+)\/revoke$/,
871898 describe: (m) => ({
test/pinned-page.test.ts +76
@@ -158,3 +158,79 @@ describe("pinned list page", () => {
158158 }
159159 }, 30_000);
160160 });
161
162 describe("pinned list page actions", () => {
163 test("Update, Roll back and Check go through a confirm, are admin only and same-origin, and show refusals", async () => {
164 const dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-acts-"));
165 const app = createApp({ dataDir, port: 0 });
166 const servers = await serve(app);
167 const V2 = "b".repeat(40);
168 try {
169 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
170 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst });
171 if (!made.ok) throw new Error(made.error);
172 }
173 const adminTok = createToken(app.db, getUserByName(app.db, "alice")!.id, "admin", "admin").token;
174 const bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token;
175 cloudNet.fetch = async () => new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }), { status: 200 });
176 const added = await addAccount(app, getUserByName(app.db, "alice"), "digitalocean", "main", "dop_v1_9Zq4TxLm7Kp2Vb8nWcR5");
177 if (!added.ok) throw new Error(String(added.error));
178 allowWrite(app, getUserByName(app.db, "alice"), "digitalocean", "main", true);
179 const calls: string[] = [];
180 net.fetch = async (url, init) => {
181 const path = new URL(url).pathname;
182 calls.push(`${init?.method ?? "GET"} ${path}`);
183 if (path === "/v2/volumes") return new Response(JSON.stringify({ volume: { id: "vol-1" } }), { status: 201 });
184 if (init?.method === "DELETE") return new Response(null, { status: 204 });
185 return new Response(JSON.stringify({ droplet: { id: 7, networks: { v4: [{ type: "public", ip_address: "203.0.113.9" }] } } }), { status: 202 });
186 };
187 net.probe = async () => new Response("ok", { status: 200 });
188 const hdr = (tok: string, origin = servers.url) => ({ authorization: `Bearer ${tok}`, origin, "content-type": "application/x-www-form-urlencoded" });
189 const act = (path: string, tok: string, body: Record<string, string> = {}, origin?: string) =>
190 fetch(servers.url + path, { method: "POST", redirect: "manual", headers: hdr(tok, origin), body: new URLSearchParams(body) });
191 const get = (path: string, tok: string) => fetch(servers.url + path, { redirect: "manual", headers: { authorization: `Bearer ${tok}` } });
192 const after = async (r: Response) =>
193 (await fetch(servers.url + r.headers.get("location")!, { headers: { authorization: `Bearer ${adminTok}`, cookie: (r.headers.get("set-cookie") ?? "").split(";")[0]! } })).text();
194 const dep = await act("/deploy-to", adminTok, { name: "acme", account: "main", app: "alice/shop", source: SOURCE, version: V });
195 expect(dep.status).toBe(303);
196
197 const list = await (await get("/pinned", adminTok)).text();
198 for (const w of ["/pinned/acme/update", "/pinned/acme/rollback", "/pinned/acme/check"]) expect(list).toContain(w);
199
200 // The confirm page words each and carries the version to the POST.
201 const conf = await (await get(`/-/confirm?to=${encodeURIComponent("/pinned/acme/update")}&version=${V2}`, adminTok)).text();
202 expect(conf).toContain('action="/pinned/acme/update"');
203 expect(conf).toContain(`value="${V2}"`);
204 expect((await get("/-/confirm?to=/pinned/Bad_Name/update", adminTok)).status).toBe(404);
205
206 // Not an admin: 404 and nothing moves. Cross-origin: 403.
207 for (const a of ["update", "rollback", "check"]) expect((await act(`/pinned/acme/${a}`, bobTok, { version: V2 })).status).toBe(404);
208 expect((await act("/pinned/acme/update", adminTok, { version: V2 }, "https://evil.example")).status).toBe(403);
209 expect(getPinned(app.db, "acme")!.version).toBe(V);
210
211 // A refusal is shown as its sentence.
212 const refused = await act("/pinned/acme/rollback", adminTok);
213 expect(refused.status).toBe(303);
214 const shown = await after(refused);
215 expect(shown).toContain("There is no previous version");
216 const bad = await act("/pinned/acme/update", adminTok, { version: "main" });
217 expect(await after(bad)).toContain("A version is a full 40-character commit id");
218
219 // Update, then roll back, then check.
220 expect((await act("/pinned/acme/update", adminTok, { version: V2 })).status).toBe(303);
221 expect(getPinned(app.db, "acme")!.version).toBe(V2);
222 expect((await act("/pinned/acme/rollback", adminTok)).status).toBe(303);
223 expect(getPinned(app.db, "acme")!.version).toBe(V);
224 const before = calls.length;
225 const chk = await act("/pinned/acme/check", adminTok);
226 expect(chk.status).toBe(303);
227 expect(calls.length).toBeGreaterThan(before);
228 const checked = await (await get("/pinned", adminTok)).text();
229 expect(checked).not.toContain("never checked");
230 expect((await act("/pinned/nope/check", adminTok)).status).toBe(303);
231 } finally {
232 await servers.stop();
233 await rm(dataDir, { recursive: true, force: true });
234 }
235 }, 30_000);
236 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs