@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.
- package/dist/components/k3s.js +1 -24
- 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 +405 -946
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +3 -3
- package/dist/ingress.d.ts +1 -1
- package/dist/inspect.d.ts +23 -0
- package/dist/inspect.js +65 -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 +2040 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5489 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2725 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +133 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +846 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/src/vendor/rrweb-record.min.js +5061 -0
|
@@ -0,0 +1,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;
|