@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/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";
|
|
@@ -459,6 +459,16 @@ const BUILDKITD_UP_PATH = "/usr/local/bin/spectest-buildkitd-up";
|
|
|
459
459
|
async function imageCachePaths() {
|
|
460
460
|
return imageCachePathsSync(IMAGE_CACHE_MANIFEST);
|
|
461
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* The bring-up, once. A promise and not a flag: every build of a
|
|
464
|
+
* bootstrap calls this at the same time now that builds go in together,
|
|
465
|
+
* and a flag set to "not yet" while the first caller waits for buildkitd
|
|
466
|
+
* sent every other build to dockerd's own builder — which cannot build
|
|
467
|
+
* on the erofs store at all (`lease does not exist`), so Specific's
|
|
468
|
+
* first three cold builds on SDK 0.76.0 failed inside ten seconds
|
|
469
|
+
* (2026-09-07). Sharing the promise makes every caller wait for the one
|
|
470
|
+
* bring-up and read its one answer.
|
|
471
|
+
*/
|
|
462
472
|
let _localBuilder;
|
|
463
473
|
/**
|
|
464
474
|
* Start buildkitd inside this VM with its state on the cache disk, and
|
|
@@ -469,10 +479,11 @@ let _localBuilder;
|
|
|
469
479
|
* best-effort: a daemon that will not start falls back to the shared host
|
|
470
480
|
* one, which is a slower build and not a failed one.
|
|
471
481
|
*/
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
482
|
+
function ensureLocalBuildkitd() {
|
|
483
|
+
_localBuilder ??= bringUpLocalBuildkitd();
|
|
484
|
+
return _localBuilder;
|
|
485
|
+
}
|
|
486
|
+
async function bringUpLocalBuildkitd() {
|
|
476
487
|
const paths = await imageCachePaths();
|
|
477
488
|
if (!paths)
|
|
478
489
|
return false;
|
|
@@ -515,7 +526,6 @@ async function ensureLocalBuildkitd() {
|
|
|
515
526
|
// hit is a snapshot that already exists — no tar blob to unpack
|
|
516
527
|
// before the first uncached step, no export pass after a miss.
|
|
517
528
|
await ensureBuildkitCacheUnion(paths);
|
|
518
|
-
_localBuilder = true;
|
|
519
529
|
return true;
|
|
520
530
|
}
|
|
521
531
|
/**
|
|
@@ -914,11 +924,12 @@ async function ensureCertificates(svc, tag) {
|
|
|
914
924
|
* context silently became the whole repo, and the checked-out file was
|
|
915
925
|
* clobbered for any in-env tooling that read it too.
|
|
916
926
|
*/
|
|
917
|
-
|
|
918
|
-
|
|
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) {
|
|
919
931
|
try {
|
|
920
|
-
const text = await fs.readFile(
|
|
921
|
-
// Ours, from a previous bootstrap of this workspace — not the project's.
|
|
932
|
+
const text = await fs.readFile(file, "utf8");
|
|
922
933
|
if (isGeneratedDockerignore(text))
|
|
923
934
|
return null;
|
|
924
935
|
return text;
|
|
@@ -927,22 +938,70 @@ async function readProjectDockerignore() {
|
|
|
927
938
|
return null;
|
|
928
939
|
}
|
|
929
940
|
}
|
|
930
|
-
/** Ignore rules for one dockerfile build. Composition (and the reason the
|
|
931
|
-
* order matters) lives in `harness/build-context.ts`; this supplies the
|
|
932
|
-
* project's own file, read once per bootstrap. */
|
|
933
|
-
function serviceDockerignore(exclude) {
|
|
934
|
-
return composeServiceDockerignore(PROJECT_DOCKERIGNORE, exclude);
|
|
935
|
-
}
|
|
936
941
|
/// In-flight/finished dockerfile builds of this bootstrap, keyed by
|
|
937
|
-
/// sha256(
|
|
938
|
-
/// identical image definition (e.g. an API server
|
|
939
|
-
/// same codebase with different entrypoints)
|
|
940
|
-
/// and `docker tag` the result. Cleared at
|
|
941
|
-
///
|
|
942
|
-
///
|
|
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).
|
|
943
949
|
const BUILD_DEDUP = new Map();
|
|
944
|
-
function buildContentKey(
|
|
945
|
-
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
|
+
};
|
|
946
1005
|
}
|
|
947
1006
|
async function prepareServiceImage(svc, opts) {
|
|
948
1007
|
const tag = imageTag(svc.name);
|
|
@@ -977,11 +1036,10 @@ async function prepareServiceImage(svc, opts) {
|
|
|
977
1036
|
}
|
|
978
1037
|
// Dockerfile build. Within one bootstrap, identical definitions (shared
|
|
979
1038
|
// codebase images) dedup to a single build. Only bootstrap opts in: the
|
|
980
|
-
// dedup key
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
|
|
984
|
-
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);
|
|
985
1043
|
if (opts?.dedup) {
|
|
986
1044
|
const key = buildContentKey(image);
|
|
987
1045
|
const inflight = BUILD_DEDUP.get(key);
|
|
@@ -1017,16 +1075,6 @@ async function prepareServiceImage(svc, opts) {
|
|
|
1017
1075
|
}
|
|
1018
1076
|
return buildServiceImage(svc.name, image, tag);
|
|
1019
1077
|
}
|
|
1020
|
-
/** The built form of a dockerfile image. `resolveServiceImage` read a
|
|
1021
|
-
* `path` into `content` at config time, so a service that still carries
|
|
1022
|
-
* `path` here skipped that step — a programming error, not user input. */
|
|
1023
|
-
function dockerfileContent(svc) {
|
|
1024
|
-
const image = svc.image;
|
|
1025
|
-
if (image.type !== "dockerfile" || typeof image.content !== "string") {
|
|
1026
|
-
throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
|
|
1027
|
-
}
|
|
1028
|
-
return image;
|
|
1029
|
-
}
|
|
1030
1078
|
/**
|
|
1031
1079
|
* Build a dockerfile service's image — the project's Dockerfile as
|
|
1032
1080
|
* written, nothing appended.
|
|
@@ -1050,22 +1098,33 @@ async function buildServiceImage(name, image, tag) {
|
|
|
1050
1098
|
async function runServiceBuild(name, image, tag) {
|
|
1051
1099
|
let buildSteps;
|
|
1052
1100
|
{
|
|
1053
|
-
|
|
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.
|
|
1054
1119
|
const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
|
|
1055
1120
|
await fs.mkdir(dfDir, { recursive: true });
|
|
1056
1121
|
const dfPath = path.join(dfDir, "Dockerfile");
|
|
1057
|
-
await fs.writeFile(dfPath, content);
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
// on both the remote-buildx and DOCKER_BUILDKIT paths (client-side
|
|
1064
|
-
// context filtering). The root `.dockerignore` written at bootstrap
|
|
1065
|
-
// stays as the fallback for the legacy non-BuildKit builder, which
|
|
1066
|
-
// predates per-Dockerfile ignores — and is written ONLY when the
|
|
1067
|
-
// project ships none of its own (see readProjectDockerignore).
|
|
1068
|
-
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)` : ""));
|
|
1069
1128
|
// The in-VM buildkitd on the container store first: the build runs
|
|
1070
1129
|
// inside the guest's own isolation boundary, against this project's
|
|
1071
1130
|
// own layer cache, and the finished image is already in the store
|
|
@@ -1080,7 +1139,8 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1080
1139
|
let buildArgs;
|
|
1081
1140
|
// The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
|
|
1082
1141
|
// so every builder — in-VM BuildKit, dockerd's own, legacy — takes it.
|
|
1083
|
-
const argFlags = buildArgFlags(image.buildArgs);
|
|
1142
|
+
const argFlags = [...buildTargetFlags(image.target), ...buildArgFlags(image.buildArgs)];
|
|
1143
|
+
const contextDir = image.contextDir;
|
|
1084
1144
|
if (useLocal) {
|
|
1085
1145
|
// The in-VM builder is BuildKit's containerd worker on this VM's
|
|
1086
1146
|
// own image store (CONTAINER_STORE.md): the output is an image
|
|
@@ -1100,15 +1160,15 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1100
1160
|
"--progress=plain",
|
|
1101
1161
|
"--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
|
|
1102
1162
|
...argFlags,
|
|
1103
|
-
"-f", dfPath,
|
|
1163
|
+
"-f", dfPath, contextDir,
|
|
1104
1164
|
];
|
|
1105
1165
|
}
|
|
1106
1166
|
else if (useBuildKit) {
|
|
1107
|
-
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain",
|
|
1167
|
+
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", contextDir];
|
|
1108
1168
|
buildEnv.DOCKER_BUILDKIT = "1";
|
|
1109
1169
|
}
|
|
1110
1170
|
else {
|
|
1111
|
-
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath,
|
|
1171
|
+
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, contextDir];
|
|
1112
1172
|
}
|
|
1113
1173
|
progressService(name, { status: "building", detail: "starting build" });
|
|
1114
1174
|
const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
|
|
@@ -2614,9 +2674,9 @@ const RUNTIME_SERVICES = new Map();
|
|
|
2614
2674
|
function specToNamedService(spec) {
|
|
2615
2675
|
const { name, ...rest } = spec;
|
|
2616
2676
|
// A runtime spec skipped `defineEnvironment`, so its config-time passes
|
|
2617
|
-
// run here: a `path` Dockerfile
|
|
2618
|
-
// adapters get their configure step.
|
|
2619
|
-
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) };
|
|
2620
2680
|
return { name, ...applyCoverageAdapters(name, svc) };
|
|
2621
2681
|
}
|
|
2622
2682
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
@@ -2935,18 +2995,14 @@ async function bootstrapInner() {
|
|
|
2935
2995
|
ensureNetwork(),
|
|
2936
2996
|
(async () => {
|
|
2937
2997
|
await fs.mkdir(WORKSPACE, { recursive: true });
|
|
2938
|
-
//
|
|
2939
|
-
//
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
// and the legacy builder reads the project's file directly —
|
|
2946
|
-
// which is what the project asked for. Clobbering it also broke
|
|
2947
|
-
// 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)
|
|
2948
3005
|
return;
|
|
2949
|
-
}
|
|
2950
3006
|
await fs.writeFile(path.join(WORKSPACE, ".dockerignore"), unionDockerignore(services));
|
|
2951
3007
|
})(),
|
|
2952
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
|