flagon-io/g1t

public

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

g1t/services/deployments/src/index.ts

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