@specific.dev/spectest 0.76.1 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  lowerIngress,
32
32
  dnsName as makeDnsDecl,
33
33
  isWildcard,
34
- resolveServiceImage,
34
+ validateServiceImage,
35
35
  proxy as makeProxyDecl,
36
36
  } from "./index.js";
37
37
  import type { DnsTarget, LoweredIngress } from "./index.js";
@@ -67,12 +67,13 @@ import { isMobileApp, openPersistentMobile } from "./mobile.js";
67
67
  // exact-then-longest-suffix rule and the one-label certificate rule are
68
68
  // each easy to restate subtly differently.
69
69
  import {
70
- DEFAULT_DOCKERIGNORE,
71
- GENERATED_DOCKERIGNORE_HEADER,
70
+ type ResolvedBuild,
72
71
  buildArgFlags,
73
72
  buildContentKey as computeBuildContentKey,
73
+ buildTargetFlags,
74
74
  imageTag,
75
75
  isGeneratedDockerignore,
76
+ pickProjectIgnore,
76
77
  serviceDockerignore as composeServiceDockerignore,
77
78
  unionDockerignore,
78
79
  } from "./harness/build-context.js";
@@ -93,7 +94,7 @@ import { pollUntilReady } from "./harness/ready-poll.js";
93
94
  import { runWrapperRules } from "./harness/wrapper-rules.js";
94
95
  import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
95
96
  import { cpus } from "node:os";
96
- import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
97
+ import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
97
98
  import {
98
99
  isTextualContentType,
99
100
  looksBinary,
@@ -1252,12 +1253,12 @@ async function ensureCertificates(svc: NamedService, tag: string): Promise<strin
1252
1253
  * context silently became the whole repo, and the checked-out file was
1253
1254
  * clobbered for any in-env tooling that read it too.
1254
1255
  */
1255
- let PROJECT_DOCKERIGNORE: string | null = null;
1256
-
1257
- async function readProjectDockerignore(): Promise<string | null> {
1256
+ /** A project ignore file, or null when it is missing — or when it is one
1257
+ * we wrote ourselves on an earlier bootstrap of this workspace, which is
1258
+ * not the project's intent. */
1259
+ async function readIgnoreFile(file: string): Promise<string | null> {
1258
1260
  try {
1259
- const text = await fs.readFile(path.join(WORKSPACE, ".dockerignore"), "utf8");
1260
- // Ours, from a previous bootstrap of this workspace — not the project's.
1261
+ const text = await fs.readFile(file, "utf8");
1261
1262
  if (isGeneratedDockerignore(text)) return null;
1262
1263
  return text;
1263
1264
  } catch {
@@ -1265,33 +1266,84 @@ async function readProjectDockerignore(): Promise<string | null> {
1265
1266
  }
1266
1267
  }
1267
1268
 
1268
- /** Ignore rules for one dockerfile build. Composition (and the reason the
1269
- * order matters) lives in `harness/build-context.ts`; this supplies the
1270
- * project's own file, read once per bootstrap. */
1271
- function serviceDockerignore(exclude?: readonly string[]): string {
1272
- return composeServiceDockerignore(PROJECT_DOCKERIGNORE, exclude);
1273
- }
1274
-
1275
1269
  /// In-flight/finished dockerfile builds of this bootstrap, keyed by
1276
- /// sha256(dockerfile content + exclude list). Services that share an
1277
- /// identical image definition (e.g. an API server and a worker running the
1278
- /// same codebase with different entrypoints) build ONCE; the others wait
1279
- /// and `docker tag` the result. Cleared at every bootstrap() — the build
1280
- /// CONTEXT (/workspace) is an input too, so dedup is only valid within one
1281
- /// workspace generation (runtime services started mid-test share it).
1270
+ /// sha256(Dockerfile bytes + context dir + ignore rules + target + args).
1271
+ /// Services that share an identical image definition (e.g. an API server
1272
+ /// and a worker running the same codebase with different entrypoints)
1273
+ /// build ONCE; the others wait and `docker tag` the result. Cleared at
1274
+ /// every bootstrap() — the CONTENTS of the build context are an input too,
1275
+ /// so dedup is only valid within one workspace generation (runtime
1276
+ /// services started mid-test share it).
1282
1277
  const BUILD_DEDUP = new Map<
1283
1278
  string,
1284
1279
  { name: string; promise: Promise<{ tag: string; buildSteps?: BuildStep[] }> }
1285
1280
  >();
1286
1281
 
1287
- type DockerfileBuild = {
1288
- content: string;
1289
- exclude?: readonly string[];
1290
- buildArgs?: Readonly<Record<string, string>>;
1282
+ /** A dockerfile build resolved against the repo, plus where the context is. */
1283
+ type DockerfileBuild = ResolvedBuild & {
1284
+ /** Absolute context directory in the VM. */
1285
+ contextDir: string;
1286
+ /** Where the project's ignore rules came from, for the boot log. */
1287
+ ignoreSource: string | null;
1288
+ /** How many `exclude` patterns the service added, for the boot log. */
1289
+ excludeCount: number;
1291
1290
  };
1292
1291
 
1293
- function buildContentKey(image: DockerfileBuild): string {
1294
- return computeBuildContentKey(() => new Bun.CryptoHasher("sha256") as any, image);
1292
+ function buildContentKey(build: ResolvedBuild): string {
1293
+ return computeBuildContentKey(() => new Bun.CryptoHasher("sha256") as any, build);
1294
+ }
1295
+
1296
+ /**
1297
+ * Read everything a dockerfile build needs off the mounted project: the
1298
+ * Dockerfile (`content` as given, or `path` read from the repo now, at
1299
+ * build time — never copied through the config), the context directory,
1300
+ * and the project's own ignore rules chosen the way `docker build`
1301
+ * chooses them (`<Dockerfile>.dockerignore` beside a `path` Dockerfile,
1302
+ * else `<context>/.dockerignore`; see `pickProjectIgnore`). Our defaults
1303
+ * and the service's `exclude` are composed around those into the
1304
+ * per-service ignore file the build is pointed at.
1305
+ *
1306
+ * `validateServiceImage` already refused a missing file or directory at
1307
+ * load time, so a failure here is a read error, not a typo.
1308
+ */
1309
+ async function resolveDockerfileBuild(svc: NamedService): Promise<DockerfileBuild> {
1310
+ const image = svc.image;
1311
+ if (image.type !== "dockerfile") {
1312
+ throw new Error(`service "${svc.name}" image is not a Dockerfile build`);
1313
+ }
1314
+ const contextRel = image.context ?? ".";
1315
+ const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`);
1316
+ let content: string;
1317
+ let adjacent: string | null = null;
1318
+ let adjacentPath: string | null = null;
1319
+ if (typeof image.path === "string") {
1320
+ const file = resolveExistingProjectPath(image.path, `service "${svc.name}" Dockerfile`);
1321
+ content = await fs.readFile(file, "utf8");
1322
+ adjacentPath = `${image.path}.dockerignore`;
1323
+ adjacent = await readIgnoreFile(`${file}.dockerignore`);
1324
+ } else if (typeof image.content === "string") {
1325
+ content = image.content;
1326
+ } else {
1327
+ throw new Error(`service "${svc.name}" image { type: "dockerfile" } has neither \`path\` nor \`content\``);
1328
+ }
1329
+ const contextRoot = await readIgnoreFile(path.join(contextDir, ".dockerignore"));
1330
+ const projectIgnore = pickProjectIgnore(adjacent, contextRoot);
1331
+ const ignoreSource =
1332
+ adjacent !== null
1333
+ ? adjacentPath
1334
+ : contextRoot !== null
1335
+ ? path.posix.join(contextRel, ".dockerignore")
1336
+ : null;
1337
+ return {
1338
+ content,
1339
+ context: contextRel,
1340
+ contextDir,
1341
+ ignore: composeServiceDockerignore(projectIgnore, image.exclude),
1342
+ ignoreSource,
1343
+ excludeCount: image.exclude?.length ?? 0,
1344
+ target: image.target,
1345
+ buildArgs: image.buildArgs,
1346
+ };
1295
1347
  }
1296
1348
 
1297
1349
  async function prepareServiceImage(
@@ -1330,11 +1382,10 @@ async function prepareServiceImage(
1330
1382
  }
1331
1383
  // Dockerfile build. Within one bootstrap, identical definitions (shared
1332
1384
  // codebase images) dedup to a single build. Only bootstrap opts in: the
1333
- // dedup key is dockerfile content + exclude, but the build CONTEXT
1334
- // (/workspace) is an input too — a runtime service started mid-test
1335
- // after setup/test code mutated /workspace must rebuild, not share a
1336
- // pre-mutation image.
1337
- const image = dockerfileContent(svc);
1385
+ // dedup key names the context directory but not its contents — a
1386
+ // runtime service started mid-test after setup/test code mutated
1387
+ // /workspace must rebuild, not share a pre-mutation image.
1388
+ const image = await resolveDockerfileBuild(svc);
1338
1389
  if (opts?.dedup) {
1339
1390
  const key = buildContentKey(image);
1340
1391
  const inflight = BUILD_DEDUP.get(key);
@@ -1369,17 +1420,6 @@ async function prepareServiceImage(
1369
1420
  return buildServiceImage(svc.name, image, tag);
1370
1421
  }
1371
1422
 
1372
- /** The built form of a dockerfile image. `resolveServiceImage` read a
1373
- * `path` into `content` at config time, so a service that still carries
1374
- * `path` here skipped that step — a programming error, not user input. */
1375
- function dockerfileContent(svc: NamedService): DockerfileBuild {
1376
- const image = svc.image;
1377
- if (image.type !== "dockerfile" || typeof image.content !== "string") {
1378
- throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
1379
- }
1380
- return image;
1381
- }
1382
-
1383
1423
  /**
1384
1424
  * Build a dockerfile service's image — the project's Dockerfile as
1385
1425
  * written, nothing appended.
@@ -1412,22 +1452,35 @@ async function runServiceBuild(
1412
1452
  ): Promise<{ ok: boolean; log: string; buildSteps?: BuildStep[] }> {
1413
1453
  let buildSteps: BuildStep[] | undefined;
1414
1454
  {
1415
- const content = image.content;
1455
+ // The build runs straight against the mounted project: the context is
1456
+ // the repo directory the service named (the root by default), and a
1457
+ // `path` Dockerfile's bytes were read from the repo just now. The one
1458
+ // thing written is a per-service pair under `.spectest/services/`:
1459
+ // the Dockerfile's text and, beside it, the ignore rules. That pair
1460
+ // exists because BuildKit takes extra ignore rules from exactly one
1461
+ // place — a `<Dockerfile>.dockerignore` next to the Dockerfile it was
1462
+ // pointed at — and the rules here are per service (our defaults, the
1463
+ // project's own file, ITS OWN `exclude`): two services building one
1464
+ // repo Dockerfile with different excludes need two of them, and
1465
+ // nothing may be written next to the user's file. So `-f` names our
1466
+ // copy and the context stays the user's directory; a Dockerfile
1467
+ // outside its context is ordinary `docker build -f`. Verified on both
1468
+ // the buildx and DOCKER_BUILDKIT paths (client-side context
1469
+ // filtering). The root `.dockerignore` written at bootstrap stays as
1470
+ // the fallback for the legacy non-BuildKit builder, which predates
1471
+ // per-Dockerfile ignores — and is written ONLY when the project ships
1472
+ // none of its own.
1416
1473
  const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
1417
1474
  await fs.mkdir(dfDir, { recursive: true });
1418
1475
  const dfPath = path.join(dfDir, "Dockerfile");
1419
- await fs.writeFile(dfPath, content);
1420
- // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
1421
- // (next to the Dockerfile) in preference to the context root's
1422
- // `.dockerignore`, so this build sees the defaults, the project's own
1423
- // `.dockerignore`, and ITS OWN `exclude` — one service excluding
1424
- // `handhelds/**` no longer empties a sibling's build context. Verified
1425
- // on both the remote-buildx and DOCKER_BUILDKIT paths (client-side
1426
- // context filtering). The root `.dockerignore` written at bootstrap
1427
- // stays as the fallback for the legacy non-BuildKit builder, which
1428
- // predates per-Dockerfile ignores — and is written ONLY when the
1429
- // project ships none of its own (see readProjectDockerignore).
1430
- await fs.writeFile(`${dfPath}.dockerignore`, serviceDockerignore(image.exclude));
1476
+ await fs.writeFile(dfPath, image.content);
1477
+ await fs.writeFile(`${dfPath}.dockerignore`, image.ignore);
1478
+ console.log(
1479
+ `[build] ${name}: context ${image.context}` +
1480
+ (image.target ? `, target ${image.target}` : "") +
1481
+ (image.ignoreSource ? `, ignore rules from ${image.ignoreSource}` : ", no project ignore file") +
1482
+ (image.excludeCount > 0 ? ` + ${image.excludeCount} exclude pattern(s)` : ""),
1483
+ );
1431
1484
  // The in-VM buildkitd on the container store first: the build runs
1432
1485
  // inside the guest's own isolation boundary, against this project's
1433
1486
  // own layer cache, and the finished image is already in the store
@@ -1442,7 +1495,8 @@ async function runServiceBuild(
1442
1495
  let buildArgs: string[];
1443
1496
  // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1444
1497
  // so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
1445
- const argFlags = buildArgFlags(image.buildArgs);
1498
+ const argFlags = [...buildTargetFlags(image.target), ...buildArgFlags(image.buildArgs)];
1499
+ const contextDir = image.contextDir;
1446
1500
  if (useLocal) {
1447
1501
  // The in-VM builder is BuildKit's containerd worker on this VM's
1448
1502
  // own image store (CONTAINER_STORE.md): the output is an image
@@ -1462,13 +1516,13 @@ async function runServiceBuild(
1462
1516
  "--progress=plain",
1463
1517
  "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1464
1518
  ...argFlags,
1465
- "-f", dfPath, WORKSPACE,
1519
+ "-f", dfPath, contextDir,
1466
1520
  ];
1467
1521
  } else if (useBuildKit) {
1468
- buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1522
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", contextDir];
1469
1523
  buildEnv.DOCKER_BUILDKIT = "1";
1470
1524
  } else {
1471
- buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, WORKSPACE];
1525
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
1472
1526
  }
1473
1527
  progressService(name, { status: "building", detail: "starting build" });
1474
1528
  const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
@@ -3145,9 +3199,9 @@ const RUNTIME_SERVICES = new Map<string, NamedService>();
3145
3199
  function specToNamedService(spec: RuntimeServiceSpec): NamedService {
3146
3200
  const { name, ...rest } = spec;
3147
3201
  // A runtime spec skipped `defineEnvironment`, so its config-time passes
3148
- // run here: a `path` Dockerfile is read into `content`, and coverage
3149
- // adapters get their configure step.
3150
- const svc = { ...(rest as ServiceConfig), image: resolveServiceImage(name, rest.image) };
3202
+ // run here: the image is checked (a `path` Dockerfile and a `context`
3203
+ // must exist), and coverage adapters get their configure step.
3204
+ const svc = { ...(rest as ServiceConfig), image: validateServiceImage(name, rest.image) };
3151
3205
  return { name, ...applyCoverageAdapters(name, svc) };
3152
3206
  }
3153
3207
 
@@ -3505,20 +3559,13 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3505
3559
  ensureNetwork(),
3506
3560
  (async () => {
3507
3561
  await fs.mkdir(WORKSPACE, { recursive: true });
3508
- // Read the project's own .dockerignore BEFORE we consider writing
3509
- // one — every per-service ignore composes on top of it.
3510
- PROJECT_DOCKERIGNORE = await readProjectDockerignore();
3511
- if (PROJECT_DOCKERIGNORE !== null) {
3512
- console.log(
3513
- "[bootstrap] using the project's .dockerignore for every build context",
3514
- );
3515
- // Never overwrite it. BuildKit reads the per-service
3516
- // `<Dockerfile>.dockerignore` (which already folds this file in),
3517
- // and the legacy builder reads the project's file directly —
3518
- // which is what the project asked for. Clobbering it also broke
3519
- // any in-env tooling that reads it.
3520
- return;
3521
- }
3562
+ // Never overwrite the project's own root `.dockerignore`. BuildKit
3563
+ // reads the per-service `<Dockerfile>.dockerignore` (which folds the
3564
+ // project's file in, see resolveDockerfileBuild), and the legacy
3565
+ // builder reads the project's file directly — which is what the
3566
+ // project asked for. Clobbering it also broke any in-env tooling
3567
+ // that reads it.
3568
+ if ((await readIgnoreFile(path.join(WORKSPACE, ".dockerignore"))) !== null) return;
3522
3569
  await fs.writeFile(
3523
3570
  path.join(WORKSPACE, ".dockerignore"),
3524
3571
  unionDockerignore(services),
@@ -1,24 +1,31 @@
1
- // `image: { type: "dockerfile", path }` — a Dockerfile in the repo is read
2
- // into `content` when the environment loads, so the harness only ever sees
3
- // bytes. The project root is `SPECTEST_WORKSPACE` (the VM's /workspace).
4
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
1
+ // `image: { type: "dockerfile", path, context, target }` — checked when the
2
+ // environment loads, carried as written. The harness reads the Dockerfile
3
+ // and its ignore file off the repo at build time. The project root is
4
+ // `SPECTEST_WORKSPACE` (the VM's /workspace).
5
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
6
+ import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
7
  import { tmpdir } from "node:os";
7
8
  import path from "node:path";
8
9
 
9
10
  // Set before the SDK reads it (module-load time), so no top-level import.
11
+ // One root for the whole file: the SDK reads SPECTEST_WORKSPACE once, when
12
+ // `./index` is first imported, so a per-test root would leave every later
13
+ // test pointing at the first one's deleted directory.
10
14
  let root = "";
11
- beforeEach(() => {
15
+ beforeAll(() => {
12
16
  root = mkdtempSync(path.join(tmpdir(), "spectest-dockerfile-"));
13
17
  process.env.SPECTEST_WORKSPACE = root;
14
18
  process.env.SPECTEST_APP_DIR = path.join(root, "app-copy");
15
19
  });
16
- afterEach(() => rmSync(root, { recursive: true, force: true }));
20
+ afterEach(() => {
21
+ for (const entry of readdirSync(root)) rmSync(path.join(root, entry), { recursive: true, force: true });
22
+ });
23
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
17
24
 
18
25
  const load = async () => await import("./index");
19
26
 
20
27
  describe("dockerfile path", () => {
21
- test("a repo-relative path is read into content, exclude kept", async () => {
28
+ test("a repo-relative path is kept as written, with its context and target", async () => {
22
29
  mkdirSync(path.join(root, "apps/api"), { recursive: true });
23
30
  writeFileSync(path.join(root, "apps/api/Dockerfile"), "FROM node:20\nCOPY . /app\n");
24
31
  const { defineEnvironment } = await load();
@@ -29,6 +36,8 @@ describe("dockerfile path", () => {
29
36
  image: {
30
37
  type: "dockerfile",
31
38
  path: "apps/api/Dockerfile",
39
+ context: "apps/api",
40
+ target: "runtime",
32
41
  exclude: ["docs/**"],
33
42
  buildArgs: { NODE_ENV: "test" },
34
43
  },
@@ -37,7 +46,9 @@ describe("dockerfile path", () => {
37
46
  });
38
47
  expect(env.config.services.api.image).toEqual({
39
48
  type: "dockerfile",
40
- content: "FROM node:20\nCOPY . /app\n",
49
+ path: "apps/api/Dockerfile",
50
+ context: "apps/api",
51
+ target: "runtime",
41
52
  exclude: ["docs/**"],
42
53
  buildArgs: { NODE_ENV: "test" },
43
54
  });
@@ -53,23 +64,55 @@ describe("dockerfile path", () => {
53
64
  ).toThrow(/service "api" Dockerfile "nope\/Dockerfile" is not in the VM/);
54
65
  });
55
66
 
67
+ test("a path that is a directory is refused, pointing at `context`", async () => {
68
+ mkdirSync(path.join(root, "apps/api"), { recursive: true });
69
+ const { validateServiceImage } = await load();
70
+ expect(() => validateServiceImage("api", { type: "dockerfile", path: "apps/api" })).toThrow(
71
+ /is not a file/,
72
+ );
73
+ });
74
+
75
+ test("a missing or non-directory context fails the load", async () => {
76
+ writeFileSync(path.join(root, "Dockerfile"), "FROM x\n");
77
+ const { validateServiceImage } = await load();
78
+ expect(() =>
79
+ validateServiceImage("api", { type: "dockerfile", path: "Dockerfile", context: "packages" }),
80
+ ).toThrow(/service "api" build context "packages" is not in the VM/);
81
+ expect(() =>
82
+ validateServiceImage("api", { type: "dockerfile", path: "Dockerfile", context: "Dockerfile" }),
83
+ ).toThrow(/is not a directory/);
84
+ expect(() =>
85
+ validateServiceImage("api", { type: "dockerfile", content: "FROM x", context: "" }),
86
+ ).toThrow(/context must be a directory path/);
87
+ });
88
+
89
+ test("a target must look like a stage name", async () => {
90
+ const { validateServiceImage } = await load();
91
+ expect(() =>
92
+ validateServiceImage("api", { type: "dockerfile", content: "FROM x", target: "run time" }),
93
+ ).toThrow(/target must be a Dockerfile stage name/);
94
+ expect(() =>
95
+ validateServiceImage("api", { type: "dockerfile", content: "FROM x", target: "" }),
96
+ ).toThrow(/target must be a Dockerfile stage name/);
97
+ });
98
+
56
99
  test("path and content together, or neither, is an error", async () => {
57
- const { resolveServiceImage } = await load();
100
+ const { validateServiceImage } = await load();
58
101
  expect(() =>
59
- resolveServiceImage("api", { type: "dockerfile", path: "Dockerfile", content: "FROM x" } as never),
102
+ validateServiceImage("api", { type: "dockerfile", path: "Dockerfile", content: "FROM x" } as never),
60
103
  ).toThrow(/both `path` and `content`/);
61
- expect(() => resolveServiceImage("api", { type: "dockerfile" } as never)).toThrow(
104
+ expect(() => validateServiceImage("api", { type: "dockerfile" } as never)).toThrow(
62
105
  /needs `path`/,
63
106
  );
64
107
  });
65
108
 
66
109
  test("buildArgs keys must be ARG names and values strings", async () => {
67
- const { resolveServiceImage } = await load();
110
+ const { validateServiceImage } = await load();
68
111
  expect(() =>
69
- resolveServiceImage("api", { type: "dockerfile", content: "FROM x", buildArgs: { "NO-DASH": "1" } }),
112
+ validateServiceImage("api", { type: "dockerfile", content: "FROM x", buildArgs: { "NO-DASH": "1" } }),
70
113
  ).toThrow(/not a valid ARG name/);
71
114
  expect(() =>
72
- resolveServiceImage("api", {
115
+ validateServiceImage("api", {
73
116
  type: "dockerfile",
74
117
  content: "FROM x",
75
118
  buildArgs: { PORT: 3000 } as never,
@@ -78,10 +121,10 @@ describe("dockerfile path", () => {
78
121
  });
79
122
 
80
123
  test("content and registry images pass through untouched", async () => {
81
- const { resolveServiceImage } = await load();
124
+ const { validateServiceImage } = await load();
82
125
  const inline = { type: "dockerfile", content: "FROM x\n" } as const;
83
- expect(resolveServiceImage("a", inline)).toBe(inline);
126
+ expect(validateServiceImage("a", inline)).toBe(inline);
84
127
  const reg = { type: "registry", reference: "nginx" } as const;
85
- expect(resolveServiceImage("a", reg)).toBe(reg);
128
+ expect(validateServiceImage("a", reg)).toBe(reg);
86
129
  });
87
130
  });
Binary file
@@ -47,10 +47,24 @@ export function isGeneratedDockerignore(text: string): boolean {
47
47
  return text.startsWith(GENERATED_DOCKERIGNORE_HEADER);
48
48
  }
49
49
 
50
+ /**
51
+ * The project's own ignore rules for one build, chosen the way BuildKit
52
+ * chooses them: a `<Dockerfile>.dockerignore` next to the Dockerfile wins
53
+ * outright, else the context root's `.dockerignore`, else nothing. The two
54
+ * are never merged — a Dockerfile-adjacent file is the whole rule set for
55
+ * that build, exactly as `docker build` reads it.
56
+ */
57
+ export function pickProjectIgnore(
58
+ adjacent: string | null,
59
+ contextRoot: string | null,
60
+ ): string | null {
61
+ return adjacent ?? contextRoot;
62
+ }
63
+
50
64
  /**
51
65
  * Ignore rules for one dockerfile build, in precedence order: our
52
- * defaults, then the project's own `.dockerignore` verbatim, then that
53
- * service's `exclude`.
66
+ * defaults, then the project's own ignore file verbatim (see
67
+ * {@link pickProjectIgnore}), then that service's `exclude`.
54
68
  *
55
69
  * **The order is load-bearing**, because of the `**`-plus-negations idiom
56
70
  * that monorepos use to keep a build context small:
@@ -113,44 +127,69 @@ export function unionDockerignore(
113
127
  return [GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n";
114
128
  }
115
129
 
130
+ /**
131
+ * Everything one dockerfile build reads, resolved from the project: the
132
+ * Dockerfile's bytes (from `path` or `content`), the context directory
133
+ * (project-root-relative, `.` by default), the composed ignore rules, the
134
+ * stage to stop at, and the build args. The harness resolves this once per
135
+ * service and both the dedup key and the build itself work from it.
136
+ */
137
+ export interface ResolvedBuild {
138
+ /** The Dockerfile text. */
139
+ content: string;
140
+ /** Project-root-relative build context directory (`.` for the root). */
141
+ context: string;
142
+ /** The generated `<Dockerfile>.dockerignore` text. */
143
+ ignore: string;
144
+ /** `--target` stage, if any. */
145
+ target?: string;
146
+ buildArgs?: Readonly<Record<string, string>>;
147
+ }
148
+
116
149
  /**
117
150
  * Identity of a dockerfile build within one bootstrap, so services sharing
118
151
  * an image definition (an api and a worker on the same codebase with
119
152
  * different entrypoints) build once and the rest just re-tag.
120
153
  *
121
- * The build **context** is an input too, but it isn't hashed: the context
122
- * is `/workspace`, which is fixed within a bootstrap and mutable between
123
- * them, so this key is only ever valid inside one workspace generation.
124
- * The dedup map is cleared per bootstrap for exactly that reason.
154
+ * The context *directory* is part of the key, but its *contents* are not:
155
+ * `/workspace` is fixed within a bootstrap and mutable between them, so
156
+ * this key is only ever valid inside one workspace generation. The dedup
157
+ * map is cleared per bootstrap for exactly that reason.
125
158
  */
126
159
  export interface Hasher {
127
160
  update(s: string): Hasher;
128
161
  digest(encoding: "hex"): string;
129
162
  }
130
163
 
131
- export function buildContentKey(
132
- createHasher: () => Hasher,
133
- image: { content: string; exclude?: readonly string[]; buildArgs?: Readonly<Record<string, string>> },
134
- ): string {
164
+ export function buildContentKey(createHasher: () => Hasher, build: ResolvedBuild): string {
135
165
  return (
136
166
  createHasher()
137
- .update(image.content)
138
- // A separator that cannot occur in either field. Without it a
139
- // Dockerfile whose text ends with an exclude list would hash the
167
+ .update(build.content)
168
+ // A separator that cannot occur in any field. Without it a
169
+ // Dockerfile whose text ends with an ignore list would hash the
140
170
  // same as that Dockerfile with the list actually set, and two
141
171
  // genuinely different builds would collapse into one.
142
172
  .update("\0")
143
- .update(JSON.stringify(image.exclude ?? []))
173
+ .update(build.context)
174
+ .update("\0")
175
+ .update(build.ignore)
176
+ .update("\0")
177
+ .update(build.target ?? "")
144
178
  // Build args are an input to the image (an ARG picks the base image,
145
179
  // the NODE_ENV of an install step…), so two services on one
146
180
  // Dockerfile with different args must not share a build. Sorted, so
147
181
  // key order in the user's object doesn't split identical builds.
148
182
  .update("\0")
149
- .update(JSON.stringify(buildArgFlags(image.buildArgs)))
183
+ .update(JSON.stringify(buildArgFlags(build.buildArgs)))
150
184
  .digest("hex")
151
185
  );
152
186
  }
153
187
 
188
+ /** `--target <stage>`, or nothing. A plain client flag every builder takes. */
189
+ export function buildTargetFlags(target: string | undefined): string[] {
190
+ return target ? ["--target", target] : [];
191
+ }
192
+
154
193
  /** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
155
194
  export function buildArgFlags(buildArgs: Readonly<Record<string, string>> | undefined): string[] {
156
195
  return Object.entries(buildArgs ?? {})