Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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