Skip to content
1,409 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 });
1097 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(bumpSandboxName(args)));
1098 await sandbox.run({
1099 kind: "bump",
1100 repo,
1101 branch: args.branch,
1102 reservation: admitted.held,
1103 limits: admitted.limits,
1104 build: { kind: "bump", repo, minutes: BUMP_MINUTES, hosts: registryHosts(args) },
1105 meter: meter(repo, `${args.kind === "version" ? "Version" : "Security"} update in ${repo.namespace}/${repo.name}`),
1106 envVars: bumpEnv(args, base, token),
1107 });
1108 } catch (error) {
1109 await this.release(admitted.held);
1110 return fail("conflict", `The runner could not start the ${what}: ${String(error).replace(/^Error: /, "")}`);
1111 }
1112 return ok(true);
1113 }
1114
1115 /**
1116 * Events from the bus. Each one that could change what a pull request
1117 * needs next moves it along: the work service decides whether it can
1118 * merge by the repository's settings, and merges it if so. Merge checks
1119 * and the merge queue's states start here too.
1120 */
1121 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
1122 for (const message of batch.messages) {
1123 const event = message.body;
1124 switch (event.type) {
1125 case "pull.opened":
1126 case "pull.ready":
1127 case "pull.updated":
1128 case "checks.completed":
1129 case "review.completed":
1130 case "pull.mergeability":
1131 await this.advance(event.data.pullId);
1132 break;
1133 // Its head or its target moved and both changed the same files:
1134 // find out whether it still merges cleanly.
1135 case "pull.mergecheck":
1136 await this.startMergecheck(event.data.pullId);
1137 break;
1138 // Something joined, left or landed: test the next batch if none is.
1139 case "queue.changed":
1140 await this.buildQueue(event.data.repoId);
1141 break;
1142 // A person approved or asked for changes, which may let it merge.
1143 case "comment.created":
1144 if (event.data.pullId && event.data.verdict) await this.advance(event.data.pullId);
1145 break;
1146 // The branch the others would land on has moved.
1147 case "pull.merged":
1148 await this.advanceAll(event.data.repoId);
1149 break;
1150 // Read-only or gone: the runs there stop.
1151 case "repo.archived":
1152 case "repo.deleted":
1153 await this.stopRunsIn(event.data.repoId);
1154 break;
1155 }
1156 message.ack();
1157 }
1158 }
1159
1160 /** A sweep, for steps whose trigger was missed or whose sandbox died, and the nightly backups. */
1161 async scheduled(): Promise<void> {
1162 await this.advanceAll();
1163 await this.startBackups().catch((error: unknown) => console.log("backups not started", String(error)));
1164 }
1165
1166 /**
1167 * Starts a few of the nightly backups the repos service queued, each in
1168 * a sandbox of its own that holds only its job's token: the sandbox asks
1169 * for a read-only git credential itself, when it is ready to clone. No
1170 * plan is asked and nothing is metered: backups are g1t's own work.
1171 */
1172 private async startBackups(): Promise<void> {
1173 const pace = backupPace(this.env.BACKUPS_PER_SWEEP, this.env.BACKUPS_RUNNING);
1174 if (pace.perSweep === 0) return;
1175 const repos = reposClient(this.env.REPOS);
1176 for (const claim of await repos.claimBackups(pace.perSweep, pace.running)) {
1177 try {
1178 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(backupSandboxName(claim)));
1179 await sandbox.run({
1180 kind: "backup",
1181 jobId: claim.jobId,
1182 token: claim.token,
1183 // For `abuse.flagged`: whose repository it was.
1184 owner: { workspace: claim.path.namespace, repo: `${claim.path.namespace}/${claim.path.name}` },
1185 envVars: backupEnv(claim, "https://api.g1t.sh"),
1186 });
1187 } catch (error) {
1188 await repos.failBackup(claim.jobId, claim.token, `The sandbox could not start: ${String(error)}`).catch(() => null);
1189 }
1190 }
1191 }
1192
1193 private async advanceAll(repoId?: string): Promise<void> {
1194 const pulls = await workClient(this.env.WORK).managedPulls(repoId);
1195 for (const pullId of pulls) await this.advance(pullId);
1196 }
1197
1198 /**
1199 * Merges a pull request by the repository's settings when it is ready:
1200 * the work service decides and merges, and claims the step, so calling
1201 * this twice merges nothing twice.
1202 */
1203 private async advance(pullId: string): Promise<void> {
1204 const next = await workClient(this.env.WORK).advance(pullId);
1205 // g1t no longer reviews, revises or catches up pull requests itself.
1206 if (next.action !== "none") console.log("lifecycle step not taken", pullId, next.action);
1207 }
1208
1209 /**
1210 * Starts the next batch of a repository's merge queue, if it has one
1211 * ready: a sandbox per entry, all at once, each building the default
1212 * branch with that entry and everything ahead of it.
1213 */
1214 private async buildQueue(repoId: string): Promise<void> {
1215 const work = workClient(this.env.WORK);
1216 const jobs = await work.queueBuild(repoId);
1217 // Merge queue sandboxes, like any other, only as the workspace's plan
1218 // allows: refused states fail at once, saying why. A state whose
1219 // sandbox could not start fails at once too, rather than holding the
1220 // queue until it times out.
1221 await Promise.all(
1222 jobs.map(async (job) => {
1223 const admitted = await this.admitSandbox("queue", job.repo, DEFAULT_MINUTES.queue);
1224 if (!admitted.ok) {
1225 await work.failQueue(job.entryId, job.token, `Not started: ${admitted.message}`);
1226 return;
1227 }
1228 await this.holding(admitted, () => this.startQueueRun(job, admitted)).catch((error: unknown) =>
1229 work.failQueue(job.entryId, job.token, `Its sandbox could not start: ${String(error)}`),
1230 );
1231 }),
1232 );
1233 }
1234
1235 private async startQueueRun(job: QueueJob, granted: Granted): Promise<void> {
1236 // To read the changes and push the tested state, as a member.
1237 // Reads each queued change; pushes only the queue's own branch.
1238 const token = await runCredential(this.env.IDENTITY, {
1239 onBehalfOf: job.actor,
1240 repo: job.repo,
1241 kind: "queue",
1242 use: "runner",
1243 number: job.stack.at(-1)?.number ?? null,
1244 read: job.stack.map((item) => item.source),
1245 push: [{ repo: job.repo, branch: job.branch }],
1246 ttlSeconds: CHECKS_TOKEN_TTL_SECONDS,
1247 });
1248 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1249 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`queue-${job.entryId}-${job.baseCommit}`));
1250 await sandbox.run({
1251 kind: "queue",
1252 entryId: job.entryId,
1253 token: job.token,
1254 reservation: granted.held,
1255 limits: granted.limits,
1256 selfHosted: granted.route,
1257 track: {
1258 actor: job.actor,
1259 repo: job.repo,
1260 kind: "queue",
1261 number: job.stack.at(-1)?.number ?? null,
1262 title: `Merge queue: ${job.stack.map((item) => `#${item.number}`).join(" + ")}`,
1263 },
1264 meter: meter(job.repo, `Merge queue on ${job.repo.namespace}/${job.repo.name}`),
1265 envVars: {
1266 MODE: "queue",
1267 G1T_API: "https://api.g1t.sh",
1268 QUEUE_ENTRY: job.entryId,
1269 QUEUE_TOKEN: job.token,
1270 G1T_USER: job.actor.username,
1271 G1T_TOKEN: token,
1272 BASE_REMOTE: remote(job.repo),
1273 BASE_COMMIT: job.baseCommit,
1274 QUEUE_BRANCH: job.branch,
1275 STACK: JSON.stringify(
1276 job.stack.map((item) => ({
1277 number: item.number,
1278 title: item.title,
1279 remote: remote(item.source),
1280 branch: item.branch,
1281 commit: item.commit,
1282 })),
1283 ),
1284 CHECKS: JSON.stringify(job.checks),
1285 CONTRACT_CHECKS: JSON.stringify(job.contractChecks),
1286 },
1287 });
1288 }
1289
1290 /**
1291 * Merges a pull request's head into its target in a sandbox of its own,
1292 * pushing nothing, to find the files that conflict.
1293 * The work service decides when one is needed and how many may run.
1294 */
1295 private async startMergecheck(pullId: string): Promise<void> {
1296 const work = workClient(this.env.WORK);
1297 const started = await work.startMergecheck(pullId);
1298 if (!started.ok) return;
1299 const job = started.value;
1300 let granted: Granted | null = null;
1301 try {
1302 // Like any sandbox, only as the workspace's plan allows.
1303 const admitted = await this.admitSandbox("check", job.repo, DEFAULT_MINUTES.mergecheck);
1304 if (!admitted.ok) throw new Error(`Not started: ${admitted.message}`);
1305 granted = admitted;
1306 // To read the change, which may be private, as whoever opened it.
1307 const token = await runCredential(this.env.IDENTITY, {
1308 onBehalfOf: job.author,
1309 repo: job.repo,
1310 kind: "mergecheck",
1311 use: "runner",
1312 number: job.number,
1313 read: [job.repo, job.source],
1314 ttlSeconds: MERGECHECK_TOKEN_TTL_SECONDS,
1315 });
1316 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1317 // One sandbox per pair of commits: asking twice starts nothing twice.
1318 const sandbox = this.env.SANDBOX.get(
1319 this.env.SANDBOX.idFromName(`mergecheck-${job.pullId}-${job.head}-${job.base}`),
1320 );
1321 await sandbox.run({
1322 kind: "mergecheck",
1323 pullId: job.pullId,
1324 token: job.token,
1325 reservation: granted.held,
1326 limits: granted.limits,
1327 selfHosted: granted.route,
1328 meter: meter(job.repo, `Merge check of ${job.repo.namespace}/${job.repo.name}#${job.number}`),
1329 envVars: {
1330 MODE: "mergecheck",
1331 G1T_API: "https://api.g1t.sh",
1332 MERGECHECK_PULL: job.pullId,
1333 MERGECHECK_TOKEN: job.token,
1334 G1T_USER: job.author.username,
1335 G1T_TOKEN: token,
1336 BASE_REMOTE: remote(job.repo),
1337 BASE_COMMIT: job.base,
1338 HEAD_REMOTE: remote(job.source),
1339 HEAD_BRANCH: job.branch,
1340 HEAD_COMMIT: job.head,
1341 },
1342 });
1343 } catch (error) {
1344 if (granted) await this.release(granted.held);
1345 await work.failMergecheck(job.pullId, job.token, error instanceof Error ? error.message : String(error));
1346 }
1347 }
1348
1349 /**
1350 * A refusal if `repo` takes no work from anyone: it is archived, so
1351 * read-only, or it was deleted (repos hides a deleted one, so it is not
1352 * found). Null when repos cannot answer now; the other checks still run.
1353 */
1354 private async closedRepo(actor: User, repo: RepoPath): Promise<Result<never> | null> {
1355 const repos = reposClient(this.env.REPOS);
1356 const found = await repos.get(repo, actor).catch(() => null);
1357 if (!found) return null;
1358 if (!found.ok) {
1359 return found.error.code === "not_found"
1360 ? fail("not_found", `There is no repository at ${repo.namespace}/${repo.name}, or it was deleted.`)
1361 : null;
1362 }
1363 const status = await repos.statusById(found.value.id).catch(() => null);
1364 if (status?.deleted) {
1365 return fail("not_found", `${found.value.namespace}/${found.value.name} was deleted. An owner can restore it from the workspace's settings.`);
1366 }
1367 if (status?.archived || found.value.archivedAt) {
1368 return fail(
1369 "forbidden",
1370 `${found.value.namespace}/${found.value.name} is archived, so it is read-only. An owner can unarchive it in its settings.`,
1371 );
1372 }
1373 return null;
1374 }
1375
1376 /**
1377 * Stops every run in a repository that was archived or deleted: the
1378 * work service marks them stopped when it hears of it, and lists them
1379 * here (`runs_in_repo`, by id, so a deleted repository's runs are found
1380 * too), and each sandbox is destroyed. Never throws.
1381 */
1382 private async stopRunsIn(repoId: string): Promise<void> {
1383 try {
1384 const response = await this.env.WORK.fetch("https://work/rpc/runs_in_repo", {
1385 method: "POST",
1386 headers: { "content-type": "application/json" },
1387 body: JSON.stringify({ repoId }),
1388 });
1389 if (!response.ok) return;
1390 const runs = (await response.json()) as { runId: string; sandbox: string | null }[];
1391 for (const run of runs) {
1392 if (!run.sandbox) continue;
1393 try {
1394 await this.env.SANDBOX.get(this.env.SANDBOX.idFromString(run.sandbox)).halt("The repository was archived or deleted.");
1395 } catch (error) {
1396 // Already gone, or never started.
1397 console.log("sandbox not destroyed", run.runId, String(error));
1398 }
1399 }
1400 } catch (error) {
1401 console.error("could not stop the runs in", repoId, error);
1402 }
1403 }
1404
1405 private async defaultBranch(repo: RepoPath, viewer: Viewer): Promise<string> {
1406 const found = await reposClient(this.env.REPOS).get(repo, viewer);
1407 return found.ok ? found.value.defaultBranch : "main";
1408 }
1409}