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.

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