Public: anyone can read this; the crew can change it.
A pinned forge: one action puts a forge on my cloud, and it changes only when I say #8705
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8697-a-pinned-forge-one-action-puts-a-forge-o → main 4 commits fork
24 files
changed +1250
−1
docs/features/pinned-forge.md added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | # A pinned forge (#8697) | |
| 2 | ||
| 3 | A pinned forge is a forge the owner put on their own cloud. It has **no crew** and **no automatic release**. Its version and config change only through one explicit `Update`, recorded with who and when; `Rollback` is one action to the previous version. | |
| 4 | ||
| 5 | ## Doors (forge admin, admin-scope token) | |
| 6 | ||
| 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 | - 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`). | |
| 10 | ||
| 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 | ||
| 13 | ## Why it cannot move on its own | |
| 14 | ||
| 15 | `pinned_forge.version` and `.config` are guarded by a SQLite trigger (migration `20261103090000-pinned-forge`): an UPDATE passes only with a `change_id` that points at a newer `update` or `rollback` row in `pinned_change` whose target is exactly the new version and config. `pinned_change` cannot be updated or deleted. `crew` and `auto_release` are CHECKed to 0. `test/pinned-forge.test.ts` tries each other way and fails if one works. A pinned forge is a row and a droplet, not a repository on this forge, so the crew, the release keeper and upstream following have nothing of it to touch. | |
| 16 | ||
| 17 | ## Credentials | |
| 18 | ||
| 19 | The cloud token stays sealed. It is opened for the one call that needs it (a `secret.read` event names the run), sent as an `Authorization` header over HTTPS, and never reaches argv, a row, an event, a request body or the boot script. The boot script holds no secret: the new forge's first visitor claims it. Config refuses names that look like secrets. | |
| 20 | ||
| 21 | ## Targets: what runs, what it costs, what is refused | |
| 22 | ||
| 23 | Prices are list prices as last read (USD a month); the owner's own bill is the truth. Source of truth in code: `src/cloud/targets.ts`. | |
| 24 | ||
| 25 | | Target | Status | What runs | Cost | Refused / caveat | | |
| 26 | |---|---|---|---|---| | |
| 27 | | DigitalOcean | **deploys today** | One droplet (`s-1vcpu-2gb`, Ubuntu 24.04) with a 10 GiB block volume as `FORGE_APP_DATA`. An Update deletes the droplet and starts a new one on the same volume, so data is untouched. | ~$12 + ~$1.20 | A token cannot be narrowed below "create droplets and volumes". | | |
| 28 | | AWS | not written | EC2 `t4g.small` + EBS gp3, same boot script; SigV4 already exists for reads. | ~$12 + ~$0.80 + public IPv4 ~$3.65 | Wants a role scoped to a tag rather than a long-lived key. | | |
| 29 | | Azure | not written | B1ms VM + managed disk through a service principal on one resource group. | ~$15 + ~$1 + public IP ~$3.65 | Needs a resource group, network and public IP first: three calls more. | | |
| 30 | | Cloudflare | **refused** | Workers/Durable Objects keep state, but not as files a Bun process opens. Containers run, with a disk wiped on sleep or replace. | Workers Paid from $5 | No volume that survives; the forge refuses rather than lose repositories quietly. | | |
| 31 | | Vercel | **refused** | Functions and Sandboxes: short-lived, scratch disk. | Pro from $20/seat | No long-running process, no persistent disk. Vercel can host an app a forge ships, not the forge. | | |
| 32 | ||
| 33 | The cloud facts for Cloudflare and Vercel are from memory of their documentation, last written 2026-11-02; check them before relying on a refusal for a decision beyond this forge. | |
| 34 | ||
| 35 | ## Prior art | |
| 36 | ||
| 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 | ||
| 39 | ## Failing half-way | |
| 40 | ||
| 41 | 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. | |
| 42 | ||
| 43 | ## Not done | |
| 44 | ||
| 45 | - No canary on a real account: #8746. | |
| 46 | - 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. | |
| 48 | - AWS and Azure drivers. |
src/actions/pinned.ts added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | /** | |
| 2 | * Pinned forges, as a forge admin reaches them (#8697): `deploy` puts one on | |
| 3 | * the owner's cloud, `update` is the only way its version or config change, | |
| 4 | * `rollback` is one action back to the previous version. All three are the | |
| 5 | * admin's, need the cloud account's writes switched on, and open the sealed | |
| 6 | * token for the one call that needs it (a `secret.read` event names it). Each | |
| 7 | * change is recorded with who and when before the cloud is touched; if the | |
| 8 | * cloud refuses, the record stays and the forge says `failed`, so nothing | |
| 9 | * moves without a line saying so. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { App } from "../app.ts"; | |
| 13 | import { gatedFetch } from "../cloud/gate.ts"; | |
| 14 | import { provision, replace } from "../cloud/droplet.ts"; | |
| 15 | import type { Fetch } from "../cloud/providers.ts"; | |
| 16 | import { target as targetOf } from "../cloud/targets.ts"; | |
| 17 | import { getCloudAccount, secretNameOf } from "../core/cloud_account.ts"; | |
| 18 | import { | |
| 19 | APP, NAME, changesOf, checkConfig, getPinned, insertPinned, listPinned, previousOf, | |
| 20 | dropPinned, recordChange, setRemote, validSource, validVersion, type Change, type Pinned, | |
| 21 | } from "../core/pinned.ts"; | |
| 22 | import { resolveSecrets } from "../core/secret_store.ts"; | |
| 23 | import type { User } from "../core/users.ts"; | |
| 24 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 25 | ||
| 26 | /** Swapped in tests; the real forge always uses `fetch`. */ | |
| 27 | export const net: { fetch: Fetch } = { fetch: (u, i) => fetch(u, i) }; | |
| 28 | ||
| 29 | const admin = (a: User | undefined): Result<User, string> => (a?.isAdmin ? ok(a) : err("not_found")); | |
| 30 | ||
| 31 | export interface PinnedView extends Pinned { | |
| 32 | readonly crew: false; | |
| 33 | readonly autoRelease: false; | |
| 34 | readonly changes: Change[]; | |
| 35 | } | |
| 36 | ||
| 37 | const view = (app: App, p: Pinned): PinnedView => ({ ...p, crew: false, autoRelease: false, changes: changesOf(app.db, p.id) }); | |
| 38 | ||
| 39 | export function pinnedForges(app: App, actor: User | undefined): Result<PinnedView[], string> { | |
| 40 | const who = admin(actor); | |
| 41 | return who.ok ? ok(listPinned(app.db).map((p) => view(app, p))) : who; | |
| 42 | } | |
| 43 | ||
| 44 | /** 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>> { | |
| 46 | const account = getCloudAccount(app.db, p.target, p.account); | |
| 47 | 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."); | |
| 49 | const secret = secretNameOf(account.provider, account.name); | |
| 50 | const token = resolveSecrets(app.db, app.config.dataDir, app.bus, [secret], { actor: who.id, run: `pinned:${purpose}` })[secret]; | |
| 51 | if (token === undefined) return err("No such credential."); | |
| 52 | try { | |
| 53 | return ok(await go(gatedFetch(app.db, { provider: p.target, account: p.account }, `pinned:${purpose}`, net.fetch), token)); | |
| 54 | } catch (e) { | |
| 55 | return err(`The cloud refused: ${e instanceof Error ? e.message.slice(0, 200) : "failed"}`); | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | export async function deployPinned( | |
| 60 | app: App, | |
| 61 | actor: User | undefined, | |
| 62 | input: { name: string; target: string; account: string; app: string; source: string; version: string; config?: unknown }, | |
| 63 | ): Promise<Result<PinnedView, string>> { | |
| 64 | const who = admin(actor); | |
| 65 | if (!who.ok) return who; | |
| 66 | const t = targetOf(input.target); | |
| 67 | if (!t) return err("That is not a target the forge knows."); | |
| 68 | if (t.status === "refused") return err(t.refused); | |
| 69 | if (t.status !== "first") return err(`${t.label} is not written yet: ${t.refused}`); | |
| 70 | if (!NAME.test(input.name)) return err("A name is lowercase letters, digits and dashes, starting with a letter."); | |
| 71 | if (!APP.test(input.app)) return err("Name the app as owner/name."); | |
| 72 | if (!validSource(input.source)) return err("The source is an https git address."); | |
| 73 | if (!validVersion(input.version)) return err("A version is a full 40-character commit id or a tag like v1.2.3."); | |
| 74 | const config = checkConfig(input.config); | |
| 75 | if (!config.ok) return config; | |
| 76 | // A first deploy that failed before any droplet exists may be tried again: its row goes, | |
| 77 | // its volume (if one was made) is kept for the retry, and the new deploy is recorded afresh. | |
| 78 | const old = getPinned(app.db, input.name); | |
| 79 | if (old && !(old.state === "failed" && old.remote === "" && old.target === t.id)) return err("A pinned forge has that name already."); | |
| 80 | if (old) dropPinned(app.db, old.name); | |
| 81 | const p = insertPinned(app.db, { ...input, target: t.id, config: config.value }, who.value.id); | |
| 82 | app.bus.append({ kind: "pinned.deployed", actorId: who.value.id, payload: { name: p.name, target: p.target, version: p.version } }); | |
| 83 | if (old?.volume) setRemote(app.db, p.name, { volume: old.volume }); | |
| 84 | const made = await withCloud(app, who.value, p, "deploy", (f, token) => | |
| 85 | provision(f, token, getPinned(app.db, p.name)!, { | |
| 86 | ...(old?.volume ? { have: old.volume } : {}), | |
| 87 | onVolume: (volume) => setRemote(app.db, p.name, { volume }), | |
| 88 | }), | |
| 89 | ); | |
| 90 | if (!made.ok) { | |
| 91 | setRemote(app.db, p.name, { state: "failed" }); | |
| 92 | return made; | |
| 93 | } | |
| 94 | setRemote(app.db, p.name, { ...made.value, state: "running" }); | |
| 95 | return ok(view(app, getPinned(app.db, p.name)!)); | |
| 96 | } | |
| 97 | ||
| 98 | async function change( | |
| 99 | app: App, | |
| 100 | actor: User | undefined, | |
| 101 | name: string, | |
| 102 | kind: "update" | "rollback", | |
| 103 | next: (p: Pinned) => Result<{ version: string; config: Record<string, string> }, string>, | |
| 104 | ): Promise<Result<PinnedView, string>> { | |
| 105 | const who = admin(actor); | |
| 106 | if (!who.ok) return who; | |
| 107 | const before = getPinned(app.db, name); | |
| 108 | if (!before) return err("No such pinned forge."); | |
| 109 | const to = next(before); | |
| 110 | if (!to.ok) return to; | |
| 111 | const same = to.value.version === before.version && JSON.stringify(to.value.config) === JSON.stringify(before.config); | |
| 112 | // A failed forge may be asked again for what it already says, to finish the replacement; nothing new to record. | |
| 113 | if (same && before.state !== "failed") return err("That is what it runs already."); | |
| 114 | if (!same) { | |
| 115 | const c = recordChange(app.db, before, kind, to.value, who.value.id); | |
| 116 | app.bus.append({ kind: kind === "update" ? "pinned.update" : "pinned.rollback", actorId: who.value.id, payload: { name, from: c.fromVersion, to: c.toVersion } }); | |
| 117 | } | |
| 118 | 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: "" }) })); | |
| 120 | if (!done.ok) { | |
| 121 | setRemote(app.db, name, { state: "failed" }); | |
| 122 | return done; | |
| 123 | } | |
| 124 | setRemote(app.db, name, { ...done.value, state: "running" }); | |
| 125 | return ok(view(app, getPinned(app.db, name)!)); | |
| 126 | } | |
| 127 | ||
| 128 | /** The only door to a new version or config. Either may be left as it is. */ | |
| 129 | export const updatePinned = ( | |
| 130 | app: App, | |
| 131 | actor: User | undefined, | |
| 132 | name: string, | |
| 133 | input: { version?: string | undefined; config?: unknown }, | |
| 134 | ): Promise<Result<PinnedView, string>> => | |
| 135 | change(app, actor, name, "update", (p) => { | |
| 136 | const version = input.version ?? p.version; | |
| 137 | if (!validVersion(version)) return err("A version is a full 40-character commit id or a tag like v1.2.3."); | |
| 138 | const config = input.config === undefined ? ok(p.config) : checkConfig(input.config); | |
| 139 | return config.ok ? ok({ version, config: config.value }) : config; | |
| 140 | }); | |
| 141 | ||
| 142 | /** One action back to what the latest change replaced. */ | |
| 143 | export const rollbackPinned = (app: App, actor: User | undefined, name: string): Promise<Result<PinnedView, string>> => | |
| 144 | change(app, actor, name, "rollback", (p) => { | |
| 145 | const prev = previousOf(app.db, p); | |
| 146 | return prev ? ok(prev) : err("There is no previous version: this forge has only ever run one."); | |
| 147 | }); |
src/cloud/droplet.ts added +123
| @@ -0,0 +1,123 @@ | ||
| 1 | /** | |
| 2 | * DigitalOcean, the first target (#8697): the requests that put a pinned | |
| 3 | * forge on a droplet with a volume, and replace the droplet on an explicit | |
| 4 | * Update while the volume — the forge's data — stays. Plain data in, plain | |
| 5 | * data out: the caller owns the token and the fetch. The token travels in an | |
| 6 | * Authorization header over HTTPS, never on argv, and the boot script holds no | |
| 7 | * secret at all: the new forge's first visitor claims it at /first-admin. | |
| 8 | * | |
| 9 | * Calls (DigitalOcean API v2): POST /volumes, POST /droplets, DELETE /droplets/{id}. | |
| 10 | * The caller records each id the moment it exists (`onVolume`, `onDeleted`), | |
| 11 | * so a failure half-way leaves a row a retry can finish, never an orphan. | |
| 12 | */ | |
| 13 | ||
| 14 | import type { Fetch } from "./providers.ts"; | |
| 15 | import type { Pinned } from "../core/pinned.ts"; | |
| 16 | ||
| 17 | const API = "https://api.digitalocean.com/v2"; | |
| 18 | export const REGION = "nyc3"; | |
| 19 | export const SIZE = "s-1vcpu-2gb"; | |
| 20 | /** The Bun the droplet installs: it moves only when this line does, in a pull, so the runtime is pinned as the forge is. */ | |
| 21 | export const BUN_VERSION = "1.3.14"; | |
| 22 | ||
| 23 | /** cloud-config for the droplet: Bun, the pinned source at the pinned version, a systemd unit. */ | |
| 24 | export function bootConfig(p: Pick<Pinned, "name" | "source" | "version" | "config">): string { | |
| 25 | // DigitalOcean mounts a volume at /mnt/<its name, dashes as underscores>. | |
| 26 | const data = `/mnt/${volumeName(p.name).replace(/-/g, "_")}`; | |
| 27 | const env = Object.entries({ ...p.config, FORGE_APP_DATA: data }) | |
| 28 | .map(([k, v]) => `${k}=${v}`) | |
| 29 | .join("\n"); | |
| 30 | // Every interpolated value is validated in core/pinned.ts (version, source, config) or by NAME. | |
| 31 | return `#cloud-config | |
| 32 | write_files: | |
| 33 | - path: /etc/forge/env | |
| 34 | permissions: "0600" | |
| 35 | content: ${JSON.stringify(env + "\n")} | |
| 36 | - path: /etc/systemd/system/forge.service | |
| 37 | content: | | |
| 38 | [Unit] | |
| 39 | Description=forge ${p.name} pinned at ${p.version} | |
| 40 | After=network-online.target | |
| 41 | [Service] | |
| 42 | EnvironmentFile=/etc/forge/env | |
| 43 | WorkingDirectory=/opt/forge | |
| 44 | ExecStart=/root/.bun/bin/bun run src/main.ts | |
| 45 | Restart=on-failure | |
| 46 | [Install] | |
| 47 | WantedBy=multi-user.target | |
| 48 | runcmd: | |
| 49 | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" | |
| 50 | - git clone ${p.source} /opt/forge | |
| 51 | - git -C /opt/forge checkout --detach ${p.version} | |
| 52 | - systemctl enable --now forge.service | |
| 53 | `; | |
| 54 | } | |
| 55 | ||
| 56 | const headers = (token: string) => ({ authorization: `Bearer ${token}`, "content-type": "application/json" }); | |
| 57 | ||
| 58 | async function call(f: Fetch, token: string, method: string, path: string, body?: unknown): Promise<any> { | |
| 59 | const res = await f(`${API}${path}`, { | |
| 60 | method, | |
| 61 | headers: headers(token), | |
| 62 | ...(body === undefined ? {} : { body: JSON.stringify(body) }), | |
| 63 | signal: AbortSignal.timeout(20_000), | |
| 64 | }); | |
| 65 | if (!res.ok) throw new Error(`api.digitalocean.com answered ${res.status}`); | |
| 66 | return res.status === 204 ? {} : await res.json(); | |
| 67 | } | |
| 68 | ||
| 69 | const volumeName = (name: string) => `forge-${name}-data`; | |
| 70 | ||
| 71 | function createDroplet(f: Fetch, token: string, p: Pinned, volumeId: string) { | |
| 72 | return call(f, token, "POST", "/droplets", { | |
| 73 | name: `forge-${p.name}`, | |
| 74 | region: REGION, | |
| 75 | size: SIZE, | |
| 76 | image: "ubuntu-24-04-x64", | |
| 77 | volumes: [volumeId], | |
| 78 | user_data: bootConfig(p), | |
| 79 | tags: ["forge-pinned"], | |
| 80 | }); | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Volume first, then the droplet that mounts it. A volume a failed earlier try | |
| 85 | * made (`have`) is reused; a new one is recorded through `onVolume` before the | |
| 86 | * droplet is asked for. Returns the ids to keep. | |
| 87 | */ | |
| 88 | export async function provision( | |
| 89 | f: Fetch, token: string, p: Pinned, hooks: { have?: string; onVolume(id: string): void }, | |
| 90 | ): Promise<{ remote: string; volume: string }> { | |
| 91 | let volume = hooks.have ?? ""; | |
| 92 | if (volume === "") { | |
| 93 | const v = await call(f, token, "POST", "/volumes", { | |
| 94 | name: volumeName(p.name), region: REGION, size_gigabytes: 10, filesystem_type: "ext4", | |
| 95 | }); | |
| 96 | volume = String(v.volume?.id ?? ""); | |
| 97 | if (!volume) throw new Error("DigitalOcean made no volume"); | |
| 98 | hooks.onVolume(volume); | |
| 99 | } | |
| 100 | const d = await createDroplet(f, token, p, volume); | |
| 101 | return { remote: String(d.droplet?.id ?? ""), volume }; | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * An explicit Update or rollback: delete the old droplet, start a new one on | |
| 106 | * the same volume. A droplet already gone (404, or none recorded after a failed | |
| 107 | * create) counts as deleted, and `onDeleted` clears the recorded id at once, so | |
| 108 | * a retry after a failed create does not delete twice. | |
| 109 | */ | |
| 110 | export async function replace(f: Fetch, token: string, p: Pinned, hooks: { onDeleted(): void }): Promise<{ remote: string }> { | |
| 111 | if (p.volume === "") throw new Error("this forge has no volume to keep"); | |
| 112 | if (p.remote !== "") { | |
| 113 | if (!/^\d+$/.test(p.remote)) throw new Error("this forge has no droplet to replace"); | |
| 114 | try { | |
| 115 | await call(f, token, "DELETE", `/droplets/${p.remote}`); | |
| 116 | } catch (e) { | |
| 117 | if (!(e instanceof Error && e.message.endsWith("answered 404"))) throw e; | |
| 118 | } | |
| 119 | hooks.onDeleted(); | |
| 120 | } | |
| 121 | const d = await createDroplet(f, token, p, p.volume); | |
| 122 | return { remote: String(d.droplet?.id ?? "") }; | |
| 123 | } |
src/cloud/targets.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** | |
| 2 | * Where a forge can be put, and what each place can and cannot hold (#8697). | |
| 3 | * A forge is a Bun process, SQLite and a disk that outlives the process, so a | |
| 4 | * target needs a machine and a volume. Prices are list prices for the | |
| 5 | * smallest size that runs a forge, USD a month, as last read; the page says | |
| 6 | * so, and the owner's own bill is the truth. | |
| 7 | */ | |
| 8 | ||
| 9 | export type TargetId = "digitalocean" | "aws" | "azure" | "cloudflare" | "vercel"; | |
| 10 | ||
| 11 | export interface Target { | |
| 12 | readonly id: TargetId; | |
| 13 | readonly label: string; | |
| 14 | /** `first` deploys today; `planned` has a machine and a volume but no driver yet; `refused` cannot hold a forge. */ | |
| 15 | readonly status: "first" | "planned" | "refused"; | |
| 16 | readonly runs: string; | |
| 17 | readonly cost: string; | |
| 18 | readonly refused: string; | |
| 19 | } | |
| 20 | ||
| 21 | export const TARGETS: readonly Target[] = [ | |
| 22 | { | |
| 23 | id: "digitalocean", | |
| 24 | label: "DigitalOcean", | |
| 25 | status: "first", | |
| 26 | runs: "One droplet (Ubuntu, Bun from the pinned source) with one block volume mounted as FORGE_APP_DATA. Replacing the droplet keeps the volume, so a version change never touches the data.", | |
| 27 | cost: "about $12 (s-1vcpu-2gb) + $1.20 (10 GiB volume)", | |
| 28 | refused: "Nothing the forge needs is refused; the token must be able to create droplets and volumes, which DigitalOcean cannot narrow further.", | |
| 29 | }, | |
| 30 | { | |
| 31 | id: "aws", | |
| 32 | label: "AWS", | |
| 33 | status: "planned", | |
| 34 | runs: "One EC2 instance (t4g.small) with one EBS gp3 volume, same boot script. Needs SigV4 signing for RunInstances, which providers.ts already does for reads.", | |
| 35 | cost: "about $12 + $0.80 (10 GiB gp3) + a public IPv4 address, about $3.65", | |
| 36 | refused: "A long-lived access key with EC2 write is the broadest credential of the three; the driver will ask for a role scoped to one tag, and is not written yet.", | |
| 37 | }, | |
| 38 | { | |
| 39 | id: "azure", | |
| 40 | label: "Azure", | |
| 41 | status: "planned", | |
| 42 | runs: "One VM (B1ms) with one managed disk, through a service principal with Contributor on one resource group.", | |
| 43 | cost: "about $15 + $1 (10 GiB standard SSD) + a public IP, about $3.65", | |
| 44 | refused: "Not written yet: it needs a resource group, a network and a public IP created first, so it is three calls more than the droplet.", | |
| 45 | }, | |
| 46 | { | |
| 47 | id: "cloudflare", | |
| 48 | label: "Cloudflare", | |
| 49 | status: "refused", | |
| 50 | runs: "Workers and Durable Objects hold state, but not as a file system a Bun process can open. Cloudflare Containers run a container, with a disk that is wiped when the container sleeps or is replaced.", | |
| 51 | cost: "Workers Paid, $5 a month minimum, plus container time", | |
| 52 | refused: "A forge keeps bare git repositories and SQLite on a disk that survives restarts. Cloudflare offers no such volume to a container, so the forge refuses to put one there rather than lose data quietly.", | |
| 53 | }, | |
| 54 | { | |
| 55 | id: "vercel", | |
| 56 | label: "Vercel", | |
| 57 | status: "refused", | |
| 58 | runs: "Functions and Sandboxes: short-lived, one request or one task, with a scratch disk that does not persist.", | |
| 59 | cost: "Pro from $20 a seat a month", | |
| 60 | refused: "No long-running process and no persistent disk, so a forge cannot live there. Vercel can host an app a forge ships, which is a different thing.", | |
| 61 | }, | |
| 62 | ]; | |
| 63 | ||
| 64 | export const target = (id: string): Target | undefined => TARGETS.find((t) => t.id === id); |
src/contract/openapi/routes/getApiV1AdminPinned.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/admin/pinned", | |
| 6 | summary: "The forge's pinned forges with each one's recorded changes, and the targets with what each can and cannot host; a forge administrator's", | |
| 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/contract/openapi/routes/index.ts +4
| @@ -35,6 +35,7 @@ export { route as getApiV1AdminDrills } from "./getApiV1AdminDrills.ts"; | ||
| 35 | 35 | export { route as getApiV1AdminInvites } from "./getApiV1AdminInvites.ts"; |
| 36 | 36 | export { route as getApiV1AdminLimits } from "./getApiV1AdminLimits.ts"; |
| 37 | 37 | export { route as getApiV1AdminPeople } from "./getApiV1AdminPeople.ts"; |
| 38 | export { route as getApiV1AdminPinned } from "./getApiV1AdminPinned.ts"; | |
| 38 | 39 | export { route as getApiV1AdminProfile } from "./getApiV1AdminProfile.ts"; |
| 39 | 40 | export { route as getApiV1AdminQuotas } from "./getApiV1AdminQuotas.ts"; |
| 40 | 41 | export { route as getApiV1AdminSettings } from "./getApiV1AdminSettings.ts"; |
| @@ -193,6 +194,9 @@ export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1Admin | ||
| 193 | 194 | export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1AdminAccountsUsernameUnsuspend.ts"; |
| 194 | 195 | export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts"; |
| 195 | 196 | export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts"; |
| 197 | export { route as postApiV1AdminPinned } from "./postApiV1AdminPinned.ts"; | |
| 198 | export { route as postApiV1AdminPinnedNameRollback } from "./postApiV1AdminPinnedNameRollback.ts"; | |
| 199 | export { route as postApiV1AdminPinnedNameUpdate } from "./postApiV1AdminPinnedNameUpdate.ts"; | |
| 196 | 200 | export { route as postApiV1AdminReleasesShaRollback } from "./postApiV1AdminReleasesShaRollback.ts"; |
| 197 | 201 | export { route as postApiV1Agents } from "./postApiV1Agents.ts"; |
| 198 | 202 | export { route as postApiV1Apps } from "./postApiV1Apps.ts"; |
src/contract/openapi/routes/postApiV1AdminPinned.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: "/api/v1/admin/pinned", | |
| 6 | summary: "Put a pinned forge on the owner's cloud: no crew, no automatic release; needs an admin-scope token and a cloud account with writes allowed", | |
| 7 | auth: true, | |
| 8 | status: 201, | |
| 9 | body: { name: "string", target: "string", account: "string", app: "string", source: "string", version: "string", config: "object" }, | |
| 10 | refusals: { | |
| 11 | not_found: "only a forge administrator sees this route", | |
| 12 | refused: "the request was understood and refused; the message says why (422)", | |
| 13 | }, | |
| 14 | }; |
src/contract/openapi/routes/postApiV1AdminPinnedNameRollback.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}/rollback", | |
| 6 | summary: "One action back to the version the latest change replaced; recorded like any change", | |
| 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/contract/openapi/routes/postApiV1AdminPinnedNameUpdate.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: "/api/v1/admin/pinned/{name}/update", | |
| 6 | summary: "The only way a pinned forge changes version or config; recorded with who and when", | |
| 7 | auth: true, | |
| 8 | body: { version: "string", config: "object" }, | |
| 9 | refusals: { | |
| 10 | not_found: "only a forge administrator sees this route", | |
| 11 | refused: "the request was understood and refused; the message says why (422)", | |
| 12 | }, | |
| 13 | }; |
src/core/event_audience.ts +2
| @@ -176,6 +176,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 176 | 176 | kind === "forge.upstream" || |
| 177 | 177 | kind === "forge.updates_taken" || |
| 178 | 178 | kind === "cloud.write_allowed" || |
| 179 | // A pinned forge's deploy, update and rollback name the owner's cloud (#8697). | |
| 180 | kind.startsWith("pinned.") || | |
| 179 | 181 | // Which of a person's connected accounts a mirror syncs through is too. |
| 180 | 182 | kind.startsWith("origin.") || |
| 181 | 183 | kind === "repo.settings_updated" || |
src/core/migration-pins/20261103090000-pinned-forge.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | b0bf868cfb029ac5288cae0d3f2a0e01db2d98774f1072f220e6ef2c295df7a6 |
src/core/migrations/20261103090000-pinned-forge.ts added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | // A pinned forge (#8697): a forge the owner put on their own cloud, which has | |
| 2 | // no crew and no automatic release. Its version and config are changed only by | |
| 3 | // a row in pinned_change (who, when, from, to); the trigger refuses any other | |
| 4 | // UPDATE of them, and the CHECKs keep crew and auto_release at 0. Additive. | |
| 5 | export default ` | |
| 6 | CREATE TABLE pinned_forge ( | |
| 7 | id TEXT PRIMARY KEY, | |
| 8 | name TEXT NOT NULL UNIQUE, | |
| 9 | target TEXT NOT NULL, | |
| 10 | account TEXT NOT NULL, | |
| 11 | app TEXT NOT NULL, | |
| 12 | source TEXT NOT NULL, | |
| 13 | version TEXT NOT NULL, | |
| 14 | config TEXT NOT NULL, | |
| 15 | crew INTEGER NOT NULL DEFAULT 0 CHECK (crew = 0), | |
| 16 | auto_release INTEGER NOT NULL DEFAULT 0 CHECK (auto_release = 0), | |
| 17 | change_id INTEGER NOT NULL DEFAULT 0, | |
| 18 | remote TEXT NOT NULL DEFAULT '', | |
| 19 | volume TEXT NOT NULL DEFAULT '', | |
| 20 | state TEXT NOT NULL DEFAULT 'new', | |
| 21 | created_at TEXT NOT NULL | |
| 22 | ); | |
| 23 | CREATE TABLE pinned_change ( | |
| 24 | id INTEGER PRIMARY KEY, | |
| 25 | pinned_id TEXT NOT NULL, | |
| 26 | kind TEXT NOT NULL CHECK (kind IN ('deploy','update','rollback')), | |
| 27 | from_version TEXT, | |
| 28 | to_version TEXT NOT NULL, | |
| 29 | from_config TEXT, | |
| 30 | to_config TEXT NOT NULL, | |
| 31 | by TEXT NOT NULL, | |
| 32 | at TEXT NOT NULL | |
| 33 | ); | |
| 34 | CREATE INDEX pinned_change_forge ON pinned_change (pinned_id, id); | |
| 35 | CREATE TRIGGER pinned_change_kept BEFORE UPDATE ON pinned_change | |
| 36 | BEGIN SELECT RAISE(ABORT, 'pinned: a recorded change is history'); END; | |
| 37 | CREATE TRIGGER pinned_change_kept_del BEFORE DELETE ON pinned_change | |
| 38 | BEGIN SELECT RAISE(ABORT, 'pinned: a recorded change is history'); END; | |
| 39 | CREATE TRIGGER pinned_only_by_update | |
| 40 | BEFORE UPDATE OF version, config ON pinned_forge | |
| 41 | WHEN NEW.version IS NOT OLD.version OR NEW.config IS NOT OLD.config | |
| 42 | BEGIN | |
| 43 | SELECT RAISE(ABORT, 'pinned: version and config change only through an explicit Update') | |
| 44 | WHERE NEW.change_id <= OLD.change_id OR NOT EXISTS ( | |
| 45 | SELECT 1 FROM pinned_change c | |
| 46 | WHERE c.id = NEW.change_id AND c.pinned_id = OLD.id | |
| 47 | AND c.kind IN ('update','rollback') | |
| 48 | AND c.to_version = NEW.version AND c.to_config = NEW.config); | |
| 49 | END; | |
| 50 | `; |
src/core/pinned.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * Pinned forges (#8697): rows, and the one way their version and config move. | |
| 3 | * `recordChange` is the only writer of either; the schema trigger refuses an | |
| 4 | * UPDATE that skips it (migration 20261103090000). A pinned forge has no crew | |
| 5 | * and no automatic release, and the CHECKs keep it so. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { id as newId } from "../kernel/id.ts"; | |
| 10 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 11 | ||
| 12 | export interface Pinned { | |
| 13 | readonly id: string; | |
| 14 | readonly name: string; | |
| 15 | readonly target: string; | |
| 16 | readonly account: string; | |
| 17 | readonly app: string; | |
| 18 | readonly source: string; | |
| 19 | readonly version: string; | |
| 20 | readonly config: Record<string, string>; | |
| 21 | readonly remote: string; | |
| 22 | readonly volume: string; | |
| 23 | readonly state: string; | |
| 24 | readonly createdAt: string; | |
| 25 | } | |
| 26 | ||
| 27 | export interface Change { | |
| 28 | readonly id: number; | |
| 29 | readonly kind: "deploy" | "update" | "rollback"; | |
| 30 | readonly fromVersion: string | null; | |
| 31 | readonly toVersion: string; | |
| 32 | readonly by: string; | |
| 33 | readonly at: string; | |
| 34 | } | |
| 35 | ||
| 36 | export const NAME = /^[a-z][a-z0-9-]{0,39}$/; | |
| 37 | /** A full commit id or a release tag such as v1.4.2 — nothing a shell or git could read as a flag. */ | |
| 38 | export const VERSION = /^(?:[0-9a-f]{40}|v\d{1,4}\.\d{1,4}\.\d{1,4})$/; | |
| 39 | export const APP = /^[a-z0-9][a-z0-9_-]{0,38}\/[a-z0-9][a-z0-9_.-]{0,99}$/; | |
| 40 | const SOURCE = /^https:\/\/[a-z0-9][a-z0-9.-]{0,100}(?:\/[A-Za-z0-9._~-]{1,100}){1,4}$/; | |
| 41 | const KEY = /^[A-Z][A-Z0-9_]{0,63}$/; | |
| 42 | // A value is written to a systemd EnvironmentFile on the machine: no quote, | |
| 43 | // backslash, dollar, backtick or newline can reach it. | |
| 44 | const VALUE = /^[A-Za-z0-9 _.,:\/@+=-]{0,512}$/; | |
| 45 | const SECRETISH = /SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY/; | |
| 46 | ||
| 47 | export function validVersion(v: string): boolean { | |
| 48 | return VERSION.test(v); | |
| 49 | } | |
| 50 | ||
| 51 | export function validSource(s: string): boolean { | |
| 52 | return SOURCE.test(s) && !s.includes(".."); | |
| 53 | } | |
| 54 | ||
| 55 | /** Config is plain, non-secret settings; a secret-looking name is refused so none lands in a row. */ | |
| 56 | export function checkConfig(raw: unknown): Result<Record<string, string>, string> { | |
| 57 | if (raw === undefined) return ok({}); | |
| 58 | if (typeof raw !== "object" || raw === null || Array.isArray(raw)) | |
| 59 | return err("config is an object of NAME: value"); | |
| 60 | const out: Record<string, string> = {}; | |
| 61 | const entries = Object.entries(raw); | |
| 62 | if (entries.length > 40) return err("config holds at most 40 settings"); | |
| 63 | for (const [k, v] of entries) { | |
| 64 | if (!KEY.test(k)) return err(`config name ${k.slice(0, 20)} is capitals, digits and underscores`); | |
| 65 | if (SECRETISH.test(k)) return err(`${k} looks like a secret; secrets are sealed in cloud accounts, never in config`); | |
| 66 | if (typeof v !== "string" || !VALUE.test(v)) return err(`config ${k} is plain text of letters, digits and . , : / @ + = - _`); | |
| 67 | out[k] = v; | |
| 68 | } | |
| 69 | return ok(Object.fromEntries(Object.entries(out).sort(([a], [b]) => (a < b ? -1 : 1)))); | |
| 70 | } | |
| 71 | ||
| 72 | interface Row { | |
| 73 | 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 | } | |
| 76 | ||
| 77 | const toPinned = (r: Row): Pinned => ({ | |
| 78 | id: r.id, name: r.name, target: r.target, account: r.account, app: r.app, source: r.source, | |
| 79 | 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 | }); | |
| 82 | ||
| 83 | export const listPinned = (db: Database): Pinned[] => | |
| 84 | db.query<Row, []>("SELECT * FROM pinned_forge ORDER BY created_at, id").all().map(toPinned); | |
| 85 | ||
| 86 | export function getPinned(db: Database, name: string): Pinned | undefined { | |
| 87 | const r = db.query<Row, [string]>("SELECT * FROM pinned_forge WHERE name = ?1").get(name); | |
| 88 | return r ? toPinned(r) : undefined; | |
| 89 | } | |
| 90 | ||
| 91 | export function changesOf(db: Database, pinnedId: string): Change[] { | |
| 92 | return db | |
| 93 | .query<{ id: number; kind: Change["kind"]; from_version: string | null; to_version: string; by: string; at: string }, [string]>( | |
| 94 | "SELECT id, kind, from_version, to_version, by, at FROM pinned_change WHERE pinned_id = ?1 ORDER BY id DESC", | |
| 95 | ) | |
| 96 | .all(pinnedId) | |
| 97 | .map((c) => ({ id: c.id, kind: c.kind, fromVersion: c.from_version, toVersion: c.to_version, by: c.by, at: c.at })); | |
| 98 | } | |
| 99 | ||
| 100 | export function insertPinned( | |
| 101 | db: Database, | |
| 102 | p: { name: string; target: string; account: string; app: string; source: string; version: string; config: Record<string, string> }, | |
| 103 | by: string, | |
| 104 | ): Pinned { | |
| 105 | const at = new Date().toISOString(); | |
| 106 | const pid = newId(); | |
| 107 | const config = JSON.stringify(p.config); | |
| 108 | db.transaction(() => { | |
| 109 | const c = db | |
| 110 | .query("INSERT INTO pinned_change (pinned_id, kind, from_version, to_version, from_config, to_config, by, at) VALUES (?1, 'deploy', NULL, ?2, NULL, ?3, ?4, ?5)") | |
| 111 | .run(pid, p.version, config, by, at); | |
| 112 | db.query( | |
| 113 | "INSERT INTO pinned_forge (id, name, target, account, app, source, version, config, change_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", | |
| 114 | ).run(pid, p.name, p.target, p.account, p.app, p.source, p.version, config, Number(c.lastInsertRowid), at); | |
| 115 | })(); | |
| 116 | return getPinned(db, p.name)!; | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * The one explicit change: write who, when, from and to, then move the pin. | |
| 121 | * Returns the stored change. Rollback's target is the `from` of the latest | |
| 122 | * change, so it is always one action to the previous version. | |
| 123 | */ | |
| 124 | export function recordChange( | |
| 125 | db: Database, | |
| 126 | forge: Pinned, | |
| 127 | kind: "update" | "rollback", | |
| 128 | to: { version: string; config: Record<string, string> }, | |
| 129 | by: string, | |
| 130 | ): Change { | |
| 131 | const at = new Date().toISOString(); | |
| 132 | const toConfig = JSON.stringify(to.config); | |
| 133 | db.transaction(() => { | |
| 134 | const c = db | |
| 135 | .query("INSERT INTO pinned_change (pinned_id, kind, from_version, to_version, from_config, to_config, by, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)") | |
| 136 | .run(forge.id, kind, forge.version, to.version, JSON.stringify(forge.config), toConfig, by, at); | |
| 137 | db.query("UPDATE pinned_forge SET version = ?1, config = ?2, change_id = ?3 WHERE id = ?4").run( | |
| 138 | to.version, toConfig, Number(c.lastInsertRowid), forge.id, | |
| 139 | ); | |
| 140 | })(); | |
| 141 | return changesOf(db, forge.id)[0]!; | |
| 142 | } | |
| 143 | ||
| 144 | /** What a rollback would restore: the latest change's `from`, if it had one. */ | |
| 145 | export function previousOf(db: Database, forge: Pinned): { version: string; config: Record<string, string> } | undefined { | |
| 146 | const r = db | |
| 147 | .query<{ from_version: string | null; from_config: string | null }, [string]>( | |
| 148 | "SELECT from_version, from_config FROM pinned_change WHERE pinned_id = ?1 ORDER BY id DESC LIMIT 1", | |
| 149 | ) | |
| 150 | .get(forge.id); | |
| 151 | return r?.from_version != null && r.from_config != null | |
| 152 | ? { version: r.from_version, config: JSON.parse(r.from_config) as Record<string, string> } | |
| 153 | : undefined; | |
| 154 | } | |
| 155 | ||
| 156 | /** 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); | |
| 160 | } | |
| 161 | ||
| 162 | /** The row of a deploy that never reached a droplet, so its name can be tried again; its recorded change stays as history. */ | |
| 163 | export function dropPinned(db: Database, name: string): void { | |
| 164 | db.query("DELETE FROM pinned_forge WHERE name = ?1 AND remote = '' AND state = 'failed'").run(name); | |
| 165 | } |
src/crew/conversation_confirm.ts +1
| @@ -89,6 +89,7 @@ export const ASKING: Readonly<Record<string, Asking>> = { | ||
| 89 | 89 | converge: { does: "have a fork's crew merge the parent's updates" }, |
| 90 | 90 | origins: { does: "sync an origin or replace the internal hosts one may live on", asks: not("action", "list", "repos") }, |
| 91 | 91 | cloud_account: { does: "change a cloud account" }, |
| 92 | pinned_forge: { does: "deploy, update or roll back a pinned forge on the owner's cloud", asks: not("verb", "list") }, | |
| 92 | 93 | outsiders: { does: "let non-writers direct an app's crew, or stop them" }, |
| 93 | 94 | seal: { does: "seal or unseal an issue or pull" }, |
| 94 | 95 | migrate: { does: "bring a repository in from a public address" }, |
src/http/api/admin.ts +42
| @@ -14,6 +14,8 @@ 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"; | |
| 18 | import { TARGETS } from "../../cloud/targets.ts"; | |
| 17 | 19 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; |
| 18 | 20 | import { setRole } from "../../actions/roles.ts"; |
| 19 | 21 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; |
| @@ -37,6 +39,46 @@ export const accountAdmin = (ctx: Ctx, scope: "ro" | "admin"): Response | undefi | ||
| 37 | 39 | ctx.auth.user?.isAdmin === true ? requireScope(ctx, scope) : notFound(); |
| 38 | 40 | |
| 39 | 41 | export function adminRoutes(router: Router<Handler>): void { |
| 42 | // ---- pinned forges (#8697): one deploy, and a change only by Update ---- | |
| 43 | const pinnedFail = (error: string): Response => | |
| 44 | error === "not_found" || error === "No such pinned forge." ? notFound() : apiError("refused", 422, error); | |
| 45 | ||
| 46 | router.add("GET", "/api/v1/admin/pinned", (ctx) => { | |
| 47 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 48 | const denied = accountAdmin(ctx, "ro"); | |
| 49 | if (denied) return denied; | |
| 50 | const seen = pinnedForges(ctx.app, ctx.auth.user); | |
| 51 | return seen.ok ? json({ items: seen.value, targets: TARGETS, next_cursor: null }) : notFound(); | |
| 52 | }); | |
| 53 | ||
| 54 | router.add("POST", "/api/v1/admin/pinned", async (ctx) => { | |
| 55 | const denied = accountAdmin(ctx, "admin"); | |
| 56 | if (denied) return denied; | |
| 57 | const i = await body(ctx.request); | |
| 58 | const made = await deployPinned(ctx.app, ctx.auth.user, { | |
| 59 | name: str(i.name), target: str(i.target), account: str(i.account), app: str(i.app), | |
| 60 | source: str(i.source), version: str(i.version), config: i.config, | |
| 61 | }); | |
| 62 | return made.ok ? json(made.value, 201) : pinnedFail(made.error); | |
| 63 | }); | |
| 64 | ||
| 65 | router.add("POST", "/api/v1/admin/pinned/:name/update", async (ctx) => { | |
| 66 | const denied = accountAdmin(ctx, "admin"); | |
| 67 | if (denied) return denied; | |
| 68 | const i = await body(ctx.request); | |
| 69 | const done = await updatePinned(ctx.app, ctx.auth.user, ctx.params.name!, { | |
| 70 | version: i.version === undefined ? undefined : str(i.version), config: i.config, | |
| 71 | }); | |
| 72 | return done.ok ? json(done.value) : pinnedFail(done.error); | |
| 73 | }); | |
| 74 | ||
| 75 | router.add("POST", "/api/v1/admin/pinned/:name/rollback", async (ctx) => { | |
| 76 | const denied = accountAdmin(ctx, "admin"); | |
| 77 | if (denied) return denied; | |
| 78 | const done = await rollbackPinned(ctx.app, ctx.auth.user, ctx.params.name!); | |
| 79 | return done.ok ? json(done.value) : pinnedFail(done.error); | |
| 80 | }); | |
| 81 | ||
| 40 | 82 | // ---- accounts: the owner's view of who joined (#660) ---- |
| 41 | 83 | const accountError = (error: string): Response => |
| 42 | 84 | error === "not_found" |
src/http/mcp/tools/index.ts +1
| @@ -98,6 +98,7 @@ export { tool as origins } from "./origins.ts"; | ||
| 98 | 98 | export { tool as outsiders } from "./outsiders.ts"; |
| 99 | 99 | export { tool as parent_unlink } from "./parent_unlink.ts"; |
| 100 | 100 | export { tool as person } from "./person.ts"; |
| 101 | export { tool as pinned_forge } from "./pinned_forge.ts"; | |
| 101 | 102 | export { tool as pins } from "./pins.ts"; |
| 102 | 103 | export { tool as plan } from "./plan.ts"; |
| 103 | 104 | export { tool as preview } from "./preview.ts"; |
src/http/mcp/tools/pinned_forge.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | import { deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../../actions/pinned.ts"; | |
| 2 | import { TARGETS } from "../../../cloud/targets.ts"; | |
| 3 | import { scopeAllows } from "../../../core/tokens.ts"; | |
| 4 | import { good, bad, type Tool, str } from "../shared.ts"; | |
| 5 | ||
| 6 | export const tool: Tool = { | |
| 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).", | |
| 10 | inputSchema: { | |
| 11 | type: "object", | |
| 12 | properties: { | |
| 13 | verb: { type: "string", enum: ["list", "deploy", "update", "rollback"] }, | |
| 14 | name: { type: "string", maxLength: 40 }, | |
| 15 | target: { type: "string", enum: TARGETS.map((t) => t.id) }, | |
| 16 | account: { type: "string", maxLength: 32 }, | |
| 17 | app: { type: "string", maxLength: 140 }, | |
| 18 | source: { type: "string", maxLength: 300 }, | |
| 19 | version: { type: "string", maxLength: 40, description: "A 40-character commit id or a tag like v1.2.3." }, | |
| 20 | config: { type: "object", description: "Plain NAME: value settings; never a secret." }, | |
| 21 | }, | |
| 22 | required: ["verb"], | |
| 23 | }, | |
| 24 | // Marked a write so a `ro` token never lists the owner's cloud arrangements. | |
| 25 | writes: true, | |
| 26 | async run(ctx, user, args) { | |
| 27 | if (!user.isAdmin) return bad("not_found", "no such tool here"); | |
| 28 | const verb = str(args["verb"]); | |
| 29 | if (verb === "list") { | |
| 30 | const seen = pinnedForges(ctx.app, user); | |
| 31 | return seen.ok ? good({ items: seen.value, targets: TARGETS }) : bad("not_found", "no such tool here"); | |
| 32 | } | |
| 33 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 34 | return bad("forbidden", "changing a pinned forge needs an admin-scope token"); | |
| 35 | const name = str(args["name"]); | |
| 36 | const done = | |
| 37 | verb === "deploy" | |
| 38 | ? await deployPinned(ctx.app, user, { | |
| 39 | name, target: str(args["target"]), account: str(args["account"]), app: str(args["app"]), | |
| 40 | source: str(args["source"]), version: str(args["version"]), config: args["config"], | |
| 41 | }) | |
| 42 | : verb === "update" | |
| 43 | ? await updatePinned(ctx.app, user, name, { | |
| 44 | version: args["version"] === undefined ? undefined : str(args["version"]), | |
| 45 | config: args["config"], | |
| 46 | }) | |
| 47 | : verb === "rollback" | |
| 48 | ? await rollbackPinned(ctx.app, user, name) | |
| 49 | : undefined; | |
| 50 | if (done === undefined) return bad("bad_verb", "verb is list, deploy, update or rollback"); | |
| 51 | if (!done.ok) return bad("refused", done.error); | |
| 52 | return good({ says: `${verb} ${name}: now ${done.value.version}, ${done.value.state}.`, forge: done.value }); | |
| 53 | }, | |
| 54 | }; |
src/tui/screens/entries.ts +2
| @@ -29,6 +29,7 @@ import { entry as orgGrants } from "./orggrants.ts"; | ||
| 29 | 29 | import { entry as orgTeams } from "./orgteams.ts"; |
| 30 | 30 | import { entry as owner } from "./owner.ts"; |
| 31 | 31 | import { entry as people } from "./people.ts"; |
| 32 | import { entry as pinned } from "./pinned.ts"; | |
| 32 | 33 | import { entry as profile } from "./profile.ts"; |
| 33 | 34 | import { entry as quotas } from "./quotas.ts"; |
| 34 | 35 | import { entry as resilience } from "./resilience.ts"; |
| @@ -82,6 +83,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 82 | 83 | bulk, |
| 83 | 84 | subscriptions, |
| 84 | 85 | cloud, |
| 86 | pinned, | |
| 85 | 87 | connections, |
| 86 | 88 | domains, |
| 87 | 89 | feed, |
src/tui/screens/pinned.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | /** | |
| 2 | * Pinned forges in the terminal, a forge admin's (#8697): each forge with its | |
| 3 | * version, state and last changes, as the API lists them. `d` deploys, `u` | |
| 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. | |
| 7 | * Anyone else sees nothing and does nothing. | |
| 8 | */ | |
| 9 | ||
| 10 | import { deployPinned, pinnedForges, rollbackPinned, updatePinned } from "../../actions/pinned.ts"; | |
| 11 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 12 | import { binding } from "../app/keys.ts"; | |
| 13 | import { style } from "../term/style.ts"; | |
| 14 | import { truncate } from "../term/width.ts"; | |
| 15 | import type { Session } from "../session.ts"; | |
| 16 | import { fill, len, split } from "../ui/layout.ts"; | |
| 17 | import { ListState, drawList, live, promptScreen, type ListRow } from "./common.ts"; | |
| 18 | import type { PaletteEntry } from "./entries.ts"; | |
| 19 | ||
| 20 | const short = (v: string): string => (v.length === 40 ? v.slice(0, 10) : v); | |
| 21 | ||
| 22 | export function pinnedScreen(f: Session, ctx: Ctx): Screen { | |
| 23 | const state = new ListState(); | |
| 24 | let note = ""; | |
| 25 | const read = () => pinnedForges(f.app, f.user); | |
| 26 | const forges = () => { | |
| 27 | const r = read(); | |
| 28 | return r.ok ? r.value : []; | |
| 29 | }; | |
| 30 | const say = (text: string): void => { | |
| 31 | note = text; | |
| 32 | ctx.invalidate(); | |
| 33 | }; | |
| 34 | const settle = async (r: Promise<{ ok: true; value: { name: string; version: string } } | { ok: false; error: string }>, what: string): Promise<void> => { | |
| 35 | const made = await r; | |
| 36 | say(made.ok ? `${what} ${made.value.name} at ${short(made.value.version)}.` : made.error); | |
| 37 | }; | |
| 38 | ||
| 39 | const deploy = (parent: Screen): Screen => | |
| 40 | promptScreen(ctx, parent, { | |
| 41 | title: "Deploy a pinned forge", | |
| 42 | label: "name provider/account owner/app https-source version", | |
| 43 | placeholder: "acme digitalocean/main alice/shop https://host/alice/shop v1.0.0", | |
| 44 | onSubmit: (raw) => { | |
| 45 | const [name = "", cloud = "", app = "", source = "", version = "", ...rest] = raw.trim().split(/\s+/); | |
| 46 | const [target = "", account = "", ...more] = cloud.split("/"); | |
| 47 | if (rest.length > 0 || more.length > 0 || version === "" || account === "") return "Write: name provider/account owner/app source version."; | |
| 48 | say(`Deploying ${name}…`); | |
| 49 | return settle(deployPinned(f.app, f.user, { name, target, account, app, source, version }), "Deployed"); | |
| 50 | }, | |
| 51 | }); | |
| 52 | ||
| 53 | const screen: Screen = { | |
| 54 | title: "Pinned forges", | |
| 55 | keys: [binding(["j", "k"], "move cursor"), binding(["d"], "deploy"), binding(["u"], "update"), binding(["r"], "roll back")], | |
| 56 | async load(signal) { | |
| 57 | live(f, signal, { kinds: ["pinned.deployed", "pinned.update", "pinned.rollback"] }, () => ctx.invalidate()); | |
| 58 | }, | |
| 59 | render(area, s) { | |
| 60 | const c = ctx.theme.c; | |
| 61 | const r = read(); | |
| 62 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 63 | const selected = forges()[state.index]; | |
| 64 | const text = note !== "" ? note : !r.ok ? "Pinned forges are a forge admin's." : selected ? `${selected.app} from ${selected.source}` : "No pinned forge. Press d to deploy one."; | |
| 65 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 66 | const rows: ListRow[] = forges().map((p) => ({ | |
| 67 | title: p.name, | |
| 68 | ...(p.state === "failed" ? { tone: "danger" as const } : {}), | |
| 69 | secondary: `${p.target} · ${short(p.version)} · ${p.state}`, | |
| 70 | meta: p.changes[0] ? `${p.changes[0].kind} ${p.changes[0].at.slice(0, 16).replace("T", " ")}` : "", | |
| 71 | })); | |
| 72 | drawList(s, list!, c, state, rows); | |
| 73 | }, | |
| 74 | key(e) { | |
| 75 | if (state.key(e, forges().length)) return true; | |
| 76 | if (e.ctrl || e.alt || !read().ok) return false; | |
| 77 | if (e.key === "d") { | |
| 78 | ctx.push(deploy(screen)); | |
| 79 | return true; | |
| 80 | } | |
| 81 | const p = forges()[state.index]; | |
| 82 | if (p === undefined) return false; | |
| 83 | if (e.key === "r") { | |
| 84 | say(`Rolling ${p.name} back…`); | |
| 85 | void settle(rollbackPinned(f.app, f.user, p.name), "Rolled back"); | |
| 86 | } else if (e.key === "u") | |
| 87 | ctx.push( | |
| 88 | promptScreen(ctx, screen, { | |
| 89 | title: `Update ${p.name}`, | |
| 90 | label: "the new version: a 40-character commit id or a tag", | |
| 91 | onSubmit: (version) => { | |
| 92 | say(`Updating ${p.name}…`); | |
| 93 | return settle(updatePinned(f.app, f.user, p.name, { version: version.trim() }), "Updated"); | |
| 94 | }, | |
| 95 | }), | |
| 96 | ); | |
| 97 | else return false; | |
| 98 | return true; | |
| 99 | }, | |
| 100 | }; | |
| 101 | return screen; | |
| 102 | } | |
| 103 | ||
| 104 | /** Pinned forges, as the API and the MCP have them: a forge admin (#8697). */ | |
| 105 | export const entry: PaletteEntry = { | |
| 106 | id: "pinned", | |
| 107 | title: "Pinned forges", | |
| 108 | hint: "deploy, update and roll back a forge on your cloud", | |
| 109 | after: ["cloud"], | |
| 110 | offered: (session) => session.user?.isAdmin === true, | |
| 111 | run: (session, ctx) => ctx.push(pinnedScreen(session, ctx)), | |
| 112 | }; |
test/conversation-confirm.test.ts +1 −1
| @@ -137,7 +137,7 @@ describe("the one list of what asks first", () => { | ||
| 137 | 137 | expect(listed || NOT_ASKED.has(t.name), `${t.name} writes: ask first (ASKING) or name it in NOT_ASKED`).toBe(true); |
| 138 | 138 | expect(listed && NOT_ASKED.has(t.name), `${t.name} is on both lists`).toBe(false); |
| 139 | 139 | } |
| 140 | for (const name of ["land_past_gate", "share", "invites", "egress_grants", "sever", "parent_unlink", "quotas", "spawn", "app_deploy", "stop", "converge", "origins", "cloud_account", "outsiders", "seal", "migrate"]) | |
| 140 | for (const name of ["land_past_gate", "share", "invites", "egress_grants", "sever", "parent_unlink", "quotas", "spawn", "app_deploy", "stop", "converge", "origins", "cloud_account", "pinned_forge", "outsiders", "seal", "migrate"]) | |
| 141 | 141 | expect(ASKING[name], name).toBeDefined(); |
| 142 | 142 | }); |
| 143 | 143 |
test/mcp.test.ts +1
| @@ -303,6 +303,7 @@ describe("the forge over MCP", () => { | ||
| 303 | 303 | "egress_grants", |
| 304 | 304 | "explore", |
| 305 | 305 | "person", |
| 306 | "pinned_forge", | |
| 306 | 307 | "search", |
| 307 | 308 | "feed", |
| 308 | 309 | "feed_chips", |
test/palette-roster.test.ts +2
| @@ -110,6 +110,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 110 | 110 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 111 | 111 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 112 | 112 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 113 | ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"], | |
| 113 | 114 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 114 | 115 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 115 | 116 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], |
| @@ -164,6 +165,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 164 | 165 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 165 | 166 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 166 | 167 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 168 | ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"], | |
| 167 | 169 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], |
| 168 | 170 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 169 | 171 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
test/pinned-forge-recovery.test.ts added +153
| @@ -0,0 +1,153 @@ | ||
| 1 | /** | |
| 2 | * A pinned forge that fails half-way is recoverable (#8697): a failed create | |
| 3 | * after the delete, a failed first deploy, and a volume made before the droplet | |
| 4 | * all leave a row a retry finishes. The terminal door reaches the same actions. | |
| 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 { deployPinned, net, rollbackPinned, updatePinned } from "../src/actions/pinned.ts"; | |
| 13 | import { BUN_VERSION, bootConfig } from "../src/cloud/droplet.ts"; | |
| 14 | import { getPinned } from "../src/core/pinned.ts"; | |
| 15 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 16 | import { Session } from "../src/tui/session.ts"; | |
| 17 | import { entry, pinnedScreen } from "../src/tui/screens/pinned.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | const V1 = "a".repeat(40); | |
| 23 | const V2 = "b".repeat(40); | |
| 24 | const SOURCE = "https://forge.example.org/trevato/forge"; | |
| 25 | ||
| 26 | describe("pinned forge recovery", () => { | |
| 27 | let dataDir = ""; | |
| 28 | let app: App; | |
| 29 | const calls: string[] = []; | |
| 30 | /** Paths whose next call fails, with the status. */ | |
| 31 | const fail = new Map<string, number>(); | |
| 32 | let deleted = new Set<string>(); | |
| 33 | ||
| 34 | const admin = () => getUserByName(app.db, "alice")!; | |
| 35 | const input = (over: Record<string, unknown> = {}) => ({ | |
| 36 | name: "acme", target: "digitalocean", account: "main", app: "alice/shop", source: SOURCE, version: V1, config: {}, ...over, | |
| 37 | }); | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-rec-")); | |
| 41 | app = createApp({ dataDir, port: 0 }); | |
| 42 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 43 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst }); | |
| 44 | if (!made.ok) throw new Error(made.error); | |
| 45 | } | |
| 46 | cloudNet.fetch = async () => new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }), { status: 200 }); | |
| 47 | const added = await addAccount(app, admin(), "digitalocean", "main", "dop_v1_9Zq4TxLm7Kp2Vb8nWcR5"); | |
| 48 | if (!added.ok) throw new Error(String(added.error)); | |
| 49 | allowWrite(app, admin(), "digitalocean", "main", true); | |
| 50 | net.fetch = async (url, init) => { | |
| 51 | const path = new URL(url).pathname; | |
| 52 | const method = init?.method ?? "GET"; | |
| 53 | calls.push(`${method} ${path}`); | |
| 54 | const key = `${method} ${path.replace(/\/\d+$/, "")}`; | |
| 55 | const status = fail.get(key); | |
| 56 | if (status !== undefined) { fail.delete(key); return new Response("{}", { status }); } | |
| 57 | if (path === "/v2/volumes") return new Response(JSON.stringify({ volume: { id: "vol-9" } }), { status: 201 }); | |
| 58 | if (method === "DELETE") { | |
| 59 | if (deleted.has(path)) return new Response("{}", { status: 404 }); | |
| 60 | deleted.add(path); | |
| 61 | return new Response(null, { status: 204 }); | |
| 62 | } | |
| 63 | return new Response(JSON.stringify({ droplet: { id: 2000 + calls.length } }), { status: 202 }); | |
| 64 | }; | |
| 65 | }); | |
| 66 | ||
| 67 | afterAll(async () => { | |
| 68 | app.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a failed first deploy keeps its volume, and the same name deploys again reusing it", async () => { | |
| 73 | fail.set("POST /v2/droplets", 500); | |
| 74 | const first = await deployPinned(app, admin(), input()); | |
| 75 | expect(first.ok).toBe(false); | |
| 76 | // The volume id was recorded before the droplet was asked for. | |
| 77 | expect(getPinned(app.db, "acme")).toMatchObject({ state: "failed", remote: "", volume: "vol-9" }); | |
| 78 | calls.length = 0; | |
| 79 | const again = await deployPinned(app, admin(), input()); | |
| 80 | expect(again.ok).toBe(true); | |
| 81 | expect(calls).toEqual(["POST /v2/droplets"]); | |
| 82 | expect(getPinned(app.db, "acme")).toMatchObject({ state: "running", volume: "vol-9" }); | |
| 83 | // A running forge still owns its name. | |
| 84 | expect(await deployPinned(app, admin(), input())).toMatchObject({ ok: false, error: "A pinned forge has that name already." }); | |
| 85 | }); | |
| 86 | ||
| 87 | test("an Update whose create fails after the delete can be rolled back or tried again", async () => { | |
| 88 | fail.set("POST /v2/droplets", 500); | |
| 89 | expect((await updatePinned(app, admin(), "acme", { version: V2 })).ok).toBe(false); | |
| 90 | expect(getPinned(app.db, "acme")).toMatchObject({ state: "failed", remote: "", version: V2 }); | |
| 91 | calls.length = 0; | |
| 92 | // The old droplet is not deleted a second time. | |
| 93 | const back = await rollbackPinned(app, admin(), "acme"); | |
| 94 | expect(back.ok).toBe(true); | |
| 95 | expect(calls).toEqual(["POST /v2/droplets"]); | |
| 96 | expect(getPinned(app.db, "acme")).toMatchObject({ state: "running", version: V1 }); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a droplet already gone (404) counts as deleted", async () => { | |
| 100 | app.db.run("UPDATE pinned_forge SET remote = '777' WHERE name = 'acme'"); | |
| 101 | deleted.add("/v2/droplets/777"); | |
| 102 | expect((await updatePinned(app, admin(), "acme", { version: V2 })).ok).toBe(true); | |
| 103 | expect(getPinned(app.db, "acme")).toMatchObject({ state: "running", version: V2 }); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a failed forge may be told the version it already has, to finish the replacement", async () => { | |
| 107 | app.db.run("UPDATE pinned_forge SET state = 'failed', remote = '' WHERE name = 'acme'"); | |
| 108 | expect((await updatePinned(app, admin(), "acme", { version: V2 })).ok).toBe(true); | |
| 109 | expect(getPinned(app.db, "acme")?.state).toBe("running"); | |
| 110 | }); | |
| 111 | ||
| 112 | test("the boot script installs the Bun version it names, not whatever is latest", () => { | |
| 113 | const script = bootConfig({ name: "acme", source: SOURCE, version: V1, config: {} }); | |
| 114 | expect(script).toContain(`bash -s "bun-v${BUN_VERSION}"`); | |
| 115 | expect(BUN_VERSION).toMatch(/^\d+\.\d+\.\d+$/); | |
| 116 | }); | |
| 117 | ||
| 118 | test("terminal: the palette offers Pinned forges to an admin only; d, u and r reach the actions", async () => { | |
| 119 | const bob = new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, "bob")! }); | |
| 120 | const alice = new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: admin() }); | |
| 121 | expect(entry.offered(bob)).toBe(false); | |
| 122 | expect(entry.offered(alice)).toBe(true); | |
| 123 | ||
| 124 | const pushed: any[] = []; | |
| 125 | const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {}, pop() {}, toast() {}, setCursor() {} } as never; | |
| 126 | const key = (s: { key?: (e: never) => boolean }, k: string) => s.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never); | |
| 127 | const refused = pinnedScreen(bob, ctx); | |
| 128 | for (const k of ["d", "u", "r"]) key(refused, k); | |
| 129 | expect(pushed).toEqual([]); | |
| 130 | ||
| 131 | const screen = pinnedScreen(alice, ctx); | |
| 132 | key(screen, "d"); | |
| 133 | expect(pushed).toHaveLength(1); | |
| 134 | // r rolls back the forge under the cursor: one action, recorded. | |
| 135 | const before = getPinned(app.db, "acme")!.version; | |
| 136 | key(screen, "r"); | |
| 137 | await new Promise((r) => setTimeout(r, 50)); | |
| 138 | expect(getPinned(app.db, "acme")!.version).not.toBe(before); | |
| 139 | }); | |
| 140 | ||
| 141 | test("terminal: the screen follows the bus while it is on the stack", async () => { | |
| 142 | const alice = new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: admin() }); | |
| 143 | let invalidated = 0; | |
| 144 | const ctx = { push() {}, invalidate: () => invalidated++ } as never; | |
| 145 | const abort = new AbortController(); | |
| 146 | await pinnedScreen(alice, ctx).load!(abort.signal); | |
| 147 | const n = invalidated; | |
| 148 | await updatePinned(app, admin(), "acme", { version: V2 }); | |
| 149 | await new Promise((r) => setTimeout(r, 120)); | |
| 150 | expect(invalidated).toBeGreaterThan(n); | |
| 151 | abort.abort(); | |
| 152 | }); | |
| 153 | }); |
test/pinned-forge.test.ts added +226
| @@ -0,0 +1,226 @@ | ||
| 1 | /** | |
| 2 | * A pinned forge (#8697): deployed once, changed only by an explicit Update | |
| 3 | * that records who and when, rolled back in one action, and never holding a | |
| 4 | * credential in a row, an event or the boot script. Every other way to move | |
| 5 | * its version or config is tried here and refused. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addAccount, allowWrite, net as cloudNet } from "../src/actions/cloud.ts"; | |
| 13 | import { deployPinned, net, pinnedForges, rollbackPinned, updatePinned } from "../src/actions/pinned.ts"; | |
| 14 | import { bootConfig } from "../src/cloud/droplet.ts"; | |
| 15 | import { TARGETS } from "../src/cloud/targets.ts"; | |
| 16 | import { changesOf, checkConfig, getPinned } from "../src/core/pinned.ts"; | |
| 17 | import { createToken } from "../src/core/tokens.ts"; | |
| 18 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const TOKEN = "dop_v1_9Zq4TxLm7Kp2Vb8nWcR5"; | |
| 25 | const V1 = "a".repeat(40); | |
| 26 | const V2 = "b".repeat(40); | |
| 27 | const SOURCE = "https://forge.example.org/trevato/forge"; | |
| 28 | ||
| 29 | describe("pinned forge", () => { | |
| 30 | let dataDir = ""; | |
| 31 | let app: App; | |
| 32 | let servers: Servers; | |
| 33 | const calls: { method: string; url: string; auth: string | null; body: any }[] = []; | |
| 34 | let failNext = false; | |
| 35 | ||
| 36 | const admin = () => getUserByName(app.db, "alice")!; | |
| 37 | const input = (over: Record<string, unknown> = {}) => ({ | |
| 38 | name: "acme", target: "digitalocean", account: "main", app: "alice/shop", source: SOURCE, version: V1, config: { PUBLIC_NAME: "Acme" }, ...over, | |
| 39 | }); | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-")); | |
| 43 | app = createApp({ dataDir, port: 0 }); | |
| 44 | servers = await serve(app); | |
| 45 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 46 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst }); | |
| 47 | if (!made.ok) throw new Error(made.error); | |
| 48 | } | |
| 49 | cloudNet.fetch = async () => new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }), { status: 200 }); | |
| 50 | const added = await addAccount(app, admin(), "digitalocean", "main", TOKEN); | |
| 51 | if (!added.ok) throw new Error(String(added.error)); | |
| 52 | net.fetch = async (url, init) => { | |
| 53 | const u = new URL(url); | |
| 54 | calls.push({ method: init?.method ?? "GET", url, auth: new Headers(init?.headers).get("authorization"), body: init?.body ? JSON.parse(String(init.body)) : undefined }); | |
| 55 | if (failNext) { failNext = false; return new Response("{}", { status: 500 }); } | |
| 56 | if (u.pathname === "/v2/volumes") return new Response(JSON.stringify({ volume: { id: "vol-1" } }), { status: 201 }); | |
| 57 | if (init?.method === "DELETE") return new Response(null, { status: 204 }); | |
| 58 | return new Response(JSON.stringify({ droplet: { id: 1000 + calls.length } }), { status: 202 }); | |
| 59 | }; | |
| 60 | }, 30_000); | |
| 61 | ||
| 62 | afterAll(async () => { | |
| 63 | servers.stop(); | |
| 64 | app.close(); | |
| 65 | await rm(dataDir, { recursive: true, force: true }); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a deploy needs a cloud account that may write", async () => { | |
| 69 | const r = await deployPinned(app, admin(), input()); | |
| 70 | expect(r).toMatchObject({ ok: false }); | |
| 71 | expect(String((r as any).error)).toContain("read-only"); | |
| 72 | expect(calls).toHaveLength(0); | |
| 73 | // The attempt is recorded, and the forge says it failed rather than running. | |
| 74 | expect(getPinned(app.db, "acme")?.state).toBe("failed"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("deploy makes a volume, then a droplet on it; the token is a header, never in a body or the boot script", async () => { | |
| 78 | app.db.run("DELETE FROM pinned_forge"); | |
| 79 | allowWrite(app, admin(), "digitalocean", "main", true); | |
| 80 | const r = await deployPinned(app, admin(), input()); | |
| 81 | expect(r.ok).toBe(true); | |
| 82 | expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual(["POST /v2/volumes", "POST /v2/droplets"]); | |
| 83 | expect(calls.every((c) => c.auth === `Bearer ${TOKEN}`)).toBe(true); | |
| 84 | const droplet = calls[1]!.body; | |
| 85 | expect(droplet.volumes).toEqual(["vol-1"]); | |
| 86 | expect(JSON.stringify(calls.map((c) => c.body))).not.toContain(TOKEN); | |
| 87 | expect(droplet.user_data).toContain(`checkout --detach ${V1}`); | |
| 88 | expect(droplet.user_data).toContain("FORGE_APP_DATA=/mnt/forge_acme_data"); | |
| 89 | const p = getPinned(app.db, "acme")!; | |
| 90 | expect(p).toMatchObject({ state: "running", volume: "vol-1" }); | |
| 91 | const events = JSON.stringify(app.db.query("SELECT payload FROM event WHERE kind LIKE 'pinned.%'").all()); | |
| 92 | expect(events).not.toContain(TOKEN); | |
| 93 | expect(JSON.stringify(pinnedForges(app, admin()))).not.toContain(TOKEN); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a pinned forge has no crew and no automatic release, and the schema keeps it so", () => { | |
| 97 | const v = pinnedForges(app, admin()); | |
| 98 | expect(v.ok && v.value[0]).toMatchObject({ crew: false, autoRelease: false }); | |
| 99 | expect(() => app.db.run("UPDATE pinned_forge SET crew = 1")).toThrow(); | |
| 100 | expect(() => app.db.run("UPDATE pinned_forge SET auto_release = 1")).toThrow(); | |
| 101 | }); | |
| 102 | ||
| 103 | test("version and config move only through Update: every other way is refused", async () => { | |
| 104 | const before = getPinned(app.db, "acme")!; | |
| 105 | // Straight SQL, as any other code path in the forge would try. | |
| 106 | expect(() => app.db.run("UPDATE pinned_forge SET version = ?1", [V2])).toThrow(/explicit Update/); | |
| 107 | expect(() => app.db.run("UPDATE pinned_forge SET config = '{}'")).toThrow(/explicit Update/); | |
| 108 | // Forging a change id that points at no matching change. | |
| 109 | expect(() => app.db.run("UPDATE pinned_forge SET version = ?1, change_id = change_id + 1", [V2])).toThrow(/explicit Update/); | |
| 110 | // A change recorded for one version cannot authorize another. | |
| 111 | const c = app.db.query("INSERT INTO pinned_change (pinned_id, kind, from_version, to_version, from_config, to_config, by, at) VALUES (?1,'update',?2,?3,'{}','{}','x','now')").run(before.id, V1, V2); | |
| 112 | expect(() => app.db.run("UPDATE pinned_forge SET version = ?1, config = ?2, change_id = ?3", ["c".repeat(40), "{}", Number(c.lastInsertRowid)])).toThrow(/explicit Update/); | |
| 113 | // A deploy-kind change cannot be replayed as an update. | |
| 114 | const d = app.db.query("SELECT id FROM pinned_change WHERE kind = 'deploy'").get() as { id: number }; | |
| 115 | expect(() => app.db.run("UPDATE pinned_forge SET version = ?1, change_id = ?2", [V2, d.id])).toThrow(); | |
| 116 | // History cannot be rewritten or deleted. | |
| 117 | expect(() => app.db.run("UPDATE pinned_change SET by = 'someone'")).toThrow(/history/); | |
| 118 | expect(() => app.db.run("DELETE FROM pinned_change")).toThrow(/history/); | |
| 119 | expect(getPinned(app.db, "acme")!.version).toBe(before.version); | |
| 120 | // Not an admin, not a thing. | |
| 121 | const bob = getUserByName(app.db, "bob"); | |
| 122 | expect(await updatePinned(app, bob, "acme", { version: V2 })).toEqual({ ok: false, error: "not_found" }); | |
| 123 | expect(await rollbackPinned(app, undefined, "acme")).toEqual({ ok: false, error: "not_found" }); | |
| 124 | expect(getPinned(app.db, "acme")!.version).toBe(V1); | |
| 125 | }); | |
| 126 | ||
| 127 | test("Update records who and when, replaces the droplet and keeps the volume", async () => { | |
| 128 | calls.length = 0; | |
| 129 | const r = await updatePinned(app, admin(), "acme", { version: V2, config: { PUBLIC_NAME: "Acme Ltd" } }); | |
| 130 | expect(r.ok).toBe(true); | |
| 131 | expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([expect.stringMatching(/^DELETE \/v2\/droplets\/\d+$/), "POST /v2/droplets"]); | |
| 132 | expect(calls[1]!.body.volumes).toEqual(["vol-1"]); | |
| 133 | expect(calls[1]!.body.user_data).toContain(`checkout --detach ${V2}`); | |
| 134 | const [latest] = changesOf(app.db, getPinned(app.db, "acme")!.id); | |
| 135 | expect(latest).toMatchObject({ kind: "update", fromVersion: V1, toVersion: V2, by: admin().id }); | |
| 136 | expect(Date.parse(latest!.at)).toBeGreaterThan(0); | |
| 137 | }); | |
| 138 | ||
| 139 | test("Update refuses an unsafe version, secret-looking config, and a no-op", async () => { | |
| 140 | calls.length = 0; | |
| 141 | for (const version of ["main", "--upload-pack=x", `${V1}; rm -rf /`, "v1.2"]) | |
| 142 | expect(await updatePinned(app, admin(), "acme", { version })).toMatchObject({ ok: false }); | |
| 143 | expect(await updatePinned(app, admin(), "acme", { config: { API_TOKEN: "x" } })).toMatchObject({ ok: false }); | |
| 144 | expect(await updatePinned(app, admin(), "acme", { config: { A: "$(id)" } })).toMatchObject({ ok: false }); | |
| 145 | expect(await updatePinned(app, admin(), "acme", {})).toMatchObject({ ok: false, error: "That is what it runs already." }); | |
| 146 | expect(calls).toHaveLength(0); | |
| 147 | expect(getPinned(app.db, "acme")!.version).toBe(V2); | |
| 148 | }); | |
| 149 | ||
| 150 | test("Rollback is one action to the previous version and is recorded", async () => { | |
| 151 | const r = await rollbackPinned(app, admin(), "acme"); | |
| 152 | expect(r.ok && r.value).toMatchObject({ version: V1, config: { PUBLIC_NAME: "Acme" } }); | |
| 153 | const [latest] = changesOf(app.db, getPinned(app.db, "acme")!.id); | |
| 154 | expect(latest).toMatchObject({ kind: "rollback", fromVersion: V2, toVersion: V1 }); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a cloud failure leaves the change recorded and the forge marked failed", async () => { | |
| 158 | failNext = true; | |
| 159 | const r = await updatePinned(app, admin(), "acme", { version: V2 }); | |
| 160 | expect(r).toMatchObject({ ok: false }); | |
| 161 | expect(getPinned(app.db, "acme")).toMatchObject({ version: V2, state: "failed" }); | |
| 162 | expect(changesOf(app.db, getPinned(app.db, "acme")!.id)[0]).toMatchObject({ kind: "update", toVersion: V2 }); | |
| 163 | }); | |
| 164 | ||
| 165 | test("a forge with only its first version has nothing to roll back to", async () => { | |
| 166 | app.db.run("UPDATE pinned_forge SET name = 'old' WHERE name = 'acme'"); | |
| 167 | const r = await deployPinned(app, admin(), input({ name: "fresh" })); | |
| 168 | expect(r.ok).toBe(true); | |
| 169 | expect(await rollbackPinned(app, admin(), "fresh")).toMatchObject({ ok: false }); | |
| 170 | }); | |
| 171 | ||
| 172 | test("Cloudflare and Vercel are refused with a reason; AWS and Azure are named as not written", async () => { | |
| 173 | for (const target of ["cloudflare", "vercel", "aws", "azure"]) { | |
| 174 | const r = await deployPinned(app, admin(), input({ name: `x-${target}`, target })); | |
| 175 | expect(r.ok).toBe(false); | |
| 176 | } | |
| 177 | expect(TARGETS.map((t) => t.id)).toEqual(["digitalocean", "aws", "azure", "cloudflare", "vercel"]); | |
| 178 | expect(getPinned(app.db, "x-vercel")).toBeUndefined(); | |
| 179 | }); | |
| 180 | ||
| 181 | test("boot script and config hold no shell metacharacter that user text could smuggle", () => { | |
| 182 | expect(checkConfig({ A: "x\nB=1" }).ok).toBe(false); | |
| 183 | expect(checkConfig({ A: 'x"y' }).ok).toBe(false); | |
| 184 | expect(checkConfig({ "a b": "x" }).ok).toBe(false); | |
| 185 | const script = bootConfig({ name: "acme", source: SOURCE, version: V1, config: { A: "b" } }); | |
| 186 | expect(script).toContain("A=b"); | |
| 187 | expect(script.startsWith("#cloud-config")).toBe(true); | |
| 188 | }); | |
| 189 | ||
| 190 | test("HTTP: the admin routes list, update and roll back; a member sees nothing", async () => { | |
| 191 | const tok = createToken(app.db, admin().id, "admin", "admin").token; | |
| 192 | const bob = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token; | |
| 193 | const h = (t: string) => ({ authorization: `Bearer ${t}`, "content-type": "application/json" }); | |
| 194 | const list = await fetch(`${servers.url}/api/v1/admin/pinned`, { headers: h(tok) }); | |
| 195 | expect(list.status).toBe(200); | |
| 196 | const body = (await list.json()) as any; | |
| 197 | expect(body.targets).toHaveLength(5); | |
| 198 | expect(body.items.length).toBeGreaterThan(0); | |
| 199 | expect((await fetch(`${servers.url}/api/v1/admin/pinned`, { headers: h(bob) })).status).toBeGreaterThanOrEqual(403); | |
| 200 | const up = await fetch(`${servers.url}/api/v1/admin/pinned/fresh/update`, { method: "POST", headers: h(tok), body: JSON.stringify({ version: V2 }) }); | |
| 201 | expect(up.status).toBe(200); | |
| 202 | expect(((await up.json()) as any).version).toBe(V2); | |
| 203 | const back = await fetch(`${servers.url}/api/v1/admin/pinned/fresh/rollback`, { method: "POST", headers: h(tok), body: "{}" }); | |
| 204 | expect(((await back.json()) as any).version).toBe(V1); | |
| 205 | const bad = await fetch(`${servers.url}/api/v1/admin/pinned/fresh/update`, { method: "POST", headers: h(tok), body: JSON.stringify({ version: "main" }) }); | |
| 206 | expect(bad.status).toBe(422); | |
| 207 | const missing = await fetch(`${servers.url}/api/v1/admin/pinned/nope/rollback`, { method: "POST", headers: h(tok), body: "{}" }); | |
| 208 | expect(missing.status).toBe(404); | |
| 209 | }); | |
| 210 | ||
| 211 | test("MCP pinned_forge lists and updates for an admin-scope token", async () => { | |
| 212 | const tok = createToken(app.db, admin().id, "admin", "admin").token; | |
| 213 | const call = async (args: Record<string, unknown>) => { | |
| 214 | const res = await fetch(`${servers.url}/mcp`, { | |
| 215 | method: "POST", | |
| 216 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` }, | |
| 217 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "pinned_forge", arguments: args } }), | |
| 218 | }); | |
| 219 | const text = ((await res.json()) as any).result.content[0].text as string; | |
| 220 | return text.startsWith("{") ? JSON.parse(text) : { text }; | |
| 221 | }; | |
| 222 | expect((await call({ verb: "list" })).items.length).toBeGreaterThan(0); | |
| 223 | expect((await call({ verb: "update", name: "fresh", version: V2 })).says).toContain(V2); | |
| 224 | expect((await call({ verb: "update", name: "fresh", version: "nope" })).text).toContain("full 40-character"); | |
| 225 | }); | |
| 226 | }); |
new above
offline