@checkstack/healthcheck-ssh-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 +24 -0
- package/src/index.ts +23 -0
- package/src/plugin-metadata.ts +8 -0
- package/src/strategy.test.ts +245 -0
- package/src/strategy.ts +403 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @checkstack/healthcheck-ssh-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,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/healthcheck-ssh-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
|
+
"ssh2": "^1.15.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "^1.0.0",
|
|
19
|
+
"@types/ssh2": "^1.15.0",
|
|
20
|
+
"typescript": "^5.0.0",
|
|
21
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
22
|
+
"@checkstack/scripts": "workspace:*"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBackendPlugin,
|
|
3
|
+
coreServices,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import { SshHealthCheckStrategy } 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 SSH Health Check Strategy...");
|
|
18
|
+
const strategy = new SshHealthCheckStrategy();
|
|
19
|
+
healthCheckRegistry.register(strategy);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { SshHealthCheckStrategy, SshClient } from "./strategy";
|
|
3
|
+
|
|
4
|
+
describe("SshHealthCheckStrategy", () => {
|
|
5
|
+
// Helper to create mock SSH client
|
|
6
|
+
const createMockClient = (
|
|
7
|
+
config: {
|
|
8
|
+
exitCode?: number;
|
|
9
|
+
stdout?: string;
|
|
10
|
+
stderr?: string;
|
|
11
|
+
execError?: Error;
|
|
12
|
+
connectError?: Error;
|
|
13
|
+
} = {}
|
|
14
|
+
): SshClient => ({
|
|
15
|
+
connect: mock(() =>
|
|
16
|
+
config.connectError
|
|
17
|
+
? Promise.reject(config.connectError)
|
|
18
|
+
: Promise.resolve({
|
|
19
|
+
exec: mock(() =>
|
|
20
|
+
config.execError
|
|
21
|
+
? Promise.reject(config.execError)
|
|
22
|
+
: Promise.resolve({
|
|
23
|
+
exitCode: config.exitCode ?? 0,
|
|
24
|
+
stdout: config.stdout ?? "",
|
|
25
|
+
stderr: config.stderr ?? "",
|
|
26
|
+
})
|
|
27
|
+
),
|
|
28
|
+
end: mock(() => {}),
|
|
29
|
+
})
|
|
30
|
+
),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("execute", () => {
|
|
34
|
+
it("should return healthy for successful connection", async () => {
|
|
35
|
+
const strategy = new SshHealthCheckStrategy(createMockClient());
|
|
36
|
+
|
|
37
|
+
const result = await strategy.execute({
|
|
38
|
+
host: "localhost",
|
|
39
|
+
port: 22,
|
|
40
|
+
username: "user",
|
|
41
|
+
password: "secret",
|
|
42
|
+
timeout: 5000,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result.status).toBe("healthy");
|
|
46
|
+
expect(result.metadata?.connected).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should return healthy for successful command execution", async () => {
|
|
50
|
+
const strategy = new SshHealthCheckStrategy(
|
|
51
|
+
createMockClient({ exitCode: 0, stdout: "OK" })
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const result = await strategy.execute({
|
|
55
|
+
host: "localhost",
|
|
56
|
+
port: 22,
|
|
57
|
+
username: "user",
|
|
58
|
+
password: "secret",
|
|
59
|
+
timeout: 5000,
|
|
60
|
+
command: "echo OK",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.status).toBe("healthy");
|
|
64
|
+
expect(result.metadata?.commandSuccess).toBe(true);
|
|
65
|
+
expect(result.metadata?.stdout).toBe("OK");
|
|
66
|
+
expect(result.metadata?.exitCode).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should return unhealthy for connection error", async () => {
|
|
70
|
+
const strategy = new SshHealthCheckStrategy(
|
|
71
|
+
createMockClient({ connectError: new Error("Connection refused") })
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const result = await strategy.execute({
|
|
75
|
+
host: "localhost",
|
|
76
|
+
port: 22,
|
|
77
|
+
username: "user",
|
|
78
|
+
password: "secret",
|
|
79
|
+
timeout: 5000,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result.status).toBe("unhealthy");
|
|
83
|
+
expect(result.message).toContain("Connection refused");
|
|
84
|
+
expect(result.metadata?.connected).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should return unhealthy for non-zero exit code", async () => {
|
|
88
|
+
const strategy = new SshHealthCheckStrategy(
|
|
89
|
+
createMockClient({ exitCode: 1, stderr: "Error" })
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const result = await strategy.execute({
|
|
93
|
+
host: "localhost",
|
|
94
|
+
port: 22,
|
|
95
|
+
username: "user",
|
|
96
|
+
password: "secret",
|
|
97
|
+
timeout: 5000,
|
|
98
|
+
command: "exit 1",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.status).toBe("unhealthy");
|
|
102
|
+
expect(result.metadata?.exitCode).toBe(1);
|
|
103
|
+
expect(result.metadata?.commandSuccess).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should pass connectionTime assertion when below threshold", async () => {
|
|
107
|
+
const strategy = new SshHealthCheckStrategy(createMockClient());
|
|
108
|
+
|
|
109
|
+
const result = await strategy.execute({
|
|
110
|
+
host: "localhost",
|
|
111
|
+
port: 22,
|
|
112
|
+
username: "user",
|
|
113
|
+
password: "secret",
|
|
114
|
+
timeout: 5000,
|
|
115
|
+
assertions: [
|
|
116
|
+
{ field: "connectionTime", operator: "lessThan", value: 5000 },
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(result.status).toBe("healthy");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should pass exitCode assertion", async () => {
|
|
124
|
+
const strategy = new SshHealthCheckStrategy(
|
|
125
|
+
createMockClient({ exitCode: 0 })
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const result = await strategy.execute({
|
|
129
|
+
host: "localhost",
|
|
130
|
+
port: 22,
|
|
131
|
+
username: "user",
|
|
132
|
+
password: "secret",
|
|
133
|
+
timeout: 5000,
|
|
134
|
+
command: "true",
|
|
135
|
+
assertions: [{ field: "exitCode", operator: "equals", value: 0 }],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(result.status).toBe("healthy");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should fail exitCode assertion when non-zero", async () => {
|
|
142
|
+
const strategy = new SshHealthCheckStrategy(
|
|
143
|
+
createMockClient({ exitCode: 1 })
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const result = await strategy.execute({
|
|
147
|
+
host: "localhost",
|
|
148
|
+
port: 22,
|
|
149
|
+
username: "user",
|
|
150
|
+
password: "secret",
|
|
151
|
+
timeout: 5000,
|
|
152
|
+
command: "false",
|
|
153
|
+
assertions: [{ field: "exitCode", operator: "equals", value: 0 }],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(result.status).toBe("unhealthy");
|
|
157
|
+
expect(result.message).toContain("Assertion failed");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("should pass stdout assertion", async () => {
|
|
161
|
+
const strategy = new SshHealthCheckStrategy(
|
|
162
|
+
createMockClient({ stdout: "OK: Service running" })
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const result = await strategy.execute({
|
|
166
|
+
host: "localhost",
|
|
167
|
+
port: 22,
|
|
168
|
+
username: "user",
|
|
169
|
+
password: "secret",
|
|
170
|
+
timeout: 5000,
|
|
171
|
+
command: "systemctl status myservice",
|
|
172
|
+
assertions: [{ field: "stdout", operator: "contains", value: "OK" }],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(result.status).toBe("healthy");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("aggregateResult", () => {
|
|
180
|
+
it("should calculate averages correctly", () => {
|
|
181
|
+
const strategy = new SshHealthCheckStrategy();
|
|
182
|
+
const runs = [
|
|
183
|
+
{
|
|
184
|
+
id: "1",
|
|
185
|
+
status: "healthy" as const,
|
|
186
|
+
latencyMs: 100,
|
|
187
|
+
checkId: "c1",
|
|
188
|
+
timestamp: new Date(),
|
|
189
|
+
metadata: {
|
|
190
|
+
connected: true,
|
|
191
|
+
connectionTimeMs: 50,
|
|
192
|
+
commandTimeMs: 10,
|
|
193
|
+
exitCode: 0,
|
|
194
|
+
commandSuccess: true,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "2",
|
|
199
|
+
status: "healthy" as const,
|
|
200
|
+
latencyMs: 150,
|
|
201
|
+
checkId: "c1",
|
|
202
|
+
timestamp: new Date(),
|
|
203
|
+
metadata: {
|
|
204
|
+
connected: true,
|
|
205
|
+
connectionTimeMs: 100,
|
|
206
|
+
commandTimeMs: 20,
|
|
207
|
+
exitCode: 0,
|
|
208
|
+
commandSuccess: true,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
214
|
+
|
|
215
|
+
expect(aggregated.avgConnectionTime).toBe(75);
|
|
216
|
+
expect(aggregated.avgCommandTime).toBe(15);
|
|
217
|
+
expect(aggregated.successRate).toBe(100);
|
|
218
|
+
expect(aggregated.errorCount).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("should count errors", () => {
|
|
222
|
+
const strategy = new SshHealthCheckStrategy();
|
|
223
|
+
const runs = [
|
|
224
|
+
{
|
|
225
|
+
id: "1",
|
|
226
|
+
status: "unhealthy" as const,
|
|
227
|
+
latencyMs: 100,
|
|
228
|
+
checkId: "c1",
|
|
229
|
+
timestamp: new Date(),
|
|
230
|
+
metadata: {
|
|
231
|
+
connected: false,
|
|
232
|
+
connectionTimeMs: 100,
|
|
233
|
+
commandSuccess: false,
|
|
234
|
+
error: "Connection refused",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
const aggregated = strategy.aggregateResult(runs);
|
|
240
|
+
|
|
241
|
+
expect(aggregated.errorCount).toBe(1);
|
|
242
|
+
expect(aggregated.successRate).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
package/src/strategy.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { Client } from "ssh2";
|
|
2
|
+
import {
|
|
3
|
+
HealthCheckStrategy,
|
|
4
|
+
HealthCheckResult,
|
|
5
|
+
HealthCheckRunForAggregation,
|
|
6
|
+
Versioned,
|
|
7
|
+
z,
|
|
8
|
+
timeThresholdField,
|
|
9
|
+
numericField,
|
|
10
|
+
booleanField,
|
|
11
|
+
stringField,
|
|
12
|
+
evaluateAssertions,
|
|
13
|
+
configString,
|
|
14
|
+
configNumber,
|
|
15
|
+
} from "@checkstack/backend-api";
|
|
16
|
+
import {
|
|
17
|
+
healthResultBoolean,
|
|
18
|
+
healthResultNumber,
|
|
19
|
+
healthResultString,
|
|
20
|
+
} from "@checkstack/healthcheck-common";
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// SCHEMAS
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Assertion schema for SSH health checks using shared factories.
|
|
28
|
+
*/
|
|
29
|
+
const sshAssertionSchema = z.discriminatedUnion("field", [
|
|
30
|
+
timeThresholdField("connectionTime"),
|
|
31
|
+
timeThresholdField("commandTime"),
|
|
32
|
+
numericField("exitCode", { min: 0 }),
|
|
33
|
+
booleanField("commandSuccess"),
|
|
34
|
+
stringField("stdout"),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export type SshAssertion = z.infer<typeof sshAssertionSchema>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Configuration schema for SSH health checks.
|
|
41
|
+
*/
|
|
42
|
+
export const sshConfigSchema = z.object({
|
|
43
|
+
host: z.string().describe("SSH server hostname"),
|
|
44
|
+
port: z.number().int().min(1).max(65_535).default(22).describe("SSH port"),
|
|
45
|
+
username: z.string().describe("SSH username"),
|
|
46
|
+
password: configString({ "x-secret": true })
|
|
47
|
+
.describe("Password for authentication")
|
|
48
|
+
.optional(),
|
|
49
|
+
privateKey: configString({ "x-secret": true })
|
|
50
|
+
.describe("Private key for authentication")
|
|
51
|
+
.optional(),
|
|
52
|
+
passphrase: configString({ "x-secret": true })
|
|
53
|
+
.describe("Passphrase for private key")
|
|
54
|
+
.optional(),
|
|
55
|
+
timeout: configNumber({})
|
|
56
|
+
.min(100)
|
|
57
|
+
.default(10_000)
|
|
58
|
+
.describe("Connection timeout in milliseconds"),
|
|
59
|
+
command: configString({})
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("Command to execute for health check (optional)"),
|
|
62
|
+
assertions: z
|
|
63
|
+
.array(sshAssertionSchema)
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Validation conditions"),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export type SshConfig = z.infer<typeof sshConfigSchema>;
|
|
69
|
+
export type SshConfigInput = z.input<typeof sshConfigSchema>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Per-run result metadata.
|
|
73
|
+
*/
|
|
74
|
+
const sshResultSchema = z.object({
|
|
75
|
+
connected: healthResultBoolean({
|
|
76
|
+
"x-chart-type": "boolean",
|
|
77
|
+
"x-chart-label": "Connected",
|
|
78
|
+
}),
|
|
79
|
+
connectionTimeMs: healthResultNumber({
|
|
80
|
+
"x-chart-type": "line",
|
|
81
|
+
"x-chart-label": "Connection Time",
|
|
82
|
+
"x-chart-unit": "ms",
|
|
83
|
+
}),
|
|
84
|
+
commandTimeMs: healthResultNumber({
|
|
85
|
+
"x-chart-type": "line",
|
|
86
|
+
"x-chart-label": "Command Time",
|
|
87
|
+
"x-chart-unit": "ms",
|
|
88
|
+
}).optional(),
|
|
89
|
+
exitCode: healthResultNumber({
|
|
90
|
+
"x-chart-type": "counter",
|
|
91
|
+
"x-chart-label": "Exit Code",
|
|
92
|
+
}).optional(),
|
|
93
|
+
stdout: healthResultString({
|
|
94
|
+
"x-chart-type": "text",
|
|
95
|
+
"x-chart-label": "Stdout",
|
|
96
|
+
}).optional(),
|
|
97
|
+
stderr: healthResultString({
|
|
98
|
+
"x-chart-type": "text",
|
|
99
|
+
"x-chart-label": "Stderr",
|
|
100
|
+
}).optional(),
|
|
101
|
+
commandSuccess: healthResultBoolean({
|
|
102
|
+
"x-chart-type": "boolean",
|
|
103
|
+
"x-chart-label": "Command Success",
|
|
104
|
+
}),
|
|
105
|
+
failedAssertion: sshAssertionSchema.optional(),
|
|
106
|
+
error: healthResultString({
|
|
107
|
+
"x-chart-type": "status",
|
|
108
|
+
"x-chart-label": "Error",
|
|
109
|
+
}).optional(),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export type SshResult = z.infer<typeof sshResultSchema>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Aggregated metadata for buckets.
|
|
116
|
+
*/
|
|
117
|
+
const sshAggregatedSchema = z.object({
|
|
118
|
+
avgConnectionTime: healthResultNumber({
|
|
119
|
+
"x-chart-type": "line",
|
|
120
|
+
"x-chart-label": "Avg Connection Time",
|
|
121
|
+
"x-chart-unit": "ms",
|
|
122
|
+
}),
|
|
123
|
+
avgCommandTime: healthResultNumber({
|
|
124
|
+
"x-chart-type": "line",
|
|
125
|
+
"x-chart-label": "Avg Command Time",
|
|
126
|
+
"x-chart-unit": "ms",
|
|
127
|
+
}),
|
|
128
|
+
successRate: healthResultNumber({
|
|
129
|
+
"x-chart-type": "gauge",
|
|
130
|
+
"x-chart-label": "Success Rate",
|
|
131
|
+
"x-chart-unit": "%",
|
|
132
|
+
}),
|
|
133
|
+
errorCount: healthResultNumber({
|
|
134
|
+
"x-chart-type": "counter",
|
|
135
|
+
"x-chart-label": "Errors",
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
export type SshAggregatedResult = z.infer<typeof sshAggregatedSchema>;
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// SSH CLIENT INTERFACE (for testability)
|
|
143
|
+
// ============================================================================
|
|
144
|
+
|
|
145
|
+
export interface SshCommandResult {
|
|
146
|
+
exitCode: number;
|
|
147
|
+
stdout: string;
|
|
148
|
+
stderr: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface SshConnection {
|
|
152
|
+
exec(command: string): Promise<SshCommandResult>;
|
|
153
|
+
end(): void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface SshClient {
|
|
157
|
+
connect(config: {
|
|
158
|
+
host: string;
|
|
159
|
+
port: number;
|
|
160
|
+
username: string;
|
|
161
|
+
password?: string;
|
|
162
|
+
privateKey?: string;
|
|
163
|
+
passphrase?: string;
|
|
164
|
+
readyTimeout: number;
|
|
165
|
+
}): Promise<SshConnection>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Default client using ssh2
|
|
169
|
+
const defaultSshClient: SshClient = {
|
|
170
|
+
connect(config) {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const client = new Client();
|
|
173
|
+
|
|
174
|
+
client.on("ready", () => {
|
|
175
|
+
resolve({
|
|
176
|
+
exec(command: string): Promise<SshCommandResult> {
|
|
177
|
+
return new Promise((execResolve, execReject) => {
|
|
178
|
+
client.exec(command, (err, stream) => {
|
|
179
|
+
if (err) {
|
|
180
|
+
execReject(err);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let stdout = "";
|
|
185
|
+
let stderr = "";
|
|
186
|
+
|
|
187
|
+
stream.on("data", (data: Buffer) => {
|
|
188
|
+
stdout += data.toString();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
stream.stderr.on("data", (data: Buffer) => {
|
|
192
|
+
stderr += data.toString();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
stream.on("close", (code: number | null) => {
|
|
196
|
+
execResolve({
|
|
197
|
+
exitCode: code ?? 0,
|
|
198
|
+
stdout: stdout.trim(),
|
|
199
|
+
stderr: stderr.trim(),
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
stream.on("error", execReject);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
end() {
|
|
208
|
+
client.end();
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
client.on("error", reject);
|
|
214
|
+
|
|
215
|
+
client.connect({
|
|
216
|
+
host: config.host,
|
|
217
|
+
port: config.port,
|
|
218
|
+
username: config.username,
|
|
219
|
+
password: config.password,
|
|
220
|
+
privateKey: config.privateKey,
|
|
221
|
+
passphrase: config.passphrase,
|
|
222
|
+
readyTimeout: config.readyTimeout,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// STRATEGY
|
|
230
|
+
// ============================================================================
|
|
231
|
+
|
|
232
|
+
export class SshHealthCheckStrategy
|
|
233
|
+
implements HealthCheckStrategy<SshConfig, SshResult, SshAggregatedResult>
|
|
234
|
+
{
|
|
235
|
+
id = "ssh";
|
|
236
|
+
displayName = "SSH Health Check";
|
|
237
|
+
description = "SSH server connectivity and command execution health check";
|
|
238
|
+
|
|
239
|
+
private sshClient: SshClient;
|
|
240
|
+
|
|
241
|
+
constructor(sshClient: SshClient = defaultSshClient) {
|
|
242
|
+
this.sshClient = sshClient;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
config: Versioned<SshConfig> = new Versioned({
|
|
246
|
+
version: 1,
|
|
247
|
+
schema: sshConfigSchema,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
result: Versioned<SshResult> = new Versioned({
|
|
251
|
+
version: 1,
|
|
252
|
+
schema: sshResultSchema,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
aggregatedResult: Versioned<SshAggregatedResult> = new Versioned({
|
|
256
|
+
version: 1,
|
|
257
|
+
schema: sshAggregatedSchema,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
aggregateResult(
|
|
261
|
+
runs: HealthCheckRunForAggregation<SshResult>[]
|
|
262
|
+
): SshAggregatedResult {
|
|
263
|
+
let totalConnectionTime = 0;
|
|
264
|
+
let totalCommandTime = 0;
|
|
265
|
+
let successCount = 0;
|
|
266
|
+
let errorCount = 0;
|
|
267
|
+
let validRuns = 0;
|
|
268
|
+
let commandRuns = 0;
|
|
269
|
+
|
|
270
|
+
for (const run of runs) {
|
|
271
|
+
if (run.metadata?.error) {
|
|
272
|
+
errorCount++;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (run.status === "healthy") {
|
|
276
|
+
successCount++;
|
|
277
|
+
}
|
|
278
|
+
if (run.metadata) {
|
|
279
|
+
totalConnectionTime += run.metadata.connectionTimeMs;
|
|
280
|
+
if (run.metadata.commandTimeMs !== undefined) {
|
|
281
|
+
totalCommandTime += run.metadata.commandTimeMs;
|
|
282
|
+
commandRuns++;
|
|
283
|
+
}
|
|
284
|
+
validRuns++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
avgConnectionTime: validRuns > 0 ? totalConnectionTime / validRuns : 0,
|
|
290
|
+
avgCommandTime: commandRuns > 0 ? totalCommandTime / commandRuns : 0,
|
|
291
|
+
successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
|
|
292
|
+
errorCount,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async execute(config: SshConfigInput): Promise<HealthCheckResult<SshResult>> {
|
|
297
|
+
const validatedConfig = this.config.validate(config);
|
|
298
|
+
const start = performance.now();
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
// Connect to SSH server
|
|
302
|
+
const connection = await this.sshClient.connect({
|
|
303
|
+
host: validatedConfig.host,
|
|
304
|
+
port: validatedConfig.port,
|
|
305
|
+
username: validatedConfig.username,
|
|
306
|
+
password: validatedConfig.password,
|
|
307
|
+
privateKey: validatedConfig.privateKey,
|
|
308
|
+
passphrase: validatedConfig.passphrase,
|
|
309
|
+
readyTimeout: validatedConfig.timeout,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const connectionTimeMs = Math.round(performance.now() - start);
|
|
313
|
+
|
|
314
|
+
let commandTimeMs: number | undefined;
|
|
315
|
+
let exitCode: number | undefined;
|
|
316
|
+
let stdout: string | undefined;
|
|
317
|
+
let stderr: string | undefined;
|
|
318
|
+
let commandSuccess = true;
|
|
319
|
+
|
|
320
|
+
// Execute command if provided
|
|
321
|
+
if (validatedConfig.command) {
|
|
322
|
+
const commandStart = performance.now();
|
|
323
|
+
try {
|
|
324
|
+
const result = await connection.exec(validatedConfig.command);
|
|
325
|
+
exitCode = result.exitCode;
|
|
326
|
+
stdout = result.stdout;
|
|
327
|
+
stderr = result.stderr;
|
|
328
|
+
commandSuccess = result.exitCode === 0;
|
|
329
|
+
commandTimeMs = Math.round(performance.now() - commandStart);
|
|
330
|
+
} catch {
|
|
331
|
+
commandSuccess = false;
|
|
332
|
+
commandTimeMs = Math.round(performance.now() - commandStart);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
connection.end();
|
|
337
|
+
|
|
338
|
+
const result: Omit<SshResult, "failedAssertion" | "error"> = {
|
|
339
|
+
connected: true,
|
|
340
|
+
connectionTimeMs,
|
|
341
|
+
commandTimeMs,
|
|
342
|
+
exitCode,
|
|
343
|
+
stdout,
|
|
344
|
+
stderr,
|
|
345
|
+
commandSuccess,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Evaluate assertions using shared utility
|
|
349
|
+
const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
|
|
350
|
+
connectionTime: connectionTimeMs,
|
|
351
|
+
commandTime: commandTimeMs ?? 0,
|
|
352
|
+
exitCode: exitCode ?? 0,
|
|
353
|
+
commandSuccess,
|
|
354
|
+
stdout: stdout ?? "",
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (failedAssertion) {
|
|
358
|
+
return {
|
|
359
|
+
status: "unhealthy",
|
|
360
|
+
latencyMs: connectionTimeMs + (commandTimeMs ?? 0),
|
|
361
|
+
message: `Assertion failed: ${failedAssertion.field} ${
|
|
362
|
+
failedAssertion.operator
|
|
363
|
+
}${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
|
|
364
|
+
metadata: { ...result, failedAssertion },
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!commandSuccess && validatedConfig.command) {
|
|
369
|
+
return {
|
|
370
|
+
status: "unhealthy",
|
|
371
|
+
latencyMs: connectionTimeMs + (commandTimeMs ?? 0),
|
|
372
|
+
message: `Command failed with exit code ${exitCode}`,
|
|
373
|
+
metadata: result,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const message = validatedConfig.command
|
|
378
|
+
? `SSH connected, command executed (exit ${exitCode}) in ${commandTimeMs}ms`
|
|
379
|
+
: `SSH connected in ${connectionTimeMs}ms`;
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
status: "healthy",
|
|
383
|
+
latencyMs: connectionTimeMs + (commandTimeMs ?? 0),
|
|
384
|
+
message,
|
|
385
|
+
metadata: result,
|
|
386
|
+
};
|
|
387
|
+
} catch (error: unknown) {
|
|
388
|
+
const end = performance.now();
|
|
389
|
+
const isError = error instanceof Error;
|
|
390
|
+
return {
|
|
391
|
+
status: "unhealthy",
|
|
392
|
+
latencyMs: Math.round(end - start),
|
|
393
|
+
message: isError ? error.message : "SSH connection failed",
|
|
394
|
+
metadata: {
|
|
395
|
+
connected: false,
|
|
396
|
+
connectionTimeMs: Math.round(end - start),
|
|
397
|
+
commandSuccess: false,
|
|
398
|
+
error: isError ? error.name : "UnknownError",
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|