@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 +128 -72
- package/dist/harness/build-context.d.ts +35 -11
- package/dist/harness/build-context.js +26 -8
- package/dist/index.d.ts +49 -21
- package/dist/index.js +37 -26
- package/package.json +1 -1
- package/src/daemon.ts +142 -83
- package/src/dockerfile-path.test.ts +61 -18
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +54 -15
- package/src/index.ts +82 -36
|
@@ -47,10 +47,24 @@ export function isGeneratedDockerignore(text: string): boolean {
|
|
|
47
47
|
return text.startsWith(GENERATED_DOCKERIGNORE_HEADER);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The project's own ignore rules for one build, chosen the way BuildKit
|
|
52
|
+
* chooses them: a `<Dockerfile>.dockerignore` next to the Dockerfile wins
|
|
53
|
+
* outright, else the context root's `.dockerignore`, else nothing. The two
|
|
54
|
+
* are never merged — a Dockerfile-adjacent file is the whole rule set for
|
|
55
|
+
* that build, exactly as `docker build` reads it.
|
|
56
|
+
*/
|
|
57
|
+
export function pickProjectIgnore(
|
|
58
|
+
adjacent: string | null,
|
|
59
|
+
contextRoot: string | null,
|
|
60
|
+
): string | null {
|
|
61
|
+
return adjacent ?? contextRoot;
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
/**
|
|
51
65
|
* Ignore rules for one dockerfile build, in precedence order: our
|
|
52
|
-
* defaults, then the project's own
|
|
53
|
-
* service's `exclude`.
|
|
66
|
+
* defaults, then the project's own ignore file verbatim (see
|
|
67
|
+
* {@link pickProjectIgnore}), then that service's `exclude`.
|
|
54
68
|
*
|
|
55
69
|
* **The order is load-bearing**, because of the `**`-plus-negations idiom
|
|
56
70
|
* that monorepos use to keep a build context small:
|
|
@@ -113,44 +127,69 @@ export function unionDockerignore(
|
|
|
113
127
|
return [GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n";
|
|
114
128
|
}
|
|
115
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Everything one dockerfile build reads, resolved from the project: the
|
|
132
|
+
* Dockerfile's bytes (from `path` or `content`), the context directory
|
|
133
|
+
* (project-root-relative, `.` by default), the composed ignore rules, the
|
|
134
|
+
* stage to stop at, and the build args. The harness resolves this once per
|
|
135
|
+
* service and both the dedup key and the build itself work from it.
|
|
136
|
+
*/
|
|
137
|
+
export interface ResolvedBuild {
|
|
138
|
+
/** The Dockerfile text. */
|
|
139
|
+
content: string;
|
|
140
|
+
/** Project-root-relative build context directory (`.` for the root). */
|
|
141
|
+
context: string;
|
|
142
|
+
/** The generated `<Dockerfile>.dockerignore` text. */
|
|
143
|
+
ignore: string;
|
|
144
|
+
/** `--target` stage, if any. */
|
|
145
|
+
target?: string;
|
|
146
|
+
buildArgs?: Readonly<Record<string, string>>;
|
|
147
|
+
}
|
|
148
|
+
|
|
116
149
|
/**
|
|
117
150
|
* Identity of a dockerfile build within one bootstrap, so services sharing
|
|
118
151
|
* an image definition (an api and a worker on the same codebase with
|
|
119
152
|
* different entrypoints) build once and the rest just re-tag.
|
|
120
153
|
*
|
|
121
|
-
* The
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
154
|
+
* The context *directory* is part of the key, but its *contents* are not:
|
|
155
|
+
* `/workspace` is fixed within a bootstrap and mutable between them, so
|
|
156
|
+
* this key is only ever valid inside one workspace generation. The dedup
|
|
157
|
+
* map is cleared per bootstrap for exactly that reason.
|
|
125
158
|
*/
|
|
126
159
|
export interface Hasher {
|
|
127
160
|
update(s: string): Hasher;
|
|
128
161
|
digest(encoding: "hex"): string;
|
|
129
162
|
}
|
|
130
163
|
|
|
131
|
-
export function buildContentKey(
|
|
132
|
-
createHasher: () => Hasher,
|
|
133
|
-
image: { content: string; exclude?: readonly string[]; buildArgs?: Readonly<Record<string, string>> },
|
|
134
|
-
): string {
|
|
164
|
+
export function buildContentKey(createHasher: () => Hasher, build: ResolvedBuild): string {
|
|
135
165
|
return (
|
|
136
166
|
createHasher()
|
|
137
|
-
.update(
|
|
138
|
-
// A separator that cannot occur in
|
|
139
|
-
// Dockerfile whose text ends with an
|
|
167
|
+
.update(build.content)
|
|
168
|
+
// A separator that cannot occur in any field. Without it a
|
|
169
|
+
// Dockerfile whose text ends with an ignore list would hash the
|
|
140
170
|
// same as that Dockerfile with the list actually set, and two
|
|
141
171
|
// genuinely different builds would collapse into one.
|
|
142
172
|
.update("\0")
|
|
143
|
-
.update(
|
|
173
|
+
.update(build.context)
|
|
174
|
+
.update("\0")
|
|
175
|
+
.update(build.ignore)
|
|
176
|
+
.update("\0")
|
|
177
|
+
.update(build.target ?? "")
|
|
144
178
|
// Build args are an input to the image (an ARG picks the base image,
|
|
145
179
|
// the NODE_ENV of an install step…), so two services on one
|
|
146
180
|
// Dockerfile with different args must not share a build. Sorted, so
|
|
147
181
|
// key order in the user's object doesn't split identical builds.
|
|
148
182
|
.update("\0")
|
|
149
|
-
.update(JSON.stringify(buildArgFlags(
|
|
183
|
+
.update(JSON.stringify(buildArgFlags(build.buildArgs)))
|
|
150
184
|
.digest("hex")
|
|
151
185
|
);
|
|
152
186
|
}
|
|
153
187
|
|
|
188
|
+
/** `--target <stage>`, or nothing. A plain client flag every builder takes. */
|
|
189
|
+
export function buildTargetFlags(target: string | undefined): string[] {
|
|
190
|
+
return target ? ["--target", target] : [];
|
|
191
|
+
}
|
|
192
|
+
|
|
154
193
|
/** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
|
|
155
194
|
export function buildArgFlags(buildArgs: Readonly<Record<string, string>> | undefined): string[] {
|
|
156
195
|
return Object.entries(buildArgs ?? {})
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// over HTTP.
|
|
6
6
|
|
|
7
7
|
import { strict as nodeAssert } from "node:assert";
|
|
8
|
-
import {
|
|
8
|
+
import { statSync } from "node:fs";
|
|
9
9
|
|
|
10
10
|
import { recordAssertion, safeSerialize } from "./recorder.js";
|
|
11
11
|
import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
|
|
@@ -1138,17 +1138,33 @@ export type ServiceImage =
|
|
|
1138
1138
|
* Path of a Dockerfile in your repo, relative to the project root
|
|
1139
1139
|
* (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
|
|
1140
1140
|
* This is the preferred form: the test environment builds the same
|
|
1141
|
-
* Dockerfile production does, so the two cannot drift. The
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1141
|
+
* Dockerfile production does, so the two cannot drift. The file is
|
|
1142
|
+
* read from the repo at build time; a missing file fails the
|
|
1143
|
+
* environment load and names the path. Mutually exclusive with
|
|
1144
|
+
* `content`.
|
|
1145
|
+
*
|
|
1146
|
+
* Ignore rules come from the repo the way `docker build` reads them:
|
|
1147
|
+
* a `<Dockerfile>.dockerignore` beside this file, else the context
|
|
1148
|
+
* directory's `.dockerignore`. `exclude` adds to whichever applied.
|
|
1147
1149
|
*/
|
|
1148
1150
|
path: string;
|
|
1149
1151
|
content?: never;
|
|
1152
|
+
/**
|
|
1153
|
+
* Build context directory, relative to the project root. Defaults to
|
|
1154
|
+
* the project root, as with `docker build -f apps/api/Dockerfile .`.
|
|
1155
|
+
* `"apps/api"` is `docker build apps/api`: `COPY` / `ADD` resolve
|
|
1156
|
+
* against that directory, and so do the ignore rules.
|
|
1157
|
+
*/
|
|
1158
|
+
context?: string;
|
|
1150
1159
|
/** Extra glob patterns to exclude from the build context. */
|
|
1151
1160
|
exclude?: readonly string[];
|
|
1161
|
+
/**
|
|
1162
|
+
* Stage to build, as `docker build --target <stage>`. Lets a
|
|
1163
|
+
* production Dockerfile keep a final stage the tests never need (an
|
|
1164
|
+
* ops layer, a slow install) while the environment stops at the
|
|
1165
|
+
* stage before it.
|
|
1166
|
+
*/
|
|
1167
|
+
target?: string;
|
|
1152
1168
|
/**
|
|
1153
1169
|
* `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
|
|
1154
1170
|
* Build-time only: they are not in the container's environment (use
|
|
@@ -1161,16 +1177,24 @@ export type ServiceImage =
|
|
|
1161
1177
|
| {
|
|
1162
1178
|
type: "dockerfile";
|
|
1163
1179
|
/**
|
|
1164
|
-
* Dockerfile contents,
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1180
|
+
* Dockerfile contents, built as if the file sat outside the build
|
|
1181
|
+
* context (it is never written into your repo). The context is the
|
|
1182
|
+
* project root (where `spectest/` lives) unless `context` says
|
|
1183
|
+
* otherwise, and `COPY` / `ADD` resolve against it. Prefer `path`,
|
|
1184
|
+
* which points at the Dockerfile your repo already has. Mutually
|
|
1185
|
+
* exclusive with `path`.
|
|
1169
1186
|
*/
|
|
1170
1187
|
content: string;
|
|
1171
1188
|
path?: never;
|
|
1189
|
+
/**
|
|
1190
|
+
* Build context directory, relative to the project root. Defaults to
|
|
1191
|
+
* the project root. The context's own `.dockerignore` applies.
|
|
1192
|
+
*/
|
|
1193
|
+
context?: string;
|
|
1172
1194
|
/** Extra glob patterns to exclude from the build context. */
|
|
1173
1195
|
exclude?: readonly string[];
|
|
1196
|
+
/** Stage to build, as `docker build --target <stage>`. */
|
|
1197
|
+
target?: string;
|
|
1174
1198
|
/**
|
|
1175
1199
|
* `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
|
|
1176
1200
|
* Build-time only: they are not in the container's environment (use
|
|
@@ -1203,20 +1227,27 @@ function validateBuildArgs(serviceName: string, buildArgs: Readonly<Record<strin
|
|
|
1203
1227
|
}
|
|
1204
1228
|
}
|
|
1205
1229
|
|
|
1230
|
+
/** A Dockerfile stage name, as `--target` accepts it: no whitespace. */
|
|
1231
|
+
const BUILD_TARGET_RE = /^[^\s]+$/;
|
|
1232
|
+
|
|
1206
1233
|
/**
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1234
|
+
* Check a service's `image` at config time, so a mistake fails the
|
|
1235
|
+
* environment load with a message that names it, instead of a build
|
|
1236
|
+
* minutes later. Nothing is rewritten: a `path` Dockerfile stays a path,
|
|
1237
|
+
* and the harness reads it — and the ignore file beside it — from the
|
|
1238
|
+
* repo when it builds (`daemon.ts::resolveDockerfileBuild`). The wire
|
|
1239
|
+
* config therefore carries `path`, `context` and `target` as written; the
|
|
1240
|
+
* control plane only tells a Dockerfile build from a registry pull, and
|
|
1241
|
+
* the warm-template hash covers the files because they are part of the
|
|
1242
|
+
* project tree.
|
|
1212
1243
|
*
|
|
1213
|
-
* Runs
|
|
1214
|
-
*
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
1244
|
+
* Runs in `defineEnvironment`, and in the daemon's runtime `startService`
|
|
1245
|
+
* twin, inside the VM, where the repo sits at the project root. Existence
|
|
1246
|
+
* checks go through the project-file resolver, so the same rules as
|
|
1247
|
+
* `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
|
|
1217
1248
|
* instead of read stale).
|
|
1218
1249
|
*/
|
|
1219
|
-
export function
|
|
1250
|
+
export function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage {
|
|
1220
1251
|
if (image.type !== "dockerfile") return image;
|
|
1221
1252
|
validateBuildArgs(serviceName, image.buildArgs);
|
|
1222
1253
|
const hasPath = typeof image.path === "string";
|
|
@@ -1231,20 +1262,35 @@ export function resolveServiceImage(serviceName: string, image: ServiceImage): S
|
|
|
1231
1262
|
`service "${serviceName}" image { type: "dockerfile" } needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``,
|
|
1232
1263
|
);
|
|
1233
1264
|
}
|
|
1234
|
-
if (
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1265
|
+
if (hasPath) {
|
|
1266
|
+
const file = resolveExistingProjectPath(image.path as string, `service "${serviceName}" Dockerfile`);
|
|
1267
|
+
if (!statSync(file).isFile()) {
|
|
1268
|
+
throw new Error(
|
|
1269
|
+
`service "${serviceName}" Dockerfile ${JSON.stringify(image.path)} is not a file — \`path\` names the Dockerfile itself, \`context\` names the directory to build from`,
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
if (image.context !== undefined) {
|
|
1274
|
+
if (typeof image.context !== "string" || image.context === "") {
|
|
1275
|
+
throw new Error(
|
|
1276
|
+
`service "${serviceName}" image context must be a directory path relative to the project root (\`"."\` for the root)`,
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context`);
|
|
1280
|
+
if (!statSync(dir).isDirectory()) {
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
`service "${serviceName}" build context ${JSON.stringify(image.context)} is not a directory`,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
if (image.target !== undefined) {
|
|
1287
|
+
if (typeof image.target !== "string" || !BUILD_TARGET_RE.test(image.target)) {
|
|
1288
|
+
throw new Error(
|
|
1289
|
+
`service "${serviceName}" image target must be a Dockerfile stage name (\`FROM … AS <name>\`); got ${JSON.stringify(image.target)}`,
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1243
1292
|
}
|
|
1244
|
-
|
|
1245
|
-
if (image.exclude !== undefined) lowered.exclude = image.exclude;
|
|
1246
|
-
if (image.buildArgs !== undefined) lowered.buildArgs = image.buildArgs;
|
|
1247
|
-
return lowered;
|
|
1293
|
+
return image;
|
|
1248
1294
|
}
|
|
1249
1295
|
|
|
1250
1296
|
// Coverage adapters live in `coverage.ts`; the type is re-declared here
|
|
@@ -2389,7 +2435,7 @@ export function defineEnvironment<
|
|
|
2389
2435
|
for (const [key, svc] of Object.entries(expanded)) {
|
|
2390
2436
|
expanded[key] = applyCoverageAdapters(key, {
|
|
2391
2437
|
...svc,
|
|
2392
|
-
image:
|
|
2438
|
+
image: validateServiceImage(key, svc.image),
|
|
2393
2439
|
});
|
|
2394
2440
|
}
|
|
2395
2441
|
const config: EnvironmentConfig<S> = {
|