@specific.dev/spectest 0.38.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.
Files changed (103) hide show
  1. package/dist/components/k3s.js +1 -24
  2. package/dist/components/supabase.d.ts +87 -27
  3. package/dist/components/supabase.js +352 -69
  4. package/dist/daemon.d.ts +38 -0
  5. package/dist/daemon.js +405 -946
  6. package/dist/harness/build-context.d.ts +82 -0
  7. package/dist/harness/build-context.js +113 -0
  8. package/dist/harness/buildkit-progress.d.ts +37 -0
  9. package/dist/harness/buildkit-progress.js +66 -0
  10. package/dist/harness/container-run.d.ts +89 -0
  11. package/dist/harness/container-run.js +118 -0
  12. package/dist/harness/file-mounts.d.ts +91 -0
  13. package/dist/harness/file-mounts.js +119 -0
  14. package/dist/harness/hostmatch.d.ts +65 -0
  15. package/dist/harness/hostmatch.js +108 -0
  16. package/dist/harness/http-proxy.d.ts +62 -0
  17. package/dist/harness/http-proxy.js +104 -0
  18. package/dist/harness/ingress-table.d.ts +148 -0
  19. package/dist/harness/ingress-table.js +129 -0
  20. package/dist/harness/log-delta.d.ts +54 -0
  21. package/dist/harness/log-delta.js +83 -0
  22. package/dist/harness/main.d.ts +47 -0
  23. package/dist/harness/main.js +164 -0
  24. package/dist/harness/methods.d.ts +54 -0
  25. package/dist/harness/methods.js +65 -0
  26. package/dist/harness/names-registry.d.ts +63 -0
  27. package/dist/harness/names-registry.js +90 -0
  28. package/dist/harness/protocol.d.ts +88 -0
  29. package/dist/harness/protocol.js +96 -0
  30. package/dist/harness/ready-poll.d.ts +47 -0
  31. package/dist/harness/ready-poll.js +67 -0
  32. package/dist/harness/service-graph.d.ts +29 -0
  33. package/dist/harness/service-graph.js +92 -0
  34. package/dist/harness/volume-paths.d.ts +70 -0
  35. package/dist/harness/volume-paths.js +81 -0
  36. package/dist/index.d.ts +3 -3
  37. package/dist/ingress.d.ts +1 -1
  38. package/dist/inspect.d.ts +23 -0
  39. package/dist/inspect.js +65 -0
  40. package/dist/resolver.js +5 -8
  41. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  42. package/dist/vendor/rrweb-record.min.js +5061 -0
  43. package/package.json +7 -1
  44. package/src/aws-sigv4.ts +218 -0
  45. package/src/browser.ts +2040 -0
  46. package/src/components/aws.ts +554 -0
  47. package/src/components/email.ts +398 -0
  48. package/src/components/expo.ts +167 -0
  49. package/src/components/index.ts +81 -0
  50. package/src/components/k3s.ts +2061 -0
  51. package/src/components/postgres.ts +132 -0
  52. package/src/components/replayFake.ts +1015 -0
  53. package/src/components/s3.ts +132 -0
  54. package/src/components/supabase.ts +1699 -0
  55. package/src/daemon.ts +5489 -0
  56. package/src/harness/build-context.test.ts +0 -0
  57. package/src/harness/build-context.ts +146 -0
  58. package/src/harness/buildkit-progress.test.ts +98 -0
  59. package/src/harness/buildkit-progress.ts +74 -0
  60. package/src/harness/container-run.test.ts +209 -0
  61. package/src/harness/container-run.ts +158 -0
  62. package/src/harness/file-mounts.test.ts +185 -0
  63. package/src/harness/file-mounts.ts +145 -0
  64. package/src/harness/hostmatch.test.ts +148 -0
  65. package/src/harness/hostmatch.ts +109 -0
  66. package/src/harness/http-proxy.test.ts +156 -0
  67. package/src/harness/http-proxy.ts +119 -0
  68. package/src/harness/ingress-rebind.test.ts +125 -0
  69. package/src/harness/ingress-table.test.ts +172 -0
  70. package/src/harness/ingress-table.ts +186 -0
  71. package/src/harness/log-delta.test.ts +125 -0
  72. package/src/harness/log-delta.ts +100 -0
  73. package/src/harness/main.test.ts +211 -0
  74. package/src/harness/main.ts +196 -0
  75. package/src/harness/methods.test.ts +63 -0
  76. package/src/harness/methods.ts +92 -0
  77. package/src/harness/names-registry.test.ts +137 -0
  78. package/src/harness/names-registry.ts +108 -0
  79. package/src/harness/protocol.test.ts +148 -0
  80. package/src/harness/protocol.ts +163 -0
  81. package/src/harness/ready-poll.test.ts +172 -0
  82. package/src/harness/ready-poll.ts +93 -0
  83. package/src/harness/service-graph.test.ts +97 -0
  84. package/src/harness/service-graph.ts +97 -0
  85. package/src/harness/volume-paths.test.ts +102 -0
  86. package/src/harness/volume-paths.ts +112 -0
  87. package/src/ids.ts +89 -0
  88. package/src/index.ts +2725 -0
  89. package/src/ingress.ts +305 -0
  90. package/src/inspect.ts +739 -0
  91. package/src/locator.ts +716 -0
  92. package/src/mobile.ts +133 -0
  93. package/src/record-secrets.ts +41 -0
  94. package/src/recorder.ts +846 -0
  95. package/src/redis.ts +202 -0
  96. package/src/replay-bundle.ts +108 -0
  97. package/src/resolver.ts +348 -0
  98. package/src/s3.ts +333 -0
  99. package/src/sql.ts +243 -0
  100. package/src/terminal.ts +740 -0
  101. package/src/url-match.ts +67 -0
  102. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  103. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -0,0 +1,146 @@
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
+
10
+ /** Every service's built image is tagged in one namespace. */
11
+ export function imageTag(name: string): string {
12
+ return `spectest/${name}:latest`;
13
+ }
14
+
15
+ /**
16
+ * Always excluded from a build context: version control, our own state,
17
+ * dependency and build output directories, and dotenv files.
18
+ */
19
+ export const DEFAULT_DOCKERIGNORE: readonly string[] = [
20
+ ".git",
21
+ ".spectest",
22
+ "spectest",
23
+ "node_modules",
24
+ "target",
25
+ "__pycache__",
26
+ ".venv",
27
+ ".env",
28
+ ".env.local",
29
+ ".env.*",
30
+ "dist",
31
+ "build",
32
+ ".next",
33
+ ".turbo",
34
+ ".DS_Store",
35
+ ];
36
+
37
+ /**
38
+ * First line of a `.dockerignore` we generated, so a later bootstrap can
39
+ * tell our file apart from one the project ships and never mistakes its
40
+ * own output for user intent.
41
+ */
42
+ export const GENERATED_DOCKERIGNORE_HEADER =
43
+ "# spectest-generated — do not edit (your own .dockerignore is honoured verbatim)";
44
+
45
+ /** Is this text a file we wrote ourselves on an earlier bootstrap? */
46
+ export function isGeneratedDockerignore(text: string): boolean {
47
+ return text.startsWith(GENERATED_DOCKERIGNORE_HEADER);
48
+ }
49
+
50
+ /**
51
+ * Ignore rules for one dockerfile build, in precedence order: our
52
+ * defaults, then the project's own `.dockerignore` verbatim, then that
53
+ * service's `exclude`.
54
+ *
55
+ * **The order is load-bearing**, because of the `**`-plus-negations idiom
56
+ * that monorepos use to keep a build context small:
57
+ *
58
+ * ```
59
+ * ** ← the project's file: exclude everything
60
+ * !go.mod ← …then re-include exactly what the build reads
61
+ * !cmd/api/**
62
+ * ```
63
+ *
64
+ * Our defaults must come *first* so that `**` subsumes them; if they came
65
+ * after, they would re-exclude nothing useful but would sit below the
66
+ * negations and confuse the intent. The service's `exclude` comes *last*
67
+ * so it still gets the final word over both.
68
+ *
69
+ * Written per service (`.spectest/services/<name>/Dockerfile.dockerignore`)
70
+ * because BuildKit gives a Dockerfile-adjacent ignore file precedence over
71
+ * the context root's — which is what stops one service's `exclude` from
72
+ * shrinking a sibling's build context.
73
+ */
74
+ export function serviceDockerignore(
75
+ projectDockerignore: string | null,
76
+ exclude?: readonly string[],
77
+ ): string {
78
+ const parts = [DEFAULT_DOCKERIGNORE.join("\n")];
79
+ if (projectDockerignore !== null) {
80
+ parts.push(`# --- from the project's .dockerignore ---\n${projectDockerignore.trimEnd()}`);
81
+ }
82
+ if (exclude && exclude.length > 0) {
83
+ parts.push(`# --- from this service's exclude ---\n${exclude.join("\n")}`);
84
+ }
85
+ return parts.join("\n") + "\n";
86
+ }
87
+
88
+ /**
89
+ * The context-root `.dockerignore` we write **only when the project ships
90
+ * none**, as a fallback for the legacy non-BuildKit builder.
91
+ *
92
+ * Deliberately does not include the project's own rules: it exists only in
93
+ * the case where there are none. Overwriting a project's file was a real
94
+ * regression — a carefully minimised context silently became the whole
95
+ * repo, which is a 10x build-time hit on a monorepo, and any in-env
96
+ * tooling that read the file saw ours instead.
97
+ */
98
+ export function unionDockerignore(
99
+ services: readonly { image: { type: string; exclude?: readonly string[] } }[],
100
+ ): string {
101
+ const seen = new Set<string>(DEFAULT_DOCKERIGNORE);
102
+ const extras: string[] = [];
103
+ for (const s of services) {
104
+ if (s.image.type === "dockerfile" && s.image.exclude) {
105
+ for (const e of s.image.exclude) {
106
+ if (!seen.has(e)) {
107
+ seen.add(e);
108
+ extras.push(e);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return [GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n";
114
+ }
115
+
116
+ /**
117
+ * Identity of a dockerfile build within one bootstrap, so services sharing
118
+ * an image definition (an api and a worker on the same codebase with
119
+ * different entrypoints) build once and the rest just re-tag.
120
+ *
121
+ * The build **context** is an input too, but it isn't hashed: the context
122
+ * is `/workspace`, which is fixed within a bootstrap and mutable between
123
+ * them, so this key is only ever valid inside one workspace generation.
124
+ * The dedup map is cleared per bootstrap for exactly that reason.
125
+ */
126
+ export interface Hasher {
127
+ update(s: string): Hasher;
128
+ digest(encoding: "hex"): string;
129
+ }
130
+
131
+ export function buildContentKey(
132
+ createHasher: () => Hasher,
133
+ image: { content: string; exclude?: readonly string[] },
134
+ ): string {
135
+ return (
136
+ createHasher()
137
+ .update(image.content)
138
+ // A separator that cannot occur in either field. Without it a
139
+ // Dockerfile whose text ends with an exclude list would hash the
140
+ // same as that Dockerfile with the list actually set, and two
141
+ // genuinely different builds would collapse into one.
142
+ .update("\0")
143
+ .update(JSON.stringify(image.exclude ?? []))
144
+ .digest("hex")
145
+ );
146
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { summarizeBuildKit } from "./buildkit-progress";
4
+
5
+ /** A realistic slice of `docker build --progress=plain` output. */
6
+ const SAMPLE = `
7
+ #1 [internal] load build definition from Dockerfile
8
+ #1 transferring dockerfile: 512B done
9
+ #1 DONE 0.1s
10
+
11
+ #5 [builder 2/6] COPY go.mod go.sum ./
12
+ #5 CACHED
13
+
14
+ #7 [builder 3/6] RUN go build -o /out/api ./cmd/api
15
+ #7 sha256:deadbeef
16
+ #7 12.113 building...
17
+ #7 DONE 12.4s
18
+
19
+ #9 [stage-1 2/2] COPY --from=builder /out/api /usr/local/bin/api
20
+ #9 DONE 0.3s
21
+ `;
22
+
23
+ describe("summarizeBuildKit", () => {
24
+ test("names each step from its bracketed line", () => {
25
+ const steps = summarizeBuildKit(SAMPLE);
26
+ const names = steps.map((s) => s.name);
27
+ expect(names).toContain("RUN go build -o /out/api ./cmd/api");
28
+ expect(names).toContain("COPY go.mod go.sum ./");
29
+ });
30
+
31
+ test("slowest first, so the answer to 'why is this slow' leads", () => {
32
+ const steps = summarizeBuildKit(SAMPLE);
33
+ expect(steps[0].name).toBe("RUN go build -o /out/api ./cmd/api");
34
+ expect(steps[0].secs).toBeCloseTo(12.4, 5);
35
+ });
36
+
37
+ /** "This was cached" is the answer to "why was this build fast" —
38
+ * dropping cached steps makes a fully-cached build look like it did
39
+ * nothing at all. */
40
+ test("cached steps are kept, at zero seconds", () => {
41
+ const cached = summarizeBuildKit(SAMPLE).find((s) => s.name.startsWith("COPY go.mod"))!;
42
+ expect(cached.cached).toBe(true);
43
+ expect(cached.secs).toBe(0);
44
+ });
45
+
46
+ test("uncached steps are not marked cached", () => {
47
+ const step = summarizeBuildKit(SAMPLE).find((s) => s.name.startsWith("RUN go build"))!;
48
+ expect(step.cached).toBe(false);
49
+ });
50
+
51
+ /** BuildKit repeats a step id on digest and progress lines; only the
52
+ * first naming line is the command. */
53
+ test("a step's name is not overwritten by later lines for the same id", () => {
54
+ const out = summarizeBuildKit(`
55
+ #7 [builder 3/6] RUN make
56
+ #7 [builder 3/6] 1.234 some log output that looks like a name
57
+ #7 DONE 1.0s
58
+ `);
59
+ expect(out).toHaveLength(1);
60
+ expect(out[0].name).toBe("RUN make");
61
+ });
62
+
63
+ test("digest and transfer noise never becomes a step", () => {
64
+ const steps = summarizeBuildKit(SAMPLE);
65
+ expect(steps.some((s) => s.name.includes("sha256"))).toBe(false);
66
+ expect(steps.some((s) => s.name.includes("transferring"))).toBe(false);
67
+ });
68
+
69
+ test("a step with no DONE line reports zero rather than NaN", () => {
70
+ const out = summarizeBuildKit("#3 [x 1/2] RUN something\n");
71
+ expect(out).toHaveLength(1);
72
+ expect(out[0].secs).toBe(0);
73
+ expect(Number.isNaN(out[0].secs)).toBe(false);
74
+ });
75
+
76
+ test("a long step name is truncated", () => {
77
+ const long = "RUN " + "x".repeat(500);
78
+ const out = summarizeBuildKit(`#1 [a 1/1] ${long}\n#1 DONE 1.0s\n`);
79
+ expect(out[0].name.length).toBeLessThanOrEqual(80);
80
+ });
81
+
82
+ test("empty output yields no steps", () => {
83
+ expect(summarizeBuildKit("")).toEqual([]);
84
+ });
85
+
86
+ test("sub-second durations parse", () => {
87
+ const out = summarizeBuildKit("#1 [a 1/1] COPY . .\n#1 DONE 0.05s\n");
88
+ expect(out[0].secs).toBeCloseTo(0.05, 5);
89
+ });
90
+
91
+ /** A step can report CACHED after a DONE when only part of its work was
92
+ * reused; the measured duration is the more useful number. */
93
+ test("CACHED after DONE does not clobber the measured duration", () => {
94
+ const out = summarizeBuildKit("#1 [a 1/1] RUN x\n#1 DONE 3.0s\n#1 CACHED\n");
95
+ expect(out[0].secs).toBeCloseTo(3.0, 5);
96
+ expect(out[0].cached).toBe(true);
97
+ });
98
+ });
@@ -0,0 +1,74 @@
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
+
22
+ export interface BuildStep {
23
+ name: string;
24
+ secs: number;
25
+ cached: boolean;
26
+ }
27
+
28
+ /** Longest step name we keep — these are Dockerfile lines and can be huge. */
29
+ const MAX_NAME = 80;
30
+
31
+ /**
32
+ * Summarise a build's steps, slowest first.
33
+ *
34
+ * Only the **first** line seen for a step id names it: BuildKit repeats
35
+ * the id for progress and digest lines, and a later one would overwrite
36
+ * the actual command with noise.
37
+ *
38
+ * A `CACHED` step is recorded at zero seconds rather than dropped —
39
+ * "this was cached" is the answer to "why was this build fast", and
40
+ * omitting it makes a fully-cached build look like it did nothing.
41
+ */
42
+ export function summarizeBuildKit(out: string): BuildStep[] {
43
+ const names = new Map<string, string>();
44
+ const secs = new Map<string, number>();
45
+ const cached = new Set<string>();
46
+
47
+ for (const line of out.split("\n")) {
48
+ let m = line.match(/^#(\d+)\s+\[[^\]]*\]\s+(.+)$/);
49
+ if (m) {
50
+ const id = `#${m[1]}`;
51
+ if (!names.has(id)) names.set(id, m[2].trim().slice(0, MAX_NAME));
52
+ continue;
53
+ }
54
+ m = line.match(/^#(\d+)\s+DONE\s+([\d.]+)s/);
55
+ if (m) {
56
+ secs.set(`#${m[1]}`, parseFloat(m[2]));
57
+ continue;
58
+ }
59
+ m = line.match(/^#(\d+)\s+CACHED/);
60
+ if (m) {
61
+ const id = `#${m[1]}`;
62
+ cached.add(id);
63
+ // Don't clobber a real duration: a step can report CACHED after a
64
+ // DONE when part of its work was reused.
65
+ if (!secs.has(id)) secs.set(id, 0);
66
+ }
67
+ }
68
+
69
+ const steps: BuildStep[] = [];
70
+ for (const [id, name] of names) {
71
+ steps.push({ name, secs: secs.get(id) ?? 0, cached: cached.has(id) });
72
+ }
73
+ return steps.sort((a, b) => b.secs - a.secs);
74
+ }
@@ -0,0 +1,209 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { TCP_RETRIES2, runContainerArgs, type ContainerRunInput } from "./container-run";
4
+
5
+ const base = (over: Partial<ContainerRunInput> = {}): ContainerRunInput => ({
6
+ svc: { name: "web" },
7
+ tag: "spectest/web:latest",
8
+ network: "spectest-net",
9
+ hostCacheName: "spectest-host",
10
+ caPath: "/etc/spectest/ca.crt",
11
+ ...over,
12
+ });
13
+
14
+ /** The value after a `-e` / `--sysctl` style flag. */
15
+ function valueAfter(args: string[], flag: string): string | undefined {
16
+ const i = args.indexOf(flag);
17
+ return i === -1 ? undefined : args[i + 1];
18
+ }
19
+ const envOf = (args: string[], name: string): string | undefined =>
20
+ args.filter((a, i) => args[i - 1] === "-e").find((a) => a.startsWith(`${name}=`))
21
+ ?.slice(name.length + 1);
22
+
23
+ describe("identity and network", () => {
24
+ test("the image is the last thing before any command override", () => {
25
+ const args = runContainerArgs(base());
26
+ expect(args[args.length - 1]).toBe("spectest/web:latest");
27
+ });
28
+
29
+ test("names the container and its host after the service", () => {
30
+ const args = runContainerArgs(base());
31
+ expect(args).toContain("--name=web");
32
+ expect(args).toContain("--hostname=web");
33
+ expect(args).toContain("--network=spectest-net");
34
+ });
35
+
36
+ /** A single-label host breaks headless Chromium, which attempts TLS
37
+ * against it; every service therefore also answers to a FQDN. */
38
+ test("always adds the .internal alias", () => {
39
+ expect(runContainerArgs(base())).toContain("--network-alias=web.internal");
40
+ });
41
+
42
+ test("extra aliases are added alongside, not instead of, .internal", () => {
43
+ const args = runContainerArgs(base({ aliases: ["api.test", "www.test"] }));
44
+ expect(args).toContain("--network-alias=web.internal");
45
+ expect(args).toContain("--network-alias=api.test");
46
+ expect(args).toContain("--network-alias=www.test");
47
+ });
48
+
49
+ test("bounds TCP give-up time inside the container's netns", () => {
50
+ const args = runContainerArgs(base());
51
+ expect(valueAfter(args, "--sysctl")).toBe(`net.ipv4.tcp_retries2=${TCP_RETRIES2}`);
52
+ });
53
+ });
54
+
55
+ describe("host resolution", () => {
56
+ test("pins every ingress hostname at the gateway", () => {
57
+ const args = runContainerArgs(
58
+ base({ gatewayIp: "10.42.0.1", ingressHosts: ["api.stripe.com", "app.test"] }),
59
+ );
60
+ expect(args).toContain("--add-host=api.stripe.com:10.42.0.1");
61
+ expect(args).toContain("--add-host=app.test:10.42.0.1");
62
+ });
63
+
64
+ /** The gateway is discovered from dockerd and may not be known yet.
65
+ * Emitting `--add-host=h:` would be a docker error, not a no-op. */
66
+ test("emits no ingress host entries when the gateway is unknown", () => {
67
+ const args = runContainerArgs(base({ gatewayIp: null, ingressHosts: ["api.stripe.com"] }));
68
+ expect(args.some((a) => a.startsWith("--add-host=api.stripe.com"))).toBe(false);
69
+ });
70
+
71
+ test("names the host cache so apps can address it without an IP", () => {
72
+ const args = runContainerArgs(base({ hostCacheGateway: "10.42.0.1" }));
73
+ expect(args).toContain("--add-host=spectest-host:10.42.0.1");
74
+ });
75
+
76
+ test("omits the host cache where there isn't one", () => {
77
+ const args = runContainerArgs(base({ hostCacheGateway: null }));
78
+ expect(args.some((a) => a.startsWith("--add-host=spectest-host"))).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe("certificate trust", () => {
83
+ test("mounts the CA and appends it to Node's roots", () => {
84
+ const args = runContainerArgs(base());
85
+ expect(args).toContain("--volume=/etc/spectest/ca.crt:/etc/spectest/ca.crt:ro");
86
+ expect(envOf(args, "NODE_EXTRA_CA_CERTS")).toBe("/etc/spectest/ca.crt");
87
+ });
88
+
89
+ /** The load-bearing one. These three variables REPLACE the trust store,
90
+ * so handing them the bare CA leaves the container trusting spectest and
91
+ * no public root — Python requests and every AWS SDK then fail to verify
92
+ * real HTTPS, in any image. They must get the combined bundle. */
93
+ test("the replacing variables get the bundle, never the bare CA", () => {
94
+ const args = runContainerArgs(base({ caBundle: "/etc/spectest/ca-bundle.crt" }));
95
+ for (const name of ["SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "AWS_CA_BUNDLE"]) {
96
+ expect(envOf(args, name)).toBe("/etc/spectest/ca-bundle.crt");
97
+ expect(envOf(args, name)).not.toBe("/etc/spectest/ca.crt");
98
+ }
99
+ expect(args).toContain(
100
+ "--volume=/etc/spectest/ca-bundle.crt:/etc/spectest/ca-bundle.crt:ro",
101
+ );
102
+ });
103
+
104
+ test("with no bundle, the replacing variables are left unset", () => {
105
+ const args = runContainerArgs(base({ caBundle: null }));
106
+ expect(envOf(args, "SSL_CERT_FILE")).toBeUndefined();
107
+ expect(envOf(args, "AWS_CA_BUNDLE")).toBeUndefined();
108
+ // The appending one is still safe to set on its own.
109
+ expect(envOf(args, "NODE_EXTRA_CA_CERTS")).toBe("/etc/spectest/ca.crt");
110
+ });
111
+
112
+ test("a bundle equal to the CA path is not mounted twice", () => {
113
+ const args = runContainerArgs(base({ caBundle: "/etc/spectest/ca.crt" }));
114
+ const mounts = args.filter((a) => a === "--volume=/etc/spectest/ca.crt:/etc/spectest/ca.crt:ro");
115
+ expect(mounts).toHaveLength(1);
116
+ });
117
+ });
118
+
119
+ describe("service configuration", () => {
120
+ test("passes the service's own environment through", () => {
121
+ const args = runContainerArgs(base({ svc: { name: "web", env: { PORT: "3000" } } }));
122
+ expect(envOf(args, "PORT")).toBe("3000");
123
+ });
124
+
125
+ /** A project must be able to override what we set — an env var it
126
+ * declares is an explicit statement, ours is a default. */
127
+ test("the service's environment comes after ours, so it wins", () => {
128
+ const args = runContainerArgs(
129
+ base({
130
+ svc: { name: "web", env: { NODE_EXTRA_CA_CERTS: "/custom.pem" } },
131
+ caBundle: "/etc/spectest/ca-bundle.crt",
132
+ }),
133
+ );
134
+ const ours = args.indexOf("NODE_EXTRA_CA_CERTS=/etc/spectest/ca.crt");
135
+ const theirs = args.indexOf("NODE_EXTRA_CA_CERTS=/custom.pem");
136
+ expect(ours).toBeGreaterThanOrEqual(0);
137
+ expect(theirs).toBeGreaterThan(ours);
138
+ });
139
+
140
+ test("passes volume flags through untouched", () => {
141
+ const args = runContainerArgs(base({ volumeFlags: ["--volume=/a:/b", "--mount=type=tmpfs"] }));
142
+ expect(args).toContain("--volume=/a:/b");
143
+ expect(args).toContain("--mount=type=tmpfs");
144
+ });
145
+
146
+ test("carries privileged, tmpfs, cgroupns and workdir", () => {
147
+ const args = runContainerArgs(
148
+ base({
149
+ svc: {
150
+ name: "web",
151
+ privileged: true,
152
+ tmpfs: ["/run", "/tmp"],
153
+ cgroupns: "host",
154
+ workdir: "/srv",
155
+ },
156
+ }),
157
+ );
158
+ expect(args).toContain("--privileged");
159
+ expect(args).toContain("--tmpfs=/run");
160
+ expect(args).toContain("--tmpfs=/tmp");
161
+ expect(args).toContain("--cgroupns=host");
162
+ expect(args).toContain("--workdir=/srv");
163
+ });
164
+
165
+ test("omits every optional flag when nothing asks for it", () => {
166
+ const args = runContainerArgs(base());
167
+ expect(args).not.toContain("--privileged");
168
+ expect(args.some((a) => a.startsWith("--tmpfs="))).toBe(false);
169
+ expect(args.some((a) => a.startsWith("--cgroupns="))).toBe(false);
170
+ expect(args.some((a) => a.startsWith("--workdir="))).toBe(false);
171
+ expect(args).not.toContain("--entrypoint=/bin/sh");
172
+ });
173
+ });
174
+
175
+ describe("command vs args", () => {
176
+ test("command replaces the entrypoint and runs through sh -c", () => {
177
+ const args = runContainerArgs(base({ svc: { name: "web", command: "node server.js" } }));
178
+ expect(args).toContain("--entrypoint=/bin/sh");
179
+ expect(args.slice(-3)).toEqual(["spectest/web:latest", "-c", "node server.js"]);
180
+ });
181
+
182
+ /** The init-wrapped-image case: postgres needs extra flags without
183
+ * losing the entrypoint that sets the cluster up. */
184
+ test("args override CMD and keep the entrypoint", () => {
185
+ const args = runContainerArgs(
186
+ base({ svc: { name: "db", args: ["postgres", "-c", "wal_level=logical"] } }),
187
+ );
188
+ expect(args).not.toContain("--entrypoint=/bin/sh");
189
+ expect(args.slice(-4)).toEqual([
190
+ "spectest/web:latest",
191
+ "postgres",
192
+ "-c",
193
+ "wal_level=logical",
194
+ ]);
195
+ });
196
+
197
+ /** Accepting both would silently drop one, and which one is dropped is
198
+ * not something a reader could predict from the config. */
199
+ test("declaring both is rejected, naming the service", () => {
200
+ expect(() =>
201
+ runContainerArgs(base({ svc: { name: "web", command: "x", args: ["y"] } })),
202
+ ).toThrow(/service web:.*mutually exclusive/s);
203
+ });
204
+
205
+ test("an empty args array is not treated as an override", () => {
206
+ const args = runContainerArgs(base({ svc: { name: "web", args: [] } }));
207
+ expect(args[args.length - 1]).toBe("spectest/web:latest");
208
+ });
209
+ });
@@ -0,0 +1,158 @@
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
+ /** The subset of a service's config that shapes its container. */
39
+ export interface ContainerService {
40
+ name: string;
41
+ env?: Record<string, string>;
42
+ workdir?: string;
43
+ privileged?: boolean;
44
+ tmpfs?: readonly string[];
45
+ cgroupns?: string;
46
+ command?: string;
47
+ args?: readonly string[];
48
+ }
49
+
50
+ export interface ContainerRunInput {
51
+ svc: ContainerService;
52
+ /** The image tag to run. */
53
+ tag: string;
54
+ /** Docker network to attach to. */
55
+ network: string;
56
+ /** Extra `--network-alias` names beyond `<name>` and `<name>.internal`. */
57
+ aliases?: readonly string[];
58
+ /** Volume/file/certificate mount flags, already assembled. */
59
+ volumeFlags?: readonly string[];
60
+ /** Ingress hostnames to pin to {@link ContainerRunInput.gatewayIp}. */
61
+ ingressHosts?: readonly string[];
62
+ /** The bridge gateway, where the ingress listeners are. Null when it
63
+ * isn't known yet, in which case no `--add-host` is emitted. */
64
+ gatewayIp?: string | null;
65
+ /** The host image-cache gateway, reachable as `spectest-host`. */
66
+ hostCacheGateway?: string | null;
67
+ hostCacheName: string;
68
+ /** The in-VM root CA, bind-mounted and appended to Node's roots. */
69
+ caPath: string;
70
+ /** Public roots ⧺ our CA, for the variables that *replace* the store. */
71
+ caBundle?: string | null;
72
+ }
73
+
74
+ /**
75
+ * Bound TCP give-up time inside the container's own network namespace.
76
+ *
77
+ * `net.ipv4.tcp_retries2` is per-netns and a fresh netns resets to the
78
+ * kernel default (15, roughly 15 minutes of RTO backoff), so lowering it on
79
+ * the guest's init netns does not reach containers — and the flows that
80
+ * actually wedge live here: buildkit pulling base images and exporting
81
+ * cache, and k3s's containerd pulling images, all over the VM↔host path to
82
+ * the host registry. On a lost retransmit under concurrent forks such a
83
+ * flow otherwise stalls a build for minutes; six retries resets a genuinely
84
+ * stuck connection in tens of seconds and the client reconnects. Live
85
+ * connections keep getting ACKs and are unaffected.
86
+ *
87
+ * Safe because every service runs on the bridge network with its own netns,
88
+ * never `--network=host`, where writing `net.*` is denied.
89
+ */
90
+ export const TCP_RETRIES2 = 6;
91
+
92
+ /** Build the full `docker run` argv. Pure: no I/O, no module state. */
93
+ export function runContainerArgs(input: ContainerRunInput): string[] {
94
+ const { svc, tag, network } = input;
95
+
96
+ if (svc.command && svc.args?.length) {
97
+ throw new Error(
98
+ `service ${svc.name}: \`command\` and \`args\` are mutually exclusive ` +
99
+ `(command replaces the entrypoint with /bin/sh -c; args keeps it)`,
100
+ );
101
+ }
102
+
103
+ const args = [
104
+ "run",
105
+ "-d",
106
+ "--restart=no",
107
+ `--name=${svc.name}`,
108
+ `--hostname=${svc.name}`,
109
+ `--network=${network}`,
110
+ `--network-alias=${svc.name}.internal`,
111
+ ];
112
+
113
+ for (const alias of input.aliases ?? []) args.push(`--network-alias=${alias}`);
114
+
115
+ args.push("--sysctl", `net.ipv4.tcp_retries2=${TCP_RETRIES2}`);
116
+
117
+ // Ingress hostnames via /etc/hosts, which beats Docker's embedded DNS —
118
+ // so app code reaching a fake or a TLS-terminated proxy lands on the
119
+ // harness listener without touching the container's resolver config.
120
+ if (input.gatewayIp) {
121
+ for (const h of input.ingressHosts ?? []) {
122
+ args.push(`--add-host=${h}:${input.gatewayIp}`);
123
+ }
124
+ }
125
+ if (input.hostCacheGateway) {
126
+ args.push(`--add-host=${input.hostCacheName}:${input.hostCacheGateway}`);
127
+ }
128
+
129
+ if (svc.workdir) args.push(`--workdir=${svc.workdir}`);
130
+
131
+ // See the module header: the appending variable takes the bare CA, the
132
+ // replacing ones take the combined bundle.
133
+ args.push(`--volume=${input.caPath}:${input.caPath}:ro`);
134
+ args.push("-e", `NODE_EXTRA_CA_CERTS=${input.caPath}`);
135
+ const bundle = input.caBundle;
136
+ if (bundle) {
137
+ if (bundle !== input.caPath) args.push(`--volume=${bundle}:${bundle}:ro`);
138
+ args.push("-e", `SSL_CERT_FILE=${bundle}`);
139
+ args.push("-e", `REQUESTS_CA_BUNDLE=${bundle}`);
140
+ args.push("-e", `AWS_CA_BUNDLE=${bundle}`);
141
+ }
142
+
143
+ // The service's own env comes after ours, so a project can override the
144
+ // defaults we set rather than being silently overridden by them.
145
+ for (const [k, v] of Object.entries(svc.env ?? {})) args.push("-e", `${k}=${v}`);
146
+
147
+ for (const flag of input.volumeFlags ?? []) args.push(flag);
148
+ if (svc.privileged) args.push("--privileged");
149
+ for (const p of svc.tmpfs ?? []) args.push(`--tmpfs=${p}`);
150
+ if (svc.cgroupns) args.push(`--cgroupns=${svc.cgroupns}`);
151
+
152
+ if (svc.command) args.push("--entrypoint=/bin/sh");
153
+ args.push(tag);
154
+ if (svc.command) args.push("-c", svc.command);
155
+ else if (svc.args?.length) args.push(...svc.args);
156
+
157
+ return args;
158
+ }