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

App previews install from the repository's shared bun cache, so a new preview environment downloads nothing it already has #7662

Merged opened this

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

Merged

scout/forge:scout/7544-app-previews-install-from-the-repository → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/7544-app-previews-install-from-the-repository was merged into main as a425d66.

Review

Sign in to review this change.
5 files changed +390 −8
docs/APPS.md +19
@@ -698,6 +698,25 @@ into it take turns under a lock file, so two never extract one package
698698 into the same directory. A `job` check installs in its own pod and is not
699699 retried.
700700
701 **An app environment installs from that same cache** (`src/apps/process.ts`,
702 #7544). Before a cold checkout's `install`, production or a preview, the
703 forge fills the repository's cache on the host from the checkout's
704 `bun.lock`, the way checks fill it. The install then reads the cache:
705 read-only and copying inside a cell (`BUN_INSTALL_CACHE_DIR`), or under the
706 lock file without one. The environment's home stays its own, and a new
707 preview fetches nothing the cache already holds. A checkout with no
708 `bun.lock`, or one the fill does not trust, installs into its home as
709 before; so does one whose fill fails (the registry unreachable, the lock
710 cancelled, a timeout): the cache is an optimisation, and its failure is a
711 `cache not filled:` line in the deploy's log, never a failed deploy. A warm
712 start still runs no install.
713
714 Uncontained, the install holds the cache's lock for its whole run, since
715 an install outside a cell (its own lifecycle scripts included) could write
716 the cache. The cost: two cold environments of one repository install one
717 after the other there. Inside a cell the cache is read-only, so only the
718 fill takes the lock and installs run side by side.
719
701720 **Placement.** `FORGE_CHECKS_PLACEMENT=process` (the default) runs a check
702721 in a cell on the forge's machine. `job` runs install and check as one
703722 Kubernetes Job (`src/cluster/jobs.ts`) instead: `FORGE_CHECKS_IMAGE` is its
src/apps/process.ts +80 −7
@@ -19,6 +19,7 @@ import { isReservedAppEnvName } from "../core/app_env.ts";
1919 import { err, ok, type Result } from "../kernel/result.ts";
2020 import { spellings, stripForgeDirs } from "../runs/paths.ts";
2121 import { seconds } from "./timing.ts";
22 import { cellInstall, prefillInstallCache, withLock } from "../checks/install.ts";
2223 import {
2324 commandPid,
2425 killTree,
@@ -71,6 +72,14 @@ export interface StartInput {
7172 * start — the forge coming back, or an environment waking — only runs it.
7273 */
7374 readonly warm?: boolean | undefined;
75 /**
76 * The repository's shared bun cache (`checks/install.ts`
77 * `checkInstallCache`), the one its checks fill (#7544). The forge fills
78 * it on the host from the checkout's lockfile, then the install reads it:
79 * read-only inside a cell, under its lock without one. A checkout the
80 * fill will not trust (no `bun.lock`) installs as before, into its home.
81 */
82 readonly installCache?: string | undefined;
7483 }
7584
7685 /** What a caller knows about a cell; the rest comes from the manifest. */
@@ -380,6 +389,73 @@ export function stepCell(input: StartInput): CellSpec | undefined {
380389 };
381390 }
382391
392 /** The forge's own `BUN_CONFIG_REGISTRY`, when its environment names one. */
393 function forgeRegistry(): Record<string, string> {
394 const registry = process.env["BUN_CONFIG_REGISTRY"];
395 return registry === undefined || registry === "" ? {} : { BUN_CONFIG_REGISTRY: registry };
396 }
397
398 /** How an install step runs: plain data, so its composition is testable without a cell. */
399 export interface InstallPlan {
400 readonly command: string;
401 readonly env: Record<string, string>;
402 readonly cell: CellSpec | undefined;
403 /** The lock it holds while it runs, if any. */
404 readonly lock: string | undefined;
405 }
406
407 /**
408 * The install once the fill has answered (#7544): from the repository's
409 * shared cache when it `filled`, else into the environment's own home as
410 * before. Inside a cell only the cache is added to its view, read-only;
411 * uncontained, the install could write the cache, so it takes its turn
412 * under the cache's lock.
413 */
outdated

A failed fill (err) returns here and fails the deploy at the install stage. Log the error and fall through to the home install instead: if (!fill.ok) { input.log(cache not filled: ${fill.error}); return step({ command, env }, stepCell(input)); }, then the !fill.value case follows the same path. Test it.

414 export function installPlan(
415 command: string,
416 input: StartInput,
417 env: Record<string, string>,
418 filled: boolean,
419 ): InstallPlan {
420 const cache = input.installCache;
421 const cell = stepCell(input);
422 if (cache === undefined || !filled) return { command, env, cell, lock: undefined };
423 if (cell !== undefined)
424 return { ...cellInstall(command, env, cache), cell: { ...cell, reveal: [...(cell.reveal ?? []), cache] }, lock: undefined };
425 return { command, env: { ...env, BUN_INSTALL_CACHE_DIR: cache }, cell: undefined, lock: `${cache}.lock` };
426 }
427
428 /**
429 * The install step. The cache is an optimisation: a fill that fails (the
430 * registry unreachable, the lock cancelled, a timeout) is logged and the
431 * install runs into the environment's home, as it did before the cache.
432 */
433 async function install(
434 command: string,
435 input: StartInput,
436 env: Record<string, string>,
437 ): Promise<Result<void, string>> {
438 let filled = false;
439 if (input.installCache !== undefined) {
440 const fill = await prefillInstallCache({
441 cache: input.installCache,
442 dir: input.dir,
443 env,
444 log: input.log,
445 signal: input.signal,
446 timeoutMs: STEP_TIMEOUT_MS,
447 });
448 if (fill.ok) filled = fill.value;
449 else input.log(`cache not filled: ${fill.error}`);
450 }
451 const plan = installPlan(command, input, env, filled);
452 const step = (): Promise<Result<void, string>> =>
453 runStep("install", plan.command, { cwd: input.dir, env: plan.env, log: input.log, signal: input.signal, cell: plan.cell });
454 if (plan.lock === undefined) return step();
455 const held = await withLock(plan.lock, step, { signal: input.signal });
456 return held.ok ? held.value : held;
457 }
458
383459 /**
384460 * Install, then start the app and run its tests side by side, and wait for
385461 * the app to answer on its port. Returns as soon as it does — `tested`
@@ -394,16 +470,13 @@ export async function startProcess(
394470 const stepEnv = childEnv({
395471 CI: "1",
396472 FORGE_APP_ENV: input.env === "production" ? "production" : "preview",
473 // A mirror the forge itself is pointed at is the one the fill and the
474 // install fetch from (`checks/install.ts` DEFAULT_REGISTRY otherwise).
475 ...forgeRegistry(),
397476 });
398477
399478 if (manifest.install !== null && input.warm !== true) {
400 const installed = await runStep("install", manifest.install, {
401 cwd: input.dir,
402 env: stepEnv,
403 log,
404 signal: input.signal,
405 cell: stepCell(input),
406 });
479 const installed = await install(manifest.install, input, stepEnv);
407480 if (!installed.ok)
408481 return err({ stage: "install", message: installed.error });
409482 }
src/apps/runtime.ts +4
@@ -32,6 +32,7 @@ import { mintAppToken, renewAppToken, revokeAppToken } from "./token.ts";
3232 import type { ForgeEvent } from "../core/events.ts";
3333 import { announceRun } from "../actions/runs.ts";
3434 import { requeueAbandonedChecks } from "../checks/service.ts";
35 import { checkInstallCache } from "../checks/install.ts";
3536 import { getPull, listPulls, type Pull } from "../core/pulls.ts";
3637 import { pullMayRun } from "../core/pull_trust.ts";
3738 import { canEnterApp, getRepoById, type Repo } from "../core/repos.ts";
@@ -1403,6 +1404,9 @@ export class AppsRuntime {
14031404 log: write,
14041405 warm,
14051406 cell: this.cellFor(state.app, state.env),
1407 // Every environment, production too, installs from the cache the
1408 // repository's checks fill, so a new preview fetches nothing it has.
1409 installCache: checkInstallCache(this.app.config.dataDir, repo.id),
14061410 };
14071411 this.noteCell(run.id, write);
14081412 clock.lap("setup");
test/app-install-cache.test.ts added +239
@@ -0,0 +1,239 @@
1 /**
2 * An app environment installs from its repository's shared bun cache
3 * (#7544): the forge fills it on the host from the lockfile, as checks do,
4 * so a second environment of the same app at the same lockfile — a new
5 * preview — installs with its registry dead. A checkout with no `bun.lock`
6 * installs as it always did, into its own home.
7 */
8 import { describe, expect, setDefaultTimeout, test } from "bun:test";
9 import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import type { AppManifest } from "../src/apps/manifest.ts";
14 import { installPlan, startProcess, type StartInput } from "../src/apps/process.ts";
15
16 setDefaultTimeout(60_000);
17
18 const manifest: AppManifest = {
19 run: `${process.execPath} server.ts`,
20 install: "bun install --frozen-lockfile",
21 test: null,
22 health: "/",
23 ready: 10,
24 tty: null,
25 service: null,
26 looks: ["/"],
27 };
28
29 const SERVER = `Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response("ok") });\n`;
30
31 /** A registry on loopback serving one package, `[email protected]`; counts its requests. */
32 async function registry(dir: string): Promise<{ url: string; hits: string[]; stop: () => void }> {
33 mkdirSync(join(dir, "pkg", "package"), { recursive: true });
34 writeFileSync(join(dir, "pkg", "package", "package.json"), JSON.stringify({ name: "fixture-pkg", version: "1.0.0" }));
35 const made = Bun.spawnSync(["tar", "-czf", join(dir, "fixture.tgz"), "-C", join(dir, "pkg"), "package"]);
36 if (made.exitCode !== 0) throw new Error("tar is needed for the fixture registry");
37 const tarball = new Uint8Array(await Bun.file(join(dir, "fixture.tgz")).arrayBuffer());
38 const hits: string[] = [];
39 const server: ReturnType<typeof Bun.serve> = Bun.serve({
40 port: 0,
41 hostname: "127.0.0.1",
42 fetch(req): Response {
43 const path = new URL(req.url).pathname;
44 hits.push(path);
45 if (path === "/fixture-pkg/-/fixture-pkg-1.0.0.tgz") return new Response(tarball);
46 if (path === "/fixture-pkg")
47 return Response.json({
48 name: "fixture-pkg",
49 "dist-tags": { latest: "1.0.0" },
50 versions: {
51 "1.0.0": {
52 name: "fixture-pkg",
53 version: "1.0.0",
54 dist: { tarball: `http://127.0.0.1:${server.port}/fixture-pkg/-/fixture-pkg-1.0.0.tgz` },
55 },
56 },
57 });
58 return new Response("not found", { status: 404 });
59 },
60 });
61 return { url: `http://127.0.0.1:${server.port}/`, hits, stop: () => void server.stop(true) };
62 }
63
64 /** An app's checkout depending on the fixture, with the lockfile bun writes against it. */
65 async function checkout(dir: string, reg: string): Promise<string> {
66 const co = join(dir, "first");
67 mkdirSync(co);
68 writeFileSync(join(co, "package.json"), JSON.stringify({ name: "x", version: "1.0.0", dependencies: { "fixture-pkg": "1.0.0" } }));
69 writeFileSync(join(co, "server.ts"), SERVER);
70 // Async: the fixture registry answers from this process's event loop.
71 const made = Bun.spawn(["bun", "install", "--lockfile-only"], {
72 cwd: co,
73 env: { PATH: process.env["PATH"] ?? "", HOME: dir, BUN_CONFIG_REGISTRY: reg, BUN_INSTALL_CACHE_DIR: join(dir, "lock-cache") },
74 stdout: "pipe",
75 stderr: "pipe",
76 });
77 if ((await made.exited) !== 0) throw new Error(`fixture lockfile: ${await new Response(made.stderr).text()}`);
78 return co;
79 }
80
81 /** Run `body` with the forge's own environment pointing bun at `url`, as an operator's mirror would. */
82 async function withRegistry<T>(url: string, body: () => Promise<T>): Promise<T> {
83 const before = process.env["BUN_CONFIG_REGISTRY"];
84 process.env["BUN_CONFIG_REGISTRY"] = url;
85 try {
86 return await body();
87 } finally {
88 if (before === undefined) delete process.env["BUN_CONFIG_REGISTRY"];
89 else process.env["BUN_CONFIG_REGISTRY"] = before;
90 }
91 }
92
93 async function start(dir: string, extra: Partial<StartInput>, lines: string[]): Promise<boolean> {
94 const started = await startProcess({
95 dir,
96 dataDir: dir,
97 manifest,
98 env: "production",
99 url: "http://app.test",
100 commitOid: "0".repeat(40),
101 forgeUrl: "http://forge.test",
102 identityKey: "key",
103 log: (line) => lines.push(line),
104 signal: new AbortController().signal,
105 ...extra,
106 });
107 if (started.ok) await started.value.handle.stop();
108 return started.ok;
109 }
110
111 describe("an app environment's install (#7544)", () => {
112 test("a second environment at the same lockfile installs from the shared cache with the registry dead", async () => {
113 const root = await mkdtemp(join(tmpdir(), "app-install-cache-"));
114 const reg = await registry(root);
115 try {
116 const first = await checkout(root, reg.url);
117 const second = join(root, "second");
118 cpSync(first, second, { recursive: true });
119 const cache = join(root, "shared", "bun");
120 const lines: string[] = [];
121
122 expect(await withRegistry(reg.url, () => start(first, { installCache: cache }, lines))).toBe(true);
123 expect(reg.hits).toContain("/fixture-pkg/-/fixture-pkg-1.0.0.tgz");
124 expect(existsSync(join(first, "node_modules", "fixture-pkg", "package.json"))).toBe(true);
125 // The repository's cache holds it, not the environment's home.
126 expect(readdirSync(cache).some((name) => name.startsWith("fixture-pkg@"))).toBe(true);
127
128 reg.stop();
129 reg.hits.length = 0;
130 const again: string[] = [];
131 expect(await withRegistry(reg.url, () => start(second, { installCache: cache }, again))).toBe(true);
132 expect(existsSync(join(second, "node_modules", "fixture-pkg", "package.json"))).toBe(true);
133 expect(reg.hits).toEqual([]);
134 expect(existsSync(`${cache}.lock`)).toBe(false);
135 } finally {
136 reg.stop();
137 await rm(root, { recursive: true, force: true });
138 }
139 });
140
141 test("a checkout with no bun.lock installs as before, without the shared cache", async () => {
142 const root = await mkdtemp(join(tmpdir(), "app-install-cache-"));
143 try {
144 writeFileSync(join(root, "server.ts"), SERVER);
145 const cache = join(root, "shared", "bun");
146 const lines: string[] = [];
147 const ok = await start(
148 root,
149 {
150 installCache: cache,
151 manifest: { ...manifest, install: `printf '%s' "\${BUN_INSTALL_CACHE_DIR:-none}" > seen` },
152 },
153 lines,
154 );
155 expect(ok).toBe(true);
156 expect(readFileSync(join(root, "seen"), "utf8")).toBe("none");
157 expect(lines).toContain("cache not filled: no bun lockfile in the checkout");
158 } finally {
159 await rm(root, { recursive: true, force: true });
160 }
161 });
162
163 test("a fill that fails does not fail the deploy: the install runs into its home as before", async () => {
164 const root = await mkdtemp(join(tmpdir(), "app-install-cache-"));
165 const reg = await registry(root);
166 try {
167 const co = await checkout(root, reg.url);
168 // A cold cache and a dead registry: the fill cannot fetch.
169 reg.stop();
170 const cache = join(root, "shared", "bun");
171 const lines: string[] = [];
172 const ok = await withRegistry(reg.url, () =>
173 start(
174 co,
175 {
176 installCache: cache,
177 manifest: { ...manifest, install: `printf '%s' "\${BUN_INSTALL_CACHE_DIR:-none}" > seen` },
178 },
179 lines,
180 ),
181 );
182 expect(ok).toBe(true);
183 expect(readFileSync(join(co, "seen"), "utf8")).toBe("none");
184 expect(lines.some((line) => line.startsWith("cache not filled: filling the install cache exited"))).toBe(true);
185 expect(existsSync(`${cache}.lock`)).toBe(false);
186 } finally {
187 reg.stop();
188 await rm(root, { recursive: true, force: true });
189 }
190 });
191 });
192
193 describe("the install's plan (#7544)", () => {
194 const input = (extra: Partial<StartInput>): StartInput => ({
195 dir: "/co",
196 dataDir: "/data",
197 manifest,
198 env: "pull-7",
199 url: "http://app.test",
200 commitOid: "0".repeat(40),
201 forgeUrl: "http://forge.test",
202 identityKey: "key",
203 log: () => {},
204 signal: new AbortController().signal,
205 ...extra,
206 });
207 const cell = { hide: ["/data"], home: "/homes/app" };
208 const env = { PATH: "/bin" };
209
210 test("in a cell, a filled cache is revealed read-only and the install copies from it", () => {
211 const plan = installPlan("bun install --frozen-lockfile", input({ cell, installCache: "/cache/bun" }), env, true);
212 expect(plan.cell?.reveal).toEqual(["/cache/bun"]);
213 expect(plan.cell?.writable).toEqual([]);
214 expect(plan.cell?.home).toBe("/homes/app");
215 expect(plan.command).toBe("bun install --backend=copyfile --frozen-lockfile");
216 expect(plan.env).toEqual({ PATH: "/bin", BUN_INSTALL_CACHE_DIR: "/cache/bun" });
217 expect(plan.lock).toBeUndefined();
218 });
219
220 test("uncontained, a filled cache is read under its lock", () => {
221 const plan = installPlan("bun install", input({ installCache: "/cache/bun" }), env, true);
222 expect(plan.cell).toBeUndefined();
223 expect(plan.command).toBe("bun install");
224 expect(plan.env["BUN_INSTALL_CACHE_DIR"]).toBe("/cache/bun");
225 expect(plan.lock).toBe("/cache/bun.lock");
226 });
227
228 test("an unfilled cache, or none, leaves the install as it was", () => {
229 for (const plan of [
230 installPlan("bun install", input({ cell, installCache: "/cache/bun" }), env, false),
231 installPlan("bun install", input({ cell }), env, true),
232 ]) {
233 expect(plan.command).toBe("bun install");
234 expect(plan.env).toEqual(env);
235 expect(plan.cell?.reveal).toBeUndefined();
236 expect(plan.lock).toBeUndefined();
237 }
238 });
239 });
test/apps-adversarial-test-step.test.ts +48 −1
@@ -24,6 +24,8 @@ import type { Repo } from "../src/core/repos.ts";
2424 import { listRuns, type Run } from "../src/core/runs.ts";
2525 import { createUser, type User } from "../src/core/users.ts";
2626 import { serve, type Servers } from "../src/http/server.ts";
27 import { checkInstallCache } from "../src/checks/install.ts";
28 import { readRunLog } from "../src/runs/log.ts";
2729
2830 const DOMAIN = "apps.test";
2931
@@ -85,10 +87,11 @@ describe("apps adversarial: the install and test steps", () => {
8587 "the deploy settling",
8688 );
8789
88 /** The pull branch carrying only this forge.json, opened against the default branch. */
90 /** The pull branch carrying this forge.json (and any `extra` files), opened against the default branch. */
8991 async function pullWith(
9092 branch: string,
9193 manifest: Record<string, string>,
94 extra: readonly { path: string; content: string }[] = [],
9295 ): Promise<{ number: number; commitOid: string }> {
9396 const committed = await commitToBranch(app, alice, repo, {
9497 branch,
@@ -99,6 +102,7 @@ describe("apps adversarial: the install and test steps", () => {
99102 path: "forge.json",
100103 content: `${JSON.stringify({ app: manifest }, null, 2)}\n`,
101104 },
105 ...extra,
102106 ],
103107 });
104108 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
@@ -176,6 +180,49 @@ describe("apps adversarial: the install and test steps", () => {
176180 );
177181 }, 20_000);
178182
183 test("a preview's install reads its repository's shared cache (#7544)", async () => {
184 // A local dependency: a real bun.lock the fill trusts, and nothing fetched.
185 const pull = await pullWith(
186 "shared-cache",
187 {
188 install: `bun install --frozen-lockfile && echo "install cache: \${BUN_INSTALL_CACHE_DIR:-none}"`,
189 run: "bun run src/server.ts",
190 },
191 [
192 { path: "dep/package.json", content: `{"name":"dep","version":"1.0.0"}\n` },
193 {
194 path: "package.json",
195 content: `${JSON.stringify({ name: "steps", version: "0.1.0", private: true, dependencies: { dep: "file:./dep" } })}\n`,
196 },
197 {
198 path: "bun.lock",
199 content: `{
200 "lockfileVersion": 1,
201 "configVersion": 1,
202 "workspaces": {
203 "": {
204 "name": "steps",
205 "dependencies": {
206 "dep": "file:./dep",
207 },
208 },
209 },
210 "packages": {
211 "dep": ["dep@file:dep", {}],
212 }
213 }
214 `,
215 },
216 ],
217 );
218 const run = await settled(
219 (r) => r.env === `pull-${pull.number}` && r.commitOid === pull.commitOid,
220 );
221 const log = readRunLog(dataDir, run.id).lines;
222 expect(run.state).toBe("ok");
223 expect(log).toContain(`install cache: ${checkInstallCache(dataDir, repo.id)}`);
224 }, 20_000);
225
179226 test("closing a pull whose deploy already answered kills its still-running test step — no orphan `bun test`", async () => {
180227 // A merge lands this same forge.json on the default branch and
181228 // production redeploys it too; only the preview's test step sleeps, so

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs