g1t/services/deployments/src/index.ts

1,031 lines41,917 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};
151
152function toDeployment(row: DeploymentRow): Deployment {
153 return {
154 id: row.id,
155 kind: row.kind,
156 branch: row.branch,
157 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
170function 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
181class 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
193 private get projects() {
194 return projectsClient(this.env.PROJECTS);
195 }
196
197 /** 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
210 /**
211 * 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.
214 */
215 private async resolve(
216 project: { id: string; slug: string; repoId: string; repo: RepoPath },
217 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" },
223 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 }),
232 });
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 /**
239 * 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.
242 */
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
253 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
254 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
255 }
256
257 /** 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> {
272 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,
279 productionUrl: appUrl(await this.scriptFor(project, null)),
280 };
281 }
282
283 /** 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);
287 }
288
289 // ---- Methods for the site and the API ------------------------------
290
291 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)));
295 }
296
297 async updateSettings(a: {
298 actor: User;
299 project: ProjectRef;
300 changes: Partial<DeploySettings>;
301 }): Promise<Result<DeploySettings>> {
302 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));
306 const next = { ...before, ...a.changes };
307 if (next.enabled && !before.enabled) {
308 // Turning it on starts paid work: only with the workspace's plan.
309 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
310 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(
316 `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`,
321 )
322 .bind(
323 project.id,
324 project.workspace,
325 project.slug,
326 repoOf(project).id,
327 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.
338 if (!next.enabled) await this.takeDownWhere(project.id, null);
339 else {
340 if (!next.previews) await this.takeDownWhere(project.id, "preview");
341 if (!next.production) await this.takeDownWhere(project.id, "production");
342 }
343 // Turned on: production goes up from the default branch at once.
344 if (next.enabled && next.production && (!before.enabled || !before.production)) {
345 await this.deployProduction(project, null, a.actor.username);
346 }
347 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
348 }
349
350 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;
353 const [deployments, apps] = await Promise.all([
354 this.db
355 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
356 .bind(project.value.id, LIST_LIMIT)
357 .all<DeploymentRow>(),
358 this.db
359 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
360 .bind(project.value.id)
361 .all<AppRow>(),
362 ]);
363 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
364 }
365
366 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;
369 const row = await this.db
370 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
371 .bind(a.id, project.value.id)
372 .first<DeploymentRow>();
373 if (!row) return fail("not_found", "No such deployment.");
374 return ok({ ...toDeployment(row), log: row.log });
375 }
376
377 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.");
393 }
394
395 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);
399 return ok(true);
400 }
401
402 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
431 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 }>(),
446 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(),
447 ]);
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: {
467 project: Project;
468 kind: DeployKind;
469 branch: string | null;
470 number: number | null;
471 commit: string;
472 source: RepoPath;
473 reader: User;
474 createdBy: string;
475 settings: SettingsRow;
476 /** A push, or work by a member or an agent; see `insider`. */
477 trusted: boolean;
478 }): Promise<Result<Deployment>> {
479 const { project } = input;
480 const repo = repoOf(project);
481 const script = await this.scriptFor(project, input.branch);
482 const id = newId("dpl");
483 const token = randomToken();
484 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
485 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(
493 `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
496 )
497 .bind(
498 id,
499 project.id,
500 project.workspace,
501 project.slug,
502 repo.id,
503 `${repo.path.namespace}/${repo.path.name}`,
504 input.kind,
505 input.branch,
506 input.number,
507 input.commit,
508 script,
509 refused ? "skipped" : "queued",
510 refused,
511 refused ? null : await sha256(token),
512 input.trusted ? 1 : 0,
513 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();
527 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 );
534 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,
543 rootDir: project.source.rootDir,
544 buildCommand: input.settings.build_command,
545 outputDir: input.settings.output_dir,
546 buildEnv: build.variables,
547 buildSecrets: build.secrets,
548 }),
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
555 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
556 const settings = await this.settingsRow(project.id);
557 if (!settings?.enabled || !settings.production) return null;
558 const actor = await this.workspaceActor(project.workspace);
559 if (!actor) return null;
560 const repo = repoOf(project);
561 let head = commit;
562 if (!head) {
563 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;
565 }
566 if (!head) return null;
567 return this.start({
568 project,
569 kind: "production",
570 branch: null,
571 number: null,
572 commit: head,
573 source: repo.path,
574 reader: actor,
575 createdBy,
576 settings,
577 // The default branch only moves by people and agents with access.
578 trusted: true,
579 });
580 }
581
582 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
583 const settings = await this.settingsRow(project.id);
584 if (!settings?.enabled || !settings.previews) return null;
585 const actor = await this.workspaceActor(project.workspace);
586 if (!actor) return null;
587 const repo = repoOf(project);
588 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
589 if (!detail.ok) return null;
590 const { pull } = detail.value;
591 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
592 // A pull request from a fork (as g1t's agents work) has no branch here.
593 const branch = pull.branch ?? `pr-${number}`;
594 if (!force) {
595 // Already built, or being built, at this commit.
596 const same = await this.db
597 .prepare(
598 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
599 AND status IN ('queued', 'building', 'ready')`,
600 )
601 .bind(project.id, branch, pull.headCommit)
602 .first();
603 if (same) return null;
604 }
605 return this.start({
606 project,
607 kind: "preview",
608 branch,
609 number,
610 commit: pull.headCommit,
611 source: pull.fork ?? repo.path,
612 // The pull request's fork may be private: read it as its author.
613 reader: pull.author,
614 createdBy,
615 settings,
616 trusted: await this.insider(repo.path, pull.author, actor),
617 });
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> {
635 // 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) : "");
637 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;
657 const [namespace, name] = row.repo.split("/") as [string, string];
658 try {
659 // Running apps' secrets and variables are bound here, by g1t:
660 // they never pass through the build's sandbox.
661 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 );
666 await cloudflare.putScript(
667 row.script,
668 worker,
669 typeof body.completionJwt === "string" ? body.completionJwt : null,
670 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
671 runtime,
672 );
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(
687 `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`,
690 )
691 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
692 ]);
693 await this.chargeBuild(row, seconds);
694 await this.notePeak(row.workspace);
695 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
696 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);
718 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
719 }
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;
725 const what =
726 row.kind === "preview"
727 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
728 : `${row.workspace}/${row.slug} to production`;
729 await billingClient(this.env.BILLING).chargeFeature({
730 workspace: row.workspace,
731 feature: "deployments",
732 costMicros: cost,
733 description: `Building ${what} (${Math.ceil(seconds)} s)`,
734 repo: row.repo,
735 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 )
742 .bind(row.workspace, month(), Math.ceil(seconds), cost)
743 .run();
744 }
745
746 /** Remembers the most apps the workspace had up at once this month. */
747 private async notePeak(workspace: string): Promise<void> {
748 await this.db
749 .prepare(
750 `INSERT INTO meters (namespace, month, peak_apps)
751 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))
752 ON CONFLICT (namespace, month) DO UPDATE SET
753 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))`,
754 )
755 .bind(workspace, month())
756 .run();
757 }
758
759 /**
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})`;
772 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
773 method: "POST",
774 headers: { "content-type": "application/json" },
775 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
776 }).catch(() => undefined);
777 }
778
779 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
785 // ---- 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
792 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
793 const apps = await this.db
794 .prepare(
795 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
796 )
797 .bind(projectId, kind, branch ?? null)
798 .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":
808 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 }
812 break;
813 case "pull.closed":
814 case "pull.merged":
815 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 }
822 break;
823 case "git.push":
824 if (!event.data.defaultBranch) break;
825 for (const project of await this.projects.byRepo(event.data.repoId)) {
826 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
827 }
828 break;
829 }
830 }
831
832 // ---- The sweep -----------------------------------------------------
833
834 /**
835 * Every few minutes: builds that died are failed; usage is counted; idle
836 * 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.
839 */
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;
849 const workspaces = [...new Set(apps.map((app) => app.workspace))];
850
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") {
856 for (const app of apps.filter((a) => a.workspace === workspace)) await this.removeApp(app.script);
857 }
858 }
859
860 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
861 await this.count(apps).catch((error) => console.error("could not count usage", error));
862 await this.takeDownIdle();
863 await this.chargeMonths();
864 }
865
866 /** 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
881 /** 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;
893 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
894 sum.requests += used.requests;
895 sum.cpuMs += used.cpuMs;
896 perWorkspace.set(app.workspace, sum);
897 }
898 const at = now();
899 for (const [workspace, used] of perWorkspace) {
900 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 )
906 .bind(workspace, month(), used.requests, used.cpuMs, at)
907 .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 }
920 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
921 }
922
923 /** Previews no one has visited in their project's idle days. */
924 private async takeDownIdle(): Promise<void> {
925 const idle = await this.db
926 .prepare(
927 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
928 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);
989 case "overview":
990 return service.overview(args);
991 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>;