@specific.dev/spectest 0.76.0 → 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/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // The daemon loads this file on boot and the control plane talks to it
5
5
  // over HTTP.
6
6
  import { strict as nodeAssert } from "node:assert";
7
- import { readFileSync } from "node:fs";
7
+ import { statSync } from "node:fs";
8
8
  import { recordAssertion, safeSerialize } from "./recorder.js";
9
9
  import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
10
10
  // `field` — a provenance-preserving, null-safe selector: a `null`/`undefined`
@@ -236,20 +236,26 @@ function validateBuildArgs(serviceName, buildArgs) {
236
236
  }
237
237
  }
238
238
  }
239
+ /** A Dockerfile stage name, as `--target` accepts it: no whitespace. */
240
+ const BUILD_TARGET_RE = /^[^\s]+$/;
239
241
  /**
240
- * Resolve a service's `image` to the form the harness builds: a `path`
241
- * Dockerfile is read from the repo and becomes `content`. The wire config
242
- * therefore never carries `path` — the control plane and the daemon's build
243
- * code only ever see Dockerfile bytes, and the warm-template hash covers
244
- * them because the file is part of the project tree.
242
+ * Check a service's `image` at config time, so a mistake fails the
243
+ * environment load with a message that names it, instead of a build
244
+ * minutes later. Nothing is rewritten: a `path` Dockerfile stays a path,
245
+ * and the harness reads it — and the ignore file beside it — from the
246
+ * repo when it builds (`daemon.ts::resolveDockerfileBuild`). The wire
247
+ * config therefore carries `path`, `context` and `target` as written; the
248
+ * control plane only tells a Dockerfile build from a registry pull, and
249
+ * the warm-template hash covers the files because they are part of the
250
+ * project tree.
245
251
  *
246
- * Runs at config time (`defineEnvironment`, and the daemon's runtime
247
- * `startService` twin), inside the VM, where the repo sits at the project
248
- * root. The read goes through the project-file resolver, so the same rules
249
- * as `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
252
+ * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
253
+ * twin, inside the VM, where the repo sits at the project root. Existence
254
+ * checks go through the project-file resolver, so the same rules as
255
+ * `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
250
256
  * instead of read stale).
251
257
  */
252
- export function resolveServiceImage(serviceName, image) {
258
+ export function validateServiceImage(serviceName, image) {
253
259
  if (image.type !== "dockerfile")
254
260
  return image;
255
261
  validateBuildArgs(serviceName, image.buildArgs);
@@ -261,22 +267,27 @@ export function resolveServiceImage(serviceName, image) {
261
267
  if (!hasPath && !hasContent) {
262
268
  throw new Error(`service "${serviceName}" image { type: "dockerfile" } needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``);
263
269
  }
264
- if (!hasPath)
265
- return image;
266
- const file = resolveExistingProjectPath(image.path, `service "${serviceName}" Dockerfile`);
267
- let content;
268
- try {
269
- content = readFileSync(file, "utf8");
270
+ if (hasPath) {
271
+ const file = resolveExistingProjectPath(image.path, `service "${serviceName}" Dockerfile`);
272
+ if (!statSync(file).isFile()) {
273
+ throw new Error(`service "${serviceName}" Dockerfile ${JSON.stringify(image.path)} is not a file — \`path\` names the Dockerfile itself, \`context\` names the directory to build from`);
274
+ }
270
275
  }
271
- catch (e) {
272
- throw new Error(`service "${serviceName}" Dockerfile ${JSON.stringify(image.path)} could not be read: ${e.message}`);
276
+ if (image.context !== undefined) {
277
+ if (typeof image.context !== "string" || image.context === "") {
278
+ throw new Error(`service "${serviceName}" image context must be a directory path relative to the project root (\`"."\` for the root)`);
279
+ }
280
+ const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context`);
281
+ if (!statSync(dir).isDirectory()) {
282
+ throw new Error(`service "${serviceName}" build context ${JSON.stringify(image.context)} is not a directory`);
283
+ }
284
+ }
285
+ if (image.target !== undefined) {
286
+ if (typeof image.target !== "string" || !BUILD_TARGET_RE.test(image.target)) {
287
+ throw new Error(`service "${serviceName}" image target must be a Dockerfile stage name (\`FROM … AS <name>\`); got ${JSON.stringify(image.target)}`);
288
+ }
273
289
  }
274
- const lowered = { type: "dockerfile", content };
275
- if (image.exclude !== undefined)
276
- lowered.exclude = image.exclude;
277
- if (image.buildArgs !== undefined)
278
- lowered.buildArgs = image.buildArgs;
279
- return lowered;
290
+ return image;
280
291
  }
281
292
  function validateEnvironmentConfig(config) {
282
293
  const entries = Object.entries(config.services);
@@ -389,7 +400,7 @@ export function defineEnvironment(input) {
389
400
  for (const [key, svc] of Object.entries(expanded)) {
390
401
  expanded[key] = applyCoverageAdapters(key, {
391
402
  ...svc,
392
- image: resolveServiceImage(key, svc.image),
403
+ image: validateServiceImage(key, svc.image),
393
404
  });
394
405
  }
395
406
  const config = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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,
@@ -736,7 +737,17 @@ async function imageCachePaths(): Promise<ImageCachePaths | null> {
736
737
  return imageCachePathsSync(IMAGE_CACHE_MANIFEST);
737
738
  }
738
739
 
739
- let _localBuilder: boolean | undefined;
740
+ /**
741
+ * The bring-up, once. A promise and not a flag: every build of a
742
+ * bootstrap calls this at the same time now that builds go in together,
743
+ * and a flag set to "not yet" while the first caller waits for buildkitd
744
+ * sent every other build to dockerd's own builder — which cannot build
745
+ * on the erofs store at all (`lease does not exist`), so Specific's
746
+ * first three cold builds on SDK 0.76.0 failed inside ten seconds
747
+ * (2026-09-07). Sharing the promise makes every caller wait for the one
748
+ * bring-up and read its one answer.
749
+ */
750
+ let _localBuilder: Promise<boolean> | undefined;
740
751
  /**
741
752
  * Start buildkitd inside this VM with its state on the cache disk, and
742
753
  * register it as a buildx `remote` builder.
@@ -746,9 +757,12 @@ let _localBuilder: boolean | undefined;
746
757
  * best-effort: a daemon that will not start falls back to the shared host
747
758
  * one, which is a slower build and not a failed one.
748
759
  */
749
- async function ensureLocalBuildkitd(): Promise<boolean> {
750
- if (_localBuilder !== undefined) return _localBuilder;
751
- _localBuilder = false;
760
+ function ensureLocalBuildkitd(): Promise<boolean> {
761
+ _localBuilder ??= bringUpLocalBuildkitd();
762
+ return _localBuilder;
763
+ }
764
+
765
+ async function bringUpLocalBuildkitd(): Promise<boolean> {
752
766
  const paths = await imageCachePaths();
753
767
  if (!paths) return false;
754
768
  const state = paths.root;
@@ -795,7 +809,6 @@ async function ensureLocalBuildkitd(): Promise<boolean> {
795
809
  // hit is a snapshot that already exists — no tar blob to unpack
796
810
  // before the first uncached step, no export pass after a miss.
797
811
  await ensureBuildkitCacheUnion(paths);
798
- _localBuilder = true;
799
812
  return true;
800
813
  }
801
814
 
@@ -1240,12 +1253,12 @@ async function ensureCertificates(svc: NamedService, tag: string): Promise<strin
1240
1253
  * context silently became the whole repo, and the checked-out file was
1241
1254
  * clobbered for any in-env tooling that read it too.
1242
1255
  */
1243
- let PROJECT_DOCKERIGNORE: string | null = null;
1244
-
1245
- 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> {
1246
1260
  try {
1247
- const text = await fs.readFile(path.join(WORKSPACE, ".dockerignore"), "utf8");
1248
- // Ours, from a previous bootstrap of this workspace — not the project's.
1261
+ const text = await fs.readFile(file, "utf8");
1249
1262
  if (isGeneratedDockerignore(text)) return null;
1250
1263
  return text;
1251
1264
  } catch {
@@ -1253,33 +1266,84 @@ async function readProjectDockerignore(): Promise<string | null> {
1253
1266
  }
1254
1267
  }
1255
1268
 
1256
- /** Ignore rules for one dockerfile build. Composition (and the reason the
1257
- * order matters) lives in `harness/build-context.ts`; this supplies the
1258
- * project's own file, read once per bootstrap. */
1259
- function serviceDockerignore(exclude?: readonly string[]): string {
1260
- return composeServiceDockerignore(PROJECT_DOCKERIGNORE, exclude);
1261
- }
1262
-
1263
1269
  /// In-flight/finished dockerfile builds of this bootstrap, keyed by
1264
- /// sha256(dockerfile content + exclude list). Services that share an
1265
- /// identical image definition (e.g. an API server and a worker running the
1266
- /// same codebase with different entrypoints) build ONCE; the others wait
1267
- /// and `docker tag` the result. Cleared at every bootstrap() — the build
1268
- /// CONTEXT (/workspace) is an input too, so dedup is only valid within one
1269
- /// 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).
1270
1277
  const BUILD_DEDUP = new Map<
1271
1278
  string,
1272
1279
  { name: string; promise: Promise<{ tag: string; buildSteps?: BuildStep[] }> }
1273
1280
  >();
1274
1281
 
1275
- type DockerfileBuild = {
1276
- content: string;
1277
- exclude?: readonly string[];
1278
- 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;
1279
1290
  };
1280
1291
 
1281
- function buildContentKey(image: DockerfileBuild): string {
1282
- 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
+ };
1283
1347
  }
1284
1348
 
1285
1349
  async function prepareServiceImage(
@@ -1318,11 +1382,10 @@ async function prepareServiceImage(
1318
1382
  }
1319
1383
  // Dockerfile build. Within one bootstrap, identical definitions (shared
1320
1384
  // codebase images) dedup to a single build. Only bootstrap opts in: the
1321
- // dedup key is dockerfile content + exclude, but the build CONTEXT
1322
- // (/workspace) is an input too — a runtime service started mid-test
1323
- // after setup/test code mutated /workspace must rebuild, not share a
1324
- // pre-mutation image.
1325
- 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);
1326
1389
  if (opts?.dedup) {
1327
1390
  const key = buildContentKey(image);
1328
1391
  const inflight = BUILD_DEDUP.get(key);
@@ -1357,17 +1420,6 @@ async function prepareServiceImage(
1357
1420
  return buildServiceImage(svc.name, image, tag);
1358
1421
  }
1359
1422
 
1360
- /** The built form of a dockerfile image. `resolveServiceImage` read a
1361
- * `path` into `content` at config time, so a service that still carries
1362
- * `path` here skipped that step — a programming error, not user input. */
1363
- function dockerfileContent(svc: NamedService): DockerfileBuild {
1364
- const image = svc.image;
1365
- if (image.type !== "dockerfile" || typeof image.content !== "string") {
1366
- throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
1367
- }
1368
- return image;
1369
- }
1370
-
1371
1423
  /**
1372
1424
  * Build a dockerfile service's image — the project's Dockerfile as
1373
1425
  * written, nothing appended.
@@ -1400,22 +1452,35 @@ async function runServiceBuild(
1400
1452
  ): Promise<{ ok: boolean; log: string; buildSteps?: BuildStep[] }> {
1401
1453
  let buildSteps: BuildStep[] | undefined;
1402
1454
  {
1403
- 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.
1404
1473
  const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
1405
1474
  await fs.mkdir(dfDir, { recursive: true });
1406
1475
  const dfPath = path.join(dfDir, "Dockerfile");
1407
- await fs.writeFile(dfPath, content);
1408
- // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
1409
- // (next to the Dockerfile) in preference to the context root's
1410
- // `.dockerignore`, so this build sees the defaults, the project's own
1411
- // `.dockerignore`, and ITS OWN `exclude` — one service excluding
1412
- // `handhelds/**` no longer empties a sibling's build context. Verified
1413
- // on both the remote-buildx and DOCKER_BUILDKIT paths (client-side
1414
- // context filtering). The root `.dockerignore` written at bootstrap
1415
- // stays as the fallback for the legacy non-BuildKit builder, which
1416
- // predates per-Dockerfile ignores — and is written ONLY when the
1417
- // project ships none of its own (see readProjectDockerignore).
1418
- 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
+ );
1419
1484
  // The in-VM buildkitd on the container store first: the build runs
1420
1485
  // inside the guest's own isolation boundary, against this project's
1421
1486
  // own layer cache, and the finished image is already in the store
@@ -1430,7 +1495,8 @@ async function runServiceBuild(
1430
1495
  let buildArgs: string[];
1431
1496
  // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1432
1497
  // so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
1433
- const argFlags = buildArgFlags(image.buildArgs);
1498
+ const argFlags = [...buildTargetFlags(image.target), ...buildArgFlags(image.buildArgs)];
1499
+ const contextDir = image.contextDir;
1434
1500
  if (useLocal) {
1435
1501
  // The in-VM builder is BuildKit's containerd worker on this VM's
1436
1502
  // own image store (CONTAINER_STORE.md): the output is an image
@@ -1450,13 +1516,13 @@ async function runServiceBuild(
1450
1516
  "--progress=plain",
1451
1517
  "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1452
1518
  ...argFlags,
1453
- "-f", dfPath, WORKSPACE,
1519
+ "-f", dfPath, contextDir,
1454
1520
  ];
1455
1521
  } else if (useBuildKit) {
1456
- buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1522
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", contextDir];
1457
1523
  buildEnv.DOCKER_BUILDKIT = "1";
1458
1524
  } else {
1459
- buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, WORKSPACE];
1525
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
1460
1526
  }
1461
1527
  progressService(name, { status: "building", detail: "starting build" });
1462
1528
  const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
@@ -3133,9 +3199,9 @@ const RUNTIME_SERVICES = new Map<string, NamedService>();
3133
3199
  function specToNamedService(spec: RuntimeServiceSpec): NamedService {
3134
3200
  const { name, ...rest } = spec;
3135
3201
  // A runtime spec skipped `defineEnvironment`, so its config-time passes
3136
- // run here: a `path` Dockerfile is read into `content`, and coverage
3137
- // adapters get their configure step.
3138
- 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) };
3139
3205
  return { name, ...applyCoverageAdapters(name, svc) };
3140
3206
  }
3141
3207
 
@@ -3493,20 +3559,13 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3493
3559
  ensureNetwork(),
3494
3560
  (async () => {
3495
3561
  await fs.mkdir(WORKSPACE, { recursive: true });
3496
- // Read the project's own .dockerignore BEFORE we consider writing
3497
- // one — every per-service ignore composes on top of it.
3498
- PROJECT_DOCKERIGNORE = await readProjectDockerignore();
3499
- if (PROJECT_DOCKERIGNORE !== null) {
3500
- console.log(
3501
- "[bootstrap] using the project's .dockerignore for every build context",
3502
- );
3503
- // Never overwrite it. BuildKit reads the per-service
3504
- // `<Dockerfile>.dockerignore` (which already folds this file in),
3505
- // and the legacy builder reads the project's file directly —
3506
- // which is what the project asked for. Clobbering it also broke
3507
- // any in-env tooling that reads it.
3508
- return;
3509
- }
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;
3510
3569
  await fs.writeFile(
3511
3570
  path.join(WORKSPACE, ".dockerignore"),
3512
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