@checkstack/healthcheck-backend 1.22.0 → 1.23.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 +95 -0
- package/package.json +31 -30
- package/src/index.ts +10 -0
- package/src/queue-executor.test.ts +270 -0
- package/src/queue-executor.ts +143 -20
- package/src/satellite-liveness.test.ts +199 -0
- package/src/satellite-liveness.ts +106 -0
- package/tsconfig.json +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,100 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 1.23.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 88f4333: Per-satellite offline threshold, connectivity notifications, and stop satellite-only checks going silent
|
|
8
|
+
|
|
9
|
+
**A satellite going offline was invisible, and so were its checks.** Three
|
|
10
|
+
related changes:
|
|
11
|
+
|
|
12
|
+
**Per-satellite offline threshold.** The 45-second global constant is now a
|
|
13
|
+
per-satellite override (**Offline after**, 2 minutes to 24 hours), because
|
|
14
|
+
tolerance is a property of the link, not of the platform: a satellite on a flaky
|
|
15
|
+
uplink needs grace that should not be forced on every other satellite. The
|
|
16
|
+
threshold is carried on every row read by `computeStatus`, so the entity read,
|
|
17
|
+
the admin list and the heartbeat monitor cannot disagree about the same
|
|
18
|
+
satellite. Additive, nullable column - existing satellites keep the default.
|
|
19
|
+
|
|
20
|
+
**Connectivity notifications.** Satellites are now a notification target with a
|
|
21
|
+
**Satellite connectivity** subscription: a warning when a satellite stops
|
|
22
|
+
heartbeating, informational when it returns. A reconnect only notifies if the
|
|
23
|
+
satellite was actually offline, so a redeploy is not an event. (The same
|
|
24
|
+
transitions remain available as `satellite.heartbeat_lost` / `.connected`
|
|
25
|
+
automation triggers for anyone wanting different routing.)
|
|
26
|
+
|
|
27
|
+
**Satellite-only checks no longer go silent.** BUG FIX: a check with
|
|
28
|
+
`includeLocal: false` whose satellites were all offline recorded NOTHING, so it
|
|
29
|
+
displayed its last known status indefinitely - a dead probe was indistinguishable
|
|
30
|
+
from a passing one. The core now records a `degraded` run with a clear message.
|
|
31
|
+
Degraded rather than unhealthy because the target may be fine; what failed is our
|
|
32
|
+
ability to observe it. Liveness that cannot be resolved is treated as "executing"
|
|
33
|
+
so a transient lookup failure cannot mark the whole fleet degraded at once.
|
|
34
|
+
|
|
35
|
+
Checks also surface staleness: a last run older than five intervals (minimum ten
|
|
36
|
+
minutes) is highlighted, so an ageing status is visible even with no run to
|
|
37
|
+
explain it. Paused checks are never stale, and neither is a RETIRED slice - one
|
|
38
|
+
whose environment was removed or whose satellite was unassigned - because
|
|
39
|
+
warning about something you retired on purpose trains operators to ignore the
|
|
40
|
+
badge.
|
|
41
|
+
|
|
42
|
+
The unobservable run does NOT notify subscribers. One offline satellite degrades
|
|
43
|
+
every check assigned to it in the same tick, and `healthy -> degraded` is an
|
|
44
|
+
escalation, so notifying per check would turn a single root cause into one alert
|
|
45
|
+
per check. The satellite's own connectivity subscription reports the cause once;
|
|
46
|
+
the runs are still recorded, so health and the UI stay honest.
|
|
47
|
+
|
|
48
|
+
Satellite liveness is cached on the shared platform cache with a 5s TTL. The
|
|
49
|
+
executor asks per tick of every satellite-only check and the read is a full
|
|
50
|
+
scan, so the uncached version scaled with the number of such checks. The TTL is
|
|
51
|
+
well below the smallest offline threshold the schema allows, so a cached answer
|
|
52
|
+
can lag a transition by one tick but never span one.
|
|
53
|
+
|
|
54
|
+
Corrects the user guide, which claimed offline satellites produced failed runs -
|
|
55
|
+
they produced nothing at all.
|
|
56
|
+
|
|
57
|
+
### Patch Changes
|
|
58
|
+
|
|
59
|
+
- Updated dependencies [88f4333]
|
|
60
|
+
- Updated dependencies [1deaac5]
|
|
61
|
+
- Updated dependencies [88f4333]
|
|
62
|
+
- Updated dependencies [88f4333]
|
|
63
|
+
- Updated dependencies [88f4333]
|
|
64
|
+
- Updated dependencies [88f4333]
|
|
65
|
+
- Updated dependencies [88f4333]
|
|
66
|
+
- Updated dependencies [1deaac5]
|
|
67
|
+
- Updated dependencies [56e5375]
|
|
68
|
+
- Updated dependencies [88f4333]
|
|
69
|
+
- @checkstack/common@0.24.0
|
|
70
|
+
- @checkstack/healthcheck-common@1.19.1
|
|
71
|
+
- @checkstack/status-page-common@0.7.0
|
|
72
|
+
- @checkstack/incident-common@1.11.0
|
|
73
|
+
- @checkstack/maintenance-common@1.11.0
|
|
74
|
+
- @checkstack/command-backend@0.3.0
|
|
75
|
+
- @checkstack/incident-backend@1.14.0
|
|
76
|
+
- @checkstack/satellite-backend@0.10.0
|
|
77
|
+
- @checkstack/status-page-backend@0.7.0
|
|
78
|
+
- @checkstack/notification-common@1.9.0
|
|
79
|
+
- @checkstack/ai-backend@0.11.5
|
|
80
|
+
- @checkstack/backend-api@0.35.0
|
|
81
|
+
- @checkstack/satellite-common@0.12.0
|
|
82
|
+
- @checkstack/automation-backend@0.11.9
|
|
83
|
+
- @checkstack/secrets-backend@0.3.10
|
|
84
|
+
- @checkstack/ai-common@0.6.8
|
|
85
|
+
- @checkstack/cache-api@0.3.21
|
|
86
|
+
- @checkstack/catalog-backend@1.10.2
|
|
87
|
+
- @checkstack/catalog-common@2.8.2
|
|
88
|
+
- @checkstack/gitops-backend@0.5.28
|
|
89
|
+
- @checkstack/gitops-common@0.7.5
|
|
90
|
+
- @checkstack/healthcheck-execution@0.35.1
|
|
91
|
+
- @checkstack/queue-api@0.4.1
|
|
92
|
+
- @checkstack/script-packages-backend@0.4.7
|
|
93
|
+
- @checkstack/sdk@0.136.1
|
|
94
|
+
- @checkstack/secrets-common@0.3.4
|
|
95
|
+
- @checkstack/signal-common@0.3.2
|
|
96
|
+
- @checkstack/cache-utils@0.3.2
|
|
97
|
+
|
|
3
98
|
## 1.22.0
|
|
4
99
|
|
|
5
100
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,33 +14,34 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/ai-backend": "0.11.
|
|
18
|
-
"@checkstack/ai-common": "0.6.
|
|
19
|
-
"@checkstack/automation-backend": "0.11.
|
|
20
|
-
"@checkstack/backend-api": "0.
|
|
21
|
-
"@checkstack/cache-api": "0.3.
|
|
22
|
-
"@checkstack/cache-utils": "0.3.
|
|
23
|
-
"@checkstack/catalog-backend": "1.10.
|
|
24
|
-
"@checkstack/catalog-common": "2.8.
|
|
25
|
-
"@checkstack/command-backend": "0.
|
|
26
|
-
"@checkstack/common": "0.
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
28
|
-
"@checkstack/gitops-common": "0.7.
|
|
29
|
-
"@checkstack/healthcheck-common": "1.19.
|
|
30
|
-
"@checkstack/healthcheck-execution": "0.35.
|
|
31
|
-
"@checkstack/incident-backend": "1.
|
|
32
|
-
"@checkstack/incident-common": "1.
|
|
33
|
-
"@checkstack/maintenance-common": "1.
|
|
34
|
-
"@checkstack/notification-common": "1.
|
|
35
|
-
"@checkstack/queue-api": "0.4.
|
|
36
|
-
"@checkstack/satellite-backend": "0.
|
|
37
|
-
"@checkstack/
|
|
38
|
-
"@checkstack/
|
|
39
|
-
"@checkstack/
|
|
40
|
-
"@checkstack/secrets-
|
|
41
|
-
"@checkstack/
|
|
42
|
-
"@checkstack/
|
|
43
|
-
"@checkstack/status-page-
|
|
17
|
+
"@checkstack/ai-backend": "0.11.5",
|
|
18
|
+
"@checkstack/ai-common": "0.6.8",
|
|
19
|
+
"@checkstack/automation-backend": "0.11.9",
|
|
20
|
+
"@checkstack/backend-api": "0.35.0",
|
|
21
|
+
"@checkstack/cache-api": "0.3.21",
|
|
22
|
+
"@checkstack/cache-utils": "0.3.2",
|
|
23
|
+
"@checkstack/catalog-backend": "1.10.2",
|
|
24
|
+
"@checkstack/catalog-common": "2.8.2",
|
|
25
|
+
"@checkstack/command-backend": "0.3.0",
|
|
26
|
+
"@checkstack/common": "0.24.0",
|
|
27
|
+
"@checkstack/gitops-backend": "0.5.28",
|
|
28
|
+
"@checkstack/gitops-common": "0.7.5",
|
|
29
|
+
"@checkstack/healthcheck-common": "1.19.1",
|
|
30
|
+
"@checkstack/healthcheck-execution": "0.35.1",
|
|
31
|
+
"@checkstack/incident-backend": "1.14.0",
|
|
32
|
+
"@checkstack/incident-common": "1.11.0",
|
|
33
|
+
"@checkstack/maintenance-common": "1.11.0",
|
|
34
|
+
"@checkstack/notification-common": "1.9.0",
|
|
35
|
+
"@checkstack/queue-api": "0.4.1",
|
|
36
|
+
"@checkstack/satellite-backend": "0.10.0",
|
|
37
|
+
"@checkstack/satellite-common": "0.12.0",
|
|
38
|
+
"@checkstack/script-packages-backend": "0.4.7",
|
|
39
|
+
"@checkstack/sdk": "0.136.1",
|
|
40
|
+
"@checkstack/secrets-backend": "0.3.10",
|
|
41
|
+
"@checkstack/secrets-common": "0.3.4",
|
|
42
|
+
"@checkstack/signal-common": "0.3.2",
|
|
43
|
+
"@checkstack/status-page-backend": "0.7.0",
|
|
44
|
+
"@checkstack/status-page-common": "0.7.0",
|
|
44
45
|
"@hono/zod-validator": "^0.7.6",
|
|
45
46
|
"@orpc/contract": "^1.14.4",
|
|
46
47
|
"@orpc/server": "^1.14.4",
|
|
@@ -52,8 +53,8 @@
|
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
55
|
-
"@checkstack/scripts": "0.7.
|
|
56
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
56
|
+
"@checkstack/scripts": "0.7.7",
|
|
57
|
+
"@checkstack/test-utils-backend": "0.1.62",
|
|
57
58
|
"@checkstack/tsconfig": "0.0.7",
|
|
58
59
|
"@types/bun": "^1.0.0",
|
|
59
60
|
"@types/pg": "^8.20.0",
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
persistRunAndReact,
|
|
5
5
|
} from "./queue-executor";
|
|
6
6
|
import { reconcileHealthCheckJobs } from "./schedule-reconciler";
|
|
7
|
+
import { SatelliteApi } from "@checkstack/satellite-common";
|
|
7
8
|
import { setupRetentionJob } from "./retention-job";
|
|
8
9
|
import * as schema from "./schema";
|
|
9
10
|
import {
|
|
@@ -523,6 +524,15 @@ export default createBackendPlugin({
|
|
|
523
524
|
cache,
|
|
524
525
|
secretResolver,
|
|
525
526
|
internalSecrets,
|
|
527
|
+
// Lets a satellite-ONLY check notice that none of its satellites are
|
|
528
|
+
// online and record a degraded run, instead of silently recording
|
|
529
|
+
// nothing and leaving its last status on screen forever. Resolved by
|
|
530
|
+
// RPC so this plugin keeps no direct dependency on satellite-backend.
|
|
531
|
+
getOnlineSatelliteIds: async () => {
|
|
532
|
+
const satelliteClient = rpcClient.forPlugin(SatelliteApi);
|
|
533
|
+
const result = await satelliteClient.getOnlineSatelliteIds();
|
|
534
|
+
return result.satelliteIds;
|
|
535
|
+
},
|
|
526
536
|
});
|
|
527
537
|
|
|
528
538
|
// Setup retention job for tiered storage (daily aggregation)
|
|
@@ -336,6 +336,276 @@ describe("Queue-Based Health Check Executor", () => {
|
|
|
336
336
|
});
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
+
describe("executeHealthCheckJob - satellite-only checks with no online satellite", () => {
|
|
340
|
+
/**
|
|
341
|
+
* Builds a worker whose one configuration is satellite-ONLY (includeLocal
|
|
342
|
+
* false, satellites assigned) and captures the queue handler.
|
|
343
|
+
*/
|
|
344
|
+
const setupSatelliteOnlyWorker = async (opts: {
|
|
345
|
+
getOnlineSatelliteIds?: () => Promise<string[]>;
|
|
346
|
+
/** Override the assignment shape to model a configuration change. */
|
|
347
|
+
configRow?: Partial<{
|
|
348
|
+
includeLocal: boolean;
|
|
349
|
+
satelliteIds: string[];
|
|
350
|
+
paused: boolean;
|
|
351
|
+
}>;
|
|
352
|
+
/** Records every subscriber notification the run would deliver. */
|
|
353
|
+
onNotify?: (input: unknown) => void;
|
|
354
|
+
}) => {
|
|
355
|
+
const mockDb = createMockDb();
|
|
356
|
+
const mockLogger = createMockLogger();
|
|
357
|
+
const mockQueueManager = createMockQueueManager();
|
|
358
|
+
const mockSignalService = createMockSignalService();
|
|
359
|
+
const mockCatalogClient = createMockCatalogClient();
|
|
360
|
+
|
|
361
|
+
// The shared mock has no `selectDistinct`; the rollup read uses it.
|
|
362
|
+
(mockDb as any).selectDistinct = mock(() => ({
|
|
363
|
+
from: mock(() => ({
|
|
364
|
+
where: mock(() => Promise.resolve([])),
|
|
365
|
+
})),
|
|
366
|
+
}));
|
|
367
|
+
|
|
368
|
+
(mockDb.select as any) = mock(() => ({
|
|
369
|
+
from: mock(() => ({
|
|
370
|
+
innerJoin: mock(() => ({
|
|
371
|
+
where: mock(() =>
|
|
372
|
+
Promise.resolve([
|
|
373
|
+
{
|
|
374
|
+
configId: "config-1",
|
|
375
|
+
configName: "Satellite check",
|
|
376
|
+
strategyId: "test-strategy",
|
|
377
|
+
config: {},
|
|
378
|
+
collectors: [],
|
|
379
|
+
interval: 30,
|
|
380
|
+
enabled: true,
|
|
381
|
+
// The shape this branch exists for: executed by satellites,
|
|
382
|
+
// never by the core. Overridable so a test can model an
|
|
383
|
+
// assignment change.
|
|
384
|
+
paused: false,
|
|
385
|
+
includeLocal: false,
|
|
386
|
+
satelliteIds: ["sat-1", "sat-2"],
|
|
387
|
+
environmentIds: null,
|
|
388
|
+
...opts.configRow,
|
|
389
|
+
},
|
|
390
|
+
]),
|
|
391
|
+
),
|
|
392
|
+
})),
|
|
393
|
+
where: mock(() => Promise.resolve([])),
|
|
394
|
+
})),
|
|
395
|
+
}));
|
|
396
|
+
|
|
397
|
+
const queue =
|
|
398
|
+
mockQueueManager.getQueue<HealthCheckJobPayload>("health-checks");
|
|
399
|
+
let capturedHandler:
|
|
400
|
+
| ((job: { data: HealthCheckJobPayload }) => Promise<void>)
|
|
401
|
+
| undefined;
|
|
402
|
+
(queue.consume as any) = mock(
|
|
403
|
+
async (
|
|
404
|
+
handler: (job: { data: HealthCheckJobPayload }) => Promise<void>,
|
|
405
|
+
) => {
|
|
406
|
+
capturedHandler = handler;
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
await setupHealthCheckWorker({
|
|
411
|
+
db: mockDb as unknown as Parameters<
|
|
412
|
+
typeof setupHealthCheckWorker
|
|
413
|
+
>[0]["db"],
|
|
414
|
+
advisoryLock: mockAdvisoryLock,
|
|
415
|
+
registry: createMockRegistry(),
|
|
416
|
+
collectorRegistry:
|
|
417
|
+
createMockCollectorRegistry() as unknown as Parameters<
|
|
418
|
+
typeof setupHealthCheckWorker
|
|
419
|
+
>[0]["collectorRegistry"],
|
|
420
|
+
logger: mockLogger,
|
|
421
|
+
queueManager: mockQueueManager,
|
|
422
|
+
signalService: mockSignalService,
|
|
423
|
+
catalogClient: mockCatalogClient as unknown as Parameters<
|
|
424
|
+
typeof setupHealthCheckWorker
|
|
425
|
+
>[0]["catalogClient"],
|
|
426
|
+
notificationClient: {
|
|
427
|
+
notifyForSubscription: (input: unknown) => {
|
|
428
|
+
opts.onNotify?.(input);
|
|
429
|
+
return Promise.resolve({ notifiedCount: 0 });
|
|
430
|
+
},
|
|
431
|
+
} as unknown as Parameters<
|
|
432
|
+
typeof setupHealthCheckWorker
|
|
433
|
+
>[0]["notificationClient"],
|
|
434
|
+
maintenanceClient: createMockMaintenanceClient() as unknown as Parameters<
|
|
435
|
+
typeof setupHealthCheckWorker
|
|
436
|
+
>[0]["maintenanceClient"],
|
|
437
|
+
incidentClient: createMockIncidentClient() as unknown as Parameters<
|
|
438
|
+
typeof setupHealthCheckWorker
|
|
439
|
+
>[0]["incidentClient"],
|
|
440
|
+
getEmitHook: () => undefined,
|
|
441
|
+
cache: passthroughCache,
|
|
442
|
+
slowCheckRuntime: null,
|
|
443
|
+
...(opts.getOnlineSatelliteIds
|
|
444
|
+
? { getOnlineSatelliteIds: opts.getOnlineSatelliteIds }
|
|
445
|
+
: {}),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
return { capturedHandler, mockLogger, mockSignalService };
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const run = async (
|
|
452
|
+
handler:
|
|
453
|
+
| ((job: { data: HealthCheckJobPayload }) => Promise<void>)
|
|
454
|
+
| undefined,
|
|
455
|
+
) => {
|
|
456
|
+
await handler?.({
|
|
457
|
+
data: {
|
|
458
|
+
configId: "config-1",
|
|
459
|
+
systemId: "system-1",
|
|
460
|
+
environmentId: null,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
it("stays silent while at least one assigned satellite is online", async () => {
|
|
466
|
+
const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
|
|
467
|
+
getOnlineSatelliteIds: async () => ["sat-2"],
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await run(capturedHandler);
|
|
471
|
+
|
|
472
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
473
|
+
expect.stringContaining("satellite-only, skipping local execution"),
|
|
474
|
+
);
|
|
475
|
+
expect(mockLogger.warn).not.toHaveBeenCalledWith(
|
|
476
|
+
expect.stringContaining("no online satellite"),
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("warns and records a run when NO assigned satellite is online", async () => {
|
|
481
|
+
// The regression this guards: the core used to return silently here, so a
|
|
482
|
+
// check whose satellites were all down kept displaying its last known
|
|
483
|
+
// status forever - a dead probe reading exactly like a passing one.
|
|
484
|
+
const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
|
|
485
|
+
getOnlineSatelliteIds: async () => [],
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// `persistRunAndReact` needs the full insert/aggregate/entity chain, which
|
|
489
|
+
// this file's mock database does not provide - so the call throws HERE and
|
|
490
|
+
// not in production, where it is the same function every successful run
|
|
491
|
+
// goes through. That is a mock limit, not the behaviour under test: what
|
|
492
|
+
// this pins is that the executor took the RECORD path instead of the
|
|
493
|
+
// silent one. What gets recorded is pinned separately and purely by
|
|
494
|
+
// `buildUnobservableRun` in `satellite-liveness.test.ts`.
|
|
495
|
+
await run(capturedHandler).catch(() => {});
|
|
496
|
+
|
|
497
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
498
|
+
expect.stringContaining("no online satellite"),
|
|
499
|
+
);
|
|
500
|
+
// It must NOT take the silent path.
|
|
501
|
+
expect(mockLogger.debug).not.toHaveBeenCalledWith(
|
|
502
|
+
expect.stringContaining("satellite-only, skipping local execution"),
|
|
503
|
+
);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("stays silent when satellite liveness cannot be resolved", async () => {
|
|
507
|
+
// A transient failure to reach the satellite service must never mark every
|
|
508
|
+
// satellite-only check in the fleet degraded at once.
|
|
509
|
+
const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
|
|
510
|
+
getOnlineSatelliteIds: async () => {
|
|
511
|
+
throw new Error("satellite service unreachable");
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
await run(capturedHandler);
|
|
516
|
+
|
|
517
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
518
|
+
expect.stringContaining("satellite-only, skipping local execution"),
|
|
519
|
+
);
|
|
520
|
+
expect(mockLogger.warn).not.toHaveBeenCalledWith(
|
|
521
|
+
expect.stringContaining("no online satellite"),
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("stays silent when no liveness resolver is wired at all", async () => {
|
|
526
|
+
// Pre-existing behaviour for a deployment without the resolver: the core
|
|
527
|
+
// says nothing and lets the satellites report.
|
|
528
|
+
const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({});
|
|
529
|
+
|
|
530
|
+
await run(capturedHandler);
|
|
531
|
+
|
|
532
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
533
|
+
expect.stringContaining("satellite-only, skipping local execution"),
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* An operator CHANGING an assignment must never look like a failure. These
|
|
539
|
+
* are the cases that have historically been got wrong: the platform reacts
|
|
540
|
+
* to a deliberate configuration change as though the check had broken.
|
|
541
|
+
*/
|
|
542
|
+
it("records nothing when the satellites are removed from the assignment", async () => {
|
|
543
|
+
const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
|
|
544
|
+
// No satellite is online at all - but the assignment no longer names
|
|
545
|
+
// any, so this check is simply not satellite-only any more.
|
|
546
|
+
getOnlineSatelliteIds: async () => [],
|
|
547
|
+
configRow: { satelliteIds: [] },
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
await run(capturedHandler).catch(() => {});
|
|
551
|
+
|
|
552
|
+
expect(mockLogger.warn).not.toHaveBeenCalledWith(
|
|
553
|
+
expect.stringContaining("no online satellite"),
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("records nothing when local execution is turned back on", async () => {
|
|
558
|
+
const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
|
|
559
|
+
getOnlineSatelliteIds: async () => [],
|
|
560
|
+
configRow: { includeLocal: true },
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
await run(capturedHandler).catch(() => {});
|
|
564
|
+
|
|
565
|
+
expect(mockLogger.warn).not.toHaveBeenCalledWith(
|
|
566
|
+
expect.stringContaining("no online satellite"),
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("a PAUSED satellite-only check records nothing, even with every satellite offline", async () => {
|
|
571
|
+
// Paused is checked BEFORE the satellite branch, so a paused check is
|
|
572
|
+
// quiet on purpose and must not manufacture a degraded run.
|
|
573
|
+
const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
|
|
574
|
+
getOnlineSatelliteIds: async () => [],
|
|
575
|
+
configRow: { paused: true },
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
await run(capturedHandler).catch(() => {});
|
|
579
|
+
|
|
580
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
581
|
+
expect.stringContaining("is paused, skipping execution"),
|
|
582
|
+
);
|
|
583
|
+
expect(mockLogger.warn).not.toHaveBeenCalledWith(
|
|
584
|
+
expect.stringContaining("no online satellite"),
|
|
585
|
+
);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("does NOT notify subscribers for the unobservable run", async () => {
|
|
589
|
+
// One offline satellite degrades EVERY check assigned to it in the same
|
|
590
|
+
// tick, and healthy -> degraded is an escalation, so notifying per check
|
|
591
|
+
// turns a single root cause into one alert per check. The satellite's own
|
|
592
|
+
// connectivity subscription reports the cause once. The RUN is still
|
|
593
|
+
// recorded - only the per-check alert is withheld.
|
|
594
|
+
const notified: unknown[] = [];
|
|
595
|
+
const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
|
|
596
|
+
getOnlineSatelliteIds: async () => [],
|
|
597
|
+
onNotify: (input) => notified.push(input),
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
await run(capturedHandler).catch(() => {});
|
|
601
|
+
|
|
602
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
603
|
+
expect.stringContaining("no online satellite"),
|
|
604
|
+
);
|
|
605
|
+
expect(notified).toHaveLength(0);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
339
609
|
describe("executeHealthCheckJob - collector run-context", () => {
|
|
340
610
|
it("passes curated run-context to the collector (name falls back to id when configName is null)", async () => {
|
|
341
611
|
const mockDb = createMockDb();
|
package/src/queue-executor.ts
CHANGED
|
@@ -78,6 +78,10 @@ import {
|
|
|
78
78
|
type HealthEntityState,
|
|
79
79
|
} from "./health-entity";
|
|
80
80
|
import { encodeHealthEntityId } from "./health-entity-id";
|
|
81
|
+
import {
|
|
82
|
+
buildUnobservableRun,
|
|
83
|
+
resolveSatelliteOnlyOutcome,
|
|
84
|
+
} from "./satellite-liveness";
|
|
81
85
|
import type { EntityHandle } from "@checkstack/automation-backend";
|
|
82
86
|
|
|
83
87
|
type Db = SafeDatabase<typeof schema>;
|
|
@@ -628,6 +632,20 @@ export async function persistRunAndReact(params: {
|
|
|
628
632
|
sourceLabel: string;
|
|
629
633
|
/** Timestamp used for the hourly aggregate bucket (the run's execution time). */
|
|
630
634
|
runTimestamp: Date;
|
|
635
|
+
/**
|
|
636
|
+
* Record the run and its transition, but do NOT notify subscribers.
|
|
637
|
+
*
|
|
638
|
+
* For a run whose cause is a single shared failure that is ALREADY notified
|
|
639
|
+
* elsewhere. The unobservable-run path is the case: one satellite going
|
|
640
|
+
* offline makes every check assigned to it degrade at once, and
|
|
641
|
+
* `healthy -> degraded` is an escalation, so without this a single satellite
|
|
642
|
+
* outage fans out into one notification per check. The satellite's own
|
|
643
|
+
* connectivity subscription names the actual root cause once.
|
|
644
|
+
*
|
|
645
|
+
* The run, the transition and the health state are still written, so the UI
|
|
646
|
+
* stays honest - only the per-check alert is withheld.
|
|
647
|
+
*/
|
|
648
|
+
suppressSubscriberNotification?: boolean;
|
|
631
649
|
}): Promise<void> {
|
|
632
650
|
const {
|
|
633
651
|
db,
|
|
@@ -655,6 +673,7 @@ export async function persistRunAndReact(params: {
|
|
|
655
673
|
sourceId,
|
|
656
674
|
sourceLabel,
|
|
657
675
|
runTimestamp,
|
|
676
|
+
suppressSubscriberNotification = false,
|
|
658
677
|
} = params;
|
|
659
678
|
|
|
660
679
|
const envEntityId = encodeHealthEntityId({ systemId, environmentId });
|
|
@@ -762,22 +781,30 @@ export async function persistRunAndReact(params: {
|
|
|
762
781
|
toStatus: newState.status,
|
|
763
782
|
});
|
|
764
783
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
784
|
+
if (suppressSubscriberNotification) {
|
|
785
|
+
logger.debug(
|
|
786
|
+
`Recorded ${newState.status} for ${configId}/${systemId} without notifying: ` +
|
|
787
|
+
"the underlying cause is notified once at its source",
|
|
788
|
+
);
|
|
789
|
+
} else {
|
|
790
|
+
await notifyStateChange({
|
|
791
|
+
notificationClient,
|
|
792
|
+
systemId,
|
|
793
|
+
systemName,
|
|
794
|
+
configurationId: configId,
|
|
795
|
+
configurationName: configName,
|
|
796
|
+
previousStatus:
|
|
797
|
+
previousStatus === "unknown" ? "healthy" : previousStatus,
|
|
798
|
+
newStatus: newState.status,
|
|
799
|
+
environmentId,
|
|
800
|
+
environmentName,
|
|
801
|
+
service,
|
|
802
|
+
catalogClient,
|
|
803
|
+
maintenanceClient,
|
|
804
|
+
incidentClient,
|
|
805
|
+
logger,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
781
808
|
|
|
782
809
|
if (!isFannedOut) {
|
|
783
810
|
await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
|
|
@@ -859,6 +886,15 @@ async function executeHealthCheckJob(props: {
|
|
|
859
886
|
* is skipped and the run executes exactly as before (full timeout, no lane).
|
|
860
887
|
*/
|
|
861
888
|
slowCheckRuntime?: SlowCheckRuntime | null;
|
|
889
|
+
/**
|
|
890
|
+
* Resolves the ids of every currently-online satellite.
|
|
891
|
+
*
|
|
892
|
+
* Injected rather than imported so this module keeps no dependency on the
|
|
893
|
+
* satellite plugin, and so the unobservable-check path is testable without
|
|
894
|
+
* one. When absent, satellite-only checks behave exactly as they did before:
|
|
895
|
+
* the core stays silent and lets the satellites report.
|
|
896
|
+
*/
|
|
897
|
+
getOnlineSatelliteIds?: () => Promise<string[]>;
|
|
862
898
|
}): Promise<void> {
|
|
863
899
|
const {
|
|
864
900
|
payload,
|
|
@@ -878,6 +914,7 @@ async function executeHealthCheckJob(props: {
|
|
|
878
914
|
secretResolver,
|
|
879
915
|
internalSecrets,
|
|
880
916
|
slowCheckRuntime,
|
|
917
|
+
getOnlineSatelliteIds,
|
|
881
918
|
} = props;
|
|
882
919
|
const { configId, systemId } = payload;
|
|
883
920
|
|
|
@@ -951,16 +988,91 @@ async function executeHealthCheckJob(props: {
|
|
|
951
988
|
return;
|
|
952
989
|
}
|
|
953
990
|
|
|
954
|
-
// If includeLocal is false and satellites are assigned,
|
|
955
|
-
//
|
|
991
|
+
// If includeLocal is false and satellites are assigned, the SATELLITES
|
|
992
|
+
// execute this check and the core does not.
|
|
993
|
+
//
|
|
994
|
+
// But "the core does not run it" is not the same as "nothing needs to
|
|
995
|
+
// happen". If every assigned satellite is offline, nobody runs it, and
|
|
996
|
+
// returning silently (as this once did) leaves the check displaying its
|
|
997
|
+
// last known status forever - a dead probe reading exactly like a passing
|
|
998
|
+
// one. So an unobservable check records a `degraded` run instead.
|
|
956
999
|
if (
|
|
957
1000
|
!configRow.includeLocal &&
|
|
958
1001
|
configRow.satelliteIds &&
|
|
959
1002
|
configRow.satelliteIds.length > 0
|
|
960
1003
|
) {
|
|
961
|
-
|
|
962
|
-
|
|
1004
|
+
const satelliteIds = configRow.satelliteIds;
|
|
1005
|
+
// Left UNSET (not empty) when liveness cannot be resolved: an empty list
|
|
1006
|
+
// would read as "every satellite is offline" and mark the whole fleet's
|
|
1007
|
+
// satellite-only checks degraded on a transient lookup failure.
|
|
1008
|
+
let onlineSatelliteIds: string[] | undefined;
|
|
1009
|
+
if (getOnlineSatelliteIds) {
|
|
1010
|
+
try {
|
|
1011
|
+
onlineSatelliteIds = await getOnlineSatelliteIds();
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
logger.warn(
|
|
1014
|
+
`Could not resolve satellite liveness for ${configId}/${systemId}; treating as executing`,
|
|
1015
|
+
error,
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const outcome = resolveSatelliteOnlyOutcome({
|
|
1021
|
+
satelliteIds,
|
|
1022
|
+
...(onlineSatelliteIds === undefined ? {} : { onlineSatelliteIds }),
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
if (outcome === "satellites-executing") {
|
|
1026
|
+
logger.debug(
|
|
1027
|
+
`Health check ${configId} for system ${systemId} is satellite-only, skipping local execution`,
|
|
1028
|
+
);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
logger.warn(
|
|
1033
|
+
`Health check ${configId} for system ${systemId} has no online satellite ` +
|
|
1034
|
+
`(${satelliteIds.length} assigned); recording a degraded run so the gap is visible`,
|
|
963
1035
|
);
|
|
1036
|
+
|
|
1037
|
+
let unobservableSystemName = systemId;
|
|
1038
|
+
try {
|
|
1039
|
+
const system = await catalogClient.getSystem({ systemId });
|
|
1040
|
+
if (system) unobservableSystemName = system.name;
|
|
1041
|
+
} catch {
|
|
1042
|
+
// Fall back to the id; a missing display name must not swallow the run.
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
await persistRunAndReact({
|
|
1046
|
+
db,
|
|
1047
|
+
service,
|
|
1048
|
+
cache,
|
|
1049
|
+
signalService,
|
|
1050
|
+
notificationClient,
|
|
1051
|
+
catalogClient,
|
|
1052
|
+
maintenanceClient,
|
|
1053
|
+
incidentClient,
|
|
1054
|
+
...(getHealthEntity ? { getHealthEntity } : {}),
|
|
1055
|
+
getEmitHook,
|
|
1056
|
+
collectorRegistry,
|
|
1057
|
+
advisoryLock,
|
|
1058
|
+
logger,
|
|
1059
|
+
systemId,
|
|
1060
|
+
systemName: unobservableSystemName,
|
|
1061
|
+
configId,
|
|
1062
|
+
...(configRow.configName ? { configName: configRow.configName } : {}),
|
|
1063
|
+
// The job payload already names the single (config, system, env) slice
|
|
1064
|
+
// this tick owns, so the stale run lands on exactly the slice the
|
|
1065
|
+
// satellites would have reported for.
|
|
1066
|
+
...buildUnobservableRun({
|
|
1067
|
+
environmentId: payload.environmentId,
|
|
1068
|
+
satelliteIds,
|
|
1069
|
+
}),
|
|
1070
|
+
runTimestamp: new Date(),
|
|
1071
|
+
// One offline satellite degrades EVERY check assigned to it in the same
|
|
1072
|
+
// tick. Notifying per check would turn a single root cause into a
|
|
1073
|
+
// storm; the satellite's connectivity subscription reports it once.
|
|
1074
|
+
suppressSubscriberNotification: true,
|
|
1075
|
+
});
|
|
964
1076
|
return;
|
|
965
1077
|
}
|
|
966
1078
|
|
|
@@ -1772,6 +1884,15 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1772
1884
|
* don't exercise the bulkhead), or a concrete runtime to drive it.
|
|
1773
1885
|
*/
|
|
1774
1886
|
slowCheckRuntime?: SlowCheckRuntime | null;
|
|
1887
|
+
/**
|
|
1888
|
+
* Resolves the ids of every currently-online satellite.
|
|
1889
|
+
*
|
|
1890
|
+
* Injected rather than imported so this module keeps no dependency on the
|
|
1891
|
+
* satellite plugin, and so the unobservable-check path is testable without
|
|
1892
|
+
* one. When absent, satellite-only checks behave exactly as they did before:
|
|
1893
|
+
* the core stays silent and lets the satellites report.
|
|
1894
|
+
*/
|
|
1895
|
+
getOnlineSatelliteIds?: () => Promise<string[]>;
|
|
1775
1896
|
}): Promise<void> {
|
|
1776
1897
|
const {
|
|
1777
1898
|
db,
|
|
@@ -1790,6 +1911,7 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1790
1911
|
cache,
|
|
1791
1912
|
secretResolver,
|
|
1792
1913
|
internalSecrets,
|
|
1914
|
+
getOnlineSatelliteIds,
|
|
1793
1915
|
} = props;
|
|
1794
1916
|
|
|
1795
1917
|
// Resolve the slow-check runtime once at startup unless the caller supplied
|
|
@@ -1826,6 +1948,7 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1826
1948
|
secretResolver,
|
|
1827
1949
|
internalSecrets,
|
|
1828
1950
|
slowCheckRuntime,
|
|
1951
|
+
...(getOnlineSatelliteIds ? { getOnlineSatelliteIds } : {}),
|
|
1829
1952
|
});
|
|
1830
1953
|
},
|
|
1831
1954
|
{
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildUnobservableResult,
|
|
4
|
+
buildUnobservableRun,
|
|
5
|
+
resolveSatelliteOnlyOutcome,
|
|
6
|
+
} from "./satellite-liveness";
|
|
7
|
+
|
|
8
|
+
describe("resolveSatelliteOnlyOutcome", () => {
|
|
9
|
+
test("satellites execute the check when any assigned one is online", () => {
|
|
10
|
+
expect(
|
|
11
|
+
resolveSatelliteOnlyOutcome({
|
|
12
|
+
satelliteIds: ["a", "b"],
|
|
13
|
+
onlineSatelliteIds: ["b"],
|
|
14
|
+
}),
|
|
15
|
+
).toBe("satellites-executing");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("records an unobservable run when NO assigned satellite is online", () => {
|
|
19
|
+
// The bug: this used to return silently, so the check kept displaying its
|
|
20
|
+
// last status forever and a dead probe read exactly like a passing one.
|
|
21
|
+
expect(
|
|
22
|
+
resolveSatelliteOnlyOutcome({
|
|
23
|
+
satelliteIds: ["a", "b"],
|
|
24
|
+
onlineSatelliteIds: [],
|
|
25
|
+
}),
|
|
26
|
+
).toBe("record-unobservable");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ignores online satellites this check is not assigned to", () => {
|
|
30
|
+
expect(
|
|
31
|
+
resolveSatelliteOnlyOutcome({
|
|
32
|
+
satelliteIds: ["a"],
|
|
33
|
+
onlineSatelliteIds: ["someone-else"],
|
|
34
|
+
}),
|
|
35
|
+
).toBe("record-unobservable");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("stays silent when liveness is UNKNOWN", () => {
|
|
39
|
+
// A transient failure to reach the satellite service must never mark every
|
|
40
|
+
// satellite-only check in the fleet degraded at once. Unknown is not the
|
|
41
|
+
// same as offline, and silence is the pre-existing, safe direction.
|
|
42
|
+
expect(
|
|
43
|
+
resolveSatelliteOnlyOutcome({ satelliteIds: ["a"] }),
|
|
44
|
+
).toBe("satellites-executing");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a check with no assigned satellites is treated as executing", () => {
|
|
48
|
+
// Not reachable from the caller (the branch requires assignments), but the
|
|
49
|
+
// function must not claim an empty assignment set is unobservable.
|
|
50
|
+
expect(
|
|
51
|
+
resolveSatelliteOnlyOutcome({
|
|
52
|
+
satelliteIds: [],
|
|
53
|
+
onlineSatelliteIds: [],
|
|
54
|
+
}),
|
|
55
|
+
).toBe("satellites-executing");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("one online satellite out of many is enough", () => {
|
|
59
|
+
expect(
|
|
60
|
+
resolveSatelliteOnlyOutcome({
|
|
61
|
+
satelliteIds: ["a", "b", "c"],
|
|
62
|
+
onlineSatelliteIds: ["c"],
|
|
63
|
+
}),
|
|
64
|
+
).toBe("satellites-executing");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("buildUnobservableResult", () => {
|
|
69
|
+
test("says plainly that health is UNKNOWN, not that the target is down", () => {
|
|
70
|
+
// The run is degraded because we could not observe, not because the service
|
|
71
|
+
// failed. The message must not let an operator conclude otherwise.
|
|
72
|
+
const result = buildUnobservableResult({ satelliteIds: ["a", "b"] });
|
|
73
|
+
|
|
74
|
+
expect(String(result.error)).toContain("unknown");
|
|
75
|
+
expect(String(result.error)).toContain("monitoring gap");
|
|
76
|
+
expect(String(result.error)).toContain("not a confirmed outage");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("carries a machine-readable marker and the assignment count", () => {
|
|
80
|
+
const result = buildUnobservableResult({ satelliteIds: ["a", "b"] });
|
|
81
|
+
|
|
82
|
+
expect(result.satelliteOffline).toBe(true);
|
|
83
|
+
expect(result.assignedSatelliteCount).toBe(2);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("buildUnobservableRun", () => {
|
|
88
|
+
test("records DEGRADED, never unhealthy", () => {
|
|
89
|
+
// Unhealthy would raise incident-grade alarms about services that may be
|
|
90
|
+
// perfectly healthy, every time a satellite host reboots.
|
|
91
|
+
expect(
|
|
92
|
+
buildUnobservableRun({ environmentId: null, satelliteIds: ["a"] }).status,
|
|
93
|
+
).toBe("degraded");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("lands on the slice the job owns, including a concrete environment", () => {
|
|
97
|
+
// The satellites would have reported for this exact slice, so the gap has
|
|
98
|
+
// to be recorded there and not on the rollup.
|
|
99
|
+
expect(
|
|
100
|
+
buildUnobservableRun({ environmentId: "env-1", satelliteIds: ["a"] })
|
|
101
|
+
.environmentId,
|
|
102
|
+
).toBe("env-1");
|
|
103
|
+
expect(
|
|
104
|
+
buildUnobservableRun({ environmentId: null, satelliteIds: ["a"] })
|
|
105
|
+
.environmentId,
|
|
106
|
+
).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("attributes the run to the core, not to a satellite", () => {
|
|
110
|
+
// A satellite reported nothing - the core is what noticed the gap.
|
|
111
|
+
expect(
|
|
112
|
+
buildUnobservableRun({ environmentId: null, satelliteIds: ["a"] })
|
|
113
|
+
.sourceLabel,
|
|
114
|
+
).toBe("Local");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("carries the explanatory result payload", () => {
|
|
118
|
+
const run = buildUnobservableRun({
|
|
119
|
+
environmentId: null,
|
|
120
|
+
satelliteIds: ["a", "b"],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(run.result.satelliteOffline).toBe(true);
|
|
124
|
+
expect(run.result.assignedSatelliteCount).toBe(2);
|
|
125
|
+
expect(String(run.result.error)).toContain("unknown");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("resolveSatelliteOnlyOutcome - assignment changes must not fabricate a gap", () => {
|
|
130
|
+
/**
|
|
131
|
+
* The class of bug these guard: an operator changes an assignment and the
|
|
132
|
+
* platform reacts as though something FAILED. Removing a satellite, adding
|
|
133
|
+
* local execution back, or swapping one satellite for another are all
|
|
134
|
+
* deliberate acts - none of them means "nobody is checking this".
|
|
135
|
+
*/
|
|
136
|
+
test("emptying the satellite list is not an outage", () => {
|
|
137
|
+
// The executor's branch requires a non-empty list, but the predicate must
|
|
138
|
+
// agree independently - a future caller must not be able to turn a cleared
|
|
139
|
+
// assignment into a degraded run.
|
|
140
|
+
expect(
|
|
141
|
+
resolveSatelliteOnlyOutcome({
|
|
142
|
+
satelliteIds: [],
|
|
143
|
+
onlineSatelliteIds: [],
|
|
144
|
+
}),
|
|
145
|
+
).toBe("satellites-executing");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("swapping to a different, online satellite is not an outage", () => {
|
|
149
|
+
expect(
|
|
150
|
+
resolveSatelliteOnlyOutcome({
|
|
151
|
+
satelliteIds: ["new-sat"],
|
|
152
|
+
onlineSatelliteIds: ["new-sat"],
|
|
153
|
+
}),
|
|
154
|
+
).toBe("satellites-executing");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("one online satellite is enough even when the others were deleted", () => {
|
|
158
|
+
// A deleted satellite simply stops appearing in the online set. As long as
|
|
159
|
+
// ONE assigned satellite is still online, the check is being executed.
|
|
160
|
+
expect(
|
|
161
|
+
resolveSatelliteOnlyOutcome({
|
|
162
|
+
satelliteIds: ["deleted-sat", "live-sat"],
|
|
163
|
+
onlineSatelliteIds: ["live-sat", "unrelated-sat"],
|
|
164
|
+
}),
|
|
165
|
+
).toBe("satellites-executing");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("an entirely unrelated fleet being online is NOT enough", () => {
|
|
169
|
+
// Guards the inverse mistake: "some satellite somewhere is up" must not be
|
|
170
|
+
// read as "this check is being executed".
|
|
171
|
+
expect(
|
|
172
|
+
resolveSatelliteOnlyOutcome({
|
|
173
|
+
satelliteIds: ["mine"],
|
|
174
|
+
onlineSatelliteIds: ["someone-elses", "another"],
|
|
175
|
+
}),
|
|
176
|
+
).toBe("record-unobservable");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("an empty online set with no assignment is not an outage", () => {
|
|
180
|
+
expect(
|
|
181
|
+
resolveSatelliteOnlyOutcome({ satelliteIds: [], onlineSatelliteIds: [] }),
|
|
182
|
+
).toBe("satellites-executing");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("duplicate ids in an assignment do not change the verdict", () => {
|
|
186
|
+
expect(
|
|
187
|
+
resolveSatelliteOnlyOutcome({
|
|
188
|
+
satelliteIds: ["a", "a"],
|
|
189
|
+
onlineSatelliteIds: ["a"],
|
|
190
|
+
}),
|
|
191
|
+
).toBe("satellites-executing");
|
|
192
|
+
expect(
|
|
193
|
+
resolveSatelliteOnlyOutcome({
|
|
194
|
+
satelliteIds: ["a", "a"],
|
|
195
|
+
onlineSatelliteIds: [],
|
|
196
|
+
}),
|
|
197
|
+
).toBe("record-unobservable");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a satellite-only check should do when the core reaches its tick.
|
|
3
|
+
*
|
|
4
|
+
* ## The bug this exists to close
|
|
5
|
+
*
|
|
6
|
+
* A check with `includeLocal: false` and assigned satellites is executed BY
|
|
7
|
+
* those satellites; the core's own tick has nothing to run and returned
|
|
8
|
+
* immediately. If every assigned satellite is offline, nobody executes it - and
|
|
9
|
+
* because the core recorded nothing at all, the check kept displaying whatever
|
|
10
|
+
* status it last had, indefinitely. A dead probe was indistinguishable from a
|
|
11
|
+
* passing one, which is the single worst failure mode a monitoring tool can
|
|
12
|
+
* have.
|
|
13
|
+
*
|
|
14
|
+
* So the core now records a `degraded` run instead of staying silent.
|
|
15
|
+
* `degraded` rather than `unhealthy` because the target may be perfectly
|
|
16
|
+
* healthy - what failed is our ability to observe it. Marking it unhealthy
|
|
17
|
+
* would raise incident-grade alarms about services that are fine, every time a
|
|
18
|
+
* satellite host reboots.
|
|
19
|
+
*/
|
|
20
|
+
export type SatelliteOnlyOutcome =
|
|
21
|
+
/** Satellites are executing this check; the core has nothing to do. */
|
|
22
|
+
| "satellites-executing"
|
|
23
|
+
/** No assigned satellite is online - record a stale run so the gap is visible. */
|
|
24
|
+
| "record-unobservable";
|
|
25
|
+
|
|
26
|
+
export function resolveSatelliteOnlyOutcome({
|
|
27
|
+
satelliteIds,
|
|
28
|
+
onlineSatelliteIds,
|
|
29
|
+
}: {
|
|
30
|
+
/** Satellites assigned to this check. */
|
|
31
|
+
satelliteIds: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* Currently-online satellite ids. `undefined` when liveness could not be
|
|
34
|
+
* determined at all (no resolver wired, or the lookup failed).
|
|
35
|
+
*/
|
|
36
|
+
onlineSatelliteIds?: readonly string[];
|
|
37
|
+
}): SatelliteOnlyOutcome {
|
|
38
|
+
// Unknown liveness must NEVER manufacture a degraded run: a transient failure
|
|
39
|
+
// to reach the satellite service would otherwise mark every satellite-only
|
|
40
|
+
// check degraded across the fleet at once. Staying silent is the pre-existing
|
|
41
|
+
// behaviour and the safe direction for an unknown.
|
|
42
|
+
if (onlineSatelliteIds === undefined) return "satellites-executing";
|
|
43
|
+
|
|
44
|
+
// An empty assignment set has nothing to be offline. `[].some()` is false, so
|
|
45
|
+
// without this guard a check with no satellites would be reported as
|
|
46
|
+
// unobservable - a degraded run for a configuration that cannot produce one.
|
|
47
|
+
if (satelliteIds.length === 0) return "satellites-executing";
|
|
48
|
+
|
|
49
|
+
const online = new Set(onlineSatelliteIds);
|
|
50
|
+
const anyOnline = satelliteIds.some((id) => online.has(id));
|
|
51
|
+
|
|
52
|
+
return anyOnline ? "satellites-executing" : "record-unobservable";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The result payload recorded for an unobservable run. */
|
|
56
|
+
export function buildUnobservableResult({
|
|
57
|
+
satelliteIds,
|
|
58
|
+
}: {
|
|
59
|
+
satelliteIds: readonly string[];
|
|
60
|
+
}): Record<string, unknown> {
|
|
61
|
+
const count = satelliteIds.length;
|
|
62
|
+
return {
|
|
63
|
+
error:
|
|
64
|
+
`No assigned satellite is online (${count} assigned), so this check could not be executed. ` +
|
|
65
|
+
"The target's actual health is unknown - this is a monitoring gap, not a confirmed outage.",
|
|
66
|
+
satelliteOffline: true,
|
|
67
|
+
assignedSatelliteCount: count,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The `persistRunAndReact` arguments for an unobservable run.
|
|
73
|
+
*
|
|
74
|
+
* Extracted so the RECORDED VALUES - degraded, the payload's environment slice,
|
|
75
|
+
* the local source label - are pinned by a test. The executor's mock database
|
|
76
|
+
* cannot service `persistRunAndReact`'s insert/aggregate chain, so the only way
|
|
77
|
+
* to assert what gets written is to make the decision about what to write a
|
|
78
|
+
* separate, pure step.
|
|
79
|
+
*/
|
|
80
|
+
export function buildUnobservableRun({
|
|
81
|
+
environmentId,
|
|
82
|
+
satelliteIds,
|
|
83
|
+
}: {
|
|
84
|
+
/** The single (config, system, env) slice this job owns. */
|
|
85
|
+
environmentId: string | null;
|
|
86
|
+
satelliteIds: readonly string[];
|
|
87
|
+
}): {
|
|
88
|
+
status: "degraded";
|
|
89
|
+
environmentId: string | null;
|
|
90
|
+
sourceLabel: string;
|
|
91
|
+
result: Record<string, unknown>;
|
|
92
|
+
} {
|
|
93
|
+
return {
|
|
94
|
+
// Degraded, NOT unhealthy: the target may be perfectly healthy and what
|
|
95
|
+
// failed is our ability to observe it. Unhealthy would raise
|
|
96
|
+
// incident-grade alarms about healthy services on every satellite reboot.
|
|
97
|
+
status: "degraded",
|
|
98
|
+
// The job payload already names the slice the satellites would have
|
|
99
|
+
// reported for, so the gap lands exactly where the missing runs would have.
|
|
100
|
+
environmentId,
|
|
101
|
+
// Recorded by the CORE, which is what noticed the gap - not by a satellite,
|
|
102
|
+
// which by definition reported nothing.
|
|
103
|
+
sourceLabel: "Local",
|
|
104
|
+
result: buildUnobservableResult({ satelliteIds }),
|
|
105
|
+
};
|
|
106
|
+
}
|