@specific.dev/spectest 0.90.3 → 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/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/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/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/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
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/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
|
+
}
|
package/src/components/k3s.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { installPodTrust } from "./k3s-trust.js";
|
|
3
|
+
import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
|
|
1
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
5
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
3
6
|
import { randomUUID } from "node:crypto";
|
|
@@ -31,6 +34,7 @@ import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
|
|
|
31
34
|
import type {
|
|
32
35
|
ServiceDefinition,
|
|
33
36
|
ServiceHelpersContext,
|
|
37
|
+
ServiceSetupContext,
|
|
34
38
|
SpectestContext,
|
|
35
39
|
} from "../index.js";
|
|
36
40
|
import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
|
|
@@ -41,6 +45,11 @@ import {
|
|
|
41
45
|
nestedStoreDir,
|
|
42
46
|
type ImageCachePaths,
|
|
43
47
|
} from "../harness/image-cache.js";
|
|
48
|
+
import { stickyDiskPathSync } from "../harness/sticky-paths.js";
|
|
49
|
+
import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
|
|
50
|
+
import { prePullK3sImages } from "../harness/k3s-prepull.js";
|
|
51
|
+
import { componentRuntime, kubernetesImages, type KubernetesImage } from "../harness/component-images.js";
|
|
52
|
+
export type { KubernetesImage } from "../harness/component-images.js";
|
|
44
53
|
import { deepUnwrap, readTag, wrap } from "../inspect.js";
|
|
45
54
|
import type { Wrapped } from "../inspect.js";
|
|
46
55
|
import { recorderAnnotate, recorderRemove } from "../recorder.js";
|
|
@@ -50,7 +59,19 @@ export type {
|
|
|
50
59
|
KubernetesListObject,
|
|
51
60
|
} from "@kubernetes/client-node";
|
|
52
61
|
|
|
62
|
+
/** Kubernetes pods receive read-only environment CA mounts during admission.
|
|
63
|
+
* Image bytes and digests stay unchanged; explicit pod trust settings win. */
|
|
53
64
|
export interface K3sOptions {
|
|
65
|
+
/** Images available to kubelet before project setup. Use registry strings,
|
|
66
|
+
* or Dockerfile builds with a full `reference` matching your manifests. Builds
|
|
67
|
+
* support path/content, context, exclude, target and buildArgs, and run in
|
|
68
|
+
* parallel with service builds through the standard BuildKit pipeline.
|
|
69
|
+
* Setup waits for every pull and fails if a requested image cannot be pulled.
|
|
70
|
+
* Sticky-enabled projects retain image content and unpacked layers between
|
|
71
|
+
* fresh builds automatically. Digest references can reuse the cache offline;
|
|
72
|
+
* tags are resolved again on each fresh setup. Pod imagePullPolicy still applies.
|
|
73
|
+
*/
|
|
74
|
+
images?: readonly KubernetesImage[];
|
|
54
75
|
/**
|
|
55
76
|
* Image tag for the official `rancher/k3s` image. Default
|
|
56
77
|
* `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
|
|
@@ -178,8 +199,13 @@ const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
|
|
|
178
199
|
const BOOT_MARKER = ".spectest-boot-incomplete";
|
|
179
200
|
|
|
180
201
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
202
|
+
* Sticky-enabled projects bind a per-service/version directory on the
|
|
203
|
+
* sticky disk at CLUSTER_STORE_TARGET and use overlayfs. Publication's
|
|
204
|
+
* sticky-kubernetes adapter removes runtime state and retains images and
|
|
205
|
+
* unpacked layers under the shared filesystem budget. Test forks do not
|
|
206
|
+
* write back. The preparation wrapper below also handles delta rebuilds.
|
|
207
|
+
*
|
|
208
|
+
* Legacy image-cache integration (`CONTAINER_STORE.md` §12):
|
|
183
209
|
*
|
|
184
210
|
* The cluster's embedded containerd keeps its root on the **root disk**
|
|
185
211
|
* of the environment's image cache, in a directory of its own
|
|
@@ -346,8 +372,8 @@ function storePrepareSh(cache: ImageCachePaths | null): string {
|
|
|
346
372
|
`timeout 60 ctr -a "$A" -n k8s.io containers rm $ids >/dev/null 2>&1 || true; fi; ` +
|
|
347
373
|
// containerd 2.x keeps sandboxes in a store of their own; on the 1.7
|
|
348
374
|
// k3s ships they are ordinary containers and this is a no-op.
|
|
349
|
-
`sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls
|
|
350
|
-
`[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm $sb >/dev/null 2>&1; return 0; }; ` +
|
|
375
|
+
`sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
|
|
376
|
+
`[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
|
|
351
377
|
adopt +
|
|
352
378
|
`if [ -e "$M" ]; then ` +
|
|
353
379
|
`echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
|
|
@@ -724,7 +750,7 @@ async function issueIngressCert(
|
|
|
724
750
|
const id = `spectest-k3s-ingress-${randomUUID().slice(0, 8)}`;
|
|
725
751
|
const keyPath = `/tmp/${id}.key`;
|
|
726
752
|
const crtPath = `/tmp/${id}.crt`;
|
|
727
|
-
const sans = hostnames.map((h) =>
|
|
753
|
+
const sans = hostnames.map((h) => `${isIP(h) ? "IP" : "DNS"}:${h}`).join(",");
|
|
728
754
|
const r = await runProcess(
|
|
729
755
|
"openssl",
|
|
730
756
|
[
|
|
@@ -2007,6 +2033,7 @@ const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
|
|
|
2007
2033
|
|
|
2008
2034
|
export function k3s(opts: K3sOptions = {}) {
|
|
2009
2035
|
const version = opts.version ?? DEFAULT_K3S_VERSION;
|
|
2036
|
+
const images = kubernetesImages(opts.images ?? []);
|
|
2010
2037
|
const extra = opts.extraArgs ?? [];
|
|
2011
2038
|
assertUsableExtraArgs(extra);
|
|
2012
2039
|
const registryEnabled = opts.registry !== false;
|
|
@@ -2023,7 +2050,8 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2023
2050
|
// The environment's image cache, when this VM carries one: the
|
|
2024
2051
|
// cluster's store goes on its root disk and its layers come off the
|
|
2025
2052
|
// shared layers disk (see the store section above).
|
|
2026
|
-
const
|
|
2053
|
+
const sticky = stickyDiskPathSync();
|
|
2054
|
+
const cache = sticky ? null : imageCachePathsSync();
|
|
2027
2055
|
const serverArgs = [
|
|
2028
2056
|
"k3s",
|
|
2029
2057
|
"server",
|
|
@@ -2122,6 +2150,7 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2122
2150
|
const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
|
|
2123
2151
|
const def = {
|
|
2124
2152
|
image: { type: "registry", reference: `rancher/k3s:${version}` },
|
|
2153
|
+
imageBuilds: images.builds,
|
|
2125
2154
|
command: cmd,
|
|
2126
2155
|
privileged: true,
|
|
2127
2156
|
tmpfs: ["/run", "/var/run"],
|
|
@@ -2135,15 +2164,17 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2135
2164
|
// the same netns and are reachable the same way, without appearing
|
|
2136
2165
|
// in this list (it's documentation, not a firewall).
|
|
2137
2166
|
ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
|
|
2138
|
-
//
|
|
2167
|
+
// Sticky disks retain an independent overlayfs store per service/version.
|
|
2168
|
+
// The legacy image cache (see the store section above) keeps its
|
|
2139
2169
|
// containerd root on the image cache's root disk, the shared layers
|
|
2140
2170
|
// disk read-only at its guest path, golden's mkfs.erofs and the adopt
|
|
2141
2171
|
// helper — or, with no cache, a per-environment volume. Two clusters
|
|
2142
2172
|
// in one environment get separate roots either way (the service
|
|
2143
2173
|
// token names the directory), because two containerd daemons cannot
|
|
2144
2174
|
// share a store: bolt takes an exclusive lock.
|
|
2145
|
-
volumes:
|
|
2146
|
-
? [
|
|
2175
|
+
volumes: sticky
|
|
2176
|
+
? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
|
|
2177
|
+
: cache ? [
|
|
2147
2178
|
{ source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
|
|
2148
2179
|
{ source: cache.layers, target: cache.layers, readOnly: true },
|
|
2149
2180
|
{ source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
|
|
@@ -2164,12 +2195,52 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2164
2195
|
command: readyCmd,
|
|
2165
2196
|
timeoutSecs: opts.readyTimeoutSecs ?? 120,
|
|
2166
2197
|
},
|
|
2167
|
-
setup: async (
|
|
2198
|
+
setup: async (ctx: ServiceSetupContext<K3sHelpers>) => {
|
|
2199
|
+
const { name, helpers, exec } = ctx;
|
|
2200
|
+
const runtime = componentRuntime(ctx);
|
|
2201
|
+
if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
|
|
2202
|
+
if (runtime?.caBundle && caPresent()) {
|
|
2203
|
+
const { cert, key } = await issueIngressCert([runtime.gateway]);
|
|
2204
|
+
await installPodTrust({ name, gateway: runtime.gateway, cert, key,
|
|
2205
|
+
ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
|
|
2206
|
+
}, {
|
|
2207
|
+
apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
|
|
2208
|
+
ensureBundle: async (namespace, configMap, bundle) => {
|
|
2209
|
+
try {
|
|
2210
|
+
await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
|
|
2211
|
+
metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
|
|
2212
|
+
} });
|
|
2213
|
+
} catch (err) {
|
|
2214
|
+
// A simultaneous Pod may have created the same namespace's bundle.
|
|
2215
|
+
const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
|
|
2216
|
+
if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true) throw err;
|
|
2217
|
+
}
|
|
2218
|
+
},
|
|
2219
|
+
});
|
|
2220
|
+
// The API server observes webhook registration asynchronously. A dry-run
|
|
2221
|
+
// Pod proves TLS reachability and admission before any project workload.
|
|
2222
|
+
const deadline = Date.now() + 30_000;
|
|
2223
|
+
for (;;) {
|
|
2224
|
+
try {
|
|
2225
|
+
const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
|
|
2226
|
+
metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
|
|
2227
|
+
} })).unwrap();
|
|
2228
|
+
if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
|
|
2229
|
+
if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
|
|
2230
|
+
} catch (err) {
|
|
2231
|
+
if (Date.now() >= deadline) throw err;
|
|
2232
|
+
}
|
|
2233
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
if (runtime) await loadK3sBuildImages(name, runtime.images, runtime.gateway, exec);
|
|
2168
2237
|
await setupK3sCluster(name, helpers, {
|
|
2169
2238
|
registry: registryEnabled,
|
|
2170
2239
|
ingressDomains,
|
|
2171
2240
|
traefik: traefikEnabled,
|
|
2172
2241
|
});
|
|
2242
|
+
await prePullK3sImages(name, images.pulls, exec);
|
|
2243
|
+
if (sticky) await registerStickyKubernetesStore(name, version);
|
|
2173
2244
|
},
|
|
2174
2245
|
helpers: async ({ name, exec, poll }: ServiceHelpersContext): Promise<K3sHelpers> => {
|
|
2175
2246
|
// Read the cluster's kubeconfig and address the API server by its
|