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