@checkstack/healthcheck-backend 1.21.3 → 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 +314 -0
- package/drizzle/0021_amazing_wolf_cub.sql +8 -0
- package/drizzle/meta/0021_snapshot.json +717 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +31 -29
- package/src/ai/system-signals-contributor.test.ts +1 -0
- package/src/cache.test.ts +3 -0
- package/src/effective-environments.test.ts +63 -2
- package/src/effective-environments.ts +34 -0
- package/src/health-entity.ts +8 -2
- package/src/health-state.ts +15 -6
- package/src/index.ts +33 -0
- package/src/queue-executor.test.ts +270 -0
- package/src/queue-executor.ts +672 -569
- package/src/router-satellite-ingest.test.ts +136 -0
- package/src/router.ts +65 -4
- package/src/satellite-liveness.test.ts +199 -0
- package/src/satellite-liveness.ts +106 -0
- package/src/schema.ts +21 -0
- package/src/service-batching.test.ts +3 -1
- package/src/service-ingest-assertions.test.ts +33 -60
- package/src/service-paused-filter.test.ts +9 -4
- package/src/service-rollup-worst-wins.test.ts +147 -55
- package/src/service.ts +355 -148
- package/src/state-evaluator.test.ts +49 -0
- package/src/system-health-override.ts +6 -1
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { call } from "@orpc/server";
|
|
3
|
+
import { createMockRpcContext } from "@checkstack/backend-api";
|
|
4
|
+
import { createHealthCheckRouter } from "./router";
|
|
5
|
+
import { createStubHealthCheckCache } from "./cache-test-stub";
|
|
6
|
+
import type { HealthRunReaction } from "./queue-executor";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A satellite result MUST drive the SAME core post-run path a local run does:
|
|
10
|
+
* the reactive `health` entity write, the notification, the automation hooks,
|
|
11
|
+
* etc. The router routes it there via the injected `reactToSatelliteRun`
|
|
12
|
+
* reactor. These tests pin that wiring so ingest can never silently regress to
|
|
13
|
+
* an insert-only path (which is what left satellite-detected outages silent).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// A service principal - ingestSatelliteResult is a backend-to-backend proc.
|
|
17
|
+
const serviceContext = () =>
|
|
18
|
+
createMockRpcContext({
|
|
19
|
+
user: { type: "service", pluginId: "satellite-backend" } as never,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function buildRouter(opts: {
|
|
23
|
+
reactToSatelliteRun?: (run: HealthRunReaction) => Promise<void>;
|
|
24
|
+
transaction?: ReturnType<typeof mock>;
|
|
25
|
+
}) {
|
|
26
|
+
// db.select(...).from(...).where(...) resolves the config row (name +
|
|
27
|
+
// collectors) that processSatelliteResult reads.
|
|
28
|
+
const database = {
|
|
29
|
+
select: mock(() => ({
|
|
30
|
+
from: mock(() => ({
|
|
31
|
+
where: mock(() =>
|
|
32
|
+
Promise.resolve([{ name: "Ping", collectors: [] }]),
|
|
33
|
+
),
|
|
34
|
+
})),
|
|
35
|
+
})),
|
|
36
|
+
transaction:
|
|
37
|
+
opts.transaction ??
|
|
38
|
+
mock(async () => {
|
|
39
|
+
/* not expected in the reactor path */
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const collectorRegistry = {
|
|
44
|
+
register: mock(() => {}),
|
|
45
|
+
getCollector: mock(() => undefined),
|
|
46
|
+
getCollectors: mock(() => []),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const catalogClient = {
|
|
50
|
+
getSystem: mock(async () => ({ id: "sys-1", name: "API Server" })),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return createHealthCheckRouter({
|
|
54
|
+
database: database as never,
|
|
55
|
+
registry: { getStrategy: mock(() => undefined) } as never,
|
|
56
|
+
collectorRegistry: collectorRegistry as never,
|
|
57
|
+
gitOpsClient: {} as never,
|
|
58
|
+
getEmitHook: () => undefined,
|
|
59
|
+
cache: createStubHealthCheckCache(),
|
|
60
|
+
configService: {} as never,
|
|
61
|
+
catalogClient: catalogClient as never,
|
|
62
|
+
maintenanceClient: {} as never,
|
|
63
|
+
logger: {
|
|
64
|
+
debug: mock(() => {}),
|
|
65
|
+
info: mock(() => {}),
|
|
66
|
+
warn: mock(() => {}),
|
|
67
|
+
error: mock(() => {}),
|
|
68
|
+
} as never,
|
|
69
|
+
...(opts.reactToSatelliteRun
|
|
70
|
+
? { reactToSatelliteRun: opts.reactToSatelliteRun }
|
|
71
|
+
: {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ingestInput = {
|
|
76
|
+
configId: "config-1",
|
|
77
|
+
systemId: "sys-1",
|
|
78
|
+
status: "unhealthy" as const,
|
|
79
|
+
latencyMs: 123,
|
|
80
|
+
result: {
|
|
81
|
+
status: "unhealthy" as const,
|
|
82
|
+
latencyMs: 123,
|
|
83
|
+
message: "Check failed",
|
|
84
|
+
metadata: { connected: true, collectors: {} },
|
|
85
|
+
},
|
|
86
|
+
executedAt: "2026-07-03T10:00:00.000Z",
|
|
87
|
+
sourceId: "sat-eu",
|
|
88
|
+
sourceLabel: "EU West",
|
|
89
|
+
environmentId: "env-prod",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
describe("ingestSatelliteResult - drives the shared post-run path", () => {
|
|
93
|
+
it("routes a satellite result through reactToSatelliteRun with its processed payload", async () => {
|
|
94
|
+
const runs: HealthRunReaction[] = [];
|
|
95
|
+
const router = buildRouter({
|
|
96
|
+
reactToSatelliteRun: async (run) => {
|
|
97
|
+
runs.push(run);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await call(router.ingestSatelliteResult, ingestInput, {
|
|
102
|
+
context: serviceContext(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(runs).toHaveLength(1);
|
|
106
|
+
const run = runs[0];
|
|
107
|
+
// The run carries the satellite's SOURCE + environment, and its display
|
|
108
|
+
// name was resolved for the notification the shared path will send.
|
|
109
|
+
expect(run.systemId).toBe("sys-1");
|
|
110
|
+
expect(run.systemName).toBe("API Server");
|
|
111
|
+
expect(run.configId).toBe("config-1");
|
|
112
|
+
expect(run.configName).toBe("Ping");
|
|
113
|
+
expect(run.environmentId).toBe("env-prod");
|
|
114
|
+
expect(run.status).toBe("unhealthy");
|
|
115
|
+
expect(run.sourceId).toBe("sat-eu");
|
|
116
|
+
expect(run.sourceLabel).toBe("EU West");
|
|
117
|
+
expect(run.runTimestamp).toEqual(new Date("2026-07-03T10:00:00.000Z"));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("falls back to an insert-only persist when no reactor is wired", async () => {
|
|
121
|
+
// Record that the insert-only fallback opened a transaction, without
|
|
122
|
+
// executing its body (the aggregate internals need a full db mock and are
|
|
123
|
+
// covered elsewhere); the point is that ingest still records the run.
|
|
124
|
+
const transaction = mock(async () => {});
|
|
125
|
+
const router = buildRouter({ transaction });
|
|
126
|
+
|
|
127
|
+
await call(router.ingestSatelliteResult, ingestInput, {
|
|
128
|
+
context: serviceContext(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// The insert-only fallback ran (a run is still recorded), but there is no
|
|
132
|
+
// reactor path to drive notifications/automations - which is exactly why
|
|
133
|
+
// the real host always wires `reactToSatelliteRun`.
|
|
134
|
+
expect(transaction).toHaveBeenCalledTimes(1);
|
|
135
|
+
});
|
|
136
|
+
});
|
package/src/router.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { CatalogApi } from "@checkstack/catalog-common";
|
|
|
46
46
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
47
47
|
import type { Logger } from "@checkstack/backend-api";
|
|
48
48
|
import type { HealthCheckCache } from "./cache";
|
|
49
|
+
import type { HealthRunReaction } from "./queue-executor";
|
|
49
50
|
import {
|
|
50
51
|
applySystemHealthOverrides,
|
|
51
52
|
type SystemHealthOverrideReader,
|
|
@@ -104,6 +105,17 @@ export const createHealthCheckRouter = (opts: {
|
|
|
104
105
|
* Optional so tests / no-incident deployments simply skip the fold.
|
|
105
106
|
*/
|
|
106
107
|
incidentHealthOverrideReader?: SystemHealthOverrideReader;
|
|
108
|
+
/**
|
|
109
|
+
* Drives the SHARED core post-run path for an ingested satellite result -
|
|
110
|
+
* the reactive `health` entity write, cache reconcile, realtime signal,
|
|
111
|
+
* automation hooks, transition record, and subscriber notification - so a
|
|
112
|
+
* satellite-detected change reacts exactly like a local run. The host binds
|
|
113
|
+
* the service dependencies once (via a `persistRunAndReact` closure) and
|
|
114
|
+
* passes this narrowed reactor, so the local and satellite callers cannot
|
|
115
|
+
* pass different deps and drift. Optional so tests may omit it, in which case
|
|
116
|
+
* ingest falls back to an insert-only persist.
|
|
117
|
+
*/
|
|
118
|
+
reactToSatelliteRun?: (run: HealthRunReaction) => Promise<void>;
|
|
107
119
|
}) => {
|
|
108
120
|
const {
|
|
109
121
|
database,
|
|
@@ -118,6 +130,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
118
130
|
signalService,
|
|
119
131
|
recomputeSystemRollupHealth,
|
|
120
132
|
incidentHealthOverrideReader,
|
|
133
|
+
reactToSatelliteRun,
|
|
121
134
|
} = opts;
|
|
122
135
|
// Create service instance once - shared across all handlers
|
|
123
136
|
const service = new HealthCheckService(
|
|
@@ -612,6 +625,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
612
625
|
enabled: input.body.enabled,
|
|
613
626
|
stateThresholds: input.body.stateThresholds,
|
|
614
627
|
satelliteIds: input.body.satelliteIds,
|
|
628
|
+
satelliteEnvironmentIds: input.body.satelliteEnvironmentIds,
|
|
615
629
|
environmentIds: input.body.environmentIds,
|
|
616
630
|
includeLocal: input.body.includeLocal,
|
|
617
631
|
notificationPolicy: input.body.notificationPolicy,
|
|
@@ -634,6 +648,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
634
648
|
enabled: input.enabled,
|
|
635
649
|
stateThresholds: input.stateThresholds,
|
|
636
650
|
satelliteIds: input.satelliteIds,
|
|
651
|
+
satelliteEnvironmentIds: input.satelliteEnvironmentIds,
|
|
637
652
|
environmentIds: input.environmentIds,
|
|
638
653
|
includeLocal: input.includeLocal,
|
|
639
654
|
notificationPolicy: input.notificationPolicy,
|
|
@@ -899,10 +914,56 @@ export const createHealthCheckRouter = (opts: {
|
|
|
899
914
|
|
|
900
915
|
ingestSatelliteResult: os.ingestSatelliteResult.handler(
|
|
901
916
|
async ({ input }) => {
|
|
902
|
-
|
|
903
|
-
//
|
|
904
|
-
|
|
905
|
-
|
|
917
|
+
// Process the satellite's raw result (evaluate assertions, strip
|
|
918
|
+
// ephemeral fields, resolve the check name) WITHOUT persisting...
|
|
919
|
+
const { status, resultRecord, configName } =
|
|
920
|
+
await service.processSatelliteResult({
|
|
921
|
+
configId: input.configId,
|
|
922
|
+
status: input.status,
|
|
923
|
+
result: input.result,
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
if (reactToSatelliteRun) {
|
|
927
|
+
// ...then persist + REACT through the SAME core post-run path a local
|
|
928
|
+
// run uses (reactive entity, cache reconcile, signals, automation
|
|
929
|
+
// hooks, transition, notification). Previously ingest only inserted
|
|
930
|
+
// the row, so a satellite-detected outage fired no notifications or
|
|
931
|
+
// automations - routing it here is what keeps the two from drifting.
|
|
932
|
+
const system = await catalogClient
|
|
933
|
+
.getSystem({ systemId: input.systemId })
|
|
934
|
+
.catch(() => null);
|
|
935
|
+
await reactToSatelliteRun({
|
|
936
|
+
systemId: input.systemId,
|
|
937
|
+
systemName: system?.name ?? input.systemId,
|
|
938
|
+
configId: input.configId,
|
|
939
|
+
configName,
|
|
940
|
+
environmentId: input.environmentId ?? null,
|
|
941
|
+
// The env NAME is intentionally omitted (no extra catalog
|
|
942
|
+
// round-trip): the notifier falls back to the system name.
|
|
943
|
+
status,
|
|
944
|
+
latencyMs: input.latencyMs,
|
|
945
|
+
result: resultRecord,
|
|
946
|
+
sourceId: input.sourceId,
|
|
947
|
+
sourceLabel: input.sourceLabel,
|
|
948
|
+
runTimestamp: new Date(input.executedAt),
|
|
949
|
+
});
|
|
950
|
+
} else {
|
|
951
|
+
// No reactor wired (older host / test harness): fall back to a bare
|
|
952
|
+
// persist + cache refresh so the run is still recorded. The real host
|
|
953
|
+
// always wires the reactor above.
|
|
954
|
+
await service.insertSatelliteRun({
|
|
955
|
+
configId: input.configId,
|
|
956
|
+
systemId: input.systemId,
|
|
957
|
+
environmentId: input.environmentId ?? null,
|
|
958
|
+
status,
|
|
959
|
+
latencyMs: input.latencyMs,
|
|
960
|
+
result: resultRecord,
|
|
961
|
+
sourceId: input.sourceId,
|
|
962
|
+
sourceLabel: input.sourceLabel,
|
|
963
|
+
executedAt: input.executedAt,
|
|
964
|
+
});
|
|
965
|
+
await cache.invalidateSystem(input.systemId);
|
|
966
|
+
}
|
|
906
967
|
},
|
|
907
968
|
),
|
|
908
969
|
|
|
@@ -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
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -111,6 +111,27 @@ export const systemHealthChecks = pgTable(
|
|
|
111
111
|
* `length === 0`; jsonb stores both faithfully.
|
|
112
112
|
*/
|
|
113
113
|
environmentIds: jsonb("environment_ids").$type<string[]>(),
|
|
114
|
+
/**
|
|
115
|
+
* Per-SATELLITE environment scoping, keyed by satellite id.
|
|
116
|
+
*
|
|
117
|
+
* Additive on top of `satelliteIds`, which still answers "which satellites
|
|
118
|
+
* run this". This answers "and for which environments", so a prod satellite
|
|
119
|
+
* can run only the prod environment instead of probing every environment
|
|
120
|
+
* from a network it may have no route to.
|
|
121
|
+
*
|
|
122
|
+
* Semantics per entry, mirroring `environmentIds`:
|
|
123
|
+
* - key ABSENT (or `null`) => that satellite runs every environment the
|
|
124
|
+
* assignment resolves to. This is the backfilled default, so existing
|
|
125
|
+
* rows keep behaving as they did.
|
|
126
|
+
* - `[]` => opt out: that satellite runs ONCE with no environment.
|
|
127
|
+
* - non-empty => exactly those ids, INTERSECTED with the assignment's own
|
|
128
|
+
* effective set - a satellite can never widen the assignment's scope,
|
|
129
|
+
* only narrow it.
|
|
130
|
+
*/
|
|
131
|
+
satelliteEnvironmentIds:
|
|
132
|
+
jsonb("satellite_environment_ids").$type<
|
|
133
|
+
Record<string, string[] | null>
|
|
134
|
+
>(),
|
|
114
135
|
/**
|
|
115
136
|
* Whether to also run this check locally on the core instance.
|
|
116
137
|
* Defaults to true. Only relevant when satelliteIds is set.
|
|
@@ -97,7 +97,9 @@ describe("HealthCheckService.getSystemHealthStatus - read batching", () => {
|
|
|
97
97
|
|
|
98
98
|
const result = await service.getSystemHealthStatus("system-1");
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
// `unknown`, not `healthy`: a system with no checks is unmeasured, and this
|
|
101
|
+
// test is about transaction batching, not about inventing a status.
|
|
102
|
+
expect(result.status).toBe("unknown");
|
|
101
103
|
expect(result.checkStatuses).toHaveLength(0);
|
|
102
104
|
const transaction = (mockDb as unknown as { transaction: ReturnType<typeof mock> })
|
|
103
105
|
.transaction;
|