Skip to content

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

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