@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
package/dist/android-step.js
CHANGED
|
@@ -15,7 +15,7 @@ export function androidTargetLabel(target) {
|
|
|
15
15
|
case "id":
|
|
16
16
|
return `id ${quoted}`;
|
|
17
17
|
case "accessibility":
|
|
18
|
-
return `
|
|
18
|
+
return `label ${quoted}`;
|
|
19
19
|
case "xpath":
|
|
20
20
|
return `XPath ${quoted}`;
|
|
21
21
|
case "custom":
|
|
@@ -74,5 +74,5 @@ export function androidStepTitle(step) {
|
|
|
74
74
|
label = `run command ${JSON.stringify(step.command)}`;
|
|
75
75
|
break;
|
|
76
76
|
}
|
|
77
|
-
return
|
|
77
|
+
return label;
|
|
78
78
|
}
|
|
@@ -103,6 +103,7 @@ export declare function nodeMajorFromRange(range: string): number | null;
|
|
|
103
103
|
export declare function expo(opts?: ExpoOptions): {
|
|
104
104
|
image: {
|
|
105
105
|
type: "dockerfile";
|
|
106
|
+
reference?: string;
|
|
106
107
|
path: string;
|
|
107
108
|
content?: never;
|
|
108
109
|
context?: string;
|
|
@@ -111,6 +112,7 @@ export declare function expo(opts?: ExpoOptions): {
|
|
|
111
112
|
buildArgs?: Readonly<Record<string, string>>;
|
|
112
113
|
} | {
|
|
113
114
|
type: "dockerfile";
|
|
115
|
+
reference?: string;
|
|
114
116
|
content: string;
|
|
115
117
|
path?: never;
|
|
116
118
|
context?: string;
|
|
@@ -2,7 +2,7 @@ export { temporal, temporalClient, type TemporalOptions, type TemporalClient, ty
|
|
|
2
2
|
export { postgres, type PostgresOptions, type PostgresHelpers, } from "./postgres.js";
|
|
3
3
|
export { s3, type S3Options, type S3Helpers, } from "./s3.js";
|
|
4
4
|
export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
|
|
5
|
-
export { k3s, type K3sOptions, type K3sHelpers, type K3sClient, type TaggedObjectApi, type KubernetesObjectRef, type ApplyOptions, type RolloutTarget, type WaitOptions, type LogsOptions, type KubernetesObject, type KubernetesListObject, } from "./k3s.js";
|
|
5
|
+
export { k3s, type K3sOptions, type KubernetesImage, type K3sHelpers, type K3sClient, type TaggedObjectApi, type KubernetesObjectRef, type ApplyOptions, type RolloutTarget, type WaitOptions, type LogsOptions, type KubernetesObject, type KubernetesListObject, } from "./k3s.js";
|
|
6
6
|
export { expo, type ExpoOptions, type ExpoHelpers, type ExpoPackageManager, } from "./expo.js";
|
|
7
7
|
export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
|
|
8
8
|
export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export declare const POD_CA_PATH = "/spectest/ca-bundle.crt";
|
|
2
|
+
interface Container {
|
|
3
|
+
env?: {
|
|
4
|
+
name: string;
|
|
5
|
+
value?: string;
|
|
6
|
+
valueFrom?: unknown;
|
|
7
|
+
}[];
|
|
8
|
+
volumeMounts?: {
|
|
9
|
+
name: string;
|
|
10
|
+
mountPath: string;
|
|
11
|
+
subPath?: string;
|
|
12
|
+
readOnly?: boolean;
|
|
13
|
+
}[];
|
|
14
|
+
}
|
|
15
|
+
interface Pod {
|
|
16
|
+
metadata?: {
|
|
17
|
+
annotations?: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
spec?: {
|
|
20
|
+
os?: {
|
|
21
|
+
name: string;
|
|
22
|
+
};
|
|
23
|
+
volumes?: {
|
|
24
|
+
name: string;
|
|
25
|
+
configMap?: {
|
|
26
|
+
name: string;
|
|
27
|
+
};
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}[];
|
|
30
|
+
containers?: Container[];
|
|
31
|
+
initContainers?: Container[];
|
|
32
|
+
ephemeralContainers?: Container[];
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
interface Patch {
|
|
36
|
+
op: "add";
|
|
37
|
+
path: string;
|
|
38
|
+
value: unknown;
|
|
39
|
+
}
|
|
40
|
+
export declare const TRUST_OPT_OUT = "spectest.dev/inject-ca";
|
|
41
|
+
export declare function podTrustPatch(pod: Pod, configMap: string, ephemeral?: boolean): Patch[];
|
|
42
|
+
export interface TrustDependencies {
|
|
43
|
+
/** Ensure this immutable ConfigMap exists in the requesting namespace. */
|
|
44
|
+
ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
|
|
45
|
+
apply(configuration: unknown): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export declare function installPodTrust(options: {
|
|
48
|
+
name: string;
|
|
49
|
+
gateway: string;
|
|
50
|
+
bundle: string;
|
|
51
|
+
cert: string;
|
|
52
|
+
key: string;
|
|
53
|
+
ca: string;
|
|
54
|
+
}, deps: TrustDependencies): Promise<void>;
|
|
55
|
+
/** Release a component listener on replacement or in standalone tests. */
|
|
56
|
+
export declare function stopPodTrust(name: string): void;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
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 } from "node:https";
|
|
5
|
+
export const POD_CA_PATH = "/spectest/ca-bundle.crt";
|
|
6
|
+
const TRUST_ENV = {
|
|
7
|
+
NODE_EXTRA_CA_CERTS: POD_CA_PATH, SSL_CERT_FILE: POD_CA_PATH,
|
|
8
|
+
REQUESTS_CA_BUNDLE: POD_CA_PATH, AWS_CA_BUNDLE: POD_CA_PATH,
|
|
9
|
+
PGSSLROOTCERT: POD_CA_PATH, DENO_TLS_CA_STORE: "mozilla,system",
|
|
10
|
+
};
|
|
11
|
+
const MOUNT_PATHS = [POD_CA_PATH, "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"];
|
|
12
|
+
export const TRUST_OPT_OUT = "spectest.dev/inject-ca";
|
|
13
|
+
export function podTrustPatch(pod, configMap, ephemeral = false) {
|
|
14
|
+
if (!pod.spec || pod.spec.os?.name === "windows" || pod.metadata?.annotations?.[TRUST_OPT_OUT] === "false")
|
|
15
|
+
return [];
|
|
16
|
+
const patches = [];
|
|
17
|
+
const volumes = pod.spec.volumes ?? [];
|
|
18
|
+
// Reuse our immutable ConfigMap, but never overwrite a user's volume.
|
|
19
|
+
let volume = volumes.find(v => v.configMap?.name === configMap)?.name;
|
|
20
|
+
if (!volume && ephemeral)
|
|
21
|
+
return []; // Pod volumes cannot change on this subresource.
|
|
22
|
+
if (!volume) {
|
|
23
|
+
const names = new Set(volumes.map(v => v.name));
|
|
24
|
+
volume = "spectest-ca";
|
|
25
|
+
for (let n = 1; names.has(volume); n++)
|
|
26
|
+
volume = `spectest-ca-${n}`;
|
|
27
|
+
const value = { name: volume, configMap: { name: configMap, defaultMode: 0o444, items: [{ key: "ca-bundle.crt", path: "ca-bundle.crt" }] } };
|
|
28
|
+
patches.push(pod.spec.volumes
|
|
29
|
+
? { op: "add", path: "/spec/volumes/-", value }
|
|
30
|
+
: { op: "add", path: "/spec/volumes", value: [value] });
|
|
31
|
+
}
|
|
32
|
+
const groups = ephemeral ? ["ephemeralContainers"] : ["containers", "initContainers"];
|
|
33
|
+
for (const group of groups) {
|
|
34
|
+
for (const [index, container] of (pod.spec[group] ?? []).entries()) {
|
|
35
|
+
const prefix = `/spec/${group}/${index}`;
|
|
36
|
+
const env = container.env ?? [];
|
|
37
|
+
const names = new Set(env.map(e => e.name));
|
|
38
|
+
const extraEnv = Object.entries(TRUST_ENV).filter(([name]) => !names.has(name)).map(([name, value]) => ({ name, value }));
|
|
39
|
+
const mounts = container.volumeMounts ?? [];
|
|
40
|
+
// A user mount at a path (or a parent directory) owns that path.
|
|
41
|
+
const extraMounts = MOUNT_PATHS.filter(path => !mounts.some(m => path === m.mountPath || path.startsWith(m.mountPath.replace(/\/$/, "") + "/")))
|
|
42
|
+
.map(mountPath => ({ name: volume, mountPath, subPath: "ca-bundle.crt", readOnly: true }));
|
|
43
|
+
if (extraEnv.length)
|
|
44
|
+
patches.push({ op: "add", path: `${prefix}/env`, value: [...env, ...extraEnv] });
|
|
45
|
+
if (extraMounts.length)
|
|
46
|
+
patches.push({ op: "add", path: `${prefix}/volumeMounts`, value: [...mounts, ...extraMounts] });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return patches;
|
|
50
|
+
}
|
|
51
|
+
const servers = new Map();
|
|
52
|
+
export async function installPodTrust(options, deps) {
|
|
53
|
+
const configMap = `spectest-ca-${createHash("sha256").update(options.bundle).digest("hex").slice(0, 20)}`;
|
|
54
|
+
const endpoint = `/admit/${randomUUID()}`;
|
|
55
|
+
const server = createServer({ cert: options.cert, key: options.key }, async (req, res) => {
|
|
56
|
+
if (req.method !== "POST" || new URL(req.url ?? "/", "https://local").pathname !== endpoint) {
|
|
57
|
+
res.writeHead(404).end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let size = 0;
|
|
62
|
+
try {
|
|
63
|
+
for await (const chunk of req) {
|
|
64
|
+
size += chunk.length;
|
|
65
|
+
if (size > 2 * 1024 * 1024) {
|
|
66
|
+
res.writeHead(413).end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
chunks.push(Buffer.from(chunk));
|
|
70
|
+
}
|
|
71
|
+
const review = JSON.parse(Buffer.concat(chunks).toString());
|
|
72
|
+
const request = review.request;
|
|
73
|
+
if (!request?.uid || request.resource?.resource !== "pods" || !request.object || !request.namespace) {
|
|
74
|
+
res.writeHead(400).end("invalid Pod admission request");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
|
|
78
|
+
// Dry runs return the same patch but perform no API writes.
|
|
79
|
+
if (patch.length && !request.dryRun)
|
|
80
|
+
await deps.ensureBundle(request.namespace, configMap, options.bundle);
|
|
81
|
+
res.setHeader("Content-Type", "application/json");
|
|
82
|
+
res.end(JSON.stringify({ apiVersion: "admission.k8s.io/v1", kind: "AdmissionReview", response: {
|
|
83
|
+
uid: request.uid, allowed: true,
|
|
84
|
+
...(patch.length ? { patchType: "JSONPatch", patch: Buffer.from(JSON.stringify(patch)).toString("base64") } : {}),
|
|
85
|
+
} }));
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error(`[k3s-trust] ${options.name}: ${err instanceof Error ? err.message : err}`);
|
|
89
|
+
res.writeHead(500).end("could not mount the environment CA");
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
server.requestTimeout = 15_000;
|
|
93
|
+
try {
|
|
94
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, options.gateway, resolve); });
|
|
95
|
+
const port = server.address().port;
|
|
96
|
+
await deps.apply({
|
|
97
|
+
apiVersion: "admissionregistration.k8s.io/v1", kind: "MutatingWebhookConfiguration",
|
|
98
|
+
metadata: { name: "spectest-pod-trust" },
|
|
99
|
+
webhooks: [{
|
|
100
|
+
name: "pod-trust.spectest.dev", admissionReviewVersions: ["v1"],
|
|
101
|
+
sideEffects: "NoneOnDryRun", failurePolicy: "Fail", timeoutSeconds: 10,
|
|
102
|
+
reinvocationPolicy: "IfNeeded", matchPolicy: "Equivalent",
|
|
103
|
+
clientConfig: { url: `https://${options.gateway}:${port}${endpoint}`, caBundle: Buffer.from(options.ca).toString("base64") },
|
|
104
|
+
rules: [{ operations: ["CREATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods"], scope: "Namespaced" },
|
|
105
|
+
{ operations: ["UPDATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods/ephemeralcontainers"], scope: "Namespaced" }],
|
|
106
|
+
}],
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
server.closeAllConnections();
|
|
111
|
+
server.close();
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
const previous = servers.get(options.name);
|
|
115
|
+
servers.set(options.name, server);
|
|
116
|
+
previous?.closeAllConnections();
|
|
117
|
+
previous?.close();
|
|
118
|
+
}
|
|
119
|
+
/** Release a component listener on replacement or in standalone tests. */
|
|
120
|
+
export function stopPodTrust(name) {
|
|
121
|
+
const server = servers.get(name);
|
|
122
|
+
servers.delete(name);
|
|
123
|
+
server?.closeAllConnections();
|
|
124
|
+
server?.close();
|
|
125
|
+
}
|
package/dist/components/k3s.d.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, PatchStrategy, type KubernetesListObject, type KubernetesObject, type V1DeleteOptions, type V1Job, type V1Status } from "@kubernetes/client-node";
|
|
2
|
-
import type { ServiceHelpersContext } from "../index.js";
|
|
2
|
+
import type { ServiceHelpersContext, ServiceSetupContext } from "../index.js";
|
|
3
|
+
import { type KubernetesImage } from "../harness/component-images.js";
|
|
4
|
+
export type { KubernetesImage } from "../harness/component-images.js";
|
|
3
5
|
import type { Wrapped } from "../inspect.js";
|
|
4
6
|
export type { KubernetesObject, KubernetesListObject, } from "@kubernetes/client-node";
|
|
7
|
+
/** Kubernetes pods receive read-only environment CA mounts during admission.
|
|
8
|
+
* Image bytes and digests stay unchanged; explicit pod trust settings win. */
|
|
5
9
|
export interface K3sOptions {
|
|
10
|
+
/** Images available to kubelet before project setup. Use registry strings,
|
|
11
|
+
* or Dockerfile builds with a full `reference` matching your manifests. Builds
|
|
12
|
+
* support path/content, context, exclude, target and buildArgs, and run in
|
|
13
|
+
* parallel with service builds through the standard BuildKit pipeline.
|
|
14
|
+
* Setup waits for every pull and fails if a requested image cannot be pulled.
|
|
15
|
+
* Sticky-enabled projects retain image content and unpacked layers between
|
|
16
|
+
* fresh builds automatically. Digest references can reuse the cache offline;
|
|
17
|
+
* tags are resolved again on each fresh setup. Pod imagePullPolicy still applies.
|
|
18
|
+
*/
|
|
19
|
+
images?: readonly KubernetesImage[];
|
|
6
20
|
/**
|
|
7
21
|
* Image tag for the official `rancher/k3s` image. Default
|
|
8
22
|
* `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
|
|
@@ -319,6 +333,7 @@ export declare function k3s(opts?: K3sOptions): {
|
|
|
319
333
|
type: "registry";
|
|
320
334
|
reference: string;
|
|
321
335
|
};
|
|
336
|
+
imageBuilds: import("../index.js").ComponentImageBuild[];
|
|
322
337
|
command: string;
|
|
323
338
|
privileged: true;
|
|
324
339
|
tmpfs: string[];
|
|
@@ -344,9 +359,6 @@ export declare function k3s(opts?: K3sOptions): {
|
|
|
344
359
|
command: string;
|
|
345
360
|
timeoutSecs: number;
|
|
346
361
|
};
|
|
347
|
-
setup: (
|
|
348
|
-
name: string;
|
|
349
|
-
helpers: K3sHelpers;
|
|
350
|
-
}) => Promise<void>;
|
|
362
|
+
setup: (ctx: ServiceSetupContext<K3sHelpers>) => Promise<void>;
|
|
351
363
|
helpers: ({ name, exec, poll }: ServiceHelpersContext) => Promise<K3sHelpers>;
|
|
352
364
|
};
|
package/dist/components/k3s.js
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";
|
|
@@ -7,6 +11,10 @@ import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, KubernetesObjectApi, Patc
|
|
|
7
11
|
import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
|
|
8
12
|
import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
|
|
9
13
|
import { MKFS_EROFS_PATH, STORE_ADOPT_PATH, imageCachePathsSync, nestedStoreDir, } from "../harness/image-cache.js";
|
|
14
|
+
import { stickyDiskPathSync } from "../harness/sticky-paths.js";
|
|
15
|
+
import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
|
|
16
|
+
import { prePullK3sImages } from "../harness/k3s-prepull.js";
|
|
17
|
+
import { componentRuntime, kubernetesImages } from "../harness/component-images.js";
|
|
10
18
|
import { deepUnwrap, readTag, wrap } from "../inspect.js";
|
|
11
19
|
import { recorderAnnotate, recorderRemove } from "../recorder.js";
|
|
12
20
|
/** Port the in-cluster registry listens on (plain HTTP). */
|
|
@@ -32,8 +40,13 @@ const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
|
|
|
32
40
|
*/
|
|
33
41
|
const BOOT_MARKER = ".spectest-boot-incomplete";
|
|
34
42
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
43
|
+
* Sticky-enabled projects bind a per-service/version directory on the
|
|
44
|
+
* sticky disk at CLUSTER_STORE_TARGET and use overlayfs. Publication's
|
|
45
|
+
* sticky-kubernetes adapter removes runtime state and retains images and
|
|
46
|
+
* unpacked layers under the shared filesystem budget. Test forks do not
|
|
47
|
+
* write back. The preparation wrapper below also handles delta rebuilds.
|
|
48
|
+
*
|
|
49
|
+
* Legacy image-cache integration (`CONTAINER_STORE.md` §12):
|
|
37
50
|
*
|
|
38
51
|
* The cluster's embedded containerd keeps its root on the **root disk**
|
|
39
52
|
* of the environment's image cache, in a directory of its own
|
|
@@ -195,8 +208,8 @@ function storePrepareSh(cache) {
|
|
|
195
208
|
`timeout 60 ctr -a "$A" -n k8s.io containers rm $ids >/dev/null 2>&1 || true; fi; ` +
|
|
196
209
|
// containerd 2.x keeps sandboxes in a store of their own; on the 1.7
|
|
197
210
|
// k3s ships they are ordinary containers and this is a no-op.
|
|
198
|
-
`sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls
|
|
199
|
-
`[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm $sb >/dev/null 2>&1; return 0; }; ` +
|
|
211
|
+
`sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
|
|
212
|
+
`[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
|
|
200
213
|
adopt +
|
|
201
214
|
`if [ -e "$M" ]; then ` +
|
|
202
215
|
`echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
|
|
@@ -287,7 +300,7 @@ async function issueIngressCert(hostnames) {
|
|
|
287
300
|
const id = `spectest-k3s-ingress-${randomUUID().slice(0, 8)}`;
|
|
288
301
|
const keyPath = `/tmp/${id}.key`;
|
|
289
302
|
const crtPath = `/tmp/${id}.crt`;
|
|
290
|
-
const sans = hostnames.map((h) =>
|
|
303
|
+
const sans = hostnames.map((h) => `${isIP(h) ? "IP" : "DNS"}:${h}`).join(",");
|
|
291
304
|
const r = await runProcess("openssl", [
|
|
292
305
|
"req",
|
|
293
306
|
"-newkey",
|
|
@@ -1231,48 +1244,51 @@ async function waitForJob(deps, name, opts) {
|
|
|
1231
1244
|
* reachable over HTTPS with a cert the test framework already trusts.
|
|
1232
1245
|
*/
|
|
1233
1246
|
async function setupK3sCluster(name, helpers, opts) {
|
|
1234
|
-
|
|
1235
|
-
if (tlsEnabled) {
|
|
1236
|
-
const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
|
|
1237
|
-
// Apply the cert Secret + dynamic-config ConfigMap before the
|
|
1238
|
-
// Deployment that mounts them. `stringData` lets us hand over plain
|
|
1239
|
-
// PEM; the API server base64-encodes it.
|
|
1240
|
-
await helpers.client.core.createNamespacedSecret({
|
|
1241
|
-
namespace: "kube-system",
|
|
1242
|
-
body: {
|
|
1243
|
-
metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
|
|
1244
|
-
type: "kubernetes.io/tls",
|
|
1245
|
-
stringData: { "tls.crt": cert, "tls.key": key },
|
|
1246
|
-
},
|
|
1247
|
-
});
|
|
1248
|
-
await helpers.client.core.createNamespacedConfigMap({
|
|
1249
|
-
namespace: "kube-system",
|
|
1250
|
-
body: {
|
|
1251
|
-
metadata: {
|
|
1252
|
-
name: TRAEFIK_DYNAMIC_CONFIGMAP,
|
|
1253
|
-
namespace: "kube-system",
|
|
1254
|
-
},
|
|
1255
|
-
data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
|
|
1256
|
-
},
|
|
1257
|
-
});
|
|
1258
|
-
}
|
|
1259
|
-
if (opts.traefik)
|
|
1260
|
-
await helpers.apply(buildTraefikManifest(tlsEnabled));
|
|
1261
|
-
if (opts.registry)
|
|
1262
|
-
await helpers.apply(REGISTRY_MANIFEST);
|
|
1263
|
-
// Both rollouts proceed independently inside the cluster — wait on them
|
|
1264
|
-
// concurrently (they used to serialize, wasting up to a rollout's tail).
|
|
1265
|
-
// 250ms polling: these two sit on the cold start's critical path, where
|
|
1266
|
-
// the default 500ms interval wastes real time.
|
|
1247
|
+
// The registry can start while Traefik's certificates are being prepared.
|
|
1267
1248
|
const wait = (deployment) => helpers.waitForRollout(deployment, {
|
|
1268
1249
|
namespace: "kube-system",
|
|
1269
1250
|
timeoutMs: 120_000,
|
|
1270
1251
|
intervalMs: 250,
|
|
1271
1252
|
});
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1253
|
+
await waitForAll([
|
|
1254
|
+
(async () => {
|
|
1255
|
+
if (!opts.traefik)
|
|
1256
|
+
return;
|
|
1257
|
+
const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
|
|
1258
|
+
if (tlsEnabled) {
|
|
1259
|
+
const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
|
|
1260
|
+
// Apply the cert Secret + dynamic-config ConfigMap before the
|
|
1261
|
+
// Deployment that mounts them. `stringData` lets us hand over plain
|
|
1262
|
+
// PEM; the API server base64-encodes it.
|
|
1263
|
+
await helpers.client.core.createNamespacedSecret({
|
|
1264
|
+
namespace: "kube-system",
|
|
1265
|
+
body: {
|
|
1266
|
+
metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
|
|
1267
|
+
type: "kubernetes.io/tls",
|
|
1268
|
+
stringData: { "tls.crt": cert, "tls.key": key },
|
|
1269
|
+
},
|
|
1270
|
+
});
|
|
1271
|
+
await helpers.client.core.createNamespacedConfigMap({
|
|
1272
|
+
namespace: "kube-system",
|
|
1273
|
+
body: {
|
|
1274
|
+
metadata: {
|
|
1275
|
+
name: TRAEFIK_DYNAMIC_CONFIGMAP,
|
|
1276
|
+
namespace: "kube-system",
|
|
1277
|
+
},
|
|
1278
|
+
data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
|
|
1279
|
+
},
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
await helpers.apply(buildTraefikManifest(tlsEnabled));
|
|
1283
|
+
await wait("traefik");
|
|
1284
|
+
})(),
|
|
1285
|
+
(async () => {
|
|
1286
|
+
if (!opts.registry)
|
|
1287
|
+
return;
|
|
1288
|
+
await helpers.apply(REGISTRY_MANIFEST);
|
|
1289
|
+
await wait("spectest-registry");
|
|
1290
|
+
})(),
|
|
1291
|
+
]);
|
|
1276
1292
|
}
|
|
1277
1293
|
/**
|
|
1278
1294
|
* Snapshot of a namespace's state, dumped whenever a wait times out: pod
|
|
@@ -1393,6 +1409,7 @@ async function collectDiagnostics(client, namespace) {
|
|
|
1393
1409
|
const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
|
|
1394
1410
|
export function k3s(opts = {}) {
|
|
1395
1411
|
const version = opts.version ?? DEFAULT_K3S_VERSION;
|
|
1412
|
+
const images = kubernetesImages(opts.images ?? []);
|
|
1396
1413
|
const extra = opts.extraArgs ?? [];
|
|
1397
1414
|
assertUsableExtraArgs(extra);
|
|
1398
1415
|
const registryEnabled = opts.registry !== false;
|
|
@@ -1409,7 +1426,8 @@ export function k3s(opts = {}) {
|
|
|
1409
1426
|
// The environment's image cache, when this VM carries one: the
|
|
1410
1427
|
// cluster's store goes on its root disk and its layers come off the
|
|
1411
1428
|
// shared layers disk (see the store section above).
|
|
1412
|
-
const
|
|
1429
|
+
const sticky = stickyDiskPathSync();
|
|
1430
|
+
const cache = sticky ? null : imageCachePathsSync();
|
|
1413
1431
|
const serverArgs = [
|
|
1414
1432
|
"k3s",
|
|
1415
1433
|
"server",
|
|
@@ -1507,6 +1525,7 @@ export function k3s(opts = {}) {
|
|
|
1507
1525
|
const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
|
|
1508
1526
|
const def = {
|
|
1509
1527
|
image: { type: "registry", reference: `rancher/k3s:${version}` },
|
|
1528
|
+
imageBuilds: images.builds,
|
|
1510
1529
|
command: cmd,
|
|
1511
1530
|
privileged: true,
|
|
1512
1531
|
tmpfs: ["/run", "/var/run"],
|
|
@@ -1520,21 +1539,23 @@ export function k3s(opts = {}) {
|
|
|
1520
1539
|
// the same netns and are reachable the same way, without appearing
|
|
1521
1540
|
// in this list (it's documentation, not a firewall).
|
|
1522
1541
|
ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
|
|
1523
|
-
//
|
|
1542
|
+
// Sticky disks retain an independent overlayfs store per service/version.
|
|
1543
|
+
// The legacy image cache (see the store section above) keeps its
|
|
1524
1544
|
// containerd root on the image cache's root disk, the shared layers
|
|
1525
1545
|
// disk read-only at its guest path, golden's mkfs.erofs and the adopt
|
|
1526
1546
|
// helper — or, with no cache, a per-environment volume. Two clusters
|
|
1527
1547
|
// in one environment get separate roots either way (the service
|
|
1528
1548
|
// token names the directory), because two containerd daemons cannot
|
|
1529
1549
|
// share a store: bolt takes an exclusive lock.
|
|
1530
|
-
volumes:
|
|
1531
|
-
? [
|
|
1550
|
+
volumes: sticky
|
|
1551
|
+
? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
|
|
1552
|
+
: cache ? [
|
|
1532
1553
|
{ source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
|
|
1533
1554
|
{ source: cache.layers, target: cache.layers, readOnly: true },
|
|
1534
1555
|
{ source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
|
|
1535
1556
|
{ source: STORE_ADOPT_PATH, target: STORE_ADOPT_PATH, readOnly: true },
|
|
1536
1557
|
]
|
|
1537
|
-
|
|
1558
|
+
: [{ target: CLUSTER_STORE_TARGET }],
|
|
1538
1559
|
files: [
|
|
1539
1560
|
...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
|
|
1540
1561
|
...(cache
|
|
@@ -1549,12 +1570,64 @@ export function k3s(opts = {}) {
|
|
|
1549
1570
|
command: readyCmd,
|
|
1550
1571
|
timeoutSecs: opts.readyTimeoutSecs ?? 120,
|
|
1551
1572
|
},
|
|
1552
|
-
setup: async (
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1573
|
+
setup: async (ctx) => {
|
|
1574
|
+
const { name, helpers, exec } = ctx;
|
|
1575
|
+
const runtime = componentRuntime(ctx);
|
|
1576
|
+
if (images.builds.length && !runtime)
|
|
1577
|
+
throw new Error(`k3s(${name}): prepared image inputs are missing`);
|
|
1578
|
+
await waitForAll([
|
|
1579
|
+
runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
|
|
1580
|
+
prePullK3sImages(name, images.pulls, exec),
|
|
1581
|
+
(async () => {
|
|
1582
|
+
if (runtime?.caBundle && caPresent()) {
|
|
1583
|
+
const { cert, key } = await issueIngressCert([runtime.gateway]);
|
|
1584
|
+
await installPodTrust({ name, gateway: runtime.gateway, cert, key,
|
|
1585
|
+
ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
|
|
1586
|
+
}, {
|
|
1587
|
+
apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
|
|
1588
|
+
ensureBundle: async (namespace, configMap, bundle) => {
|
|
1589
|
+
try {
|
|
1590
|
+
await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
|
|
1591
|
+
metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
|
|
1592
|
+
} });
|
|
1593
|
+
}
|
|
1594
|
+
catch (err) {
|
|
1595
|
+
// A simultaneous Pod may have created the same namespace's bundle.
|
|
1596
|
+
const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
|
|
1597
|
+
if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true)
|
|
1598
|
+
throw err;
|
|
1599
|
+
}
|
|
1600
|
+
},
|
|
1601
|
+
});
|
|
1602
|
+
// The API server observes webhook registration asynchronously. A dry-run
|
|
1603
|
+
// Pod proves TLS reachability and admission before any project workload.
|
|
1604
|
+
const deadline = Date.now() + 30_000;
|
|
1605
|
+
for (;;) {
|
|
1606
|
+
try {
|
|
1607
|
+
const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
|
|
1608
|
+
metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
|
|
1609
|
+
} })).unwrap();
|
|
1610
|
+
if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
|
|
1611
|
+
break;
|
|
1612
|
+
if (Date.now() >= deadline)
|
|
1613
|
+
throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
|
|
1614
|
+
}
|
|
1615
|
+
catch (err) {
|
|
1616
|
+
if (Date.now() >= deadline)
|
|
1617
|
+
throw err;
|
|
1618
|
+
}
|
|
1619
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
await setupK3sCluster(name, helpers, {
|
|
1623
|
+
registry: registryEnabled,
|
|
1624
|
+
ingressDomains,
|
|
1625
|
+
traefik: traefikEnabled,
|
|
1626
|
+
});
|
|
1627
|
+
})(),
|
|
1628
|
+
]);
|
|
1629
|
+
if (sticky)
|
|
1630
|
+
await registerStickyKubernetesStore(name, version);
|
|
1558
1631
|
},
|
|
1559
1632
|
helpers: async ({ name, exec, poll }) => {
|
|
1560
1633
|
// Read the cluster's kubeconfig and address the API server by its
|