import { accountDb, repoDb } from "../scoped-db"; import { repoOwnerId } from "./scope"; import { completeJob, runnerStub } from "./jobs"; import { CLOUD_RUNS_ON_VALUES, cloudRunsOnPlaceholders } from "./runner-sizes"; const MAX_FORK_JOBS_SWEPT = 500; /** SQL predicate: every runs-on label of `j` is among the runner's labels (`r.labels`) or `self-hosted`. */ export const LABELS_MATCH_SQL = `json_array_length(j.runs_on) > 0 AND NOT EXISTS ( SELECT 1 FROM json_each(j.runs_on) AS wanted WHERE lower(wanted.value) != 'self-hosted' AND lower(wanted.value) NOT IN (SELECT lower(have.value) FROM json_each(r.labels) AS have))`; export async function forkRunsAllowed(repoId: string): Promise { const row = await repoDb(repoId).first<{ allow_fork_pull_requests: number }>( "SELECT allow_fork_pull_requests FROM runner_policies WHERE repo_id = ?", repoId, ); return row?.allow_fork_pull_requests === 1; } /** * Fork jobs only run on runners registered to the repo itself whose container isolation was declared by the admin * who minted the registration token (never by the daemon), and that carry every runs-on label. */ export async function hasForkEligibleRunner(repoId: string, runsOn: string[]): Promise { const ownerId = await repoOwnerId(repoId); if (!ownerId) return false; const row = await accountDb(ownerId).first<{ ok: number }>( `SELECT 1 AS ok FROM runners r, (SELECT ? AS runs_on) j WHERE r.repo_id = ? AND r.isolation_attested = 1 AND ${LABELS_MATCH_SQL} LIMIT 1`, JSON.stringify(runsOn), repoId, ); return row !== null; } type ForkJob = { id: string; runs_on: string; status: string }; /** Self-hosted (non-cloud) jobs of fork or untrusted runs in the repo. */ async function forkJobs(repoId: string, statuses: string): Promise { return repoDb(repoId).all( `SELECT j.id, j.runs_on, j.status FROM workflow_jobs j JOIN workflow_runs w ON w.id = j.run_id WHERE j.repo_id = ?1 AND j.status IN (${statuses}) AND (w.source_repo_id != w.repo_id OR w.untrusted = 1) AND j.runs_on NOT IN (${cloudRunsOnPlaceholders(3)}) LIMIT ?2`, repoId, MAX_FORK_JOBS_SWEPT, ...CLOUD_RUNS_ON_VALUES, ); } /** Fails queued fork jobs that no remaining runner may take, e.g. after the last eligible runner was removed. */ export async function failStrandedForkJobs(repoId: string): Promise { for (const job of await forkJobs(repoId, "'queued'")) { if (!(await hasForkEligibleRunner(repoId, JSON.parse(job.runs_on)))) await completeJob(job.id, "failure"); } } /** Withdrawing the opt-in fails queued fork jobs and stops running ones on self-hosted machines at once. */ async function revokeForkJobs(repoId: string): Promise { for (const job of await forkJobs(repoId, "'queued', 'in_progress'")) { try { if (job.status === "in_progress") await runnerStub(job.id).cancel("fork pull request runs were disabled"); else await completeJob(job.id, "failure"); } catch (error) { console.error("Failed to revoke fork job", job.id, error); } } } export async function setForkRunsAllowed(repoId: string, allowed: boolean, updatedBy: string): Promise { await repoDb(repoId).run( `INSERT INTO runner_policies (repo_id, allow_fork_pull_requests, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (repo_id) DO UPDATE SET allow_fork_pull_requests = excluded.allow_fork_pull_requests, updated_by = excluded.updated_by, updated_at = excluded.updated_at`, repoId, allowed ? 1 : 0, updatedBy, Date.now(), ); if (!allowed) await revokeForkJobs(repoId); }