g1t/services/runner/src/self-hosted.ts
| 1 | /** |
| 2 | * g1t's own work on a workspace's self-hosted runners. When a workspace (or |
| 3 | * one of its repositories) says so under Settings, Runners, an agent's run, |
| 4 | * checks, a review, a merge check or the merge queue is not started in a |
| 5 | * sandbox here: the sandbox's Durable Object hands the same environment to |
| 6 | * the actions service as a task, a runner of the workspace's with the |
| 7 | * right labels takes it on its next poll, and the actions service tells |
| 8 | * the Durable Object how it ended (`task_ended`), which then does exactly |
| 9 | * what it does when a container stops. The model calls still go through |
| 10 | * g1t's model proxy with the run's own credential, so spend, budgets and |
| 11 | * the audit log work as they do in a sandbox. Network guardrails cannot be |
| 12 | * enforced on someone else's machine, and the run says so. |
| 13 | */ |
| 14 | |
| 15 | import type { RepoPath, ServiceBinding } from "@g1t/contracts"; |
| 16 | |
| 17 | /** The actions service's JSON protocol. */ |
| 18 | async function call<T>(actions: ServiceBinding, method: string, args: object): Promise<T> { |
| 19 | const response = await actions.fetch(`https://actions/rpc/${method}`, { |
| 20 | method: "POST", |
| 21 | headers: { "content-type": "application/json" }, |
| 22 | body: JSON.stringify(args), |
| 23 | }); |
| 24 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 25 | return (await response.json()) as T; |
| 26 | } |
| 27 | |
| 28 | /** |
| 29 | * The labels g1t's own work in `repo` runs on, when it runs on the |
| 30 | * workspace's runners; null for g1t's sandboxes. Never throws: when the |
| 31 | * actions service cannot say, the work runs in a sandbox as before. |
| 32 | */ |
| 33 | export async function selfHostedRoute(actions: ServiceBinding, repo: RepoPath): Promise<string[] | null> { |
| 34 | try { |
| 35 | const labels = await call<string[] | null>(actions, "runner_route", { workspace: repo.namespace.toLowerCase(), repo }); |
| 36 | return Array.isArray(labels) && labels.length > 0 ? labels : null; |
| 37 | } catch (error) { |
| 38 | console.log("self-hosted route unknown; using a sandbox", repo.namespace, String(error)); |
| 39 | return null; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | type Outcome<T> = { ok: true; value: T } | { ok: false; error: { message: string } }; |
| 44 | |
| 45 | /** Hands a sandbox's work to the workspace's runners. Returns the task's id. */ |
| 46 | export async function enqueueTask( |
| 47 | actions: ServiceBinding, |
| 48 | task: { |
| 49 | sandbox: string; |
| 50 | repo: RepoPath; |
| 51 | kind: string; |
| 52 | title: string; |
| 53 | labels: string[]; |
| 54 | env: Record<string, string>; |
| 55 | timeoutMinutes: number; |
| 56 | }, |
| 57 | ): Promise<string> { |
| 58 | const queued = await call<Outcome<string>>(actions, "enqueue_task", { ...task, workspace: task.repo.namespace.toLowerCase() }); |
| 59 | if (!queued.ok) throw new Error(queued.error.message); |
| 60 | return queued.value; |
| 61 | } |
| 62 | |
| 63 | /** Withdraws a sandbox's task: a person stopped it, or its time ran out. Never throws. */ |
| 64 | export async function cancelTask(actions: ServiceBinding, sandbox: string, reason: string | null): Promise<void> { |
| 65 | await call(actions, "cancel_task", { sandbox, reason }).catch((error: unknown) => |
| 66 | console.log("self-hosted task not cancelled", sandbox, String(error)), |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * The environment a self-hosted runner gets: what the sandbox would have |
| 72 | * been started with, less what only makes sense in g1t's sandbox (the |
| 73 | * egress certificate and proxy), with the mining watch off, since the |
| 74 | * machine and its electricity are the workspace's. |
| 75 | */ |
| 76 | export function taskEnv(vars: Record<string, string>): Record<string, string> { |
| 77 | const env: Record<string, string> = {}; |
| 78 | for (const [name, value] of Object.entries(vars)) { |
| 79 | if (/^(HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|SSL_CERT_FILE|G1T_EGRESS_CA)$/i.test(name)) continue; |
| 80 | env[name] = value; |
| 81 | } |
| 82 | env.G1T_ABUSE = "off"; |
| 83 | env.G1T_SELF_HOSTED = "1"; |
| 84 | return env; |
| 85 | } |
| 86 | |
| 87 | /** What the run's session says when it goes to the workspace's runners. */ |
| 88 | export function handedOverStep(labels: string[]): string { |
| 89 | return `Handed to a self-hosted runner with labels ${labels.join(", ")}. g1t's network guardrails are not enforced on your own machines.`; |
| 90 | } |
| 91 | |
| 92 | /** Where the work is: the tracked repository, else the meter's `owner/name`. */ |
| 93 | export function taskRepo(track: { repo: RepoPath } | undefined, meter: { repo: string } | undefined, owner: { repo: string | null } | undefined): RepoPath | null { |
| 94 | if (track) return track.repo; |
| 95 | const full = meter?.repo ?? owner?.repo ?? null; |
| 96 | if (!full) return null; |
| 97 | const [namespace, name] = full.split("/"); |
| 98 | return namespace && name ? { namespace, name } : null; |
| 99 | } |