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