g1t/services/deployments/src/index.ts

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