pr_01m47d24b0e6n91zwymwxg0vpx/services/deployments/src/index.ts

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