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