@specific.dev/spectest 0.92.0 → 0.93.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/README.md +7 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/k3s.d.ts +26 -4
- package/dist/components/k3s.js +105 -61
- package/dist/daemon.js +30 -97
- package/dist/harness/built-image.d.ts +16 -0
- package/dist/harness/built-image.js +57 -0
- package/dist/harness/certificate.d.ts +15 -0
- package/dist/harness/certificate.js +67 -0
- package/dist/harness/k3s-image-loader.js +2 -53
- package/dist/harness/registry-seed.d.ts +12 -0
- package/dist/harness/registry-seed.js +61 -0
- package/dist/harness/service-graph.d.ts +3 -0
- package/dist/harness/service-graph.js +19 -9
- package/dist/index.d.ts +6 -6
- package/package.json +1 -1
- package/src/components/index.ts +1 -0
- package/src/components/k3s-registry.test.ts +50 -0
- package/src/components/k3s.ts +114 -52
- package/src/daemon.ts +30 -108
- package/src/harness/built-image.ts +53 -0
- package/src/harness/certificate.test.ts +31 -0
- package/src/harness/certificate.ts +73 -0
- package/src/harness/eval-typecheck.test.ts +28 -0
- package/src/harness/fetch.test.ts +28 -0
- package/src/harness/k3s-image-loader.ts +2 -47
- package/src/harness/registry-seed.test.ts +60 -0
- package/src/harness/registry-seed.ts +61 -0
- package/src/harness/service-graph.test.ts +31 -1
- package/src/harness/service-graph.ts +23 -0
- package/src/index.ts +12 -8
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
1
2
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
2
3
|
import { waitForAll } from "./harness/concurrency.js";
|
|
3
4
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
@@ -89,7 +90,7 @@ import {
|
|
|
89
90
|
serviceDockerignore as composeServiceDockerignore,
|
|
90
91
|
unionDockerignore,
|
|
91
92
|
} from "./harness/build-context.js";
|
|
92
|
-
import {
|
|
93
|
+
import { startServices } from "./harness/service-graph.js";
|
|
93
94
|
import {
|
|
94
95
|
casesMetadata as catalogueCases,
|
|
95
96
|
groupsMetadata as catalogueGroups,
|
|
@@ -1859,35 +1860,6 @@ async function withContainerPostMortem(name: string, err: unknown): Promise<unkn
|
|
|
1859
1860
|
}
|
|
1860
1861
|
}
|
|
1861
1862
|
|
|
1862
|
-
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
1863
|
-
* walk it. Rules live in `harness/service-graph.ts`. */
|
|
1864
|
-
function validateServiceGraph(services: NamedService[]): Map<string, NamedService> {
|
|
1865
|
-
return validateGraph(services);
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
async function startServices(
|
|
1869
|
-
services: NamedService[],
|
|
1870
|
-
startOne: (svc: NamedService) => Promise<void>,
|
|
1871
|
-
): Promise<void> {
|
|
1872
|
-
const byName = validateServiceGraph(services);
|
|
1873
|
-
const started = new Map<string, Promise<void>>();
|
|
1874
|
-
const start = (svc: NamedService): Promise<void> => {
|
|
1875
|
-
const existing = started.get(svc.name);
|
|
1876
|
-
if (existing) return existing;
|
|
1877
|
-
const p = (async () => {
|
|
1878
|
-
await Promise.all(
|
|
1879
|
-
(svc.dependsOn ?? [])
|
|
1880
|
-
.filter((d) => byName.has(d))
|
|
1881
|
-
.map((d) => start(byName.get(d)!)),
|
|
1882
|
-
);
|
|
1883
|
-
await startOne(svc);
|
|
1884
|
-
})();
|
|
1885
|
-
started.set(svc.name, p);
|
|
1886
|
-
return p;
|
|
1887
|
-
};
|
|
1888
|
-
await Promise.all(services.map(start));
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
1863
|
// ────────────────────────────────────────────────────────────────────────
|
|
1892
1864
|
// Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
|
|
1893
1865
|
//
|
|
@@ -2057,72 +2029,6 @@ function buildIngress(project: Project): void {
|
|
|
2057
2029
|
}
|
|
2058
2030
|
}
|
|
2059
2031
|
|
|
2060
|
-
/**
|
|
2061
|
-
* Generate a leaf cert + key for one ingress route (fake or service
|
|
2062
|
-
* proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
|
|
2063
|
-
* hostname the route answers to, so a client connecting with TLS
|
|
2064
|
-
* verifies cleanly regardless of which hostname it used. Shells out
|
|
2065
|
-
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
2066
|
-
* bookworm ships 3.0.x).
|
|
2067
|
-
*
|
|
2068
|
-
* `label` is a short tag baked into the cert Subject CN and the temp
|
|
2069
|
-
* file names — only used for diagnostics, not for TLS verification.
|
|
2070
|
-
*/
|
|
2071
|
-
async function generateHostCert(
|
|
2072
|
-
label: string,
|
|
2073
|
-
hostnames: string[],
|
|
2074
|
-
): Promise<{ cert: string; key: string }> {
|
|
2075
|
-
const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
|
|
2076
|
-
const keyPath = path.join("/tmp", `${id}.key`);
|
|
2077
|
-
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
2078
|
-
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
2079
|
-
const args = [
|
|
2080
|
-
"req",
|
|
2081
|
-
"-newkey",
|
|
2082
|
-
"rsa:2048",
|
|
2083
|
-
"-nodes",
|
|
2084
|
-
"-keyout",
|
|
2085
|
-
keyPath,
|
|
2086
|
-
"-out",
|
|
2087
|
-
crtPath,
|
|
2088
|
-
"-x509",
|
|
2089
|
-
"-CA",
|
|
2090
|
-
CA_PATH,
|
|
2091
|
-
"-CAkey",
|
|
2092
|
-
CA_KEY_PATH,
|
|
2093
|
-
"-days",
|
|
2094
|
-
"3650",
|
|
2095
|
-
"-subj",
|
|
2096
|
-
`/CN=spectest-${label}`,
|
|
2097
|
-
"-addext",
|
|
2098
|
-
`subjectAltName=${sans}`,
|
|
2099
|
-
"-addext",
|
|
2100
|
-
"basicConstraints=CA:FALSE",
|
|
2101
|
-
"-addext",
|
|
2102
|
-
"extendedKeyUsage=serverAuth",
|
|
2103
|
-
"-addext",
|
|
2104
|
-
"keyUsage=digitalSignature,keyEncipherment",
|
|
2105
|
-
];
|
|
2106
|
-
const r = await shx("openssl", args, 30_000);
|
|
2107
|
-
if (r.code !== 0) {
|
|
2108
|
-
throw new Error(
|
|
2109
|
-
`openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`,
|
|
2110
|
-
);
|
|
2111
|
-
}
|
|
2112
|
-
try {
|
|
2113
|
-
const [cert, key] = await Promise.all([
|
|
2114
|
-
fs.readFile(crtPath, "utf8"),
|
|
2115
|
-
fs.readFile(keyPath, "utf8"),
|
|
2116
|
-
]);
|
|
2117
|
-
return { cert, key };
|
|
2118
|
-
} finally {
|
|
2119
|
-
await Promise.all([
|
|
2120
|
-
fs.unlink(keyPath).catch(() => {}),
|
|
2121
|
-
fs.unlink(crtPath).catch(() => {}),
|
|
2122
|
-
]);
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
2032
|
/** Resolve the spectest-net bridge gateway IP — the address containers
|
|
2127
2033
|
* use to reach the VM host. Asks dockerd via the docker CLI; cached for
|
|
2128
2034
|
* the daemon's life because the network is recreated only on reload. */
|
|
@@ -2165,7 +2071,11 @@ type Route = IngressRoute<FakeRuntime>;
|
|
|
2165
2071
|
*/
|
|
2166
2072
|
const NATIVE_FETCH: typeof fetch = globalThis.fetch.bind(globalThis);
|
|
2167
2073
|
|
|
2168
|
-
/**
|
|
2074
|
+
/** Mint ingress and container certificates with the environment CA. */
|
|
2075
|
+
function generateHostCert(label: string, hostnames: string[]): Promise<{ cert: string; key: string }> {
|
|
2076
|
+
return mintHostCert(label, hostnames, CA_PATH, CA_KEY_PATH);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2169
2079
|
async function startIngress(): Promise<void> {
|
|
2170
2080
|
const hasIngress =
|
|
2171
2081
|
FAKES.size > 0 ||
|
|
@@ -2507,6 +2417,9 @@ function bindIngressServer(
|
|
|
2507
2417
|
// {@link stopIngressServers} depends on: a half-alive listener shares
|
|
2508
2418
|
// the port silently instead of colliding loudly.
|
|
2509
2419
|
reusePort: true,
|
|
2420
|
+
// OCI layer uploads can exceed Bun's default 128 MiB request limit.
|
|
2421
|
+
// Large proxy bodies stream through; this limit does not allocate a buffer.
|
|
2422
|
+
maxRequestBodySize: 1024 ** 4,
|
|
2510
2423
|
// Bun.serve defaults to a 10s idleTimeout, which kills any proxied
|
|
2511
2424
|
// request whose upstream takes >10s to produce bytes — under parallel
|
|
2512
2425
|
// test load that surfaced as "fetch failed"/"other side closed" on
|
|
@@ -3519,9 +3432,13 @@ async function bootstrap(prepared?: PreparedImages): Promise<BootstrapTimings> {
|
|
|
3519
3432
|
// hook's — into the boot log, so GET /boot-log can explain a slow or
|
|
3520
3433
|
// finished boot. Restored in the finally at the end.
|
|
3521
3434
|
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
3435
|
+
// One dispatcher for the whole bootstrap: service hooks run concurrently.
|
|
3436
|
+
// Only each hook's async scope opts into wrapped fetch responses.
|
|
3437
|
+
const restoreFetch = installFetchWrapper();
|
|
3522
3438
|
try {
|
|
3523
3439
|
return await bootstrapInner(prepared);
|
|
3524
3440
|
} finally {
|
|
3441
|
+
restoreFetch();
|
|
3525
3442
|
restoreBootLog();
|
|
3526
3443
|
}
|
|
3527
3444
|
}
|
|
@@ -3695,20 +3612,25 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3695
3612
|
await waitForAll([...builds.map(task => prep.get(task.name)!), (async () => {
|
|
3696
3613
|
if (svc.setup) {
|
|
3697
3614
|
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3698
|
-
const
|
|
3615
|
+
const fetchScope = createInstrumentationScope(false);
|
|
3699
3616
|
try {
|
|
3700
|
-
await
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3617
|
+
await runInstrumented(fetchScope, async () => {
|
|
3618
|
+
const helpers = await ensureHelpers(svc.name, svc);
|
|
3619
|
+
await svc.setup!({
|
|
3620
|
+
name: svc.name,
|
|
3621
|
+
helpers,
|
|
3622
|
+
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3623
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3624
|
+
images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
|
|
3625
|
+
gateway: await bridgeGatewayIp(),
|
|
3626
|
+
caBundle: await ensureCaBundle(),
|
|
3627
|
+
} },
|
|
3628
|
+
});
|
|
3709
3629
|
});
|
|
3710
3630
|
} catch (err) {
|
|
3711
3631
|
throw await withContainerPostMortem(svc.name, err);
|
|
3632
|
+
} finally {
|
|
3633
|
+
fetchScope.active = false;
|
|
3712
3634
|
}
|
|
3713
3635
|
}
|
|
3714
3636
|
})()]);
|
|
@@ -4857,7 +4779,7 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
|
|
|
4857
4779
|
exec: (service, command, opts) =>
|
|
4858
4780
|
componentExec(service, command, opts, execTimeoutMs),
|
|
4859
4781
|
// Read `globalThis.fetch` at call time: the wrapper is installed for
|
|
4860
|
-
// the duration of a test / eval / project setup, so a context built
|
|
4782
|
+
// the duration of bootstrap / a test / eval / project setup, so a context built
|
|
4861
4783
|
// before it lands still gets the wrapped one.
|
|
4862
4784
|
fetch: ((input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
|
|
4863
4785
|
globalThis.fetch(input, init)) as unknown as SpectestContext["fetch"],
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Read build results without reserializing manifests or exporting layers. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { canonicalImageReference } from "./component-images.js";
|
|
6
|
+
|
|
7
|
+
export interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
|
|
8
|
+
interface Manifest { schemaVersion: number; mediaType: string; config: Descriptor; layers: Descriptor[]; annotations?: Record<string, string> }
|
|
9
|
+
const digest = (bytes: Uint8Array) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
10
|
+
const descriptor = (bytes: Uint8Array, mediaType: string): Descriptor => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const HOST_CTR = ["--address", "/run/containerd/containerd.sock", "--namespace", "moby"];
|
|
13
|
+
|
|
14
|
+
async function hostContent(id: string): Promise<Buffer> {
|
|
15
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error(`invalid content digest ${id}`);
|
|
16
|
+
const result = await run("ctr", [...HOST_CTR, "content", "get", id], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
|
|
17
|
+
if (digest(result.stdout) !== id) throw new Error(`corrupt image metadata ${id}`);
|
|
18
|
+
return result.stdout;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Preserve the original index/manifest bytes and all application descriptors. */
|
|
22
|
+
export async function hostImage(source: string) {
|
|
23
|
+
const name = canonicalImageReference(source);
|
|
24
|
+
const { stdout } = await run("ctr", [...HOST_CTR, "images", "ls", `name==${name}`], { timeout: 30_000 });
|
|
25
|
+
const found = stdout.split("\n").map(line => line.trim().split(/\s+/)).find(row => row[0] === name)?.[2];
|
|
26
|
+
if (!found) throw new Error(`built image ${name} is missing from the VM container store`);
|
|
27
|
+
const metadata = new Map<string, Buffer>();
|
|
28
|
+
const allowed = new Map<string, Descriptor>();
|
|
29
|
+
let configDigest: string | undefined;
|
|
30
|
+
async function visit(id: string, selected: boolean, depth = 0): Promise<Descriptor> {
|
|
31
|
+
if (depth > 4) throw new Error(`built image ${name} has a recursive manifest index`);
|
|
32
|
+
const bytes = await hostContent(id);
|
|
33
|
+
const obj = JSON.parse(bytes.toString());
|
|
34
|
+
const desc = descriptor(bytes, obj.mediaType);
|
|
35
|
+
metadata.set(id, bytes);
|
|
36
|
+
allowed.set(id, desc);
|
|
37
|
+
if (obj.config && Array.isArray(obj.layers)) {
|
|
38
|
+
const manifest = obj as Manifest;
|
|
39
|
+
for (const blob of [manifest.config, ...manifest.layers]) allowed.set(blob.digest, blob);
|
|
40
|
+
if (selected) configDigest = manifest.config.digest;
|
|
41
|
+
} else if (Array.isArray(obj.manifests)) {
|
|
42
|
+
for (const entry of obj.manifests as Descriptor[]) {
|
|
43
|
+
const matches = !entry.platform || (entry.platform.os === "linux" && entry.platform.architecture === (process.arch === "arm64" ? "arm64" : "amd64"));
|
|
44
|
+
await visit(entry.digest, selected && matches, depth + 1);
|
|
45
|
+
}
|
|
46
|
+
} else throw new Error(`built image ${name} has invalid image metadata`);
|
|
47
|
+
return desc;
|
|
48
|
+
}
|
|
49
|
+
const target = await visit(found, true);
|
|
50
|
+
if (!configDigest) throw new Error(`built image ${name} has no image for this VM's platform`);
|
|
51
|
+
return { target, metadata, allowed, configDigest };
|
|
52
|
+
}
|
|
53
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { X509Certificate } from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { generateHostCert } from "./certificate.js";
|
|
7
|
+
|
|
8
|
+
test("long ingress names retain their full SAN while the diagnostic CN stays bounded", async () => {
|
|
9
|
+
const dir = await mkdtemp(join(tmpdir(), "spectest-leaf-"));
|
|
10
|
+
const ca = join(dir, "ca.crt");
|
|
11
|
+
const key = join(dir, "ca.key");
|
|
12
|
+
try {
|
|
13
|
+
const root = Bun.spawnSync(["openssl", "req", "-x509", "-newkey", "rsa:2048",
|
|
14
|
+
"-nodes", "-days", "1", "-subj", "/CN=test-ca", "-keyout", key, "-out", ca]);
|
|
15
|
+
expect(root.exitCode, root.stderr.toString()).toBe(0);
|
|
16
|
+
// Long enough to exceed both the former CN and temporary filename limits.
|
|
17
|
+
const host = `${"a".repeat(63)}.${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(61)}`;
|
|
18
|
+
const { cert, key: leafKey } = await generateHostCert(host, [host, "*.example.test"], ca, key);
|
|
19
|
+
const leaf = new X509Certificate(cert);
|
|
20
|
+
expect(leaf.subject.length).toBeLessThanOrEqual(67); // "CN=" + 64 bytes
|
|
21
|
+
expect(leaf.checkHost(host)).toBe(host);
|
|
22
|
+
expect(leaf.checkHost("api.example.test")).toBe("*.example.test");
|
|
23
|
+
expect(leafKey).toContain("PRIVATE KEY");
|
|
24
|
+
const file = join(dir, "leaf.crt");
|
|
25
|
+
await writeFile(file, cert);
|
|
26
|
+
const verified = Bun.spawnSync(["openssl", "verify", "-CAfile", ca, "-verify_hostname", host, file]);
|
|
27
|
+
expect(verified.exitCode, verified.stderr.toString()).toBe(0);
|
|
28
|
+
} finally {
|
|
29
|
+
await rm(dir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
}, 10_000);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a leaf cert + key for one ingress route (fake or service
|
|
11
|
+
* proxy), signed by the in-VM root CA. SANs cover every
|
|
12
|
+
* hostname the route answers to, so a client connecting with TLS
|
|
13
|
+
* verifies cleanly regardless of which hostname it used. Shells out
|
|
14
|
+
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
15
|
+
* bookworm ships 3.0.x).
|
|
16
|
+
*
|
|
17
|
+
* `label` is a diagnostic tag baked into the cert Subject CN and the temp
|
|
18
|
+
* file names — only used for diagnostics, not for TLS verification.
|
|
19
|
+
*/
|
|
20
|
+
export async function generateHostCert(
|
|
21
|
+
label: string,
|
|
22
|
+
hostnames: string[],
|
|
23
|
+
caPath: string,
|
|
24
|
+
caKeyPath: string,
|
|
25
|
+
): Promise<{ cert: string; key: string }> {
|
|
26
|
+
// CN is diagnostic only; SANs retain the full hostnames. Bound the label
|
|
27
|
+
// for both OpenSSL's 64-byte CN limit and filesystem component limits.
|
|
28
|
+
const commonName = `spectest-${label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 55)}`;
|
|
29
|
+
const id = `${commonName}-${randomUUID()}`;
|
|
30
|
+
const keyPath = path.join("/tmp", `${id}.key`);
|
|
31
|
+
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
32
|
+
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
33
|
+
const args = [
|
|
34
|
+
"req",
|
|
35
|
+
"-newkey",
|
|
36
|
+
"rsa:2048",
|
|
37
|
+
"-nodes",
|
|
38
|
+
"-keyout",
|
|
39
|
+
keyPath,
|
|
40
|
+
"-out",
|
|
41
|
+
crtPath,
|
|
42
|
+
"-x509",
|
|
43
|
+
"-CA",
|
|
44
|
+
caPath,
|
|
45
|
+
"-CAkey",
|
|
46
|
+
caKeyPath,
|
|
47
|
+
"-days",
|
|
48
|
+
"3650",
|
|
49
|
+
"-subj",
|
|
50
|
+
`/CN=${commonName}`,
|
|
51
|
+
"-addext",
|
|
52
|
+
`subjectAltName=${sans}`,
|
|
53
|
+
"-addext",
|
|
54
|
+
"basicConstraints=CA:FALSE",
|
|
55
|
+
"-addext",
|
|
56
|
+
"extendedKeyUsage=serverAuth",
|
|
57
|
+
"-addext",
|
|
58
|
+
"keyUsage=digitalSignature,keyEncipherment",
|
|
59
|
+
];
|
|
60
|
+
try {
|
|
61
|
+
await execFileAsync("openssl", args, { timeout: 30_000 });
|
|
62
|
+
const [cert, key] = await Promise.all([
|
|
63
|
+
fs.readFile(crtPath, "utf8"),
|
|
64
|
+
fs.readFile(keyPath, "utf8"),
|
|
65
|
+
]);
|
|
66
|
+
return { cert, key };
|
|
67
|
+
} finally {
|
|
68
|
+
await Promise.all([
|
|
69
|
+
fs.unlink(keyPath).catch(() => {}),
|
|
70
|
+
fs.unlink(crtPath).catch(() => {}),
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -50,6 +50,34 @@ function run(command: string, args: string[], timeout: number): Promise<{ code:
|
|
|
50
50
|
const opts = () => ({ appDir, file, projectEntry, typecheckDir, run });
|
|
51
51
|
const query = 'const rows = await ctx.svc.supabase.sql<{ analyzed: boolean }>`SELECT true AS analyzed`;';
|
|
52
52
|
|
|
53
|
+
test("annotated services keep helper types while literals without helpers stay absent", async () => {
|
|
54
|
+
await fs.writeFile(projectEntry, `
|
|
55
|
+
import { defineEnvironment, type ServiceDefinition } from "@specific.dev/spectest";
|
|
56
|
+
interface Helpers { value(): number }
|
|
57
|
+
const annotated: ServiceDefinition<Helpers> = {
|
|
58
|
+
image: "example:latest", helpers: async () => ({ value: () => 42 }),
|
|
59
|
+
};
|
|
60
|
+
const checked = { image: "example:latest", helpers: () => ({ value: () => 42 }) } satisfies ServiceDefinition<Helpers>;
|
|
61
|
+
const env = defineEnvironment({ name: "helpers", services: {
|
|
62
|
+
annotated, checked, plain: { image: "example:latest" },
|
|
63
|
+
absent: { image: "example:latest", helpers: undefined },
|
|
64
|
+
} });
|
|
65
|
+
export default env.project([]);
|
|
66
|
+
`);
|
|
67
|
+
await fs.writeFile(file, `
|
|
68
|
+
const a: number = ctx.svc.annotated.value();
|
|
69
|
+
const b: number = ctx.svc.checked.value();
|
|
70
|
+
// @ts-expect-error plain services have no handle
|
|
71
|
+
ctx.svc.plain;
|
|
72
|
+
// @ts-expect-error explicitly absent helpers have no handle either
|
|
73
|
+
ctx.svc.absent;
|
|
74
|
+
// @ts-expect-error helper return type must not widen to any
|
|
75
|
+
const wrong: string = ctx.svc.annotated.value();
|
|
76
|
+
export default a + b;
|
|
77
|
+
`);
|
|
78
|
+
expect(await checkEval(opts())).toMatchObject({ status: "ok", errors: [], totalErrors: 0 });
|
|
79
|
+
}, 30_000);
|
|
80
|
+
|
|
53
81
|
test("eval receives the default-exported project's service/fake types and globals", async () => {
|
|
54
82
|
await fs.writeFile(file, `${query}
|
|
55
83
|
const ready: boolean = rows[0].analyzed.unwrap();
|
|
@@ -3,6 +3,7 @@ import { expect as assertValue } from "../index.js";
|
|
|
3
3
|
import { readRaw, readTag, type SpectestFetch, type WrappedResponse } from "../inspect.js";
|
|
4
4
|
import { recordFake, startRecording, stopRecording, type HttpEvent } from "../recorder.js";
|
|
5
5
|
import { installFetchWrapper, isTransportError } from "./fetch.js";
|
|
6
|
+
import { startServices } from "./service-graph.js";
|
|
6
7
|
import {
|
|
7
8
|
bindInstrumentationScope,
|
|
8
9
|
createInstrumentationScope,
|
|
@@ -356,4 +357,31 @@ describe("fetch execution scopes", () => {
|
|
|
356
357
|
setup.active = false;
|
|
357
358
|
}
|
|
358
359
|
});
|
|
360
|
+
|
|
361
|
+
test("parallel service setup scopes keep wrapping after a sibling fails", async () => {
|
|
362
|
+
const gate = deferred();
|
|
363
|
+
const slowStarted = deferred();
|
|
364
|
+
const setup = startServices([{ name: "bad" }, { name: "slow" }], async ({ name }) => {
|
|
365
|
+
const hookScope = createInstrumentationScope(false);
|
|
366
|
+
try {
|
|
367
|
+
await runInstrumented(hookScope, async () => {
|
|
368
|
+
if (name === "bad") { await slowStarted.promise; throw new Error("failed hook"); }
|
|
369
|
+
slowStarted.resolve();
|
|
370
|
+
await gate.promise;
|
|
371
|
+
const res = await fetch(url("/service-setup")) as unknown as WrappedResponse;
|
|
372
|
+
expect(res.status.unwrap()).toBe(200);
|
|
373
|
+
expect((await res.json<{ value: number }>()).value.unwrap()).toBe(42);
|
|
374
|
+
await runUninstrumented(async () => {
|
|
375
|
+
expect((await fetch(url("/fake"))).status).toBe(200);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
} finally { hookScope.active = false; }
|
|
379
|
+
});
|
|
380
|
+
const outcome = setup.catch(error => error);
|
|
381
|
+
await slowStarted.promise;
|
|
382
|
+
expect((await fetch(url("/background"))).status).toBe(200);
|
|
383
|
+
gate.resolve();
|
|
384
|
+
expect((await outcome).message).toBe("failed hook");
|
|
385
|
+
expect(stopRecording()).toEqual([]);
|
|
386
|
+
});
|
|
359
387
|
});
|
|
@@ -1,62 +1,17 @@
|
|
|
1
1
|
/** A short-lived, read-only OCI endpoint over the host containerd content store.
|
|
2
2
|
* k3s fetches only missing blobs and unpacks only missing snapshots. No Docker
|
|
3
3
|
* archive, staging registry, or second persistent copy of application blobs. */
|
|
4
|
-
import {
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
7
|
import type { SpectestContext } from "../index.js";
|
|
9
8
|
import { canonicalImageReference, type PreparedComponentImage } from "./component-images.js";
|
|
10
9
|
import { waitForK3sRuntime } from "./k3s-prepull.js";
|
|
10
|
+
import { hostImage } from "./built-image.js";
|
|
11
11
|
import { waitForAll } from "./concurrency.js";
|
|
12
12
|
|
|
13
|
-
interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
|
|
14
|
-
interface Manifest { schemaVersion: number; mediaType: string; config: Descriptor; layers: Descriptor[]; annotations?: Record<string, string> }
|
|
15
|
-
const digest = (bytes: Uint8Array) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
16
|
-
const descriptor = (bytes: Uint8Array, mediaType: string): Descriptor => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
17
|
-
const run = promisify(execFile);
|
|
18
13
|
const HOST_CTR = ["--address", "/run/containerd/containerd.sock", "--namespace", "moby"];
|
|
19
14
|
|
|
20
|
-
async function hostContent(id: string): Promise<Buffer> {
|
|
21
|
-
if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error(`invalid content digest ${id}`);
|
|
22
|
-
const result = await run("ctr", [...HOST_CTR, "content", "get", id], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
|
|
23
|
-
if (digest(result.stdout) !== id) throw new Error(`corrupt image metadata ${id}`);
|
|
24
|
-
return result.stdout;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Preserve the original index/manifest bytes and all application descriptors. */
|
|
28
|
-
async function hostImage(source: string) {
|
|
29
|
-
const name = canonicalImageReference(source);
|
|
30
|
-
const { stdout } = await run("ctr", [...HOST_CTR, "images", "ls", `name==${name}`], { timeout: 30_000 });
|
|
31
|
-
const found = stdout.split("\n").map(line => line.trim().split(/\s+/)).find(row => row[0] === name)?.[2];
|
|
32
|
-
if (!found) throw new Error(`built image ${name} is missing from the VM container store`);
|
|
33
|
-
const metadata = new Map<string, Buffer>();
|
|
34
|
-
const allowed = new Map<string, Descriptor>();
|
|
35
|
-
let configDigest: string | undefined;
|
|
36
|
-
async function visit(id: string, selected: boolean, depth = 0): Promise<Descriptor> {
|
|
37
|
-
if (depth > 4) throw new Error(`built image ${name} has a recursive manifest index`);
|
|
38
|
-
const bytes = await hostContent(id);
|
|
39
|
-
const obj = JSON.parse(bytes.toString());
|
|
40
|
-
const desc = descriptor(bytes, obj.mediaType);
|
|
41
|
-
metadata.set(id, bytes);
|
|
42
|
-
allowed.set(id, desc);
|
|
43
|
-
if (obj.config && Array.isArray(obj.layers)) {
|
|
44
|
-
const manifest = obj as Manifest;
|
|
45
|
-
for (const blob of [manifest.config, ...manifest.layers]) allowed.set(blob.digest, blob);
|
|
46
|
-
if (selected) configDigest = manifest.config.digest;
|
|
47
|
-
} else if (Array.isArray(obj.manifests)) {
|
|
48
|
-
for (const entry of obj.manifests as Descriptor[]) {
|
|
49
|
-
const matches = !entry.platform || (entry.platform.os === "linux" && entry.platform.architecture === (process.arch === "arm64" ? "arm64" : "amd64"));
|
|
50
|
-
await visit(entry.digest, selected && matches, depth + 1);
|
|
51
|
-
}
|
|
52
|
-
} else throw new Error(`built image ${name} has invalid image metadata`);
|
|
53
|
-
return desc;
|
|
54
|
-
}
|
|
55
|
-
const target = await visit(found, true);
|
|
56
|
-
if (!configDigest) throw new Error(`built image ${name} has no image for this VM's platform`);
|
|
57
|
-
return { target, metadata, allowed, configDigest };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
15
|
export async function loadK3sBuildImages(
|
|
61
16
|
service: string, images: readonly PreparedComponentImage[],
|
|
62
17
|
gateway: string, exec: SpectestContext["exec"],
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { seedRegistryImage } from "./registry-seed";
|
|
7
|
+
import type { Descriptor } from "./built-image";
|
|
8
|
+
|
|
9
|
+
const roots: string[] = [];
|
|
10
|
+
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
|
11
|
+
|
|
12
|
+
async function fixture() {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), "registry-seed-")); roots.push(root);
|
|
14
|
+
const content = `${root}/content`; await mkdir(content);
|
|
15
|
+
const metadata = new Map<string, Buffer>(), allowed = new Map<string, Descriptor>();
|
|
16
|
+
async function blob(value: string, mediaType: string, manifest = false) {
|
|
17
|
+
const bytes = Buffer.from(value);
|
|
18
|
+
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
19
|
+
const descriptor = { digest, size: bytes.length, mediaType };
|
|
20
|
+
await writeFile(`${content}/${digest.slice(7)}`, bytes);
|
|
21
|
+
allowed.set(digest, descriptor);
|
|
22
|
+
if (manifest) metadata.set(digest, bytes);
|
|
23
|
+
return descriptor;
|
|
24
|
+
}
|
|
25
|
+
const config = await blob('{"rootfs":{"type":"layers","diff_ids":[]}}', "application/vnd.oci.image.config.v1+json");
|
|
26
|
+
const layer = await blob("layer bytes", "application/vnd.oci.image.layer.v1.tar");
|
|
27
|
+
const manifestType = "application/vnd.oci.image.manifest.v1+json";
|
|
28
|
+
const manifest = await blob(JSON.stringify({ schemaVersion: 2, mediaType: manifestType, config, layers: [layer] }), manifestType, true);
|
|
29
|
+
const indexType = "application/vnd.oci.image.index.v1+json";
|
|
30
|
+
const target = await blob(JSON.stringify({ schemaVersion: 2, mediaType: indexType, manifests: [manifest] }), indexType, true);
|
|
31
|
+
return { root, content, layer, image: { target, metadata, allowed, configDigest: config.digest } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("seeding preserves index/manifest bytes and shares blobs while repository state stays separate", async () => {
|
|
35
|
+
const { root, content, image } = await fixture();
|
|
36
|
+
for (const name of ["a", "b"]) {
|
|
37
|
+
await seedRegistryImage(`${root}/${name}`, content, "registry.test/team/code:latest", image, content);
|
|
38
|
+
}
|
|
39
|
+
const base = "/docker/registry/v2";
|
|
40
|
+
for (const id of image.allowed.keys()) {
|
|
41
|
+
const hex = id.slice(7), path = `${base}/blobs/sha256/${hex.slice(0, 2)}/${hex}/data`;
|
|
42
|
+
expect(await readFile(`${root}/a${path}`)).toEqual(await readFile(`${content}/${hex}`));
|
|
43
|
+
expect((await stat(`${root}/a${path}`)).ino).toBe((await stat(`${root}/b${path}`)).ino);
|
|
44
|
+
}
|
|
45
|
+
const tag = `${base}/repositories/team/code/_manifests/tags/latest/current/link`;
|
|
46
|
+
expect(await readFile(`${root}/a${tag}`, "utf8")).toBe(image.target.digest);
|
|
47
|
+
await writeFile(`${root}/a${tag}`, `sha256:${"f".repeat(64)}`);
|
|
48
|
+
expect(await readFile(`${root}/b${tag}`, "utf8")).toBe(image.target.digest);
|
|
49
|
+
// A new environment seeded against the same cached blobs has only its declaration.
|
|
50
|
+
await seedRegistryImage(`${root}/fresh`, content, "registry.test/team/code:latest", image, content);
|
|
51
|
+
expect(await readdir(`${root}/fresh${base}/repositories/team/code/_manifests/tags`)).toEqual(["latest"]);
|
|
52
|
+
expect(await readFile(`${root}/fresh${tag}`, "utf8")).toBe(image.target.digest);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an incomplete cached build cannot publish a usable tag", async () => {
|
|
56
|
+
const { root, content, image, layer } = await fixture();
|
|
57
|
+
await writeFile(`${content}/${layer.digest.slice(7)}`, "truncated");
|
|
58
|
+
await expect(seedRegistryImage(`${root}/data`, content, "registry.test/code:latest", image, content)).rejects.toThrow("incomplete");
|
|
59
|
+
expect(await Bun.file(`${root}/data/docker/registry/v2/repositories/code/_manifests/tags/latest/current/link`).exists()).toBe(false);
|
|
60
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Distribution v2 publication. Blob data stays in containerd, mounted
|
|
2
|
+
* read-only in the registry. Repository metadata and uploads MUST live on
|
|
3
|
+
* the environment rootfs, outside persistent image/build caches. */
|
|
4
|
+
import { mkdir, stat, symlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { hostImage } from "./built-image.js";
|
|
7
|
+
import { canonicalImageReference, type PreparedComponentImage } from "./component-images.js";
|
|
8
|
+
|
|
9
|
+
export const REGISTRY_DATA = "/var/lib/spectest-registry";
|
|
10
|
+
export const REGISTRY_CONTENT = "/spectest-registry-content";
|
|
11
|
+
export const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
|
|
12
|
+
export const REGISTRY_IMAGE = "registry:2.8.3";
|
|
13
|
+
export const registryDirectory = (service: string) => `/var/lib/spectest-registries/${service}`;
|
|
14
|
+
|
|
15
|
+
/** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
|
|
16
|
+
* before starting the registry so it cannot cache partial publication.
|
|
17
|
+
* The prepared build's containerd image record retains the referenced blobs. */
|
|
18
|
+
export async function seedRegistryImage(
|
|
19
|
+
directory: string, contentDirectory: string, reference: string,
|
|
20
|
+
image: Awaited<ReturnType<typeof hostImage>>,
|
|
21
|
+
mountedContent = REGISTRY_CONTENT,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const canonical = canonicalImageReference(reference);
|
|
24
|
+
if (canonical.includes("@")) throw new Error("registry publication must name a tag");
|
|
25
|
+
const repository = canonical.slice(canonical.indexOf("/") + 1, canonical.lastIndexOf(":"));
|
|
26
|
+
const tag = canonical.slice(canonical.lastIndexOf(":") + 1);
|
|
27
|
+
const root = `${directory}/docker/registry/v2`;
|
|
28
|
+
const repo = `${root}/repositories/${repository}`;
|
|
29
|
+
const link = async (path: string, value: string) => {
|
|
30
|
+
await mkdir(dirname(path), { recursive: true });
|
|
31
|
+
await writeFile(path, value);
|
|
32
|
+
};
|
|
33
|
+
for (const [id, descriptor] of image.allowed) {
|
|
34
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error(`invalid registry seed digest ${id}`);
|
|
35
|
+
const hex = id.slice(7);
|
|
36
|
+
const info = await stat(`${contentDirectory}/${hex}`);
|
|
37
|
+
if (!info.isFile() || info.size !== descriptor.size) throw new Error(`missing or incomplete registry seed blob ${id}`);
|
|
38
|
+
const destination = `${root}/blobs/sha256/${hex.slice(0, 2)}/${hex}/data`;
|
|
39
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
40
|
+
try { await symlink(`${mountedContent}/${hex}`, destination); }
|
|
41
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
42
|
+
await link(`${repo}/_layers/sha256/${hex}/link`, id);
|
|
43
|
+
if (image.metadata.has(id)) await link(`${repo}/_manifests/revisions/sha256/${hex}/link`, id);
|
|
44
|
+
}
|
|
45
|
+
const id = image.target.digest;
|
|
46
|
+
await link(`${repo}/_manifests/tags/${tag}/index/sha256/${id.slice(7)}/link`, id);
|
|
47
|
+
await link(`${repo}/_manifests/tags/${tag}/current/link`, id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function seedRegistry(
|
|
51
|
+
service: string, contentDirectory: string, images: readonly PreparedComponentImage[],
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
// Sequential metadata updates avoid races when several tags share blobs.
|
|
54
|
+
// Builds themselves still run concurrently with all environment preparation.
|
|
55
|
+
for (const image of images) {
|
|
56
|
+
await image.ready;
|
|
57
|
+
const built = await hostImage(image.source);
|
|
58
|
+
await seedRegistryImage(registryDirectory(service), contentDirectory, image.reference, built);
|
|
59
|
+
console.log(`[k3s-registry] ${service}: ${image.reference} @ ${built.target.digest} (cached blobs, no export)`);
|
|
60
|
+
}
|
|
61
|
+
}
|