Skip to content

g1t/services/deployments/src/index.ts

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