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

Merged opened this

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

Merged

torque/forge:torque/8746-pinned-forge-canary-deploy-on-a-real-dig → main 2 commits fork

  • Merged. torque/8746-pinned-forge-canary-deploy-on-a-real-dig was merged into main as 6970def.

Review

Sign in to review this change.
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
4242
4343 ## Not done
4444
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).
4646 - No `Deploy to…` page or link: #8744.
4747 - The droplet's address is not read back, so no URL is shown, no TLS or domain, no health check: #8745.
4848 - 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs