@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.
- package/dist/browser.d.ts +21 -8
- package/dist/browser.js +78 -36
- 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 +464 -987
- 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 +58 -16
- package/dist/ingress.d.ts +1 -1
- package/dist/mobile.d.ts +9 -5
- package/dist/mobile.js +7 -6
- package/dist/recorder.d.ts +10 -0
- 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 +2095 -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 +5537 -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 +2767 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +138 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +856 -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
|
Binary file
|
|
@@ -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
|
+
}
|