pr_01m47d24b0e6n91zwymwxg0vpx/services/deployments/src/index.ts

1,131 lines46,373 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 branch: string | null,
220 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
221 const [rows, references] = await Promise.all([
222 this.rows(project, environment, trusted),
223 this.references(project.id, environment === "preview" ? branch : null),
224 ]);
225 // The project's own rows win over a dependency's address of the same name.
226 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
227 }
228
229 /**
230 * Each dependency's address, under the name the dependency gives it:
231 * for a preview, the same branch's preview of it if one is up, else its
232 * production; for production, its production.
233 */
234 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
235 const graph = await this.projects.graph(projectId).catch(() => null);
236 const out: Record<string, string> = {};
237 for (const dependency of graph?.dependsOn ?? []) {
238 if (!dependency.as) continue;
239 const app =
240 (branch
241 ? await this.db
242 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
243 .bind(dependency.id, branch)
244 .first<{ script: string }>()
245 : null) ??
246 (await this.db
247 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
248 .bind(dependency.id)
249 .first<{ script: string }>());
250 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
251 }
252 return out;
253 }
254
255 private async rows(
256 project: { id: string; slug: string; repoId: string; repo: RepoPath },
257 environment: DeployKind,
258 trusted: boolean,
259 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
260 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
261 method: "POST",
262 headers: { "content-type": "application/json" },
263 body: JSON.stringify({
264 repoId: project.repoId,
265 repo: project.repo,
266 projectId: project.id,
267 projectSlug: project.slug,
268 consumer: "deployments",
269 environment,
270 trusted,
271 }),
272 });
273 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
274 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
275 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
276 }
277
278 /**
279 * Whether a pull request's author is trusted with the project's secrets:
280 * g1t's agent, or a member of the workspace. Someone from outside gets a
281 * preview built without them, as their workflows run.
282 */
283 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
284 if (author.kind === "agent" || author.username === "g1t-agent") return true;
285 // On a private repository only members can open one at all.
286 const found = await reposClient(this.env.REPOS).get(repo, actor);
287 if (found.ok && found.value.isPrivate) return true;
288 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
289 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
290 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
291 }
292
293 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
294 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
295 }
296
297 /** The name an app gets, unique among apps: production, or a branch's preview. */
298 private async scriptFor(project: Project, branch: string | null): Promise<string> {
299 const base = await label(project.workspace, project.slug, branch);
300 const holder = await this.db
301 .prepare(
302 `SELECT project_id, branch FROM apps WHERE script = ?1
303 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
304 )
305 .bind(base)
306 .first<{ project_id: string; branch: string | null }>();
307 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
308 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
309 }
310
311 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
312 return {
313 enabled: !!row?.enabled,
314 previews: row ? !!row.previews : true,
315 production: row ? !!row.production : true,
316 buildCommand: row?.build_command ?? null,
317 outputDir: row?.output_dir ?? null,
318 idleDays: row?.idle_days ?? 7,
319 productionUrl: appUrl(await this.scriptFor(project, null)),
320 };
321 }
322
323 /** The project, if `viewer` belongs to its workspace. */
324 private async memberProject(ref: ProjectRef, viewer: Viewer): Promise<Result<Project>> {
325 if (!isMember(viewer, ref.workspace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
326 return this.projects.get(ref.workspace, ref.slug, viewer);
327 }
328
329 // ---- Methods for the site and the API ------------------------------
330
331 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
332 const project = await this.memberProject(a.project, a.viewer);
333 if (!project.ok) return project;
334 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
335 }
336
337 async updateSettings(a: {
338 actor: User;
339 project: ProjectRef;
340 changes: Partial<DeploySettings>;
341 }): Promise<Result<DeploySettings>> {
342 const found = await this.memberProject(a.project, a.actor);
343 if (!found.ok) return found;
344 const project = found.value;
345 const before = await this.toSettings(project, await this.settingsRow(project.id));
346 const next = { ...before, ...a.changes };
347 if (next.enabled && !before.enabled) {
348 // Turning it on starts paid work: only with the workspace's plan.
349 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
350 if (!plan.ok) return plan;
351 }
352 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
353 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
354 await this.db
355 .prepare(
356 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
357 output_dir, idle_days, updated_by, updated_at)
358 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
359 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
360 production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12`,
361 )
362 .bind(
363 project.id,
364 project.workspace,
365 project.slug,
366 repoOf(project).id,
367 next.enabled ? 1 : 0,
368 next.previews ? 1 : 0,
369 next.production ? 1 : 0,
370 clip(next.buildCommand),
371 clip(next.outputDir),
372 idleDays,
373 a.actor.username,
374 now(),
375 )
376 .run();
377 // What was turned off comes down now; nothing keeps running unasked.
378 if (!next.enabled) await this.takeDownWhere(project.id, null);
379 else {
380 if (!next.previews) await this.takeDownWhere(project.id, "preview");
381 if (!next.production) await this.takeDownWhere(project.id, "production");
382 }
383 // Turned on: production goes up from the default branch at once.
384 if (next.enabled && next.production && (!before.enabled || !before.production)) {
385 await this.deployProduction(project, null, a.actor.username);
386 }
387 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
388 }
389
390 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
391 const project = await this.memberProject(a.project, a.viewer);
392 if (!project.ok) return project;
393 const [deployments, apps] = await Promise.all([
394 this.db
395 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
396 .bind(project.value.id, LIST_LIMIT)
397 .all<DeploymentRow>(),
398 this.db
399 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
400 .bind(project.value.id)
401 .all<AppRow>(),
402 ]);
403 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
404 }
405
406 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
407 const project = await this.memberProject(a.project, a.viewer);
408 if (!project.ok) return project;
409 const row = await this.db
410 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
411 .bind(a.id, project.value.id)
412 .first<DeploymentRow>();
413 if (!row) return fail("not_found", "No such deployment.");
414 return ok({ ...toDeployment(row), log: row.log });
415 }
416
417 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
418 const found = await this.memberProject(a.project, a.actor);
419 if (!found.ok) return found;
420 const project = found.value;
421 const settings = await this.settingsRow(project.id);
422 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
423 if (a.branch == null) {
424 return (await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy.");
425 }
426 // A branch's preview comes from its pull request.
427 const app = await this.db
428 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
429 .bind(project.id, a.branch)
430 .first<{ number: number }>();
431 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
432 return (await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open.");
433 }
434
435 /**
436 * A preview stack: the projects that use this one get previews of their
437 * own default branch, under the same branch name, so each reaches this
438 * branch's preview through its dependency's variable. A change to an API
439 * can then be clicked through in the apps that call it.
440 */
441 async stack(
442 a: { actor: User; project: ProjectRef; branch: string },
443 background: (work: Promise<unknown>) => void,
444 ): Promise<Result<string[]>> {
445 const found = await this.memberProject(a.project, a.actor);
446 if (!found.ok) return found;
447 const upstream = await this.db
448 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
449 .bind(found.value.id, a.branch)
450 .first();
451 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
452 const graph = await this.projects.graph(found.value.id);
453 const ready: { project: Project; settings: SettingsRow }[] = [];
454 for (const dependent of graph.usedBy) {
455 const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
456 if (!project.ok) continue;
457 const settings = await this.settingsRow(project.value.id);
458 if (settings?.enabled && settings.previews) ready.push({ project: project.value, settings });
459 }
460 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
461 // The builds start after the answer: a person moving on from the page
462 // does not stop them.
463 background(
464 (async () => {
465 for (const { project, settings } of ready) {
466 const actor = await this.workspaceActor(project.workspace);
467 if (!actor) continue;
468 const repo = repoOf(project);
469 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
470 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
471 if (!head) continue;
472 await this.start({
473 project,
474 kind: "preview",
475 branch: a.branch,
476 number: null,
477 commit: head,
478 source: repo.path,
479 reader: actor,
480 createdBy: a.actor.username,
481 settings,
482 // Its own default branch, asked for by a member.
483 trusted: true,
484 });
485 }
486 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
487 );
488 return ok(ready.map(({ project }) => project.name));
489 }
490
491 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
492 const project = await this.memberProject(a.project, a.actor);
493 if (!project.ok) return project;
494 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
495 return ok(true);
496 }
497
498 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
499 const workspace = a.workspace.toLowerCase();
500 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
501 const [settings, apps, latest] = await Promise.all([
502 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ?").bind(workspace).all<{ slug: string; enabled: number }>(),
503 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
504 this.db
505 .prepare(
506 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
507 )
508 .bind(workspace)
509 .all<DeploymentRow>(),
510 ]);
511 return ok(
512 settings.results.map((row) => {
513 const own = apps.results.filter((app) => app.slug === row.slug);
514 const production = own.find((app) => app.kind === "production");
515 const newest = latest.results.find((d) => d.slug === row.slug);
516 return {
517 slug: row.slug,
518 enabled: !!row.enabled,
519 production: production ? toLive(production) : null,
520 previews: own.filter((app) => app.kind === "preview").length,
521 latest: newest ? toDeployment(newest) : null,
522 };
523 }),
524 );
525 }
526
527 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
528 const slug = a.workspace.toLowerCase();
529 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
530 const [meter, apps] = await Promise.all([
531 this.db
532 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
533 .bind(slug, month())
534 .first<{
535 requests: number;
536 cpu_ms: number;
537 peak_apps: number;
538 build_seconds: number;
539 build_micros: number;
540 counted_at: string | null;
541 }>(),
542 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(),
543 ]);
544 return ok({
545 month: month(),
546 requests: meter?.requests ?? 0,
547 cpuMs: meter?.cpu_ms ?? 0,
548 apps: apps?.n ?? 0,
549 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
550 buildSeconds: meter?.build_seconds ?? 0,
551 buildMicros: meter?.build_micros ?? 0,
552 countedAt: meter?.counted_at ?? null,
553 });
554 }
555
556 // ---- Starting builds -----------------------------------------------
557
558 /**
559 * Opens a deployment and starts its build. Skipped, with the reason
560 * recorded, when the workspace's plan is off.
561 */
562 private async start(input: {
563 project: Project;
564 kind: DeployKind;
565 branch: string | null;
566 number: number | null;
567 commit: string;
568 source: RepoPath;
569 reader: User;
570 createdBy: string;
571 settings: SettingsRow;
572 /** A push, or work by a member or an agent; see `insider`. */
573 trusted: boolean;
574 }): Promise<Result<Deployment>> {
575 const { project } = input;
576 const repo = repoOf(project);
577 const script = await this.scriptFor(project, input.branch);
578 const id = newId("dpl");
579 const token = randomToken();
580 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
581 const cloudflare = this.cloudflare;
582 const refused = !plan.ok
583 ? plan.error.message
584 : !cloudflare
585 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
586 : null;
587 await this.db
588 .prepare(
589 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
590 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
591 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
592 )
593 .bind(
594 id,
595 project.id,
596 project.workspace,
597 project.slug,
598 repo.id,
599 `${repo.path.namespace}/${repo.path.name}`,
600 input.kind,
601 input.branch,
602 input.number,
603 input.commit,
604 script,
605 refused ? "skipped" : "queued",
606 refused,
607 refused ? null : await sha256(token),
608 input.trusted ? 1 : 0,
609 input.createdBy,
610 now(),
611 refused ? now() : null,
612 )
613 .run();
614 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
615 // Older builds of the same app are replaced by this one.
616 await this.db
617 .prepare(
618 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
619 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
620 )
621 .bind(now(), script, id)
622 .run();
623 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
624 // What the project's secrets and variables give builds of this kind.
625 const build = await this.resolve(
626 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
627 input.kind,
628 input.trusted,
629 input.branch,
630 );
631 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
632 method: "POST",
633 headers: { "content-type": "application/json" },
634 body: JSON.stringify({
635 deployId: id,
636 token,
637 actor: input.reader,
638 source: input.source,
639 commit: input.commit,
640 rootDir: project.source.rootDir,
641 buildCommand: input.settings.build_command,
642 outputDir: input.settings.output_dir,
643 buildEnv: build.variables,
644 buildSecrets: build.secrets,
645 }),
646 });
647 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
648 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
649 return ok(toDeployment((await this.deploymentRow(id))!));
650 }
651
652 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
653 const settings = await this.settingsRow(project.id);
654 if (!settings?.enabled || !settings.production) return null;
655 const actor = await this.workspaceActor(project.workspace);
656 if (!actor) return null;
657 const repo = repoOf(project);
658 let head = commit;
659 if (!head) {
660 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
661 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
662 }
663 if (!head) return null;
664 return this.start({
665 project,
666 kind: "production",
667 branch: null,
668 number: null,
669 commit: head,
670 source: repo.path,
671 reader: actor,
672 createdBy,
673 settings,
674 // The default branch only moves by people and agents with access.
675 trusted: true,
676 });
677 }
678
679 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
680 const settings = await this.settingsRow(project.id);
681 if (!settings?.enabled || !settings.previews) return null;
682 const actor = await this.workspaceActor(project.workspace);
683 if (!actor) return null;
684 const repo = repoOf(project);
685 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
686 if (!detail.ok) return null;
687 const { pull } = detail.value;
688 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
689 // A pull request from a fork (as g1t's agents work) has no branch here.
690 const branch = pull.branch ?? `pr-${number}`;
691 if (!force) {
692 // Already built, or being built, at this commit.
693 const same = await this.db
694 .prepare(
695 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
696 AND status IN ('queued', 'building', 'ready')`,
697 )
698 .bind(project.id, branch, pull.headCommit)
699 .first();
700 if (same) return null;
701 }
702 return this.start({
703 project,
704 kind: "preview",
705 branch,
706 number,
707 commit: pull.headCommit,
708 source: pull.fork ?? repo.path,
709 // The pull request's fork may be private: read it as its author.
710 reader: pull.author,
711 createdBy,
712 settings,
713 trusted: await this.insider(repo.path, pull.author, actor),
714 });
715 }
716
717 // ---- A build's reports ---------------------------------------------
718
719 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
720 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
721 }
722
723 /** The build, if `token` is its own and it is still under way. */
724 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
725 const row = await this.deploymentRow(id);
726 if (!row?.token_hash || typeof token !== "string") return null;
727 if (row.token_hash !== (await sha256(token))) return null;
728 return row.status === "queued" || row.status === "building" ? row : null;
729 }
730
731 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
732 // Each report, for the logs: a build's own failure says why.
733 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
734 const row = await this.building(id, body.token);
735 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
736 const cloudflare = this.cloudflare;
737 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
738 switch (step) {
739 case "started":
740 await this.db
741 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
742 .bind(now(), id)
743 .run();
744 return Response.json(ok(true));
745 case "session": {
746 const manifest = body.manifest as Manifest | undefined;
747 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
748 const session = await cloudflare.openUpload(row.script, manifest);
749 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
750 }
751 case "finish": {
752 const worker = (body.worker ?? {}) as BuiltWorker;
753 const seconds = Number(body.buildSeconds) || 0;
754 const [namespace, name] = row.repo.split("/") as [string, string];
755 try {
756 // Running apps' secrets and variables are bound here, by g1t:
757 // they never pass through the build's sandbox.
758 const runtime = await this.resolve(
759 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
760 row.kind,
761 !!row.trusted,
762 row.branch,
763 );
764 await cloudflare.putScript(
765 row.script,
766 worker,
767 typeof body.completionJwt === "string" ? body.completionJwt : null,
768 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
769 runtime,
770 );
771 } catch (error) {
772 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
773 return Response.json(ok(false));
774 }
775 const at = now();
776 await this.db.batch([
777 this.db
778 .prepare(
779 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
780 WHERE id = ?`,
781 )
782 .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
783 this.db
784 .prepare(
785 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
786 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
787 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9`,
788 )
789 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
790 ]);
791 await this.chargeBuild(row, seconds);
792 await this.notePeak(row.workspace);
793 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
794 return Response.json(ok(true));
795 }
796 case "fail":
797 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
798 return Response.json(ok(true));
799 default:
800 return Response.json(fail("not_found", "No such step."), { status: 404 });
801 }
802 }
803
804 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
805 const row = await this.deploymentRow(id);
806 if (!row || (row.status !== "queued" && row.status !== "building")) return;
807 await this.db
808 .prepare(
809 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
810 WHERE id = ?`,
811 )
812 .bind(message.slice(0, 2000), log, seconds, now(), id)
813 .run();
814 // A failed build still used its sandbox.
815 if (seconds) await this.chargeBuild(row, seconds);
816 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
817 }
818
819 /** Each build is charged by the second at the container price plus the margin. */
820 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
821 const cost = Math.ceil(seconds) * DEPLOYMENTS_ALLOWANCE.microsPerBuildSecond;
822 if (cost <= 0) return;
823 const what =
824 row.kind === "preview"
825 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
826 : `${row.workspace}/${row.slug} to production`;
827 await billingClient(this.env.BILLING).chargeFeature({
828 workspace: row.workspace,
829 feature: "deployments",
830 costMicros: cost,
831 description: `Building ${what} (${Math.ceil(seconds)} s)`,
832 repo: row.repo,
833 reference: `deploy/${row.id}`,
834 });
835 await this.db
836 .prepare(
837 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
838 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
839 )
840 .bind(row.workspace, month(), Math.ceil(seconds), cost)
841 .run();
842 }
843
844 /** Remembers the most apps the workspace had up at once this month. */
845 private async notePeak(workspace: string): Promise<void> {
846 await this.db
847 .prepare(
848 `INSERT INTO meters (namespace, month, peak_apps)
849 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))
850 ON CONFLICT (namespace, month) DO UPDATE SET
851 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1))`,
852 )
853 .bind(workspace, month())
854 .run();
855 }
856
857 /**
858 * The check on the commit: `g1t / deploy`, or, for one of several
859 * projects on a repository, `g1t / deploy (<project>)`.
860 */
861 private async status(
862 repoId: string,
863 sha: string,
864 project: { slug: string; primary: boolean },
865 state: string,
866 description: string,
867 targetUrl: string,
868 ): Promise<void> {
869 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
870 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
871 method: "POST",
872 headers: { "content-type": "application/json" },
873 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
874 }).catch(() => undefined);
875 }
876
877 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
878 const projects = await this.projects.byRepo(row.repo_id);
879 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
880 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
881 }
882
883 // ---- Taking apps down ----------------------------------------------
884
885 private async removeApp(script: string): Promise<void> {
886 await this.cloudflare?.deleteScript(script);
887 await this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script).run();
888 }
889
890 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
891 const apps = await this.db
892 .prepare(
893 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
894 )
895 .bind(projectId, kind, branch ?? null)
896 .all<{ script: string }>();
897 for (const app of apps.results) await this.removeApp(app.script);
898 }
899
900 // ---- Events --------------------------------------------------------
901
902 async onEvent(event: G1tEvent): Promise<void> {
903 switch (event.type) {
904 case "pull.opened":
905 case "pull.ready":
906 case "pull.updated":
907 for (const project of await this.projects.byRepo(event.data.repoId)) {
908 await this.deployPreview(project, event.data.number, "g1t");
909 }
910 break;
911 case "pull.closed":
912 case "pull.merged":
913 for (const project of await this.projects.byRepo(event.data.repoId)) {
914 const apps = await this.db
915 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
916 .bind(project.id, event.data.number)
917 .all<{ script: string }>();
918 for (const app of apps.results) await this.removeApp(app.script);
919 }
920 break;
921 case "git.push":
922 if (!event.data.defaultBranch) break;
923 for (const project of await this.projects.byRepo(event.data.repoId)) {
924 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
925 }
926 break;
927 }
928 }
929
930 // ---- The sweep -----------------------------------------------------
931
932 /**
933 * Every few minutes: builds that died are failed; usage is counted; idle
934 * previews, the apps of workspaces whose plan ended, and scripts no app
935 * holds come down; and a month that is over is charged past its
936 * allowance.
937 */
938 async sweep(): Promise<void> {
939 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
940 const stuck = await this.db
941 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
942 .bind(cutoff)
943 .all<{ id: string }>();
944 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
945
946 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
947 const workspaces = [...new Set(apps.map((app) => app.workspace))];
948
949 // Apps of workspaces whose plan has ended come down.
950 const billing = billingClient(this.env.BILLING);
951 for (const workspace of workspaces) {
952 const plan = await billing.hasFeature(workspace, "deployments");
953 if (!plan.ok && plan.error.code === "payment_required") {
954 for (const app of apps.filter((a) => a.workspace === workspace)) await this.removeApp(app.script);
955 }
956 }
957
958 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
959 await this.count(apps).catch((error) => console.error("could not count usage", error));
960 await this.takeDownIdle();
961 await this.chargeMonths();
962 }
963
964 /** Scripts in the namespace that no app holds, such as ones renamed. */
965 private async removeOrphans(apps: AppRow[]): Promise<void> {
966 const cloudflare = this.cloudflare;
967 if (!cloudflare) return;
968 const held = new Set(apps.map((app) => app.script));
969 const building = await this.db
970 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
971 .all<{ script: string }>();
972 for (const row of building.results) held.add(row.script);
973 const cutoff = Date.now() - ORPHAN_AFTER_MS;
974 for (const script of await cloudflare.listScripts()) {
975 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
976 }
977 }
978
979 /** Counts this month's requests and CPU time per workspace, from analytics. */
980 private async count(apps: AppRow[]): Promise<void> {
981 const cloudflare = this.cloudflare;
982 if (!cloudflare || apps.length === 0) return;
983 const start = `${month()}-01T00:00:00Z`;
984 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
985 // Analytics only counts apps that are up; the meter keeps what earlier
986 // apps used by never going down.
987 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
988 for (const app of apps) {
989 const used = totals.get(app.script);
990 if (!used) continue;
991 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
992 sum.requests += used.requests;
993 sum.cpuMs += used.cpuMs;
994 perWorkspace.set(app.workspace, sum);
995 }
996 const at = now();
997 for (const [workspace, used] of perWorkspace) {
998 await this.db
999 .prepare(
1000 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1001 ON CONFLICT (namespace, month) DO UPDATE SET
1002 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1003 )
1004 .bind(workspace, month(), used.requests, used.cpuMs, at)
1005 .run();
1006 }
1007 // When each preview last answered anyone, for the idle sweep.
1008 const recent = await cloudflare.usage(
1009 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1010 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1011 at,
1012 );
1013 for (const [script, used] of recent) {
1014 if (used.requests > 0) {
1015 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1016 }
1017 }
1018 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
1019 }
1020
1021 /** Previews no one has visited in their project's idle days. */
1022 private async takeDownIdle(): Promise<void> {
1023 const idle = await this.db
1024 .prepare(
1025 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
1026 WHERE apps.kind = 'preview'
1027 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
1028 )
1029 .all<{ script: string }>();
1030 for (const { script } of idle.results) await this.removeApp(script);
1031 }
1032
1033 /** Charges each month that is over for what it used past the allowance, once. */
1034 private async chargeMonths(): Promise<void> {
1035 const due = await this.db
1036 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
1037 .bind(month())
1038 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
1039 const a = DEPLOYMENTS_ALLOWANCE;
1040 for (const meter of due.results) {
1041 const extraRequests = Math.max(0, meter.requests - a.requests);
1042 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
1043 const extraApps = Math.max(0, meter.peak_apps - a.apps);
1044 const cost = Math.ceil(
1045 (extraRequests / 1_000_000) * a.microsPerMillionRequests +
1046 (extraCpu / 1_000_000) * a.microsPerMillionCpuMs +
1047 extraApps * a.microsPerAppMonth,
1048 );
1049 if (cost > 0) {
1050 const parts = [
1051 extraApps && `${extraApps} extra apps`,
1052 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
1053 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
1054 ].filter(Boolean);
1055 const charged = await billingClient(this.env.BILLING).chargeFeature({
1056 workspace: meter.namespace,
1057 feature: "deployments",
1058 costMicros: cost,
1059 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
1060 reference: `deployments/${meter.namespace}/${meter.month}`,
1061 });
1062 if (!charged.ok) continue;
1063 }
1064 await this.db
1065 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
1066 .bind(now(), meter.namespace, meter.month)
1067 .run();
1068 }
1069 }
1070}
1071
1072/** `POST /rpc/<method>`: the arguments are the body. */
1073async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
1074 switch (method) {
1075 case "settings":
1076 return service.settings(args);
1077 case "update_settings":
1078 return service.updateSettings(args);
1079 case "list":
1080 return service.list(args);
1081 case "get":
1082 return service.get(args);
1083 case "redeploy":
1084 return service.redeploy(args);
1085 case "take_down":
1086 return service.takeDown(args);
1087 case "stack":
1088 return service.stack(args, (work) => ctx.waitUntil(work));
1089 case "overview":
1090 return service.overview(args);
1091 case "usage":
1092 return service.usage(args);
1093 default:
1094 return undefined;
1095 }
1096}
1097
1098export default {
1099 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1100 const { pathname } = new URL(request.url);
1101 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1102 const service = new Deployments(env);
1103 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1104 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1105 if (rpcMatch) {
1106 const result = await rpc(service, rpcMatch[1], body, ctx);
1107 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1108 }
1109 // A build's reports, forwarded by the API.
1110 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1111 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1112 return new Response("Not found\n", { status: 404 });
1113 },
1114
1115 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1116 const service = new Deployments(env);
1117 for (const message of batch.messages) {
1118 try {
1119 await service.onEvent(message.body);
1120 message.ack();
1121 } catch (error) {
1122 console.error("deployments could not handle", message.body.type, error);
1123 message.retry();
1124 }
1125 }
1126 },
1127
1128 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1129 await new Deployments(env).sweep();
1130 },
1131} satisfies ExportedHandler<Env, G1tEvent>;