@specific.dev/spectest 0.90.2 → 0.91.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/browser-coverage.d.ts +5 -5
- package/dist/browser-coverage.js +11 -14
- package/dist/components/expo.d.ts +2 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/k3s-trust.d.ts +57 -0
- package/dist/components/k3s-trust.js +125 -0
- package/dist/components/k3s.d.ts +17 -5
- package/dist/components/k3s.js +77 -11
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +3 -3
- package/dist/daemon.js +53 -14
- package/dist/harness/component-images.d.ts +31 -0
- package/dist/harness/component-images.js +69 -0
- package/dist/harness/k3s-image-loader.d.ts +5 -0
- package/dist/harness/k3s-image-loader.js +150 -0
- package/dist/harness/k3s-prepull.d.ts +7 -0
- package/dist/harness/k3s-prepull.js +39 -0
- package/dist/harness/sticky-consumer.d.ts +3 -3
- package/dist/harness/sticky-consumer.js +19 -4
- package/dist/harness/sticky-kubernetes.d.ts +15 -0
- package/dist/harness/sticky-kubernetes.js +163 -0
- package/dist/harness/sticky-paths.d.ts +5 -0
- package/dist/harness/sticky-paths.js +18 -0
- package/dist/index.d.ts +32 -2
- package/dist/index.js +25 -2
- package/package.json +1 -1
- package/src/browser-coverage.ts +11 -13
- package/src/components/index.ts +1 -0
- package/src/components/k3s-trust.test.ts +81 -0
- package/src/components/k3s-trust.ts +135 -0
- package/src/components/k3s.ts +81 -10
- package/src/coverage.test.ts +50 -0
- package/src/coverage.ts +3 -3
- package/src/daemon.ts +56 -15
- package/src/harness/component-images.test.ts +44 -0
- package/src/harness/component-images.ts +73 -0
- package/src/harness/k3s-image-loader.ts +129 -0
- package/src/harness/k3s-prepull.test.ts +47 -0
- package/src/harness/k3s-prepull.ts +36 -0
- package/src/harness/sticky-consumer.test.ts +28 -0
- package/src/harness/sticky-consumer.ts +14 -3
- package/src/harness/sticky-kubernetes.test.ts +88 -0
- package/src/harness/sticky-kubernetes.ts +165 -0
- package/src/harness/sticky-paths.ts +18 -0
- package/src/index.ts +48 -3
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** Kubernetes owns a separate containerd store; Docker/BuildKit GC cannot
|
|
2
|
+
* collect it. Maintenance runs only on the isolated publication clone. */
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
7
|
+
import { STICKY_ROOT } from "./sticky-paths.js";
|
|
8
|
+
const STORE_TARGET = "/var/lib/rancher/k3s/agent/containerd";
|
|
9
|
+
const SOCKET = "/run/spectest-cache.sock";
|
|
10
|
+
const ROOT = `${STICKY_ROOT}/kubernetes`;
|
|
11
|
+
const IMAGE_ID = /^sha256:[a-f0-9]{64}$/;
|
|
12
|
+
const SERVICE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
13
|
+
export function stickyKubernetesDirectory(service, version) {
|
|
14
|
+
// Version changes must not open old metadata with a different containerd.
|
|
15
|
+
const layout = createHash("sha256").update(`overlayfs-v1:${version}`).digest("hex").slice(0, 16);
|
|
16
|
+
return `${ROOT}/${service}/${layout}`;
|
|
17
|
+
}
|
|
18
|
+
const runCommand = async (program, args, timeout = 30_000) => (await promisify(execFile)(program, args, { timeout })).stdout.trim();
|
|
19
|
+
export async function registerStickyKubernetesStore(service, version, run = runCommand) {
|
|
20
|
+
if (!SERVICE_NAME.test(service))
|
|
21
|
+
throw new Error(`invalid Kubernetes cache service name: ${service}`);
|
|
22
|
+
// Respect custom Dockerfile overrides of the component's image.
|
|
23
|
+
const image = (await run("docker", ["inspect", "--format", "{{.Image}}", service])).trim();
|
|
24
|
+
if (!IMAGE_ID.test(image))
|
|
25
|
+
throw new Error(`cannot identify Kubernetes runtime image for ${service}`);
|
|
26
|
+
const directory = stickyKubernetesDirectory(service, version);
|
|
27
|
+
await mkdir(directory, { recursive: true });
|
|
28
|
+
const temporary = `${directory}/store.${randomUUID()}.tmp`;
|
|
29
|
+
await writeFile(temporary, JSON.stringify({ schema: 1, image, updatedAt: Date.now() }));
|
|
30
|
+
await rename(temporary, `${directory}/store.json`);
|
|
31
|
+
}
|
|
32
|
+
/** Standalone containerd never starts CRI/kubelet or any inherited workload.
|
|
33
|
+
* Native API deletion preserves content and committed image snapshots while
|
|
34
|
+
* releasing sandbox, container, lease and writable snapshot references. */
|
|
35
|
+
export const KUBERNETES_CACHE_MAINTENANCE_SH = `set -eu
|
|
36
|
+
exec 2>&1
|
|
37
|
+
cat > /run/cache.toml <<'CONFIG'
|
|
38
|
+
version = 2
|
|
39
|
+
disabled_plugins = ["io.containerd.grpc.v1.cri", "io.containerd.cri.v1.runtime", "io.containerd.cri.v1.images"]
|
|
40
|
+
CONFIG
|
|
41
|
+
containerd --config /run/cache.toml --root ${STORE_TARGET} --state /run/cache-state --address ${SOCKET} > /run/cache.log 2>&1 &
|
|
42
|
+
pid=$!
|
|
43
|
+
stop() { kill "$pid"; wait "$pid"; }
|
|
44
|
+
trap 'stop; exit 0' TERM INT
|
|
45
|
+
trap 'kill "$pid" 2>/dev/null || true' EXIT
|
|
46
|
+
c() { ctr --address ${SOCKET} --namespace k8s.io "$@"; }
|
|
47
|
+
i=0
|
|
48
|
+
until c version >/dev/null 2>&1; do
|
|
49
|
+
kill -0 "$pid" || { cat /run/cache.log; exit 1; }
|
|
50
|
+
i=$((i+1)); [ "$i" -lt 300 ] || { cat /run/cache.log; exit 1; }
|
|
51
|
+
sleep 0.1
|
|
52
|
+
done
|
|
53
|
+
ids=$(c containers ls -q)
|
|
54
|
+
if [ -n "$ids" ]; then c containers rm $ids; fi
|
|
55
|
+
# 1.7 has no sandbox store; 2.x does.
|
|
56
|
+
case "$(containerd --version)" in
|
|
57
|
+
*v1.*) ;;
|
|
58
|
+
*) sandboxes=$(c sandboxes ls)
|
|
59
|
+
ids=$(printf '%s\\n' "$sandboxes" | awk 'NR > 1 {print $1}')
|
|
60
|
+
if [ -n "$ids" ]; then c sandboxes rm --force $ids; fi ;;
|
|
61
|
+
esac
|
|
62
|
+
ids=$(c leases ls -q)
|
|
63
|
+
if [ -n "$ids" ]; then c leases rm $ids; fi
|
|
64
|
+
# Remove children before parents, and tolerate asynchronous GC winning a race.
|
|
65
|
+
previous=''
|
|
66
|
+
while :; do
|
|
67
|
+
snapshots=$(c snapshots --snapshotter overlayfs ls)
|
|
68
|
+
ids=$(printf '%s\\n' "$snapshots" | awk 'NR > 1 && $NF != "Committed" {print $1}')
|
|
69
|
+
[ -n "$ids" ] || break
|
|
70
|
+
[ "$ids" != "$previous" ] || { echo 'cannot clean Kubernetes writable snapshots' >&2; exit 1; }
|
|
71
|
+
previous="$ids"
|
|
72
|
+
for id in $ids; do c snapshots --snapshotter overlayfs rm "$id" >/dev/null 2>&1 || true; done
|
|
73
|
+
done
|
|
74
|
+
touch /run/cache-ready
|
|
75
|
+
while :; do sleep 1 & wait $!; done
|
|
76
|
+
`;
|
|
77
|
+
export async function openKubernetesCacheMaintenance(run, root = ROOT) {
|
|
78
|
+
const stores = [];
|
|
79
|
+
const protectedImages = new Set();
|
|
80
|
+
const closeStore = async (store) => {
|
|
81
|
+
if (!store.active)
|
|
82
|
+
return;
|
|
83
|
+
await run("docker", ["stop", "--time", "30", store.container], 45_000);
|
|
84
|
+
const status = JSON.parse(await run("docker", ["inspect", "--format", "{{json .State}}", store.container]));
|
|
85
|
+
if (status.ExitCode !== 0 || status.OOMKilled)
|
|
86
|
+
throw new Error(`unclean Kubernetes cache shutdown: ${store.container}`);
|
|
87
|
+
await run("docker", ["rm", store.container]);
|
|
88
|
+
store.active = false;
|
|
89
|
+
if (!stores.some(other => other.active && other.descriptor.image === store.descriptor.image))
|
|
90
|
+
protectedImages.delete(store.descriptor.image);
|
|
91
|
+
// These contain runtime state, not image content or unpacked layers.
|
|
92
|
+
await run("rm", ["-rf", ...["io.containerd.runtime.v2.task", "io.containerd.grpc.v1.cri", "io.containerd.cri.v1.runtime", "tmpmounts"]
|
|
93
|
+
.map(child => `${store.directory}/containerd/${child}`)]);
|
|
94
|
+
};
|
|
95
|
+
const close = async () => {
|
|
96
|
+
const failures = [];
|
|
97
|
+
for (const store of stores) {
|
|
98
|
+
try {
|
|
99
|
+
await closeStore(store);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
failures.push(error);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (failures.length)
|
|
106
|
+
throw new AggregateError(failures, "Kubernetes cache shutdown failed");
|
|
107
|
+
};
|
|
108
|
+
const entries = await readdir(root, { withFileTypes: true }).catch((error) => {
|
|
109
|
+
if (error.code === "ENOENT")
|
|
110
|
+
return [];
|
|
111
|
+
throw error;
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
for (const service of entries) {
|
|
115
|
+
if (!service.isDirectory() || !SERVICE_NAME.test(service.name))
|
|
116
|
+
continue;
|
|
117
|
+
for (const layout of await readdir(`${root}/${service.name}`, { withFileTypes: true })) {
|
|
118
|
+
if (!layout.isDirectory() || !/^[a-f0-9]{16}$/.test(layout.name))
|
|
119
|
+
continue;
|
|
120
|
+
const directory = `${root}/${service.name}/${layout.name}`;
|
|
121
|
+
const descriptor = JSON.parse(await readFile(`${directory}/store.json`, "utf8"));
|
|
122
|
+
if (descriptor.schema !== 1 || !IMAGE_ID.test(descriptor.image) || !Number.isSafeInteger(descriptor.updatedAt)) {
|
|
123
|
+
throw new Error(`invalid Kubernetes cache descriptor: ${directory}`);
|
|
124
|
+
}
|
|
125
|
+
const container = `spectest-k8s-cache-${randomUUID()}`;
|
|
126
|
+
await run("docker", ["run", "-d", "--name", container, "--network", "none", "--privileged", "--stop-signal", "SIGTERM", "--tmpfs", "/run", "--tmpfs", "/var/run",
|
|
127
|
+
"--mount", `type=bind,source=${directory}/containerd,target=${STORE_TARGET}`,
|
|
128
|
+
"--entrypoint", "/bin/sh", descriptor.image, "-c", KUBERNETES_CACHE_MAINTENANCE_SH]);
|
|
129
|
+
stores.push({ directory, descriptor, container, active: true });
|
|
130
|
+
protectedImages.add(descriptor.image);
|
|
131
|
+
try {
|
|
132
|
+
await run("docker", ["exec", container, "sh", "-c", "i=0; until [ -f /run/cache-ready ]; do i=$((i+1)); [ $i -lt 900 ] || exit 1; sleep 0.1; done"], 100_000);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
const logs = await run("docker", ["logs", "--tail", "40", container]).catch(() => "");
|
|
136
|
+
throw new Error(`Kubernetes cache preparation failed for ${directory}: ${logs}`, { cause: error });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
stores.sort((a, b) => a.descriptor.updatedAt - b.descriptor.updatedAt || a.directory.localeCompare(b.directory));
|
|
141
|
+
let next = 0;
|
|
142
|
+
const pruneOldest = async () => {
|
|
143
|
+
const store = stores[next++];
|
|
144
|
+
if (!store)
|
|
145
|
+
return false;
|
|
146
|
+
await run("docker", ["exec", store.container, "sh", "-ec",
|
|
147
|
+
`ids=$(ctr -a ${SOCKET} -n k8s.io images ls -q); if [ -n "$ids" ]; then ctr -a ${SOCKET} -n k8s.io images rm --sync $ids; fi`], 120_000);
|
|
148
|
+
await closeStore(store);
|
|
149
|
+
// No writer remains. Discard the now-empty cache and its descriptor so
|
|
150
|
+
// obsolete k3s binaries can also be collected by the outer Docker GC.
|
|
151
|
+
await run("rm", ["-rf", store.directory]);
|
|
152
|
+
return true;
|
|
153
|
+
};
|
|
154
|
+
// Abandoned services/versions should not occupy the disk indefinitely.
|
|
155
|
+
while (stores[next] && stores[next].descriptor.updatedAt < Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
156
|
+
await pruneOldest();
|
|
157
|
+
return { protectedImages, pruneOldest, close };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
await close().catch(() => { });
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const STICKY_ROOT = "/var/lib/spectest-sticky";
|
|
2
|
+
export declare const STICKY_RUNTIME = "/run/spectest-sticky";
|
|
3
|
+
/** Written only after the supervisor mounts this project's private disk. */
|
|
4
|
+
export declare function stickyDiskPathSync(manifest?: string): string | null;
|
|
5
|
+
export declare function isOnStickyDisk(path: string, root?: string | null): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
export const STICKY_ROOT = "/var/lib/spectest-sticky";
|
|
4
|
+
export const STICKY_RUNTIME = "/run/spectest-sticky";
|
|
5
|
+
/** Written only after the supervisor mounts this project's private disk. */
|
|
6
|
+
export function stickyDiskPathSync(manifest = `${STICKY_RUNTIME}/consumer.json`) {
|
|
7
|
+
try {
|
|
8
|
+
const config = JSON.parse(readFileSync(manifest, "utf8"));
|
|
9
|
+
return config.path === STICKY_ROOT ? STICKY_ROOT : null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function isOnStickyDisk(path, root = stickyDiskPathSync()) {
|
|
16
|
+
const absolute = resolve(path);
|
|
17
|
+
return root !== null && (absolute === root || absolute.startsWith(`${root}/`));
|
|
18
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -74,6 +74,8 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
|
|
|
74
74
|
export interface ServiceConfig {
|
|
75
75
|
/** Image definition: registry pull or inline build. */
|
|
76
76
|
image: ServiceImage;
|
|
77
|
+
/** @internal Images built by the harness and loaded into this component. */
|
|
78
|
+
imageBuilds?: readonly ComponentImageBuild[];
|
|
77
79
|
/**
|
|
78
80
|
* Shell command to run (sh -c). Defaults to the image's CMD. NB: this
|
|
79
81
|
* **replaces the image's entrypoint** with `/bin/sh -c` — an
|
|
@@ -741,11 +743,14 @@ export interface ServiceTls {
|
|
|
741
743
|
* daemon forwards proxied requests here over `spectest-net`. */
|
|
742
744
|
port: number;
|
|
743
745
|
}
|
|
744
|
-
|
|
746
|
+
/** @internal Normalized wire representation; legacy definitions remain accepted. */
|
|
747
|
+
export type NormalizedServiceImage = {
|
|
745
748
|
type: "registry";
|
|
746
749
|
reference: string;
|
|
747
750
|
} | {
|
|
748
751
|
type: "dockerfile";
|
|
752
|
+
/** Optional full local image reference. */
|
|
753
|
+
reference?: string;
|
|
749
754
|
/**
|
|
750
755
|
* Path of a Dockerfile in your repo, relative to the project root
|
|
751
756
|
* (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
|
|
@@ -787,6 +792,8 @@ export type ServiceImage = {
|
|
|
787
792
|
buildArgs?: Readonly<Record<string, string>>;
|
|
788
793
|
} | {
|
|
789
794
|
type: "dockerfile";
|
|
795
|
+
/** Optional full local image reference. */
|
|
796
|
+
reference?: string;
|
|
790
797
|
/**
|
|
791
798
|
* Dockerfile contents, built as if the file sat outside the build
|
|
792
799
|
* context (it is never written into your repo). The context is the
|
|
@@ -815,6 +822,29 @@ export type ServiceImage = {
|
|
|
815
822
|
*/
|
|
816
823
|
buildArgs?: Readonly<Record<string, string>>;
|
|
817
824
|
};
|
|
825
|
+
/** Dockerfile build options shared by services and Kubernetes images. */
|
|
826
|
+
export type BuildImage = (Omit<Extract<NormalizedServiceImage, {
|
|
827
|
+
type: "dockerfile";
|
|
828
|
+
path: string;
|
|
829
|
+
}>, "type"> | Omit<Extract<NormalizedServiceImage, {
|
|
830
|
+
type: "dockerfile";
|
|
831
|
+
content: string;
|
|
832
|
+
}>, "type">) & {
|
|
833
|
+
/** Full image reference, e.g. "k8s.internal:5000/runtime:spectest".
|
|
834
|
+
* Optional for a service; required for a Kubernetes build. This names a
|
|
835
|
+
* local image and does not publish it to a registry. */
|
|
836
|
+
reference?: string;
|
|
837
|
+
};
|
|
838
|
+
/** A registry reference or Dockerfile build. Tagged objects from older SDKs
|
|
839
|
+
* remain accepted for compatibility; new definitions do not need `type`. */
|
|
840
|
+
export type ServiceImage = string | BuildImage | NormalizedServiceImage;
|
|
841
|
+
/** @internal Keep the wire format compatible with existing control planes. */
|
|
842
|
+
export declare function normalizeServiceImage(image: ServiceImage): NormalizedServiceImage;
|
|
843
|
+
/** @internal Serializes build-only work with the owning component. */
|
|
844
|
+
export interface ComponentImageBuild {
|
|
845
|
+
reference: string;
|
|
846
|
+
image: BuildImage;
|
|
847
|
+
}
|
|
818
848
|
/**
|
|
819
849
|
* Check a service's `image` at config time, so a mistake fails the
|
|
820
850
|
* environment load with a message that names it, instead of a build
|
|
@@ -831,7 +861,7 @@ export type ServiceImage = {
|
|
|
831
861
|
* checks go through the project-file resolver, so the same rules as
|
|
832
862
|
* `ctx.readProjectFile` apply.
|
|
833
863
|
*/
|
|
834
|
-
export declare function validateServiceImage(serviceName: string,
|
|
864
|
+
export declare function validateServiceImage(serviceName: string, input: ServiceImage): NormalizedServiceImage;
|
|
835
865
|
export interface VolumeMount {
|
|
836
866
|
/**
|
|
837
867
|
* Named shared volume. Two services mounting the same `name` share one
|
package/dist/index.js
CHANGED
|
@@ -220,6 +220,19 @@ function expandServiceGroups(input) {
|
|
|
220
220
|
}
|
|
221
221
|
return out;
|
|
222
222
|
}
|
|
223
|
+
/** @internal Keep the wire format compatible with existing control planes. */
|
|
224
|
+
export function normalizeServiceImage(image) {
|
|
225
|
+
if (typeof image === "string")
|
|
226
|
+
return { type: "registry", reference: image };
|
|
227
|
+
if (!image || typeof image !== "object" || Array.isArray(image))
|
|
228
|
+
throw new Error("image must be a registry reference or Dockerfile build");
|
|
229
|
+
if ("type" in image) {
|
|
230
|
+
if (image.type !== "registry" && image.type !== "dockerfile")
|
|
231
|
+
throw new Error("unsupported image type");
|
|
232
|
+
return image;
|
|
233
|
+
}
|
|
234
|
+
return { ...image, type: "dockerfile" };
|
|
235
|
+
}
|
|
223
236
|
/** A Dockerfile `ARG` name: what `docker build --build-arg` accepts as a key. */
|
|
224
237
|
const BUILD_ARG_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
225
238
|
function validateBuildArgs(serviceName, buildArgs) {
|
|
@@ -255,9 +268,17 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
|
|
|
255
268
|
* checks go through the project-file resolver, so the same rules as
|
|
256
269
|
* `ctx.readProjectFile` apply.
|
|
257
270
|
*/
|
|
258
|
-
export function validateServiceImage(serviceName,
|
|
271
|
+
export function validateServiceImage(serviceName, input) {
|
|
272
|
+
const image = normalizeServiceImage(input);
|
|
273
|
+
const reference = typeof input === "string" ? input : "reference" in input ? input.reference : undefined;
|
|
274
|
+
if (reference !== undefined && (typeof reference !== "string" || !reference || reference.startsWith("-") || /[\s\x00]/.test(reference)))
|
|
275
|
+
throw new Error(`service "${serviceName}" image reference must be a non-empty image name without whitespace`);
|
|
259
276
|
if (image.type !== "dockerfile")
|
|
260
277
|
return image;
|
|
278
|
+
if (reference?.includes("@"))
|
|
279
|
+
throw new Error(`service "${serviceName}" build reference must be an image name/tag, not a digest`);
|
|
280
|
+
if (image.exclude !== undefined && (!Array.isArray(image.exclude) || image.exclude.some(e => typeof e !== "string")))
|
|
281
|
+
throw new Error(`service "${serviceName}" image exclude must be an array of glob patterns`);
|
|
261
282
|
validateBuildArgs(serviceName, image.buildArgs);
|
|
262
283
|
const hasPath = typeof image.path === "string";
|
|
263
284
|
const hasContent = typeof image.content === "string";
|
|
@@ -265,7 +286,7 @@ export function validateServiceImage(serviceName, image) {
|
|
|
265
286
|
throw new Error(`service "${serviceName}" image sets both \`path\` and \`content\` — point at the Dockerfile in your repo with \`path\`, or inline it with \`content\`, not both`);
|
|
266
287
|
}
|
|
267
288
|
if (!hasPath && !hasContent) {
|
|
268
|
-
throw new Error(`service "${serviceName}" image
|
|
289
|
+
throw new Error(`service "${serviceName}" image build needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``);
|
|
269
290
|
}
|
|
270
291
|
if (hasPath) {
|
|
271
292
|
const file = resolveExistingProjectPath(image.path, `service "${serviceName}" Dockerfile`);
|
|
@@ -403,6 +424,8 @@ export function defineEnvironment(input) {
|
|
|
403
424
|
const { fakes, services: rawServices, ...rest } = input;
|
|
404
425
|
const expanded = expandServiceGroups(rawServices);
|
|
405
426
|
for (const [key, svc] of Object.entries(expanded)) {
|
|
427
|
+
for (const build of svc.imageBuilds ?? [])
|
|
428
|
+
validateServiceImage(`${key} image ${build.reference}`, build.image);
|
|
406
429
|
expanded[key] = applyCoverageAdapters(key, {
|
|
407
430
|
...svc,
|
|
408
431
|
image: validateServiceImage(key, svc.image),
|
package/package.json
CHANGED
package/src/browser-coverage.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
//
|
|
13
13
|
// Harvest runs before every browser step (a click that navigates
|
|
14
14
|
// cross-site replaces the renderer, and the old counters with it) and at
|
|
15
|
-
// every coverage capture. `
|
|
16
|
-
// one
|
|
15
|
+
// every coverage capture. `takeBrowserCoverageReport` renders and clears
|
|
16
|
+
// one service's totals for the `case-coverage` bundle.
|
|
17
17
|
//
|
|
18
18
|
// Configured by the daemon at /load with the host → service table; a
|
|
19
19
|
// project with no `coverage: { browser: true }` service never attaches
|
|
@@ -220,17 +220,15 @@ export async function harvestAllBrowserCoverage(): Promise<void> {
|
|
|
220
220
|
await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp)));
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
export function
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
TOTALS.clear();
|
|
233
|
-
return out;
|
|
223
|
+
/** Take one service's lcov report and clear only its totals. Other
|
|
224
|
+
* services retain their coverage until their own capture. Each capture
|
|
225
|
+
* is a delta; after every service is captured, a forked child inherits
|
|
226
|
+
* empty totals from its parent. */
|
|
227
|
+
export function takeBrowserCoverageReport(service: string): string {
|
|
228
|
+
const files = TOTALS.get(service);
|
|
229
|
+
const report = files ? lcovDocument(files) : "TN:\n";
|
|
230
|
+
TOTALS.delete(service);
|
|
231
|
+
return report;
|
|
234
232
|
}
|
|
235
233
|
|
|
236
234
|
/** The current totals, without clearing them (tests). */
|
package/src/components/index.ts
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { request } from "node:https";
|
|
7
|
+
import { installPodTrust, podTrustPatch, stopPodTrust, TRUST_OPT_OUT } from "./k3s-trust";
|
|
8
|
+
|
|
9
|
+
function patched<T>(value: T, patch: ReturnType<typeof podTrustPatch>): T {
|
|
10
|
+
const out = structuredClone(value);
|
|
11
|
+
for (const operation of patch) {
|
|
12
|
+
const keys = operation.path.slice(1).split("/");
|
|
13
|
+
const key = keys.pop()!;
|
|
14
|
+
const parent = keys.reduce((o, k) => o[k], out as any);
|
|
15
|
+
if (key === "-") parent.push(operation.value); else parent[key] = operation.value;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("mounts trust into regular and init containers without changing images or security settings", () => {
|
|
21
|
+
const pod = { spec: { containers: [{ name: "app", image: "app@sha256:original", securityContext: { runAsUser: 1000, readOnlyRootFilesystem: true } }], initContainers: [{ name: "init", image: "init:1" }] } };
|
|
22
|
+
const patch = podTrustPatch(pod, "bundle");
|
|
23
|
+
const result: any = patched(pod, patch);
|
|
24
|
+
expect(result.spec.containers[0].image).toBe("app@sha256:original");
|
|
25
|
+
expect(result.spec.containers[0].securityContext).toEqual(pod.spec.containers[0].securityContext);
|
|
26
|
+
expect(result.spec.initContainers[0].env).toContainEqual({ name: "SSL_CERT_FILE", value: "/spectest/ca-bundle.crt" });
|
|
27
|
+
expect(result.spec.volumes[0].configMap.name).toBe("bundle");
|
|
28
|
+
expect(result.spec.containers[0].volumeMounts.every((m: any) => m.readOnly)).toBe(true);
|
|
29
|
+
expect(podTrustPatch(result, "bundle")).toEqual([]);
|
|
30
|
+
expect(pod).not.toHaveProperty("spec.volumes");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("preserves explicit trust settings and mounts, including volume name collisions", () => {
|
|
34
|
+
const pod = { spec: {
|
|
35
|
+
volumes: [{ name: "spectest-ca", emptyDir: {} }],
|
|
36
|
+
containers: [{ env: [{ name: "SSL_CERT_FILE", value: "/custom.pem" }], volumeMounts: [{ name: "custom", mountPath: "/etc/ssl" }] }],
|
|
37
|
+
} };
|
|
38
|
+
const result: any = patched(pod, podTrustPatch(pod, "bundle"));
|
|
39
|
+
expect(result.spec.volumes[1].name).toBe("spectest-ca-1");
|
|
40
|
+
expect(result.spec.containers[0].env.filter((e: any) => e.name === "SSL_CERT_FILE")).toEqual([{ name: "SSL_CERT_FILE", value: "/custom.pem" }]);
|
|
41
|
+
expect(result.spec.containers[0].volumeMounts.some((m: any) => m.mountPath === "/etc/ssl/certs/ca-certificates.crt")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("opt-out, Windows and ephemeral container updates are handled without changing immutable Pod volumes", () => {
|
|
45
|
+
expect(podTrustPatch({ metadata: { annotations: { [TRUST_OPT_OUT]: "false" } }, spec: { containers: [{}] } }, "bundle")).toEqual([]);
|
|
46
|
+
expect(podTrustPatch({ spec: { os: { name: "windows" }, containers: [{}] } }, "bundle")).toEqual([]);
|
|
47
|
+
expect(podTrustPatch({ spec: { ephemeralContainers: [{}] } }, "bundle", true)).toEqual([]);
|
|
48
|
+
const result: any = patched({ spec: { containers: [{}], ephemeralContainers: [{}], volumes: [{ name: "existing", configMap: { name: "bundle" } }] } }, podTrustPatch({ spec: { ephemeralContainers: [{}], volumes: [{ name: "existing", configMap: { name: "bundle" } }] } }, "bundle", true));
|
|
49
|
+
expect(result.spec.volumes).toHaveLength(1);
|
|
50
|
+
expect(result.spec.containers[0]).toEqual({});
|
|
51
|
+
expect(result.spec.ephemeralContainers[0].volumeMounts[0].name).toBe("existing");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => stopPodTrust("fixture"));
|
|
55
|
+
test("admission uses verified TLS, provisions bundles on real requests, and fails closed", async () => {
|
|
56
|
+
const dir = mkdtempSync(join(tmpdir(), "k3s-trust-"));
|
|
57
|
+
try {
|
|
58
|
+
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", join(dir, "key"), "-out", join(dir, "cert"), "-subj", "/CN=fixture", "-addext", "subjectAltName=IP:127.0.0.1", "-days", "1"], { stdio: "ignore" });
|
|
59
|
+
const cert = readFileSync(join(dir, "cert"), "utf8");
|
|
60
|
+
let config: any, calls = 0, fail = false;
|
|
61
|
+
await installPodTrust({ name: "fixture", gateway: "127.0.0.1", bundle: "bundle", cert, key: readFileSync(join(dir, "key"), "utf8"), ca: cert }, {
|
|
62
|
+
apply: async c => { config = c; },
|
|
63
|
+
ensureBundle: async namespace => { expect(namespace).toBe("app"); calls++; if (fail) throw new Error("API unavailable"); },
|
|
64
|
+
});
|
|
65
|
+
expect(config.webhooks[0].failurePolicy).toBe("Fail");
|
|
66
|
+
const send = (dryRun = false) => new Promise<{ status: number; body: any }>((resolve, reject) => {
|
|
67
|
+
const req = request(config.webhooks[0].clientConfig.url + "?timeout=10s", { method: "POST", ca: cert }, res => {
|
|
68
|
+
let body = ""; res.on("data", c => body += c); res.on("end", () => resolve({ status: res.statusCode!, body: res.statusCode === 200 ? JSON.parse(body) : body }));
|
|
69
|
+
});
|
|
70
|
+
req.on("error", reject);
|
|
71
|
+
req.end(JSON.stringify({ request: { uid: "request-1", resource: { resource: "pods" }, namespace: "app", dryRun, object: { spec: { containers: [{}] } } } }));
|
|
72
|
+
});
|
|
73
|
+
const dry = await send(true);
|
|
74
|
+
expect(dry.body.response.uid).toBe("request-1");
|
|
75
|
+
expect(calls).toBe(0);
|
|
76
|
+
expect((await send()).status).toBe(200);
|
|
77
|
+
expect(calls).toBe(1);
|
|
78
|
+
fail = true;
|
|
79
|
+
expect((await send()).status).toBe(500);
|
|
80
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
81
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** Pod trust belongs to the cluster. Admission adds read-only ConfigMap
|
|
2
|
+
* mounts and environment defaults, without changing any image bytes. */
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { createServer, type Server } from "node:https";
|
|
5
|
+
|
|
6
|
+
export const POD_CA_PATH = "/spectest/ca-bundle.crt";
|
|
7
|
+
const TRUST_ENV: Readonly<Record<string, string>> = {
|
|
8
|
+
NODE_EXTRA_CA_CERTS: POD_CA_PATH, SSL_CERT_FILE: POD_CA_PATH,
|
|
9
|
+
REQUESTS_CA_BUNDLE: POD_CA_PATH, AWS_CA_BUNDLE: POD_CA_PATH,
|
|
10
|
+
PGSSLROOTCERT: POD_CA_PATH, DENO_TLS_CA_STORE: "mozilla,system",
|
|
11
|
+
};
|
|
12
|
+
const MOUNT_PATHS = [POD_CA_PATH, "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"];
|
|
13
|
+
interface Container {
|
|
14
|
+
env?: { name: string; value?: string; valueFrom?: unknown }[];
|
|
15
|
+
volumeMounts?: { name: string; mountPath: string; subPath?: string; readOnly?: boolean }[];
|
|
16
|
+
}
|
|
17
|
+
interface Pod {
|
|
18
|
+
metadata?: { annotations?: Record<string, string> };
|
|
19
|
+
spec?: {
|
|
20
|
+
os?: { name: string };
|
|
21
|
+
volumes?: { name: string; configMap?: { name: string }; [key: string]: unknown }[];
|
|
22
|
+
containers?: Container[]; initContainers?: Container[]; ephemeralContainers?: Container[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
interface Patch { op: "add"; path: string; value: unknown }
|
|
26
|
+
export const TRUST_OPT_OUT = "spectest.dev/inject-ca";
|
|
27
|
+
|
|
28
|
+
export function podTrustPatch(pod: Pod, configMap: string, ephemeral = false): Patch[] {
|
|
29
|
+
if (!pod.spec || pod.spec.os?.name === "windows" || pod.metadata?.annotations?.[TRUST_OPT_OUT] === "false") return [];
|
|
30
|
+
const patches: Patch[] = [];
|
|
31
|
+
const volumes = pod.spec.volumes ?? [];
|
|
32
|
+
// Reuse our immutable ConfigMap, but never overwrite a user's volume.
|
|
33
|
+
let volume = volumes.find(v => v.configMap?.name === configMap)?.name;
|
|
34
|
+
if (!volume && ephemeral) return []; // Pod volumes cannot change on this subresource.
|
|
35
|
+
if (!volume) {
|
|
36
|
+
const names = new Set(volumes.map(v => v.name));
|
|
37
|
+
volume = "spectest-ca";
|
|
38
|
+
for (let n = 1; names.has(volume); n++) volume = `spectest-ca-${n}`;
|
|
39
|
+
const value = { name: volume, configMap: { name: configMap, defaultMode: 0o444, items: [{ key: "ca-bundle.crt", path: "ca-bundle.crt" }] } };
|
|
40
|
+
patches.push(pod.spec.volumes
|
|
41
|
+
? { op: "add", path: "/spec/volumes/-", value }
|
|
42
|
+
: { op: "add", path: "/spec/volumes", value: [value] });
|
|
43
|
+
}
|
|
44
|
+
const groups = ephemeral ? ["ephemeralContainers"] as const : ["containers", "initContainers"] as const;
|
|
45
|
+
for (const group of groups) {
|
|
46
|
+
for (const [index, container] of (pod.spec[group] ?? []).entries()) {
|
|
47
|
+
const prefix = `/spec/${group}/${index}`;
|
|
48
|
+
const env = container.env ?? [];
|
|
49
|
+
const names = new Set(env.map(e => e.name));
|
|
50
|
+
const extraEnv = Object.entries(TRUST_ENV).filter(([name]) => !names.has(name)).map(([name, value]) => ({ name, value }));
|
|
51
|
+
const mounts = container.volumeMounts ?? [];
|
|
52
|
+
// A user mount at a path (or a parent directory) owns that path.
|
|
53
|
+
const extraMounts = MOUNT_PATHS.filter(path => !mounts.some(m => path === m.mountPath || path.startsWith(m.mountPath.replace(/\/$/, "") + "/")))
|
|
54
|
+
.map(mountPath => ({ name: volume!, mountPath, subPath: "ca-bundle.crt", readOnly: true }));
|
|
55
|
+
if (extraEnv.length) patches.push({ op: "add", path: `${prefix}/env`, value: [...env, ...extraEnv] });
|
|
56
|
+
if (extraMounts.length) patches.push({ op: "add", path: `${prefix}/volumeMounts`, value: [...mounts, ...extraMounts] });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return patches;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface AdmissionRequest {
|
|
63
|
+
uid: string; namespace?: string; dryRun?: boolean; subResource?: string;
|
|
64
|
+
resource?: { resource: string }; object?: Pod;
|
|
65
|
+
}
|
|
66
|
+
export interface TrustDependencies {
|
|
67
|
+
/** Ensure this immutable ConfigMap exists in the requesting namespace. */
|
|
68
|
+
ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
|
|
69
|
+
apply(configuration: unknown): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
const servers = new Map<string, Server>();
|
|
72
|
+
|
|
73
|
+
export async function installPodTrust(options: {
|
|
74
|
+
name: string; gateway: string; bundle: string; cert: string; key: string; ca: string;
|
|
75
|
+
}, deps: TrustDependencies): Promise<void> {
|
|
76
|
+
const configMap = `spectest-ca-${createHash("sha256").update(options.bundle).digest("hex").slice(0, 20)}`;
|
|
77
|
+
const endpoint = `/admit/${randomUUID()}`;
|
|
78
|
+
const server = createServer({ cert: options.cert, key: options.key }, async (req, res) => {
|
|
79
|
+
if (req.method !== "POST" || new URL(req.url ?? "/", "https://local").pathname !== endpoint) { res.writeHead(404).end(); return; }
|
|
80
|
+
const chunks: Buffer[] = [];
|
|
81
|
+
let size = 0;
|
|
82
|
+
try {
|
|
83
|
+
for await (const chunk of req) {
|
|
84
|
+
size += chunk.length;
|
|
85
|
+
if (size > 2 * 1024 * 1024) { res.writeHead(413).end(); return; }
|
|
86
|
+
chunks.push(Buffer.from(chunk));
|
|
87
|
+
}
|
|
88
|
+
const review = JSON.parse(Buffer.concat(chunks).toString());
|
|
89
|
+
const request = review.request as AdmissionRequest | undefined;
|
|
90
|
+
if (!request?.uid || request.resource?.resource !== "pods" || !request.object || !request.namespace) {
|
|
91
|
+
res.writeHead(400).end("invalid Pod admission request"); return;
|
|
92
|
+
}
|
|
93
|
+
const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
|
|
94
|
+
// Dry runs return the same patch but perform no API writes.
|
|
95
|
+
if (patch.length && !request.dryRun) await deps.ensureBundle(request.namespace, configMap, options.bundle);
|
|
96
|
+
res.setHeader("Content-Type", "application/json");
|
|
97
|
+
res.end(JSON.stringify({ apiVersion: "admission.k8s.io/v1", kind: "AdmissionReview", response: {
|
|
98
|
+
uid: request.uid, allowed: true,
|
|
99
|
+
...(patch.length ? { patchType: "JSONPatch", patch: Buffer.from(JSON.stringify(patch)).toString("base64") } : {}),
|
|
100
|
+
} }));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(`[k3s-trust] ${options.name}: ${err instanceof Error ? err.message : err}`);
|
|
103
|
+
res.writeHead(500).end("could not mount the environment CA");
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
server.requestTimeout = 15_000;
|
|
107
|
+
try {
|
|
108
|
+
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(0, options.gateway, resolve); });
|
|
109
|
+
const port = (server.address() as { port: number }).port;
|
|
110
|
+
await deps.apply({
|
|
111
|
+
apiVersion: "admissionregistration.k8s.io/v1", kind: "MutatingWebhookConfiguration",
|
|
112
|
+
metadata: { name: "spectest-pod-trust" },
|
|
113
|
+
webhooks: [{
|
|
114
|
+
name: "pod-trust.spectest.dev", admissionReviewVersions: ["v1"],
|
|
115
|
+
sideEffects: "NoneOnDryRun", failurePolicy: "Fail", timeoutSeconds: 10,
|
|
116
|
+
reinvocationPolicy: "IfNeeded", matchPolicy: "Equivalent",
|
|
117
|
+
clientConfig: { url: `https://${options.gateway}:${port}${endpoint}`, caBundle: Buffer.from(options.ca).toString("base64") },
|
|
118
|
+
rules: [{ operations: ["CREATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods"], scope: "Namespaced" },
|
|
119
|
+
{ operations: ["UPDATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods/ephemeralcontainers"], scope: "Namespaced" }],
|
|
120
|
+
}],
|
|
121
|
+
});
|
|
122
|
+
} catch (err) {
|
|
123
|
+
server.closeAllConnections(); server.close(); throw err;
|
|
124
|
+
}
|
|
125
|
+
const previous = servers.get(options.name);
|
|
126
|
+
servers.set(options.name, server);
|
|
127
|
+
previous?.closeAllConnections(); previous?.close();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Release a component listener on replacement or in standalone tests. */
|
|
131
|
+
export function stopPodTrust(name: string): void {
|
|
132
|
+
const server = servers.get(name);
|
|
133
|
+
servers.delete(name);
|
|
134
|
+
server?.closeAllConnections(); server?.close();
|
|
135
|
+
}
|