@specific.dev/spectest 0.76.1 → 0.78.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.1",
3
+ "version": "0.78.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -2,8 +2,6 @@ import type { ServiceDefinition } from "../index.js";
2
2
  import { S3Client, type S3ClientLike } from "../s3.js";
3
3
 
4
4
  export interface S3Options {
5
- /** HTTP port the S3 API is served on. Default `9090`. */
6
- port?: number;
7
5
  /**
8
6
  * Buckets to create on first boot. The backing server's own `initialBuckets`
9
7
  * env is unreliable across versions, so the component creates them in `setup`
@@ -19,7 +17,7 @@ export interface S3Options {
19
17
  /**
20
18
  * Hostnames to additionally serve the store at over **HTTPS** (CA-trusted),
21
19
  * via the daemon's TLS-terminating reverse proxy. Optional — defaults to none;
22
- * the store is always reachable at `http://<key>:<port>` regardless. Set this
20
+ * the store is always reachable at `http://<key>:9090` regardless. Set this
23
21
  * only when the app under test hardcodes a specific prod S3 endpoint it can't
24
22
  * be told to override (`https://<bucket>.s3.amazonaws.com`, a provider's
25
23
  * storage host): the daemon mints a cert for each host and proxies to the
@@ -51,6 +49,16 @@ export interface S3Helpers {
51
49
  // Java 25 (`NoClassDefFoundError: KotlinBuiltIns$2`) — fixed in 5.1.0.
52
50
  const S3_IMAGE = "adobe/s3mock:5.1.0";
53
51
 
52
+ // Port the store listens on, fixed like the image and deliberately NOT an
53
+ // option. The backing server's HTTP port is baked into the image, and nothing
54
+ // the component returns can move it — so a `port` option could only relabel
55
+ // the ready check, the declared ports and the client endpoint while the
56
+ // container kept listening here. That is exactly what it did until 0.78.0: a
57
+ // project that passed one got 60 s of refused probes and an environment that
58
+ // never booted, with a healthy container beside it. A service reaches the
59
+ // store at `http://<key>:9090`.
60
+ const S3_PORT = 9090;
61
+
54
62
  /**
55
63
  * A ready-to-use, S3-compatible object store (backed by
56
64
  * [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
@@ -83,7 +91,6 @@ const S3_IMAGE = "adobe/s3mock:5.1.0";
83
91
  * ```
84
92
  */
85
93
  export function s3(opts: S3Options = {}) {
86
- const port = opts.port ?? 9090;
87
94
  const buckets = opts.buckets ?? [];
88
95
  const defaultBucket = opts.bucket ?? buckets[0];
89
96
  const accessKeyId = opts.accessKeyId ?? "s3";
@@ -92,16 +99,16 @@ export function s3(opts: S3Options = {}) {
92
99
 
93
100
  return {
94
101
  image: { type: "registry", reference: S3_IMAGE },
95
- ports: [port],
102
+ ports: [S3_PORT],
96
103
  ...(opts.env ? { env: opts.env } : {}),
97
104
  ...(opts.hosts
98
- ? { tls: opts.hosts.map((hostname) => ({ hostname, port })) }
105
+ ? { tls: opts.hosts.map((hostname) => ({ hostname, port: S3_PORT })) }
99
106
  : {}),
100
107
  // GET / returns the ListBuckets XML with 200 once the store is up.
101
- readyCheck: { type: "http" as const, port, path: "/", timeoutSecs: 60 },
108
+ readyCheck: { type: "http" as const, port: S3_PORT, path: "/", timeoutSecs: 60 },
102
109
  helpers: ({ name }: { name: string }): S3Helpers => ({
103
110
  client: new S3Client({
104
- endpoint: `http://${name}:${port}`,
111
+ endpoint: `http://${name}:${S3_PORT}`,
105
112
  region,
106
113
  accessKeyId,
107
114
  secretAccessKey,
@@ -116,7 +123,7 @@ export function s3(opts: S3Options = {}) {
116
123
  // CreateBucket is a plain `PUT` on the bucket root; the store
117
124
  // returns 200 (or 409 if it already exists — idempotent enough to
118
125
  // ignore).
119
- const res = await fetch(`http://${name}:${port}/${bucket}`, {
126
+ const res = await fetch(`http://${name}:${S3_PORT}/${bucket}`, {
120
127
  method: "PUT",
121
128
  });
122
129
  if (!res.ok && res.status !== 409) {
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