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