@specific.dev/spectest 0.27.0 → 0.28.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/browser.d.ts +2 -2
- package/dist/browser.js +1 -1
- package/dist/components/k3s.d.ts +29 -0
- package/dist/components/k3s.js +69 -12
- package/dist/components/replayFake.js +2 -2
- package/dist/daemon.js +426 -38
- package/dist/index.d.ts +156 -11
- package/dist/recorder.d.ts +12 -1
- package/package.json +1 -1
package/dist/browser.d.ts
CHANGED
|
@@ -154,8 +154,8 @@ export interface Browser {
|
|
|
154
154
|
/**
|
|
155
155
|
* Capture a PNG screenshot of the viewport and upload it as a downloadable
|
|
156
156
|
* **artifact**. Resolves to the artifact's `art_…` id — fetch it locally
|
|
157
|
-
* with `spectest artifact download <id>`. Eval-only (`
|
|
158
|
-
*
|
|
157
|
+
* with `spectest artifact download <id>`. Eval-only (`spectest env eval`);
|
|
158
|
+
* throws with a clear message during test runs.
|
|
159
159
|
*/
|
|
160
160
|
screenshot(): Promise<string>;
|
|
161
161
|
/**
|
package/dist/browser.js
CHANGED
|
@@ -1187,7 +1187,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1187
1187
|
const register = recorder?.registerArtifact;
|
|
1188
1188
|
if (!register) {
|
|
1189
1189
|
throw new Error("screenshot() uploads the capture as a downloadable artifact and is " +
|
|
1190
|
-
"currently only available inside an eval (
|
|
1190
|
+
"currently only available inside an eval (`spectest env eval`) — " +
|
|
1191
1191
|
"it cannot be used in test runs.");
|
|
1192
1192
|
}
|
|
1193
1193
|
// Raw CDP rather than page.screenshot(): captures the viewport
|
package/dist/components/k3s.d.ts
CHANGED
|
@@ -7,8 +7,37 @@ export interface K3sOptions {
|
|
|
7
7
|
/**
|
|
8
8
|
* Extra arguments appended to `k3s server`. Useful for `--tls-san=...`,
|
|
9
9
|
* additional `--disable=<addon>`, custom CIDRs, etc.
|
|
10
|
+
*
|
|
11
|
+
* Two values are **rejected** rather than silently ignored, because the
|
|
12
|
+
* component owns those decisions and passing them here has no effect:
|
|
13
|
+
* `--disable=traefik` (use `traefik: false`) and `--disable=servicelb`
|
|
14
|
+
* (use `loadBalancer: false`).
|
|
10
15
|
*/
|
|
11
16
|
extraArgs?: string[];
|
|
17
|
+
/**
|
|
18
|
+
* Install the component's own Traefik ingress controller (hostNetwork,
|
|
19
|
+
* default IngressClass, wired to the in-VM CA when `ingressDomains` is
|
|
20
|
+
* set). Default `true`.
|
|
21
|
+
*
|
|
22
|
+
* Set `false` to run a cluster with no ingress controller at all — for
|
|
23
|
+
* a project that installs its own (via Helm, an operator, or a raw
|
|
24
|
+
* manifest) and wants the port to itself. The bundled k3s Traefik is
|
|
25
|
+
* disabled either way, so `false` really does mean none.
|
|
26
|
+
*/
|
|
27
|
+
traefik?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Run k3s's ServiceLB (klipper-lb) so `type: LoadBalancer` Services are
|
|
30
|
+
* assigned an address instead of sitting at `<pending>` forever.
|
|
31
|
+
* Default `true`.
|
|
32
|
+
*
|
|
33
|
+
* The assigned address is the **node IP** — the k3s container's own IP
|
|
34
|
+
* on `spectest-net` — so a LoadBalancer on port 8080 is reachable from
|
|
35
|
+
* a test or a peer service at `<cluster-key>.internal:8080`. Because
|
|
36
|
+
* klipper-lb binds the port on the node, a LoadBalancer that asks for
|
|
37
|
+
* port 80 or 443 collides with the component's hostNetwork Traefik;
|
|
38
|
+
* either pick another port or pass `traefik: false`.
|
|
39
|
+
*/
|
|
40
|
+
loadBalancer?: boolean;
|
|
12
41
|
/**
|
|
13
42
|
* Readiness probe timeout in seconds. k3s on a warm image is ready in
|
|
14
43
|
* a few seconds; the first cold start of an env (image pull + cluster
|
package/dist/components/k3s.js
CHANGED
|
@@ -10,6 +10,31 @@ import { readRaw, readTag, wrap } from "../inspect.js";
|
|
|
10
10
|
import { recorderAnnotate, recorderRemove } from "../recorder.js";
|
|
11
11
|
/** Port the in-cluster registry listens on (plain HTTP). */
|
|
12
12
|
const K3S_REGISTRY_PORT = 5000;
|
|
13
|
+
/**
|
|
14
|
+
* `extraArgs` entries the component overrides anyway. Passing one of
|
|
15
|
+
* these looks like it works — the flag really is appended to the k3s
|
|
16
|
+
* command line — but the component's own behaviour is what decides the
|
|
17
|
+
* outcome, so the cluster comes up contradicting the argument. Failing
|
|
18
|
+
* at load time costs a second; discovering it costs a boot cycle.
|
|
19
|
+
*/
|
|
20
|
+
const OVERRIDDEN_EXTRA_ARGS = {
|
|
21
|
+
traefik: "the component installs its own Traefik (hostNetwork) after the cluster is up — " +
|
|
22
|
+
"pass `traefik: false` to have no ingress controller at all",
|
|
23
|
+
servicelb: "ServiceLB is controlled by the `loadBalancer` option — pass `loadBalancer: false` to disable it",
|
|
24
|
+
};
|
|
25
|
+
function assertUsableExtraArgs(extra) {
|
|
26
|
+
for (const arg of extra) {
|
|
27
|
+
const m = /^--disable[=\s]+(.+)$/.exec(arg.trim());
|
|
28
|
+
const addon = m?.[1]?.trim();
|
|
29
|
+
if (!addon)
|
|
30
|
+
continue;
|
|
31
|
+
const why = OVERRIDDEN_EXTRA_ARGS[addon];
|
|
32
|
+
if (why) {
|
|
33
|
+
throw new Error(`k3s(): extraArgs cannot control "${addon}" — ${why}. ` +
|
|
34
|
+
`(Remove ${JSON.stringify(arg)} from extraArgs.)`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
13
38
|
function runProcess(cmd, args, timeoutMs = 30_000) {
|
|
14
39
|
return new Promise((resolve, reject) => {
|
|
15
40
|
const cp = nodeSpawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -723,7 +748,7 @@ async function waitForDeployment(clusterName, helpers, deployment, timeoutMs) {
|
|
|
723
748
|
* reachable over HTTPS with a cert the test framework already trusts.
|
|
724
749
|
*/
|
|
725
750
|
async function setupK3sCluster(name, helpers, opts) {
|
|
726
|
-
const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
|
|
751
|
+
const tlsEnabled = opts.traefik && opts.ingressDomains.length > 0 && caPresent();
|
|
727
752
|
if (tlsEnabled) {
|
|
728
753
|
const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
|
|
729
754
|
// Apply the cert Secret + dynamic-config ConfigMap before the
|
|
@@ -748,12 +773,15 @@ async function setupK3sCluster(name, helpers, opts) {
|
|
|
748
773
|
},
|
|
749
774
|
});
|
|
750
775
|
}
|
|
751
|
-
|
|
776
|
+
if (opts.traefik)
|
|
777
|
+
await helpers.apply(buildTraefikManifest(tlsEnabled));
|
|
752
778
|
if (opts.registry)
|
|
753
779
|
await helpers.apply(REGISTRY_MANIFEST);
|
|
754
780
|
// Both rollouts proceed independently inside the cluster — wait on them
|
|
755
781
|
// concurrently (they used to serialize, wasting up to a rollout's tail).
|
|
756
|
-
const waits =
|
|
782
|
+
const waits = opts.traefik
|
|
783
|
+
? [waitForDeployment(name, helpers, "traefik", 120_000)]
|
|
784
|
+
: [];
|
|
757
785
|
if (opts.registry) {
|
|
758
786
|
waits.push(waitForDeployment(name, helpers, "spectest-registry", 120_000));
|
|
759
787
|
}
|
|
@@ -891,7 +919,10 @@ const DEFAULT_K3S_VERSION = "v1.32.1-k3s1";
|
|
|
891
919
|
export function k3s(opts = {}) {
|
|
892
920
|
const version = opts.version ?? DEFAULT_K3S_VERSION;
|
|
893
921
|
const extra = opts.extraArgs ?? [];
|
|
922
|
+
assertUsableExtraArgs(extra);
|
|
894
923
|
const registryEnabled = opts.registry !== false;
|
|
924
|
+
const traefikEnabled = opts.traefik !== false;
|
|
925
|
+
const loadBalancerEnabled = opts.loadBalancer !== false;
|
|
895
926
|
// Wildcard ingress domains. Drives both the `provides(... dnsName)`
|
|
896
927
|
// wiring below and (when non-empty) the CA-signed TLS default cert that
|
|
897
928
|
// setupK3sCluster mints so these domains are reachable over HTTPS.
|
|
@@ -917,14 +948,26 @@ export function k3s(opts = {}) {
|
|
|
917
948
|
"--resolv-conf=/run/spectest-resolv.conf",
|
|
918
949
|
// metrics-server isn't useful in a test cluster.
|
|
919
950
|
"--disable=metrics-server",
|
|
920
|
-
// traefik
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
951
|
+
// The bundled traefik is disabled because we install our own with
|
|
952
|
+
// hostNetwork (see setupK3sCluster) — ours is wired to the in-VM CA
|
|
953
|
+
// for `ingressDomains` TLS and to a fixed IngressClass. Opt out of
|
|
954
|
+
// ours entirely with `k3s({ traefik: false })`; passing
|
|
955
|
+
// `--disable=traefik` via `extraArgs` does NOT do that (it only
|
|
956
|
+
// re-disables the bundled one, which is already off) and is rejected
|
|
957
|
+
// below rather than silently ignored.
|
|
926
958
|
"--disable=traefik",
|
|
927
|
-
|
|
959
|
+
// ServiceLB (klipper-lb) stays ENABLED, so `type: LoadBalancer`
|
|
960
|
+
// Services get an address and work. It was disabled for years
|
|
961
|
+
// because klipper-lb binds its ports with a CNI portmap hostPort,
|
|
962
|
+
// and portmap's iptables-nft rules need the `xt_comment` netfilter
|
|
963
|
+
// match — absent from the hosted provider's stock kernel, which made
|
|
964
|
+
// every LoadBalancer hang at <pending> with no svclb DaemonSet. We
|
|
965
|
+
// build the guest kernel ourselves now and it carries
|
|
966
|
+
// CONFIG_NETFILTER_XT_MATCH_COMMENT (see
|
|
967
|
+
// scripts/local-vms-kernel-additions.config), so the constraint is
|
|
968
|
+
// gone. local-storage stays enabled too: a controller pod that binds
|
|
969
|
+
// no host ports.
|
|
970
|
+
...(loadBalancerEnabled ? [] : ["--disable=servicelb"]),
|
|
928
971
|
// Pod CIDR MUST avoid 10.42.0.0/16: that's the spectest-br0 host
|
|
929
972
|
// bridge subnet, whose gateway 10.42.0.1 fronts the host image caches
|
|
930
973
|
// (zot :5000-5007, buildkitd :1234). k3s's *default* pod CIDR is also
|
|
@@ -959,6 +1002,16 @@ export function k3s(opts = {}) {
|
|
|
959
1002
|
"printf 'nameserver %s\\noptions ndots:0\\n' \"$GW\" > /run/spectest-resolv.conf; " +
|
|
960
1003
|
"else echo 'spectest: no default gateway found; k3s pod DNS for peer services will not resolve' >&2; " +
|
|
961
1004
|
": > /run/spectest-resolv.conf; fi; " +
|
|
1005
|
+
// Make the root mount shared. A CSI node plugin bind-mounts volumes
|
|
1006
|
+
// under /var/lib/kubelet and needs that propagation to reach the
|
|
1007
|
+
// kubelet and the workload pod; docker gives a container a private
|
|
1008
|
+
// root, so without this every CSI node plugin fails to publish and
|
|
1009
|
+
// any snapshot/PVC-backed storage test is impossible. `kind` does the
|
|
1010
|
+
// same in its entrypoint for the same reason. Best-effort: on a
|
|
1011
|
+
// kernel/runtime that refuses it, the cluster still boots — only CSI
|
|
1012
|
+
// is affected.
|
|
1013
|
+
"mount --make-rshared / 2>/dev/null || " +
|
|
1014
|
+
"echo 'spectest: could not make / rshared; CSI node plugins may fail to publish volumes' >&2; " +
|
|
962
1015
|
`exec ${serverArgs}`;
|
|
963
1016
|
// Plain /readyz probe. On a warm zot cache the cluster's images are
|
|
964
1017
|
// already local, so the first boot completes in seconds; the
|
|
@@ -972,10 +1025,13 @@ export function k3s(opts = {}) {
|
|
|
972
1025
|
tmpfs: ["/run", "/var/run"],
|
|
973
1026
|
cgroupns: "host",
|
|
974
1027
|
// 80/443 are advisory — peer services and host code reach them via
|
|
975
|
-
// the k3s container's IP.
|
|
976
|
-
// the container's netns
|
|
1028
|
+
// the k3s container's IP. The component's Traefik binds them directly
|
|
1029
|
+
// (hostNetwork, so it shares the container's netns). 5000 is the
|
|
977
1030
|
// in-cluster registry (hostNetwork pod bound to the container netns),
|
|
978
1031
|
// reached by peers at the cluster's own `<key>.internal:5000` alias.
|
|
1032
|
+
// A `type: LoadBalancer` Service's ports are bound by klipper-lb in
|
|
1033
|
+
// the same netns and are reachable the same way, without appearing
|
|
1034
|
+
// in this list (it's documentation, not a firewall).
|
|
979
1035
|
ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
|
|
980
1036
|
// NOTE: do NOT mount /var/lib/rancher/k3s/agent/containerd as a cache
|
|
981
1037
|
// volume. It was tried (to spare a recreated cluster re-pulling its
|
|
@@ -1002,6 +1058,7 @@ export function k3s(opts = {}) {
|
|
|
1002
1058
|
await setupK3sCluster(name, helpers, {
|
|
1003
1059
|
registry: registryEnabled,
|
|
1004
1060
|
ingressDomains,
|
|
1061
|
+
traefik: traefikEnabled,
|
|
1005
1062
|
});
|
|
1006
1063
|
},
|
|
1007
1064
|
helpers: async ({ name, exec }) => {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// - RECORD (MITM): the request is forwarded to the REAL host and the
|
|
20
20
|
// request/response pair is captured (decoded, redacted), and the real
|
|
21
21
|
// response is returned to the app so a manual session behaves like
|
|
22
|
-
// production. This runs under `
|
|
22
|
+
// production. This runs under `spectest env eval` against a manual env.
|
|
23
23
|
//
|
|
24
24
|
// Because the fake's hostname IS the real host, the in-VM resolver points
|
|
25
25
|
// that name at the daemon — so a naive `fetch("https://api.stripe.com")`
|
|
@@ -509,7 +509,7 @@ export function replayFake(opts) {
|
|
|
509
509
|
if (missing.length > 0) {
|
|
510
510
|
const refs = [...new Set(missing)];
|
|
511
511
|
return new Response(`replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
|
|
512
|
-
`(configure them on the project's Secrets page, and record via
|
|
512
|
+
`(configure them on the project's Secrets page, and record via \`spectest env eval\`).\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
513
513
|
}
|
|
514
514
|
// Resolve the REAL host's IP via an external resolver so we don't loop
|
|
515
515
|
// back into the daemon (the in-VM resolver answers our own gateway for
|
package/dist/daemon.js
CHANGED
|
@@ -293,6 +293,38 @@ function shxStream(file, args, timeoutMs, env, onLine) {
|
|
|
293
293
|
}
|
|
294
294
|
});
|
|
295
295
|
}
|
|
296
|
+
// ── Boot log ─────────────────────────────────────────────────────────────
|
|
297
|
+
// Everything the daemon logs while bringing the environment up, including
|
|
298
|
+
// `console.log` from service `setup` hooks and project `setup`. Those used
|
|
299
|
+
// to reach only the VM's journal, so on a *successful* boot they were
|
|
300
|
+
// invisible — the one case where a long bootstrap most needs explaining.
|
|
301
|
+
// Kept in daemon memory (so it rides snapshots like everything else) and
|
|
302
|
+
// served by GET /boot-log; capped so a chatty setup can't grow unbounded.
|
|
303
|
+
const BOOT_LOG_MAX_BYTES = 512 * 1024;
|
|
304
|
+
const BOOT_LOG = {
|
|
305
|
+
lines: [],
|
|
306
|
+
bytes: 0,
|
|
307
|
+
dropped: 0,
|
|
308
|
+
};
|
|
309
|
+
/** A {@link LineSink} that appends to the capped boot log. */
|
|
310
|
+
const BOOT_LOG_SINK = {
|
|
311
|
+
push(line) {
|
|
312
|
+
BOOT_LOG.lines.push(line);
|
|
313
|
+
BOOT_LOG.bytes += line.length;
|
|
314
|
+
// Drop from the front: the tail is what explains where a boot is now.
|
|
315
|
+
while (BOOT_LOG.bytes > BOOT_LOG_MAX_BYTES && BOOT_LOG.lines.length > 1) {
|
|
316
|
+
const gone = BOOT_LOG.lines.shift();
|
|
317
|
+
BOOT_LOG.bytes -= gone.length;
|
|
318
|
+
BOOT_LOG.dropped += 1;
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
function bootLogText() {
|
|
323
|
+
const head = BOOT_LOG.dropped > 0
|
|
324
|
+
? `[spectest: ${BOOT_LOG.dropped} earlier line(s) dropped — boot log capped at ${BOOT_LOG_MAX_BYTES} bytes]\n`
|
|
325
|
+
: "";
|
|
326
|
+
return head + BOOT_LOG.lines.join("");
|
|
327
|
+
}
|
|
296
328
|
let BOOTSTRAP_PROGRESS = null;
|
|
297
329
|
function progressInit(services) {
|
|
298
330
|
BOOTSTRAP_PROGRESS = {
|
|
@@ -517,6 +549,63 @@ async function ensureFiles(svc) {
|
|
|
517
549
|
}
|
|
518
550
|
return flags;
|
|
519
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Mint each `svc.certificates` entry from the in-VM root CA and return
|
|
554
|
+
* `--volume` flags bind-mounting the PEMs into the container, using the
|
|
555
|
+
* same pre-entrypoint injection point as {@link ensureFiles}.
|
|
556
|
+
*
|
|
557
|
+
* This is what lets a service terminate TLS *itself* with a certificate
|
|
558
|
+
* the environment already trusts — the `tls` field can't help there,
|
|
559
|
+
* since it terminates in the daemon and proxies plain HTTP to the
|
|
560
|
+
* upstream. Anything that routes by SNI, verifies a client cert, or
|
|
561
|
+
* speaks a protocol with its own TLS handshake needs the key material.
|
|
562
|
+
*
|
|
563
|
+
* Minted fresh on every bootstrap rather than cached: certs are cheap
|
|
564
|
+
* (~50 ms), and a stale one outliving a CA rotation would fail in a way
|
|
565
|
+
* that reads as a code bug.
|
|
566
|
+
*/
|
|
567
|
+
async function ensureCertificates(svc) {
|
|
568
|
+
const flags = [];
|
|
569
|
+
const certs = svc.certificates ?? [];
|
|
570
|
+
if (certs.length === 0)
|
|
571
|
+
return flags;
|
|
572
|
+
if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
|
|
573
|
+
throw new Error(`service "${svc.name}": certificates require the in-VM root CA at ${CA_PATH}`);
|
|
574
|
+
}
|
|
575
|
+
const dir = path.join(WORKSPACE, ".spectest", "certs", svc.name);
|
|
576
|
+
await fs.mkdir(dir, { recursive: true });
|
|
577
|
+
for (const [i, c] of certs.entries()) {
|
|
578
|
+
for (const [label, p] of [
|
|
579
|
+
["certPath", c.certPath],
|
|
580
|
+
["keyPath", c.keyPath],
|
|
581
|
+
...(c.caPath ? [["caPath", c.caPath]] : []),
|
|
582
|
+
]) {
|
|
583
|
+
if (!p.startsWith("/")) {
|
|
584
|
+
throw new Error(`service "${svc.name}": certificate ${label} ${JSON.stringify(p)} must be absolute`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const hostnames = c.hostnames.map((h) => h.replaceAll("{{SPECTEST_SERVICE}}", svc.name));
|
|
588
|
+
if (hostnames.length === 0) {
|
|
589
|
+
throw new Error(`service "${svc.name}": certificate entry ${i} lists no hostnames`);
|
|
590
|
+
}
|
|
591
|
+
const { cert, key } = await generateHostCert(`${svc.name}-${i}`, hostnames);
|
|
592
|
+
const certHost = path.join(dir, `${i}.crt`);
|
|
593
|
+
const keyHost = path.join(dir, `${i}.key`);
|
|
594
|
+
await fs.writeFile(certHost, cert);
|
|
595
|
+
await fs.writeFile(keyHost, key);
|
|
596
|
+
// The key's mode has to be set on the staged file: a bind mount
|
|
597
|
+
// carries the host inode's permissions straight through, and a
|
|
598
|
+
// server that checks (postgres, ssh) refuses a lax one.
|
|
599
|
+
if (c.mode)
|
|
600
|
+
await fs.chmod(keyHost, parseInt(c.mode, 8));
|
|
601
|
+
flags.push(`--volume=${certHost}:${c.certPath}:ro`);
|
|
602
|
+
flags.push(`--volume=${keyHost}:${c.keyPath}:ro`);
|
|
603
|
+
if (c.caPath)
|
|
604
|
+
flags.push(`--volume=${CA_PATH}:${c.caPath}:ro`);
|
|
605
|
+
console.log(`[bootstrap] ${svc.name}: minted certificate for ${hostnames.join(", ")}`);
|
|
606
|
+
}
|
|
607
|
+
return flags;
|
|
608
|
+
}
|
|
520
609
|
function imageTag(name) {
|
|
521
610
|
return `spectest/${name}:latest`;
|
|
522
611
|
}
|
|
@@ -537,6 +626,54 @@ const DEFAULT_DOCKERIGNORE = [
|
|
|
537
626
|
".turbo",
|
|
538
627
|
".DS_Store",
|
|
539
628
|
];
|
|
629
|
+
/** First line of the `.dockerignore` we generate ourselves, so a later
|
|
630
|
+
* bootstrap can tell our file apart from one the project ships and never
|
|
631
|
+
* mistakes its own output for user intent. */
|
|
632
|
+
const GENERATED_DOCKERIGNORE_HEADER = "# spectest-generated — do not edit (your own .dockerignore is honoured verbatim)";
|
|
633
|
+
/**
|
|
634
|
+
* The project's own `/workspace/.dockerignore`, read once per bootstrap
|
|
635
|
+
* before we write anything, or `null` when it ships none.
|
|
636
|
+
*
|
|
637
|
+
* This file is the project's statement about what belongs in a build
|
|
638
|
+
* context, and it is frequently the difference between a 30-second and a
|
|
639
|
+
* 30-minute build (the `**` + negations idiom keeps a monorepo's context
|
|
640
|
+
* down to the handful of files a Go build actually reads). We used to
|
|
641
|
+
* ignore it entirely *and* overwrite it — so a carefully minimised
|
|
642
|
+
* context silently became the whole repo, and the checked-out file was
|
|
643
|
+
* clobbered for any in-env tooling that read it too.
|
|
644
|
+
*/
|
|
645
|
+
let PROJECT_DOCKERIGNORE = null;
|
|
646
|
+
async function readProjectDockerignore() {
|
|
647
|
+
try {
|
|
648
|
+
const text = await fs.readFile(path.join(WORKSPACE, ".dockerignore"), "utf8");
|
|
649
|
+
// Ours, from a previous bootstrap of this workspace — not the project's.
|
|
650
|
+
if (text.startsWith(GENERATED_DOCKERIGNORE_HEADER))
|
|
651
|
+
return null;
|
|
652
|
+
return text;
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Ignore rules for one dockerfile build, in precedence order: our
|
|
660
|
+
* defaults, then the project's own `.dockerignore` verbatim, then that
|
|
661
|
+
* service's `exclude`.
|
|
662
|
+
*
|
|
663
|
+
* Order is load-bearing for the `**` + negations idiom — the project's
|
|
664
|
+
* `**` subsumes our defaults, its `!` lines re-include exactly what the
|
|
665
|
+
* build needs, and the per-service `exclude` still gets the last word.
|
|
666
|
+
*/
|
|
667
|
+
function serviceDockerignore(exclude) {
|
|
668
|
+
const parts = [DEFAULT_DOCKERIGNORE.join("\n")];
|
|
669
|
+
if (PROJECT_DOCKERIGNORE !== null) {
|
|
670
|
+
parts.push(`# --- from the project's .dockerignore ---\n${PROJECT_DOCKERIGNORE.trimEnd()}`);
|
|
671
|
+
}
|
|
672
|
+
if (exclude && exclude.length > 0) {
|
|
673
|
+
parts.push(`# --- from this service's exclude ---\n${exclude.join("\n")}`);
|
|
674
|
+
}
|
|
675
|
+
return parts.join("\n") + "\n";
|
|
676
|
+
}
|
|
540
677
|
function unionDockerignore(services) {
|
|
541
678
|
const seen = new Set(DEFAULT_DOCKERIGNORE);
|
|
542
679
|
const extras = [];
|
|
@@ -550,7 +687,7 @@ function unionDockerignore(services) {
|
|
|
550
687
|
}
|
|
551
688
|
}
|
|
552
689
|
}
|
|
553
|
-
return [...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n";
|
|
690
|
+
return ([GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n");
|
|
554
691
|
}
|
|
555
692
|
/// In-flight/finished dockerfile builds of this bootstrap, keyed by
|
|
556
693
|
/// sha256(dockerfile content + exclude list). Services that share an
|
|
@@ -650,14 +787,15 @@ async function buildServiceImage(name, image, tag) {
|
|
|
650
787
|
await fs.writeFile(dfPath, image.content);
|
|
651
788
|
// Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
|
|
652
789
|
// (next to the Dockerfile) in preference to the context root's
|
|
653
|
-
// `.dockerignore`, so this build sees the defaults
|
|
654
|
-
// `exclude`
|
|
655
|
-
// empties a sibling's build context. Verified
|
|
656
|
-
// and DOCKER_BUILDKIT paths (client-side
|
|
657
|
-
//
|
|
658
|
-
// the legacy non-BuildKit builder, which
|
|
659
|
-
// ignores
|
|
660
|
-
|
|
790
|
+
// `.dockerignore`, so this build sees the defaults, the project's own
|
|
791
|
+
// `.dockerignore`, and ITS OWN `exclude` — one service excluding
|
|
792
|
+
// `handhelds/**` no longer empties a sibling's build context. Verified
|
|
793
|
+
// on both the remote-buildx and DOCKER_BUILDKIT paths (client-side
|
|
794
|
+
// context filtering). The root `.dockerignore` written at bootstrap
|
|
795
|
+
// stays as the fallback for the legacy non-BuildKit builder, which
|
|
796
|
+
// predates per-Dockerfile ignores — and is written ONLY when the
|
|
797
|
+
// project ships none of its own (see readProjectDockerignore).
|
|
798
|
+
await fs.writeFile(`${dfPath}.dockerignore`, serviceDockerignore(image.exclude));
|
|
661
799
|
const useRemote = await ensureRemoteBuilder();
|
|
662
800
|
// Both the remote builder and a local buildx are BuildKit, so both emit
|
|
663
801
|
// per-step timing on stderr under `--progress=plain` (parsed below). Only
|
|
@@ -1966,6 +2104,32 @@ async function registerDnsName(hostname, target) {
|
|
|
1966
2104
|
await writeRegistry();
|
|
1967
2105
|
recordEnv({ op: "dnsName", hostname: decl.hostname, ip, durationMs: 0 }, resv);
|
|
1968
2106
|
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Mint a leaf certificate from the in-VM root CA and hand back the PEMs
|
|
2109
|
+
* — the implementation behind `ctx.certificate`.
|
|
2110
|
+
*
|
|
2111
|
+
* The value-returning counterpart to the `certificates` service field:
|
|
2112
|
+
* use that to hand a cert to a container at boot, use this when a *test*
|
|
2113
|
+
* needs the material as data — loading it into a Kubernetes Secret,
|
|
2114
|
+
* posting it to a control-plane API, driving a client-cert handshake.
|
|
2115
|
+
* The returned `ca` is the same root every service and `ctx.fetch` /
|
|
2116
|
+
* `ctx.browser()` already trust, so a server configured with these PEMs
|
|
2117
|
+
* verifies cleanly with no `rejectUnauthorized: false`.
|
|
2118
|
+
*/
|
|
2119
|
+
async function mintCertificate(hostnames) {
|
|
2120
|
+
const resv = reserveEvent();
|
|
2121
|
+
const t = Date.now();
|
|
2122
|
+
if (!Array.isArray(hostnames) || hostnames.length === 0) {
|
|
2123
|
+
throw new Error("ctx.certificate(hostnames): at least one hostname is required");
|
|
2124
|
+
}
|
|
2125
|
+
if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
|
|
2126
|
+
throw new Error(`ctx.certificate(): the in-VM root CA is missing at ${CA_PATH}`);
|
|
2127
|
+
}
|
|
2128
|
+
const { cert, key } = await generateHostCert("ctx", hostnames);
|
|
2129
|
+
const ca = await fs.readFile(CA_PATH, "utf8");
|
|
2130
|
+
recordEnv({ op: "certificate", hostnames, durationMs: Date.now() - t }, resv);
|
|
2131
|
+
return { cert, key, ca };
|
|
2132
|
+
}
|
|
1969
2133
|
// ────────────────────────────────────────────────────────────────────────
|
|
1970
2134
|
// Runtime services — containers started after bootstrap (from a test, an
|
|
1971
2135
|
// eval, project setup, or a fake handler reacting to the app under test).
|
|
@@ -2005,7 +2169,11 @@ async function startRuntimeService(spec) {
|
|
|
2005
2169
|
const imageRef = svc.image.type === "registry" ? svc.image.reference : "(dockerfile)";
|
|
2006
2170
|
try {
|
|
2007
2171
|
const { tag } = await prepareServiceImage(svc);
|
|
2008
|
-
const flags = [
|
|
2172
|
+
const flags = [
|
|
2173
|
+
...(await ensureVolumes(svc)),
|
|
2174
|
+
...(await ensureFiles(svc)),
|
|
2175
|
+
...(await ensureCertificates(svc)),
|
|
2176
|
+
];
|
|
2009
2177
|
await runContainer(svc, tag, flags, aliases);
|
|
2010
2178
|
await waitForReady(svc);
|
|
2011
2179
|
const ip = (await serviceContainerIp(svc.name)) ?? "";
|
|
@@ -2054,6 +2222,7 @@ const FAKE_CTX = {
|
|
|
2054
2222
|
startService: startRuntimeService,
|
|
2055
2223
|
stopService: stopRuntimeService,
|
|
2056
2224
|
dnsName: registerDnsName,
|
|
2225
|
+
certificate: mintCertificate,
|
|
2057
2226
|
};
|
|
2058
2227
|
/** Build (or fetch from cache) the helpers record for a fake — the
|
|
2059
2228
|
* value that ends up at `ctx.fakes.<name>`. Defaults to `{}` (a fake
|
|
@@ -2184,6 +2353,18 @@ function logBootstrapTimings(t) {
|
|
|
2184
2353
|
console.log(`[bootstrap] total ${t.totalMs}ms across ${t.services.length} service(s)`);
|
|
2185
2354
|
}
|
|
2186
2355
|
async function bootstrap() {
|
|
2356
|
+
// Tee everything this bootstrap logs — ours and every service `setup`
|
|
2357
|
+
// hook's — into the boot log, so GET /boot-log can explain a slow or
|
|
2358
|
+
// finished boot. Restored in the finally at the end.
|
|
2359
|
+
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
2360
|
+
try {
|
|
2361
|
+
return await bootstrapInner();
|
|
2362
|
+
}
|
|
2363
|
+
finally {
|
|
2364
|
+
restoreBootLog();
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
async function bootstrapInner() {
|
|
2187
2368
|
const bootStart = Date.now();
|
|
2188
2369
|
const cfg = requireLoaded().project.environment;
|
|
2189
2370
|
const services = namedServices(cfg);
|
|
@@ -2201,6 +2382,18 @@ async function bootstrap() {
|
|
|
2201
2382
|
ensureNetwork(),
|
|
2202
2383
|
(async () => {
|
|
2203
2384
|
await fs.mkdir(WORKSPACE, { recursive: true });
|
|
2385
|
+
// Read the project's own .dockerignore BEFORE we consider writing
|
|
2386
|
+
// one — every per-service ignore composes on top of it.
|
|
2387
|
+
PROJECT_DOCKERIGNORE = await readProjectDockerignore();
|
|
2388
|
+
if (PROJECT_DOCKERIGNORE !== null) {
|
|
2389
|
+
console.log("[bootstrap] using the project's .dockerignore for every build context");
|
|
2390
|
+
// Never overwrite it. BuildKit reads the per-service
|
|
2391
|
+
// `<Dockerfile>.dockerignore` (which already folds this file in),
|
|
2392
|
+
// and the legacy builder reads the project's file directly —
|
|
2393
|
+
// which is what the project asked for. Clobbering it also broke
|
|
2394
|
+
// any in-env tooling that reads it.
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2204
2397
|
await fs.writeFile(path.join(WORKSPACE, ".dockerignore"), unionDockerignore(services));
|
|
2205
2398
|
})(),
|
|
2206
2399
|
]);
|
|
@@ -2283,7 +2476,11 @@ async function bootstrap() {
|
|
|
2283
2476
|
// deps; this adds the image edge. The two compose: we run the moment
|
|
2284
2477
|
// both are satisfied, with no whole-graph barrier between them.
|
|
2285
2478
|
await prep.get(svc.name);
|
|
2286
|
-
const flags = [
|
|
2479
|
+
const flags = [
|
|
2480
|
+
...(await ensureVolumes(svc)),
|
|
2481
|
+
...(await ensureFiles(svc)),
|
|
2482
|
+
...(await ensureCertificates(svc)),
|
|
2483
|
+
];
|
|
2287
2484
|
const tag = tags.get(svc.name);
|
|
2288
2485
|
if (!tag)
|
|
2289
2486
|
throw new Error(`internal: no image tag for ${svc.name}`);
|
|
@@ -2351,6 +2548,17 @@ async function bootstrap() {
|
|
|
2351
2548
|
* env bring-up; the control plane surfaces them as a start failure.
|
|
2352
2549
|
*/
|
|
2353
2550
|
async function runProjectSetup() {
|
|
2551
|
+
// Same tee as bootstrap: a project `setup` hook's console output is
|
|
2552
|
+
// part of explaining the boot, not something to lose on success.
|
|
2553
|
+
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
2554
|
+
try {
|
|
2555
|
+
return await runProjectSetupInner();
|
|
2556
|
+
}
|
|
2557
|
+
finally {
|
|
2558
|
+
restoreBootLog();
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
async function runProjectSetupInner() {
|
|
2354
2562
|
const proj = requireLoaded().project;
|
|
2355
2563
|
if (!proj.setup)
|
|
2356
2564
|
return { ran: false, durationMs: 0 };
|
|
@@ -2370,6 +2578,7 @@ async function runProjectSetup() {
|
|
|
2370
2578
|
svc,
|
|
2371
2579
|
fakes,
|
|
2372
2580
|
dnsName: registerDnsName,
|
|
2581
|
+
certificate: mintCertificate,
|
|
2373
2582
|
startService: startRuntimeService,
|
|
2374
2583
|
stopService: stopRuntimeService,
|
|
2375
2584
|
};
|
|
@@ -2717,6 +2926,7 @@ const EXEC_FRAME_CAP_BYTES = 1024 * 1024;
|
|
|
2717
2926
|
* `opts.env`.
|
|
2718
2927
|
*/
|
|
2719
2928
|
async function openInstrumentedTerminal(service, opts, testStart, sessions, recordEvents, idScope) {
|
|
2929
|
+
assertKnownOpts("ctx.terminal", opts, TERMINAL_OPT_KEYS);
|
|
2720
2930
|
const cols = opts?.cols ?? 80;
|
|
2721
2931
|
const rows = opts?.rows ?? 24;
|
|
2722
2932
|
const session = newTerminalSession(testStart, service, opts?.command ?? "(interactive)", cols, rows, idScope);
|
|
@@ -2750,6 +2960,7 @@ const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
|
|
|
2750
2960
|
* `exitCode`, never thrown. Unlike `execInService` this records nothing —
|
|
2751
2961
|
* setup/helpers-factory time has no test timeline. */
|
|
2752
2962
|
function componentExec(service, command, opts) {
|
|
2963
|
+
assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
|
|
2753
2964
|
const argv = ["exec", "-i"];
|
|
2754
2965
|
if (opts?.cwd)
|
|
2755
2966
|
argv.push("-w", opts.cwd);
|
|
@@ -2767,21 +2978,30 @@ function componentExec(service, command, opts) {
|
|
|
2767
2978
|
const err = [];
|
|
2768
2979
|
child.stdout.on("data", (c) => out.push(c));
|
|
2769
2980
|
child.stderr.on("data", (c) => err.push(c));
|
|
2770
|
-
|
|
2981
|
+
let timedOut = false;
|
|
2982
|
+
const timer = setTimeout(() => {
|
|
2983
|
+
timedOut = true;
|
|
2984
|
+
child.kill("SIGKILL");
|
|
2985
|
+
}, timeoutMs);
|
|
2771
2986
|
child.on("error", (e) => {
|
|
2772
2987
|
clearTimeout(timer);
|
|
2773
2988
|
reject(e);
|
|
2774
2989
|
});
|
|
2775
2990
|
child.on("close", (code) => {
|
|
2776
2991
|
clearTimeout(timer);
|
|
2992
|
+
// A SIGKILL'd child reports 137, which reads like an OOM kill.
|
|
2993
|
+
// Report the `timeout(1)` convention plus a note instead, so a
|
|
2994
|
+
// wedged command is diagnosable from the result alone.
|
|
2995
|
+
const stderrText = Buffer.concat(err).toString("utf8");
|
|
2777
2996
|
resolve({
|
|
2778
2997
|
stdout: Buffer.concat(out).toString("utf8"),
|
|
2779
|
-
stderr:
|
|
2780
|
-
|
|
2998
|
+
stderr: timedOut
|
|
2999
|
+
? `${stderrText}\ntimeout after ${timeoutMs}ms`
|
|
3000
|
+
: stderrText,
|
|
3001
|
+
exitCode: timedOut ? EXEC_TIMEOUT_EXIT_CODE : (code ?? -1),
|
|
2781
3002
|
});
|
|
2782
3003
|
});
|
|
2783
|
-
|
|
2784
|
-
child.stdin.end(opts.stdin);
|
|
3004
|
+
feedStdin(child, opts?.stdin);
|
|
2785
3005
|
});
|
|
2786
3006
|
}
|
|
2787
3007
|
function componentContext() {
|
|
@@ -2933,18 +3153,68 @@ function installFetchWrapper() {
|
|
|
2933
3153
|
/** Build the `docker exec` argv for a service command. An optional `cwd`
|
|
2934
3154
|
* becomes `-w <cwd>` (the working-directory option of `ctx.exec`), so the
|
|
2935
3155
|
* command runs from that directory without it being baked into the command
|
|
2936
|
-
* string.
|
|
2937
|
-
*
|
|
2938
|
-
|
|
3156
|
+
* string. `-i` is passed whenever stdin will be piped — without it docker
|
|
3157
|
+
* attaches no stdin and the payload is silently discarded. Used by both the
|
|
3158
|
+
* buffered and streaming variants so they stay in lockstep. */
|
|
3159
|
+
function dockerExecArgs(service, command, opts) {
|
|
2939
3160
|
const args = ["exec"];
|
|
2940
|
-
if (
|
|
2941
|
-
args.push("-
|
|
3161
|
+
if (opts?.stdin !== undefined)
|
|
3162
|
+
args.push("-i");
|
|
3163
|
+
if (opts?.cwd)
|
|
3164
|
+
args.push("-w", opts.cwd);
|
|
2942
3165
|
args.push(service, "sh", "-lc", command);
|
|
2943
3166
|
return args;
|
|
2944
3167
|
}
|
|
2945
|
-
|
|
3168
|
+
/** Exit code reported when an exec is killed by its own `timeoutMs`
|
|
3169
|
+
* (the `timeout(1)` convention, matching `shx`). */
|
|
3170
|
+
const EXEC_TIMEOUT_EXIT_CODE = 124;
|
|
3171
|
+
/**
|
|
3172
|
+
* Reject option objects carrying properties we don't implement, instead
|
|
3173
|
+
* of ignoring them. TypeScript's excess-property check already flags
|
|
3174
|
+
* these on an object literal, but `spectest test`'s typecheck is
|
|
3175
|
+
* advisory and never gates a run — so a typo'd or unsupported option
|
|
3176
|
+
* (`{ stdin }` before it was supported, `{ timeout }` for `timeoutMs`)
|
|
3177
|
+
* would otherwise vanish without a trace and surface much later as
|
|
3178
|
+
* "the command got no input".
|
|
3179
|
+
*/
|
|
3180
|
+
function assertKnownOpts(fnLabel, opts, allowed) {
|
|
3181
|
+
if (!opts || typeof opts !== "object")
|
|
3182
|
+
return;
|
|
3183
|
+
const unknown = Object.keys(opts).filter((k) => !allowed.includes(k));
|
|
3184
|
+
if (unknown.length === 0)
|
|
3185
|
+
return;
|
|
3186
|
+
throw new Error(`${fnLabel}: unsupported option${unknown.length > 1 ? "s" : ""} ` +
|
|
3187
|
+
`${unknown.map((k) => JSON.stringify(k)).join(", ")} — supported: ` +
|
|
3188
|
+
`${allowed.map((k) => JSON.stringify(k)).join(", ")}`);
|
|
3189
|
+
}
|
|
3190
|
+
const EXEC_OPT_KEYS = ["cwd", "stdin", "timeoutMs"];
|
|
3191
|
+
const TERMINAL_OPT_KEYS = ["cols", "rows", "env", "command", "timeoutMs"];
|
|
3192
|
+
const POLL_OPT_KEYS = ["timeoutMs", "intervalMs"];
|
|
3193
|
+
/** Write `stdin` to the child and close the stream. A command that never
|
|
3194
|
+
* reads stdin makes the write fail with EPIPE — harmless, and it must not
|
|
3195
|
+
* take down the daemon as an unhandled 'error' event. */
|
|
3196
|
+
function feedStdin(child, stdin) {
|
|
3197
|
+
if (stdin === undefined)
|
|
3198
|
+
return;
|
|
3199
|
+
const s = child.stdin;
|
|
3200
|
+
if (!s)
|
|
3201
|
+
return;
|
|
3202
|
+
s.on("error", () => { });
|
|
3203
|
+
s.end(stdin);
|
|
3204
|
+
}
|
|
3205
|
+
function execInService(service, command, opts) {
|
|
2946
3206
|
return new Promise((resolve) => {
|
|
2947
|
-
execFile("docker", dockerExecArgs(service, command,
|
|
3207
|
+
const child = execFile("docker", dockerExecArgs(service, command, opts), { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
3208
|
+
if (timer)
|
|
3209
|
+
clearTimeout(timer);
|
|
3210
|
+
if (timedOut) {
|
|
3211
|
+
resolve({
|
|
3212
|
+
stdout: stdout.toString(),
|
|
3213
|
+
stderr: `${stderr.toString()}\ntimeout after ${opts.timeoutMs}ms`,
|
|
3214
|
+
exitCode: EXEC_TIMEOUT_EXIT_CODE,
|
|
3215
|
+
});
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
2948
3218
|
const exitCode = err && typeof err.code === "number"
|
|
2949
3219
|
? Number(err.code)
|
|
2950
3220
|
: err
|
|
@@ -2956,6 +3226,14 @@ function execInService(service, command, cwd) {
|
|
|
2956
3226
|
exitCode,
|
|
2957
3227
|
});
|
|
2958
3228
|
});
|
|
3229
|
+
let timedOut = false;
|
|
3230
|
+
const timer = opts?.timeoutMs && opts.timeoutMs > 0
|
|
3231
|
+
? setTimeout(() => {
|
|
3232
|
+
timedOut = true;
|
|
3233
|
+
child.kill("SIGKILL");
|
|
3234
|
+
}, opts.timeoutMs)
|
|
3235
|
+
: undefined;
|
|
3236
|
+
feedStdin(child, opts?.stdin);
|
|
2959
3237
|
});
|
|
2960
3238
|
}
|
|
2961
3239
|
/** `execInService` for the no-recorder contexts (`setup`/`eval`): wraps the
|
|
@@ -2963,7 +3241,8 @@ function execInService(service, command, cwd) {
|
|
|
2963
3241
|
* honest at runtime, but with no provenance (there's no event to link to).
|
|
2964
3242
|
* The recorded `ctx.exec` used during tests is `recordedExec` below. */
|
|
2965
3243
|
async function execInServiceWrapped(service, command, opts) {
|
|
2966
|
-
|
|
3244
|
+
assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
|
|
3245
|
+
const res = await execInService(service, command, opts);
|
|
2967
3246
|
return wrap(res, undefined);
|
|
2968
3247
|
}
|
|
2969
3248
|
/** Per-stream cap on the ExecResult strings the streaming variant
|
|
@@ -2984,10 +3263,10 @@ const EXEC_RESULT_CAP_BYTES = 16 * 1024 * 1024;
|
|
|
2984
3263
|
* analogue of what a terminal would have shown — while the returned
|
|
2985
3264
|
* `ExecResult` keeps them separate as before.
|
|
2986
3265
|
*/
|
|
2987
|
-
function execInServiceStreaming(service, command, onChunk,
|
|
3266
|
+
function execInServiceStreaming(service, command, onChunk, opts) {
|
|
2988
3267
|
return new Promise((resolve) => {
|
|
2989
|
-
const child = spawn("docker", dockerExecArgs(service, command,
|
|
2990
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3268
|
+
const child = spawn("docker", dockerExecArgs(service, command, opts), {
|
|
3269
|
+
stdio: [opts?.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
|
|
2991
3270
|
});
|
|
2992
3271
|
const acc = { stdout: "", stderr: "" };
|
|
2993
3272
|
const decoders = {
|
|
@@ -3012,21 +3291,41 @@ function execInServiceStreaming(service, command, onChunk, cwd) {
|
|
|
3012
3291
|
child.stdout?.on("data", (c) => drain("stdout", c));
|
|
3013
3292
|
child.stderr?.on("data", (c) => drain("stderr", c));
|
|
3014
3293
|
let settled = false;
|
|
3294
|
+
let timedOut = false;
|
|
3015
3295
|
const finish = (exitCode) => {
|
|
3016
3296
|
if (settled)
|
|
3017
3297
|
return;
|
|
3018
3298
|
settled = true;
|
|
3299
|
+
if (timer)
|
|
3300
|
+
clearTimeout(timer);
|
|
3019
3301
|
// Flush any multi-byte tail the decoders are still holding.
|
|
3020
3302
|
acc.stdout += decoders.stdout.decode();
|
|
3021
3303
|
acc.stderr += decoders.stderr.decode();
|
|
3022
|
-
|
|
3304
|
+
if (timedOut) {
|
|
3305
|
+
const note = `\ntimeout after ${opts.timeoutMs}ms`;
|
|
3306
|
+
acc.stderr += note;
|
|
3307
|
+
onChunk("stderr", note);
|
|
3308
|
+
}
|
|
3309
|
+
resolve({
|
|
3310
|
+
stdout: acc.stdout,
|
|
3311
|
+
stderr: acc.stderr,
|
|
3312
|
+
exitCode: timedOut ? EXEC_TIMEOUT_EXIT_CODE : exitCode,
|
|
3313
|
+
});
|
|
3023
3314
|
};
|
|
3315
|
+
const timer = opts?.timeoutMs && opts.timeoutMs > 0
|
|
3316
|
+
? setTimeout(() => {
|
|
3317
|
+
timedOut = true;
|
|
3318
|
+
child.kill("SIGKILL");
|
|
3319
|
+
}, opts.timeoutMs)
|
|
3320
|
+
: undefined;
|
|
3024
3321
|
// `close` (not `exit`) so both pipes are fully drained first.
|
|
3025
3322
|
child.on("close", (code) => finish(code ?? 1));
|
|
3026
3323
|
child.on("error", () => finish(1));
|
|
3324
|
+
feedStdin(child, opts?.stdin);
|
|
3027
3325
|
});
|
|
3028
3326
|
}
|
|
3029
3327
|
async function pollCall(description, fn, opts) {
|
|
3328
|
+
assertKnownOpts("ctx.poll", opts, POLL_OPT_KEYS);
|
|
3030
3329
|
const timeoutMs = opts?.timeoutMs ?? 30_000;
|
|
3031
3330
|
const intervalMs = opts?.intervalMs ?? 1_000;
|
|
3032
3331
|
const start = Date.now();
|
|
@@ -3096,14 +3395,6 @@ async function pollCall(description, fn, opts) {
|
|
|
3096
3395
|
}
|
|
3097
3396
|
throw new Error(`poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`);
|
|
3098
3397
|
}
|
|
3099
|
-
/**
|
|
3100
|
-
* Tee `console.*` output into `chunks` for the duration of a test/eval.
|
|
3101
|
-
* Bun's console writes through its own native sink, NOT
|
|
3102
|
-
* `process.stdout.write`, so patching the streams alone misses every
|
|
3103
|
-
* `console.log` the test makes — the captured `log` came back empty.
|
|
3104
|
-
* The original method still runs, so the daemon journal keeps the line.
|
|
3105
|
-
* Returns a restore function for the caller's `finally`.
|
|
3106
|
-
*/
|
|
3107
3398
|
function captureConsole(chunks) {
|
|
3108
3399
|
const methods = ["log", "info", "warn", "error", "debug"];
|
|
3109
3400
|
const orig = new Map();
|
|
@@ -3161,6 +3452,7 @@ async function runOne(testCase) {
|
|
|
3161
3452
|
// frames are presentation-only; the ExecResult (and any assertions on
|
|
3162
3453
|
// it) still sees the plain separated stdout/stderr.
|
|
3163
3454
|
const recordedExec = async (service, command, opts) => {
|
|
3455
|
+
assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
|
|
3164
3456
|
const t = Date.now();
|
|
3165
3457
|
const cwd = opts?.cwd;
|
|
3166
3458
|
const resv = reserveEvent();
|
|
@@ -3190,14 +3482,21 @@ async function runOne(testCase) {
|
|
|
3190
3482
|
// `\r\n` too keeps a CR|LF split across chunk boundaries
|
|
3191
3483
|
// harmless (`\r\r\n` renders identically).
|
|
3192
3484
|
session.pushFrame((Date.now() - t) / 1000, data.replace(/\r?\n/g, "\r\n"));
|
|
3193
|
-
},
|
|
3485
|
+
}, opts);
|
|
3194
3486
|
session.markClosed();
|
|
3195
3487
|
const stdout = truncateUtf8(res.stdout);
|
|
3196
3488
|
const stderr = truncateUtf8(res.stderr);
|
|
3489
|
+
// The piped payload is never echoed by the process, so the asciicast
|
|
3490
|
+
// can't show it — carry it on the event instead (truncated like the
|
|
3491
|
+
// output streams) so the timeline shows what the command was fed.
|
|
3492
|
+
const stdin = opts?.stdin !== undefined ? truncateUtf8(opts.stdin) : undefined;
|
|
3197
3493
|
const seq = recordExec({
|
|
3198
3494
|
service,
|
|
3199
3495
|
command,
|
|
3200
3496
|
cwd,
|
|
3497
|
+
...(stdin
|
|
3498
|
+
? { stdin: stdin.value, stdinTruncated: stdin.truncated }
|
|
3499
|
+
: {}),
|
|
3201
3500
|
exitCode: res.exitCode,
|
|
3202
3501
|
stdout: stdout.value,
|
|
3203
3502
|
stdoutTruncated: stdout.truncated,
|
|
@@ -3380,6 +3679,7 @@ async function runOne(testCase) {
|
|
|
3380
3679
|
fakes,
|
|
3381
3680
|
poll: pollCall,
|
|
3382
3681
|
dnsName: registerDnsName,
|
|
3682
|
+
certificate: mintCertificate,
|
|
3383
3683
|
startService: startRuntimeService,
|
|
3384
3684
|
stopService: stopRuntimeService,
|
|
3385
3685
|
};
|
|
@@ -3549,6 +3849,55 @@ async function ensureDeps(code) {
|
|
|
3549
3849
|
});
|
|
3550
3850
|
return missing;
|
|
3551
3851
|
}
|
|
3852
|
+
/**
|
|
3853
|
+
* Turn the bare parser error an `export default` misuse produces
|
|
3854
|
+
* ("Unexpected export") into something actionable.
|
|
3855
|
+
*
|
|
3856
|
+
* An eval snippet is imported as a **real ESM module**, so `export
|
|
3857
|
+
* default` is a module-level declaration: there can be exactly one, and
|
|
3858
|
+
* it cannot sit inside an `if`/`try`/loop body. Early-return style —
|
|
3859
|
+
* `if (!x) { out.y = z; export default out; }` — is therefore a syntax
|
|
3860
|
+
* error, and the raw message says nothing about why or what to do.
|
|
3861
|
+
*
|
|
3862
|
+
* We only ever *augment* a message that already failed, and only when
|
|
3863
|
+
* the snippet actually uses `export default`, so a genuine unrelated
|
|
3864
|
+
* syntax error keeps its own text.
|
|
3865
|
+
*/
|
|
3866
|
+
function explainEvalExportError(code, message) {
|
|
3867
|
+
if (!/unexpected export|export declarations?|'export'|"export"/i.test(message)) {
|
|
3868
|
+
return message;
|
|
3869
|
+
}
|
|
3870
|
+
// Not line-anchored: the early-return idiom this exists to explain puts
|
|
3871
|
+
// the offending export mid-line (`if (!x) { …; export default out; }`),
|
|
3872
|
+
// which an `^`-anchored match would miss entirely.
|
|
3873
|
+
const matches = [...code.matchAll(/\bexport\s+default\s/g)];
|
|
3874
|
+
if (matches.length === 0)
|
|
3875
|
+
return message;
|
|
3876
|
+
// Anything but whitespace before it on its own line means it's nested in
|
|
3877
|
+
// a block or statement rather than declared at the top level.
|
|
3878
|
+
const nested = matches.some((m) => {
|
|
3879
|
+
const lineStart = code.lastIndexOf("\n", m.index) + 1;
|
|
3880
|
+
return code.slice(lineStart, m.index).trim().length > 0;
|
|
3881
|
+
});
|
|
3882
|
+
const counted = matches.length > 1
|
|
3883
|
+
? `this snippet has ${matches.length} \`export default\` statements, but a module may only have one`
|
|
3884
|
+
: null;
|
|
3885
|
+
const placed = nested
|
|
3886
|
+
? "`export default` appears inside a block (`if`/`try`/loop), where a module-level declaration isn't allowed"
|
|
3887
|
+
: null;
|
|
3888
|
+
const reason = counted && placed
|
|
3889
|
+
? `${counted}, and ${placed}`
|
|
3890
|
+
: (counted ?? placed ?? "`export default` isn't at the module's top level");
|
|
3891
|
+
return (`${message}\n\n` +
|
|
3892
|
+
`spectest: an eval snippet is imported as a real ES module, and ${reason}. ` +
|
|
3893
|
+
"For early-return style, compute the value in a function and export its result once:\n\n" +
|
|
3894
|
+
" export default await (async () => {\n" +
|
|
3895
|
+
" const out = {};\n" +
|
|
3896
|
+
" if (!x) return { ...out, y: z }; // early return, not early export\n" +
|
|
3897
|
+
" out.more = await ctx.exec(\"web\", \"…\");\n" +
|
|
3898
|
+
" return out;\n" +
|
|
3899
|
+
" })();\n");
|
|
3900
|
+
}
|
|
3552
3901
|
async function evalCode(code, secrets) {
|
|
3553
3902
|
const start = Date.now();
|
|
3554
3903
|
// Eval-scoped secret channel for record-mode fakes — set before the
|
|
@@ -3688,6 +4037,7 @@ async function evalCode(code, secrets) {
|
|
|
3688
4037
|
fakes,
|
|
3689
4038
|
poll: pollCall,
|
|
3690
4039
|
dnsName: registerDnsName,
|
|
4040
|
+
certificate: mintCertificate,
|
|
3691
4041
|
startService: startRuntimeService,
|
|
3692
4042
|
stopService: stopRuntimeService,
|
|
3693
4043
|
};
|
|
@@ -3715,9 +4065,10 @@ async function evalCode(code, secrets) {
|
|
|
3715
4065
|
}
|
|
3716
4066
|
catch (err) {
|
|
3717
4067
|
const e = err;
|
|
4068
|
+
const message = e.message ?? String(err);
|
|
3718
4069
|
outcome = {
|
|
3719
4070
|
ok: false,
|
|
3720
|
-
error: { message:
|
|
4071
|
+
error: { message: explainEvalExportError(code, message), stack: e.stack },
|
|
3721
4072
|
};
|
|
3722
4073
|
}
|
|
3723
4074
|
finally {
|
|
@@ -3951,6 +4302,43 @@ async function handle(req, res, state) {
|
|
|
3951
4302
|
jsonResponse(res, 200, BOOTSTRAP_PROGRESS ?? {});
|
|
3952
4303
|
return;
|
|
3953
4304
|
}
|
|
4305
|
+
if (method === "GET" && url.startsWith("/env-logs")) {
|
|
4306
|
+
// Live logs for a running environment: the daemon's own boot log
|
|
4307
|
+
// (bootstrap + every `setup` hook's console output) plus each
|
|
4308
|
+
// service's container logs. Unlike the per-case deltas this reads
|
|
4309
|
+
// without touching LOG_MARKERS, so calling it never perturbs a run.
|
|
4310
|
+
const q = new URL(url, "http://daemon").searchParams;
|
|
4311
|
+
const only = q.get("service") ?? undefined;
|
|
4312
|
+
const tail = Math.max(1, Math.min(10_000, Number(q.get("tail") ?? "200") || 200));
|
|
4313
|
+
const l = loaded;
|
|
4314
|
+
const byName = new Map();
|
|
4315
|
+
if (l)
|
|
4316
|
+
for (const s of namedServices(l.project.environment))
|
|
4317
|
+
byName.set(s.name, s);
|
|
4318
|
+
for (const [name, s] of RUNTIME_SERVICES)
|
|
4319
|
+
byName.set(name, s);
|
|
4320
|
+
const names = [...byName.keys()].filter((n) => !only || n === only);
|
|
4321
|
+
if (only && names.length === 0) {
|
|
4322
|
+
jsonResponse(res, 404, {
|
|
4323
|
+
error: `unknown service ${JSON.stringify(only)}; known: ${[...byName.keys()].join(", ") || "(none)"}`,
|
|
4324
|
+
});
|
|
4325
|
+
return;
|
|
4326
|
+
}
|
|
4327
|
+
const services = await Promise.all(names.map(async (name) => {
|
|
4328
|
+
const r = await docker(["logs", "--timestamps", `--tail=${tail}`, name], 30_000);
|
|
4329
|
+
const out = capMiddle(r.stdout, LOG_DELTA_MAX_BYTES);
|
|
4330
|
+
const err = capMiddle(r.code === 0 ? r.stderr : r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
|
|
4331
|
+
return {
|
|
4332
|
+
service: name,
|
|
4333
|
+
stdout: out.value,
|
|
4334
|
+
stdoutTruncated: out.truncated,
|
|
4335
|
+
stderr: err.value,
|
|
4336
|
+
stderrTruncated: err.truncated,
|
|
4337
|
+
};
|
|
4338
|
+
}));
|
|
4339
|
+
jsonResponse(res, 200, { bootLog: bootLogText(), services });
|
|
4340
|
+
return;
|
|
4341
|
+
}
|
|
3954
4342
|
if (method === "POST" && url === "/load") {
|
|
3955
4343
|
// Env only — services/fakes/setup. For the legacy single-file layout the
|
|
3956
4344
|
// entry also defines the tests, so `cases` is populated here; for the
|
package/dist/index.d.ts
CHANGED
|
@@ -104,6 +104,42 @@ export interface ServiceConfig {
|
|
|
104
104
|
* `k3s server` starts.
|
|
105
105
|
*/
|
|
106
106
|
files?: readonly FileMount[];
|
|
107
|
+
/**
|
|
108
|
+
* Leaf certificates minted from the in-VM root CA and written into the
|
|
109
|
+
* container **before it starts**, so the service can terminate TLS
|
|
110
|
+
* *itself* with a certificate the whole environment already trusts.
|
|
111
|
+
*
|
|
112
|
+
* This is the counterpart to {@link ServiceConfig.tls}. `tls` puts the
|
|
113
|
+
* daemon in front as a terminating reverse proxy and forwards plain
|
|
114
|
+
* HTTP to the service — right for an ordinary web app, wrong for
|
|
115
|
+
* anything that has to see the TLS handshake: a gateway that routes by
|
|
116
|
+
* SNI, a server that verifies client certificates, or a protocol with
|
|
117
|
+
* its own TLS layer. Those need key material, and the only alternative
|
|
118
|
+
* was a self-signed cert plus `rejectUnauthorized: false` — which
|
|
119
|
+
* tests a code path production never runs.
|
|
120
|
+
*
|
|
121
|
+
* ```ts
|
|
122
|
+
* services: {
|
|
123
|
+
* gateway: {
|
|
124
|
+
* image: { type: "registry", reference: "…" },
|
|
125
|
+
* hostnames: ["sql.gateway.test"],
|
|
126
|
+
* certificates: [{
|
|
127
|
+
* hostnames: ["sql.gateway.test", "*.sql.gateway.test"],
|
|
128
|
+
* certPath: "/tls/tls.crt",
|
|
129
|
+
* keyPath: "/tls/tls.key",
|
|
130
|
+
* mode: "0600",
|
|
131
|
+
* }],
|
|
132
|
+
* },
|
|
133
|
+
* }
|
|
134
|
+
* // a test then connects with full verification on:
|
|
135
|
+
* // new Client({ connectionString, ssl: { rejectUnauthorized: true } })
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* For a certificate a *test* needs as a value — to load into a
|
|
139
|
+
* Kubernetes Secret, say — use `ctx.certificate(hostnames)` instead,
|
|
140
|
+
* which returns the PEMs rather than mounting them.
|
|
141
|
+
*/
|
|
142
|
+
certificates?: readonly CertificateMount[];
|
|
107
143
|
/** Other services (keys in the services map) that must be ready first. */
|
|
108
144
|
dependsOn?: readonly string[];
|
|
109
145
|
readyCheck?: ReadyCheck;
|
|
@@ -159,15 +195,19 @@ export interface ComponentContext {
|
|
|
159
195
|
*/
|
|
160
196
|
exec(service: string, command: string | string[], opts?: ComponentExecOpts): Promise<ExecResult>;
|
|
161
197
|
}
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Options for {@link ComponentContext.exec} — the same set
|
|
200
|
+
* {@link TestContext.exec} takes, deliberately aliased rather than
|
|
201
|
+
* redeclared: the two surfaces drifted once (the component one grew
|
|
202
|
+
* `stdin`/`timeoutMs`, the test one silently ignored them), and an alias
|
|
203
|
+
* makes that impossible to repeat.
|
|
204
|
+
*
|
|
205
|
+
* The one behavioural difference is the `timeoutMs` default: a component
|
|
206
|
+
* exec runs during boot, where nothing else bounds it, so it defaults to
|
|
207
|
+
* 120 s. A test-context exec has no default — the enclosing test's own
|
|
208
|
+
* timeout is the ceiling there.
|
|
209
|
+
*/
|
|
210
|
+
export type ComponentExecOpts = ExecOpts;
|
|
171
211
|
/** What a service's `setup` hook receives. */
|
|
172
212
|
export interface ServiceSetupContext<H extends Record<string, any> = Record<string, never>> extends ComponentContext {
|
|
173
213
|
/** The service's key in the services map (container + DNS name). */
|
|
@@ -432,6 +472,38 @@ export interface FileMount {
|
|
|
432
472
|
*/
|
|
433
473
|
mode?: string;
|
|
434
474
|
}
|
|
475
|
+
/** PEM material returned by `ctx.certificate(hostnames)`. */
|
|
476
|
+
export interface CertificateMaterial {
|
|
477
|
+
/** PEM certificate, signed by the in-VM root CA. */
|
|
478
|
+
cert: string;
|
|
479
|
+
/** PEM private key for {@link cert}. */
|
|
480
|
+
key: string;
|
|
481
|
+
/** PEM root CA certificate — the one the environment already trusts. */
|
|
482
|
+
ca: string;
|
|
483
|
+
}
|
|
484
|
+
/** One leaf certificate materialized into a service container — see
|
|
485
|
+
* {@link ServiceConfig.certificates}. */
|
|
486
|
+
export interface CertificateMount {
|
|
487
|
+
/** SANs the leaf covers. Wildcards (`*.example.com`) are allowed. */
|
|
488
|
+
hostnames: readonly string[];
|
|
489
|
+
/** Absolute path inside the container for the PEM certificate. */
|
|
490
|
+
certPath: string;
|
|
491
|
+
/** Absolute path inside the container for the PEM private key. */
|
|
492
|
+
keyPath: string;
|
|
493
|
+
/**
|
|
494
|
+
* Optional absolute path for the root CA certificate. Every container
|
|
495
|
+
* already trusts the CA (`SSL_CERT_FILE` and friends are pre-set), so
|
|
496
|
+
* this is only for software that wants an explicit CA file — client
|
|
497
|
+
* certificate verification, a `sslrootcert=` connection parameter.
|
|
498
|
+
*/
|
|
499
|
+
caPath?: string;
|
|
500
|
+
/**
|
|
501
|
+
* Optional octal mode (e.g. `"0600"`) applied to the staged key.
|
|
502
|
+
* Servers that refuse a group/world-readable key (postgres, ssh) need
|
|
503
|
+
* this; the default is the writer's umask.
|
|
504
|
+
*/
|
|
505
|
+
mode?: string;
|
|
506
|
+
}
|
|
435
507
|
export type ReadyCheck = {
|
|
436
508
|
type: "tcp";
|
|
437
509
|
port: number;
|
|
@@ -524,6 +596,10 @@ export interface FakeContext {
|
|
|
524
596
|
/** Map a DNS name onto a service IP (or the daemon ingress). See
|
|
525
597
|
* {@link TestContext.dnsName}. */
|
|
526
598
|
dnsName(hostname: string, target: DnsTarget): Promise<void>;
|
|
599
|
+
/** Mint a CA-signed leaf certificate. See
|
|
600
|
+
* {@link TestContext.certificate} — the primitive a fake standing in
|
|
601
|
+
* for a TLS-provisioning provider hands back to the app under test. */
|
|
602
|
+
certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
|
|
527
603
|
}
|
|
528
604
|
/**
|
|
529
605
|
* A single test step. Created via `test(...)` or `createTest(services)`.
|
|
@@ -536,7 +612,7 @@ export interface FakeContext {
|
|
|
536
612
|
* is strongly typed.
|
|
537
613
|
*/
|
|
538
614
|
export interface TestCase<T = unknown, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
|
|
539
|
-
/** Stable id (slug of name). Used in
|
|
615
|
+
/** Stable id (slug of name). Used in run results and CLI flags. */
|
|
540
616
|
readonly id: string;
|
|
541
617
|
readonly name: string;
|
|
542
618
|
/** Parent test, if any. Single-parent for now. */
|
|
@@ -750,6 +826,38 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
|
|
|
750
826
|
* ```
|
|
751
827
|
*/
|
|
752
828
|
dnsName(hostname: string, target: DnsTarget): Promise<void>;
|
|
829
|
+
/**
|
|
830
|
+
* Mint a leaf certificate from the in-VM root CA and return the PEMs.
|
|
831
|
+
*
|
|
832
|
+
* The value-returning counterpart to the `certificates` service field
|
|
833
|
+
* (see {@link ServiceConfig.certificates}): that one hands a
|
|
834
|
+
* certificate to a container before it boots, this one hands it to
|
|
835
|
+
* *you* — for loading into a Kubernetes `kubernetes.io/tls` Secret,
|
|
836
|
+
* posting to a control-plane API that provisions TLS endpoints, or
|
|
837
|
+
* driving a client-certificate handshake.
|
|
838
|
+
*
|
|
839
|
+
* The returned `ca` is the same root the whole environment already
|
|
840
|
+
* trusts (`ctx.fetch`, `ctx.browser()`, every service container), so a
|
|
841
|
+
* server configured with these PEMs verifies cleanly — no
|
|
842
|
+
* `rejectUnauthorized: false`, no `sslmode=require` downgrade.
|
|
843
|
+
* Wildcards (`*.example.com`) are allowed in `hostnames`.
|
|
844
|
+
*
|
|
845
|
+
* ```ts
|
|
846
|
+
* const { cert, key } = await ctx.certificate(["*.apps.test"]);
|
|
847
|
+
* await ctx.svc.k8s.apply(`
|
|
848
|
+
* apiVersion: v1
|
|
849
|
+
* kind: Secret
|
|
850
|
+
* metadata: { name: apps-tls, namespace: default }
|
|
851
|
+
* type: kubernetes.io/tls
|
|
852
|
+
* stringData:
|
|
853
|
+
* tls.crt: |
|
|
854
|
+
* ${cert.replace(/^/gm, " ")}
|
|
855
|
+
* tls.key: |
|
|
856
|
+
* ${key.replace(/^/gm, " ")}
|
|
857
|
+
* `);
|
|
858
|
+
* ```
|
|
859
|
+
*/
|
|
860
|
+
certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
|
|
753
861
|
/**
|
|
754
862
|
* Start a real container on `spectest-net` at runtime — a peer machine
|
|
755
863
|
* with its own IP, reachable like any boot service. Returns once the
|
|
@@ -785,7 +893,11 @@ export interface ExecResult {
|
|
|
785
893
|
stderr: string;
|
|
786
894
|
exitCode: number;
|
|
787
895
|
}
|
|
788
|
-
/** Options for {@link TestContext.exec}.
|
|
896
|
+
/** Options for {@link TestContext.exec}.
|
|
897
|
+
*
|
|
898
|
+
* Unknown properties are rejected at runtime (`ctx.exec` throws), not
|
|
899
|
+
* silently ignored — the type-level excess-property check is advisory
|
|
900
|
+
* here, since `spectest test`'s typecheck never gates a run. */
|
|
789
901
|
export interface ExecOpts {
|
|
790
902
|
/**
|
|
791
903
|
* Working directory inside the container to run the command from.
|
|
@@ -797,6 +909,32 @@ export interface ExecOpts {
|
|
|
797
909
|
* -w <cwd>`, so a relative path resolves against the image's WORKDIR.
|
|
798
910
|
*/
|
|
799
911
|
cwd?: string;
|
|
912
|
+
/**
|
|
913
|
+
* Piped to the command's stdin, then closed — the natural way to feed
|
|
914
|
+
* a manifest to `kubectl apply -f -` or a SQL file to `psql -f -`
|
|
915
|
+
* without staging a temp file in the container:
|
|
916
|
+
*
|
|
917
|
+
* ```ts
|
|
918
|
+
* await ctx.exec("k8s", "kubectl apply -f -", { stdin: manifestYaml });
|
|
919
|
+
* ```
|
|
920
|
+
*
|
|
921
|
+
* The payload is written after the process starts and the stream is
|
|
922
|
+
* closed immediately after, so a command that never reads stdin still
|
|
923
|
+
* exits normally (the write is allowed to fail with EPIPE).
|
|
924
|
+
*/
|
|
925
|
+
stdin?: string;
|
|
926
|
+
/**
|
|
927
|
+
* Kill the command (SIGKILL) after this long and return `exitCode`
|
|
928
|
+
* 124 with a `timeout after <n>ms` note on stderr, rather than
|
|
929
|
+
* hanging until the enclosing test's own timeout fires.
|
|
930
|
+
*
|
|
931
|
+
* **There is no default** — an `exec` runs as long as it likes. In a
|
|
932
|
+
* test the enclosing `env.test(..., { timeoutMs })` (60 s by default)
|
|
933
|
+
* is the real ceiling; in `setup`/`eval`, where no test timeout
|
|
934
|
+
* applies, an unbounded command hangs the boot, so set this when the
|
|
935
|
+
* command can plausibly wedge.
|
|
936
|
+
*/
|
|
937
|
+
timeoutMs?: number;
|
|
800
938
|
}
|
|
801
939
|
export interface TestSuite<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
|
|
802
940
|
tests: TestCase<unknown, S, F>[];
|
|
@@ -983,6 +1121,13 @@ export interface ProjectSetupContext<S extends ServicesMap = ServicesMap, F exte
|
|
|
983
1121
|
* test inherits the route without re-registering.
|
|
984
1122
|
*/
|
|
985
1123
|
dnsName(hostname: string, target: DnsTarget): Promise<void>;
|
|
1124
|
+
/**
|
|
1125
|
+
* Mint a leaf certificate from the in-VM root CA (see
|
|
1126
|
+
* {@link TestContext.certificate}). Minted here it's captured into the
|
|
1127
|
+
* warm-template snapshot, so every test inherits whatever you seeded it
|
|
1128
|
+
* into — the fit for a cluster-wide TLS Secret applied once at setup.
|
|
1129
|
+
*/
|
|
1130
|
+
certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
|
|
986
1131
|
/**
|
|
987
1132
|
* Start a runtime service (see {@link TestContext.startService}). Started
|
|
988
1133
|
* here, it's captured into the warm-template snapshot and inherited by
|
package/dist/recorder.d.ts
CHANGED
|
@@ -26,6 +26,15 @@ export interface ExecEvent extends BaseEvent {
|
|
|
26
26
|
* detail view surfaces it. Absent when the call used no `cwd`.
|
|
27
27
|
*/
|
|
28
28
|
cwd?: string;
|
|
29
|
+
/**
|
|
30
|
+
* The payload piped to the command's stdin (`ctx.exec(svc, cmd, {
|
|
31
|
+
* stdin })`), truncated to OUTPUT_SNIPPET_BYTES. Recorded because the
|
|
32
|
+
* asciicast only captures what the command *wrote* — without this, a
|
|
33
|
+
* `kubectl apply -f -` step shows its error but not the manifest that
|
|
34
|
+
* caused it. Absent when the call piped nothing.
|
|
35
|
+
*/
|
|
36
|
+
stdin?: string;
|
|
37
|
+
stdinTruncated?: boolean;
|
|
29
38
|
exitCode: number;
|
|
30
39
|
/** stdout truncated to OUTPUT_SNIPPET_BYTES. */
|
|
31
40
|
stdout: string;
|
|
@@ -316,7 +325,9 @@ export interface EmailEvent extends BaseEvent {
|
|
|
316
325
|
export interface EnvEvent extends BaseEvent {
|
|
317
326
|
kind: "env";
|
|
318
327
|
/** Which environment primitive ran. */
|
|
319
|
-
op: "startService" | "stopService" | "dnsName";
|
|
328
|
+
op: "startService" | "stopService" | "dnsName" | "certificate";
|
|
329
|
+
/** SANs of a minted leaf (certificate). */
|
|
330
|
+
hostnames?: string[];
|
|
320
331
|
/** Service name (startService / stopService). */
|
|
321
332
|
service?: string;
|
|
322
333
|
/** Image reference started (startService). */
|