Skip to content

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

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