@checkstack/healthcheck-ssh-backend 0.4.3 → 0.5.1
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 +80 -0
- package/package.json +3 -3
- package/src/command-collector.test.ts +20 -0
- package/src/command-collector.ts +39 -2
- package/src/strategy.test.ts +57 -0
- package/src/strategy.ts +38 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# @checkstack/healthcheck-ssh-backend
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [bd41130]
|
|
8
|
+
- @checkstack/backend-api@0.32.0
|
|
9
|
+
- @checkstack/healthcheck-common@1.16.1
|
|
10
|
+
|
|
11
|
+
## 0.5.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- 43e4484: Extend `{{ … }}` environment templating across every built-in health-check type
|
|
16
|
+
and add editor UX for it, so one check config can cover N environments (mirrors
|
|
17
|
+
the existing HTTP `url` pattern).
|
|
18
|
+
|
|
19
|
+
Templatable connection/target fields now marked `x-templatable`:
|
|
20
|
+
|
|
21
|
+
- TLS: `host`, `servername`; TCP: `host`; Ping: `host`; gRPC: `host`, `service`.
|
|
22
|
+
- MySQL / Postgres: `host`, `database`, `user`, `query`.
|
|
23
|
+
- SSH: `host`, `username`, `command`; Redis: `host`, `args`; RCON: `host`,
|
|
24
|
+
`command`.
|
|
25
|
+
- DNS: `hostname`, `nameserver`; Jenkins: `url` (`baseUrl`), `jobName`;
|
|
26
|
+
Container: `endpoint`, `container`.
|
|
27
|
+
- SNMP: `host` (strategy), `oid` (collector).
|
|
28
|
+
- Script (shell): `cwd` (working directory).
|
|
29
|
+
|
|
30
|
+
This closes the last gaps so the coverage is now truly every built-in
|
|
31
|
+
health-check type. The Script collectors' `script` bodies are deliberately NOT
|
|
32
|
+
templatable: rendering `{{ … }}` into shell/TypeScript source would splice env
|
|
33
|
+
values into executed code. Per-environment data reaches those scripts safely via
|
|
34
|
+
the reserved `CHECKSTACK_ENV_*` shell vars (shell collector) and
|
|
35
|
+
`globalThis.context.environment` (inline collector) instead.
|
|
36
|
+
|
|
37
|
+
Because templating strips `{{ }}` and renders an undefined variable to an empty
|
|
38
|
+
string, every REQUIRED templatable field now has a post-render config-error
|
|
39
|
+
guard so an empty/invalid render is treated as a transport failure instead of a
|
|
40
|
+
silent "healthy" empty probe. Strategy connection fields (host, database, user,
|
|
41
|
+
endpoint, container, Jenkins base URL, SNMP host) throw from `createClient`;
|
|
42
|
+
collector target fields (query, command, hostname, jobName, SNMP oid) return a
|
|
43
|
+
`CollectorResult` with an `error`. Jenkins `baseUrl` moves its `.url()` validation to post-render.
|
|
44
|
+
Secret fields (passwords/tokens/keys) are never templatable; optional fields
|
|
45
|
+
(SNI `servername`, gRPC `service`, DNS `nameserver`, Redis `args`, Script `cwd`)
|
|
46
|
+
are templatable but not non-empty-guarded, since an empty render is a legitimate
|
|
47
|
+
"unset". SSRF/egress guards continue to run on the rendered host (rendering
|
|
48
|
+
happens before `createClient`).
|
|
49
|
+
|
|
50
|
+
Editor UX (`@checkstack/ui` + `@checkstack/healthcheck-frontend`):
|
|
51
|
+
|
|
52
|
+
- The environment "Preview as" picker + live preview line now also apply to the
|
|
53
|
+
strategy (connection) form, not just collector forms, so host/port templates
|
|
54
|
+
preview too.
|
|
55
|
+
- A single-line templatable field shows a small "Templating" badge next to its
|
|
56
|
+
label and, when a completion provider is supplied, renders a
|
|
57
|
+
`TemplateValueInput` with `{{ … }}` autocomplete. The health-check editor
|
|
58
|
+
seeds the provider with the fixed `environment.* / check.* / system.*`
|
|
59
|
+
namespace (`createReferenceCompletionProvider`, new `@checkstack/ui` export),
|
|
60
|
+
and `DynamicForm` gains a `templatableFieldsOnly` prop so only `x-templatable`
|
|
61
|
+
fields become template inputs (automation keeps templating every string field).
|
|
62
|
+
|
|
63
|
+
BREAKING CHANGE: none. Existing non-templatable configs and stored values are
|
|
64
|
+
unaffected; only fields explicitly marked `x-templatable` change behavior.
|
|
65
|
+
|
|
66
|
+
The `@checkstack/ai-backend` bump reflects the regenerated docs index for the
|
|
67
|
+
updated health-check collector and config-schema templating documentation.
|
|
68
|
+
|
|
69
|
+
Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback.
|
|
70
|
+
|
|
71
|
+
### Patch Changes
|
|
72
|
+
|
|
73
|
+
- Updated dependencies [43e4484]
|
|
74
|
+
- Updated dependencies [43e4484]
|
|
75
|
+
- Updated dependencies [43e4484]
|
|
76
|
+
- Updated dependencies [43e4484]
|
|
77
|
+
- Updated dependencies [43e4484]
|
|
78
|
+
- Updated dependencies [43e4484]
|
|
79
|
+
- Updated dependencies [43e4484]
|
|
80
|
+
- @checkstack/healthcheck-common@1.16.0
|
|
81
|
+
- @checkstack/backend-api@0.31.1
|
|
82
|
+
|
|
3
83
|
## 0.4.3
|
|
4
84
|
|
|
5
85
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-ssh-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"pack": "bunx @checkstack/scripts plugin-pack"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@checkstack/backend-api": "0.
|
|
20
|
+
"@checkstack/backend-api": "0.32.0",
|
|
21
21
|
"@checkstack/common": "0.22.0",
|
|
22
|
-
"@checkstack/healthcheck-common": "1.
|
|
22
|
+
"@checkstack/healthcheck-common": "1.16.1",
|
|
23
23
|
"@checkstack/healthcheck-ssh-common": "0.1.29",
|
|
24
24
|
"ssh2": "1.16.0"
|
|
25
25
|
},
|
|
@@ -67,6 +67,26 @@ describe("CommandCollector", () => {
|
|
|
67
67
|
|
|
68
68
|
expect(client.exec).toHaveBeenCalledWith("ls -la /tmp");
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
// A templatable `command` that renders to empty (e.g. `{{ environment.command }}`
|
|
72
|
+
// with no environment) is a config error - it must fail as a transport
|
|
73
|
+
// failure (populated `error`) and never invoke the client.
|
|
74
|
+
it("should return a transport error when the rendered command is empty", async () => {
|
|
75
|
+
const collector = new CommandCollector();
|
|
76
|
+
const client = createMockClient();
|
|
77
|
+
|
|
78
|
+
const result = await collector.execute({
|
|
79
|
+
config: { command: " " },
|
|
80
|
+
client,
|
|
81
|
+
pluginId: "test",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result.error).toMatch(/Rendered command is empty/);
|
|
85
|
+
expect(result.result.exitCode).toBe(0);
|
|
86
|
+
expect(result.result.stdout).toBe("");
|
|
87
|
+
expect(result.result.stderr).toBe("");
|
|
88
|
+
expect(client.exec).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
70
90
|
});
|
|
71
91
|
|
|
72
92
|
describe("mergeResult", () => {
|
package/src/command-collector.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Versioned,
|
|
3
3
|
z,
|
|
4
|
+
configString,
|
|
4
5
|
type HealthCheckRunForAggregation,
|
|
5
6
|
type CollectorResult,
|
|
6
7
|
type CollectorStrategy,
|
|
@@ -24,11 +25,27 @@ import type { SshTransportClient } from "@checkstack/healthcheck-ssh-common";
|
|
|
24
25
|
// ============================================================================
|
|
25
26
|
|
|
26
27
|
const commandConfigSchema = z.object({
|
|
27
|
-
|
|
28
|
+
// Templatable: supports `{{ environment.command }}` so one config covers N
|
|
29
|
+
// environments. `.min(1)` still guards the STORED value (a `{{ }}` template is
|
|
30
|
+
// non-empty); the CONCRETE rendered command is re-checked POST-RENDER in
|
|
31
|
+
// `execute` because an empty render must not run as a successful command.
|
|
32
|
+
command: configString({ "x-templatable": true })
|
|
33
|
+
.min(1)
|
|
34
|
+
.describe(
|
|
35
|
+
"Shell command to execute. Supports templating, e.g. {{ environment.command }}",
|
|
36
|
+
),
|
|
28
37
|
});
|
|
29
38
|
|
|
30
39
|
export type CommandConfig = z.infer<typeof commandConfigSchema>;
|
|
31
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Post-render validator for the rendered `command`. An empty render (e.g. an
|
|
43
|
+
* env-less run resolving `{{ environment.command }}` to "") is a config error
|
|
44
|
+
* that prevents the probe - transport-failure semantics - not a healthy empty
|
|
45
|
+
* command.
|
|
46
|
+
*/
|
|
47
|
+
const renderedCommandSchema = z.string().trim().min(1);
|
|
48
|
+
|
|
32
49
|
// ============================================================================
|
|
33
50
|
// RESULT SCHEMAS
|
|
34
51
|
// ============================================================================
|
|
@@ -152,7 +169,27 @@ export class CommandCollector implements CollectorStrategy<
|
|
|
152
169
|
pluginId: string;
|
|
153
170
|
}): Promise<CollectorResult<CommandResult>> {
|
|
154
171
|
const startTime = Date.now();
|
|
155
|
-
|
|
172
|
+
|
|
173
|
+
// Post-render guard: `command` is a templatable string, so the concrete
|
|
174
|
+
// value is re-validated here after the executor rendered `{{ environment.* }}`.
|
|
175
|
+
// An empty render is a config error - fail as a transport failure rather
|
|
176
|
+
// than running (and "succeeding" at) an empty command.
|
|
177
|
+
const command = renderedCommandSchema.safeParse(config.command);
|
|
178
|
+
if (!command.success) {
|
|
179
|
+
return {
|
|
180
|
+
result: {
|
|
181
|
+
exitCode: 0,
|
|
182
|
+
stdout: "",
|
|
183
|
+
stderr: "",
|
|
184
|
+
executionTimeMs: Date.now() - startTime,
|
|
185
|
+
},
|
|
186
|
+
error:
|
|
187
|
+
`Rendered command is empty: ${JSON.stringify(config.command)}. ` +
|
|
188
|
+
`Check the {{ environment.* }} templating for this environment.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const result = await client.exec(command.data);
|
|
156
193
|
const executionTimeMs = Date.now() - startTime;
|
|
157
194
|
|
|
158
195
|
return {
|
package/src/strategy.test.ts
CHANGED
|
@@ -66,6 +66,63 @@ describe("SshHealthCheckStrategy", () => {
|
|
|
66
66
|
}),
|
|
67
67
|
).rejects.toThrow("Connection refused");
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
it("should connect with a concrete rendered host and username", async () => {
|
|
71
|
+
const mockClient = createMockClient();
|
|
72
|
+
const strategy = new SshHealthCheckStrategy(mockClient);
|
|
73
|
+
|
|
74
|
+
const connectedClient = await strategy.createClient({
|
|
75
|
+
host: "ssh.prod.example.com",
|
|
76
|
+
port: 22,
|
|
77
|
+
username: "deploy",
|
|
78
|
+
password: "secret",
|
|
79
|
+
timeout: 5000,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(mockClient.connect).toHaveBeenCalledWith(
|
|
83
|
+
expect.objectContaining({
|
|
84
|
+
host: "ssh.prod.example.com",
|
|
85
|
+
username: "deploy",
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
connectedClient.close();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// A required templatable field (host/username) that renders to empty (e.g.
|
|
93
|
+
// `{{ environment.host }}` with no environment) is a config error - it must
|
|
94
|
+
// fail as a transport failure, never attempt an empty connection.
|
|
95
|
+
it("should throw when the rendered host is empty", async () => {
|
|
96
|
+
const mockClient = createMockClient();
|
|
97
|
+
const strategy = new SshHealthCheckStrategy(mockClient);
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
strategy.createClient({
|
|
101
|
+
host: "",
|
|
102
|
+
port: 22,
|
|
103
|
+
username: "user",
|
|
104
|
+
password: "secret",
|
|
105
|
+
timeout: 5000,
|
|
106
|
+
}),
|
|
107
|
+
).rejects.toThrow(/Rendered SSH connection fields are empty/);
|
|
108
|
+
expect(mockClient.connect).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should throw when the rendered username is empty/whitespace", async () => {
|
|
112
|
+
const mockClient = createMockClient();
|
|
113
|
+
const strategy = new SshHealthCheckStrategy(mockClient);
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
strategy.createClient({
|
|
117
|
+
host: "localhost",
|
|
118
|
+
port: 22,
|
|
119
|
+
username: " ",
|
|
120
|
+
password: "secret",
|
|
121
|
+
timeout: 5000,
|
|
122
|
+
}),
|
|
123
|
+
).rejects.toThrow(/Rendered SSH connection fields are empty/);
|
|
124
|
+
expect(mockClient.connect).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
69
126
|
});
|
|
70
127
|
|
|
71
128
|
describe("client.exec", () => {
|
package/src/strategy.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
mergeCounter,
|
|
14
14
|
mergeMinMax,
|
|
15
15
|
z,
|
|
16
|
+
configString,
|
|
16
17
|
configSecret,
|
|
17
18
|
type ConnectedClient,
|
|
18
19
|
type TransportTimings,
|
|
@@ -36,9 +37,16 @@ import type { SshTransportClient, SshCommandResult } from "./transport-client";
|
|
|
36
37
|
* Configuration schema for SSH health checks.
|
|
37
38
|
*/
|
|
38
39
|
export const sshConfigSchema = baseStrategyConfigSchema.extend({
|
|
39
|
-
|
|
40
|
+
// Templatable connection fields: support `{{ environment.host }}` etc. so one
|
|
41
|
+
// config covers N environments. Presence is enforced POST-RENDER in
|
|
42
|
+
// `createClient`. The auth secrets stay `configSecret` (never templatable).
|
|
43
|
+
host: configString({ "x-templatable": true }).describe(
|
|
44
|
+
"SSH server hostname. Supports templating, e.g. {{ environment.host }}",
|
|
45
|
+
),
|
|
40
46
|
port: z.number().int().min(1).max(65_535).default(22).describe("SSH port"),
|
|
41
|
-
username:
|
|
47
|
+
username: configString({ "x-templatable": true }).describe(
|
|
48
|
+
"SSH username. Supports templating, e.g. {{ environment.user }}",
|
|
49
|
+
),
|
|
42
50
|
password: configSecret({ id: "password" })
|
|
43
51
|
.describe("Password for authentication")
|
|
44
52
|
.optional(),
|
|
@@ -53,6 +61,15 @@ export const sshConfigSchema = baseStrategyConfigSchema.extend({
|
|
|
53
61
|
export type SshConfig = z.infer<typeof sshConfigSchema>;
|
|
54
62
|
export type SshConfigInput = z.input<typeof sshConfigSchema>;
|
|
55
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Post-render validator for required connection fields. The stored values are
|
|
66
|
+
* plain templatable strings, so presence cannot be checked at store time; the
|
|
67
|
+
* executor renders `{{ environment.* }}` per environment, then this rejects a
|
|
68
|
+
* render that collapsed to empty/whitespace. An empty host/username is a config
|
|
69
|
+
* error that prevents the probe - transport-failure semantics.
|
|
70
|
+
*/
|
|
71
|
+
const renderedRequiredSchema = z.string().trim().min(1);
|
|
72
|
+
|
|
56
73
|
/**
|
|
57
74
|
* Per-run result metadata.
|
|
58
75
|
*/
|
|
@@ -293,14 +310,31 @@ export class SshHealthCheckStrategy implements HealthCheckStrategy<
|
|
|
293
310
|
): Promise<ConnectedClient<SshTransportClient>> {
|
|
294
311
|
const validatedConfig = this.config.validate(config);
|
|
295
312
|
|
|
313
|
+
// Post-render guard: `host`/`username` are templatable strings, so their
|
|
314
|
+
// presence cannot be checked at store time. The executor has already
|
|
315
|
+
// rendered `{{ environment.* }}`; reject a render that collapsed to empty so
|
|
316
|
+
// the run fails clearly instead of attempting an empty connection.
|
|
317
|
+
const rendered = z
|
|
318
|
+
.object({ host: renderedRequiredSchema, username: renderedRequiredSchema })
|
|
319
|
+
.safeParse({
|
|
320
|
+
host: validatedConfig.host,
|
|
321
|
+
username: validatedConfig.username,
|
|
322
|
+
});
|
|
323
|
+
if (!rendered.success) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Rendered SSH connection fields are empty (host/username). ` +
|
|
326
|
+
`Check the {{ environment.* }} templating for this environment.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
296
330
|
// The SSH handshake (TCP connect + auth + channel ready) is a single
|
|
297
331
|
// measurable phase up front; record it as connectMs. The per-command exec
|
|
298
332
|
// time is filled in below as processingMs (last-command-wins).
|
|
299
333
|
const connectStart = performance.now();
|
|
300
334
|
const connection = await this.sshClient.connect({
|
|
301
|
-
host:
|
|
335
|
+
host: rendered.data.host,
|
|
302
336
|
port: validatedConfig.port,
|
|
303
|
-
username:
|
|
337
|
+
username: rendered.data.username,
|
|
304
338
|
password: validatedConfig.password,
|
|
305
339
|
privateKey: validatedConfig.privateKey,
|
|
306
340
|
passphrase: validatedConfig.passphrase,
|