@specific.dev/spectest 0.76.1 → 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 +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/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/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,
|
|
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
|
-
|
|
928
|
-
|
|
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(
|
|
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(
|
|
948
|
-
/// identical image definition (e.g. an API server
|
|
949
|
-
/// same codebase with different entrypoints)
|
|
950
|
-
/// and `docker tag` the result. Cleared at
|
|
951
|
-
///
|
|
952
|
-
///
|
|
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(
|
|
955
|
-
return computeBuildContentKey(() => new Bun.CryptoHasher("sha256"),
|
|
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
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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,
|
|
1163
|
+
"-f", dfPath, contextDir,
|
|
1114
1164
|
];
|
|
1115
1165
|
}
|
|
1116
1166
|
else if (useBuildKit) {
|
|
1117
|
-
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain",
|
|
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,
|
|
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
|
|
2628
|
-
// adapters get their configure step.
|
|
2629
|
-
const svc = { ...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
|
-
//
|
|
2949
|
-
//
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
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
|
|
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
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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,
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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,
|
|
113
|
+
export function buildContentKey(createHasher, build) {
|
|
104
114
|
return (createHasher()
|
|
105
|
-
.update(
|
|
106
|
-
// A separator that cannot occur in
|
|
107
|
-
// Dockerfile whose text ends with an
|
|
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(
|
|
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(
|
|
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
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
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,
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
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
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
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
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
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
|
|
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
|
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 = {
|