@checkstack/healthcheck-backend 1.10.2 → 1.11.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 +68 -0
- package/package.json +29 -29
- package/src/ai/healthcheck-propose.test.ts +89 -0
- package/src/ai/healthcheck-propose.ts +45 -11
- package/src/index.ts +1 -0
- package/src/router-create-and-assign.test.ts +220 -0
- package/src/router.ts +147 -29
- package/src/service.ts +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 1.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- defb97b: fix(healthcheck): emit a realtime signal on config/assignment changes
|
|
8
|
+
|
|
9
|
+
The health-check executor broadcasts run/status signals, but config and
|
|
10
|
+
assignment CRUD (create/update/delete/pause/resume, associate/disassociate,
|
|
11
|
+
create-and-assign) emitted nothing - so a check created or edited out-of-band
|
|
12
|
+
(the AI assistant, GitOps, another pod/user) did not appear in an open Health
|
|
13
|
+
Checks list until the first run fired a status signal, up to an interval later.
|
|
14
|
+
|
|
15
|
+
Add a `HEALTHCHECK_CONFIG_CHANGED` (`healthcheck.config.changed`) signal,
|
|
16
|
+
broadcast from every config/assignment mutation, so the frontend signal
|
|
17
|
+
auto-invalidator refreshes the `[[healthcheck]]` cache on every connected client
|
|
18
|
+
immediately.
|
|
19
|
+
|
|
20
|
+
- defb97b: feat(healthcheck): atomically create and assign a health check in one step
|
|
21
|
+
|
|
22
|
+
Add a `createAndAssign` RPC that creates a health-check configuration and
|
|
23
|
+
assigns it to a system in a single transaction, so the common "one system, one
|
|
24
|
+
check" case can never leave a dormant, unassigned check that runs nothing. When
|
|
25
|
+
the assignment is enabled it is scheduled immediately, exactly like
|
|
26
|
+
`associateSystem`.
|
|
27
|
+
|
|
28
|
+
The AI `healthcheck.propose` tool now prefers the HTTP strategy for a URL
|
|
29
|
+
(instead of authoring a script health check) and, when given `assignToSystemId`,
|
|
30
|
+
creates, assigns, and starts the check in the same approval.
|
|
31
|
+
|
|
32
|
+
Also fixes a latent bug where the `associateSystem` handler silently dropped the
|
|
33
|
+
per-assignment `notificationPolicy` before it reached the database.
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- Updated dependencies [defb97b]
|
|
38
|
+
- Updated dependencies [defb97b]
|
|
39
|
+
- Updated dependencies [defb97b]
|
|
40
|
+
- Updated dependencies [defb97b]
|
|
41
|
+
- Updated dependencies [defb97b]
|
|
42
|
+
- Updated dependencies [defb97b]
|
|
43
|
+
- Updated dependencies [defb97b]
|
|
44
|
+
- @checkstack/ai-backend@0.10.0
|
|
45
|
+
- @checkstack/catalog-backend@1.6.0
|
|
46
|
+
- @checkstack/catalog-common@2.5.0
|
|
47
|
+
- @checkstack/common@0.18.0
|
|
48
|
+
- @checkstack/healthcheck-common@1.9.0
|
|
49
|
+
- @checkstack/automation-backend@0.10.2
|
|
50
|
+
- @checkstack/incident-backend@1.8.6
|
|
51
|
+
- @checkstack/incident-common@1.6.4
|
|
52
|
+
- @checkstack/maintenance-common@1.7.4
|
|
53
|
+
- @checkstack/sdk@0.116.1
|
|
54
|
+
- @checkstack/ai-common@0.6.2
|
|
55
|
+
- @checkstack/backend-api@0.26.1
|
|
56
|
+
- @checkstack/cache-api@0.3.15
|
|
57
|
+
- @checkstack/command-backend@0.2.14
|
|
58
|
+
- @checkstack/gitops-backend@0.5.14
|
|
59
|
+
- @checkstack/gitops-common@0.6.7
|
|
60
|
+
- @checkstack/notification-common@1.4.2
|
|
61
|
+
- @checkstack/queue-api@0.3.15
|
|
62
|
+
- @checkstack/satellite-backend@0.7.3
|
|
63
|
+
- @checkstack/script-packages-backend@0.3.18
|
|
64
|
+
- @checkstack/secrets-backend@0.2.14
|
|
65
|
+
- @checkstack/secrets-common@0.2.7
|
|
66
|
+
- @checkstack/signal-common@0.2.13
|
|
67
|
+
- @checkstack/status-page-backend@0.4.1
|
|
68
|
+
- @checkstack/status-page-common@0.4.1
|
|
69
|
+
- @checkstack/cache-utils@0.2.20
|
|
70
|
+
|
|
3
71
|
## 1.10.2
|
|
4
72
|
|
|
5
73
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,32 +14,32 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.26.
|
|
18
|
-
"@checkstack/ai-backend": "0.
|
|
19
|
-
"@checkstack/ai-common": "0.6.
|
|
20
|
-
"@checkstack/script-packages-backend": "0.3.
|
|
21
|
-
"@checkstack/cache-api": "0.3.
|
|
22
|
-
"@checkstack/cache-utils": "0.2.
|
|
23
|
-
"@checkstack/catalog-backend": "1.
|
|
24
|
-
"@checkstack/catalog-common": "2.
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
26
|
-
"@checkstack/common": "0.
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
28
|
-
"@checkstack/gitops-common": "0.6.
|
|
29
|
-
"@checkstack/healthcheck-common": "1.
|
|
30
|
-
"@checkstack/secrets-common": "0.2.
|
|
31
|
-
"@checkstack/secrets-backend": "0.2.
|
|
32
|
-
"@checkstack/incident-backend": "1.8.
|
|
33
|
-
"@checkstack/incident-common": "1.6.
|
|
34
|
-
"@checkstack/automation-backend": "0.10.
|
|
35
|
-
"@checkstack/maintenance-common": "1.7.
|
|
36
|
-
"@checkstack/notification-common": "1.4.
|
|
37
|
-
"@checkstack/queue-api": "0.3.
|
|
38
|
-
"@checkstack/satellite-backend": "0.7.
|
|
39
|
-
"@checkstack/sdk": "0.
|
|
40
|
-
"@checkstack/signal-common": "0.2.
|
|
41
|
-
"@checkstack/status-page-backend": "0.4.
|
|
42
|
-
"@checkstack/status-page-common": "0.4.
|
|
17
|
+
"@checkstack/backend-api": "0.26.1",
|
|
18
|
+
"@checkstack/ai-backend": "0.10.0",
|
|
19
|
+
"@checkstack/ai-common": "0.6.2",
|
|
20
|
+
"@checkstack/script-packages-backend": "0.3.18",
|
|
21
|
+
"@checkstack/cache-api": "0.3.15",
|
|
22
|
+
"@checkstack/cache-utils": "0.2.20",
|
|
23
|
+
"@checkstack/catalog-backend": "1.6.0",
|
|
24
|
+
"@checkstack/catalog-common": "2.5.0",
|
|
25
|
+
"@checkstack/command-backend": "0.2.14",
|
|
26
|
+
"@checkstack/common": "0.18.0",
|
|
27
|
+
"@checkstack/gitops-backend": "0.5.14",
|
|
28
|
+
"@checkstack/gitops-common": "0.6.7",
|
|
29
|
+
"@checkstack/healthcheck-common": "1.9.0",
|
|
30
|
+
"@checkstack/secrets-common": "0.2.7",
|
|
31
|
+
"@checkstack/secrets-backend": "0.2.14",
|
|
32
|
+
"@checkstack/incident-backend": "1.8.6",
|
|
33
|
+
"@checkstack/incident-common": "1.6.4",
|
|
34
|
+
"@checkstack/automation-backend": "0.10.2",
|
|
35
|
+
"@checkstack/maintenance-common": "1.7.4",
|
|
36
|
+
"@checkstack/notification-common": "1.4.2",
|
|
37
|
+
"@checkstack/queue-api": "0.3.15",
|
|
38
|
+
"@checkstack/satellite-backend": "0.7.3",
|
|
39
|
+
"@checkstack/sdk": "0.116.1",
|
|
40
|
+
"@checkstack/signal-common": "0.2.13",
|
|
41
|
+
"@checkstack/status-page-backend": "0.4.1",
|
|
42
|
+
"@checkstack/status-page-common": "0.4.1",
|
|
43
43
|
"@hono/zod-validator": "^0.7.6",
|
|
44
44
|
"drizzle-orm": "^0.45.0",
|
|
45
45
|
"hono": "^4.12.25",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
54
|
-
"@checkstack/scripts": "0.6.
|
|
55
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
54
|
+
"@checkstack/scripts": "0.6.5",
|
|
55
|
+
"@checkstack/test-utils-backend": "0.1.48",
|
|
56
56
|
"@checkstack/tsconfig": "0.0.7",
|
|
57
57
|
"@types/bun": "^1.0.0",
|
|
58
58
|
"@types/tdigest": "^0.1.5",
|
|
@@ -43,11 +43,13 @@ function fakeRpcClient({
|
|
|
43
43
|
getStrategies,
|
|
44
44
|
getCollectors,
|
|
45
45
|
createConfiguration,
|
|
46
|
+
createAndAssign,
|
|
46
47
|
validateConfiguration,
|
|
47
48
|
}: {
|
|
48
49
|
getStrategies: ReturnType<typeof mock>;
|
|
49
50
|
getCollectors: ReturnType<typeof mock>;
|
|
50
51
|
createConfiguration: ReturnType<typeof mock>;
|
|
52
|
+
createAndAssign?: ReturnType<typeof mock>;
|
|
51
53
|
validateConfiguration?: ReturnType<typeof mock>;
|
|
52
54
|
}): RpcClient {
|
|
53
55
|
return {
|
|
@@ -55,6 +57,7 @@ function fakeRpcClient({
|
|
|
55
57
|
getStrategies,
|
|
56
58
|
getCollectors,
|
|
57
59
|
createConfiguration,
|
|
60
|
+
createAndAssign: createAndAssign ?? mock(() => Promise.resolve({})),
|
|
58
61
|
validateConfiguration: validateConfiguration ?? okValidate(),
|
|
59
62
|
}),
|
|
60
63
|
} as unknown as RpcClient;
|
|
@@ -265,4 +268,90 @@ describe("healthcheck.propose composite tool", () => {
|
|
|
265
268
|
expect(createConfiguration).toHaveBeenCalledTimes(1);
|
|
266
269
|
expect(result.configuration.id).toBe("hc1");
|
|
267
270
|
});
|
|
271
|
+
|
|
272
|
+
test("description steers the model to HTTP, not script", () => {
|
|
273
|
+
const tool = createHealthcheckProposeTool();
|
|
274
|
+
// The model picked a script for a plain URL; the description must now make
|
|
275
|
+
// HTTP the default and call out the assign-in-one-step flow.
|
|
276
|
+
expect(tool.description).toContain("PREFER THE HTTP STRATEGY");
|
|
277
|
+
expect(tool.description).toContain("healthcheck-http.request");
|
|
278
|
+
expect(tool.description).toContain("assignToSystemId");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("dryRun without a target system warns the check will not run yet", async () => {
|
|
282
|
+
const rpcClient = fakeRpcClient({
|
|
283
|
+
getStrategies: mock(() => Promise.resolve([httpStrategy])),
|
|
284
|
+
getCollectors: mock(() => Promise.resolve([])),
|
|
285
|
+
createConfiguration: mock(),
|
|
286
|
+
});
|
|
287
|
+
const tool = createHealthcheckProposeTool();
|
|
288
|
+
|
|
289
|
+
const preview = await tool.dryRun!({ input: validInput, principal, rpcClient });
|
|
290
|
+
expect(preview.summary).toContain("NOT assigned");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("dryRun with a target system says it will assign + start the check", async () => {
|
|
294
|
+
const rpcClient = fakeRpcClient({
|
|
295
|
+
getStrategies: mock(() => Promise.resolve([httpStrategy])),
|
|
296
|
+
getCollectors: mock(() => Promise.resolve([])),
|
|
297
|
+
createConfiguration: mock(),
|
|
298
|
+
});
|
|
299
|
+
const tool = createHealthcheckProposeTool();
|
|
300
|
+
|
|
301
|
+
const preview = await tool.dryRun!({
|
|
302
|
+
input: { ...validInput, assignToSystemId: "sys1" },
|
|
303
|
+
principal,
|
|
304
|
+
rpcClient,
|
|
305
|
+
});
|
|
306
|
+
expect(preview.summary).toMatch(/assign/i);
|
|
307
|
+
expect(preview.summary).not.toContain("NOT assigned");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("execute (apply) with assignToSystemId atomically creates AND assigns", async () => {
|
|
311
|
+
const created = {
|
|
312
|
+
id: "hc2",
|
|
313
|
+
name: "Probe foo status",
|
|
314
|
+
strategyId: "healthcheck-http.http",
|
|
315
|
+
config: { url: "https://foo.bar/status" },
|
|
316
|
+
intervalSeconds: 60,
|
|
317
|
+
paused: false,
|
|
318
|
+
createdAt: new Date(),
|
|
319
|
+
updatedAt: new Date(),
|
|
320
|
+
};
|
|
321
|
+
let captured: {
|
|
322
|
+
systemId?: string;
|
|
323
|
+
configuration?: { name?: string; strategyId?: string };
|
|
324
|
+
} = {};
|
|
325
|
+
const createAndAssign = mock(
|
|
326
|
+
(arg: {
|
|
327
|
+
systemId: string;
|
|
328
|
+
configuration: { name: string; strategyId: string };
|
|
329
|
+
}) => {
|
|
330
|
+
captured = arg;
|
|
331
|
+
return Promise.resolve(created);
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
const createConfiguration = mock(() => Promise.resolve(created));
|
|
335
|
+
const rpcClient = fakeRpcClient({
|
|
336
|
+
getStrategies: mock(() => Promise.resolve([httpStrategy])),
|
|
337
|
+
getCollectors: mock(() => Promise.resolve([])),
|
|
338
|
+
createConfiguration,
|
|
339
|
+
createAndAssign,
|
|
340
|
+
});
|
|
341
|
+
const tool = createHealthcheckProposeTool();
|
|
342
|
+
|
|
343
|
+
const result = await tool.execute({
|
|
344
|
+
input: { ...validInput, assignToSystemId: "sys1" },
|
|
345
|
+
principal,
|
|
346
|
+
rpcClient,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// The atomic create+assign path is used; the create-only path is NOT.
|
|
350
|
+
expect(createAndAssign).toHaveBeenCalledTimes(1);
|
|
351
|
+
expect(createConfiguration).not.toHaveBeenCalled();
|
|
352
|
+
expect(captured.systemId).toBe("sys1");
|
|
353
|
+
expect(captured.configuration?.name).toBe("Probe foo status");
|
|
354
|
+
expect(captured.configuration?.strategyId).toBe("healthcheck-http.http");
|
|
355
|
+
expect(result.configuration.id).toBe("hc2");
|
|
356
|
+
});
|
|
268
357
|
});
|
|
@@ -27,7 +27,22 @@ import { validateCollectorAssertions } from "./assertion-validation";
|
|
|
27
27
|
* create skeleton (name, strategyId, config, intervalSeconds, collectors).
|
|
28
28
|
*/
|
|
29
29
|
export const HealthcheckProposeInputSchema =
|
|
30
|
-
CreateHealthCheckConfigurationSchema
|
|
30
|
+
CreateHealthCheckConfigurationSchema.extend({
|
|
31
|
+
/**
|
|
32
|
+
* Optional: the id of a system to atomically assign + start the check on in
|
|
33
|
+
* the SAME step (resolve a system name to its id with `catalog.listSystems`
|
|
34
|
+
* first). When set, the check is created, assigned, and starts running
|
|
35
|
+
* immediately. When omitted, the check is created but stays dormant until
|
|
36
|
+
* the operator assigns it to a system.
|
|
37
|
+
*/
|
|
38
|
+
assignToSystemId: z.string().optional(),
|
|
39
|
+
/**
|
|
40
|
+
* Optional per-assignment environment selector, only meaningful with
|
|
41
|
+
* `assignToSystemId`. Omit (or null) to run once per environment the system
|
|
42
|
+
* belongs to (the default fan-out) - this is almost always what you want.
|
|
43
|
+
*/
|
|
44
|
+
environmentIds: z.array(z.string()).nullable().optional(),
|
|
45
|
+
});
|
|
31
46
|
|
|
32
47
|
export type HealthcheckProposeInput = z.infer<
|
|
33
48
|
typeof HealthcheckProposeInputSchema
|
|
@@ -67,12 +82,13 @@ function formatIssues(
|
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/**
|
|
70
|
-
* Appended to
|
|
71
|
-
*
|
|
72
|
-
*
|
|
85
|
+
* Appended to a health-check propose summary (and echoed in the tool
|
|
86
|
+
* description) when the draft is NOT being assigned: a health check only runs
|
|
87
|
+
* once it is assigned to a system. The model should prefer to assign in the
|
|
88
|
+
* same step by passing `assignToSystemId`.
|
|
73
89
|
*/
|
|
74
|
-
const
|
|
75
|
-
"
|
|
90
|
+
const UNASSIGNED_GUIDANCE =
|
|
91
|
+
"This check is NOT assigned to a system yet, so it will not run. Prefer to assign it in the same step: resolve the target system's id with catalog.listSystems and pass it as assignToSystemId so the check is created, assigned, and started immediately. Otherwise, tell the operator to assign it to a system (Health Checks -> the check -> assign to a system) for it to start running.";
|
|
76
92
|
|
|
77
93
|
/**
|
|
78
94
|
* Validate a drafted health-check configuration via the health-check plugin's
|
|
@@ -248,7 +264,10 @@ export function createHealthcheckProposeTool(): RegisteredAiTool<
|
|
|
248
264
|
scriptCollectors.length > 0
|
|
249
265
|
? ` (includes ${scriptCollectors.length} script collector${scriptCollectors.length === 1 ? "" : "s"})`
|
|
250
266
|
: "";
|
|
251
|
-
const
|
|
267
|
+
const assignmentNote = input.assignToSystemId
|
|
268
|
+
? " Then assign it to the selected system and start it immediately - it runs once per environment the system belongs to."
|
|
269
|
+
: ` ${UNASSIGNED_GUIDANCE}`;
|
|
270
|
+
const summary = `Create health check "${input.name}" using strategy "${strategy.displayName}" with ${collectorCount} collector(s), running every ${input.intervalSeconds}s${scriptNote}.${assignmentNote}`;
|
|
252
271
|
|
|
253
272
|
return {
|
|
254
273
|
summary,
|
|
@@ -261,7 +280,7 @@ export function createHealthcheckProposeTool(): RegisteredAiTool<
|
|
|
261
280
|
return {
|
|
262
281
|
name: "healthcheck.propose",
|
|
263
282
|
description:
|
|
264
|
-
|
|
283
|
+
'Validate a drafted health check (strategy, collectors, interval, and any inline script source) and return it for a human to review and apply. Never creates a health check directly - a person must approve the proposal. PREFER THE HTTP STRATEGY for anything reachable over HTTP(S): for a plain URL, use strategyId "http" with the "healthcheck-http.request" collector (config { url }) and assert on statusCode - do NOT author a SCRIPT health check unless the user explicitly asks for a script or no built-in strategy fits the target. To make the check actually run, pass assignToSystemId so it is created AND assigned in one step (resolve the system\'s id with catalog.listSystems first; create the system with catalog.createSystem if it does not exist). Most systems have exactly one check. If you do not know what an endpoint returns, call probeUrl first to inspect its status code and body, then assert on the real response. For a script health check, test it with testScript first. Use getCapabilitySchema to get exact collector config fields AND the assertable result fields + valid operators before drafting assertions (assertion field must be a result-schema field like statusCode, operator must be a full word like equals/greaterThan, never an abbreviation).',
|
|
265
284
|
effect: "mutate",
|
|
266
285
|
input: HealthcheckProposeInputSchema,
|
|
267
286
|
requiredAccessRules: [
|
|
@@ -273,17 +292,32 @@ export function createHealthcheckProposeTool(): RegisteredAiTool<
|
|
|
273
292
|
dryRun,
|
|
274
293
|
async execute({ input, rpcClient }) {
|
|
275
294
|
// Only reached via `apply` (the propose/apply token gate). The create
|
|
276
|
-
// handler runs its own zod + registry validation;
|
|
295
|
+
// handler runs its own zod + registry validation; re-validating the
|
|
277
296
|
// server-stored payload against the input schema is already done by the
|
|
278
297
|
// propose/apply service before we get here.
|
|
279
298
|
const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
|
|
280
|
-
const
|
|
299
|
+
const configurationInput = {
|
|
281
300
|
name: input.name,
|
|
282
301
|
strategyId: input.strategyId,
|
|
283
302
|
config: input.config,
|
|
284
303
|
intervalSeconds: input.intervalSeconds,
|
|
285
304
|
collectors: input.collectors,
|
|
286
|
-
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// When a target system is named, atomically create AND assign so the
|
|
308
|
+
// check starts running immediately - no dormant, unassigned check. The
|
|
309
|
+
// user-scoped client enforces `catalog.system` manage on that system.
|
|
310
|
+
if (input.assignToSystemId) {
|
|
311
|
+
const configuration = await healthcheckClient.createAndAssign({
|
|
312
|
+
configuration: configurationInput,
|
|
313
|
+
systemId: input.assignToSystemId,
|
|
314
|
+
environmentIds: input.environmentIds,
|
|
315
|
+
});
|
|
316
|
+
return { configuration };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const configuration =
|
|
320
|
+
await healthcheckClient.createConfiguration(configurationInput);
|
|
287
321
|
return { configuration };
|
|
288
322
|
},
|
|
289
323
|
};
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { createHealthCheckRouter } from "./router";
|
|
3
|
+
import { createMockRpcContext } from "@checkstack/backend-api";
|
|
4
|
+
import { call } from "@orpc/server";
|
|
5
|
+
import type { HealthCheckCache } from "./cache";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Router-level tests for the atomic create+assign path and the
|
|
9
|
+
* `notificationPolicy` forwarding regression. They use a capturing DB so the
|
|
10
|
+
* exact insert values reaching the database can be asserted (the bug being
|
|
11
|
+
* guarded was the `associateSystem` handler silently dropping
|
|
12
|
+
* `input.body.notificationPolicy`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const passthroughCache: HealthCheckCache = {
|
|
16
|
+
wrapSystemHealthStatus: (_systemId, loader) => loader(),
|
|
17
|
+
invalidateSystem: async () => {},
|
|
18
|
+
invalidateAllSystems: async () => 0,
|
|
19
|
+
scope: {} as HealthCheckCache["scope"],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const mockUser = {
|
|
23
|
+
type: "user" as const,
|
|
24
|
+
id: "test-user",
|
|
25
|
+
accessRules: ["*"],
|
|
26
|
+
roles: ["admin"],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface CapturedInsert {
|
|
30
|
+
values: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A DB that records every `.values(...)` payload. Supports the chains the
|
|
35
|
+
* service uses: `insert().values().returning()`,
|
|
36
|
+
* `insert().values().onConflictDoUpdate()`, a bare awaited
|
|
37
|
+
* `insert().values()`, and `transaction(fn)` (createAndAssign).
|
|
38
|
+
*/
|
|
39
|
+
function createCapturingDb(captured: CapturedInsert[]) {
|
|
40
|
+
const insert = () => ({
|
|
41
|
+
values: (values: Record<string, unknown>) => {
|
|
42
|
+
captured.push({ values });
|
|
43
|
+
return Object.assign(Promise.resolve(undefined), {
|
|
44
|
+
onConflictDoUpdate: () => Promise.resolve(undefined),
|
|
45
|
+
onConflictDoNothing: () => Promise.resolve(undefined),
|
|
46
|
+
returning: () =>
|
|
47
|
+
Promise.resolve([
|
|
48
|
+
{
|
|
49
|
+
id: "cfg-new",
|
|
50
|
+
paused: false,
|
|
51
|
+
createdAt: new Date(),
|
|
52
|
+
updatedAt: new Date(),
|
|
53
|
+
collectors: null,
|
|
54
|
+
...values,
|
|
55
|
+
},
|
|
56
|
+
]),
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const emptyWhere = Object.assign(Promise.resolve([]), {
|
|
61
|
+
where: () => Promise.resolve([]),
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
insert,
|
|
65
|
+
select: () => ({ from: () => emptyWhere }),
|
|
66
|
+
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
|
67
|
+
fn({ insert }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SignalBroadcast {
|
|
72
|
+
signalId: string;
|
|
73
|
+
payload: Record<string, unknown>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildRouter(
|
|
77
|
+
captured: CapturedInsert[],
|
|
78
|
+
broadcasts?: SignalBroadcast[],
|
|
79
|
+
) {
|
|
80
|
+
const signalService = broadcasts
|
|
81
|
+
? ({
|
|
82
|
+
broadcast: (
|
|
83
|
+
signal: { id: string },
|
|
84
|
+
payload: Record<string, unknown>,
|
|
85
|
+
) => {
|
|
86
|
+
broadcasts.push({ signalId: signal.id, payload });
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
},
|
|
89
|
+
} as never)
|
|
90
|
+
: undefined;
|
|
91
|
+
return createHealthCheckRouter({
|
|
92
|
+
database: createCapturingDb(captured) as never,
|
|
93
|
+
registry: { getStrategy: mock(() => undefined) } as never,
|
|
94
|
+
collectorRegistry: { getCollector: mock(() => undefined) } as never,
|
|
95
|
+
gitOpsClient: {
|
|
96
|
+
getProvenance: mock(() => Promise.resolve(null)),
|
|
97
|
+
} as never,
|
|
98
|
+
signalService,
|
|
99
|
+
getEmitHook: () => undefined,
|
|
100
|
+
cache: passthroughCache,
|
|
101
|
+
configService: {
|
|
102
|
+
get: mock(async () => undefined),
|
|
103
|
+
set: mock(async () => {}),
|
|
104
|
+
} as never,
|
|
105
|
+
catalogClient: { getSystem: mock(async () => null) } as never,
|
|
106
|
+
maintenanceClient: {
|
|
107
|
+
hasActiveMaintenance: mock(async () => ({ active: false })),
|
|
108
|
+
} as never,
|
|
109
|
+
logger: {
|
|
110
|
+
debug: mock(() => {}),
|
|
111
|
+
info: mock(() => {}),
|
|
112
|
+
warn: mock(() => {}),
|
|
113
|
+
error: mock(() => {}),
|
|
114
|
+
} as never,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const policy = { suppressDeEscalations: true };
|
|
119
|
+
const CONFIG_ID = "12345678-1234-4234-8234-123456789012";
|
|
120
|
+
|
|
121
|
+
describe("associateSystem notificationPolicy forwarding (regression)", () => {
|
|
122
|
+
it("passes the per-assignment notificationPolicy through to the DB write", async () => {
|
|
123
|
+
const captured: CapturedInsert[] = [];
|
|
124
|
+
const router = buildRouter(captured);
|
|
125
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
126
|
+
|
|
127
|
+
await call(
|
|
128
|
+
router.associateSystem,
|
|
129
|
+
{
|
|
130
|
+
systemId: "sys-1",
|
|
131
|
+
body: {
|
|
132
|
+
configurationId: CONFIG_ID,
|
|
133
|
+
enabled: false, // skip scheduling; we only assert the persisted row
|
|
134
|
+
includeLocal: true,
|
|
135
|
+
notificationPolicy: policy,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{ context },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(captured).toHaveLength(1);
|
|
142
|
+
expect(captured[0].values.notificationPolicy).toEqual(policy);
|
|
143
|
+
expect(captured[0].values.systemId).toBe("sys-1");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("createAndAssign router handler", () => {
|
|
148
|
+
it("atomically writes the config AND the assignment, forwarding policy + env fan-out", async () => {
|
|
149
|
+
const captured: CapturedInsert[] = [];
|
|
150
|
+
const router = buildRouter(captured);
|
|
151
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
152
|
+
|
|
153
|
+
const result = await call(
|
|
154
|
+
router.createAndAssign,
|
|
155
|
+
{
|
|
156
|
+
systemId: "sys-1",
|
|
157
|
+
configuration: {
|
|
158
|
+
name: "Payments API root",
|
|
159
|
+
strategyId: "healthcheck-http.http",
|
|
160
|
+
config: { url: "https://api.example.com/healthz" },
|
|
161
|
+
intervalSeconds: 60,
|
|
162
|
+
},
|
|
163
|
+
enabled: false,
|
|
164
|
+
includeLocal: true,
|
|
165
|
+
environmentIds: null,
|
|
166
|
+
notificationPolicy: policy,
|
|
167
|
+
},
|
|
168
|
+
{ context },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Two writes: the config row, then the assignment row.
|
|
172
|
+
expect(captured).toHaveLength(2);
|
|
173
|
+
const configInsert = captured.find(
|
|
174
|
+
(c) => c.values.name === "Payments API root",
|
|
175
|
+
);
|
|
176
|
+
const assignmentInsert = captured.find(
|
|
177
|
+
(c) => c.values.configurationId !== undefined,
|
|
178
|
+
);
|
|
179
|
+
expect(configInsert).toBeDefined();
|
|
180
|
+
expect(assignmentInsert).toBeDefined();
|
|
181
|
+
expect(assignmentInsert?.values.systemId).toBe("sys-1");
|
|
182
|
+
expect(assignmentInsert?.values.notificationPolicy).toEqual(policy);
|
|
183
|
+
// null environmentIds = fan out to all of the system's environments.
|
|
184
|
+
expect(assignmentInsert?.values.environmentIds).toBeNull();
|
|
185
|
+
// Returns the created configuration.
|
|
186
|
+
expect(result.name).toBe("Payments API root");
|
|
187
|
+
expect(result.id).toBe("cfg-new");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("broadcasts healthcheck.config.changed so open clients refresh", async () => {
|
|
191
|
+
const captured: CapturedInsert[] = [];
|
|
192
|
+
const broadcasts: SignalBroadcast[] = [];
|
|
193
|
+
const router = buildRouter(captured, broadcasts);
|
|
194
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
195
|
+
|
|
196
|
+
await call(
|
|
197
|
+
router.createAndAssign,
|
|
198
|
+
{
|
|
199
|
+
systemId: "sys-1",
|
|
200
|
+
configuration: {
|
|
201
|
+
name: "Payments API root",
|
|
202
|
+
strategyId: "healthcheck-http.http",
|
|
203
|
+
config: { url: "https://api.example.com/healthz" },
|
|
204
|
+
intervalSeconds: 60,
|
|
205
|
+
},
|
|
206
|
+
enabled: false,
|
|
207
|
+
includeLocal: true,
|
|
208
|
+
environmentIds: null,
|
|
209
|
+
},
|
|
210
|
+
{ context },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const change = broadcasts.find(
|
|
214
|
+
(b) => b.signalId === "healthcheck.config.changed",
|
|
215
|
+
);
|
|
216
|
+
expect(change).toBeDefined();
|
|
217
|
+
expect(change?.payload.entity).toBe("assignment");
|
|
218
|
+
expect(change?.payload.systemId).toBe("sys-1");
|
|
219
|
+
});
|
|
220
|
+
});
|
package/src/router.ts
CHANGED
|
@@ -9,8 +9,12 @@ import {
|
|
|
9
9
|
type CollectorRegistry,
|
|
10
10
|
type ConfigService,
|
|
11
11
|
} from "@checkstack/backend-api";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
healthCheckContract,
|
|
14
|
+
HEALTHCHECK_CONFIG_CHANGED,
|
|
15
|
+
} from "@checkstack/healthcheck-common";
|
|
13
16
|
import type { StrategyCategory } from "@checkstack/healthcheck-common";
|
|
17
|
+
import { type SignalService } from "@checkstack/signal-common";
|
|
14
18
|
import {
|
|
15
19
|
resolveResolutionRootFromStore,
|
|
16
20
|
resolveScriptPackagesDir,
|
|
@@ -21,7 +25,7 @@ import { runCollectorScriptTest } from "./collector-script-test";
|
|
|
21
25
|
import { healthCheckHooks } from "./hooks";
|
|
22
26
|
import * as schema from "./schema";
|
|
23
27
|
import { toJsonSchemaWithChartMeta } from "./schema-utils";
|
|
24
|
-
import type
|
|
28
|
+
import { extractErrorMessage, type InferClient } from "@checkstack/common";
|
|
25
29
|
import { GitOpsApi } from "@checkstack/gitops-common";
|
|
26
30
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
27
31
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
@@ -45,6 +49,14 @@ export const createHealthCheckRouter = (opts: {
|
|
|
45
49
|
catalogClient: InferClient<typeof CatalogApi>;
|
|
46
50
|
maintenanceClient: InferClient<typeof MaintenanceApi>;
|
|
47
51
|
logger: Logger;
|
|
52
|
+
/**
|
|
53
|
+
* Broadcasts `healthcheck.config.changed` so every client's `[[healthcheck]]`
|
|
54
|
+
* cache refreshes after a config/assignment mutation - the only way an
|
|
55
|
+
* out-of-band write (AI assistant, GitOps, another pod/user) reaches an
|
|
56
|
+
* already-open Health Checks list before the first run fires a status signal.
|
|
57
|
+
* Optional so existing tests can omit it.
|
|
58
|
+
*/
|
|
59
|
+
signalService?: SignalService;
|
|
48
60
|
}) => {
|
|
49
61
|
const {
|
|
50
62
|
database,
|
|
@@ -56,6 +68,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
56
68
|
catalogClient,
|
|
57
69
|
maintenanceClient,
|
|
58
70
|
logger,
|
|
71
|
+
signalService,
|
|
59
72
|
} = opts;
|
|
60
73
|
// Create service instance once - shared across all handlers
|
|
61
74
|
const service = new HealthCheckService(
|
|
@@ -84,6 +97,77 @@ export const createHealthCheckRouter = (opts: {
|
|
|
84
97
|
}
|
|
85
98
|
};
|
|
86
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Fire the `healthcheck.config.changed` signal so every client's
|
|
102
|
+
* `[[healthcheck]]` cache refreshes after a config/assignment write (the
|
|
103
|
+
* executor's run/status signals only fire once a check actually runs).
|
|
104
|
+
* Best-effort: a signal failure must never fail the mutation.
|
|
105
|
+
*/
|
|
106
|
+
const broadcastConfigChanged = async (payload: {
|
|
107
|
+
entity: "configuration" | "assignment";
|
|
108
|
+
action: "created" | "updated" | "deleted";
|
|
109
|
+
configurationId?: string;
|
|
110
|
+
systemId?: string;
|
|
111
|
+
}) => {
|
|
112
|
+
try {
|
|
113
|
+
await signalService?.broadcast(HEALTHCHECK_CONFIG_CHANGED, payload);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.warn(
|
|
116
|
+
`Failed to broadcast healthcheck.config.changed signal: ${extractErrorMessage(error, "unknown")}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Post-assignment side effects shared by `associateSystem` and
|
|
123
|
+
* `createAndAssign`: invalidate the system cache, schedule the first run when
|
|
124
|
+
* the assignment is enabled, and emit the `assignmentChanged` hook. Kept in
|
|
125
|
+
* one place so both entry points stay in lock-step and an enabled assignment
|
|
126
|
+
* always starts running immediately.
|
|
127
|
+
*/
|
|
128
|
+
const scheduleAndNotifyAssignment = async (args: {
|
|
129
|
+
systemId: string;
|
|
130
|
+
configurationId: string;
|
|
131
|
+
enabled: boolean;
|
|
132
|
+
queueManager: RpcContext["queueManager"];
|
|
133
|
+
}) => {
|
|
134
|
+
await cache.invalidateSystem(args.systemId);
|
|
135
|
+
|
|
136
|
+
// If enabling the health check, schedule it immediately so it starts
|
|
137
|
+
// probing right away.
|
|
138
|
+
if (args.enabled) {
|
|
139
|
+
const config = await service.getConfiguration(args.configurationId);
|
|
140
|
+
if (config) {
|
|
141
|
+
const { scheduleHealthCheck } = await import("./queue-executor");
|
|
142
|
+
await scheduleHealthCheck({
|
|
143
|
+
queueManager: args.queueManager,
|
|
144
|
+
payload: {
|
|
145
|
+
configId: config.id,
|
|
146
|
+
systemId: args.systemId,
|
|
147
|
+
},
|
|
148
|
+
intervalSeconds: config.intervalSeconds,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Notify subscribers (e.g., satellite-backend) that assignments changed.
|
|
154
|
+
const emitHook = getEmitHook();
|
|
155
|
+
if (emitHook) {
|
|
156
|
+
await emitHook(healthCheckHooks.assignmentChanged, {
|
|
157
|
+
systemId: args.systemId,
|
|
158
|
+
configurationId: args.configurationId,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Refresh every client's Health Checks / assignment views.
|
|
163
|
+
await broadcastConfigChanged({
|
|
164
|
+
entity: "assignment",
|
|
165
|
+
action: "updated",
|
|
166
|
+
configurationId: args.configurationId,
|
|
167
|
+
systemId: args.systemId,
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
87
171
|
return os.router({
|
|
88
172
|
getStrategies: os.getStrategies.handler(async ({ context }) => {
|
|
89
173
|
return context.healthCheckRegistry.getStrategiesWithMeta().map((r) => ({
|
|
@@ -164,6 +248,11 @@ export const createHealthCheckRouter = (opts: {
|
|
|
164
248
|
// safe move is to drop every per-system status cache so the next read
|
|
165
249
|
// recomputes from fresh DB state.
|
|
166
250
|
await cache.invalidateAllSystems();
|
|
251
|
+
await broadcastConfigChanged({
|
|
252
|
+
entity: "configuration",
|
|
253
|
+
action: "created",
|
|
254
|
+
configurationId: created.id,
|
|
255
|
+
});
|
|
167
256
|
return created;
|
|
168
257
|
}),
|
|
169
258
|
|
|
@@ -195,6 +284,11 @@ export const createHealthCheckRouter = (opts: {
|
|
|
195
284
|
}
|
|
196
285
|
// Configuration update affects every system that has it associated.
|
|
197
286
|
await cache.invalidateAllSystems();
|
|
287
|
+
await broadcastConfigChanged({
|
|
288
|
+
entity: "configuration",
|
|
289
|
+
action: "updated",
|
|
290
|
+
configurationId: config.id,
|
|
291
|
+
});
|
|
198
292
|
return config;
|
|
199
293
|
}),
|
|
200
294
|
|
|
@@ -202,18 +296,33 @@ export const createHealthCheckRouter = (opts: {
|
|
|
202
296
|
await enforceNotGitOpsLocked("Healthcheck", input.id);
|
|
203
297
|
await service.deleteConfiguration(input.id);
|
|
204
298
|
await cache.invalidateAllSystems();
|
|
299
|
+
await broadcastConfigChanged({
|
|
300
|
+
entity: "configuration",
|
|
301
|
+
action: "deleted",
|
|
302
|
+
configurationId: input.id,
|
|
303
|
+
});
|
|
205
304
|
}),
|
|
206
305
|
|
|
207
306
|
pauseConfiguration: os.pauseConfiguration.handler(async ({ input }) => {
|
|
208
307
|
await enforceNotGitOpsLocked("Healthcheck", input.id);
|
|
209
308
|
await service.pauseConfiguration(input.id);
|
|
210
309
|
await cache.invalidateAllSystems();
|
|
310
|
+
await broadcastConfigChanged({
|
|
311
|
+
entity: "configuration",
|
|
312
|
+
action: "updated",
|
|
313
|
+
configurationId: input.id,
|
|
314
|
+
});
|
|
211
315
|
}),
|
|
212
316
|
|
|
213
317
|
resumeConfiguration: os.resumeConfiguration.handler(async ({ input }) => {
|
|
214
318
|
await enforceNotGitOpsLocked("Healthcheck", input.id);
|
|
215
319
|
await service.resumeConfiguration(input.id);
|
|
216
320
|
await cache.invalidateAllSystems();
|
|
321
|
+
await broadcastConfigChanged({
|
|
322
|
+
entity: "configuration",
|
|
323
|
+
action: "updated",
|
|
324
|
+
configurationId: input.id,
|
|
325
|
+
});
|
|
217
326
|
}),
|
|
218
327
|
|
|
219
328
|
getSystemConfigurations: os.getSystemConfigurations.handler(
|
|
@@ -238,35 +347,37 @@ export const createHealthCheckRouter = (opts: {
|
|
|
238
347
|
satelliteIds: input.body.satelliteIds,
|
|
239
348
|
environmentIds: input.body.environmentIds,
|
|
240
349
|
includeLocal: input.body.includeLocal,
|
|
350
|
+
notificationPolicy: input.body.notificationPolicy,
|
|
241
351
|
});
|
|
242
|
-
await
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (config) {
|
|
250
|
-
const { scheduleHealthCheck } = await import("./queue-executor");
|
|
251
|
-
await scheduleHealthCheck({
|
|
252
|
-
queueManager: context.queueManager,
|
|
253
|
-
payload: {
|
|
254
|
-
configId: config.id,
|
|
255
|
-
systemId: input.systemId,
|
|
256
|
-
},
|
|
257
|
-
intervalSeconds: config.intervalSeconds,
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
}
|
|
352
|
+
await scheduleAndNotifyAssignment({
|
|
353
|
+
systemId: input.systemId,
|
|
354
|
+
configurationId: input.body.configurationId,
|
|
355
|
+
enabled: input.body.enabled,
|
|
356
|
+
queueManager: context.queueManager,
|
|
357
|
+
});
|
|
358
|
+
}),
|
|
261
359
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
360
|
+
createAndAssign: os.createAndAssign.handler(async ({ input, context }) => {
|
|
361
|
+
await enforceNotGitOpsLocked("System", input.systemId);
|
|
362
|
+
// Atomic create + assign in one transaction so the common 1-1 case can
|
|
363
|
+
// never leave a dormant, unassigned check.
|
|
364
|
+
const configuration = await service.createAndAssign({
|
|
365
|
+
configuration: input.configuration,
|
|
366
|
+
systemId: input.systemId,
|
|
367
|
+
enabled: input.enabled,
|
|
368
|
+
stateThresholds: input.stateThresholds,
|
|
369
|
+
satelliteIds: input.satelliteIds,
|
|
370
|
+
environmentIds: input.environmentIds,
|
|
371
|
+
includeLocal: input.includeLocal,
|
|
372
|
+
notificationPolicy: input.notificationPolicy,
|
|
373
|
+
});
|
|
374
|
+
await scheduleAndNotifyAssignment({
|
|
375
|
+
systemId: input.systemId,
|
|
376
|
+
configurationId: configuration.id,
|
|
377
|
+
enabled: input.enabled,
|
|
378
|
+
queueManager: context.queueManager,
|
|
379
|
+
});
|
|
380
|
+
return configuration;
|
|
270
381
|
}),
|
|
271
382
|
|
|
272
383
|
disassociateSystem: os.disassociateSystem.handler(async ({ input }) => {
|
|
@@ -282,6 +393,13 @@ export const createHealthCheckRouter = (opts: {
|
|
|
282
393
|
configurationId: input.configId,
|
|
283
394
|
});
|
|
284
395
|
}
|
|
396
|
+
|
|
397
|
+
await broadcastConfigChanged({
|
|
398
|
+
entity: "assignment",
|
|
399
|
+
action: "deleted",
|
|
400
|
+
configurationId: input.configId,
|
|
401
|
+
systemId: input.systemId,
|
|
402
|
+
});
|
|
285
403
|
}),
|
|
286
404
|
|
|
287
405
|
getPlatformNotificationDefaults:
|
package/src/service.ts
CHANGED
|
@@ -291,6 +291,74 @@ export class HealthCheckService {
|
|
|
291
291
|
});
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Atomically create a health-check configuration AND assign it to a system in
|
|
296
|
+
* a single transaction. Backs the `createAndAssign` RPC: the common 1-1
|
|
297
|
+
* onboarding case ("this system gets one check") can never leave a dormant,
|
|
298
|
+
* unassigned check because the config row and its `systemHealthChecks`
|
|
299
|
+
* assignment row commit together or not at all. Scheduling of the first run
|
|
300
|
+
* is the caller's (router's) responsibility, mirroring `associateSystem`.
|
|
301
|
+
*/
|
|
302
|
+
async createAndAssign(props: {
|
|
303
|
+
configuration: CreateHealthCheckConfiguration;
|
|
304
|
+
systemId: string;
|
|
305
|
+
enabled?: boolean;
|
|
306
|
+
stateThresholds?: StateThresholds;
|
|
307
|
+
satelliteIds?: string[];
|
|
308
|
+
/**
|
|
309
|
+
* Per-assignment environment selector. `null` (or `undefined`) = all
|
|
310
|
+
* current environments; `[]` = opt out (env-less); non-empty = those ids.
|
|
311
|
+
*/
|
|
312
|
+
environmentIds?: string[] | null;
|
|
313
|
+
includeLocal?: boolean;
|
|
314
|
+
notificationPolicy?: NotificationPolicy;
|
|
315
|
+
}): Promise<HealthCheckConfiguration> {
|
|
316
|
+
const {
|
|
317
|
+
configuration,
|
|
318
|
+
systemId,
|
|
319
|
+
enabled = true,
|
|
320
|
+
stateThresholds: stateThresholds_,
|
|
321
|
+
satelliteIds,
|
|
322
|
+
environmentIds,
|
|
323
|
+
includeLocal = true,
|
|
324
|
+
notificationPolicy,
|
|
325
|
+
} = props;
|
|
326
|
+
|
|
327
|
+
// Preserve the null/[]/list distinction faithfully (see associateSystem).
|
|
328
|
+
const environmentIdsValue: string[] | null = environmentIds ?? null;
|
|
329
|
+
const versionedThresholds: VersionedStateThresholds | undefined =
|
|
330
|
+
stateThresholds_ ? stateThresholds.create(stateThresholds_) : undefined;
|
|
331
|
+
|
|
332
|
+
const created = await this.db.transaction(async (tx) => {
|
|
333
|
+
const [config] = await tx
|
|
334
|
+
.insert(healthCheckConfigurations)
|
|
335
|
+
.values({
|
|
336
|
+
name: configuration.name,
|
|
337
|
+
strategyId: configuration.strategyId,
|
|
338
|
+
config: configuration.config,
|
|
339
|
+
collectors: configuration.collectors ?? undefined,
|
|
340
|
+
intervalSeconds: configuration.intervalSeconds,
|
|
341
|
+
isTemplate: false,
|
|
342
|
+
})
|
|
343
|
+
.returning();
|
|
344
|
+
|
|
345
|
+
await tx.insert(systemHealthChecks).values({
|
|
346
|
+
systemId,
|
|
347
|
+
configurationId: config.id,
|
|
348
|
+
enabled,
|
|
349
|
+
stateThresholds: versionedThresholds,
|
|
350
|
+
satelliteIds: satelliteIds ?? undefined,
|
|
351
|
+
environmentIds: environmentIdsValue,
|
|
352
|
+
includeLocal,
|
|
353
|
+
notificationPolicy: notificationPolicy ?? undefined,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return config;
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
return this.mapConfig(created);
|
|
360
|
+
}
|
|
361
|
+
|
|
294
362
|
/**
|
|
295
363
|
* Flip the `enabled` flag on an existing `systemHealthChecks` row
|
|
296
364
|
* without touching any of the other configuration (thresholds,
|