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