@mark1russell7/kind 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +142 -2
- package/build/index.d.ts +393 -0
- package/build/index.js +2325 -0
- package/build/index.js.map +1 -0
- package/build/judge-CDDCr1Z8.d.ts +695 -0
- package/build/judge-CW1IX2zP.js +1018 -0
- package/build/judge-CW1IX2zP.js.map +1 -0
- package/build/judge.d.ts +2 -0
- package/build/judge.js +1 -0
- package/image/Dockerfile +18 -0
- package/image/agent.mjs +122 -0
- package/image/app.mjs +140 -0
- package/image/client.mjs +68 -0
- package/image/npm-lock.json +106 -0
- package/image/package.json +9 -0
- package/image/watch.mjs +175 -0
- package/package.json +83 -3
- package/src/backends.ts +70 -0
- package/src/compose.ts +68 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "systems-kind-lab",
|
|
3
|
+
"lockfileVersion": 3,
|
|
4
|
+
"requires": true,
|
|
5
|
+
"packages": {
|
|
6
|
+
"": {
|
|
7
|
+
"name": "systems-kind-lab",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"ioredis": "6.0.0",
|
|
10
|
+
"zod": "4.6.6"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"node_modules/@ioredis/commands": {
|
|
14
|
+
"version": "2.0.0",
|
|
15
|
+
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz",
|
|
16
|
+
"integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==",
|
|
17
|
+
"license": "MIT"
|
|
18
|
+
},
|
|
19
|
+
"node_modules/cluster-key-slot": {
|
|
20
|
+
"version": "1.1.1",
|
|
21
|
+
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
|
|
22
|
+
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
|
|
23
|
+
"license": "Apache-2.0",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=0.10.0"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"node_modules/debug": {
|
|
29
|
+
"version": "4.4.3",
|
|
30
|
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
31
|
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"ms": "^2.1.3"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=6.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"supports-color": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"node_modules/denque": {
|
|
46
|
+
"version": "2.1.0",
|
|
47
|
+
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
|
48
|
+
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
|
49
|
+
"license": "Apache-2.0",
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=0.10"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"node_modules/ioredis": {
|
|
55
|
+
"version": "6.0.0",
|
|
56
|
+
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz",
|
|
57
|
+
"integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==",
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@ioredis/commands": "2.0.0",
|
|
61
|
+
"cluster-key-slot": "1.1.1",
|
|
62
|
+
"debug": "4.4.3",
|
|
63
|
+
"denque": "2.1.0",
|
|
64
|
+
"redis-errors": "1.2.0",
|
|
65
|
+
"standard-as-callback": "2.1.0"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=20.0.0"
|
|
69
|
+
},
|
|
70
|
+
"funding": {
|
|
71
|
+
"type": "opencollective",
|
|
72
|
+
"url": "https://opencollective.com/ioredis"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"node_modules/ms": {
|
|
76
|
+
"version": "2.1.3",
|
|
77
|
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
78
|
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
79
|
+
"license": "MIT"
|
|
80
|
+
},
|
|
81
|
+
"node_modules/redis-errors": {
|
|
82
|
+
"version": "1.2.0",
|
|
83
|
+
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
|
84
|
+
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
|
85
|
+
"license": "MIT",
|
|
86
|
+
"engines": {
|
|
87
|
+
"node": ">=4"
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"node_modules/standard-as-callback": {
|
|
91
|
+
"version": "2.1.0",
|
|
92
|
+
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
|
93
|
+
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
|
94
|
+
"license": "MIT"
|
|
95
|
+
},
|
|
96
|
+
"node_modules/zod": {
|
|
97
|
+
"version": "4.6.6",
|
|
98
|
+
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.6.tgz",
|
|
99
|
+
"integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==",
|
|
100
|
+
"license": "MIT",
|
|
101
|
+
"funding": {
|
|
102
|
+
"url": "https://github.com/sponsors/colinhacks"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
package/image/watch.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The watcher of a kind cell. It runs in a container on the Docker network of the cluster, thus in
|
|
2
|
+
// the VM, and it stamps each event on CLOCK_MONOTONIC when it arrives. It watches the pods, the
|
|
3
|
+
// EndpointSlices and the events of one namespace through the API, and it follows the log of each
|
|
4
|
+
// pod with the label lab.systems/log=1. A log line is JSON with its own stamp, and the watcher
|
|
5
|
+
// passes it on as it is.
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
|
|
8
|
+
const kube = JSON.parse(process.env.KUBE ?? "{}");
|
|
9
|
+
const namespaces = (process.env.NAMESPACES ?? "default").split(",");
|
|
10
|
+
const now = () => Number(process.hrtime.bigint()) / 1e6;
|
|
11
|
+
const server = new URL(kube.server);
|
|
12
|
+
const agent = new https.Agent({
|
|
13
|
+
ca: Buffer.from(kube.ca, "base64"),
|
|
14
|
+
cert: Buffer.from(kube.cert, "base64"),
|
|
15
|
+
key: Buffer.from(kube.key, "base64"),
|
|
16
|
+
keepAlive: true,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let pending = [];
|
|
20
|
+
const flush = () => {
|
|
21
|
+
if (pending.length > 0) process.stdout.write(pending.join(""));
|
|
22
|
+
pending = [];
|
|
23
|
+
};
|
|
24
|
+
setInterval(flush, 50);
|
|
25
|
+
const say = (line) => pending.push(`${JSON.stringify(line)}\n`);
|
|
26
|
+
|
|
27
|
+
/** a stream of lines from the API, again after each end, from the last resource version it saw */
|
|
28
|
+
const stream = (path, query, onLine, again = true) => {
|
|
29
|
+
let version = null;
|
|
30
|
+
const open = () => {
|
|
31
|
+
const search = new URLSearchParams(query);
|
|
32
|
+
if (version !== null) search.set("resourceVersion", version);
|
|
33
|
+
const req = https.request(
|
|
34
|
+
{ host: server.hostname, port: server.port, path: `${path}?${search}`, method: "GET", agent },
|
|
35
|
+
(res) => {
|
|
36
|
+
if (res.statusCode !== 200) {
|
|
37
|
+
say({ ev: "watch-fail", t: now(), path, status: res.statusCode });
|
|
38
|
+
res.resume();
|
|
39
|
+
if (res.statusCode === 410) version = null;
|
|
40
|
+
if (again) setTimeout(open, 500);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let partial = "";
|
|
44
|
+
res.setEncoding("utf8");
|
|
45
|
+
res.on("data", (chunk) => {
|
|
46
|
+
const t = now();
|
|
47
|
+
partial += chunk;
|
|
48
|
+
const split = partial.split("\n");
|
|
49
|
+
partial = split.pop() ?? "";
|
|
50
|
+
for (const text of split) {
|
|
51
|
+
if (text.trim() === "") continue;
|
|
52
|
+
const next = onLine(text, t);
|
|
53
|
+
if (typeof next === "string") version = next;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
res.on("end", () => {
|
|
57
|
+
if (again) setTimeout(open, 100);
|
|
58
|
+
});
|
|
59
|
+
res.on("error", () => undefined);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
req.on("error", (error) => {
|
|
63
|
+
say({ ev: "watch-fail", t: now(), path, why: String(error.message) });
|
|
64
|
+
if (again) setTimeout(open, 500);
|
|
65
|
+
});
|
|
66
|
+
req.end();
|
|
67
|
+
};
|
|
68
|
+
open();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const stateOf = (state) => {
|
|
72
|
+
if (state?.running) return "running";
|
|
73
|
+
if (state?.waiting) return `waiting:${state.waiting.reason ?? ""}`;
|
|
74
|
+
if (state?.terminated) return `terminated:${state.terminated.reason ?? ""}:${state.terminated.exitCode}`;
|
|
75
|
+
return "none";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const following = new Set();
|
|
79
|
+
const follow = (ns, pod, container, id) => {
|
|
80
|
+
const key = `${ns}/${pod}/${container}/${id}`;
|
|
81
|
+
if (following.has(key)) return;
|
|
82
|
+
following.add(key);
|
|
83
|
+
stream(
|
|
84
|
+
`/api/v1/namespaces/${ns}/pods/${pod}/log`,
|
|
85
|
+
{ container, follow: "true" },
|
|
86
|
+
(text) => {
|
|
87
|
+
if (text.startsWith("{")) pending.push(`${text}\n`);
|
|
88
|
+
else say({ ev: "text", t: now(), p: pod, text: text.slice(0, 500) });
|
|
89
|
+
},
|
|
90
|
+
false,
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (const ns of namespaces) {
|
|
95
|
+
stream(`/api/v1/namespaces/${ns}/pods`, { watch: "1", allowWatchBookmarks: "true" }, (text, t) => {
|
|
96
|
+
const event = JSON.parse(text);
|
|
97
|
+
const pod = event.object;
|
|
98
|
+
if (event.type === "BOOKMARK") return pod?.metadata?.resourceVersion;
|
|
99
|
+
if (event.type === "ERROR") {
|
|
100
|
+
say({ ev: "watch-fail", t, path: "pods", status: pod?.code });
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const ready = (pod.status?.conditions ?? []).find((condition) => condition.type === "Ready")?.status === "True";
|
|
104
|
+
const containers = (pod.status?.containerStatuses ?? []).map((status) => ({
|
|
105
|
+
n: status.name,
|
|
106
|
+
r: status.ready,
|
|
107
|
+
rc: status.restartCount,
|
|
108
|
+
st: stateOf(status.state),
|
|
109
|
+
last: stateOf(status.lastState),
|
|
110
|
+
id: status.containerID ?? null,
|
|
111
|
+
}));
|
|
112
|
+
say({
|
|
113
|
+
ev: "pod",
|
|
114
|
+
t,
|
|
115
|
+
type: event.type,
|
|
116
|
+
ns,
|
|
117
|
+
name: pod.metadata.name,
|
|
118
|
+
node: pod.spec?.nodeName ?? null,
|
|
119
|
+
ip: pod.status?.podIP ?? null,
|
|
120
|
+
del: pod.metadata.deletionTimestamp !== undefined,
|
|
121
|
+
ready,
|
|
122
|
+
phase: pod.status?.phase ?? null,
|
|
123
|
+
cs: containers,
|
|
124
|
+
labels: pod.metadata.labels ?? {},
|
|
125
|
+
});
|
|
126
|
+
if (event.type !== "DELETED" && pod.metadata.labels?.["lab.systems/log"] === "1")
|
|
127
|
+
for (const status of pod.status?.containerStatuses ?? [])
|
|
128
|
+
if (status.state?.running && status.containerID) follow(ns, pod.metadata.name, status.name, status.containerID);
|
|
129
|
+
return pod.metadata.resourceVersion;
|
|
130
|
+
});
|
|
131
|
+
stream(`/apis/discovery.k8s.io/v1/namespaces/${ns}/endpointslices`, { watch: "1", allowWatchBookmarks: "true" }, (text, t) => {
|
|
132
|
+
const event = JSON.parse(text);
|
|
133
|
+
const slice = event.object;
|
|
134
|
+
if (event.type === "BOOKMARK") return slice?.metadata?.resourceVersion;
|
|
135
|
+
if (event.type === "ERROR") return null;
|
|
136
|
+
say({
|
|
137
|
+
ev: "slice",
|
|
138
|
+
t,
|
|
139
|
+
type: event.type,
|
|
140
|
+
ns,
|
|
141
|
+
name: slice.metadata.name,
|
|
142
|
+
svc: slice.metadata.labels?.["kubernetes.io/service-name"] ?? null,
|
|
143
|
+
eps: (slice.endpoints ?? []).map((endpoint) => ({
|
|
144
|
+
ip: endpoint.addresses?.[0] ?? null,
|
|
145
|
+
r: endpoint.conditions?.ready ?? null,
|
|
146
|
+
s: endpoint.conditions?.serving ?? null,
|
|
147
|
+
x: endpoint.conditions?.terminating ?? null,
|
|
148
|
+
pod: endpoint.targetRef?.name ?? null,
|
|
149
|
+
node: endpoint.nodeName ?? null,
|
|
150
|
+
})),
|
|
151
|
+
});
|
|
152
|
+
return slice.metadata.resourceVersion;
|
|
153
|
+
});
|
|
154
|
+
stream(`/api/v1/namespaces/${ns}/events`, { watch: "1", allowWatchBookmarks: "true" }, (text, t) => {
|
|
155
|
+
const event = JSON.parse(text);
|
|
156
|
+
const object = event.object;
|
|
157
|
+
if (event.type === "BOOKMARK") return object?.metadata?.resourceVersion;
|
|
158
|
+
if (event.type === "ERROR") return null;
|
|
159
|
+
say({
|
|
160
|
+
ev: "event",
|
|
161
|
+
t,
|
|
162
|
+
type: event.type,
|
|
163
|
+
ns,
|
|
164
|
+
reason: object.reason ?? null,
|
|
165
|
+
msg: String(object.message ?? "").slice(0, 300),
|
|
166
|
+
obj: object.involvedObject?.name ?? null,
|
|
167
|
+
kind: object.involvedObject?.kind ?? null,
|
|
168
|
+
n: object.count ?? object.series?.count ?? 1,
|
|
169
|
+
last: object.lastTimestamp ?? object.eventTime ?? null,
|
|
170
|
+
src: object.source?.component ?? object.reportingComponent ?? null,
|
|
171
|
+
});
|
|
172
|
+
return object.metadata.resourceVersion;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
say({ ev: "watcher", what: "start", t: now(), namespaces });
|
package/package.json
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package",
|
|
2
3
|
"name": "@mark1russell7/kind",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"description": "Kubernetes in Docker as a lab for connection timers: a kind cluster from a scenario, faults in the namespaces of its pods, observations on the one monotonic clock of the VM, and predictions from the ordering law. Its judge entry reads and judges a raw record without Docker.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"kubernetes",
|
|
8
|
+
"kind",
|
|
9
|
+
"kube-proxy",
|
|
10
|
+
"endpoints",
|
|
11
|
+
"readiness",
|
|
12
|
+
"graceful-shutdown",
|
|
13
|
+
"fault-injection",
|
|
14
|
+
"chaos-engineering",
|
|
15
|
+
"testcontainers"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://mark1russell7.github.io/systems/",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/mark1russell7/systems.git",
|
|
21
|
+
"directory": "packages/kind"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/mark1russell7/systems/issues"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Mark Russell (https://github.com/mark1russell7)",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"main": "./build/index.js",
|
|
31
|
+
"types": "./build/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./build/index.d.ts",
|
|
35
|
+
"default": "./build/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./judge": {
|
|
38
|
+
"types": "./build/judge.d.ts",
|
|
39
|
+
"default": "./build/judge.js"
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"build",
|
|
45
|
+
"image/Dockerfile",
|
|
46
|
+
"image/package.json",
|
|
47
|
+
"image/npm-lock.json",
|
|
48
|
+
"image/app.mjs",
|
|
49
|
+
"image/client.mjs",
|
|
50
|
+
"image/agent.mjs",
|
|
51
|
+
"image/watch.mjs",
|
|
52
|
+
"src/compose.ts",
|
|
53
|
+
"src/backends.ts",
|
|
54
|
+
"README.md",
|
|
55
|
+
"LICENSE"
|
|
56
|
+
],
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=22.19"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@mark1russell7/harness": "^0.9.0",
|
|
62
|
+
"@mark1russell7/quantity": "^0.2.0",
|
|
63
|
+
"@mark1russell7/service": "^0.4.0",
|
|
64
|
+
"testcontainers": "12.2.0"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"zod": "^4.1.0"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@mark1russell7/cue": "github:mark1russell7/cue",
|
|
71
|
+
"@types/node": "^26.6.4",
|
|
72
|
+
"tsdown": "^0.23.1",
|
|
73
|
+
"typescript": "^7.0.2",
|
|
74
|
+
"vitest": "^5.0.3",
|
|
75
|
+
"zod": "^4.6.6"
|
|
76
|
+
},
|
|
77
|
+
"publishConfig": {
|
|
78
|
+
"access": "public"
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"build": "tsdown",
|
|
82
|
+
"test": "vitest run",
|
|
83
|
+
"test:watch": "vitest",
|
|
84
|
+
"typecheck": "tsc -p tsconfig.json --noEmit --emitDeclarationOnly false && tsc -p tsconfig.test.json"
|
|
85
|
+
}
|
|
6
86
|
}
|
package/src/backends.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The parser of the rules of kube-proxy. The agent of each node reads the rules with it, and the
|
|
2
|
+
// image copies this file, thus it imports nothing.
|
|
3
|
+
|
|
4
|
+
/** A service rule names the Service whose backends the agent reads: its namespace, its name, and its ClusterIP and port for IPVS. */
|
|
5
|
+
export interface ServiceRule {
|
|
6
|
+
readonly mode: "iptables" | "ipvs" | "nftables";
|
|
7
|
+
readonly ns: string;
|
|
8
|
+
readonly svc: string;
|
|
9
|
+
readonly ip: string;
|
|
10
|
+
readonly port: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const hexIp = (hex: string): string =>
|
|
14
|
+
[0, 2, 4, 6].map((at) => parseInt(hex.slice(at, at + 2), 16)).join(".");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* This function gives the backends of a Service in a dump of the rules of kube-proxy, as
|
|
18
|
+
* `ip:port`, sorted. A backend of IPVS with the weight zero takes no new connection, thus it is
|
|
19
|
+
* not a backend.
|
|
20
|
+
* - iptables: the `KUBE-SVC` chain of the Service jumps to a `KUBE-SEP` chain for each backend, and that chain does the DNAT.
|
|
21
|
+
* - nftables: the `service-` chain of the Service does the DNAT to each backend, or it names an `endpoint-` chain for each one.
|
|
22
|
+
* - IPVS: `/proc/net/ip_vs` has the line of the virtual service, then a line for each real server.
|
|
23
|
+
*/
|
|
24
|
+
export const backendsOf = (mode: ServiceRule["mode"], text: string, rule: Pick<ServiceRule, "ns" | "svc" | "ip" | "port">): string[] => {
|
|
25
|
+
const out = new Set<string>();
|
|
26
|
+
const lines = text.split("\n");
|
|
27
|
+
if (mode === "iptables") {
|
|
28
|
+
const seps = new Set<string>();
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
if (!line.startsWith("-A KUBE-SVC-") || !line.includes(`${rule.ns}/${rule.svc}:`)) continue;
|
|
31
|
+
const jump = / -j (KUBE-SEP-\S+)/.exec(line);
|
|
32
|
+
if (jump !== null) seps.add(jump[1]!);
|
|
33
|
+
}
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const chain = /^-A (KUBE-SEP-\S+) /.exec(line);
|
|
36
|
+
if (chain === null || !seps.has(chain[1]!)) continue;
|
|
37
|
+
const to = / --to-destination (\S+)/.exec(line);
|
|
38
|
+
if (to !== null) out.add(to[1]!);
|
|
39
|
+
}
|
|
40
|
+
} else if (mode === "nftables") {
|
|
41
|
+
// a chain name with a slash can print in quotes: `chain "service-…" {` and `goto "endpoint-…"`
|
|
42
|
+
let inside = false;
|
|
43
|
+
const ours = `-${rule.ns}/${rule.svc}/`;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const head = /^\s*chain\s+"?([^"\s{]+)"?\s*\{/.exec(line);
|
|
46
|
+
if (head !== null) inside = head[1]!.startsWith("service-") && head[1]!.includes(ours);
|
|
47
|
+
else if (/^\s*(set|map)\s/.test(line)) inside = false;
|
|
48
|
+
if (!inside) continue;
|
|
49
|
+
// one chain for each endpoint, as kube-proxy 1.31 writes it
|
|
50
|
+
for (const match of line.matchAll(/(?:goto|jump)\s+"?endpoint-[0-9A-Za-z]+(-[^\s,}"]+)__([0-9.]+)\/(\d+)"?/g))
|
|
51
|
+
if (match[1]!.startsWith(ours)) out.add(`${match[2]}:${match[3]}`);
|
|
52
|
+
// a DNAT in the service chain, as kube-proxy 1.37 writes it: `numgen random mod 2 map { 0 : IP . PORT, … }`, or `dnat to IP:PORT`
|
|
53
|
+
for (const match of line.matchAll(/(?:^|[\s{,:])(\d+\.\d+\.\d+\.\d+) \. (\d+)(?=\s*[,}]|\s*$)/g)) out.add(`${match[1]}:${match[2]}`);
|
|
54
|
+
for (const match of line.matchAll(/\bdnat (?:ip )?to (\d+\.\d+\.\d+\.\d+):(\d+)/g)) out.add(`${match[1]}:${match[2]}`);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
let inside = false;
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
// each virtual service starts a block: a UDP or an SCTP one ends the block of the TCP service before it
|
|
60
|
+
const service = /^(TCP|UDP|SCTP)\s+([0-9A-F]{8}):([0-9A-F]{4})\s/.exec(line);
|
|
61
|
+
if (service !== null) {
|
|
62
|
+
inside = service[1] === "TCP" && hexIp(service[2]!) === rule.ip && parseInt(service[3]!, 16) === rule.port;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const real = /^\s+->\s+([0-9A-F]{8}):([0-9A-F]{4})\s+\S+\s+(\d+)/.exec(line);
|
|
66
|
+
if (inside && real !== null && Number(real[3]) > 0) out.add(`${hexIp(real[1]!)}:${parseInt(real[2]!, 16)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...out].sort();
|
|
70
|
+
};
|
package/src/compose.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The composition of the service of a kind cell. The app of the image and `lint()` compose the same
|
|
2
|
+
// service from the same declaration: the image copies this file, and Node strips its types. Thus
|
|
3
|
+
// this file imports only packages and types.
|
|
4
|
+
import type { CheckFn, HealthServer, ServiceBuilder, TransportImpl } from "@mark1russell7/service";
|
|
5
|
+
import { service } from "@mark1russell7/service";
|
|
6
|
+
import type { RedisClient } from "@mark1russell7/service/redis";
|
|
7
|
+
import type { Declaration } from "./cell.ts";
|
|
8
|
+
|
|
9
|
+
/** These are the parts of a running service. A composition for `lint()` gives none of them. */
|
|
10
|
+
export interface Parts {
|
|
11
|
+
readonly redis?: RedisClient | undefined;
|
|
12
|
+
readonly flag?: CheckFn | undefined;
|
|
13
|
+
readonly http?: TransportImpl | undefined;
|
|
14
|
+
readonly health?: HealthServer | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** This name is the controller of the service, and its one route reads Redis when the service has Redis. */
|
|
18
|
+
export const CONTROLLER = "work";
|
|
19
|
+
export const ROUTE = "GET /work";
|
|
20
|
+
/** This name is the check that a flag file fails: the seam of the liveness restart. */
|
|
21
|
+
export const FLAG_CHECK = "self.flag";
|
|
22
|
+
/** This name is the watchdog of the event loop. */
|
|
23
|
+
export const WATCHDOG = "self.event-loop";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* This function composes the service of a declaration: its policies, its couplings, its checks,
|
|
27
|
+
* one server, one controller and each stage of the shutdown. A declaration with Redis needs the
|
|
28
|
+
* client, because the client declares the dependency and its checks.
|
|
29
|
+
*/
|
|
30
|
+
export const compose = (name: string, declaration: Declaration, parts: Parts = {}): ServiceBuilder => {
|
|
31
|
+
const svc = service(name)
|
|
32
|
+
.readiness(declaration.readiness)
|
|
33
|
+
.liveness(declaration.liveness)
|
|
34
|
+
.startup(declaration.startup)
|
|
35
|
+
.cooperativeWindow(declaration.cooperativeWindow);
|
|
36
|
+
for (const coupling of declaration.couplings) svc.couple(coupling);
|
|
37
|
+
if (declaration.redis !== null) {
|
|
38
|
+
if (parts.redis === undefined) throw new Error(`${name} declares Redis, thus its composition needs the client`);
|
|
39
|
+
parts.redis.attach(svc, { poll: declaration.poll, steps: { "round-trip": declaration.redis.gates } });
|
|
40
|
+
}
|
|
41
|
+
if (declaration.flag !== null)
|
|
42
|
+
svc.check(
|
|
43
|
+
{
|
|
44
|
+
name: FLAG_CHECK,
|
|
45
|
+
dependency: null,
|
|
46
|
+
step: "local-state",
|
|
47
|
+
ownConnection: true,
|
|
48
|
+
gates: declaration.flag,
|
|
49
|
+
poll: declaration.poll,
|
|
50
|
+
},
|
|
51
|
+
parts.flag,
|
|
52
|
+
);
|
|
53
|
+
if (declaration.watchdog !== null)
|
|
54
|
+
svc.watchdog({
|
|
55
|
+
name: WATCHDOG,
|
|
56
|
+
tick: declaration.watchdog.tick,
|
|
57
|
+
threshold: declaration.watchdog.threshold,
|
|
58
|
+
gates: "liveness",
|
|
59
|
+
poll: declaration.poll,
|
|
60
|
+
});
|
|
61
|
+
svc.server("http", {}, parts.http);
|
|
62
|
+
const controller = svc.controller(CONTROLLER);
|
|
63
|
+
if (declaration.redis !== null) controller.requires(parts.redis!.name);
|
|
64
|
+
controller.route(ROUTE, { idempotent: true });
|
|
65
|
+
if (parts.health !== undefined) svc.healthServer(parts.health);
|
|
66
|
+
for (const [stage, cap] of declaration.phases) svc.phase(stage, cap);
|
|
67
|
+
return svc;
|
|
68
|
+
};
|