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