pr_01m47d15m3e54sn21z27rpy5n9/services/deployments/src/index.ts

923 lines36,219 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Deployments: a preview for every pull request, production on g1t.page1/**
2 * The deployments service: every pull request gets a live preview on
3 * g1t.page, and the default branch goes to production on every push.
4 *
5 * It reacts to events (a pull request opened, ready, pushed to, closed or
6 * merged; a push to the default branch), asks billing whether the
7 * workspace pays for Deployments, and asks the runner to build the commit
8 * in a sandbox. The sandbox reports back through the API with a token for
9 * that build alone; this service opens the upload of its files and puts
10 * the finished app in the Workers for Platforms namespace, where the
11 * `*.g1t.page` dispatcher finds it by hostname.
12 *
13 * Nothing here is free. A build is charged by the second; requests, CPU
14 * time and apps past the plan's allowance are charged once the month is
15 * over. A Worker runs only while it answers a request, so an app no one
16 * visits costs nothing, and a preview is taken down when its pull request
17 * closes or after its repository's idle days.
18 *
19 * Reached through service bindings (`POST /rpc/<method>`) and, for a
20 * build's reports, through the API (`POST /jobs/<id>/<step>`).
21 */
22
23import {
24 DEPLOYMENTS_ALLOWANCE,
25 billingClient,
26 fail,
27 identityClient,
28 newId,
29 ok,
30 reposClient,
31 workClient,
32 type DeployKind,
33 type DeploySettings,
34 type DeployStatus,
35 type DeployUsage,
36 type Deployment,
37 type G1tEvent,
38 type LiveApp,
39 type RepoPath,
40 type Result,
41 type ServiceBinding,
42 type User,
43 type Viewer,
44} from "@g1t/contracts";
45
46import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
47import { appUrl, scriptName } from "./names";
48
49type Env = {
50 DB: D1Database;
51 REPOS: ServiceBinding;
52 WORK: ServiceBinding;
53 IDENTITY: ServiceBinding;
54 BILLING: ServiceBinding;
55 RUNNER: ServiceBinding;
Secrets and variables: one list, rows per environment, for workflows and deployments56 /** Secrets and variables: the actions service holds the one store. */
57 ACTIONS: ServiceBinding;
Deployments: a preview for every pull request, production on g1t.page58 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
59 CLOUDFLARE_API_TOKEN?: string;
60 CLOUDFLARE_ACCOUNT_ID: string;
61 DISPATCH_NAMESPACE: string;
62 SITE: string;
63};
64
65/** A build that has not reported in this long has died. */
66const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
67const LIST_LIMIT = 50;
68const STATUS_CONTEXT = "g1t / deploy";
69
70const now = () => new Date().toISOString();
71const month = (at = new Date()) => at.toISOString().slice(0, 7);
72
73async function sha256(text: string): Promise<string> {
74 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
75 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
76}
77
78function randomToken(): string {
79 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
80}
81
82function isMember(viewer: Viewer, slug: string): boolean {
83 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
84}
85
86type SettingsRow = {
87 repo_id: string;
88 namespace: string;
89 name: string;
90 enabled: number;
91 previews: number;
92 production: number;
93 build_command: string | null;
94 output_dir: string | null;
95 idle_days: number;
96};
97
98type DeploymentRow = {
99 id: string;
100 repo_id: string;
101 namespace: string;
102 name: string;
103 kind: DeployKind;
104 number: number | null;
105 commit_sha: string;
106 script: string;
107 status: DeployStatus;
108 error: string | null;
109 warnings: string;
110 log: string | null;
111 token_hash: string | null;
Secrets and variables: one list, rows per environment, for workflows and deployments112 trusted: number;
Deployments: a preview for every pull request, production on g1t.page113 build_seconds: number | null;
114 created_by: string;
115 created_at: string;
116 finished_at: string | null;
117};
118
119type AppRow = {
120 script: string;
121 repo_id: string;
122 namespace: string;
123 name: string;
124 kind: DeployKind;
125 number: number | null;
126 commit_sha: string;
127 deployed_at: string;
128 created_at: string;
129 last_request_at: string | null;
130};
131
132function toDeployment(row: DeploymentRow): Deployment {
133 return {
134 id: row.id,
135 kind: row.kind,
136 number: row.number,
137 commit: row.commit_sha,
138 status: row.status,
139 url: appUrl(row.script),
140 error: row.error,
141 warnings: JSON.parse(row.warnings || "[]") as string[],
142 buildSeconds: row.build_seconds,
143 createdBy: row.created_by,
144 createdAt: row.created_at,
145 finishedAt: row.finished_at,
146 };
147}
148
149class Deployments {
150 constructor(private readonly env: Env) {}
151
152 private get cloudflare(): Cloudflare | null {
153 const token = this.env.CLOUDFLARE_API_TOKEN;
154 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
155 }
156
157 private get db() {
158 return this.env.DB;
159 }
160
161 /** The workspace itself, as the service acts for it. */
162 private async workspaceActor(slug: string): Promise<User | null> {
163 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
164 if (!workspace) return null;
165 return {
166 id: workspace.id,
167 username: workspace.slug,
168 kind: "workspace",
169 verified: true,
170 workspaces: [{ slug: workspace.slug, role: "member" }],
171 };
172 }
173
174 private async pathById(id: string): Promise<RepoPath | null> {
175 const response = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
176 method: "POST",
177 headers: { "content-type": "application/json" },
178 body: JSON.stringify({ id }),
179 });
180 return response.ok ? ((await response.json()) as RepoPath | null) : null;
181 }
182
Secrets and variables: one list, rows per environment, for workflows and deployments183 /**
184 * What the repository's secrets and variables available to deployments
185 * give production or a preview: its build's environment, and the same
186 * again as the running app's bindings. Untrusted builds get no secrets.
187 */
188 private async resolve(
189 repoId: string,
190 repo: RepoPath,
191 environment: DeployKind,
192 trusted: boolean,
193 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
194 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
195 method: "POST",
196 headers: { "content-type": "application/json" },
197 body: JSON.stringify({ repoId, repo, consumer: "deployments", environment, trusted }),
198 });
199 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
200 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
201 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
202 }
203
204 /**
205 * Whether a pull request's author is trusted with the repository's
206 * secrets: g1t's agent, or a member of the workspace. Someone from
207 * outside gets a preview built without them, as their workflows run.
208 */
209 private async insider(repo: RepoPath, author: User, actor: User): Promise<boolean> {
210 if (author.kind === "agent" || author.username === "g1t-agent") return true;
211 // On a private repository only members can open one at all.
212 const found = await reposClient(this.env.REPOS).get(repo, actor);
213 if (found.ok && found.value.isPrivate) return true;
214 if (author.workspaces?.some((m) => m.slug === repo.namespace.toLowerCase())) return true;
215 const members = await identityClient(this.env.IDENTITY).listMembers(repo.namespace, actor);
216 return members.ok && members.value.some((m) => m.username.toLowerCase() === author.username.toLowerCase());
217 }
218
Deployments: a preview for every pull request, production on g1t.page219 private async settingsRow(repoId: string): Promise<SettingsRow | null> {
220 return this.db.prepare("SELECT * FROM settings WHERE repo_id = ?").bind(repoId).first<SettingsRow>();
221 }
222
223 private async toSettings(repo: RepoPath, row: SettingsRow | null): Promise<DeploySettings> {
224 return {
225 enabled: !!row?.enabled,
226 previews: row ? !!row.previews : true,
227 production: row ? !!row.production : true,
228 buildCommand: row?.build_command ?? null,
229 outputDir: row?.output_dir ?? null,
230 idleDays: row?.idle_days ?? 7,
231 productionUrl: appUrl(await scriptName(repo, null)),
232 };
233 }
234
235 /** The repository, if `viewer` belongs to its workspace and it is not a fork. */
236 private async memberRepo(repo: RepoPath, viewer: Viewer) {
237 if (!isMember(viewer, repo.namespace)) return fail("forbidden", "Only members of the workspace can manage its deployments.");
238 const found = await reposClient(this.env.REPOS).get(repo, viewer);
239 if (!found.ok) return found;
240 if (found.value.forkOf) return fail("invalid", "A pull request's working copy does not deploy on its own.");
241 return found;
242 }
243
244 // ---- Methods for the site and the API ------------------------------
245
246 async settings(a: { repo: RepoPath; viewer: Viewer }): Promise<Result<DeploySettings>> {
247 const repo = await this.memberRepo(a.repo, a.viewer);
248 if (!repo.ok) return repo;
249 return ok(await this.toSettings(a.repo, await this.settingsRow(repo.value.id)));
250 }
251
252 async updateSettings(a: {
253 actor: User;
254 repo: RepoPath;
255 changes: Partial<DeploySettings>;
256 }): Promise<Result<DeploySettings>> {
257 const repo = await this.memberRepo(a.repo, a.actor);
258 if (!repo.ok) return repo;
259 const before = await this.toSettings(a.repo, await this.settingsRow(repo.value.id));
260 const next = { ...before, ...a.changes };
261 if (next.enabled && !before.enabled) {
262 // Turning it on starts paid work: only with the workspace's plan.
263 const plan = await billingClient(this.env.BILLING).hasFeature(a.repo.namespace, "deployments");
264 if (!plan.ok) return plan;
265 }
266 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
267 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
268 await this.db
269 .prepare(
270 `INSERT INTO settings (repo_id, namespace, name, enabled, previews, production, build_command, output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments271 idle_days, updated_by, updated_at)
272 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
Deployments: a preview for every pull request, production on g1t.page273 ON CONFLICT (repo_id) DO UPDATE SET namespace = ?2, name = ?3, enabled = ?4, previews = ?5, production = ?6,
Secrets and variables: one list, rows per environment, for workflows and deployments274 build_command = ?7, output_dir = ?8, idle_days = ?9, updated_by = ?10, updated_at = ?11`,
Deployments: a preview for every pull request, production on g1t.page275 )
276 .bind(
277 repo.value.id,
278 repo.value.namespace,
279 repo.value.name,
280 next.enabled ? 1 : 0,
281 next.previews ? 1 : 0,
282 next.production ? 1 : 0,
283 clip(next.buildCommand),
284 clip(next.outputDir),
285 idleDays,
286 a.actor.username,
287 now(),
288 )
289 .run();
290 // What was turned off comes down now; nothing keeps running unasked.
291 if (!next.enabled) await this.takeDownWhere(repo.value.id, null);
292 else {
293 if (!next.previews) await this.takeDownWhere(repo.value.id, "preview");
294 if (!next.production) await this.takeDownWhere(repo.value.id, "production");
295 }
296 // Turned on: production goes up from the default branch at once.
297 if (next.enabled && next.production && (!before.enabled || !before.production)) {
298 await this.deployProduction(repo.value.id, a.repo, repo.value.defaultBranch, null, a.actor.username);
299 }
300 return ok(await this.toSettings(a.repo, await this.settingsRow(repo.value.id)));
301 }
302
303 async list(a: { repo: RepoPath; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
304 const repo = await this.memberRepo(a.repo, a.viewer);
305 if (!repo.ok) return repo;
306 const [deployments, apps] = await Promise.all([
307 this.db
308 .prepare("SELECT * FROM deployments WHERE repo_id = ? ORDER BY id DESC LIMIT ?")
309 .bind(repo.value.id, LIST_LIMIT)
310 .all<DeploymentRow>(),
311 this.db
312 .prepare("SELECT * FROM apps WHERE repo_id = ? ORDER BY kind DESC, number DESC")
313 .bind(repo.value.id)
314 .all<AppRow>(),
315 ]);
316 return ok({
317 deployments: deployments.results.map(toDeployment),
318 live: apps.results.map((app) => ({
319 kind: app.kind,
320 number: app.number,
321 url: appUrl(app.script),
322 commit: app.commit_sha,
323 deployedAt: app.deployed_at,
324 })),
325 });
326 }
327
328 async get(a: { repo: RepoPath; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
329 const repo = await this.memberRepo(a.repo, a.viewer);
330 if (!repo.ok) return repo;
331 const row = await this.db
332 .prepare("SELECT * FROM deployments WHERE id = ? AND repo_id = ?")
333 .bind(a.id, repo.value.id)
334 .first<DeploymentRow>();
335 if (!row) return fail("not_found", "No such deployment.");
336 return ok({ ...toDeployment(row), log: row.log });
337 }
338
339 async redeploy(a: { actor: User; repo: RepoPath; number: number | null }): Promise<Result<Deployment>> {
340 const repo = await this.memberRepo(a.repo, a.actor);
341 if (!repo.ok) return repo;
342 const settings = await this.settingsRow(repo.value.id);
343 if (!settings?.enabled) return fail("conflict", "Deployments are off for this repository.");
344 const started =
345 a.number == null
346 ? await this.deployProduction(repo.value.id, a.repo, repo.value.defaultBranch, null, a.actor.username)
347 : await this.deployPreview(repo.value.id, a.repo, a.number, a.actor.username, true);
348 return started ?? fail("conflict", "There was nothing to deploy.");
349 }
350
351 async takeDown(a: { actor: User; repo: RepoPath; number: number | null }): Promise<Result<true>> {
352 const repo = await this.memberRepo(a.repo, a.actor);
353 if (!repo.ok) return repo;
354 const script = await scriptName(a.repo, a.number);
355 await this.removeApp(script);
356 return ok(true);
357 }
358
359 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
360 const slug = a.workspace.toLowerCase();
361 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
362 const [meter, apps] = await Promise.all([
363 this.db
364 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
365 .bind(slug, month())
366 .first<{
367 requests: number;
368 cpu_ms: number;
369 peak_apps: number;
370 build_seconds: number;
371 build_micros: number;
372 counted_at: string | null;
373 }>(),
374 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE namespace = ?").bind(slug).first<{ n: number }>(),
375 ]);
376 return ok({
377 month: month(),
378 requests: meter?.requests ?? 0,
379 cpuMs: meter?.cpu_ms ?? 0,
380 apps: apps?.n ?? 0,
381 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
382 buildSeconds: meter?.build_seconds ?? 0,
383 buildMicros: meter?.build_micros ?? 0,
384 countedAt: meter?.counted_at ?? null,
385 });
386 }
387
388 // ---- Starting builds -----------------------------------------------
389
390 /**
391 * Opens a deployment and starts its build. Skipped, with the reason
392 * recorded, when the workspace's plan is off.
393 */
394 private async start(input: {
395 repoId: string;
396 repo: RepoPath;
397 kind: DeployKind;
398 number: number | null;
399 commit: string;
400 source: RepoPath;
401 reader: User;
402 createdBy: string;
403 settings: SettingsRow;
Secrets and variables: one list, rows per environment, for workflows and deployments404 /** A push, or work by a member or an agent; see `trusted`. */
405 trusted: boolean;
Deployments: a preview for every pull request, production on g1t.page406 }): Promise<Result<Deployment>> {
407 const script = await scriptName(input.repo, input.number);
408 const id = newId("dpl");
409 const token = randomToken();
410 const plan = await billingClient(this.env.BILLING).hasFeature(input.repo.namespace, "deployments");
411 const cloudflare = this.cloudflare;
412 const refused = !plan.ok
413 ? plan.error.message
414 : !cloudflare
415 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
416 : null;
417 await this.db
418 .prepare(
419 `INSERT INTO deployments (id, repo_id, namespace, name, kind, number, commit_sha, script, status, error,
Secrets and variables: one list, rows per environment, for workflows and deployments420 token_hash, trusted, created_by, created_at, finished_at)
421 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Deployments: a preview for every pull request, production on g1t.page422 )
423 .bind(
424 id,
425 input.repoId,
426 input.repo.namespace,
427 input.repo.name,
428 input.kind,
429 input.number,
430 input.commit,
431 script,
432 refused ? "skipped" : "queued",
433 refused,
434 refused ? null : await sha256(token),
Secrets and variables: one list, rows per environment, for workflows and deployments435 input.trusted ? 1 : 0,
Deployments: a preview for every pull request, production on g1t.page436 input.createdBy,
437 now(),
438 refused ? now() : null,
439 )
440 .run();
441 if (refused) return ok(toDeployment((await this.deploymentRow(id))!));
442 // Older builds of the same app are replaced by this one.
443 await this.db
444 .prepare(
445 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
446 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
447 )
448 .bind(now(), script, id)
449 .run();
450 await this.status(input.repoId, input.commit, "pending", "Building", `${this.env.SITE}/${input.repo.namespace}/${input.repo.name}/deployments/${id}`);
Secrets and variables: one list, rows per environment, for workflows and deployments451 // What the repository's secrets and variables give builds of this kind.
452 const build = await this.resolve(input.repoId, input.repo, input.kind, input.trusted);
Deployments: a preview for every pull request, production on g1t.page453 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
454 method: "POST",
455 headers: { "content-type": "application/json" },
456 body: JSON.stringify({
457 deployId: id,
458 token,
459 actor: input.reader,
460 source: input.source,
461 commit: input.commit,
462 buildCommand: input.settings.build_command,
463 outputDir: input.settings.output_dir,
Secrets and variables: one list, rows per environment, for workflows and deployments464 buildEnv: build.variables,
465 buildSecrets: build.secrets,
Deployments: a preview for every pull request, production on g1t.page466 }),
467 });
468 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
469 if (!started.ok) await this.finishFailed(id, started.error.message, null, null);
470 return ok(toDeployment((await this.deploymentRow(id))!));
471 }
472
473 private async deployProduction(
474 repoId: string,
475 repo: RepoPath,
476 branch: string,
477 commit: string | null,
478 createdBy: string,
479 ): Promise<Result<Deployment> | null> {
480 const settings = await this.settingsRow(repoId);
481 if (!settings?.enabled || !settings.production) return null;
482 const actor = await this.workspaceActor(repo.namespace);
483 if (!actor) return null;
484 let head = commit;
485 if (!head) {
486 const branches = await reposClient(this.env.REPOS).branches(repo, actor);
487 head = branches.ok ? (branches.value.find((b) => b.name === branch)?.hash ?? null) : null;
488 }
489 if (!head) return null;
490 return this.start({
491 repoId,
492 repo,
493 kind: "production",
494 number: null,
495 commit: head,
496 source: repo,
497 reader: actor,
498 createdBy,
499 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments500 // The default branch only moves by people and agents with access.
501 trusted: true,
Deployments: a preview for every pull request, production on g1t.page502 });
503 }
504
505 private async deployPreview(
506 repoId: string,
507 repo: RepoPath,
508 number: number,
509 createdBy: string,
510 force = false,
511 ): Promise<Result<Deployment> | null> {
512 const settings = await this.settingsRow(repoId);
513 if (!settings?.enabled || !settings.previews) return null;
514 const actor = await this.workspaceActor(repo.namespace);
515 if (!actor) return null;
516 const detail = await workClient(this.env.WORK).getPull(repo, number, actor);
517 if (!detail.ok) return null;
518 const { pull } = detail.value;
519 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
520 if (!force) {
521 // Already built, or being built, at this commit.
522 const same = await this.db
523 .prepare(
524 `SELECT id FROM deployments WHERE repo_id = ? AND kind = 'preview' AND number = ? AND commit_sha = ?
525 AND status IN ('queued', 'building', 'ready')`,
526 )
527 .bind(repoId, number, pull.headCommit)
528 .first();
529 if (same) return null;
530 }
531 return this.start({
532 repoId,
533 repo,
534 kind: "preview",
535 number,
536 commit: pull.headCommit,
537 source: pull.fork ?? repo,
538 // The pull request's fork may be private: read it as its author.
539 reader: pull.author,
540 createdBy,
541 settings,
Secrets and variables: one list, rows per environment, for workflows and deployments542 trusted: await this.insider(repo, pull.author, actor),
Deployments: a preview for every pull request, production on g1t.page543 });
544 }
545
546 // ---- A build's reports ---------------------------------------------
547
548 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
549 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
550 }
551
552 /** The build, if `token` is its own and it is still under way. */
553 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
554 const row = await this.deploymentRow(id);
555 if (!row?.token_hash || typeof token !== "string") return null;
556 if (row.token_hash !== (await sha256(token))) return null;
557 return row.status === "queued" || row.status === "building" ? row : null;
558 }
559
560 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
561 const row = await this.building(id, body.token);
562 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
563 const cloudflare = this.cloudflare;
564 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
565 switch (step) {
566 case "started":
567 await this.db
568 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
569 .bind(now(), id)
570 .run();
571 return Response.json(ok(true));
572 case "session": {
573 const manifest = body.manifest as Manifest | undefined;
574 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
575 const session = await cloudflare.openUpload(row.script, manifest);
576 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
577 }
578 case "finish": {
579 const worker = (body.worker ?? {}) as BuiltWorker;
580 const seconds = Number(body.buildSeconds) || 0;
581 try {
Secrets and variables: one list, rows per environment, for workflows and deployments582 // Running apps' secrets and variables are bound here, by g1t:
583 // they never pass through the build's sandbox.
584 const runtime = await this.resolve(row.repo_id, { namespace: row.namespace, name: row.name }, row.kind, !!row.trusted);
Deployments: a preview for every pull request, production on g1t.page585 await cloudflare.putScript(
586 row.script,
587 worker,
588 typeof body.completionJwt === "string" ? body.completionJwt : null,
589 [`workspace:${row.namespace}`, `repo:${row.namespace}/${row.name}`, row.kind],
Secrets and variables: one list, rows per environment, for workflows and deployments590 runtime,
Deployments: a preview for every pull request, production on g1t.page591 );
592 } catch (error) {
593 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
594 return Response.json(ok(false));
595 }
596 const at = now();
597 await this.db.batch([
598 this.db
599 .prepare(
600 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?
601 WHERE id = ?`,
602 )
603 .bind(JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []), String(body.log ?? ""), seconds, at, id),
604 this.db
605 .prepare(
606 `INSERT INTO apps (script, repo_id, namespace, name, kind, number, commit_sha, deployed_at, created_at)
607 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)
608 ON CONFLICT (script) DO UPDATE SET commit_sha = ?7, deployed_at = ?8`,
609 )
610 .bind(row.script, row.repo_id, row.namespace, row.name, row.kind, row.number, row.commit_sha, at),
611 ]);
612 await this.chargeBuild(row, seconds);
613 await this.notePeak(row.namespace);
614 await this.status(
615 row.repo_id,
616 row.commit_sha,
617 "success",
618 row.kind === "preview" ? "Preview is live" : "Production is live",
619 appUrl(row.script),
620 );
621 return Response.json(ok(true));
622 }
623 case "fail":
624 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
625 return Response.json(ok(true));
626 default:
627 return Response.json(fail("not_found", "No such step."), { status: 404 });
628 }
629 }
630
631 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
632 const row = await this.deploymentRow(id);
633 if (!row || (row.status !== "queued" && row.status !== "building")) return;
634 await this.db
635 .prepare(
636 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
637 WHERE id = ?`,
638 )
639 .bind(message.slice(0, 2000), log, seconds, now(), id)
640 .run();
641 // A failed build still used its sandbox.
642 if (seconds) await this.chargeBuild(row, seconds);
643 await this.status(
644 row.repo_id,
645 row.commit_sha,
646 "failure",
647 "Deployment failed",
648 `${this.env.SITE}/${row.namespace}/${row.name}/deployments/${id}`,
649 );
650 }
651
652 /** Each build is charged by the second at the container price plus the margin. */
653 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
654 const cost = Math.ceil(seconds) * DEPLOYMENTS_ALLOWANCE.microsPerBuildSecond;
655 if (cost <= 0) return;
656 const what = row.kind === "preview" ? `the preview of ${row.namespace}/${row.name}#${row.number}` : `${row.namespace}/${row.name} to production`;
657 await billingClient(this.env.BILLING).chargeFeature({
658 workspace: row.namespace,
659 feature: "deployments",
660 costMicros: cost,
661 description: `Building ${what} (${Math.ceil(seconds)} s)`,
662 repo: `${row.namespace}/${row.name}`,
663 reference: `deploy/${row.id}`,
664 });
665 await this.db
666 .prepare(
667 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
668 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
669 )
670 .bind(row.namespace, month(), Math.ceil(seconds), cost)
671 .run();
672 }
673
674 /** Remembers the most apps the workspace had up at once this month. */
675 private async notePeak(namespace: string): Promise<void> {
676 await this.db
677 .prepare(
678 `INSERT INTO meters (namespace, month, peak_apps)
679 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE namespace = ?1))
680 ON CONFLICT (namespace, month) DO UPDATE SET
681 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE namespace = ?1))`,
682 )
683 .bind(namespace, month())
684 .run();
685 }
686
687 private async status(repoId: string, sha: string, state: string, description: string, targetUrl: string): Promise<void> {
688 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
689 method: "POST",
690 headers: { "content-type": "application/json" },
691 body: JSON.stringify({ repoId, sha, context: STATUS_CONTEXT, state, description, targetUrl }),
692 }).catch(() => undefined);
693 }
694
695 // ---- Taking apps down ----------------------------------------------
696
697 private async removeApp(script: string): Promise<void> {
698 await this.cloudflare?.deleteScript(script);
699 await this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script).run();
700 }
701
702 private async takeDownWhere(repoId: string, kind: DeployKind | null, number?: number): Promise<void> {
703 const apps = await this.db
704 .prepare(
705 `SELECT script FROM apps WHERE repo_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR number = ?3)`,
706 )
707 .bind(repoId, kind, number ?? null)
708 .all<{ script: string }>();
709 for (const app of apps.results) await this.removeApp(app.script);
710 }
711
712 // ---- Events --------------------------------------------------------
713
714 async onEvent(event: G1tEvent): Promise<void> {
715 switch (event.type) {
716 case "pull.opened":
717 case "pull.ready":
718 case "pull.updated": {
719 const repo = await this.pathById(event.data.repoId);
720 if (repo) await this.deployPreview(event.data.repoId, repo, event.data.number, "g1t");
721 break;
722 }
723 case "pull.closed":
724 case "pull.merged":
725 await this.takeDownWhere(event.data.repoId, "preview", event.data.number);
726 break;
727 case "git.push": {
728 if (!event.data.defaultBranch) break;
729 const repo = await this.pathById(event.data.repoId);
730 if (!repo) break;
731 await this.deployProduction(
732 event.data.repoId,
733 repo,
734 event.data.ref.replace(/^refs\/heads\//, ""),
735 event.data.after,
736 event.actor ?? "g1t",
737 );
738 break;
739 }
740 }
741 }
742
743 // ---- The sweep -----------------------------------------------------
744
745 /**
746 * Every few minutes: builds that died are failed; usage is counted; idle
747 * previews and the apps of workspaces whose plan ended come down; and a
748 * month that is over is charged past its allowance.
749 */
750 async sweep(): Promise<void> {
751 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
752 const stuck = await this.db
753 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
754 .bind(cutoff)
755 .all<{ id: string }>();
756 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
757
758 const apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
759 const workspaces = [...new Set(apps.map((app) => app.namespace))];
760
761 // Apps of workspaces whose plan has ended come down.
762 const billing = billingClient(this.env.BILLING);
763 for (const workspace of workspaces) {
764 const plan = await billing.hasFeature(workspace, "deployments");
765 if (!plan.ok && plan.error.code === "payment_required") {
766 for (const app of apps.filter((a) => a.namespace === workspace)) await this.removeApp(app.script);
767 }
768 }
769
770 await this.count(apps).catch((error) => console.error("could not count usage", error));
771 await this.takeDownIdle();
772 await this.chargeMonths();
773 }
774
775 /** Counts this month's requests and CPU time per workspace, from analytics. */
776 private async count(apps: AppRow[]): Promise<void> {
777 const cloudflare = this.cloudflare;
778 if (!cloudflare || apps.length === 0) return;
779 const start = `${month()}-01T00:00:00Z`;
780 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
781 // Analytics only counts apps that are up; the meter keeps what earlier
782 // apps used by never going down.
783 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
784 for (const app of apps) {
785 const used = totals.get(app.script);
786 if (!used) continue;
787 const sum = perWorkspace.get(app.namespace) ?? { requests: 0, cpuMs: 0 };
788 sum.requests += used.requests;
789 sum.cpuMs += used.cpuMs;
790 perWorkspace.set(app.namespace, sum);
791 }
792 const at = now();
793 for (const [namespace, used] of perWorkspace) {
794 await this.db
795 .prepare(
796 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
797 ON CONFLICT (namespace, month) DO UPDATE SET
798 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
799 )
800 .bind(namespace, month(), used.requests, used.cpuMs, at)
801 .run();
802 }
803 // When each preview last answered anyone, for the idle sweep.
804 const recent = await cloudflare.usage(
805 apps.filter((app) => app.kind === "preview").map((app) => app.script),
806 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
807 at,
808 );
809 for (const [script, used] of recent) {
810 if (used.requests > 0) {
811 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
812 }
813 }
814 for (const namespace of new Set(apps.map((app) => app.namespace))) await this.notePeak(namespace);
815 }
816
817 /** Previews no one has visited in their repository's idle days. */
818 private async takeDownIdle(): Promise<void> {
819 const idle = await this.db
820 .prepare(
821 `SELECT apps.script FROM apps JOIN settings ON settings.repo_id = apps.repo_id
822 WHERE apps.kind = 'preview'
823 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
824 )
825 .all<{ script: string }>();
826 for (const { script } of idle.results) await this.removeApp(script);
827 }
828
829 /** Charges each month that is over for what it used past the allowance, once. */
830 private async chargeMonths(): Promise<void> {
831 const due = await this.db
832 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
833 .bind(month())
834 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
835 const a = DEPLOYMENTS_ALLOWANCE;
836 for (const meter of due.results) {
837 const extraRequests = Math.max(0, meter.requests - a.requests);
838 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
839 const extraApps = Math.max(0, meter.peak_apps - a.apps);
840 const cost = Math.ceil(
841 (extraRequests / 1_000_000) * a.microsPerMillionRequests +
842 (extraCpu / 1_000_000) * a.microsPerMillionCpuMs +
843 extraApps * a.microsPerAppMonth,
844 );
845 if (cost > 0) {
846 const parts = [
847 extraApps && `${extraApps} extra apps`,
848 extraRequests && `${extraRequests.toLocaleString("en-US")} extra requests`,
849 extraCpu && `${extraCpu.toLocaleString("en-US")} extra CPU ms`,
850 ].filter(Boolean);
851 const charged = await billingClient(this.env.BILLING).chargeFeature({
852 workspace: meter.namespace,
853 feature: "deployments",
854 costMicros: cost,
855 description: `Deployments in ${meter.month} past the plan: ${parts.join(", ")}`,
856 reference: `deployments/${meter.namespace}/${meter.month}`,
857 });
858 if (!charged.ok) continue;
859 }
860 await this.db
861 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
862 .bind(now(), meter.namespace, meter.month)
863 .run();
864 }
865 }
866}
867
868/** `POST /rpc/<method>`: the arguments are the body. */
869async function rpc(service: Deployments, method: string, args: any): Promise<unknown> {
870 switch (method) {
871 case "settings":
872 return service.settings(args);
873 case "update_settings":
874 return service.updateSettings(args);
875 case "list":
876 return service.list(args);
877 case "get":
878 return service.get(args);
879 case "redeploy":
880 return service.redeploy(args);
881 case "take_down":
882 return service.takeDown(args);
883 case "usage":
884 return service.usage(args);
885 default:
886 return undefined;
887 }
888}
889
890export default {
891 async fetch(request: Request, env: Env): Promise<Response> {
892 const { pathname } = new URL(request.url);
893 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
894 const service = new Deployments(env);
895 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
896 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
897 if (rpcMatch) {
898 const result = await rpc(service, rpcMatch[1], body);
899 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
900 }
901 // A build's reports, forwarded by the API.
902 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
903 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
904 return new Response("Not found\n", { status: 404 });
905 },
906
907 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
908 const service = new Deployments(env);
909 for (const message of batch.messages) {
910 try {
911 await service.onEvent(message.body);
912 message.ack();
913 } catch (error) {
914 console.error("deployments could not handle", message.body.type, error);
915 message.retry();
916 }
917 }
918 },
919
920 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
921 await new Deployments(env).sweep();
922 },
923} satisfies ExportedHandler<Env, G1tEvent>;