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