@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 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.2",
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
  },
@@ -30,11 +30,11 @@ describe("SshHealthCheckStrategy", () => {
30
30
  ),
31
31
  });
32
32
 
33
- describe("execute", () => {
34
- it("should return healthy for successful connection", async () => {
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 result = await strategy.execute({
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(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
- });
45
+ expect(connectedClient.client).toBeDefined();
46
+ expect(connectedClient.client.exec).toBeDefined();
47
+ expect(connectedClient.close).toBeDefined();
62
48
 
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);
49
+ connectedClient.close();
67
50
  });
68
51
 
69
- it("should return unhealthy for connection error", async () => {
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
- 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);
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
- it("should return unhealthy for non-zero exit code", async () => {
69
+ describe("client.exec", () => {
70
+ it("should execute command successfully", async () => {
88
71
  const strategy = new SshHealthCheckStrategy(
89
- createMockClient({ exitCode: 1, stderr: "Error" })
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
- expect(result.status).toBe("healthy");
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
- 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
- });
85
+ expect(result.exitCode).toBe(0);
86
+ expect(result.stdout).toBe("OK");
137
87
 
138
- expect(result.status).toBe("healthy");
88
+ connectedClient.close();
139
89
  });
140
90
 
141
- it("should fail exitCode assertion when non-zero", async () => {
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
- 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
- );
103
+ const result = await connectedClient.client.exec("exit 1");
164
104
 
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
- });
105
+ expect(result.exitCode).toBe(1);
106
+ expect(result.stderr).toBe("Error");
174
107
 
175
- expect(result.status).toBe("healthy");
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
- export type SshResult = z.infer<typeof sshResultSchema>;
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
- avgCommandTime: healthResultNumber({
77
+ maxConnectionTime: healthResultNumber({
124
78
  "x-chart-type": "line",
125
- "x-chart-label": "Avg Command Time",
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
- export type SshAggregatedResult = z.infer<typeof sshAggregatedSchema>;
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 HealthCheckStrategy<SshConfig, SshResult, SshAggregatedResult>
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
- 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
- }
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: validRuns > 0 ? totalConnectionTime / validRuns : 0,
290
- avgCommandTime: commandRuns > 0 ? totalCommandTime / commandRuns : 0,
291
- successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
252
+ avgConnectionTime,
253
+ maxConnectionTime,
254
+ successRate,
292
255
  errorCount,
293
256
  };
294
257
  }
295
258
 
296
- async execute(config: SshConfigInput): Promise<HealthCheckResult<SshResult>> {
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
- 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`;
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
- 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
- }
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";