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