g1t/services/deployments/src/index.ts

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