g1t/services/deployments/src/index.ts

1,031 lines41,917 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/**
Projects: what a workspace builds and runs, first on every page2 * 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.
Deployments: a preview for every pull request, production on g1t.page5 *
6 * It reacts to events (a pull request opened, ready, pushed to, closed or
Projects: what a workspace builds and runs, first on every page7 * 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.
Deployments: a preview for every pull request, production on g1t.page14 *
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
Projects: what a workspace builds and runs, first on every page19 * closes or after its project's idle days.
Deployments: a preview for every pull request, production on g1t.page20 *
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,
Projects: what a workspace builds and runs, first on every page32 projectsClient,
Deployments: a preview for every pull request, production on g1t.page33 reposClient,
34 workClient,
35 type DeployKind,
36 type DeploySettings,
37 type DeployStatus,
38 type DeployUsage,
39 type Deployment,
40 type G1tEvent,
41 type LiveApp,
Projects: what a workspace builds and runs, first on every page42 type Project,
43 type ProjectDeploys,
44 type ProjectRef,
Deployments: a preview for every pull request, production on g1t.page45 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";
Projects: what a workspace builds and runs, first on every page53import { appUrl, label, uniqueLabel } from "./names";
Deployments: a preview for every pull request, production on g1t.page54
55type Env = {
56 DB: D1Database;
57 REPOS: ServiceBinding;
58 WORK: ServiceBinding;
59 IDENTITY: ServiceBinding;
60 BILLING: ServiceBinding;
61 RUNNER: ServiceBinding;
Projects: what a workspace builds and runs, first on every page62 PROJECTS: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments63 /** Secrets and variables: the actions service holds the one store. */
64 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page65 /** 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;
Projects: what a workspace builds and runs, first on every page74/** A script in the namespace that no app holds, older than this, is removed. */
75const ORPHAN_AFTER_MS = 60 * 60 * 1000;
Deployments: a preview for every pull request, production on g1t.page76const 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
Projects: what a workspace builds and runs, first on every page95/** 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
Deployments: a preview for every pull request, production on g1t.page101type SettingsRow = {
Projects: what a workspace builds and runs, first on every page102 project_id: string;
103 workspace: string;
104 slug: string;
Deployments: a preview for every pull request, production on g1t.page105 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;
Projects: what a workspace builds and runs, first on every page116 project_id: string;
117 workspace: string;
118 slug: string;
Deployments: a preview for every pull request, production on g1t.page119 repo_id: string;
Projects: what a workspace builds and runs, first on every page120 repo: string;
Deployments: a preview for every pull request, production on g1t.page121 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page122 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page123 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;
Secrets and variables: one list, rows per environment, for workflows and deployments131 trusted: number;
Deployments: a preview for every pull request, production on g1t.page132 build_seconds: number | null;
133 created_by: string;
134 created_at: string;
135 finished_at: string | null;
136};
137
138type AppRow = {
139 script: string;
Projects: what a workspace builds and runs, first on every page140 project_id: string;
141 workspace: string;
142 slug: string;
Deployments: a preview for every pull request, production on g1t.page143 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page144 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page145 number: number | null;
146 commit_sha: string;
147 deployed_at: string;
148 created_at: string;
149 last_request_at: string | null;
150};
151
152function toDeployment(row: DeploymentRow): Deployment {
153 return {
154 id: row.id,
155 kind: row.kind,
Projects: what a workspace builds and runs, first on every page156 branch: row.branch,
Deployments: a preview for every pull request, production on g1t.page157 number: row.number,
158 commit: row.commit_sha,
159 status: row.status,
160 url: appUrl(row.script),
161 error: row.error,
162 warnings: JSON.parse(row.warnings || "[]") as string[],
163 buildSeconds: row.build_seconds,
164 createdBy: row.created_by,
165 createdAt: row.created_at,
166 finishedAt: row.finished_at,
167 };
168}
169
Projects: what a workspace builds and runs, first on every page170function toLive(app: AppRow): LiveApp {
171 return {
172 kind: app.kind,
173 branch: app.branch,
174 number: app.number,
175 url: appUrl(app.script),
176 commit: app.commit_sha,
177 deployedAt: app.deployed_at,
178 };
179}
180
Deployments: a preview for every pull request, production on g1t.page181class Deployments {
182 constructor(private readonly env: Env) {}
183
184 private get cloudflare(): Cloudflare | null {
185 const token = this.env.CLOUDFLARE_API_TOKEN;
186 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
187 }
188
189 private get db() {
190 return this.env.DB;
191 }
192
Projects: what a workspace builds and runs, first on every page193 private get projects() {
194 return projectsClient(this.env.PROJECTS);
195 }
196
Deployments: a preview for every pull request, production on g1t.page197 /** The workspace itself, as the service acts for it. */
198 private async workspaceActor(slug: string): Promise<User | null> {
199 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
200 if (!workspace) return null;
201 return {
202 id: workspace.id,
203 username: workspace.slug,
204 kind: "workspace",
205 verified: true,
206 workspaces: [{ slug: workspace.slug, role: "member" }],
207 };
208 }
209
Secrets and variables: one list, rows per environment, for workflows and deployments210 /**
Projects: what a workspace builds and runs, first on every page211 * What the project's secrets and variables available to deployments give
212 * production or a preview: its build's environment, and the same again as
213 * the running app's bindings. Untrusted builds get no secrets.
Secrets and variables: one list, rows per environment, for workflows and deployments214 */
215 private async resolve(
Projects: what a workspace builds and runs, first on every page216 project: { id: string; slug: string; repoId: string; repo: RepoPath },
Secrets and variables: one list, rows per environment, for workflows and deployments217 environment: DeployKind,
218 trusted: boolean,
219 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
220 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
221 method: "POST",
222 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page223 body: JSON.stringify({
224 repoId: project.repoId,
225 repo: project.repo,
226 projectId: project.id,
227 projectSlug: project.slug,
228 consumer: "deployments",
229 environment,
230 trusted,
231 }),
Secrets and variables: one list, rows per environment, for workflows and deployments232 });
233 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
234 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
235 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
236 }
237
238 /**
Projects: what a workspace builds and runs, first on every page239 * Whether a pull request's author is trusted with the project's secrets:
240 * g1t's agent, or a member of the workspace. Someone from outside gets a
241 * preview built without them, as their workflows run.
Secrets and variables: one list, rows per environment, for workflows and deployments242 */
243 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
244 if (author.kind === "agent" || author.username === "g1t-agent") return true;
245 // On a private repository only members can open one at all.
246 const found = await reposClient(this.env.REPOS).get(repo, actor);
247 if (found.ok && found.value.isPrivate) return true;
248 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
249 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
250 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
251 }
252
Projects: what a workspace builds and runs, first on every page253 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
254 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
Deployments: a preview for every pull request, production on g1t.page255 }
256
Projects: what a workspace builds and runs, first on every page257 /** The name an app gets, unique among apps: production, or a branch's preview. */
258 private async scriptFor(project: Project, branch: string | null): Promise<string> {
259 const base = await label(project.workspace, project.slug, branch);
260 const holder = await this.db
261 .prepare(
262 `SELECT project_id, branch FROM apps WHERE script = ?1
263 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
264 )
265 .bind(base)
266 .first<{ project_id: string; branch: string | null }>();
267 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
268 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
269 }
270
271 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
Deployments: a preview for every pull request, production on g1t.page272 return {
273 enabled: !!row?.enabled,
274 previews: row ? !!row.previews : true,
275 production: row ? !!row.production : true,
276 buildCommand: row?.build_command ?? null,
277 outputDir: row?.output_dir ?? null,
278 idleDays: row?.idle_days ?? 7,
Projects: what a workspace builds and runs, first on every page279 productionUrl: appUrl(await this.scriptFor(project, null)),
Deployments: a preview for every pull request, production on g1t.page280 };
281 }
282
Projects: what a workspace builds and runs, first on every page283 /** The project, if `viewer` belongs to its workspace. */
284 private async memberProject(ref: ProjectRef, viewer: Viewer): Promise<Result<Project>> {
285 if (!isMember(viewer, ref.workspace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
286 return this.projects.get(ref.workspace, ref.slug, viewer);
Deployments: a preview for every pull request, production on g1t.page287 }
288
289 // ---- Methods for the site and the API ------------------------------
290
Projects: what a workspace builds and runs, first on every page291 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
292 const project = await this.memberProject(a.project, a.viewer);
293 if (!project.ok) return project;
294 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
Deployments: a preview for every pull request, production on g1t.page295 }
296
297 async updateSettings(a: {
298 actor: User;
Projects: what a workspace builds and runs, first on every page299 project: ProjectRef;
Deployments: a preview for every pull request, production on g1t.page300 changes: Partial<DeploySettings>;
301 }): Promise<Result<DeploySettings>> {
Projects: what a workspace builds and runs, first on every page302 const found = await this.memberProject(a.project, a.actor);
303 if (!found.ok) return found;
304 const project = found.value;
305 const before = await this.toSettings(project, await this.settingsRow(project.id));
Deployments: a preview for every pull request, production on g1t.page306 const next = { ...before, ...a.changes };
307 if (next.enabled && !before.enabled) {
308 // Turning it on starts paid work: only with the workspace's plan.
Projects: what a workspace builds and runs, first on every page309 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page310 if (!plan.ok) return plan;
311 }
312 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
313 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
314 await this.db
315 .prepare(
Projects: what a workspace builds and runs, first on every page316 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
317 output_dir, idle_days, updated_by, updated_at)
318 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
319 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
320 production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12`,
Deployments: a preview for every pull request, production on g1t.page321 )
322 .bind(
Projects: what a workspace builds and runs, first on every page323 project.id,
324 project.workspace,
325 project.slug,
326 repoOf(project).id,
Deployments: a preview for every pull request, production on g1t.page327 next.enabled ? 1 : 0,
328 next.previews ? 1 : 0,
329 next.production ? 1 : 0,
330 clip(next.buildCommand),
331 clip(next.outputDir),
332 idleDays,
333 a.actor.username,
334 now(),
335 )
336 .run();
337 // What was turned off comes down now; nothing keeps running unasked.
Projects: what a workspace builds and runs, first on every page338 if (!next.enabled) await this.takeDownWhere(project.id, null);
Deployments: a preview for every pull request, production on g1t.page339 else {
Projects: what a workspace builds and runs, first on every page340 if (!next.previews) await this.takeDownWhere(project.id, "preview");
341 if (!next.production) await this.takeDownWhere(project.id, "production");
Deployments: a preview for every pull request, production on g1t.page342 }
343 // Turned on: production goes up from the default branch at once.
344 if (next.enabled && next.production && (!before.enabled || !before.production)) {
Projects: what a workspace builds and runs, first on every page345 await this.deployProduction(project, null, a.actor.username);
Deployments: a preview for every pull request, production on g1t.page346 }
Projects: what a workspace builds and runs, first on every page347 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
Deployments: a preview for every pull request, production on g1t.page348 }
349
Projects: what a workspace builds and runs, first on every page350 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
351 const project = await this.memberProject(a.project, a.viewer);
352 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page353 const [deployments, apps] = await Promise.all([
354 this.db
Projects: what a workspace builds and runs, first on every page355 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
356 .bind(project.value.id, LIST_LIMIT)
Deployments: a preview for every pull request, production on g1t.page357 .all<DeploymentRow>(),
358 this.db
Projects: what a workspace builds and runs, first on every page359 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
360 .bind(project.value.id)
Deployments: a preview for every pull request, production on g1t.page361 .all<AppRow>(),
362 ]);
Projects: what a workspace builds and runs, first on every page363 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
Deployments: a preview for every pull request, production on g1t.page364 }
365
Projects: what a workspace builds and runs, first on every page366 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
367 const project = await this.memberProject(a.project, a.viewer);
368 if (!project.ok) return project;
Deployments: a preview for every pull request, production on g1t.page369 const row = await this.db
Projects: what a workspace builds and runs, first on every page370 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
371 .bind(a.id, project.value.id)
Deployments: a preview for every pull request, production on g1t.page372 .first<DeploymentRow>();
373 if (!row) return fail("not_found", "No such deployment.");
374 return ok({ ...toDeployment(row), log: row.log });
375 }
376
Projects: what a workspace builds and runs, first on every page377 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
378 const found = await this.memberProject(a.project, a.actor);
379 if (!found.ok) return found;
380 const project = found.value;
381 const settings = await this.settingsRow(project.id);
382 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
383 if (a.branch == null) {
384 return (await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy.");
385 }
386 // A branch's preview comes from its pull request.
387 const app = await this.db
388 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
389 .bind(project.id, a.branch)
390 .first<{ number: number }>();
391 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
392 return (await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open.");
Deployments: a preview for every pull request, production on g1t.page393 }
394
Projects: what a workspace builds and runs, first on every page395 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
396 const project = await this.memberProject(a.project, a.actor);
397 if (!project.ok) return project;
398 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
Deployments: a preview for every pull request, production on g1t.page399 return ok(true);
400 }
401
Projects: what a workspace builds and runs, first on every page402 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
403 const workspace = a.workspace.toLowerCase();
404 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
405 const [settings, apps, latest] = await Promise.all([
406 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ?").bind(workspace).all<{ slug: string; enabled: number }>(),
407 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
408 this.db
409 .prepare(
410 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
411 )
412 .bind(workspace)
413 .all<DeploymentRow>(),
414 ]);
415 return ok(
416 settings.results.map((row) => {
417 const own = apps.results.filter((app) => app.slug === row.slug);
418 const production = own.find((app) => app.kind === "production");
419 const newest = latest.results.find((d) => d.slug === row.slug);
420 return {
421 slug: row.slug,
422 enabled: !!row.enabled,
423 production: production ? toLive(production) : null,
424 previews: own.filter((app) => app.kind === "preview").length,
425 latest: newest ? toDeployment(newest) : null,
426 };
427 }),
428 );
429 }
430
Deployments: a preview for every pull request, production on g1t.page431 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
432 const slug = a.workspace.toLowerCase();
433 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
434 const [meter, apps] = await Promise.all([
435 this.db
436 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
437 .bind(slug, month())
438 .first<{
439 requests: number;
440 cpu_ms: number;
441 peak_apps: number;
442 build_seconds: number;
443 build_micros: number;
444 counted_at: string | null;
445 }>(),
Projects: what a workspace builds and runs, first on every page446 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(),
Deployments: a preview for every pull request, production on g1t.page447 ]);
448 return ok({
449 month: month(),
450 requests: meter?.requests ?? 0,
451 cpuMs: meter?.cpu_ms ?? 0,
452 apps: apps?.n ?? 0,
453 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
454 buildSeconds: meter?.build_seconds ?? 0,
455 buildMicros: meter?.build_micros ?? 0,
456 countedAt: meter?.counted_at ?? null,
457 });
458 }
459
460 // ---- Starting builds -----------------------------------------------
461
462 /**
463 * Opens a deployment and starts its build. Skipped, with the reason
464 * recorded, when the workspace's plan is off.
465 */
466 private async start(input: {
Projects: what a workspace builds and runs, first on every page467 project: Project;
Deployments: a preview for every pull request, production on g1t.page468 kind: DeployKind;
Projects: what a workspace builds and runs, first on every page469 branch: string | null;
Deployments: a preview for every pull request, production on g1t.page470 number: number | null;
471 commit: string;
472 source: RepoPath;
473 reader: User;
474 createdBy: string;
475 settings: SettingsRow;
Projects: what a workspace builds and runs, first on every page476 /** A push, or work by a member or an agent; see `insider`. */
Secrets and variables: one list, rows per environment, for workflows and deployments477 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page478 }): Promise<Result<Deployment>> {
Projects: what a workspace builds and runs, first on every page479 const { project } = input;
480 const repo = repoOf(project);
481 const script = await this.scriptFor(project, input.branch);
Deployments: a preview for every pull request, production on g1t.page482 const id = newId("dpl");
483 const token = randomToken();
Projects: what a workspace builds and runs, first on every page484 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
Deployments: a preview for every pull request, production on g1t.page485 const cloudflare = this.cloudflare;
486 const refused = !plan.ok
487 ? plan.error.message
488 : !cloudflare
489 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
490 : null;
491 await this.db
492 .prepare(
Projects: what a workspace builds and runs, first on every page493 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
494 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
495 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page496 )
497 .bind(
498 id,
Projects: what a workspace builds and runs, first on every page499 project.id,
500 project.workspace,
501 project.slug,
502 repo.id,
503 `${repo.path.namespace}/${repo.path.name}`,
Deployments: a preview for every pull request, production on g1t.page504 input.kind,
Projects: what a workspace builds and runs, first on every page505 input.branch,
Deployments: a preview for every pull request, production on g1t.page506 input.number,
507 input.commit,
508 script,
509 refused ? "skipped" : "queued",
510 refused,
511 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments512 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page513 input.createdBy,
514 now(),
515 refused ? now() : null,
516 )
517 .run();
518 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
519 // Older builds of the same app are replaced by this one.
520 await this.db
521 .prepare(
522 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
523 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
524 )
525 .bind(now(), script, id)
526 .run();
Projects: what a workspace builds and runs, first on every page527 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
528 // What the project's secrets and variables give builds of this kind.
529 const build = await this.resolve(
530 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
531 input.kind,
532 input.trusted,
533 );
Deployments: a preview for every pull request, production on g1t.page534 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
535 method: "POST",
536 headers: { "content-type": "application/json" },
537 body: JSON.stringify({
538 deployId: id,
539 token,
540 actor: input.reader,
541 source: input.source,
542 commit: input.commit,
Projects: what a workspace builds and runs, first on every page543 rootDir: project.source.rootDir,
Deployments: a preview for every pull request, production on g1t.page544 buildCommand: input.settings.build_command,
545 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments546 buildEnv: build.variables,
547 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page548 }),
549 });
550 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
551 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
552 return ok(toDeployment((await this.deploymentRow(id))!));
553 }
554
Projects: what a workspace builds and runs, first on every page555 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
556 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page557 if (!settings?.enabled || !settings.production) return null;
Projects: what a workspace builds and runs, first on every page558 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page559 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page560 const repo = repoOf(project);
Deployments: a preview for every pull request, production on g1t.page561 let head = commit;
562 if (!head) {
Projects: what a workspace builds and runs, first on every page563 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
564 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
Deployments: a preview for every pull request, production on g1t.page565 }
566 if (!head) return null;
567 return this.start({
Projects: what a workspace builds and runs, first on every page568 project,
Deployments: a preview for every pull request, production on g1t.page569 kind: "production",
Projects: what a workspace builds and runs, first on every page570 branch: null,
Deployments: a preview for every pull request, production on g1t.page571 number: null,
572 commit: head,
Projects: what a workspace builds and runs, first on every page573 source: repo.path,
Deployments: a preview for every pull request, production on g1t.page574 reader: actor,
575 createdBy,
576 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments577 // The default branch only moves by people and agents with access.
578 trusted: true,
Deployments: a preview for every pull request, production on g1t.page579 });
580 }
581
Projects: what a workspace builds and runs, first on every page582 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
583 const settings = await this.settingsRow(project.id);
Deployments: a preview for every pull request, production on g1t.page584 if (!settings?.enabled || !settings.previews) return null;
Projects: what a workspace builds and runs, first on every page585 const actor = await this.workspaceActor(project.workspace);
Deployments: a preview for every pull request, production on g1t.page586 if (!actor) return null;
Projects: what a workspace builds and runs, first on every page587 const repo = repoOf(project);
588 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
Deployments: a preview for every pull request, production on g1t.page589 if (!detail.ok) return null;
590 const { pull } = detail.value;
591 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
Projects: what a workspace builds and runs, first on every page592 // A pull request from a fork (as g1t's agents work) has no branch here.
593 const branch = pull.branch ?? `pr-${number}`;
Deployments: a preview for every pull request, production on g1t.page594 if (!force) {
595 // Already built, or being built, at this commit.
596 const same = await this.db
597 .prepare(
Projects: what a workspace builds and runs, first on every page598 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
Deployments: a preview for every pull request, production on g1t.page599 AND status IN ('queued', 'building', 'ready')`,
600 )
Projects: what a workspace builds and runs, first on every page601 .bind(project.id, branch, pull.headCommit)
Deployments: a preview for every pull request, production on g1t.page602 .first();
603 if (same) return null;
604 }
605 return this.start({
Projects: what a workspace builds and runs, first on every page606 project,
Deployments: a preview for every pull request, production on g1t.page607 kind: "preview",
Projects: what a workspace builds and runs, first on every page608 branch,
Deployments: a preview for every pull request, production on g1t.page609 number,
610 commit: pull.headCommit,
Projects: what a workspace builds and runs, first on every page611 source: pull.fork ?? repo.path,
Deployments: a preview for every pull request, production on g1t.page612 // The pull request's fork may be private: read it as its author.
613 reader: pull.author,
614 createdBy,
615 settings,
Projects: what a workspace builds and runs, first on every page616 trusted: await this.insider(repo.path, pull.author, actor),
Deployments: a preview for every pull request, production on g1t.page617 });
618 }
619
620 // ---- A build's reports ---------------------------------------------
621
622 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
623 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
624 }
625
626 /** The build, if `token` is its own and it is still under way. */
627 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
628 const row = await this.deploymentRow(id);
629 if (!row?.token_hash || typeof token !== "string") return null;
630 if (row.token_hash !== (await sha256(token))) return null;
631 return row.status === "queued" || row.status === "building" ? row : null;
632 }
633
634 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
Deployments work end to end: fixes from the first live run635 // Each report, for the logs: a build's own failure says why.
636 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
Deployments: a preview for every pull request, production on g1t.page637 const row = await this.building(id, body.token);
638 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
639 const cloudflare = this.cloudflare;
640 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
641 switch (step) {
642 case "started":
643 await this.db
644 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
645 .bind(now(), id)
646 .run();
647 return Response.json(ok(true));
648 case "session": {
649 const manifest = body.manifest as Manifest | undefined;
650 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
651 const session = await cloudflare.openUpload(row.script, manifest);
652 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
653 }
654 case "finish": {
655 const worker = (body.worker ?? {}) as BuiltWorker;
656 const seconds = Number(body.buildSeconds) || 0;
Projects: what a workspace builds and runs, first on every page657 const [namespace, name] = row.repo.split("/") as [string, string];
Deployments: a preview for every pull request, production on g1t.page658 try {
Secrets and variables: one list, rows per environment, for workflows and deployments659 // Running apps' secrets and variables are bound here, by g1t:
660 // they never pass through the build's sandbox.
Projects: what a workspace builds and runs, first on every page661 const runtime = await this.resolve(
662 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
663 row.kind,
664 !!row.trusted,
665 );
Deployments: a preview for every pull request, production on g1t.page666 await cloudflare.putScript(
667 row.script,
668 worker,
669 typeof body.completionJwt === "string" ? body.completionJwt : null,
Projects: what a workspace builds and runs, first on every page670 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments671 runtime,
Deployments: a preview for every pull request, production on g1t.page672 );
673 } catch (error) {
674 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
675 return Response.json(ok(false));
676 }
677 const at = now();
678 await this.db.batch([
679 this.db
680 .prepare(
681 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
682 WHERE id = ?`,
683 )
684 .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
685 this.db
686 .prepare(
Projects: what a workspace builds and runs, first on every page687 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
688 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
689 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9`,
Deployments: a preview for every pull request, production on g1t.page690 )
Projects: what a workspace builds and runs, first on every page691 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
Deployments: a preview for every pull request, production on g1t.page692 ]);
693 await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page694 await this.notePeak(row.workspace);
695 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
Deployments: a preview for every pull request, production on g1t.page696 return Response.json(ok(true));
697 }
698 case "fail":
699 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
700 return Response.json(ok(true));
701 default:
702 return Response.json(fail("not_found", "No such step."), { status: 404 });
703 }
704 }
705
706 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
707 const row = await this.deploymentRow(id);
708 if (!row || (row.status !== "queued" && row.status !== "building")) return;
709 await this.db
710 .prepare(
711 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
712 WHERE id = ?`,
713 )
714 .bind(message.slice(0, 2000), log, seconds, now(), id)
715 .run();
716 // A failed build still used its sandbox.
717 if (seconds) await this.chargeBuild(row, seconds);
Projects: what a workspace builds and runs, first on every page718 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Deployments: a preview for every pull request, production on g1t.page719 }
720
721 /** Each build is charged by the second at the container price plus the margin. */
722 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
723 const cost = Math.ceil(seconds) * DEPLOYMENTS_ALLOWANCE.microsPerBuildSecond;
724 if (cost <= 0) return;
Projects: what a workspace builds and runs, first on every page725 const what =
726 row.kind === "preview"
727 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
728 : `${row.workspace}/${row.slug} to production`;
Deployments: a preview for every pull request, production on g1t.page729 await billingClient(this.env.BILLING).chargeFeature({
Projects: what a workspace builds and runs, first on every page730 workspace: row.workspace,
Deployments: a preview for every pull request, production on g1t.page731 feature: "deployments",
732 costMicros: cost,
733 description: `Building ${what} (${Math.ceil(seconds)} s)`,
Projects: what a workspace builds and runs, first on every page734 repo: row.repo,
Deployments: a preview for every pull request, production on g1t.page735 reference: `deploy/${row.id}`,
736 });
737 await this.db
738 .prepare(
739 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
740 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
741 )
Projects: what a workspace builds and runs, first on every page742 .bind(row.workspace, month(), Math.ceil(seconds), cost)
Deployments: a preview for every pull request, production on g1t.page743 .run();
744 }
745
746 /** Remembers the most apps the workspace had up at once this month. */
Projects: what a workspace builds and runs, first on every page747 private async notePeak(workspace: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page748 await this.db
749 .prepare(
750 `INSERT INTO meters (namespace, month, peak_apps)
Projects: what a workspace builds and runs, first on every page751 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))
Deployments: a preview for every pull request, production on g1t.page752 ON CONFLICT (namespace, month) DO UPDATE SET
Projects: what a workspace builds and runs, first on every page753 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))`,
Deployments: a preview for every pull request, production on g1t.page754 )
Projects: what a workspace builds and runs, first on every page755 .bind(workspace, month())
Deployments: a preview for every pull request, production on g1t.page756 .run();
757 }
758
Projects: what a workspace builds and runs, first on every page759 /**
760 * The check on the commit: `g1t / deploy`, or, for one of several
761 * projects on a repository, `g1t / deploy (<project>)`.
762 */
763 private async status(
764 repoId: string,
765 sha: string,
766 project: { slug: string; primary: boolean },
767 state: string,
768 description: string,
769 targetUrl: string,
770 ): Promise<void> {
771 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
Deployments: a preview for every pull request, production on g1t.page772 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
773 method: "POST",
774 headers: { "content-type": "application/json" },
Projects: what a workspace builds and runs, first on every page775 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
Deployments: a preview for every pull request, production on g1t.page776 }).catch(() => undefined);
777 }
778
Projects: what a workspace builds and runs, first on every page779 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
780 const projects = await this.projects.byRepo(row.repo_id);
781 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
782 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
783 }
784
Deployments: a preview for every pull request, production on g1t.page785 // ---- Taking apps down ----------------------------------------------
786
787 private async removeApp(script: string): Promise<void> {
788 await this.cloudflare?.deleteScript(script);
789 await this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script).run();
790 }
791
Projects: what a workspace builds and runs, first on every page792 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
Deployments: a preview for every pull request, production on g1t.page793 const apps = await this.db
794 .prepare(
Projects: what a workspace builds and runs, first on every page795 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
Deployments: a preview for every pull request, production on g1t.page796 )
Projects: what a workspace builds and runs, first on every page797 .bind(projectId, kind, branch ?? null)
Deployments: a preview for every pull request, production on g1t.page798 .all<{ script: string }>();
799 for (const app of apps.results) await this.removeApp(app.script);
800 }
801
802 // ---- Events --------------------------------------------------------
803
804 async onEvent(event: G1tEvent): Promise<void> {
805 switch (event.type) {
806 case "pull.opened":
807 case "pull.ready":
Projects: what a workspace builds and runs, first on every page808 case "pull.updated":
809 for (const project of await this.projects.byRepo(event.data.repoId)) {
810 await this.deployPreview(project, event.data.number, "g1t");
811 }
Deployments: a preview for every pull request, production on g1t.page812 break;
813 case "pull.closed":
814 case "pull.merged":
Projects: what a workspace builds and runs, first on every page815 for (const project of await this.projects.byRepo(event.data.repoId)) {
816 const apps = await this.db
817 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
818 .bind(project.id, event.data.number)
819 .all<{ script: string }>();
820 for (const app of apps.results) await this.removeApp(app.script);
821 }
Deployments: a preview for every pull request, production on g1t.page822 break;
Projects: what a workspace builds and runs, first on every page823 case "git.push":
Deployments: a preview for every pull request, production on g1t.page824 if (!event.data.defaultBranch) break;
Projects: what a workspace builds and runs, first on every page825 for (const project of await this.projects.byRepo(event.data.repoId)) {
826 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
827 }
Deployments: a preview for every pull request, production on g1t.page828 break;
829 }
830 }
831
832 // ---- The sweep -----------------------------------------------------
833
834 /**
835 * Every few minutes: builds that died are failed; usage is counted; idle
Projects: what a workspace builds and runs, first on every page836 * previews, the apps of workspaces whose plan ended, and scripts no app
837 * holds come down; and a month that is over is charged past its
838 * allowance.
Deployments: a preview for every pull request, production on g1t.page839 */
840 async sweep(): Promise<void> {
841 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
842 const stuck = await this.db
843 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
844 .bind(cutoff)
845 .all<{ id: string }>();
846 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
847
848 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
Projects: what a workspace builds and runs, first on every page849 const workspaces = [...new Set(apps.map((app) => app.workspace))];
Deployments: a preview for every pull request, production on g1t.page850
851 // Apps of workspaces whose plan has ended come down.
852 const billing = billingClient(this.env.BILLING);
853 for (const workspace of workspaces) {
854 const plan = await billing.hasFeature(workspace, "deployments");
855 if (!plan.ok && plan.error.code === "payment_required") {
Projects: what a workspace builds and runs, first on every page856 for (const app of apps.filter((a) => a.workspace === workspace)) await this.removeApp(app.script);
Deployments: a preview for every pull request, production on g1t.page857 }
858 }
859
Projects: what a workspace builds and runs, first on every page860 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
Deployments: a preview for every pull request, production on g1t.page861 await this.count(apps).catch((error) => console.error("could not count usage", error));
862 await this.takeDownIdle();
863 await this.chargeMonths();
864 }
865
Projects: what a workspace builds and runs, first on every page866 /** Scripts in the namespace that no app holds, such as ones renamed. */
867 private async removeOrphans(apps: AppRow[]): Promise<void> {
868 const cloudflare = this.cloudflare;
869 if (!cloudflare) return;
870 const held = new Set(apps.map((app) => app.script));
871 const building = await this.db
872 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
873 .all<{ script: string }>();
874 for (const row of building.results) held.add(row.script);
875 const cutoff = Date.now() - ORPHAN_AFTER_MS;
876 for (const script of await cloudflare.listScripts()) {
877 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
878 }
879 }
880
Deployments: a preview for every pull request, production on g1t.page881 /** Counts this month's requests and CPU time per workspace, from analytics. */
882 private async count(apps: AppRow[]): Promise<void> {
883 const cloudflare = this.cloudflare;
884 if (!cloudflare || apps.length === 0) return;
885 const start = `${month()}-01T00:00:00Z`;
886 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
887 // Analytics only counts apps that are up; the meter keeps what earlier
888 // apps used by never going down.
889 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
890 for (const app of apps) {
891 const used = totals.get(app.script);
892 if (!used) continue;
Projects: what a workspace builds and runs, first on every page893 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
Deployments: a preview for every pull request, production on g1t.page894 sum.requests += used.requests;
895 sum.cpuMs += used.cpuMs;
Projects: what a workspace builds and runs, first on every page896 perWorkspace.set(app.workspace, sum);
Deployments: a preview for every pull request, production on g1t.page897 }
898 const at = now();
Projects: what a workspace builds and runs, first on every page899 for (const [workspace, used] of perWorkspace) {
Deployments: a preview for every pull request, production on g1t.page900 await this.db
901 .prepare(
902 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
903 ON CONFLICT (namespace, month) DO UPDATE SET
904 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
905 )
Projects: what a workspace builds and runs, first on every page906 .bind(workspace, month(), used.requests, used.cpuMs, at)
Deployments: a preview for every pull request, production on g1t.page907 .run();
908 }
909 // When each preview last answered anyone, for the idle sweep.
910 const recent = await cloudflare.usage(
911 apps.filter((app) => app.kind === "preview").map((app) => app.script),
912 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
913 at,
914 );
915 for (const [script, used] of recent) {
916 if (used.requests > 0) {
917 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
918 }
919 }
Projects: what a workspace builds and runs, first on every page920 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
Deployments: a preview for every pull request, production on g1t.page921 }
922
Projects: what a workspace builds and runs, first on every page923 /** Previews no one has visited in their project's idle days. */
Deployments: a preview for every pull request, production on g1t.page924 private async takeDownIdle(): Promise<void> {
925 const idle = await this.db
926 .prepare(
Projects: what a workspace builds and runs, first on every page927 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Deployments: a preview for every pull request, production on g1t.page928 WHERE apps.kind = 'preview'
929 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
930 )
931 .all<{ script: string }>();
932 for (const { script } of idle.results) await this.removeApp(script);
933 }
934
935 /** Charges each month that is over for what it used past the allowance, once. */
936 private async chargeMonths(): Promise<void> {
937 const due = await this.db
938 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
939 .bind(month())
940 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
941 const a = DEPLOYMENTS_ALLOWANCE;
942 for (const meter of due.results) {
943 const extraRequests = Math.max(0, meter.requests - a.requests);
944 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
945 const extraApps = Math.max(0, meter.peak_apps - a.apps);
946 const cost = Math.ceil(
947 (extraRequests / 1_000_000) * a.microsPerMillionRequests +
948 (extraCpu / 1_000_000) * a.microsPerMillionCpuMs +
949 extraApps * a.microsPerAppMonth,
950 );
951 if (cost > 0) {
952 const parts = [
953 extraApps && `${extraApps} extra apps`,
954 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
955 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
956 ].filter(Boolean);
957 const charged = await billingClient(this.env.BILLING).chargeFeature({
958 workspace: meter.namespace,
959 feature: "deployments",
960 costMicros: cost,
961 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
962 reference: `deployments/${meter.namespace}/${meter.month}`,
963 });
964 if (!charged.ok) continue;
965 }
966 await this.db
967 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
968 .bind(now(), meter.namespace, meter.month)
969 .run();
970 }
971 }
972}
973
974/** `POST /rpc/<method>`: the arguments are the body. */
975async function rpc(service: Deployments, method: string, args: any): Promise<unknown> {
976 switch (method) {
977 case "settings":
978 return service.settings(args);
979 case "update_settings":
980 return service.updateSettings(args);
981 case "list":
982 return service.list(args);
983 case "get":
984 return service.get(args);
985 case "redeploy":
986 return service.redeploy(args);
987 case "take_down":
988 return service.takeDown(args);
Projects: what a workspace builds and runs, first on every page989 case "overview":
990 return service.overview(args);
Deployments: a preview for every pull request, production on g1t.page991 case "usage":
992 return service.usage(args);
993 default:
994 return undefined;
995 }
996}
997
998export default {
999 async fetch(request: Request, env: Env): Promise<Response> {
1000 const { pathname } = new URL(request.url);
1001 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1002 const service = new Deployments(env);
1003 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1004 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1005 if (rpcMatch) {
1006 const result = await rpc(service, rpcMatch[1], body);
1007 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1008 }
1009 // A build's reports, forwarded by the API.
1010 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1011 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1012 return new Response("Not found\n", { status: 404 });
1013 },
1014
1015 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1016 const service = new Deployments(env);
1017 for (const message of batch.messages) {
1018 try {
1019 await service.onEvent(message.body);
1020 message.ack();
1021 } catch (error) {
1022 console.error("deployments could not handle", message.body.type, error);
1023 message.retry();
1024 }
1025 }
1026 },
1027
1028 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1029 await new Deployments(env).sweep();
1030 },
1031} satisfies ExportedHandler<Env, G1tEvent>;