Public: anyone can read this; the crew can change it.

checks.placement: job runs a check as a cluster Job and records node and placement; process unchanged #1863

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1815-checks-placement-job-runs-a-check-as-a-c → main 4 commits fork 6 threads · 6 unresolved

  • Merged. scout/1815-checks-placement-job-runs-a-check-as-a-c was merged into main as ec44e0d.

Review

Sign in to review this change.
15 files changed +606 −36
docs/APPS.md +18
@@ -358,6 +358,23 @@ A `check` that is present and wrong fails with the reason, so a commit is
358358 never silently unchecked. A check under way when the forge restarts is
359359 queued again, and the forge that carries on runs it from the start.
360360
361 **Placement.** `FORGE_CHECKS_PLACEMENT=process` (the default) runs a check
362 in a cell on the forge's machine. `job` runs install and check as one
363 Kubernetes Job (`src/cluster/jobs.ts`) instead: `FORGE_CHECKS_IMAGE` is its
364 image, `FORGE_CHECKS_CLAIM` a ReadWriteMany claim holding the forge's checks
365 root (mounted at the same path, where the checkout is exported),
366 `FORGE_CHECKS_NODE_SELECTOR` (`key=value,…`) and `FORGE_CHECKS_TOLERATIONS`
367 (a JSON list) say where it may land. Only `CI` and `FORGE_*` variables cross
368 to the Job. Its log is written into the run's log when it ends, and the run
369 records `placement` and the `node` its pod ran on. A malformed setting leaves
370 `process` in place. No cell wraps a Job. The pod mounts only its own
371 checkout (`subPath` = the run id), mounts no service-account token, runs
372 non-root without privilege escalation, and carries the check's limits as

Nit: runAsNonRoot: true makes a pod fail to start when its image runs as root, and common images like oven/bun:1 do (the tests use it as the example). Say here that FORGE_CHECKS_IMAGE must run as a non-root user who can write to the claim. Otherwise the first real setup fails at pod start with no hint why.

373 `resources.limits`. **Egress is not enforced by the forge on a Job**: the
374 run is recorded as uncontained ("egress not enforced on a cluster job"), and
375 the pod carries the label `forge-check=true` so an operator's NetworkPolicy
376 can select it and hold what `egress` declares.
377
361378 Checks share `FORGE_CHECKS_CONCURRENCY` slots (default `1`), and when one
362379 frees, it goes to the most recently requested check waiting, not the one
363380 queued longest — a fresh push is never stuck behind pulls that queued hours
@@ -1079,6 +1096,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does.
10791096 | `FORGE_PREVIEW_IDLE_DAYS` | `3` | days a pull may sit untouched before its preview sleeps; `0` = never |
10801097 | `FORGE_CREW_CONCURRENCY` | `2` | agent runs at once (live: Settings → Capacity) |
10811098 | `FORGE_CHECKS_CONCURRENCY` | `1` | checks at once (live: Settings → Capacity) |
1099 | `FORGE_CHECKS_PLACEMENT` | `process` | `job` runs a check's commands as a cluster Job (see Placement above) |
10821100 | `FORGE_CHECKS_DRAIN_S` | `300` | seconds a roll lets running checks finish first |
10831101 | `FORGE_CHECK_SHARDS` | the machine's cores | test shards per check (live: Settings → Capacity) |
10841102 | `FORGE_CELLS_EGRESS` | `enforce` | `report` lets undeclared egress through on 80/443 (live: Settings → Capacity) |
scripts/gate.ts +1
@@ -50,6 +50,7 @@ export const GATE: readonly string[] = [
5050 "test/chaos.test.ts",
5151 "test/flow-stage.test.ts",
5252 "test/cluster-jobs.test.ts",
53 "test/checks-placement.test.ts",
5354 "test/fuzz-found.test.ts",
5455 "test/fuzz-parsers.test.ts",
5556 "test/attach-look.test.ts",
src/actions/runs.ts +1
@@ -42,6 +42,7 @@ export function runPayload(run: Run): Record<string, unknown> {
4242 summary: run.summary,
4343 costUsd: run.costUsd,
4444 turns: run.turns,
45 ...(run.placement === "job" ? { placement: run.placement, node: run.node } : {}),
4546 ...(run.issueId === null ? {} : { issueId: run.issueId }),
4647 };
4748 }
src/checks/job_step.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * A check's steps as one cluster Job (`checks.placement: job`): install and
3 * check run in order in the Job's container, over the checks root the claim
4 * holds. The Job's log is written into the run's log and its node recorded;
5 * the answer has `runStep`'s shape, so the caller is the same for both.
6 */
7 import { runJob, type JobOptions, type JobSpec } from "../cluster/jobs.ts";
8 import { err, ok, type Result } from "../kernel/result.ts";
9 import type { Placement } from "./placement.ts";
10
11 /** What a NetworkPolicy selects a check's pod by (docs/APPS.md, Placement). */
12 export const JOB_LABELS = { "forge-check": "true" };
13
14 export async function runJobStep(
15 placement: Extract<Placement, { mode: "job" }>,
16 input: {
17 runId: string;
18 dir: string;
19 commands: string[];
20 env: Record<string, string>;
21 log: (line: string) => void;
22 signal: AbortSignal;
23 timeoutMs: number;
24 limits: { memoryMiB: number; cpus: number; diskMiB: number };
25 /** The Job's own output, apart from the echoed commands. */
26 output: string[];
27 onNode: (node: string) => void;
28 options?: JobOptions;
29 },
30 ): Promise<Result<void, string>> {
31 // Only the check's own variables cross to the cluster, never the forge's.
32 const env = Object.fromEntries(Object.entries(input.env).filter(([k]) => k === "CI" || k.startsWith("FORGE_")));
33 const spec: JobSpec = {
34 name: `check-${input.runId.toLowerCase().replace(/[^a-z0-9]/g, "-")}`.slice(0, 53).replace(/-+$/, ""),
35 image: placement.image,
36 command: ["/bin/sh", "-c", input.commands.join(" && ")],
outdated

High: the claim holds the whole checks root and is mounted at this run's dir, which is also the working directory. The check runs over every concurrent checkout (cross-repo and private read/write) instead of its own. Use subPath: runId on the volumeMount (add subPath to JobSpec.volume, validated as one segment) so the pod sees only <root>/<runId>.

37 env,
38 nodeSelector: placement.nodeSelector,
39 tolerations: placement.tolerations,
40 // Only this run's own checkout, never the root the claim holds.
41 volume: { claim: placement.claim, mountPath: input.dir, subPath: input.runId },
42 limits: input.limits,
43 labels: JOB_LABELS,
44 };
45 for (const c of input.commands) input.log(`$ ${c}`);
46 let result;
47 try {
48 result = await runJob(spec, { timeoutMs: input.timeoutMs, signal: input.signal, ...input.options });
49 } catch (error) {
50 if (input.signal.aborted) return err("check cancelled");
51 return err(`job could not run: ${error instanceof Error ? error.message : String(error)}`);
52 }
53 if (result.node) input.onNode(result.node);
54 for (const line of result.lines) {
55 input.output.push(line);
56 input.log(line);
57 }
58 if (result.timedOut) return err(`check took longer than ${Math.round(input.timeoutMs / 1000)}s`);
59 if (result.exitCode === 0) return ok();
60 return err(`check exited with code ${result.exitCode ?? "unknown"}`);
61 }
src/checks/placement.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * Where a check's commands run: `process` (the default, a cell on the
3 * forge's own machine) or `job` (a Kubernetes Job, through
4 * `cluster/jobs.ts`). Read from the forge's environment once, validated at
5 * this boundary; anything malformed leaves the default in place.
6 * FORGE_CHECKS_PLACEMENT process | job
7 * FORGE_CHECKS_IMAGE the Job's image
8 * FORGE_CHECKS_CLAIM a ReadWriteMany claim holding the checks root
9 * FORGE_CHECKS_NODE_SELECTOR key=value,key=value
10 * FORGE_CHECKS_TOLERATIONS a JSON list of tolerations
11 */
12 import type { Toleration } from "../cluster/jobs.ts";
13
14 export type Placement =
15 | { readonly mode: "process" }
16 | {
17 readonly mode: "job";
18 readonly image: string;
19 readonly claim: string;
20 readonly nodeSelector: Record<string, string>;
21 readonly tolerations: Toleration[];
22 };
23
24 const LABEL = /^[A-Za-z0-9]([-A-Za-z0-9_./]{0,61}[A-Za-z0-9])?$/;
25 const EFFECTS = ["NoSchedule", "PreferNoSchedule", "NoExecute"];
26
27 export function parseSelector(text: string): Record<string, string> | undefined {
28 const out: Record<string, string> = {};
29 for (const pair of text.split(",").filter((p) => p.trim() !== "")) {
30 const at = pair.indexOf("=");
31 const key = pair.slice(0, at).trim();
32 const value = pair.slice(at + 1).trim();
33 if (at < 1 || !LABEL.test(key) || !(value === "" || LABEL.test(value))) return undefined;
34 out[key] = value;
35 }
36 return out;
37 }
38
39 export function parseTolerations(text: string): Toleration[] | undefined {
40 let list: unknown;
41 try {
42 list = JSON.parse(text);
43 } catch {
44 return undefined;
45 }
46 if (!Array.isArray(list) || list.length > 20) return undefined;
47 const out: Toleration[] = [];
48 for (const t of list as Record<string, unknown>[]) {
49 if (typeof t !== "object" || t === null) return undefined;
50 const str = (k: string) => (typeof t[k] === "string" ? (t[k] as string) : undefined);
51 const key = str("key");
52 const operator = str("operator");
53 const value = str("value");
54 const effect = str("effect");
55 if (operator !== undefined && operator !== "Exists" && operator !== "Equal") return undefined;
56 if (effect !== undefined && !EFFECTS.includes(effect)) return undefined;
57 if ((key !== undefined && !LABEL.test(key)) || (value !== undefined && !(value === "" || LABEL.test(value))))
58 return undefined;
59 out.push({
60 ...(key === undefined ? {} : { key }),
61 ...(operator === undefined ? {} : { operator }),
62 ...(value === undefined ? {} : { value }),
63 ...(effect === undefined ? {} : { effect: effect as NonNullable<Toleration["effect"]> }),
64 });
65 }
66 return out;
67 }
68
69 export function placementFromEnv(env: Record<string, string | undefined> = process.env): Placement {
70 if (env["FORGE_CHECKS_PLACEMENT"]?.trim() !== "job") return { mode: "process" };
71 const image = env["FORGE_CHECKS_IMAGE"]?.trim() ?? "";
72 const claim = env["FORGE_CHECKS_CLAIM"]?.trim() ?? "";
73 const nodeSelector = parseSelector(env["FORGE_CHECKS_NODE_SELECTOR"] ?? "");
74 const tolerations = parseTolerations(env["FORGE_CHECKS_TOLERATIONS"]?.trim() || "[]");
75 // A job placement that cannot be built is not guessed at.
76 if (image === "" || /\s/.test(image) || claim === "" || !nodeSelector || !tolerations) return { mode: "process" };
77 return { mode: "job", image, claim, nodeSelector, tolerations };
78 }
79
80 /**
81 * Where a run ran, in plain words, for a person to read. Nothing for the
82 * default `process` or a run that predates the record: only the unusual is
83 * said. The node comes from the cluster, so it is cut to printable ASCII
84 * here as well as escaped by whatever shows it.
85 */
86 export function placementWords(run: { placement: string; node: string }): string {
87 if (run.placement !== "job") return "";
88 const node = run.node.replace(/[^\x21-\x7e]/g, "").slice(0, 253);
89 return node === "" ? "ran as a cluster job" : `ran as a cluster job on ${node}`;
90 }
src/checks/service.ts +75 −30
@@ -71,6 +71,10 @@ import {
7171 type CellSpec,
7272 } from "../apps/contain.ts";
7373 import { summariseCheckFailure } from "./summary.ts";
74 import type { Result } from "../kernel/result.ts";
75 import { placementFromEnv, type Placement } from "./placement.ts";
76 import { runJobStep } from "./job_step.ts";
77 import type { JobOptions } from "../cluster/jobs.ts";
7478 import type { CheckDetail } from "../core/runs.ts";
7579
7680 const log = createLog("checks");
@@ -93,6 +97,13 @@ export interface ChecksOptions {
9397 * @default 0
9498 */
9599 readonly drainMs?: number | undefined;
100 /**
101 * Where a check's commands run (`checks/placement.ts`). @default from the
102 * environment: `process`.
103 */
104 readonly placement?: Placement | undefined;
105 /** The kube client a `job` placement speaks through; tests stand one in. */
106 readonly job?: JobOptions | undefined;
96107 }
97108
98109 export interface ChecksHandle {
@@ -198,6 +209,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
198209 const capacity = (): number =>
199210 capacityCount(app.db, "checks.concurrency", options.concurrency ?? 1);
200211 const gate = new Gate(capacity());
212 const placement = options.placement ?? placementFromEnv();
201213 /** Full gates on land in flight: not slots in the queue, but stopped with it. */
202214 const gates = new Set<AbortController>();
203215 const inFlight = new Map<
@@ -657,15 +669,29 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
657669 // The check is repository code: it runs in a cell of its own
658670 // (`apps/contain.ts`), its checkout and a home its only places to write,
659671 // the forge, the registry and what it declared its only way out.
660 const cells = await cellsAvailable();
672 const job = placement.mode === "job" ? placement : undefined;
661673 if (x.runId !== undefined)
662 noteRunProgress(app.db, x.runId, {
663 sandbox: { enforced: cells.ok, note: cells.ok ? "" : cells.reason },
664 });
665 if (!cells.ok)
666 write(`uncontained here: ${cells.reason}; the check runs unrestricted`);
674 noteRunProgress(app.db, x.runId, { placement: placement.mode });
675 // A Job is its own boundary: no cell here, the cluster holds it.

The cell enforced egress (manifest.egress), limits and a hidden data dir. The Job drops egress and limits with nothing in their place, and the run records no sandbox line, so it reads as calm when it is less contained. Either enforce them (resources, plus a NetworkPolicy label the docs require) or record sandbox: { enforced: false, note: … } so the page says so.

676 const cells = job === undefined ? await cellsAvailable() : undefined;
677 if (cells !== undefined) {
678 if (x.runId !== undefined)
679 noteRunProgress(app.db, x.runId, {
680 sandbox: { enforced: cells.ok, note: cells.ok ? "" : cells.reason },
681 });
682 if (!cells.ok)
683 write(`uncontained here: ${cells.reason}; the check runs unrestricted`);
684 } else {
685 // The cluster holds a Job, but not as a cell does: the egress a
686 // check declared is not enforced here, and the run says so.
687 if (x.runId !== undefined)
688 noteRunProgress(app.db, x.runId, {
689 sandbox: { enforced: false, note: "egress not enforced on a cluster job" },
690 });
691 write("placement: job");
692 }
667693 const home = `${dir}.home`;
668 const cell: CellSpec | undefined = cells.ok
694 const cell: CellSpec | undefined = cells?.ok
669695 ? {
670696 dir,
671697 writable: [],
@@ -701,36 +727,55 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
701727 // plain processes here — as they do on a runner without bwrap.
702728 FORGE_CELLS: "off",
703729 });
704 if (manifest.install !== null) {
705 const installed = await runStep("install", manifest.install, {
706 cwd: dir,
730 let ran: Result<void, string>;
731 const checkLines: string[] = [];
732 if (job !== undefined) {
733 ran = await runJobStep(job, {
734 runId: run.id,
735 dir,
736 commands: manifest.install === null ? [command] : [manifest.install, command],
707737 env,
708738 log: write,
739 output: checkLines,
740 signal,
741 timeoutMs: manifest.timeout * 1000,
742 limits: resolveLimits("step", manifest.limits),
743 onNode: (node) => {
744 if (x.runId !== undefined) noteRunProgress(app.db, x.runId, { node });
745 },
746 ...(options.job === undefined ? {} : { options: options.job }),
747 });
748 } else {
749 if (manifest.install !== null) {
750 const installed = await runStep("install", manifest.install, {
751 cwd: dir,
752 env,
753 log: write,
754 signal,
755 timeoutMs: manifest.timeout * 1000,
756 cell,
757 });
758 if (!installed.ok) return { ok: false, error: installed.error };
759 }
760 // The check step's own lines are kept beside what `write` already
761 // logs, so a failure can be summarised once here — never by reading
762 // the log back, on this run or any later render of it. `runStep`
763 // echoes the command as its first line before any output of the
764 // command's own; that echo is not output to summarise from.
765 let sawEcho = false;
766 ran = await runStep("check", command, {
767 cwd: dir,
768 env,
769 log: (line) => {
770 if (sawEcho) checkLines.push(line);
771 sawEcho = true;
772 write(line);
773 },
709774 signal,
710775 timeoutMs: manifest.timeout * 1000,
711776 cell,
712777 });
713 if (!installed.ok) return { ok: false, error: installed.error };
714778 }
715 // The check step's own lines are kept beside what `write` already
716 // logs, so a failure can be summarised once here — never by reading
717 // the log back, on this run or any later render of it. `runStep`
718 // echoes the command as its first line before any output of the
719 // command's own; that echo is not output to summarise from.
720 const checkLines: string[] = [];
721 let sawEcho = false;
722 const ran = await runStep("check", command, {
723 cwd: dir,
724 env,
725 log: (line) => {
726 if (sawEcho) checkLines.push(line);
727 sawEcho = true;
728 write(line);
729 },
730 signal,
731 timeoutMs: manifest.timeout * 1000,
732 cell,
733 });
734779 if (!ran.ok) {
735780 const { summary, detail } = summariseCheckFailure(
736781 checkLines,
src/cluster/jobs.ts +65 −4
@@ -26,6 +26,12 @@ export type JobSpec = {
2626 env?: Record<string, string>;
2727 nodeSelector?: Record<string, string>;
2828 tolerations?: Toleration[];
29 /** A claim mounted at `mountPath`, which is also the container's working directory. */
30 volume?: { claim: string; mountPath: string; subPath: string };
31 /** Resource ceilings for the container. */
32 limits?: { memoryMiB: number; cpus: number; diskMiB: number };
33 /** Labels on the pod, for a NetworkPolicy to select it by. */
34 labels?: Record<string, string>;
2935 };
3036
3137 export type JobResult = {
@@ -33,6 +39,8 @@ export type JobResult = {
3339 exitCode: number | null;
3440 lines: string[];
3541 timedOut: boolean;
42 /** The node the Job's pod ran on; absent when it never reached one. */
43 node?: string;
3644 };
3745
3846 export type Credentials = { server: string; token: string; ca?: string; namespace: string };
@@ -47,6 +55,8 @@ export type JobOptions = {
4755 sleep?: (ms: number) => Promise<void>;
4856 /** The most log bytes read; the rest of the log is left unread. */
4957 maxLogBytes?: number;
58 /** Aborted: the Job is deleted and the call throws. */
59 signal?: AbortSignal;
5060 };
5161
5262 /** The service account the pod runs as; throws when there is none. */
@@ -73,6 +83,23 @@ export function manifest(spec: JobSpec): object {
7383 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof value !== "string") throw new Error(`bad env ${name}`);
7484 return { name, value };
7585 });
86 const vol = spec.volume;
87 if (
88 vol &&
89 (!NAME.test(vol.claim) ||
90 !/^\/[\w./-]+$/.test(vol.mountPath) ||
91 vol.mountPath.includes("..") ||
92 !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(vol.subPath))
93 )
94 throw new Error("bad volume");
95 const lim = spec.limits;
96 if (lim && ![lim.memoryMiB, lim.cpus, lim.diskMiB].every((n) => Number.isFinite(n) && n > 0))
97 throw new Error("bad limits");
98 const labels: Record<string, string> = {};
outdated

High: this pod runs repository code with the namespace's service-account token auto-mounted, no resources and no securityContext. Add automountServiceAccountToken: false, securityContext: { runAsNonRoot: true, allowPrivilegeEscalation: false } and resources.limits from the check's declared limits. Test each one in cluster-jobs.test.ts.

99 for (const [k, v] of Object.entries(spec.labels ?? {})) {
100 if (!/^[A-Za-z0-9][-A-Za-z0-9_.\/]{0,62}$/.test(k) || !/^[A-Za-z0-9]?[-A-Za-z0-9_.]{0,62}$/.test(v)) throw new Error(`bad label ${k}`);
101 labels[k] = v;
102 }
76103 return {
77104 apiVersion: "batch/v1",
78105 kind: "Job",
@@ -81,12 +108,38 @@ export function manifest(spec: JobSpec): object {
81108 backoffLimit: 0,
82109 ttlSecondsAfterFinished: 600,
83110 template: {
84 metadata: { labels: { "job-name": spec.name } },
111 metadata: { labels: { ...labels, "job-name": spec.name } },
85112 spec: {
86113 restartPolicy: "Never",
114 // Repository code gets no kube credential and no privilege.
115 automountServiceAccountToken: false,
116 securityContext: { runAsNonRoot: true },
87117 nodeSelector: spec.nodeSelector ?? {},
88118 tolerations: spec.tolerations ?? [],
89 containers: [{ name: "job", image: spec.image, command: spec.command, env }],
119 containers: [
120 {
121 name: "job",
122 image: spec.image,
123 command: spec.command,
124 env,
125 securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } },
126 ...(lim
127 ? {
128 resources: {
129 limits: {
130 memory: `${Math.ceil(lim.memoryMiB)}Mi`,
131 cpu: String(lim.cpus),
132 "ephemeral-storage": `${Math.ceil(lim.diskMiB)}Mi`,
133 },
134 },
135 }
136 : {}),
137 ...(vol
138 ? { workingDir: vol.mountPath, volumeMounts: [{ name: "work", mountPath: vol.mountPath, subPath: vol.subPath }] }
139 : {}),
140 },
141 ],
142 ...(vol ? { volumes: [{ name: "work", persistentVolumeClaim: { claimName: vol.claim } }] } : {}),
90143 },
91144 },
92145 },
@@ -97,6 +150,7 @@ export function manifest(spec: JobSpec): object {
97150 type Job = { status?: { succeeded?: number; failed?: number } };
98151 type Pod = {
99152 metadata?: { name?: string };
153 spec?: { nodeName?: string };
100154 status?: { containerStatuses?: { state?: { terminated?: { exitCode?: number } } }[] };
101155 };
102156 type PodList = { items?: Pod[] };
@@ -152,16 +206,20 @@ export async function runJob(spec: JobSpec, opts: JobOptions = {}): Promise<JobR
152206 const deadline = now() + timeoutMs;
153207 let done: "succeeded" | "failed" | null = null;
154208 while (!done) {
209 if (opts.signal?.aborted) throw new Error("job cancelled");
155210 const job = await json<Job>("GET", jobPath);
156211 if ((job.status?.succeeded ?? 0) > 0) done = "succeeded";
157212 else if ((job.status?.failed ?? 0) > 0) done = "failed";
158 else if (now() >= deadline) return { exitCode: null, lines: await logLines(), timedOut: true };
213 else if (now() >= deadline) {
214 const pod = await findPod().catch(() => undefined);
215 return { exitCode: null, lines: await logLines(pod?.metadata?.name), timedOut: true, ...nodeOf(pod) };
216 }
159217 else await sleep(pollMs);
160218 }
161219 const pod = await findPod();
162220 const code: unknown = pod?.status?.containerStatuses?.[0]?.state?.terminated?.exitCode;
163221 const exitCode = typeof code === "number" ? code : done === "succeeded" ? 0 : null;
164 return { exitCode, lines: await logLines(pod?.metadata?.name), timedOut: false };
222 return { exitCode, lines: await logLines(pod?.metadata?.name), timedOut: false, ...nodeOf(pod) };
165223 } finally {
166224 if (created) {
167225 try {
@@ -191,6 +249,9 @@ export async function runJob(spec: JobSpec, opts: JobOptions = {}): Promise<JobR
191249 }
192250 }
193251
252 const nodeOf = (pod?: Pod): { node?: string } =>
253 typeof pod?.spec?.nodeName === "string" && pod.spec.nodeName ? { node: pod.spec.nodeName } : {};
254
194255 /** Read a body chunk by chunk, stopping at `max` bytes; the rest is never held. */
195256 async function readCapped(body: ReadableStream<Uint8Array>, max: number): Promise<string> {
196257 const reader = body.getReader();
src/core/runs.ts +16 −1
@@ -76,6 +76,10 @@ export interface Run {
7676 readonly sandboxEnforced: boolean | null;
7777 /** Why it was not, when it was not. */
7878 readonly sandboxNote: string;
79 /** Where a check ran: `process`, `job`, or empty when unrecorded. */
80 readonly placement: string;
81 /** The node a `job` check's pod ran on; empty otherwise. */
82 readonly node: string;
7983 /** A failed check's own detail, when its output was recognised; null otherwise. */
8084 readonly checkDetail: CheckDetail | null;
8185 /** The variant group this run belongs to; null for an ordinary run. */
@@ -108,6 +112,8 @@ interface RunRow {
108112 sandbox_enforced: number | null;
109113 sandbox_note: string;
110114 check_detail: string;
115 placement: string;
116 node: string;
111117 variant_group: string | null;
112118 created_at: string;
113119 started_at: string | null;
@@ -136,6 +142,8 @@ const toRun = (r: RunRow): Run => ({
136142 sandboxEnforced:
137143 r.sandbox_enforced === null ? null : r.sandbox_enforced !== 0,
138144 sandboxNote: r.sandbox_note,
145 placement: r.placement,
146 node: r.node,
139147 checkDetail: r.check_detail ? (JSON.parse(r.check_detail) as CheckDetail) : null,
140148 variantGroup: r.variant_group,
141149 createdAt: r.created_at,
@@ -342,13 +350,18 @@ export function noteRunProgress(
342350 subscriptionId?: string | undefined;
343351 /** Whether the shell is held to the sandbox, and why not when it is not. */
344352 sandbox?: { enforced: boolean; note: string } | undefined;
353 /** Where a check ran, and the node when a cluster Job did. */
354 placement?: string | undefined;
355 node?: string | undefined;
345356 },
346357 ): void {
347358 db.query(
348359 `UPDATE run SET session_id = COALESCE(?2, session_id), cost_usd = COALESCE(?3, cost_usd),
349360 branch = COALESCE(?4, branch), subscription_id = COALESCE(?5, subscription_id),
350361 sandbox_enforced = COALESCE(?6, sandbox_enforced),
351 sandbox_note = COALESCE(?7, sandbox_note)
362 sandbox_note = COALESCE(?7, sandbox_note),
363 placement = COALESCE(?8, placement),
364 node = COALESCE(?9, node)
352365 WHERE id = ?1`,
353366 ).run(
354367 runId,
@@ -358,6 +371,8 @@ export function noteRunProgress(
358371 progress.subscriptionId ?? null,
359372 progress.sandbox === undefined ? null : progress.sandbox.enforced ? 1 : 0,
360373 progress.sandbox?.note.slice(0, 300) ?? null,
374 progress.placement?.slice(0, 20) ?? null,
375 progress.node?.slice(0, 253) ?? null,
361376 );
362377 }
363378
src/core/schema.ts +6
@@ -1804,6 +1804,12 @@ export const migrations: readonly string[] = [
18041804 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
18051805 ) WITHOUT ROWID;
18061806 `,
1807 // 91 — where a check ran (#1815): `process` (the forge's own cell) or `job`
1808 // (a cluster Job), and the node a Job's pod landed on. Empty: not recorded.
1809 `
1810 ALTER TABLE run ADD COLUMN placement TEXT NOT NULL DEFAULT '';
1811 ALTER TABLE run ADD COLUMN node TEXT NOT NULL DEFAULT '';
1812 `,
18071813 ];
18081814
18091815 /**
src/http/mcp.ts +2
@@ -153,6 +153,7 @@ import { manifestFor } from "../actions/here.ts";
153153 import { clientBucket } from "./peer.ts";
154154 import { fileReport } from "../actions/reports.ts";
155155 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
156 import { placementWords } from "../checks/placement.ts";
156157 import { everydayOf } from "../core/everyday.ts";
157158 import { nextStepsFor } from "../core/next-steps.ts";
158159
@@ -755,6 +756,7 @@ const TOOLS: readonly Tool[] = [
755756 since: r.startedAt ?? r.createdAt,
756757 // What a run costs is the owner's fact (`canSeeOwnerFacts`).
757758 costUsd: owned.has(r.repoId) ? r.costUsd : 0,
759 ...(placementWords(r) === "" ? {} : { ran: placementWords(r) }),

Nit: placementWords(r) is called twice. Keep the result in a const.

758760 };
759761 }),
760762 ]);
src/tui/screens/runlist.ts +2
@@ -11,6 +11,7 @@
1111 * the summary that says what failed.
1212 */
1313
14 import { placementWords } from "../../checks/placement.ts";
1415 import { getIssuesByIds, type Issue } from "../../core/issues.ts";
1516 import type { Repo } from "../../core/repos.ts";
1617 import type { Run, RunState } from "../../core/runs.ts";
@@ -108,6 +109,7 @@ export function runRows(
108109 meta: metaOf(
109110 run.commitOid === "" ? "" : run.commitOid.slice(0, 7),
110111 costText(run.costUsd),
112 placementWords(run),
111113 relTime(runWhen(run)),
112114 ),
113115 };
src/web/studio/run.ts +2 −1
@@ -27,7 +27,7 @@ import { page } from "../layout.ts";
2727 import { btn, sha } from "../ui.ts";
2828 import { previewCard } from "../pages/apps.ts";
2929 import { costText, durationText, runTitle, runTokens } from "../pages/runs.ts";
30 import { sandboxLine } from "./sandbox.ts";
30 import { placementLine, sandboxLine } from "./sandbox.ts";
3131 import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts";
3232 import { changeHref, tellHref } from "./nouns.ts";
3333 import { unnamedRun, voiceFor, type Actor } from "./voice.ts";
@@ -176,6 +176,7 @@ export async function runView(
176176 </p>`,
177177 )}
178178 ${sandboxLine(run)}
179 ${placementLine(run)}
179180 ${when(
180181 held.length > 0,
181182 () =>
src/web/studio/sandbox.ts +7
@@ -6,8 +6,15 @@
66 */
77
88 import type { Run } from "../../core/runs.ts";
9 import { placementWords } from "../../checks/placement.ts";
910 import { html, type Html } from "../html.ts";
1011
12 /** Where a check ran, when that was a cluster job; nothing for the default. */
13 export function placementLine(run: Run): Html | null {
14 const words = placementWords(run);
15 return words === "" ? null : html`<p class="muted small" id="run-placement">${words[0]!.toUpperCase() + words.slice(1)}.</p>`;
16 }
17
1118 export function sandboxLine(run: Run): Html | null {
1219 if (run.sandboxEnforced === null) return null;
1320 const agent = run.kind === "agent";
test/checks-placement.test.ts added +222
@@ -0,0 +1,222 @@
1 /**
2 * `checks.placement` (#1815): `process` is today's check; `job` runs it as a
3 * cluster Job through a stand-in kube client, streams its log into the run
4 * and records the node and the placement.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { parseSelector, parseTolerations, placementFromEnv } from "../src/checks/placement.ts";
13 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
14 import { runRows } from "../src/tui/screens/runlist.ts";
15 import { placementLine } from "../src/web/studio/sandbox.ts";
16 import { runPayload } from "../src/actions/runs.ts";
17 import { placementWords } from "../src/checks/placement.ts";
18 import { listRuns, type Run } from "../src/core/runs.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import type { Repo } from "../src/core/repos.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { readRunLog } from "../src/runs/log.ts";
23
24 const checkJson = (run: string): string => `{"check": {"run": ${JSON.stringify(run)}, "install": ""}}\n`;
25
26 describe("placement from the environment", () => {
27 const job = {
28 FORGE_CHECKS_PLACEMENT: "job",
29 FORGE_CHECKS_IMAGE: "oven/bun:1",
30 FORGE_CHECKS_CLAIM: "checks-root",
31 };
32 test("process is the default, and what is malformed leaves it", () => {
33 expect(placementFromEnv({})).toEqual({ mode: "process" });
34 expect(placementFromEnv({ FORGE_CHECKS_PLACEMENT: "process" })).toEqual({ mode: "process" });
35 expect(placementFromEnv({ FORGE_CHECKS_PLACEMENT: "job" })).toEqual({ mode: "process" });
36 expect(placementFromEnv({ ...job, FORGE_CHECKS_NODE_SELECTOR: "bad key=x" })).toEqual({ mode: "process" });
37 expect(placementFromEnv({ ...job, FORGE_CHECKS_TOLERATIONS: "[{\"effect\":\"Sometimes\"}]" })).toEqual({ mode: "process" });
38 });
39 test("job carries its node selector and tolerations", () => {
40 expect(
41 placementFromEnv({
42 ...job,
43 FORGE_CHECKS_NODE_SELECTOR: "pool=ci, arch=arm64",
44 FORGE_CHECKS_TOLERATIONS: '[{"key":"ci","operator":"Exists","effect":"NoSchedule"}]',
45 }),
46 ).toEqual({
47 mode: "job",
48 image: "oven/bun:1",
49 claim: "checks-root",
50 nodeSelector: { pool: "ci", arch: "arm64" },
51 tolerations: [{ key: "ci", operator: "Exists", effect: "NoSchedule" }],
52 });
53 expect(parseSelector("a")).toBeUndefined();
54 expect(parseTolerations("{}")).toBeUndefined();
55 });
56 });
57
58 describe("checks placed", () => {
59 let dataDir: string;
60 let app: App;
61 let servers: Servers;
62 let checks: ChecksHandle;
63 let alice: User;
64 let repo: Repo;
65 const calls: { method: string; url: string; body?: string }[] = [];
66 let exit = 0;
67
68 // A stand-in kube API: the Job succeeds at once, its pod on node n7.
69 const fetch = async (url: string, init: RequestInit) => {
70 calls.push({ method: String(init.method), url, ...(typeof init.body === "string" ? { body: init.body } : {}) });
71 const path = new URL(url).pathname;
72 if (init.method === "POST" || init.method === "DELETE") return Response.json({});
73 if (path.endsWith("/log")) return new Response("from the job\nsecond line\n");
74 if (path.endsWith("/pods"))
75 return Response.json({
76 items: [
77 {
78 metadata: { name: "p1" },
79 spec: { nodeName: "n7" },
80 status: { containerStatuses: [{ state: { terminated: { exitCode: exit } } }] },
81 },
82 ],
83 });
84 return Response.json({ status: exit === 0 ? { succeeded: 1 } : { failed: 1 } });
85 };
86
87 async function settled(oid: string): Promise<Run> {
88 const deadline = Date.now() + 30_000;
89 while (Date.now() < deadline) {
90 const run = listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find((r) => r.commitOid === oid);
91 if (run && (run.state === "ok" || run.state === "failed")) return run;
92 await Bun.sleep(50);
93 }
94 throw new Error("check did not settle");
95 }
96
97 async function boot(placement: Parameters<typeof startChecks>[1]["placement"]): Promise<void> {
98 dataDir = await mkdtemp(join(tmpdir(), "forge-placement-"));
99 app = createApp({ dataDir, port: 0 });
100 servers = await serve(app);
101 checks = startChecks(app, {
102 forgeUrl: servers.url,
103 concurrency: 1,
104 placement,
105 job: {
106 fetch: fetch as never,
107 credentials: { server: "https://k", token: "tok", namespace: "forge" },
108 pollMs: 1,
109 },
110 });
111 const a = await createUser(app.db, {
112 trusted: true,
113 username: "alice",
114 email: "[email protected]",
115 password: "a-good-passphrase",
116 adminIfFirst: true,
117 });
118 if (!a.ok) throw new Error(a.error);
119 alice = a.value;
120 const made = await createRepo(app, alice, { name: "lib", visibility: "public" });
121 if (!made.ok) throw new Error(String(made.error));
122 repo = made.value;
123 }
124 async function down(): Promise<void> {
125 await checks.stop();
126 servers.stop();
127 app.close();
128 await rm(dataDir, { recursive: true, force: true });
129 }
130 const push = async (run: string) => {
131 const c = await commitToBranch(app, alice, repo, {
132 message: "Declare a check",
133 files: [{ path: "forge.json", content: checkJson(run) }],
134 });
135 if (!c.ok) throw new Error(JSON.stringify(c.error));
136 return c.value.commitOid;
137 };
138
139 describe("job", () => {
140 beforeAll(() =>
141 boot({
142 mode: "job",
143 image: "oven/bun:1",
144 claim: "checks-root",
145 nodeSelector: { pool: "ci" },
146 tolerations: [{ key: "ci", operator: "Exists" }],
147 }),
148 );
149 afterAll(down);
150
151 test("a check runs as a Job: log in the run, node and placement recorded", async () => {
152 const run = await settled(await push("exit 0"));
153 expect(run.state).toBe("ok");
154 expect(run.placement).toBe("job");
155 expect(run.node).toBe("n7");
156 const log = readRunLog(dataDir, run.id).lines.join("\n");
157 expect(log).toContain("from the job");
158 expect(log).toContain("$ exit 0");
159 const made = JSON.parse(calls.find((c) => c.method === "POST")!.body!);
160 const pod = made.spec.template.spec;
161 expect(pod.nodeSelector).toEqual({ pool: "ci" });
162 expect(pod.tolerations).toEqual([{ key: "ci", operator: "Exists" }]);
163 expect(pod.volumes[0].persistentVolumeClaim.claimName).toBe("checks-root");

Nit: this assertion is the same as the line above it. Drop one.

164 expect(pod.volumes[0].persistentVolumeClaim.claimName).toBe("checks-root");
165 expect(pod.containers[0].volumeMounts[0].subPath).toBe(run.id);
166 expect(pod.automountServiceAccountToken).toBe(false);
167 expect(pod.containers[0].resources.limits.memory).toMatch(/Mi$/);
168 expect(made.spec.template.metadata.labels["forge-check"]).toBe("true");
169 expect(run.sandboxEnforced).toBe(false);
170 expect(run.sandboxNote).toBe("egress not enforced on a cluster job");
171 expect(pod.containers[0].image).toBe("oven/bun:1");
172 expect(pod.containers[0].command.at(-1)).toBe("exit 0");
173 expect(calls.some((c) => c.method === "DELETE")).toBe(true);
174 });
175
176 test("the page, the terminal and the events say where a job ran; a process run says nothing", async () => {
177 const run = await settled(await push("exit 0 # page"));
178 expect(run.placement).toBe("job");
179 const page = await (await globalThis.fetch(`${servers.url}/alice/lib/runs/${run.id}`)).text();
180 expect(page).toContain("Ran as a cluster job on n7.");
181 expect(runRows([run], new Map())[0]!.meta).toContain("ran as a cluster job on n7");
182 expect(runPayload(run)).toMatchObject({ placement: "job", node: "n7" });
183 const plain = { ...run, placement: "process", node: "" };
184 expect(placementLine(plain)).toBeNull();
185 expect(runRows([plain], new Map())[0]!.meta).not.toContain("cluster");
186 expect(runPayload(plain)).not.toHaveProperty("node");
187 });
188
189 test("a node name from the cluster is escaped on the page and clean in the terminal", () => {
190 const odd = { placement: "job", node: "<b>n\u001b[31m7" };
191 expect(placementWords(odd)).toBe("ran as a cluster job on <b>n[31m7");
192 expect(String(placementLine({ ...odd } as never))).toContain("&lt;b&gt;");
193 });
194
195 test("a failing Job fails the check, still on its node", async () => {
196 exit = 2;
197 try {
198 const run = await settled(await push("exit 2"));
199 expect(run.state).toBe("failed");
200 expect(run.summary).toContain("second line");
201 expect(run.node).toBe("n7");
202 } finally {
203 exit = 0;
204 }
205 });
206 });
207
208 describe("process", () => {
209 beforeAll(() => boot(undefined));
210 afterAll(down);
211
212 test("the default runs here, calls no cluster and records process", async () => {
213 calls.length = 0;
214 const run = await settled(await push("echo here"));
215 expect(run.state).toBe("ok");
216 expect(run.placement).toBe("process");
217 expect(run.node).toBe("");
218 expect(readRunLog(dataDir, run.id).lines.join("\n")).toContain("here");
219 expect(calls).toHaveLength(0);
220 });
221 });
222 });
test/cluster-jobs.test.ts +38
@@ -86,3 +86,41 @@ test("a log larger than the cap comes back truncated, by bytes, and is asked for
8686 expect(r.lines).toEqual(["ééééé"]);
8787 expect(k.calls.some((c) => c.url.endsWith("/log?limitBytes=10"))).toBe(true);
8888 });
89
90 test("the pod's node is reported, and a volume is mounted where the container works", async () => {
91 const k = kube([{ succeeded: 1 }]);
92 const base = k.fetch;
93 const fetch = async (url: string, init: any) =>
94 new URL(url).pathname.endsWith("/pods")
95 ? Response.json({ items: [{ metadata: { name: "p1" }, spec: { nodeName: "n3" } }] })
96 : base(url, init);
97 const r = await runJob({ ...spec, volume: { claim: "root", mountPath: "/data/checks/x", subPath: "x" } }, { fetch, credentials, ...clock() });
98 expect(r.node).toBe("n3");
99 const pod = JSON.parse(k.calls[0]!.body!).spec.template.spec;
100 expect(pod.containers[0].workingDir).toBe("/data/checks/x");
101 expect(pod.volumes).toEqual([{ name: "work", persistentVolumeClaim: { claimName: "root" } }]);
102 expect(pod.containers[0].volumeMounts).toEqual([{ name: "work", mountPath: "/data/checks/x", subPath: "x" }]);
103 expect(() => manifest({ ...spec, volume: { claim: "root", mountPath: "/a/b", subPath: "../other" } })).toThrow("bad volume");
104 expect(() => manifest({ ...spec, volume: { claim: "root", mountPath: "/a/b", subPath: "a/b" } })).toThrow("bad volume");
105 expect(() => manifest({ ...spec, volume: { claim: "root", mountPath: "/a/../b", subPath: "x" } })).toThrow("bad volume");
106 });
107
108 test("an aborted call deletes the Job and throws", async () => {
109 const k = kube([{ active: 1 }]);
110 const ac = new AbortController();
111 ac.abort();
112 await expect(runJob(spec, { fetch: k.fetch, credentials, signal: ac.signal, ...clock() })).rejects.toThrow("cancelled");
113 expect(deleted(k.calls)).toBe(1);
114 });
115
116 test("the pod holds no service-account token, no privilege, and the declared limits", () => {
117 const pod = (manifest({ ...spec, limits: { memoryMiB: 2048, cpus: 2, diskMiB: 1024 }, labels: { "forge-check": "true" } }) as any).spec.template;
118 expect(pod.spec.automountServiceAccountToken).toBe(false);
119 expect(pod.spec.securityContext).toEqual({ runAsNonRoot: true });
120 const c = pod.spec.containers[0];
121 expect(c.securityContext).toEqual({ allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } });
122 expect(c.resources.limits).toEqual({ memory: "2048Mi", cpu: "2", "ephemeral-storage": "1024Mi" });
123 expect(pod.metadata.labels).toEqual({ "forge-check": "true", "job-name": "build-1" });
124 expect(() => manifest({ ...spec, limits: { memoryMiB: 0, cpus: 1, diskMiB: 1 } })).toThrow("bad limits");
125 expect(() => manifest({ ...spec, labels: { "a b": "c" } })).toThrow("bad label");
126 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs