Skip to content

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

g1t.sh
2,525 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, jobEndedMessage, 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 /**
1034 * For the actions service, when a workflow job ends (`finish_job`), with
1035 * any conclusion: a build the job opened and did not finish or fail is
1036 * failed now, saying how the job ended, rather than left building until
1037 * the sweep's 45 minutes. A build the job finished is left as it is.
1038 */
1039 async workflowJobEnded(a: { jobId?: unknown; conclusion?: unknown }): Promise<Result<number>> {
1040 if (typeof a.jobId !== "string" || !a.jobId) return fail("invalid", "No job.");
1041 const open = await this.db
1042 .prepare("SELECT id FROM deployments WHERE job_id = ? AND status IN ('queued', 'building')")
1043 .bind(a.jobId)
1044 .all<{ id: string }>();
1045 const message = jobEndedMessage(typeof a.conclusion === "string" ? a.conclusion : "");
1046 for (const { id } of open.results) await this.finishFailed(id, message, null, null);
1047 return ok(open.results.length);
1048 }
1049
1050 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
1051 const settings = await this.settingsRow(project.id);
1052 if (!settings?.enabled || !settings.production || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
1053 const actor = await this.workspaceActor(project.workspace);
1054 if (!actor) return null;
1055 const repo = repoOf(project);
1056 let head = commit;
1057 if (!head) {
1058 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
1059 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
1060 }
1061 if (!head) return null;
1062 return this.start({
1063 project,
1064 kind: "production",
1065 branch: null,
1066 number: null,
1067 commit: head,
1068 source: repo.path,
1069 reader: actor,
1070 createdBy,
1071 settings,
1072 // The default branch only moves by people and agents with access.
1073 trusted: true,
1074 });
1075 }
1076
1077 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
1078 const settings = await this.settingsRow(project.id);
1079 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
1080 const actor = await this.workspaceActor(project.workspace);
1081 if (!actor) return null;
1082 const repo = repoOf(project);
1083 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
1084 if (!detail.ok) return null;
1085 const { pull } = detail.value;
1086 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
1087 // A pull request from a fork (as g1t's agents work) has no branch here.
1088 const branch = pull.branch ?? `pr-${number}`;
1089 if (!force) {
1090 // Already built, or being built, at this commit.
1091 const same = await this.db
1092 .prepare(
1093 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
1094 AND status IN ('queued', 'building', 'ready')`,
1095 )
1096 .bind(project.id, branch, pull.headCommit)
1097 .first();
1098 if (same) return null;
1099 }
1100 return this.start({
1101 project,
1102 kind: "preview",
1103 branch,
1104 number,
1105 commit: pull.headCommit,
1106 source: pull.fork ?? repo.path,
1107 // The pull request's fork may be private: read it as whoever it is
1108 // for (whoever asked g1t for it, or its author), who is also who is
1109 // trusted or not with the project's secrets.
1110 reader: workOwner(pull),
1111 createdBy,
1112 settings,
1113 trusted: await this.insider(repo.path, workOwner(pull), actor),
1114 });
1115 }
1116
1117 // ---- A build's reports ---------------------------------------------
1118
1119 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
1120 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
1121 }
1122
1123 /** The build, if `token` is its own and it is still under way. */
1124 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
1125 const row = await this.deploymentRow(id);
1126 if (!row?.token_hash || typeof token !== "string") return null;
1127 if (row.token_hash !== (await sha256(token))) return null;
1128 return row.status === "queued" || row.status === "building" ? row : null;
1129 }
1130
1131 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
1132 // Each report, for the logs: a build's own failure says why.
1133 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
1134 const row = await this.building(id, body.token);
1135 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
1136 const cloudflare = this.cloudflare;
1137 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
1138 switch (step) {
1139 case "started":
1140 await this.db
1141 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
1142 .bind(now(), id)
1143 .run();
1144 await this.buildChanged(id);
1145 return Response.json(ok(true));
1146 case "session": {
1147 const manifest = body.manifest as Manifest | undefined;
1148 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
1149 const session = await cloudflare.openUpload(row.script, manifest);
1150 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
1151 }
1152 case "finish": {
1153 const worker = (body.worker ?? {}) as BuiltWorker;
1154 const seconds = Number(body.buildSeconds) || 0;
1155 const [namespace, name] = row.repo.split("/") as [string, string];
1156 try {
1157 // Running apps' secrets and variables are bound here, by g1t:
1158 // they never pass through the build's sandbox.
1159 const runtime = await this.resolve(
1160 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
1161 row.kind,
1162 !!row.trusted,
1163 );
1164 await cloudflare.putScript(
1165 row.script,
1166 worker,
1167 typeof body.completionJwt === "string" ? body.completionJwt : null,
1168 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
1169 runtime,
1170 );
1171 } catch (error) {
1172 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1173 return Response.json(ok(false));
1174 }
1175 const at = now();
1176 const wasRedirect = await this.db.prepare("SELECT 1 FROM redirects WHERE script = ?").bind(row.script).first();
1177 await this.db.batch([
1178 this.db
1179 .prepare(
1180 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
1181 WHERE id = ?`,
1182 )
1183 .bind(
1184 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1185 String(body.log ?? ""),
1186 seconds,
1187 at,
1188 detectedKind(body.detected),
1189 id,
1190 ),
1191 // The build it replaces is no longer what the app serves.
1192 this.db
1193 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1194 .bind(row.script, id),
1195 this.db
1196 .prepare(
1197 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1198 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
1199 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9, paused_at = NULL`,
1200 )
1201 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
1202 // A name that redirected elsewhere (a move undone) is an app again.
1203 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
1204 ]);
1205 if (wasRedirect) {
1206 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1207 }
1208 // The same app at an older name (its project moved) now redirects
1209 // here; it stays up as it was if the redirect cannot be put.
1210 await this.supersede(cloudflare, row, row.script);
1211 // The project's own domains serve production wherever it is up.
1212 if (row.kind === "production") {
1213 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1214 }
1215 await this.chargeBuild(row, seconds);
1216 await this.notePeak(row.workspace);
1217 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
1218 await this.announce(row, null);
1219 await this.buildChanged(id);
1220 // A screenshot of production as it now is, for the project's overview.
1221 if (row.kind === "production") {
1222 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1223 console.error("could not ask for a screenshot", error),
1224 );
1225 }
1226 return Response.json(ok(true));
1227 }
1228 case "fail":
1229 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1230 return Response.json(ok(true));
1231 default:
1232 return Response.json(fail("not_found", "No such step."), { status: 404 });
1233 }
1234 }
1235
1236 /**
1237 * Older names of the app `script`, now up: one project's production, or
1238 * its preview of one branch, has one name, so any other app row for the
1239 * same is the app under a name it had before its project moved (its
1240 * workspace renamed, its repository renamed or transferred). Each is
1241 * replaced in the namespace by a redirect to the new name, its app row
1242 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1243 * the sweep to hold the old script) and in `DOMAINS` under the old
1244 * hostname, which the dispatcher follows before running anything, so the
1245 * old address redirects even if the old script is paused. A name that
1246 * cannot be redirected is left as it is, for the next deploy or sweep.
1247 */
1248 private async supersede(
1249 cloudflare: Cloudflare,
1250 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1251 script: string,
1252 ): Promise<string[]> {
1253 const older = await this.db
1254 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?")
1255 .bind(app.project_id, app.kind, app.branch, script)
1256 .all<{ script: string }>();
1257 const target = appHost(script);
1258 const done: string[] = [];
1259 for (const { script: old } of older.results) {
1260 try {
1261 await cloudflare.redirectScript(old, target);
1262 } catch (error) {
1263 console.error("could not redirect", old, "to", script, error);
1264 continue;
1265 }
1266 const at = now();
1267 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1268 await this.db.batch([
1269 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1270 this.db
1271 .prepare(
1272 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1273 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1274 )
1275 .bind(old, target, app.workspace, at, expires),
1276 // Its builds are no longer live under the old name.
1277 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1278 ]);
1279 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1280 expiration: Math.floor(Date.parse(expires) / 1000),
1281 }).catch((error) => console.error("could not record redirect", old, error));
1282 done.push(old);
1283 }
1284 return done;
1285 }
1286
1287 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1288 const row = await this.deploymentRow(id);
1289 if (!row || (row.status !== "queued" && row.status !== "building")) return;
1290 // A commit that is gone says so plainly; git's own words stay in the log.
1291 if (commitMissing(message)) {
1292 log = log ?? message;
1293 message = missingCommitMessage(row);
1294 }
1295 await this.db
1296 .prepare(
1297 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1298 WHERE id = ?`,
1299 )
1300 .bind(message.slice(0, 2000), log, seconds, now(), id)
1301 .run();
1302 // A failed build still used its sandbox.
1303 if (seconds) await this.chargeBuild(row, seconds);
1304 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
1305 await this.announce(row, message.slice(0, 300));
1306 await this.buildChanged(id);
1307 }
1308
1309 /**
1310 * Publishes a finished build: `deployment.failed` with what went wrong,
1311 * or `deployment.succeeded`, saying whether the build of the same app
1312 * before it failed. Whoever started it is the actor when it was a
1313 * person known by id; otherwise `triggeredBy` names them, or g1t.
1314 */
1315 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1316 if (!this.env.EVENTS) return;
1317 const previous = error
1318 ? null
1319 : await this.db
1320 .prepare(
1321 `SELECT status FROM deployments
1322 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1323 ORDER BY created_at DESC LIMIT 1`,
1324 )
1325 .bind(row.script, row.id, row.created_at)
1326 .first<{ status: string }>();
1327 const byId = row.created_by.startsWith("usr_");
1328 const event = {
1329 source: "deployments",
1330 repoId: row.repo_id,
1331 actor: byId ? row.created_by : null,
1332 data: {
1333 deploymentId: row.id,
1334 projectId: row.project_id,
1335 repoId: row.repo_id,
1336 workspace: row.workspace,
1337 project: row.slug,
1338 kind: row.kind,
1339 branch: row.branch,
1340 number: row.kind === "preview" ? row.number : null,
1341 commit: row.commit_sha,
1342 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1343 error,
1344 recovered: previous?.status === "failed",
1345 triggeredBy: byId ? "" : row.created_by,
1346 },
1347 };
1348 await eventsClient(this.env.EVENTS)
1349 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1350 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
1351 }
1352
1353 /**
1354 * Each build is charged by the second, from the first, at the container
1355 * price plus the margin. A build a workflow job ran is not: the job's
1356 * time is charged as Actions time.
1357 */
1358 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
1359 if (row.run_id) return;
1360 const costs = await this.costs();
1361 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
1362 if (cost <= 0) return;
1363 const what =
1364 row.kind === "preview"
1365 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1366 : `${row.workspace}/${row.slug} to production`;
1367 await billingClient(this.env.BILLING).chargeFeature({
1368 workspace: row.workspace,
1369 feature: "deployments",
1370 costMicros: cost,
1371 description: `Building ${what} (${Math.ceil(seconds)} s)`,
1372 repo: row.repo,
1373 reference: `deploy/${row.id}`,
1374 // Every second is metered; billing prices it from its price book and
1375 // tallies the month's build time.
1376 buildSeconds: Math.ceil(seconds),
1377 });
1378 await this.db
1379 .prepare(
1380 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1381 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1382 )
1383 .bind(row.workspace, month(), Math.ceil(seconds), cost)
1384 .run();
1385 }
1386
1387 /**
1388 * What each unit costs g1t now, from billing's price book, which follows
1389 * what Cloudflare bills. The plan's figures if billing cannot say.
1390 */
1391 private async costs(): Promise<{
1392 buildSecond: number;
1393 millionRequests: number;
1394 millionCpuMs: number;
1395 domainMonth: number;
1396 /** What one custom domain is charged a month: the cost plus the margin. */
1397 domainMonthPrice: number;
1398 }> {
1399 const a = DEPLOYMENT_COSTS;
1400 const book = await billingClient(this.env.BILLING)
1401 .prices()
1402 .catch(() => null);
1403 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1404 return {
1405 buildSecond: cost("build_second", a.microsPerBuildSecond),
1406 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1407 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
1408 domainMonth: cost("custom_domain_month", a.microsPerDomainMonth),
1409 domainMonthPrice:
1410 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
1411 };
1412 }
1413
1414 /** Remembers the most apps (for information; never charged) and custom domains the workspace had at once this month. */
1415 private async notePeak(workspace: string): Promise<void> {
1416 await this.db
1417 .prepare(
1418 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1419 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1420 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
1421 ON CONFLICT (namespace, month) DO UPDATE SET
1422 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1423 peak_domains = MAX(peak_domains, (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))`,
1424 )
1425 .bind(workspace, month())
1426 .run();
1427 }
1428
1429 /**
1430 * The check on the commit: `g1t / deploy`, or, for one of several
1431 * projects on a repository, `g1t / deploy (<project>)`.
1432 */
1433 private async status(
1434 repoId: string,
1435 sha: string,
1436 project: { slug: string; primary: boolean },
1437 state: string,
1438 description: string,
1439 targetUrl: string,
1440 ): Promise<void> {
1441 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
1442 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1443 method: "POST",
1444 headers: { "content-type": "application/json" },
1445 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
1446 }).catch(() => undefined);
1447 }
1448
1449 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1450 const projects = await this.projects.byRepo(row.repo_id);
1451 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1452 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1453 }
1454
1455 // ---- Taking apps down ----------------------------------------------
1456
1457 private async removeApp(script: string): Promise<void> {
1458 await this.cloudflare?.deleteScript(script);
1459 await this.db.batch([
1460 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1461 // Its build is no longer live anywhere.
1462 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1463 ]);
1464 }
1465
1466 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
1467 const apps = await this.db
1468 .prepare(
1469 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
1470 )
1471 .bind(projectId, kind, branch ?? null)
1472 .all<{ script: string }>();
1473 for (const app of apps.results) await this.removeApp(app.script);
1474 }
1475
1476 // ---- Events --------------------------------------------------------
1477
1478 /** `attempts`: which delivery of the event this is, from 1. */
1479 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
1480 switch (event.type) {
1481 case "workspace.renamed":
1482 await this.renamed(event.data, attempts);
1483 break;
1484 case "repo.transferred":
1485 case "repo.renamed":
1486 await this.moved(repoMove(event)!, attempts);
1487 break;
1488 // A repository that went or came back with its workspace is the
1489 // workspace's to handle: its apps are paused, not taken down.
1490 case "repo.deleted":
1491 if (!event.data.withWorkspace) await this.repoDeleted(event.data.repoId);
1492 break;
1493 case "repo.restored":
1494 if (!event.data.withWorkspace) await this.repoRestored(event.data.repoId, attempts);
1495 break;
1496 case "workspace.deleting":
1497 await this.workspaceDeleting(event.data.slug);
1498 break;
1499 case "workspace.restored":
1500 await this.workspaceRestored(event.data.slug);
1501 break;
1502 case "workspace.deleted":
1503 await this.workspacePurged(event.data.slug);
1504 break;
1505 case "repo.purged":
1506 await this.repoPurged(event.data.repoId);
1507 await this.repoDeployments.purge(event.data.repoId);
1508 break;
1509 case "repo.default_branch_changed":
1510 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1511 break;
1512 case "branch.renamed":
1513 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1514 break;
1515
1516 case "pull.opened":
1517 case "pull.ready":
1518 case "pull.updated":
1519 case "pull.reopened":
1520 for (const project of await this.projects.byRepo(event.data.repoId)) {
1521 // Its managed deploy workflow builds it instead (`syncManagedDeploy`).
1522 if (await this.buildsViaWorkflow(project.id)) continue;
1523 await this.deployPreview(project, event.data.number, "g1t");
1524 }
1525 break;
1526 case "pull.closed":
1527 case "pull.merged":
1528 for (const project of await this.projects.byRepo(event.data.repoId)) {
1529 const apps = await this.db
1530 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1531 .bind(project.id, event.data.number)
1532 .all<{ script: string }>();
1533 for (const app of apps.results) await this.removeApp(app.script);
1534 }
1535 break;
1536 case "git.push":
1537 if (!event.data.defaultBranch) break;
1538 // A mirror deploys only while g1t leads it: standing by, or in CI
1539 // failover, the remote's own deploys stand (see contracts mirrors).
1540 if (!mirrorWritable(event.data.mirror)) break;
1541 for (const project of await this.projects.byRepo(event.data.repoId)) {
1542 if (await this.buildsViaWorkflow(project.id)) continue;
1543 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1544 }
1545 break;
1546 }
1547 }
1548
1549 /**
1550 * A workspace's slug changed, and with it every app's name: production
1551 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1552 *
1553 * Its rows move to the slug it has now (asked of identity, so a delivery
1554 * twice over, or an older rename after a newer one, ends the same), and
1555 * each app (paused or not) is built again from the same commit under its
1556 * new name; see `followMoves`. The old name keeps serving the app until
1557 * the new one is live; then it redirects to the new name (see
1558 * `supersede`), held for as long as the workspace holds its old slug.
1559 * Builds under way for the old name are dropped and started again under
1560 * the new one.
1561 */
1562 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1563 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1564 const stale = staleSlugs(renamed, current);
1565 if (stale.length === 0) return;
1566 const marks = stale.map(() => "?").join(", ");
1567
1568 // The workspace's projects, under any of its names: a second delivery
1569 // finds them under the new one, with whatever is left to do.
1570 const rows = await this.db
1571 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1572 .bind(...stale, current)
1573 .all<{ project_id: string }>();
1574 const projectIds = rows.results.map((row) => row.project_id);
1575 const projects = await this.projectsById(projectIds);
1576 // The projects service hears of the rename on its own queue: wait for
1577 // it a few deliveries, so the builds read the repository by its new name.
1578 const behind = [...projects.values()].some((p) => p.workspace !== current);
1579 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`);
1580
1581 // Every row moves at once.
1582 const at = now();
1583 const statements: D1PreparedStatement[] = [];
1584 for (const slug of stale) {
1585 statements.push(
1586 this.db
1587 .prepare(
1588 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`,
1589 )
1590 .bind(RENAMED_ERROR, at, slug),
1591 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1592 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1593 this.db
1594 .prepare(
1595 `UPDATE deployments SET workspace = ?1,
1596 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1597 WHERE workspace = ?2`,
1598 )
1599 .bind(current, slug),
1600 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1601 this.domains.rename(slug, current),
1602 // Counters add up; the peak is the higher; a month charged stays charged.
1603 this.db
1604 .prepare(
1605 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1606 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1607 FROM meters WHERE namespace = ?2
1608 ON CONFLICT (namespace, month) DO UPDATE SET
1609 requests = requests + excluded.requests,
1610 cpu_ms = cpu_ms + excluded.cpu_ms,
1611 peak_apps = MAX(peak_apps, excluded.peak_apps),
1612 peak_domains = MAX(peak_domains, excluded.peak_domains),
1613 build_seconds = build_seconds + excluded.build_seconds,
1614 build_micros = build_micros + excluded.build_micros,
1615 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1616 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1617 )
1618 .bind(current, slug),
1619 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1620 );
1621 }
1622 await this.db.batch(statements);
1623
1624 // Each app again, under its new name.
1625 const followed = await this.followMoves(projectIds, {
1626 projects,
1627 adjust: (project) => this.underSlug(project, current, stale),
1628 });
1629 if (followed.failed.length > 0) {
1630 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
1631 }
1632 }
1633
1634 /**
1635 * A repository moved: transferred to another workspace, and its projects
1636 * with it, or renamed within its own, when its own project's slug follows
1637 * its name (see the projects service). Each app's name is
1638 * `<project>-<workspace>`, so the apps of every project whose workspace or
1639 * slug changed (production and every preview, paused or not) are built
1640 * again, from the same commit, under the new name; see `followMoves`. As
1641 * after a workspace rename, the old name keeps serving until the new one
1642 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1643 * The project's custom domains follow its production. Builds under way
1644 * are started again under the new name. What the apps used this month
1645 * stays on the old workspace's meter; from now on, the new workspace's
1646 * counts it.
1647 *
1648 * Projects whose name did not change (a rename where the new name was
1649 * taken, or a project of another name) only learn the repository's new
1650 * path. What is left to rebuild is read from the rows each time, so a
1651 * second or late delivery builds only what the first could not, and one
1652 * whose rebuild could not be queued is delivered again (and, past the
1653 * queue's retries, followed up by the sweep).
1654 */
1655 private async moved(move: RepoMove, attempts: number): Promise<void> {
1656 const current = await currentMovedPath(this.env.REPOS, move);
1657 if (staleMovedPaths(move, current).length === 0) return;
1658 const [workspace, name] = current.split("/") as [string, string];
1659 const settings = await this.db
1660 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1661 .bind(move.repoId)
1662 .all<{ project_id: string; workspace: string; slug: string }>();
1663 if (settings.results.length === 0) return;
1664
1665 // The projects service hears of the move on its own queue: wait for it
1666 // a few deliveries, so builds read the project where and as it is now.
1667 const projects = new Map<string, Project>();
1668 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1669 const behind = settings.results.some(({ project_id }) => {
1670 const project = projects.get(project_id);
1671 if (!project || project.source.kind !== "hosted") return false;
1672 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1673 });
1674 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1675
1676 // Its history goes with it, as the repository's issues do.
1677 const statements: D1PreparedStatement[] = [
1678 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1679 ];
1680 // The projects whose apps' names change, and have not been moved yet.
1681 const moving = settings.results
1682 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1683 .map((row) => row.project_id);
1684 if (moving.length > 0) {
1685 const ids = moving.map(() => "?").join(", ");
1686 statements.push(
1687 this.db
1688 .prepare(
1689 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1690 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1691 )
1692 .bind(MOVED_ERROR, now(), ...moving),
1693 );
1694 }
1695 for (const projectId of moving) {
1696 const slug = projects.get(projectId)?.slug ?? null;
1697 statements.push(
1698 this.db
1699 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1700 .bind(workspace, slug, projectId),
1701 this.db
1702 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1703 .bind(workspace, slug, projectId),
1704 this.db
1705 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1706 .bind(workspace, slug, projectId),
1707 // Within the workspace its apps are listed under the project's name
1708 // now. One left behind in another workspace stays as it is until the
1709 // new name is live and redirects it.
1710 this.db
1711 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1712 .bind(workspace, slug, projectId),
1713 );
1714 }
1715 await this.db.batch(statements);
1716
1717 // Each app again, under its new name. App rows under the old name stay
1718 // until the new one is live, which redirects them.
1719 const followed = await this.followMoves(
1720 settings.results.map((row) => row.project_id),
1721 {
1722 projects,
1723 adjust: (found) =>
1724 found.source.kind === "hosted"
1725 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1726 : { ...found, workspace },
1727 },
1728 );
1729 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1730 }
1731
1732 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1733 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1734 const projects = new Map<string, Project>();
1735 if (projectIds.length === 0) return projects;
1736 const repoIds = new Set<string>();
1737 for (let i = 0; i < projectIds.length; i += 50) {
1738 const chunk = projectIds.slice(i, i + 50);
1739 const rows = await this.db
1740 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1741 .bind(...chunk)
1742 .all<{ repo_id: string }>();
1743 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1744 }
1745 const wanted = new Set(projectIds);
1746 for (const repoId of repoIds) {
1747 for (const project of await this.projects.byRepo(repoId)) {
1748 if (wanted.has(project.id)) projects.set(project.id, project);
1749 }
1750 }
1751 return projects;
1752 }
1753
1754 /** Whether `script` is the name an app of the project has where the project is now. */
1755 private async namedNow(
1756 script: string,
1757 settings: { project_id: string; workspace: string; slug: string },
1758 branch: string | null,
1759 ): Promise<boolean> {
1760 const base = await label(settings.workspace, settings.slug, branch);
1761 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1762 }
1763
1764 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1765 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1766 const stale: AppRow[] = [];
1767 for (const app of apps) {
1768 const row = settings.get(app.project_id);
1769 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1770 }
1771 return stale;
1772 }
1773
1774 /**
1775 * Brings the apps of the projects `projectIds` (every project when null)
1776 * under the names they have where the projects are now: each app still
1777 * under an older name, paused or not, and each build a move dropped, is
1778 * built again under its new name from the same commit, and once that is
1779 * live the old name redirects to it (`supersede`).
1780 *
1781 * Idempotent, and safe to run again at any time: an app already up under
1782 * its new name only has its old names redirected; one whose rebuild is
1783 * queued or under way is left to it; one whose workspace has no
1784 * Deployments or is over its limit waits, without a refused deployment
1785 * each time; one whose commit is gone, or whose rebuild failed the same
1786 * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff`
1787 * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`,
1788 * doubled for each failure, waits. Returns what could not be queued, for an
1789 * event's delivery to be retried.
1790 */
1791 private async followMoves(
1792 projectIds: string[] | null,
1793 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1794 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1795 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1796 if (projectIds && projectIds.length === 0) return result;
1797 const cloudflare = this.cloudflare;
1798 if (!cloudflare) return result;
1799
1800 const settingsRows =
1801 projectIds == null
1802 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1803 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1804 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1805 if (settings.size === 0) return result;
1806 const ids = [...settings.keys()];
1807
1808 const apps: AppRow[] = [];
1809 const dropped: DroppedBuild[] = [];
1810 for (let i = 0; i < ids.length; i += 50) {
1811 const chunk = ids.slice(i, i + 50);
1812 const marks = chunk.map(() => "?").join(", ");
1813 const [appRows, droppedRows] = await Promise.all([
1814 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1815 // Builds a move dropped, that nothing has been built in place of since.
1816 this.db
1817 .prepare(
1818 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1819 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1820 AND NOT EXISTS (
1821 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1822 AND n.created_at > d.created_at AND n.status != 'skipped'
1823 )`,
1824 )
1825 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1826 .all<DeploymentRow>(),
1827 ]);
1828 apps.push(...appRows.results);
1829 dropped.push(...droppedRows.results);
1830 }
1831 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1832 if (targets.length === 0) return result;
1833
1834 const projects = new Map(options.projects ?? []);
1835 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1836 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1837 const billing = billingClient(this.env.BILLING);
1838 const open = new Map<string, boolean>();
1839 const actors = new Map<string, User | null>();
1840
1841 for (const target of targets) {
1842 const row = settings.get(target.projectId)!;
1843 const found = projects.get(target.projectId);
1844 if (!found) continue;
1845 const project = options.adjust ? options.adjust(found) : found;
1846 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1847 // Built only where deployments has the project now; the projects
1848 // service catches up on its own queue, and a later run builds then.
1849 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1850 result.waiting++;
1851 continue;
1852 }
1853 try {
1854 const script = await this.scriptFor(project, target.branch);
1855 // Already up under its new name: only its old names are left to redirect.
1856 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1857 if (up) {
1858 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1859 continue;
1860 }
1861 const history = await this.recentBuilds(script);
1862 const last = history[0];
1863 if (last && (last.status === "queued" || last.status === "building")) {
1864 result.queued++;
1865 continue;
1866 }
1867 // A commit that is gone, or a build that failed the same way a few
1868 // times, is not tried again; a push or a redeploy builds it.
1869 // Otherwise the sweep waits longer after each failure.
1870 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff: options.backoff }).kind !== "build") {
1871 result.waiting++;
1872 continue;
1873 }
1874 // A workspace without Deployments, or over its limit, waits for it
1875 // rather than gathering refused deployments.
1876 if (!open.has(project.workspace)) {
1877 const [plan, limit] = await Promise.all([
1878 billing.hasFeature(project.workspace, "deployments"),
1879 billing.checkLimit(project.workspace),
1880 ]);
1881 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1882 }
1883 if (!open.get(project.workspace)) {
1884 result.waiting++;
1885 continue;
1886 }
1887 if (!actors.has(project.workspace)) actors.set(project.workspace, await this.workspaceActor(project.workspace));
1888 const started =
1889 target.kind === "production"
1890 ? await this.deployProduction(project, target.commit, "g1t")
1891 : target.number != null
1892 ? await this.deployPreview(project, target.number, "g1t", true)
1893 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1894 const outcome = rebuildOutcome(started);
1895 if (outcome === "queued") result.queued++;
1896 else if (outcome === "failed") {
1897 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1898 result.failed.push(`${named}: ${why}`);
1899 }
1900 } catch (error) {
1901 result.failed.push(`${named}: ${String(error)}`);
1902 }
1903 }
1904 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1905 return result;
1906 }
1907
1908 /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */
1909 private async recentBuilds(script: string): Promise<PastBuild[]> {
1910 const rows = await this.db
1911 .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?")
1912 .bind(script, MAX_IDENTICAL_FAILURES)
1913 .all<PastBuild>();
1914 return rows.results;
1915 }
1916
1917 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1918 private async projectIdsFor(repoId: string): Promise<string[]> {
1919 const rows = await this.db
1920 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1921 .bind(repoId)
1922 .all<{ project_id: string }>();
1923 return rows.results.map((row) => row.project_id);
1924 }
1925
1926 /**
1927 * A repository was deleted, restorable for a while: every app of its
1928 * projects (production and previews) comes down, builds under way are
1929 * dropped, and nothing builds for it until it is restored. Its settings
1930 * and custom domains are kept for the restore; until then a domain has
1931 * nothing up to serve, as when production is turned off.
1932 */
1933 private async repoDeleted(repoId: string): Promise<void> {
1934 const projectIds = await this.projectIdsFor(repoId);
1935 if (projectIds.length === 0) return;
1936 const at = now();
1937 await this.db.batch([
1938 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1939 this.db
1940 .prepare(
1941 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1942 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1943 )
1944 .bind(at, repoId),
1945 ]);
1946 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1947 }
1948
1949 /**
1950 * A deleted repository is back: production goes up again from its
1951 * default branch, for each project that has it on. Previews come back
1952 * with the next push to their pull requests.
1953 */
1954 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1955 const deleted = await this.db
1956 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1957 .bind(repoId)
1958 .all<{ project_id: string }>();
1959 const ids = deleted.results.map((row) => row.project_id);
1960 if (ids.length === 0) return;
1961 // The projects service hears of the restore on its own queue, and hides
1962 // the projects until then: wait for it a few deliveries.
1963 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1964 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1965 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1966 for (const project of projects) {
1967 try {
1968 const started = await this.deployProduction(project, null, "g1t");
1969 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1970 } catch (error) {
1971 console.error("could not deploy after restore", project.slug, error);
1972 }
1973 }
1974 }
1975
1976 /**
1977 * A workspace was deleted, restorable by g1t's staff for a while: every
1978 * app of its projects (production and previews) is paused, answering with
1979 * a notice and running nothing, builds under way are dropped, and nothing
1980 * builds for it until it is restored. Nothing is taken down: scripts,
1981 * settings and custom domains are kept for the restore. Never for a
1982 * protected workspace, whatever was published.
1983 */
1984 private async workspaceDeleting(slug: string): Promise<void> {
1985 const workspace = slug.toLowerCase();
1986 if (isProtectedWorkspace(workspace)) {
1987 console.error("workspace.deleting ignored for protected", workspace);
1988 return;
1989 }
1990 const at = now();
1991 await this.db.batch([
1992 this.db
1993 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1994 .bind(at, workspace),
1995 this.db
1996 .prepare(
1997 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1998 WHERE workspace = ? AND status IN ('queued', 'building')`,
1999 )
2000 .bind(at, workspace),
2001 ]);
2002 const cloudflare = this.cloudflare;
2003 for (const app of await this.appsOfWorkspace(workspace)) {
2004 if (app.paused_at) continue;
2005 await cloudflare?.pauseScript(app.script);
2006 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
2007 }
2008 }
2009
2010 /**
2011 * A deleted workspace is back: it builds again, and its paused apps are
2012 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
2013 * (the sweep tries again any it could not).
2014 */
2015 private async workspaceRestored(slug: string): Promise<void> {
2016 const workspace = slug.toLowerCase();
2017 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
2018 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
2019 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
2020 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
2021 }
2022
2023 /**
2024 * A deleted workspace is purged: whatever its projects still have up
2025 * comes down and their custom domains go, as for a purged repository.
2026 * Its own repositories' projects are purged with them (`repo.purged`);
2027 * this catches any building from a repository it had transferred away.
2028 */
2029 private async workspacePurged(slug: string): Promise<void> {
2030 const workspace = slug.toLowerCase();
2031 if (isProtectedWorkspace(workspace)) return;
2032 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
2033 for (const { project_id } of rows.results) {
2034 await this.takeDownWhere(project_id, null);
2035 await this.domains.removeWhere("project_id", project_id);
2036 }
2037 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
2038 await this.db.batch([
2039 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
2040 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
2041 ]);
2042 }
2043
2044 /** The apps of a workspace's projects, and any still under its name. */
2045 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
2046 const rows = await this.db
2047 .prepare(
2048 `SELECT * FROM apps WHERE workspace = ?1
2049 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
2050 )
2051 .bind(workspace)
2052 .all<AppRow>();
2053 return rows.results;
2054 }
2055
2056 /**
2057 * A deleted repository is gone for good: its projects' custom domains are
2058 * removed (from the dispatcher and from Cloudflare), any app or redirect
2059 * still up comes down, and every row kept for them goes. What they used
2060 * stays on their workspace's meter.
2061 */
2062 private async repoPurged(repoId: string): Promise<void> {
2063 const projectIds = await this.projectIdsFor(repoId);
2064 const domains = this.domains;
2065 for (const projectId of projectIds) {
2066 await this.takeDownWhere(projectId, null);
2067 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
2068 await domains.removeWhere("project_id", projectId);
2069 }
2070 // The redirects left at names its apps had before.
2071 const scripts = await this.db
2072 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
2073 .bind(repoId)
2074 .all<{ script: string }>();
2075 const hosts = scripts.results.map(({ script }) => appHost(script));
2076 for (let i = 0; i < hosts.length; i += 50) {
2077 const chunk = hosts.slice(i, i + 50);
2078 const redirects = await this.db
2079 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
2080 .bind(...chunk)
2081 .all<{ script: string }>();
2082 for (const { script } of redirects.results) {
2083 await this.cloudflare?.deleteScript(script);
2084 await this.env.DOMAINS?.delete(appHost(script)).catch((error) => console.error("could not drop redirect", script, error));
2085 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
2086 }
2087 }
2088 await this.db.batch([
2089 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
2090 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
2091 ]);
2092 }
2093
2094 /**
2095 * The default branch is another one now: production is built from it, as
2096 * from a push to it, unless production already serves (or is building)
2097 * its commit, as when the default branch was only renamed.
2098 */
2099 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
2100 for (const found of await this.projects.byRepo(repoId)) {
2101 if (found.source.kind !== "hosted") continue;
2102 // Projects may not have heard yet: the event names the branch.
2103 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
2104 const actor = await this.workspaceActor(project.workspace);
2105 if (!actor) continue;
2106 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
2107 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
2108 if (!head) continue;
2109 const same = await this.db
2110 .prepare(
2111 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
2112 AND status IN ('queued', 'building', 'ready')`,
2113 )
2114 .bind(project.id, head)
2115 .first();
2116 if (same) continue;
2117 await this.deployProduction(project, head, createdBy);
2118 }
2119 }
2120
2121 /**
2122 * A branch was renamed: its preview is the same app, so its rows follow.
2123 * The app keeps its name until it is next built; then it goes up under
2124 * the new branch's name, and the old one redirects there (see `supersede`).
2125 */
2126 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
2127 const projectIds = await this.projectIdsFor(repoId);
2128 if (projectIds.length === 0) return;
2129 const ids = projectIds.map(() => "?").join(", ");
2130 await this.db.batch([
2131 this.db
2132 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2133 .bind(to, from, ...projectIds),
2134 this.db
2135 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2136 .bind(to, from, ...projectIds),
2137 ]);
2138 }
2139
2140 /** `project` under the workspace's slug now, whether or not projects has caught up. */
2141 private underSlug(project: Project, current: string, stale: string[]): Project {
2142 const source =
2143 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
2144 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
2145 : project.source;
2146 return { ...project, workspace: current, source };
2147 }
2148
2149 /** A stack's preview (no pull request of its own) built again at `commit`. */
2150 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
2151 if (!actor || branch == null) return null;
2152 const settings = await this.settingsRow(project.id);
2153 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
2154 return this.start({
2155 project,
2156 kind: "preview",
2157 branch,
2158 number: null,
2159 commit,
2160 source: repoOf(project).path,
2161 reader: actor,
2162 createdBy: "g1t",
2163 settings,
2164 // As `stack` built it: the project's own default branch.
2165 trusted: true,
2166 });
2167 }
2168
2169 // ---- The sweep -----------------------------------------------------
2170
2171 /**
2172 * Every few minutes: builds that died are failed; usage is counted; idle
2173 * previews, the apps of workspaces whose plan ended, and scripts no app
2174 * holds come down; and a month that is over is charged past its
2175 * allowance.
2176 *
2177 * Each step stands alone: one that fails is logged and the rest still
2178 * run. Taking idle previews down and charging months, which only read
2179 * g1t's own tables, go before the steps that wait on Cloudflare's API,
2180 * so a slow or failing API never holds them up.
2181 */
2182 async sweep(): Promise<void> {
2183 const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error));
2184 await step("failing stuck builds", async () => {
2185 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
2186 const stuck = await this.db
2187 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
2188 .bind(cutoff)
2189 .all<{ id: string }>();
2190 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
2191 });
2192 await step("taking down idle previews", () => this.takeDownIdle());
2193 await step("charging months", () => this.chargeMonths());
2194
2195 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2196 // Each app is its project's workspace's, as deployments has it now: an
2197 // app still under the name it had before its project moved is the new
2198 // workspace's, and plans and limits are checked there.
2199 const settings = new Map(
2200 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
2201 );
2202 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2203 // A deleted workspace's apps stay paused as they are, for a restore:
2204 // its plan ended with the deletion, and that must not take them down.
2205 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
2206 const live = apps.filter((app) => !held(app));
2207 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
2208
2209 // Apps of workspaces whose plan has ended come down.
2210 const billing = billingClient(this.env.BILLING);
2211 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
2212 for (const workspace of workspaces) {
2213 await step(`ending ${workspace}'s apps`, async () => {
2214 const plan = await billing.hasFeature(workspace, "deployments");
2215 if (!plan.ok && plan.error.code === "payment_required") {
2216 for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
2217 // Custom domains cost g1t by the month: they go with the plan.
2218 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
2219 }
2220 });
2221 }
2222
2223 // Apps whose project moved and are not up under the new name yet: a
2224 // move's rebuild that could not start is tried again here.
2225 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
2226 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2227
2228 await this.domains
2229 .sweep(async (projectId) => {
2230 const app = await this.db
2231 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
2232 .bind(projectId)
2233 .first<{ script: string }>();
2234 return app?.script ?? null;
2235 })
2236 .catch((error) => console.error("could not check domains", error));
2237
2238 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
2239 await this.count(apps).catch((error) => console.error("could not count usage", error));
2240 }
2241
2242 /**
2243 * Pauses the apps of workspaces that reached their limit for usage not
2244 * yet paid for, and rebuilds them from the same commit once they are
2245 * under it again. Paused apps answer with a notice and run nothing.
2246 *
2247 * The workspace is the project's now (see `ownerOf`), never the one an
2248 * app's row was written under, so an app left under its old name after
2249 * a transfer is paused only if its new workspace is over its limit. Such
2250 * an app is resumed by being built under its new name (`followMoves`),
2251 * not here.
2252 */
2253 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
2254 const cloudflare = this.cloudflare;
2255 if (!cloudflare) return;
2256 const billing = billingClient(this.env.BILLING);
2257 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2258 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
2259 const limit = await billing.checkLimit(workspace);
2260 if (!limit.ok) continue;
2261 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
2262 if (limit.value.state === "stopped") {
2263 for (const app of theirs.filter((a) => !a.paused_at)) {
2264 await cloudflare.pauseScript(app.script);
2265 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2266 }
2267 continue;
2268 }
2269 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2270 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
2271 if (paused.length === 0) continue;
2272 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
2273 for (const app of paused) {
2274 const project = projects.get(app.project_id);
2275 if (!project) continue;
2276 // A failed or refused rebuild leaves it paused, to try again later:
2277 // longer after each failure, and not once its commit is gone or it
2278 // failed the same way a few times.
2279 const history = await this.recentBuilds(app.script);
2280 if (history[0]?.status === "queued" || history[0]?.status === "building") continue;
2281 const backoff = history[0]?.status === "failed";
2282 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue;
2283 const rebuilt =
2284 app.kind === "production"
2285 ? await this.deployProduction(project, app.commit_sha, "g1t")
2286 : app.number != null
2287 ? await this.deployPreview(project, app.number, "g1t", true)
2288 : null;
2289 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2290 }
2291 }
2292 }
2293
2294 /**
2295 * Scripts in the namespace that no app holds, such as ones renamed. An
2296 * old address that redirects to its app's new one is held until its
2297 * redirect expires, then removed with the rest.
2298 */
2299 private async removeOrphans(apps: AppRow[]): Promise<void> {
2300 const cloudflare = this.cloudflare;
2301 if (!cloudflare) return;
2302 // Their entries in `DOMAINS` expire on their own, at the same time.
2303 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2304 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2305 const held = new Set([...apps.map((app) => app.script), ...redirects.results.map((r) => r.script)]);
2306 const building = await this.db
2307 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2308 .all<{ script: string }>();
2309 for (const row of building.results) held.add(row.script);
2310 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2311 for (const script of await cloudflare.listScripts()) {
2312 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2313 }
2314 }
2315
2316 /** Counts this month's requests and CPU time per workspace, from analytics. */
2317 private async count(apps: AppRow[]): Promise<void> {
2318 const cloudflare = this.cloudflare;
2319 if (!cloudflare || apps.length === 0) return;
2320 const start = `${month()}-01T00:00:00Z`;
2321 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2322 // Analytics only counts apps that are up; the meter keeps what earlier
2323 // apps used by never going down.
2324 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2325 for (const app of apps) {
2326 const used = totals.get(app.script);
2327 if (!used) continue;
2328 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
2329 sum.requests += used.requests;
2330 sum.cpuMs += used.cpuMs;
2331 perWorkspace.set(app.workspace, sum);
2332 }
2333 const at = now();
2334 // Written only when the count moves the meter: most sweeps it does not.
2335 const stored = new Map(
2336 (
2337 await this.db
2338 .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?")
2339 .bind(month())
2340 .all<{ namespace: string; requests: number; cpu_ms: number }>()
2341 ).results.map((row) => [row.namespace, row]),
2342 );
2343 for (const [workspace, used] of perWorkspace) {
2344 if (!movesMeter(stored.get(workspace), used)) continue;
2345 await this.db
2346 .prepare(
2347 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2348 ON CONFLICT (namespace, month) DO UPDATE SET
2349 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2350 )
2351 .bind(workspace, month(), used.requests, used.cpuMs, at)
2352 .run();
2353 }
2354 // When each preview last answered anyone, for the idle sweep.
2355 const recent = await cloudflare.usage(
2356 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2357 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2358 at,
2359 );
2360 // At an hour's resolution: previews idle for days are what it finds.
2361 const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
2362 const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at]));
2363 for (const [script, used] of recent) {
2364 const last = lastRequest.get(script);
2365 if (used.requests > 0 && (!last || last < hourAgo)) {
2366 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2367 }
2368 }
2369 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
2370 // What this month's traffic and custom domains will cost, from the
2371 // first request and the first domain, so the workspace's limit counts
2372 // it now rather than when the month closes, and its Billing page shows it.
2373 const costs = await this.costs();
2374 const billing = billingClient(this.env.BILLING);
2375 const meters = await this.db
2376 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2377 .bind(month())
2378 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2379 for (const meter of meters.results) {
2380 const cost = monthCost(meter, costs);
2381 await billing
2382 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
2383 .catch((error) => console.error("could not note pending usage", error));
2384 if ((meter.peak_domains ?? 0) > 0) {
2385 await billing
2386 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2387 .catch((error) => console.error("could not note pending usage", error));
2388 }
2389 }
2390 }
2391
2392 /** Previews no one has visited in their project's idle days. */
2393 private async takeDownIdle(): Promise<void> {
2394 const idle = await this.db
2395 .prepare(
2396 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
2397 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
2398 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2399 )
2400 .all<{ script: string }>();
2401 for (const { script } of idle.results) await this.removeApp(script);
2402 }
2403
2404 /** Charges each month that is over for its requests, CPU time and custom domains, from the first, once. */
2405 private async chargeMonths(): Promise<void> {
2406 const due = await this.db
2407 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2408 .bind(month())
2409 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2410 const costs = await this.costs();
2411 for (const meter of due.results) {
2412 const cost = monthCost(meter, costs);
2413 if (cost.micros > 0) {
2414 const charged = await billingClient(this.env.BILLING).chargeFeature({
2415 workspace: meter.namespace,
2416 feature: "deployments",
2417 costMicros: cost.micros,
2418 description: `Deployments in ${meter.month}: ${cost.description}`,
2419 reference: `deployments/${meter.namespace}/${meter.month}`,
2420 });
2421 if (!charged.ok) continue;
2422 }
2423 await this.db
2424 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2425 .bind(now(), meter.namespace, meter.month)
2426 .run();
2427 }
2428 }
2429}
2430
2431/** `POST /rpc/<method>`: the arguments are the body. */
2432async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
2433 switch (method) {
2434 case "settings":
2435 return service.settings(args);
2436 case "is_enabled":
2437 return service.isEnabled(args);
2438 case "update_settings":
2439 return service.updateSettings(args);
2440 case "list":
2441 return service.list(args);
2442 case "get":
2443 return service.get(args);
2444 case "redeploy":
2445 return service.redeploy(args);
2446 // For the API: a g1t Actions job opening a build it runs itself.
2447 case "start_from_workflow":
2448 return service.startFromWorkflow(args);
2449 // For the actions service: a job ended, so its open builds end too.
2450 case "workflow_job_ended":
2451 return service.workflowJobEnded(args);
2452 case "take_down":
2453 return service.takeDown(args);
2454 case "overview":
2455 return service.overview(args);
2456 case "usage":
2457 return service.usage(args);
2458 case "domains":
2459 return service.listDomains(args);
2460 case "add_domain":
2461 return service.addDomain(args);
2462 case "remove_domain":
2463 return service.removeDomain(args);
2464 case "refresh_domain":
2465 return service.refreshDomain(args);
2466 // A repository's deployments wherever they run (repo-deployments.ts).
2467 case "list_deployments":
2468 return service.repoDeployments.list({ ...args, source: args.source == null ? null : sourceOf(args.source) ?? "none" });
2469 case "get_deployment":
2470 return service.repoDeployments.get(args);
2471 case "list_deployment_statuses":
2472 return service.repoDeployments.statuses(args);
2473 case "list_environments":
2474 return service.repoDeployments.environments(args);
2475 case "get_environment":
2476 return service.repoDeployments.environment(args);
2477 case "create_deployment":
2478 return service.repoDeployments.create(args);
2479 case "create_deployment_status":
2480 return service.repoDeployments.createStatus(args);
2481 // For the actions service: a run's deployment to one environment.
2482 case "actions_deployment":
2483 return service.repoDeployments.fromActions(args);
2484 default:
2485 return undefined;
2486 }
2487}
2488
2489export default {
2490 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2491 const { pathname } = new URL(request.url);
2492 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2493 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2494 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2495 if (rpcMatch) {
2496 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2497 const opened = openD1(env.DB, request);
2498 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
2499 const result = await rpc(service, rpcMatch[1], body, ctx);
2500 return opened.finish(result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result));
2501 }
2502 const service = new Deployments(env);
2503 // A build's reports, forwarded by the API.
2504 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2505 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2506 return new Response("Not found\n", { status: 404 });
2507 },
2508
2509 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2510 const service = new Deployments(env);
2511 for (const message of batch.messages) {
2512 try {
2513 await service.onEvent(message.body, message.attempts);
2514 message.ack();
2515 } catch (error) {
2516 console.error("deployments could not handle", message.body.type, error);
2517 message.retry();
2518 }
2519 }
2520 },
2521
2522 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2523 await new Deployments(env).sweep();
2524 },
2525} satisfies ExportedHandler<Env, G1tEvent>;