@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
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/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,
|
|
@@ -736,7 +737,17 @@ async function imageCachePaths(): Promise<ImageCachePaths | null> {
|
|
|
736
737
|
return imageCachePathsSync(IMAGE_CACHE_MANIFEST);
|
|
737
738
|
}
|
|
738
739
|
|
|
739
|
-
|
|
740
|
+
/**
|
|
741
|
+
* The bring-up, once. A promise and not a flag: every build of a
|
|
742
|
+
* bootstrap calls this at the same time now that builds go in together,
|
|
743
|
+
* and a flag set to "not yet" while the first caller waits for buildkitd
|
|
744
|
+
* sent every other build to dockerd's own builder — which cannot build
|
|
745
|
+
* on the erofs store at all (`lease does not exist`), so Specific's
|
|
746
|
+
* first three cold builds on SDK 0.76.0 failed inside ten seconds
|
|
747
|
+
* (2026-09-07). Sharing the promise makes every caller wait for the one
|
|
748
|
+
* bring-up and read its one answer.
|
|
749
|
+
*/
|
|
750
|
+
let _localBuilder: Promise<boolean> | undefined;
|
|
740
751
|
/**
|
|
741
752
|
* Start buildkitd inside this VM with its state on the cache disk, and
|
|
742
753
|
* register it as a buildx `remote` builder.
|
|
@@ -746,9 +757,12 @@ let _localBuilder: boolean | undefined;
|
|
|
746
757
|
* best-effort: a daemon that will not start falls back to the shared host
|
|
747
758
|
* one, which is a slower build and not a failed one.
|
|
748
759
|
*/
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
_localBuilder
|
|
760
|
+
function ensureLocalBuildkitd(): Promise<boolean> {
|
|
761
|
+
_localBuilder ??= bringUpLocalBuildkitd();
|
|
762
|
+
return _localBuilder;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function bringUpLocalBuildkitd(): Promise<boolean> {
|
|
752
766
|
const paths = await imageCachePaths();
|
|
753
767
|
if (!paths) return false;
|
|
754
768
|
const state = paths.root;
|
|
@@ -795,7 +809,6 @@ async function ensureLocalBuildkitd(): Promise<boolean> {
|
|
|
795
809
|
// hit is a snapshot that already exists — no tar blob to unpack
|
|
796
810
|
// before the first uncached step, no export pass after a miss.
|
|
797
811
|
await ensureBuildkitCacheUnion(paths);
|
|
798
|
-
_localBuilder = true;
|
|
799
812
|
return true;
|
|
800
813
|
}
|
|
801
814
|
|
|
@@ -1240,12 +1253,12 @@ async function ensureCertificates(svc: NamedService, tag: string): Promise<strin
|
|
|
1240
1253
|
* context silently became the whole repo, and the checked-out file was
|
|
1241
1254
|
* clobbered for any in-env tooling that read it too.
|
|
1242
1255
|
*/
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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> {
|
|
1246
1260
|
try {
|
|
1247
|
-
const text = await fs.readFile(
|
|
1248
|
-
// Ours, from a previous bootstrap of this workspace — not the project's.
|
|
1261
|
+
const text = await fs.readFile(file, "utf8");
|
|
1249
1262
|
if (isGeneratedDockerignore(text)) return null;
|
|
1250
1263
|
return text;
|
|
1251
1264
|
} catch {
|
|
@@ -1253,33 +1266,84 @@ async function readProjectDockerignore(): Promise<string | null> {
|
|
|
1253
1266
|
}
|
|
1254
1267
|
}
|
|
1255
1268
|
|
|
1256
|
-
/** Ignore rules for one dockerfile build. Composition (and the reason the
|
|
1257
|
-
* order matters) lives in `harness/build-context.ts`; this supplies the
|
|
1258
|
-
* project's own file, read once per bootstrap. */
|
|
1259
|
-
function serviceDockerignore(exclude?: readonly string[]): string {
|
|
1260
|
-
return composeServiceDockerignore(PROJECT_DOCKERIGNORE, exclude);
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
1269
|
/// In-flight/finished dockerfile builds of this bootstrap, keyed by
|
|
1264
|
-
/// sha256(
|
|
1265
|
-
/// identical image definition (e.g. an API server
|
|
1266
|
-
/// same codebase with different entrypoints)
|
|
1267
|
-
/// and `docker tag` the result. Cleared at
|
|
1268
|
-
///
|
|
1269
|
-
///
|
|
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).
|
|
1270
1277
|
const BUILD_DEDUP = new Map<
|
|
1271
1278
|
string,
|
|
1272
1279
|
{ name: string; promise: Promise<{ tag: string; buildSteps?: BuildStep[] }> }
|
|
1273
1280
|
>();
|
|
1274
1281
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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;
|
|
1279
1290
|
};
|
|
1280
1291
|
|
|
1281
|
-
function buildContentKey(
|
|
1282
|
-
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
|
+
};
|
|
1283
1347
|
}
|
|
1284
1348
|
|
|
1285
1349
|
async function prepareServiceImage(
|
|
@@ -1318,11 +1382,10 @@ async function prepareServiceImage(
|
|
|
1318
1382
|
}
|
|
1319
1383
|
// Dockerfile build. Within one bootstrap, identical definitions (shared
|
|
1320
1384
|
// codebase images) dedup to a single build. Only bootstrap opts in: the
|
|
1321
|
-
// dedup key
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
|
|
1325
|
-
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);
|
|
1326
1389
|
if (opts?.dedup) {
|
|
1327
1390
|
const key = buildContentKey(image);
|
|
1328
1391
|
const inflight = BUILD_DEDUP.get(key);
|
|
@@ -1357,17 +1420,6 @@ async function prepareServiceImage(
|
|
|
1357
1420
|
return buildServiceImage(svc.name, image, tag);
|
|
1358
1421
|
}
|
|
1359
1422
|
|
|
1360
|
-
/** The built form of a dockerfile image. `resolveServiceImage` read a
|
|
1361
|
-
* `path` into `content` at config time, so a service that still carries
|
|
1362
|
-
* `path` here skipped that step — a programming error, not user input. */
|
|
1363
|
-
function dockerfileContent(svc: NamedService): DockerfileBuild {
|
|
1364
|
-
const image = svc.image;
|
|
1365
|
-
if (image.type !== "dockerfile" || typeof image.content !== "string") {
|
|
1366
|
-
throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
|
|
1367
|
-
}
|
|
1368
|
-
return image;
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
1423
|
/**
|
|
1372
1424
|
* Build a dockerfile service's image — the project's Dockerfile as
|
|
1373
1425
|
* written, nothing appended.
|
|
@@ -1400,22 +1452,35 @@ async function runServiceBuild(
|
|
|
1400
1452
|
): Promise<{ ok: boolean; log: string; buildSteps?: BuildStep[] }> {
|
|
1401
1453
|
let buildSteps: BuildStep[] | undefined;
|
|
1402
1454
|
{
|
|
1403
|
-
|
|
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.
|
|
1404
1473
|
const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
|
|
1405
1474
|
await fs.mkdir(dfDir, { recursive: true });
|
|
1406
1475
|
const dfPath = path.join(dfDir, "Dockerfile");
|
|
1407
|
-
await fs.writeFile(dfPath, content);
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
// stays as the fallback for the legacy non-BuildKit builder, which
|
|
1416
|
-
// predates per-Dockerfile ignores — and is written ONLY when the
|
|
1417
|
-
// project ships none of its own (see readProjectDockerignore).
|
|
1418
|
-
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
|
+
);
|
|
1419
1484
|
// The in-VM buildkitd on the container store first: the build runs
|
|
1420
1485
|
// inside the guest's own isolation boundary, against this project's
|
|
1421
1486
|
// own layer cache, and the finished image is already in the store
|
|
@@ -1430,7 +1495,8 @@ async function runServiceBuild(
|
|
|
1430
1495
|
let buildArgs: string[];
|
|
1431
1496
|
// The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
|
|
1432
1497
|
// so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
|
|
1433
|
-
const argFlags = buildArgFlags(image.buildArgs);
|
|
1498
|
+
const argFlags = [...buildTargetFlags(image.target), ...buildArgFlags(image.buildArgs)];
|
|
1499
|
+
const contextDir = image.contextDir;
|
|
1434
1500
|
if (useLocal) {
|
|
1435
1501
|
// The in-VM builder is BuildKit's containerd worker on this VM's
|
|
1436
1502
|
// own image store (CONTAINER_STORE.md): the output is an image
|
|
@@ -1450,13 +1516,13 @@ async function runServiceBuild(
|
|
|
1450
1516
|
"--progress=plain",
|
|
1451
1517
|
"--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
|
|
1452
1518
|
...argFlags,
|
|
1453
|
-
"-f", dfPath,
|
|
1519
|
+
"-f", dfPath, contextDir,
|
|
1454
1520
|
];
|
|
1455
1521
|
} else if (useBuildKit) {
|
|
1456
|
-
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain",
|
|
1522
|
+
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", contextDir];
|
|
1457
1523
|
buildEnv.DOCKER_BUILDKIT = "1";
|
|
1458
1524
|
} else {
|
|
1459
|
-
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath,
|
|
1525
|
+
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
|
|
1460
1526
|
}
|
|
1461
1527
|
progressService(name, { status: "building", detail: "starting build" });
|
|
1462
1528
|
const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
|
|
@@ -3133,9 +3199,9 @@ const RUNTIME_SERVICES = new Map<string, NamedService>();
|
|
|
3133
3199
|
function specToNamedService(spec: RuntimeServiceSpec): NamedService {
|
|
3134
3200
|
const { name, ...rest } = spec;
|
|
3135
3201
|
// A runtime spec skipped `defineEnvironment`, so its config-time passes
|
|
3136
|
-
// run here: a `path` Dockerfile
|
|
3137
|
-
// adapters get their configure step.
|
|
3138
|
-
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) };
|
|
3139
3205
|
return { name, ...applyCoverageAdapters(name, svc) };
|
|
3140
3206
|
}
|
|
3141
3207
|
|
|
@@ -3493,20 +3559,13 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
|
|
|
3493
3559
|
ensureNetwork(),
|
|
3494
3560
|
(async () => {
|
|
3495
3561
|
await fs.mkdir(WORKSPACE, { recursive: true });
|
|
3496
|
-
//
|
|
3497
|
-
//
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
// Never overwrite it. BuildKit reads the per-service
|
|
3504
|
-
// `<Dockerfile>.dockerignore` (which already folds this file in),
|
|
3505
|
-
// and the legacy builder reads the project's file directly —
|
|
3506
|
-
// which is what the project asked for. Clobbering it also broke
|
|
3507
|
-
// any in-env tooling that reads it.
|
|
3508
|
-
return;
|
|
3509
|
-
}
|
|
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;
|
|
3510
3569
|
await fs.writeFile(
|
|
3511
3570
|
path.join(WORKSPACE, ".dockerignore"),
|
|
3512
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
|