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,567 lines67,665 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. The plan includes 200 build minutes a month, and
16 * builds past them are charged by the second; requests, CPU
17 * time and apps past the plan's allowance are charged once the month is
18 * over. A Worker runs only while it answers a request, so an app no one
19 * visits costs nothing, and a preview is taken down when its pull request
20 * closes or after its project's idle days.
21 *
22 * Reached through service bindings (`POST /rpc/<method>`) and, for a
23 * build's reports, through the API (`POST /jobs/<id>/<step>`).
24 */
25
26import {
27 CUSTOM_DOMAIN_TARGET,
28 DEPLOYMENTS_ALLOWANCE,
29 SLUG_HOLD_DAYS,
30 billingClient,
31 currentWorkspaceSlug,
32 fail,
33 identityClient,
34 newId,
35 ok,
36 projectsClient,
37 reposClient,
38 staleSlugs,
39 workClient,
40 type DeployKind,
41 type DeploySettings,
42 type DeployStatus,
43 type DeployUsage,
44 type Deployment,
45 type Domain,
46 type G1tEvent,
47 type LiveApp,
48 type Project,
49 type ProjectDeploys,
50 type ProjectDomains,
51 type ProjectRef,
52 type RepoPath,
53 type Result,
54 type ServiceBinding,
55 type User,
56 type Viewer,
57} from "@g1t/contracts";
58
59import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
60import { CustomHostnames } from "./custom-hostnames";
61import { Domains, NOT_ENABLED_NOTICE, extraDomains, toDomain } from "./domains";
62import { appHost, appUrl, label, uniqueLabel } from "./names";
63
64type Env = {
65 DB: D1Database;
66 REPOS: ServiceBinding;
67 WORK: ServiceBinding;
68 IDENTITY: ServiceBinding;
69 BILLING: ServiceBinding;
70 RUNNER: ServiceBinding;
71 PROJECTS: ServiceBinding;
72 /** Secrets and variables: the actions service holds the one store. */
73 ACTIONS: ServiceBinding;
74 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
75 CLOUDFLARE_API_TOKEN?: string;
76 CLOUDFLARE_ACCOUNT_ID: string;
77 DISPATCH_NAMESPACE: string;
78 SITE: string;
79 /** Custom domains: hostname to app, read by the dispatcher. */
80 DOMAINS?: KVNamespace;
81 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
82 CUSTOM_HOSTNAMES_ZONE_ID?: string;
83};
84
85/** A build that has not reported in this long has died. */
86const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
87/** A script in the namespace that no app holds, older than this, is removed. */
88const ORPHAN_AFTER_MS = 60 * 60 * 1000;
89const LIST_LIMIT = 50;
90const STATUS_CONTEXT = "g1t / deploy";
91/**
92 * Deliveries of `workspace.renamed` that wait for the projects service to
93 * have seen it too, before going ahead with the new slug regardless.
94 */
95const RENAME_WAITS = 3;
96
97const now = () => new Date().toISOString();
98const month = (at = new Date()) => at.toISOString().slice(0, 7);
99
100async function sha256(text: string): Promise<string> {
101 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
102 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
103}
104
105function randomToken(): string {
106 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
107}
108
109function isMember(viewer: Viewer, slug: string): boolean {
110 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
111}
112
113/** The repository a project builds from. */
114function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
115 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
116 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
117}
118
119type SettingsRow = {
120 project_id: string;
121 workspace: string;
122 slug: string;
123 repo_id: string;
124 enabled: number;
125 previews: number;
126 production: number;
127 build_command: string | null;
128 output_dir: string | null;
129 idle_days: number;
130};
131
132type DeploymentRow = {
133 id: string;
134 project_id: string;
135 workspace: string;
136 slug: string;
137 repo_id: string;
138 repo: string;
139 kind: DeployKind;
140 branch: string | null;
141 number: number | null;
142 commit_sha: string;
143 script: string;
144 status: DeployStatus;
145 error: string | null;
146 warnings: string;
147 log: string | null;
148 token_hash: string | null;
149 trusted: number;
150 build_seconds: number | null;
151 created_by: string;
152 created_at: string;
153 finished_at: string | null;
154};
155
156type AppRow = {
157 script: string;
158 project_id: string;
159 workspace: string;
160 slug: string;
161 kind: DeployKind;
162 branch: string | null;
163 number: number | null;
164 commit_sha: string;
165 deployed_at: string;
166 created_at: string;
167 last_request_at: string | null;
168 /** Set while its workspace is over its limit; see `holdToLimits`. */
169 paused_at: string | null;
170};
171
172function toDeployment(row: DeploymentRow): Deployment {
173 return {
174 id: row.id,
175 kind: row.kind,
176 branch: row.branch,
177 number: row.number,
178 commit: row.commit_sha,
179 status: row.status,
180 url: appUrl(row.script),
181 error: row.error,
182 warnings: JSON.parse(row.warnings || "[]") as string[],
183 buildSeconds: row.build_seconds,
184 createdBy: row.created_by,
185 createdAt: row.created_at,
186 finishedAt: row.finished_at,
187 };
188}
189
190function toLive(app: AppRow): LiveApp {
191 return {
192 kind: app.kind,
193 branch: app.branch,
194 number: app.number,
195 url: appUrl(app.script),
196 commit: app.commit_sha,
197 deployedAt: app.deployed_at,
198 };
199}
200
201class Deployments {
202 constructor(private readonly env: Env) {}
203
204 private get cloudflare(): Cloudflare | null {
205 const token = this.env.CLOUDFLARE_API_TOKEN;
206 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
207 }
208
209 private get db() {
210 return this.env.DB;
211 }
212
213 private get domains(): Domains {
214 const token = this.env.CLOUDFLARE_API_TOKEN;
215 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
216 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
217 }
218
219 private get projects() {
220 return projectsClient(this.env.PROJECTS);
221 }
222
223 /** The workspace itself, as the service acts for it. */
224 private async workspaceActor(slug: string): Promise<User | null> {
225 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
226 if (!workspace) return null;
227 return {
228 id: workspace.id,
229 username: workspace.slug,
230 kind: "workspace",
231 verified: true,
232 workspaces: [{ slug: workspace.slug, role: "member" }],
233 };
234 }
235
236 /**
237 * What the project's secrets and variables available to deployments give
238 * production or a preview: its build's environment, and the same again as
239 * the running app's bindings. Untrusted builds get no secrets.
240 */
241 private async resolve(
242 project: { id: string; slug: string; repoId: string; repo: RepoPath },
243 environment: DeployKind,
244 trusted: boolean,
245 branch: string | null,
246 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
247 const [rows, references] = await Promise.all([
248 this.rows(project, environment, trusted),
249 this.references(project.id, environment === "preview" ? branch : null),
250 ]);
251 // The project's own rows win over a dependency's address of the same name.
252 return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
253 }
254
255 /**
256 * Each dependency's address, under the name the dependency gives it:
257 * for a preview, the same branch's preview of it if one is up, else its
258 * production; for production, its production.
259 */
260 private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
261 const graph = await this.projects.graph(projectId).catch(() => null);
262 const out: Record<string, string> = {};
263 for (const dependency of graph?.dependsOn ?? []) {
264 if (!dependency.as) continue;
265 const app =
266 (branch
267 ? await this.db
268 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
269 .bind(dependency.id, branch)
270 .first<{ script: string }>()
271 : null) ??
272 (await this.db
273 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
274 .bind(dependency.id)
275 .first<{ script: string }>());
276 out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
277 }
278 return out;
279 }
280
281 private async rows(
282 project: { id: string; slug: string; repoId: string; repo: RepoPath },
283 environment: DeployKind,
284 trusted: boolean,
285 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
286 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
287 method: "POST",
288 headers: { "content-type": "application/json" },
289 body: JSON.stringify({
290 repoId: project.repoId,
291 repo: project.repo,
292 projectId: project.id,
293 projectSlug: project.slug,
294 consumer: "deployments",
295 environment,
296 trusted,
297 }),
298 });
299 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
300 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
301 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
302 }
303
304 /**
305 * Whether a pull request's author is trusted with the project's secrets:
306 * g1t's agent, or a member of the workspace. Someone from outside gets a
307 * preview built without them, as their workflows run.
308 */
309 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
310 if (author.kind === "agent" || author.username === "g1t-agent") return true;
311 // On a private repository only members can open one at all.
312 const found = await reposClient(this.env.REPOS).get(repo, actor);
313 if (found.ok && found.value.isPrivate) return true;
314 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
315 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
316 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
317 }
318
319 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
320 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
321 }
322
323 /** The name an app gets, unique among apps: production, or a branch's preview. */
324 private async scriptFor(project: Project, branch: string | null): Promise<string> {
325 const base = await label(project.workspace, project.slug, branch);
326 const holder = await this.db
327 .prepare(
328 `SELECT project_id, branch FROM apps WHERE script = ?1
329 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
330 )
331 .bind(base)
332 .first<{ project_id: string; branch: string | null }>();
333 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
334 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
335 }
336
337 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
338 return {
339 enabled: !!row?.enabled,
340 previews: row ? !!row.previews : true,
341 production: row ? !!row.production : true,
342 buildCommand: row?.build_command ?? null,
343 outputDir: row?.output_dir ?? null,
344 idleDays: row?.idle_days ?? 7,
345 productionUrl: appUrl(await this.scriptFor(project, null)),
346 primaryDomain: await this.domains.primary(project.id).catch(() => null),
347 };
348 }
349
350 /** The project, if `viewer` belongs to its workspace. */
351 private async memberProject(ref: ProjectRef, viewer: Viewer): Promise<Result<Project>> {
352 if (!isMember(viewer, ref.workspace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
353 return this.projects.get(ref.workspace, ref.slug, viewer);
354 }
355
356 // ---- Methods for the site and the API ------------------------------
357
358 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
359 const project = await this.memberProject(a.project, a.viewer);
360 if (!project.ok) return project;
361 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
362 }
363
364 async updateSettings(a: {
365 actor: User;
366 project: ProjectRef;
367 changes: Partial<DeploySettings>;
368 }): Promise<Result<DeploySettings>> {
369 const found = await this.memberProject(a.project, a.actor);
370 if (!found.ok) return found;
371 const project = found.value;
372 const before = await this.toSettings(project, await this.settingsRow(project.id));
373 const next = { ...before, ...a.changes };
374 if (next.enabled && !before.enabled) {
375 // Turning it on starts paid work: only with the workspace's plan.
376 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
377 if (!plan.ok) return plan;
378 }
379 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
380 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
381 await this.db
382 .prepare(
383 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
384 output_dir, idle_days, updated_by, updated_at)
385 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
386 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
387 production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12`,
388 )
389 .bind(
390 project.id,
391 project.workspace,
392 project.slug,
393 repoOf(project).id,
394 next.enabled ? 1 : 0,
395 next.previews ? 1 : 0,
396 next.production ? 1 : 0,
397 clip(next.buildCommand),
398 clip(next.outputDir),
399 idleDays,
400 a.actor.username,
401 now(),
402 )
403 .run();
404 // What was turned off comes down now; nothing keeps running unasked.
405 if (!next.enabled) await this.takeDownWhere(project.id, null);
406 else {
407 if (!next.previews) await this.takeDownWhere(project.id, "preview");
408 if (!next.production) await this.takeDownWhere(project.id, "production");
409 }
410 // Turned on: production goes up from the default branch at once.
411 if (next.enabled && next.production && (!before.enabled || !before.production)) {
412 await this.deployProduction(project, null, a.actor.username);
413 }
414 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
415 }
416
417 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
418 const project = await this.memberProject(a.project, a.viewer);
419 if (!project.ok) return project;
420 const [deployments, apps] = await Promise.all([
421 this.db
422 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
423 .bind(project.value.id, LIST_LIMIT)
424 .all<DeploymentRow>(),
425 this.db
426 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
427 .bind(project.value.id)
428 .all<AppRow>(),
429 ]);
430 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
431 }
432
433 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
434 const project = await this.memberProject(a.project, a.viewer);
435 if (!project.ok) return project;
436 const row = await this.db
437 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
438 .bind(a.id, project.value.id)
439 .first<DeploymentRow>();
440 if (!row) return fail("not_found", "No such deployment.");
441 return ok({ ...toDeployment(row), log: row.log });
442 }
443
444 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
445 const found = await this.memberProject(a.project, a.actor);
446 if (!found.ok) return found;
447 const project = found.value;
448 const settings = await this.settingsRow(project.id);
449 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
450 if (a.branch == null) {
451 return (await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy.");
452 }
453 // A branch's preview comes from its pull request.
454 const app = await this.db
455 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
456 .bind(project.id, a.branch)
457 .first<{ number: number }>();
458 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
459 return (await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open.");
460 }
461
462 /**
463 * A preview stack: the projects that use this one get previews of their
464 * own default branch, under the same branch name, so each reaches this
465 * branch's preview through its dependency's variable. A change to an API
466 * can then be clicked through in the apps that call it.
467 */
468 async stack(
469 a: { actor: User; project: ProjectRef; branch: string },
470 background: (work: Promise<unknown>) => void,
471 ): Promise<Result<string[]>> {
472 const found = await this.memberProject(a.project, a.actor);
473 if (!found.ok) return found;
474 const upstream = await this.db
475 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
476 .bind(found.value.id, a.branch)
477 .first();
478 if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
479 const graph = await this.projects.graph(found.value.id);
480 const ready: { project: Project; settings: SettingsRow }[] = [];
481 for (const dependent of graph.usedBy) {
482 const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
483 if (!project.ok) continue;
484 const settings = await this.settingsRow(project.value.id);
485 if (settings?.enabled && settings.previews) ready.push({ project: project.value, settings });
486 }
487 if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
488 // The builds start after the answer: a person moving on from the page
489 // does not stop them.
490 background(
491 (async () => {
492 for (const { project, settings } of ready) {
493 const actor = await this.workspaceActor(project.workspace);
494 if (!actor) continue;
495 const repo = repoOf(project);
496 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
497 const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
498 if (!head) continue;
499 await this.start({
500 project,
501 kind: "preview",
502 branch: a.branch,
503 number: null,
504 commit: head,
505 source: repo.path,
506 reader: actor,
507 createdBy: a.actor.username,
508 settings,
509 // Its own default branch, asked for by a member.
510 trusted: true,
511 });
512 }
513 })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
514 );
515 return ok(ready.map(({ project }) => project.name));
516 }
517
518 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
519 const project = await this.memberProject(a.project, a.actor);
520 if (!project.ok) return project;
521 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
522 return ok(true);
523 }
524
525 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
526 const workspace = a.workspace.toLowerCase();
527 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
528 const [settings, apps, latest] = await Promise.all([
529 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ?").bind(workspace).all<{ slug: string; enabled: number }>(),
530 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
531 this.db
532 .prepare(
533 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
534 )
535 .bind(workspace)
536 .all<DeploymentRow>(),
537 ]);
538 return ok(
539 settings.results.map((row) => {
540 const own = apps.results.filter((app) => app.slug === row.slug);
541 const production = own.find((app) => app.kind === "production");
542 const newest = latest.results.find((d) => d.slug === row.slug);
543 return {
544 slug: row.slug,
545 enabled: !!row.enabled,
546 production: production ? toLive(production) : null,
547 previews: own.filter((app) => app.kind === "preview").length,
548 latest: newest ? toDeployment(newest) : null,
549 };
550 }),
551 );
552 }
553
554 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
555 const slug = a.workspace.toLowerCase();
556 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
557 const [meter, apps] = await Promise.all([
558 this.db
559 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
560 .bind(slug, month())
561 .first<{
562 requests: number;
563 cpu_ms: number;
564 peak_apps: number;
565 build_seconds: number;
566 build_micros: number;
567 counted_at: string | null;
568 }>(),
569 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(),
570 ]);
571 return ok({
572 month: month(),
573 requests: meter?.requests ?? 0,
574 cpuMs: meter?.cpu_ms ?? 0,
575 apps: apps?.n ?? 0,
576 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
577 buildSeconds: meter?.build_seconds ?? 0,
578 buildMicros: meter?.build_micros ?? 0,
579 countedAt: meter?.counted_at ?? null,
580 });
581 }
582
583 // ---- Custom domains ------------------------------------------------
584
585 async listDomains(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<ProjectDomains>> {
586 const project = await this.memberProject(a.project, a.viewer);
587 if (!project.ok) return project;
588 const domains = this.domains;
589 await domains.catchUp(project.value.id).catch((error) => console.error("could not check domains", error));
590 const [rows, available, used] = await Promise.all([
591 domains.forProject(project.value.id),
592 domains.available(),
593 domains.countFor(project.value.workspace),
594 ]);
595 return ok({
596 domains: rows.map(toDomain),
597 target: CUSTOM_DOMAIN_TARGET,
598 available,
599 notice: available ? null : NOT_ENABLED_NOTICE,
600 included: DEPLOYMENTS_ALLOWANCE.customDomains,
601 used,
602 });
603 }
604
605 async addDomain(a: { actor: User; project: ProjectRef; hostname: string; twin?: boolean }): Promise<Result<Domain[]>> {
606 const found = await this.memberProject(a.project, a.actor);
607 if (!found.ok) return found;
608 const project = found.value;
609 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
610 if (!plan.ok) return plan;
611 const added = await this.domains.add({
612 project: { id: project.id, workspace: project.workspace, slug: project.slug },
613 script: await this.productionScript(project),
614 hostname: String(a.hostname ?? ""),
615 twin: !!a.twin,
616 by: a.actor.username,
617 });
618 if (!added.ok) return fail(added.code, added.message);
619 await this.notePeak(project.workspace);
620 return ok(added.rows.map(toDomain));
621 }
622
623 async removeDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<true>> {
624 const found = await this.memberProject(a.project, a.actor);
625 if (!found.ok) return found;
626 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
627 if (!row) return fail("not_found", "No such domain.");
628 await this.domains.remove(row);
629 return ok(true);
630 }
631
632 async refreshDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<Domain>> {
633 const found = await this.memberProject(a.project, a.actor);
634 if (!found.ok) return found;
635 const domains = this.domains;
636 const row = await domains.byId(found.value.id, String(a.id ?? ""));
637 if (!row) return fail("not_found", "No such domain.");
638 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
639 await this.notePeak(found.value.workspace);
640 return ok(toDomain(after));
641 }
642
643 /** The script production is up under, or the name it will have. */
644 private async productionScript(project: Project): Promise<string> {
645 const app = await this.db
646 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
647 .bind(project.id)
648 .first<{ script: string }>();
649 return app?.script ?? (await this.scriptFor(project, null));
650 }
651
652 // ---- Starting builds -----------------------------------------------
653
654 /**
655 * Opens a deployment and starts its build. Skipped, with the reason
656 * recorded, when the workspace's plan is off.
657 */
658 private async start(input: {
659 project: Project;
660 kind: DeployKind;
661 branch: string | null;
662 number: number | null;
663 commit: string;
664 source: RepoPath;
665 reader: User;
666 createdBy: string;
667 settings: SettingsRow;
668 /** A push, or work by a member or an agent; see `insider`. */
669 trusted: boolean;
670 }): Promise<Result<Deployment>> {
671 const { project } = input;
672 const repo = repoOf(project);
673 const script = await this.scriptFor(project, input.branch);
674 const id = newId("dpl");
675 const token = randomToken();
676 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
677 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
678 const cloudflare = this.cloudflare;
679 const refused = !plan.ok
680 ? plan.error.message
681 : limit.ok && limit.value.state === "stopped"
682 ? (limit.value.message ?? "The workspace reached its usage limit.")
683 : !cloudflare
684 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
685 : null;
686 await this.db
687 .prepare(
688 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
689 script, status, error, token_hash, trusted, created_by, created_at, finished_at)
690 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
691 )
692 .bind(
693 id,
694 project.id,
695 project.workspace,
696 project.slug,
697 repo.id,
698 `${repo.path.namespace}/${repo.path.name}`,
699 input.kind,
700 input.branch,
701 input.number,
702 input.commit,
703 script,
704 refused ? "skipped" : "queued",
705 refused,
706 refused ? null : await sha256(token),
707 input.trusted ? 1 : 0,
708 input.createdBy,
709 now(),
710 refused ? now() : null,
711 )
712 .run();
713 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
714 // Older builds of the same app are replaced by this one.
715 await this.db
716 .prepare(
717 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
718 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
719 )
720 .bind(now(), script, id)
721 .run();
722 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
723 // What the project's secrets and variables give builds of this kind.
724 const build = await this.resolve(
725 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
726 input.kind,
727 input.trusted,
728 input.branch,
729 );
730 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
731 method: "POST",
732 headers: { "content-type": "application/json" },
733 body: JSON.stringify({
734 deployId: id,
735 token,
736 actor: input.reader,
737 source: input.source,
738 commit: input.commit,
739 rootDir: project.source.rootDir,
740 buildCommand: input.settings.build_command,
741 outputDir: input.settings.output_dir,
742 buildEnv: build.variables,
743 buildSecrets: build.secrets,
744 }),
745 });
746 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
747 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
748 return ok(toDeployment((await this.deploymentRow(id))!));
749 }
750
751 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
752 const settings = await this.settingsRow(project.id);
753 if (!settings?.enabled || !settings.production) return null;
754 const actor = await this.workspaceActor(project.workspace);
755 if (!actor) return null;
756 const repo = repoOf(project);
757 let head = commit;
758 if (!head) {
759 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
760 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
761 }
762 if (!head) return null;
763 return this.start({
764 project,
765 kind: "production",
766 branch: null,
767 number: null,
768 commit: head,
769 source: repo.path,
770 reader: actor,
771 createdBy,
772 settings,
773 // The default branch only moves by people and agents with access.
774 trusted: true,
775 });
776 }
777
778 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
779 const settings = await this.settingsRow(project.id);
780 if (!settings?.enabled || !settings.previews) return null;
781 const actor = await this.workspaceActor(project.workspace);
782 if (!actor) return null;
783 const repo = repoOf(project);
784 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
785 if (!detail.ok) return null;
786 const { pull } = detail.value;
787 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
788 // A pull request from a fork (as g1t's agents work) has no branch here.
789 const branch = pull.branch ?? `pr-${number}`;
790 if (!force) {
791 // Already built, or being built, at this commit.
792 const same = await this.db
793 .prepare(
794 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
795 AND status IN ('queued', 'building', 'ready')`,
796 )
797 .bind(project.id, branch, pull.headCommit)
798 .first();
799 if (same) return null;
800 }
801 return this.start({
802 project,
803 kind: "preview",
804 branch,
805 number,
806 commit: pull.headCommit,
807 source: pull.fork ?? repo.path,
808 // The pull request's fork may be private: read it as its author.
809 reader: pull.author,
810 createdBy,
811 settings,
812 trusted: await this.insider(repo.path, pull.author, actor),
813 });
814 }
815
816 // ---- A build's reports ---------------------------------------------
817
818 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
819 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
820 }
821
822 /** The build, if `token` is its own and it is still under way. */
823 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
824 const row = await this.deploymentRow(id);
825 if (!row?.token_hash || typeof token !== "string") return null;
826 if (row.token_hash !== (await sha256(token))) return null;
827 return row.status === "queued" || row.status === "building" ? row : null;
828 }
829
830 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
831 // Each report, for the logs: a build's own failure says why.
832 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
833 const row = await this.building(id, body.token);
834 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
835 const cloudflare = this.cloudflare;
836 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
837 switch (step) {
838 case "started":
839 await this.db
840 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
841 .bind(now(), id)
842 .run();
843 return Response.json(ok(true));
844 case "session": {
845 const manifest = body.manifest as Manifest | undefined;
846 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
847 const session = await cloudflare.openUpload(row.script, manifest);
848 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
849 }
850 case "finish": {
851 const worker = (body.worker ?? {}) as BuiltWorker;
852 const seconds = Number(body.buildSeconds) || 0;
853 const [namespace, name] = row.repo.split("/") as [string, string];
854 try {
855 // Running apps' secrets and variables are bound here, by g1t:
856 // they never pass through the build's sandbox.
857 const runtime = await this.resolve(
858 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
859 row.kind,
860 !!row.trusted,
861 row.branch,
862 );
863 await cloudflare.putScript(
864 row.script,
865 worker,
866 typeof body.completionJwt === "string" ? body.completionJwt : null,
867 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
868 runtime,
869 );
870 } catch (error) {
871 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
872 return Response.json(ok(false));
873 }
874 // The same app at an older name (its workspace was renamed) now
875 // redirects here; it stays up as it was if the redirect cannot be put.
876 const redirected = await this.supersede(cloudflare, row);
877 const at = now();
878 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
879 await this.db.batch([
880 this.db
881 .prepare(
882 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
883 WHERE id = ?`,
884 )
885 .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
886 // The build it replaces is no longer what the app serves.
887 this.db
888 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
889 .bind(row.script, id),
890 this.db
891 .prepare(
892 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
893 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
894 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9, paused_at = NULL`,
895 )
896 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
897 // A name that redirected elsewhere (a rename undone) is an app again.
898 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
899 ...redirected.flatMap((old) => [
900 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
901 this.db
902 .prepare(
903 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
904 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
905 )
906 .bind(old, appHost(row.script), row.workspace, at, expires),
907 ]),
908 ]);
909 // The project's own domains serve production wherever it is up.
910 if (row.kind === "production") {
911 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
912 }
913 await this.chargeBuild(row, seconds);
914 await this.notePeak(row.workspace);
915 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
916 return Response.json(ok(true));
917 }
918 case "fail":
919 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
920 return Response.json(ok(true));
921 default:
922 return Response.json(fail("not_found", "No such step."), { status: 404 });
923 }
924 }
925
926 /**
927 * Older names of the app `row` just put up: one project's production, or
928 * its preview of one branch, has one name, so any other app row for the
929 * same is the app under the name it had before its workspace was renamed.
930 * Each is replaced in the namespace by a redirect to the new name; the
931 * names that were are returned, for their app rows to go.
932 */
933 private async supersede(cloudflare: Cloudflare, row: DeploymentRow): Promise<string[]> {
934 const older = await this.db
935 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?")
936 .bind(row.project_id, row.kind, row.branch, row.script)
937 .all<{ script: string }>();
938 const done: string[] = [];
939 for (const { script } of older.results) {
940 try {
941 await cloudflare.redirectScript(script, appHost(row.script));
942 done.push(script);
943 } catch (error) {
944 // Left as it is, the next deploy of this app tries again.
945 console.error("could not redirect", script, "to", row.script, error);
946 }
947 }
948 return done;
949 }
950
951 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
952 const row = await this.deploymentRow(id);
953 if (!row || (row.status !== "queued" && row.status !== "building")) return;
954 await this.db
955 .prepare(
956 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
957 WHERE id = ?`,
958 )
959 .bind(message.slice(0, 2000), log, seconds, now(), id)
960 .run();
961 // A failed build still used its sandbox.
962 if (seconds) await this.chargeBuild(row, seconds);
963 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
964 }
965
966 /** Each build is charged by the second at the container price plus the margin, past the plan's included build minutes (billing applies those). */
967 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
968 const costs = await this.costs();
969 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
970 if (cost <= 0) return;
971 const what =
972 row.kind === "preview"
973 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
974 : `${row.workspace}/${row.slug} to production`;
975 await billingClient(this.env.BILLING).chargeFeature({
976 workspace: row.workspace,
977 feature: "deployments",
978 costMicros: cost,
979 description: `Building ${what} (${Math.ceil(seconds)} s)`,
980 repo: row.repo,
981 reference: `deploy/${row.id}`,
982 // The plan's included build minutes pay for what they can; billing
983 // charges the rest.
984 buildSeconds: Math.ceil(seconds),
985 });
986 await this.db
987 .prepare(
988 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
989 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
990 )
991 .bind(row.workspace, month(), Math.ceil(seconds), cost)
992 .run();
993 }
994
995 /**
996 * What each unit costs g1t now, from billing's price book, which follows
997 * what Cloudflare bills. The plan's figures if billing cannot say.
998 */
999 private async costs(): Promise<{
1000 buildSecond: number;
1001 millionRequests: number;
1002 millionCpuMs: number;
1003 appMonth: number;
1004 domainMonth: number;
1005 }> {
1006 const a = DEPLOYMENTS_ALLOWANCE;
1007 const book = await billingClient(this.env.BILLING)
1008 .prices()
1009 .catch(() => null);
1010 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1011 return {
1012 buildSecond: cost("build_second", a.microsPerBuildSecond),
1013 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1014 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
1015 appMonth: cost("app_month", a.microsPerAppMonth),
1016 domainMonth: cost("custom_domain_month", a.microsPerDomainMonth),
1017 };
1018 }
1019
1020 /** Remembers the most apps, and custom domains, the workspace had at once this month. */
1021 private async notePeak(workspace: string): Promise<void> {
1022 await this.db
1023 .prepare(
1024 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1025 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1026 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
1027 ON CONFLICT (namespace, month) DO UPDATE SET
1028 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1029 peak_domains = MAX(peak_domains, (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))`,
1030 )
1031 .bind(workspace, month())
1032 .run();
1033 }
1034
1035 /**
1036 * The check on the commit: `g1t / deploy`, or, for one of several
1037 * projects on a repository, `g1t / deploy (<project>)`.
1038 */
1039 private async status(
1040 repoId: string,
1041 sha: string,
1042 project: { slug: string; primary: boolean },
1043 state: string,
1044 description: string,
1045 targetUrl: string,
1046 ): Promise<void> {
1047 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
1048 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1049 method: "POST",
1050 headers: { "content-type": "application/json" },
1051 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl }),
1052 }).catch(() => undefined);
1053 }
1054
1055 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1056 const projects = await this.projects.byRepo(row.repo_id);
1057 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1058 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1059 }
1060
1061 // ---- Taking apps down ----------------------------------------------
1062
1063 private async removeApp(script: string): Promise<void> {
1064 await this.cloudflare?.deleteScript(script);
1065 await this.db.batch([
1066 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1067 // Its build is no longer live anywhere.
1068 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1069 ]);
1070 }
1071
1072 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
1073 const apps = await this.db
1074 .prepare(
1075 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
1076 )
1077 .bind(projectId, kind, branch ?? null)
1078 .all<{ script: string }>();
1079 for (const app of apps.results) await this.removeApp(app.script);
1080 }
1081
1082 // ---- Events --------------------------------------------------------
1083
1084 /** `attempts`: which delivery of the event this is, from 1. */
1085 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
1086 switch (event.type) {
1087 case "workspace.renamed":
1088 await this.renamed(event.data, attempts);
1089 break;
1090 case "pull.opened":
1091 case "pull.ready":
1092 case "pull.updated":
1093 for (const project of await this.projects.byRepo(event.data.repoId)) {
1094 await this.deployPreview(project, event.data.number, "g1t");
1095 }
1096 break;
1097 case "pull.closed":
1098 case "pull.merged":
1099 for (const project of await this.projects.byRepo(event.data.repoId)) {
1100 const apps = await this.db
1101 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1102 .bind(project.id, event.data.number)
1103 .all<{ script: string }>();
1104 for (const app of apps.results) await this.removeApp(app.script);
1105 }
1106 break;
1107 case "git.push":
1108 if (!event.data.defaultBranch) break;
1109 for (const project of await this.projects.byRepo(event.data.repoId)) {
1110 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1111 }
1112 break;
1113 }
1114 }
1115
1116 /**
1117 * A workspace's slug changed, and with it every app's name: production
1118 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1119 *
1120 * Its rows move to the slug it has now (asked of identity, so a delivery
1121 * twice over, or an older rename after a newer one, ends the same), and
1122 * each app that is up is built again from the same commit under its new
1123 * name. The old name keeps serving the app until the new one is live;
1124 * then the build's finish puts a redirect to the new name in its place
1125 * (see `supersede`), held for as long as the workspace holds its old slug.
1126 *
1127 * Paused apps are left: they are rebuilt, under the new name, when the
1128 * workspace is under its limit again, and the old name redirects then.
1129 * Builds under way for the old name are dropped and started again under
1130 * the new one. Only rows still under an old slug are acted on, so a
1131 * second delivery builds nothing.
1132 */
1133 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1134 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1135 const stale = staleSlugs(renamed, current);
1136 if (stale.length === 0) return;
1137 const marks = stale.map(() => "?").join(", ");
1138
1139 // What to build again, read before the rows move.
1140 const [apps, building] = await Promise.all([
1141 this.db
1142 .prepare(`SELECT * FROM apps WHERE workspace IN (${marks}) AND paused_at IS NULL`)
1143 .bind(...stale)
1144 .all<AppRow>(),
1145 this.db
1146 .prepare(`SELECT * FROM deployments WHERE workspace IN (${marks}) AND status IN ('queued', 'building') ORDER BY id`)
1147 .bind(...stale)
1148 .all<DeploymentRow>(),
1149 ]);
1150 type Target = { projectId: string; kind: DeployKind; branch: string | null; number: number | null; commit: string };
1151 const targets = new Map<string, Target>();
1152 const key = (t: { project_id: string; kind: DeployKind; branch: string | null }) => `${t.project_id}/${t.kind}/${t.branch ?? ""}`;
1153 for (const app of apps.results) {
1154 targets.set(key(app), { projectId: app.project_id, kind: app.kind, branch: app.branch, number: app.number, commit: app.commit_sha });
1155 }
1156 // A build under way is newer than what is up.
1157 for (const row of building.results) {
1158 targets.set(key(row), { projectId: row.project_id, kind: row.kind, branch: row.branch, number: row.number, commit: row.commit_sha });
1159 }
1160
1161 // The projects, as the projects service has them now.
1162 const projectIds = [...new Set([...targets.values()].map((t) => t.projectId))];
1163 const projects = new Map<string, Project>();
1164 if (projectIds.length > 0) {
1165 const repoIds = await this.db
1166 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${projectIds.map(() => "?").join(", ")})`)
1167 .bind(...projectIds)
1168 .all<{ repo_id: string }>();
1169 for (const { repo_id } of repoIds.results) {
1170 for (const project of await this.projects.byRepo(repo_id)) {
1171 if (projectIds.includes(project.id)) projects.set(project.id, project);
1172 }
1173 }
1174 // The projects service hears of the rename on its own queue: wait for
1175 // it a few deliveries, so the builds read the repository by its new name.
1176 const behind = [...projects.values()].some((p) => p.workspace !== current);
1177 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`);
1178 }
1179
1180 // Every row moves at once.
1181 const at = now();
1182 const statements: D1PreparedStatement[] = [];
1183 for (const slug of stale) {
1184 statements.push(
1185 this.db
1186 .prepare(
1187 `UPDATE deployments SET status = 'skipped', error = 'The workspace was renamed: built again under its new name.',
1188 finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`,
1189 )
1190 .bind(at, slug),
1191 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1192 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1193 this.db
1194 .prepare(
1195 `UPDATE deployments SET workspace = ?1,
1196 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1197 WHERE workspace = ?2`,
1198 )
1199 .bind(current, slug),
1200 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1201 this.domains.rename(slug, current),
1202 // Counters add up; the peak is the higher; a month charged stays charged.
1203 this.db
1204 .prepare(
1205 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1206 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1207 FROM meters WHERE namespace = ?2
1208 ON CONFLICT (namespace, month) DO UPDATE SET
1209 requests = requests + excluded.requests,
1210 cpu_ms = cpu_ms + excluded.cpu_ms,
1211 peak_apps = MAX(peak_apps, excluded.peak_apps),
1212 peak_domains = MAX(peak_domains, excluded.peak_domains),
1213 build_seconds = build_seconds + excluded.build_seconds,
1214 build_micros = build_micros + excluded.build_micros,
1215 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1216 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1217 )
1218 .bind(current, slug),
1219 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1220 );
1221 }
1222 await this.db.batch(statements);
1223
1224 // Each app again, under its new name. Its dependencies' addresses are
1225 // read again too, so apps that call one another follow the rename.
1226 // Failures are logged, not retried: the rows have moved, and the next
1227 // deploy of the app puts it under its new name all the same.
1228 const actor = await this.workspaceActor(current);
1229 for (const target of targets.values()) {
1230 const found = projects.get(target.projectId);
1231 if (!found) continue;
1232 const project = this.underSlug(found, current, stale);
1233 try {
1234 const started =
1235 target.kind === "production"
1236 ? await this.deployProduction(project, target.commit, "g1t")
1237 : target.number != null
1238 ? await this.deployPreview(project, target.number, "g1t", true)
1239 : await this.rebuildStack(project, target.branch, target.commit, actor);
1240 if (started && !started.ok) console.log("could not rebuild", project.slug, target.branch, started.error.message);
1241 } catch (error) {
1242 console.error("could not rebuild after rename", project.slug, target.branch, error);
1243 }
1244 }
1245 }
1246
1247 /** `project` under the workspace's slug now, whether or not projects has caught up. */
1248 private underSlug(project: Project, current: string, stale: string[]): Project {
1249 const source =
1250 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
1251 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
1252 : project.source;
1253 return { ...project, workspace: current, source };
1254 }
1255
1256 /** A stack's preview (no pull request of its own) built again at `commit`. */
1257 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
1258 if (!actor || branch == null) return null;
1259 const settings = await this.settingsRow(project.id);
1260 if (!settings?.enabled || !settings.previews) return null;
1261 return this.start({
1262 project,
1263 kind: "preview",
1264 branch,
1265 number: null,
1266 commit,
1267 source: repoOf(project).path,
1268 reader: actor,
1269 createdBy: "g1t",
1270 settings,
1271 // As `stack` built it: the project's own default branch.
1272 trusted: true,
1273 });
1274 }
1275
1276 // ---- The sweep -----------------------------------------------------
1277
1278 /**
1279 * Every few minutes: builds that died are failed; usage is counted; idle
1280 * previews, the apps of workspaces whose plan ended, and scripts no app
1281 * holds come down; and a month that is over is charged past its
1282 * allowance.
1283 */
1284 async sweep(): Promise<void> {
1285 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
1286 const stuck = await this.db
1287 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
1288 .bind(cutoff)
1289 .all<{ id: string }>();
1290 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
1291
1292 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
1293 const workspaces = [...new Set(apps.map((app) => app.workspace))];
1294
1295 // Apps of workspaces whose plan has ended come down.
1296 const billing = billingClient(this.env.BILLING);
1297 await this.holdToLimits(apps).catch((error) => console.error("could not apply limits", error));
1298 for (const workspace of workspaces) {
1299 const plan = await billing.hasFeature(workspace, "deployments");
1300 if (!plan.ok && plan.error.code === "payment_required") {
1301 for (const app of apps.filter((a) => a.workspace === workspace)) await this.removeApp(app.script);
1302 // Custom domains cost g1t by the month: they go with the plan.
1303 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
1304 }
1305 }
1306
1307 await this.domains
1308 .sweep(async (projectId) => {
1309 const app = await this.db
1310 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
1311 .bind(projectId)
1312 .first<{ script: string }>();
1313 return app?.script ?? null;
1314 })
1315 .catch((error) => console.error("could not check domains", error));
1316
1317 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
1318 await this.count(apps).catch((error) => console.error("could not count usage", error));
1319 await this.takeDownIdle();
1320 await this.chargeMonths();
1321 }
1322
1323 /**
1324 * Pauses the apps of workspaces that reached their limit for usage not
1325 * yet paid for, and rebuilds them from the same commit once they are
1326 * under it again. Paused apps answer with a notice and run nothing.
1327 */
1328 private async holdToLimits(apps: AppRow[]): Promise<void> {
1329 const cloudflare = this.cloudflare;
1330 if (!cloudflare) return;
1331 const billing = billingClient(this.env.BILLING);
1332 for (const workspace of [...new Set(apps.map((app) => app.workspace))]) {
1333 const limit = await billing.checkLimit(workspace);
1334 if (!limit.ok) continue;
1335 const theirs = apps.filter((app) => app.workspace === workspace);
1336 if (limit.value.state === "stopped") {
1337 for (const app of theirs.filter((a) => !a.paused_at)) {
1338 await cloudflare.pauseScript(app.script);
1339 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
1340 }
1341 continue;
1342 }
1343 const paused = theirs.filter((a) => a.paused_at);
1344 if (paused.length === 0) continue;
1345 const actor = await this.workspaceActor(workspace);
1346 if (!actor) continue;
1347 for (const app of paused) {
1348 const project = await this.projects.get(workspace, app.slug, actor);
1349 if (!project.ok) continue;
1350 // A failed or refused rebuild leaves it paused, to try again next time.
1351 const rebuilt =
1352 app.kind === "production"
1353 ? await this.deployProduction(project.value, app.commit_sha, "g1t")
1354 : app.number != null
1355 ? await this.deployPreview(project.value, app.number, "g1t", true)
1356 : null;
1357 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1358 }
1359 }
1360 }
1361
1362 /**
1363 * Scripts in the namespace that no app holds, such as ones renamed. An
1364 * old address that redirects to its app's new one is held until its
1365 * redirect expires, then removed with the rest.
1366 */
1367 private async removeOrphans(apps: AppRow[]): Promise<void> {
1368 const cloudflare = this.cloudflare;
1369 if (!cloudflare) return;
1370 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
1371 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
1372 const held = new Set([...apps.map((app) => app.script), ...redirects.results.map((r) => r.script)]);
1373 const building = await this.db
1374 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
1375 .all<{ script: string }>();
1376 for (const row of building.results) held.add(row.script);
1377 const cutoff = Date.now() - ORPHAN_AFTER_MS;
1378 for (const script of await cloudflare.listScripts()) {
1379 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
1380 }
1381 }
1382
1383 /** Counts this month's requests and CPU time per workspace, from analytics. */
1384 private async count(apps: AppRow[]): Promise<void> {
1385 const cloudflare = this.cloudflare;
1386 if (!cloudflare || apps.length === 0) return;
1387 const start = `${month()}-01T00:00:00Z`;
1388 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
1389 // Analytics only counts apps that are up; the meter keeps what earlier
1390 // apps used by never going down.
1391 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
1392 for (const app of apps) {
1393 const used = totals.get(app.script);
1394 if (!used) continue;
1395 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
1396 sum.requests += used.requests;
1397 sum.cpuMs += used.cpuMs;
1398 perWorkspace.set(app.workspace, sum);
1399 }
1400 const at = now();
1401 for (const [workspace, used] of perWorkspace) {
1402 await this.db
1403 .prepare(
1404 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
1405 ON CONFLICT (namespace, month) DO UPDATE SET
1406 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
1407 )
1408 .bind(workspace, month(), used.requests, used.cpuMs, at)
1409 .run();
1410 }
1411 // When each preview last answered anyone, for the idle sweep.
1412 const recent = await cloudflare.usage(
1413 apps.filter((app) => app.kind === "preview").map((app) => app.script),
1414 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
1415 at,
1416 );
1417 for (const [script, used] of recent) {
1418 if (used.requests > 0) {
1419 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
1420 }
1421 }
1422 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
1423 // What this month's traffic past the plan will cost, so the workspace's
1424 // limit counts it now rather than when the month closes.
1425 const costs = await this.costs();
1426 const a = DEPLOYMENTS_ALLOWANCE;
1427 for (const workspace of perWorkspace.keys()) {
1428 const meter = await this.db
1429 .prepare("SELECT requests, cpu_ms, peak_apps, peak_domains FROM meters WHERE namespace = ? AND month = ?")
1430 .bind(workspace, month())
1431 .first<{ requests: number; cpu_ms: number; peak_apps: number; peak_domains: number }>();
1432 if (!meter) continue;
1433 const cost = Math.ceil(
1434 (Math.max(0, meter.requests - a.requests) / 1_000_000) * costs.millionRequests +
1435 (Math.max(0, meter.cpu_ms - a.cpuMs) / 1_000_000) * costs.millionCpuMs +
1436 Math.max(0, meter.peak_apps - a.apps) * costs.appMonth +
1437 extraDomains(meter.peak_domains ?? 0) * costs.domainMonth,
1438 );
1439 await billingClient(this.env.BILLING)
1440 .notePending(workspace, "deployments", cost)
1441 .catch((error) => console.error("could not note pending usage", error));
1442 }
1443 }
1444
1445 /** Previews no one has visited in their project's idle days. */
1446 private async takeDownIdle(): Promise<void> {
1447 const idle = await this.db
1448 .prepare(
1449 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
1450 WHERE apps.kind = 'preview'
1451 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
1452 )
1453 .all<{ script: string }>();
1454 for (const { script } of idle.results) await this.removeApp(script);
1455 }
1456
1457 /** Charges each month that is over for what it used past the allowance, once. */
1458 private async chargeMonths(): Promise<void> {
1459 const due = await this.db
1460 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
1461 .bind(month())
1462 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number; peak_domains: number }>();
1463 const a = DEPLOYMENTS_ALLOWANCE;
1464 const costs = await this.costs();
1465 for (const meter of due.results) {
1466 const extraRequests = Math.max(0, meter.requests - a.requests);
1467 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
1468 const extraApps = Math.max(0, meter.peak_apps - a.apps);
1469 const moreDomains = extraDomains(meter.peak_domains ?? 0);
1470 const cost = Math.ceil(
1471 (extraRequests / 1_000_000) * costs.millionRequests +
1472 (extraCpu / 1_000_000) * costs.millionCpuMs +
1473 extraApps * costs.appMonth +
1474 moreDomains * costs.domainMonth,
1475 );
1476 if (cost > 0) {
1477 const parts = [
1478 extraApps && `${extraApps} extra apps`,
1479 moreDomains && `${moreDomains} extra custom domains`,
1480 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
1481 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
1482 ].filter(Boolean);
1483 const charged = await billingClient(this.env.BILLING).chargeFeature({
1484 workspace: meter.namespace,
1485 feature: "deployments",
1486 costMicros: cost,
1487 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
1488 reference: `deployments/${meter.namespace}/${meter.month}`,
1489 });
1490 if (!charged.ok) continue;
1491 }
1492 await this.db
1493 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
1494 .bind(now(), meter.namespace, meter.month)
1495 .run();
1496 }
1497 }
1498}
1499
1500/** `POST /rpc/<method>`: the arguments are the body. */
1501async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
1502 switch (method) {
1503 case "settings":
1504 return service.settings(args);
1505 case "update_settings":
1506 return service.updateSettings(args);
1507 case "list":
1508 return service.list(args);
1509 case "get":
1510 return service.get(args);
1511 case "redeploy":
1512 return service.redeploy(args);
1513 case "take_down":
1514 return service.takeDown(args);
1515 case "stack":
1516 return service.stack(args, (work) => ctx.waitUntil(work));
1517 case "overview":
1518 return service.overview(args);
1519 case "usage":
1520 return service.usage(args);
1521 case "domains":
1522 return service.listDomains(args);
1523 case "add_domain":
1524 return service.addDomain(args);
1525 case "remove_domain":
1526 return service.removeDomain(args);
1527 case "refresh_domain":
1528 return service.refreshDomain(args);
1529 default:
1530 return undefined;
1531 }
1532}
1533
1534export default {
1535 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1536 const { pathname } = new URL(request.url);
1537 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
1538 const service = new Deployments(env);
1539 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1540 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
1541 if (rpcMatch) {
1542 const result = await rpc(service, rpcMatch[1], body, ctx);
1543 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
1544 }
1545 // A build's reports, forwarded by the API.
1546 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
1547 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
1548 return new Response("Not found\n", { status: 404 });
1549 },
1550
1551 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1552 const service = new Deployments(env);
1553 for (const message of batch.messages) {
1554 try {
1555 await service.onEvent(message.body, message.attempts);
1556 message.ack();
1557 } catch (error) {
1558 console.error("deployments could not handle", message.body.type, error);
1559 message.retry();
1560 }
1561 }
1562 },
1563
1564 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
1565 await new Deployments(env).sweep();
1566 },
1567} satisfies ExportedHandler<Env, G1tEvent>;