@specific.dev/spectest 0.39.0 → 0.43.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.
Files changed (105) hide show
  1. package/dist/browser.d.ts +21 -8
  2. package/dist/browser.js +78 -36
  3. package/dist/components/supabase.d.ts +87 -27
  4. package/dist/components/supabase.js +352 -69
  5. package/dist/daemon.d.ts +38 -0
  6. package/dist/daemon.js +464 -987
  7. package/dist/harness/build-context.d.ts +82 -0
  8. package/dist/harness/build-context.js +113 -0
  9. package/dist/harness/buildkit-progress.d.ts +37 -0
  10. package/dist/harness/buildkit-progress.js +66 -0
  11. package/dist/harness/container-run.d.ts +89 -0
  12. package/dist/harness/container-run.js +118 -0
  13. package/dist/harness/file-mounts.d.ts +91 -0
  14. package/dist/harness/file-mounts.js +119 -0
  15. package/dist/harness/hostmatch.d.ts +65 -0
  16. package/dist/harness/hostmatch.js +108 -0
  17. package/dist/harness/http-proxy.d.ts +62 -0
  18. package/dist/harness/http-proxy.js +104 -0
  19. package/dist/harness/ingress-table.d.ts +148 -0
  20. package/dist/harness/ingress-table.js +129 -0
  21. package/dist/harness/log-delta.d.ts +54 -0
  22. package/dist/harness/log-delta.js +83 -0
  23. package/dist/harness/main.d.ts +47 -0
  24. package/dist/harness/main.js +164 -0
  25. package/dist/harness/methods.d.ts +54 -0
  26. package/dist/harness/methods.js +65 -0
  27. package/dist/harness/names-registry.d.ts +63 -0
  28. package/dist/harness/names-registry.js +90 -0
  29. package/dist/harness/protocol.d.ts +88 -0
  30. package/dist/harness/protocol.js +96 -0
  31. package/dist/harness/ready-poll.d.ts +47 -0
  32. package/dist/harness/ready-poll.js +67 -0
  33. package/dist/harness/service-graph.d.ts +29 -0
  34. package/dist/harness/service-graph.js +92 -0
  35. package/dist/harness/volume-paths.d.ts +70 -0
  36. package/dist/harness/volume-paths.js +81 -0
  37. package/dist/index.d.ts +58 -16
  38. package/dist/ingress.d.ts +1 -1
  39. package/dist/mobile.d.ts +9 -5
  40. package/dist/mobile.js +7 -6
  41. package/dist/recorder.d.ts +10 -0
  42. package/dist/resolver.js +5 -8
  43. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  44. package/dist/vendor/rrweb-record.min.js +5061 -0
  45. package/package.json +7 -1
  46. package/src/aws-sigv4.ts +218 -0
  47. package/src/browser.ts +2095 -0
  48. package/src/components/aws.ts +554 -0
  49. package/src/components/email.ts +398 -0
  50. package/src/components/expo.ts +167 -0
  51. package/src/components/index.ts +81 -0
  52. package/src/components/k3s.ts +2061 -0
  53. package/src/components/postgres.ts +132 -0
  54. package/src/components/replayFake.ts +1015 -0
  55. package/src/components/s3.ts +132 -0
  56. package/src/components/supabase.ts +1699 -0
  57. package/src/daemon.ts +5537 -0
  58. package/src/harness/build-context.test.ts +0 -0
  59. package/src/harness/build-context.ts +146 -0
  60. package/src/harness/buildkit-progress.test.ts +98 -0
  61. package/src/harness/buildkit-progress.ts +74 -0
  62. package/src/harness/container-run.test.ts +209 -0
  63. package/src/harness/container-run.ts +158 -0
  64. package/src/harness/file-mounts.test.ts +185 -0
  65. package/src/harness/file-mounts.ts +145 -0
  66. package/src/harness/hostmatch.test.ts +148 -0
  67. package/src/harness/hostmatch.ts +109 -0
  68. package/src/harness/http-proxy.test.ts +156 -0
  69. package/src/harness/http-proxy.ts +119 -0
  70. package/src/harness/ingress-rebind.test.ts +125 -0
  71. package/src/harness/ingress-table.test.ts +172 -0
  72. package/src/harness/ingress-table.ts +186 -0
  73. package/src/harness/log-delta.test.ts +125 -0
  74. package/src/harness/log-delta.ts +100 -0
  75. package/src/harness/main.test.ts +211 -0
  76. package/src/harness/main.ts +196 -0
  77. package/src/harness/methods.test.ts +63 -0
  78. package/src/harness/methods.ts +92 -0
  79. package/src/harness/names-registry.test.ts +137 -0
  80. package/src/harness/names-registry.ts +108 -0
  81. package/src/harness/protocol.test.ts +148 -0
  82. package/src/harness/protocol.ts +163 -0
  83. package/src/harness/ready-poll.test.ts +172 -0
  84. package/src/harness/ready-poll.ts +93 -0
  85. package/src/harness/service-graph.test.ts +97 -0
  86. package/src/harness/service-graph.ts +97 -0
  87. package/src/harness/volume-paths.test.ts +102 -0
  88. package/src/harness/volume-paths.ts +112 -0
  89. package/src/ids.ts +89 -0
  90. package/src/index.ts +2767 -0
  91. package/src/ingress.ts +305 -0
  92. package/src/inspect.ts +739 -0
  93. package/src/locator.ts +716 -0
  94. package/src/mobile.ts +138 -0
  95. package/src/record-secrets.ts +41 -0
  96. package/src/recorder.ts +856 -0
  97. package/src/redis.ts +202 -0
  98. package/src/replay-bundle.ts +108 -0
  99. package/src/resolver.ts +348 -0
  100. package/src/s3.ts +333 -0
  101. package/src/sql.ts +243 -0
  102. package/src/terminal.ts +740 -0
  103. package/src/url-match.ts +67 -0
  104. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  105. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * What goes into a docker build: the image tag, the ignore rules, and the
3
+ * key that lets two identical builds collapse into one.
4
+ *
5
+ * Ported out of `daemon.ts`. The functions here read no module state —
6
+ * the project's own `.dockerignore` is passed in rather than reached for —
7
+ * which is what makes the ordering rule below testable at all.
8
+ */
9
+ /** Every service's built image is tagged in one namespace. */
10
+ export declare function imageTag(name: string): string;
11
+ /**
12
+ * Always excluded from a build context: version control, our own state,
13
+ * dependency and build output directories, and dotenv files.
14
+ */
15
+ export declare const DEFAULT_DOCKERIGNORE: readonly string[];
16
+ /**
17
+ * First line of a `.dockerignore` we generated, so a later bootstrap can
18
+ * tell our file apart from one the project ships and never mistakes its
19
+ * own output for user intent.
20
+ */
21
+ export declare const GENERATED_DOCKERIGNORE_HEADER = "# spectest-generated \u2014 do not edit (your own .dockerignore is honoured verbatim)";
22
+ /** Is this text a file we wrote ourselves on an earlier bootstrap? */
23
+ export declare function isGeneratedDockerignore(text: string): boolean;
24
+ /**
25
+ * Ignore rules for one dockerfile build, in precedence order: our
26
+ * defaults, then the project's own `.dockerignore` verbatim, then that
27
+ * service's `exclude`.
28
+ *
29
+ * **The order is load-bearing**, because of the `**`-plus-negations idiom
30
+ * that monorepos use to keep a build context small:
31
+ *
32
+ * ```
33
+ * ** ← the project's file: exclude everything
34
+ * !go.mod ← …then re-include exactly what the build reads
35
+ * !cmd/api/**
36
+ * ```
37
+ *
38
+ * Our defaults must come *first* so that `**` subsumes them; if they came
39
+ * after, they would re-exclude nothing useful but would sit below the
40
+ * negations and confuse the intent. The service's `exclude` comes *last*
41
+ * so it still gets the final word over both.
42
+ *
43
+ * Written per service (`.spectest/services/<name>/Dockerfile.dockerignore`)
44
+ * because BuildKit gives a Dockerfile-adjacent ignore file precedence over
45
+ * the context root's — which is what stops one service's `exclude` from
46
+ * shrinking a sibling's build context.
47
+ */
48
+ export declare function serviceDockerignore(projectDockerignore: string | null, exclude?: readonly string[]): string;
49
+ /**
50
+ * The context-root `.dockerignore` we write **only when the project ships
51
+ * none**, as a fallback for the legacy non-BuildKit builder.
52
+ *
53
+ * Deliberately does not include the project's own rules: it exists only in
54
+ * the case where there are none. Overwriting a project's file was a real
55
+ * regression — a carefully minimised context silently became the whole
56
+ * repo, which is a 10x build-time hit on a monorepo, and any in-env
57
+ * tooling that read the file saw ours instead.
58
+ */
59
+ export declare function unionDockerignore(services: readonly {
60
+ image: {
61
+ type: string;
62
+ exclude?: readonly string[];
63
+ };
64
+ }[]): string;
65
+ /**
66
+ * Identity of a dockerfile build within one bootstrap, so services sharing
67
+ * an image definition (an api and a worker on the same codebase with
68
+ * different entrypoints) build once and the rest just re-tag.
69
+ *
70
+ * The build **context** is an input too, but it isn't hashed: the context
71
+ * is `/workspace`, which is fixed within a bootstrap and mutable between
72
+ * them, so this key is only ever valid inside one workspace generation.
73
+ * The dedup map is cleared per bootstrap for exactly that reason.
74
+ */
75
+ export interface Hasher {
76
+ update(s: string): Hasher;
77
+ digest(encoding: "hex"): string;
78
+ }
79
+ export declare function buildContentKey(createHasher: () => Hasher, image: {
80
+ content: string;
81
+ exclude?: readonly string[];
82
+ }): string;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What goes into a docker build: the image tag, the ignore rules, and the
3
+ * key that lets two identical builds collapse into one.
4
+ *
5
+ * Ported out of `daemon.ts`. The functions here read no module state —
6
+ * the project's own `.dockerignore` is passed in rather than reached for —
7
+ * which is what makes the ordering rule below testable at all.
8
+ */
9
+ /** Every service's built image is tagged in one namespace. */
10
+ export function imageTag(name) {
11
+ return `spectest/${name}:latest`;
12
+ }
13
+ /**
14
+ * Always excluded from a build context: version control, our own state,
15
+ * dependency and build output directories, and dotenv files.
16
+ */
17
+ export const DEFAULT_DOCKERIGNORE = [
18
+ ".git",
19
+ ".spectest",
20
+ "spectest",
21
+ "node_modules",
22
+ "target",
23
+ "__pycache__",
24
+ ".venv",
25
+ ".env",
26
+ ".env.local",
27
+ ".env.*",
28
+ "dist",
29
+ "build",
30
+ ".next",
31
+ ".turbo",
32
+ ".DS_Store",
33
+ ];
34
+ /**
35
+ * First line of a `.dockerignore` we generated, so a later bootstrap can
36
+ * tell our file apart from one the project ships and never mistakes its
37
+ * own output for user intent.
38
+ */
39
+ export const GENERATED_DOCKERIGNORE_HEADER = "# spectest-generated — do not edit (your own .dockerignore is honoured verbatim)";
40
+ /** Is this text a file we wrote ourselves on an earlier bootstrap? */
41
+ export function isGeneratedDockerignore(text) {
42
+ return text.startsWith(GENERATED_DOCKERIGNORE_HEADER);
43
+ }
44
+ /**
45
+ * Ignore rules for one dockerfile build, in precedence order: our
46
+ * defaults, then the project's own `.dockerignore` verbatim, then that
47
+ * service's `exclude`.
48
+ *
49
+ * **The order is load-bearing**, because of the `**`-plus-negations idiom
50
+ * that monorepos use to keep a build context small:
51
+ *
52
+ * ```
53
+ * ** ← the project's file: exclude everything
54
+ * !go.mod ← …then re-include exactly what the build reads
55
+ * !cmd/api/**
56
+ * ```
57
+ *
58
+ * Our defaults must come *first* so that `**` subsumes them; if they came
59
+ * after, they would re-exclude nothing useful but would sit below the
60
+ * negations and confuse the intent. The service's `exclude` comes *last*
61
+ * so it still gets the final word over both.
62
+ *
63
+ * Written per service (`.spectest/services/<name>/Dockerfile.dockerignore`)
64
+ * because BuildKit gives a Dockerfile-adjacent ignore file precedence over
65
+ * the context root's — which is what stops one service's `exclude` from
66
+ * shrinking a sibling's build context.
67
+ */
68
+ export function serviceDockerignore(projectDockerignore, exclude) {
69
+ const parts = [DEFAULT_DOCKERIGNORE.join("\n")];
70
+ if (projectDockerignore !== null) {
71
+ parts.push(`# --- from the project's .dockerignore ---\n${projectDockerignore.trimEnd()}`);
72
+ }
73
+ if (exclude && exclude.length > 0) {
74
+ parts.push(`# --- from this service's exclude ---\n${exclude.join("\n")}`);
75
+ }
76
+ return parts.join("\n") + "\n";
77
+ }
78
+ /**
79
+ * The context-root `.dockerignore` we write **only when the project ships
80
+ * none**, as a fallback for the legacy non-BuildKit builder.
81
+ *
82
+ * Deliberately does not include the project's own rules: it exists only in
83
+ * the case where there are none. Overwriting a project's file was a real
84
+ * regression — a carefully minimised context silently became the whole
85
+ * repo, which is a 10x build-time hit on a monorepo, and any in-env
86
+ * tooling that read the file saw ours instead.
87
+ */
88
+ export function unionDockerignore(services) {
89
+ const seen = new Set(DEFAULT_DOCKERIGNORE);
90
+ const extras = [];
91
+ for (const s of services) {
92
+ if (s.image.type === "dockerfile" && s.image.exclude) {
93
+ for (const e of s.image.exclude) {
94
+ if (!seen.has(e)) {
95
+ seen.add(e);
96
+ extras.push(e);
97
+ }
98
+ }
99
+ }
100
+ }
101
+ return [GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n";
102
+ }
103
+ export function buildContentKey(createHasher, image) {
104
+ return (createHasher()
105
+ .update(image.content)
106
+ // A separator that cannot occur in either field. Without it a
107
+ // Dockerfile whose text ends with an exclude list would hash the
108
+ // same as that Dockerfile with the list actually set, and two
109
+ // genuinely different builds would collapse into one.
110
+ .update("\0")
111
+ .update(JSON.stringify(image.exclude ?? []))
112
+ .digest("hex"));
113
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reading `docker build --progress=plain` output.
3
+ *
4
+ * BuildKit's plain progress is the only machine-readable account of what a
5
+ * build actually did, and it is what the dashboard shows when a bootstrap
6
+ * is slow — which step took the time, and which were cache hits. Parsing
7
+ * it is pure string work, so it belongs out here where it can be tested
8
+ * against real output shapes rather than inferred from the regexes.
9
+ *
10
+ * Ported out of `daemon.ts`.
11
+ *
12
+ * The format, for the parts we read:
13
+ *
14
+ * ```
15
+ * #7 [builder 3/6] RUN go build ./...
16
+ * #7 sha256:abc… ← noise; ignored
17
+ * #7 DONE 12.4s
18
+ * #9 CACHED
19
+ * ```
20
+ */
21
+ export interface BuildStep {
22
+ name: string;
23
+ secs: number;
24
+ cached: boolean;
25
+ }
26
+ /**
27
+ * Summarise a build's steps, slowest first.
28
+ *
29
+ * Only the **first** line seen for a step id names it: BuildKit repeats
30
+ * the id for progress and digest lines, and a later one would overwrite
31
+ * the actual command with noise.
32
+ *
33
+ * A `CACHED` step is recorded at zero seconds rather than dropped —
34
+ * "this was cached" is the answer to "why was this build fast", and
35
+ * omitting it makes a fully-cached build look like it did nothing.
36
+ */
37
+ export declare function summarizeBuildKit(out: string): BuildStep[];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Reading `docker build --progress=plain` output.
3
+ *
4
+ * BuildKit's plain progress is the only machine-readable account of what a
5
+ * build actually did, and it is what the dashboard shows when a bootstrap
6
+ * is slow — which step took the time, and which were cache hits. Parsing
7
+ * it is pure string work, so it belongs out here where it can be tested
8
+ * against real output shapes rather than inferred from the regexes.
9
+ *
10
+ * Ported out of `daemon.ts`.
11
+ *
12
+ * The format, for the parts we read:
13
+ *
14
+ * ```
15
+ * #7 [builder 3/6] RUN go build ./...
16
+ * #7 sha256:abc… ← noise; ignored
17
+ * #7 DONE 12.4s
18
+ * #9 CACHED
19
+ * ```
20
+ */
21
+ /** Longest step name we keep — these are Dockerfile lines and can be huge. */
22
+ const MAX_NAME = 80;
23
+ /**
24
+ * Summarise a build's steps, slowest first.
25
+ *
26
+ * Only the **first** line seen for a step id names it: BuildKit repeats
27
+ * the id for progress and digest lines, and a later one would overwrite
28
+ * the actual command with noise.
29
+ *
30
+ * A `CACHED` step is recorded at zero seconds rather than dropped —
31
+ * "this was cached" is the answer to "why was this build fast", and
32
+ * omitting it makes a fully-cached build look like it did nothing.
33
+ */
34
+ export function summarizeBuildKit(out) {
35
+ const names = new Map();
36
+ const secs = new Map();
37
+ const cached = new Set();
38
+ for (const line of out.split("\n")) {
39
+ let m = line.match(/^#(\d+)\s+\[[^\]]*\]\s+(.+)$/);
40
+ if (m) {
41
+ const id = `#${m[1]}`;
42
+ if (!names.has(id))
43
+ names.set(id, m[2].trim().slice(0, MAX_NAME));
44
+ continue;
45
+ }
46
+ m = line.match(/^#(\d+)\s+DONE\s+([\d.]+)s/);
47
+ if (m) {
48
+ secs.set(`#${m[1]}`, parseFloat(m[2]));
49
+ continue;
50
+ }
51
+ m = line.match(/^#(\d+)\s+CACHED/);
52
+ if (m) {
53
+ const id = `#${m[1]}`;
54
+ cached.add(id);
55
+ // Don't clobber a real duration: a step can report CACHED after a
56
+ // DONE when part of its work was reused.
57
+ if (!secs.has(id))
58
+ secs.set(id, 0);
59
+ }
60
+ }
61
+ const steps = [];
62
+ for (const [id, name] of names) {
63
+ steps.push({ name, secs: secs.get(id) ?? 0, cached: cached.has(id) });
64
+ }
65
+ return steps.sort((a, b) => b.secs - a.secs);
66
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Assembling the `docker run` command line for a service container.
3
+ *
4
+ * Ported out of `daemon.ts` as part of the harness split. This is the most
5
+ * consequential command the harness builds — it decides a container's
6
+ * network identity, what it trusts, what it can see of the host, and what
7
+ * it actually executes — and it had no tests, because the only way to
8
+ * reach it was to start a real container.
9
+ *
10
+ * Splitting the argv construction from running it makes every rule below
11
+ * assertable in milliseconds. The caller keeps the side effects: removing
12
+ * a leftover container, invoking docker, and reading the result.
13
+ *
14
+ * ## Rules encoded here
15
+ *
16
+ * **Two names, always.** Every service answers to its bare name and to
17
+ * `<name>.internal`. The multi-label form exists because a single-label
18
+ * host breaks things that assume a FQDN — notably headless Chromium, which
19
+ * tries TLS against a bare name and fails with a protocol error even
20
+ * though `fetch` to the same URL is fine.
21
+ *
22
+ * **Certificate trust is two different mechanisms, and mixing them up
23
+ * breaks images silently.** `NODE_EXTRA_CA_CERTS` *appends* to the trust
24
+ * store, so it takes the bare spectest CA. `SSL_CERT_FILE`,
25
+ * `REQUESTS_CA_BUNDLE` and `AWS_CA_BUNDLE` *replace* it, so they must get
26
+ * the combined bundle — public roots plus ours. Pointing those three at
27
+ * the bare CA leaves the container trusting spectest and nothing else, and
28
+ * every outbound HTTPS call to a real service fails to verify. Node never
29
+ * showed the problem, which is why it went unnoticed: only the appending
30
+ * variable was in play.
31
+ *
32
+ * **`command` and `args` are different overrides.** `command` replaces the
33
+ * entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
34
+ * overrides CMD, which is what init-wrapped images like postgres need to
35
+ * take extra flags. Accepting both would silently drop one.
36
+ */
37
+ /** The subset of a service's config that shapes its container. */
38
+ export interface ContainerService {
39
+ name: string;
40
+ env?: Record<string, string>;
41
+ workdir?: string;
42
+ privileged?: boolean;
43
+ tmpfs?: readonly string[];
44
+ cgroupns?: string;
45
+ command?: string;
46
+ args?: readonly string[];
47
+ }
48
+ export interface ContainerRunInput {
49
+ svc: ContainerService;
50
+ /** The image tag to run. */
51
+ tag: string;
52
+ /** Docker network to attach to. */
53
+ network: string;
54
+ /** Extra `--network-alias` names beyond `<name>` and `<name>.internal`. */
55
+ aliases?: readonly string[];
56
+ /** Volume/file/certificate mount flags, already assembled. */
57
+ volumeFlags?: readonly string[];
58
+ /** Ingress hostnames to pin to {@link ContainerRunInput.gatewayIp}. */
59
+ ingressHosts?: readonly string[];
60
+ /** The bridge gateway, where the ingress listeners are. Null when it
61
+ * isn't known yet, in which case no `--add-host` is emitted. */
62
+ gatewayIp?: string | null;
63
+ /** The host image-cache gateway, reachable as `spectest-host`. */
64
+ hostCacheGateway?: string | null;
65
+ hostCacheName: string;
66
+ /** The in-VM root CA, bind-mounted and appended to Node's roots. */
67
+ caPath: string;
68
+ /** Public roots ⧺ our CA, for the variables that *replace* the store. */
69
+ caBundle?: string | null;
70
+ }
71
+ /**
72
+ * Bound TCP give-up time inside the container's own network namespace.
73
+ *
74
+ * `net.ipv4.tcp_retries2` is per-netns and a fresh netns resets to the
75
+ * kernel default (15, roughly 15 minutes of RTO backoff), so lowering it on
76
+ * the guest's init netns does not reach containers — and the flows that
77
+ * actually wedge live here: buildkit pulling base images and exporting
78
+ * cache, and k3s's containerd pulling images, all over the VM↔host path to
79
+ * the host registry. On a lost retransmit under concurrent forks such a
80
+ * flow otherwise stalls a build for minutes; six retries resets a genuinely
81
+ * stuck connection in tens of seconds and the client reconnects. Live
82
+ * connections keep getting ACKs and are unaffected.
83
+ *
84
+ * Safe because every service runs on the bridge network with its own netns,
85
+ * never `--network=host`, where writing `net.*` is denied.
86
+ */
87
+ export declare const TCP_RETRIES2 = 6;
88
+ /** Build the full `docker run` argv. Pure: no I/O, no module state. */
89
+ export declare function runContainerArgs(input: ContainerRunInput): string[];
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Assembling the `docker run` command line for a service container.
3
+ *
4
+ * Ported out of `daemon.ts` as part of the harness split. This is the most
5
+ * consequential command the harness builds — it decides a container's
6
+ * network identity, what it trusts, what it can see of the host, and what
7
+ * it actually executes — and it had no tests, because the only way to
8
+ * reach it was to start a real container.
9
+ *
10
+ * Splitting the argv construction from running it makes every rule below
11
+ * assertable in milliseconds. The caller keeps the side effects: removing
12
+ * a leftover container, invoking docker, and reading the result.
13
+ *
14
+ * ## Rules encoded here
15
+ *
16
+ * **Two names, always.** Every service answers to its bare name and to
17
+ * `<name>.internal`. The multi-label form exists because a single-label
18
+ * host breaks things that assume a FQDN — notably headless Chromium, which
19
+ * tries TLS against a bare name and fails with a protocol error even
20
+ * though `fetch` to the same URL is fine.
21
+ *
22
+ * **Certificate trust is two different mechanisms, and mixing them up
23
+ * breaks images silently.** `NODE_EXTRA_CA_CERTS` *appends* to the trust
24
+ * store, so it takes the bare spectest CA. `SSL_CERT_FILE`,
25
+ * `REQUESTS_CA_BUNDLE` and `AWS_CA_BUNDLE` *replace* it, so they must get
26
+ * the combined bundle — public roots plus ours. Pointing those three at
27
+ * the bare CA leaves the container trusting spectest and nothing else, and
28
+ * every outbound HTTPS call to a real service fails to verify. Node never
29
+ * showed the problem, which is why it went unnoticed: only the appending
30
+ * variable was in play.
31
+ *
32
+ * **`command` and `args` are different overrides.** `command` replaces the
33
+ * entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
34
+ * overrides CMD, which is what init-wrapped images like postgres need to
35
+ * take extra flags. Accepting both would silently drop one.
36
+ */
37
+ /**
38
+ * Bound TCP give-up time inside the container's own network namespace.
39
+ *
40
+ * `net.ipv4.tcp_retries2` is per-netns and a fresh netns resets to the
41
+ * kernel default (15, roughly 15 minutes of RTO backoff), so lowering it on
42
+ * the guest's init netns does not reach containers — and the flows that
43
+ * actually wedge live here: buildkit pulling base images and exporting
44
+ * cache, and k3s's containerd pulling images, all over the VM↔host path to
45
+ * the host registry. On a lost retransmit under concurrent forks such a
46
+ * flow otherwise stalls a build for minutes; six retries resets a genuinely
47
+ * stuck connection in tens of seconds and the client reconnects. Live
48
+ * connections keep getting ACKs and are unaffected.
49
+ *
50
+ * Safe because every service runs on the bridge network with its own netns,
51
+ * never `--network=host`, where writing `net.*` is denied.
52
+ */
53
+ export const TCP_RETRIES2 = 6;
54
+ /** Build the full `docker run` argv. Pure: no I/O, no module state. */
55
+ export function runContainerArgs(input) {
56
+ const { svc, tag, network } = input;
57
+ if (svc.command && svc.args?.length) {
58
+ throw new Error(`service ${svc.name}: \`command\` and \`args\` are mutually exclusive ` +
59
+ `(command replaces the entrypoint with /bin/sh -c; args keeps it)`);
60
+ }
61
+ const args = [
62
+ "run",
63
+ "-d",
64
+ "--restart=no",
65
+ `--name=${svc.name}`,
66
+ `--hostname=${svc.name}`,
67
+ `--network=${network}`,
68
+ `--network-alias=${svc.name}.internal`,
69
+ ];
70
+ for (const alias of input.aliases ?? [])
71
+ args.push(`--network-alias=${alias}`);
72
+ args.push("--sysctl", `net.ipv4.tcp_retries2=${TCP_RETRIES2}`);
73
+ // Ingress hostnames via /etc/hosts, which beats Docker's embedded DNS —
74
+ // so app code reaching a fake or a TLS-terminated proxy lands on the
75
+ // harness listener without touching the container's resolver config.
76
+ if (input.gatewayIp) {
77
+ for (const h of input.ingressHosts ?? []) {
78
+ args.push(`--add-host=${h}:${input.gatewayIp}`);
79
+ }
80
+ }
81
+ if (input.hostCacheGateway) {
82
+ args.push(`--add-host=${input.hostCacheName}:${input.hostCacheGateway}`);
83
+ }
84
+ if (svc.workdir)
85
+ args.push(`--workdir=${svc.workdir}`);
86
+ // See the module header: the appending variable takes the bare CA, the
87
+ // replacing ones take the combined bundle.
88
+ args.push(`--volume=${input.caPath}:${input.caPath}:ro`);
89
+ args.push("-e", `NODE_EXTRA_CA_CERTS=${input.caPath}`);
90
+ const bundle = input.caBundle;
91
+ if (bundle) {
92
+ if (bundle !== input.caPath)
93
+ args.push(`--volume=${bundle}:${bundle}:ro`);
94
+ args.push("-e", `SSL_CERT_FILE=${bundle}`);
95
+ args.push("-e", `REQUESTS_CA_BUNDLE=${bundle}`);
96
+ args.push("-e", `AWS_CA_BUNDLE=${bundle}`);
97
+ }
98
+ // The service's own env comes after ours, so a project can override the
99
+ // defaults we set rather than being silently overridden by them.
100
+ for (const [k, v] of Object.entries(svc.env ?? {}))
101
+ args.push("-e", `${k}=${v}`);
102
+ for (const flag of input.volumeFlags ?? [])
103
+ args.push(flag);
104
+ if (svc.privileged)
105
+ args.push("--privileged");
106
+ for (const p of svc.tmpfs ?? [])
107
+ args.push(`--tmpfs=${p}`);
108
+ if (svc.cgroupns)
109
+ args.push(`--cgroupns=${svc.cgroupns}`);
110
+ if (svc.command)
111
+ args.push("--entrypoint=/bin/sh");
112
+ args.push(tag);
113
+ if (svc.command)
114
+ args.push("-c", svc.command);
115
+ else if (svc.args?.length)
116
+ args.push(...svc.args);
117
+ return args;
118
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Staging `files` and `certificates` for bind-mounting into a container.
3
+ *
4
+ * Ported out of `daemon.ts` as part of the harness split. These are the
5
+ * *pre-entrypoint* injection point — the only way to put something in place
6
+ * before the container's own process starts, which is what a config file a
7
+ * daemon reads at startup, or a TLS key it refuses to run without, actually
8
+ * needs. `setup` and `helpers` both run too late.
9
+ *
10
+ * What lives here is the part that decides *what* is staged and with which
11
+ * permissions. Writing the bytes, minting the certificate and running chown
12
+ * stay with the caller — but every rule that has ever been reported as a bug
13
+ * is a pure function below, with a test.
14
+ *
15
+ * ## The permission rules, and why they are not obvious
16
+ *
17
+ * **A bind mount carries the host inode's mode and ownership straight
18
+ * through, and the harness writes as root.** So `mode` on its own is not
19
+ * enough: locking a file down to `0600` gives the container a file owned by
20
+ * root that whoever it runs as cannot open. Postgres refusing to start on
21
+ * its own TLS key is the canonical report. `user`/`group` exist to name the
22
+ * reader, and naming one is what makes a strict mode usable.
23
+ *
24
+ * **Declaring a reader also implies the mode.** A key whose owner is named
25
+ * gets the strictest mode that owner can still open — `0600` when a user is
26
+ * named, `0640` when only a group is. A server that checks (postgres, ssh)
27
+ * refuses a lax key, so defaulting to something permissive would just move
28
+ * the failure. This only applies when an owner was named, so no existing
29
+ * environment changes behaviour.
30
+ *
31
+ * **`-1` means "leave this half alone", and Bun will not accept it.**
32
+ * chown(2) and node both read `-1` as unchanged, which is how plain `chown`
33
+ * lets you set a user without touching the group. Bun's `fs.chown` rejects
34
+ * it with `EPERM` (measured on Bun 1.3.14), so the untouched half has to be
35
+ * filled in from the file's current owner before the call —
36
+ * {@link resolveChownIds} is that, kept separate so the rule is testable
37
+ * without a filesystem.
38
+ */
39
+ /** Numeric ids for a staged file. `-1` means "unchanged". */
40
+ export interface OwnerIds {
41
+ uid: number;
42
+ gid: number;
43
+ }
44
+ /** `{{SPECTEST_SERVICE}}` → the service's map key.
45
+ *
46
+ * Lets a component author self-referential config without knowing the key
47
+ * the user will choose for it — k3s's `registries.yaml` pointing at
48
+ * `<key>.internal:5000` is the reason this exists. */
49
+ export declare function expandServiceToken(text: string, service: string): string;
50
+ /** Parse a `user`/`group` value that may be a numeric id already. */
51
+ export declare function numericId(value: string | undefined): number | undefined;
52
+ /**
53
+ * Does resolving this owner need the image's `/etc/passwd` + `/etc/group`?
54
+ *
55
+ * Probing an image costs a `docker create` + `docker cp`, so it is worth
56
+ * knowing that two numeric ids need no probe at all.
57
+ */
58
+ export declare function needsIdTables(user?: string, group?: string): boolean;
59
+ /**
60
+ * The mode a certificate's key should get.
61
+ *
62
+ * `undefined` when no owner was named — the file keeps whatever the harness
63
+ * wrote, which is the pre-existing behaviour for every environment that
64
+ * never asked about ownership.
65
+ */
66
+ export declare function defaultKeyMode(explicit: string | undefined, owner: OwnerIds | undefined): string | undefined;
67
+ /**
68
+ * Fill in the halves of a chown that were meant to be left alone.
69
+ *
70
+ * Takes the file's current ids rather than reading them, so the rule is a
71
+ * pure function. See the module header for why `-1` cannot simply be passed
72
+ * through under Bun.
73
+ */
74
+ export declare function resolveChownIds(owner: OwnerIds, current: OwnerIds): OwnerIds;
75
+ /** True when the owner asks for no change at all, so the chown can be
76
+ * skipped entirely rather than resolved and re-applied. */
77
+ export declare function isNoopChown(owner: OwnerIds): boolean;
78
+ /**
79
+ * Reject a container path that isn't absolute.
80
+ *
81
+ * A relative path would be resolved by docker against the container's
82
+ * working directory, so the file would land somewhere that depends on the
83
+ * image rather than where the project said — a mount that appears to work
84
+ * and puts the file in the wrong place is worse than one that refuses.
85
+ */
86
+ export declare function assertAbsolute(service: string, label: string, p: string): void;
87
+ /** The hostnames a certificate entry covers, with the service token
88
+ * expanded and the empty case refused. */
89
+ export declare function certificateHostnames(service: string, index: number, hostnames: readonly string[]): string[];
90
+ /** A read-only single-file bind mount. */
91
+ export declare function mountFlag(hostPath: string, containerPath: string): string;