@checkstack/healthcheck-backend 1.2.0 → 1.3.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 +212 -0
- package/package.json +14 -14
- package/src/automations.test.ts +255 -0
- package/src/automations.ts +340 -0
- package/src/hooks.ts +69 -4
- package/src/index.ts +37 -52
- package/src/queue-executor.test.ts +137 -0
- package/src/queue-executor.ts +130 -27
- package/src/router.test.ts +5 -0
- package/src/router.ts +12 -1
- package/src/service-assignments.test.ts +184 -0
- package/src/service.ts +65 -0
- package/tsconfig.json +3 -3
|
@@ -72,6 +72,7 @@ const createMockCatalogClient = () => ({
|
|
|
72
72
|
// Other methods not used in queue-executor
|
|
73
73
|
getEntities: mock(async () => ({ systems: [], groups: [] })),
|
|
74
74
|
getSystems: mock(async () => ({ systems: [] })),
|
|
75
|
+
getSystem: mock(async () => null),
|
|
75
76
|
getGroups: mock(async () => []),
|
|
76
77
|
createSystem: mock(async () => ({})),
|
|
77
78
|
updateSystem: mock(async () => ({})),
|
|
@@ -415,4 +416,140 @@ describe("Queue-Based Health Check Executor", () => {
|
|
|
415
416
|
expect(mockSignalService.getRecordedSignals()).toHaveLength(0);
|
|
416
417
|
});
|
|
417
418
|
});
|
|
419
|
+
|
|
420
|
+
describe("executeHealthCheckJob - collector run-context", () => {
|
|
421
|
+
it("passes curated run-context to the collector (name falls back to id when configName is null)", async () => {
|
|
422
|
+
const mockDb = createMockDb();
|
|
423
|
+
const mockRegistry = createMockRegistry();
|
|
424
|
+
const mockLogger = createMockLogger();
|
|
425
|
+
const mockQueueManager = createMockQueueManager();
|
|
426
|
+
const mockCatalogClient = createMockCatalogClient();
|
|
427
|
+
const mockMaintenanceClient = createMockMaintenanceClient();
|
|
428
|
+
const mockIncidentClient = createMockIncidentClient();
|
|
429
|
+
const mockSignalService = createMockSignalService();
|
|
430
|
+
|
|
431
|
+
// Catalog resolves the system name.
|
|
432
|
+
(mockCatalogClient.getSystem as any) = mock(async () => ({
|
|
433
|
+
id: "system-1",
|
|
434
|
+
name: "web-01",
|
|
435
|
+
}));
|
|
436
|
+
|
|
437
|
+
// configName is null -> run-context check.name must fall back to id.
|
|
438
|
+
let selectCallCount = 0;
|
|
439
|
+
(mockDb.select as any) = mock(() => {
|
|
440
|
+
selectCallCount++;
|
|
441
|
+
if (selectCallCount === 2) {
|
|
442
|
+
return {
|
|
443
|
+
from: mock(() => ({
|
|
444
|
+
innerJoin: mock(() => ({
|
|
445
|
+
where: mock(() =>
|
|
446
|
+
Promise.resolve([
|
|
447
|
+
{
|
|
448
|
+
configId: "config-1",
|
|
449
|
+
configName: null,
|
|
450
|
+
strategyId: "test-strategy",
|
|
451
|
+
config: { timeout: 5000 },
|
|
452
|
+
collectors: [
|
|
453
|
+
{ id: "col-1", collectorId: "test-collector", config: {} },
|
|
454
|
+
],
|
|
455
|
+
interval: 45,
|
|
456
|
+
enabled: true,
|
|
457
|
+
paused: false,
|
|
458
|
+
includeLocal: true,
|
|
459
|
+
satelliteIds: [],
|
|
460
|
+
},
|
|
461
|
+
]),
|
|
462
|
+
),
|
|
463
|
+
})),
|
|
464
|
+
})),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
from: mock(() => ({
|
|
469
|
+
innerJoin: mock(() => ({
|
|
470
|
+
where: mock(() => Promise.resolve([])),
|
|
471
|
+
})),
|
|
472
|
+
})),
|
|
473
|
+
};
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Capture the run-context the collector receives.
|
|
477
|
+
let capturedRunContext: unknown;
|
|
478
|
+
const collectorExecute = mock(
|
|
479
|
+
async (params: { runContext?: unknown }) => {
|
|
480
|
+
capturedRunContext = params.runContext;
|
|
481
|
+
return { result: {} };
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
const mockCollectorRegistry = {
|
|
485
|
+
register: mock(() => {}),
|
|
486
|
+
getCollector: mock(() => ({
|
|
487
|
+
collector: {
|
|
488
|
+
id: "test-collector",
|
|
489
|
+
execute: collectorExecute,
|
|
490
|
+
mergeResult: mock(() => ({})),
|
|
491
|
+
},
|
|
492
|
+
})),
|
|
493
|
+
getCollectors: mock(() => []),
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const queue =
|
|
497
|
+
mockQueueManager.getQueue<HealthCheckJobPayload>("health-checks");
|
|
498
|
+
let capturedHandler:
|
|
499
|
+
| ((job: { data: HealthCheckJobPayload }) => Promise<void>)
|
|
500
|
+
| undefined;
|
|
501
|
+
(queue.consume as any) = mock(
|
|
502
|
+
async (
|
|
503
|
+
handler: (job: { data: HealthCheckJobPayload }) => Promise<void>,
|
|
504
|
+
) => {
|
|
505
|
+
capturedHandler = handler;
|
|
506
|
+
},
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
await setupHealthCheckWorker({
|
|
510
|
+
db: mockDb as unknown as Parameters<
|
|
511
|
+
typeof setupHealthCheckWorker
|
|
512
|
+
>[0]["db"],
|
|
513
|
+
registry: mockRegistry,
|
|
514
|
+
collectorRegistry: mockCollectorRegistry as unknown as Parameters<
|
|
515
|
+
typeof setupHealthCheckWorker
|
|
516
|
+
>[0]["collectorRegistry"],
|
|
517
|
+
logger: mockLogger,
|
|
518
|
+
queueManager: mockQueueManager,
|
|
519
|
+
signalService: mockSignalService,
|
|
520
|
+
catalogClient: mockCatalogClient as unknown as Parameters<
|
|
521
|
+
typeof setupHealthCheckWorker
|
|
522
|
+
>[0]["catalogClient"],
|
|
523
|
+
notificationClient: {
|
|
524
|
+
notifyForSubscription: () => Promise.resolve({ notifiedCount: 0 }),
|
|
525
|
+
} as unknown as Parameters<
|
|
526
|
+
typeof setupHealthCheckWorker
|
|
527
|
+
>[0]["notificationClient"],
|
|
528
|
+
maintenanceClient: mockMaintenanceClient as unknown as Parameters<
|
|
529
|
+
typeof setupHealthCheckWorker
|
|
530
|
+
>[0]["maintenanceClient"],
|
|
531
|
+
incidentClient: mockIncidentClient as unknown as Parameters<
|
|
532
|
+
typeof setupHealthCheckWorker
|
|
533
|
+
>[0]["incidentClient"],
|
|
534
|
+
getEmitHook: () => undefined,
|
|
535
|
+
cache: passthroughCache,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
if (capturedHandler) {
|
|
539
|
+
// The collector runs early in the execution sequence; downstream
|
|
540
|
+
// aggregation/persistence touches DB surfaces the lightweight mock
|
|
541
|
+
// doesn't model, so tolerate a later throw — the run-context we
|
|
542
|
+
// assert on is captured synchronously at collector-execute time.
|
|
543
|
+
await capturedHandler({
|
|
544
|
+
data: { configId: "config-1", systemId: "system-1" },
|
|
545
|
+
}).catch(() => {});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
expect(collectorExecute).toHaveBeenCalled();
|
|
549
|
+
expect(capturedRunContext).toEqual({
|
|
550
|
+
check: { id: "config-1", name: "config-1", intervalSeconds: 45 },
|
|
551
|
+
system: { id: "system-1", name: "web-01" },
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
});
|
|
418
555
|
});
|
package/src/queue-executor.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type BaseStrategyConfig,
|
|
9
9
|
type ConnectedClient,
|
|
10
10
|
type TransportClient,
|
|
11
|
+
type CollectorRunContext,
|
|
11
12
|
} from "@checkstack/backend-api";
|
|
12
13
|
import { QueueManager } from "@checkstack/queue-api";
|
|
13
14
|
import {
|
|
@@ -62,8 +63,13 @@ type IncidentClient = InferClient<typeof IncidentApi>;
|
|
|
62
63
|
type NotificationClient = InferClient<typeof NotificationApi>;
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* Emit the checkCompleted hook if available
|
|
66
|
-
*
|
|
66
|
+
* Emit the checkCompleted hook if available, plus the narrower
|
|
67
|
+
* `checkFailed` hook when the result wasn't `healthy` (so operators
|
|
68
|
+
* can wire a typed "trigger on failure" automation without having to
|
|
69
|
+
* filter `checkCompleted` themselves).
|
|
70
|
+
*
|
|
71
|
+
* Extracted to avoid duplicating the hook emission pattern across
|
|
72
|
+
* success/error paths.
|
|
67
73
|
*/
|
|
68
74
|
async function emitCheckCompletedHook({
|
|
69
75
|
getEmitHook,
|
|
@@ -81,14 +87,26 @@ async function emitCheckCompletedHook({
|
|
|
81
87
|
result: Record<string, unknown> | undefined;
|
|
82
88
|
}): Promise<void> {
|
|
83
89
|
const emitHook = getEmitHook();
|
|
84
|
-
if (emitHook)
|
|
85
|
-
|
|
90
|
+
if (!emitHook) return;
|
|
91
|
+
const timestamp = new Date().toISOString();
|
|
92
|
+
await emitHook(healthCheckHooks.checkCompleted, {
|
|
93
|
+
systemId,
|
|
94
|
+
configurationId,
|
|
95
|
+
status,
|
|
96
|
+
latencyMs,
|
|
97
|
+
result,
|
|
98
|
+
timestamp,
|
|
99
|
+
});
|
|
100
|
+
// Narrow follow-up — informational for automation triggers; the
|
|
101
|
+
// auto-incident pipeline still runs on its own thresholds.
|
|
102
|
+
if (status !== "healthy") {
|
|
103
|
+
await emitHook(healthCheckHooks.checkFailed, {
|
|
86
104
|
systemId,
|
|
87
105
|
configurationId,
|
|
88
106
|
status,
|
|
89
107
|
latencyMs,
|
|
90
108
|
result,
|
|
91
|
-
timestamp
|
|
109
|
+
timestamp,
|
|
92
110
|
});
|
|
93
111
|
}
|
|
94
112
|
}
|
|
@@ -102,9 +120,11 @@ export interface HealthCheckJobPayload {
|
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
/**
|
|
105
|
-
* Queue name for health check execution
|
|
123
|
+
* Queue name for health check execution. Exported so consumers like
|
|
124
|
+
* the `healthcheck.run_now` automation action can enqueue a one-off
|
|
125
|
+
* job without re-importing the recurring-job factory.
|
|
106
126
|
*/
|
|
107
|
-
const HEALTH_CHECK_QUEUE = "health-checks";
|
|
127
|
+
export const HEALTH_CHECK_QUEUE = "health-checks";
|
|
108
128
|
|
|
109
129
|
/**
|
|
110
130
|
* Worker group for health check execution (work-queue mode)
|
|
@@ -179,6 +199,14 @@ async function maybeOpenAutoIncidentForCheck(props: {
|
|
|
179
199
|
systemName: string;
|
|
180
200
|
configurationId: string;
|
|
181
201
|
configurationName: string;
|
|
202
|
+
/**
|
|
203
|
+
* Same closure-based getter the queue executor uses elsewhere; let
|
|
204
|
+
* us fire the `flapping_detected` automation hook from inside the
|
|
205
|
+
* flapping evaluator without re-threading `emitHook` through every
|
|
206
|
+
* intermediate caller. Optional — when absent, the hook simply
|
|
207
|
+
* doesn't fire (e.g. in unit tests that don't care about it).
|
|
208
|
+
*/
|
|
209
|
+
getEmitHook?: () => EmitHookFn | undefined;
|
|
182
210
|
previousState: {
|
|
183
211
|
checkStatuses: Array<{
|
|
184
212
|
configurationId: string;
|
|
@@ -202,6 +230,7 @@ async function maybeOpenAutoIncidentForCheck(props: {
|
|
|
202
230
|
systemName,
|
|
203
231
|
configurationId,
|
|
204
232
|
configurationName,
|
|
233
|
+
getEmitHook,
|
|
205
234
|
previousState,
|
|
206
235
|
newState,
|
|
207
236
|
} = props;
|
|
@@ -287,6 +316,33 @@ async function maybeOpenAutoIncidentForCheck(props: {
|
|
|
287
316
|
policy,
|
|
288
317
|
recentTransitionCount: count,
|
|
289
318
|
});
|
|
319
|
+
|
|
320
|
+
// Fire the informational `flapping_detected` automation hook
|
|
321
|
+
// independently of the auto-incident decision: an operator may
|
|
322
|
+
// care about flapping even with the auto-incident pipeline
|
|
323
|
+
// turned off.
|
|
324
|
+
if (
|
|
325
|
+
policy.flappingTrigger.enabled &&
|
|
326
|
+
count >= policy.flappingTrigger.transitions
|
|
327
|
+
) {
|
|
328
|
+
const emit = getEmitHook?.();
|
|
329
|
+
if (emit) {
|
|
330
|
+
try {
|
|
331
|
+
await emit(healthCheckHooks.flappingDetected, {
|
|
332
|
+
systemId,
|
|
333
|
+
configurationId,
|
|
334
|
+
transitionCount: count,
|
|
335
|
+
windowMinutes: policy.flappingTrigger.windowMinutes,
|
|
336
|
+
timestamp: new Date().toISOString(),
|
|
337
|
+
});
|
|
338
|
+
} catch (error) {
|
|
339
|
+
logger.warn(
|
|
340
|
+
`Failed to emit healthcheck.flapping_detected hook for ${systemId}/${configurationId}:`,
|
|
341
|
+
error,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
290
346
|
} catch (error) {
|
|
291
347
|
logger.warn(
|
|
292
348
|
`Failed to record unhealthy transition for ${systemId}/${configurationId}:`,
|
|
@@ -612,6 +668,17 @@ async function executeHealthCheckJob(props: {
|
|
|
612
668
|
logger.debug(`Could not fetch system name for ${systemId}, using ID`);
|
|
613
669
|
}
|
|
614
670
|
|
|
671
|
+
// Curated, read-only run-context metadata exposed to collectors.
|
|
672
|
+
// Metadata only - never secrets or config.
|
|
673
|
+
const runContext: CollectorRunContext = {
|
|
674
|
+
check: {
|
|
675
|
+
id: configId,
|
|
676
|
+
name: configRow.configName || configId,
|
|
677
|
+
intervalSeconds: configRow.interval,
|
|
678
|
+
},
|
|
679
|
+
system: { id: systemId, name: systemName },
|
|
680
|
+
};
|
|
681
|
+
|
|
615
682
|
const strategy = registry.getStrategy(configRow.strategyId);
|
|
616
683
|
if (!strategy) {
|
|
617
684
|
logger.warn(
|
|
@@ -662,6 +729,7 @@ async function executeHealthCheckJob(props: {
|
|
|
662
729
|
config: collectorEntry.config,
|
|
663
730
|
client: connectedClient!.client,
|
|
664
731
|
pluginId: configRow.strategyId,
|
|
732
|
+
runContext,
|
|
665
733
|
});
|
|
666
734
|
|
|
667
735
|
// Check for collector-level error
|
|
@@ -861,6 +929,7 @@ async function executeHealthCheckJob(props: {
|
|
|
861
929
|
systemName,
|
|
862
930
|
configurationId: configId,
|
|
863
931
|
configurationName: configRow.configName,
|
|
932
|
+
getEmitHook,
|
|
864
933
|
previousState,
|
|
865
934
|
newState,
|
|
866
935
|
});
|
|
@@ -971,16 +1040,20 @@ async function executeHealthCheckJob(props: {
|
|
|
971
1040
|
// Emit integration hooks for external integrations
|
|
972
1041
|
const emitHook = getEmitHook();
|
|
973
1042
|
if (emitHook) {
|
|
1043
|
+
const healthyChecks = newState.checkStatuses.filter(
|
|
1044
|
+
(c) => c.status === "healthy",
|
|
1045
|
+
).length;
|
|
1046
|
+
const totalChecks = newState.checkStatuses.length;
|
|
1047
|
+
const timestamp = new Date().toISOString();
|
|
1048
|
+
|
|
974
1049
|
if (newState.status === "healthy" && previousStatus !== "healthy") {
|
|
975
1050
|
// Recovery: system became healthy
|
|
976
1051
|
await emitHook(healthCheckHooks.systemHealthy, {
|
|
977
1052
|
systemId,
|
|
978
1053
|
previousStatus,
|
|
979
|
-
healthyChecks
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
totalChecks: newState.checkStatuses.length,
|
|
983
|
-
timestamp: new Date().toISOString(),
|
|
1054
|
+
healthyChecks,
|
|
1055
|
+
totalChecks,
|
|
1056
|
+
timestamp,
|
|
984
1057
|
});
|
|
985
1058
|
logger.debug(
|
|
986
1059
|
`Emitted systemHealthy hook: ${previousStatus} → ${newState.status}`,
|
|
@@ -994,16 +1067,28 @@ async function executeHealthCheckJob(props: {
|
|
|
994
1067
|
systemId,
|
|
995
1068
|
previousStatus,
|
|
996
1069
|
newStatus: newState.status,
|
|
997
|
-
healthyChecks
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
totalChecks: newState.checkStatuses.length,
|
|
1001
|
-
timestamp: new Date().toISOString(),
|
|
1070
|
+
healthyChecks,
|
|
1071
|
+
totalChecks,
|
|
1072
|
+
timestamp,
|
|
1002
1073
|
});
|
|
1003
1074
|
logger.debug(
|
|
1004
1075
|
`Emitted systemDegraded hook: ${previousStatus} → ${newState.status}`,
|
|
1005
1076
|
);
|
|
1006
1077
|
}
|
|
1078
|
+
|
|
1079
|
+
// Umbrella hook — fires on every transition. Emitted alongside
|
|
1080
|
+
// the directional hooks so existing subscribers stay unchanged
|
|
1081
|
+
// while new automation triggers can react to any change.
|
|
1082
|
+
if (previousStatus !== newState.status) {
|
|
1083
|
+
await emitHook(healthCheckHooks.systemHealthChanged, {
|
|
1084
|
+
systemId,
|
|
1085
|
+
previousStatus,
|
|
1086
|
+
newStatus: newState.status,
|
|
1087
|
+
healthyChecks,
|
|
1088
|
+
totalChecks,
|
|
1089
|
+
timestamp,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1007
1092
|
}
|
|
1008
1093
|
}
|
|
1009
1094
|
|
|
@@ -1018,6 +1103,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1018
1103
|
systemName,
|
|
1019
1104
|
configurationId: configId,
|
|
1020
1105
|
configurationName: configRow.configName,
|
|
1106
|
+
getEmitHook,
|
|
1021
1107
|
previousState,
|
|
1022
1108
|
newState,
|
|
1023
1109
|
});
|
|
@@ -1120,16 +1206,20 @@ async function executeHealthCheckJob(props: {
|
|
|
1120
1206
|
// Emit integration hooks for external integrations
|
|
1121
1207
|
const emitHook = getEmitHook();
|
|
1122
1208
|
if (emitHook) {
|
|
1209
|
+
const healthyChecks = newState.checkStatuses.filter(
|
|
1210
|
+
(c) => c.status === "healthy",
|
|
1211
|
+
).length;
|
|
1212
|
+
const totalChecks = newState.checkStatuses.length;
|
|
1213
|
+
const timestamp = new Date().toISOString();
|
|
1214
|
+
|
|
1123
1215
|
if (newState.status === "healthy" && previousStatus !== "healthy") {
|
|
1124
1216
|
// Recovery: system became healthy
|
|
1125
1217
|
await emitHook(healthCheckHooks.systemHealthy, {
|
|
1126
1218
|
systemId,
|
|
1127
1219
|
previousStatus,
|
|
1128
|
-
healthyChecks
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
totalChecks: newState.checkStatuses.length,
|
|
1132
|
-
timestamp: new Date().toISOString(),
|
|
1220
|
+
healthyChecks,
|
|
1221
|
+
totalChecks,
|
|
1222
|
+
timestamp,
|
|
1133
1223
|
});
|
|
1134
1224
|
logger.debug(
|
|
1135
1225
|
`Emitted systemHealthy hook: ${previousStatus} → ${newState.status}`,
|
|
@@ -1143,16 +1233,28 @@ async function executeHealthCheckJob(props: {
|
|
|
1143
1233
|
systemId,
|
|
1144
1234
|
previousStatus,
|
|
1145
1235
|
newStatus: newState.status,
|
|
1146
|
-
healthyChecks
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
totalChecks: newState.checkStatuses.length,
|
|
1150
|
-
timestamp: new Date().toISOString(),
|
|
1236
|
+
healthyChecks,
|
|
1237
|
+
totalChecks,
|
|
1238
|
+
timestamp,
|
|
1151
1239
|
});
|
|
1152
1240
|
logger.debug(
|
|
1153
1241
|
`Emitted systemDegraded hook: ${previousStatus} → ${newState.status}`,
|
|
1154
1242
|
);
|
|
1155
1243
|
}
|
|
1244
|
+
|
|
1245
|
+
// Umbrella hook — fires on every transition. Emitted alongside
|
|
1246
|
+
// the directional hooks so existing subscribers stay unchanged
|
|
1247
|
+
// while new automation triggers can react to any change.
|
|
1248
|
+
if (previousStatus !== newState.status) {
|
|
1249
|
+
await emitHook(healthCheckHooks.systemHealthChanged, {
|
|
1250
|
+
systemId,
|
|
1251
|
+
previousStatus,
|
|
1252
|
+
newStatus: newState.status,
|
|
1253
|
+
healthyChecks,
|
|
1254
|
+
totalChecks,
|
|
1255
|
+
timestamp,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1156
1258
|
}
|
|
1157
1259
|
}
|
|
1158
1260
|
|
|
@@ -1167,6 +1269,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1167
1269
|
systemName,
|
|
1168
1270
|
configurationId: configId,
|
|
1169
1271
|
configurationName: configName,
|
|
1272
|
+
getEmitHook,
|
|
1170
1273
|
previousState,
|
|
1171
1274
|
newState,
|
|
1172
1275
|
});
|
package/src/router.test.ts
CHANGED
|
@@ -68,6 +68,10 @@ describe("HealthCheck Router", () => {
|
|
|
68
68
|
getRedacted: mock(async () => undefined),
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
+
const mockCatalogClient = {
|
|
72
|
+
getSystem: mock(async () => null),
|
|
73
|
+
};
|
|
74
|
+
|
|
71
75
|
const router = createHealthCheckRouter({
|
|
72
76
|
database: mockDb as never,
|
|
73
77
|
registry: mockRegistry,
|
|
@@ -76,6 +80,7 @@ describe("HealthCheck Router", () => {
|
|
|
76
80
|
getEmitHook: () => undefined,
|
|
77
81
|
cache: passthroughCache,
|
|
78
82
|
configService: mockConfigService as never,
|
|
83
|
+
catalogClient: mockCatalogClient as never,
|
|
79
84
|
});
|
|
80
85
|
|
|
81
86
|
it("getStrategies returns strategies from registry", async () => {
|
package/src/router.ts
CHANGED
|
@@ -17,6 +17,7 @@ import * as schema from "./schema";
|
|
|
17
17
|
import { toJsonSchemaWithChartMeta } from "./schema-utils";
|
|
18
18
|
import type { InferClient } from "@checkstack/common";
|
|
19
19
|
import { GitOpsApi } from "@checkstack/gitops-common";
|
|
20
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
20
21
|
import type { HealthCheckCache } from "./cache";
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -33,14 +34,24 @@ export const createHealthCheckRouter = (opts: {
|
|
|
33
34
|
getEmitHook: () => ((hook: { id: string }, payload: Record<string, unknown>) => Promise<void>) | undefined;
|
|
34
35
|
cache: HealthCheckCache;
|
|
35
36
|
configService: ConfigService;
|
|
37
|
+
catalogClient: InferClient<typeof CatalogApi>;
|
|
36
38
|
}) => {
|
|
37
|
-
const {
|
|
39
|
+
const {
|
|
40
|
+
database,
|
|
41
|
+
registry,
|
|
42
|
+
collectorRegistry,
|
|
43
|
+
getEmitHook,
|
|
44
|
+
cache,
|
|
45
|
+
configService,
|
|
46
|
+
catalogClient,
|
|
47
|
+
} = opts;
|
|
38
48
|
// Create service instance once - shared across all handlers
|
|
39
49
|
const service = new HealthCheckService(
|
|
40
50
|
database,
|
|
41
51
|
registry,
|
|
42
52
|
collectorRegistry,
|
|
43
53
|
configService,
|
|
54
|
+
catalogClient,
|
|
44
55
|
);
|
|
45
56
|
|
|
46
57
|
// Create contract implementer with context type AND auto auth middleware
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { HealthCheckService } from "./service";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for getAssignmentsForSatellite run-context population:
|
|
6
|
+
* - assignments carry configName (from the config row's name)
|
|
7
|
+
* - systemName resolves via the optional catalog client, falling back to
|
|
8
|
+
* systemId when no client is wired or the lookup fails.
|
|
9
|
+
*/
|
|
10
|
+
describe("HealthCheckService.getAssignmentsForSatellite", () => {
|
|
11
|
+
const SATELLITE_ID = "sat-1";
|
|
12
|
+
|
|
13
|
+
type Association = {
|
|
14
|
+
systemId: string;
|
|
15
|
+
configurationId: string;
|
|
16
|
+
satelliteIds: string[] | null;
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Config = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
strategyId: string;
|
|
24
|
+
config: Record<string, unknown>;
|
|
25
|
+
collectors: unknown[] | null;
|
|
26
|
+
intervalSeconds: number;
|
|
27
|
+
paused: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let associations: Association[] = [];
|
|
31
|
+
let configs: Config[] = [];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Mock db: the method issues two distinct select shapes:
|
|
35
|
+
* - associations: .select({...}).from(systemHealthChecks) -> awaited array
|
|
36
|
+
* - config: .select().from(...).where(...) -> awaited array
|
|
37
|
+
* We disambiguate by call order: the first select() resolves associations,
|
|
38
|
+
* subsequent select().from().where() resolve a single matching config.
|
|
39
|
+
*/
|
|
40
|
+
function createMockDb() {
|
|
41
|
+
let firstSelect = true;
|
|
42
|
+
return {
|
|
43
|
+
select: mock(() => {
|
|
44
|
+
if (firstSelect) {
|
|
45
|
+
firstSelect = false;
|
|
46
|
+
return {
|
|
47
|
+
from: mock(() => Promise.resolve([...associations])),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
from: mock(() => ({
|
|
52
|
+
where: mock(() => {
|
|
53
|
+
// Return the next unmatched config in order; the loop fetches
|
|
54
|
+
// one config per matching association.
|
|
55
|
+
return Promise.resolve(configs.length > 0 ? [configs[0]] : []);
|
|
56
|
+
}),
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
associations = [];
|
|
65
|
+
configs = [];
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("populates configName and resolves systemName via the catalog client", async () => {
|
|
69
|
+
associations = [
|
|
70
|
+
{
|
|
71
|
+
systemId: "system-1",
|
|
72
|
+
configurationId: "config-1",
|
|
73
|
+
satelliteIds: [SATELLITE_ID],
|
|
74
|
+
enabled: true,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
configs = [
|
|
78
|
+
{
|
|
79
|
+
id: "config-1",
|
|
80
|
+
name: "API health",
|
|
81
|
+
strategyId: "http",
|
|
82
|
+
config: { url: "https://example.com" },
|
|
83
|
+
collectors: null,
|
|
84
|
+
intervalSeconds: 60,
|
|
85
|
+
paused: false,
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const getSystem = mock(() =>
|
|
90
|
+
Promise.resolve({ id: "system-1", name: "Production API" }),
|
|
91
|
+
);
|
|
92
|
+
const catalogClient = { getSystem } as never;
|
|
93
|
+
|
|
94
|
+
const mockDb = createMockDb();
|
|
95
|
+
const service = new HealthCheckService(
|
|
96
|
+
mockDb as never,
|
|
97
|
+
{} as never,
|
|
98
|
+
{} as never,
|
|
99
|
+
undefined,
|
|
100
|
+
catalogClient,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const result = await service.getAssignmentsForSatellite(SATELLITE_ID);
|
|
104
|
+
|
|
105
|
+
expect(result).toHaveLength(1);
|
|
106
|
+
expect(result[0].configName).toBe("API health");
|
|
107
|
+
expect(result[0].systemName).toBe("Production API");
|
|
108
|
+
expect(getSystem).toHaveBeenCalledWith({ systemId: "system-1" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("falls back to systemId when no catalog client is provided", async () => {
|
|
112
|
+
associations = [
|
|
113
|
+
{
|
|
114
|
+
systemId: "system-1",
|
|
115
|
+
configurationId: "config-1",
|
|
116
|
+
satelliteIds: [SATELLITE_ID],
|
|
117
|
+
enabled: true,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
configs = [
|
|
121
|
+
{
|
|
122
|
+
id: "config-1",
|
|
123
|
+
name: "API health",
|
|
124
|
+
strategyId: "http",
|
|
125
|
+
config: {},
|
|
126
|
+
collectors: null,
|
|
127
|
+
intervalSeconds: 30,
|
|
128
|
+
paused: false,
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const mockDb = createMockDb();
|
|
133
|
+
const service = new HealthCheckService(
|
|
134
|
+
mockDb as never,
|
|
135
|
+
{} as never,
|
|
136
|
+
{} as never,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const result = await service.getAssignmentsForSatellite(SATELLITE_ID);
|
|
140
|
+
|
|
141
|
+
expect(result).toHaveLength(1);
|
|
142
|
+
expect(result[0].configName).toBe("API health");
|
|
143
|
+
expect(result[0].systemName).toBe("system-1");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("falls back to systemId when the catalog lookup throws", async () => {
|
|
147
|
+
associations = [
|
|
148
|
+
{
|
|
149
|
+
systemId: "system-1",
|
|
150
|
+
configurationId: "config-1",
|
|
151
|
+
satelliteIds: [SATELLITE_ID],
|
|
152
|
+
enabled: true,
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
configs = [
|
|
156
|
+
{
|
|
157
|
+
id: "config-1",
|
|
158
|
+
name: "API health",
|
|
159
|
+
strategyId: "http",
|
|
160
|
+
config: {},
|
|
161
|
+
collectors: null,
|
|
162
|
+
intervalSeconds: 60,
|
|
163
|
+
paused: false,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const getSystem = mock(() => Promise.reject(new Error("catalog down")));
|
|
168
|
+
const catalogClient = { getSystem } as never;
|
|
169
|
+
|
|
170
|
+
const mockDb = createMockDb();
|
|
171
|
+
const service = new HealthCheckService(
|
|
172
|
+
mockDb as never,
|
|
173
|
+
{} as never,
|
|
174
|
+
{} as never,
|
|
175
|
+
undefined,
|
|
176
|
+
catalogClient,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const result = await service.getAssignmentsForSatellite(SATELLITE_ID);
|
|
180
|
+
|
|
181
|
+
expect(result).toHaveLength(1);
|
|
182
|
+
expect(result[0].systemName).toBe("system-1");
|
|
183
|
+
});
|
|
184
|
+
});
|