Public: anyone can read this; the crew can change it.
Pinned forge: canary deploy on a real DigitalOcean account in under 10 minutes (follow-up to #8697) #8790
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8746-pinned-forge-canary-deploy-on-a-real-dig → main 2 commits fork
3 files
changed +225
−1
docs/features/pinned-forge.md +18 −1
| @@ -42,7 +42,24 @@ Each id is recorded the moment it exists. A first deploy that fails before a dro | ||
| 42 | 42 | |
| 43 | 43 | ## Not done |
| 44 | 44 | |
| 45 | - No canary on a real account: #8746. | |
| 45 | - No canary result on a real account yet: the script is written, the run needs an owner's token (#8746, see Canary). | |
| 46 | 46 | - No `Deploy to…` page or link: #8744. |
| 47 | 47 | - The droplet's address is not read back, so no URL is shown, no TLS or domain, no health check: #8745. |
| 48 | 48 | - AWS and Azure drivers. |
| 49 | ||
| 50 | ## Canary (#8746) | |
| 51 | ||
| 52 | The driver is tested against a fake API only. `scripts/pinned-canary.ts` runs one real Deploy, Update and Rollback through the admin API, times each, and fails past 10 minutes or when a version or state is not the asked one: | |
| 53 | ||
| 54 | ||
| 55 | FORGE_TOKEN=<admin token> bun scripts/pinned-canary.ts https://your.forge --account <do account, writes allowed> \ | |
| 56 | --app owner/name --source https://… --version <commit> --next <second commit or tag> | |
| 57 | ||
| 58 | ||
| 59 | The name defaults to `canary-<date>-<time>`, so a second run the same day does not collide. Versions and source are checked before any paid call, and on failure the script prints the droplet and volume ids it recorded. It leaves a droplet and a volume (~$13 a month) the driver cannot delete; remove them in the DigitalOcean console. `test/pinned-canary.test.ts` covers the script against a fake forge. | |
| 60 | ||
| 61 | ### Canary results | |
| 62 | ||
| 63 | Not run yet. It needs a DigitalOcean token an owner has put in Settings → Cloud accounts; none was available to the agent that wrote the script. Paste the table the script prints here, with the date, and close #8746. | |
| 64 | ||
| 65 | This is an operator script, not a feature: it has no screen in `src/tui/` and no MCP verb because it drives the doors that exist (the HTTP admin API, the same actions the terminal and `pinned_forge` use); a second door to a one-off paid test would be one more thing to keep honest. It is small, touches no `src/`, and was written so the owner's run is one command; it does not displace the pipeline list. The pull refers to #8746 and does not close it: the issue closes when the table above is real. |
scripts/pinned-canary.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/pinned-canary.ts <forge url> --account <name> --app <owner/name> --source <https git url> | |
| 3 | * --version <40-hex or vX.Y.Z> --next <second version> [--name <pinned name>] [--budget <seconds>]` | |
| 4 | * | |
| 5 | * The canary for pinned forges (#8746): one real Deploy, Update and Rollback | |
| 6 | * through the forge's admin API against a real cloud account, each step timed, | |
| 7 | * the whole run held to a budget (default 600 s). `FORGE_TOKEN` is an | |
| 8 | * admin-scope token of the forge that holds the cloud account (writes allowed); | |
| 9 | * the cloud token itself never passes through here. Prints the table to paste | |
| 10 | * into docs/features/pinned-forge.md ("Canary results") and exits 1 when a step | |
| 11 | * fails, a version is not the one asked for, or the budget is spent. | |
| 12 | * | |
| 13 | * It creates a droplet and a volume the driver cannot delete: remove them in | |
| 14 | * the cloud console afterwards (the report names the ids). | |
| 15 | */ | |
| 16 | ||
| 17 | export interface Step { | |
| 18 | readonly step: string; | |
| 19 | readonly seconds: number; | |
| 20 | readonly version: string; | |
| 21 | readonly state: string; | |
| 22 | readonly remote: string; | |
| 23 | readonly volume: string; | |
| 24 | } | |
| 25 | ||
| 26 | export interface Report { | |
| 27 | readonly steps: Step[]; | |
| 28 | readonly total: number; | |
| 29 | readonly budget: number; | |
| 30 | readonly failure: string | undefined; | |
| 31 | } | |
| 32 | ||
| 33 | type Call = (path: string, body: unknown) => Promise<{ status: number; json: Record<string, unknown> }>; | |
| 34 | ||
| 35 | const text = (v: unknown): string => (typeof v === "string" ? v : ""); | |
| 36 | ||
| 37 | /** Run deploy, update, rollback through `call`; `now` is seconds (injected so a test needs no clock). */ | |
| 38 | export async function canary( | |
| 39 | call: Call, | |
| 40 | now: () => number, | |
| 41 | o: { name: string; account: string; app: string; source: string; version: string; next: string; budget: number }, | |
| 42 | ): Promise<Report> { | |
| 43 | const steps: Step[] = []; | |
| 44 | const t0 = now(); | |
| 45 | const plan: Array<[string, string, unknown, string]> = [ | |
| 46 | ["deploy", "/api/v1/admin/pinned", { name: o.name, target: "digitalocean", account: o.account, app: o.app, source: o.source, version: o.version }, o.version], | |
| 47 | ["update", `/api/v1/admin/pinned/${o.name}/update`, { version: o.next }, o.next], | |
| 48 | ["rollback", `/api/v1/admin/pinned/${o.name}/rollback`, {}, o.version], | |
| 49 | ]; | |
| 50 | const finish = (failure?: string): Report => ({ steps, total: now() - t0, budget: o.budget, failure }); | |
| 51 | for (const [step, path, body, want] of plan) { | |
| 52 | const t = now(); | |
| 53 | let r; | |
| 54 | try { | |
| 55 | r = await call(path, body); | |
| 56 | } catch (e) { | |
| 57 | return finish(`${step}: ${e instanceof Error ? e.message : "request failed"}`); | |
| 58 | } | |
| 59 | const s: Step = { step, seconds: now() - t, version: text(r.json.version), state: text(r.json.state), remote: text(r.json.remote), volume: text(r.json.volume) }; | |
| 60 | steps.push(s); | |
| 61 | if (r.status >= 300) return finish(`${step}: HTTP ${r.status} ${text(r.json.message) || text(r.json.error)}`.trim()); | |
| 62 | if (s.state !== "running") return finish(`${step}: state is "${s.state}", not running`); | |
| 63 | if (s.version !== want) return finish(`${step}: version is "${s.version}", wanted "${want}"`); | |
| 64 | if (!s.remote) return finish(`${step}: no droplet id recorded`); | |
| 65 | } | |
| 66 | const total = now() - t0; | |
| 67 | return finish(total > o.budget ? `took ${total.toFixed(0)} s, over the ${o.budget} s budget` : undefined); | |
| 68 | } | |
| 69 | ||
| 70 | const VERSION = /^(?:[0-9a-f]{40}|v\d+\.\d+\.\d+)$/; | |
| 71 | ||
| 72 | /** Refuse before any paid call: a version is a full commit id or vX.Y.Z; the source is https. */ | |
| 73 | export function invalid(o: { version: string; next: string; source: string }): string | undefined { | |
| 74 | if (!VERSION.test(o.version)) return "--version is a 40-character commit id or a tag like v1.2.3"; | |
| 75 | if (!VERSION.test(o.next)) return "--next is a 40-character commit id or a tag like v1.2.3"; | |
| 76 | if (o.version === o.next) return "--next must differ from --version, or the Update changes nothing"; | |
| 77 | try { | |
| 78 | if (new URL(o.source).protocol !== "https:") return "--source must be https"; | |
| 79 | } catch { | |
| 80 | return "--source must be an https URL"; | |
| 81 | } | |
| 82 | return undefined; | |
| 83 | } | |
| 84 | ||
| 85 | /** What a failed run may have left billing: every droplet and volume id the steps recorded. */ | |
| 86 | export function leftovers(r: Report): string { | |
| 87 | const droplets = [...new Set(r.steps.map((s) => s.remote).filter(Boolean))]; | |
| 88 | const volumes = [...new Set(r.steps.map((s) => s.volume).filter(Boolean))]; | |
| 89 | return `droplets: ${droplets.join(", ") || "none recorded"}; volumes: ${volumes.join(", ") || "none recorded"}`; | |
| 90 | } | |
| 91 | ||
| 92 | /** The table for the doc: step, seconds, version, droplet, volume. */ | |
| 93 | export function table(r: Report): string { | |
| 94 | const rows = r.steps.map((s) => `| ${s.step} | ${s.seconds.toFixed(1)} | ${s.version.slice(0, 12)} | ${s.remote} | ${s.volume} |`); | |
| 95 | return [ | |
| 96 | "| Step | Seconds | Version | Droplet | Volume |", | |
| 97 | "|---|---|---|---|---|", | |
| 98 | ...rows, | |
| 99 | `| **total** | **${r.total.toFixed(1)}** (budget ${r.budget}) | | | |`, | |
| 100 | ].join("\n"); | |
| 101 | } | |
| 102 | ||
| 103 | if (import.meta.main) { | |
| 104 | const args = process.argv.slice(2); | |
| 105 | const flag = (n: string): string | undefined => { | |
| 106 | const i = args.indexOf(n); | |
| 107 | return i < 0 ? undefined : args[i + 1]; | |
| 108 | }; | |
| 109 | const base = args[0]; | |
| 110 | const token = process.env.FORGE_TOKEN; | |
| 111 | const [account, app, source, version, next] = ["--account", "--app", "--source", "--version", "--next"].map(flag); | |
| 112 | const budget = Number(flag("--budget") ?? 600); | |
| 113 | if (!base?.startsWith("http") || !token || !account || !app || !source || !version || !next || !(budget > 0)) { | |
| 114 | console.error("usage: FORGE_TOKEN=<admin token> bun scripts/pinned-canary.ts <forge url> --account <n> --app <owner/name> --source <https url> --version <v> --next <v2> [--name <n>] [--budget <s>]"); | |
| 115 | process.exit(2); | |
| 116 | } | |
| 117 | const call: Call = async (path, body) => { | |
| 118 | const res = await fetch(new URL(path, base), { | |
| 119 | method: "POST", | |
| 120 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 121 | body: JSON.stringify(body), | |
| 122 | }); | |
| 123 | return { status: res.status, json: (await res.json().catch(() => ({}))) as Record<string, unknown> }; | |
| 124 | }; | |
| 125 | const refused = invalid({ version, next, source }); | |
| 126 | if (refused) { | |
| 127 | console.error(refused); | |
| 128 | process.exit(2); | |
| 129 | } | |
| 130 | const name = flag("--name") ?? `canary-${new Date().toISOString().slice(0, 19).replace(/\D/g, "").replace(/^(\d{8})/, "$1-")}`; | |
| 131 | const report = await canary(call, () => performance.now() / 1000, { name, account, app, source, version, next, budget }); | |
| 132 | console.log(table(report)); | |
| 133 | if (report.failure) { | |
| 134 | console.error(`FAILED: ${report.failure}\nStill billing, remove in the cloud console: ${leftovers(report)}`); | |
| 135 | process.exit(1); | |
| 136 | } | |
| 137 | } |
test/pinned-canary.test.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { canary, invalid, leftovers, table } from "../scripts/pinned-canary.ts"; | |
| 3 | ||
| 4 | const A = "a".repeat(40); | |
| 5 | const B = "b".repeat(40); | |
| 6 | const o = { name: "c", account: "do", app: "o/n", source: "https://x/y.git", version: A, next: B, budget: 600 }; | |
| 7 | ||
| 8 | function fake(opts: { rollbackTo?: string; status?: number } = {}) { | |
| 9 | const seen: string[] = []; | |
| 10 | let clock = 0; | |
| 11 | const call = async (path: string) => { | |
| 12 | seen.push(path); | |
| 13 | clock += 100; | |
| 14 | const version = path.endsWith("/update") ? B : path.endsWith("/rollback") ? (opts.rollbackTo ?? A) : A; | |
| 15 | return { status: opts.status ?? 200, json: { version, state: "running", remote: "d1", volume: "v1" } }; | |
| 16 | }; | |
| 17 | return { call, seen, now: () => clock }; | |
| 18 | } | |
| 19 | ||
| 20 | test("deploy, update, rollback are timed and the table totals them", async () => { | |
| 21 | const f = fake(); | |
| 22 | const r = await canary(f.call, f.now, o); | |
| 23 | expect(r.failure).toBeUndefined(); | |
| 24 | expect(f.seen).toEqual(["/api/v1/admin/pinned", "/api/v1/admin/pinned/c/update", "/api/v1/admin/pinned/c/rollback"]); | |
| 25 | expect(r.steps.map((s) => s.seconds)).toEqual([100, 100, 100]); | |
| 26 | expect(table(r)).toContain("| **total** | **300.0** (budget 600) |"); | |
| 27 | }); | |
| 28 | ||
| 29 | test("a rollback that lands on the wrong version fails the canary", async () => { | |
| 30 | const f = fake({ rollbackTo: B }); | |
| 31 | expect((await canary(f.call, f.now, o)).failure).toContain("rollback: version"); | |
| 32 | }); | |
| 33 | ||
| 34 | test("an HTTP refusal stops at that step; a spent budget fails", async () => { | |
| 35 | const f = fake({ status: 422 }); | |
| 36 | const r = await canary(f.call, f.now, o); | |
| 37 | expect(r.steps).toHaveLength(1); | |
| 38 | expect(r.failure).toContain("deploy: HTTP 422"); | |
| 39 | const g = fake(); | |
| 40 | expect((await canary(g.call, g.now, { ...o, budget: 250 })).failure).toContain("over the 250 s budget"); | |
| 41 | }); | |
| 42 | ||
| 43 | test("a thrown request ends the run and names the step", async () => { | |
| 44 | const r = await canary(async () => { throw new Error("ECONNRESET"); }, () => 0, o); | |
| 45 | expect(r.failure).toBe("deploy: ECONNRESET"); | |
| 46 | expect(r.steps).toHaveLength(0); | |
| 47 | expect(leftovers(r)).toBe("droplets: none recorded; volumes: none recorded"); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a response with no droplet id fails, and its volume is still named for cleanup", async () => { | |
| 51 | const call = async () => ({ status: 201, json: { version: A, state: "running", remote: "", volume: "v9" } }); | |
| 52 | const r = await canary(call, () => 0, o); | |
| 53 | expect(r.failure).toBe("deploy: no droplet id recorded"); | |
| 54 | expect(leftovers(r)).toBe("droplets: none recorded; volumes: v9"); | |
| 55 | }); | |
| 56 | ||
| 57 | test("leftovers lists the ids of the steps that ran", async () => { | |
| 58 | const f = fake({ rollbackTo: B }); | |
| 59 | expect(leftovers(await canary(f.call, f.now, o))).toBe("droplets: d1; volumes: v1"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("versions and source are checked before any paid call", () => { | |
| 63 | const ok = { version: A, next: "v1.2.3", source: "https://x/y.git" }; | |
| 64 | expect(invalid(ok)).toBeUndefined(); | |
| 65 | expect(invalid({ ...ok, version: "main" })).toContain("--version"); | |
| 66 | expect(invalid({ ...ok, next: "abc123" })).toContain("--next"); | |
| 67 | expect(invalid({ ...ok, next: A })).toContain("differ"); | |
| 68 | expect(invalid({ ...ok, source: "http://x/y.git" })).toContain("https"); | |
| 69 | expect(invalid({ ...ok, source: "git@x:y.git" })).toContain("https"); | |
| 70 | }); |
new above
offline