@checkstack/healthcheck-backend 1.16.0 → 1.17.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 CHANGED
@@ -1,5 +1,139 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 390d9cf: Add a **Container** health-check strategy for monitoring Docker and Podman
8
+ containers that expose no external service of their own. It reports container
9
+ existence, running state, healthcheck status, exit code, restart count, and
10
+ OOM-killed via the **Container Status** collector, and CPU/memory usage via the
11
+ **Container Stats** collector. Both collectors issue only read (GET) requests
12
+ against the runtime REST API.
13
+
14
+ The check runs wherever the executor runs: locally on the core instance (the
15
+ default) to watch containers that share a host with Checkstack, or on a
16
+ satellite pinned to another host.
17
+
18
+ Critically, Checkstack never touches the raw container socket. The strategy
19
+ talks the Docker Engine / Podman libpod API over either a unix socket path or an
20
+ `http(s)` endpoint, so operators point it at a **read-only socket-proxy**
21
+ (`lscr.io/linuxserver/socket-proxy` with `POST=0`) running next to whichever
22
+ Checkstack instance runs the check - core or a satellite - or at a rootless
23
+ Podman socket. The raw socket is mounted only into the proxy; even a compromised
24
+ instance can only read container state, never control the host. A stopped or missing container is a successful collection whose metrics
25
+ feed assertions (following the transport-failure-vs-metric rule) - only an
26
+ unreachable runtime endpoint fails the check. Container `exec` probes are
27
+ intentionally not offered because they would require write access to the socket.
28
+
29
+ To support in-product setup guidance, the health-check strategy contract gains
30
+ an optional `setupInstructions` (Markdown) field, surfaced in the DTO and
31
+ rendered as a collapsible "Setup guide" callout above the strategy config fields
32
+ in the editor. The Container strategy populates it with the secure proxy setup.
33
+
34
+ The hardened socket-proxy compose is maintained as a single canonical file
35
+ (`deploy/socket-proxy/docker-compose.yml`) that operators `include:` from their
36
+ core or satellite compose, so the read-only / `POST=0` / internal-network
37
+ hardening is defined in exactly one place; the docs and the in-product setup
38
+ guide reference it rather than duplicating the YAML.
39
+
40
+ Also removes a stale hand-written `HealthCheckStrategyDto` interface in
41
+ `@checkstack/healthcheck-common` that shadowed (and lagged behind) the
42
+ Zod-inferred DTO; the inferred type from `schemas.ts` is now the single source
43
+ of truth and correctly carries `resultSchema`, `aggregatedResultSchema`, and the
44
+ new `setupInstructions`.
45
+
46
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback
47
+ that shaped this release.
48
+
49
+ - fc64fad: Dependencies can now be scoped to a specific environment and/or health check of
50
+ the upstream system, each with its own severity - a "matrix" of scope cells.
51
+
52
+ Previously a dependency watched the upstream's overall health (any check, any
53
+ environment) at the edge's impact type, with optional per-check rules. That
54
+ default is unchanged: with no scope cells configured, the dependency behaves
55
+ exactly as before. Now each cell pins a check (a specific configuration, or
56
+ "any"), an environment (a specific environment, or "any"), and a severity
57
+ (informational / degraded / critical). When a dependency has any cells, only
58
+ those slices are watched (they replace the whole-system watch) and the worst
59
+ result across cells wins. This lets you express, e.g., "System A depends on
60
+ System B only in `prod`", or "only when B's TLS check in `prod` fails", and lets
61
+ different cells carry different severities.
62
+
63
+ Because each environment is evaluated on its own slice, a scoped dependency
64
+ catches an environment-specific outage that the upstream's overall status
65
+ (worst-wins across environments) would otherwise hide. The dependency evaluator
66
+ now reads per-(check, environment) health via a new
67
+ `@checkstack/healthcheck-common` bulk contract `getBulkSystemHealthMatrix` (and
68
+ its `@checkstack/healthcheck-backend` implementation), which returns each
69
+ system's cross-environment rollup plus a per-environment slice. Incident
70
+ overrides still fold into the overall rollup, so incident-forced statuses keep
71
+ propagating through dependencies.
72
+
73
+ The scope-cell store gains a nullable `environment_id` column and makes
74
+ `health_check_id` nullable (forward-only migration; existing rows keep working
75
+ as "any check, any environment"). The dependency editor's per-check panel
76
+ becomes a scope-matrix editor with check + environment + severity rows.
77
+
78
+ Transitive (multi-hop) dependencies still cascade using the upstream's overall
79
+ status; per-environment cascades across multiple hops are not yet propagated.
80
+
81
+ - 9d30324: Incidents can now optionally override the health status of their affected
82
+ systems. When creating or editing an incident you can pick "Override system
83
+ health" (Degraded or Unhealthy); while the incident is active (not resolved)
84
+ that status is folded into every affected system's derived health via
85
+ worst-wins, so it shows on every health surface (status pages, dashboards,
86
+ dependency map, catalog badges). A health check reporting a worse status still
87
+ wins, and the override lifts automatically when the incident resolves. This
88
+ covers components that no automated check can monitor (e.g. a running app whose
89
+ licenses were revoked so it won't open).
90
+
91
+ The override is a deliberate operator choice, independent of the incident's
92
+ severity. A new service-typed incident RPC `getActiveHealthOverrides` exposes
93
+ active overrides per system, which `@checkstack/healthcheck-backend` reads and
94
+ folds into `getSystemHealthStatus`. The system-health response gains an optional
95
+ `override` field naming the contributing incident so UIs can explain why a
96
+ system reads unhealthy when its checks look fine. The system health badge uses
97
+ it to show, on hover, when a status was forced by an incident.
98
+
99
+ The dashboard "problem system" signal attributes an override-forced status to
100
+ the incident ("Forced by incident: <title>") instead of misreporting
101
+ "0 of N checks failing", while a genuinely worse health check still drives the
102
+ signal and its detail. Public status pages reflect the forced status but never
103
+ carry the incident title (the widget DTOs project only the status), so an
104
+ override cannot leak the name of a hidden incident.
105
+
106
+ Behavior change: a system's derived health now reflects active incident
107
+ overrides in addition to its health checks. Adds a forward-only migration for
108
+ the new nullable `incidents.health_override` column.
109
+
110
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback
111
+ that shaped this release.
112
+
113
+ ### Patch Changes
114
+
115
+ - Updated dependencies [390d9cf]
116
+ - Updated dependencies [390d9cf]
117
+ - Updated dependencies [fc64fad]
118
+ - Updated dependencies [fc64fad]
119
+ - Updated dependencies [9d30324]
120
+ - Updated dependencies [9d30324]
121
+ - Updated dependencies [b218e3e]
122
+ - @checkstack/ai-backend@0.10.8
123
+ - @checkstack/backend-api@0.30.0
124
+ - @checkstack/healthcheck-common@1.14.0
125
+ - @checkstack/incident-common@1.8.0
126
+ - @checkstack/incident-backend@1.10.0
127
+ - @checkstack/automation-backend@0.10.10
128
+ - @checkstack/catalog-backend@1.6.8
129
+ - @checkstack/command-backend@0.2.20
130
+ - @checkstack/gitops-backend@0.5.20
131
+ - @checkstack/satellite-backend@0.8.2
132
+ - @checkstack/script-packages-backend@0.3.24
133
+ - @checkstack/secrets-backend@0.3.2
134
+ - @checkstack/status-page-backend@0.4.7
135
+ - @checkstack/sdk@0.125.1
136
+
3
137
  ## 1.16.0
4
138
 
5
139
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,31 +14,31 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.10.7",
17
+ "@checkstack/ai-backend": "0.10.8",
18
18
  "@checkstack/ai-common": "0.6.5",
19
- "@checkstack/automation-backend": "0.10.9",
20
- "@checkstack/backend-api": "0.29.1",
19
+ "@checkstack/automation-backend": "0.10.10",
20
+ "@checkstack/backend-api": "0.30.0",
21
21
  "@checkstack/cache-api": "0.3.18",
22
22
  "@checkstack/cache-utils": "0.2.23",
23
- "@checkstack/catalog-backend": "1.6.7",
23
+ "@checkstack/catalog-backend": "1.6.8",
24
24
  "@checkstack/catalog-common": "2.6.2",
25
- "@checkstack/command-backend": "0.2.19",
25
+ "@checkstack/command-backend": "0.2.20",
26
26
  "@checkstack/common": "0.21.0",
27
- "@checkstack/gitops-backend": "0.5.19",
27
+ "@checkstack/gitops-backend": "0.5.20",
28
28
  "@checkstack/gitops-common": "0.7.2",
29
- "@checkstack/healthcheck-common": "1.13.0",
30
- "@checkstack/incident-backend": "1.9.5",
31
- "@checkstack/incident-common": "1.7.2",
29
+ "@checkstack/healthcheck-common": "1.14.0",
30
+ "@checkstack/incident-backend": "1.10.0",
31
+ "@checkstack/incident-common": "1.8.0",
32
32
  "@checkstack/maintenance-common": "1.8.2",
33
33
  "@checkstack/notification-common": "1.5.2",
34
34
  "@checkstack/queue-api": "0.3.18",
35
- "@checkstack/satellite-backend": "0.8.1",
36
- "@checkstack/script-packages-backend": "0.3.23",
37
- "@checkstack/sdk": "0.123.1",
38
- "@checkstack/secrets-backend": "0.3.1",
35
+ "@checkstack/satellite-backend": "0.8.2",
36
+ "@checkstack/script-packages-backend": "0.3.24",
37
+ "@checkstack/sdk": "0.125.1",
38
+ "@checkstack/secrets-backend": "0.3.2",
39
39
  "@checkstack/secrets-common": "0.3.1",
40
40
  "@checkstack/signal-common": "0.2.16",
41
- "@checkstack/status-page-backend": "0.4.6",
41
+ "@checkstack/status-page-backend": "0.4.7",
42
42
  "@checkstack/status-page-common": "0.5.2",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@orpc/contract": "^1.14.4",
@@ -53,7 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@checkstack/drizzle-helper": "0.0.6",
55
55
  "@checkstack/scripts": "0.7.2",
56
- "@checkstack/test-utils-backend": "0.1.53",
56
+ "@checkstack/test-utils-backend": "0.1.54",
57
57
  "@checkstack/tsconfig": "0.0.7",
58
58
  "@types/bun": "^1.0.0",
59
59
  "@types/tdigest": "^0.1.5",
package/src/index.ts CHANGED
@@ -524,6 +524,35 @@ export default createBackendPlugin({
524
524
  logger,
525
525
  signalService,
526
526
  configSecrets,
527
+ // Fold active incident health overrides into the user-facing system
528
+ // health reads (worst-wins). Reuses the incident client already built
529
+ // above; maps the incident rows to the source-agnostic override shape
530
+ // the fold expects. Kept out of the shared deriver so SLO/AI/entity
531
+ // paths stay checks-only (see router.ts).
532
+ incidentHealthOverrideReader: {
533
+ getActiveOverrides: async (systemIds) => {
534
+ const { overrides } =
535
+ await incidentClient.getActiveHealthOverrides({ systemIds });
536
+ const mapped: Record<
537
+ string,
538
+ {
539
+ status: "degraded" | "unhealthy";
540
+ source: string;
541
+ reason: string;
542
+ sourceId?: string;
543
+ }[]
544
+ > = {};
545
+ for (const [systemId, list] of Object.entries(overrides)) {
546
+ mapped[systemId] = list.map((o) => ({
547
+ status: o.status,
548
+ source: "incident",
549
+ reason: o.incidentTitle,
550
+ sourceId: o.incidentId,
551
+ }));
552
+ }
553
+ return mapped;
554
+ },
555
+ },
527
556
  recomputeSystemRollupHealth: (systemId) =>
528
557
  recomputeSystemRollupHealth({
529
558
  systemId,
package/src/router.ts CHANGED
@@ -39,6 +39,11 @@ import { CatalogApi } from "@checkstack/catalog-common";
39
39
  import { MaintenanceApi } from "@checkstack/maintenance-common";
40
40
  import type { Logger } from "@checkstack/backend-api";
41
41
  import type { HealthCheckCache } from "./cache";
42
+ import {
43
+ applySystemHealthOverrides,
44
+ type SystemHealthOverrideReader,
45
+ } from "./system-health-override";
46
+ import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
42
47
 
43
48
  /**
44
49
  * Creates the healthcheck router using contract-based implementation.
@@ -81,6 +86,17 @@ export const createHealthCheckRouter = (opts: {
81
86
  * router MUST receive it or writes would store inline secrets verbatim.
82
87
  */
83
88
  configSecrets?: HealthCheckSecretsDeps;
89
+ /**
90
+ * Reads active incident health overrides and folds them into the two
91
+ * user-facing system-health reads (single + bulk) via worst-wins, so a system
92
+ * shows the status an active incident forces even when its checks look fine.
93
+ * Applied OUTSIDE the status cache (always live, so an override lifts the
94
+ * instant its incident resolves) and ONLY in these RPC handlers - never in the
95
+ * shared `getSystemHealthStatus` deriver, whose other callers (SLO downtime,
96
+ * the AI signals scan, the persisted `health` entity) must stay checks-only.
97
+ * Optional so tests / no-incident deployments simply skip the fold.
98
+ */
99
+ incidentHealthOverrideReader?: SystemHealthOverrideReader;
84
100
  }) => {
85
101
  const {
86
102
  database,
@@ -94,6 +110,7 @@ export const createHealthCheckRouter = (opts: {
94
110
  logger,
95
111
  signalService,
96
112
  recomputeSystemRollupHealth,
113
+ incidentHealthOverrideReader,
97
114
  } = opts;
98
115
  // Create service instance once - shared across all handlers
99
116
  const service = new HealthCheckService(
@@ -105,6 +122,43 @@ export const createHealthCheckRouter = (opts: {
105
122
  opts.configSecrets,
106
123
  );
107
124
 
125
+ /**
126
+ * Fold active incident health overrides into a batch of checks-only system
127
+ * statuses via worst-wins. Reads overrides for all systems in ONE incident RPC
128
+ * (or none, when no reader is wired). Resilient by design: incidents are a
129
+ * best-effort enrichment of health, so if the read fails the checks-only
130
+ * statuses are returned unchanged rather than failing the whole health read.
131
+ */
132
+ const foldIncidentOverrides = async (
133
+ statuses: Record<string, SystemHealthStatusResponse>,
134
+ ): Promise<Record<string, SystemHealthStatusResponse>> => {
135
+ const systemIds = Object.keys(statuses);
136
+ if (!incidentHealthOverrideReader || systemIds.length === 0) {
137
+ return statuses;
138
+ }
139
+ let overridesBySystem: Awaited<
140
+ ReturnType<SystemHealthOverrideReader["getActiveOverrides"]>
141
+ >;
142
+ try {
143
+ overridesBySystem =
144
+ await incidentHealthOverrideReader.getActiveOverrides(systemIds);
145
+ } catch (error) {
146
+ logger.warn(
147
+ "Failed to read incident health overrides; returning checks-only status",
148
+ { error: extractErrorMessage(error) },
149
+ );
150
+ return statuses;
151
+ }
152
+ const folded: Record<string, SystemHealthStatusResponse> = {};
153
+ for (const [systemId, base] of Object.entries(statuses)) {
154
+ folded[systemId] = applySystemHealthOverrides({
155
+ base,
156
+ overrides: overridesBySystem[systemId] ?? [],
157
+ });
158
+ }
159
+ return folded;
160
+ };
161
+
108
162
  // Create contract implementer with context type AND auto auth middleware
109
163
  const os = implement(healthCheckContract)
110
164
  .$context<RpcContext>()
@@ -201,6 +255,7 @@ export const createHealthCheckRouter = (opts: {
201
255
  displayName: r.strategy.displayName,
202
256
  description: r.strategy.description,
203
257
  category: (r.strategy.category ?? "other") as StrategyCategory,
258
+ setupInstructions: r.strategy.setupInstructions,
204
259
  configSchema: toJsonSchema(r.strategy.config.schema),
205
260
  resultSchema: r.strategy.result
206
261
  ? toJsonSchemaWithChartMeta(r.strategy.result.schema)
@@ -622,9 +677,13 @@ export const createHealthCheckRouter = (opts: {
622
677
  ),
623
678
  getSystemHealthStatus: os.getSystemHealthStatus.handler(
624
679
  async ({ input }) => {
625
- return cache.wrapSystemHealthStatus(input.systemId, () =>
680
+ const base = await cache.wrapSystemHealthStatus(input.systemId, () =>
626
681
  service.getSystemHealthStatus(input.systemId),
627
682
  );
683
+ const folded = await foldIncidentOverrides({
684
+ [input.systemId]: base,
685
+ });
686
+ return folded[input.systemId]!;
628
687
  },
629
688
  ),
630
689
 
@@ -634,10 +693,7 @@ export const createHealthCheckRouter = (opts: {
634
693
  // and invalidated by id on mutations, so dashboards with overlapping
635
694
  // (but non-identical) system sets share cache entries. See
636
695
  // ./cache.ts for the key/TTL/invalidation contract.
637
- const statuses: Record<
638
- string,
639
- Awaited<ReturnType<typeof service.getSystemHealthStatus>>
640
- > = {};
696
+ const statuses: Record<string, SystemHealthStatusResponse> = {};
641
697
  await Promise.all(
642
698
  input.systemIds.map(async (systemId) => {
643
699
  statuses[systemId] = await cache.wrapSystemHealthStatus(
@@ -646,6 +702,37 @@ export const createHealthCheckRouter = (opts: {
646
702
  );
647
703
  }),
648
704
  );
705
+ return { statuses: await foldIncidentOverrides(statuses) };
706
+ },
707
+ ),
708
+
709
+ getBulkSystemHealthMatrix: os.getBulkSystemHealthMatrix.handler(
710
+ async ({ input }) => {
711
+ const matrix = await service.getBulkSystemHealthMatrix(input.systemIds);
712
+
713
+ // Fold active incident overrides into each system's OVERALL rollup, so
714
+ // an incident-forced status still propagates through dependencies (as
715
+ // it does via getBulkSystemHealthStatus). Per-environment slices track
716
+ // health-check status only - incidents force whole-system health, which
717
+ // any-environment (env=null) dependency cells read from this rollup.
718
+ const overallOnly: Record<string, SystemHealthStatusResponse> = {};
719
+ for (const [systemId, m] of Object.entries(matrix)) {
720
+ overallOnly[systemId] = {
721
+ status: m.status,
722
+ evaluatedAt: new Date(),
723
+ checkStatuses: m.checkStatuses,
724
+ };
725
+ }
726
+ const folded = await foldIncidentOverrides(overallOnly);
727
+
728
+ const statuses: Record<string, (typeof matrix)[string]> = {};
729
+ for (const [systemId, m] of Object.entries(matrix)) {
730
+ statuses[systemId] = {
731
+ status: folded[systemId]?.status ?? m.status,
732
+ checkStatuses: m.checkStatuses,
733
+ environments: m.environments,
734
+ };
735
+ }
649
736
  return { statuses };
650
737
  },
651
738
  ),
package/src/service.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  gte,
42
42
  lte,
43
43
  isNull,
44
+ isNotNull,
44
45
  inArray,
45
46
  } from "drizzle-orm";
46
47
  import { ORPCError } from "@orpc/server";
@@ -1341,6 +1342,98 @@ export class HealthCheckService {
1341
1342
  return Object.fromEntries(entries);
1342
1343
  }
1343
1344
 
1345
+ /**
1346
+ * Bulk per-(system, check, environment) health for the given systems.
1347
+ *
1348
+ * For each system returns the cross-environment rollup (status +
1349
+ * checkStatuses, same as {@link getSystemHealthStatus}) PLUS a slice per
1350
+ * environment the system has runs for. Consumers that scope by environment
1351
+ * (the dependency map) must read the per-environment slice, because the
1352
+ * rollup deliberately hides a single failing environment.
1353
+ *
1354
+ * Cost scales with the number of environments each system actually fans out
1355
+ * to (`1 + #envs` status evaluations per system); systems with only env-less
1356
+ * runs cost the same as a plain rollup read. Not on any per-run hot path.
1357
+ */
1358
+ async getBulkSystemHealthMatrix(systemIds: string[]): Promise<
1359
+ Record<
1360
+ string,
1361
+ {
1362
+ status: HealthCheckStatus;
1363
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1364
+ environments: Record<
1365
+ string,
1366
+ {
1367
+ status: HealthCheckStatus;
1368
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1369
+ }
1370
+ >;
1371
+ }
1372
+ >
1373
+ > {
1374
+ const result: Record<
1375
+ string,
1376
+ {
1377
+ status: HealthCheckStatus;
1378
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1379
+ environments: Record<
1380
+ string,
1381
+ {
1382
+ status: HealthCheckStatus;
1383
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1384
+ }
1385
+ >;
1386
+ }
1387
+ > = {};
1388
+
1389
+ await Promise.all(
1390
+ systemIds.map(async (systemId) => {
1391
+ const overall = await this.getSystemHealthStatus(systemId);
1392
+
1393
+ // Environments this system actually has runs for (env-less excluded -
1394
+ // it is folded into the rollup and never a real environment id).
1395
+ const envRows = await this.db
1396
+ .selectDistinct({ environmentId: healthCheckRuns.environmentId })
1397
+ .from(healthCheckRuns)
1398
+ .where(
1399
+ and(
1400
+ eq(healthCheckRuns.systemId, systemId),
1401
+ isNotNull(healthCheckRuns.environmentId),
1402
+ ),
1403
+ );
1404
+
1405
+ const environments: Record<
1406
+ string,
1407
+ {
1408
+ status: HealthCheckStatus;
1409
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1410
+ }
1411
+ > = {};
1412
+ await Promise.all(
1413
+ envRows.map(async ({ environmentId }) => {
1414
+ if (!environmentId) return;
1415
+ const envStatus = await this.getSystemHealthStatus(
1416
+ systemId,
1417
+ environmentId,
1418
+ );
1419
+ environments[environmentId] = {
1420
+ status: envStatus.status,
1421
+ checkStatuses: envStatus.checkStatuses,
1422
+ };
1423
+ }),
1424
+ );
1425
+
1426
+ result[systemId] = {
1427
+ status: overall.status,
1428
+ checkStatuses: overall.checkStatuses,
1429
+ environments,
1430
+ };
1431
+ }),
1432
+ );
1433
+
1434
+ return result;
1435
+ }
1436
+
1344
1437
  /**
1345
1438
  * Get comprehensive health overview for a system.
1346
1439
  * Returns all health checks with their last 25 runs for sparkline visualization.
@@ -91,7 +91,17 @@ async function healthStatuses(
91
91
  const { statuses } = await ctx.rpcClient
92
92
  .forPlugin(HealthCheckApi)
93
93
  .getBulkSystemHealthStatus({ systemIds: ids });
94
- return statuses;
94
+ // Project to ONLY the derived status. `getBulkSystemHealthStatus` folds active
95
+ // incident overrides into `status` (so the public page shows the forced
96
+ // status), but the response also carries `override.reason` = the incident
97
+ // TITLE. Status pages are public and incidents may be hidden, so we drop
98
+ // everything but the status here - the incident name must never reach a public
99
+ // widget DTO.
100
+ const projected: Record<string, { status: string } | undefined> = {};
101
+ for (const [systemId, value] of Object.entries(statuses)) {
102
+ projected[systemId] = value ? { status: value.status } : undefined;
103
+ }
104
+ return projected;
95
105
  }
96
106
 
97
107
  function publicStatus({
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ applySystemHealthOverrides,
4
+ worstHealthStatus,
5
+ type SystemHealthOverrideInput,
6
+ } from "./system-health-override";
7
+ import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
8
+
9
+ const at = new Date("2026-07-05T00:00:00.000Z");
10
+
11
+ function base(
12
+ status: SystemHealthStatusResponse["status"],
13
+ ): SystemHealthStatusResponse {
14
+ return { status, evaluatedAt: at, checkStatuses: [] };
15
+ }
16
+
17
+ function override(
18
+ status: "degraded" | "unhealthy",
19
+ sourceId: string,
20
+ ): SystemHealthOverrideInput {
21
+ return {
22
+ status,
23
+ source: "incident",
24
+ reason: `Incident ${sourceId}`,
25
+ sourceId,
26
+ };
27
+ }
28
+
29
+ describe("worstHealthStatus", () => {
30
+ it("orders unhealthy > degraded > healthy", () => {
31
+ expect(worstHealthStatus("healthy", "degraded")).toBe("degraded");
32
+ expect(worstHealthStatus("degraded", "unhealthy")).toBe("unhealthy");
33
+ expect(worstHealthStatus("unhealthy", "degraded")).toBe("unhealthy");
34
+ expect(worstHealthStatus("healthy", "healthy")).toBe("healthy");
35
+ });
36
+ });
37
+
38
+ describe("applySystemHealthOverrides", () => {
39
+ it("returns the base unchanged (no override field) when there are no overrides", () => {
40
+ const result = applySystemHealthOverrides({
41
+ base: base("healthy"),
42
+ overrides: [],
43
+ });
44
+ expect(result.status).toBe("healthy");
45
+ expect(result.override).toBeUndefined();
46
+ });
47
+
48
+ it("raises a healthy system to the override status and records the source", () => {
49
+ const result = applySystemHealthOverrides({
50
+ base: base("healthy"),
51
+ overrides: [override("unhealthy", "inc-1")],
52
+ });
53
+ expect(result.status).toBe("unhealthy");
54
+ expect(result.override).toEqual({
55
+ status: "unhealthy",
56
+ source: "incident",
57
+ reason: "Incident inc-1",
58
+ sourceId: "inc-1",
59
+ });
60
+ });
61
+
62
+ it("applies an override even when the system has no health checks", () => {
63
+ const result = applySystemHealthOverrides({
64
+ base: base("healthy"), // no-checks default is healthy
65
+ overrides: [override("degraded", "inc-2")],
66
+ });
67
+ expect(result.status).toBe("degraded");
68
+ expect(result.override?.status).toBe("degraded");
69
+ });
70
+
71
+ it("keeps the worse HEALTH CHECK status when a check is worse than the override", () => {
72
+ // Override says degraded, but a check reports unhealthy -> unhealthy wins.
73
+ const result = applySystemHealthOverrides({
74
+ base: base("unhealthy"),
75
+ overrides: [override("degraded", "inc-3")],
76
+ });
77
+ expect(result.status).toBe("unhealthy");
78
+ // The override is still surfaced (it contributed), at its own status.
79
+ expect(result.override?.status).toBe("degraded");
80
+ });
81
+
82
+ it("picks the worst override when several incidents override the same system", () => {
83
+ const result = applySystemHealthOverrides({
84
+ base: base("healthy"),
85
+ overrides: [
86
+ override("degraded", "inc-a"),
87
+ override("unhealthy", "inc-b"),
88
+ override("degraded", "inc-c"),
89
+ ],
90
+ });
91
+ expect(result.status).toBe("unhealthy");
92
+ expect(result.override?.sourceId).toBe("inc-b");
93
+ });
94
+ });
@@ -0,0 +1,93 @@
1
+ import type {
2
+ HealthCheckStatus,
3
+ SystemHealthStatusResponse,
4
+ SystemHealthOverride,
5
+ } from "@checkstack/healthcheck-common";
6
+
7
+ /**
8
+ * Worst-wins ordering for the derived health vocabulary: a higher rank is a
9
+ * worse status. Mirrors the inline `unhealthy > degraded > healthy` ordering the
10
+ * per-check rollup uses in `service.ts`, extracted so the incident-override fold
11
+ * shares the exact same comparison.
12
+ */
13
+ const HEALTH_RANK: Record<HealthCheckStatus, number> = {
14
+ healthy: 0,
15
+ degraded: 1,
16
+ unhealthy: 2,
17
+ };
18
+
19
+ /** Returns whichever status is worse (ties return `a`). */
20
+ export function worstHealthStatus(
21
+ a: HealthCheckStatus,
22
+ b: HealthCheckStatus,
23
+ ): HealthCheckStatus {
24
+ return HEALTH_RANK[b] > HEALTH_RANK[a] ? b : a;
25
+ }
26
+
27
+ /**
28
+ * A non-health-check contribution to a system's health, as consumed by the
29
+ * fold. Kept source-agnostic (`source`/`sourceId`) so the health plugin does not
30
+ * hard-code incident semantics; the incident-backed reader maps its rows into
31
+ * this shape.
32
+ */
33
+ export interface SystemHealthOverrideInput {
34
+ /** The status this contributor forces. Never `healthy` in practice. */
35
+ status: HealthCheckStatus;
36
+ /** Contributor kind, e.g. "incident". */
37
+ source: string;
38
+ /** Human-readable reason, e.g. the incident title. */
39
+ reason: string;
40
+ /** Opaque id of the contributing record, e.g. the incident id. */
41
+ sourceId?: string;
42
+ }
43
+
44
+ /**
45
+ * Reads active health overrides for a set of systems. Implemented in the plugin
46
+ * wiring over the incident RPC; injected into the health service so the service
47
+ * stays free of a direct incident dependency and tests can stub it.
48
+ */
49
+ export interface SystemHealthOverrideReader {
50
+ getActiveOverrides(
51
+ systemIds: string[],
52
+ ): Promise<Record<string, SystemHealthOverrideInput[]>>;
53
+ }
54
+
55
+ /**
56
+ * Fold a system's active overrides into its health-check-derived status via
57
+ * worst-wins. The overall status becomes the worst of the checks-derived status
58
+ * and every override, so an override that raises a system to `degraded` never
59
+ * masks a health check reporting `unhealthy` (the worse status always wins), and
60
+ * an override applies even when the system has no health checks at all.
61
+ *
62
+ * The worst contributing override (if any) is surfaced on `override` so a UI can
63
+ * explain why a system reads worse than its checks alone. When the overrides are
64
+ * empty the base response is returned unchanged (no `override`).
65
+ */
66
+ export function applySystemHealthOverrides({
67
+ base,
68
+ overrides,
69
+ }: {
70
+ base: SystemHealthStatusResponse;
71
+ overrides: SystemHealthOverrideInput[];
72
+ }): SystemHealthStatusResponse {
73
+ if (overrides.length === 0) return base;
74
+
75
+ // The override that reads worst wins the `override` slot; a tie keeps the
76
+ // first (query order), which for incidents is a stable, arbitrary pick.
77
+ let worst = overrides[0]!;
78
+ for (const candidate of overrides) {
79
+ if (HEALTH_RANK[candidate.status] > HEALTH_RANK[worst.status]) {
80
+ worst = candidate;
81
+ }
82
+ }
83
+
84
+ const status = worstHealthStatus(base.status, worst.status);
85
+ const override: SystemHealthOverride = {
86
+ status: worst.status,
87
+ source: worst.source,
88
+ reason: worst.reason,
89
+ sourceId: worst.sourceId,
90
+ };
91
+
92
+ return { ...base, status, override };
93
+ }