@specific.dev/spectest 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +388 -941
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +3 -3
- package/dist/ingress.d.ts +1 -1
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2040 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5489 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2725 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +133 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +846 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/src/vendor/rrweb-record.min.js +5061 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DNS names registry: the file the harness writes and
|
|
3
|
+
* `spectest-resolver` reads.
|
|
4
|
+
*
|
|
5
|
+
* Ported out of `daemon.ts`. The point of a shared module is that this
|
|
6
|
+
* format had **two independent implementations** — the writer here and
|
|
7
|
+
* the reader in `resolver.ts` — with nothing keeping them in step. That
|
|
8
|
+
* is the same shape as the `config.rs` ↔ SDK drift the harness split
|
|
9
|
+
* exists to remove, just inside one language.
|
|
10
|
+
*
|
|
11
|
+
* The registry answers names that Docker's own DNS cannot:
|
|
12
|
+
* - fake hostnames (`api.stripe.com` → the bridge gateway),
|
|
13
|
+
* - names bound at runtime by `ctx.dnsName`,
|
|
14
|
+
* - **wildcards** (`*.us-east-1.amazonaws.com`), which have no
|
|
15
|
+
* `--add-host` or `--network-alias` equivalent and therefore *only*
|
|
16
|
+
* exist here.
|
|
17
|
+
*
|
|
18
|
+
* Lookup is exact-first, then longest matching suffix — the same
|
|
19
|
+
* precedence the ingress routes and the SNI cert table use.
|
|
20
|
+
*/
|
|
21
|
+
export const EMPTY_REGISTRY = { hosts: {}, wildcards: [] };
|
|
22
|
+
/** Serialise for the resolver. */
|
|
23
|
+
export function encodeRegistry(doc, now) {
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
hosts: doc.hosts,
|
|
26
|
+
wildcards: doc.wildcards,
|
|
27
|
+
updatedAt: now,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse a registry file.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately total: a truncated or malformed file yields an **empty**
|
|
34
|
+
* registry rather than throwing. The resolver reads this on a hot path
|
|
35
|
+
* while the harness may be rewriting it, and a parse error there would
|
|
36
|
+
* take out DNS for the whole environment — every name, not just the one
|
|
37
|
+
* being added. Degrading to "no custom names" is recoverable; the next
|
|
38
|
+
* successful read restores everything.
|
|
39
|
+
*/
|
|
40
|
+
export function decodeRegistry(text) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(text);
|
|
43
|
+
const hosts = raw.hosts && typeof raw.hosts === "object" ? raw.hosts : {};
|
|
44
|
+
const wildcards = Array.isArray(raw.wildcards)
|
|
45
|
+
? raw.wildcards.filter((w) => !!w && typeof w.suffix === "string" && typeof w.ip === "string")
|
|
46
|
+
: [];
|
|
47
|
+
return { hosts, wildcards, updatedAt: raw.updatedAt };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { hosts: {}, wildcards: [] };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** `"*.example.com"` → `".example.com"`, the form stored in `wildcards`. */
|
|
54
|
+
export function suffixOf(pattern) {
|
|
55
|
+
return pattern.startsWith("*.") ? pattern.slice(1) : pattern;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a name: exact entry first, then the longest matching wildcard
|
|
59
|
+
* suffix. Returns `null` when nothing matches, which is the resolver's
|
|
60
|
+
* signal to fall through to Docker's DNS and then upstream.
|
|
61
|
+
*/
|
|
62
|
+
export function lookup(doc, name) {
|
|
63
|
+
const exact = doc.hosts[name];
|
|
64
|
+
if (exact)
|
|
65
|
+
return exact;
|
|
66
|
+
let best = null;
|
|
67
|
+
for (const w of doc.wildcards) {
|
|
68
|
+
if (name.endsWith(w.suffix) && (!best || w.suffix.length > best.suffix.length)) {
|
|
69
|
+
best = w;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return best?.ip ?? null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Add or replace a name.
|
|
76
|
+
*
|
|
77
|
+
* A wildcard replaces any existing entry for the same suffix rather than
|
|
78
|
+
* appending, so re-registering a name (which `ctx.dnsName` allows, and
|
|
79
|
+
* which a re-run of a `setup` hook does) cannot grow the list without
|
|
80
|
+
* bound or leave two entries racing on equal suffix length.
|
|
81
|
+
*/
|
|
82
|
+
export function upsert(doc, hostname, ip) {
|
|
83
|
+
if (hostname.startsWith("*.")) {
|
|
84
|
+
const suffix = suffixOf(hostname);
|
|
85
|
+
const wildcards = doc.wildcards.filter((w) => w.suffix !== suffix);
|
|
86
|
+
wildcards.push({ suffix, ip });
|
|
87
|
+
return { ...doc, wildcards };
|
|
88
|
+
}
|
|
89
|
+
return { ...doc, hosts: { ...doc.hosts, [hostname]: ip } };
|
|
90
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The supervisor↔harness protocol — TypeScript half.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `crates/spectest-vm-agent/src/protocol.rs`. Read that file's
|
|
5
|
+
* header for the design; the rules that matter when editing this one:
|
|
6
|
+
*
|
|
7
|
+
* - `hello` carries a protocol version AND a capability list in both
|
|
8
|
+
* directions, from the first release. A release that announces nothing
|
|
9
|
+
* can only ever be treated as "oldest".
|
|
10
|
+
* - **Unknown fields are ignored in both directions.** Nothing here
|
|
11
|
+
* validates against a closed field set, deliberately — that is the
|
|
12
|
+
* mistake `config.rs` made, where a newer SDK's config was rejected
|
|
13
|
+
* outright by an older server.
|
|
14
|
+
* - A message never changes meaning. It only gains optional fields.
|
|
15
|
+
* - An unrecognised *method* must still parse, so the receiver can answer
|
|
16
|
+
* with an error instead of dropping the frame and hanging the sender.
|
|
17
|
+
*
|
|
18
|
+
* Anti-drift: the messages are defined once in
|
|
19
|
+
* `protocols/supervisor-harness/frames.json`, and both this module's test suite and
|
|
20
|
+
* the Rust one parse that file. A field added on one side and forgotten on
|
|
21
|
+
* the other fails there rather than in a VM.
|
|
22
|
+
*/
|
|
23
|
+
/** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
|
|
24
|
+
export declare const PROTOCOL_VERSION = 1;
|
|
25
|
+
/** Methods either side can send. */
|
|
26
|
+
export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "run" | "eval" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
|
|
27
|
+
/** Methods this build knows how to dispatch. A method outside this set still
|
|
28
|
+
* *parses* — it is answered with an error, never dropped. */
|
|
29
|
+
export declare const KNOWN_METHODS: ReadonlySet<string>;
|
|
30
|
+
export interface ProtocolError {
|
|
31
|
+
message: string;
|
|
32
|
+
code?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface RequestFrame {
|
|
35
|
+
kind: "request";
|
|
36
|
+
/** Correlates the response. */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Typed as `string`, not `Method`: a newer peer's method must parse. */
|
|
39
|
+
method: string;
|
|
40
|
+
params?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
export interface ResponseFrame {
|
|
43
|
+
kind: "response";
|
|
44
|
+
id: string;
|
|
45
|
+
ok: boolean;
|
|
46
|
+
result?: Record<string, unknown>;
|
|
47
|
+
error?: ProtocolError;
|
|
48
|
+
}
|
|
49
|
+
/** Fire-and-forget. No id, no reply — progress that blocked on an ack would
|
|
50
|
+
* make the UI a correctness dependency. */
|
|
51
|
+
export interface EventFrame {
|
|
52
|
+
kind: "event";
|
|
53
|
+
method: string;
|
|
54
|
+
params?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
export type Frame = RequestFrame | ResponseFrame | EventFrame;
|
|
57
|
+
/** `hello` payload, sent in both directions. `sdkVersion` is on the
|
|
58
|
+
* harness's reply only. */
|
|
59
|
+
export interface Hello {
|
|
60
|
+
protocolVersion: number;
|
|
61
|
+
capabilities?: string[];
|
|
62
|
+
sdkVersion?: string;
|
|
63
|
+
}
|
|
64
|
+
/** One contribution to the L2 declared cache key. The supervisor hashes
|
|
65
|
+
* these without interpreting them — a fingerprint it could interpret
|
|
66
|
+
* would just be the schema coupling again. */
|
|
67
|
+
export interface Contribution {
|
|
68
|
+
id: string;
|
|
69
|
+
kind: string;
|
|
70
|
+
key: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Parse one NDJSON line.
|
|
74
|
+
*
|
|
75
|
+
* Throws only on frames that cannot be *routed* — malformed JSON, or a
|
|
76
|
+
* missing `kind`/`id`/`method`. An unknown method is not such a case: it
|
|
77
|
+
* parses, and the dispatcher answers with an error. Unknown *fields* are
|
|
78
|
+
* left untouched on the returned object rather than stripped, so a value
|
|
79
|
+
* this build ignores still round-trips if forwarded.
|
|
80
|
+
*/
|
|
81
|
+
export declare function decodeFrame(line: string): Frame;
|
|
82
|
+
/** Encode one frame as a single NDJSON line, newline included. */
|
|
83
|
+
export declare function encodeFrame(frame: Frame): string;
|
|
84
|
+
/** A successful reply to `id`. */
|
|
85
|
+
export declare function ok(id: string, result?: Record<string, unknown>): ResponseFrame;
|
|
86
|
+
/** A failed reply to `id`. Always carries the id: a request that failed must
|
|
87
|
+
* never leave its caller waiting. */
|
|
88
|
+
export declare function fail(id: string, message: string, code?: string): ResponseFrame;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The supervisor↔harness protocol — TypeScript half.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `crates/spectest-vm-agent/src/protocol.rs`. Read that file's
|
|
5
|
+
* header for the design; the rules that matter when editing this one:
|
|
6
|
+
*
|
|
7
|
+
* - `hello` carries a protocol version AND a capability list in both
|
|
8
|
+
* directions, from the first release. A release that announces nothing
|
|
9
|
+
* can only ever be treated as "oldest".
|
|
10
|
+
* - **Unknown fields are ignored in both directions.** Nothing here
|
|
11
|
+
* validates against a closed field set, deliberately — that is the
|
|
12
|
+
* mistake `config.rs` made, where a newer SDK's config was rejected
|
|
13
|
+
* outright by an older server.
|
|
14
|
+
* - A message never changes meaning. It only gains optional fields.
|
|
15
|
+
* - An unrecognised *method* must still parse, so the receiver can answer
|
|
16
|
+
* with an error instead of dropping the frame and hanging the sender.
|
|
17
|
+
*
|
|
18
|
+
* Anti-drift: the messages are defined once in
|
|
19
|
+
* `protocols/supervisor-harness/frames.json`, and both this module's test suite and
|
|
20
|
+
* the Rust one parse that file. A field added on one side and forgotten on
|
|
21
|
+
* the other fails there rather than in a VM.
|
|
22
|
+
*/
|
|
23
|
+
/** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
|
|
24
|
+
export const PROTOCOL_VERSION = 1;
|
|
25
|
+
/** Methods this build knows how to dispatch. A method outside this set still
|
|
26
|
+
* *parses* — it is answered with an error, never dropped. */
|
|
27
|
+
export const KNOWN_METHODS = new Set([
|
|
28
|
+
"hello",
|
|
29
|
+
"load",
|
|
30
|
+
"loadTests",
|
|
31
|
+
"fingerprint",
|
|
32
|
+
"bootstrap",
|
|
33
|
+
"run",
|
|
34
|
+
"eval",
|
|
35
|
+
"teardown",
|
|
36
|
+
"shutdown",
|
|
37
|
+
"createArtifact",
|
|
38
|
+
"completeArtifact",
|
|
39
|
+
"progress",
|
|
40
|
+
"step",
|
|
41
|
+
]);
|
|
42
|
+
/**
|
|
43
|
+
* Parse one NDJSON line.
|
|
44
|
+
*
|
|
45
|
+
* Throws only on frames that cannot be *routed* — malformed JSON, or a
|
|
46
|
+
* missing `kind`/`id`/`method`. An unknown method is not such a case: it
|
|
47
|
+
* parses, and the dispatcher answers with an error. Unknown *fields* are
|
|
48
|
+
* left untouched on the returned object rather than stripped, so a value
|
|
49
|
+
* this build ignores still round-trips if forwarded.
|
|
50
|
+
*/
|
|
51
|
+
export function decodeFrame(line) {
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = JSON.parse(line);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
throw new Error(`harness protocol: malformed JSON frame: ${e.message}`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof raw !== "object" || raw === null) {
|
|
60
|
+
throw new Error("harness protocol: frame is not an object");
|
|
61
|
+
}
|
|
62
|
+
const f = raw;
|
|
63
|
+
switch (f.kind) {
|
|
64
|
+
case "request":
|
|
65
|
+
if (typeof f.id !== "string")
|
|
66
|
+
throw new Error("harness protocol: request has no id");
|
|
67
|
+
if (typeof f.method !== "string")
|
|
68
|
+
throw new Error("harness protocol: request has no method");
|
|
69
|
+
return f;
|
|
70
|
+
case "response":
|
|
71
|
+
if (typeof f.id !== "string")
|
|
72
|
+
throw new Error("harness protocol: response has no id");
|
|
73
|
+
if (typeof f.ok !== "boolean")
|
|
74
|
+
throw new Error("harness protocol: response has no ok");
|
|
75
|
+
return f;
|
|
76
|
+
case "event":
|
|
77
|
+
if (typeof f.method !== "string")
|
|
78
|
+
throw new Error("harness protocol: event has no method");
|
|
79
|
+
return f;
|
|
80
|
+
default:
|
|
81
|
+
throw new Error(`harness protocol: unknown frame kind ${JSON.stringify(f.kind)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Encode one frame as a single NDJSON line, newline included. */
|
|
85
|
+
export function encodeFrame(frame) {
|
|
86
|
+
return `${JSON.stringify(frame)}\n`;
|
|
87
|
+
}
|
|
88
|
+
/** A successful reply to `id`. */
|
|
89
|
+
export function ok(id, result = {}) {
|
|
90
|
+
return { kind: "response", id, ok: true, result };
|
|
91
|
+
}
|
|
92
|
+
/** A failed reply to `id`. Always carries the id: a request that failed must
|
|
93
|
+
* never leave its caller waiting. */
|
|
94
|
+
export function fail(id, message, code) {
|
|
95
|
+
return { kind: "response", id, ok: false, error: code ? { message, code } : { message } };
|
|
96
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The polling schedule behind a readiness probe.
|
|
3
|
+
*
|
|
4
|
+
* Ported out of `daemon.ts`, separated from the probes themselves so the
|
|
5
|
+
* timing rules can be tested without opening a socket or spawning a
|
|
6
|
+
* `docker exec`.
|
|
7
|
+
*
|
|
8
|
+
* Why a ramp rather than a fixed interval: a flat 500 ms delay quantises
|
|
9
|
+
* *every* service's measured ready latency to a multiple of 500 ms, and
|
|
10
|
+
* that error compounds down a `dependsOn` chain — a four-deep chain of
|
|
11
|
+
* services that are each genuinely ready in 60 ms reports two seconds.
|
|
12
|
+
* Probing fast at first catches quick services honestly; ramping up caps
|
|
13
|
+
* the polling load on genuinely slow ones.
|
|
14
|
+
*
|
|
15
|
+
* Exec probes keep a higher floor because each attempt spawns a
|
|
16
|
+
* `docker exec`, which costs far more than a TCP connect.
|
|
17
|
+
*/
|
|
18
|
+
export type ProbeKind = "tcp" | "http" | "exec";
|
|
19
|
+
/** Delays between attempts, in ms. The last entry repeats forever. */
|
|
20
|
+
export declare const RAMP_DEFAULT: readonly number[];
|
|
21
|
+
export declare const RAMP_EXEC: readonly number[];
|
|
22
|
+
export declare const DEFAULT_TIMEOUT_SECS = 60;
|
|
23
|
+
export declare function rampFor(kind: ProbeKind): readonly number[];
|
|
24
|
+
/** Delay before attempt number `attempt` (0-based), clamped to the ramp's tail. */
|
|
25
|
+
export declare function delayForAttempt(ramp: readonly number[], attempt: number): number;
|
|
26
|
+
export interface PollOptions {
|
|
27
|
+
kind: ProbeKind;
|
|
28
|
+
timeoutSecs?: number;
|
|
29
|
+
/** Injectable for tests; defaults to the wall clock. */
|
|
30
|
+
now?: () => number;
|
|
31
|
+
sleep?: (ms: number) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export interface PollResult {
|
|
34
|
+
ready: boolean;
|
|
35
|
+
attempts: number;
|
|
36
|
+
elapsedMs: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Poll `probe` until it returns true or the deadline passes.
|
|
40
|
+
*
|
|
41
|
+
* The probe is **always attempted at least once**, even with a zero or
|
|
42
|
+
* negative timeout. A deadline check placed before the first attempt
|
|
43
|
+
* would make `timeoutSecs: 0` mean "never check", which reads as "no
|
|
44
|
+
* timeout" to anyone writing it — and would fail a service that was
|
|
45
|
+
* ready all along.
|
|
46
|
+
*/
|
|
47
|
+
export declare function pollUntilReady(probe: () => Promise<boolean>, opts: PollOptions): Promise<PollResult>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The polling schedule behind a readiness probe.
|
|
3
|
+
*
|
|
4
|
+
* Ported out of `daemon.ts`, separated from the probes themselves so the
|
|
5
|
+
* timing rules can be tested without opening a socket or spawning a
|
|
6
|
+
* `docker exec`.
|
|
7
|
+
*
|
|
8
|
+
* Why a ramp rather than a fixed interval: a flat 500 ms delay quantises
|
|
9
|
+
* *every* service's measured ready latency to a multiple of 500 ms, and
|
|
10
|
+
* that error compounds down a `dependsOn` chain — a four-deep chain of
|
|
11
|
+
* services that are each genuinely ready in 60 ms reports two seconds.
|
|
12
|
+
* Probing fast at first catches quick services honestly; ramping up caps
|
|
13
|
+
* the polling load on genuinely slow ones.
|
|
14
|
+
*
|
|
15
|
+
* Exec probes keep a higher floor because each attempt spawns a
|
|
16
|
+
* `docker exec`, which costs far more than a TCP connect.
|
|
17
|
+
*/
|
|
18
|
+
/** Delays between attempts, in ms. The last entry repeats forever. */
|
|
19
|
+
export const RAMP_DEFAULT = [50, 100, 150, 250, 400, 500];
|
|
20
|
+
export const RAMP_EXEC = [250, 250, 400, 400, 500];
|
|
21
|
+
export const DEFAULT_TIMEOUT_SECS = 60;
|
|
22
|
+
export function rampFor(kind) {
|
|
23
|
+
return kind === "exec" ? RAMP_EXEC : RAMP_DEFAULT;
|
|
24
|
+
}
|
|
25
|
+
/** Delay before attempt number `attempt` (0-based), clamped to the ramp's tail. */
|
|
26
|
+
export function delayForAttempt(ramp, attempt) {
|
|
27
|
+
return ramp[Math.min(attempt, ramp.length - 1)];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Poll `probe` until it returns true or the deadline passes.
|
|
31
|
+
*
|
|
32
|
+
* The probe is **always attempted at least once**, even with a zero or
|
|
33
|
+
* negative timeout. A deadline check placed before the first attempt
|
|
34
|
+
* would make `timeoutSecs: 0` mean "never check", which reads as "no
|
|
35
|
+
* timeout" to anyone writing it — and would fail a service that was
|
|
36
|
+
* ready all along.
|
|
37
|
+
*/
|
|
38
|
+
export async function pollUntilReady(probe, opts) {
|
|
39
|
+
const now = opts.now ?? Date.now;
|
|
40
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
41
|
+
const timeoutSecs = opts.timeoutSecs ?? DEFAULT_TIMEOUT_SECS;
|
|
42
|
+
const ramp = rampFor(opts.kind);
|
|
43
|
+
const started = now();
|
|
44
|
+
const deadline = started + timeoutSecs * 1000;
|
|
45
|
+
let attempts = 0;
|
|
46
|
+
for (;;) {
|
|
47
|
+
attempts++;
|
|
48
|
+
if (await probe()) {
|
|
49
|
+
return { ready: true, attempts, elapsedMs: now() - started };
|
|
50
|
+
}
|
|
51
|
+
const remaining = deadline - now();
|
|
52
|
+
if (remaining <= 0) {
|
|
53
|
+
return { ready: false, attempts, elapsedMs: now() - started };
|
|
54
|
+
}
|
|
55
|
+
// Clamp rather than give up early. An earlier version returned as soon
|
|
56
|
+
// as `now + delay` passed the deadline, which stopped probing up to a
|
|
57
|
+
// full ramp step (500 ms) BEFORE the timeout the user asked for — so a
|
|
58
|
+
// service that became ready in that final window was reported as
|
|
59
|
+
// failed. Harmless when everything is fast, and exactly wrong under
|
|
60
|
+
// load, which is when readiness is marginal in the first place.
|
|
61
|
+
//
|
|
62
|
+
// Clamping keeps the original "probe until the deadline" behaviour and
|
|
63
|
+
// still never sleeps past it.
|
|
64
|
+
const delay = Math.min(delayForAttempt(ramp, attempts - 1), remaining);
|
|
65
|
+
await sleep(delay);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dependsOn` graph: validation, and the traversal order bring-up
|
|
3
|
+
* uses.
|
|
4
|
+
*
|
|
5
|
+
* Ported out of `daemon.ts`. Validation runs before anything starts, so
|
|
6
|
+
* the DAG runner can assume a clean graph — an unknown dependency or a
|
|
7
|
+
* cycle must be a clear error at load, not a container that never becomes
|
|
8
|
+
* ready and eventually times out with no explanation.
|
|
9
|
+
*/
|
|
10
|
+
export interface GraphNode {
|
|
11
|
+
name: string;
|
|
12
|
+
dependsOn?: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Check the graph and return the name→node map used to walk it.
|
|
16
|
+
*
|
|
17
|
+
* Rejects a dependency on a service that doesn't exist, and any cycle.
|
|
18
|
+
*/
|
|
19
|
+
export declare function validateServiceGraph<T extends GraphNode>(services: readonly T[]): Map<string, T>;
|
|
20
|
+
/**
|
|
21
|
+
* Services in an order where every node follows its dependencies.
|
|
22
|
+
*
|
|
23
|
+
* Bring-up itself does **not** walk levels — each service starts the
|
|
24
|
+
* moment its own dependencies are ready, so an unrelated slow probe never
|
|
25
|
+
* holds back a branch that is ready. This ordering is for the places that
|
|
26
|
+
* genuinely need a sequence (teardown, reporting), and for asserting the
|
|
27
|
+
* graph is walkable at all.
|
|
28
|
+
*/
|
|
29
|
+
export declare function topologicalOrder<T extends GraphNode>(services: readonly T[]): T[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dependsOn` graph: validation, and the traversal order bring-up
|
|
3
|
+
* uses.
|
|
4
|
+
*
|
|
5
|
+
* Ported out of `daemon.ts`. Validation runs before anything starts, so
|
|
6
|
+
* the DAG runner can assume a clean graph — an unknown dependency or a
|
|
7
|
+
* cycle must be a clear error at load, not a container that never becomes
|
|
8
|
+
* ready and eventually times out with no explanation.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Check the graph and return the name→node map used to walk it.
|
|
12
|
+
*
|
|
13
|
+
* Rejects a dependency on a service that doesn't exist, and any cycle.
|
|
14
|
+
*/
|
|
15
|
+
export function validateServiceGraph(services) {
|
|
16
|
+
const byName = new Map(services.map((s) => [s.name, s]));
|
|
17
|
+
// A duplicate name would silently shadow one definition and make the
|
|
18
|
+
// graph lie about what is running. The old code built the map without
|
|
19
|
+
// checking, so the second service simply replaced the first.
|
|
20
|
+
if (byName.size !== services.length) {
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
const dupe = services.find((s) => !seen.add(s.name))?.name;
|
|
23
|
+
throw new Error(`duplicate service name ${JSON.stringify(dupe)}`);
|
|
24
|
+
}
|
|
25
|
+
for (const s of services) {
|
|
26
|
+
for (const d of s.dependsOn ?? []) {
|
|
27
|
+
if (!byName.has(d)) {
|
|
28
|
+
throw new Error(`service ${s.name} depends on unknown service ${d}`);
|
|
29
|
+
}
|
|
30
|
+
if (d === s.name) {
|
|
31
|
+
throw new Error(`service ${s.name} depends on itself`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Cycle detection by DFS colouring: white = unseen, gray = on the
|
|
36
|
+
// current stack (so meeting gray again is a cycle), black = finished.
|
|
37
|
+
const WHITE = 0;
|
|
38
|
+
const GRAY = 1;
|
|
39
|
+
const BLACK = 2;
|
|
40
|
+
const color = new Map(services.map((s) => [s.name, WHITE]));
|
|
41
|
+
const stack = [];
|
|
42
|
+
const visit = (name) => {
|
|
43
|
+
color.set(name, GRAY);
|
|
44
|
+
stack.push(name);
|
|
45
|
+
for (const d of byName.get(name).dependsOn ?? []) {
|
|
46
|
+
const c = color.get(d);
|
|
47
|
+
if (c === GRAY) {
|
|
48
|
+
// Naming the cycle matters: "service dependency cycle" alone
|
|
49
|
+
// leaves the user to find it by eye in a large services map.
|
|
50
|
+
const from = stack.indexOf(d);
|
|
51
|
+
const loop = [...stack.slice(from), d].join(" → ");
|
|
52
|
+
throw new Error(`service dependency cycle: ${loop}`);
|
|
53
|
+
}
|
|
54
|
+
if (c === WHITE)
|
|
55
|
+
visit(d);
|
|
56
|
+
}
|
|
57
|
+
stack.pop();
|
|
58
|
+
color.set(name, BLACK);
|
|
59
|
+
};
|
|
60
|
+
for (const s of services)
|
|
61
|
+
if (color.get(s.name) === WHITE)
|
|
62
|
+
visit(s.name);
|
|
63
|
+
return byName;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Services in an order where every node follows its dependencies.
|
|
67
|
+
*
|
|
68
|
+
* Bring-up itself does **not** walk levels — each service starts the
|
|
69
|
+
* moment its own dependencies are ready, so an unrelated slow probe never
|
|
70
|
+
* holds back a branch that is ready. This ordering is for the places that
|
|
71
|
+
* genuinely need a sequence (teardown, reporting), and for asserting the
|
|
72
|
+
* graph is walkable at all.
|
|
73
|
+
*/
|
|
74
|
+
export function topologicalOrder(services) {
|
|
75
|
+
const byName = validateServiceGraph(services);
|
|
76
|
+
const out = [];
|
|
77
|
+
const done = new Set();
|
|
78
|
+
const visit = (name) => {
|
|
79
|
+
if (done.has(name))
|
|
80
|
+
return;
|
|
81
|
+
const node = byName.get(name);
|
|
82
|
+
for (const d of node.dependsOn ?? [])
|
|
83
|
+
visit(d);
|
|
84
|
+
done.add(name);
|
|
85
|
+
out.push(node);
|
|
86
|
+
};
|
|
87
|
+
// Declaration order among independent services, so the result is stable
|
|
88
|
+
// rather than dependent on Map iteration incidentals.
|
|
89
|
+
for (const s of services)
|
|
90
|
+
visit(s.name);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a service's volume is backed on the VM's filesystem.
|
|
3
|
+
*
|
|
4
|
+
* Ported out of `daemon.ts`. Pure path arithmetic, but it decides
|
|
5
|
+
* something with real consequences: **which volumes survive a
|
|
6
|
+
* delta-restore teardown**.
|
|
7
|
+
*
|
|
8
|
+
* Teardown wipes `/workspace` to give a restored environment fresh-state
|
|
9
|
+
* semantics. Anything that must survive it therefore has to live outside
|
|
10
|
+
* `/workspace` — and that is exactly what `cache: true` selects, by
|
|
11
|
+
* rooting the directory under `/var/cache/spectest/volumes` instead.
|
|
12
|
+
*
|
|
13
|
+
* The flag is only ever correct for **content-addressed accelerator
|
|
14
|
+
* data**: package stores, layer caches — data whose presence can change
|
|
15
|
+
* how *fast* something runs but never *what* it does. It is wrong for any
|
|
16
|
+
* real state, because a restored environment would then start with a
|
|
17
|
+
* previous run's data and stop being reproducible. (Counter-example worth
|
|
18
|
+
* remembering: `k3s()` deliberately does not cache its containerd store —
|
|
19
|
+
* a fresh cluster over an un-cleanly-killed store wedged the apiserver.)
|
|
20
|
+
*/
|
|
21
|
+
/** Root of the per-environment state tree. Wiped by delta teardown. */
|
|
22
|
+
export declare const DEFAULT_WORKSPACE = "/workspace";
|
|
23
|
+
/** Root of the cache tree. Deliberately outside the workspace. */
|
|
24
|
+
export declare const CACHE_ROOT = "/var/cache/spectest/volumes";
|
|
25
|
+
/** Directory holding named shared volumes, under whichever root applies. */
|
|
26
|
+
export declare const SHARED_DIR = "_shared";
|
|
27
|
+
export interface VolumeSpec {
|
|
28
|
+
/** Named shared volume — every service mounting this name gets the same
|
|
29
|
+
* directory. Mutually exclusive with `source`. */
|
|
30
|
+
name?: string;
|
|
31
|
+
/** Host path. Absolute paths are used as-is; relative ones resolve under
|
|
32
|
+
* the service's own directory. */
|
|
33
|
+
source?: string;
|
|
34
|
+
/** Path inside the container. Used to derive a directory when neither
|
|
35
|
+
* `name` nor `source` is given. */
|
|
36
|
+
target: string;
|
|
37
|
+
/** Survive the delta-restore teardown. Content-addressed data only. */
|
|
38
|
+
cache?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Make an arbitrary string safe as a single path segment.
|
|
42
|
+
*
|
|
43
|
+
* Strips leading slashes, replaces anything outside `[A-Za-z0-9_-]`, and
|
|
44
|
+
* trims the dashes that leaves at the edges. The character filter also
|
|
45
|
+
* removes `.`, so `..` cannot survive — a volume name or target can't
|
|
46
|
+
* escape its root.
|
|
47
|
+
*/
|
|
48
|
+
export declare function sanitizeSegment(p: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* The host directory backing one volume mount.
|
|
51
|
+
*
|
|
52
|
+
* Resolution order:
|
|
53
|
+
* 1. `name` — a shared directory under `_shared`, so two services
|
|
54
|
+
* mounting the same name genuinely share one directory.
|
|
55
|
+
* 2. An **absolute** `source` — used verbatim. The project asked for a
|
|
56
|
+
* specific path, so it gets it.
|
|
57
|
+
* 3. A relative `source`, or nothing at all — under the service's own
|
|
58
|
+
* directory, derived from `target` when `source` is absent.
|
|
59
|
+
*
|
|
60
|
+
* `workspace` is a parameter rather than a module constant so the rule is
|
|
61
|
+
* testable without touching the filesystem.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveHostPath(service: string, vol: VolumeSpec, workspace?: string): string;
|
|
64
|
+
/**
|
|
65
|
+
* Does this volume survive a delta-restore teardown?
|
|
66
|
+
*
|
|
67
|
+
* True for cache-flagged volumes and for absolute sources outside the
|
|
68
|
+
* workspace — the two ways a directory ends up beyond `rm -rf /workspace`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function survivesTeardown(vol: VolumeSpec, service: string, workspace?: string): boolean;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a service's volume is backed on the VM's filesystem.
|
|
3
|
+
*
|
|
4
|
+
* Ported out of `daemon.ts`. Pure path arithmetic, but it decides
|
|
5
|
+
* something with real consequences: **which volumes survive a
|
|
6
|
+
* delta-restore teardown**.
|
|
7
|
+
*
|
|
8
|
+
* Teardown wipes `/workspace` to give a restored environment fresh-state
|
|
9
|
+
* semantics. Anything that must survive it therefore has to live outside
|
|
10
|
+
* `/workspace` — and that is exactly what `cache: true` selects, by
|
|
11
|
+
* rooting the directory under `/var/cache/spectest/volumes` instead.
|
|
12
|
+
*
|
|
13
|
+
* The flag is only ever correct for **content-addressed accelerator
|
|
14
|
+
* data**: package stores, layer caches — data whose presence can change
|
|
15
|
+
* how *fast* something runs but never *what* it does. It is wrong for any
|
|
16
|
+
* real state, because a restored environment would then start with a
|
|
17
|
+
* previous run's data and stop being reproducible. (Counter-example worth
|
|
18
|
+
* remembering: `k3s()` deliberately does not cache its containerd store —
|
|
19
|
+
* a fresh cluster over an un-cleanly-killed store wedged the apiserver.)
|
|
20
|
+
*/
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
/** Root of the per-environment state tree. Wiped by delta teardown. */
|
|
23
|
+
export const DEFAULT_WORKSPACE = "/workspace";
|
|
24
|
+
/** Root of the cache tree. Deliberately outside the workspace. */
|
|
25
|
+
export const CACHE_ROOT = "/var/cache/spectest/volumes";
|
|
26
|
+
/** Directory holding named shared volumes, under whichever root applies. */
|
|
27
|
+
export const SHARED_DIR = "_shared";
|
|
28
|
+
/**
|
|
29
|
+
* Make an arbitrary string safe as a single path segment.
|
|
30
|
+
*
|
|
31
|
+
* Strips leading slashes, replaces anything outside `[A-Za-z0-9_-]`, and
|
|
32
|
+
* trims the dashes that leaves at the edges. The character filter also
|
|
33
|
+
* removes `.`, so `..` cannot survive — a volume name or target can't
|
|
34
|
+
* escape its root.
|
|
35
|
+
*/
|
|
36
|
+
export function sanitizeSegment(p) {
|
|
37
|
+
return p
|
|
38
|
+
.replace(/^\/+/, "")
|
|
39
|
+
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
40
|
+
.replace(/^-+|-+$/g, "");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The host directory backing one volume mount.
|
|
44
|
+
*
|
|
45
|
+
* Resolution order:
|
|
46
|
+
* 1. `name` — a shared directory under `_shared`, so two services
|
|
47
|
+
* mounting the same name genuinely share one directory.
|
|
48
|
+
* 2. An **absolute** `source` — used verbatim. The project asked for a
|
|
49
|
+
* specific path, so it gets it.
|
|
50
|
+
* 3. A relative `source`, or nothing at all — under the service's own
|
|
51
|
+
* directory, derived from `target` when `source` is absent.
|
|
52
|
+
*
|
|
53
|
+
* `workspace` is a parameter rather than a module constant so the rule is
|
|
54
|
+
* testable without touching the filesystem.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveHostPath(service, vol, workspace = DEFAULT_WORKSPACE) {
|
|
57
|
+
const stateRoot = [workspace, ".spectest", "volumes"];
|
|
58
|
+
if (vol.name) {
|
|
59
|
+
const root = vol.cache ? [CACHE_ROOT, SHARED_DIR] : [...stateRoot, SHARED_DIR];
|
|
60
|
+
return path.join(...root, sanitizeSegment(vol.name));
|
|
61
|
+
}
|
|
62
|
+
// An absolute source is the project's own path; `cache` doesn't apply
|
|
63
|
+
// because the location was already chosen explicitly.
|
|
64
|
+
if (vol.source && vol.source.startsWith("/"))
|
|
65
|
+
return vol.source;
|
|
66
|
+
const root = vol.cache ? [CACHE_ROOT, service] : [...stateRoot, service];
|
|
67
|
+
if (vol.source) {
|
|
68
|
+
return path.join(...root, vol.source.replace(/^\/+/, ""));
|
|
69
|
+
}
|
|
70
|
+
return path.join(...root, sanitizeSegment(vol.target));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Does this volume survive a delta-restore teardown?
|
|
74
|
+
*
|
|
75
|
+
* True for cache-flagged volumes and for absolute sources outside the
|
|
76
|
+
* workspace — the two ways a directory ends up beyond `rm -rf /workspace`.
|
|
77
|
+
*/
|
|
78
|
+
export function survivesTeardown(vol, service, workspace = DEFAULT_WORKSPACE) {
|
|
79
|
+
const host = resolveHostPath(service, vol, workspace);
|
|
80
|
+
return !host.startsWith(`${workspace}/`) && host !== workspace;
|
|
81
|
+
}
|