@specific.dev/spectest 0.90.2 → 0.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-coverage.d.ts +5 -5
- package/dist/browser-coverage.js +11 -14
- package/dist/components/expo.d.ts +2 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/k3s-trust.d.ts +57 -0
- package/dist/components/k3s-trust.js +125 -0
- package/dist/components/k3s.d.ts +17 -5
- package/dist/components/k3s.js +77 -11
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +3 -3
- package/dist/daemon.js +53 -14
- package/dist/harness/component-images.d.ts +31 -0
- package/dist/harness/component-images.js +69 -0
- package/dist/harness/k3s-image-loader.d.ts +5 -0
- package/dist/harness/k3s-image-loader.js +150 -0
- package/dist/harness/k3s-prepull.d.ts +7 -0
- package/dist/harness/k3s-prepull.js +39 -0
- package/dist/harness/sticky-consumer.d.ts +3 -3
- package/dist/harness/sticky-consumer.js +19 -4
- package/dist/harness/sticky-kubernetes.d.ts +15 -0
- package/dist/harness/sticky-kubernetes.js +163 -0
- package/dist/harness/sticky-paths.d.ts +5 -0
- package/dist/harness/sticky-paths.js +18 -0
- package/dist/index.d.ts +32 -2
- package/dist/index.js +25 -2
- package/package.json +1 -1
- package/src/browser-coverage.ts +11 -13
- package/src/components/index.ts +1 -0
- package/src/components/k3s-trust.test.ts +81 -0
- package/src/components/k3s-trust.ts +135 -0
- package/src/components/k3s.ts +81 -10
- package/src/coverage.test.ts +50 -0
- package/src/coverage.ts +3 -3
- package/src/daemon.ts +56 -15
- package/src/harness/component-images.test.ts +44 -0
- package/src/harness/component-images.ts +73 -0
- package/src/harness/k3s-image-loader.ts +129 -0
- package/src/harness/k3s-prepull.test.ts +47 -0
- package/src/harness/k3s-prepull.ts +36 -0
- package/src/harness/sticky-consumer.test.ts +28 -0
- package/src/harness/sticky-consumer.ts +14 -3
- package/src/harness/sticky-kubernetes.test.ts +88 -0
- package/src/harness/sticky-kubernetes.ts +165 -0
- package/src/harness/sticky-paths.ts +18 -0
- package/src/index.ts +48 -3
package/src/components/k3s.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { installPodTrust } from "./k3s-trust.js";
|
|
3
|
+
import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
|
|
1
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
5
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
3
6
|
import { randomUUID } from "node:crypto";
|
|
@@ -31,6 +34,7 @@ import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
|
|
|
31
34
|
import type {
|
|
32
35
|
ServiceDefinition,
|
|
33
36
|
ServiceHelpersContext,
|
|
37
|
+
ServiceSetupContext,
|
|
34
38
|
SpectestContext,
|
|
35
39
|
} from "../index.js";
|
|
36
40
|
import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
|
|
@@ -41,6 +45,11 @@ import {
|
|
|
41
45
|
nestedStoreDir,
|
|
42
46
|
type ImageCachePaths,
|
|
43
47
|
} from "../harness/image-cache.js";
|
|
48
|
+
import { stickyDiskPathSync } from "../harness/sticky-paths.js";
|
|
49
|
+
import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
|
|
50
|
+
import { prePullK3sImages } from "../harness/k3s-prepull.js";
|
|
51
|
+
import { componentRuntime, kubernetesImages, type KubernetesImage } from "../harness/component-images.js";
|
|
52
|
+
export type { KubernetesImage } from "../harness/component-images.js";
|
|
44
53
|
import { deepUnwrap, readTag, wrap } from "../inspect.js";
|
|
45
54
|
import type { Wrapped } from "../inspect.js";
|
|
46
55
|
import { recorderAnnotate, recorderRemove } from "../recorder.js";
|
|
@@ -50,7 +59,19 @@ export type {
|
|
|
50
59
|
KubernetesListObject,
|
|
51
60
|
} from "@kubernetes/client-node";
|
|
52
61
|
|
|
62
|
+
/** Kubernetes pods receive read-only environment CA mounts during admission.
|
|
63
|
+
* Image bytes and digests stay unchanged; explicit pod trust settings win. */
|
|
53
64
|
export interface K3sOptions {
|
|
65
|
+
/** Images available to kubelet before project setup. Use registry strings,
|
|
66
|
+
* or Dockerfile builds with a full `reference` matching your manifests. Builds
|
|
67
|
+
* support path/content, context, exclude, target and buildArgs, and run in
|
|
68
|
+
* parallel with service builds through the standard BuildKit pipeline.
|
|
69
|
+
* Setup waits for every pull and fails if a requested image cannot be pulled.
|
|
70
|
+
* Sticky-enabled projects retain image content and unpacked layers between
|
|
71
|
+
* fresh builds automatically. Digest references can reuse the cache offline;
|
|
72
|
+
* tags are resolved again on each fresh setup. Pod imagePullPolicy still applies.
|
|
73
|
+
*/
|
|
74
|
+
images?: readonly KubernetesImage[];
|
|
54
75
|
/**
|
|
55
76
|
* Image tag for the official `rancher/k3s` image. Default
|
|
56
77
|
* `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
|
|
@@ -178,8 +199,13 @@ const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
|
|
|
178
199
|
const BOOT_MARKER = ".spectest-boot-incomplete";
|
|
179
200
|
|
|
180
201
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
202
|
+
* Sticky-enabled projects bind a per-service/version directory on the
|
|
203
|
+
* sticky disk at CLUSTER_STORE_TARGET and use overlayfs. Publication's
|
|
204
|
+
* sticky-kubernetes adapter removes runtime state and retains images and
|
|
205
|
+
* unpacked layers under the shared filesystem budget. Test forks do not
|
|
206
|
+
* write back. The preparation wrapper below also handles delta rebuilds.
|
|
207
|
+
*
|
|
208
|
+
* Legacy image-cache integration (`CONTAINER_STORE.md` §12):
|
|
183
209
|
*
|
|
184
210
|
* The cluster's embedded containerd keeps its root on the **root disk**
|
|
185
211
|
* of the environment's image cache, in a directory of its own
|
|
@@ -346,8 +372,8 @@ function storePrepareSh(cache: ImageCachePaths | null): string {
|
|
|
346
372
|
`timeout 60 ctr -a "$A" -n k8s.io containers rm $ids >/dev/null 2>&1 || true; fi; ` +
|
|
347
373
|
// containerd 2.x keeps sandboxes in a store of their own; on the 1.7
|
|
348
374
|
// k3s ships they are ordinary containers and this is a no-op.
|
|
349
|
-
`sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls
|
|
350
|
-
`[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm $sb >/dev/null 2>&1; return 0; }; ` +
|
|
375
|
+
`sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
|
|
376
|
+
`[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
|
|
351
377
|
adopt +
|
|
352
378
|
`if [ -e "$M" ]; then ` +
|
|
353
379
|
`echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
|
|
@@ -724,7 +750,7 @@ async function issueIngressCert(
|
|
|
724
750
|
const id = `spectest-k3s-ingress-${randomUUID().slice(0, 8)}`;
|
|
725
751
|
const keyPath = `/tmp/${id}.key`;
|
|
726
752
|
const crtPath = `/tmp/${id}.crt`;
|
|
727
|
-
const sans = hostnames.map((h) =>
|
|
753
|
+
const sans = hostnames.map((h) => `${isIP(h) ? "IP" : "DNS"}:${h}`).join(",");
|
|
728
754
|
const r = await runProcess(
|
|
729
755
|
"openssl",
|
|
730
756
|
[
|
|
@@ -2007,6 +2033,7 @@ const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
|
|
|
2007
2033
|
|
|
2008
2034
|
export function k3s(opts: K3sOptions = {}) {
|
|
2009
2035
|
const version = opts.version ?? DEFAULT_K3S_VERSION;
|
|
2036
|
+
const images = kubernetesImages(opts.images ?? []);
|
|
2010
2037
|
const extra = opts.extraArgs ?? [];
|
|
2011
2038
|
assertUsableExtraArgs(extra);
|
|
2012
2039
|
const registryEnabled = opts.registry !== false;
|
|
@@ -2023,7 +2050,8 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2023
2050
|
// The environment's image cache, when this VM carries one: the
|
|
2024
2051
|
// cluster's store goes on its root disk and its layers come off the
|
|
2025
2052
|
// shared layers disk (see the store section above).
|
|
2026
|
-
const
|
|
2053
|
+
const sticky = stickyDiskPathSync();
|
|
2054
|
+
const cache = sticky ? null : imageCachePathsSync();
|
|
2027
2055
|
const serverArgs = [
|
|
2028
2056
|
"k3s",
|
|
2029
2057
|
"server",
|
|
@@ -2122,6 +2150,7 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2122
2150
|
const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
|
|
2123
2151
|
const def = {
|
|
2124
2152
|
image: { type: "registry", reference: `rancher/k3s:${version}` },
|
|
2153
|
+
imageBuilds: images.builds,
|
|
2125
2154
|
command: cmd,
|
|
2126
2155
|
privileged: true,
|
|
2127
2156
|
tmpfs: ["/run", "/var/run"],
|
|
@@ -2135,15 +2164,17 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2135
2164
|
// the same netns and are reachable the same way, without appearing
|
|
2136
2165
|
// in this list (it's documentation, not a firewall).
|
|
2137
2166
|
ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
|
|
2138
|
-
//
|
|
2167
|
+
// Sticky disks retain an independent overlayfs store per service/version.
|
|
2168
|
+
// The legacy image cache (see the store section above) keeps its
|
|
2139
2169
|
// containerd root on the image cache's root disk, the shared layers
|
|
2140
2170
|
// disk read-only at its guest path, golden's mkfs.erofs and the adopt
|
|
2141
2171
|
// helper — or, with no cache, a per-environment volume. Two clusters
|
|
2142
2172
|
// in one environment get separate roots either way (the service
|
|
2143
2173
|
// token names the directory), because two containerd daemons cannot
|
|
2144
2174
|
// share a store: bolt takes an exclusive lock.
|
|
2145
|
-
volumes:
|
|
2146
|
-
? [
|
|
2175
|
+
volumes: sticky
|
|
2176
|
+
? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
|
|
2177
|
+
: cache ? [
|
|
2147
2178
|
{ source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
|
|
2148
2179
|
{ source: cache.layers, target: cache.layers, readOnly: true },
|
|
2149
2180
|
{ source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
|
|
@@ -2164,12 +2195,52 @@ export function k3s(opts: K3sOptions = {}) {
|
|
|
2164
2195
|
command: readyCmd,
|
|
2165
2196
|
timeoutSecs: opts.readyTimeoutSecs ?? 120,
|
|
2166
2197
|
},
|
|
2167
|
-
setup: async (
|
|
2198
|
+
setup: async (ctx: ServiceSetupContext<K3sHelpers>) => {
|
|
2199
|
+
const { name, helpers, exec } = ctx;
|
|
2200
|
+
const runtime = componentRuntime(ctx);
|
|
2201
|
+
if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
|
|
2202
|
+
if (runtime?.caBundle && caPresent()) {
|
|
2203
|
+
const { cert, key } = await issueIngressCert([runtime.gateway]);
|
|
2204
|
+
await installPodTrust({ name, gateway: runtime.gateway, cert, key,
|
|
2205
|
+
ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
|
|
2206
|
+
}, {
|
|
2207
|
+
apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
|
|
2208
|
+
ensureBundle: async (namespace, configMap, bundle) => {
|
|
2209
|
+
try {
|
|
2210
|
+
await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
|
|
2211
|
+
metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
|
|
2212
|
+
} });
|
|
2213
|
+
} catch (err) {
|
|
2214
|
+
// A simultaneous Pod may have created the same namespace's bundle.
|
|
2215
|
+
const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
|
|
2216
|
+
if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true) throw err;
|
|
2217
|
+
}
|
|
2218
|
+
},
|
|
2219
|
+
});
|
|
2220
|
+
// The API server observes webhook registration asynchronously. A dry-run
|
|
2221
|
+
// Pod proves TLS reachability and admission before any project workload.
|
|
2222
|
+
const deadline = Date.now() + 30_000;
|
|
2223
|
+
for (;;) {
|
|
2224
|
+
try {
|
|
2225
|
+
const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
|
|
2226
|
+
metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
|
|
2227
|
+
} })).unwrap();
|
|
2228
|
+
if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
|
|
2229
|
+
if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
|
|
2230
|
+
} catch (err) {
|
|
2231
|
+
if (Date.now() >= deadline) throw err;
|
|
2232
|
+
}
|
|
2233
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
if (runtime) await loadK3sBuildImages(name, runtime.images, runtime.gateway, exec);
|
|
2168
2237
|
await setupK3sCluster(name, helpers, {
|
|
2169
2238
|
registry: registryEnabled,
|
|
2170
2239
|
ingressDomains,
|
|
2171
2240
|
traefik: traefikEnabled,
|
|
2172
2241
|
});
|
|
2242
|
+
await prePullK3sImages(name, images.pulls, exec);
|
|
2243
|
+
if (sticky) await registerStickyKubernetesStore(name, version);
|
|
2173
2244
|
},
|
|
2174
2245
|
helpers: async ({ name, exec, poll }: ServiceHelpersContext): Promise<K3sHelpers> => {
|
|
2175
2246
|
// Read the cluster's kubeconfig and address the API server by its
|
package/src/coverage.test.ts
CHANGED
|
@@ -135,6 +135,56 @@ describe("units", () => {
|
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
describe("browserCoverage", () => {
|
|
138
|
+
for (const order of ["dashboard first", "admin first", "concurrent"] as const) {
|
|
139
|
+
test(`services retain their own coverage and deltas: ${order}`, async () => {
|
|
140
|
+
const reports = new Map<string, string>();
|
|
141
|
+
const contexts = ["dashboard", "admin"].map((service): CoverageCaptureContext => ({
|
|
142
|
+
...fakeCtx("unused"),
|
|
143
|
+
service,
|
|
144
|
+
async writeReport(name, content) {
|
|
145
|
+
expect(name).toBe("browser.lcov");
|
|
146
|
+
reports.set(service, content);
|
|
147
|
+
},
|
|
148
|
+
}));
|
|
149
|
+
if (order === "admin first") contexts.reverse();
|
|
150
|
+
const capture = async () => {
|
|
151
|
+
if (order === "concurrent") {
|
|
152
|
+
await Promise.all(contexts.map((ctx) => browser().capture!(ctx)));
|
|
153
|
+
} else {
|
|
154
|
+
for (const ctx of contexts) await browser().capture!(ctx);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
foldBrowserCoverage("dashboard", new Map([["src/dashboard.ts", new Map([[1, 2]])]]));
|
|
158
|
+
foldBrowserCoverage("admin", new Map([["src/admin.ts", new Map([[3, 4]])]]));
|
|
159
|
+
await capture();
|
|
160
|
+
expect(reports.get("dashboard")).toBe("TN:\nSF:src/dashboard.ts\nDA:1,2\nLF:1\nLH:1\nend_of_record\n");
|
|
161
|
+
expect(reports.get("admin")).toBe("TN:\nSF:src/admin.ts\nDA:3,4\nLF:1\nLH:1\nend_of_record\n");
|
|
162
|
+
|
|
163
|
+
await capture();
|
|
164
|
+
expect(reports.get("dashboard")).toBe("TN:\n");
|
|
165
|
+
expect(reports.get("admin")).toBe("TN:\n");
|
|
166
|
+
|
|
167
|
+
foldBrowserCoverage("admin", new Map([["src/admin.ts", new Map([[3, 1]])]]));
|
|
168
|
+
await capture();
|
|
169
|
+
expect(reports.get("dashboard")).toBe("TN:\n");
|
|
170
|
+
expect(reports.get("admin")).toBe("TN:\nSF:src/admin.ts\nDA:3,1\nLF:1\nLH:1\nend_of_record\n");
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
test("capturing a service with no coverage preserves other services' totals", async () => {
|
|
175
|
+
const reports = new Map<string, string>();
|
|
176
|
+
foldBrowserCoverage("dashboard", new Map([["src/dashboard.ts", new Map([[1, 2]])]]));
|
|
177
|
+
for (const service of ["admin", "dashboard"]) {
|
|
178
|
+
await browser().capture!({
|
|
179
|
+
...fakeCtx("unused"),
|
|
180
|
+
service,
|
|
181
|
+
async writeReport(_name, content) { reports.set(service, content); },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
expect(reports.get("admin")).toBe("TN:\n");
|
|
185
|
+
expect(reports.get("dashboard")).toBe("TN:\nSF:src/dashboard.ts\nDA:1,2\nLF:1\nLH:1\nend_of_record\n");
|
|
186
|
+
});
|
|
187
|
+
|
|
138
188
|
test("writes an empty lcov when nothing ran for the service", async () => {
|
|
139
189
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "spectest-cov-"));
|
|
140
190
|
await browser().capture!(fakeCtx(dir));
|
package/src/coverage.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
import { createHash } from "node:crypto";
|
|
31
31
|
|
|
32
32
|
import type { ServiceConfig } from "./index.js";
|
|
33
|
-
import { harvestAllBrowserCoverage,
|
|
33
|
+
import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-coverage.js";
|
|
34
34
|
|
|
35
35
|
import { COVERAGE_CONTAINER_DIR, type CoverageReportsMode } from "./harness/coverage.js";
|
|
36
36
|
export { COVERAGE_CONTAINER_DIR, type CoverageReportsMode };
|
|
@@ -969,7 +969,7 @@ export async function collectNodeReports(dir: string): Promise<NodeReportStats>
|
|
|
969
969
|
* loads from this service's origins, maps it back to source through the
|
|
970
970
|
* served source map, and writes one lcov per capture (`browser.lcov`)
|
|
971
971
|
* holding what ran since the previous capture: every live session is
|
|
972
|
-
* harvested first, and
|
|
972
|
+
* harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
|
|
973
973
|
* an lcov with no records, which is a report saying so.
|
|
974
974
|
*/
|
|
975
975
|
export function browser(): CoverageAdapter {
|
|
@@ -981,7 +981,7 @@ export function browser(): CoverageAdapter {
|
|
|
981
981
|
},
|
|
982
982
|
async capture(ctx) {
|
|
983
983
|
await harvestAllBrowserCoverage();
|
|
984
|
-
const lcov =
|
|
984
|
+
const lcov = takeBrowserCoverageReport(ctx.service);
|
|
985
985
|
await ctx.writeReport("browser.lcov", lcov);
|
|
986
986
|
},
|
|
987
987
|
};
|
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
1
2
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
2
3
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
3
4
|
// a container). Owns:
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
dnsName as makeDnsDecl,
|
|
36
37
|
isWildcard,
|
|
37
38
|
validateServiceImage,
|
|
39
|
+
normalizeServiceImage,
|
|
38
40
|
proxy as makeProxyDecl,
|
|
39
41
|
} from "./index.js";
|
|
40
42
|
import type { DnsTarget, LoweredIngress } from "./index.js";
|
|
@@ -154,6 +156,7 @@ import {
|
|
|
154
156
|
} from "./harness/ingress-table.js";
|
|
155
157
|
import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
|
|
156
158
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
159
|
+
import { isOnStickyDisk } from "./harness/sticky-paths.js";
|
|
157
160
|
import {
|
|
158
161
|
BUILDKIT_CACHE_DIR,
|
|
159
162
|
BUILDKIT_CACHE_UNION,
|
|
@@ -239,7 +242,7 @@ import type {
|
|
|
239
242
|
ServiceConfig,
|
|
240
243
|
ServiceDefinition,
|
|
241
244
|
ServiceHandles,
|
|
242
|
-
|
|
245
|
+
NormalizedServiceImage,
|
|
243
246
|
SpectestContext,
|
|
244
247
|
Terminal,
|
|
245
248
|
TerminalOpts,
|
|
@@ -256,10 +259,21 @@ import type { InternalTerminal, TerminalFrameSink } from "./terminal.js";
|
|
|
256
259
|
* name (container name, image tag, ready-probe host). Widened to
|
|
257
260
|
* accept any helpers shape so a typed `setup({ helpers })` call from
|
|
258
261
|
* a component (e.g. K3sHelpers) lines up at the call site. */
|
|
259
|
-
type NamedService = ServiceDefinition<Record<string, unknown
|
|
262
|
+
type NamedService = Omit<ServiceDefinition<Record<string, unknown>>, "image"> & { name: string; image: NormalizedServiceImage };
|
|
260
263
|
|
|
261
264
|
function namedServices(cfg: EnvironmentConfig): NamedService[] {
|
|
262
|
-
return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
|
|
265
|
+
return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def, image: normalizeServiceImage(def.image) }));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Build tasks participate in image preparation, never container startup. */
|
|
269
|
+
function imageTasks(services: NamedService[]): NamedService[] {
|
|
270
|
+
const tasks = componentBuildTasks(services).map(task => ({ ...task, image: validateServiceImage(task.name, task.image) }));
|
|
271
|
+
const names = new Set(services.map(s => s.name));
|
|
272
|
+
for (const task of tasks) {
|
|
273
|
+
if (names.has(task.name)) throw new Error(`image task name conflicts with service: ${task.name}`);
|
|
274
|
+
names.add(task.name);
|
|
275
|
+
}
|
|
276
|
+
return [...services, ...tasks];
|
|
263
277
|
}
|
|
264
278
|
|
|
265
279
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
@@ -977,7 +991,7 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
|
|
|
977
991
|
// A directory on a cache disk is the same kind of thing: a nested
|
|
978
992
|
// runtime's containerd root (`k3s()`), kept as a cache by the
|
|
979
993
|
// lineage exactly as the container store one level up is.
|
|
980
|
-
const durable = host.startsWith("/var/cache/spectest/") || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
|
|
994
|
+
const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host) || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
|
|
981
995
|
if (vol.source?.startsWith("/") && !durable) {
|
|
982
996
|
await recordAbsoluteVolumeDir(host);
|
|
983
997
|
}
|
|
@@ -1365,6 +1379,18 @@ async function resolveDockerfileBuild(svc: NamedService): Promise<DockerfileBuil
|
|
|
1365
1379
|
async function prepareServiceImage(
|
|
1366
1380
|
svc: NamedService,
|
|
1367
1381
|
opts?: { dedup?: boolean },
|
|
1382
|
+
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
1383
|
+
const result = await prepareServiceImageInner(svc, opts);
|
|
1384
|
+
if (svc.image.type === "dockerfile" && svc.image.reference) {
|
|
1385
|
+
const tagged = await docker(["tag", result.tag, svc.image.reference], 30_000);
|
|
1386
|
+
if (tagged.code !== 0) throw new Error(`cannot name built image ${svc.image.reference}: ${tagged.stderr}`);
|
|
1387
|
+
}
|
|
1388
|
+
return result;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
async function prepareServiceImageInner(
|
|
1392
|
+
svc: NamedService,
|
|
1393
|
+
opts?: { dedup?: boolean },
|
|
1368
1394
|
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
1369
1395
|
const tag = imageTag(svc.name);
|
|
1370
1396
|
if (svc.image.type === "registry") {
|
|
@@ -1414,7 +1440,7 @@ async function prepareServiceImage(
|
|
|
1414
1440
|
progressService(svc.name, { status: "failed" });
|
|
1415
1441
|
throw err;
|
|
1416
1442
|
}
|
|
1417
|
-
//
|
|
1443
|
+
// Reuse the same application image; runtime trust is added separately.
|
|
1418
1444
|
const tagr = await docker(["tag", first.tag, tag], 30_000);
|
|
1419
1445
|
if (tagr.code !== 0) {
|
|
1420
1446
|
throw new Error(`docker tag ${first.tag} ${tag} failed: ${tagr.stderr.trim()}`);
|
|
@@ -3175,7 +3201,8 @@ function specToNamedService(spec: RuntimeServiceSpec): NamedService {
|
|
|
3175
3201
|
// run here: the image is checked (a `path` Dockerfile and a `context`
|
|
3176
3202
|
// must exist), and coverage adapters get their configure step.
|
|
3177
3203
|
const svc = { ...(rest as ServiceConfig), image: validateServiceImage(name, rest.image) };
|
|
3178
|
-
|
|
3204
|
+
const configured = applyCoverageAdapters(name, svc);
|
|
3205
|
+
return { name, ...configured, image: normalizeServiceImage(configured.image) };
|
|
3179
3206
|
}
|
|
3180
3207
|
|
|
3181
3208
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
@@ -3507,12 +3534,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3507
3534
|
const bootStart = Date.now();
|
|
3508
3535
|
const cfg: EnvironmentConfig = requireLoaded().project.environment;
|
|
3509
3536
|
const services = namedServices(cfg);
|
|
3537
|
+
const tasks = imageTasks(services);
|
|
3538
|
+
const componentTasks = componentBuildTasks(services);
|
|
3510
3539
|
const timings = new Map<string, ServiceTiming>();
|
|
3511
3540
|
if (prepared && (prepared.version !== 1 || !prepared.images ||
|
|
3512
|
-
Object.keys(prepared.images).sort().join("\n") !==
|
|
3541
|
+
Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
|
|
3513
3542
|
throw new Error("prepared image manifest does not match the environment's services");
|
|
3514
3543
|
}
|
|
3515
|
-
progressInit(
|
|
3544
|
+
progressInit(tasks);
|
|
3516
3545
|
// Build dedup is only valid within one workspace generation — a fresh
|
|
3517
3546
|
// bootstrap may follow a workspace re-upload with the same dockerfile
|
|
3518
3547
|
// text but different build-context content.
|
|
@@ -3535,7 +3564,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3535
3564
|
if ((await readIgnoreFile(path.join(WORKSPACE, ".dockerignore"))) !== null) return;
|
|
3536
3565
|
await fs.writeFile(
|
|
3537
3566
|
path.join(WORKSPACE, ".dockerignore"),
|
|
3538
|
-
unionDockerignore(
|
|
3567
|
+
unionDockerignore(tasks),
|
|
3539
3568
|
);
|
|
3540
3569
|
})(),
|
|
3541
3570
|
]);
|
|
@@ -3572,11 +3601,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3572
3601
|
if (found.code !== 0 || found.stdout.trim() !== entry.image) {
|
|
3573
3602
|
throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
|
|
3574
3603
|
}
|
|
3575
|
-
|
|
3604
|
+
const tag = imageTag(svc.name);
|
|
3605
|
+
const tagged = await docker(["tag", entry.image, tag], 30_000);
|
|
3606
|
+
if (tagged.code !== 0) throw new Error(`cannot restore prepared image name for ${svc.name}: ${tagged.stderr}`);
|
|
3607
|
+
return { tag, buildSteps: entry.buildSteps };
|
|
3576
3608
|
};
|
|
3577
3609
|
const prep = new Map<string, Promise<void>>();
|
|
3578
3610
|
const prepOffsets = new Map<string, number>();
|
|
3579
|
-
for (const svc of
|
|
3611
|
+
for (const svc of tasks) {
|
|
3580
3612
|
const p = (async () => {
|
|
3581
3613
|
const t0 = Date.now();
|
|
3582
3614
|
prepOffsets.set(svc.name, Date.now() - bootStart);
|
|
@@ -3602,7 +3634,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3602
3634
|
// No application services, setup hooks, ingress, or runtime state in the builder.
|
|
3603
3635
|
await Promise.all(prep.values());
|
|
3604
3636
|
const images: PreparedImages["images"] = Object.create(null);
|
|
3605
|
-
for (const svc of
|
|
3637
|
+
for (const svc of tasks) {
|
|
3606
3638
|
const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)!], 30_000);
|
|
3607
3639
|
const image = out.stdout.trim();
|
|
3608
3640
|
if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
|
|
@@ -3654,6 +3686,8 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3654
3686
|
progressService(svc.name, { status: "probing", detail: "ready check" });
|
|
3655
3687
|
await waitForReady(svc);
|
|
3656
3688
|
const tSetup = Date.now();
|
|
3689
|
+
const builds = componentTasks.filter(task => task.owner === svc.name);
|
|
3690
|
+
await Promise.all(builds.map(task => prep.get(task.name)!));
|
|
3657
3691
|
if (svc.setup) {
|
|
3658
3692
|
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3659
3693
|
const helpers = await ensureHelpers(svc.name, svc);
|
|
@@ -3662,11 +3696,17 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3662
3696
|
name: svc.name,
|
|
3663
3697
|
helpers,
|
|
3664
3698
|
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3699
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3700
|
+
images: builds.map(task => ({ source: tags.get(task.name)!, reference: task.reference })),
|
|
3701
|
+
gateway: await bridgeGatewayIp(),
|
|
3702
|
+
caBundle: await ensureCaBundle(),
|
|
3703
|
+
} },
|
|
3665
3704
|
});
|
|
3666
3705
|
} catch (err) {
|
|
3667
3706
|
throw await withContainerPostMortem(svc.name, err);
|
|
3668
3707
|
}
|
|
3669
3708
|
}
|
|
3709
|
+
for (const task of builds) progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
|
|
3670
3710
|
progressService(svc.name, { status: "ready", detail: undefined });
|
|
3671
3711
|
const ti = timings.get(svc.name);
|
|
3672
3712
|
if (ti) {
|
|
@@ -4223,9 +4263,10 @@ async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
|
|
|
4223
4263
|
async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
|
|
4224
4264
|
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
4225
4265
|
const sources: ReachSource[] = [];
|
|
4226
|
-
|
|
4266
|
+
const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
|
|
4267
|
+
for (const svc of imageTasks(all)) {
|
|
4227
4268
|
const src: ReachSource = {
|
|
4228
|
-
service: svc.name,
|
|
4269
|
+
service: owners.get(svc.name) ?? svc.name,
|
|
4229
4270
|
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
|
|
4230
4271
|
...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
|
|
4231
4272
|
};
|
|
@@ -6720,7 +6761,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6720
6761
|
bootstrap: async (params) =>
|
|
6721
6762
|
exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
|
|
6722
6763
|
const prepared = params.prepared === undefined ? undefined : preparedImages(
|
|
6723
|
-
params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name),
|
|
6764
|
+
params.prepared, params.identity, imageTasks(namedServices(requireLoaded().project.environment)).map(s => s.name),
|
|
6724
6765
|
);
|
|
6725
6766
|
return { ok: true, timings: await bootstrap(prepared) };
|
|
6726
6767
|
}),
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { canonicalImageReference, componentBuildTasks, kubernetesImages } from "./component-images";
|
|
3
|
+
import { validateServiceImage, type BuildImage } from "../index";
|
|
4
|
+
|
|
5
|
+
test("services and Kubernetes share all Dockerfile build options without a type discriminator", () => {
|
|
6
|
+
const image: BuildImage & { reference: string } = {
|
|
7
|
+
reference: "k8s.internal:5000/runtime:spectest", content: "FROM scratch AS test",
|
|
8
|
+
target: "test", context: ".", exclude: ["fixtures"], buildArgs: { MODE: "test" },
|
|
9
|
+
};
|
|
10
|
+
expect(validateServiceImage("app", { ...image, context: undefined })).toEqual({ ...image, context: undefined, type: "dockerfile" });
|
|
11
|
+
expect(validateServiceImage("redis", "redis:7.4")).toEqual({ type: "registry", reference: "redis:7.4" });
|
|
12
|
+
const parsed = kubernetesImages(["redis:7.4", image]);
|
|
13
|
+
expect(parsed.pulls).toEqual(["redis:7.4"]);
|
|
14
|
+
expect(parsed.builds).toEqual([{ reference: image.reference, image }]);
|
|
15
|
+
const tasks = componentBuildTasks([{ name: "cluster", image: "k3s:latest", imageBuilds: parsed.builds }]);
|
|
16
|
+
expect(tasks[0].image).toBe(image);
|
|
17
|
+
expect(tasks[0].owner).toBe("cluster");
|
|
18
|
+
expect(tasks[0].name).toMatch(/^spectest-image-[a-f0-9]+$/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("image identity validates references and rejects conflicting definitions", () => {
|
|
22
|
+
expect(canonicalImageReference("redis:7.4")).toBe("docker.io/library/redis:7.4");
|
|
23
|
+
expect(canonicalImageReference("docker.io/redis:7.4")).toBe("docker.io/library/redis:7.4");
|
|
24
|
+
expect(canonicalImageReference("company/api")).toBe("docker.io/company/api:latest");
|
|
25
|
+
expect(canonicalImageReference("localhost:5000/api")).toBe("localhost:5000/api:latest");
|
|
26
|
+
expect(kubernetesImages(["redis:7.4", "redis:7.4"]).pulls).toHaveLength(1);
|
|
27
|
+
for (const value of ["", "--help", "https://example.com/api", "has space", "bad:tag:extra"]) {
|
|
28
|
+
expect(() => kubernetesImages([value])).toThrow();
|
|
29
|
+
}
|
|
30
|
+
expect(() => kubernetesImages([{ content: "FROM scratch" } as never])).toThrow("reference");
|
|
31
|
+
expect(() => kubernetesImages([{ type: "registry", reference: "redis:7" } as never])).toThrow();
|
|
32
|
+
expect(() => kubernetesImages([{ reference: `api@sha256:${"a".repeat(64)}`, content: "FROM scratch" }])).toThrow("digest");
|
|
33
|
+
expect(() => kubernetesImages(["api:1", { reference: "docker.io/library/api:1", content: "FROM scratch" }])).toThrow("conflicting");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("build task identity survives list reordering but is scoped to the owning cluster", () => {
|
|
37
|
+
const a = { reference: "app:1", image: { content: "FROM scratch" } };
|
|
38
|
+
const b = { reference: "app:2", image: { content: "FROM scratch" } };
|
|
39
|
+
const tasks = (name: string, imageBuilds: typeof a[]) => componentBuildTasks([{ name, image: "k3s:1", imageBuilds }]);
|
|
40
|
+
expect(tasks("cluster", [a, b])[0].name).toBe(tasks("cluster", [b, a])[1].name);
|
|
41
|
+
expect(tasks("cluster", [a])[0].name).not.toBe(tasks("other", [a])[0].name);
|
|
42
|
+
expect(() => validateServiceImage("app", { content: "FROM scratch", buildArgs: { "bad-key": "x" } })).toThrow("ARG");
|
|
43
|
+
expect(() => validateServiceImage("app", { content: "FROM scratch", exclude: [1] as never })).toThrow("exclude");
|
|
44
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
|
|
3
|
+
|
|
4
|
+
export type KubernetesImage = string | (BuildImage & { reference: string });
|
|
5
|
+
|
|
6
|
+
/** containerd/CRI use fully qualified names, including the default tag. */
|
|
7
|
+
export function canonicalImageReference(reference: string): string {
|
|
8
|
+
if (typeof reference !== "string" || !reference || reference.startsWith("-") || /[\s\x00]/.test(reference)) {
|
|
9
|
+
throw new Error("image reference must be a non-empty image name without whitespace");
|
|
10
|
+
}
|
|
11
|
+
const slash = reference.indexOf("/");
|
|
12
|
+
const first = slash < 0 ? "" : reference.slice(0, slash);
|
|
13
|
+
let result = slash < 0 ? `docker.io/library/${reference}`
|
|
14
|
+
: first.includes(".") || first.includes(":") || first === "localhost" ? reference : `docker.io/${reference}`;
|
|
15
|
+
if (result.startsWith("index.docker.io/")) result = result.replace("index.docker.io/", "docker.io/");
|
|
16
|
+
if (result.startsWith("docker.io/") && !result.slice(10).includes("/")) result = `docker.io/library/${result.slice(10)}`;
|
|
17
|
+
const [name, digest] = result.split("@");
|
|
18
|
+
const tagAt = name.lastIndexOf(":") > name.lastIndexOf("/") ? name.lastIndexOf(":") : -1;
|
|
19
|
+
const repository = tagAt < 0 ? name : name.slice(0, tagAt);
|
|
20
|
+
if (!/^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*$/.test(repository)
|
|
21
|
+
|| (tagAt >= 0 && !/^[\w][\w.-]{0,127}$/.test(name.slice(tagAt + 1)))
|
|
22
|
+
|| (result.includes("@") && (!digest || !/^sha256:[a-f0-9]{64}$/.test(digest) || result.split("@").length !== 2))) {
|
|
23
|
+
throw new Error(`invalid image reference: ${reference}`);
|
|
24
|
+
}
|
|
25
|
+
if (tagAt < 0 && !digest) result += ":latest";
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: string[]; builds: ComponentImageBuild[] } {
|
|
30
|
+
if (!Array.isArray(images)) throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
|
|
31
|
+
const pulls: string[] = [];
|
|
32
|
+
const builds: ComponentImageBuild[] = [];
|
|
33
|
+
const seen = new Map<string, string>();
|
|
34
|
+
for (const image of images) {
|
|
35
|
+
if (typeof image !== "string" && (!image || typeof image !== "object" || "type" in image || !image.reference)) {
|
|
36
|
+
throw new Error("k3s(): images expects registry strings or Dockerfile builds with a reference");
|
|
37
|
+
}
|
|
38
|
+
const reference = canonicalImageReference(typeof image === "string" ? image : image.reference);
|
|
39
|
+
if (typeof image !== "string" && reference.includes("@")) throw new Error("k3s(): a build reference must be an image name/tag, not a digest");
|
|
40
|
+
const identity = JSON.stringify(image);
|
|
41
|
+
const previous = seen.get(reference);
|
|
42
|
+
if (previous !== undefined) {
|
|
43
|
+
if (previous !== identity) throw new Error(`k3s(): conflicting images for ${reference}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
seen.set(reference, identity);
|
|
47
|
+
if (typeof image === "string") pulls.push(image);
|
|
48
|
+
else builds.push({ reference, image });
|
|
49
|
+
}
|
|
50
|
+
return { pulls, builds };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Stable internal task names, separate from the service graph. */
|
|
54
|
+
export function componentBuildTasks<T extends ServiceConfig & { name: string }>(services: readonly T[]) {
|
|
55
|
+
return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
|
|
56
|
+
name: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
|
|
57
|
+
owner: svc.name,
|
|
58
|
+
reference: build.reference,
|
|
59
|
+
image: build.image,
|
|
60
|
+
})));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Private handoff to a component's setup hook. Builds stay generic; the
|
|
64
|
+
* component decides how its runtime consumes them and establishes trust. */
|
|
65
|
+
export const COMPONENT_RUNTIME = Symbol.for("spectest.component-runtime");
|
|
66
|
+
export interface ComponentRuntime {
|
|
67
|
+
images: readonly { source: string; reference: string }[];
|
|
68
|
+
gateway: string;
|
|
69
|
+
caBundle: string | null;
|
|
70
|
+
}
|
|
71
|
+
export function componentRuntime(context: object): ComponentRuntime | undefined {
|
|
72
|
+
return (context as { [COMPONENT_RUNTIME]?: ComponentRuntime })[COMPONENT_RUNTIME];
|
|
73
|
+
}
|