@specific.dev/spectest 0.72.0 → 0.73.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 +22 -148
- package/dist/harness/container-run.d.ts +26 -0
- package/dist/harness/container-run.js +37 -0
- package/package.json +1 -1
- package/src/daemon.ts +22 -159
- package/src/harness/container-run.test.ts +31 -1
- package/src/harness/container-run.ts +37 -0
package/dist/daemon.js
CHANGED
|
@@ -913,7 +913,6 @@ async function prepareServiceImage(svc, opts) {
|
|
|
913
913
|
if (tagr.code !== 0) {
|
|
914
914
|
throw new Error(`docker tag ${ref} ${tag} failed: ${tagr.stderr.trim()}`);
|
|
915
915
|
}
|
|
916
|
-
await ensureCaTrustedImage(svc.name, tag);
|
|
917
916
|
return { tag };
|
|
918
917
|
}
|
|
919
918
|
// Dockerfile build. Within one bootstrap, identical definitions (shared
|
|
@@ -958,61 +957,6 @@ async function prepareServiceImage(svc, opts) {
|
|
|
958
957
|
}
|
|
959
958
|
return buildServiceImage(svc.name, image, tag);
|
|
960
959
|
}
|
|
961
|
-
/** Printed by the folded CA step when it could not write the trust
|
|
962
|
-
* store at all — the one outcome that still needs the derivative build. */
|
|
963
|
-
const CA_FOLD_UNWRITABLE = "[spectest-ca] trust store not writable";
|
|
964
|
-
/**
|
|
965
|
-
* The CA-trust steps as a suffix appended to a dockerfile service's OWN
|
|
966
|
-
* Dockerfile, so one build produces the finished image instead of a build
|
|
967
|
-
* plus a derivative rebuild ({@link ensureCaTrustedImage}) per service.
|
|
968
|
-
* Returns null when there is no CA to layer, or when the PEM can't be
|
|
969
|
-
* quoted — the caller then falls back to the derivative build.
|
|
970
|
-
*
|
|
971
|
-
* The PEM is written INLINE rather than `COPY`d: the build context is
|
|
972
|
-
* `/workspace` under a per-service ignore file that the project itself
|
|
973
|
-
* contributes to (a `**` line with re-includes is the common idiom), and
|
|
974
|
-
* a context path we don't control is a context path that can be excluded.
|
|
975
|
-
* printf needs nothing but a shell.
|
|
976
|
-
*
|
|
977
|
-
* Two rules make this safe to bolt onto user code. It must never fail the
|
|
978
|
-
* build — every branch ends in an echo, so the RUN exits 0 whatever the
|
|
979
|
-
* image lacks — and it must never change the image, beyond the trust
|
|
980
|
-
* store: notably no `USER root`, since we cannot know statically what
|
|
981
|
-
* user to hand back. An image that declares a non-root user therefore
|
|
982
|
-
* fails to write and is finished by the derivative build, which inspects
|
|
983
|
-
* the built image and can escalate properly.
|
|
984
|
-
*/
|
|
985
|
-
async function caTrustSuffix() {
|
|
986
|
-
if (!existsSync(CA_PATH))
|
|
987
|
-
return null;
|
|
988
|
-
const pem = (await fs.readFile(CA_PATH, "utf8")).trim();
|
|
989
|
-
// A quote in the PEM would break out of the shell quoting below. PEM is
|
|
990
|
-
// base64 and dashes, so this is a guard, not a case we expect.
|
|
991
|
-
if (!pem || pem.includes("'"))
|
|
992
|
-
return null;
|
|
993
|
-
const args = pem
|
|
994
|
-
.split("\n")
|
|
995
|
-
.map((l) => `'${l.trimEnd()}'`)
|
|
996
|
-
.join(" ");
|
|
997
|
-
const dst = "/usr/local/share/ca-certificates/spectest-ca.crt";
|
|
998
|
-
return `
|
|
999
|
-
# spectest: trust the environment's root CA. Appended by the harness —
|
|
1000
|
-
# not part of the project's Dockerfile.
|
|
1001
|
-
RUN P='[spectest-ca]'; \\
|
|
1002
|
-
mkdir -p /usr/local/share/ca-certificates 2>/dev/null; \\
|
|
1003
|
-
if printf '%s\\n' ${args} > ${dst} 2>/dev/null; then \\
|
|
1004
|
-
if command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates >/dev/null 2>&1; then \\
|
|
1005
|
-
echo "$P trusted via update-ca-certificates"; \\
|
|
1006
|
-
elif command -v update-ca-trust >/dev/null 2>&1 && cp ${dst} /etc/pki/ca-trust/source/anchors/spectest-ca.crt && update-ca-trust extract >/dev/null 2>&1; then \\
|
|
1007
|
-
echo "$P trusted via update-ca-trust"; \\
|
|
1008
|
-
else \\
|
|
1009
|
-
echo "$P no system CA trust tool in image; env-var trust only"; \\
|
|
1010
|
-
fi; \\
|
|
1011
|
-
else \\
|
|
1012
|
-
echo "$P trust store not writable by this image's user"; \\
|
|
1013
|
-
fi
|
|
1014
|
-
`;
|
|
1015
|
-
}
|
|
1016
960
|
/** The built form of a dockerfile image. `resolveServiceImage` read a
|
|
1017
961
|
* `path` into `content` at config time, so a service that still carries
|
|
1018
962
|
* `path` here skipped that step — a programming error, not user input. */
|
|
@@ -1024,58 +968,29 @@ function dockerfileContent(svc) {
|
|
|
1024
968
|
return image;
|
|
1025
969
|
}
|
|
1026
970
|
/**
|
|
1027
|
-
* Build a dockerfile service's image
|
|
971
|
+
* Build a dockerfile service's image — the project's Dockerfile as
|
|
972
|
+
* written, nothing appended.
|
|
1028
973
|
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
* image
|
|
1036
|
-
* can't write the trust store).
|
|
974
|
+
* Trust in the environment's CA is not the image's business any more:
|
|
975
|
+
* `runContainer` mounts the combined bundle over the system trust store
|
|
976
|
+
* (`container-run.ts`, `SYSTEM_TRUST_BUNDLE_PATHS`). Until 2026-09-06 a
|
|
977
|
+
* CA step was folded into this build and a derivative `docker build`
|
|
978
|
+
* finished any image the fold could not write to (non-root `USER`, no
|
|
979
|
+
* shell), which was one or two extra builds per service, and one per
|
|
980
|
+
* pulled image, on every cold start.
|
|
1037
981
|
*/
|
|
1038
982
|
async function buildServiceImage(name, image, tag) {
|
|
1039
|
-
const
|
|
1040
|
-
let attempt = await runServiceBuild(name, image, tag, suffix);
|
|
1041
|
-
if (!attempt.ok && suffix) {
|
|
1042
|
-
// Our step must not be able to break a project's build.
|
|
1043
|
-
// eslint-disable-next-line no-console
|
|
1044
|
-
console.warn(`[ca-trust] folded CA step could not run in ${name}'s image; rebuilding without it`);
|
|
1045
|
-
attempt = await runServiceBuild(name, image, tag, null);
|
|
1046
|
-
}
|
|
983
|
+
const attempt = await runServiceBuild(name, image, tag);
|
|
1047
984
|
if (!attempt.ok) {
|
|
1048
985
|
progressService(name, { status: "failed" });
|
|
1049
986
|
throw new Error(`docker build for ${name} failed:\n${attempt.log}`);
|
|
1050
987
|
}
|
|
1051
|
-
// The derivative build is still needed for the one thing the folded step
|
|
1052
|
-
// cannot do: write the trust store of an image that does not run as
|
|
1053
|
-
// root. That is decided on the IMAGE, not on the build log — a CACHED
|
|
1054
|
-
// layer prints nothing, so a log-only check would quietly stop
|
|
1055
|
-
// re-applying the moment BuildKit had the layer. The log covers the
|
|
1056
|
-
// rarer case of a root image whose /etc is read-only.
|
|
1057
|
-
//
|
|
1058
|
-
// An image with no trust tool at all needs nothing further: the
|
|
1059
|
-
// derivative build would reach the same dead end, and `runContainer`'s
|
|
1060
|
-
// env vars are the fallback either way.
|
|
1061
|
-
const needsDerivative = !attempt.folded ||
|
|
1062
|
-
attempt.log.includes(CA_FOLD_UNWRITABLE) ||
|
|
1063
|
-
!(await imageRunsAsRoot(tag));
|
|
1064
|
-
if (needsDerivative)
|
|
1065
|
-
await ensureCaTrustedImage(name, tag);
|
|
1066
988
|
return { tag, buildSteps: attempt.buildSteps };
|
|
1067
989
|
}
|
|
1068
|
-
|
|
1069
|
-
async function imageRunsAsRoot(tag) {
|
|
1070
|
-
const user = (await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)).stdout.trim();
|
|
1071
|
-
return user === "" || user === "root" || user === "0";
|
|
1072
|
-
}
|
|
1073
|
-
async function runServiceBuild(name, image, tag, caSuffix) {
|
|
990
|
+
async function runServiceBuild(name, image, tag) {
|
|
1074
991
|
let buildSteps;
|
|
1075
992
|
{
|
|
1076
|
-
const content =
|
|
1077
|
-
? `${image.content.replace(/\n*$/, "\n")}${caSuffix}`
|
|
1078
|
-
: image.content;
|
|
993
|
+
const content = image.content;
|
|
1079
994
|
const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
|
|
1080
995
|
await fs.mkdir(dfDir, { recursive: true });
|
|
1081
996
|
const dfPath = path.join(dfDir, "Dockerfile");
|
|
@@ -1176,9 +1091,7 @@ async function runServiceBuild(name, image, tag, caSuffix) {
|
|
|
1176
1091
|
});
|
|
1177
1092
|
const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
|
|
1178
1093
|
if (build.code !== 0) {
|
|
1179
|
-
|
|
1180
|
-
// step appended is retried without it before anyone hears about it.
|
|
1181
|
-
return { ok: false, folded: caSuffix !== null, log };
|
|
1094
|
+
return { ok: false, log };
|
|
1182
1095
|
}
|
|
1183
1096
|
if (useBuildKit) {
|
|
1184
1097
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
@@ -1187,50 +1100,7 @@ async function runServiceBuild(name, image, tag, caSuffix) {
|
|
|
1187
1100
|
.filter((s) => s.secs >= 1)
|
|
1188
1101
|
.slice(0, 12);
|
|
1189
1102
|
}
|
|
1190
|
-
return { ok: true,
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
/**
|
|
1194
|
-
* Build a derivative image on top of `tag` that copies the spectest
|
|
1195
|
-
* root CA into the system trust store. Tagged back as `tag`, so the
|
|
1196
|
-
* rest of the orchestrator (runContainer, image cache) is oblivious.
|
|
1197
|
-
* Failures are warned-and-ignored: the env-var injection in
|
|
1198
|
-
* runContainer is the universal fallback, so apps that use it (most
|
|
1199
|
-
* Node/Python/Ruby/AWS clients) still trust the CA even when the
|
|
1200
|
-
* image's trust store can't be updated.
|
|
1201
|
-
*/
|
|
1202
|
-
async function ensureCaTrustedImage(serviceName, tag) {
|
|
1203
|
-
if (!existsSync(CA_PATH)) {
|
|
1204
|
-
// Daemon running outside a base-snapshot VM (dev/test). Nothing to
|
|
1205
|
-
// layer; env vars also harmless (they point at a missing path, but
|
|
1206
|
-
// most consumers ignore missing files).
|
|
1207
|
-
return;
|
|
1208
|
-
}
|
|
1209
|
-
const ctxDir = path.join(WORKSPACE, ".spectest", "ca-trust", serviceName);
|
|
1210
|
-
await fs.mkdir(ctxDir, { recursive: true });
|
|
1211
|
-
await fs.copyFile(CA_PATH, path.join(ctxDir, "spectest-ca.crt"));
|
|
1212
|
-
// An image that declares a non-root `USER` runs this step as that user,
|
|
1213
|
-
// and writing the trust store then fails ("cannot create
|
|
1214
|
-
// /etc/ssl/certs/ca-certificates.crt.new: Permission denied") — the CA
|
|
1215
|
-
// silently never lands. Take root for the one command, then hand the
|
|
1216
|
-
// image back its own user so containers still run as it.
|
|
1217
|
-
const declaredUser = (await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)).stdout.trim();
|
|
1218
|
-
const needsRoot = declaredUser !== "" && declaredUser !== "root" && declaredUser !== "0";
|
|
1219
|
-
const dockerfile = `FROM ${tag}
|
|
1220
|
-
${needsRoot ? "USER root\n" : ""}COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
|
|
1221
|
-
RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
|
|
1222
|
-
update-ca-certificates; \\
|
|
1223
|
-
elif command -v update-ca-trust >/dev/null 2>&1; then \\
|
|
1224
|
-
cp /usr/local/share/ca-certificates/spectest-ca.crt /etc/pki/ca-trust/source/anchors/spectest-ca.crt && update-ca-trust extract; \\
|
|
1225
|
-
else \\
|
|
1226
|
-
echo "[spectest] no system CA trust tool in image; env-var trust only"; \\
|
|
1227
|
-
fi
|
|
1228
|
-
${needsRoot ? `USER ${declaredUser}\n` : ""}`;
|
|
1229
|
-
await fs.writeFile(path.join(ctxDir, "Dockerfile"), dockerfile);
|
|
1230
|
-
const build = await docker(["build", "-t", tag, ctxDir], 300_000);
|
|
1231
|
-
if (build.code !== 0) {
|
|
1232
|
-
// eslint-disable-next-line no-console
|
|
1233
|
-
console.warn(`[ca-trust] could not layer spectest CA into ${serviceName} (${tag}); env-var fallback only:\n${build.stderr.trim() || build.stdout.trim()}`);
|
|
1103
|
+
return { ok: true, log, buildSteps };
|
|
1234
1104
|
}
|
|
1235
1105
|
}
|
|
1236
1106
|
/** Memoized across containers — the CA never rotates within a VM's life. */
|
|
@@ -1240,10 +1110,14 @@ let caBundlePromise = null;
|
|
|
1240
1110
|
* return its path, or null when there's no CA to trust (daemon running
|
|
1241
1111
|
* outside a base-snapshot VM).
|
|
1242
1112
|
*
|
|
1243
|
-
* This is what the replace-semantics trust env vars must point at
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1113
|
+
* This is what the replace-semantics trust env vars must point at, and
|
|
1114
|
+
* what `runContainer` mounts over the image's own system trust store
|
|
1115
|
+
* (`container-run.ts`). Falling back to the bare CA when the guest bundle
|
|
1116
|
+
* is unreadable keeps the old behaviour for the variables — fakes verify,
|
|
1117
|
+
* public HTTPS doesn't — which is strictly better than dropping the CA and
|
|
1118
|
+
* breaking the fakes everything else depends on; the system-store mount
|
|
1119
|
+
* is skipped in that case, since the bare CA there would take the public
|
|
1120
|
+
* roots away from clients that read no variable.
|
|
1247
1121
|
*/
|
|
1248
1122
|
async function ensureCaBundle() {
|
|
1249
1123
|
caBundlePromise ??= (async () => {
|
|
@@ -35,6 +35,20 @@
|
|
|
35
35
|
* `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
|
|
36
36
|
* and the platform store is `SSL_CERT_FILE` — the combined bundle.
|
|
37
37
|
*
|
|
38
|
+
* **The system trust store is the fourth, and it is a mount, not a
|
|
39
|
+
* layer.** Go, curl, OpenSSL's defaults and Python's `ssl` with no
|
|
40
|
+
* variable set read the distro's bundle file directly, so the combined
|
|
41
|
+
* bundle is bind-mounted read-only over the paths those bundles live at
|
|
42
|
+
* ({@link SYSTEM_TRUST_BUNDLE_PATHS}). Until 2026-09-06 this was a
|
|
43
|
+
* derivative image build per service (`COPY` the CA, run
|
|
44
|
+
* `update-ca-certificates`) — a `docker build` for every image, built or
|
|
45
|
+
* pulled, on every cold start, and most of what a cached bring-up still
|
|
46
|
+
* paid. A mount costs nothing, needs no cache, and behaves the same on a
|
|
47
|
+
* warm, delta or cold start. What it does not cover: a Java keystore
|
|
48
|
+
* (`cacerts`), and an entrypoint that runs `update-ca-certificates`
|
|
49
|
+
* itself — its final `mv` over the bundle fails with EBUSY on a
|
|
50
|
+
* mountpoint.
|
|
51
|
+
*
|
|
38
52
|
* **`command` and `args` are different overrides.** `command` replaces the
|
|
39
53
|
* entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
|
|
40
54
|
* overrides CMD, which is what init-wrapped images like postgres need to
|
|
@@ -91,5 +105,17 @@ export interface ContainerRunInput {
|
|
|
91
105
|
* never `--network=host`, where writing `net.*` is denied.
|
|
92
106
|
*/
|
|
93
107
|
export declare const TCP_RETRIES2 = 6;
|
|
108
|
+
/**
|
|
109
|
+
* Where a distro keeps the bundle that clients reading the system store
|
|
110
|
+
* open. Debian, Ubuntu and Alpine resolve every default path to the first
|
|
111
|
+
* through symlinks (`/etc/ssl/cert.pem`, `/usr/lib/ssl/cert.pem`); the
|
|
112
|
+
* RHEL family resolves to the second (itself a symlink into
|
|
113
|
+
* `/etc/pki/ca-trust/extracted/pem/`, which the runtime follows before it
|
|
114
|
+
* mounts). Go checks both names itself. An image with neither path gets
|
|
115
|
+
* the file created by the mount, which is what a scratch image with a
|
|
116
|
+
* Go binary wants. Read-only, so a container cannot change what its
|
|
117
|
+
* siblings trust.
|
|
118
|
+
*/
|
|
119
|
+
export declare const SYSTEM_TRUST_BUNDLE_PATHS: readonly ["/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"];
|
|
94
120
|
/** Build the full `docker run` argv. Pure: no I/O, no module state. */
|
|
95
121
|
export declare function runContainerArgs(input: ContainerRunInput): string[];
|
|
@@ -35,6 +35,20 @@
|
|
|
35
35
|
* `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
|
|
36
36
|
* and the platform store is `SSL_CERT_FILE` — the combined bundle.
|
|
37
37
|
*
|
|
38
|
+
* **The system trust store is the fourth, and it is a mount, not a
|
|
39
|
+
* layer.** Go, curl, OpenSSL's defaults and Python's `ssl` with no
|
|
40
|
+
* variable set read the distro's bundle file directly, so the combined
|
|
41
|
+
* bundle is bind-mounted read-only over the paths those bundles live at
|
|
42
|
+
* ({@link SYSTEM_TRUST_BUNDLE_PATHS}). Until 2026-09-06 this was a
|
|
43
|
+
* derivative image build per service (`COPY` the CA, run
|
|
44
|
+
* `update-ca-certificates`) — a `docker build` for every image, built or
|
|
45
|
+
* pulled, on every cold start, and most of what a cached bring-up still
|
|
46
|
+
* paid. A mount costs nothing, needs no cache, and behaves the same on a
|
|
47
|
+
* warm, delta or cold start. What it does not cover: a Java keystore
|
|
48
|
+
* (`cacerts`), and an entrypoint that runs `update-ca-certificates`
|
|
49
|
+
* itself — its final `mv` over the bundle fails with EBUSY on a
|
|
50
|
+
* mountpoint.
|
|
51
|
+
*
|
|
38
52
|
* **`command` and `args` are different overrides.** `command` replaces the
|
|
39
53
|
* entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
|
|
40
54
|
* overrides CMD, which is what init-wrapped images like postgres need to
|
|
@@ -57,6 +71,21 @@
|
|
|
57
71
|
* never `--network=host`, where writing `net.*` is denied.
|
|
58
72
|
*/
|
|
59
73
|
export const TCP_RETRIES2 = 6;
|
|
74
|
+
/**
|
|
75
|
+
* Where a distro keeps the bundle that clients reading the system store
|
|
76
|
+
* open. Debian, Ubuntu and Alpine resolve every default path to the first
|
|
77
|
+
* through symlinks (`/etc/ssl/cert.pem`, `/usr/lib/ssl/cert.pem`); the
|
|
78
|
+
* RHEL family resolves to the second (itself a symlink into
|
|
79
|
+
* `/etc/pki/ca-trust/extracted/pem/`, which the runtime follows before it
|
|
80
|
+
* mounts). Go checks both names itself. An image with neither path gets
|
|
81
|
+
* the file created by the mount, which is what a scratch image with a
|
|
82
|
+
* Go binary wants. Read-only, so a container cannot change what its
|
|
83
|
+
* siblings trust.
|
|
84
|
+
*/
|
|
85
|
+
export const SYSTEM_TRUST_BUNDLE_PATHS = [
|
|
86
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
87
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
88
|
+
];
|
|
60
89
|
/** Build the full `docker run` argv. Pure: no I/O, no module state. */
|
|
61
90
|
export function runContainerArgs(input) {
|
|
62
91
|
const { svc, tag, network } = input;
|
|
@@ -107,6 +136,14 @@ export function runContainerArgs(input) {
|
|
|
107
136
|
args.push("-e", `SSL_CERT_FILE=${bundle}`);
|
|
108
137
|
args.push("-e", `REQUESTS_CA_BUNDLE=${bundle}`);
|
|
109
138
|
args.push("-e", `AWS_CA_BUNDLE=${bundle}`);
|
|
139
|
+
// The system store itself, for clients that read no variable at all.
|
|
140
|
+
// Only a real bundle: the caller falls back to the bare CA when the
|
|
141
|
+
// guest's roots are unreadable, and the bare CA over the system store
|
|
142
|
+
// would leave Go and curl trusting spectest and nothing else.
|
|
143
|
+
if (bundle !== input.caPath) {
|
|
144
|
+
for (const p of SYSTEM_TRUST_BUNDLE_PATHS)
|
|
145
|
+
args.push(`--volume=${bundle}:${p}:ro`);
|
|
146
|
+
}
|
|
110
147
|
}
|
|
111
148
|
// The service's own env comes after ours, so a project can override the
|
|
112
149
|
// defaults we set rather than being silently overridden by them.
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -1239,7 +1239,6 @@ async function prepareServiceImage(
|
|
|
1239
1239
|
if (tagr.code !== 0) {
|
|
1240
1240
|
throw new Error(`docker tag ${ref} ${tag} failed: ${tagr.stderr.trim()}`);
|
|
1241
1241
|
}
|
|
1242
|
-
await ensureCaTrustedImage(svc.name, tag);
|
|
1243
1242
|
return { tag };
|
|
1244
1243
|
}
|
|
1245
1244
|
// Dockerfile build. Within one bootstrap, identical definitions (shared
|
|
@@ -1283,61 +1282,6 @@ async function prepareServiceImage(
|
|
|
1283
1282
|
return buildServiceImage(svc.name, image, tag);
|
|
1284
1283
|
}
|
|
1285
1284
|
|
|
1286
|
-
/** Printed by the folded CA step when it could not write the trust
|
|
1287
|
-
* store at all — the one outcome that still needs the derivative build. */
|
|
1288
|
-
const CA_FOLD_UNWRITABLE = "[spectest-ca] trust store not writable";
|
|
1289
|
-
|
|
1290
|
-
/**
|
|
1291
|
-
* The CA-trust steps as a suffix appended to a dockerfile service's OWN
|
|
1292
|
-
* Dockerfile, so one build produces the finished image instead of a build
|
|
1293
|
-
* plus a derivative rebuild ({@link ensureCaTrustedImage}) per service.
|
|
1294
|
-
* Returns null when there is no CA to layer, or when the PEM can't be
|
|
1295
|
-
* quoted — the caller then falls back to the derivative build.
|
|
1296
|
-
*
|
|
1297
|
-
* The PEM is written INLINE rather than `COPY`d: the build context is
|
|
1298
|
-
* `/workspace` under a per-service ignore file that the project itself
|
|
1299
|
-
* contributes to (a `**` line with re-includes is the common idiom), and
|
|
1300
|
-
* a context path we don't control is a context path that can be excluded.
|
|
1301
|
-
* printf needs nothing but a shell.
|
|
1302
|
-
*
|
|
1303
|
-
* Two rules make this safe to bolt onto user code. It must never fail the
|
|
1304
|
-
* build — every branch ends in an echo, so the RUN exits 0 whatever the
|
|
1305
|
-
* image lacks — and it must never change the image, beyond the trust
|
|
1306
|
-
* store: notably no `USER root`, since we cannot know statically what
|
|
1307
|
-
* user to hand back. An image that declares a non-root user therefore
|
|
1308
|
-
* fails to write and is finished by the derivative build, which inspects
|
|
1309
|
-
* the built image and can escalate properly.
|
|
1310
|
-
*/
|
|
1311
|
-
async function caTrustSuffix(): Promise<string | null> {
|
|
1312
|
-
if (!existsSync(CA_PATH)) return null;
|
|
1313
|
-
const pem = (await fs.readFile(CA_PATH, "utf8")).trim();
|
|
1314
|
-
// A quote in the PEM would break out of the shell quoting below. PEM is
|
|
1315
|
-
// base64 and dashes, so this is a guard, not a case we expect.
|
|
1316
|
-
if (!pem || pem.includes("'")) return null;
|
|
1317
|
-
const args = pem
|
|
1318
|
-
.split("\n")
|
|
1319
|
-
.map((l) => `'${l.trimEnd()}'`)
|
|
1320
|
-
.join(" ");
|
|
1321
|
-
const dst = "/usr/local/share/ca-certificates/spectest-ca.crt";
|
|
1322
|
-
return `
|
|
1323
|
-
# spectest: trust the environment's root CA. Appended by the harness —
|
|
1324
|
-
# not part of the project's Dockerfile.
|
|
1325
|
-
RUN P='[spectest-ca]'; \\
|
|
1326
|
-
mkdir -p /usr/local/share/ca-certificates 2>/dev/null; \\
|
|
1327
|
-
if printf '%s\\n' ${args} > ${dst} 2>/dev/null; then \\
|
|
1328
|
-
if command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates >/dev/null 2>&1; then \\
|
|
1329
|
-
echo "$P trusted via update-ca-certificates"; \\
|
|
1330
|
-
elif command -v update-ca-trust >/dev/null 2>&1 && cp ${dst} /etc/pki/ca-trust/source/anchors/spectest-ca.crt && update-ca-trust extract >/dev/null 2>&1; then \\
|
|
1331
|
-
echo "$P trusted via update-ca-trust"; \\
|
|
1332
|
-
else \\
|
|
1333
|
-
echo "$P no system CA trust tool in image; env-var trust only"; \\
|
|
1334
|
-
fi; \\
|
|
1335
|
-
else \\
|
|
1336
|
-
echo "$P trust store not writable by this image's user"; \\
|
|
1337
|
-
fi
|
|
1338
|
-
`;
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
1285
|
/** The built form of a dockerfile image. `resolveServiceImage` read a
|
|
1342
1286
|
* `path` into `content` at config time, so a service that still carries
|
|
1343
1287
|
* `path` here skipped that step — a programming error, not user input. */
|
|
@@ -1350,73 +1294,38 @@ function dockerfileContent(svc: NamedService): DockerfileBuild {
|
|
|
1350
1294
|
}
|
|
1351
1295
|
|
|
1352
1296
|
/**
|
|
1353
|
-
* Build a dockerfile service's image
|
|
1297
|
+
* Build a dockerfile service's image — the project's Dockerfile as
|
|
1298
|
+
* written, nothing appended.
|
|
1354
1299
|
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
* image
|
|
1362
|
-
* can't write the trust store).
|
|
1300
|
+
* Trust in the environment's CA is not the image's business any more:
|
|
1301
|
+
* `runContainer` mounts the combined bundle over the system trust store
|
|
1302
|
+
* (`container-run.ts`, `SYSTEM_TRUST_BUNDLE_PATHS`). Until 2026-09-06 a
|
|
1303
|
+
* CA step was folded into this build and a derivative `docker build`
|
|
1304
|
+
* finished any image the fold could not write to (non-root `USER`, no
|
|
1305
|
+
* shell), which was one or two extra builds per service, and one per
|
|
1306
|
+
* pulled image, on every cold start.
|
|
1363
1307
|
*/
|
|
1364
1308
|
async function buildServiceImage(
|
|
1365
1309
|
name: string,
|
|
1366
1310
|
image: DockerfileBuild,
|
|
1367
1311
|
tag: string,
|
|
1368
1312
|
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
1369
|
-
const
|
|
1370
|
-
let attempt = await runServiceBuild(name, image, tag, suffix);
|
|
1371
|
-
if (!attempt.ok && suffix) {
|
|
1372
|
-
// Our step must not be able to break a project's build.
|
|
1373
|
-
// eslint-disable-next-line no-console
|
|
1374
|
-
console.warn(
|
|
1375
|
-
`[ca-trust] folded CA step could not run in ${name}'s image; rebuilding without it`,
|
|
1376
|
-
);
|
|
1377
|
-
attempt = await runServiceBuild(name, image, tag, null);
|
|
1378
|
-
}
|
|
1313
|
+
const attempt = await runServiceBuild(name, image, tag);
|
|
1379
1314
|
if (!attempt.ok) {
|
|
1380
1315
|
progressService(name, { status: "failed" });
|
|
1381
1316
|
throw new Error(`docker build for ${name} failed:\n${attempt.log}`);
|
|
1382
1317
|
}
|
|
1383
|
-
// The derivative build is still needed for the one thing the folded step
|
|
1384
|
-
// cannot do: write the trust store of an image that does not run as
|
|
1385
|
-
// root. That is decided on the IMAGE, not on the build log — a CACHED
|
|
1386
|
-
// layer prints nothing, so a log-only check would quietly stop
|
|
1387
|
-
// re-applying the moment BuildKit had the layer. The log covers the
|
|
1388
|
-
// rarer case of a root image whose /etc is read-only.
|
|
1389
|
-
//
|
|
1390
|
-
// An image with no trust tool at all needs nothing further: the
|
|
1391
|
-
// derivative build would reach the same dead end, and `runContainer`'s
|
|
1392
|
-
// env vars are the fallback either way.
|
|
1393
|
-
const needsDerivative =
|
|
1394
|
-
!attempt.folded ||
|
|
1395
|
-
attempt.log.includes(CA_FOLD_UNWRITABLE) ||
|
|
1396
|
-
!(await imageRunsAsRoot(tag));
|
|
1397
|
-
if (needsDerivative) await ensureCaTrustedImage(name, tag);
|
|
1398
1318
|
return { tag, buildSteps: attempt.buildSteps };
|
|
1399
1319
|
}
|
|
1400
1320
|
|
|
1401
|
-
/** Whether `tag`'s declared `USER` is root (or unset, which means root). */
|
|
1402
|
-
async function imageRunsAsRoot(tag: string): Promise<boolean> {
|
|
1403
|
-
const user = (
|
|
1404
|
-
await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)
|
|
1405
|
-
).stdout.trim();
|
|
1406
|
-
return user === "" || user === "root" || user === "0";
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
1321
|
async function runServiceBuild(
|
|
1410
1322
|
name: string,
|
|
1411
1323
|
image: DockerfileBuild,
|
|
1412
1324
|
tag: string,
|
|
1413
|
-
|
|
1414
|
-
): Promise<{ ok: boolean; folded: boolean; log: string; buildSteps?: BuildStep[] }> {
|
|
1325
|
+
): Promise<{ ok: boolean; log: string; buildSteps?: BuildStep[] }> {
|
|
1415
1326
|
let buildSteps: BuildStep[] | undefined;
|
|
1416
1327
|
{
|
|
1417
|
-
const content =
|
|
1418
|
-
? `${image.content.replace(/\n*$/, "\n")}${caSuffix}`
|
|
1419
|
-
: image.content;
|
|
1328
|
+
const content = image.content;
|
|
1420
1329
|
const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
|
|
1421
1330
|
await fs.mkdir(dfDir, { recursive: true });
|
|
1422
1331
|
const dfPath = path.join(dfDir, "Dockerfile");
|
|
@@ -1514,9 +1423,7 @@ async function runServiceBuild(
|
|
|
1514
1423
|
});
|
|
1515
1424
|
const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
|
|
1516
1425
|
if (build.code !== 0) {
|
|
1517
|
-
|
|
1518
|
-
// step appended is retried without it before anyone hears about it.
|
|
1519
|
-
return { ok: false, folded: caSuffix !== null, log };
|
|
1426
|
+
return { ok: false, log };
|
|
1520
1427
|
}
|
|
1521
1428
|
if (useBuildKit) {
|
|
1522
1429
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
@@ -1525,55 +1432,7 @@ async function runServiceBuild(
|
|
|
1525
1432
|
.filter((s) => s.secs >= 1)
|
|
1526
1433
|
.slice(0, 12);
|
|
1527
1434
|
}
|
|
1528
|
-
return { ok: true,
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
/**
|
|
1533
|
-
* Build a derivative image on top of `tag` that copies the spectest
|
|
1534
|
-
* root CA into the system trust store. Tagged back as `tag`, so the
|
|
1535
|
-
* rest of the orchestrator (runContainer, image cache) is oblivious.
|
|
1536
|
-
* Failures are warned-and-ignored: the env-var injection in
|
|
1537
|
-
* runContainer is the universal fallback, so apps that use it (most
|
|
1538
|
-
* Node/Python/Ruby/AWS clients) still trust the CA even when the
|
|
1539
|
-
* image's trust store can't be updated.
|
|
1540
|
-
*/
|
|
1541
|
-
async function ensureCaTrustedImage(serviceName: string, tag: string): Promise<void> {
|
|
1542
|
-
if (!existsSync(CA_PATH)) {
|
|
1543
|
-
// Daemon running outside a base-snapshot VM (dev/test). Nothing to
|
|
1544
|
-
// layer; env vars also harmless (they point at a missing path, but
|
|
1545
|
-
// most consumers ignore missing files).
|
|
1546
|
-
return;
|
|
1547
|
-
}
|
|
1548
|
-
const ctxDir = path.join(WORKSPACE, ".spectest", "ca-trust", serviceName);
|
|
1549
|
-
await fs.mkdir(ctxDir, { recursive: true });
|
|
1550
|
-
await fs.copyFile(CA_PATH, path.join(ctxDir, "spectest-ca.crt"));
|
|
1551
|
-
// An image that declares a non-root `USER` runs this step as that user,
|
|
1552
|
-
// and writing the trust store then fails ("cannot create
|
|
1553
|
-
// /etc/ssl/certs/ca-certificates.crt.new: Permission denied") — the CA
|
|
1554
|
-
// silently never lands. Take root for the one command, then hand the
|
|
1555
|
-
// image back its own user so containers still run as it.
|
|
1556
|
-
const declaredUser = (
|
|
1557
|
-
await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)
|
|
1558
|
-
).stdout.trim();
|
|
1559
|
-
const needsRoot = declaredUser !== "" && declaredUser !== "root" && declaredUser !== "0";
|
|
1560
|
-
const dockerfile = `FROM ${tag}
|
|
1561
|
-
${needsRoot ? "USER root\n" : ""}COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
|
|
1562
|
-
RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
|
|
1563
|
-
update-ca-certificates; \\
|
|
1564
|
-
elif command -v update-ca-trust >/dev/null 2>&1; then \\
|
|
1565
|
-
cp /usr/local/share/ca-certificates/spectest-ca.crt /etc/pki/ca-trust/source/anchors/spectest-ca.crt && update-ca-trust extract; \\
|
|
1566
|
-
else \\
|
|
1567
|
-
echo "[spectest] no system CA trust tool in image; env-var trust only"; \\
|
|
1568
|
-
fi
|
|
1569
|
-
${needsRoot ? `USER ${declaredUser}\n` : ""}`;
|
|
1570
|
-
await fs.writeFile(path.join(ctxDir, "Dockerfile"), dockerfile);
|
|
1571
|
-
const build = await docker(["build", "-t", tag, ctxDir], 300_000);
|
|
1572
|
-
if (build.code !== 0) {
|
|
1573
|
-
// eslint-disable-next-line no-console
|
|
1574
|
-
console.warn(
|
|
1575
|
-
`[ca-trust] could not layer spectest CA into ${serviceName} (${tag}); env-var fallback only:\n${build.stderr.trim() || build.stdout.trim()}`,
|
|
1576
|
-
);
|
|
1435
|
+
return { ok: true, log, buildSteps };
|
|
1577
1436
|
}
|
|
1578
1437
|
}
|
|
1579
1438
|
|
|
@@ -1585,10 +1444,14 @@ let caBundlePromise: Promise<string | null> | null = null;
|
|
|
1585
1444
|
* return its path, or null when there's no CA to trust (daemon running
|
|
1586
1445
|
* outside a base-snapshot VM).
|
|
1587
1446
|
*
|
|
1588
|
-
* This is what the replace-semantics trust env vars must point at
|
|
1589
|
-
*
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1447
|
+
* This is what the replace-semantics trust env vars must point at, and
|
|
1448
|
+
* what `runContainer` mounts over the image's own system trust store
|
|
1449
|
+
* (`container-run.ts`). Falling back to the bare CA when the guest bundle
|
|
1450
|
+
* is unreadable keeps the old behaviour for the variables — fakes verify,
|
|
1451
|
+
* public HTTPS doesn't — which is strictly better than dropping the CA and
|
|
1452
|
+
* breaking the fakes everything else depends on; the system-store mount
|
|
1453
|
+
* is skipped in that case, since the bare CA there would take the public
|
|
1454
|
+
* roots away from clients that read no variable.
|
|
1592
1455
|
*/
|
|
1593
1456
|
async function ensureCaBundle(): Promise<string | null> {
|
|
1594
1457
|
caBundlePromise ??= (async () => {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
SYSTEM_TRUST_BUNDLE_PATHS,
|
|
5
|
+
TCP_RETRIES2,
|
|
6
|
+
runContainerArgs,
|
|
7
|
+
type ContainerRunInput,
|
|
8
|
+
} from "./container-run";
|
|
4
9
|
|
|
5
10
|
const base = (over: Partial<ContainerRunInput> = {}): ContainerRunInput => ({
|
|
6
11
|
svc: { name: "web" },
|
|
@@ -122,6 +127,31 @@ describe("certificate trust", () => {
|
|
|
122
127
|
const mounts = args.filter((a) => a === "--volume=/etc/spectest/ca.crt:/etc/spectest/ca.crt:ro");
|
|
123
128
|
expect(mounts).toHaveLength(1);
|
|
124
129
|
});
|
|
130
|
+
|
|
131
|
+
/** Clients that read no variable — Go, curl, OpenSSL defaults, Python's
|
|
132
|
+
* `ssl` — open the distro's bundle file. The combined bundle is mounted
|
|
133
|
+
* over each known location, read-only, in place of the derivative
|
|
134
|
+
* image build this replaced. */
|
|
135
|
+
test("mounts the bundle over the system trust store paths", () => {
|
|
136
|
+
const args = runContainerArgs(base({ caBundle: "/etc/spectest/ca-bundle.crt" }));
|
|
137
|
+
expect(SYSTEM_TRUST_BUNDLE_PATHS).toContain("/etc/ssl/certs/ca-certificates.crt");
|
|
138
|
+
expect(SYSTEM_TRUST_BUNDLE_PATHS).toContain("/etc/pki/tls/certs/ca-bundle.crt");
|
|
139
|
+
for (const p of SYSTEM_TRUST_BUNDLE_PATHS) {
|
|
140
|
+
expect(args).toContain(`--volume=/etc/spectest/ca-bundle.crt:${p}:ro`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** The bare CA over the system store would drop every public root, the
|
|
145
|
+
* same trap as the replacing variables — so no bundle means no mount. */
|
|
146
|
+
test("never mounts the bare CA over the system trust store", () => {
|
|
147
|
+
// No bundle at all, and the fallback where the bundle IS the bare CA.
|
|
148
|
+
for (const caBundle of [null, "/etc/spectest/ca.crt"]) {
|
|
149
|
+
const args = runContainerArgs(base({ caBundle }));
|
|
150
|
+
for (const p of SYSTEM_TRUST_BUNDLE_PATHS) {
|
|
151
|
+
expect(args.some((a) => a.endsWith(`:${p}:ro`))).toBe(false);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
125
155
|
});
|
|
126
156
|
|
|
127
157
|
describe("service configuration", () => {
|
|
@@ -35,6 +35,20 @@
|
|
|
35
35
|
* `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
|
|
36
36
|
* and the platform store is `SSL_CERT_FILE` — the combined bundle.
|
|
37
37
|
*
|
|
38
|
+
* **The system trust store is the fourth, and it is a mount, not a
|
|
39
|
+
* layer.** Go, curl, OpenSSL's defaults and Python's `ssl` with no
|
|
40
|
+
* variable set read the distro's bundle file directly, so the combined
|
|
41
|
+
* bundle is bind-mounted read-only over the paths those bundles live at
|
|
42
|
+
* ({@link SYSTEM_TRUST_BUNDLE_PATHS}). Until 2026-09-06 this was a
|
|
43
|
+
* derivative image build per service (`COPY` the CA, run
|
|
44
|
+
* `update-ca-certificates`) — a `docker build` for every image, built or
|
|
45
|
+
* pulled, on every cold start, and most of what a cached bring-up still
|
|
46
|
+
* paid. A mount costs nothing, needs no cache, and behaves the same on a
|
|
47
|
+
* warm, delta or cold start. What it does not cover: a Java keystore
|
|
48
|
+
* (`cacerts`), and an entrypoint that runs `update-ca-certificates`
|
|
49
|
+
* itself — its final `mv` over the bundle fails with EBUSY on a
|
|
50
|
+
* mountpoint.
|
|
51
|
+
*
|
|
38
52
|
* **`command` and `args` are different overrides.** `command` replaces the
|
|
39
53
|
* entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
|
|
40
54
|
* overrides CMD, which is what init-wrapped images like postgres need to
|
|
@@ -95,6 +109,22 @@ export interface ContainerRunInput {
|
|
|
95
109
|
*/
|
|
96
110
|
export const TCP_RETRIES2 = 6;
|
|
97
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Where a distro keeps the bundle that clients reading the system store
|
|
114
|
+
* open. Debian, Ubuntu and Alpine resolve every default path to the first
|
|
115
|
+
* through symlinks (`/etc/ssl/cert.pem`, `/usr/lib/ssl/cert.pem`); the
|
|
116
|
+
* RHEL family resolves to the second (itself a symlink into
|
|
117
|
+
* `/etc/pki/ca-trust/extracted/pem/`, which the runtime follows before it
|
|
118
|
+
* mounts). Go checks both names itself. An image with neither path gets
|
|
119
|
+
* the file created by the mount, which is what a scratch image with a
|
|
120
|
+
* Go binary wants. Read-only, so a container cannot change what its
|
|
121
|
+
* siblings trust.
|
|
122
|
+
*/
|
|
123
|
+
export const SYSTEM_TRUST_BUNDLE_PATHS = [
|
|
124
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
125
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
126
|
+
] as const;
|
|
127
|
+
|
|
98
128
|
/** Build the full `docker run` argv. Pure: no I/O, no module state. */
|
|
99
129
|
export function runContainerArgs(input: ContainerRunInput): string[] {
|
|
100
130
|
const { svc, tag, network } = input;
|
|
@@ -151,6 +181,13 @@ export function runContainerArgs(input: ContainerRunInput): string[] {
|
|
|
151
181
|
args.push("-e", `SSL_CERT_FILE=${bundle}`);
|
|
152
182
|
args.push("-e", `REQUESTS_CA_BUNDLE=${bundle}`);
|
|
153
183
|
args.push("-e", `AWS_CA_BUNDLE=${bundle}`);
|
|
184
|
+
// The system store itself, for clients that read no variable at all.
|
|
185
|
+
// Only a real bundle: the caller falls back to the bare CA when the
|
|
186
|
+
// guest's roots are unreadable, and the bare CA over the system store
|
|
187
|
+
// would leave Go and curl trusting spectest and nothing else.
|
|
188
|
+
if (bundle !== input.caPath) {
|
|
189
|
+
for (const p of SYSTEM_TRUST_BUNDLE_PATHS) args.push(`--volume=${bundle}:${p}:ro`);
|
|
190
|
+
}
|
|
154
191
|
}
|
|
155
192
|
|
|
156
193
|
// The service's own env comes after ours, so a project can override the
|