Skip to content

g1t/services/deployments/src/index.ts

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