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