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