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
1,410 linesCodeBlameRaw
1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
5 type BumpArgs,
6 UPDATE_BRANCH_PREFIX,
7 type RunKind,
8 agentsClient,
9 type G1tEvent,
10 type QueueJob,
11 type RepoPath,
12 type Result,
13 type ServiceBinding,
14 type User,
15 type Viewer,
16 type ComputeEntitlements,
17 type ComputeKind,
18 ComputeGate,
19 actualMicros,
20 eventsClient,
21 refusalMessage,
22 sandboxEstimateMicros,
23 billingClient,
24 fail,
25 ok,
26 reposClient,
27 workClient,
28 type InstanceType,
29 STANDARD_INSTANCE,
30 instanceNamed,
31} from "@g1t/contracts";
32
33import { BUMP_MINUTES, BUMP_TOKEN_TTL_SECONDS, bumpEnv, bumpProblem, bumpSandboxName, systemActor, registryHosts } from "./bump";
34import { BACKUP_MINUTES, backupEnv, backupPace, backupSandboxName } from "./backup";
35import { holdCredentials, revokeCredentials, runCredential } from "./credentials";
36import { cancelTask, enqueueTask, handedOverStep, selfHostedRoute, taskEnv, taskRepo } from "./self-hosted";
37import { answered, capReached, describeError, stoppedShort, tellStopped, withinTimeCap } from "./lifecycle";
38import {
39 ABUSE_EXIT_CODE,
40 ABUSE_HOST,
41 ABUSE_MESSAGE,
42 ALARM_GRACE_SECONDS,
43 type PlanLimits,
44 type RunGuard,
45 abuse,
46 buildGuardFor,
47 dockerFor,
48 egress,
49 egressHosts,
50 guardFor,
51 harnessEnv,
52 newlyBlocked,
53 reportRun,
54 SANDBOX_BINDINGS,
55 sandboxNamespace,
56 type WorkflowJob,
57 timeCapMessage,
58 withPlanLimits,
59} from "./guard";
60import { newlyMisdirected } from "./broker";
61import { EGRESS_ENV } from "./egress";
62
63// Outbound interception, which network guardrails use, needs this exported.
64export { ContainerProxy } from "@cloudflare/containers";
65
66export interface RunnerEnv {
67 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
68 /**
69 * Larger machines for workflow jobs that ask for one with `runs-on`
70 * (`g1t-2core`, `g1t-4core`): the same image on a larger instance type.
71 */
72 SANDBOX_2CORE?: DurableObjectNamespace<Sandbox2Core>;
73 SANDBOX_4CORE?: DurableObjectNamespace<Sandbox4Core>;
74 IDENTITY: ServiceBinding;
75 REPOS: ServiceBinding;
76 WORK: ServiceBinding;
77 BILLING: ServiceBinding;
78 /** GitHub Actions jobs: told when a job's sandbox dies without reporting. */
79 ACTIONS: ServiceBinding;
80 /** Told when a deploy sandbox dies without reporting. */
81 DEPLOYMENTS: ServiceBinding;
82 /** The event bus: `abuse.flagged`, for g1t's staff. */
83 EVENTS?: ServiceBinding;
84 CLOUDFLARE_ACCOUNT_ID: string;
85 /**
86 * `off` starts every sandbox with an open network whatever its
87 * guardrails say: a switch for the operator, should egress through the
88 * Worker misbehave. Anything else enforces them.
89 */
90 EGRESS?: string;
91 /**
92 * `off` stops sandboxes watching themselves for mining (crates/runner
93 * abuse.rs): a switch for the operator, should it stop real work.
94 * Anything else leaves it on. Miners named in commands are refused
95 * either way.
96 */
97 ABUSE_WATCH?: string;
98 /**
99 * `off` leaves workflow jobs without a Docker Engine of their own
100 * (crates/runner docker/): a switch for the operator. Anything else
101 * gives each job one, started the first time it is used.
102 */
103 DOCKER?: string;
104 /**
105 * Nightly backups (backup.ts): how many queued backups one sweep starts
106 * (`0`: none, backups off here), and how many may run at once.
107 */
108 BACKUPS_PER_SWEEP?: string;
109 BACKUPS_RUNNING?: string;
110}
111
112/**
113 * What a sandbox is doing. Runs of an issue's commands (`checks`) are from
114 * before checks were workflows; a sandbox that was started for work g1t no
115 * longer does is told apart by its kind and only cleaned up.
116 */
117type Run =
118 | { kind: "checks"; runId: string; token: string }
119 /** One combined state of a merge queue, being built and checked. */
120 | { kind: "queue"; entryId: string; token: string }
121 /** Whether a pull request merges cleanly: two commits merged, nothing pushed. */
122 | { kind: "mergecheck"; pullId: string; token: string }
123 /** One job of a GitHub Actions workflow. */
124 | { kind: "actions"; jobId: string; token: string }
125 /** A build of one commit, deployed to g1t.page. */
126 | { kind: "deploy"; deployId: string; token: string }
127 /**
128 * A security update: one package raised in its lockfiles and pushed to
129 * its branch. The security service opens the pull request when it hears
130 * the push, so a failure has no one to tell.
131 */
132 | { kind: "bump"; repo: RepoPath; branch: string }
133 /**
134 * A repository's nightly backup: a bundle cut and sent to the repos
135 * service. g1t's own work, never charged to the workspace.
136 */
137 | { kind: "backup"; jobId: string; token: string };
138/**
139 * Whose sandbox time it is, reported when the sandbox stops, and the
140 * machine it ran on when it was not the standard one.
141 */
142type Meter = {
143 workspace: string;
144 repo: string;
145 description: string;
146 instance?: string | null;
147};
148/**
149 * What billing reserved for a sandbox's work (`ComputeGate.admit`), settled
150 * when it stops at what it cost: its seconds.
151 */
152type Held = { id: string; workspace: string; microsPerSecond: number };
153/**
154 * A sandbox that is not tracked as a run but still runs under guardrails:
155 * a workflow job, a deploy build or an update, in `repo`, for `minutes` at
156 * most.
157 */
158type Build = {
159 kind: "actions" | "deploy" | "bump";
160 /** The project whose guardrails apply: never a pull request's working copy. */
161 repo: RepoPath;
162 /** Its id, so it is found even if it moved since. */
163 repoId?: string | null;
164 minutes: number;
165 /** A workflow job's workflow, environment and trust, for workflow-only domains. */
166 job?: WorkflowJob | null;
167 /** More hosts it may reach: an update's private registries. */
168 hosts?: string[];
169 /**
170 * A workflow job that may be given brokered secrets: its HTTP and HTTPS
171 * pass the Worker, which fills them in, even when its network is open.
172 */
173 brokered?: boolean;
174};
175/** Deploy builds are metered by the Deployments plan, not here. */
176type RunRequest = Run & {
177 envVars: Record<string, string>;
178 meter?: Meter;
179 track?: Track;
180 /** The workspace's plan's caps, applied under its guardrails' (lower of each). */
181 limits?: PlanLimits;
182 reservation?: Held | null;
183 build?: Build;
184 /** Whose sandbox it is, when it has no meter: for `abuse.flagged`. */
185 owner?: { workspace: string; repo: string };
186 /**
187 * The labels of the workspace's self-hosted runners this work goes to
188 * instead of a container (self-hosted.ts). Null or absent: a container.
189 */
190 selfHosted?: string[] | null;
191};
192
193/** What a sandbox is, as billing meters it. */
194function computeKindOf(kind: Run["kind"]): ComputeKind | null {
195 switch (kind) {
196 case "checks":
197 case "mergecheck":
198 // A security update resolves lockfiles, as cheap as a check.
199 case "bump":
200 return "check";
201 case "queue":
202 return "queue";
203 case "actions":
204 return "workflow";
205 case "deploy":
206 return "deploy";
207 // Not metered: a backup is g1t's own cost; nor is a sandbox of a kind
208 // g1t no longer starts, which ran before it stopped starting them.
209 default:
210 return null;
211 }
212}
213
214/** One gate per isolate, so entitlements and prices are kept between calls. */
215let gate: ComputeGate | null = null;
216function gateFor(env: { BILLING: ServiceBinding }): ComputeGate {
217 gate ??= new ComputeGate(env.BILLING);
218 return gate;
219}
220
221/**
222 * What to record the sandbox as, so people can watch it: a run of the
223 * merge queue.
224 */
225type Track = {
226 actor: User;
227 repo: RepoPath;
228 kind: RunKind;
229 number?: number | null;
230 pullId?: string | null;
231 title?: string | null;
232 startedBy?: string | null;
233};
234/** The run a sandbox reports to, kept so it can be closed when it stops. */
235type TrackedRun = { runId: string; token: string };
236
237function meter(repo: RepoPath, description: string): Meter {
238 return { workspace: repo.namespace, repo: `${repo.namespace}/${repo.name}`, description };
239}
240
241/** What the deployments service asks a sandbox to build. */
242type DeployJob = {
243 deployId: string;
244 /** The workspace the project is in, which pays. */
245 workspace?: string;
246 /** What the deployments service reserved for the build, settled when it stops. */
247 reservation?: string | null;
248 /** The price it reserved at, per second. */
249 microsPerSecond?: number | null;
250 /** The plan's longest run, in minutes; the build gets the lower of this and its own. */
251 maxRunMinutes?: number | null;
252 /** Lets the sandbox, and nothing else, report this build. */
253 token: string;
254 /** Whose access reads the commit. */
255 actor: User;
256 /** The repository the commit is in: the pull request's fork, or the repository. */
257 source: RepoPath;
258 /**
259 * The project's repository, whose guardrails the build runs under, and
260 * its id. A preview's `source` is its pull request's working copy, so
261 * the two differ. Older callers send only `source`.
262 */
263 repo?: RepoPath | null;
264 repoId?: string | null;
265 commit: string;
266 /** Where in the repository the project lives; empty for all of it. */
267 rootDir?: string;
268 buildCommand?: string | null;
269 outputDir?: string | null;
270 /** What it is built as (`auto`, `static`, `workers` or `docker`), and its Dockerfile's path when set. */
271 buildAs?: string | null;
272 dockerfile?: string | null;
273 /** The repository's variables for deploy builds. */
274 buildEnv?: Record<string, string>;
275 /** Its secrets for deploy builds: set like variables, and redacted from the log. */
276 buildSecrets?: Record<string, string>;
277};
278
279/** Long enough to install and build; then the read token stops working. */
280const DEPLOY_TOKEN_TTL_SECONDS = 30 * 60;
281
282/** Long enough to clone, build and test a merge queue's state; then the token stops working. */
283const CHECKS_TOKEN_TTL_SECONDS = 45 * 60;
284
285/** Long enough to clone and merge two commits; then the read token stops working. */
286const MERGECHECK_TOKEN_TTL_SECONDS = 10 * 60;
287
288/**
289 * One sandbox, for one piece of work: a workflow job, a build, a state of
290 * the merge queue, a merge check, an update or a backup. The image's entrypoint
291 * is the g1t runner, which does the work and exits; this class only starts
292 * it and cleans up if it dies without reporting.
293 */
294export class AttemptSandbox extends Container<RunnerEnv> {
295 // Past the longest default time cap (90 minutes) and its alarm. A run whose guardrails allow longer (up to 240 minutes) is kept
296 // past it by `onActivityExpired`, so only its own cap ends it. A finished
297 // run's process exits and stops the sandbox well before this.
298 sleepAfter = "100m";
299 // A guarded sandbox's HTTPS goes through `egress` too (guard.ts).
300 interceptHttps = true;
301 static {
302 // Assigned, not declared: a class field would hide the setter that
303 // registers the handler with the containers library.
304 AttemptSandbox.outboundHandlers = { egress, abuse };
305 }
306
307 async run(request: RunRequest): Promise<void> {
308 const { envVars, meter, track, limits, reservation, build, owner, selfHosted, ...run } = request;
309 // What billing reserved is settled however this ends, once.
310 if (reservation) await this.ctx.storage.put("reservation", reservation);
311 let guard: RunGuard | null;
312 try {
313 // A tracked run gets its project's guardrails, and so do workflow
314 // jobs and deploy builds; no sandbox for one starts without them.
315 // The plan's caps apply under them: the lower of each.
316 guard = track
317 ? withPlanLimits(await guardFor(this.env.WORK, track.repo, track.kind), limits)
318 : build
319 ? withPlanLimits(await buildGuardFor(this.env.WORK, build.repo, build.kind, build.minutes, build.repoId, build.job, build.hosts), limits)
320 : null;
321 } catch (error) {
322 // Thrown to the caller, which says why the work did not start; logged
323 // here too, so a sandbox that never started is traceable on its own.
324 console.error("sandbox not started", run.kind, describeError(error));
325 await this.settle(0);
326 throw error;
327 }
328 await this.ctx.storage.put("run", run);
329 // What an earlier attempt in this object left: its reason, its cap
330 // and its cap's alarm, which would otherwise stop this attempt when
331 // the earlier one's time ran out (re-runs use the same object).
332 await this.ctx.storage.delete(["abuse", "stopReason", "remote", "misdirected", "timeCap"]);
333 this.deleteSchedules("timeUp");
334 if (meter) await this.ctx.storage.put("meter", { ...meter, started: Date.now() });
335 await this.ctx.storage.put("started", Date.now());
336 const who = meter ? { workspace: meter.workspace, repo: meter.repo } : owner;
337 if (who) await this.ctx.storage.put("owner", { ...who, kind: track?.kind ?? run.kind });
338 const tracked = track ? await this.openRun(track, envVars, guard) : null;
339 // Its credentials are tied to the run, and revoked when it stops.
340 await holdCredentials(this.env.IDENTITY, this.ctx.storage, envVars, tracked?.runId ?? null);
341 try {
342 const vars = tracked ? { ...envVars, AGENT_RUN: tracked.runId, AGENT_RUN_TOKEN: tracked.token } : envVars;
343 // The workspace's own runner, not a container: the same environment,
344 // handed over as a task. Network guardrails cannot be enforced there.
345 const repo = selfHosted?.length ? taskRepo(track, meter, owner) : null;
346 if (selfHosted?.length && repo) {
347 const harness = guard ? harnessEnv(guard, vars, false) : {};
348 const minutes = guard?.minutes ?? limits?.minutes ?? 60;
349 await enqueueTask(this.env.ACTIONS, {
350 sandbox: this.ctx.id.toString(),
351 repo,
352 kind: track?.kind ?? run.kind,
353 title: track?.title ?? meter?.description ?? `${run.kind} in ${repo.namespace}/${repo.name}`,
354 labels: selfHosted,
355 env: taskEnv({ ...vars, ...harness }),
356 timeoutMinutes: minutes,
357 runId: tracked?.runId ?? null,
358 });
359 await this.ctx.storage.put("remote", true);
360 if (tracked) await reportRun(this.env.WORK, tracked, { steps: [handedOverStep(selfHosted)] });
361 if (guard) {
362 await this.ctx.storage.put("timeCap", guard.minutes);
363 await this.schedule(guard.minutes * 60 + ALARM_GRACE_SECONDS, "timeUp");
364 }
365 return;
366 }
367 const restricted = (guard?.policy.restrictNetwork ?? false) && this.env.EGRESS !== "off";
368 // A workflow job's brokered secrets are filled in by `egress` from
369 // the job's stand-ins, which it asks for with the job's token.
370 const job = run.kind === "actions" ? { id: run.jobId, token: run.token } : undefined;
371 // A job whose network is open but which may be given brokered
372 // secrets still sends its HTTP and HTTPS through `egress`, which
373 // reaches every host for it; other protocols go straight out, as
374 // they would.
375 const brokering = !restricted && job !== undefined && build?.brokered === true && this.env.EGRESS !== "off";
376 if (guard && restricted) {
377 this.enableInternet = false;
378 await this.setOutboundHandler("egress", { hosts: egressHosts(guard, {}, vars), job });
379 } else if (brokering) {
380 await this.setOutboundHandler("egress", { hosts: [], open: true, job });
381 } else if (this.env.EGRESS !== "off") {
382 // An open sandbox can still report that it stopped itself for
383 // mining; a guarded one does through `egress`.
384 await this.setOutboundByHost(ABUSE_HOST, "abuse").catch((error: unknown) =>
385 console.log("abuse reports not routed", String(error)),
386 );
387 }
388 const harness = guard ? harnessEnv(guard, vars, restricted) : {};
389 // A build needs only the certificate variables, not the rules.
390 if (build) delete harness.GUARDRAILS;
391 // Its HTTPS is re-signed as it passes the Worker, so it trusts that
392 // certificate as a guarded sandbox does.
393 const trust = brokering ? EGRESS_ENV : {};
394 const watch: Record<string, string> = this.env.ABUSE_WATCH === "off" ? { G1T_ABUSE: "off" } : {};
395 await this.start({ envVars: { ...vars, ...harness, ...trust, ...watch }, enableInternet: !restricted });
396 // A backup has no guardrails, but still a time cap.
397 const cap = guard?.minutes ?? (run.kind === "backup" ? BACKUP_MINUTES : null);
398 if (cap) {
399 await this.ctx.storage.put("timeCap", cap);
400 await this.schedule(cap * 60 + ALARM_GRACE_SECONDS, "timeUp");
401 }
402 } catch (error) {
403 console.error("sandbox not started", run.kind, describeError(error));
404 await revokeCredentials(this.env.IDENTITY, this.ctx.storage);
405 if (tracked) await this.closeRun("failed", `The sandbox could not start: ${String(error)}`);
406 await this.settle(0);
407 throw error;
408 }
409 }
410
411 /** Settles what billing reserved for this sandbox at `micros`, once. */
412 private async settle(micros: number): Promise<void> {
413 const held = await this.ctx.storage.get<Held>("reservation");
414 if (!held) return;
415 await this.ctx.storage.delete("reservation");
416 await gateFor(this.env).settle(held.id, micros);
417 }
418
419 /** Settles the reservation at what the sandbox cost: its seconds at the price billing reserved at. */
420 private async settleStopped(started: number | undefined): Promise<void> {
421 const held = await this.ctx.storage.get<Held>("reservation");
422 if (!held) return;
423 const seconds = started ? Math.max(1, Math.ceil((Date.now() - started) / 1000)) : 0;
424 await this.settle(actualMicros(seconds, held.microsPerSecond));
425 }
426
427 /**
428 * The sandbox stopped itself because it looked like it was mining
429 * (crates/runner abuse.rs), or exited saying so. Stops the run with
430 * `ABUSE_MESSAGE`, tells g1t's staff with `abuse.flagged`, and destroys
431 * the sandbox. Once.
432 */
433 async flagAbuse(verdict: unknown): Promise<void> {
434 if (await this.ctx.storage.get<boolean>("abuse")) return;
435 await this.ctx.storage.put("abuse", true);
436 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
437 if (tracked) await reportRun(this.env.WORK, tracked, { halt: "abuse", error: ABUSE_MESSAGE });
438 const owner = await this.ctx.storage.get<{ workspace: string; repo: string | null; kind: string }>("owner");
439 console.log("abuse flagged", owner?.workspace, owner?.repo, owner?.kind, JSON.stringify(verdict));
440 if (this.env.EVENTS && owner) {
441 await eventsClient(this.env.EVENTS)
442 .publish([
443 {
444 type: "abuse.flagged",
445 source: "runner",
446 // Never on a repository's timeline or its webhooks.
447 repoId: null,
448 actor: null,
449 data: {
450 workspace: owner.workspace,
451 repo: owner.repo ?? null,
452 run: tracked?.runId ?? null,
453 kind: owner.kind,
454 sandbox: this.ctx.id.toString(),
455 metrics: verdict && typeof verdict === "object" ? (verdict as Record<string, unknown>) : null,
456 },
457 },
458 ])
459 .catch((error: unknown) => console.log("abuse.flagged not published", String(error)));
460 }
461 await this.destroy().catch((error: unknown) => console.log("sandbox not destroyed for abuse", String(error)));
462 }
463
464 /**
465 * Records the run, which the sandbox then reports its steps to. Never
466 * stops the sandbox from starting: without a record it just goes unseen.
467 */
468 private async openRun(track: Track, envVars: Record<string, string>, guard: RunGuard | null): Promise<TrackedRun | null> {
469 const opened = await agentsClient(this.env.WORK)
470 .openRun({
471 ...track,
472 model: null,
473 sandbox: this.ctx.id.toString(),
474 budgetUsd: guard?.policy.budgetUsd ?? null,
475 timeCapMinutes: guard?.minutes ?? null,
476 })
477 .catch((error: unknown) => ({ ok: false as const, error: { message: String(error) } }));
478 if (!opened.ok) {
479 console.log("run not recorded", track.kind, opened.error.message);
480 return null;
481 }
482 await this.ctx.storage.put("agentRun", opened.value);
483 return opened.value;
484 }
485
486 /** A host this sandbox was refused, said once as a step of its run. */
487 async noteBlocked(host: string): Promise<void> {
488 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
489 if (!tracked) return;
490 const noted = newlyBlocked((await this.ctx.storage.get<string[]>("blocked")) ?? [], host);
491 if (!noted) return;
492 await this.ctx.storage.put("blocked", noted.seen);
493 await reportRun(this.env.WORK, tracked, { steps: [noted.step] });
494 }
495
496 /**
497 * A workflow job sent a brokered secret's stand-in to a host it is not
498 * for: said once per secret and host, in the job's log.
499 */
500 async noteMisdirected(name: string, host: string): Promise<void> {
501 const run = await this.ctx.storage.get<Run>("run");
502 if (run?.kind !== "actions") return;
503 const noted = newlyMisdirected((await this.ctx.storage.get<string[]>("misdirected")) ?? [], name, host);
504 if (!noted) return;
505 await this.ctx.storage.put("misdirected", noted.seen);
506 try {
507 const response = await this.env.ACTIONS.fetch("https://actions/rpc/job_report", {
508 method: "POST",
509 headers: { "content-type": "application/json" },
510 body: JSON.stringify({ job: run.jobId, token: run.token, report: { kind: "note", text: noted.note } }),
511 });
512 await answered("job_report", response);
513 } catch (error) {
514 console.log("misdirected secret not noted", name, String(error));
515 }
516 }
517
518 /** The run's time cap has passed: stop it, as stopped for time. */
519 async timeUp(): Promise<void> {
520 // It already stopped (nothing to stop), or the run in it now is a later
521 // attempt whose cap has not passed: this alarm is an earlier attempt's,
522 // left by a version of g1t that did not clear it, and the run now has
523 // its own.
524 const started = await this.ctx.storage.get<number>("started");
525 const minutes = (await this.ctx.storage.get<number>("timeCap")) ?? 0;
526 if (!capReached(started, minutes, Date.now())) {
527 if (started) console.log("an earlier attempt's time cap passed; the run now keeps going", this.ctx.id.toString());
528 return;
529 }
530 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
531 // A workflow job or a build has no run to halt: it fails saying why.
532 await this.ctx.storage.put("stopReason", timeCapMessage(minutes));
533 if (tracked) await reportRun(this.env.WORK, tracked, { halt: "time", error: timeCapMessage(minutes) });
534 if (await this.ctx.storage.get<boolean>("remote")) {
535 await cancelTask(this.env.ACTIONS, this.ctx.id.toString(), timeCapMessage(minutes));
536 await this.remoteEnded(1, null);
537 return;
538 }
539 await this.destroy().catch((error: unknown) => console.log("sandbox not destroyed at its time cap", String(error)));
540 }
541
542 /**
543 * Stops this sandbox's work: its container, or the task a self-hosted
544 * runner holds, which it hears about on its next poll.
545 */
546 async halt(reason: string | null): Promise<void> {
547 if (await this.ctx.storage.get<boolean>("remote")) {
548 await cancelTask(this.env.ACTIONS, this.ctx.id.toString(), reason);
549 await this.remoteEnded(1, reason);
550 return;
551 }
552 // A container already gone has nothing to stop; one that will not stop
553 // is logged, and its time cap still ends it.
554 await this.destroy().catch((error: unknown) => console.error("sandbox not destroyed", reason, describeError(error)));
555 }
556
557 /**
558 * The library's `sleepAfter` has passed. The runner never fetches its
559 * container, so to the library every sandbox looks idle: a run inside its
560 * time cap keeps going, and the cap's own alarm (`timeUp`) ends it. Only
561 * a sandbox with no cap is stopped for inactivity.
562 */
563 override async onActivityExpired(): Promise<void> {
564 const started = await this.ctx.storage.get<number>("started");
565 const cap = await this.ctx.storage.get<number>("timeCap");
566 if (withinTimeCap(started, cap, Date.now(), ALARM_GRACE_SECONDS)) return;
567 await super.onActivityExpired();
568 }
569
570 /**
571 * A container that crashed or could not be reached, as the library tells
572 * it. Logged at error level with the sandbox, never thrown: the library
573 * ignores what this throws, and the stop that follows is handled by
574 * `onStop` or by `run`, which says why the work did not start.
575 */
576 override onError(error: unknown): void {
577 console.error("sandbox container error", this.ctx.id.toString(), describeError(error));
578 }
579
580 /**
581 * The library's alarm: scheduled callbacks, the container's keep-alive,
582 * and `onStop` once it has stopped. A failure is logged with the retry it
583 * was, then thrown so Cloudflare tries the alarm again.
584 */
585 override async alarm(alarmProps?: AlarmInvocationInfo): Promise<void> {
586 try {
587 await super.alarm(alarmProps);
588 } catch (error) {
589 console.error("sandbox alarm failed", this.ctx.id.toString(), `retry ${alarmProps?.retryCount ?? 0}`, describeError(error));
590 throw error;
591 }
592 }
593
594 /**
595 * A self-hosted runner's task ended (the actions service says so, or g1t
596 * stopped it): everything a container's stop does, once.
597 */
598 async remoteEnded(exitCode: number, reason: string | null): Promise<void> {
599 if (!(await this.ctx.storage.get<boolean>("remote"))) return;
600 await this.ctx.storage.delete("remote");
601 await this.ctx.storage.put("selfHostedEnded", true);
602 if (exitCode !== 0 && reason && !(await this.ctx.storage.get<string>("stopReason"))) {
603 await this.ctx.storage.put("stopReason", reason);
604 }
605 await this.onStop({ exitCode, reason: "exit" } as StopParams);
606 await this.ctx.storage.delete("selfHostedEnded");
607 }
608
609 /**
610 * Ends the run's record, once. Returns the status it ended with:
611 * `stopped` when a person stopped it first.
612 */
613 private async closeRun(outcome: "succeeded" | "failed", error?: string): Promise<string | null> {
614 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
615 if (!tracked) return null;
616 await this.ctx.storage.delete("agentRun");
617 const closed = await agentsClient(this.env.WORK)
618 .closeRun(tracked.runId, tracked.token, outcome, error)
619 .catch(() => null);
620 return closed?.ok ? closed.value : null;
621 }
622
623 /** Reports how long the sandbox ran, once, whatever it exited with. */
624 private async meterStop(): Promise<void> {
625 const metered = await this.ctx.storage.get<Meter & { started: number }>("meter");
626 if (!metered) return;
627 await this.ctx.storage.delete("meter");
628 const seconds = Math.max(1, Math.ceil((Date.now() - metered.started) / 1000));
629 const run = await this.ctx.storage.get<Run>("run");
630 // On the workspace's own runner: its minutes, at $0.
631 const selfHosted = (await this.ctx.storage.get<boolean>("selfHostedEnded")) ?? false;
632 const recorded = await billingClient(this.env.BILLING)
633 .recordSandbox({
634 workspace: metered.workspace,
635 seconds,
636 description: selfHosted ? `${metered.description} on a self-hosted runner` : metered.description,
637 repo: metered.repo,
638 reference: `sandbox/${this.ctx.id.toString()}/${metered.started}`,
639 // Whether g1t's open-source pool may pay for it.
640 kind: run ? computeKindOf(run.kind) : null,
641 selfHosted,
642 instance: metered.instance ?? null,
643 })
644 .catch((error: unknown) => ({ ok: false as const, error: { message: String(error) } }));
645 if (!recorded.ok) console.log("sandbox time not recorded", metered.workspace, seconds, recorded.error.message);
646 }
647
648 override async onStop({ exitCode, reason }: StopParams): Promise<void> {
649 await revokeCredentials(this.env.IDENTITY, this.ctx.storage);
650 const started = await this.ctx.storage.get<number>("started");
651 await this.meterStop();
652 // It stopped itself for mining, and could not say so before it went.
653 if (exitCode === ABUSE_EXIT_CODE && !(await this.ctx.storage.get<boolean>("abuse"))) {
654 await this.flagAbuse(null);
655 }
656 const flagged = (await this.ctx.storage.get<boolean>("abuse")) ?? false;
657 // Why it stopped, when g1t stopped it: said in place of a plain failure.
658 const why = flagged ? ABUSE_MESSAGE : ((await this.ctx.storage.get<string>("stopReason")) ?? null);
659 // Stopped by g1t, it failed whatever exit code the library passed on.
660 const short = stoppedShort(exitCode, why);
661 await this.closeRun(
662 short ? "failed" : "succeeded",
663 short ? (why ?? `The sandbox exited with ${exitCode}.`) : undefined,
664 );
665 await this.settleStopped(started);
666 await this.ctx.storage.delete("started");
667 if (!short) return;
668 const run = await this.ctx.storage.get<Run>("run");
669 console.log("sandbox stopped", run?.kind, "exit", exitCode, reason);
670 if (!run) return;
671 // Never thrown: this runs in the sandbox's alarm, which a throw would
672 // fail, retry and count as an error, running all of the above again.
673 // What could not be told is logged, and the sweep catches it up.
674 await tellStopped(run.kind, () => this.reportStopped(run, why, exitCode));
675 }
676
677 /**
678 * Tells whoever is waiting on the sandbox's work that it stopped without
679 * finishing it. Each is refused harmlessly when the sandbox reported its
680 * end before it stopped. Throws when the service could not be reached.
681 */
682 private async reportStopped(run: Run, why: string | null, exitCode: number): Promise<void> {
683 if (run.kind === "actions") {
684 // Refused harmlessly if the job reported its end before it stopped.
685 const response = await this.env.ACTIONS.fetch("https://actions/rpc/job_report", {
686 method: "POST",
687 headers: { "content-type": "application/json" },
688 body: JSON.stringify({
689 job: run.jobId,
690 token: run.token,
691 report: { kind: "done", conclusion: "failure", reason: why ?? "The runner stopped before the job finished." },
692 }),
693 });
694 await answered("job_report", response);
695 return;
696 }
697 // Nothing was pushed, so no pull request opens; why is in its log.
698 if (run.kind === "bump") return;
699 if (run.kind === "backup") {
700 // Refused harmlessly if the sandbox reported before it stopped; the
701 // job is otherwise tried again later tonight.
702 await reposClient(this.env.REPOS).failBackup(run.jobId, run.token, why ?? `The sandbox exited with ${exitCode}.`);
703 return;
704 }
705 if (run.kind === "deploy") {
706 // Refused harmlessly if the build reported its end before it stopped.
707 const response = await this.env.DEPLOYMENTS.fetch(`https://deployments/jobs/${run.deployId}/fail`, {
708 method: "POST",
709 headers: { "content-type": "application/json" },
710 body: JSON.stringify({ token: run.token, message: why ?? "The build stopped before it finished." }),
711 });
712 await answered("deploy fail", response);
713 return;
714 }
715 const work = workClient(this.env.WORK);
716 if (run.kind === "checks") {
717 // Refused harmlessly if the run did report before it stopped.
718 await work.reportChecks(run.runId, run.token, {
719 error: why ?? "The sandbox stopped before the checks finished.",
720 });
721 return;
722 }
723 if (run.kind === "queue") {
724 // Refused harmlessly if the state was reported before it stopped.
725 await work.failQueue(run.entryId, run.token, why ?? "The sandbox stopped before the state was checked.");
726 return;
727 }
728 if (run.kind === "mergecheck") {
729 // Refused harmlessly if the probe reported before it stopped.
730 await work.failMergecheck(run.pullId, run.token, why ?? "The sandbox stopped before the merge check finished.");
731 return;
732 }
733 // A sandbox of a kind g1t no longer starts, which ran from before: it
734 // has nobody left to tell.
735 }
736}
737
738/**
739 * What the compute gate decided for one start: go, with what billing
740 * reserved and the plan's caps; or not, refused with what to tell people.
741 */
742type Granted = {
743 ok: true;
744 held: Held | null;
745 limits: PlanLimits;
746 /** The labels of the self-hosted runners it goes to; null for a sandbox. */
747 route: string[] | null;
748};
749type Admitted = Granted | { ok: false; code: string; message: string };
750
751/**
752 * The guardrails' default time cap of each kind of run, for estimating what
753 * it may cost before it starts; the sandbox applies the project's own.
754 */
755const DEFAULT_MINUTES: Record<"queue" | "mergecheck", number> = {
756 queue: 45,
757 mergecheck: 10,
758};
759
760/** A plan's caps on one run, for the sandbox to apply under its guardrails'. */
761function limitsOf(ent: ComputeEntitlements | null): PlanLimits {
762 return {
763 minutes: ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null,
764 budgetUsd: ent && ent.runCapMicros > 0 ? ent.runCapMicros / 1_000_000 : null,
765 };
766}
767
768/** The shorter of a kind's time cap and the plan's, for an estimate. */
769function estimateMinutes(minutes: number, ent: ComputeEntitlements | null): number {
770 return ent && ent.maxRunMinutes > 0 ? Math.min(minutes, ent.maxRunMinutes) : minutes;
771}
772
773/** A start the gate did not let through, as a result for whoever asked. */
774function notAdmitted(admitted: Exclude<Admitted, Granted>): Result<never> {
775 return fail("payment_required", admitted.message);
776}
777
778/**
779 * Larger sandboxes for workflow jobs that ask for one in `runs-on`: the
780 * same image and behaviour on a larger Containers instance type, each a
781 * class of its own (wrangler.jsonc). Outbound handlers are registered by
782 * class, so each registers its own.
783 */
784export class Sandbox2Core extends AttemptSandbox {
785 static {
786 Sandbox2Core.outboundHandlers = { egress, abuse };
787 }
788}
789export class Sandbox4Core extends AttemptSandbox {
790 static {
791 Sandbox4Core.outboundHandlers = { egress, abuse };
792 }
793}
794
795/** What the actions service sends to start a job (`StartJobArgs`). */
796type ActionsJobArgs = {
797 job: string;
798 token: string;
799 repo: RepoPath;
800 timeoutMinutes: number;
801 /** Its workflow file, `.g1t/workflows/deploy.yml`. */
802 workflow?: string | null;
803 /** The environment it names plainly. */
804 environment?: string | null;
805 /** Not a pull request from a fork: only then are workflow-only domains given. */
806 trusted?: boolean;
807 /** The machine its `runs-on` asked for, by label; absent, the standard one. */
808 instance?: string | null;
809 /** Whether it may be given brokered secrets, which its requests need filled in. */
810 brokered?: boolean;
811};
812
813export default class RunnerService extends WorkerEntrypoint<RunnerEnv> {
814 /**
815 * The JSON protocol the Rust services speak: `POST /rpc/<method>` with the
816 * arguments as the body. Only bound services can.
817 */
818 async fetch(request: Request): Promise<Response> {
819 const { pathname } = new URL(request.url);
820 if (request.method === "POST" && pathname === "/rpc/start_actions_job") {
821 return Response.json(await this.startActionsJob((await request.json()) as ActionsJobArgs));
822 }
823 if (request.method === "POST" && pathname === "/rpc/stop_actions_job") {
824 const args = (await request.json()) as { job: string };
825 // Whichever machine it asked for: the job's object in every namespace.
826 await Promise.all(
827 Object.keys(SANDBOX_BINDINGS).map((className) => {
828 const namespace = sandboxNamespace(this.env, className) as unknown as DurableObjectNamespace<AttemptSandbox>;
829 return namespace
830 .get(namespace.idFromName(`actions:${args.job}`))
831 .destroy()
832 .catch(() => undefined);
833 }),
834 );
835 return Response.json(ok(true));
836 }
837 // A self-hosted runner's task ended: the sandbox that handed it over
838 // does what it does when a container stops.
839 if (request.method === "POST" && pathname === "/rpc/task_ended") {
840 const args = (await request.json()) as { sandbox: string; exitCode: number; reason?: string | null };
841 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromString(args.sandbox));
842 await sandbox.remoteEnded(args.exitCode, args.reason ?? null);
843 return Response.json(ok(true));
844 }
845 if (request.method === "POST" && pathname === "/rpc/bump") {
846 return Response.json(await this.startBump(await request.json()));
847 }
848 if (request.method === "POST" && pathname === "/rpc/start_deploy") {
849 return Response.json(await this.startDeploy((await request.json()) as DeployJob));
850 }
851 return new Response("Not found\n", { status: 404 });
852 }
853
854 // ---- The compute gate (@g1t/contracts compute.ts) -------------------------
855
856 /** Whether `repo` is public: what g1t's open-source pool can pay for. */
857 private async isPublic(repo: RepoPath): Promise<boolean> {
858 const found = await reposClient(this.env.REPOS)
859 .get(repo, null)
860 .catch(() => null);
861 return Boolean(found?.ok && !found.value.isPrivate);
862 }
863
864 /**
865 * Whether a sandbox (the merge queue, a
866 * merge check, a workflow job) may start in `repo`, with what it may cost
867 * for `minutes` reserved. Public repositories' checks, workflows and
868 * queue can be paid by the open-source pool. Never throws.
869 */
870 private async admitSandbox(
871 kind: ComputeKind,
872 repo: RepoPath,
873 minutes: number,
874 instance: InstanceType = STANDARD_INSTANCE,
875 { selfHosted = true }: { selfHosted?: boolean } = {},
876 ): Promise<Admitted> {
877 const workspace = repo.namespace.toLowerCase();
878 const compute = gateFor(this.env);
879 const ent = await compute.entitlements(workspace);
880 if (ent?.paused) return { ok: false, code: "paused", message: refusalMessage("paused", workspace, kind, ent.paused) };
881 // Checks and the merge queue go to the workspace's own runners when it
882 // says so, and cost nothing there. Workflow jobs choose with `runs-on`.
883 const route = selfHosted && (kind === "check" || kind === "queue") ? await selfHostedRoute(this.env.ACTIONS, repo) : null;
884 if (route) return { ok: true, held: null, limits: limitsOf(ent), route };
885 const [standardMicros, isPublic] = await Promise.all([compute.microsPerSecond(), this.isPublic(repo)]);
886 // A larger machine is reserved for at what it costs with every vCPU busy.
887 const microsPerSecond = standardMicros * instance.estimateScale;
888 const admission = await compute.admit(
889 { workspace, repo, public: isPublic, kind, estimateMicros: sandboxEstimateMicros(estimateMinutes(minutes, ent), microsPerSecond) },
890 ent,
891 );
892 if (!admission.ok) return { ok: false, code: admission.code, message: admission.message };
893 return {
894 ok: true,
895 held: admission.reservation ? { id: admission.reservation.id, workspace, microsPerSecond } : null,
896 limits: limitsOf(ent),
897 route: null,
898 };
899 }
900
901 /** Gives back what was reserved for a start that never reached its sandbox. */
902 private async release(held: Held | null): Promise<void> {
903 if (held) await gateFor(this.env).settle(held.id, 0);
904 }
905
906 /**
907 * Runs `start`, giving back what was reserved if it fails. A sandbox that
908 * could not start has given it back already; settling twice at nothing
909 * is harmless.
910 */
911 private async holding<T>(granted: Granted, start: () => Promise<T>): Promise<T> {
912 try {
913 return await start();
914 } catch (error) {
915 await this.release(granted.held);
916 throw error;
917 }
918 }
919
920 /**
921 * Starts one job of a GitHub Actions workflow in a sandbox of its own.
922 * The sandbox fetches the job, its contexts and its secrets with the
923 * job's token, and reports back to the actions service through the API.
924 * Jobs run on g1t's machines, so only for workspaces that may use them.
925 */
926 private async startActionsJob(args: ActionsJobArgs): Promise<Result<true>> {
927 // The machine its `runs-on` asked for; the standard one otherwise.
928 const instance = instanceNamed(args.instance);
929 // Workflow jobs run on g1t's machines: only as the workspace's plan
930 // allows, or on a public repository, from the open-source pool.
931 const admitted = await this.admitSandbox("workflow", args.repo, args.timeoutMinutes, instance);
932 if (!admitted.ok) return fail("payment_required", `Not started: ${admitted.message}`);
933 const namespace = this.jobNamespace(instance);
934 if (!namespace) {
935 await this.release(admitted.held);
936 return fail("invalid", `Not started: ${instance.label} machines are not available here.`);
937 }
938 const sandbox = namespace.get(namespace.idFromName(`actions:${args.job}`));
939 const on = instance === STANDARD_INSTANCE ? "" : ` on ${instance.label}`;
940 try {
941 await sandbox.run({
942 kind: "actions",
943 jobId: args.job,
944 token: args.token,
945 reservation: admitted.held,
946 limits: admitted.limits,
947 // The project's network list plus what builds need (and, for a
948 // trusted run, the workflow-only domains its workflow and
949 // environment are given), and the job's own time limit.
950 build: {
951 kind: "actions",
952 repo: args.repo,
953 minutes: Math.max(1, args.timeoutMinutes),
954 job: { workflow: args.workflow ?? null, environment: args.environment ?? null, trusted: args.trusted === true },
955 brokered: args.brokered === true,
956 },
957 meter: {
958 ...meter(args.repo, `A workflow job in ${args.repo.namespace}/${args.repo.name}${on}`),
959 instance: instance === STANDARD_INSTANCE ? null : instance.label,
960 },
961 envVars: {
962 MODE: "actions",
963 G1T_API: "https://api.g1t.sh",
964 ACTIONS_JOB: args.job,
965 ACTIONS_TOKEN: args.token,
966 // Docker of the job's own, inside its sandbox (crates/runner docker/).
967 G1T_DOCKER: dockerFor(this.env.DOCKER),
968 },
969 });
970 } catch (error) {
971 // A sandbox that could not start, or stopped at once: the job fails
972 // with why, rather than waiting to be noticed.
973 return {
974 ok: false,
975 error: { code: "conflict", message: `The runner could not start the job: ${String(error).replace(/^Error: /, "")}` },
976 };
977 }
978 // `true`, not null: an outcome needs a value.
979 return ok(true);
980 }
981
982 /** The sandboxes of a machine size: each instance type is a class of its own. */
983 private jobNamespace(instance: InstanceType): DurableObjectNamespace<AttemptSandbox> | null {
984 if (instance === STANDARD_INSTANCE) return this.env.SANDBOX;
985 const bound = instance.label === "g1t-4core" ? this.env.SANDBOX_4CORE : instance.label === "g1t-2core" ? this.env.SANDBOX_2CORE : undefined;
986 return (bound as DurableObjectNamespace<AttemptSandbox> | undefined) ?? null;
987 }
988
989 /**
990 * Builds one commit in a sandbox of its own and deploys it to g1t.page.
991 * Asked by the deployments service, which has already checked that the
992 * workspace pays for Deployments; that plan is what
993 * lets a build use g1t's machines.
994 */
995 private async startDeploy(job: DeployJob): Promise<Result<true>> {
996 // To read the commit, which may be private, as whoever pushed it.
997 const token = await runCredential(this.env.IDENTITY, {
998 onBehalfOf: job.actor,
999 repo: job.source,
1000 kind: "deploy",
1001 use: "runner",
1002 read: [job.source],
1003 ttlSeconds: DEPLOY_TOKEN_TTL_SECONDS,
1004 });
1005 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`deploy:${job.deployId}`));
1006 const workspace = (job.workspace ?? job.source.namespace).toLowerCase();
1007 // The project the build is for: its guardrails, and who it is charged to.
1008 const project = job.repo ?? job.source;
1009 try {
1010 await sandbox.run({
1011 kind: "deploy",
1012 deployId: job.deployId,
1013 token: job.token,
1014 reservation: job.reservation
1015 ? { id: job.reservation, workspace, microsPerSecond: job.microsPerSecond ?? 0 }
1016 : null,
1017 limits: { minutes: job.maxRunMinutes ?? null },
1018 // The project's network list plus registries and Cloudflare's API,
1019 // for as long as its read token lasts.
1020 build: { kind: "deploy", repo: project, repoId: job.repoId ?? null, minutes: DEPLOY_TOKEN_TTL_SECONDS / 60 },
1021 owner: { workspace, repo: `${project.namespace}/${project.name}` },
1022 envVars: {
1023 MODE: "deploy",
1024 G1T_API: "https://api.g1t.sh",
1025 DEPLOY_ID: job.deployId,
1026 DEPLOY_TOKEN: job.token,
1027 G1T_USER: job.actor.username,
1028 G1T_TOKEN: token,
1029 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
1030 GIT_COMMIT: job.commit,
1031 ROOT_DIR: job.rootDir ?? "",
1032 BUILD_COMMAND: job.buildCommand ?? "",
1033 OUTPUT_DIR: job.outputDir ?? "",
1034 BUILD_AS: job.buildAs ?? "auto",
1035 DOCKERFILE: job.dockerfile ?? "",
1036 // A container build's own Docker Engine (crates/runner docker/),
1037 // started only if the project is built as one.
1038 G1T_DOCKER: dockerFor(this.env.DOCKER),
1039 BUILD_ENV: JSON.stringify(job.buildEnv ?? {}),
1040 BUILD_SECRETS: JSON.stringify(job.buildSecrets ?? {}),
1041 },
1042 });
1043 } catch (error) {
1044 return {
1045 ok: false,
1046 error: { code: "conflict", message: `The runner could not start the build: ${String(error).replace(/^Error: /, "")}` },
1047 };
1048 }
1049 return ok(true);
1050 }
1051
1052 /**
1053 * Makes a security update in a sandbox of its own (crates/runner
1054 * bump.rs): raises one package to a fixed version in the lockfiles
1055 * named, commits that as g1t and pushes it to its `g1t/security/…`
1056 * branch. A version update (`kind: "version"`) raises one or more
1057 * packages the same way, to the branch its dependency update file names,
1058 * which is never the default one. Asked by the security service, which
1059 * opens the pull request when it hears the push; nothing here opens one.
1060 * Admitted, reserved and metered like checks, always in g1t's sandbox (a
1061 * self-hosted runner may not know the mode), under the project's network
1062 * list plus the package registries. Returns whether the sandbox started.
1063 */
1064 private async startBump(input: unknown): Promise<Result<boolean>> {
1065 const problem = bumpProblem(input, UPDATE_BRANCH_PREFIX);
1066 if (problem) return fail("invalid", problem);
1067 const args = input as BumpArgs;
1068 const repo = args.repo;
1069 const what = args.kind === "version" ? "version update" : "security update";
1070 const actor = systemActor(repo.namespace);
1071 const closed = await this.closedRepo(actor, repo);
1072 if (closed) return closed;
1073 const admitted = await this.admitSandbox("check", repo, BUMP_MINUTES, STANDARD_INSTANCE, { selfHosted: false });
1074 if (!admitted.ok) return notAdmitted(admitted);
1075 try {
1076 const defaultBranch = await this.defaultBranch(repo, actor);
1077 // From its `target-branch`, which its pull request merges into, or
1078 // the default branch.
1079 const base = args.base ?? defaultBranch;
1080 // A version update names its own branch, which is never the one it
1081 // starts from, nor the default one.
1082 if (args.kind === "version" && (args.branch === defaultBranch || args.branch === base)) {
1083 await this.release(admitted.held);
1084 return fail("invalid", `A version update cannot push to ${args.branch}, the branch it starts from.`);
1085 }
1086 // As g1t, for the workspace: reads the repository and pushes this
1087 // branch only, with no API operations.
1088 const token = await runCredential(this.env.IDENTITY, {
1089 onBehalfOf: actor,
1090 repo,
1091 kind: "bump",
1092 use: "runner",
1093 read: [repo],
1094 push: [{ repo, branch: args.branch }],
1095 ttlSeconds: BUMP_TOKEN_TTL_SECONDS,
1096 agent: actor.username,
1097 });
1098 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(bumpSandboxName(args)));
1099 await sandbox.run({
1100 kind: "bump",
1101 repo,
1102 branch: args.branch,
1103 reservation: admitted.held,
1104 limits: admitted.limits,
1105 build: { kind: "bump", repo, minutes: BUMP_MINUTES, hosts: registryHosts(args) },
1106 meter: meter(repo, `${args.kind === "version" ? "Version" : "Security"} update in ${repo.namespace}/${repo.name}`),
1107 envVars: bumpEnv(args, base, token),
1108 });
1109 } catch (error) {
1110 await this.release(admitted.held);
1111 return fail("conflict", `The runner could not start the ${what}: ${String(error).replace(/^Error: /, "")}`);
1112 }
1113 return ok(true);
1114 }
1115
1116 /**
1117 * Events from the bus. Each one that could change what a pull request
1118 * needs next moves it along: the work service decides whether it can
1119 * merge by the repository's settings, and merges it if so. Merge checks
1120 * and the merge queue's states start here too.
1121 */
1122 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
1123 for (const message of batch.messages) {
1124 const event = message.body;
1125 switch (event.type) {
1126 case "pull.opened":
1127 case "pull.ready":
1128 case "pull.updated":
1129 case "checks.completed":
1130 case "review.completed":
1131 case "pull.mergeability":
1132 await this.advance(event.data.pullId);
1133 break;
1134 // Its head or its target moved and both changed the same files:
1135 // find out whether it still merges cleanly.
1136 case "pull.mergecheck":
1137 await this.startMergecheck(event.data.pullId);
1138 break;
1139 // Something joined, left or landed: test the next batch if none is.
1140 case "queue.changed":
1141 await this.buildQueue(event.data.repoId);
1142 break;
1143 // A person approved or asked for changes, which may let it merge.
1144 case "comment.created":
1145 if (event.data.pullId && event.data.verdict) await this.advance(event.data.pullId);
1146 break;
1147 // The branch the others would land on has moved.
1148 case "pull.merged":
1149 await this.advanceAll(event.data.repoId);
1150 break;
1151 // Read-only or gone: the runs there stop.
1152 case "repo.archived":
1153 case "repo.deleted":
1154 await this.stopRunsIn(event.data.repoId);
1155 break;
1156 }
1157 message.ack();
1158 }
1159 }
1160
1161 /** A sweep, for steps whose trigger was missed or whose sandbox died, and the nightly backups. */
1162 async scheduled(): Promise<void> {
1163 await this.advanceAll();
1164 await this.startBackups().catch((error: unknown) => console.log("backups not started", String(error)));
1165 }
1166
1167 /**
1168 * Starts a few of the nightly backups the repos service queued, each in
1169 * a sandbox of its own that holds only its job's token: the sandbox asks
1170 * for a read-only git credential itself, when it is ready to clone. No
1171 * plan is asked and nothing is metered: backups are g1t's own work.
1172 */
1173 private async startBackups(): Promise<void> {
1174 const pace = backupPace(this.env.BACKUPS_PER_SWEEP, this.env.BACKUPS_RUNNING);
1175 if (pace.perSweep === 0) return;
1176 const repos = reposClient(this.env.REPOS);
1177 for (const claim of await repos.claimBackups(pace.perSweep, pace.running)) {
1178 try {
1179 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(backupSandboxName(claim)));
1180 await sandbox.run({
1181 kind: "backup",
1182 jobId: claim.jobId,
1183 token: claim.token,
1184 // For `abuse.flagged`: whose repository it was.
1185 owner: { workspace: claim.path.namespace, repo: `${claim.path.namespace}/${claim.path.name}` },
1186 envVars: backupEnv(claim, "https://api.g1t.sh"),
1187 });
1188 } catch (error) {
1189 await repos.failBackup(claim.jobId, claim.token, `The sandbox could not start: ${String(error)}`).catch(() => null);
1190 }
1191 }
1192 }
1193
1194 private async advanceAll(repoId?: string): Promise<void> {
1195 const pulls = await workClient(this.env.WORK).managedPulls(repoId);
1196 for (const pullId of pulls) await this.advance(pullId);
1197 }
1198
1199 /**
1200 * Merges a pull request by the repository's settings when it is ready:
1201 * the work service decides and merges, and claims the step, so calling
1202 * this twice merges nothing twice.
1203 */
1204 private async advance(pullId: string): Promise<void> {
1205 const next = await workClient(this.env.WORK).advance(pullId);
1206 // g1t no longer reviews, revises or catches up pull requests itself.
1207 if (next.action !== "none") console.log("lifecycle step not taken", pullId, next.action);
1208 }
1209
1210 /**
1211 * Starts the next batch of a repository's merge queue, if it has one
1212 * ready: a sandbox per entry, all at once, each building the default
1213 * branch with that entry and everything ahead of it.
1214 */
1215 private async buildQueue(repoId: string): Promise<void> {
1216 const work = workClient(this.env.WORK);
1217 const jobs = await work.queueBuild(repoId);
1218 // Merge queue sandboxes, like any other, only as the workspace's plan
1219 // allows: refused states fail at once, saying why. A state whose
1220 // sandbox could not start fails at once too, rather than holding the
1221 // queue until it times out.
1222 await Promise.all(
1223 jobs.map(async (job) => {
1224 const admitted = await this.admitSandbox("queue", job.repo, DEFAULT_MINUTES.queue);
1225 if (!admitted.ok) {
1226 await work.failQueue(job.entryId, job.token, `Not started: ${admitted.message}`);
1227 return;
1228 }
1229 await this.holding(admitted, () => this.startQueueRun(job, admitted)).catch((error: unknown) =>
1230 work.failQueue(job.entryId, job.token, `Its sandbox could not start: ${String(error)}`),
1231 );
1232 }),
1233 );
1234 }
1235
1236 private async startQueueRun(job: QueueJob, granted: Granted): Promise<void> {
1237 // To read the changes and push the tested state, as a member.
1238 // Reads each queued change; pushes only the queue's own branch.
1239 const token = await runCredential(this.env.IDENTITY, {
1240 onBehalfOf: job.actor,
1241 repo: job.repo,
1242 kind: "queue",
1243 use: "runner",
1244 number: job.stack.at(-1)?.number ?? null,
1245 read: job.stack.map((item) => item.source),
1246 push: [{ repo: job.repo, branch: job.branch }],
1247 ttlSeconds: CHECKS_TOKEN_TTL_SECONDS,
1248 });
1249 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1250 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`queue-${job.entryId}-${job.baseCommit}`));
1251 await sandbox.run({
1252 kind: "queue",
1253 entryId: job.entryId,
1254 token: job.token,
1255 reservation: granted.held,
1256 limits: granted.limits,
1257 selfHosted: granted.route,
1258 track: {
1259 actor: job.actor,
1260 repo: job.repo,
1261 kind: "queue",
1262 number: job.stack.at(-1)?.number ?? null,
1263 title: `Merge queue: ${job.stack.map((item) => `#${item.number}`).join(" + ")}`,
1264 },
1265 meter: meter(job.repo, `Merge queue on ${job.repo.namespace}/${job.repo.name}`),
1266 envVars: {
1267 MODE: "queue",
1268 G1T_API: "https://api.g1t.sh",
1269 QUEUE_ENTRY: job.entryId,
1270 QUEUE_TOKEN: job.token,
1271 G1T_USER: job.actor.username,
1272 G1T_TOKEN: token,
1273 BASE_REMOTE: remote(job.repo),
1274 BASE_COMMIT: job.baseCommit,
1275 QUEUE_BRANCH: job.branch,
1276 STACK: JSON.stringify(
1277 job.stack.map((item) => ({
1278 number: item.number,
1279 title: item.title,
1280 remote: remote(item.source),
1281 branch: item.branch,
1282 commit: item.commit,
1283 })),
1284 ),
1285 CHECKS: JSON.stringify(job.checks),
1286 CONTRACT_CHECKS: JSON.stringify(job.contractChecks),
1287 },
1288 });
1289 }
1290
1291 /**
1292 * Merges a pull request's head into its target in a sandbox of its own,
1293 * pushing nothing, to find the files that conflict.
1294 * The work service decides when one is needed and how many may run.
1295 */
1296 private async startMergecheck(pullId: string): Promise<void> {
1297 const work = workClient(this.env.WORK);
1298 const started = await work.startMergecheck(pullId);
1299 if (!started.ok) return;
1300 const job = started.value;
1301 let granted: Granted | null = null;
1302 try {
1303 // Like any sandbox, only as the workspace's plan allows.
1304 const admitted = await this.admitSandbox("check", job.repo, DEFAULT_MINUTES.mergecheck);
1305 if (!admitted.ok) throw new Error(`Not started: ${admitted.message}`);
1306 granted = admitted;
1307 // To read the change, which may be private, as whoever opened it.
1308 const token = await runCredential(this.env.IDENTITY, {
1309 onBehalfOf: job.author,
1310 repo: job.repo,
1311 kind: "mergecheck",
1312 use: "runner",
1313 number: job.number,
1314 read: [job.repo, job.source],
1315 ttlSeconds: MERGECHECK_TOKEN_TTL_SECONDS,
1316 });
1317 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1318 // One sandbox per pair of commits: asking twice starts nothing twice.
1319 const sandbox = this.env.SANDBOX.get(
1320 this.env.SANDBOX.idFromName(`mergecheck-${job.pullId}-${job.head}-${job.base}`),
1321 );
1322 await sandbox.run({
1323 kind: "mergecheck",
1324 pullId: job.pullId,
1325 token: job.token,
1326 reservation: granted.held,
1327 limits: granted.limits,
1328 selfHosted: granted.route,
1329 meter: meter(job.repo, `Merge check of ${job.repo.namespace}/${job.repo.name}#${job.number}`),
1330 envVars: {
1331 MODE: "mergecheck",
1332 G1T_API: "https://api.g1t.sh",
1333 MERGECHECK_PULL: job.pullId,
1334 MERGECHECK_TOKEN: job.token,
1335 G1T_USER: job.author.username,
1336 G1T_TOKEN: token,
1337 BASE_REMOTE: remote(job.repo),
1338 BASE_COMMIT: job.base,
1339 HEAD_REMOTE: remote(job.source),
1340 HEAD_BRANCH: job.branch,
1341 HEAD_COMMIT: job.head,
1342 },
1343 });
1344 } catch (error) {
1345 if (granted) await this.release(granted.held);
1346 await work.failMergecheck(job.pullId, job.token, error instanceof Error ? error.message : String(error));
1347 }
1348 }
1349
1350 /**
1351 * A refusal if `repo` takes no work from anyone: it is archived, so
1352 * read-only, or it was deleted (repos hides a deleted one, so it is not
1353 * found). Null when repos cannot answer now; the other checks still run.
1354 */
1355 private async closedRepo(actor: User, repo: RepoPath): Promise<Result<never> | null> {
1356 const repos = reposClient(this.env.REPOS);
1357 const found = await repos.get(repo, actor).catch(() => null);
1358 if (!found) return null;
1359 if (!found.ok) {
1360 return found.error.code === "not_found"
1361 ? fail("not_found", `There is no repository at ${repo.namespace}/${repo.name}, or it was deleted.`)
1362 : null;
1363 }
1364 const status = await repos.statusById(found.value.id).catch(() => null);
1365 if (status?.deleted) {
1366 return fail("not_found", `${found.value.namespace}/${found.value.name} was deleted. An owner can restore it from the workspace's settings.`);
1367 }
1368 if (status?.archived || found.value.archivedAt) {
1369 return fail(
1370 "forbidden",
1371 `${found.value.namespace}/${found.value.name} is archived, so it is read-only. An owner can unarchive it in its settings.`,
1372 );
1373 }
1374 return null;
1375 }
1376
1377 /**
1378 * Stops every run in a repository that was archived or deleted: the
1379 * work service marks them stopped when it hears of it, and lists them
1380 * here (`runs_in_repo`, by id, so a deleted repository's runs are found
1381 * too), and each sandbox is destroyed. Never throws.
1382 */
1383 private async stopRunsIn(repoId: string): Promise<void> {
1384 try {
1385 const response = await this.env.WORK.fetch("https://work/rpc/runs_in_repo", {
1386 method: "POST",
1387 headers: { "content-type": "application/json" },
1388 body: JSON.stringify({ repoId }),
1389 });
1390 if (!response.ok) return;
1391 const runs = (await response.json()) as { runId: string; sandbox: string | null }[];
1392 for (const run of runs) {
1393 if (!run.sandbox) continue;
1394 try {
1395 await this.env.SANDBOX.get(this.env.SANDBOX.idFromString(run.sandbox)).halt("The repository was archived or deleted.");
1396 } catch (error) {
1397 // Already gone, or never started.
1398 console.log("sandbox not destroyed", run.runId, String(error));
1399 }
1400 }
1401 } catch (error) {
1402 console.error("could not stop the runs in", repoId, error);
1403 }
1404 }
1405
1406 private async defaultBranch(repo: RepoPath, viewer: Viewer): Promise<string> {
1407 const found = await reposClient(this.env.REPOS).get(repo, viewer);
1408 return found.ok ? found.value.defaultBranch : "main";
1409 }
1410}