@checkstack/healthcheck-backend 0.13.1 → 0.14.1

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,46 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.14.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [b01078f]
8
+ - @checkstack/catalog-backend@0.4.0
9
+ - @checkstack/satellite-backend@0.2.3
10
+
11
+ ## 0.14.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 6c40b5b: ### GitOps Ecosystem: Healthcheck Kind Registration (Phase 5)
16
+
17
+ **gitops-common**: Added required `resolveEntityRef` to `ReconcileContext`, enabling extension reconcilers to resolve cross-kind entity references (e.g., healthcheck refs in System extensions).
18
+
19
+ **gitops-backend**: Updated reconciler to populate `resolveEntityRef` by querying local provenance — no RPC round-trip needed.
20
+
21
+ **healthcheck-backend**: Registered `kind: Healthcheck` and `System → healthchecks` extension with the EntityKindRegistry:
22
+
23
+ - Validates strategy configs against registered strategy schemas at reconcile time
24
+ - Validates collector configs against registered collector schemas at reconcile time
25
+ - Manages system ↔ healthcheck associations with automatic stale removal
26
+
27
+ **healthcheck-frontend**: Added GitOps provenance locking to the HealthCheck IDE editor — GitOps-managed health checks show a lock banner and disable editing.
28
+
29
+ **catalog-backend**: Updated test fixtures for new required `resolveEntityRef` context field.
30
+
31
+ ### Patch Changes
32
+
33
+ - Updated dependencies [6c40b5b]
34
+ - Updated dependencies [6c40b5b]
35
+ - Updated dependencies [6c40b5b]
36
+ - Updated dependencies [6c40b5b]
37
+ - Updated dependencies [6c40b5b]
38
+ - Updated dependencies [6c40b5b]
39
+ - @checkstack/catalog-backend@0.3.0
40
+ - @checkstack/gitops-backend@0.1.0
41
+ - @checkstack/gitops-common@0.1.0
42
+ - @checkstack/satellite-backend@0.2.2
43
+
3
44
  ## 0.13.1
4
45
 
5
46
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -13,17 +13,19 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/backend-api": "0.11.1",
17
- "@checkstack/catalog-backend": "0.2.23",
16
+ "@checkstack/backend-api": "0.12.0",
17
+ "@checkstack/catalog-backend": "0.2.24",
18
18
  "@checkstack/catalog-common": "1.3.1",
19
- "@checkstack/command-backend": "0.1.18",
19
+ "@checkstack/command-backend": "0.1.19",
20
20
  "@checkstack/common": "0.6.5",
21
- "@checkstack/healthcheck-common": "0.10.1",
21
+ "@checkstack/gitops-backend": "0.0.1",
22
+ "@checkstack/gitops-common": "0.0.1",
23
+ "@checkstack/healthcheck-common": "0.11.0",
22
24
  "@checkstack/incident-common": "0.4.7",
23
- "@checkstack/integration-backend": "0.1.18",
25
+ "@checkstack/integration-backend": "0.1.19",
24
26
  "@checkstack/maintenance-common": "0.4.9",
25
- "@checkstack/queue-api": "0.2.12",
26
- "@checkstack/satellite-backend": "0.1.0",
27
+ "@checkstack/queue-api": "0.2.13",
28
+ "@checkstack/satellite-backend": "0.2.1",
27
29
  "@checkstack/signal-common": "0.1.9",
28
30
  "@hono/zod-validator": "^0.7.6",
29
31
  "drizzle-orm": "^0.45.0",
@@ -35,7 +37,7 @@
35
37
  "devDependencies": {
36
38
  "@checkstack/drizzle-helper": "0.0.4",
37
39
  "@checkstack/scripts": "0.1.2",
38
- "@checkstack/test-utils-backend": "0.1.18",
40
+ "@checkstack/test-utils-backend": "0.1.19",
39
41
  "@checkstack/tsconfig": "0.0.5",
40
42
  "@types/bun": "^1.0.0",
41
43
  "@types/tdigest": "^0.1.5",
@@ -0,0 +1,596 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";
3
+ import type { ReconcileContext } from "@checkstack/gitops-common";
4
+ import {
5
+ buildHealthcheckKind,
6
+ buildSystemHealthcheckExtension,
7
+ } from "./healthcheck-gitops-kinds";
8
+ import type {
9
+ HealthCheckConfiguration,
10
+ CreateHealthCheckConfiguration,
11
+ UpdateHealthCheckConfiguration,
12
+ } from "@checkstack/healthcheck-common";
13
+ import { z } from "zod";
14
+ import { Versioned } from "@checkstack/backend-api";
15
+
16
+ /**
17
+ * Tests for the healthcheck-backend's GitOps entity kind registrations.
18
+ *
19
+ * Exercises reconcile/delete for kind: Healthcheck and the
20
+ * System → healthchecks extension in isolation with mock services.
21
+ */
22
+
23
+ // ─── Mock Healthcheck Service ──────────────────────────────────────────────
24
+
25
+ interface MockConfig {
26
+ id: string;
27
+ name: string;
28
+ strategyId: string;
29
+ config: Record<string, unknown>;
30
+ intervalSeconds: number;
31
+ collectors?: unknown[];
32
+ paused: boolean;
33
+ createdAt: Date;
34
+ updatedAt: Date;
35
+ }
36
+
37
+ interface MockAssociation {
38
+ systemId: string;
39
+ configurationId: string;
40
+ enabled: boolean;
41
+ }
42
+
43
+ function createMockService() {
44
+ const configs: MockConfig[] = [];
45
+ const associations: MockAssociation[] = [];
46
+
47
+ return {
48
+ configs,
49
+ associations,
50
+ createConfiguration: mock(async (data: CreateHealthCheckConfiguration) => {
51
+ const config: MockConfig = {
52
+ id: `hc-${configs.length + 1}`,
53
+ name: data.name,
54
+ strategyId: data.strategyId,
55
+ config: data.config,
56
+ intervalSeconds: data.intervalSeconds,
57
+ collectors: data.collectors,
58
+ paused: false,
59
+ createdAt: new Date(),
60
+ updatedAt: new Date(),
61
+ };
62
+ configs.push(config);
63
+ return config as unknown as HealthCheckConfiguration;
64
+ }),
65
+ updateConfiguration: mock(
66
+ async (id: string, data: UpdateHealthCheckConfiguration) => {
67
+ const config = configs.find((c) => c.id === id);
68
+ if (config) {
69
+ Object.assign(config, data, { updatedAt: new Date() });
70
+ }
71
+ return config as unknown as HealthCheckConfiguration | undefined;
72
+ },
73
+ ),
74
+ deleteConfiguration: mock(async (id: string) => {
75
+ const idx = configs.findIndex((c) => c.id === id);
76
+ if (idx >= 0) configs.splice(idx, 1);
77
+ }),
78
+ associateSystem: mock(
79
+ async (props: {
80
+ systemId: string;
81
+ configurationId: string;
82
+ enabled?: boolean;
83
+ }) => {
84
+ const existing = associations.find(
85
+ (a) =>
86
+ a.systemId === props.systemId &&
87
+ a.configurationId === props.configurationId,
88
+ );
89
+ if (existing) {
90
+ existing.enabled = props.enabled ?? true;
91
+ } else {
92
+ associations.push({
93
+ systemId: props.systemId,
94
+ configurationId: props.configurationId,
95
+ enabled: props.enabled ?? true,
96
+ });
97
+ }
98
+ },
99
+ ),
100
+ disassociateSystem: mock(
101
+ async (systemId: string, configurationId: string) => {
102
+ const idx = associations.findIndex(
103
+ (a) =>
104
+ a.systemId === systemId && a.configurationId === configurationId,
105
+ );
106
+ if (idx >= 0) associations.splice(idx, 1);
107
+ },
108
+ ),
109
+ getSystemConfigurations: mock(async (systemId: string) => {
110
+ return associations
111
+ .filter((a) => a.systemId === systemId)
112
+ .map((a) => {
113
+ const config = configs.find((c) => c.id === a.configurationId);
114
+ return config as unknown as HealthCheckConfiguration;
115
+ })
116
+ .filter(Boolean);
117
+ }),
118
+ };
119
+ }
120
+
121
+ // ─── Mock Registries ───────────────────────────────────────────────────────
122
+
123
+ const postgresConfigSchema = z.object({
124
+ host: z.string(),
125
+ port: z.number().optional(),
126
+ database: z.string(),
127
+ user: z.string(),
128
+ password: z.string(),
129
+ });
130
+
131
+ const cpuCollectorConfigSchema = z.object({
132
+ warningThreshold: z.number().optional(),
133
+ });
134
+
135
+ function createMockHealthCheckRegistry() {
136
+ const strategies = new Map<
137
+ string,
138
+ { id: string; config: Versioned<unknown> }
139
+ >();
140
+
141
+ // Register a test strategy
142
+ strategies.set("postgres", {
143
+ id: "postgres",
144
+ config: new Versioned({
145
+ version: 1,
146
+ schema: postgresConfigSchema,
147
+ }),
148
+ });
149
+
150
+ return {
151
+ getStrategy: (id: string) => strategies.get(id) as ReturnType<import("@checkstack/backend-api").HealthCheckRegistry["getStrategy"]>,
152
+ getStrategies: () =>
153
+ [...strategies.values()] as ReturnType<import("@checkstack/backend-api").HealthCheckRegistry["getStrategies"]>,
154
+ getStrategiesWithMeta: () => [],
155
+ register: () => {},
156
+ };
157
+ }
158
+
159
+ function createMockCollectorRegistry() {
160
+ const collectors = new Map<
161
+ string,
162
+ { qualifiedId: string; collector: { config: Versioned<unknown> } }
163
+ >();
164
+
165
+ collectors.set("collector-hardware.cpu", {
166
+ qualifiedId: "collector-hardware.cpu",
167
+ collector: {
168
+ config: new Versioned({
169
+ version: 1,
170
+ schema: cpuCollectorConfigSchema,
171
+ }),
172
+ },
173
+ });
174
+
175
+ return {
176
+ getCollector: (id: string) => collectors.get(id) as ReturnType<import("@checkstack/backend-api").CollectorRegistry["getCollector"]>,
177
+ getCollectors: () =>
178
+ [...collectors.values()] as ReturnType<import("@checkstack/backend-api").CollectorRegistry["getCollectors"]>,
179
+ getCollectorsForPlugin: () => [],
180
+ register: () => {},
181
+ };
182
+ }
183
+
184
+ // ─── Test Context ──────────────────────────────────────────────────────────
185
+
186
+ const mockContext: ReconcileContext = {
187
+ logger: {
188
+ debug: () => {},
189
+ info: () => {},
190
+ warn: () => {},
191
+ error: () => {},
192
+ },
193
+ resolveEntityRef: async () => undefined,
194
+ };
195
+
196
+ // ─── Tests: kind: Healthcheck ──────────────────────────────────────────────
197
+
198
+ describe("Healthcheck GitOps Kind: Healthcheck", () => {
199
+ let mockService: ReturnType<typeof createMockService>;
200
+ let mockHCRegistry: ReturnType<typeof createMockHealthCheckRegistry>;
201
+ let mockCollectorRegistry: ReturnType<typeof createMockCollectorRegistry>;
202
+
203
+ beforeEach(() => {
204
+ mockService = createMockService();
205
+ mockHCRegistry = createMockHealthCheckRegistry();
206
+ mockCollectorRegistry = createMockCollectorRegistry();
207
+ });
208
+
209
+ function buildKind() {
210
+ return buildHealthcheckKind({
211
+ createService: () =>
212
+ ({
213
+ createConfiguration: mockService.createConfiguration,
214
+ updateConfiguration: mockService.updateConfiguration,
215
+ deleteConfiguration: mockService.deleteConfiguration,
216
+ }) as never,
217
+ getHealthCheckRegistry: () => mockHCRegistry as never,
218
+ getCollectorRegistry: () => mockCollectorRegistry as never,
219
+ });
220
+ }
221
+
222
+ it("creates a new healthcheck configuration and returns entityId", async () => {
223
+ const kind = buildKind();
224
+
225
+ const result = await kind.reconcile({
226
+ entity: {
227
+ apiVersion: CHECKSTACK_API_VERSION,
228
+ kind: "Healthcheck",
229
+ metadata: { name: "payment-db-check", title: "Payment DB Health" },
230
+ spec: {
231
+ strategy: "postgres",
232
+ intervalSeconds: 30,
233
+ config: {
234
+ host: "db.internal",
235
+ port: 5432,
236
+ database: "payments",
237
+ user: "monitor",
238
+ password: "secret",
239
+ },
240
+ },
241
+ },
242
+ context: mockContext,
243
+ });
244
+
245
+ expect(result.entityId).toBe("hc-1");
246
+ expect(mockService.createConfiguration).toHaveBeenCalledTimes(1);
247
+ expect(mockService.configs).toHaveLength(1);
248
+ expect(mockService.configs[0].name).toBe("Payment DB Health");
249
+ expect(mockService.configs[0].strategyId).toBe("postgres");
250
+ });
251
+
252
+ it("updates an existing configuration using existingEntityId", async () => {
253
+ const kind = buildKind();
254
+
255
+ mockService.configs.push({
256
+ id: "hc-existing",
257
+ name: "Old Name",
258
+ strategyId: "postgres",
259
+ config: {},
260
+ intervalSeconds: 60,
261
+ paused: false,
262
+ createdAt: new Date(),
263
+ updatedAt: new Date(),
264
+ });
265
+
266
+ const result = await kind.reconcile({
267
+ entity: {
268
+ apiVersion: CHECKSTACK_API_VERSION,
269
+ kind: "Healthcheck",
270
+ metadata: { name: "payment-db-check", title: "Updated Check" },
271
+ spec: {
272
+ strategy: "postgres",
273
+ intervalSeconds: 15,
274
+ config: {
275
+ host: "db.new",
276
+ port: 5432,
277
+ database: "payments",
278
+ user: "monitor",
279
+ password: "new-secret",
280
+ },
281
+ },
282
+ },
283
+ existingEntityId: "hc-existing",
284
+ context: mockContext,
285
+ });
286
+
287
+ expect(result.entityId).toBe("hc-existing");
288
+ expect(mockService.updateConfiguration).toHaveBeenCalledTimes(1);
289
+ expect(mockService.createConfiguration).not.toHaveBeenCalled();
290
+ expect(mockService.configs[0].name).toBe("Updated Check");
291
+ });
292
+
293
+ it("deletes a configuration by entityId", async () => {
294
+ const kind = buildKind();
295
+
296
+ mockService.configs.push({
297
+ id: "hc-del",
298
+ name: "To Delete",
299
+ strategyId: "postgres",
300
+ config: {},
301
+ intervalSeconds: 30,
302
+ paused: false,
303
+ createdAt: new Date(),
304
+ updatedAt: new Date(),
305
+ });
306
+
307
+ await kind.delete!({
308
+ entityName: "old-check",
309
+ entityId: "hc-del",
310
+ context: mockContext,
311
+ });
312
+
313
+ expect(mockService.deleteConfiguration).toHaveBeenCalledWith("hc-del");
314
+ expect(mockService.configs).toHaveLength(0);
315
+ });
316
+
317
+ it("skips delete when entityId is missing", async () => {
318
+ const kind = buildKind();
319
+
320
+ await kind.delete!({
321
+ entityName: "unknown-check",
322
+ context: mockContext,
323
+ });
324
+
325
+ expect(mockService.deleteConfiguration).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it("throws on unknown strategy", async () => {
329
+ const kind = buildKind();
330
+
331
+ await expect(
332
+ kind.reconcile({
333
+ entity: {
334
+ apiVersion: CHECKSTACK_API_VERSION,
335
+ kind: "Healthcheck",
336
+ metadata: { name: "bad-check" },
337
+ spec: {
338
+ strategy: "nonexistent",
339
+ intervalSeconds: 30,
340
+ config: {},
341
+ },
342
+ },
343
+ context: mockContext,
344
+ }),
345
+ ).rejects.toThrow(/Unknown health check strategy "nonexistent"/);
346
+ });
347
+
348
+ it("validates config against strategy schema", async () => {
349
+ const kind = buildKind();
350
+
351
+ await expect(
352
+ kind.reconcile({
353
+ entity: {
354
+ apiVersion: CHECKSTACK_API_VERSION,
355
+ kind: "Healthcheck",
356
+ metadata: { name: "bad-config" },
357
+ spec: {
358
+ strategy: "postgres",
359
+ intervalSeconds: 30,
360
+ config: {
361
+ // Missing required fields: host, database, user, password
362
+ port: "not-a-number",
363
+ },
364
+ },
365
+ },
366
+ context: mockContext,
367
+ }),
368
+ ).rejects.toThrow(/config validation failed/);
369
+ });
370
+
371
+ it("validates collector configs against collector registry schemas", async () => {
372
+ const kind = buildKind();
373
+
374
+ await expect(
375
+ kind.reconcile({
376
+ entity: {
377
+ apiVersion: CHECKSTACK_API_VERSION,
378
+ kind: "Healthcheck",
379
+ metadata: { name: "bad-collector" },
380
+ spec: {
381
+ strategy: "postgres",
382
+ intervalSeconds: 30,
383
+ config: {
384
+ host: "db.local",
385
+ database: "test",
386
+ user: "root",
387
+ password: "pass",
388
+ },
389
+ collectors: [
390
+ {
391
+ collectorId: "unknown-collector",
392
+ config: {},
393
+ },
394
+ ],
395
+ },
396
+ },
397
+ context: mockContext,
398
+ }),
399
+ ).rejects.toThrow(/Unknown collector "unknown-collector"/);
400
+ });
401
+
402
+ it("accepts valid collector configs", async () => {
403
+ const kind = buildKind();
404
+
405
+ const result = await kind.reconcile({
406
+ entity: {
407
+ apiVersion: CHECKSTACK_API_VERSION,
408
+ kind: "Healthcheck",
409
+ metadata: { name: "with-collector" },
410
+ spec: {
411
+ strategy: "postgres",
412
+ intervalSeconds: 30,
413
+ config: {
414
+ host: "db.local",
415
+ database: "test",
416
+ user: "root",
417
+ password: "pass",
418
+ },
419
+ collectors: [
420
+ {
421
+ collectorId: "collector-hardware.cpu",
422
+ config: { warningThreshold: 90 },
423
+ },
424
+ ],
425
+ },
426
+ },
427
+ context: mockContext,
428
+ });
429
+
430
+ expect(result.entityId).toBe("hc-1");
431
+ expect(mockService.createConfiguration).toHaveBeenCalledTimes(1);
432
+ });
433
+ });
434
+
435
+ // ─── Tests: System → healthchecks extension ────────────────────────────────
436
+
437
+ describe("Healthcheck GitOps Kind: System Extension", () => {
438
+ let mockService: ReturnType<typeof createMockService>;
439
+
440
+ beforeEach(() => {
441
+ mockService = createMockService();
442
+ });
443
+
444
+ function buildExtension() {
445
+ return buildSystemHealthcheckExtension({
446
+ createService: () =>
447
+ ({
448
+ associateSystem: mockService.associateSystem,
449
+ disassociateSystem: mockService.disassociateSystem,
450
+ getSystemConfigurations: mockService.getSystemConfigurations,
451
+ }) as never,
452
+ getHealthCheckRegistry: () => createMockHealthCheckRegistry() as never,
453
+ getCollectorRegistry: () => createMockCollectorRegistry() as never,
454
+ });
455
+ }
456
+
457
+ it("creates associations from ref array", async () => {
458
+ const ext = buildExtension();
459
+
460
+ // Setup: two healthchecks exist in provenance
461
+ const contextWithRefs: ReconcileContext = {
462
+ ...mockContext,
463
+ resolveEntityRef: async ({ kind, entityName }) => {
464
+ const entries: Record<string, Record<string, string>> = {
465
+ Healthcheck: {
466
+ "db-check": "hc-1",
467
+ "api-check": "hc-2",
468
+ },
469
+ };
470
+ return entries[kind]?.[entityName];
471
+ },
472
+ };
473
+
474
+ await ext.reconcile({
475
+ entity: {
476
+ apiVersion: CHECKSTACK_API_VERSION,
477
+ kind: "System",
478
+ metadata: { name: "payment-service" },
479
+ spec: {},
480
+ },
481
+ extensionSpec: [
482
+ { ref: { kind: "Healthcheck", name: "db-check" }, degradedThreshold: 3, unhealthyThreshold: 5 },
483
+ { ref: { kind: "Healthcheck", name: "api-check" } },
484
+ ],
485
+ entityId: "sys-123",
486
+ context: contextWithRefs,
487
+ });
488
+
489
+ expect(mockService.associateSystem).toHaveBeenCalledTimes(2);
490
+ expect(mockService.associations).toHaveLength(2);
491
+ expect(mockService.associations[0].systemId).toBe("sys-123");
492
+ expect(mockService.associations[0].configurationId).toBe("hc-1");
493
+ expect(mockService.associations[1].configurationId).toBe("hc-2");
494
+ });
495
+
496
+ it("removes stale associations not in spec", async () => {
497
+ const ext = buildExtension();
498
+
499
+ // Pre-populate: system has 2 existing associations, spec only wants 1
500
+ mockService.configs.push(
501
+ {
502
+ id: "hc-keep",
503
+ name: "Keep",
504
+ strategyId: "postgres",
505
+ config: {},
506
+ intervalSeconds: 30,
507
+ paused: false,
508
+ createdAt: new Date(),
509
+ updatedAt: new Date(),
510
+ },
511
+ {
512
+ id: "hc-remove",
513
+ name: "Remove",
514
+ strategyId: "postgres",
515
+ config: {},
516
+ intervalSeconds: 30,
517
+ paused: false,
518
+ createdAt: new Date(),
519
+ updatedAt: new Date(),
520
+ },
521
+ );
522
+ mockService.associations.push(
523
+ { systemId: "sys-123", configurationId: "hc-keep", enabled: true },
524
+ { systemId: "sys-123", configurationId: "hc-remove", enabled: true },
525
+ );
526
+
527
+ const contextWithRefs: ReconcileContext = {
528
+ ...mockContext,
529
+ resolveEntityRef: async ({ kind, entityName }) => {
530
+ const entries: Record<string, Record<string, string>> = {
531
+ Healthcheck: { "keep-check": "hc-keep" },
532
+ };
533
+ return entries[kind]?.[entityName];
534
+ },
535
+ };
536
+
537
+ await ext.reconcile({
538
+ entity: {
539
+ apiVersion: CHECKSTACK_API_VERSION,
540
+ kind: "System",
541
+ metadata: { name: "my-system" },
542
+ spec: {},
543
+ },
544
+ extensionSpec: [{ ref: { kind: "Healthcheck", name: "keep-check" } }],
545
+ entityId: "sys-123",
546
+ context: contextWithRefs,
547
+ });
548
+
549
+ expect(mockService.disassociateSystem).toHaveBeenCalledTimes(1);
550
+ expect(mockService.disassociateSystem).toHaveBeenCalledWith(
551
+ "sys-123",
552
+ "hc-remove",
553
+ );
554
+ });
555
+
556
+ it("errors on unresolvable healthcheck ref", async () => {
557
+ const ext = buildExtension();
558
+
559
+ const contextEmpty: ReconcileContext = {
560
+ ...mockContext,
561
+ resolveEntityRef: async () => undefined,
562
+ };
563
+
564
+ await expect(
565
+ ext.reconcile({
566
+ entity: {
567
+ apiVersion: CHECKSTACK_API_VERSION,
568
+ kind: "System",
569
+ metadata: { name: "my-system" },
570
+ spec: {},
571
+ },
572
+ extensionSpec: [{ ref: { kind: "Healthcheck", name: "nonexistent-check" } }],
573
+ entityId: "sys-123",
574
+ context: contextEmpty,
575
+ }),
576
+ ).rejects.toThrow(/Cannot resolve Healthcheck ref "nonexistent-check"/);
577
+ });
578
+
579
+ it("skips when extensionSpec is empty", async () => {
580
+ const ext = buildExtension();
581
+
582
+ await ext.reconcile({
583
+ entity: {
584
+ apiVersion: CHECKSTACK_API_VERSION,
585
+ kind: "System",
586
+ metadata: { name: "my-system" },
587
+ spec: {},
588
+ },
589
+ extensionSpec: [],
590
+ entityId: "sys-123",
591
+ context: mockContext,
592
+ });
593
+
594
+ expect(mockService.associateSystem).not.toHaveBeenCalled();
595
+ });
596
+ });
@@ -0,0 +1,312 @@
1
+ import { z } from "zod";
2
+ import type {
3
+ EntityKindDefinition,
4
+ EntityKindExtensionDefinition,
5
+ EntityKindRegistry,
6
+ ReconcileContext,
7
+ } from "@checkstack/gitops-common";
8
+ import { CHECKSTACK_API_VERSION, secretField, entityRefSchema } from "@checkstack/gitops-common";
9
+ import type {
10
+ HealthCheckRegistry,
11
+ CollectorRegistry,
12
+ } from "@checkstack/backend-api";
13
+ import { HealthCheckService } from "./service";
14
+
15
+
16
+ /**
17
+ * Lazy accessor functions — populated during init(), consumed during reconcile.
18
+ * Safe because reconcile only runs during sync (afterPluginsReady), by which
19
+ * point init() has completed.
20
+ */
21
+ interface HealthcheckGitOpsKindsDeps {
22
+ createService: () => HealthCheckService;
23
+ getHealthCheckRegistry: () => HealthCheckRegistry;
24
+ getCollectorRegistry: () => CollectorRegistry;
25
+ }
26
+
27
+ // ─── Healthcheck Spec Schema ───────────────────────────────────────────────
28
+
29
+ /**
30
+ * GitOps spec schema for kind: Healthcheck.
31
+ *
32
+ * Strategy `config` fields use `secretField()` for sensitive values
33
+ * (passwords, tokens) that are resolved by the reconciliation engine.
34
+ */
35
+ const healthcheckSpecSchema = z.object({
36
+ strategy: z.string().min(1),
37
+ intervalSeconds: z.number().int().min(1),
38
+ config: z.record(z.string(), z.union([z.unknown(), secretField()])),
39
+ collectors: z
40
+ .array(
41
+ z.object({
42
+ collectorId: z.string().min(1),
43
+ config: z.record(z.string(), z.unknown()),
44
+ assertions: z
45
+ .array(
46
+ z.object({
47
+ field: z.string(),
48
+ jsonPath: z.string().optional(),
49
+ operator: z.string(),
50
+ value: z.unknown().optional(),
51
+ }),
52
+ )
53
+ .optional(),
54
+ }),
55
+ )
56
+ .optional(),
57
+ });
58
+
59
+ type HealthcheckSpec = z.infer<typeof healthcheckSpecSchema>;
60
+
61
+ // ─── System Extension Schema ───────────────────────────────────────────────
62
+
63
+ const systemHealthcheckExtensionSchema = z
64
+ .array(
65
+ z.object({
66
+ ref: entityRefSchema,
67
+ degradedThreshold: z.number().int().min(1).optional(),
68
+ unhealthyThreshold: z.number().int().min(1).optional(),
69
+ satelliteIds: z.array(z.string()).optional(),
70
+ includeLocal: z.boolean().optional(),
71
+ }),
72
+ )
73
+ .optional();
74
+
75
+ type SystemHealthcheckExtension = z.infer<
76
+ typeof systemHealthcheckExtensionSchema
77
+ >;
78
+
79
+ // ─── Kind Builder Functions ────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Build the ``kind: Healthcheck`` definition.
83
+ *
84
+ * Exported for isolated unit testing — the register() phase calls this
85
+ * and passes the result to `kindRegistry.registerKind()`.
86
+ */
87
+ export function buildHealthcheckKind(
88
+ deps: HealthcheckGitOpsKindsDeps,
89
+ ): EntityKindDefinition<HealthcheckSpec> {
90
+ return {
91
+ apiVersion: CHECKSTACK_API_VERSION,
92
+ kind: "Healthcheck",
93
+ specSchema: healthcheckSpecSchema,
94
+
95
+ reconcile: async ({
96
+ entity,
97
+ existingEntityId,
98
+ context,
99
+ }: {
100
+ entity: { metadata: { name: string; title?: string; description?: string }; spec: HealthcheckSpec };
101
+ existingEntityId?: string;
102
+ context: ReconcileContext;
103
+ }) => {
104
+ const service = deps.createService();
105
+ const spec = entity.spec;
106
+
107
+ // Validate strategy exists in registry
108
+ const healthCheckRegistry = deps.getHealthCheckRegistry();
109
+ const strategy = healthCheckRegistry.getStrategy(spec.strategy);
110
+ if (!strategy) {
111
+ throw new Error(
112
+ `Unknown health check strategy "${spec.strategy}". ` +
113
+ `Available: ${healthCheckRegistry.getStrategies().map((s) => s.id).join(", ")}`,
114
+ );
115
+ }
116
+
117
+ // Validate config against strategy's Zod schema
118
+ const configValidation = strategy.config.schema.safeParse(spec.config);
119
+ if (!configValidation.success) {
120
+ throw new Error(
121
+ `Strategy "${spec.strategy}" config validation failed: ${configValidation.error.message}`,
122
+ );
123
+ }
124
+
125
+ // Validate collector configs against their registry schemas
126
+ if (spec.collectors) {
127
+ for (const collector of spec.collectors) {
128
+ const collectorReg = deps.getCollectorRegistry();
129
+ const registered = collectorReg.getCollector(
130
+ collector.collectorId,
131
+ );
132
+ if (!registered) {
133
+ throw new Error(
134
+ `Unknown collector "${collector.collectorId}". ` +
135
+ `Available: ${collectorReg.getCollectors().map((c) => c.qualifiedId).join(", ")}`,
136
+ );
137
+ }
138
+ const collectorConfigValidation =
139
+ registered.collector.config.schema.safeParse(collector.config);
140
+ if (!collectorConfigValidation.success) {
141
+ throw new Error(
142
+ `Collector "${collector.collectorId}" config validation failed: ${collectorConfigValidation.error.message}`,
143
+ );
144
+ }
145
+ }
146
+ }
147
+
148
+ // Create or update configuration
149
+ const displayName = entity.metadata.title ?? entity.metadata.name;
150
+
151
+ if (existingEntityId) {
152
+ await service.updateConfiguration(existingEntityId, {
153
+ name: displayName,
154
+ strategyId: spec.strategy,
155
+ config: spec.config,
156
+ intervalSeconds: spec.intervalSeconds,
157
+ collectors: spec.collectors?.map((c) => ({
158
+ id: c.collectorId,
159
+ collectorId: c.collectorId,
160
+ config: c.config,
161
+ assertions: c.assertions,
162
+ })),
163
+ });
164
+ context.logger.info(
165
+ `GitOps: updated Healthcheck "${displayName}" (id: ${existingEntityId})`,
166
+ );
167
+ return { entityId: existingEntityId };
168
+ }
169
+
170
+ const config = await service.createConfiguration({
171
+ name: displayName,
172
+ strategyId: spec.strategy,
173
+ config: spec.config,
174
+ intervalSeconds: spec.intervalSeconds,
175
+ collectors: spec.collectors?.map((c) => ({
176
+ id: c.collectorId,
177
+ collectorId: c.collectorId,
178
+ config: c.config,
179
+ assertions: c.assertions,
180
+ })),
181
+ });
182
+ context.logger.info(
183
+ `GitOps: created Healthcheck "${displayName}" (id: ${config.id})`,
184
+ );
185
+ return { entityId: config.id };
186
+ },
187
+
188
+ delete: async ({
189
+ entityId,
190
+ context,
191
+ }: {
192
+ entityName: string;
193
+ entityId?: string;
194
+ context: ReconcileContext;
195
+ }) => {
196
+ if (!entityId) return;
197
+ const service = deps.createService();
198
+ await service.deleteConfiguration(entityId);
199
+ context.logger.info(`GitOps: deleted Healthcheck (id: ${entityId})`);
200
+ },
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Build the System → healthchecks extension definition.
206
+ *
207
+ * Resolves health check entity references to configuration IDs via
208
+ * `context.resolveEntityRef`, then manages system ↔ health check associations.
209
+ */
210
+ export function buildSystemHealthcheckExtension(
211
+ deps: HealthcheckGitOpsKindsDeps,
212
+ ): EntityKindExtensionDefinition<SystemHealthcheckExtension> {
213
+ return {
214
+ apiVersion: CHECKSTACK_API_VERSION,
215
+ kind: "System",
216
+ namespace: "healthchecks",
217
+ specSchema: systemHealthcheckExtensionSchema,
218
+
219
+ reconcile: async ({
220
+ entity,
221
+ extensionSpec,
222
+ entityId,
223
+ context,
224
+ }: {
225
+ entity: { metadata: { name: string } };
226
+ extensionSpec: SystemHealthcheckExtension;
227
+ entityId: string;
228
+ context: ReconcileContext;
229
+ }) => {
230
+ if (!extensionSpec || extensionSpec.length === 0) return;
231
+
232
+ const service = deps.createService();
233
+
234
+ // entityId is injected directly from the base reconciler — no provenance lookup needed
235
+ const systemEntityId = entityId;
236
+
237
+ // Resolve each healthcheck ref → configurationId
238
+ const desiredConfigIds = new Set<string>();
239
+
240
+ for (const entry of extensionSpec) {
241
+ const configId = await context.resolveEntityRef({
242
+ kind: entry.ref.kind,
243
+ entityName: entry.ref.name,
244
+ });
245
+
246
+ if (!configId) {
247
+ throw new Error(
248
+ `Cannot resolve ${entry.ref.kind} ref "${entry.ref.name}" — ensure the entity exists`,
249
+ );
250
+ }
251
+
252
+ desiredConfigIds.add(configId);
253
+
254
+ // Build state thresholds from the shorthand
255
+ const stateThresholds =
256
+ entry.degradedThreshold || entry.unhealthyThreshold
257
+ ? ({
258
+ mode: "consecutive" as const,
259
+ healthy: { minSuccessCount: 1 },
260
+ degraded: {
261
+ minFailureCount: entry.degradedThreshold ?? 2,
262
+ },
263
+ unhealthy: {
264
+ minFailureCount: entry.unhealthyThreshold ?? 5,
265
+ },
266
+ })
267
+ : undefined;
268
+
269
+ await service.associateSystem({
270
+ systemId: systemEntityId,
271
+ configurationId: configId,
272
+ enabled: true,
273
+ stateThresholds,
274
+ satelliteIds: entry.satelliteIds,
275
+ includeLocal: entry.includeLocal,
276
+ });
277
+
278
+ context.logger.info(
279
+ `GitOps: associated ${entry.ref.kind} "${entry.ref.name}" (${configId}) with System "${entity.metadata.name}"`,
280
+ );
281
+ }
282
+
283
+ // Remove stale associations not in the spec
284
+ const currentAssociations =
285
+ await service.getSystemConfigurations(systemEntityId);
286
+ for (const existing of currentAssociations) {
287
+ if (!desiredConfigIds.has(existing.id)) {
288
+ await service.disassociateSystem(systemEntityId, existing.id);
289
+ context.logger.info(
290
+ `GitOps: removed stale association ${existing.id} from System "${entity.metadata.name}"`,
291
+ );
292
+ }
293
+ }
294
+ },
295
+ };
296
+ }
297
+
298
+ // ─── Registration Entry Point ──────────────────────────────────────────────
299
+
300
+ /**
301
+ * Register all healthcheck-related GitOps entity kinds and extensions.
302
+ * Called from healthcheck-backend's `register()` phase.
303
+ */
304
+ export function registerHealthcheckGitOpsKinds({
305
+ kindRegistry,
306
+ ...deps
307
+ }: HealthcheckGitOpsKindsDeps & {
308
+ kindRegistry: EntityKindRegistry;
309
+ }): void {
310
+ kindRegistry.registerKind(buildHealthcheckKind(deps));
311
+ kindRegistry.registerKindExtension(buildSystemHealthcheckExtension(deps));
312
+ }
package/src/index.ts CHANGED
@@ -16,11 +16,15 @@ import {
16
16
  coreServices,
17
17
  type EmitHookFn,
18
18
  type SafeDatabase,
19
+ type HealthCheckRegistry,
20
+ type CollectorRegistry,
19
21
  } from "@checkstack/backend-api";
20
22
  import { integrationEventExtensionPoint } from "@checkstack/integration-backend";
23
+ import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
21
24
  import { z } from "zod";
22
25
  import { createHealthCheckRouter } from "./router";
23
26
  import { HealthCheckService } from "./service";
27
+ import { registerHealthcheckGitOpsKinds } from "./healthcheck-gitops-kinds";
24
28
  import { catalogHooks } from "@checkstack/catalog-backend";
25
29
  import { satelliteHooks } from "@checkstack/satellite-backend";
26
30
  import { CatalogApi } from "@checkstack/catalog-common";
@@ -89,6 +93,39 @@ export default createBackendPlugin({
89
93
  pluginMetadata,
90
94
  );
91
95
 
96
+ // ─── GitOps Entity Kind Registration ───────────────────────────────
97
+ // Mutable refs — populated during init(), consumed by reconcile closures.
98
+ let gitopsDb: SafeDatabase<typeof schema> | undefined;
99
+ let gitopsHealthCheckRegistry: HealthCheckRegistry | undefined;
100
+ let gitopsCollectorRegistry: CollectorRegistry | undefined;
101
+
102
+ const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
103
+ registerHealthcheckGitOpsKinds({
104
+ kindRegistry,
105
+ createService: () => {
106
+ if (!gitopsDb) throw new Error("Healthcheck database not initialized");
107
+ if (!gitopsHealthCheckRegistry)
108
+ throw new Error("HealthCheckRegistry not initialized");
109
+ if (!gitopsCollectorRegistry)
110
+ throw new Error("CollectorRegistry not initialized");
111
+ return new HealthCheckService(
112
+ gitopsDb,
113
+ gitopsHealthCheckRegistry,
114
+ gitopsCollectorRegistry,
115
+ );
116
+ },
117
+ getHealthCheckRegistry: () => {
118
+ if (!gitopsHealthCheckRegistry)
119
+ throw new Error("HealthCheckRegistry not initialized");
120
+ return gitopsHealthCheckRegistry;
121
+ },
122
+ getCollectorRegistry: () => {
123
+ if (!gitopsCollectorRegistry)
124
+ throw new Error("CollectorRegistry not initialized");
125
+ return gitopsCollectorRegistry;
126
+ },
127
+ });
128
+
92
129
  env.registerInit({
93
130
  schema,
94
131
  deps: {
@@ -113,6 +150,11 @@ export default createBackendPlugin({
113
150
  }) => {
114
151
  logger.debug("🏥 Initializing Health Check Backend...");
115
152
 
153
+ // Populate mutable refs for GitOps reconcile closures
154
+ gitopsDb = database;
155
+ gitopsHealthCheckRegistry = healthCheckRegistry;
156
+ gitopsCollectorRegistry = collectorRegistry;
157
+
116
158
  // Create catalog client for notification delegation
117
159
  const catalogClient = rpcClient.forPlugin(CatalogApi);
118
160