@checkstack/healthcheck-ssh-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 +45 -0
- package/package.json +2 -1
- package/src/command-collector.test.ts +157 -0
- package/src/command-collector.ts +151 -0
- package/src/index.ts +8 -5
- package/src/strategy.test.ts +34 -107
- package/src/strategy.ts +69 -188
- package/src/transport-client.ts +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# @checkstack/healthcheck-ssh-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
|
+
- Updated dependencies [f5b1f49]
|
|
36
|
+
- @checkstack/backend-api@0.1.0
|
|
37
|
+
- @checkstack/healthcheck-common@0.1.0
|
|
38
|
+
- @checkstack/healthcheck-ssh-common@0.1.0
|
|
39
|
+
- @checkstack/common@0.0.3
|
|
40
|
+
|
|
41
|
+
## 0.0.3
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- Updated dependencies [cb82e4d]
|
|
46
|
+
- @checkstack/healthcheck-common@0.0.3
|
|
47
|
+
|
|
3
48
|
## 0.0.2
|
|
4
49
|
|
|
5
50
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-ssh-backend",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"@checkstack/backend-api": "workspace:*",
|
|
13
13
|
"@checkstack/common": "workspace:*",
|
|
14
14
|
"@checkstack/healthcheck-common": "workspace:*",
|
|
15
|
+
"@checkstack/healthcheck-ssh-common": "workspace:*",
|
|
15
16
|
"ssh2": "^1.15.0"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { CommandCollector, type CommandConfig } from "./command-collector";
|
|
3
|
+
import type { SshTransportClient } from "@checkstack/healthcheck-ssh-common";
|
|
4
|
+
|
|
5
|
+
describe("CommandCollector", () => {
|
|
6
|
+
const createMockClient = (
|
|
7
|
+
response: {
|
|
8
|
+
exitCode?: number;
|
|
9
|
+
stdout?: string;
|
|
10
|
+
stderr?: string;
|
|
11
|
+
} = {}
|
|
12
|
+
): SshTransportClient => ({
|
|
13
|
+
exec: mock(() =>
|
|
14
|
+
Promise.resolve({
|
|
15
|
+
exitCode: response.exitCode ?? 0,
|
|
16
|
+
stdout: response.stdout ?? "",
|
|
17
|
+
stderr: response.stderr ?? "",
|
|
18
|
+
})
|
|
19
|
+
),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("execute", () => {
|
|
23
|
+
it("should execute command successfully", async () => {
|
|
24
|
+
const collector = new CommandCollector();
|
|
25
|
+
const client = createMockClient({
|
|
26
|
+
exitCode: 0,
|
|
27
|
+
stdout: "Hello World",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = await collector.execute({
|
|
31
|
+
config: { command: "echo 'Hello World'" },
|
|
32
|
+
client,
|
|
33
|
+
pluginId: "test",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result.result.exitCode).toBe(0);
|
|
37
|
+
expect(result.result.stdout).toBe("Hello World");
|
|
38
|
+
expect(result.result.executionTimeMs).toBeGreaterThanOrEqual(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should return non-zero exit code for failed command", async () => {
|
|
42
|
+
const collector = new CommandCollector();
|
|
43
|
+
const client = createMockClient({
|
|
44
|
+
exitCode: 1,
|
|
45
|
+
stderr: "Command not found",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = await collector.execute({
|
|
49
|
+
config: { command: "nonexistent-command" },
|
|
50
|
+
client,
|
|
51
|
+
pluginId: "test",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(result.result.exitCode).toBe(1);
|
|
55
|
+
expect(result.result.stderr).toBe("Command not found");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should pass command to client", async () => {
|
|
59
|
+
const collector = new CommandCollector();
|
|
60
|
+
const client = createMockClient();
|
|
61
|
+
|
|
62
|
+
await collector.execute({
|
|
63
|
+
config: { command: "ls -la /tmp" },
|
|
64
|
+
client,
|
|
65
|
+
pluginId: "test",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(client.exec).toHaveBeenCalledWith("ls -la /tmp");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("aggregateResult", () => {
|
|
73
|
+
it("should calculate average execution time and success rate", () => {
|
|
74
|
+
const collector = new CommandCollector();
|
|
75
|
+
const runs = [
|
|
76
|
+
{
|
|
77
|
+
id: "1",
|
|
78
|
+
status: "healthy" as const,
|
|
79
|
+
latencyMs: 100,
|
|
80
|
+
checkId: "c1",
|
|
81
|
+
timestamp: new Date(),
|
|
82
|
+
metadata: {
|
|
83
|
+
exitCode: 0,
|
|
84
|
+
stdout: "",
|
|
85
|
+
stderr: "",
|
|
86
|
+
executionTimeMs: 50,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "2",
|
|
91
|
+
status: "healthy" as const,
|
|
92
|
+
latencyMs: 150,
|
|
93
|
+
checkId: "c1",
|
|
94
|
+
timestamp: new Date(),
|
|
95
|
+
metadata: {
|
|
96
|
+
exitCode: 0,
|
|
97
|
+
stdout: "",
|
|
98
|
+
stderr: "",
|
|
99
|
+
executionTimeMs: 100,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const aggregated = collector.aggregateResult(runs);
|
|
105
|
+
|
|
106
|
+
expect(aggregated.avgExecutionTimeMs).toBe(75);
|
|
107
|
+
expect(aggregated.successRate).toBe(100);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should calculate success rate based on exit codes", () => {
|
|
111
|
+
const collector = new CommandCollector();
|
|
112
|
+
const runs = [
|
|
113
|
+
{
|
|
114
|
+
id: "1",
|
|
115
|
+
status: "healthy" as const,
|
|
116
|
+
latencyMs: 100,
|
|
117
|
+
checkId: "c1",
|
|
118
|
+
timestamp: new Date(),
|
|
119
|
+
metadata: {
|
|
120
|
+
exitCode: 0,
|
|
121
|
+
stdout: "",
|
|
122
|
+
stderr: "",
|
|
123
|
+
executionTimeMs: 50,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "2",
|
|
128
|
+
status: "unhealthy" as const,
|
|
129
|
+
latencyMs: 150,
|
|
130
|
+
checkId: "c1",
|
|
131
|
+
timestamp: new Date(),
|
|
132
|
+
metadata: {
|
|
133
|
+
exitCode: 1,
|
|
134
|
+
stdout: "",
|
|
135
|
+
stderr: "",
|
|
136
|
+
executionTimeMs: 100,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const aggregated = collector.aggregateResult(runs);
|
|
142
|
+
|
|
143
|
+
expect(aggregated.successRate).toBe(50);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("metadata", () => {
|
|
148
|
+
it("should have correct static properties", () => {
|
|
149
|
+
const collector = new CommandCollector();
|
|
150
|
+
|
|
151
|
+
expect(collector.id).toBe("command");
|
|
152
|
+
expect(collector.displayName).toBe("Shell Command");
|
|
153
|
+
expect(collector.allowMultiple).toBe(true);
|
|
154
|
+
expect(collector.supportedPlugins).toHaveLength(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
} from "@checkstack/healthcheck-common";
|
|
12
|
+
import { pluginMetadata as sshPluginMetadata } from "./plugin-metadata";
|
|
13
|
+
import type { SshTransportClient } from "@checkstack/healthcheck-ssh-common";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// CONFIGURATION SCHEMA
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
const commandConfigSchema = z.object({
|
|
20
|
+
command: z.string().min(1).describe("Shell command to execute"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type CommandConfig = z.infer<typeof commandConfigSchema>;
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// RESULT SCHEMAS
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
const commandResultSchema = z.object({
|
|
30
|
+
exitCode: healthResultNumber({
|
|
31
|
+
"x-chart-type": "counter",
|
|
32
|
+
"x-chart-label": "Exit Code",
|
|
33
|
+
}),
|
|
34
|
+
stdout: healthResultString({
|
|
35
|
+
"x-chart-type": "text",
|
|
36
|
+
"x-chart-label": "Standard Output",
|
|
37
|
+
}),
|
|
38
|
+
stderr: healthResultString({
|
|
39
|
+
"x-chart-type": "text",
|
|
40
|
+
"x-chart-label": "Standard Error",
|
|
41
|
+
}),
|
|
42
|
+
executionTimeMs: healthResultNumber({
|
|
43
|
+
"x-chart-type": "line",
|
|
44
|
+
"x-chart-label": "Execution Time",
|
|
45
|
+
"x-chart-unit": "ms",
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export type CommandResult = z.infer<typeof commandResultSchema>;
|
|
50
|
+
|
|
51
|
+
const commandAggregatedSchema = z.object({
|
|
52
|
+
avgExecutionTimeMs: healthResultNumber({
|
|
53
|
+
"x-chart-type": "line",
|
|
54
|
+
"x-chart-label": "Avg Execution Time",
|
|
55
|
+
"x-chart-unit": "ms",
|
|
56
|
+
}),
|
|
57
|
+
successRate: healthResultNumber({
|
|
58
|
+
"x-chart-type": "gauge",
|
|
59
|
+
"x-chart-label": "Success Rate",
|
|
60
|
+
"x-chart-unit": "%",
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export type CommandAggregatedResult = z.infer<typeof commandAggregatedSchema>;
|
|
65
|
+
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// COMMAND COLLECTOR (PSEUDO-COLLECTOR)
|
|
68
|
+
// ============================================================================
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Built-in command collector for SSH strategy.
|
|
72
|
+
* Allows users to run arbitrary shell commands as check items.
|
|
73
|
+
* This is the "basic mode" functionality exposed as a collector.
|
|
74
|
+
*/
|
|
75
|
+
export class CommandCollector
|
|
76
|
+
implements
|
|
77
|
+
CollectorStrategy<
|
|
78
|
+
SshTransportClient,
|
|
79
|
+
CommandConfig,
|
|
80
|
+
CommandResult,
|
|
81
|
+
CommandAggregatedResult
|
|
82
|
+
>
|
|
83
|
+
{
|
|
84
|
+
/**
|
|
85
|
+
* ID for this collector.
|
|
86
|
+
* Built-in collectors are identified by ownerPlugin matching the strategy's plugin.
|
|
87
|
+
* Fully-qualified: healthcheck-ssh.command
|
|
88
|
+
*/
|
|
89
|
+
id = "command";
|
|
90
|
+
displayName = "Shell Command";
|
|
91
|
+
description = "Execute a shell command and check the result";
|
|
92
|
+
|
|
93
|
+
supportedPlugins = [sshPluginMetadata];
|
|
94
|
+
|
|
95
|
+
/** Allow multiple command instances per config */
|
|
96
|
+
allowMultiple = true;
|
|
97
|
+
|
|
98
|
+
config = new Versioned({ version: 1, schema: commandConfigSchema });
|
|
99
|
+
result = new Versioned({ version: 1, schema: commandResultSchema });
|
|
100
|
+
aggregatedResult = new Versioned({
|
|
101
|
+
version: 1,
|
|
102
|
+
schema: commandAggregatedSchema,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
async execute({
|
|
106
|
+
config,
|
|
107
|
+
client,
|
|
108
|
+
}: {
|
|
109
|
+
config: CommandConfig;
|
|
110
|
+
client: SshTransportClient;
|
|
111
|
+
pluginId: string;
|
|
112
|
+
}): Promise<CollectorResult<CommandResult>> {
|
|
113
|
+
const startTime = Date.now();
|
|
114
|
+
const result = await client.exec(config.command);
|
|
115
|
+
const executionTimeMs = Date.now() - startTime;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
result: {
|
|
119
|
+
exitCode: result.exitCode,
|
|
120
|
+
stdout: result.stdout,
|
|
121
|
+
stderr: result.stderr,
|
|
122
|
+
executionTimeMs,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
aggregateResult(
|
|
128
|
+
runs: HealthCheckRunForAggregation<CommandResult>[]
|
|
129
|
+
): CommandAggregatedResult {
|
|
130
|
+
const times = runs
|
|
131
|
+
.map((r) => r.metadata?.executionTimeMs)
|
|
132
|
+
.filter((v): v is number => typeof v === "number");
|
|
133
|
+
|
|
134
|
+
const exitCodes = runs
|
|
135
|
+
.map((r) => r.metadata?.exitCode)
|
|
136
|
+
.filter((v): v is number => typeof v === "number");
|
|
137
|
+
|
|
138
|
+
const successCount = exitCodes.filter((code) => code === 0).length;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
avgExecutionTimeMs:
|
|
142
|
+
times.length > 0
|
|
143
|
+
? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
|
|
144
|
+
: 0,
|
|
145
|
+
successRate:
|
|
146
|
+
exitCodes.length > 0
|
|
147
|
+
? Math.round((successCount / exitCodes.length) * 100)
|
|
148
|
+
: 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createBackendPlugin,
|
|
3
|
-
coreServices,
|
|
4
|
-
} from "@checkstack/backend-api";
|
|
1
|
+
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
5
2
|
import { SshHealthCheckStrategy } from "./strategy";
|
|
3
|
+
import { CommandCollector } from "./command-collector";
|
|
6
4
|
import { pluginMetadata } from "./plugin-metadata";
|
|
7
5
|
|
|
8
6
|
export default createBackendPlugin({
|
|
@@ -11,12 +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 SSH Health Check Strategy...");
|
|
18
17
|
const strategy = new SshHealthCheckStrategy();
|
|
19
18
|
healthCheckRegistry.register(strategy);
|
|
19
|
+
|
|
20
|
+
// Register the built-in command collector (allows "basic mode" via collector UI)
|
|
21
|
+
collectorRegistry.register(new CommandCollector());
|
|
22
|
+
logger.debug(" -> Registered __command__ collector");
|
|
20
23
|
},
|
|
21
24
|
});
|
|
22
25
|
},
|
package/src/strategy.test.ts
CHANGED
|
@@ -30,11 +30,11 @@ describe("SshHealthCheckStrategy", () => {
|
|
|
30
30
|
),
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
describe("
|
|
34
|
-
it("should return
|
|
33
|
+
describe("createClient", () => {
|
|
34
|
+
it("should return a connected client for successful connection", async () => {
|
|
35
35
|
const strategy = new SshHealthCheckStrategy(createMockClient());
|
|
36
36
|
|
|
37
|
-
const
|
|
37
|
+
const connectedClient = await strategy.createClient({
|
|
38
38
|
host: "localhost",
|
|
39
39
|
port: 22,
|
|
40
40
|
username: "user",
|
|
@@ -42,137 +42,70 @@ describe("SshHealthCheckStrategy", () => {
|
|
|
42
42
|
timeout: 5000,
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
expect(
|
|
46
|
-
expect(
|
|
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
|
-
});
|
|
45
|
+
expect(connectedClient.client).toBeDefined();
|
|
46
|
+
expect(connectedClient.client.exec).toBeDefined();
|
|
47
|
+
expect(connectedClient.close).toBeDefined();
|
|
62
48
|
|
|
63
|
-
|
|
64
|
-
expect(result.metadata?.commandSuccess).toBe(true);
|
|
65
|
-
expect(result.metadata?.stdout).toBe("OK");
|
|
66
|
-
expect(result.metadata?.exitCode).toBe(0);
|
|
49
|
+
connectedClient.close();
|
|
67
50
|
});
|
|
68
51
|
|
|
69
|
-
it("should
|
|
52
|
+
it("should throw for connection error", async () => {
|
|
70
53
|
const strategy = new SshHealthCheckStrategy(
|
|
71
54
|
createMockClient({ connectError: new Error("Connection refused") })
|
|
72
55
|
);
|
|
73
56
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
expect(result.message).toContain("Connection refused");
|
|
84
|
-
expect(result.metadata?.connected).toBe(false);
|
|
57
|
+
await expect(
|
|
58
|
+
strategy.createClient({
|
|
59
|
+
host: "localhost",
|
|
60
|
+
port: 22,
|
|
61
|
+
username: "user",
|
|
62
|
+
password: "secret",
|
|
63
|
+
timeout: 5000,
|
|
64
|
+
})
|
|
65
|
+
).rejects.toThrow("Connection refused");
|
|
85
66
|
});
|
|
67
|
+
});
|
|
86
68
|
|
|
87
|
-
|
|
69
|
+
describe("client.exec", () => {
|
|
70
|
+
it("should execute command successfully", async () => {
|
|
88
71
|
const strategy = new SshHealthCheckStrategy(
|
|
89
|
-
createMockClient({ exitCode:
|
|
72
|
+
createMockClient({ exitCode: 0, stdout: "OK" })
|
|
90
73
|
);
|
|
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({
|
|
74
|
+
const connectedClient = await strategy.createClient({
|
|
110
75
|
host: "localhost",
|
|
111
76
|
port: 22,
|
|
112
77
|
username: "user",
|
|
113
78
|
password: "secret",
|
|
114
79
|
timeout: 5000,
|
|
115
|
-
assertions: [
|
|
116
|
-
{ field: "connectionTime", operator: "lessThan", value: 5000 },
|
|
117
|
-
],
|
|
118
80
|
});
|
|
119
81
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
it("should pass exitCode assertion", async () => {
|
|
124
|
-
const strategy = new SshHealthCheckStrategy(
|
|
125
|
-
createMockClient({ exitCode: 0 })
|
|
126
|
-
);
|
|
82
|
+
// SSH transport client takes a string command
|
|
83
|
+
const result = await connectedClient.client.exec("echo OK");
|
|
127
84
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
port: 22,
|
|
131
|
-
username: "user",
|
|
132
|
-
password: "secret",
|
|
133
|
-
timeout: 5000,
|
|
134
|
-
command: "true",
|
|
135
|
-
assertions: [{ field: "exitCode", operator: "equals", value: 0 }],
|
|
136
|
-
});
|
|
85
|
+
expect(result.exitCode).toBe(0);
|
|
86
|
+
expect(result.stdout).toBe("OK");
|
|
137
87
|
|
|
138
|
-
|
|
88
|
+
connectedClient.close();
|
|
139
89
|
});
|
|
140
90
|
|
|
141
|
-
it("should
|
|
91
|
+
it("should return non-zero exit code for failed command", async () => {
|
|
142
92
|
const strategy = new SshHealthCheckStrategy(
|
|
143
|
-
createMockClient({ exitCode: 1 })
|
|
93
|
+
createMockClient({ exitCode: 1, stderr: "Error" })
|
|
144
94
|
);
|
|
145
|
-
|
|
146
|
-
const result = await strategy.execute({
|
|
95
|
+
const connectedClient = await strategy.createClient({
|
|
147
96
|
host: "localhost",
|
|
148
97
|
port: 22,
|
|
149
98
|
username: "user",
|
|
150
99
|
password: "secret",
|
|
151
100
|
timeout: 5000,
|
|
152
|
-
command: "false",
|
|
153
|
-
assertions: [{ field: "exitCode", operator: "equals", value: 0 }],
|
|
154
101
|
});
|
|
155
102
|
|
|
156
|
-
|
|
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
|
-
);
|
|
103
|
+
const result = await connectedClient.client.exec("exit 1");
|
|
164
104
|
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
});
|
|
105
|
+
expect(result.exitCode).toBe(1);
|
|
106
|
+
expect(result.stderr).toBe("Error");
|
|
174
107
|
|
|
175
|
-
|
|
108
|
+
connectedClient.close();
|
|
176
109
|
});
|
|
177
110
|
});
|
|
178
111
|
|
|
@@ -189,9 +122,7 @@ describe("SshHealthCheckStrategy", () => {
|
|
|
189
122
|
metadata: {
|
|
190
123
|
connected: true,
|
|
191
124
|
connectionTimeMs: 50,
|
|
192
|
-
commandTimeMs: 10,
|
|
193
125
|
exitCode: 0,
|
|
194
|
-
commandSuccess: true,
|
|
195
126
|
},
|
|
196
127
|
},
|
|
197
128
|
{
|
|
@@ -203,9 +134,7 @@ describe("SshHealthCheckStrategy", () => {
|
|
|
203
134
|
metadata: {
|
|
204
135
|
connected: true,
|
|
205
136
|
connectionTimeMs: 100,
|
|
206
|
-
commandTimeMs: 20,
|
|
207
137
|
exitCode: 0,
|
|
208
|
-
commandSuccess: true,
|
|
209
138
|
},
|
|
210
139
|
},
|
|
211
140
|
];
|
|
@@ -213,7 +142,6 @@ describe("SshHealthCheckStrategy", () => {
|
|
|
213
142
|
const aggregated = strategy.aggregateResult(runs);
|
|
214
143
|
|
|
215
144
|
expect(aggregated.avgConnectionTime).toBe(75);
|
|
216
|
-
expect(aggregated.avgCommandTime).toBe(15);
|
|
217
145
|
expect(aggregated.successRate).toBe(100);
|
|
218
146
|
expect(aggregated.errorCount).toBe(0);
|
|
219
147
|
});
|
|
@@ -230,7 +158,6 @@ describe("SshHealthCheckStrategy", () => {
|
|
|
230
158
|
metadata: {
|
|
231
159
|
connected: false,
|
|
232
160
|
connectionTimeMs: 100,
|
|
233
|
-
commandSuccess: false,
|
|
234
161
|
error: "Connection refused",
|
|
235
162
|
},
|
|
236
163
|
},
|
package/src/strategy.ts
CHANGED
|
@@ -1,41 +1,24 @@
|
|
|
1
1
|
import { Client } from "ssh2";
|
|
2
2
|
import {
|
|
3
3
|
HealthCheckStrategy,
|
|
4
|
-
HealthCheckResult,
|
|
5
4
|
HealthCheckRunForAggregation,
|
|
6
5
|
Versioned,
|
|
7
6
|
z,
|
|
8
|
-
timeThresholdField,
|
|
9
|
-
numericField,
|
|
10
|
-
booleanField,
|
|
11
|
-
stringField,
|
|
12
|
-
evaluateAssertions,
|
|
13
7
|
configString,
|
|
14
8
|
configNumber,
|
|
9
|
+
type ConnectedClient,
|
|
15
10
|
} from "@checkstack/backend-api";
|
|
16
11
|
import {
|
|
17
12
|
healthResultBoolean,
|
|
18
13
|
healthResultNumber,
|
|
19
14
|
healthResultString,
|
|
20
15
|
} from "@checkstack/healthcheck-common";
|
|
16
|
+
import type { SshTransportClient, SshCommandResult } from "./transport-client";
|
|
21
17
|
|
|
22
18
|
// ============================================================================
|
|
23
19
|
// SCHEMAS
|
|
24
20
|
// ============================================================================
|
|
25
21
|
|
|
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
22
|
/**
|
|
40
23
|
* Configuration schema for SSH health checks.
|
|
41
24
|
*/
|
|
@@ -56,13 +39,6 @@ export const sshConfigSchema = z.object({
|
|
|
56
39
|
.min(100)
|
|
57
40
|
.default(10_000)
|
|
58
41
|
.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
42
|
});
|
|
67
43
|
|
|
68
44
|
export type SshConfig = z.infer<typeof sshConfigSchema>;
|
|
@@ -81,35 +57,13 @@ const sshResultSchema = z.object({
|
|
|
81
57
|
"x-chart-label": "Connection Time",
|
|
82
58
|
"x-chart-unit": "ms",
|
|
83
59
|
}),
|
|
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
60
|
error: healthResultString({
|
|
107
61
|
"x-chart-type": "status",
|
|
108
62
|
"x-chart-label": "Error",
|
|
109
63
|
}).optional(),
|
|
110
64
|
});
|
|
111
65
|
|
|
112
|
-
|
|
66
|
+
type SshResult = z.infer<typeof sshResultSchema>;
|
|
113
67
|
|
|
114
68
|
/**
|
|
115
69
|
* Aggregated metadata for buckets.
|
|
@@ -120,9 +74,9 @@ const sshAggregatedSchema = z.object({
|
|
|
120
74
|
"x-chart-label": "Avg Connection Time",
|
|
121
75
|
"x-chart-unit": "ms",
|
|
122
76
|
}),
|
|
123
|
-
|
|
77
|
+
maxConnectionTime: healthResultNumber({
|
|
124
78
|
"x-chart-type": "line",
|
|
125
|
-
"x-chart-label": "
|
|
79
|
+
"x-chart-label": "Max Connection Time",
|
|
126
80
|
"x-chart-unit": "ms",
|
|
127
81
|
}),
|
|
128
82
|
successRate: healthResultNumber({
|
|
@@ -136,18 +90,12 @@ const sshAggregatedSchema = z.object({
|
|
|
136
90
|
}),
|
|
137
91
|
});
|
|
138
92
|
|
|
139
|
-
|
|
93
|
+
type SshAggregatedResult = z.infer<typeof sshAggregatedSchema>;
|
|
140
94
|
|
|
141
95
|
// ============================================================================
|
|
142
96
|
// SSH CLIENT INTERFACE (for testability)
|
|
143
97
|
// ============================================================================
|
|
144
98
|
|
|
145
|
-
export interface SshCommandResult {
|
|
146
|
-
exitCode: number;
|
|
147
|
-
stdout: string;
|
|
148
|
-
stderr: string;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
99
|
export interface SshConnection {
|
|
152
100
|
exec(command: string): Promise<SshCommandResult>;
|
|
153
101
|
end(): void;
|
|
@@ -230,7 +178,13 @@ const defaultSshClient: SshClient = {
|
|
|
230
178
|
// ============================================================================
|
|
231
179
|
|
|
232
180
|
export class SshHealthCheckStrategy
|
|
233
|
-
implements
|
|
181
|
+
implements
|
|
182
|
+
HealthCheckStrategy<
|
|
183
|
+
SshConfig,
|
|
184
|
+
SshTransportClient,
|
|
185
|
+
SshResult,
|
|
186
|
+
SshAggregatedResult
|
|
187
|
+
>
|
|
234
188
|
{
|
|
235
189
|
id = "ssh";
|
|
236
190
|
displayName = "SSH Health Check";
|
|
@@ -260,144 +214,71 @@ export class SshHealthCheckStrategy
|
|
|
260
214
|
aggregateResult(
|
|
261
215
|
runs: HealthCheckRunForAggregation<SshResult>[]
|
|
262
216
|
): SshAggregatedResult {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
}
|
|
217
|
+
const validRuns = runs.filter((r) => r.metadata);
|
|
218
|
+
|
|
219
|
+
if (validRuns.length === 0) {
|
|
220
|
+
return {
|
|
221
|
+
avgConnectionTime: 0,
|
|
222
|
+
maxConnectionTime: 0,
|
|
223
|
+
successRate: 0,
|
|
224
|
+
errorCount: 0,
|
|
225
|
+
};
|
|
286
226
|
}
|
|
287
227
|
|
|
228
|
+
const connectionTimes = validRuns
|
|
229
|
+
.map((r) => r.metadata?.connectionTimeMs)
|
|
230
|
+
.filter((t): t is number => typeof t === "number");
|
|
231
|
+
|
|
232
|
+
const avgConnectionTime =
|
|
233
|
+
connectionTimes.length > 0
|
|
234
|
+
? Math.round(
|
|
235
|
+
connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
|
|
236
|
+
)
|
|
237
|
+
: 0;
|
|
238
|
+
|
|
239
|
+
const maxConnectionTime =
|
|
240
|
+
connectionTimes.length > 0 ? Math.max(...connectionTimes) : 0;
|
|
241
|
+
|
|
242
|
+
const successCount = validRuns.filter(
|
|
243
|
+
(r) => r.metadata?.connected === true
|
|
244
|
+
).length;
|
|
245
|
+
const successRate = Math.round((successCount / validRuns.length) * 100);
|
|
246
|
+
|
|
247
|
+
const errorCount = validRuns.filter(
|
|
248
|
+
(r) => r.metadata?.error !== undefined
|
|
249
|
+
).length;
|
|
250
|
+
|
|
288
251
|
return {
|
|
289
|
-
avgConnectionTime
|
|
290
|
-
|
|
291
|
-
successRate
|
|
252
|
+
avgConnectionTime,
|
|
253
|
+
maxConnectionTime,
|
|
254
|
+
successRate,
|
|
292
255
|
errorCount,
|
|
293
256
|
};
|
|
294
257
|
}
|
|
295
258
|
|
|
296
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Create a connected SSH transport client.
|
|
261
|
+
*/
|
|
262
|
+
async createClient(
|
|
263
|
+
config: SshConfigInput
|
|
264
|
+
): Promise<ConnectedClient<SshTransportClient>> {
|
|
297
265
|
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
266
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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`;
|
|
267
|
+
const connection = await this.sshClient.connect({
|
|
268
|
+
host: validatedConfig.host,
|
|
269
|
+
port: validatedConfig.port,
|
|
270
|
+
username: validatedConfig.username,
|
|
271
|
+
password: validatedConfig.password,
|
|
272
|
+
privateKey: validatedConfig.privateKey,
|
|
273
|
+
passphrase: validatedConfig.passphrase,
|
|
274
|
+
readyTimeout: validatedConfig.timeout,
|
|
275
|
+
});
|
|
380
276
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
}
|
|
277
|
+
return {
|
|
278
|
+
client: {
|
|
279
|
+
exec: (command: string) => connection.exec(command),
|
|
280
|
+
},
|
|
281
|
+
close: () => connection.end(),
|
|
282
|
+
};
|
|
402
283
|
}
|
|
403
284
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TransportClient } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SSH command result from remote execution.
|
|
5
|
+
*/
|
|
6
|
+
export interface SshCommandResult {
|
|
7
|
+
exitCode: number;
|
|
8
|
+
stdout: string;
|
|
9
|
+
stderr: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* SSH transport client for collector execution.
|
|
14
|
+
* Implements the generic TransportClient interface with SSH command execution.
|
|
15
|
+
*/
|
|
16
|
+
export type SshTransportClient = TransportClient<string, SshCommandResult>;
|
|
17
|
+
|
|
18
|
+
// Re-export for convenience
|
|
19
|
+
export type { TransportClient } from "@checkstack/common";
|