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