@checkstack/healthcheck-tcp-backend 0.0.2
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/CHANGELOG.md +37 -0
- package/package.json +22 -0
- package/src/index.ts +23 -0
- package/src/plugin-metadata.ts +8 -0
- package/src/strategy.test.ts +210 -0
- package/src/strategy.ts +309 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @checkstack/healthcheck-tcp-backend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
- Updated dependencies [d20d274]
|
|
9
|
+
- @checkstack/backend-api@0.0.2
|
|
10
|
+
- @checkstack/common@0.0.2
|
|
11
|
+
- @checkstack/healthcheck-common@0.0.2
|
|
12
|
+
|
|
13
|
+
## 0.0.3
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [b4eb432]
|
|
18
|
+
- Updated dependencies [a65e002]
|
|
19
|
+
- @checkstack/backend-api@1.1.0
|
|
20
|
+
- @checkstack/common@0.2.0
|
|
21
|
+
- @checkstack/healthcheck-common@0.1.1
|
|
22
|
+
|
|
23
|
+
## 0.0.2
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [ffc28f6]
|
|
28
|
+
- Updated dependencies [4dd644d]
|
|
29
|
+
- Updated dependencies [71275dd]
|
|
30
|
+
- Updated dependencies [ae19ff6]
|
|
31
|
+
- Updated dependencies [0babb9c]
|
|
32
|
+
- Updated dependencies [b55fae6]
|
|
33
|
+
- Updated dependencies [b354ab3]
|
|
34
|
+
- Updated dependencies [81f3f85]
|
|
35
|
+
- @checkstack/common@0.1.0
|
|
36
|
+
- @checkstack/backend-api@1.0.0
|
|
37
|
+
- @checkstack/healthcheck-common@0.1.0
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/healthcheck-tcp-backend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkstack/backend-api": "workspace:*",
|
|
13
|
+
"@checkstack/common": "workspace:*",
|
|
14
|
+
"@checkstack/healthcheck-common": "workspace:*"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/bun": "^1.0.0",
|
|
18
|
+
"typescript": "^5.0.0",
|
|
19
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
20
|
+
"@checkstack/scripts": "workspace:*"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBackendPlugin,
|
|
3
|
+
coreServices,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import { TcpHealthCheckStrategy } from "./strategy";
|
|
6
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
7
|
+
|
|
8
|
+
export default createBackendPlugin({
|
|
9
|
+
metadata: pluginMetadata,
|
|
10
|
+
register(env) {
|
|
11
|
+
env.registerInit({
|
|
12
|
+
deps: {
|
|
13
|
+
healthCheckRegistry: coreServices.healthCheckRegistry,
|
|
14
|
+
logger: coreServices.logger,
|
|
15
|
+
},
|
|
16
|
+
init: async ({ healthCheckRegistry, logger }) => {
|
|
17
|
+
logger.debug("🔌 Registering TCP Health Check Strategy...");
|
|
18
|
+
const strategy = new TcpHealthCheckStrategy();
|
|
19
|
+
healthCheckRegistry.register(strategy);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { TcpHealthCheckStrategy, TcpSocket, SocketFactory } from "./strategy";
|
|
3
|
+
|
|
4
|
+
describe("TcpHealthCheckStrategy", () => {
|
|
5
|
+
// Helper to create mock socket factory
|
|
6
|
+
const createMockSocket = (
|
|
7
|
+
config: {
|
|
8
|
+
connectError?: Error;
|
|
9
|
+
banner?: string;
|
|
10
|
+
} = {}
|
|
11
|
+
): SocketFactory => {
|
|
12
|
+
return () =>
|
|
13
|
+
({
|
|
14
|
+
connect: mock(() =>
|
|
15
|
+
config.connectError
|
|
16
|
+
? Promise.reject(config.connectError)
|
|
17
|
+
: Promise.resolve()
|
|
18
|
+
),
|
|
19
|
+
read: mock(() => Promise.resolve(config.banner ?? null)),
|
|
20
|
+
close: mock(() => {}),
|
|
21
|
+
} as TcpSocket);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("execute", () => {
|
|
25
|
+
it("should return healthy for successful connection", async () => {
|
|
26
|
+
const strategy = new TcpHealthCheckStrategy(createMockSocket());
|
|
27
|
+
|
|
28
|
+
const result = await strategy.execute({
|
|
29
|
+
host: "localhost",
|
|
30
|
+
port: 80,
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
readBanner: false,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result.status).toBe("healthy");
|
|
36
|
+
expect(result.metadata?.connected).toBe(true);
|
|
37
|
+
expect(result.metadata?.connectionTimeMs).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return unhealthy for connection error", async () => {
|
|
41
|
+
const strategy = new TcpHealthCheckStrategy(
|
|
42
|
+
createMockSocket({ connectError: new Error("Connection refused") })
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const result = await strategy.execute({
|
|
46
|
+
host: "localhost",
|
|
47
|
+
port: 12345,
|
|
48
|
+
timeout: 5000,
|
|
49
|
+
readBanner: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(result.status).toBe("unhealthy");
|
|
53
|
+
expect(result.message).toContain("Connection refused");
|
|
54
|
+
expect(result.metadata?.connected).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should read banner when requested", async () => {
|
|
58
|
+
const strategy = new TcpHealthCheckStrategy(
|
|
59
|
+
createMockSocket({ banner: "SSH-2.0-OpenSSH" })
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const result = await strategy.execute({
|
|
63
|
+
host: "localhost",
|
|
64
|
+
port: 22,
|
|
65
|
+
timeout: 5000,
|
|
66
|
+
readBanner: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.status).toBe("healthy");
|
|
70
|
+
expect(result.metadata?.banner).toBe("SSH-2.0-OpenSSH");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should pass connectionTime assertion when below threshold", async () => {
|
|
74
|
+
const strategy = new TcpHealthCheckStrategy(createMockSocket());
|
|
75
|
+
|
|
76
|
+
const result = await strategy.execute({
|
|
77
|
+
host: "localhost",
|
|
78
|
+
port: 80,
|
|
79
|
+
timeout: 5000,
|
|
80
|
+
readBanner: false,
|
|
81
|
+
assertions: [
|
|
82
|
+
{ field: "connectionTime", operator: "lessThan", value: 1000 },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.status).toBe("healthy");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should pass banner assertion with matching pattern", async () => {
|
|
90
|
+
const strategy = new TcpHealthCheckStrategy(
|
|
91
|
+
createMockSocket({ banner: "SSH-2.0-OpenSSH_8.9" })
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const result = await strategy.execute({
|
|
95
|
+
host: "localhost",
|
|
96
|
+
port: 22,
|
|
97
|
+
timeout: 5000,
|
|
98
|
+
readBanner: true,
|
|
99
|
+
assertions: [{ field: "banner", operator: "contains", value: "SSH" }],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.status).toBe("healthy");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should fail banner assertion when not matching", async () => {
|
|
106
|
+
const strategy = new TcpHealthCheckStrategy(
|
|
107
|
+
createMockSocket({ banner: "HTTP/1.1 200 OK" })
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const result = await strategy.execute({
|
|
111
|
+
host: "localhost",
|
|
112
|
+
port: 80,
|
|
113
|
+
timeout: 5000,
|
|
114
|
+
readBanner: true,
|
|
115
|
+
assertions: [{ field: "banner", operator: "contains", value: "SSH" }],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(result.status).toBe("unhealthy");
|
|
119
|
+
expect(result.message).toContain("Assertion failed");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should close socket after execution", async () => {
|
|
123
|
+
const closeMock = mock(() => {});
|
|
124
|
+
const strategy = new TcpHealthCheckStrategy(() => ({
|
|
125
|
+
connect: mock(() => Promise.resolve()),
|
|
126
|
+
read: mock(() => Promise.resolve(undefined)),
|
|
127
|
+
close: closeMock,
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
await strategy.execute({
|
|
131
|
+
host: "localhost",
|
|
132
|
+
port: 80,
|
|
133
|
+
timeout: 5000,
|
|
134
|
+
readBanner: false,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(closeMock).toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("aggregateResult", () => {
|
|
142
|
+
it("should calculate averages correctly", () => {
|
|
143
|
+
const strategy = new TcpHealthCheckStrategy();
|
|
144
|
+
const runs = [
|
|
145
|
+
{
|
|
146
|
+
id: "1",
|
|
147
|
+
status: "healthy" as const,
|
|
148
|
+
latencyMs: 10,
|
|
149
|
+
checkId: "c1",
|
|
150
|
+
timestamp: new Date(),
|
|
151
|
+
metadata: {
|
|
152
|
+
connected: true,
|
|
153
|
+
connectionTimeMs: 10,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "2",
|
|
158
|
+
status: "healthy" as const,
|
|
159
|
+
latencyMs: 20,
|
|
160
|
+
checkId: "c1",
|
|
161
|
+
timestamp: new Date(),
|
|
162
|
+
metadata: {
|
|
163
|
+
connected: true,
|
|
164
|
+
connectionTimeMs: 20,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
170
|
+
|
|
171
|
+
expect(aggregated.avgConnectionTime).toBe(15);
|
|
172
|
+
expect(aggregated.successRate).toBe(100);
|
|
173
|
+
expect(aggregated.errorCount).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should count errors and calculate success rate", () => {
|
|
177
|
+
const strategy = new TcpHealthCheckStrategy();
|
|
178
|
+
const runs = [
|
|
179
|
+
{
|
|
180
|
+
id: "1",
|
|
181
|
+
status: "healthy" as const,
|
|
182
|
+
latencyMs: 10,
|
|
183
|
+
checkId: "c1",
|
|
184
|
+
timestamp: new Date(),
|
|
185
|
+
metadata: {
|
|
186
|
+
connected: true,
|
|
187
|
+
connectionTimeMs: 10,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "2",
|
|
192
|
+
status: "unhealthy" as const,
|
|
193
|
+
latencyMs: 0,
|
|
194
|
+
checkId: "c1",
|
|
195
|
+
timestamp: new Date(),
|
|
196
|
+
metadata: {
|
|
197
|
+
connected: false,
|
|
198
|
+
connectionTimeMs: 0,
|
|
199
|
+
error: "Connection refused",
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
205
|
+
|
|
206
|
+
expect(aggregated.successRate).toBe(50);
|
|
207
|
+
expect(aggregated.errorCount).toBe(1);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
package/src/strategy.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HealthCheckStrategy,
|
|
3
|
+
HealthCheckResult,
|
|
4
|
+
HealthCheckRunForAggregation,
|
|
5
|
+
Versioned,
|
|
6
|
+
z,
|
|
7
|
+
timeThresholdField,
|
|
8
|
+
stringField,
|
|
9
|
+
evaluateAssertions,
|
|
10
|
+
} from "@checkstack/backend-api";
|
|
11
|
+
import {
|
|
12
|
+
healthResultBoolean,
|
|
13
|
+
healthResultNumber,
|
|
14
|
+
healthResultString,
|
|
15
|
+
} from "@checkstack/healthcheck-common";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// SCHEMAS
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Assertion schema for TCP health checks using shared factories.
|
|
23
|
+
*/
|
|
24
|
+
const tcpAssertionSchema = z.discriminatedUnion("field", [
|
|
25
|
+
timeThresholdField("connectionTime"),
|
|
26
|
+
stringField("banner"),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export type TcpAssertion = z.infer<typeof tcpAssertionSchema>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Configuration schema for TCP health checks.
|
|
33
|
+
*/
|
|
34
|
+
export const tcpConfigSchema = z.object({
|
|
35
|
+
host: z.string().describe("Hostname or IP address"),
|
|
36
|
+
port: z.number().int().min(1).max(65_535).describe("TCP port number"),
|
|
37
|
+
timeout: z
|
|
38
|
+
.number()
|
|
39
|
+
.min(100)
|
|
40
|
+
.default(5000)
|
|
41
|
+
.describe("Connection timeout in milliseconds"),
|
|
42
|
+
readBanner: z
|
|
43
|
+
.boolean()
|
|
44
|
+
.default(false)
|
|
45
|
+
.describe("Read initial banner/greeting from server"),
|
|
46
|
+
assertions: z
|
|
47
|
+
.array(tcpAssertionSchema)
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("Validation conditions"),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type TcpConfig = z.infer<typeof tcpConfigSchema>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Per-run result metadata.
|
|
56
|
+
*/
|
|
57
|
+
const tcpResultSchema = z.object({
|
|
58
|
+
connected: healthResultBoolean({
|
|
59
|
+
"x-chart-type": "boolean",
|
|
60
|
+
"x-chart-label": "Connected",
|
|
61
|
+
}),
|
|
62
|
+
connectionTimeMs: healthResultNumber({
|
|
63
|
+
"x-chart-type": "line",
|
|
64
|
+
"x-chart-label": "Connection Time",
|
|
65
|
+
"x-chart-unit": "ms",
|
|
66
|
+
}),
|
|
67
|
+
banner: healthResultString({
|
|
68
|
+
"x-chart-type": "text",
|
|
69
|
+
"x-chart-label": "Banner",
|
|
70
|
+
}).optional(),
|
|
71
|
+
failedAssertion: tcpAssertionSchema.optional(),
|
|
72
|
+
error: healthResultString({
|
|
73
|
+
"x-chart-type": "status",
|
|
74
|
+
"x-chart-label": "Error",
|
|
75
|
+
}).optional(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export type TcpResult = z.infer<typeof tcpResultSchema>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Aggregated metadata for buckets.
|
|
82
|
+
*/
|
|
83
|
+
const tcpAggregatedSchema = z.object({
|
|
84
|
+
avgConnectionTime: healthResultNumber({
|
|
85
|
+
"x-chart-type": "line",
|
|
86
|
+
"x-chart-label": "Avg Connection Time",
|
|
87
|
+
"x-chart-unit": "ms",
|
|
88
|
+
}),
|
|
89
|
+
successRate: healthResultNumber({
|
|
90
|
+
"x-chart-type": "gauge",
|
|
91
|
+
"x-chart-label": "Success Rate",
|
|
92
|
+
"x-chart-unit": "%",
|
|
93
|
+
}),
|
|
94
|
+
errorCount: healthResultNumber({
|
|
95
|
+
"x-chart-type": "counter",
|
|
96
|
+
"x-chart-label": "Errors",
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export type TcpAggregatedResult = z.infer<typeof tcpAggregatedSchema>;
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// SOCKET INTERFACE (for testability)
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
export interface TcpSocket {
|
|
107
|
+
connect(options: { host: string; port: number }): Promise<void>;
|
|
108
|
+
read(timeout: number): Promise<string | undefined>;
|
|
109
|
+
close(): void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type SocketFactory = () => TcpSocket;
|
|
113
|
+
|
|
114
|
+
// Default factory using Bun.connect
|
|
115
|
+
const defaultSocketFactory: SocketFactory = () => {
|
|
116
|
+
let socket: Awaited<ReturnType<typeof Bun.connect>> | undefined;
|
|
117
|
+
let receivedData = "";
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
async connect(options: { host: string; port: number }): Promise<void> {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
Bun.connect({
|
|
123
|
+
hostname: options.host,
|
|
124
|
+
port: options.port,
|
|
125
|
+
socket: {
|
|
126
|
+
open(sock) {
|
|
127
|
+
socket = sock;
|
|
128
|
+
resolve();
|
|
129
|
+
},
|
|
130
|
+
data(_sock, data) {
|
|
131
|
+
receivedData += new TextDecoder().decode(data);
|
|
132
|
+
},
|
|
133
|
+
error(_sock, error) {
|
|
134
|
+
reject(error);
|
|
135
|
+
},
|
|
136
|
+
close() {
|
|
137
|
+
// Connection closed
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
}).catch(reject);
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
async read(timeout: number): Promise<string | undefined> {
|
|
144
|
+
const start = Date.now();
|
|
145
|
+
while (Date.now() - start < timeout) {
|
|
146
|
+
if (receivedData.length > 0) {
|
|
147
|
+
const data = receivedData;
|
|
148
|
+
receivedData = "";
|
|
149
|
+
return data;
|
|
150
|
+
}
|
|
151
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
152
|
+
}
|
|
153
|
+
return receivedData.length > 0 ? receivedData : undefined;
|
|
154
|
+
},
|
|
155
|
+
close(): void {
|
|
156
|
+
socket?.end();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// STRATEGY
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
export class TcpHealthCheckStrategy
|
|
166
|
+
implements HealthCheckStrategy<TcpConfig, TcpResult, TcpAggregatedResult>
|
|
167
|
+
{
|
|
168
|
+
id = "tcp";
|
|
169
|
+
displayName = "TCP Health Check";
|
|
170
|
+
description = "TCP port connectivity check with optional banner grab";
|
|
171
|
+
|
|
172
|
+
private socketFactory: SocketFactory;
|
|
173
|
+
|
|
174
|
+
constructor(socketFactory: SocketFactory = defaultSocketFactory) {
|
|
175
|
+
this.socketFactory = socketFactory;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
config: Versioned<TcpConfig> = new Versioned({
|
|
179
|
+
version: 1,
|
|
180
|
+
schema: tcpConfigSchema,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
result: Versioned<TcpResult> = new Versioned({
|
|
184
|
+
version: 1,
|
|
185
|
+
schema: tcpResultSchema,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
aggregatedResult: Versioned<TcpAggregatedResult> = new Versioned({
|
|
189
|
+
version: 1,
|
|
190
|
+
schema: tcpAggregatedSchema,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
aggregateResult(
|
|
194
|
+
runs: HealthCheckRunForAggregation<TcpResult>[]
|
|
195
|
+
): TcpAggregatedResult {
|
|
196
|
+
let totalConnectionTime = 0;
|
|
197
|
+
let successCount = 0;
|
|
198
|
+
let errorCount = 0;
|
|
199
|
+
let validRuns = 0;
|
|
200
|
+
|
|
201
|
+
for (const run of runs) {
|
|
202
|
+
if (run.metadata?.error) {
|
|
203
|
+
errorCount++;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (run.status === "healthy") {
|
|
207
|
+
successCount++;
|
|
208
|
+
}
|
|
209
|
+
if (run.metadata) {
|
|
210
|
+
totalConnectionTime += run.metadata.connectionTimeMs;
|
|
211
|
+
validRuns++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
avgConnectionTime: validRuns > 0 ? totalConnectionTime / validRuns : 0,
|
|
217
|
+
successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
|
|
218
|
+
errorCount,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async execute(config: TcpConfig): Promise<HealthCheckResult<TcpResult>> {
|
|
223
|
+
const validatedConfig = this.config.validate(config);
|
|
224
|
+
const start = performance.now();
|
|
225
|
+
|
|
226
|
+
const socket = this.socketFactory();
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// Set up timeout
|
|
230
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
231
|
+
setTimeout(
|
|
232
|
+
() => reject(new Error("Connection timeout")),
|
|
233
|
+
validatedConfig.timeout
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Connect to host
|
|
238
|
+
await Promise.race([
|
|
239
|
+
socket.connect({
|
|
240
|
+
host: validatedConfig.host,
|
|
241
|
+
port: validatedConfig.port,
|
|
242
|
+
}),
|
|
243
|
+
timeoutPromise,
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
const connectionTimeMs = Math.round(performance.now() - start);
|
|
247
|
+
|
|
248
|
+
// Read banner if requested
|
|
249
|
+
let banner: string | undefined;
|
|
250
|
+
if (validatedConfig.readBanner) {
|
|
251
|
+
const bannerTimeout = Math.max(
|
|
252
|
+
1000,
|
|
253
|
+
validatedConfig.timeout - connectionTimeMs
|
|
254
|
+
);
|
|
255
|
+
banner = (await socket.read(bannerTimeout)) ?? undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
socket.close();
|
|
259
|
+
|
|
260
|
+
const result: Omit<TcpResult, "failedAssertion" | "error"> = {
|
|
261
|
+
connected: true,
|
|
262
|
+
connectionTimeMs,
|
|
263
|
+
banner,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Evaluate assertions using shared utility
|
|
267
|
+
const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
|
|
268
|
+
connectionTime: connectionTimeMs,
|
|
269
|
+
banner: banner ?? "",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (failedAssertion) {
|
|
273
|
+
return {
|
|
274
|
+
status: "unhealthy",
|
|
275
|
+
latencyMs: connectionTimeMs,
|
|
276
|
+
message: `Assertion failed: ${failedAssertion.field} ${
|
|
277
|
+
failedAssertion.operator
|
|
278
|
+
}${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
|
|
279
|
+
metadata: { ...result, failedAssertion },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
status: "healthy",
|
|
285
|
+
latencyMs: connectionTimeMs,
|
|
286
|
+
message: `Connected to ${validatedConfig.host}:${
|
|
287
|
+
validatedConfig.port
|
|
288
|
+
} in ${connectionTimeMs}ms${
|
|
289
|
+
banner ? ` (banner: ${banner.slice(0, 50)}...)` : ""
|
|
290
|
+
}`,
|
|
291
|
+
metadata: result,
|
|
292
|
+
};
|
|
293
|
+
} catch (error: unknown) {
|
|
294
|
+
socket.close();
|
|
295
|
+
const end = performance.now();
|
|
296
|
+
const isError = error instanceof Error;
|
|
297
|
+
return {
|
|
298
|
+
status: "unhealthy",
|
|
299
|
+
latencyMs: Math.round(end - start),
|
|
300
|
+
message: isError ? error.message : "TCP connection failed",
|
|
301
|
+
metadata: {
|
|
302
|
+
connected: false,
|
|
303
|
+
connectionTimeMs: Math.round(end - start),
|
|
304
|
+
error: isError ? error.name : "UnknownError",
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|