@specific.dev/spectest 0.91.0 → 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/k3s.js +90 -83
- package/dist/daemon.js +24 -20
- package/dist/harness/component-images.d.ts +7 -4
- package/dist/harness/concurrency.d.ts +2 -0
- package/dist/harness/concurrency.js +9 -0
- package/dist/harness/k3s-image-loader.d.ts +2 -4
- package/dist/harness/k3s-image-loader.js +4 -2
- package/dist/harness/k3s-prepull.d.ts +2 -1
- package/dist/harness/k3s-prepull.js +11 -15
- 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/k3s-concurrency.test.ts +60 -0
- package/src/components/k3s.ts +84 -75
- package/src/daemon.ts +22 -18
- package/src/harness/component-images.ts +7 -1
- package/src/harness/concurrency.ts +8 -0
- package/src/harness/k3s-image-loader.ts +6 -4
- package/src/harness/k3s-prepull.test.ts +12 -2
- package/src/harness/k3s-prepull.ts +9 -12
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
|
}
|
package/dist/components/k3s.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import { installPodTrust } from "./k3s-trust.js";
|
|
3
|
+
import { waitForAll } from "../harness/concurrency.js";
|
|
3
4
|
import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
|
|
4
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
6
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
@@ -1243,48 +1244,51 @@ async function waitForJob(deps, name, opts) {
|
|
|
1243
1244
|
* reachable over HTTPS with a cert the test framework already trusts.
|
|
1244
1245
|
*/
|
|
1245
1246
|
async function setupK3sCluster(name, helpers, opts) {
|
|
1246
|
-
|
|
1247
|
-
if (tlsEnabled) {
|
|
1248
|
-
const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
|
|
1249
|
-
// Apply the cert Secret + dynamic-config ConfigMap before the
|
|
1250
|
-
// Deployment that mounts them. `stringData` lets us hand over plain
|
|
1251
|
-
// PEM; the API server base64-encodes it.
|
|
1252
|
-
await helpers.client.core.createNamespacedSecret({
|
|
1253
|
-
namespace: "kube-system",
|
|
1254
|
-
body: {
|
|
1255
|
-
metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
|
|
1256
|
-
type: "kubernetes.io/tls",
|
|
1257
|
-
stringData: { "tls.crt": cert, "tls.key": key },
|
|
1258
|
-
},
|
|
1259
|
-
});
|
|
1260
|
-
await helpers.client.core.createNamespacedConfigMap({
|
|
1261
|
-
namespace: "kube-system",
|
|
1262
|
-
body: {
|
|
1263
|
-
metadata: {
|
|
1264
|
-
name: TRAEFIK_DYNAMIC_CONFIGMAP,
|
|
1265
|
-
namespace: "kube-system",
|
|
1266
|
-
},
|
|
1267
|
-
data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
|
|
1268
|
-
},
|
|
1269
|
-
});
|
|
1270
|
-
}
|
|
1271
|
-
if (opts.traefik)
|
|
1272
|
-
await helpers.apply(buildTraefikManifest(tlsEnabled));
|
|
1273
|
-
if (opts.registry)
|
|
1274
|
-
await helpers.apply(REGISTRY_MANIFEST);
|
|
1275
|
-
// Both rollouts proceed independently inside the cluster — wait on them
|
|
1276
|
-
// concurrently (they used to serialize, wasting up to a rollout's tail).
|
|
1277
|
-
// 250ms polling: these two sit on the cold start's critical path, where
|
|
1278
|
-
// the default 500ms interval wastes real time.
|
|
1247
|
+
// The registry can start while Traefik's certificates are being prepared.
|
|
1279
1248
|
const wait = (deployment) => helpers.waitForRollout(deployment, {
|
|
1280
1249
|
namespace: "kube-system",
|
|
1281
1250
|
timeoutMs: 120_000,
|
|
1282
1251
|
intervalMs: 250,
|
|
1283
1252
|
});
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
+
]);
|
|
1288
1292
|
}
|
|
1289
1293
|
/**
|
|
1290
1294
|
* Snapshot of a namespace's state, dumped whenever a wait times out: pod
|
|
@@ -1571,54 +1575,57 @@ export function k3s(opts = {}) {
|
|
|
1571
1575
|
const runtime = componentRuntime(ctx);
|
|
1572
1576
|
if (images.builds.length && !runtime)
|
|
1573
1577
|
throw new Error(`k3s(${name}): prepared image inputs are missing`);
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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));
|
|
1591
1620
|
}
|
|
1592
|
-
},
|
|
1593
|
-
});
|
|
1594
|
-
// The API server observes webhook registration asynchronously. A dry-run
|
|
1595
|
-
// Pod proves TLS reachability and admission before any project workload.
|
|
1596
|
-
const deadline = Date.now() + 30_000;
|
|
1597
|
-
for (;;) {
|
|
1598
|
-
try {
|
|
1599
|
-
const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
|
|
1600
|
-
metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
|
|
1601
|
-
} })).unwrap();
|
|
1602
|
-
if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
|
|
1603
|
-
break;
|
|
1604
|
-
if (Date.now() >= deadline)
|
|
1605
|
-
throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
|
|
1606
1621
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
if (runtime)
|
|
1615
|
-
await loadK3sBuildImages(name, runtime.images, runtime.gateway, exec);
|
|
1616
|
-
await setupK3sCluster(name, helpers, {
|
|
1617
|
-
registry: registryEnabled,
|
|
1618
|
-
ingressDomains,
|
|
1619
|
-
traefik: traefikEnabled,
|
|
1620
|
-
});
|
|
1621
|
-
await prePullK3sImages(name, images.pulls, exec);
|
|
1622
|
+
await setupK3sCluster(name, helpers, {
|
|
1623
|
+
registry: registryEnabled,
|
|
1624
|
+
ingressDomains,
|
|
1625
|
+
traefik: traefikEnabled,
|
|
1626
|
+
});
|
|
1627
|
+
})(),
|
|
1628
|
+
]);
|
|
1622
1629
|
if (sticky)
|
|
1623
1630
|
await registerStickyKubernetesStore(name, version);
|
|
1624
1631
|
},
|
package/dist/daemon.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
2
|
+
import { waitForAll } from "./harness/concurrency.js";
|
|
2
3
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
|
|
3
4
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
4
5
|
// a container). Owns:
|
|
@@ -3103,26 +3104,29 @@ async function bootstrapInner(prepared, identity) {
|
|
|
3103
3104
|
await waitForReady(svc);
|
|
3104
3105
|
const tSetup = Date.now();
|
|
3105
3106
|
const builds = componentTasks.filter(task => task.owner === svc.name);
|
|
3106
|
-
|
|
3107
|
-
if
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3107
|
+
// Setup can overlap pulls/infrastructure with builds, and consume each
|
|
3108
|
+
// image as it becomes ready. Still drain every build if setup fails.
|
|
3109
|
+
await waitForAll([...builds.map(task => prep.get(task.name)), (async () => {
|
|
3110
|
+
if (svc.setup) {
|
|
3111
|
+
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3112
|
+
const helpers = await ensureHelpers(svc.name, svc);
|
|
3113
|
+
try {
|
|
3114
|
+
await svc.setup({
|
|
3115
|
+
name: svc.name,
|
|
3116
|
+
helpers,
|
|
3117
|
+
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3118
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3119
|
+
images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name) })),
|
|
3120
|
+
gateway: await bridgeGatewayIp(),
|
|
3121
|
+
caBundle: await ensureCaBundle(),
|
|
3122
|
+
} },
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
catch (err) {
|
|
3126
|
+
throw await withContainerPostMortem(svc.name, err);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
})()]);
|
|
3126
3130
|
for (const task of builds)
|
|
3127
3131
|
progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
|
|
3128
3132
|
progressService(svc.name, { status: "ready", detail: undefined });
|
|
@@ -20,11 +20,14 @@ export declare function componentBuildTasks<T extends ServiceConfig & {
|
|
|
20
20
|
/** Private handoff to a component's setup hook. Builds stay generic; the
|
|
21
21
|
* component decides how its runtime consumes them and establishes trust. */
|
|
22
22
|
export declare const COMPONENT_RUNTIME: unique symbol;
|
|
23
|
+
export interface PreparedComponentImage {
|
|
24
|
+
source: string;
|
|
25
|
+
reference: string;
|
|
26
|
+
/** A component can load each result as soon as its own build finishes. */
|
|
27
|
+
ready?: Promise<void>;
|
|
28
|
+
}
|
|
23
29
|
export interface ComponentRuntime {
|
|
24
|
-
images: readonly
|
|
25
|
-
source: string;
|
|
26
|
-
reference: string;
|
|
27
|
-
}[];
|
|
30
|
+
images: readonly PreparedComponentImage[];
|
|
28
31
|
gateway: string;
|
|
29
32
|
caBundle: string | null;
|
|
30
33
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Wait for every writer to stop before reporting a failure to setup/cleanup. */
|
|
2
|
+
export async function waitForAll(work) {
|
|
3
|
+
const results = await Promise.allSettled(work);
|
|
4
|
+
const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
|
5
|
+
if (failures.length === 1)
|
|
6
|
+
throw failures[0];
|
|
7
|
+
if (failures.length > 1)
|
|
8
|
+
throw new AggregateError(failures, `Concurrent environment preparation failed: ${failures.map(error => error instanceof Error ? error.message : String(error)).join("; ")}`);
|
|
9
|
+
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
1
|
import type { SpectestContext } from "../index.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
reference: string;
|
|
5
|
-
}[], gateway: string, exec: SpectestContext["exec"]): Promise<void>;
|
|
2
|
+
import { type PreparedComponentImage } from "./component-images.js";
|
|
3
|
+
export declare function loadK3sBuildImages(service: string, images: readonly PreparedComponentImage[], gateway: string, exec: SpectestContext["exec"]): Promise<void>;
|
|
@@ -7,6 +7,7 @@ import { createServer } from "node:http";
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { canonicalImageReference } from "./component-images.js";
|
|
9
9
|
import { waitForK3sRuntime } from "./k3s-prepull.js";
|
|
10
|
+
import { waitForAll } from "./concurrency.js";
|
|
10
11
|
const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
11
12
|
const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
12
13
|
const run = promisify(execFile);
|
|
@@ -76,7 +77,8 @@ export async function loadK3sBuildImages(service, images, gateway, exec) {
|
|
|
76
77
|
const ctr = ["ctr", "--address", "/run/k3s/containerd/containerd.sock", "--namespace", "k8s.io"];
|
|
77
78
|
const help = await checked([...ctr, "images", "pull", "--help"]);
|
|
78
79
|
const local = help.includes("--local") ? ["--local"] : [];
|
|
79
|
-
|
|
80
|
+
await waitForAll(images.map(async (image) => {
|
|
81
|
+
await image.ready;
|
|
80
82
|
const reference = canonicalImageReference(image.reference);
|
|
81
83
|
const { target, metadata: blobs, allowed, configDigest } = await hostImage(image.source);
|
|
82
84
|
const streams = new Set();
|
|
@@ -146,5 +148,5 @@ export async function loadK3sBuildImages(service, images, gateway, exec) {
|
|
|
146
148
|
await new Promise(resolve => server.close(() => resolve()));
|
|
147
149
|
await exec(service, ["rm", "-rf", directory], { timeoutMs: 5_000 }).catch(() => undefined);
|
|
148
150
|
}
|
|
149
|
-
}
|
|
151
|
+
}));
|
|
150
152
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SpectestContext } from "../index.js";
|
|
2
2
|
/** CRI PullImage waits for unpacking and uses k3s's registry configuration.
|
|
3
3
|
* Always resolve tags on a fresh setup. Immutable digests can be used offline.
|
|
4
|
-
*
|
|
4
|
+
* Submit every image concurrently; containerd manages downloads and unpacking.
|
|
5
|
+
* Drain all pulls on failure so none outlive the setup hook.
|
|
5
6
|
*/
|
|
6
7
|
export declare function prePullK3sImages(name: string, images: readonly string[], exec: SpectestContext["exec"]): Promise<void>;
|
|
7
8
|
export declare function waitForK3sRuntime(name: string, exec: SpectestContext["exec"]): Promise<void>;
|
|
@@ -1,28 +1,24 @@
|
|
|
1
|
+
import { waitForAll } from "./concurrency.js";
|
|
1
2
|
/** CRI PullImage waits for unpacking and uses k3s's registry configuration.
|
|
2
3
|
* Always resolve tags on a fresh setup. Immutable digests can be used offline.
|
|
3
|
-
*
|
|
4
|
+
* Submit every image concurrently; containerd manages downloads and unpacking.
|
|
5
|
+
* Drain all pulls on failure so none outlive the setup hook.
|
|
4
6
|
*/
|
|
5
7
|
export async function prePullK3sImages(name, images, exec) {
|
|
6
8
|
if (!images.length)
|
|
7
9
|
return;
|
|
8
10
|
await waitForK3sRuntime(name, exec);
|
|
9
11
|
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
if (existing.exitCode === 0)
|
|
16
|
-
continue;
|
|
17
|
-
}
|
|
18
|
-
const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
|
|
19
|
-
if (result.exitCode !== 0)
|
|
20
|
-
throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
|
|
12
|
+
await waitForAll(images.map(async (image) => {
|
|
13
|
+
if (/@sha256:[a-f0-9]{64}$/.test(image)) {
|
|
14
|
+
const existing = await exec(name, [...cri, "inspecti", image], { timeoutMs: 30_000 });
|
|
15
|
+
if (existing.exitCode === 0)
|
|
16
|
+
return;
|
|
21
17
|
}
|
|
18
|
+
const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
|
|
19
|
+
if (result.exitCode !== 0)
|
|
20
|
+
throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
|
|
22
21
|
}));
|
|
23
|
-
const failed = results.find(result => result.status === "rejected");
|
|
24
|
-
if (failed?.status === "rejected")
|
|
25
|
-
throw failed.reason;
|
|
26
22
|
}
|
|
27
23
|
export async function waitForK3sRuntime(name, exec) {
|
|
28
24
|
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
package/package.json
CHANGED
package/src/android-step.test.ts
CHANGED
|
@@ -41,7 +41,7 @@ const cases: [Omit<AndroidStepDetails, "device">, string][] = [
|
|
|
41
41
|
target: { kind: "accessibility", value: "Save" },
|
|
42
42
|
state: "hidden",
|
|
43
43
|
},
|
|
44
|
-
'wait for
|
|
44
|
+
'wait for label "Save" to be hidden',
|
|
45
45
|
],
|
|
46
46
|
[
|
|
47
47
|
{
|
|
@@ -86,7 +86,7 @@ const cases: [Omit<AndroidStepDetails, "device">, string][] = [
|
|
|
86
86
|
for (const [action, title] of cases) {
|
|
87
87
|
test(title, () =>
|
|
88
88
|
expect(androidStepTitle({ device: "phone", ...action })).toBe(
|
|
89
|
-
|
|
89
|
+
title,
|
|
90
90
|
),
|
|
91
91
|
);
|
|
92
92
|
}
|
|
@@ -100,7 +100,7 @@ test("custom selector expressions remain opaque structured data", () => {
|
|
|
100
100
|
value: expression,
|
|
101
101
|
});
|
|
102
102
|
expect(androidStepTitle({ device: "phone", action: "tap", target })).toBe(
|
|
103
|
-
"
|
|
103
|
+
"tap custom selector",
|
|
104
104
|
);
|
|
105
105
|
for (const [using, kind] of [
|
|
106
106
|
["id", "id"],
|
package/src/android-step.ts
CHANGED
|
@@ -43,7 +43,7 @@ export function androidTargetLabel(target: AndroidTarget): string {
|
|
|
43
43
|
case "id":
|
|
44
44
|
return `id ${quoted}`;
|
|
45
45
|
case "accessibility":
|
|
46
|
-
return `
|
|
46
|
+
return `label ${quoted}`;
|
|
47
47
|
case "xpath":
|
|
48
48
|
return `XPath ${quoted}`;
|
|
49
49
|
case "custom":
|
|
@@ -103,5 +103,5 @@ export function androidStepTitle(step: AndroidStepDetails): string {
|
|
|
103
103
|
label = `run command ${JSON.stringify(step.command)}`;
|
|
104
104
|
break;
|
|
105
105
|
}
|
|
106
|
-
return
|
|
106
|
+
return label;
|
|
107
107
|
}
|
package/src/android.test.ts
CHANGED
|
@@ -156,12 +156,12 @@ describe("Android case lifecycle", () => {
|
|
|
156
156
|
const steps = events.filter((e) => e.kind === "android");
|
|
157
157
|
expect(steps.length).toBe(7);
|
|
158
158
|
expect(steps[0]).toMatchObject({
|
|
159
|
-
title: String.raw`
|
|
159
|
+
title: String.raw`fill text "a \"quoted\" label"`,
|
|
160
160
|
android: { device: "phone", action: "fill", target: { kind: "text", value: 'a "quoted" label' } },
|
|
161
161
|
});
|
|
162
162
|
expect(steps[1]).toMatchObject({ android: { device: "phone", action: "text", target: { kind: "id", value: "com.example.app:id/draft" } } });
|
|
163
|
-
expect(steps[2]).toMatchObject({ title: "
|
|
164
|
-
expect(steps[3]).toMatchObject({ title: '
|
|
163
|
+
expect(steps[2]).toMatchObject({ title: "take screenshot", android: { device: "phone", action: "screenshot" } });
|
|
164
|
+
expect(steps[3]).toMatchObject({ title: 'tap text "Save"', android: { device: "phone", action: "tap", target: { kind: "text", value: "Save" } } });
|
|
165
165
|
expect(steps[4]).toMatchObject({ android: { device: "phone", action: "tap", point: { x: 270, y: 1800 } } });
|
|
166
166
|
expect(steps[5]).toMatchObject({ status: "failed", android: { device: "phone", action: "tap", target: { kind: "text", value: "Gone" } } });
|
|
167
167
|
expect(JSON.stringify(steps)).not.toContain("private draft");
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { k3s } from "./k3s";
|
|
3
|
+
|
|
4
|
+
function deferred() {
|
|
5
|
+
let resolve!: () => void;
|
|
6
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
7
|
+
return { promise, resolve };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test("registry, Traefik, and every declared pull start without waiting for one another", async () => {
|
|
11
|
+
const started = new Set<string>();
|
|
12
|
+
const allStarted = deferred(), release = deferred();
|
|
13
|
+
const enter = async (name: string) => {
|
|
14
|
+
started.add(name);
|
|
15
|
+
if (started.size === 5) allStarted.resolve();
|
|
16
|
+
await release.promise;
|
|
17
|
+
};
|
|
18
|
+
const cluster = k3s({ images: ["app:1", "app:2", "app:3"] });
|
|
19
|
+
let finished = false;
|
|
20
|
+
const work = cluster.setup({ name: "cluster", helpers: {
|
|
21
|
+
apply: async (manifest: string) => { await enter(manifest.includes("spectest-registry") ? "registry" : "traefik"); },
|
|
22
|
+
waitForRollout: async () => {},
|
|
23
|
+
}, exec: async (_name: string, args: string[]) => {
|
|
24
|
+
if (args[3] === "pull") await enter(args.at(-1)!);
|
|
25
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
26
|
+
} } as never).then(() => { finished = true; });
|
|
27
|
+
try {
|
|
28
|
+
await Promise.race([allStarted.promise, Bun.sleep(1000).then(() => { throw new Error(`serialized setup: ${[...started]}`); })]);
|
|
29
|
+
expect(finished).toBe(false);
|
|
30
|
+
expect(started.size).toBe(5);
|
|
31
|
+
} finally { release.resolve(); await work; }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a failed infrastructure rollout waits for other rollouts and image pulls to stop", async () => {
|
|
35
|
+
const started = deferred(), release = deferred();
|
|
36
|
+
let active = 0, finished = false;
|
|
37
|
+
const hold = async () => {
|
|
38
|
+
if (++active === 2) started.resolve();
|
|
39
|
+
await release.promise;
|
|
40
|
+
--active;
|
|
41
|
+
};
|
|
42
|
+
const failure = new Error("Traefik failed");
|
|
43
|
+
const cluster = k3s({ images: ["app:1"] });
|
|
44
|
+
const work = cluster.setup({ name: "cluster", helpers: {
|
|
45
|
+
apply: async () => {},
|
|
46
|
+
waitForRollout: async (deployment: string) => {
|
|
47
|
+
if (deployment === "traefik") throw failure;
|
|
48
|
+
await hold();
|
|
49
|
+
},
|
|
50
|
+
}, exec: async (_name: string, args: string[]) => {
|
|
51
|
+
if (args[3] === "pull") await hold();
|
|
52
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
53
|
+
} } as never).then(() => { finished = true; }, error => { finished = true; return error; });
|
|
54
|
+
try {
|
|
55
|
+
await started.promise;
|
|
56
|
+
expect(finished).toBe(false);
|
|
57
|
+
} finally { release.resolve(); }
|
|
58
|
+
expect(await work).toBe(failure);
|
|
59
|
+
expect(active).toBe(0);
|
|
60
|
+
});
|
package/src/components/k3s.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import { installPodTrust } from "./k3s-trust.js";
|
|
3
|
+
import { waitForAll } from "../harness/concurrency.js";
|
|
3
4
|
import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
|
|
4
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
6
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
@@ -1865,48 +1866,52 @@ async function setupK3sCluster(
|
|
|
1865
1866
|
helpers: K3sHelpers,
|
|
1866
1867
|
opts: { registry: boolean; ingressDomains: string[]; traefik: boolean },
|
|
1867
1868
|
): Promise<void> {
|
|
1868
|
-
|
|
1869
|
-
if (tlsEnabled) {
|
|
1870
|
-
const { cert, key } = await issueIngressCert(
|
|
1871
|
-
opts.ingressDomains.map((d) => `*.${d}`),
|
|
1872
|
-
);
|
|
1873
|
-
// Apply the cert Secret + dynamic-config ConfigMap before the
|
|
1874
|
-
// Deployment that mounts them. `stringData` lets us hand over plain
|
|
1875
|
-
// PEM; the API server base64-encodes it.
|
|
1876
|
-
await helpers.client.core.createNamespacedSecret({
|
|
1877
|
-
namespace: "kube-system",
|
|
1878
|
-
body: {
|
|
1879
|
-
metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
|
|
1880
|
-
type: "kubernetes.io/tls",
|
|
1881
|
-
stringData: { "tls.crt": cert, "tls.key": key },
|
|
1882
|
-
},
|
|
1883
|
-
});
|
|
1884
|
-
await helpers.client.core.createNamespacedConfigMap({
|
|
1885
|
-
namespace: "kube-system",
|
|
1886
|
-
body: {
|
|
1887
|
-
metadata: {
|
|
1888
|
-
name: TRAEFIK_DYNAMIC_CONFIGMAP,
|
|
1889
|
-
namespace: "kube-system",
|
|
1890
|
-
},
|
|
1891
|
-
data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
|
|
1892
|
-
},
|
|
1893
|
-
});
|
|
1894
|
-
}
|
|
1895
|
-
if (opts.traefik) await helpers.apply(buildTraefikManifest(tlsEnabled));
|
|
1896
|
-
if (opts.registry) await helpers.apply(REGISTRY_MANIFEST);
|
|
1897
|
-
// Both rollouts proceed independently inside the cluster — wait on them
|
|
1898
|
-
// concurrently (they used to serialize, wasting up to a rollout's tail).
|
|
1899
|
-
// 250ms polling: these two sit on the cold start's critical path, where
|
|
1900
|
-
// the default 500ms interval wastes real time.
|
|
1869
|
+
// The registry can start while Traefik's certificates are being prepared.
|
|
1901
1870
|
const wait = (deployment: string): Promise<void> =>
|
|
1902
1871
|
helpers.waitForRollout(deployment, {
|
|
1903
1872
|
namespace: "kube-system",
|
|
1904
1873
|
timeoutMs: 120_000,
|
|
1905
1874
|
intervalMs: 250,
|
|
1906
1875
|
});
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
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
|
+
]);
|
|
1910
1915
|
}
|
|
1911
1916
|
|
|
1912
1917
|
/**
|
|
@@ -2199,47 +2204,51 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2199
2204
|
const { name, helpers, exec } = ctx;
|
|
2200
2205
|
const runtime = componentRuntime(ctx);
|
|
2201
2206
|
if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
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));
|
|
2217
2243
|
}
|
|
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
2244
|
}
|
|
2233
|
-
await
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
traefik: traefikEnabled,
|
|
2241
|
-
});
|
|
2242
|
-
await prePullK3sImages(name, images.pulls, exec);
|
|
2245
|
+
await setupK3sCluster(name, helpers, {
|
|
2246
|
+
registry: registryEnabled,
|
|
2247
|
+
ingressDomains,
|
|
2248
|
+
traefik: traefikEnabled,
|
|
2249
|
+
});
|
|
2250
|
+
})(),
|
|
2251
|
+
]);
|
|
2243
2252
|
if (sticky) await registerStickyKubernetesStore(name, version);
|
|
2244
2253
|
},
|
|
2245
2254
|
helpers: async ({ name, exec, poll }: ServiceHelpersContext): Promise<K3sHelpers> => {
|
package/src/daemon.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
2
|
+
import { waitForAll } from "./harness/concurrency.js";
|
|
2
3
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
3
4
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
4
5
|
// a container). Owns:
|
|
@@ -3687,25 +3688,28 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3687
3688
|
await waitForReady(svc);
|
|
3688
3689
|
const tSetup = Date.now();
|
|
3689
3690
|
const builds = componentTasks.filter(task => task.owner === svc.name);
|
|
3690
|
-
|
|
3691
|
-
if
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
await svc.
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
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
|
+
}
|
|
3707
3711
|
}
|
|
3708
|
-
}
|
|
3712
|
+
})()]);
|
|
3709
3713
|
for (const task of builds) progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
|
|
3710
3714
|
progressService(svc.name, { status: "ready", detail: undefined });
|
|
3711
3715
|
const ti = timings.get(svc.name);
|
|
@@ -63,8 +63,14 @@ export function componentBuildTasks<T extends ServiceConfig & { name: string }>(
|
|
|
63
63
|
/** Private handoff to a component's setup hook. Builds stay generic; the
|
|
64
64
|
* component decides how its runtime consumes them and establishes trust. */
|
|
65
65
|
export const COMPONENT_RUNTIME = Symbol.for("spectest.component-runtime");
|
|
66
|
+
export interface PreparedComponentImage {
|
|
67
|
+
source: string;
|
|
68
|
+
reference: string;
|
|
69
|
+
/** A component can load each result as soon as its own build finishes. */
|
|
70
|
+
ready?: Promise<void>;
|
|
71
|
+
}
|
|
66
72
|
export interface ComponentRuntime {
|
|
67
|
-
images: readonly
|
|
73
|
+
images: readonly PreparedComponentImage[];
|
|
68
74
|
gateway: string;
|
|
69
75
|
caBundle: string | null;
|
|
70
76
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Wait for every writer to stop before reporting a failure to setup/cleanup. */
|
|
2
|
+
export async function waitForAll(work: readonly Promise<unknown>[]): Promise<void> {
|
|
3
|
+
const results = await Promise.allSettled(work);
|
|
4
|
+
const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
|
5
|
+
if (failures.length === 1) throw failures[0];
|
|
6
|
+
if (failures.length > 1) throw new AggregateError(failures,
|
|
7
|
+
`Concurrent environment preparation failed: ${failures.map(error => error instanceof Error ? error.message : String(error)).join("; ")}`);
|
|
8
|
+
}
|
|
@@ -6,8 +6,9 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import type { SpectestContext } from "../index.js";
|
|
9
|
-
import { canonicalImageReference } from "./component-images.js";
|
|
9
|
+
import { canonicalImageReference, type PreparedComponentImage } from "./component-images.js";
|
|
10
10
|
import { waitForK3sRuntime } from "./k3s-prepull.js";
|
|
11
|
+
import { waitForAll } from "./concurrency.js";
|
|
11
12
|
|
|
12
13
|
interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
|
|
13
14
|
interface Manifest { schemaVersion: number; mediaType: string; config: Descriptor; layers: Descriptor[]; annotations?: Record<string, string> }
|
|
@@ -57,7 +58,7 @@ async function hostImage(source: string) {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export async function loadK3sBuildImages(
|
|
60
|
-
service: string, images: readonly
|
|
61
|
+
service: string, images: readonly PreparedComponentImage[],
|
|
61
62
|
gateway: string, exec: SpectestContext["exec"],
|
|
62
63
|
): Promise<void> {
|
|
63
64
|
if (!images.length) return;
|
|
@@ -73,7 +74,8 @@ export async function loadK3sBuildImages(
|
|
|
73
74
|
const ctr = ["ctr", "--address", "/run/k3s/containerd/containerd.sock", "--namespace", "k8s.io"];
|
|
74
75
|
const help = await checked([...ctr, "images", "pull", "--help"]);
|
|
75
76
|
const local = help.includes("--local") ? ["--local"] : [];
|
|
76
|
-
|
|
77
|
+
await waitForAll(images.map(async image => {
|
|
78
|
+
await image.ready;
|
|
77
79
|
const reference = canonicalImageReference(image.reference);
|
|
78
80
|
const { target, metadata: blobs, allowed, configDigest } = await hostImage(image.source);
|
|
79
81
|
const streams = new Set<ReturnType<typeof spawn>>();
|
|
@@ -125,5 +127,5 @@ export async function loadK3sBuildImages(
|
|
|
125
127
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
126
128
|
await exec(service, ["rm", "-rf", directory], { timeoutMs: 5_000 }).catch(() => undefined);
|
|
127
129
|
}
|
|
128
|
-
}
|
|
130
|
+
}));
|
|
129
131
|
}
|
|
@@ -20,7 +20,7 @@ test("pre-pulls reuse immutable cached images, but resolve tags on every fresh s
|
|
|
20
20
|
expect(calls.at(-1)).toEqual([...cri, "pull", digest]);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test("failed pre-pulls fail setup after
|
|
23
|
+
test("failed pre-pulls fail setup after all concurrent pulls finish", async () => {
|
|
24
24
|
let active = 0, peak = 0, finished = 0;
|
|
25
25
|
const work = prePullK3sImages("cluster", ["a:1", "b:1", "c:1"], mockExec(async args => {
|
|
26
26
|
if (args.at(-1) === "info") return { exitCode: 0 };
|
|
@@ -31,7 +31,7 @@ test("failed pre-pulls fail setup after outstanding pulls finish, with bounded c
|
|
|
31
31
|
}));
|
|
32
32
|
await expect(work).rejects.toThrow("pre-pull failed for a:1: registry unavailable");
|
|
33
33
|
expect(active).toBe(0);
|
|
34
|
-
expect(peak).toBe(
|
|
34
|
+
expect(peak).toBe(3);
|
|
35
35
|
expect(finished).toBe(3);
|
|
36
36
|
});
|
|
37
37
|
|
|
@@ -45,3 +45,13 @@ test("pre-pulls wait for the runtime even when the API server is already ready",
|
|
|
45
45
|
}));
|
|
46
46
|
expect(calls).toEqual(["info", "info", "pull"]);
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
test("simultaneous pull failures retain every failing image in the setup error", async () => {
|
|
50
|
+
const error = await prePullK3sImages("cluster", ["a:1", "b:1"], mockExec(async args => ({
|
|
51
|
+
exitCode: args[3] === "pull" ? 1 : 0, stderr: "unavailable",
|
|
52
|
+
}))).catch(error => error);
|
|
53
|
+
expect(error).toBeInstanceOf(AggregateError);
|
|
54
|
+
expect(error.errors).toHaveLength(2);
|
|
55
|
+
expect(error.message).toContain("pre-pull failed for a:1");
|
|
56
|
+
expect(error.message).toContain("pre-pull failed for b:1");
|
|
57
|
+
});
|
|
@@ -1,26 +1,23 @@
|
|
|
1
1
|
import type { SpectestContext } from "../index.js";
|
|
2
|
+
import { waitForAll } from "./concurrency.js";
|
|
2
3
|
|
|
3
4
|
/** CRI PullImage waits for unpacking and uses k3s's registry configuration.
|
|
4
5
|
* Always resolve tags on a fresh setup. Immutable digests can be used offline.
|
|
5
|
-
*
|
|
6
|
+
* Submit every image concurrently; containerd manages downloads and unpacking.
|
|
7
|
+
* Drain all pulls on failure so none outlive the setup hook.
|
|
6
8
|
*/
|
|
7
9
|
export async function prePullK3sImages(name: string, images: readonly string[], exec: SpectestContext["exec"]): Promise<void> {
|
|
8
10
|
if (!images.length) return;
|
|
9
11
|
await waitForK3sRuntime(name, exec);
|
|
10
12
|
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
const existing = await exec(name, [...cri, "inspecti", image], { timeoutMs: 30_000 });
|
|
16
|
-
if (existing.exitCode === 0) continue;
|
|
17
|
-
}
|
|
18
|
-
const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
|
|
19
|
-
if (result.exitCode !== 0) throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
|
|
13
|
+
await waitForAll(images.map(async image => {
|
|
14
|
+
if (/@sha256:[a-f0-9]{64}$/.test(image)) {
|
|
15
|
+
const existing = await exec(name, [...cri, "inspecti", image], { timeoutMs: 30_000 });
|
|
16
|
+
if (existing.exitCode === 0) return;
|
|
20
17
|
}
|
|
18
|
+
const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
|
|
19
|
+
if (result.exitCode !== 0) throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
|
|
21
20
|
}));
|
|
22
|
-
const failed = results.find(result => result.status === "rejected");
|
|
23
|
-
if (failed?.status === "rejected") throw failed.reason;
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
export async function waitForK3sRuntime(name: string, exec: SpectestContext["exec"]): Promise<void> {
|