Skip to content

g1t/services/deployments/src/index.ts

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