@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/daemon.js CHANGED
@@ -21,7 +21,7 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
21
21
  import net from "node:net";
22
22
  import path from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
- import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, resolveServiceImage, proxy as makeProxyDecl, } from "./index.js";
24
+ import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
25
25
  import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, } from "./harness/coverage.js";
26
26
  import { configureBrowserCoverage } from "./browser-coverage.js";
27
27
  import { applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, nodeCoverageToolsAvailable, nodeCoverageToolsDir, NODE_COVERAGE_TOOLS_CONTAINER_DIR, } from "./coverage.js";
@@ -32,7 +32,7 @@ import { isMobileApp, openPersistentMobile } from "./mobile.js";
32
32
  // harness/hostmatch.ts). Keeping ONE implementation is the point: the
33
33
  // exact-then-longest-suffix rule and the one-label certificate rule are
34
34
  // each easy to restate subtly differently.
35
- import { buildArgFlags, buildContentKey as computeBuildContentKey, imageTag, isGeneratedDockerignore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
35
+ import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
36
36
  import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
37
37
  import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
38
38
  import { summarizeBuildKit } from "./harness/buildkit-progress.js";
@@ -41,7 +41,7 @@ import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./ha
41
41
  import { pollUntilReady } from "./harness/ready-poll.js";
42
42
  import { runWrapperRules } from "./harness/wrapper-rules.js";
43
43
  import { cpus } from "node:os";
44
- import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
44
+ import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
45
45
  import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
46
46
  import { encodeRegistry } from "./harness/names-registry.js";
47
47
  import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
@@ -459,6 +459,16 @@ const BUILDKITD_UP_PATH = "/usr/local/bin/spectest-buildkitd-up";
459
459
  async function imageCachePaths() {
460
460
  return imageCachePathsSync(IMAGE_CACHE_MANIFEST);
461
461
  }
462
+ /**
463
+ * The bring-up, once. A promise and not a flag: every build of a
464
+ * bootstrap calls this at the same time now that builds go in together,
465
+ * and a flag set to "not yet" while the first caller waits for buildkitd
466
+ * sent every other build to dockerd's own builder — which cannot build
467
+ * on the erofs store at all (`lease does not exist`), so Specific's
468
+ * first three cold builds on SDK 0.76.0 failed inside ten seconds
469
+ * (2026-09-07). Sharing the promise makes every caller wait for the one
470
+ * bring-up and read its one answer.
471
+ */
462
472
  let _localBuilder;
463
473
  /**
464
474
  * Start buildkitd inside this VM with its state on the cache disk, and
@@ -469,10 +479,11 @@ let _localBuilder;
469
479
  * best-effort: a daemon that will not start falls back to the shared host
470
480
  * one, which is a slower build and not a failed one.
471
481
  */
472
- async function ensureLocalBuildkitd() {
473
- if (_localBuilder !== undefined)
474
- return _localBuilder;
475
- _localBuilder = false;
482
+ function ensureLocalBuildkitd() {
483
+ _localBuilder ??= bringUpLocalBuildkitd();
484
+ return _localBuilder;
485
+ }
486
+ async function bringUpLocalBuildkitd() {
476
487
  const paths = await imageCachePaths();
477
488
  if (!paths)
478
489
  return false;
@@ -515,7 +526,6 @@ async function ensureLocalBuildkitd() {
515
526
  // hit is a snapshot that already exists — no tar blob to unpack
516
527
  // before the first uncached step, no export pass after a miss.
517
528
  await ensureBuildkitCacheUnion(paths);
518
- _localBuilder = true;
519
529
  return true;
520
530
  }
521
531
  /**
@@ -914,11 +924,12 @@ async function ensureCertificates(svc, tag) {
914
924
  * context silently became the whole repo, and the checked-out file was
915
925
  * clobbered for any in-env tooling that read it too.
916
926
  */
917
- let PROJECT_DOCKERIGNORE = null;
918
- async function readProjectDockerignore() {
927
+ /** A project ignore file, or null when it is missing — or when it is one
928
+ * we wrote ourselves on an earlier bootstrap of this workspace, which is
929
+ * not the project's intent. */
930
+ async function readIgnoreFile(file) {
919
931
  try {
920
- const text = await fs.readFile(path.join(WORKSPACE, ".dockerignore"), "utf8");
921
- // Ours, from a previous bootstrap of this workspace — not the project's.
932
+ const text = await fs.readFile(file, "utf8");
922
933
  if (isGeneratedDockerignore(text))
923
934
  return null;
924
935
  return text;
@@ -927,22 +938,70 @@ async function readProjectDockerignore() {
927
938
  return null;
928
939
  }
929
940
  }
930
- /** Ignore rules for one dockerfile build. Composition (and the reason the
931
- * order matters) lives in `harness/build-context.ts`; this supplies the
932
- * project's own file, read once per bootstrap. */
933
- function serviceDockerignore(exclude) {
934
- return composeServiceDockerignore(PROJECT_DOCKERIGNORE, exclude);
935
- }
936
941
  /// In-flight/finished dockerfile builds of this bootstrap, keyed by
937
- /// sha256(dockerfile content + exclude list). Services that share an
938
- /// identical image definition (e.g. an API server and a worker running the
939
- /// same codebase with different entrypoints) build ONCE; the others wait
940
- /// and `docker tag` the result. Cleared at every bootstrap() — the build
941
- /// CONTEXT (/workspace) is an input too, so dedup is only valid within one
942
- /// workspace generation (runtime services started mid-test share it).
942
+ /// sha256(Dockerfile bytes + context dir + ignore rules + target + args).
943
+ /// Services that share an identical image definition (e.g. an API server
944
+ /// and a worker running the same codebase with different entrypoints)
945
+ /// build ONCE; the others wait and `docker tag` the result. Cleared at
946
+ /// every bootstrap() — the CONTENTS of the build context are an input too,
947
+ /// so dedup is only valid within one workspace generation (runtime
948
+ /// services started mid-test share it).
943
949
  const BUILD_DEDUP = new Map();
944
- function buildContentKey(image) {
945
- return computeBuildContentKey(() => new Bun.CryptoHasher("sha256"), image);
950
+ function buildContentKey(build) {
951
+ return computeBuildContentKey(() => new Bun.CryptoHasher("sha256"), build);
952
+ }
953
+ /**
954
+ * Read everything a dockerfile build needs off the mounted project: the
955
+ * Dockerfile (`content` as given, or `path` read from the repo now, at
956
+ * build time — never copied through the config), the context directory,
957
+ * and the project's own ignore rules chosen the way `docker build`
958
+ * chooses them (`<Dockerfile>.dockerignore` beside a `path` Dockerfile,
959
+ * else `<context>/.dockerignore`; see `pickProjectIgnore`). Our defaults
960
+ * and the service's `exclude` are composed around those into the
961
+ * per-service ignore file the build is pointed at.
962
+ *
963
+ * `validateServiceImage` already refused a missing file or directory at
964
+ * load time, so a failure here is a read error, not a typo.
965
+ */
966
+ async function resolveDockerfileBuild(svc) {
967
+ const image = svc.image;
968
+ if (image.type !== "dockerfile") {
969
+ throw new Error(`service "${svc.name}" image is not a Dockerfile build`);
970
+ }
971
+ const contextRel = image.context ?? ".";
972
+ const contextDir = resolveExistingProjectPath(contextRel, `service "${svc.name}" build context`);
973
+ let content;
974
+ let adjacent = null;
975
+ let adjacentPath = null;
976
+ if (typeof image.path === "string") {
977
+ const file = resolveExistingProjectPath(image.path, `service "${svc.name}" Dockerfile`);
978
+ content = await fs.readFile(file, "utf8");
979
+ adjacentPath = `${image.path}.dockerignore`;
980
+ adjacent = await readIgnoreFile(`${file}.dockerignore`);
981
+ }
982
+ else if (typeof image.content === "string") {
983
+ content = image.content;
984
+ }
985
+ else {
986
+ throw new Error(`service "${svc.name}" image { type: "dockerfile" } has neither \`path\` nor \`content\``);
987
+ }
988
+ const contextRoot = await readIgnoreFile(path.join(contextDir, ".dockerignore"));
989
+ const projectIgnore = pickProjectIgnore(adjacent, contextRoot);
990
+ const ignoreSource = adjacent !== null
991
+ ? adjacentPath
992
+ : contextRoot !== null
993
+ ? path.posix.join(contextRel, ".dockerignore")
994
+ : null;
995
+ return {
996
+ content,
997
+ context: contextRel,
998
+ contextDir,
999
+ ignore: composeServiceDockerignore(projectIgnore, image.exclude),
1000
+ ignoreSource,
1001
+ excludeCount: image.exclude?.length ?? 0,
1002
+ target: image.target,
1003
+ buildArgs: image.buildArgs,
1004
+ };
946
1005
  }
947
1006
  async function prepareServiceImage(svc, opts) {
948
1007
  const tag = imageTag(svc.name);
@@ -977,11 +1036,10 @@ async function prepareServiceImage(svc, opts) {
977
1036
  }
978
1037
  // Dockerfile build. Within one bootstrap, identical definitions (shared
979
1038
  // codebase images) dedup to a single build. Only bootstrap opts in: the
980
- // dedup key is dockerfile content + exclude, but the build CONTEXT
981
- // (/workspace) is an input too — a runtime service started mid-test
982
- // after setup/test code mutated /workspace must rebuild, not share a
983
- // pre-mutation image.
984
- const image = dockerfileContent(svc);
1039
+ // dedup key names the context directory but not its contents — a
1040
+ // runtime service started mid-test after setup/test code mutated
1041
+ // /workspace must rebuild, not share a pre-mutation image.
1042
+ const image = await resolveDockerfileBuild(svc);
985
1043
  if (opts?.dedup) {
986
1044
  const key = buildContentKey(image);
987
1045
  const inflight = BUILD_DEDUP.get(key);
@@ -1017,16 +1075,6 @@ async function prepareServiceImage(svc, opts) {
1017
1075
  }
1018
1076
  return buildServiceImage(svc.name, image, tag);
1019
1077
  }
1020
- /** The built form of a dockerfile image. `resolveServiceImage` read a
1021
- * `path` into `content` at config time, so a service that still carries
1022
- * `path` here skipped that step — a programming error, not user input. */
1023
- function dockerfileContent(svc) {
1024
- const image = svc.image;
1025
- if (image.type !== "dockerfile" || typeof image.content !== "string") {
1026
- throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
1027
- }
1028
- return image;
1029
- }
1030
1078
  /**
1031
1079
  * Build a dockerfile service's image — the project's Dockerfile as
1032
1080
  * written, nothing appended.
@@ -1050,22 +1098,33 @@ async function buildServiceImage(name, image, tag) {
1050
1098
  async function runServiceBuild(name, image, tag) {
1051
1099
  let buildSteps;
1052
1100
  {
1053
- const content = image.content;
1101
+ // The build runs straight against the mounted project: the context is
1102
+ // the repo directory the service named (the root by default), and a
1103
+ // `path` Dockerfile's bytes were read from the repo just now. The one
1104
+ // thing written is a per-service pair under `.spectest/services/`:
1105
+ // the Dockerfile's text and, beside it, the ignore rules. That pair
1106
+ // exists because BuildKit takes extra ignore rules from exactly one
1107
+ // place — a `<Dockerfile>.dockerignore` next to the Dockerfile it was
1108
+ // pointed at — and the rules here are per service (our defaults, the
1109
+ // project's own file, ITS OWN `exclude`): two services building one
1110
+ // repo Dockerfile with different excludes need two of them, and
1111
+ // nothing may be written next to the user's file. So `-f` names our
1112
+ // copy and the context stays the user's directory; a Dockerfile
1113
+ // outside its context is ordinary `docker build -f`. Verified on both
1114
+ // the buildx and DOCKER_BUILDKIT paths (client-side context
1115
+ // filtering). The root `.dockerignore` written at bootstrap stays as
1116
+ // the fallback for the legacy non-BuildKit builder, which predates
1117
+ // per-Dockerfile ignores — and is written ONLY when the project ships
1118
+ // none of its own.
1054
1119
  const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
1055
1120
  await fs.mkdir(dfDir, { recursive: true });
1056
1121
  const dfPath = path.join(dfDir, "Dockerfile");
1057
- await fs.writeFile(dfPath, content);
1058
- // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
1059
- // (next to the Dockerfile) in preference to the context root's
1060
- // `.dockerignore`, so this build sees the defaults, the project's own
1061
- // `.dockerignore`, and ITS OWN `exclude` — one service excluding
1062
- // `handhelds/**` no longer empties a sibling's build context. Verified
1063
- // on both the remote-buildx and DOCKER_BUILDKIT paths (client-side
1064
- // context filtering). The root `.dockerignore` written at bootstrap
1065
- // stays as the fallback for the legacy non-BuildKit builder, which
1066
- // predates per-Dockerfile ignores — and is written ONLY when the
1067
- // project ships none of its own (see readProjectDockerignore).
1068
- await fs.writeFile(`${dfPath}.dockerignore`, serviceDockerignore(image.exclude));
1122
+ await fs.writeFile(dfPath, image.content);
1123
+ await fs.writeFile(`${dfPath}.dockerignore`, image.ignore);
1124
+ console.log(`[build] ${name}: context ${image.context}` +
1125
+ (image.target ? `, target ${image.target}` : "") +
1126
+ (image.ignoreSource ? `, ignore rules from ${image.ignoreSource}` : ", no project ignore file") +
1127
+ (image.excludeCount > 0 ? ` + ${image.excludeCount} exclude pattern(s)` : ""));
1069
1128
  // The in-VM buildkitd on the container store first: the build runs
1070
1129
  // inside the guest's own isolation boundary, against this project's
1071
1130
  // own layer cache, and the finished image is already in the store
@@ -1080,7 +1139,8 @@ async function runServiceBuild(name, image, tag) {
1080
1139
  let buildArgs;
1081
1140
  // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1082
1141
  // so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
1083
- const argFlags = buildArgFlags(image.buildArgs);
1142
+ const argFlags = [...buildTargetFlags(image.target), ...buildArgFlags(image.buildArgs)];
1143
+ const contextDir = image.contextDir;
1084
1144
  if (useLocal) {
1085
1145
  // The in-VM builder is BuildKit's containerd worker on this VM's
1086
1146
  // own image store (CONTAINER_STORE.md): the output is an image
@@ -1100,15 +1160,15 @@ async function runServiceBuild(name, image, tag) {
1100
1160
  "--progress=plain",
1101
1161
  "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1102
1162
  ...argFlags,
1103
- "-f", dfPath, WORKSPACE,
1163
+ "-f", dfPath, contextDir,
1104
1164
  ];
1105
1165
  }
1106
1166
  else if (useBuildKit) {
1107
- buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1167
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", contextDir];
1108
1168
  buildEnv.DOCKER_BUILDKIT = "1";
1109
1169
  }
1110
1170
  else {
1111
- buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, WORKSPACE];
1171
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
1112
1172
  }
1113
1173
  progressService(name, { status: "building", detail: "starting build" });
1114
1174
  const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
@@ -2614,9 +2674,9 @@ const RUNTIME_SERVICES = new Map();
2614
2674
  function specToNamedService(spec) {
2615
2675
  const { name, ...rest } = spec;
2616
2676
  // A runtime spec skipped `defineEnvironment`, so its config-time passes
2617
- // run here: a `path` Dockerfile is read into `content`, and coverage
2618
- // adapters get their configure step.
2619
- const svc = { ...rest, image: resolveServiceImage(name, rest.image) };
2677
+ // run here: the image is checked (a `path` Dockerfile and a `context`
2678
+ // must exist), and coverage adapters get their configure step.
2679
+ const svc = { ...rest, image: validateServiceImage(name, rest.image) };
2620
2680
  return { name, ...applyCoverageAdapters(name, svc) };
2621
2681
  }
2622
2682
  /** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
@@ -2935,18 +2995,14 @@ async function bootstrapInner() {
2935
2995
  ensureNetwork(),
2936
2996
  (async () => {
2937
2997
  await fs.mkdir(WORKSPACE, { recursive: true });
2938
- // Read the project's own .dockerignore BEFORE we consider writing
2939
- // one — every per-service ignore composes on top of it.
2940
- PROJECT_DOCKERIGNORE = await readProjectDockerignore();
2941
- if (PROJECT_DOCKERIGNORE !== null) {
2942
- console.log("[bootstrap] using the project's .dockerignore for every build context");
2943
- // Never overwrite it. BuildKit reads the per-service
2944
- // `<Dockerfile>.dockerignore` (which already folds this file in),
2945
- // and the legacy builder reads the project's file directly —
2946
- // which is what the project asked for. Clobbering it also broke
2947
- // any in-env tooling that reads it.
2998
+ // Never overwrite the project's own root `.dockerignore`. BuildKit
2999
+ // reads the per-service `<Dockerfile>.dockerignore` (which folds the
3000
+ // project's file in, see resolveDockerfileBuild), and the legacy
3001
+ // builder reads the project's file directly — which is what the
3002
+ // project asked for. Clobbering it also broke any in-env tooling
3003
+ // that reads it.
3004
+ if ((await readIgnoreFile(path.join(WORKSPACE, ".dockerignore"))) !== null)
2948
3005
  return;
2949
- }
2950
3006
  await fs.writeFile(path.join(WORKSPACE, ".dockerignore"), unionDockerignore(services));
2951
3007
  })(),
2952
3008
  ]);
@@ -21,10 +21,18 @@ export declare const DEFAULT_DOCKERIGNORE: readonly string[];
21
21
  export declare const GENERATED_DOCKERIGNORE_HEADER = "# spectest-generated \u2014 do not edit (your own .dockerignore is honoured verbatim)";
22
22
  /** Is this text a file we wrote ourselves on an earlier bootstrap? */
23
23
  export declare function isGeneratedDockerignore(text: string): boolean;
24
+ /**
25
+ * The project's own ignore rules for one build, chosen the way BuildKit
26
+ * chooses them: a `<Dockerfile>.dockerignore` next to the Dockerfile wins
27
+ * outright, else the context root's `.dockerignore`, else nothing. The two
28
+ * are never merged — a Dockerfile-adjacent file is the whole rule set for
29
+ * that build, exactly as `docker build` reads it.
30
+ */
31
+ export declare function pickProjectIgnore(adjacent: string | null, contextRoot: string | null): string | null;
24
32
  /**
25
33
  * Ignore rules for one dockerfile build, in precedence order: our
26
- * defaults, then the project's own `.dockerignore` verbatim, then that
27
- * service's `exclude`.
34
+ * defaults, then the project's own ignore file verbatim (see
35
+ * {@link pickProjectIgnore}), then that service's `exclude`.
28
36
  *
29
37
  * **The order is load-bearing**, because of the `**`-plus-negations idiom
30
38
  * that monorepos use to keep a build context small:
@@ -62,24 +70,40 @@ export declare function unionDockerignore(services: readonly {
62
70
  exclude?: readonly string[];
63
71
  };
64
72
  }[]): string;
73
+ /**
74
+ * Everything one dockerfile build reads, resolved from the project: the
75
+ * Dockerfile's bytes (from `path` or `content`), the context directory
76
+ * (project-root-relative, `.` by default), the composed ignore rules, the
77
+ * stage to stop at, and the build args. The harness resolves this once per
78
+ * service and both the dedup key and the build itself work from it.
79
+ */
80
+ export interface ResolvedBuild {
81
+ /** The Dockerfile text. */
82
+ content: string;
83
+ /** Project-root-relative build context directory (`.` for the root). */
84
+ context: string;
85
+ /** The generated `<Dockerfile>.dockerignore` text. */
86
+ ignore: string;
87
+ /** `--target` stage, if any. */
88
+ target?: string;
89
+ buildArgs?: Readonly<Record<string, string>>;
90
+ }
65
91
  /**
66
92
  * Identity of a dockerfile build within one bootstrap, so services sharing
67
93
  * an image definition (an api and a worker on the same codebase with
68
94
  * different entrypoints) build once and the rest just re-tag.
69
95
  *
70
- * The build **context** is an input too, but it isn't hashed: the context
71
- * is `/workspace`, which is fixed within a bootstrap and mutable between
72
- * them, so this key is only ever valid inside one workspace generation.
73
- * The dedup map is cleared per bootstrap for exactly that reason.
96
+ * The context *directory* is part of the key, but its *contents* are not:
97
+ * `/workspace` is fixed within a bootstrap and mutable between them, so
98
+ * this key is only ever valid inside one workspace generation. The dedup
99
+ * map is cleared per bootstrap for exactly that reason.
74
100
  */
75
101
  export interface Hasher {
76
102
  update(s: string): Hasher;
77
103
  digest(encoding: "hex"): string;
78
104
  }
79
- export declare function buildContentKey(createHasher: () => Hasher, image: {
80
- content: string;
81
- exclude?: readonly string[];
82
- buildArgs?: Readonly<Record<string, string>>;
83
- }): string;
105
+ export declare function buildContentKey(createHasher: () => Hasher, build: ResolvedBuild): string;
106
+ /** `--target <stage>`, or nothing. A plain client flag every builder takes. */
107
+ export declare function buildTargetFlags(target: string | undefined): string[];
84
108
  /** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
85
109
  export declare function buildArgFlags(buildArgs: Readonly<Record<string, string>> | undefined): string[];
@@ -41,10 +41,20 @@ export const GENERATED_DOCKERIGNORE_HEADER = "# spectest-generated — do not ed
41
41
  export function isGeneratedDockerignore(text) {
42
42
  return text.startsWith(GENERATED_DOCKERIGNORE_HEADER);
43
43
  }
44
+ /**
45
+ * The project's own ignore rules for one build, chosen the way BuildKit
46
+ * chooses them: a `<Dockerfile>.dockerignore` next to the Dockerfile wins
47
+ * outright, else the context root's `.dockerignore`, else nothing. The two
48
+ * are never merged — a Dockerfile-adjacent file is the whole rule set for
49
+ * that build, exactly as `docker build` reads it.
50
+ */
51
+ export function pickProjectIgnore(adjacent, contextRoot) {
52
+ return adjacent ?? contextRoot;
53
+ }
44
54
  /**
45
55
  * Ignore rules for one dockerfile build, in precedence order: our
46
- * defaults, then the project's own `.dockerignore` verbatim, then that
47
- * service's `exclude`.
56
+ * defaults, then the project's own ignore file verbatim (see
57
+ * {@link pickProjectIgnore}), then that service's `exclude`.
48
58
  *
49
59
  * **The order is load-bearing**, because of the `**`-plus-negations idiom
50
60
  * that monorepos use to keep a build context small:
@@ -100,23 +110,31 @@ export function unionDockerignore(services) {
100
110
  }
101
111
  return [GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n";
102
112
  }
103
- export function buildContentKey(createHasher, image) {
113
+ export function buildContentKey(createHasher, build) {
104
114
  return (createHasher()
105
- .update(image.content)
106
- // A separator that cannot occur in either field. Without it a
107
- // Dockerfile whose text ends with an exclude list would hash the
115
+ .update(build.content)
116
+ // A separator that cannot occur in any field. Without it a
117
+ // Dockerfile whose text ends with an ignore list would hash the
108
118
  // same as that Dockerfile with the list actually set, and two
109
119
  // genuinely different builds would collapse into one.
110
120
  .update("\0")
111
- .update(JSON.stringify(image.exclude ?? []))
121
+ .update(build.context)
122
+ .update("\0")
123
+ .update(build.ignore)
124
+ .update("\0")
125
+ .update(build.target ?? "")
112
126
  // Build args are an input to the image (an ARG picks the base image,
113
127
  // the NODE_ENV of an install step…), so two services on one
114
128
  // Dockerfile with different args must not share a build. Sorted, so
115
129
  // key order in the user's object doesn't split identical builds.
116
130
  .update("\0")
117
- .update(JSON.stringify(buildArgFlags(image.buildArgs)))
131
+ .update(JSON.stringify(buildArgFlags(build.buildArgs)))
118
132
  .digest("hex"));
119
133
  }
134
+ /** `--target <stage>`, or nothing. A plain client flag every builder takes. */
135
+ export function buildTargetFlags(target) {
136
+ return target ? ["--target", target] : [];
137
+ }
120
138
  /** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
121
139
  export function buildArgFlags(buildArgs) {
122
140
  return Object.entries(buildArgs ?? {})
package/dist/index.d.ts CHANGED
@@ -734,17 +734,33 @@ export type ServiceImage = {
734
734
  * Path of a Dockerfile in your repo, relative to the project root
735
735
  * (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
736
736
  * This is the preferred form: the test environment builds the same
737
- * Dockerfile production does, so the two cannot drift. The build
738
- * context is always the project root, as with
739
- * `docker build -f apps/api/Dockerfile .`, so `COPY` / `ADD` resolve
740
- * against the repo root wherever the file sits. The file is read when
741
- * the environment loads; a missing file fails the load and names the
742
- * path. Mutually exclusive with `content`.
737
+ * Dockerfile production does, so the two cannot drift. The file is
738
+ * read from the repo at build time; a missing file fails the
739
+ * environment load and names the path. Mutually exclusive with
740
+ * `content`.
741
+ *
742
+ * Ignore rules come from the repo the way `docker build` reads them:
743
+ * a `<Dockerfile>.dockerignore` beside this file, else the context
744
+ * directory's `.dockerignore`. `exclude` adds to whichever applied.
743
745
  */
744
746
  path: string;
745
747
  content?: never;
748
+ /**
749
+ * Build context directory, relative to the project root. Defaults to
750
+ * the project root, as with `docker build -f apps/api/Dockerfile .`.
751
+ * `"apps/api"` is `docker build apps/api`: `COPY` / `ADD` resolve
752
+ * against that directory, and so do the ignore rules.
753
+ */
754
+ context?: string;
746
755
  /** Extra glob patterns to exclude from the build context. */
747
756
  exclude?: readonly string[];
757
+ /**
758
+ * Stage to build, as `docker build --target <stage>`. Lets a
759
+ * production Dockerfile keep a final stage the tests never need (an
760
+ * ops layer, a slow install) while the environment stops at the
761
+ * stage before it.
762
+ */
763
+ target?: string;
748
764
  /**
749
765
  * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
750
766
  * Build-time only: they are not in the container's environment (use
@@ -756,16 +772,24 @@ export type ServiceImage = {
756
772
  } | {
757
773
  type: "dockerfile";
758
774
  /**
759
- * Dockerfile contents, written verbatim into the build context. The
760
- * build context is the project root (where `spectest/` lives), so any
761
- * `COPY` / `ADD` references resolve relative to that directory.
762
- * Prefer `path`, which points at the Dockerfile your repo already has.
763
- * Mutually exclusive with `path`.
775
+ * Dockerfile contents, built as if the file sat outside the build
776
+ * context (it is never written into your repo). The context is the
777
+ * project root (where `spectest/` lives) unless `context` says
778
+ * otherwise, and `COPY` / `ADD` resolve against it. Prefer `path`,
779
+ * which points at the Dockerfile your repo already has. Mutually
780
+ * exclusive with `path`.
764
781
  */
765
782
  content: string;
766
783
  path?: never;
784
+ /**
785
+ * Build context directory, relative to the project root. Defaults to
786
+ * the project root. The context's own `.dockerignore` applies.
787
+ */
788
+ context?: string;
767
789
  /** Extra glob patterns to exclude from the build context. */
768
790
  exclude?: readonly string[];
791
+ /** Stage to build, as `docker build --target <stage>`. */
792
+ target?: string;
769
793
  /**
770
794
  * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
771
795
  * Build-time only: they are not in the container's environment (use
@@ -776,19 +800,23 @@ export type ServiceImage = {
776
800
  buildArgs?: Readonly<Record<string, string>>;
777
801
  };
778
802
  /**
779
- * Resolve a service's `image` to the form the harness builds: a `path`
780
- * Dockerfile is read from the repo and becomes `content`. The wire config
781
- * therefore never carries `path` — the control plane and the daemon's build
782
- * code only ever see Dockerfile bytes, and the warm-template hash covers
783
- * them because the file is part of the project tree.
803
+ * Check a service's `image` at config time, so a mistake fails the
804
+ * environment load with a message that names it, instead of a build
805
+ * minutes later. Nothing is rewritten: a `path` Dockerfile stays a path,
806
+ * and the harness reads it — and the ignore file beside it — from the
807
+ * repo when it builds (`daemon.ts::resolveDockerfileBuild`). The wire
808
+ * config therefore carries `path`, `context` and `target` as written; the
809
+ * control plane only tells a Dockerfile build from a registry pull, and
810
+ * the warm-template hash covers the files because they are part of the
811
+ * project tree.
784
812
  *
785
- * Runs at config time (`defineEnvironment`, and the daemon's runtime
786
- * `startService` twin), inside the VM, where the repo sits at the project
787
- * root. The read goes through the project-file resolver, so the same rules
788
- * as `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
813
+ * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
814
+ * twin, inside the VM, where the repo sits at the project root. Existence
815
+ * checks go through the project-file resolver, so the same rules as
816
+ * `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
789
817
  * instead of read stale).
790
818
  */
791
- export declare function resolveServiceImage(serviceName: string, image: ServiceImage): ServiceImage;
819
+ export declare function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage;
792
820
  export interface VolumeMount {
793
821
  /**
794
822
  * Named shared volume. Two services mounting the same `name` share one