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