flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/deployments/src/index.ts

1,215 lines50,442 bytesCodeBlame
1/**
2 * The deployments service: a project's production, deployed from its
3 * default branch on every push, and a live preview of every branch with an
4 * open pull request, on g1t.page.
5 *
6 * It reacts to events (a pull request opened, ready, pushed to, closed or
7 * merged; a push to the default branch) for every project built from the
8 * repository, asks billing whether the workspace pays for Deployments, and
9 * asks the runner to build the commit in a sandbox. The sandbox reports
10 * back through the API with a token for that build alone; this service
11 * opens the upload of its files and puts the finished app in the Workers
12 * for Platforms namespace, where the `*.g1t.page` dispatcher finds it by
13 * hostname.
14 *
15 * Nothing here is free. A build is charged by the second; requests, CPU
16 * time and apps past the plan's allowance are charged once the month is
17 * over. A Worker runs only while it answers a request, so an app no one
18 * visits costs nothing, and a preview is taken down when its pull request
19 * closes or after its project's idle days.
20 *
21 * Reached through service bindings (`POST /rpc/<method>`) and, for a
22 * build's reports, through the API (`POST /jobs/<id>/<step>`).
23 */
24
25import {
26 DEPLOYMENTS_ALLOWANCE,
27 billingClient,
28 fail,
29 identityClient,
30 newId,
31 ok,
32 projectsClient,
33 reposClient,
34 workClient,
35 type DeployKind,
36 type DeploySettings,
37 type DeployStatus,
38 type DeployUsage,
39 type Deployment,
40 type G1tEvent,
41 type LiveApp,
42 type Project,
43 type ProjectDeploys,
44 type ProjectRef,
45 type RepoPath,
46 type Result,
47 type ServiceBinding,
48 type User,
49 type Viewer,
50} from "@g1t/contracts";
51
52import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
53import { appUrl, label, uniqueLabel } from "./names";
54
55type Env = {
56 DB: D1Database;
57 REPOS: ServiceBinding;
58 WORK: ServiceBinding;
59 IDENTITY: ServiceBinding;
60 BILLING: ServiceBinding;
61 RUNNER: ServiceBinding;
62 PROJECTS: ServiceBinding;
63 /** Secrets and variables: the actions service holds the one store. */
64 ACTIONS: ServiceBinding;
65 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
66 CLOUDFLARE_API_TOKEN?: string;
67 CLOUDFLARE_ACCOUNT_ID: string;
68 DISPATCH_NAMESPACE: string;
69 SITE: string;
70};
71
72/** A build that has not reported in this long has died. */
73const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
74/** A script in the namespace that no app holds, older than this, is removed. */
75const ORPHAN_AFTER_MS = 60 * 60 * 1000;
76const LIST_LIMIT = 50;
77const STATUS_CONTEXT = "g1t / deploy";
78
79const now = () => new Date().toISOString();
80const month = (at = new Date()) => at.toISOString().slice(0, 7);
81
82async function sha256(text: string): Promise<string> {
83 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
84 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
85}
86
87function randomToken(): string {
88 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
89}
90
91function isMember(viewer: Viewer, slug: string): boolean {
92 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
93}
94
95/** The repository a project builds from. */
96function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
97 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
98 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
99}
100
101type SettingsRow = {
102 project_id: string;
103 workspace: string;
104 slug: string;
105 repo_id: string;
106 enabled: number;
107 previews: number;
108 production: number;
109 build_command: string | null;
110 output_dir: string | null;
111 idle_days: number;
112};
113
114type DeploymentRow = {
115 id: string;
116 project_id: string;
117 workspace: string;
118 slug: string;
119 repo_id: string;
120 repo: string;
121 kind: DeployKind;
122 branch: string | null;
123 number: number | null;
124 commit_sha: string;
125 script: string;
126 status: DeployStatus;
127 error: string | null;
128 warnings: string;
129 log: string | null;
130 token_hash: string | null;
131 trusted: number;
132 build_seconds: number | null;
133 created_by: string;
134 created_at: string;
135 finished_at: string | null;
136};
137
138type AppRow = {
139 script: string;
140 project_id: string;
141 workspace: string;
142 slug: string;
143 kind: DeployKind;
144 branch: string | null;
145 number: number | null;
146 commit_sha: string;
147 deployed_at: string;
148 created_at: string;
149 last_request_at: string | null;
150 /** Set while its workspace is over its limit; see `holdToLimits`. */
151 paused_at: string | null;
152};
153
154function toDeployment(row: DeploymentRow): Deployment {
155 return {
156 id: row.id,
157 kind: row.kind,
158 branch: row.branch,
159 number: row.number,
160 commit: row.commit_sha,
161 status: row.status,
162 url: appUrl(row.script),
163 error: row.error,
164 warnings: JSON.parse(row.warnings || "[]") as string[],
165 buildSeconds: row.build_seconds,
166 createdBy: row.created_by,
167 createdAt: row.created_at,
168 finishedAt: row.finished_at,
169 };
170}
171
172function toLive(app: AppRow): LiveApp {
173 return {
174 kind: app.kind,
175 branch: app.branch,
176 number: app.number,
177 url: appUrl(app.script),
178 commit: app.commit_sha,
179 deployedAt: app.deployed_at,
180 };
181}
182
183class Deployments {
184 constructor(private readonly env: Env) {}
185
186 private get cloudflare(): Cloudflare | null {
187 const token = this.env.CLOUDFLARE_API_TOKEN;
188 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
189 }
190
191 private get db() {
192 return this.env.DB;
193 }
194
195 private get projects() {
196 return projectsClient(this.env.PROJECTS);
197 }
198
199 /** The workspace itself, as the service acts for it. */
200 private async workspaceActor(slug: string): Promise<User | null> {
201 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
202 if (!workspace) return null;
203 return {
204 id: workspace.id,
205 username: workspace.slug,
206 kind: "workspace",
207 verified: true,
208 workspaces: [{ slug: workspace.slug, role: "member" }],
209 };
210 }
211
212 /**
213 * What the project's secrets and variables available to deployments give
214 * production or a preview: its build's environment, and the same again as
215 * the running app's bindings. Untrusted builds get no secrets.
216 */
217 private async resolve(
218 project: { id: string; slug: string; repoId: string; repo: RepoPath },
219 environment: DeployKind,
220 trusted: boolean,
221 branch: string | null,
222 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
223 const [rows, references] = await Promise.all([
224 this.rows(project, environment, trusted),
225 this.references(project.id, environment === "preview" ? branch : null),
226 ]);
227 // The project's own rows win over a dependency's address of the same name.
228 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
229 }
230
231 /**
232 * Each dependency's address, under the name the dependency gives it:
233 * for a preview, the same branch's preview of it if one is up, else its
234 * production; for production, its production.
235 */
236 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
237 const graph = await this.projects.graph(projectId).catch(() => null);
238 const out: Record<string, string> = {};
239 for (const dependency of graph?.dependsOn ?? []) {
240 if (!dependency.as) continue;
241 const app =
242 (branch
243 ? await this.db
244 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
245 .bind(dependency.id, branch)
246 .first<{ script: string }>()
247 : null) ??
248 (await this.db
249 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
250 .bind(dependency.id)
251 .first<{ script: string }>());
252 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
253 }
254 return out;
255 }
256
257 private async rows(
258 project: { id: string; slug: string; repoId: string; repo: RepoPath },
259 environment: DeployKind,
260 trusted: boolean,
261 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
262 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
263 method: "POST",
264 headers: { "content-type": "application/json" },
265 body: JSON.stringify({
266 repoId: project.repoId,
267 repo: project.repo,
268 projectId: project.id,
269 projectSlug: project.slug,
270 consumer: "deployments",
271 environment,
272 trusted,
273 }),
274 });
275 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
276 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
277 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
278 }
279
280 /**
281 * Whether a pull request's author is trusted with the project's secrets:
282 * g1t's agent, or a member of the workspace. Someone from outside gets a
283 * preview built without them, as their workflows run.
284 */
285 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
286 if (author.kind === "agent" || author.username === "g1t-agent") return true;
287 // On a private repository only members can open one at all.
288 const found = await reposClient(this.env.REPOS).get(repo, actor);
289 if (found.ok && found.value.isPrivate) return true;
290 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
291 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
292 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
293 }
294
295 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
296 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
297 }
298
299 /** The name an app gets, unique among apps: production, or a branch's preview. */
300 private async scriptFor(project: Project, branch: string | null): Promise<string> {
301 const base = await label(project.workspace, project.slug, branch);
302 const holder = await this.db
303 .prepare(
304 `SELECT project_id, branch FROM apps WHERE script = ?1
305 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
306 )
307 .bind(base)
308 .first<{ project_id: string; branch: string | null }>();
309 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
310 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
311 }
312
313 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
314 return {
315 enabled: !!row?.enabled,
316 previews: row ? !!row.previews : true,
317 production: row ? !!row.production : true,
318 buildCommand: row?.build_command ?? null,
319 outputDir: row?.output_dir ?? null,
320 idleDays: row?.idle_days ?? 7,
321 productionUrl: appUrl(await this.scriptFor(project, null)),
322 };
323 }
324
325 /** The project, if `viewer` belongs to its workspace. */
326 private async memberProject(ref: ProjectRef, viewer: Viewer): Promise<Result<Project>> {
327 if (!isMember(viewer, ref.workspace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
328 return this.projects.get(ref.workspace, ref.slug, viewer);
329 }
330
331 // ---- Methods for the site and the API ------------------------------
332
333 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
334 const project = await this.memberProject(a.project, a.viewer);
335 if (!project.ok) return project;
336 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
337 }
338
339 async updateSettings(a: {
340 actor: User;
341 project: ProjectRef;
342 changes: Partial<DeploySettings>;
343 }): Promise<Result<DeploySettings>> {
344 const found = await this.memberProject(a.project, a.actor);
345 if (!found.ok) return found;
346 const project = found.value;
347 const before = await this.toSettings(project, await this.settingsRow(project.id));
348 const next = { ...before, ...a.changes };
349 if (next.enabled && !before.enabled) {
350 // Turning it on starts paid work: only with the workspace's plan.
351 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
352 if (!plan.ok) return plan;
353 }
354 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
355 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
356 await this.db
357 .prepare(
358 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
359 output_dir, idle_days, updated_by, updated_at)
360 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
361 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
362 production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12`,
363 )
364 .bind(
365 project.id,
366 project.workspace,
367 project.slug,
368 repoOf(project).id,
369 next.enabled ? 1 : 0,
370 next.previews ? 1 : 0,
371 next.production ? 1 : 0,
372 clip(next.buildCommand),
373 clip(next.outputDir),
374 idleDays,
375 a.actor.username,
376 now(),
377 )
378 .run();
379 // What was turned off comes down now; nothing keeps running unasked.
380 if (!next.enabled) await this.takeDownWhere(project.id, null);
381 else {
382 if (!next.previews) await this.takeDownWhere(project.id, "preview");
383 if (!next.production) await this.takeDownWhere(project.id, "production");
384 }
385 // Turned on: production goes up from the default branch at once.
386 if (next.enabled && next.production && (!before.enabled || !before.production)) {
387 await this.deployProduction(project, null, a.actor.username);
388 }
389 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
390 }
391
392 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
393 const project = await this.memberProject(a.project, a.viewer);
394 if (!project.ok) return project;
395 const [deployments, apps] = await Promise.all([
396 this.db
397 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
398 .bind(project.value.id, LIST_LIMIT)
399 .all<DeploymentRow>(),
400 this.db
401 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
402 .bind(project.value.id)
403 .all<AppRow>(),
404 ]);
405 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
406 }
407
408 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
409 const project = await this.memberProject(a.project, a.viewer);
410 if (!project.ok) return project;
411 const row = await this.db
412 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
413 .bind(a.id, project.value.id)
414 .first<DeploymentRow>();
415 if (!row) return fail("not_found", "No such deployment.");
416 return ok({ ...toDeployment(row), log: row.log });
417 }
418
419 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
420 const found = await this.memberProject(a.project, a.actor);
421 if (!found.ok) return found;
422 const project = found.value;
423 const settings = await this.settingsRow(project.id);
424 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
425 if (a.branch == null) {
426 return (await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy.");
427 }
428 // A branch's preview comes from its pull request.
429 const app = await this.db
430 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
431 .bind(project.id, a.branch)
432 .first<{ number: number }>();
433 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
434 return (await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open.");
435 }
436
437 /**
438 * A preview stack: the projects that use this one get previews of their
439 * own default branch, under the same branch name, so each reaches this
440 * branch's preview through its dependency's variable. A change to an API
441 * can then be clicked through in the apps that call it.
442 */
443 async stack(
444 a: { actor: User; project: ProjectRef; branch: string },
445 background: (work: Promise<unknown>) => void,
446 ): Promise<Result<string[]>> {
447 const found = await this.memberProject(a.project, a.actor);
448 if (!found.ok) return found;
449 const upstream = await this.db
450 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
451 .bind(found.value.id, a.branch)
452 .first();
453 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
454 const graph = await this.projects.graph(found.value.id);
455 const ready: { project: Project; settings: SettingsRow }[] = [];
456 for (const dependent of graph.usedBy) {
457 const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
458 if (!project.ok) continue;
459 const settings = await this.settingsRow(project.value.id);
460 if (settings?.enabled && settings.previews) ready.push({ project: project.value, settings });
461 }
462 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
463 // The builds start after the answer: a person moving on from the page
464 // does not stop them.
465 background(
466 (async () => {
467 for (const { project, settings } of ready) {
468 const actor = await this.workspaceActor(project.workspace);
469 if (!actor) continue;
470 const repo = repoOf(project);
471 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
472 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
473 if (!head) continue;
474 await this.start({
475 project,
476 kind: "preview",
477 branch: a.branch,
478 number: null,
479 commit: head,
480 source: repo.path,
481 reader: actor,
482 createdBy: a.actor.username,
483 settings,
484 // Its own default branch, asked for by a member.
485 trusted: true,
486 });
487 }
488 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
489 );
490 return ok(ready.map(({ project }) => project.name));
491 }
492
493 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
494 const project = await this.memberProject(a.project, a.actor);
495 if (!project.ok) return project;
496 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
497 return ok(true);
498 }
499
500 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
501 const workspace = a.workspace.toLowerCase();
502 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
503 const [settings, apps, latest] = await Promise.all([
504 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ?").bind(workspace).all<{ slug: string; enabled: number }>(),
505 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
506 this.db
507 .prepare(
508 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
509 )
510 .bind(workspace)
511 .all<DeploymentRow>(),
512 ]);
513 return ok(
514 settings.results.map((row) => {
515 const own = apps.results.filter((app) => app.slug === row.slug);
516 const production = own.find((app) => app.kind === "production");
517 const newest = latest.results.find((d) => d.slug === row.slug);
518 return {
519 slug: row.slug,
520 enabled: !!row.enabled,
521 production: production ? toLive(production) : null,
522 previews: own.filter((app) => app.kind === "preview").length,
523 latest: newest ? toDeployment(newest) : null,
524 };
525 }),
526 );
527 }
528
529 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
530 const slug = a.workspace.toLowerCase();
531 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
532 const [meter, apps] = await Promise.all([
533 this.db
534 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
535 .bind(slug, month())
536 .first<{
537 requests: number;
538 cpu_ms: number;
539 peak_apps: number;
540 build_seconds: number;
541 build_micros: number;
542 counted_at: string | null;
543 }>(),
544 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(),
545 ]);
546 return ok({
547 month: month(),
548 requests: meter?.requests ?? 0,
549 cpuMs: meter?.cpu_ms ?? 0,
550 apps: apps?.n ?? 0,
551 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
552 buildSeconds: meter?.build_seconds ?? 0,
553 buildMicros: meter?.build_micros ?? 0,
554 countedAt: meter?.counted_at ?? null,
555 });
556 }
557
558 // ---- Starting builds -----------------------------------------------
559
560 /**
561 * Opens a deployment and starts its build. Skipped, with the reason
562 * recorded, when the workspace's plan is off.
563 */
564 private async start(input: {
565 project: Project;
566 kind: DeployKind;
567 branch: string | null;
568 number: number | null;
569 commit: string;
570 source: RepoPath;
571 reader: User;
572 createdBy: string;
573 settings: SettingsRow;
574 /** A push, or work by a member or an agent; see `insider`. */
575 trusted: boolean;
576 }): Promise<Result<Deployment>> {
577 const { project } = input;
578 const repo = repoOf(project);
579 const script = await this.scriptFor(project, input.branch);
580 const id = newId("dpl");
581 const token = randomToken();
582 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
583 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
584 const cloudflare = this.cloudflare;
585 const refused = !plan.ok
586 ? plan.error.message
587 : limit.ok && limit.value.state === "stopped"
588 ? (limit.value.message ?? "The workspace reached its usage limit.")
589 : !cloudflare
590 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
591 : null;
592 await this.db
593 .prepare(
594 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
595 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
596 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
597 )
598 .bind(
599 id,
600 project.id,
601 project.workspace,
602 project.slug,
603 repo.id,
604 `${repo.path.namespace}/${repo.path.name}`,
605 input.kind,
606 input.branch,
607 input.number,
608 input.commit,
609 script,
610 refused ? "skipped" : "queued",
611 refused,
612 refused ? null : await sha256(token),
613 input.trusted ? 1 : 0,
614 input.createdBy,
615 now(),
616 refused ? now() : null,
617 )
618 .run();
619 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
620 // Older builds of the same app are replaced by this one.
621 await this.db
622 .prepare(
623 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
624 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
625 )
626 .bind(now(), script, id)
627 .run();
628 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
629 // What the project's secrets and variables give builds of this kind.
630 const build = await this.resolve(
631 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
632 input.kind,
633 input.trusted,
634 input.branch,
635 );
636 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
637 method: "POST",
638 headers: { "content-type": "application/json" },
639 body: JSON.stringify({
640 deployId: id,
641 token,
642 actor: input.reader,
643 source: input.source,
644 commit: input.commit,
645 rootDir: project.source.rootDir,
646 buildCommand: input.settings.build_command,
647 outputDir: input.settings.output_dir,
648 buildEnv: build.variables,
649 buildSecrets: build.secrets,
650 }),
651 });
652 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
653 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
654 return ok(toDeployment((await this.deploymentRow(id))!));
655 }
656
657 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
658 const settings = await this.settingsRow(project.id);
659 if (!settings?.enabled || !settings.production) return null;
660 const actor = await this.workspaceActor(project.workspace);
661 if (!actor) return null;
662 const repo = repoOf(project);
663 let head = commit;
664 if (!head) {
665 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
666 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
667 }
668 if (!head) return null;
669 return this.start({
670 project,
671 kind: "production",
672 branch: null,
673 number: null,
674 commit: head,
675 source: repo.path,
676 reader: actor,
677 createdBy,
678 settings,
679 // The default branch only moves by people and agents with access.
680 trusted: true,
681 });
682 }
683
684 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
685 const settings = await this.settingsRow(project.id);
686 if (!settings?.enabled || !settings.previews) return null;
687 const actor = await this.workspaceActor(project.workspace);
688 if (!actor) return null;
689 const repo = repoOf(project);
690 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
691 if (!detail.ok) return null;
692 const { pull } = detail.value;
693 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
694 // A pull request from a fork (as g1t's agents work) has no branch here.
695 const branch = pull.branch ?? `pr-${number}`;
696 if (!force) {
697 // Already built, or being built, at this commit.
698 const same = await this.db
699 .prepare(
700 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
701 AND status IN ('queued', 'building', 'ready')`,
702 )
703 .bind(project.id, branch, pull.headCommit)
704 .first();
705 if (same) return null;
706 }
707 return this.start({
708 project,
709 kind: "preview",
710 branch,
711 number,
712 commit: pull.headCommit,
713 source: pull.fork ?? repo.path,
714 // The pull request's fork may be private: read it as its author.
715 reader: pull.author,
716 createdBy,
717 settings,
718 trusted: await this.insider(repo.path, pull.author, actor),
719 });
720 }
721
722 // ---- A build's reports ---------------------------------------------
723
724 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
725 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
726 }
727
728 /** The build, if `token` is its own and it is still under way. */
729 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
730 const row = await this.deploymentRow(id);
731 if (!row?.token_hash || typeof token !== "string") return null;
732 if (row.token_hash !== (await sha256(token))) return null;
733 return row.status === "queued" || row.status === "building" ? row : null;
734 }
735
736 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
737 // Each report, for the logs: a build's own failure says why.
738 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
739 const row = await this.building(id, body.token);
740 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
741 const cloudflare = this.cloudflare;
742 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
743 switch (step) {
744 case "started":
745 await this.db
746 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
747 .bind(now(), id)
748 .run();
749 return Response.json(ok(true));
750 case "session": {
751 const manifest = body.manifest as Manifest | undefined;
752 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
753 const session = await cloudflare.openUpload(row.script, manifest);
754 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
755 }
756 case "finish": {
757 const worker = (body.worker ?? {}) as BuiltWorker;
758 const seconds = Number(body.buildSeconds) || 0;
759 const [namespace, name] = row.repo.split("/") as [string, string];
760 try {
761 // Running apps' secrets and variables are bound here, by g1t:
762 // they never pass through the build's sandbox.
763 const runtime = await this.resolve(
764 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
765 row.kind,
766 !!row.trusted,
767 row.branch,
768 );
769 await cloudflare.putScript(
770 row.script,
771 worker,
772 typeof body.completionJwt === "string" ? body.completionJwt : null,
773 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
774 runtime,
775 );
776 } catch (error) {
777 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
778 return Response.json(ok(false));
779 }
780 const at = now();
781 await this.db.batch([
782 this.db
783 .prepare(
784 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
785 WHERE id = ?`,
786 )
787 .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
788 this.db
789 .prepare(
790 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
791 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
792 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9, paused_at = NULL`,
793 )
794 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
795 ]);
796 await this.chargeBuild(row, seconds);
797 await this.notePeak(row.workspace);
798 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
799 return Response.json(ok(true));
800 }
801 case "fail":
802 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
803 return Response.json(ok(true));
804 default:
805 return Response.json(fail("not_found", "No such step."), { status: 404 });
806 }
807 }
808
809 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
810 const row = await this.deploymentRow(id);
811 if (!row || (row.status !== "queued" && row.status !== "building")) return;
812 await this.db
813 .prepare(
814 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
815 WHERE id = ?`,
816 )
817 .bind(message.slice(0, 2000), log, seconds, now(), id)
818 .run();
819 // A failed build still used its sandbox.
820 if (seconds) await this.chargeBuild(row, seconds);
821 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
822 }
823
824 /** Each build is charged by the second at the container price plus the margin. */
825 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
826 const costs = await this.costs();
827 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
828 if (cost <= 0) return;
829 const what =
830 row.kind === "preview"
831 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
832 : `${row.workspace}/${row.slug} to production`;
833 await billingClient(this.env.BILLING).chargeFeature({
834 workspace: row.workspace,
835 feature: "deployments",
836 costMicros: cost,
837 description: `Building ${what} (${Math.ceil(seconds)} s)`,
838 repo: row.repo,
839 reference: `deploy/${row.id}`,
840 });
841 await this.db
842 .prepare(
843 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
844 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
845 )
846 .bind(row.workspace, month(), Math.ceil(seconds), cost)
847 .run();
848 }
849
850 /**
851 * What each unit costs g1t now, from billing's price book, which follows
852 * what Cloudflare bills. The plan's figures if billing cannot say.
853 */
854 private async costs(): Promise<{ buildSecond: number; millionRequests: number; millionCpuMs: number; appMonth: number }> {
855 const a = DEPLOYMENTS_ALLOWANCE;
856 const book = await billingClient(this.env.BILLING)
857 .prices()
858 .catch(() => null);
859 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
860 return {
861 buildSecond: cost("build_second", a.microsPerBuildSecond),
862 millionRequests: cost("app_requests", a.microsPerMillionRequests),
863 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
864 appMonth: cost("app_month", a.microsPerAppMonth),
865 };
866 }
867
868 /** Remembers the most apps the workspace had up at once this month. */
869 private async notePeak(workspace: string): Promise<void> {
870 await this.db
871 .prepare(
872 `INSERT INTO meters (namespace, month, peak_apps)
873 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))
874 ON CONFLICT (namespace, month) DO UPDATE SET
875 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))`,
876 )
877 .bind(workspace, month())
878 .run();
879 }
880
881 /**
882 * The check on the commit: `g1t / deploy`, or, for one of several
883 * projects on a repository, `g1t / deploy (<project>)`.
884 */
885 private async status(
886 repoId: string,
887 sha: string,
888 project: { slug: string; primary: boolean },
889 state: string,
890 description: string,
891 targetUrl: string,
892 ): Promise<void> {
893 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
894 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
895 method: "POST",
896 headers: { "content-type": "application/json" },
897 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
898 }).catch(() => undefined);
899 }
900
901 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
902 const projects = await this.projects.byRepo(row.repo_id);
903 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
904 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
905 }
906
907 // ---- Taking apps down ----------------------------------------------
908
909 private async removeApp(script: string): Promise<void> {
910 await this.cloudflare?.deleteScript(script);
911 await this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script).run();
912 }
913
914 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
915 const apps = await this.db
916 .prepare(
917 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
918 )
919 .bind(projectId, kind, branch ?? null)
920 .all<{ script: string }>();
921 for (const app of apps.results) await this.removeApp(app.script);
922 }
923
924 // ---- Events --------------------------------------------------------
925
926 async onEvent(event: G1tEvent): Promise<void> {
927 switch (event.type) {
928 case "pull.opened":
929 case "pull.ready":
930 case "pull.updated":
931 for (const project of await this.projects.byRepo(event.data.repoId)) {
932 await this.deployPreview(project, event.data.number, "g1t");
933 }
934 break;
935 case "pull.closed":
936 case "pull.merged":
937 for (const project of await this.projects.byRepo(event.data.repoId)) {
938 const apps = await this.db
939 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
940 .bind(project.id, event.data.number)
941 .all<{ script: string }>();
942 for (const app of apps.results) await this.removeApp(app.script);
943 }
944 break;
945 case "git.push":
946 if (!event.data.defaultBranch) break;
947 for (const project of await this.projects.byRepo(event.data.repoId)) {
948 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
949 }
950 break;
951 }
952 }
953
954 // ---- The sweep -----------------------------------------------------
955
956 /**
957 * Every few minutes: builds that died are failed; usage is counted; idle
958 * previews, the apps of workspaces whose plan ended, and scripts no app
959 * holds come down; and a month that is over is charged past its
960 * allowance.
961 */
962 async sweep(): Promise<void> {
963 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
964 const stuck = await this.db
965 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
966 .bind(cutoff)
967 .all<{ id: string }>();
968 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
969
970 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
971 const workspaces = [...new Set(apps.map((app) => app.workspace))];
972
973 // Apps of workspaces whose plan has ended come down.
974 const billing = billingClient(this.env.BILLING);
975 await this.holdToLimits(apps).catch((error) => console.error("could not apply limits", error));
976 for (const workspace of workspaces) {
977 const plan = await billing.hasFeature(workspace, "deployments");
978 if (!plan.ok && plan.error.code === "payment_required") {
979 for (const app of apps.filter((a) => a.workspace === workspace)) await this.removeApp(app.script);
980 }
981 }
982
983 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
984 await this.count(apps).catch((error) => console.error("could not count usage", error));
985 await this.takeDownIdle();
986 await this.chargeMonths();
987 }
988
989 /**
990 * Pauses the apps of workspaces that reached their limit for usage not
991 * yet paid for, and rebuilds them from the same commit once they are
992 * under it again. Paused apps answer with a notice and run nothing.
993 */
994 private async holdToLimits(apps: AppRow[]): Promise<void> {
995 const cloudflare = this.cloudflare;
996 if (!cloudflare) return;
997 const billing = billingClient(this.env.BILLING);
998 for (const workspace of [...new Set(apps.map((app) => app.workspace))]) {
999 const limit = await billing.checkLimit(workspace);
1000 if (!limit.ok) continue;
1001 const theirs = apps.filter((app) => app.workspace === workspace);
1002 if (limit.value.state === "stopped") {
1003 for (const app of theirs.filter((a) => !a.paused_at)) {
1004 await cloudflare.pauseScript(app.script);
1005 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
1006 }
1007 continue;
1008 }
1009 const paused = theirs.filter((a) => a.paused_at);
1010 if (paused.length === 0) continue;
1011 const actor = await this.workspaceActor(workspace);
1012 if (!actor) continue;
1013 for (const app of paused) {
1014 const project = await this.projects.get(workspace, app.slug, actor);
1015 if (!project.ok) continue;
1016 // A failed or refused rebuild leaves it paused, to try again next time.
1017 const rebuilt =
1018 app.kind === "production"
1019 ? await this.deployProduction(project.value, app.commit_sha, "g1t")
1020 : app.number != null
1021 ? await this.deployPreview(project.value, app.number, "g1t", true)
1022 : null;
1023 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1024 }
1025 }
1026 }
1027
1028 /** Scripts in the namespace that no app holds, such as ones renamed. */
1029 private async removeOrphans(apps: AppRow[]): Promise<void> {
1030 const cloudflare = this.cloudflare;
1031 if (!cloudflare) return;
1032 const held = new Set(apps.map((app) => app.script));
1033 const building = await this.db
1034 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
1035 .all<{ script: string }>();
1036 for (const row of building.results) held.add(row.script);
1037 const cutoff = Date.now() - ORPHAN_AFTER_MS;
1038 for (const script of await cloudflare.listScripts()) {
1039 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
1040 }
1041 }
1042
1043 /** Counts this month's requests and CPU time per workspace, from analytics. */
1044 private async count(apps: AppRow[]): Promise<void> {
1045 const cloudflare = this.cloudflare;
1046 if (!cloudflare || apps.length === 0) return;
1047 const start = `${month()}-01T00:00:00Z`;
1048 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
1049 // Analytics only counts apps that are up; the meter keeps what earlier
1050 // apps used by never going down.
1051 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
1052 for (const app of apps) {
1053 const used = totals.get(app.script);
1054 if (!used) continue;
1055 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
1056 sum.requests += used.requests;
1057 sum.cpuMs += used.cpuMs;
1058 perWorkspace.set(app.workspace, sum);
1059 }
1060 const at = now();
1061 for (const [workspace, used] of perWorkspace) {
1062 await this.db
1063 .prepare(
1064 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1065 ON CONFLICT (namespace, month) DO UPDATE SET
1066 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1067 )
1068 .bind(workspace, month(), used.requests, used.cpuMs, at)
1069 .run();
1070 }
1071 // When each preview last answered anyone, for the idle sweep.
1072 const recent = await cloudflare.usage(
1073 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1074 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1075 at,
1076 );
1077 for (const [script, used] of recent) {
1078 if (used.requests > 0) {
1079 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1080 }
1081 }
1082 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
1083 // What this month's traffic past the plan will cost, so the workspace's
1084 // limit counts it now rather than when the month closes.
1085 const costs = await this.costs();
1086 const a = DEPLOYMENTS_ALLOWANCE;
1087 for (const workspace of perWorkspace.keys()) {
1088 const meter = await this.db
1089 .prepare("SELECT requests, cpu_ms, peak_apps FROM meters WHERE namespace = ? AND month = ?")
1090 .bind(workspace, month())
1091 .first<{ requests: number; cpu_ms: number; peak_apps: number }>();
1092 if (!meter) continue;
1093 const cost = Math.ceil(
1094 (Math.max(0, meter.requests - a.requests) / 1_000_000) * costs.millionRequests +
1095 (Math.max(0, meter.cpu_ms - a.cpuMs) / 1_000_000) * costs.millionCpuMs +
1096 Math.max(0, meter.peak_apps - a.apps) * costs.appMonth,
1097 );
1098 await billingClient(this.env.BILLING)
1099 .notePending(workspace, "deployments", cost)
1100 .catch((error) => console.error("could not note pending usage", error));
1101 }
1102 }
1103
1104 /** Previews no one has visited in their project's idle days. */
1105 private async takeDownIdle(): Promise<void> {
1106 const idle = await this.db
1107 .prepare(
1108 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
1109 WHERE apps.kind = 'preview'
1110 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
1111 )
1112 .all<{ script: string }>();
1113 for (const { script } of idle.results) await this.removeApp(script);
1114 }
1115
1116 /** Charges each month that is over for what it used past the allowance, once. */
1117 private async chargeMonths(): Promise<void> {
1118 const due = await this.db
1119 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
1120 .bind(month())
1121 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
1122 const a = DEPLOYMENTS_ALLOWANCE;
1123 const costs = await this.costs();
1124 for (const meter of due.results) {
1125 const extraRequests = Math.max(0, meter.requests - a.requests);
1126 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
1127 const extraApps = Math.max(0, meter.peak_apps - a.apps);
1128 const cost = Math.ceil(
1129 (extraRequests / 1_000_000) * costs.millionRequests +
1130 (extraCpu / 1_000_000) * costs.millionCpuMs +
1131 extraApps * costs.appMonth,
1132 );
1133 if (cost > 0) {
1134 const parts = [
1135 extraApps && `${extraApps} extra apps`,
1136 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
1137 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
1138 ].filter(Boolean);
1139 const charged = await billingClient(this.env.BILLING).chargeFeature({
1140 workspace: meter.namespace,
1141 feature: "deployments",
1142 costMicros: cost,
1143 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
1144 reference: `deployments/${meter.namespace}/${meter.month}`,
1145 });
1146 if (!charged.ok) continue;
1147 }
1148 await this.db
1149 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
1150 .bind(now(), meter.namespace, meter.month)
1151 .run();
1152 }
1153 }
1154}
1155
1156/** `POST /rpc/<method>`: the arguments are the body. */
1157async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
1158 switch (method) {
1159 case "settings":
1160 return service.settings(args);
1161 case "update_settings":
1162 return service.updateSettings(args);
1163 case "list":
1164 return service.list(args);
1165 case "get":
1166 return service.get(args);
1167 case "redeploy":
1168 return service.redeploy(args);
1169 case "take_down":
1170 return service.takeDown(args);
1171 case "stack":
1172 return service.stack(args, (work) => ctx.waitUntil(work));
1173 case "overview":
1174 return service.overview(args);
1175 case "usage":
1176 return service.usage(args);
1177 default:
1178 return undefined;
1179 }
1180}
1181
1182export default {
1183 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1184 const { pathname } = new URL(request.url);
1185 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1186 const service = new Deployments(env);
1187 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1188 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1189 if (rpcMatch) {
1190 const result = await rpc(service, rpcMatch[1], body, ctx);
1191 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1192 }
1193 // A build's reports, forwarded by the API.
1194 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1195 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1196 return new Response("Not found\n", { status: 404 });
1197 },
1198
1199 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1200 const service = new Deployments(env);
1201 for (const message of batch.messages) {
1202 try {
1203 await service.onEvent(message.body);
1204 message.ack();
1205 } catch (error) {
1206 console.error("deployments could not handle", message.body.type, error);
1207 message.retry();
1208 }
1209 }
1210 },
1211
1212 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1213 await new Deployments(env).sweep();
1214 },
1215} satisfies ExportedHandler<Env, G1tEvent>;