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