g1t/services/deployments/src/index.ts

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