@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.
@@ -1,7 +1,5 @@
1
1
  import { type S3ClientLike } from "../s3.js";
2
2
  export interface S3Options {
3
- /** HTTP port the S3 API is served on. Default `9090`. */
4
- port?: number;
5
3
  /**
6
4
  * Buckets to create on first boot. The backing server's own `initialBuckets`
7
5
  * env is unreliable across versions, so the component creates them in `setup`
@@ -17,7 +15,7 @@ export interface S3Options {
17
15
  /**
18
16
  * Hostnames to additionally serve the store at over **HTTPS** (CA-trusted),
19
17
  * via the daemon's TLS-terminating reverse proxy. Optional — defaults to none;
20
- * the store is always reachable at `http://<key>:<port>` regardless. Set this
18
+ * the store is always reachable at `http://<key>:9090` regardless. Set this
21
19
  * only when the app under test hardcodes a specific prod S3 endpoint it can't
22
20
  * be told to override (`https://<bucket>.s3.amazonaws.com`, a provider's
23
21
  * storage host): the daemon mints a cert for each host and proxies to the
@@ -3,6 +3,15 @@ import { S3Client } from "../s3.js";
3
3
  // is the current release; avoid exactly 5.0.0, which crashed at startup on
4
4
  // Java 25 (`NoClassDefFoundError: KotlinBuiltIns$2`) — fixed in 5.1.0.
5
5
  const S3_IMAGE = "adobe/s3mock:5.1.0";
6
+ // Port the store listens on, fixed like the image and deliberately NOT an
7
+ // option. The backing server's HTTP port is baked into the image, and nothing
8
+ // the component returns can move it — so a `port` option could only relabel
9
+ // the ready check, the declared ports and the client endpoint while the
10
+ // container kept listening here. That is exactly what it did until 0.78.0: a
11
+ // project that passed one got 60 s of refused probes and an environment that
12
+ // never booted, with a healthy container beside it. A service reaches the
13
+ // store at `http://<key>:9090`.
14
+ const S3_PORT = 9090;
6
15
  /**
7
16
  * A ready-to-use, S3-compatible object store (backed by
8
17
  * [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
@@ -35,7 +44,6 @@ const S3_IMAGE = "adobe/s3mock:5.1.0";
35
44
  * ```
36
45
  */
37
46
  export function s3(opts = {}) {
38
- const port = opts.port ?? 9090;
39
47
  const buckets = opts.buckets ?? [];
40
48
  const defaultBucket = opts.bucket ?? buckets[0];
41
49
  const accessKeyId = opts.accessKeyId ?? "s3";
@@ -43,16 +51,16 @@ export function s3(opts = {}) {
43
51
  const region = opts.region ?? "us-east-1";
44
52
  return {
45
53
  image: { type: "registry", reference: S3_IMAGE },
46
- ports: [port],
54
+ ports: [S3_PORT],
47
55
  ...(opts.env ? { env: opts.env } : {}),
48
56
  ...(opts.hosts
49
- ? { tls: opts.hosts.map((hostname) => ({ hostname, port })) }
57
+ ? { tls: opts.hosts.map((hostname) => ({ hostname, port: S3_PORT })) }
50
58
  : {}),
51
59
  // GET / returns the ListBuckets XML with 200 once the store is up.
52
- readyCheck: { type: "http", port, path: "/", timeoutSecs: 60 },
60
+ readyCheck: { type: "http", port: S3_PORT, path: "/", timeoutSecs: 60 },
53
61
  helpers: ({ name }) => ({
54
62
  client: new S3Client({
55
- endpoint: `http://${name}:${port}`,
63
+ endpoint: `http://${name}:${S3_PORT}`,
56
64
  region,
57
65
  accessKeyId,
58
66
  secretAccessKey,
@@ -67,7 +75,7 @@ export function s3(opts = {}) {
67
75
  // CreateBucket is a plain `PUT` on the bucket root; the store
68
76
  // returns 200 (or 409 if it already exists — idempotent enough to
69
77
  // ignore).
70
- const res = await fetch(`http://${name}:${port}/${bucket}`, {
78
+ const res = await fetch(`http://${name}:${S3_PORT}/${bucket}`, {
71
79
  method: "PUT",
72
80
  });
73
81
  if (!res.ok && res.status !== 409) {
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";
@@ -924,11 +924,12 @@ async function ensureCertificates(svc, tag) {
924
924
  * context silently became the whole repo, and the checked-out file was
925
925
  * clobbered for any in-env tooling that read it too.
926
926
  */
927
- let PROJECT_DOCKERIGNORE = null;
928
- 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) {
929
931
  try {
930
- const text = await fs.readFile(path.join(WORKSPACE, ".dockerignore"), "utf8");
931
- // Ours, from a previous bootstrap of this workspace — not the project's.
932
+ const text = await fs.readFile(file, "utf8");
932
933
  if (isGeneratedDockerignore(text))
933
934
  return null;
934
935
  return text;
@@ -937,22 +938,70 @@ async function readProjectDockerignore() {
937
938
  return null;
938
939
  }
939
940
  }
940
- /** Ignore rules for one dockerfile build. Composition (and the reason the
941
- * order matters) lives in `harness/build-context.ts`; this supplies the
942
- * project's own file, read once per bootstrap. */
943
- function serviceDockerignore(exclude) {
944
- return composeServiceDockerignore(PROJECT_DOCKERIGNORE, exclude);
945
- }
946
941
  /// In-flight/finished dockerfile builds of this bootstrap, keyed by
947
- /// sha256(dockerfile content + exclude list). Services that share an
948
- /// identical image definition (e.g. an API server and a worker running the
949
- /// same codebase with different entrypoints) build ONCE; the others wait
950
- /// and `docker tag` the result. Cleared at every bootstrap() — the build
951
- /// CONTEXT (/workspace) is an input too, so dedup is only valid within one
952
- /// 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).
953
949
  const BUILD_DEDUP = new Map();
954
- function buildContentKey(image) {
955
- 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
+ };
956
1005
  }
957
1006
  async function prepareServiceImage(svc, opts) {
958
1007
  const tag = imageTag(svc.name);
@@ -987,11 +1036,10 @@ async function prepareServiceImage(svc, opts) {
987
1036
  }
988
1037
  // Dockerfile build. Within one bootstrap, identical definitions (shared
989
1038
  // codebase images) dedup to a single build. Only bootstrap opts in: the
990
- // dedup key is dockerfile content + exclude, but the build CONTEXT
991
- // (/workspace) is an input too — a runtime service started mid-test
992
- // after setup/test code mutated /workspace must rebuild, not share a
993
- // pre-mutation image.
994
- 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);
995
1043
  if (opts?.dedup) {
996
1044
  const key = buildContentKey(image);
997
1045
  const inflight = BUILD_DEDUP.get(key);
@@ -1027,16 +1075,6 @@ async function prepareServiceImage(svc, opts) {
1027
1075
  }
1028
1076
  return buildServiceImage(svc.name, image, tag);
1029
1077
  }
1030
- /** The built form of a dockerfile image. `resolveServiceImage` read a
1031
- * `path` into `content` at config time, so a service that still carries
1032
- * `path` here skipped that step — a programming error, not user input. */
1033
- function dockerfileContent(svc) {
1034
- const image = svc.image;
1035
- if (image.type !== "dockerfile" || typeof image.content !== "string") {
1036
- throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
1037
- }
1038
- return image;
1039
- }
1040
1078
  /**
1041
1079
  * Build a dockerfile service's image — the project's Dockerfile as
1042
1080
  * written, nothing appended.
@@ -1060,22 +1098,33 @@ async function buildServiceImage(name, image, tag) {
1060
1098
  async function runServiceBuild(name, image, tag) {
1061
1099
  let buildSteps;
1062
1100
  {
1063
- 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.
1064
1119
  const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
1065
1120
  await fs.mkdir(dfDir, { recursive: true });
1066
1121
  const dfPath = path.join(dfDir, "Dockerfile");
1067
- await fs.writeFile(dfPath, content);
1068
- // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
1069
- // (next to the Dockerfile) in preference to the context root's
1070
- // `.dockerignore`, so this build sees the defaults, the project's own
1071
- // `.dockerignore`, and ITS OWN `exclude` — one service excluding
1072
- // `handhelds/**` no longer empties a sibling's build context. Verified
1073
- // on both the remote-buildx and DOCKER_BUILDKIT paths (client-side
1074
- // context filtering). The root `.dockerignore` written at bootstrap
1075
- // stays as the fallback for the legacy non-BuildKit builder, which
1076
- // predates per-Dockerfile ignores — and is written ONLY when the
1077
- // project ships none of its own (see readProjectDockerignore).
1078
- 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)` : ""));
1079
1128
  // The in-VM buildkitd on the container store first: the build runs
1080
1129
  // inside the guest's own isolation boundary, against this project's
1081
1130
  // own layer cache, and the finished image is already in the store
@@ -1090,7 +1139,8 @@ async function runServiceBuild(name, image, tag) {
1090
1139
  let buildArgs;
1091
1140
  // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1092
1141
  // so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
1093
- const argFlags = buildArgFlags(image.buildArgs);
1142
+ const argFlags = [...buildTargetFlags(image.target), ...buildArgFlags(image.buildArgs)];
1143
+ const contextDir = image.contextDir;
1094
1144
  if (useLocal) {
1095
1145
  // The in-VM builder is BuildKit's containerd worker on this VM's
1096
1146
  // own image store (CONTAINER_STORE.md): the output is an image
@@ -1110,15 +1160,15 @@ async function runServiceBuild(name, image, tag) {
1110
1160
  "--progress=plain",
1111
1161
  "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1112
1162
  ...argFlags,
1113
- "-f", dfPath, WORKSPACE,
1163
+ "-f", dfPath, contextDir,
1114
1164
  ];
1115
1165
  }
1116
1166
  else if (useBuildKit) {
1117
- buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1167
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", contextDir];
1118
1168
  buildEnv.DOCKER_BUILDKIT = "1";
1119
1169
  }
1120
1170
  else {
1121
- buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, WORKSPACE];
1171
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
1122
1172
  }
1123
1173
  progressService(name, { status: "building", detail: "starting build" });
1124
1174
  const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
@@ -2624,9 +2674,9 @@ const RUNTIME_SERVICES = new Map();
2624
2674
  function specToNamedService(spec) {
2625
2675
  const { name, ...rest } = spec;
2626
2676
  // A runtime spec skipped `defineEnvironment`, so its config-time passes
2627
- // run here: a `path` Dockerfile is read into `content`, and coverage
2628
- // adapters get their configure step.
2629
- 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) };
2630
2680
  return { name, ...applyCoverageAdapters(name, svc) };
2631
2681
  }
2632
2682
  /** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
@@ -2945,18 +2995,14 @@ async function bootstrapInner() {
2945
2995
  ensureNetwork(),
2946
2996
  (async () => {
2947
2997
  await fs.mkdir(WORKSPACE, { recursive: true });
2948
- // Read the project's own .dockerignore BEFORE we consider writing
2949
- // one — every per-service ignore composes on top of it.
2950
- PROJECT_DOCKERIGNORE = await readProjectDockerignore();
2951
- if (PROJECT_DOCKERIGNORE !== null) {
2952
- console.log("[bootstrap] using the project's .dockerignore for every build context");
2953
- // Never overwrite it. BuildKit reads the per-service
2954
- // `<Dockerfile>.dockerignore` (which already folds this file in),
2955
- // and the legacy builder reads the project's file directly —
2956
- // which is what the project asked for. Clobbering it also broke
2957
- // 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)
2958
3005
  return;
2959
- }
2960
3006
  await fs.writeFile(path.join(WORKSPACE, ".dockerignore"), unionDockerignore(services));
2961
3007
  })(),
2962
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