@checkstack/healthcheck-tcp-backend 0.0.2 → 0.1.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/CHANGELOG.md +43 -0
- package/package.json +1 -1
- package/src/banner-collector.test.ts +107 -0
- package/src/banner-collector.ts +149 -0
- package/src/index.ts +7 -5
- package/src/strategy.test.ts +33 -81
- package/src/strategy.ts +109 -145
- package/src/transport-client.ts +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @checkstack/healthcheck-tcp-backend
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f5b1f49: Refactored health check strategies to use `createClient()` pattern with built-in collectors.
|
|
8
|
+
|
|
9
|
+
**Strategy Changes:**
|
|
10
|
+
|
|
11
|
+
- Replaced `execute()` with `createClient()` that returns a transport client
|
|
12
|
+
- Strategy configs now only contain connection parameters
|
|
13
|
+
- Collector configs handle what to do with the connection
|
|
14
|
+
|
|
15
|
+
**Built-in Collectors Added:**
|
|
16
|
+
|
|
17
|
+
- DNS: `LookupCollector` for hostname resolution
|
|
18
|
+
- gRPC: `HealthCollector` for gRPC health protocol
|
|
19
|
+
- HTTP: `RequestCollector` for HTTP requests
|
|
20
|
+
- MySQL: `QueryCollector` for database queries
|
|
21
|
+
- Ping: `PingCollector` for ICMP ping
|
|
22
|
+
- Postgres: `QueryCollector` for database queries
|
|
23
|
+
- Redis: `CommandCollector` for Redis commands
|
|
24
|
+
- Script: `ExecuteCollector` for script execution
|
|
25
|
+
- SSH: `CommandCollector` for SSH commands
|
|
26
|
+
- TCP: `BannerCollector` for TCP banner grabbing
|
|
27
|
+
- TLS: `CertificateCollector` for certificate inspection
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- Updated dependencies [f5b1f49]
|
|
32
|
+
- Updated dependencies [f5b1f49]
|
|
33
|
+
- Updated dependencies [f5b1f49]
|
|
34
|
+
- Updated dependencies [f5b1f49]
|
|
35
|
+
- @checkstack/backend-api@0.1.0
|
|
36
|
+
- @checkstack/healthcheck-common@0.1.0
|
|
37
|
+
- @checkstack/common@0.0.3
|
|
38
|
+
|
|
39
|
+
## 0.0.3
|
|
40
|
+
|
|
41
|
+
### Patch Changes
|
|
42
|
+
|
|
43
|
+
- Updated dependencies [cb82e4d]
|
|
44
|
+
- @checkstack/healthcheck-common@0.0.3
|
|
45
|
+
|
|
3
46
|
## 0.0.2
|
|
4
47
|
|
|
5
48
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { BannerCollector, type BannerConfig } from "./banner-collector";
|
|
3
|
+
import type { TcpTransportClient } from "./transport-client";
|
|
4
|
+
|
|
5
|
+
describe("BannerCollector", () => {
|
|
6
|
+
const createMockClient = (
|
|
7
|
+
response: {
|
|
8
|
+
banner?: string;
|
|
9
|
+
connected?: boolean;
|
|
10
|
+
error?: string;
|
|
11
|
+
} = {}
|
|
12
|
+
): TcpTransportClient => ({
|
|
13
|
+
exec: mock(() =>
|
|
14
|
+
Promise.resolve({
|
|
15
|
+
banner: response.banner,
|
|
16
|
+
connected: response.connected ?? true,
|
|
17
|
+
error: response.error,
|
|
18
|
+
})
|
|
19
|
+
),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("execute", () => {
|
|
23
|
+
it("should read banner successfully", async () => {
|
|
24
|
+
const collector = new BannerCollector();
|
|
25
|
+
const client = createMockClient({ banner: "SSH-2.0-OpenSSH_8.9" });
|
|
26
|
+
|
|
27
|
+
const result = await collector.execute({
|
|
28
|
+
config: { timeout: 5000 },
|
|
29
|
+
client,
|
|
30
|
+
pluginId: "test",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result.result.banner).toBe("SSH-2.0-OpenSSH_8.9");
|
|
34
|
+
expect(result.result.hasBanner).toBe(true);
|
|
35
|
+
expect(result.result.readTimeMs).toBeGreaterThanOrEqual(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should return hasBanner false when no banner", async () => {
|
|
39
|
+
const collector = new BannerCollector();
|
|
40
|
+
const client = createMockClient({ banner: undefined });
|
|
41
|
+
|
|
42
|
+
const result = await collector.execute({
|
|
43
|
+
config: { timeout: 5000 },
|
|
44
|
+
client,
|
|
45
|
+
pluginId: "test",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(result.result.hasBanner).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should pass correct parameters to client", async () => {
|
|
52
|
+
const collector = new BannerCollector();
|
|
53
|
+
const client = createMockClient();
|
|
54
|
+
|
|
55
|
+
await collector.execute({
|
|
56
|
+
config: { timeout: 3000 },
|
|
57
|
+
client,
|
|
58
|
+
pluginId: "test",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(client.exec).toHaveBeenCalledWith({
|
|
62
|
+
type: "read",
|
|
63
|
+
timeout: 3000,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("aggregateResult", () => {
|
|
69
|
+
it("should calculate average read time and banner rate", () => {
|
|
70
|
+
const collector = new BannerCollector();
|
|
71
|
+
const runs = [
|
|
72
|
+
{
|
|
73
|
+
id: "1",
|
|
74
|
+
status: "healthy" as const,
|
|
75
|
+
latencyMs: 10,
|
|
76
|
+
checkId: "c1",
|
|
77
|
+
timestamp: new Date(),
|
|
78
|
+
metadata: { banner: "SSH-2.0", hasBanner: true, readTimeMs: 50 },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "2",
|
|
82
|
+
status: "healthy" as const,
|
|
83
|
+
latencyMs: 15,
|
|
84
|
+
checkId: "c1",
|
|
85
|
+
timestamp: new Date(),
|
|
86
|
+
metadata: { hasBanner: false, readTimeMs: 100 },
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const aggregated = collector.aggregateResult(runs);
|
|
91
|
+
|
|
92
|
+
expect(aggregated.avgReadTimeMs).toBe(75);
|
|
93
|
+
expect(aggregated.bannerRate).toBe(50);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("metadata", () => {
|
|
98
|
+
it("should have correct static properties", () => {
|
|
99
|
+
const collector = new BannerCollector();
|
|
100
|
+
|
|
101
|
+
expect(collector.id).toBe("banner");
|
|
102
|
+
expect(collector.displayName).toBe("TCP Banner");
|
|
103
|
+
expect(collector.allowMultiple).toBe(false);
|
|
104
|
+
expect(collector.supportedPlugins).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Versioned,
|
|
3
|
+
z,
|
|
4
|
+
type HealthCheckRunForAggregation,
|
|
5
|
+
type CollectorResult,
|
|
6
|
+
type CollectorStrategy,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import {
|
|
9
|
+
healthResultNumber,
|
|
10
|
+
healthResultString,
|
|
11
|
+
healthResultBoolean,
|
|
12
|
+
} from "@checkstack/healthcheck-common";
|
|
13
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
14
|
+
import type { TcpTransportClient } from "./transport-client";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// CONFIGURATION SCHEMA
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
const bannerConfigSchema = z.object({
|
|
21
|
+
timeout: z
|
|
22
|
+
.number()
|
|
23
|
+
.min(100)
|
|
24
|
+
.default(5000)
|
|
25
|
+
.describe("Timeout for banner read in milliseconds"),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type BannerConfig = z.infer<typeof bannerConfigSchema>;
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// RESULT SCHEMAS
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
const bannerResultSchema = z.object({
|
|
35
|
+
banner: healthResultString({
|
|
36
|
+
"x-chart-type": "text",
|
|
37
|
+
"x-chart-label": "Banner",
|
|
38
|
+
}).optional(),
|
|
39
|
+
hasBanner: healthResultBoolean({
|
|
40
|
+
"x-chart-type": "boolean",
|
|
41
|
+
"x-chart-label": "Has Banner",
|
|
42
|
+
}),
|
|
43
|
+
readTimeMs: healthResultNumber({
|
|
44
|
+
"x-chart-type": "line",
|
|
45
|
+
"x-chart-label": "Read Time",
|
|
46
|
+
"x-chart-unit": "ms",
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type BannerResult = z.infer<typeof bannerResultSchema>;
|
|
51
|
+
|
|
52
|
+
const bannerAggregatedSchema = z.object({
|
|
53
|
+
avgReadTimeMs: healthResultNumber({
|
|
54
|
+
"x-chart-type": "line",
|
|
55
|
+
"x-chart-label": "Avg Read Time",
|
|
56
|
+
"x-chart-unit": "ms",
|
|
57
|
+
}),
|
|
58
|
+
bannerRate: healthResultNumber({
|
|
59
|
+
"x-chart-type": "gauge",
|
|
60
|
+
"x-chart-label": "Banner Rate",
|
|
61
|
+
"x-chart-unit": "%",
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export type BannerAggregatedResult = z.infer<typeof bannerAggregatedSchema>;
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// BANNER COLLECTOR
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Built-in TCP banner collector.
|
|
73
|
+
* Reads the initial banner/greeting from a TCP server.
|
|
74
|
+
*/
|
|
75
|
+
export class BannerCollector
|
|
76
|
+
implements
|
|
77
|
+
CollectorStrategy<
|
|
78
|
+
TcpTransportClient,
|
|
79
|
+
BannerConfig,
|
|
80
|
+
BannerResult,
|
|
81
|
+
BannerAggregatedResult
|
|
82
|
+
>
|
|
83
|
+
{
|
|
84
|
+
id = "banner";
|
|
85
|
+
displayName = "TCP Banner";
|
|
86
|
+
description = "Read the initial banner/greeting from the server";
|
|
87
|
+
|
|
88
|
+
supportedPlugins = [pluginMetadata];
|
|
89
|
+
|
|
90
|
+
allowMultiple = false;
|
|
91
|
+
|
|
92
|
+
config = new Versioned({ version: 1, schema: bannerConfigSchema });
|
|
93
|
+
result = new Versioned({ version: 1, schema: bannerResultSchema });
|
|
94
|
+
aggregatedResult = new Versioned({
|
|
95
|
+
version: 1,
|
|
96
|
+
schema: bannerAggregatedSchema,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
async execute({
|
|
100
|
+
config,
|
|
101
|
+
client,
|
|
102
|
+
}: {
|
|
103
|
+
config: BannerConfig;
|
|
104
|
+
client: TcpTransportClient;
|
|
105
|
+
pluginId: string;
|
|
106
|
+
}): Promise<CollectorResult<BannerResult>> {
|
|
107
|
+
const startTime = Date.now();
|
|
108
|
+
|
|
109
|
+
const response = await client.exec({
|
|
110
|
+
type: "read",
|
|
111
|
+
timeout: config.timeout,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const readTimeMs = Date.now() - startTime;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
result: {
|
|
118
|
+
banner: response.banner,
|
|
119
|
+
hasBanner: !!response.banner,
|
|
120
|
+
readTimeMs,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
aggregateResult(
|
|
126
|
+
runs: HealthCheckRunForAggregation<BannerResult>[]
|
|
127
|
+
): BannerAggregatedResult {
|
|
128
|
+
const times = runs
|
|
129
|
+
.map((r) => r.metadata?.readTimeMs)
|
|
130
|
+
.filter((v): v is number => typeof v === "number");
|
|
131
|
+
|
|
132
|
+
const hasBanners = runs
|
|
133
|
+
.map((r) => r.metadata?.hasBanner)
|
|
134
|
+
.filter((v): v is boolean => typeof v === "boolean");
|
|
135
|
+
|
|
136
|
+
const bannerCount = hasBanners.filter(Boolean).length;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
avgReadTimeMs:
|
|
140
|
+
times.length > 0
|
|
141
|
+
? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
|
|
142
|
+
: 0,
|
|
143
|
+
bannerRate:
|
|
144
|
+
hasBanners.length > 0
|
|
145
|
+
? Math.round((bannerCount / hasBanners.length) * 100)
|
|
146
|
+
: 0,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createBackendPlugin,
|
|
3
|
-
coreServices,
|
|
4
|
-
} from "@checkstack/backend-api";
|
|
1
|
+
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
5
2
|
import { TcpHealthCheckStrategy } from "./strategy";
|
|
6
3
|
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
import { BannerCollector } from "./banner-collector";
|
|
7
5
|
|
|
8
6
|
export default createBackendPlugin({
|
|
9
7
|
metadata: pluginMetadata,
|
|
@@ -11,13 +9,17 @@ export default createBackendPlugin({
|
|
|
11
9
|
env.registerInit({
|
|
12
10
|
deps: {
|
|
13
11
|
healthCheckRegistry: coreServices.healthCheckRegistry,
|
|
12
|
+
collectorRegistry: coreServices.collectorRegistry,
|
|
14
13
|
logger: coreServices.logger,
|
|
15
14
|
},
|
|
16
|
-
init: async ({ healthCheckRegistry, logger }) => {
|
|
15
|
+
init: async ({ healthCheckRegistry, collectorRegistry, logger }) => {
|
|
17
16
|
logger.debug("🔌 Registering TCP Health Check Strategy...");
|
|
18
17
|
const strategy = new TcpHealthCheckStrategy();
|
|
19
18
|
healthCheckRegistry.register(strategy);
|
|
19
|
+
collectorRegistry.register(new BannerCollector());
|
|
20
20
|
},
|
|
21
21
|
});
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
export { pluginMetadata } from "./plugin-metadata";
|
package/src/strategy.test.ts
CHANGED
|
@@ -21,120 +21,72 @@ describe("TcpHealthCheckStrategy", () => {
|
|
|
21
21
|
} as TcpSocket);
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
describe("
|
|
25
|
-
it("should return
|
|
24
|
+
describe("createClient", () => {
|
|
25
|
+
it("should return a connected client for successful connection", async () => {
|
|
26
26
|
const strategy = new TcpHealthCheckStrategy(createMockSocket());
|
|
27
27
|
|
|
28
|
-
const
|
|
28
|
+
const connectedClient = await strategy.createClient({
|
|
29
29
|
host: "localhost",
|
|
30
30
|
port: 80,
|
|
31
31
|
timeout: 5000,
|
|
32
|
-
readBanner: false,
|
|
33
32
|
});
|
|
34
33
|
|
|
35
|
-
expect(
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("should return unhealthy for connection error", async () => {
|
|
41
|
-
const strategy = new TcpHealthCheckStrategy(
|
|
42
|
-
createMockSocket({ connectError: new Error("Connection refused") })
|
|
43
|
-
);
|
|
34
|
+
expect(connectedClient.client).toBeDefined();
|
|
35
|
+
expect(connectedClient.client.exec).toBeDefined();
|
|
36
|
+
expect(connectedClient.close).toBeDefined();
|
|
44
37
|
|
|
45
|
-
|
|
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);
|
|
38
|
+
connectedClient.close();
|
|
55
39
|
});
|
|
56
40
|
|
|
57
|
-
it("should
|
|
41
|
+
it("should throw for connection error", async () => {
|
|
58
42
|
const strategy = new TcpHealthCheckStrategy(
|
|
59
|
-
createMockSocket({
|
|
43
|
+
createMockSocket({ connectError: new Error("Connection refused") })
|
|
60
44
|
);
|
|
61
45
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
expect(result.status).toBe("healthy");
|
|
70
|
-
expect(result.metadata?.banner).toBe("SSH-2.0-OpenSSH");
|
|
46
|
+
await expect(
|
|
47
|
+
strategy.createClient({
|
|
48
|
+
host: "localhost",
|
|
49
|
+
port: 12345,
|
|
50
|
+
timeout: 5000,
|
|
51
|
+
})
|
|
52
|
+
).rejects.toThrow("Connection refused");
|
|
71
53
|
});
|
|
54
|
+
});
|
|
72
55
|
|
|
73
|
-
|
|
56
|
+
describe("client.exec", () => {
|
|
57
|
+
it("should return connected status for connect action", async () => {
|
|
74
58
|
const strategy = new TcpHealthCheckStrategy(createMockSocket());
|
|
75
|
-
|
|
76
|
-
const result = await strategy.execute({
|
|
59
|
+
const connectedClient = await strategy.createClient({
|
|
77
60
|
host: "localhost",
|
|
78
61
|
port: 80,
|
|
79
62
|
timeout: 5000,
|
|
80
|
-
readBanner: false,
|
|
81
|
-
assertions: [
|
|
82
|
-
{ field: "connectionTime", operator: "lessThan", value: 1000 },
|
|
83
|
-
],
|
|
84
63
|
});
|
|
85
64
|
|
|
86
|
-
|
|
87
|
-
});
|
|
65
|
+
const result = await connectedClient.client.exec({ type: "connect" });
|
|
88
66
|
|
|
89
|
-
|
|
90
|
-
const strategy = new TcpHealthCheckStrategy(
|
|
91
|
-
createMockSocket({ banner: "SSH-2.0-OpenSSH_8.9" })
|
|
92
|
-
);
|
|
67
|
+
expect(result.connected).toBe(true);
|
|
93
68
|
|
|
94
|
-
|
|
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");
|
|
69
|
+
connectedClient.close();
|
|
103
70
|
});
|
|
104
71
|
|
|
105
|
-
it("should
|
|
72
|
+
it("should read banner with read action", async () => {
|
|
106
73
|
const strategy = new TcpHealthCheckStrategy(
|
|
107
|
-
createMockSocket({ banner: "
|
|
74
|
+
createMockSocket({ banner: "SSH-2.0-OpenSSH" })
|
|
108
75
|
);
|
|
109
|
-
|
|
110
|
-
const result = await strategy.execute({
|
|
76
|
+
const connectedClient = await strategy.createClient({
|
|
111
77
|
host: "localhost",
|
|
112
|
-
port:
|
|
78
|
+
port: 22,
|
|
113
79
|
timeout: 5000,
|
|
114
|
-
readBanner: true,
|
|
115
|
-
assertions: [{ field: "banner", operator: "contains", value: "SSH" }],
|
|
116
80
|
});
|
|
117
81
|
|
|
118
|
-
|
|
119
|
-
|
|
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,
|
|
82
|
+
const result = await connectedClient.client.exec({
|
|
83
|
+
type: "read",
|
|
84
|
+
timeout: 1000,
|
|
135
85
|
});
|
|
136
86
|
|
|
137
|
-
expect(
|
|
87
|
+
expect(result.banner).toBe("SSH-2.0-OpenSSH");
|
|
88
|
+
|
|
89
|
+
connectedClient.close();
|
|
138
90
|
});
|
|
139
91
|
});
|
|
140
92
|
|
package/src/strategy.ts
CHANGED
|
@@ -1,35 +1,28 @@
|
|
|
1
1
|
import {
|
|
2
2
|
HealthCheckStrategy,
|
|
3
|
-
HealthCheckResult,
|
|
4
3
|
HealthCheckRunForAggregation,
|
|
5
4
|
Versioned,
|
|
6
5
|
z,
|
|
7
|
-
|
|
8
|
-
stringField,
|
|
9
|
-
evaluateAssertions,
|
|
6
|
+
type ConnectedClient,
|
|
10
7
|
} from "@checkstack/backend-api";
|
|
11
8
|
import {
|
|
12
9
|
healthResultBoolean,
|
|
13
10
|
healthResultNumber,
|
|
14
11
|
healthResultString,
|
|
15
12
|
} from "@checkstack/healthcheck-common";
|
|
13
|
+
import type {
|
|
14
|
+
TcpTransportClient,
|
|
15
|
+
TcpConnectRequest,
|
|
16
|
+
TcpConnectResult,
|
|
17
|
+
} from "./transport-client";
|
|
16
18
|
|
|
17
19
|
// ============================================================================
|
|
18
20
|
// SCHEMAS
|
|
19
21
|
// ============================================================================
|
|
20
22
|
|
|
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
23
|
/**
|
|
32
24
|
* Configuration schema for TCP health checks.
|
|
25
|
+
* Connection-only parameters - action params moved to BannerCollector.
|
|
33
26
|
*/
|
|
34
27
|
export const tcpConfigSchema = z.object({
|
|
35
28
|
host: z.string().describe("Hostname or IP address"),
|
|
@@ -39,18 +32,18 @@ export const tcpConfigSchema = z.object({
|
|
|
39
32
|
.min(100)
|
|
40
33
|
.default(5000)
|
|
41
34
|
.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
35
|
});
|
|
51
36
|
|
|
52
37
|
export type TcpConfig = z.infer<typeof tcpConfigSchema>;
|
|
53
38
|
|
|
39
|
+
// Legacy config type for migrations
|
|
40
|
+
interface TcpConfigV1 {
|
|
41
|
+
host: string;
|
|
42
|
+
port: number;
|
|
43
|
+
timeout: number;
|
|
44
|
+
readBanner: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
54
47
|
/**
|
|
55
48
|
* Per-run result metadata.
|
|
56
49
|
*/
|
|
@@ -68,14 +61,13 @@ const tcpResultSchema = z.object({
|
|
|
68
61
|
"x-chart-type": "text",
|
|
69
62
|
"x-chart-label": "Banner",
|
|
70
63
|
}).optional(),
|
|
71
|
-
failedAssertion: tcpAssertionSchema.optional(),
|
|
72
64
|
error: healthResultString({
|
|
73
65
|
"x-chart-type": "status",
|
|
74
66
|
"x-chart-label": "Error",
|
|
75
67
|
}).optional(),
|
|
76
68
|
});
|
|
77
69
|
|
|
78
|
-
|
|
70
|
+
type TcpResult = z.infer<typeof tcpResultSchema>;
|
|
79
71
|
|
|
80
72
|
/**
|
|
81
73
|
* Aggregated metadata for buckets.
|
|
@@ -97,7 +89,7 @@ const tcpAggregatedSchema = z.object({
|
|
|
97
89
|
}),
|
|
98
90
|
});
|
|
99
91
|
|
|
100
|
-
|
|
92
|
+
type TcpAggregatedResult = z.infer<typeof tcpAggregatedSchema>;
|
|
101
93
|
|
|
102
94
|
// ============================================================================
|
|
103
95
|
// SOCKET INTERFACE (for testability)
|
|
@@ -113,8 +105,8 @@ export type SocketFactory = () => TcpSocket;
|
|
|
113
105
|
|
|
114
106
|
// Default factory using Bun.connect
|
|
115
107
|
const defaultSocketFactory: SocketFactory = () => {
|
|
116
|
-
let
|
|
117
|
-
let
|
|
108
|
+
let connectedSocket: Awaited<ReturnType<typeof Bun.connect>> | undefined;
|
|
109
|
+
let dataBuffer = "";
|
|
118
110
|
|
|
119
111
|
return {
|
|
120
112
|
async connect(options: { host: string; port: number }): Promise<void> {
|
|
@@ -124,11 +116,11 @@ const defaultSocketFactory: SocketFactory = () => {
|
|
|
124
116
|
port: options.port,
|
|
125
117
|
socket: {
|
|
126
118
|
open(sock) {
|
|
127
|
-
|
|
119
|
+
connectedSocket = sock;
|
|
128
120
|
resolve();
|
|
129
121
|
},
|
|
130
122
|
data(_sock, data) {
|
|
131
|
-
|
|
123
|
+
dataBuffer += data.toString();
|
|
132
124
|
},
|
|
133
125
|
error(_sock, error) {
|
|
134
126
|
reject(error);
|
|
@@ -137,23 +129,27 @@ const defaultSocketFactory: SocketFactory = () => {
|
|
|
137
129
|
// Connection closed
|
|
138
130
|
},
|
|
139
131
|
},
|
|
140
|
-
})
|
|
132
|
+
});
|
|
141
133
|
});
|
|
142
134
|
},
|
|
143
135
|
async read(timeout: number): Promise<string | undefined> {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const start = Date.now();
|
|
138
|
+
const check = () => {
|
|
139
|
+
if (dataBuffer.length > 0) {
|
|
140
|
+
resolve(dataBuffer);
|
|
141
|
+
} else if (Date.now() - start > timeout) {
|
|
142
|
+
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
143
|
+
resolve(undefined);
|
|
144
|
+
} else {
|
|
145
|
+
setTimeout(check, 50);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
check();
|
|
149
|
+
});
|
|
154
150
|
},
|
|
155
151
|
close(): void {
|
|
156
|
-
|
|
152
|
+
connectedSocket?.end();
|
|
157
153
|
},
|
|
158
154
|
};
|
|
159
155
|
};
|
|
@@ -163,7 +159,13 @@ const defaultSocketFactory: SocketFactory = () => {
|
|
|
163
159
|
// ============================================================================
|
|
164
160
|
|
|
165
161
|
export class TcpHealthCheckStrategy
|
|
166
|
-
implements
|
|
162
|
+
implements
|
|
163
|
+
HealthCheckStrategy<
|
|
164
|
+
TcpConfig,
|
|
165
|
+
TcpTransportClient,
|
|
166
|
+
TcpResult,
|
|
167
|
+
TcpAggregatedResult
|
|
168
|
+
>
|
|
167
169
|
{
|
|
168
170
|
id = "tcp";
|
|
169
171
|
displayName = "TCP Health Check";
|
|
@@ -176,13 +178,33 @@ export class TcpHealthCheckStrategy
|
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
config: Versioned<TcpConfig> = new Versioned({
|
|
179
|
-
version:
|
|
181
|
+
version: 2,
|
|
180
182
|
schema: tcpConfigSchema,
|
|
183
|
+
migrations: [
|
|
184
|
+
{
|
|
185
|
+
fromVersion: 1,
|
|
186
|
+
toVersion: 2,
|
|
187
|
+
description: "Remove readBanner (moved to BannerCollector)",
|
|
188
|
+
migrate: (data: TcpConfigV1): TcpConfig => ({
|
|
189
|
+
host: data.host,
|
|
190
|
+
port: data.port,
|
|
191
|
+
timeout: data.timeout,
|
|
192
|
+
}),
|
|
193
|
+
},
|
|
194
|
+
],
|
|
181
195
|
});
|
|
182
196
|
|
|
183
197
|
result: Versioned<TcpResult> = new Versioned({
|
|
184
|
-
version:
|
|
198
|
+
version: 2,
|
|
185
199
|
schema: tcpResultSchema,
|
|
200
|
+
migrations: [
|
|
201
|
+
{
|
|
202
|
+
fromVersion: 1,
|
|
203
|
+
toVersion: 2,
|
|
204
|
+
description: "Migrate to createClient pattern (no result changes)",
|
|
205
|
+
migrate: (data: unknown) => data,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
186
208
|
});
|
|
187
209
|
|
|
188
210
|
aggregatedResult: Versioned<TcpAggregatedResult> = new Versioned({
|
|
@@ -193,117 +215,59 @@ export class TcpHealthCheckStrategy
|
|
|
193
215
|
aggregateResult(
|
|
194
216
|
runs: HealthCheckRunForAggregation<TcpResult>[]
|
|
195
217
|
): TcpAggregatedResult {
|
|
196
|
-
|
|
197
|
-
let successCount = 0;
|
|
198
|
-
let errorCount = 0;
|
|
199
|
-
let validRuns = 0;
|
|
218
|
+
const validRuns = runs.filter((r) => r.metadata);
|
|
200
219
|
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
}
|
|
220
|
+
if (validRuns.length === 0) {
|
|
221
|
+
return { avgConnectionTime: 0, successRate: 0, errorCount: 0 };
|
|
213
222
|
}
|
|
214
223
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
});
|
|
224
|
+
const connectionTimes = validRuns
|
|
225
|
+
.map((r) => r.metadata?.connectionTimeMs)
|
|
226
|
+
.filter((t): t is number => typeof t === "number");
|
|
236
227
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
timeoutPromise,
|
|
244
|
-
]);
|
|
228
|
+
const avgConnectionTime =
|
|
229
|
+
connectionTimes.length > 0
|
|
230
|
+
? Math.round(
|
|
231
|
+
connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
|
|
232
|
+
)
|
|
233
|
+
: 0;
|
|
245
234
|
|
|
246
|
-
|
|
235
|
+
const successCount = validRuns.filter(
|
|
236
|
+
(r) => r.metadata?.connected === true
|
|
237
|
+
).length;
|
|
238
|
+
const successRate = Math.round((successCount / validRuns.length) * 100);
|
|
247
239
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const bannerTimeout = Math.max(
|
|
252
|
-
1000,
|
|
253
|
-
validatedConfig.timeout - connectionTimeMs
|
|
254
|
-
);
|
|
255
|
-
banner = (await socket.read(bannerTimeout)) ?? undefined;
|
|
256
|
-
}
|
|
240
|
+
const errorCount = validRuns.filter(
|
|
241
|
+
(r) => r.metadata?.error !== undefined
|
|
242
|
+
).length;
|
|
257
243
|
|
|
258
|
-
|
|
244
|
+
return { avgConnectionTime, successRate, errorCount };
|
|
245
|
+
}
|
|
259
246
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
247
|
+
async createClient(
|
|
248
|
+
config: TcpConfig
|
|
249
|
+
): Promise<ConnectedClient<TcpTransportClient>> {
|
|
250
|
+
const validatedConfig = this.config.validate(config);
|
|
251
|
+
const socket = this.socketFactory();
|
|
265
252
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
});
|
|
253
|
+
await socket.connect({
|
|
254
|
+
host: validatedConfig.host,
|
|
255
|
+
port: validatedConfig.port,
|
|
256
|
+
});
|
|
271
257
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
258
|
+
const client: TcpTransportClient = {
|
|
259
|
+
async exec(request: TcpConnectRequest): Promise<TcpConnectResult> {
|
|
260
|
+
if (request.type === "read" && request.timeout) {
|
|
261
|
+
const banner = await socket.read(request.timeout);
|
|
262
|
+
return { connected: true, banner };
|
|
263
|
+
}
|
|
264
|
+
return { connected: true };
|
|
265
|
+
},
|
|
266
|
+
};
|
|
282
267
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
}
|
|
268
|
+
return {
|
|
269
|
+
client,
|
|
270
|
+
close: () => socket.close(),
|
|
271
|
+
};
|
|
308
272
|
}
|
|
309
273
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TransportClient } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TCP connection request.
|
|
5
|
+
*/
|
|
6
|
+
export interface TcpConnectRequest {
|
|
7
|
+
/** Action type */
|
|
8
|
+
type: "connect" | "read";
|
|
9
|
+
/** Timeout for banner read (optional) */
|
|
10
|
+
timeout?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TCP connection result.
|
|
15
|
+
*/
|
|
16
|
+
export interface TcpConnectResult {
|
|
17
|
+
connected: boolean;
|
|
18
|
+
banner?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* TCP transport client for connection checks.
|
|
24
|
+
*/
|
|
25
|
+
export type TcpTransportClient = TransportClient<
|
|
26
|
+
TcpConnectRequest,
|
|
27
|
+
TcpConnectResult
|
|
28
|
+
>;
|