Skip to content

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

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