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

Pinned forge: read the droplet address back and show the served URL (follow-up to #8697) #8799

Merged opened this

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

Merged

mason/forge:mason/8745-pinned-forge-read-the-droplet-address-ba → main 3 commits fork

  • Merged. mason/8745-pinned-forge-read-the-droplet-address-ba was merged into main as ac523e0.

Review

Sign in to review this change.
12 files changed +321 −30
docs/features/pinned-forge.md +7 −2
@@ -6,7 +6,7 @@ A pinned forge is a forge the owner put on their own cloud. It has **no crew** a
66
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`).
9 - MCP: `pinned_forge` with `verb` `list`, `deploy`, `update`, `rollback` — the same words and the same checks (`src/actions/pinned.ts`).
9 - MCP: `pinned_forge` with `verb` `list`, `deploy`, `update`, `rollback` (and `check`) — the same words and the same checks (`src/actions/pinned.ts`).
1010
1111 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).
1212
@@ -36,6 +36,10 @@ The cloud facts for Cloudflare and Vercel are from memory of their documentation
3636
3737 Open-source "Deploy to" buttons (Heroku's `app.json`, Render's `render.yaml`, DigitalOcean's App Platform button, Railway templates) are a link carrying a repository and a settings file to a provider's sign-in. That is the shape for the button: a link with `app`, `source`, `version` that lands on the Deploy form after one sign-in. The older `deploy/` cloud path and the child-forge path (#4416, #2928, #3785) are the other prior art; neither pins.
3838
39 ## Address and health check (#8745)
40
41 `check` (`POST /api/v1/admin/pinned/:name/check`, MCP `pinned_forge` verb `check`, terminal `c`) reads the droplet from DigitalOcean (`GET /droplets/{id}`, read-only, no write permission needed), records its public IPv4 (`pinned_forge.address`, validated as IPv4), and probes `http://<address>:<PORT or 3000>/`. Every view carries `url` (empty until an address is read). A forge that answers is set `running`, so a `failed` one recovers by itself once its droplet is up; one that does not answer keeps its state and the result says `healthy: false`. Deploy and Update do not read the address: a new droplet has none for a while, so ask with `check`.
42
3943 ## Failing half-way
4044
4145 Each id is recorded the moment it exists. A first deploy that fails before a droplet exists leaves a `failed` row with the volume's id; deploying the same name again reuses that volume. An Update deletes the old droplet and clears its id at once; a 404 on that delete counts as deleted, so after a failed create a Rollback or another Update goes straight to creating. A `failed` forge may be told the version it already has, to finish the replacement. The droplet installs the Bun in `BUN_VERSION` (`src/cloud/droplet.ts`), so the runtime moves only when a pull moves it.
@@ -44,5 +48,6 @@ Each id is recorded the moment it exists. A first deploy that fails before a dro
4448
4549 - No canary on a real account: #8746.
4650 - No `Deploy to…` page or link: #8744.
47 - The droplet's address is not read back, so no URL is shown, no TLS or domain, no health check: #8745.
51 - No TLS or domain: the URL is `http://<droplet IPv4>:<PORT or 3000>/`.
52 - No page in the web studio shows the pinned forges yet; the API, MCP and terminal do.
4853 - AWS and Azure drivers.
src/actions/pinned.ts +52 −8
@@ -11,30 +11,32 @@
1111
1212 import type { App } from "../app.ts";
1313 import { gatedFetch } from "../cloud/gate.ts";
14 import { provision, replace } from "../cloud/droplet.ts";
14 import { provision, readDroplet, replace } from "../cloud/droplet.ts";
1515 import type { Fetch } from "../cloud/providers.ts";
1616 import { target as targetOf } from "../cloud/targets.ts";
1717 import { getCloudAccount, secretNameOf } from "../core/cloud_account.ts";
1818 import {
1919 APP, NAME, changesOf, checkConfig, getPinned, insertPinned, listPinned, previousOf,
20 dropPinned, recordChange, setRemote, validSource, validVersion, type Change, type Pinned,
20 dropPinned, recordChange, setRemote, urlOf, validSource, validVersion, type Change, type Pinned,
2121 } from "../core/pinned.ts";
2222 import { resolveSecrets } from "../core/secret_store.ts";
2323 import type { User } from "../core/users.ts";
2424 import { err, ok, type Result } from "../kernel/result.ts";
2525
2626 /** Swapped in tests; the real forge always uses `fetch`. */
27 export const net: { fetch: Fetch } = { fetch: (u, i) => fetch(u, i) };
27 export const net: { fetch: Fetch; probe: Fetch } = { fetch: (u, i) => fetch(u, i), probe: (u, i) => fetch(u, i) };
2828
2929 const admin = (a: User | undefined): Result<User, string> => (a?.isAdmin ? ok(a) : err("not_found"));
3030
3131 export interface PinnedView extends Pinned {
32 /** Where it is served, once the droplet's address is read; empty before. */
33 readonly url: string;
3234 readonly crew: false;
3335 readonly autoRelease: false;
3436 readonly changes: Change[];
3537 }
3638
37 const view = (app: App, p: Pinned): PinnedView => ({ ...p, crew: false, autoRelease: false, changes: changesOf(app.db, p.id) });
39 const view = (app: App, p: Pinned): PinnedView => ({ ...p, url: urlOf(p), crew: false, autoRelease: false, changes: changesOf(app.db, p.id) });
3840
3941 export function pinnedForges(app: App, actor: User | undefined): Result<PinnedView[], string> {
4042 const who = admin(actor);
@@ -42,10 +44,10 @@ export function pinnedForges(app: App, actor: User | undefined): Result<PinnedVi
4244 }
4345
4446 /** Open the account's token and run `go` with a gated fetch; the token never leaves this scope. */
45 async function withCloud<T>(app: App, who: User, p: Pinned, purpose: string, go: (f: Fetch, token: string) => Promise<T>): Promise<Result<T, string>> {
47 async function withCloud<T>(app: App, who: User, p: Pinned, purpose: string, go: (f: Fetch, token: string) => Promise<T>, writes = true): Promise<Result<T, string>> {
4648 const account = getCloudAccount(app.db, p.target, p.account);
4749 if (!account) return err("No such cloud account.");
48 if (!account.writeAllowed) return err("That cloud account is read-only; allow writes on it first.");
50 if (writes && !account.writeAllowed) return err("That cloud account is read-only; allow writes on it first.");
4951 const secret = secretNameOf(account.provider, account.name);
5052 const token = resolveSecrets(app.db, app.config.dataDir, app.bus, [secret], { actor: who.id, run: `pinned:${purpose}` })[secret];
5153 if (token === undefined) return err("No such credential.");
@@ -56,6 +58,16 @@ async function withCloud<T>(app: App, who: User, p: Pinned, purpose: string, go:
5658 }
5759 }
5860
61 /** Read the droplet back and record its public IPv4 (empty while none is assigned). The cloud's refusal is returned, so "no address yet" and "the call was refused" differ. */
62 async function readAddress(app: App, who: User, name: string): Promise<Result<string, string>> {
63 const p = getPinned(app.db, name);
64 if (!p || p.remote === "") return ok("");
65 const seen = await withCloud(app, who, p, "address", (f, token) => readDroplet(f, token, p.remote), false);
66 if (!seen.ok) return seen;
67 if (seen.value.address !== "") setRemote(app.db, name, { address: seen.value.address });
68 return ok(seen.value.address);
69 }
70
5971 export async function deployPinned(
6072 app: App,
6173 actor: User | undefined,
@@ -116,12 +128,12 @@ async function change(
116128 app.bus.append({ kind: kind === "update" ? "pinned.update" : "pinned.rollback", actorId: who.value.id, payload: { name, from: c.fromVersion, to: c.toVersion } });
117129 }
118130 const after = getPinned(app.db, name)!;
119 const done = await withCloud(app, who.value, after, kind, (f, token) => replace(f, token, after, { onDeleted: () => setRemote(app.db, name, { remote: "" }) }));
131 const done = await withCloud(app, who.value, after, kind, (f, token) => replace(f, token, after, { onDeleted: () => setRemote(app.db, name, { remote: "", address: "" }) }));
120132 if (!done.ok) {
121133 setRemote(app.db, name, { state: "failed" });
122134 return done;
123135 }
124 setRemote(app.db, name, { ...done.value, state: "running" });
136 setRemote(app.db, name, { ...done.value, address: "", state: "running" });
125137 return ok(view(app, getPinned(app.db, name)!));
126138 }
127139
@@ -145,3 +157,35 @@ export const rollbackPinned = (app: App, actor: User | undefined, name: string):
145157 const prev = previousOf(app.db, p);
146158 return prev ? ok(prev) : err("There is no previous version: this forge has only ever run one.");
147159 });
160
161 /**
162 * The health check: read the droplet's address back, ask the forge there for
163 * its front page, and say what it answered. A forge that answers is `running`,
164 * so one marked `failed` by a refused call recovers by itself once its droplet
165 * is up; one that does not answer keeps its state, and `healthy` is false.
166 */
167 export async function checkPinned(app: App, actor: User | undefined, name: string): Promise<Result<PinnedView & { healthy: boolean }, string>> {
168 const who = admin(actor);
169 if (!who.ok) return who;
170 const p = getPinned(app.db, name);
171 if (!p) return err("No such pinned forge.");
172 if (p.remote === "") return err("This forge has no droplet yet; deploy or update it first.");
173 const read = await readAddress(app, who.value, name);
174 if (!read.ok) return read;
175 const now = getPinned(app.db, name)!;
176 let healthy = false;
177 if (now.address !== "") {
178 // The probe is audited like every other call made for this account; the address is a validated public IPv4 and the port is 1-65535.
179 const probe = gatedFetch(app.db, { provider: now.target, account: now.account }, "pinned:probe", net.probe);
180 try {
181 const res = await probe(urlOf(now), { signal: AbortSignal.timeout(5_000), redirect: "manual" });
182 healthy = res.status >= 200 && res.status < 400;
183 } catch {
184 healthy = false;
185 }
186 }
187 if (healthy && now.state !== "running") setRemote(app.db, name, { state: "running" });
188 const after = getPinned(app.db, name)!;
189 app.bus.append({ kind: "pinned.check", actorId: who.value.id, payload: { name, address: after.address, state: after.state, healthy } });
190 return ok({ ...view(app, after), healthy });
191 }
src/cloud/droplet.ts +13 −1
@@ -6,7 +6,8 @@
66 * Authorization header over HTTPS, never on argv, and the boot script holds no
77 * secret at all: the new forge's first visitor claims it at /first-admin.
88 *
9 * Calls (DigitalOcean API v2): POST /volumes, POST /droplets, DELETE /droplets/{id}.
9 * Calls (DigitalOcean API v2): POST /volumes, POST /droplets, DELETE /droplets/{id}, GET /droplets/{id}
10 * (the public IPv4 the droplet was given, read back after it boots).
1011 * The caller records each id the moment it exists (`onVolume`, `onDeleted`),
1112 * so a failure half-way leaves a row a retry can finish, never an orphan.
1213 */
@@ -121,3 +122,14 @@ export async function replace(f: Fetch, token: string, p: Pinned, hooks: { onDel
121122 const d = await createDroplet(f, token, p, p.volume);
122123 return { remote: String(d.droplet?.id ?? "") };
123124 }
125
126 const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
127
128 /** Read the droplet back: its status and public IPv4 (empty while none is assigned). Only a well-formed address is returned. */
129 export async function readDroplet(f: Fetch, token: string, remote: string): Promise<{ status: string; address: string }> {
130 if (!/^\d+$/.test(remote)) throw new Error("this forge has no droplet to read");
131 const d = (await call(f, token, "GET", `/droplets/${remote}`)).droplet;
132 const v4: unknown[] = Array.isArray(d?.networks?.v4) ? d.networks.v4 : [];
133 const pub = v4.find((n: any) => n?.type === "public" && typeof n.ip_address === "string" && IPV4.test(n.ip_address)) as { ip_address: string } | undefined;
134 return { status: String(d?.status ?? ""), address: pub?.ip_address ?? "" };
135 }
src/contract/openapi/routes/index.ts +1
@@ -195,6 +195,7 @@ export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1Adm
195195 export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts";
196196 export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts";
197197 export { route as postApiV1AdminPinned } from "./postApiV1AdminPinned.ts";
198 export { route as postApiV1AdminPinnedNameCheck } from "./postApiV1AdminPinnedNameCheck.ts";
198199 export { route as postApiV1AdminPinnedNameRollback } from "./postApiV1AdminPinnedNameRollback.ts";
199200 export { route as postApiV1AdminPinnedNameUpdate } from "./postApiV1AdminPinnedNameUpdate.ts";
200201 export { route as postApiV1AdminReleasesShaRollback } from "./postApiV1AdminReleasesShaRollback.ts";
src/contract/openapi/routes/postApiV1AdminPinnedNameCheck.ts added +12
@@ -0,0 +1,12 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/admin/pinned/{name}/check",
6 summary: "Read the droplet's public address back, probe the forge there, and say whether it answers; one that does is marked running",
7 auth: true,
8 refusals: {
9 not_found: "only a forge administrator sees this route",
10 refused: "the request was understood and refused; the message says why (422)",
11 },
12 };
src/core/migration-pins/20261103110000-pinned-address.pin added +1
@@ -0,0 +1,1 @@
1 13d9c395c67ccf46eae79e8a3f5c0af8f1f1d7a163d02ca5af83e8408d3b7e60
src/core/migrations/20261103110000-pinned-address.ts added +5
@@ -0,0 +1,5 @@
1 // The public IPv4 a pinned forge's droplet was read back with (#8745), so the
2 // forge can show the address it is served at. Additive; empty until it is read.
3 export default `
4 ALTER TABLE pinned_forge ADD COLUMN address TEXT NOT NULL DEFAULT '';
5 `;
src/core/pinned.ts +15 −5
@@ -20,6 +20,7 @@ export interface Pinned {
2020 readonly config: Record<string, string>;
2121 readonly remote: string;
2222 readonly volume: string;
23 readonly address: string;
2324 readonly state: string;
2425 readonly createdAt: string;
2526 }
@@ -71,13 +72,13 @@ export function checkConfig(raw: unknown): Result<Record<string, string>, string
7172
7273 interface Row {
7374 id: string; name: string; target: string; account: string; app: string; source: string;
74 version: string; config: string; remote: string; volume: string; state: string; created_at: string;
75 version: string; config: string; remote: string; volume: string; address: string; state: string; created_at: string;
7576 }
7677
7778 const toPinned = (r: Row): Pinned => ({
7879 id: r.id, name: r.name, target: r.target, account: r.account, app: r.app, source: r.source,
7980 version: r.version, config: JSON.parse(r.config) as Record<string, string>,
80 remote: r.remote, volume: r.volume, state: r.state, createdAt: r.created_at,
81 remote: r.remote, volume: r.volume, address: r.address, state: r.state, createdAt: r.created_at,
8182 });
8283
8384 export const listPinned = (db: Database): Pinned[] =>
@@ -154,12 +155,21 @@ export function previousOf(db: Database, forge: Pinned): { version: string; conf
154155 }
155156
156157 /** Machine facts the target reports back; they never touch version or config. */
157 export function setRemote(db: Database, name: string, f: { remote?: string; volume?: string; state?: string }): void {
158 db.query("UPDATE pinned_forge SET remote = coalesce(?1, remote), volume = coalesce(?2, volume), state = coalesce(?3, state) WHERE name = ?4")
159 .run(f.remote ?? null, f.volume ?? null, f.state ?? null, name);
158 export function setRemote(db: Database, name: string, f: { remote?: string; volume?: string; address?: string; state?: string }): void {
159 db.query("UPDATE pinned_forge SET remote = coalesce(?1, remote), volume = coalesce(?2, volume), address = coalesce(?3, address), state = coalesce(?4, state) WHERE name = ?5")
160 .run(f.remote ?? null, f.volume ?? null, f.address ?? null, f.state ?? null, name);
160161 }
161162
162163 /** The row of a deploy that never reached a droplet, so its name can be tried again; its recorded change stays as history. */
163164 export function dropPinned(db: Database, name: string): void {
164165 db.query("DELETE FROM pinned_forge WHERE name = ?1 AND remote = '' AND state = 'failed'").run(name);
165166 }
167
168 /** The port the forge listens on at its droplet: the config's PORT, else the forge's own default. */
169 export const portOf = (p: Pick<Pinned, "config">): string => {
170 const n = Number(p.config["PORT"]);
171 return /^\d{1,5}$/.test(p.config["PORT"] ?? "") && n >= 1 && n <= 65535 ? String(n) : "3000";
172 };
173
174 /** Where a pinned forge is served, once its droplet's address is known; empty before. */
175 export const urlOf = (p: Pick<Pinned, "address" | "config">): string => (p.address === "" ? "" : `http://${p.address}:${portOf(p)}/`);
src/http/api/admin.ts +8 −1
@@ -14,7 +14,7 @@ import { changeCapacity, listCapacity } from "../../actions/capacity.ts";
1414 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts";
1515 import { changeLimit, listLimits } from "../../actions/limits.ts";
1616 import { setAccountCeilings, setAccountGrant } from "../../actions/model_grant.ts";
17 import { deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts";
17 import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts";
1818 import { TARGETS } from "../../cloud/targets.ts";
1919 import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
2020 import { setRole } from "../../actions/roles.ts";
@@ -72,6 +72,13 @@ export function adminRoutes(router: Router<Handler>): void {
7272 return done.ok ? json(done.value) : pinnedFail(done.error);
7373 });
7474
75 router.add("POST", "/api/v1/admin/pinned/:name/check", async (ctx) => {
76 const denied = accountAdmin(ctx, "admin");
77 if (denied) return denied;
78 const done = await checkPinned(ctx.app, ctx.auth.user, ctx.params.name!);
79 return done.ok ? json(done.value) : pinnedFail(done.error);
80 });
81
7582 router.add("POST", "/api/v1/admin/pinned/:name/rollback", async (ctx) => {
7683 const denied = accountAdmin(ctx, "admin");
7784 if (denied) return denied;
src/http/mcp/tools/pinned_forge.ts +9 −7
@@ -1,16 +1,16 @@
1 import { deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../../actions/pinned.ts";
1 import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../../actions/pinned.ts";
22 import { TARGETS } from "../../../cloud/targets.ts";
33 import { scopeAllows } from "../../../core/tokens.ts";
44 import { good, bad, type Tool, str } from "../shared.ts";
55
66 export const tool: Tool = {
77 name: "pinned_forge",
8 doc: "`{ verb, name?, target?, account?, app?, source?, version?, config? }`: a pinned forge on the owner's cloud has no crew and no automatic release. `list` shows them with every recorded change and the targets (what runs, what it costs, what is refused); `deploy` puts one on `target` with a cloud `account` (writes allowed) serving `app` owner/name from `source` at `version`; `update` is the only way its `version` or `config` change; `rollback` goes back one. A forge admin with an admin-scope token",
9 description: "Pinned forges on your cloud: list, deploy, update, rollback (admin).",
8 doc: "`{ verb, name?, target?, account?, app?, source?, version?, config? }`: a pinned forge on the owner's cloud has no crew and no automatic release. `list` shows them with every recorded change and the targets (what runs, what it costs, what is refused); `deploy` puts one on `target` with a cloud `account` (writes allowed) serving `app` owner/name from `source` at `version`; `update` is the only way its `version` or `config` change; `rollback` goes back one; `check` reads the droplet's address back and probes it (`url`, `healthy`). A forge admin with an admin-scope token",
9 description: "Pinned forges on your cloud: list, deploy, update, rollback, check (admin).",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
13 verb: { type: "string", enum: ["list", "deploy", "update", "rollback"] },
13 verb: { type: "string", enum: ["list", "deploy", "update", "rollback", "check"] },
1414 name: { type: "string", maxLength: 40 },
1515 target: { type: "string", enum: TARGETS.map((t) => t.id) },
1616 account: { type: "string", maxLength: 32 },
@@ -46,9 +46,11 @@ export const tool: Tool = {
4646 })
4747 : verb === "rollback"
4848 ? await rollbackPinned(ctx.app, user, name)
49 : undefined;
50 if (done === undefined) return bad("bad_verb", "verb is list, deploy, update or rollback");
49 : verb === "check"
50 ? await checkPinned(ctx.app, user, name)
51 : undefined;
52 if (done === undefined) return bad("bad_verb", "verb is list, deploy, update, rollback or check");
5153 if (!done.ok) return bad("refused", done.error);
52 return good({ says: `${verb} ${name}: now ${done.value.version}, ${done.value.state}.`, forge: done.value });
54 return good({ says: `${verb} ${name}: now ${done.value.version}, ${done.value.state}${done.value.url ? ` at ${done.value.url}` : ""}.`, forge: done.value });
5355 },
5456 };
src/tui/screens/pinned.ts +10 −6
@@ -2,12 +2,13 @@
22 * Pinned forges in the terminal, a forge admin's (#8697): each forge with its
33 * version, state and last changes, as the API lists them. `d` deploys, `u`
44 * updates (the only way its version or config move), `r` rolls back to the
5 * previous version, each through `actions/pinned.ts`, so the checks, the
6 * record of who and when and the events the web screens follow are the API's.
5 * previous version, `c` reads the address back and probes it, each through
6 * `actions/pinned.ts`, so the checks, the record of who and when and the events
7 * the web screens follow are the API's.
78 * Anyone else sees nothing and does nothing.
89 */
910
10 import { deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts";
11 import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts";
1112 import type { Ctx, Screen } from "../app/app.ts";
1213 import { binding } from "../app/keys.ts";
1314 import { style } from "../term/style.ts";
@@ -52,9 +53,9 @@ export function pinnedScreen(f: Session, ctx: Ctx): Screen {
5253
5354 const screen: Screen = {
5455 title: "Pinned forges",
55 keys: [binding(["j", "k"], "move cursor"), binding(["d"], "deploy"), binding(["u"], "update"), binding(["r"], "roll back")],
56 keys: [binding(["j", "k"], "move cursor"), binding(["d"], "deploy"), binding(["u"], "update"), binding(["r"], "roll back"), binding(["c"], "check")],
5657 async load(signal) {
57 live(f, signal, { kinds: ["pinned.deployed", "pinned.update", "pinned.rollback"] }, () => ctx.invalidate());
58 live(f, signal, { kinds: ["pinned.deployed", "pinned.update", "pinned.rollback", "pinned.check"] }, () => ctx.invalidate());
5859 },
5960 render(area, s) {
6061 const c = ctx.theme.c;
@@ -66,7 +67,7 @@ export function pinnedScreen(f: Session, ctx: Ctx): Screen {
6667 const rows: ListRow[] = forges().map((p) => ({
6768 title: p.name,
6869 ...(p.state === "failed" ? { tone: "danger" as const } : {}),
69 secondary: `${p.target} · ${short(p.version)} · ${p.state}`,
70 secondary: `${p.target} · ${short(p.version)} · ${p.state}${p.url !== "" ? ` · ${p.url}` : ""}`,
7071 meta: p.changes[0] ? `${p.changes[0].kind} ${p.changes[0].at.slice(0, 16).replace("T", " ")}` : "",
7172 }));
7273 drawList(s, list!, c, state, rows);
@@ -83,6 +84,9 @@ export function pinnedScreen(f: Session, ctx: Ctx): Screen {
8384 if (e.key === "r") {
8485 say(`Rolling ${p.name} back…`);
8586 void settle(rollbackPinned(f.app, f.user, p.name), "Rolled back");
87 } else if (e.key === "c") {
88 say(`Checking ${p.name}…`);
89 void checkPinned(f.app, f.user, p.name).then((r) => say(r.ok ? `${p.name} ${r.value.healthy ? "answers" : "does not answer"}${r.value.url ? ` at ${r.value.url}` : " yet: no address"}.` : r.error));
8690 } else if (e.key === "u")
8791 ctx.push(
8892 promptScreen(ctx, screen, {
test/pinned-forge-address.test.ts added +188
@@ -0,0 +1,188 @@
1 /**
2 * A pinned forge's droplet address is read back (#8745): shown as a URL, and a
3 * health check lets a failed forge recover. Tried at every door: the action,
4 * the HTTP route, the MCP verb and the terminal key.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { addAccount, allowWrite, net as cloudNet } from "../src/actions/cloud.ts";
12 import { checkPinned, deployPinned, net, pinnedForges } from "../src/actions/pinned.ts";
13 import { readDroplet } from "../src/cloud/droplet.ts";
14 import { getPinned, portOf, setRemote } from "../src/core/pinned.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, getUserByName } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { Session } from "../src/tui/session.ts";
19 import { pinnedScreen } from "../src/tui/screens/pinned.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 describe("pinned forge address", () => {
25 let dataDir = "";
26 let app: App;
27 let servers: Servers;
28 let networks: unknown = { v4: [{ type: "private", ip_address: "10.0.0.2" }, { type: "public", ip_address: "203.0.113.7" }] };
29 let probeStatus = 200;
30 let readStatus = 200;
31 const probed: string[] = [];
32 const admin = () => getUserByName(app.db, "alice")!;
33 const deploy = (name: string) =>
34 deployPinned(app, admin(), { name, target: "digitalocean", account: "main", app: "alice/shop", source: "https://forge.example.org/trevato/forge", version: "a".repeat(40), config: { PORT: "8080" } });
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-addr-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
41 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst });
42 if (!made.ok) throw new Error(made.error);
43 }
44 cloudNet.fetch = async () => new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }), { status: 200 });
45 await addAccount(app, admin(), "digitalocean", "main", "dop_v1_9Zq4TxLm7Kp2Vb8nWcR5");
46 allowWrite(app, admin(), "digitalocean", "main", true);
47 net.fetch = async (url, init) => {
48 const path = new URL(url).pathname;
49 if (path === "/v2/volumes") return new Response(JSON.stringify({ volume: { id: "vol-1" } }), { status: 201 });
50 if ((init?.method ?? "GET") === "GET")
51 return readStatus === 200
52 ? new Response(JSON.stringify({ droplet: { id: 77, status: "active", networks } }), { status: 200 })
53 : new Response("{}", { status: readStatus });
54 return new Response(JSON.stringify({ droplet: { id: 77 } }), { status: 202 });
55 };
56 net.probe = async (url) => { probed.push(url); return new Response("ok", { status: probeStatus }); };
57 }, 30_000);
58
59 afterAll(async () => {
60 servers.stop();
61 app.close();
62 await rm(dataDir, { recursive: true, force: true });
63 });
64
65 test("a deploy has no address yet; the check reads the public IPv4 back and the view carries the served URL", async () => {
66 const r = await deploy("acme");
67 expect(r).toMatchObject({ ok: true, value: { address: "", url: "" } });
68 expect(await checkPinned(app, admin(), "acme")).toMatchObject({ ok: true, value: { address: "203.0.113.7", url: "http://203.0.113.7:8080/" } });
69 const listed = pinnedForges(app, admin());
70 expect(listed.ok && listed.value[0]!.url).toBe("http://203.0.113.7:8080/");
71 });
72
73 test("only a well-formed public IPv4 is taken", async () => {
74 const f = async () => new Response(JSON.stringify({ droplet: { status: "new", networks } }), { status: 200 });
75 networks = { v4: [{ type: "public", ip_address: "1.2.3.4; rm -rf /" }, { type: "private", ip_address: "10.0.0.2" }] };
76 expect(await readDroplet(f, "t", "77")).toEqual({ status: "new", address: "" });
77 networks = {};
78 expect((await readDroplet(f, "t", "77")).address).toBe("");
79 networks = { v4: [{ type: "public", ip_address: "203.0.113.7" }] };
80 await expect(readDroplet(f, "t", "../x")).rejects.toThrow();
81 });
82
83 test("PORT is 1 to 65535, else the forge's default", () => {
84 for (const [port, want] of [["8080", "8080"], ["65535", "65535"], ["0", "3000"], ["65536", "3000"], ["99999", "3000"], ["", "3000"]] as const)
85 expect(portOf({ config: { PORT: port } })).toBe(want);
86 expect(portOf({ config: {} })).toBe("3000");
87 });
88
89 test("the check recovers a failed forge that answers, leaves one that does not, and says so on the bus", async () => {
90 const seen: any[] = [];
91 const off = app.bus.subscribe((e) => { if (e.kind === "pinned.check") seen.push(e.payload); });
92 setRemote(app.db, "acme", { state: "failed" });
93 probeStatus = 503;
94 expect(await checkPinned(app, admin(), "acme")).toMatchObject({ ok: true, value: { healthy: false, state: "failed" } });
95 probeStatus = 200;
96 expect(await checkPinned(app, admin(), "acme")).toMatchObject({ ok: true, value: { healthy: true, state: "running" } });
97 expect(probed.at(-1)).toBe("http://203.0.113.7:8080/");
98 off();
99 expect(seen).toEqual([
100 { name: "acme", address: "203.0.113.7", state: "failed", healthy: false },
101 { name: "acme", address: "203.0.113.7", state: "running", healthy: true },
102 ]);
103 // A subscriber who reads the row after the event sees the recovered state.
104 expect(getPinned(app.db, "acme")?.state).toBe("running");
105 });
106
107 test("the probe is audited like the other calls made for the account", () => {
108 const rows = app.db.query("SELECT target FROM cloud_audit WHERE purpose = 'pinned:probe'").all() as { target: string }[];
109 expect(rows.length).toBeGreaterThan(0);
110 expect(rows[0]!.target).toBe("203.0.113.7:8080/");
111 });
112
113 test("a refused read is returned, not turned into 'no address'; a read-only account may still check", async () => {
114 readStatus = 401;
115 const refused = await checkPinned(app, admin(), "acme");
116 expect(refused).toMatchObject({ ok: false });
117 expect(String((refused as any).error)).toContain("401");
118 readStatus = 200;
119 allowWrite(app, admin(), "digitalocean", "main", false);
120 expect(await checkPinned(app, admin(), "acme")).toMatchObject({ ok: true, value: { healthy: true } });
121 allowWrite(app, admin(), "digitalocean", "main", true);
122 });
123
124 test("the check is the admin's, and needs a droplet", async () => {
125 expect(await checkPinned(app, undefined, "acme")).toMatchObject({ ok: false });
126 expect(await checkPinned(app, getUserByName(app.db, "bob")!, "acme")).toMatchObject({ ok: false });
127 expect(await checkPinned(app, admin(), "nope")).toMatchObject({ ok: false, error: "No such pinned forge." });
128 const fresh = await deploy("bare");
129 expect(fresh.ok).toBe(true);
130 setRemote(app.db, "bare", { remote: "" });
131 expect(await checkPinned(app, admin(), "bare")).toMatchObject({ ok: false });
132 });
133
134 test("HTTP: admin 200, member 403 (as the sibling routes), unknown name 404, no droplet 422", async () => {
135 const h = (t: string) => ({ authorization: `Bearer ${t}`, "content-type": "application/json" });
136 const tok = createToken(app.db, admin().id, "admin", "admin").token;
137 const bob = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token;
138 const post = (name: string, t?: string) =>
139 fetch(`${servers.url}/api/v1/admin/pinned/${name}/check`, { method: "POST", headers: t ? h(t) : { "content-type": "application/json" }, body: "{}" });
140 const ok = await post("acme", tok);
141 expect(ok.status).toBe(200);
142 expect(await ok.json()).toMatchObject({ healthy: true, url: "http://203.0.113.7:8080/" });
143 expect((await post("acme", bob)).status).toBe(403);
144 expect((await post("acme")).status).toBeGreaterThanOrEqual(401);
145 expect((await post("nope", tok)).status).toBe(404);
146 expect((await post("bare", tok)).status).toBe(422);
147 });
148
149 test("MCP: the check verb says the url; a non-admin-scope token is refused", async () => {
150 const call = async (t: string, args: Record<string, unknown>) => {
151 const res = await fetch(`${servers.url}/mcp`, {
152 method: "POST",
153 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${t}` },
154 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "pinned_forge", arguments: args } }),
155 });
156 const text = ((await res.json()) as any).result.content[0].text as string;
157 return text.startsWith("{") ? JSON.parse(text) : { text };
158 };
159 const tok = createToken(app.db, admin().id, "admin", "admin").token;
160 const done = await call(tok, { verb: "check", name: "acme" });
161 expect(done.says).toBe("check acme: now " + "a".repeat(40) + ", running at http://203.0.113.7:8080/.");
162 expect(done.forge).toMatchObject({ healthy: true });
163 const ro = createToken(app.db, admin().id, "ro", "ro").token;
164 expect((await call(ro, { verb: "check", name: "acme" })).text ?? "").not.toContain("running at");
165 });
166
167 test("terminal: c checks the forge under the cursor and redraws; a member does nothing; the row shows the url", async () => {
168 const mk = (user: ReturnType<typeof admin>) => new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user });
169 let invalidated = 0;
170 const ctx = { push() {}, invalidate: () => invalidated++ } as never;
171 const key = (s: { key?: (e: never) => boolean }, k: string) => s.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never);
172 const before = probed.length;
173 expect(key(pinnedScreen(mk(getUserByName(app.db, "bob")!), ctx), "c")).toBeFalsy();
174 expect(probed.length).toBe(before);
175
176 const abort = new AbortController();
177 const screen = pinnedScreen(mk(admin()), ctx);
178 await screen.load!(abort.signal);
179 const n = invalidated;
180 expect(key(screen, "c")).toBe(true);
181 await new Promise((r) => setTimeout(r, 150));
182 expect(probed.length).toBe(before + 1);
183 expect(invalidated).toBeGreaterThan(n);
184 const list = pinnedForges(app, admin());
185 expect(list.ok && list.value[0]!.url).toBe("http://203.0.113.7:8080/");
186 abort.abort();
187 });
188 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs