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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8745-pinned-forge-read-the-droplet-address-ba → main 3 commits fork
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 | ||
| 6 | 6 | |
| 7 | 7 | - 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`. |
| 8 | 8 | - 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`). | |
| 10 | 10 | |
| 11 | 11 | 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). |
| 12 | 12 | |
| @@ -36,6 +36,10 @@ The cloud facts for Cloudflare and Vercel are from memory of their documentation | ||
| 36 | 36 | |
| 37 | 37 | 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. |
| 38 | 38 | |
| 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 | ||
| 39 | 43 | ## Failing half-way |
| 40 | 44 | |
| 41 | 45 | 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 | ||
| 44 | 48 | |
| 45 | 49 | - No canary on a real account: #8746. |
| 46 | 50 | - 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. | |
| 48 | 53 | - AWS and Azure drivers. |
src/actions/pinned.ts +52 −8
| @@ -11,30 +11,32 @@ | ||
| 11 | 11 | |
| 12 | 12 | import type { App } from "../app.ts"; |
| 13 | 13 | import { gatedFetch } from "../cloud/gate.ts"; |
| 14 | import { provision, replace } from "../cloud/droplet.ts"; | |
| 14 | import { provision, readDroplet, replace } from "../cloud/droplet.ts"; | |
| 15 | 15 | import type { Fetch } from "../cloud/providers.ts"; |
| 16 | 16 | import { target as targetOf } from "../cloud/targets.ts"; |
| 17 | 17 | import { getCloudAccount, secretNameOf } from "../core/cloud_account.ts"; |
| 18 | 18 | import { |
| 19 | 19 | 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, | |
| 21 | 21 | } from "../core/pinned.ts"; |
| 22 | 22 | import { resolveSecrets } from "../core/secret_store.ts"; |
| 23 | 23 | import type { User } from "../core/users.ts"; |
| 24 | 24 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 25 | 25 | |
| 26 | 26 | /** 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) }; | |
| 28 | 28 | |
| 29 | 29 | const admin = (a: User | undefined): Result<User, string> => (a?.isAdmin ? ok(a) : err("not_found")); |
| 30 | 30 | |
| 31 | 31 | export interface PinnedView extends Pinned { |
| 32 | /** Where it is served, once the droplet's address is read; empty before. */ | |
| 33 | readonly url: string; | |
| 32 | 34 | readonly crew: false; |
| 33 | 35 | readonly autoRelease: false; |
| 34 | 36 | readonly changes: Change[]; |
| 35 | 37 | } |
| 36 | 38 | |
| 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) }); | |
| 38 | 40 | |
| 39 | 41 | export function pinnedForges(app: App, actor: User | undefined): Result<PinnedView[], string> { |
| 40 | 42 | const who = admin(actor); |
| @@ -42,10 +44,10 @@ export function pinnedForges(app: App, actor: User | undefined): Result<PinnedVi | ||
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | /** 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>> { | |
| 46 | 48 | const account = getCloudAccount(app.db, p.target, p.account); |
| 47 | 49 | 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."); | |
| 49 | 51 | const secret = secretNameOf(account.provider, account.name); |
| 50 | 52 | const token = resolveSecrets(app.db, app.config.dataDir, app.bus, [secret], { actor: who.id, run: `pinned:${purpose}` })[secret]; |
| 51 | 53 | 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: | ||
| 56 | 58 | } |
| 57 | 59 | } |
| 58 | 60 | |
| 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 | ||
| 59 | 71 | export async function deployPinned( |
| 60 | 72 | app: App, |
| 61 | 73 | actor: User | undefined, |
| @@ -116,12 +128,12 @@ async function change( | ||
| 116 | 128 | app.bus.append({ kind: kind === "update" ? "pinned.update" : "pinned.rollback", actorId: who.value.id, payload: { name, from: c.fromVersion, to: c.toVersion } }); |
| 117 | 129 | } |
| 118 | 130 | 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: "" }) })); | |
| 120 | 132 | if (!done.ok) { |
| 121 | 133 | setRemote(app.db, name, { state: "failed" }); |
| 122 | 134 | return done; |
| 123 | 135 | } |
| 124 | setRemote(app.db, name, { ...done.value, state: "running" }); | |
| 136 | setRemote(app.db, name, { ...done.value, address: "", state: "running" }); | |
| 125 | 137 | return ok(view(app, getPinned(app.db, name)!)); |
| 126 | 138 | } |
| 127 | 139 | |
| @@ -145,3 +157,35 @@ export const rollbackPinned = (app: App, actor: User | undefined, name: string): | ||
| 145 | 157 | const prev = previousOf(app.db, p); |
| 146 | 158 | return prev ? ok(prev) : err("There is no previous version: this forge has only ever run one."); |
| 147 | 159 | }); |
| 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 @@ | ||
| 6 | 6 | * Authorization header over HTTPS, never on argv, and the boot script holds no |
| 7 | 7 | * secret at all: the new forge's first visitor claims it at /first-admin. |
| 8 | 8 | * |
| 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). | |
| 10 | 11 | * The caller records each id the moment it exists (`onVolume`, `onDeleted`), |
| 11 | 12 | * so a failure half-way leaves a row a retry can finish, never an orphan. |
| 12 | 13 | */ |
| @@ -121,3 +122,14 @@ export async function replace(f: Fetch, token: string, p: Pinned, hooks: { onDel | ||
| 121 | 122 | const d = await createDroplet(f, token, p, p.volume); |
| 122 | 123 | return { remote: String(d.droplet?.id ?? "") }; |
| 123 | 124 | } |
| 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 | ||
| 195 | 195 | export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts"; |
| 196 | 196 | export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts"; |
| 197 | 197 | export { route as postApiV1AdminPinned } from "./postApiV1AdminPinned.ts"; |
| 198 | export { route as postApiV1AdminPinnedNameCheck } from "./postApiV1AdminPinnedNameCheck.ts"; | |
| 198 | 199 | export { route as postApiV1AdminPinnedNameRollback } from "./postApiV1AdminPinnedNameRollback.ts"; |
| 199 | 200 | export { route as postApiV1AdminPinnedNameUpdate } from "./postApiV1AdminPinnedNameUpdate.ts"; |
| 200 | 201 | 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 { | ||
| 20 | 20 | readonly config: Record<string, string>; |
| 21 | 21 | readonly remote: string; |
| 22 | 22 | readonly volume: string; |
| 23 | readonly address: string; | |
| 23 | 24 | readonly state: string; |
| 24 | 25 | readonly createdAt: string; |
| 25 | 26 | } |
| @@ -71,13 +72,13 @@ export function checkConfig(raw: unknown): Result<Record<string, string>, string | ||
| 71 | 72 | |
| 72 | 73 | interface Row { |
| 73 | 74 | 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; | |
| 75 | 76 | } |
| 76 | 77 | |
| 77 | 78 | const toPinned = (r: Row): Pinned => ({ |
| 78 | 79 | id: r.id, name: r.name, target: r.target, account: r.account, app: r.app, source: r.source, |
| 79 | 80 | 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, | |
| 81 | 82 | }); |
| 82 | 83 | |
| 83 | 84 | export const listPinned = (db: Database): Pinned[] => |
| @@ -154,12 +155,21 @@ export function previousOf(db: Database, forge: Pinned): { version: string; conf | ||
| 154 | 155 | } |
| 155 | 156 | |
| 156 | 157 | /** 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); | |
| 160 | 161 | } |
| 161 | 162 | |
| 162 | 163 | /** The row of a deploy that never reached a droplet, so its name can be tried again; its recorded change stays as history. */ |
| 163 | 164 | export function dropPinned(db: Database, name: string): void { |
| 164 | 165 | db.query("DELETE FROM pinned_forge WHERE name = ?1 AND remote = '' AND state = 'failed'").run(name); |
| 165 | 166 | } |
| 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"; | ||
| 14 | 14 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts"; |
| 15 | 15 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| 16 | 16 | 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"; | |
| 18 | 18 | import { TARGETS } from "../../cloud/targets.ts"; |
| 19 | 19 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; |
| 20 | 20 | import { setRole } from "../../actions/roles.ts"; |
| @@ -72,6 +72,13 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 72 | 72 | return done.ok ? json(done.value) : pinnedFail(done.error); |
| 73 | 73 | }); |
| 74 | 74 | |
| 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 | ||
| 75 | 82 | router.add("POST", "/api/v1/admin/pinned/:name/rollback", async (ctx) => { |
| 76 | 83 | const denied = accountAdmin(ctx, "admin"); |
| 77 | 84 | 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"; | |
| 2 | 2 | import { TARGETS } from "../../../cloud/targets.ts"; |
| 3 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 4 | 4 | import { good, bad, type Tool, str } from "../shared.ts"; |
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | 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).", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
| 13 | verb: { type: "string", enum: ["list", "deploy", "update", "rollback"] }, | |
| 13 | verb: { type: "string", enum: ["list", "deploy", "update", "rollback", "check"] }, | |
| 14 | 14 | name: { type: "string", maxLength: 40 }, |
| 15 | 15 | target: { type: "string", enum: TARGETS.map((t) => t.id) }, |
| 16 | 16 | account: { type: "string", maxLength: 32 }, |
| @@ -46,9 +46,11 @@ export const tool: Tool = { | ||
| 46 | 46 | }) |
| 47 | 47 | : verb === "rollback" |
| 48 | 48 | ? 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"); | |
| 51 | 53 | 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 }); | |
| 53 | 55 | }, |
| 54 | 56 | }; |
src/tui/screens/pinned.ts +10 −6
| @@ -2,12 +2,13 @@ | ||
| 2 | 2 | * Pinned forges in the terminal, a forge admin's (#8697): each forge with its |
| 3 | 3 | * version, state and last changes, as the API lists them. `d` deploys, `u` |
| 4 | 4 | * 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. | |
| 7 | 8 | * Anyone else sees nothing and does nothing. |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | import { deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts"; | |
| 11 | import { checkPinned, deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts"; | |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 13 | import { binding } from "../app/keys.ts"; |
| 13 | 14 | import { style } from "../term/style.ts"; |
| @@ -52,9 +53,9 @@ export function pinnedScreen(f: Session, ctx: Ctx): Screen { | ||
| 52 | 53 | |
| 53 | 54 | const screen: Screen = { |
| 54 | 55 | 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")], | |
| 56 | 57 | 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()); | |
| 58 | 59 | }, |
| 59 | 60 | render(area, s) { |
| 60 | 61 | const c = ctx.theme.c; |
| @@ -66,7 +67,7 @@ export function pinnedScreen(f: Session, ctx: Ctx): Screen { | ||
| 66 | 67 | const rows: ListRow[] = forges().map((p) => ({ |
| 67 | 68 | title: p.name, |
| 68 | 69 | ...(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}` : ""}`, | |
| 70 | 71 | meta: p.changes[0] ? `${p.changes[0].kind} ${p.changes[0].at.slice(0, 16).replace("T", " ")}` : "", |
| 71 | 72 | })); |
| 72 | 73 | drawList(s, list!, c, state, rows); |
| @@ -83,6 +84,9 @@ export function pinnedScreen(f: Session, ctx: Ctx): Screen { | ||
| 83 | 84 | if (e.key === "r") { |
| 84 | 85 | say(`Rolling ${p.name} back…`); |
| 85 | 86 | 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)); | |
| 86 | 90 | } else if (e.key === "u") |
| 87 | 91 | ctx.push( |
| 88 | 92 | 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 | }); |
new above
offline