flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/deployments/src/index.ts

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