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

An app that names Rust gets cargo on its install, test and run steps from a pinned cached toolchain, and a Rust hello-world app serves #10592

Merged opened this

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

Merged

scout/forge:scout/10525-an-app-that-names-rust-gets-cargo-on-its → main 1 commit fork

  • Merged. scout/10525-an-app-that-names-rust-gets-cargo-on-its was merged into main as 119d806.

Review

Sign in to review this change.
9 files changed +299 −7
docs/APPS.md +2 −2
@@ -553,8 +553,8 @@ Only `text/html` responses the app sends uncompressed with a `Content-Length`
553553 `src/apps/toolchain.ts`. An unknown toolchain, a version that is not
554554 `major.minor`, or one this forge does not provide fails the deploy in one
555555 line ("rust 1.70 is not provided here; this forge has rust 1.82"). Without the
556 field nothing changes. For now the field is only read and checked; nothing is
557 installed from it yet.
556 field nothing changes. The pinned toolchain is put on `PATH` for the app's install, test and run
557 steps only ([`docs/features/toolchains.md`](features/toolchains.md)).
558558
559559 `adapter`, at the top level, says the app is an adapter the forge pulls
560560 from on a schedule (#437): `{ "adapter": { "schedule": { "every": 900 } } }`.
docs/features/toolchains.md added +32
@@ -0,0 +1,32 @@
1 # Pinned toolchains (rust)
2
3 An app names a toolchain in `forge.json`: `{ "toolchain": { "rust": "1.82" } }`.
4 Its **install** and **test** steps and its **run** command then see `cargo` and
5 `rustc` on `PATH`, with `CARGO_HOME` and `RUSTUP_HOME` set. Apps that do not
6 name it see none of this.
7
8 ## Where it lives (and why)
9
10 A directory on the host, `$FORGE_TOOLCHAINS/rust/<version>/`, mounted read-only
11 in the cell. Chosen over a nix store path because it needs no daemon and no new
12 dependency: an admin unpacks a release once, and every deploy only reads it.
13 Nothing is downloaded per build: resolving a pin is a file check
14 (`toolchainUse`, `src/apps/toolchain.ts`), and the test asserts two deploys make
15 no fetch. `CARGO_HOME` is a writable `.cargo` in the environment's home (the
16 toolchain stays read-only); `RUSTUP_HOME` points into the toolchain.
17 The default install step is `cargo build --release --offline`; a dependency
18 fetch is the app's own, through its declared `egress`.
19
20 Layout: `bin/` (cargo, rustc, direct binaries, not rustup proxies), `rustup/`.
21
22 ## An admin adds a Rust version
23
24 1. Unpack a standalone Rust release (`rust-<ver>-x86_64-unknown-linux-gnu`) and
25 run its `install.sh --prefix=$FORGE_TOOLCHAINS/rust/<ver>`; `bin/cargo` must
26 exist there. Make it read-only for the forge's user.
27 2. Add the `major.minor` string to `TOOLCHAINS.rust` in `src/apps/toolchain.ts`.
28 3. A pin the host lacks fails the deploy at install: "rust 1.82 is pinned but
29 not installed on this host".
30
31 The fixture `test/fixtures/rust-hello/` is the minimal app; its test is skipped
32 by name where `cargo` is absent.
src/apps/process.ts +19 −5
@@ -20,6 +20,7 @@ import { isFeatureVarName } from "./feature_env.ts";
2020 import { err, ok, type Result } from "../kernel/result.ts";
2121 import { spellings, stripForgeDirs } from "../runs/paths.ts";
2222 import { seconds } from "./timing.ts";
23 import { toolchainUse, type ToolchainUse } from "./toolchain.ts";
2324 import { notFoundHint, shellCommand } from "./run_command.ts";
2425 import { cellInstall, meteredInstall, prefillInstallCache, withLock } from "../checks/install.ts";
2526 import {
@@ -175,6 +176,8 @@ export function appEnv(input: {
175176 feature?: Record<string, string> | undefined;
176177 /** A read-only forge token for this process (`apps/token.ts`). */
177178 forgeToken?: string | undefined;
179 /** A pinned toolchain's PATH and homes (`apps/toolchain.ts`). */
180 toolchain?: Record<string, string> | undefined;
178181 }): Record<string, string> {
179182 const pull = envPull(input.env);
180183 // The operator's variables go underneath everything the forge names, so
@@ -185,6 +188,7 @@ export function appEnv(input: {
185188 ...operatorVars(input.vars),
186189 ...featureVars(input.feature),
187190 ...childEnv({
191 ...input.toolchain,
188192 PORT: String(input.port),
189193 HOST: "127.0.0.1",
190194 NODE_ENV: "production",
@@ -399,10 +403,11 @@ export async function runStep(
399403 }
400404
401405 /** The cell a step of this deploy runs in: the checkout and the home, the registry and what the app declared. */
402 export function stepCell(input: StartInput): CellSpec | undefined {
406 export function stepCell(input: StartInput, use?: ToolchainUse): CellSpec | undefined {
403407 const base = input.cell;
404408 if (base === undefined) return undefined;
405409 return {
410 ...(use === undefined ? {} : { reveal: use.reveal }),
406411 dir: input.dir,
407412 writable: [],
408413 home: base.home,
@@ -443,9 +448,10 @@ export function installPlan(
443448 input: StartInput,
444449 env: Record<string, string>,
445450 filled: boolean,
451 use?: ToolchainUse,
446452 ): InstallPlan {
447453 const cache = input.installCache;
448 const cell = stepCell(input);
454 const cell = stepCell(input, use);
449455 if (cache === undefined || !filled) return { command, env, cell, lock: undefined };
450456 if (cell !== undefined)
451457 return { ...cellInstall(command, env, cache), cell: { ...cell, reveal: [...(cell.reveal ?? []), cache] }, lock: undefined };
@@ -461,6 +467,7 @@ async function install(
461467 command: string,
462468 input: StartInput,
463469 env: Record<string, string>,
470 use?: ToolchainUse,
464471 ): Promise<Result<void, string>> {
465472 let filled = false;
466473 if (input.installCache !== undefined) {
@@ -475,7 +482,7 @@ async function install(
475482 if (fill.ok) filled = fill.value;
476483 else input.log(`cache not filled: ${fill.error}`);
477484 }
478 const plan = installPlan(command, input, env, filled);
485 const plan = installPlan(command, input, env, filled, use);
479486 const step = (): Promise<Result<void, string>> =>
480487 runStep("install", plan.command, { cwd: input.dir, env: plan.env, log: input.log, signal: input.signal, cell: plan.cell });
481488 if (plan.lock !== undefined) {
@@ -499,7 +506,12 @@ export async function startProcess(
499506 input: StartInput,
500507 ): Promise<Result<StartedProcess, StartError>> {
501508 const { manifest, log } = input;
509 const scratch = input.cell?.home ?? input.dir;
510 const used = toolchainUse(manifest.toolchain, scratch);
511 if (!used.ok) return err({ stage: "install", message: used.error });
512 const use = used.value;
502513 const stepEnv = childEnv({
514 ...use?.env,
503515 CI: "1",
504516 FORGE_APP_ENV: input.env === "production" ? "production" : "preview",
505517 // A mirror the forge itself is pointed at is the one the fill and the
@@ -508,7 +520,7 @@ export async function startProcess(
508520 });
509521
510522 if (manifest.install !== null && input.warm !== true) {
511 const installed = await install(manifest.install, input, stepEnv);
523 const installed = await install(manifest.install, input, stepEnv, use);
512524 if (!installed.ok)
513525 return err({ stage: "install", message: installed.error });
514526 }
@@ -532,7 +544,7 @@ export async function startProcess(
532544 env: stepEnv,
533545 log,
534546 signal: testAbort.signal,
535 cell: stepCell(input),
547 cell: stepCell(input, use),
536548 });
537549
538550 // The port is chosen, then released, then bound by the app: another
@@ -552,6 +564,7 @@ export async function startProcess(
552564 writable: [input.dataDir],
553565 home: input.cell.home,
554566 hide: input.cell.hide,
567 ...(use === undefined ? {} : { reveal: use.reveal }),
555568 egress: manifest.egress ?? [],
556569 granted: input.cell.granted,
557570 reach: [input.forgeUrl],
@@ -565,6 +578,7 @@ export async function startProcess(
565578 );
566579 const command = shellCommand(manifest.run);
567580 const env = appEnv({
581 toolchain: use?.env,
568582 port,
569583 dataDir: input.dataDir,
570584 env: input.env,
src/apps/toolchain.ts +45
@@ -43,3 +43,48 @@ export function parseToolchain(value: unknown): Result<ToolchainPin | undefined,
4343 }
4444 return ok(Object.keys(pin).length === 0 ? undefined : pin);
4545 }
46
47 /**
48 * Where the host keeps its toolchains: `$FORGE_TOOLCHAINS/<name>/<version>/`,
49 * read-only, laid out as `bin/` (cargo, rustc), `cargo/` and `rustup/`.
50 * An admin adds a version by unpacking it there (docs/features/toolchains.md);
51 * nothing is ever fetched at deploy time.
52 */
53 export function toolchainRoot(): string | undefined {
54 const root = process.env["FORGE_TOOLCHAINS"];
55 return root === undefined || root === "" ? undefined : root;
56 }
57
58 /** What a pinned toolchain adds to a step or the app: env, and the directory a cell must show. */
59 export interface ToolchainUse {
60 readonly env: Record<string, string>;
61 readonly reveal: readonly string[];
62 }
63
64 /**
65 * The environment for a pin, from files already on the host. Synchronous
66 * and offline by construction: it only checks the directory is there.
67 * `scratch` is a writable place for CARGO_HOME (the registry cache and
68 * locks cargo writes), since the toolchain itself is read-only.
69 */
70 export function toolchainUse(
71 pin: ToolchainPin | undefined,
72 scratch: string,
73 root: string | undefined = toolchainRoot(),
74 ): Result<ToolchainUse | undefined, string> {
75 const version = pin?.rust;
76 if (version === undefined) return ok(undefined);
77 const dir = root === undefined ? undefined : `${root.replace(/\/+$/, "")}/rust/${version}`;
78 if (dir === undefined || !Bun.file(`${dir}/bin/cargo`).size)
79 return err(`rust ${version} is pinned but not installed on this host`);
80 return ok({
81 env: {
82 PATH: `${dir}/bin:${process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin"}`,
83 CARGO_HOME: `${scratch.replace(/\/+$/, "")}/.cargo`,
84 RUSTUP_HOME: `${dir}/rustup`,
85 // Never let a proxy binary go and fetch another toolchain.
86 RUSTUP_TOOLCHAIN: version,
87 },
88 reveal: [dir],
89 });
90 }
test/fixtures/rust-hello/.gitignore added +1
@@ -0,0 +1,1 @@
1 target/
test/fixtures/rust-hello/Cargo.toml added +6
@@ -0,0 +1,6 @@
1 [package]
2 name = "rust-hello"
3 version = "0.1.0"
4 edition = "2021"
5
6 [dependencies]
test/fixtures/rust-hello/forge.json added +9
@@ -0,0 +1,9 @@
1 {
2 "toolchain": { "rust": "1.82" },
3 "app": {
4 "install": "cargo build --release --offline",
5 "run": "./target/release/rust-hello",
6 "health": "/",
7 "ready": 30
8 }
9 }
test/fixtures/rust-hello/src/main.rs added +19
@@ -0,0 +1,19 @@
1 use std::io::{Read, Write};
2 use std::net::TcpListener;
3
4 fn main() {
5 let port = std::env::var("PORT").expect("PORT");
6 let listener = TcpListener::bind(format!("127.0.0.1:{port}")).expect("bind");
7 for stream in listener.incoming().flatten() {
8 let mut stream = stream;
9 let mut buf = [0u8; 1024];
10 let _ = stream.read(&mut buf);
11 let body = "hello from rust\n";
12 let _ = write!(
13 stream,
14 "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
15 body.len(),
16 body
17 );
18 }
19 }
test/toolchain-rust.test.ts added +166
@@ -0,0 +1,166 @@
1 import { afterEach, describe, expect, test } from "bun:test";
2 import { chmod, cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { parseManifest } from "../src/apps/manifest.ts";
6 import { startProcess } from "../src/apps/process.ts";
7 import { toolchainUse } from "../src/apps/toolchain.ts";
8
9 const dirs: string[] = [];
10 const tmp = async (): Promise<string> => {
11 const d = await mkdtemp(join(tmpdir(), "forge-rust-"));
12 dirs.push(d);
13 return d;
14 };
15 afterEach(async () => {
16 for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true });
17 });
18
19 const start = (dir: string, json: string, lines: string[]) => {
20 const m = parseManifest(json, undefined);
21 if (!m.ok) throw new Error(JSON.stringify(m.error));
22 return startProcess({
23 dir,
24 dataDir: dir,
25 manifest: m.value,
26 env: "production",
27 url: "http://app.test",
28 commitOid: "0".repeat(40),
29 forgeUrl: "http://forge.test",
30 identityKey: "key",
31 log: (l) => void lines.push(l),
32 signal: new AbortController().signal,
33 });
34 };
35
36 /** A toolchain root whose `cargo` is a script that says where it was told to live. */
37 async function fakeRoot(): Promise<string> {
38 const root = await tmp();
39 const bin = join(root, "rust", "1.82", "bin");
40 await mkdir(bin, { recursive: true });
41 await writeFile(
42 join(bin, "cargo"),
43 '#!/bin/sh\necho "cargo CARGO_HOME=$CARGO_HOME RUSTUP_HOME=$RUSTUP_HOME"\n',
44 );
45 await chmod(join(bin, "cargo"), 0o755);
46 return root;
47 }
48
49 describe("a pinned rust toolchain", () => {
50 test("is put on PATH with CARGO_HOME and RUSTUP_HOME, read-only dir revealed", async () => {
51 const root = await fakeRoot();
52 const r = toolchainUse({ rust: "1.82" }, "/scratch", root);
53 expect(r.ok && r.value?.env["PATH"]).toStartWith(join(root, "rust", "1.82", "bin") + ":");
54 expect(r.ok && r.value?.env["CARGO_HOME"]).toBe("/scratch/.cargo");
55 expect(r.ok && r.value?.env["RUSTUP_HOME"]).toBe(join(root, "rust", "1.82", "rustup"));
56 expect(r.ok && r.value?.reveal).toEqual([join(root, "rust", "1.82")]);
57 });
58
59 test("no pin adds nothing; a pin the host lacks is one plain line", async () => {
60 expect(toolchainUse(undefined, "/s", "/nowhere")).toEqual({ ok: true, value: undefined });
61 const r = toolchainUse({ rust: "1.82" }, "/s", "/nowhere");
62 expect(!r.ok && r.error).toBe("rust 1.82 is pinned but not installed on this host");
63 });
64
65 test("install and run see cargo only when the app names rust", async () => {
66 const root = await fakeRoot();
67 const old = process.env["FORGE_TOOLCHAINS"];
68 process.env["FORGE_TOOLCHAINS"] = root;
69 const realFetch = globalThis.fetch;
70 let fetched = 0;
71 // Only the loopback readiness probe may use fetch; anything else is a download.
72 globalThis.fetch = ((input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
73 if (String(input).startsWith("http://127.0.0.1:")) return realFetch(input, init);
74 fetched++;
75 throw new Error("no network");
76 }) as unknown as typeof fetch;
77 try {
78 const server = "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch:()=>new Response('ok')})";
79 for (let i = 0; i < 2; i++) {
80 const dir = await tmp();
81 await writeFile(join(dir, "server.ts"), server);
82 const lines: string[] = [];
83 const s = await start(
84 dir,
85 JSON.stringify({
86 toolchain: { rust: "1.82" },
87 app: { install: "cargo > out.txt", run: "bun server.ts", health: "/", ready: 8 },
88 }),
89 lines,
90 );
91 expect(s.ok ? "" : s.error.message + lines.join("\n")).toBe("");
92 if (!s.ok) return;
93 await s.value.handle.stop();
94 expect(await readFile(join(dir, "out.txt"), "utf8")).toContain("RUSTUP_HOME=");
95 }
96 expect(fetched).toBe(0);
97
98 // An app that does not name rust sees no cargo.
99 const dir = await tmp();
100 await writeFile(join(dir, "server.ts"), server);
101 const s = await start(
102 dir,
103 JSON.stringify({ app: { install: "echo $PATH:$CARGO_HOME > out.txt", run: "bun server.ts", health: "/", ready: 8 } }),
104 [],
105 );
106 expect(s.ok).toBe(true);
107 if (!s.ok) return;
108 await s.value.handle.stop();
109 expect(await readFile(join(dir, "out.txt"), "utf8")).not.toContain(root);
110 } finally {
111 globalThis.fetch = realFetch;
112 if (old === undefined) delete process.env["FORGE_TOOLCHAINS"];
113 else process.env["FORGE_TOOLCHAINS"] = old;
114 }
115 }, 60_000);
116
117 test("an unavailable pin fails the install stage in one line", async () => {
118 const old = process.env["FORGE_TOOLCHAINS"];
119 delete process.env["FORGE_TOOLCHAINS"];
120 try {
121 const dir = await tmp();
122 const s = await start(dir, JSON.stringify({ toolchain: { rust: "1.82" }, app: { run: "true" } }), []);
123 expect(!s.ok && s.error.stage).toBe("install");
124 expect(!s.ok && s.error.message).toContain("not installed on this host");
125 } finally {
126 if (old !== undefined) process.env["FORGE_TOOLCHAINS"] = old;
127 }
128 });
129 });
130
131 // The hello-world needs a real cargo; where the check host has none, this is
132 // reported as skipped by name, never passed.
133 const sysroot = (() => {
134 if (Bun.which("cargo") === null || Bun.which("rustc") === null) return undefined;
135 const p = Bun.spawnSync(["rustc", "--print", "sysroot"]);
136 return p.exitCode === 0 ? p.stdout.toString().trim() : undefined;
137 })();
138
139 describe("rust hello-world fixture", () => {
140 (sysroot === undefined ? test.skip : test)(
141 "deploys with cargo build in install and answers /",
142 async () => {
143 const root = await tmp();
144 const version = join(root, "rust", "1.82");
145 await mkdir(version, { recursive: true });
146 await symlink(join(sysroot ?? "", "bin"), join(version, "bin"));
147 const old = process.env["FORGE_TOOLCHAINS"];
148 process.env["FORGE_TOOLCHAINS"] = root;
149 try {
150 const dir = await tmp();
151 await cp(join(import.meta.dir, "fixtures", "rust-hello"), dir, { recursive: true });
152 const lines: string[] = [];
153 const json = JSON.parse(await readFile(join(dir, "forge.json"), "utf8"));
154 const s = await start(dir, JSON.stringify(json), lines);
155 if (!s.ok) throw new Error(`${s.error.message}\n${lines.join("\n")}`);
156 const res = await fetch(`http://127.0.0.1:${s.value.handle.port}/`);
157 expect(await res.text()).toBe("hello from rust\n");
158 await s.value.handle.stop();
159 } finally {
160 if (old === undefined) delete process.env["FORGE_TOOLCHAINS"];
161 else process.env["FORGE_TOOLCHAINS"] = old;
162 }
163 },
164 120_000,
165 );
166 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs