@specific.dev/spectest 0.90.3 → 0.91.1
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/android-step.js +2 -2
- 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 +126 -53
- package/dist/daemon.js +71 -28
- package/dist/harness/component-images.d.ts +34 -0
- package/dist/harness/component-images.js +69 -0
- package/dist/harness/concurrency.d.ts +2 -0
- package/dist/harness/concurrency.js +9 -0
- package/dist/harness/k3s-image-loader.d.ts +3 -0
- package/dist/harness/k3s-image-loader.js +152 -0
- package/dist/harness/k3s-prepull.d.ts +8 -0
- package/dist/harness/k3s-prepull.js +35 -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/android-step.test.ts +3 -3
- package/src/android-step.ts +2 -2
- package/src/android.test.ts +3 -3
- package/src/components/index.ts +1 -0
- package/src/components/k3s-concurrency.test.ts +60 -0
- package/src/components/k3s-trust.test.ts +81 -0
- package/src/components/k3s-trust.ts +135 -0
- package/src/components/k3s.ts +131 -51
- package/src/daemon.ts +72 -27
- package/src/harness/component-images.test.ts +44 -0
- package/src/harness/component-images.ts +79 -0
- package/src/harness/concurrency.ts +8 -0
- package/src/harness/k3s-image-loader.ts +131 -0
- package/src/harness/k3s-prepull.test.ts +57 -0
- package/src/harness/k3s-prepull.ts +33 -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,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,7 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { installPodTrust } from "./k3s-trust.js";
|
|
3
|
+
import { waitForAll } from "../harness/concurrency.js";
|
|
4
|
+
import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
|
|
1
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
6
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
3
7
|
import { randomUUID } from "node:crypto";
|
|
@@ -31,6 +35,7 @@ import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
|
|
|
31
35
|
import type {
|
|
32
36
|
ServiceDefinition,
|
|
33
37
|
ServiceHelpersContext,
|
|
38
|
+
ServiceSetupContext,
|
|
34
39
|
SpectestContext,
|
|
35
40
|
} from "../index.js";
|
|
36
41
|
import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
|
|
@@ -41,6 +46,11 @@ import {
|
|
|
41
46
|
nestedStoreDir,
|
|
42
47
|
type ImageCachePaths,
|
|
43
48
|
} from "../harness/image-cache.js";
|
|
49
|
+
import { stickyDiskPathSync } from "../harness/sticky-paths.js";
|
|
50
|
+
import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
|
|
51
|
+
import { prePullK3sImages } from "../harness/k3s-prepull.js";
|
|
52
|
+
import { componentRuntime, kubernetesImages, type KubernetesImage } from "../harness/component-images.js";
|
|
53
|
+
export type { KubernetesImage } from "../harness/component-images.js";
|
|
44
54
|
import { deepUnwrap, readTag, wrap } from "../inspect.js";
|
|
45
55
|
import type { Wrapped } from "../inspect.js";
|
|
46
56
|
import { recorderAnnotate, recorderRemove } from "../recorder.js";
|
|
@@ -50,7 +60,19 @@ export type {
|
|
|
50
60
|
KubernetesListObject,
|
|
51
61
|
} from "@kubernetes/client-node";
|
|
52
62
|
|
|
63
|
+
/** Kubernetes pods receive read-only environment CA mounts during admission.
|
|
64
|
+
* Image bytes and digests stay unchanged; explicit pod trust settings win. */
|
|
53
65
|
export interface K3sOptions {
|
|
66
|
+
/** Images available to kubelet before project setup. Use registry strings,
|
|
67
|
+
* or Dockerfile builds with a full `reference` matching your manifests. Builds
|
|
68
|
+
* support path/content, context, exclude, target and buildArgs, and run in
|
|
69
|
+
* parallel with service builds through the standard BuildKit pipeline.
|
|
70
|
+
* Setup waits for every pull and fails if a requested image cannot be pulled.
|
|
71
|
+
* Sticky-enabled projects retain image content and unpacked layers between
|
|
72
|
+
* fresh builds automatically. Digest references can reuse the cache offline;
|
|
73
|
+
* tags are resolved again on each fresh setup. Pod imagePullPolicy still applies.
|
|
74
|
+
*/
|
|
75
|
+
images?: readonly KubernetesImage[];
|
|
54
76
|
/**
|
|
55
77
|
* Image tag for the official `rancher/k3s` image. Default
|
|
56
78
|
* `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
|
|
@@ -178,8 +200,13 @@ const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
|
|
|
178
200
|
const BOOT_MARKER = ".spectest-boot-incomplete";
|
|
179
201
|
|
|
180
202
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
203
|
+
* Sticky-enabled projects bind a per-service/version directory on the
|
|
204
|
+
* sticky disk at CLUSTER_STORE_TARGET and use overlayfs. Publication's
|
|
205
|
+
* sticky-kubernetes adapter removes runtime state and retains images and
|
|
206
|
+
* unpacked layers under the shared filesystem budget. Test forks do not
|
|
207
|
+
* write back. The preparation wrapper below also handles delta rebuilds.
|
|
208
|
+
*
|
|
209
|
+
* Legacy image-cache integration (`CONTAINER_STORE.md` §12):
|
|
183
210
|
*
|
|
184
211
|
* The cluster's embedded containerd keeps its root on the **root disk**
|
|
185
212
|
* of the environment's image cache, in a directory of its own
|
|
@@ -346,8 +373,8 @@ function storePrepareSh(cache: ImageCachePaths | null): string {
|
|
|
346
373
|
`timeout 60 ctr -a "$A" -n k8s.io containers rm $ids >/dev/null 2>&1 || true; fi; ` +
|
|
347
374
|
// containerd 2.x keeps sandboxes in a store of their own; on the 1.7
|
|
348
375
|
// 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; }; ` +
|
|
376
|
+
`sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
|
|
377
|
+
`[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
|
|
351
378
|
adopt +
|
|
352
379
|
`if [ -e "$M" ]; then ` +
|
|
353
380
|
`echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
|
|
@@ -724,7 +751,7 @@ async function issueIngressCert(
|
|
|
724
751
|
const id = `spectest-k3s-ingress-${randomUUID().slice(0, 8)}`;
|
|
725
752
|
const keyPath = `/tmp/${id}.key`;
|
|
726
753
|
const crtPath = `/tmp/${id}.crt`;
|
|
727
|
-
const sans = hostnames.map((h) =>
|
|
754
|
+
const sans = hostnames.map((h) => `${isIP(h) ? "IP" : "DNS"}:${h}`).join(",");
|
|
728
755
|
const r = await runProcess(
|
|
729
756
|
"openssl",
|
|
730
757
|
[
|
|
@@ -1839,48 +1866,52 @@ async function setupK3sCluster(
|
|
|
1839
1866
|
helpers: K3sHelpers,
|
|
1840
1867
|
opts: { registry: boolean; ingressDomains: string[]; traefik: boolean },
|
|
1841
1868
|
): Promise<void> {
|
|
1842
|
-
|
|
1843
|
-
if (tlsEnabled) {
|
|
1844
|
-
const { cert, key } = await issueIngressCert(
|
|
1845
|
-
opts.ingressDomains.map((d) => `*.${d}`),
|
|
1846
|
-
);
|
|
1847
|
-
// Apply the cert Secret + dynamic-config ConfigMap before the
|
|
1848
|
-
// Deployment that mounts them. `stringData` lets us hand over plain
|
|
1849
|
-
// PEM; the API server base64-encodes it.
|
|
1850
|
-
await helpers.client.core.createNamespacedSecret({
|
|
1851
|
-
namespace: "kube-system",
|
|
1852
|
-
body: {
|
|
1853
|
-
metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
|
|
1854
|
-
type: "kubernetes.io/tls",
|
|
1855
|
-
stringData: { "tls.crt": cert, "tls.key": key },
|
|
1856
|
-
},
|
|
1857
|
-
});
|
|
1858
|
-
await helpers.client.core.createNamespacedConfigMap({
|
|
1859
|
-
namespace: "kube-system",
|
|
1860
|
-
body: {
|
|
1861
|
-
metadata: {
|
|
1862
|
-
name: TRAEFIK_DYNAMIC_CONFIGMAP,
|
|
1863
|
-
namespace: "kube-system",
|
|
1864
|
-
},
|
|
1865
|
-
data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
|
|
1866
|
-
},
|
|
1867
|
-
});
|
|
1868
|
-
}
|
|
1869
|
-
if (opts.traefik) await helpers.apply(buildTraefikManifest(tlsEnabled));
|
|
1870
|
-
if (opts.registry) await helpers.apply(REGISTRY_MANIFEST);
|
|
1871
|
-
// Both rollouts proceed independently inside the cluster — wait on them
|
|
1872
|
-
// concurrently (they used to serialize, wasting up to a rollout's tail).
|
|
1873
|
-
// 250ms polling: these two sit on the cold start's critical path, where
|
|
1874
|
-
// the default 500ms interval wastes real time.
|
|
1869
|
+
// The registry can start while Traefik's certificates are being prepared.
|
|
1875
1870
|
const wait = (deployment: string): Promise<void> =>
|
|
1876
1871
|
helpers.waitForRollout(deployment, {
|
|
1877
1872
|
namespace: "kube-system",
|
|
1878
1873
|
timeoutMs: 120_000,
|
|
1879
1874
|
intervalMs: 250,
|
|
1880
1875
|
});
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1876
|
+
await waitForAll([
|
|
1877
|
+
(async () => {
|
|
1878
|
+
if (!opts.traefik) return;
|
|
1879
|
+
const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
|
|
1880
|
+
if (tlsEnabled) {
|
|
1881
|
+
const { cert, key } = await issueIngressCert(
|
|
1882
|
+
opts.ingressDomains.map((d) => `*.${d}`),
|
|
1883
|
+
);
|
|
1884
|
+
// Apply the cert Secret + dynamic-config ConfigMap before the
|
|
1885
|
+
// Deployment that mounts them. `stringData` lets us hand over plain
|
|
1886
|
+
// PEM; the API server base64-encodes it.
|
|
1887
|
+
await helpers.client.core.createNamespacedSecret({
|
|
1888
|
+
namespace: "kube-system",
|
|
1889
|
+
body: {
|
|
1890
|
+
metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
|
|
1891
|
+
type: "kubernetes.io/tls",
|
|
1892
|
+
stringData: { "tls.crt": cert, "tls.key": key },
|
|
1893
|
+
},
|
|
1894
|
+
});
|
|
1895
|
+
await helpers.client.core.createNamespacedConfigMap({
|
|
1896
|
+
namespace: "kube-system",
|
|
1897
|
+
body: {
|
|
1898
|
+
metadata: {
|
|
1899
|
+
name: TRAEFIK_DYNAMIC_CONFIGMAP,
|
|
1900
|
+
namespace: "kube-system",
|
|
1901
|
+
},
|
|
1902
|
+
data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
|
|
1903
|
+
},
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
await helpers.apply(buildTraefikManifest(tlsEnabled));
|
|
1907
|
+
await wait("traefik");
|
|
1908
|
+
})(),
|
|
1909
|
+
(async () => {
|
|
1910
|
+
if (!opts.registry) return;
|
|
1911
|
+
await helpers.apply(REGISTRY_MANIFEST);
|
|
1912
|
+
await wait("spectest-registry");
|
|
1913
|
+
})(),
|
|
1914
|
+
]);
|
|
1884
1915
|
}
|
|
1885
1916
|
|
|
1886
1917
|
/**
|
|
@@ -2007,6 +2038,7 @@ const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
|
|
|
2007
2038
|
|
|
2008
2039
|
export function k3s(opts: K3sOptions = {}) {
|
|
2009
2040
|
const version = opts.version ?? DEFAULT_K3S_VERSION;
|
|
2041
|
+
const images = kubernetesImages(opts.images ?? []);
|
|
2010
2042
|
const extra = opts.extraArgs ?? [];
|
|
2011
2043
|
assertUsableExtraArgs(extra);
|
|
2012
2044
|
const registryEnabled = opts.registry !== false;
|
|
@@ -2023,7 +2055,8 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2023
2055
|
// The environment's image cache, when this VM carries one: the
|
|
2024
2056
|
// cluster's store goes on its root disk and its layers come off the
|
|
2025
2057
|
// shared layers disk (see the store section above).
|
|
2026
|
-
const
|
|
2058
|
+
const sticky = stickyDiskPathSync();
|
|
2059
|
+
const cache = sticky ? null : imageCachePathsSync();
|
|
2027
2060
|
const serverArgs = [
|
|
2028
2061
|
"k3s",
|
|
2029
2062
|
"server",
|
|
@@ -2122,6 +2155,7 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2122
2155
|
const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
|
|
2123
2156
|
const def = {
|
|
2124
2157
|
image: { type: "registry", reference: `rancher/k3s:${version}` },
|
|
2158
|
+
imageBuilds: images.builds,
|
|
2125
2159
|
command: cmd,
|
|
2126
2160
|
privileged: true,
|
|
2127
2161
|
tmpfs: ["/run", "/var/run"],
|
|
@@ -2135,15 +2169,17 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2135
2169
|
// the same netns and are reachable the same way, without appearing
|
|
2136
2170
|
// in this list (it's documentation, not a firewall).
|
|
2137
2171
|
ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
|
|
2138
|
-
//
|
|
2172
|
+
// Sticky disks retain an independent overlayfs store per service/version.
|
|
2173
|
+
// The legacy image cache (see the store section above) keeps its
|
|
2139
2174
|
// containerd root on the image cache's root disk, the shared layers
|
|
2140
2175
|
// disk read-only at its guest path, golden's mkfs.erofs and the adopt
|
|
2141
2176
|
// helper — or, with no cache, a per-environment volume. Two clusters
|
|
2142
2177
|
// in one environment get separate roots either way (the service
|
|
2143
2178
|
// token names the directory), because two containerd daemons cannot
|
|
2144
2179
|
// share a store: bolt takes an exclusive lock.
|
|
2145
|
-
volumes:
|
|
2146
|
-
? [
|
|
2180
|
+
volumes: sticky
|
|
2181
|
+
? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
|
|
2182
|
+
: cache ? [
|
|
2147
2183
|
{ source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
|
|
2148
2184
|
{ source: cache.layers, target: cache.layers, readOnly: true },
|
|
2149
2185
|
{ source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
|
|
@@ -2164,12 +2200,56 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2164
2200
|
command: readyCmd,
|
|
2165
2201
|
timeoutSecs: opts.readyTimeoutSecs ?? 120,
|
|
2166
2202
|
},
|
|
2167
|
-
setup: async (
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2203
|
+
setup: async (ctx: ServiceSetupContext<K3sHelpers>) => {
|
|
2204
|
+
const { name, helpers, exec } = ctx;
|
|
2205
|
+
const runtime = componentRuntime(ctx);
|
|
2206
|
+
if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
|
|
2207
|
+
await waitForAll([
|
|
2208
|
+
runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
|
|
2209
|
+
prePullK3sImages(name, images.pulls, exec),
|
|
2210
|
+
(async () => {
|
|
2211
|
+
if (runtime?.caBundle && caPresent()) {
|
|
2212
|
+
const { cert, key } = await issueIngressCert([runtime.gateway]);
|
|
2213
|
+
await installPodTrust({ name, gateway: runtime.gateway, cert, key,
|
|
2214
|
+
ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
|
|
2215
|
+
}, {
|
|
2216
|
+
apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
|
|
2217
|
+
ensureBundle: async (namespace, configMap, bundle) => {
|
|
2218
|
+
try {
|
|
2219
|
+
await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
|
|
2220
|
+
metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
|
|
2221
|
+
} });
|
|
2222
|
+
} catch (err) {
|
|
2223
|
+
// A simultaneous Pod may have created the same namespace's bundle.
|
|
2224
|
+
const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
|
|
2225
|
+
if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true) throw err;
|
|
2226
|
+
}
|
|
2227
|
+
},
|
|
2228
|
+
});
|
|
2229
|
+
// The API server observes webhook registration asynchronously. A dry-run
|
|
2230
|
+
// Pod proves TLS reachability and admission before any project workload.
|
|
2231
|
+
const deadline = Date.now() + 30_000;
|
|
2232
|
+
for (;;) {
|
|
2233
|
+
try {
|
|
2234
|
+
const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
|
|
2235
|
+
metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
|
|
2236
|
+
} })).unwrap();
|
|
2237
|
+
if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
|
|
2238
|
+
if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
if (Date.now() >= deadline) throw err;
|
|
2241
|
+
}
|
|
2242
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
await setupK3sCluster(name, helpers, {
|
|
2246
|
+
registry: registryEnabled,
|
|
2247
|
+
ingressDomains,
|
|
2248
|
+
traefik: traefikEnabled,
|
|
2249
|
+
});
|
|
2250
|
+
})(),
|
|
2251
|
+
]);
|
|
2252
|
+
if (sticky) await registerStickyKubernetesStore(name, version);
|
|
2173
2253
|
},
|
|
2174
2254
|
helpers: async ({ name, exec, poll }: ServiceHelpersContext): Promise<K3sHelpers> => {
|
|
2175
2255
|
// Read the cluster's kubeconfig and address the API server by its
|
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
2
|
+
import { waitForAll } from "./harness/concurrency.js";
|
|
1
3
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
2
4
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
3
5
|
// a container). Owns:
|
|
@@ -35,6 +37,7 @@ import {
|
|
|
35
37
|
dnsName as makeDnsDecl,
|
|
36
38
|
isWildcard,
|
|
37
39
|
validateServiceImage,
|
|
40
|
+
normalizeServiceImage,
|
|
38
41
|
proxy as makeProxyDecl,
|
|
39
42
|
} from "./index.js";
|
|
40
43
|
import type { DnsTarget, LoweredIngress } from "./index.js";
|
|
@@ -154,6 +157,7 @@ import {
|
|
|
154
157
|
} from "./harness/ingress-table.js";
|
|
155
158
|
import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
|
|
156
159
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
160
|
+
import { isOnStickyDisk } from "./harness/sticky-paths.js";
|
|
157
161
|
import {
|
|
158
162
|
BUILDKIT_CACHE_DIR,
|
|
159
163
|
BUILDKIT_CACHE_UNION,
|
|
@@ -239,7 +243,7 @@ import type {
|
|
|
239
243
|
ServiceConfig,
|
|
240
244
|
ServiceDefinition,
|
|
241
245
|
ServiceHandles,
|
|
242
|
-
|
|
246
|
+
NormalizedServiceImage,
|
|
243
247
|
SpectestContext,
|
|
244
248
|
Terminal,
|
|
245
249
|
TerminalOpts,
|
|
@@ -256,10 +260,21 @@ import type { InternalTerminal, TerminalFrameSink } from "./terminal.js";
|
|
|
256
260
|
* name (container name, image tag, ready-probe host). Widened to
|
|
257
261
|
* accept any helpers shape so a typed `setup({ helpers })` call from
|
|
258
262
|
* a component (e.g. K3sHelpers) lines up at the call site. */
|
|
259
|
-
type NamedService = ServiceDefinition<Record<string, unknown
|
|
263
|
+
type NamedService = Omit<ServiceDefinition<Record<string, unknown>>, "image"> & { name: string; image: NormalizedServiceImage };
|
|
260
264
|
|
|
261
265
|
function namedServices(cfg: EnvironmentConfig): NamedService[] {
|
|
262
|
-
return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
|
|
266
|
+
return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def, image: normalizeServiceImage(def.image) }));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Build tasks participate in image preparation, never container startup. */
|
|
270
|
+
function imageTasks(services: NamedService[]): NamedService[] {
|
|
271
|
+
const tasks = componentBuildTasks(services).map(task => ({ ...task, image: validateServiceImage(task.name, task.image) }));
|
|
272
|
+
const names = new Set(services.map(s => s.name));
|
|
273
|
+
for (const task of tasks) {
|
|
274
|
+
if (names.has(task.name)) throw new Error(`image task name conflicts with service: ${task.name}`);
|
|
275
|
+
names.add(task.name);
|
|
276
|
+
}
|
|
277
|
+
return [...services, ...tasks];
|
|
263
278
|
}
|
|
264
279
|
|
|
265
280
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
@@ -977,7 +992,7 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
|
|
|
977
992
|
// A directory on a cache disk is the same kind of thing: a nested
|
|
978
993
|
// runtime's containerd root (`k3s()`), kept as a cache by the
|
|
979
994
|
// lineage exactly as the container store one level up is.
|
|
980
|
-
const durable = host.startsWith("/var/cache/spectest/") || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
|
|
995
|
+
const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host) || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
|
|
981
996
|
if (vol.source?.startsWith("/") && !durable) {
|
|
982
997
|
await recordAbsoluteVolumeDir(host);
|
|
983
998
|
}
|
|
@@ -1365,6 +1380,18 @@ async function resolveDockerfileBuild(svc: NamedService): Promise<DockerfileBuil
|
|
|
1365
1380
|
async function prepareServiceImage(
|
|
1366
1381
|
svc: NamedService,
|
|
1367
1382
|
opts?: { dedup?: boolean },
|
|
1383
|
+
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
1384
|
+
const result = await prepareServiceImageInner(svc, opts);
|
|
1385
|
+
if (svc.image.type === "dockerfile" && svc.image.reference) {
|
|
1386
|
+
const tagged = await docker(["tag", result.tag, svc.image.reference], 30_000);
|
|
1387
|
+
if (tagged.code !== 0) throw new Error(`cannot name built image ${svc.image.reference}: ${tagged.stderr}`);
|
|
1388
|
+
}
|
|
1389
|
+
return result;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
async function prepareServiceImageInner(
|
|
1393
|
+
svc: NamedService,
|
|
1394
|
+
opts?: { dedup?: boolean },
|
|
1368
1395
|
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
1369
1396
|
const tag = imageTag(svc.name);
|
|
1370
1397
|
if (svc.image.type === "registry") {
|
|
@@ -1414,7 +1441,7 @@ async function prepareServiceImage(
|
|
|
1414
1441
|
progressService(svc.name, { status: "failed" });
|
|
1415
1442
|
throw err;
|
|
1416
1443
|
}
|
|
1417
|
-
//
|
|
1444
|
+
// Reuse the same application image; runtime trust is added separately.
|
|
1418
1445
|
const tagr = await docker(["tag", first.tag, tag], 30_000);
|
|
1419
1446
|
if (tagr.code !== 0) {
|
|
1420
1447
|
throw new Error(`docker tag ${first.tag} ${tag} failed: ${tagr.stderr.trim()}`);
|
|
@@ -3175,7 +3202,8 @@ function specToNamedService(spec: RuntimeServiceSpec): NamedService {
|
|
|
3175
3202
|
// run here: the image is checked (a `path` Dockerfile and a `context`
|
|
3176
3203
|
// must exist), and coverage adapters get their configure step.
|
|
3177
3204
|
const svc = { ...(rest as ServiceConfig), image: validateServiceImage(name, rest.image) };
|
|
3178
|
-
|
|
3205
|
+
const configured = applyCoverageAdapters(name, svc);
|
|
3206
|
+
return { name, ...configured, image: normalizeServiceImage(configured.image) };
|
|
3179
3207
|
}
|
|
3180
3208
|
|
|
3181
3209
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
@@ -3507,12 +3535,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3507
3535
|
const bootStart = Date.now();
|
|
3508
3536
|
const cfg: EnvironmentConfig = requireLoaded().project.environment;
|
|
3509
3537
|
const services = namedServices(cfg);
|
|
3538
|
+
const tasks = imageTasks(services);
|
|
3539
|
+
const componentTasks = componentBuildTasks(services);
|
|
3510
3540
|
const timings = new Map<string, ServiceTiming>();
|
|
3511
3541
|
if (prepared && (prepared.version !== 1 || !prepared.images ||
|
|
3512
|
-
Object.keys(prepared.images).sort().join("\n") !==
|
|
3542
|
+
Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
|
|
3513
3543
|
throw new Error("prepared image manifest does not match the environment's services");
|
|
3514
3544
|
}
|
|
3515
|
-
progressInit(
|
|
3545
|
+
progressInit(tasks);
|
|
3516
3546
|
// Build dedup is only valid within one workspace generation — a fresh
|
|
3517
3547
|
// bootstrap may follow a workspace re-upload with the same dockerfile
|
|
3518
3548
|
// text but different build-context content.
|
|
@@ -3535,7 +3565,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3535
3565
|
if ((await readIgnoreFile(path.join(WORKSPACE, ".dockerignore"))) !== null) return;
|
|
3536
3566
|
await fs.writeFile(
|
|
3537
3567
|
path.join(WORKSPACE, ".dockerignore"),
|
|
3538
|
-
unionDockerignore(
|
|
3568
|
+
unionDockerignore(tasks),
|
|
3539
3569
|
);
|
|
3540
3570
|
})(),
|
|
3541
3571
|
]);
|
|
@@ -3572,11 +3602,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3572
3602
|
if (found.code !== 0 || found.stdout.trim() !== entry.image) {
|
|
3573
3603
|
throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
|
|
3574
3604
|
}
|
|
3575
|
-
|
|
3605
|
+
const tag = imageTag(svc.name);
|
|
3606
|
+
const tagged = await docker(["tag", entry.image, tag], 30_000);
|
|
3607
|
+
if (tagged.code !== 0) throw new Error(`cannot restore prepared image name for ${svc.name}: ${tagged.stderr}`);
|
|
3608
|
+
return { tag, buildSteps: entry.buildSteps };
|
|
3576
3609
|
};
|
|
3577
3610
|
const prep = new Map<string, Promise<void>>();
|
|
3578
3611
|
const prepOffsets = new Map<string, number>();
|
|
3579
|
-
for (const svc of
|
|
3612
|
+
for (const svc of tasks) {
|
|
3580
3613
|
const p = (async () => {
|
|
3581
3614
|
const t0 = Date.now();
|
|
3582
3615
|
prepOffsets.set(svc.name, Date.now() - bootStart);
|
|
@@ -3602,7 +3635,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3602
3635
|
// No application services, setup hooks, ingress, or runtime state in the builder.
|
|
3603
3636
|
await Promise.all(prep.values());
|
|
3604
3637
|
const images: PreparedImages["images"] = Object.create(null);
|
|
3605
|
-
for (const svc of
|
|
3638
|
+
for (const svc of tasks) {
|
|
3606
3639
|
const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)!], 30_000);
|
|
3607
3640
|
const image = out.stdout.trim();
|
|
3608
3641
|
if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
|
|
@@ -3654,19 +3687,30 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3654
3687
|
progressService(svc.name, { status: "probing", detail: "ready check" });
|
|
3655
3688
|
await waitForReady(svc);
|
|
3656
3689
|
const tSetup = Date.now();
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3690
|
+
const builds = componentTasks.filter(task => task.owner === svc.name);
|
|
3691
|
+
// Setup can overlap pulls/infrastructure with builds, and consume each
|
|
3692
|
+
// image as it becomes ready. Still drain every build if setup fails.
|
|
3693
|
+
await waitForAll([...builds.map(task => prep.get(task.name)!), (async () => {
|
|
3694
|
+
if (svc.setup) {
|
|
3695
|
+
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3696
|
+
const helpers = await ensureHelpers(svc.name, svc);
|
|
3697
|
+
try {
|
|
3698
|
+
await svc.setup({
|
|
3699
|
+
name: svc.name,
|
|
3700
|
+
helpers,
|
|
3701
|
+
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3702
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3703
|
+
images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
|
|
3704
|
+
gateway: await bridgeGatewayIp(),
|
|
3705
|
+
caBundle: await ensureCaBundle(),
|
|
3706
|
+
} },
|
|
3707
|
+
});
|
|
3708
|
+
} catch (err) {
|
|
3709
|
+
throw await withContainerPostMortem(svc.name, err);
|
|
3710
|
+
}
|
|
3668
3711
|
}
|
|
3669
|
-
}
|
|
3712
|
+
})()]);
|
|
3713
|
+
for (const task of builds) progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
|
|
3670
3714
|
progressService(svc.name, { status: "ready", detail: undefined });
|
|
3671
3715
|
const ti = timings.get(svc.name);
|
|
3672
3716
|
if (ti) {
|
|
@@ -4223,9 +4267,10 @@ async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
|
|
|
4223
4267
|
async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
|
|
4224
4268
|
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
4225
4269
|
const sources: ReachSource[] = [];
|
|
4226
|
-
|
|
4270
|
+
const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
|
|
4271
|
+
for (const svc of imageTasks(all)) {
|
|
4227
4272
|
const src: ReachSource = {
|
|
4228
|
-
service: svc.name,
|
|
4273
|
+
service: owners.get(svc.name) ?? svc.name,
|
|
4229
4274
|
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
|
|
4230
4275
|
...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
|
|
4231
4276
|
};
|
|
@@ -6720,7 +6765,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6720
6765
|
bootstrap: async (params) =>
|
|
6721
6766
|
exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
|
|
6722
6767
|
const prepared = params.prepared === undefined ? undefined : preparedImages(
|
|
6723
|
-
params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name),
|
|
6768
|
+
params.prepared, params.identity, imageTasks(namedServices(requireLoaded().project.environment)).map(s => s.name),
|
|
6724
6769
|
);
|
|
6725
6770
|
return { ok: true, timings: await bootstrap(prepared) };
|
|
6726
6771
|
}),
|