@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/components/s3.d.ts +1 -3
- package/dist/components/s3.js +14 -6
- package/dist/daemon.js +113 -67
- 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/components/s3.ts +16 -9
- package/src/daemon.ts +125 -78
- 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
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 {
|
|
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
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
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
|
|
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 (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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:
|
|
403
|
+
image: validateServiceImage(key, svc.image),
|
|
393
404
|
});
|
|
394
405
|
}
|
|
395
406
|
const config = {
|
package/package.json
CHANGED
package/src/components/s3.ts
CHANGED
|
@@ -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
|
|
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: [
|
|
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}:${
|
|
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}:${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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(
|
|
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(
|
|
1277
|
-
/// identical image definition (e.g. an API server
|
|
1278
|
-
/// same codebase with different entrypoints)
|
|
1279
|
-
/// and `docker tag` the result. Cleared at
|
|
1280
|
-
///
|
|
1281
|
-
///
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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(
|
|
1294
|
-
return computeBuildContentKey(() => new Bun.CryptoHasher("sha256") as any,
|
|
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
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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,
|
|
1519
|
+
"-f", dfPath, contextDir,
|
|
1466
1520
|
];
|
|
1467
1521
|
} else if (useBuildKit) {
|
|
1468
|
-
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain",
|
|
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,
|
|
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
|
|
3149
|
-
// adapters get their configure step.
|
|
3150
|
-
const svc = { ...(rest as ServiceConfig), 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
|
-
//
|
|
3509
|
-
//
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
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 }` —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import {
|
|
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
|
-
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
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 {
|
|
100
|
+
const { validateServiceImage } = await load();
|
|
58
101
|
expect(() =>
|
|
59
|
-
|
|
102
|
+
validateServiceImage("api", { type: "dockerfile", path: "Dockerfile", content: "FROM x" } as never),
|
|
60
103
|
).toThrow(/both `path` and `content`/);
|
|
61
|
-
expect(() =>
|
|
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 {
|
|
110
|
+
const { validateServiceImage } = await load();
|
|
68
111
|
expect(() =>
|
|
69
|
-
|
|
112
|
+
validateServiceImage("api", { type: "dockerfile", content: "FROM x", buildArgs: { "NO-DASH": "1" } }),
|
|
70
113
|
).toThrow(/not a valid ARG name/);
|
|
71
114
|
expect(() =>
|
|
72
|
-
|
|
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 {
|
|
124
|
+
const { validateServiceImage } = await load();
|
|
82
125
|
const inline = { type: "dockerfile", content: "FROM x\n" } as const;
|
|
83
|
-
expect(
|
|
126
|
+
expect(validateServiceImage("a", inline)).toBe(inline);
|
|
84
127
|
const reg = { type: "registry", reference: "nginx" } as const;
|
|
85
|
-
expect(
|
|
128
|
+
expect(validateServiceImage("a", reg)).toBe(reg);
|
|
86
129
|
});
|
|
87
130
|
});
|
|
Binary file
|