Skip to content

g1t/services/deployments/src/index.ts

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