g1t/services/deployments/src/index.ts

925 lines36,389 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Deployments: a preview for every pull request, production on g1t.page1/**
2 * The deployments service: every pull request gets a live preview on
3 * g1t.page, and the default branch goes to production on every push.
4 *
5 * It reacts to events (a pull request opened, ready, pushed to, closed or
6 * merged; a push to the default branch), asks billing whether the
7 * workspace pays for Deployments, and asks the runner to build the commit
8 * in a sandbox. The sandbox reports back through the API with a token for
9 * that build alone; this service opens the upload of its files and puts
10 * the finished app in the Workers for Platforms namespace, where the
11 * `*.g1t.page` dispatcher finds it by hostname.
12 *
13 * Nothing here is free. A build is charged by the second; requests, CPU
14 * time and apps past the plan's allowance are charged once the month is
15 * over. A Worker runs only while it answers a request, so an app no one
16 * visits costs nothing, and a preview is taken down when its pull request
17 * closes or after its repository's idle days.
18 *
19 * Reached through service bindings (`POST /rpc/<method>`) and, for a
20 * build's reports, through the API (`POST /jobs/<id>/<step>`).
21 */
22
23import {
24 DEPLOYMENTS_ALLOWANCE,
25 billingClient,
26 fail,
27 identityClient,
28 newId,
29 ok,
30 reposClient,
31 workClient,
32 type DeployKind,
33 type DeploySettings,
34 type DeployStatus,
35 type DeployUsage,
36 type Deployment,
37 type G1tEvent,
38 type LiveApp,
39 type RepoPath,
40 type Result,
41 type ServiceBinding,
42 type User,
43 type Viewer,
44} from "@g1t/contracts";
45
46import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
47import { appUrl, scriptName } from "./names";
48
49type Env = {
50 DB: D1Database;
51 REPOS: ServiceBinding;
52 WORK: ServiceBinding;
53 IDENTITY: ServiceBinding;
54 BILLING: ServiceBinding;
55 RUNNER: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments56 /** Secrets and variables: the actions service holds the one store. */
57 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page58 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
59 CLOUDFLARE_API_TOKEN?: string;
60 CLOUDFLARE_ACCOUNT_ID: string;
61 DISPATCH_NAMESPACE: string;
62 SITE: string;
63};
64
65/** A build that has not reported in this long has died. */
66const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
67const LIST_LIMIT = 50;
68const STATUS_CONTEXT = "g1t / deploy";
69
70const now = () => new Date().toISOString();
71const month = (at = new Date()) => at.toISOString().slice(0, 7);
72
73async function sha256(text: string): Promise<string> {
74 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
75 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
76}
77
78function randomToken(): string {
79 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
80}
81
82function isMember(viewer: Viewer, slug: string): boolean {
83 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
84}
85
86type SettingsRow = {
87 repo_id: string;
88 namespace: string;
89 name: string;
90 enabled: number;
91 previews: number;
92 production: number;
93 build_command: string | null;
94 output_dir: string | null;
95 idle_days: number;
96};
97
98type DeploymentRow = {
99 id: string;
100 repo_id: string;
101 namespace: string;
102 name: string;
103 kind: DeployKind;
104 number: number | null;
105 commit_sha: string;
106 script: string;
107 status: DeployStatus;
108 error: string | null;
109 warnings: string;
110 log: string | null;
111 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments112 trusted: number;
Deployments: a preview for every pull request, production on g1t.page113 build_seconds: number | null;
114 created_by: string;
115 created_at: string;
116 finished_at: string | null;
117};
118
119type AppRow = {
120 script: string;
121 repo_id: string;
122 namespace: string;
123 name: string;
124 kind: DeployKind;
125 number: number | null;
126 commit_sha: string;
127 deployed_at: string;
128 created_at: string;
129 last_request_at: string | null;
130};
131
132function toDeployment(row: DeploymentRow): Deployment {
133 return {
134 id: row.id,
135 kind: row.kind,
136 number: row.number,
137 commit: row.commit_sha,
138 status: row.status,
139 url: appUrl(row.script),
140 error: row.error,
141 warnings: JSON.parse(row.warnings || "[]") as string[],
142 buildSeconds: row.build_seconds,
143 createdBy: row.created_by,
144 createdAt: row.created_at,
145 finishedAt: row.finished_at,
146 };
147}
148
149class Deployments {
150 constructor(private readonly env: Env) {}
151
152 private get cloudflare(): Cloudflare | null {
153 const token = this.env.CLOUDFLARE_API_TOKEN;
154 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
155 }
156
157 private get db() {
158 return this.env.DB;
159 }
160
161 /** The workspace itself, as the service acts for it. */
162 private async workspaceActor(slug: string): Promise<User | null> {
163 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
164 if (!workspace) return null;
165 return {
166 id: workspace.id,
167 username: workspace.slug,
168 kind: "workspace",
169 verified: true,
170 workspaces: [{ slug: workspace.slug, role: "member" }],
171 };
172 }
173
174 private async pathById(id: string): Promise<RepoPath | null> {
175 const response = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
176 method: "POST",
177 headers: { "content-type": "application/json" },
178 body: JSON.stringify({ id }),
179 });
180 return response.ok ? ((await response.json()) as RepoPath | null) : null;
181 }
182
Secrets and variables: one list, rows per environment, for workflows and deployments183 /**
184 * What the repository's secrets and variables available to deployments
185 * give production or a preview: its build's environment, and the same
186 * again as the running app's bindings. Untrusted builds get no secrets.
187 */
188 private async resolve(
189 repoId: string,
190 repo: RepoPath,
191 environment: DeployKind,
192 trusted: boolean,
193 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
194 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
195 method: "POST",
196 headers: { "content-type": "application/json" },
197 body: JSON.stringify({ repoId, repo, consumer: "deployments", environment, trusted }),
198 });
199 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
200 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
201 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
202 }
203
204 /**
205 * Whether a pull request's author is trusted with the repository's
206 * secrets: g1t's agent, or a member of the workspace. Someone from
207 * outside gets a preview built without them, as their workflows run.
208 */
209 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
210 if (author.kind === "agent" || author.username === "g1t-agent") return true;
211 // On a private repository only members can open one at all.
212 const found = await reposClient(this.env.REPOS).get(repo, actor);
213 if (found.ok && found.value.isPrivate) return true;
214 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
215 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
216 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
217 }
218
Deployments: a preview for every pull request, production on g1t.page219 private async settingsRow(repoId: string): Promise<SettingsRow | null> {
220 return this.db.prepare("SELECT * FROM settings WHERE repo_id = ?").bind(repoId).first<SettingsRow>();
221 }
222
223 private async toSettings(repo: RepoPath, row: SettingsRow | null): Promise<DeploySettings> {
224 return {
225 enabled: !!row?.enabled,
226 previews: row ? !!row.previews : true,
227 production: row ? !!row.production : true,
228 buildCommand: row?.build_command ?? null,
229 outputDir: row?.output_dir ?? null,
230 idleDays: row?.idle_days ?? 7,
231 productionUrl: appUrl(await scriptName(repo, null)),
232 };
233 }
234
235 /** The repository, if `viewer` belongs to its workspace and it is not a fork. */
236 private async memberRepo(repo: RepoPath, viewer: Viewer) {
237 if (!isMember(viewer, repo.namespace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
238 const found = await reposClient(this.env.REPOS).get(repo, viewer);
239 if (!found.ok) return found;
240 if (found.value.forkOf) return fail("invalid", "A pull request's working copy does not deploy on its own.");
241 return found;
242 }
243
244 // ---- Methods for the site and the API ------------------------------
245
246 async settings(a: { repo: RepoPath; viewer: Viewer }): Promise<Result<DeploySettings>> {
247 const repo = await this.memberRepo(a.repo, a.viewer);
248 if (!repo.ok) return repo;
249 return ok(await this.toSettings(a.repo, await this.settingsRow(repo.value.id)));
250 }
251
252 async updateSettings(a: {
253 actor: User;
254 repo: RepoPath;
255 changes: Partial<DeploySettings>;
256 }): Promise<Result<DeploySettings>> {
257 const repo = await this.memberRepo(a.repo, a.actor);
258 if (!repo.ok) return repo;
259 const before = await this.toSettings(a.repo, await this.settingsRow(repo.value.id));
260 const next = { ...before, ...a.changes };
261 if (next.enabled && !before.enabled) {
262 // Turning it on starts paid work: only with the workspace's plan.
263 const plan = await billingClient(this.env.BILLING).hasFeature(a.repo.namespace, "deployments");
264 if (!plan.ok) return plan;
265 }
266 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
267 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
268 await this.db
269 .prepare(
270 `INSERT INTO settings (repo_id, namespace, name, enabled, previews, production, build_command, output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments271 idle_days, updated_by, updated_at)
272 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
Deployments: a preview for every pull request, production on g1t.page273 ON CONFLICT (repo_id) DO UPDATE SET namespace = ?2, name = ?3, enabled = ?4, previews = ?5, production = ?6,
Secrets and variables: one list, rows per environment, for workflows and deployments274 build_command = ?7, output_dir = ?8, idle_days = ?9, updated_by = ?10, updated_at = ?11`,
Deployments: a preview for every pull request, production on g1t.page275 )
276 .bind(
277 repo.value.id,
278 repo.value.namespace,
279 repo.value.name,
280 next.enabled ? 1 : 0,
281 next.previews ? 1 : 0,
282 next.production ? 1 : 0,
283 clip(next.buildCommand),
284 clip(next.outputDir),
285 idleDays,
286 a.actor.username,
287 now(),
288 )
289 .run();
290 // What was turned off comes down now; nothing keeps running unasked.
291 if (!next.enabled) await this.takeDownWhere(repo.value.id, null);
292 else {
293 if (!next.previews) await this.takeDownWhere(repo.value.id, "preview");
294 if (!next.production) await this.takeDownWhere(repo.value.id, "production");
295 }
296 // Turned on: production goes up from the default branch at once.
297 if (next.enabled && next.production && (!before.enabled || !before.production)) {
298 await this.deployProduction(repo.value.id, a.repo, repo.value.defaultBranch, null, a.actor.username);
299 }
300 return ok(await this.toSettings(a.repo, await this.settingsRow(repo.value.id)));
301 }
302
303 async list(a: { repo: RepoPath; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
304 const repo = await this.memberRepo(a.repo, a.viewer);
305 if (!repo.ok) return repo;
306 const [deployments, apps] = await Promise.all([
307 this.db
308 .prepare("SELECT * FROM deployments WHERE repo_id = ? ORDER BY id DESC LIMIT ?")
309 .bind(repo.value.id, LIST_LIMIT)
310 .all<DeploymentRow>(),
311 this.db
312 .prepare("SELECT * FROM apps WHERE repo_id = ? ORDER BY kind DESC, number DESC")
313 .bind(repo.value.id)
314 .all<AppRow>(),
315 ]);
316 return ok({
317 deployments: deployments.results.map(toDeployment),
318 live: apps.results.map((app) => ({
319 kind: app.kind,
320 number: app.number,
321 url: appUrl(app.script),
322 commit: app.commit_sha,
323 deployedAt: app.deployed_at,
324 })),
325 });
326 }
327
328 async get(a: { repo: RepoPath; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
329 const repo = await this.memberRepo(a.repo, a.viewer);
330 if (!repo.ok) return repo;
331 const row = await this.db
332 .prepare("SELECT * FROM deployments WHERE id = ? AND repo_id = ?")
333 .bind(a.id, repo.value.id)
334 .first<DeploymentRow>();
335 if (!row) return fail("not_found", "No such deployment.");
336 return ok({ ...toDeployment(row), log: row.log });
337 }
338
339 async redeploy(a: { actor: User; repo: RepoPath; number: number | null }): Promise<Result<Deployment>> {
340 const repo = await this.memberRepo(a.repo, a.actor);
341 if (!repo.ok) return repo;
342 const settings = await this.settingsRow(repo.value.id);
343 if (!settings?.enabled) return fail("conflict", "Deployments are off for this repository.");
344 const started =
345 a.number == null
346 ? await this.deployProduction(repo.value.id, a.repo, repo.value.defaultBranch, null, a.actor.username)
347 : await this.deployPreview(repo.value.id, a.repo, a.number, a.actor.username, true);
348 return started ?? fail("conflict", "There was nothing to deploy.");
349 }
350
351 async takeDown(a: { actor: User; repo: RepoPath; number: number | null }): Promise<Result<true>> {
352 const repo = await this.memberRepo(a.repo, a.actor);
353 if (!repo.ok) return repo;
354 const script = await scriptName(a.repo, a.number);
355 await this.removeApp(script);
356 return ok(true);
357 }
358
359 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
360 const slug = a.workspace.toLowerCase();
361 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
362 const [meter, apps] = await Promise.all([
363 this.db
364 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
365 .bind(slug, month())
366 .first<{
367 requests: number;
368 cpu_ms: number;
369 peak_apps: number;
370 build_seconds: number;
371 build_micros: number;
372 counted_at: string | null;
373 }>(),
374 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE namespace = ?").bind(slug).first<{ n: number }>(),
375 ]);
376 return ok({
377 month: month(),
378 requests: meter?.requests ?? 0,
379 cpuMs: meter?.cpu_ms ?? 0,
380 apps: apps?.n ?? 0,
381 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
382 buildSeconds: meter?.build_seconds ?? 0,
383 buildMicros: meter?.build_micros ?? 0,
384 countedAt: meter?.counted_at ?? null,
385 });
386 }
387
388 // ---- Starting builds -----------------------------------------------
389
390 /**
391 * Opens a deployment and starts its build. Skipped, with the reason
392 * recorded, when the workspace's plan is off.
393 */
394 private async start(input: {
395 repoId: string;
396 repo: RepoPath;
397 kind: DeployKind;
398 number: number | null;
399 commit: string;
400 source: RepoPath;
401 reader: User;
402 createdBy: string;
403 settings: SettingsRow;
Secrets and variables: one list, rows per environment, for workflows and deployments404 /** A push, or work by a member or an agent; see `trusted`. */
405 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page406 }): Promise<Result<Deployment>> {
407 const script = await scriptName(input.repo, input.number);
408 const id = newId("dpl");
409 const token = randomToken();
410 const plan = await billingClient(this.env.BILLING).hasFeature(input.repo.namespace, "deployments");
411 const cloudflare = this.cloudflare;
412 const refused = !plan.ok
413 ? plan.error.message
414 : !cloudflare
415 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
416 : null;
417 await this.db
418 .prepare(
419 `INSERT INTO deployments (id, repo_id, namespace, name, kind, number, commit_sha, script, status, error,
Secrets and variables: one list, rows per environment, for workflows and deployments420 token_hash, trusted, created_by, created_at, finished_at)
421 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page422 )
423 .bind(
424 id,
425 input.repoId,
426 input.repo.namespace,
427 input.repo.name,
428 input.kind,
429 input.number,
430 input.commit,
431 script,
432 refused ? "skipped" : "queued",
433 refused,
434 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments435 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page436 input.createdBy,
437 now(),
438 refused ? now() : null,
439 )
440 .run();
441 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
442 // Older builds of the same app are replaced by this one.
443 await this.db
444 .prepare(
445 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
446 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
447 )
448 .bind(now(), script, id)
449 .run();
450 await this.status(input.repoId, input.commit, "pending", "Building", `${this.env.SITE}/${input.repo.namespace}/${input.repo.name}/deployments/${id}`);
Secrets and variables: one list, rows per environment, for workflows and deployments451 // What the repository's secrets and variables give builds of this kind.
452 const build = await this.resolve(input.repoId, input.repo, input.kind, input.trusted);
Deployments: a preview for every pull request, production on g1t.page453 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
454 method: "POST",
455 headers: { "content-type": "application/json" },
456 body: JSON.stringify({
457 deployId: id,
458 token,
459 actor: input.reader,
460 source: input.source,
461 commit: input.commit,
462 buildCommand: input.settings.build_command,
463 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments464 buildEnv: build.variables,
465 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page466 }),
467 });
468 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
469 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
470 return ok(toDeployment((await this.deploymentRow(id))!));
471 }
472
473 private async deployProduction(
474 repoId: string,
475 repo: RepoPath,
476 branch: string,
477 commit: string | null,
478 createdBy: string,
479 ): Promise<Result<Deployment> | null> {
480 const settings = await this.settingsRow(repoId);
481 if (!settings?.enabled || !settings.production) return null;
482 const actor = await this.workspaceActor(repo.namespace);
483 if (!actor) return null;
484 let head = commit;
485 if (!head) {
486 const branches = await reposClient(this.env.REPOS).branches(repo, actor);
487 head = branches.ok ? (branches.value.find((b) => b.name === branch)?.hash ?? null) : null;
488 }
489 if (!head) return null;
490 return this.start({
491 repoId,
492 repo,
493 kind: "production",
494 number: null,
495 commit: head,
496 source: repo,
497 reader: actor,
498 createdBy,
499 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments500 // The default branch only moves by people and agents with access.
501 trusted: true,
Deployments: a preview for every pull request, production on g1t.page502 });
503 }
504
505 private async deployPreview(
506 repoId: string,
507 repo: RepoPath,
508 number: number,
509 createdBy: string,
510 force = false,
511 ): Promise<Result<Deployment> | null> {
512 const settings = await this.settingsRow(repoId);
513 if (!settings?.enabled || !settings.previews) return null;
514 const actor = await this.workspaceActor(repo.namespace);
515 if (!actor) return null;
516 const detail = await workClient(this.env.WORK).getPull(repo, number, actor);
517 if (!detail.ok) return null;
518 const { pull } = detail.value;
519 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
520 if (!force) {
521 // Already built, or being built, at this commit.
522 const same = await this.db
523 .prepare(
524 `SELECT id FROM deployments WHERE repo_id = ? AND kind = 'preview' AND number = ? AND commit_sha = ?
525 AND status IN ('queued', 'building', 'ready')`,
526 )
527 .bind(repoId, number, pull.headCommit)
528 .first();
529 if (same) return null;
530 }
531 return this.start({
532 repoId,
533 repo,
534 kind: "preview",
535 number,
536 commit: pull.headCommit,
537 source: pull.fork ?? repo,
538 // The pull request's fork may be private: read it as its author.
539 reader: pull.author,
540 createdBy,
541 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments542 trusted: await this.insider(repo, pull.author, actor),
Deployments: a preview for every pull request, production on g1t.page543 });
544 }
545
546 // ---- A build's reports ---------------------------------------------
547
548 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
549 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
550 }
551
552 /** The build, if `token` is its own and it is still under way. */
553 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
554 const row = await this.deploymentRow(id);
555 if (!row?.token_hash || typeof token !== "string") return null;
556 if (row.token_hash !== (await sha256(token))) return null;
557 return row.status === "queued" || row.status === "building" ? row : null;
558 }
559
560 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run561 // Each report, for the logs: a build's own failure says why.
562 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page563 const row = await this.building(id, body.token);
564 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
565 const cloudflare = this.cloudflare;
566 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
567 switch (step) {
568 case "started":
569 await this.db
570 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
571 .bind(now(), id)
572 .run();
573 return Response.json(ok(true));
574 case "session": {
575 const manifest = body.manifest as Manifest | undefined;
576 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
577 const session = await cloudflare.openUpload(row.script, manifest);
578 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
579 }
580 case "finish": {
581 const worker = (body.worker ?? {}) as BuiltWorker;
582 const seconds = Number(body.buildSeconds) || 0;
583 try {
Secrets and variables: one list, rows per environment, for workflows and deployments584 // Running apps' secrets and variables are bound here, by g1t:
585 // they never pass through the build's sandbox.
586 const runtime = await this.resolve(row.repo_id, { namespace: row.namespace, name: row.name }, row.kind, !!row.trusted);
Deployments: a preview for every pull request, production on g1t.page587 await cloudflare.putScript(
588 row.script,
589 worker,
590 typeof body.completionJwt === "string" ? body.completionJwt : null,
591 [`workspace:${row.namespace}`, `repo:${row.namespace}/${row.name}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments592 runtime,
Deployments: a preview for every pull request, production on g1t.page593 );
594 } catch (error) {
595 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
596 return Response.json(ok(false));
597 }
598 const at = now();
599 await this.db.batch([
600 this.db
601 .prepare(
602 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
603 WHERE id = ?`,
604 )
605 .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
606 this.db
607 .prepare(
608 `INSERT INTO apps (script, repo_id, namespace, name, kind, number, commit_sha, deployed_at, created_at)
609 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)
610 ON CONFLICT (script) DO UPDATE SET commit_sha = ?7, deployed_at = ?8`,
611 )
612 .bind(row.script, row.repo_id, row.namespace, row.name, row.kind, row.number, row.commit_sha, at),
613 ]);
614 await this.chargeBuild(row, seconds);
615 await this.notePeak(row.namespace);
616 await this.status(
617 row.repo_id,
618 row.commit_sha,
619 "success",
620 row.kind === "preview" ? "Preview is live" : "Production is live",
621 appUrl(row.script),
622 );
623 return Response.json(ok(true));
624 }
625 case "fail":
626 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
627 return Response.json(ok(true));
628 default:
629 return Response.json(fail("not_found", "No such step."), { status: 404 });
630 }
631 }
632
633 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
634 const row = await this.deploymentRow(id);
635 if (!row || (row.status !== "queued" && row.status !== "building")) return;
636 await this.db
637 .prepare(
638 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
639 WHERE id = ?`,
640 )
641 .bind(message.slice(0, 2000), log, seconds, now(), id)
642 .run();
643 // A failed build still used its sandbox.
644 if (seconds) await this.chargeBuild(row, seconds);
645 await this.status(
646 row.repo_id,
647 row.commit_sha,
648 "failure",
649 "Deployment failed",
650 `${this.env.SITE}/${row.namespace}/${row.name}/deployments/${id}`,
651 );
652 }
653
654 /** Each build is charged by the second at the container price plus the margin. */
655 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
656 const cost = Math.ceil(seconds) * DEPLOYMENTS_ALLOWANCE.microsPerBuildSecond;
657 if (cost <= 0) return;
658 const what = row.kind === "preview" ? `the preview of ${row.namespace}/${row.name}#${row.number}` : `${row.namespace}/${row.name} to production`;
659 await billingClient(this.env.BILLING).chargeFeature({
660 workspace: row.namespace,
661 feature: "deployments",
662 costMicros: cost,
663 description: `Building ${what} (${Math.ceil(seconds)} s)`,
664 repo: `${row.namespace}/${row.name}`,
665 reference: `deploy/${row.id}`,
666 });
667 await this.db
668 .prepare(
669 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
670 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
671 )
672 .bind(row.namespace, month(), Math.ceil(seconds), cost)
673 .run();
674 }
675
676 /** Remembers the most apps the workspace had up at once this month. */
677 private async notePeak(namespace: string): Promise<void> {
678 await this.db
679 .prepare(
680 `INSERT INTO meters (namespace, month, peak_apps)
681 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE namespace = ?1))
682 ON CONFLICT (namespace, month) DO UPDATE SET
683 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE namespace = ?1))`,
684 )
685 .bind(namespace, month())
686 .run();
687 }
688
689 private async status(repoId: string, sha: string, state: string, description: string, targetUrl: string): Promise<void> {
690 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
691 method: "POST",
692 headers: { "content-type": "application/json" },
693 body: JSON.stringify({ repoId, sha, context: STATUS_CONTEXT, state, description, targetUrl }),
694 }).catch(() => undefined);
695 }
696
697 // ---- Taking apps down ----------------------------------------------
698
699 private async removeApp(script: string): Promise<void> {
700 await this.cloudflare?.deleteScript(script);
701 await this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script).run();
702 }
703
704 private async takeDownWhere(repoId: string, kind: DeployKind | null, number?: number): Promise<void> {
705 const apps = await this.db
706 .prepare(
707 `SELECT script FROM apps WHERE repo_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR number = ?3)`,
708 )
709 .bind(repoId, kind, number ?? null)
710 .all<{ script: string }>();
711 for (const app of apps.results) await this.removeApp(app.script);
712 }
713
714 // ---- Events --------------------------------------------------------
715
716 async onEvent(event: G1tEvent): Promise<void> {
717 switch (event.type) {
718 case "pull.opened":
719 case "pull.ready":
720 case "pull.updated": {
721 const repo = await this.pathById(event.data.repoId);
722 if (repo) await this.deployPreview(event.data.repoId, repo, event.data.number, "g1t");
723 break;
724 }
725 case "pull.closed":
726 case "pull.merged":
727 await this.takeDownWhere(event.data.repoId, "preview", event.data.number);
728 break;
729 case "git.push": {
730 if (!event.data.defaultBranch) break;
731 const repo = await this.pathById(event.data.repoId);
732 if (!repo) break;
733 await this.deployProduction(
734 event.data.repoId,
735 repo,
736 event.data.ref.replace(/^refs\/heads\//, ""),
737 event.data.after,
738 event.actor ?? "g1t",
739 );
740 break;
741 }
742 }
743 }
744
745 // ---- The sweep -----------------------------------------------------
746
747 /**
748 * Every few minutes: builds that died are failed; usage is counted; idle
749 * previews and the apps of workspaces whose plan ended come down; and a
750 * month that is over is charged past its allowance.
751 */
752 async sweep(): Promise<void> {
753 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
754 const stuck = await this.db
755 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
756 .bind(cutoff)
757 .all<{ id: string }>();
758 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
759
760 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
761 const workspaces = [...new Set(apps.map((app) => app.namespace))];
762
763 // Apps of workspaces whose plan has ended come down.
764 const billing = billingClient(this.env.BILLING);
765 for (const workspace of workspaces) {
766 const plan = await billing.hasFeature(workspace, "deployments");
767 if (!plan.ok && plan.error.code === "payment_required") {
768 for (const app of apps.filter((a) => a.namespace === workspace)) await this.removeApp(app.script);
769 }
770 }
771
772 await this.count(apps).catch((error) => console.error("could not count usage", error));
773 await this.takeDownIdle();
774 await this.chargeMonths();
775 }
776
777 /** Counts this month's requests and CPU time per workspace, from analytics. */
778 private async count(apps: AppRow[]): Promise<void> {
779 const cloudflare = this.cloudflare;
780 if (!cloudflare || apps.length === 0) return;
781 const start = `${month()}-01T00:00:00Z`;
782 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
783 // Analytics only counts apps that are up; the meter keeps what earlier
784 // apps used by never going down.
785 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
786 for (const app of apps) {
787 const used = totals.get(app.script);
788 if (!used) continue;
789 const sum = perWorkspace.get(app.namespace) ?? { requests: 0, cpuMs: 0 };
790 sum.requests += used.requests;
791 sum.cpuMs += used.cpuMs;
792 perWorkspace.set(app.namespace, sum);
793 }
794 const at = now();
795 for (const [namespace, used] of perWorkspace) {
796 await this.db
797 .prepare(
798 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
799 ON CONFLICT (namespace, month) DO UPDATE SET
800 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
801 )
802 .bind(namespace, month(), used.requests, used.cpuMs, at)
803 .run();
804 }
805 // When each preview last answered anyone, for the idle sweep.
806 const recent = await cloudflare.usage(
807 apps.filter((app) => app.kind === "preview").map((app) => app.script),
808 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
809 at,
810 );
811 for (const [script, used] of recent) {
812 if (used.requests > 0) {
813 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
814 }
815 }
816 for (const namespace of new Set(apps.map((app) => app.namespace))) await this.notePeak(namespace);
817 }
818
819 /** Previews no one has visited in their repository's idle days. */
820 private async takeDownIdle(): Promise<void> {
821 const idle = await this.db
822 .prepare(
823 `SELECT apps.script FROM apps JOIN settings ON settings.repo_id = apps.repo_id
824 WHERE apps.kind = 'preview'
825 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
826 )
827 .all<{ script: string }>();
828 for (const { script } of idle.results) await this.removeApp(script);
829 }
830
831 /** Charges each month that is over for what it used past the allowance, once. */
832 private async chargeMonths(): Promise<void> {
833 const due = await this.db
834 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
835 .bind(month())
836 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
837 const a = DEPLOYMENTS_ALLOWANCE;
838 for (const meter of due.results) {
839 const extraRequests = Math.max(0, meter.requests - a.requests);
840 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
841 const extraApps = Math.max(0, meter.peak_apps - a.apps);
842 const cost = Math.ceil(
843 (extraRequests / 1_000_000) * a.microsPerMillionRequests +
844 (extraCpu / 1_000_000) * a.microsPerMillionCpuMs +
845 extraApps * a.microsPerAppMonth,
846 );
847 if (cost > 0) {
848 const parts = [
849 extraApps && `${extraApps} extra apps`,
850 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
851 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
852 ].filter(Boolean);
853 const charged = await billingClient(this.env.BILLING).chargeFeature({
854 workspace: meter.namespace,
855 feature: "deployments",
856 costMicros: cost,
857 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
858 reference: `deployments/${meter.namespace}/${meter.month}`,
859 });
860 if (!charged.ok) continue;
861 }
862 await this.db
863 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
864 .bind(now(), meter.namespace, meter.month)
865 .run();
866 }
867 }
868}
869
870/** `POST /rpc/<method>`: the arguments are the body. */
871async function rpc(service: Deployments, method: string, args: any): Promise<unknown> {
872 switch (method) {
873 case "settings":
874 return service.settings(args);
875 case "update_settings":
876 return service.updateSettings(args);
877 case "list":
878 return service.list(args);
879 case "get":
880 return service.get(args);
881 case "redeploy":
882 return service.redeploy(args);
883 case "take_down":
884 return service.takeDown(args);
885 case "usage":
886 return service.usage(args);
887 default:
888 return undefined;
889 }
890}
891
892export default {
893 async fetch(request: Request, env: Env): Promise<Response> {
894 const { pathname } = new URL(request.url);
895 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
896 const service = new Deployments(env);
897 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
898 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
899 if (rpcMatch) {
900 const result = await rpc(service, rpcMatch[1], body);
901 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
902 }
903 // A build's reports, forwarded by the API.
904 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
905 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
906 return new Response("Not found\n", { status: 404 });
907 },
908
909 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
910 const service = new Deployments(env);
911 for (const message of batch.messages) {
912 try {
913 await service.onEvent(message.body);
914 message.ack();
915 } catch (error) {
916 console.error("deployments could not handle", message.body.type, error);
917 message.retry();
918 }
919 }
920 },
921
922 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
923 await new Deployments(env).sweep();
924 },
925} satisfies ExportedHandler<Env, G1tEvent>;