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