@checkstack/healthcheck-backend 0.14.3 → 0.15.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 +42 -0
- package/package.json +5 -5
- package/src/healthcheck-gitops-kinds.test.ts +110 -0
- package/src/healthcheck-gitops-kinds.ts +213 -38
- package/src/index.ts +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8ef367a: Added `registerSpecSchemaDocumentation` to EntityKindRegistry to allow plugins to provide detailed JSON Schemas for specific configurations. The frontend now displays these registered schemas as dropdown alternatives, improving the developer experience when authoring GitOps configurations.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- cb65e9d: ### Schema-driven secret resolution, rotation invalidation, and security hardening
|
|
12
|
+
|
|
13
|
+
**Breaking**: Replaced `{ secretRef: "..." }` object syntax with `${{ secrets.NAME }}` template interpolation. The `secretField()`, `secretRefSchema`, `isSecretRef`, `SecretRef`, and `ResolvedSecretField` exports have been removed from `@checkstack/gitops-common`.
|
|
14
|
+
|
|
15
|
+
**Breaking**: `ReconcileContext.resolveSecretsBySchema()` now returns `{ resolved: T; warnings: string[] }` instead of `T` directly. Plugins must destructure the result. Warnings contain messages for `${{ secrets.NAME }}` templates found in non-secret fields (fields without `x-secret` annotation).
|
|
16
|
+
|
|
17
|
+
**New features**:
|
|
18
|
+
|
|
19
|
+
- Secrets can be referenced in **any string field** using `${{ secrets.NAME }}` syntax
|
|
20
|
+
- Inline interpolation is supported: `"postgres://user:${{ secrets.DB_PASS }}@host/db"`
|
|
21
|
+
- Resolution is **schema-driven** — reuses the existing `configString({ "x-secret": true })` pattern from DynamicForm
|
|
22
|
+
- Secret rotation now automatically invalidates affected entities, triggering re-reconciliation on the next sync cycle
|
|
23
|
+
- New `getSecretUsage` RPC endpoint to look up which entities reference a given secret
|
|
24
|
+
- Secrets UI now shows an expandable usage panel per secret showing referencing entities
|
|
25
|
+
- Reconciliation warnings: templates in non-secret fields are detected and surfaced in the provenance UI
|
|
26
|
+
- New `secretNameSchema` and `SECRET_NAME_REGEX` exports for validating secret names
|
|
27
|
+
|
|
28
|
+
**Security**:
|
|
29
|
+
|
|
30
|
+
- Secret names are validated at creation: must start with a letter, contain only `[a-zA-Z0-9_-]`, max 63 chars
|
|
31
|
+
- Secrets are validated to exist at sync time but **not pre-resolved** into the spec
|
|
32
|
+
- Templates in `metadata` fields are **rejected** to prevent secret leaks via display fields
|
|
33
|
+
- Only fields with `x-secret` schema annotations get resolved — no escape hatch
|
|
34
|
+
- Templates in non-secret fields emit warnings (stored in provenance, visible in UI) instead of silently passing
|
|
35
|
+
|
|
36
|
+
**Migration**: Update YAML descriptors to use `${{ secrets.NAME }}` instead of `secretRef: name`. Remove `secretField()` imports from plugin schemas — use `configString({ "x-secret": true })` to annotate secret fields. Destructure `const { resolved } = await context.resolveSecretsBySchema({ value, schema })` (return type changed from `T` to `{ resolved: T; warnings: string[] }`).
|
|
37
|
+
|
|
38
|
+
- Updated dependencies [8ef367a]
|
|
39
|
+
- Updated dependencies [cb65e9d]
|
|
40
|
+
- @checkstack/gitops-common@0.2.0
|
|
41
|
+
- @checkstack/gitops-backend@0.2.0
|
|
42
|
+
- @checkstack/catalog-backend@0.4.3
|
|
43
|
+
- @checkstack/satellite-backend@0.2.6
|
|
44
|
+
|
|
3
45
|
## 0.14.3
|
|
4
46
|
|
|
5
47
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -14,18 +14,18 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@checkstack/backend-api": "0.12.0",
|
|
17
|
-
"@checkstack/catalog-backend": "0.4.
|
|
17
|
+
"@checkstack/catalog-backend": "0.4.2",
|
|
18
18
|
"@checkstack/catalog-common": "1.3.1",
|
|
19
19
|
"@checkstack/command-backend": "0.1.19",
|
|
20
20
|
"@checkstack/common": "0.6.5",
|
|
21
|
-
"@checkstack/gitops-backend": "0.1.
|
|
22
|
-
"@checkstack/gitops-common": "0.1.
|
|
21
|
+
"@checkstack/gitops-backend": "0.1.2",
|
|
22
|
+
"@checkstack/gitops-common": "0.1.1",
|
|
23
23
|
"@checkstack/healthcheck-common": "0.11.0",
|
|
24
24
|
"@checkstack/incident-common": "0.4.7",
|
|
25
25
|
"@checkstack/integration-backend": "0.1.19",
|
|
26
26
|
"@checkstack/maintenance-common": "0.4.9",
|
|
27
27
|
"@checkstack/queue-api": "0.2.13",
|
|
28
|
-
"@checkstack/satellite-backend": "0.2.
|
|
28
|
+
"@checkstack/satellite-backend": "0.2.5",
|
|
29
29
|
"@checkstack/signal-common": "0.1.9",
|
|
30
30
|
"@hono/zod-validator": "^0.7.6",
|
|
31
31
|
"drizzle-orm": "^0.45.0",
|
|
@@ -191,6 +191,8 @@ const mockContext: ReconcileContext = {
|
|
|
191
191
|
error: () => {},
|
|
192
192
|
},
|
|
193
193
|
resolveEntityRef: async () => undefined,
|
|
194
|
+
resolveSecretsBySchema: async <T>(params: { value: T }): Promise<{ resolved: T; warnings: string[] }> =>
|
|
195
|
+
({ resolved: params.value, warnings: [] }),
|
|
194
196
|
};
|
|
195
197
|
|
|
196
198
|
// ─── Tests: kind: Healthcheck ──────────────────────────────────────────────
|
|
@@ -594,3 +596,111 @@ describe("Healthcheck GitOps Kind: System Extension", () => {
|
|
|
594
596
|
expect(mockService.associateSystem).not.toHaveBeenCalled();
|
|
595
597
|
});
|
|
596
598
|
});
|
|
599
|
+
|
|
600
|
+
// ─── Tests: Secret template resolution in healthcheck configs ──────────────
|
|
601
|
+
|
|
602
|
+
describe("Healthcheck GitOps: Secret template resolution", () => {
|
|
603
|
+
it("collectSecretNames extracts secrets from healthcheck config", async () => {
|
|
604
|
+
const { collectSecretNames } = await import("@checkstack/gitops-common");
|
|
605
|
+
|
|
606
|
+
const spec = {
|
|
607
|
+
strategy: "postgres",
|
|
608
|
+
intervalSeconds: 30,
|
|
609
|
+
config: {
|
|
610
|
+
host: "db.internal",
|
|
611
|
+
port: 5432,
|
|
612
|
+
database: "payments",
|
|
613
|
+
user: "monitor",
|
|
614
|
+
password: "${{ secrets.DB_PASS }}",
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const names = collectSecretNames({ value: spec });
|
|
619
|
+
expect(names).toEqual(["DB_PASS"]);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("collectSecretNames extracts multiple secrets from config", async () => {
|
|
623
|
+
const { collectSecretNames } = await import("@checkstack/gitops-common");
|
|
624
|
+
|
|
625
|
+
const spec = {
|
|
626
|
+
strategy: "postgres",
|
|
627
|
+
intervalSeconds: 30,
|
|
628
|
+
config: {
|
|
629
|
+
host: "db.internal",
|
|
630
|
+
user: "${{ secrets.DB_USER }}",
|
|
631
|
+
password: "${{ secrets.DB_PASS }}",
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const names = collectSecretNames({ value: spec });
|
|
636
|
+
expect(names).toContain("DB_USER");
|
|
637
|
+
expect(names).toContain("DB_PASS");
|
|
638
|
+
expect(names).toHaveLength(2);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("collectSecretNames handles inline interpolation in config values", async () => {
|
|
642
|
+
const { collectSecretNames } = await import("@checkstack/gitops-common");
|
|
643
|
+
|
|
644
|
+
const spec = {
|
|
645
|
+
strategy: "postgres",
|
|
646
|
+
intervalSeconds: 30,
|
|
647
|
+
config: {
|
|
648
|
+
connectionString:
|
|
649
|
+
"postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@host/db",
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const names = collectSecretNames({ value: spec });
|
|
654
|
+
expect(names).toContain("DB_USER");
|
|
655
|
+
expect(names).toContain("DB_PASS");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("SECRET_TEMPLATE_REGEX correctly identifies templates in healthcheck config values", async () => {
|
|
659
|
+
const { SECRET_TEMPLATE_REGEX } = await import("@checkstack/gitops-common");
|
|
660
|
+
|
|
661
|
+
const configValues = [
|
|
662
|
+
{ input: "${{ secrets.DB_PASS }}", expectedSecrets: ["DB_PASS"] },
|
|
663
|
+
{ input: "db-${{ secrets.ENV }}.internal", expectedSecrets: ["ENV"] },
|
|
664
|
+
{
|
|
665
|
+
input:
|
|
666
|
+
"postgres://${{ secrets.DB_USER }}:${{ secrets.DB_PASS }}@host/db",
|
|
667
|
+
expectedSecrets: ["DB_USER", "DB_PASS"],
|
|
668
|
+
},
|
|
669
|
+
{ input: "plain-value", expectedSecrets: [] },
|
|
670
|
+
];
|
|
671
|
+
|
|
672
|
+
for (const { input, expectedSecrets } of configValues) {
|
|
673
|
+
const found: string[] = [];
|
|
674
|
+
SECRET_TEMPLATE_REGEX.lastIndex = 0;
|
|
675
|
+
let match: RegExpExecArray | null;
|
|
676
|
+
while ((match = SECRET_TEMPLATE_REGEX.exec(input)) !== null) {
|
|
677
|
+
found.push(match[1]);
|
|
678
|
+
}
|
|
679
|
+
expect(found).toEqual(expectedSecrets);
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("template replacement produces expected resolved config", () => {
|
|
684
|
+
// Simulates what the reconciler does: replace ${{ secrets.X }} with values
|
|
685
|
+
const secrets: Record<string, string> = {
|
|
686
|
+
DB_PASS: "production-password-123",
|
|
687
|
+
ENV: "production",
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const rawConfig: Record<string, string> = {
|
|
691
|
+
host: "db-${{ secrets.ENV }}.internal",
|
|
692
|
+
password: "${{ secrets.DB_PASS }}",
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const resolvedConfig: Record<string, string> = {};
|
|
696
|
+
for (const [key, value] of Object.entries(rawConfig)) {
|
|
697
|
+
resolvedConfig[key] = value.replaceAll(
|
|
698
|
+
/\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g,
|
|
699
|
+
(_match, secretName: string) => secrets[secretName] ?? _match,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
expect(resolvedConfig.host).toBe("db-production.internal");
|
|
704
|
+
expect(resolvedConfig.password).toBe("production-password-123");
|
|
705
|
+
});
|
|
706
|
+
});
|
|
@@ -5,13 +5,23 @@ import type {
|
|
|
5
5
|
EntityKindRegistry,
|
|
6
6
|
ReconcileContext,
|
|
7
7
|
} from "@checkstack/gitops-common";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
CHECKSTACK_API_VERSION,
|
|
10
|
+
entityRefSchema,
|
|
11
|
+
} from "@checkstack/gitops-common";
|
|
9
12
|
import type {
|
|
10
13
|
HealthCheckRegistry,
|
|
11
14
|
CollectorRegistry,
|
|
12
15
|
} from "@checkstack/backend-api";
|
|
13
16
|
import { HealthCheckService } from "./service";
|
|
14
|
-
|
|
17
|
+
import {
|
|
18
|
+
DynamicOperators,
|
|
19
|
+
numericField,
|
|
20
|
+
stringField,
|
|
21
|
+
booleanField,
|
|
22
|
+
arrayField,
|
|
23
|
+
enumField,
|
|
24
|
+
} from "@checkstack/backend-api";
|
|
15
25
|
|
|
16
26
|
/**
|
|
17
27
|
* Lazy accessor functions — populated during init(), consumed during reconcile.
|
|
@@ -29,13 +39,13 @@ interface HealthcheckGitOpsKindsDeps {
|
|
|
29
39
|
/**
|
|
30
40
|
* GitOps spec schema for kind: Healthcheck.
|
|
31
41
|
*
|
|
32
|
-
* Strategy `config`
|
|
33
|
-
*
|
|
42
|
+
* Strategy `config` values may contain `${{ secrets.NAME }}` templates
|
|
43
|
+
* which are automatically resolved by the reconciliation engine.
|
|
34
44
|
*/
|
|
35
45
|
const healthcheckSpecSchema = z.object({
|
|
36
46
|
strategy: z.string().min(1),
|
|
37
47
|
intervalSeconds: z.number().int().min(1),
|
|
38
|
-
config: z.record(z.string(), z.
|
|
48
|
+
config: z.record(z.string(), z.unknown()),
|
|
39
49
|
collectors: z
|
|
40
50
|
.array(
|
|
41
51
|
z.object({
|
|
@@ -97,53 +107,83 @@ export function buildHealthcheckKind(
|
|
|
97
107
|
existingEntityId,
|
|
98
108
|
context,
|
|
99
109
|
}: {
|
|
100
|
-
entity: {
|
|
110
|
+
entity: {
|
|
111
|
+
metadata: { name: string; title?: string; description?: string };
|
|
112
|
+
spec: HealthcheckSpec;
|
|
113
|
+
};
|
|
101
114
|
existingEntityId?: string;
|
|
102
115
|
context: ReconcileContext;
|
|
103
116
|
}) => {
|
|
104
117
|
const service = deps.createService();
|
|
105
118
|
const spec = entity.spec;
|
|
106
119
|
|
|
107
|
-
//
|
|
120
|
+
// Look up strategy first — we need its typed schema for secret resolution
|
|
108
121
|
const healthCheckRegistry = deps.getHealthCheckRegistry();
|
|
109
122
|
const strategy = healthCheckRegistry.getStrategy(spec.strategy);
|
|
110
123
|
if (!strategy) {
|
|
111
124
|
throw new Error(
|
|
112
125
|
`Unknown health check strategy "${spec.strategy}". ` +
|
|
113
|
-
`Available: ${healthCheckRegistry
|
|
126
|
+
`Available: ${healthCheckRegistry
|
|
127
|
+
.getStrategies()
|
|
128
|
+
.map((s) => s.id)
|
|
129
|
+
.join(", ")}`,
|
|
114
130
|
);
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
//
|
|
118
|
-
|
|
133
|
+
// Resolve secrets using the strategy's typed schema.
|
|
134
|
+
// Only fields marked with configString({ "x-secret": true }) get resolved.
|
|
135
|
+
const { resolved: resolvedConfig } = await context.resolveSecretsBySchema(
|
|
136
|
+
{
|
|
137
|
+
value: spec.config,
|
|
138
|
+
schema: strategy.config.schema,
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Validate resolved config against strategy's Zod schema
|
|
143
|
+
const configValidation = strategy.config.schema.safeParse(resolvedConfig);
|
|
119
144
|
if (!configValidation.success) {
|
|
120
145
|
throw new Error(
|
|
121
146
|
`Strategy "${spec.strategy}" config validation failed: ${configValidation.error.message}`,
|
|
122
147
|
);
|
|
123
148
|
}
|
|
124
149
|
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
// Resolve and validate collector configs using their registry schemas
|
|
151
|
+
const resolvedCollectors = spec.collectors
|
|
152
|
+
? await Promise.all(
|
|
153
|
+
spec.collectors.map(async (c) => {
|
|
154
|
+
const collectorReg = deps.getCollectorRegistry();
|
|
155
|
+
const registered = collectorReg.getCollector(c.collectorId);
|
|
156
|
+
if (!registered) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Unknown collector "${c.collectorId}". ` +
|
|
159
|
+
`Available: ${collectorReg
|
|
160
|
+
.getCollectors()
|
|
161
|
+
.map((col) => col.qualifiedId)
|
|
162
|
+
.join(", ")}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Resolve secrets using the collector's typed schema
|
|
167
|
+
const { resolved: resolvedCollectorConfig } =
|
|
168
|
+
await context.resolveSecretsBySchema({
|
|
169
|
+
value: c.config,
|
|
170
|
+
schema: registered.collector.config.schema,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const collectorConfigValidation =
|
|
174
|
+
registered.collector.config.schema.safeParse(
|
|
175
|
+
resolvedCollectorConfig,
|
|
176
|
+
);
|
|
177
|
+
if (!collectorConfigValidation.success) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Collector "${c.collectorId}" config validation failed: ${collectorConfigValidation.error.message}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { ...c, config: resolvedCollectorConfig };
|
|
184
|
+
}),
|
|
185
|
+
)
|
|
186
|
+
: undefined;
|
|
147
187
|
|
|
148
188
|
// Create or update configuration
|
|
149
189
|
const displayName = entity.metadata.title ?? entity.metadata.name;
|
|
@@ -152,9 +192,9 @@ export function buildHealthcheckKind(
|
|
|
152
192
|
await service.updateConfiguration(existingEntityId, {
|
|
153
193
|
name: displayName,
|
|
154
194
|
strategyId: spec.strategy,
|
|
155
|
-
config:
|
|
195
|
+
config: resolvedConfig,
|
|
156
196
|
intervalSeconds: spec.intervalSeconds,
|
|
157
|
-
collectors:
|
|
197
|
+
collectors: resolvedCollectors?.map((c) => ({
|
|
158
198
|
id: c.collectorId,
|
|
159
199
|
collectorId: c.collectorId,
|
|
160
200
|
config: c.config,
|
|
@@ -170,9 +210,9 @@ export function buildHealthcheckKind(
|
|
|
170
210
|
const config = await service.createConfiguration({
|
|
171
211
|
name: displayName,
|
|
172
212
|
strategyId: spec.strategy,
|
|
173
|
-
config:
|
|
213
|
+
config: resolvedConfig,
|
|
174
214
|
intervalSeconds: spec.intervalSeconds,
|
|
175
|
-
collectors:
|
|
215
|
+
collectors: resolvedCollectors?.map((c) => ({
|
|
176
216
|
id: c.collectorId,
|
|
177
217
|
collectorId: c.collectorId,
|
|
178
218
|
config: c.config,
|
|
@@ -254,7 +294,7 @@ export function buildSystemHealthcheckExtension(
|
|
|
254
294
|
// Build state thresholds from the shorthand
|
|
255
295
|
const stateThresholds =
|
|
256
296
|
entry.degradedThreshold || entry.unhealthyThreshold
|
|
257
|
-
?
|
|
297
|
+
? {
|
|
258
298
|
mode: "consecutive" as const,
|
|
259
299
|
healthy: { minSuccessCount: 1 },
|
|
260
300
|
degraded: {
|
|
@@ -263,7 +303,7 @@ export function buildSystemHealthcheckExtension(
|
|
|
263
303
|
unhealthy: {
|
|
264
304
|
minFailureCount: entry.unhealthyThreshold ?? 5,
|
|
265
305
|
},
|
|
266
|
-
}
|
|
306
|
+
}
|
|
267
307
|
: undefined;
|
|
268
308
|
|
|
269
309
|
await service.associateSystem({
|
|
@@ -310,3 +350,138 @@ export function registerHealthcheckGitOpsKinds({
|
|
|
310
350
|
kindRegistry.registerKind(buildHealthcheckKind(deps));
|
|
311
351
|
kindRegistry.registerKindExtension(buildSystemHealthcheckExtension(deps));
|
|
312
352
|
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Register spec schema documentation for the Healthcheck kind.
|
|
356
|
+
* Called from healthcheck-backend's `afterPluginsReady` phase once registries are populated.
|
|
357
|
+
*/
|
|
358
|
+
export function registerHealthcheckGitOpsDocumentation({
|
|
359
|
+
kindRegistry,
|
|
360
|
+
healthCheckRegistry,
|
|
361
|
+
collectorRegistry,
|
|
362
|
+
}: {
|
|
363
|
+
kindRegistry: EntityKindRegistry;
|
|
364
|
+
healthCheckRegistry: HealthCheckRegistry;
|
|
365
|
+
collectorRegistry: CollectorRegistry;
|
|
366
|
+
}): void {
|
|
367
|
+
// 1. Register documentation for strategy configs (fieldPath: "config")
|
|
368
|
+
for (const registered of healthCheckRegistry.getStrategiesWithMeta()) {
|
|
369
|
+
kindRegistry.registerSpecSchemaDocumentation({
|
|
370
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
371
|
+
kind: "Healthcheck",
|
|
372
|
+
fieldPath: "config",
|
|
373
|
+
variantId: registered.ownerPluginId,
|
|
374
|
+
label: registered.strategy.displayName,
|
|
375
|
+
description: `ID: ${registered.ownerPluginId}\n\n${registered.strategy.description}`,
|
|
376
|
+
schema: registered.strategy.config.schema,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 2. Register documentation for collector configs (fieldPath: "collectors[].config")
|
|
381
|
+
for (const registered of collectorRegistry.getCollectors()) {
|
|
382
|
+
kindRegistry.registerSpecSchemaDocumentation({
|
|
383
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
384
|
+
kind: "Healthcheck",
|
|
385
|
+
fieldPath: "collectors[].config",
|
|
386
|
+
variantId: registered.qualifiedId,
|
|
387
|
+
label: registered.collector.displayName,
|
|
388
|
+
description: `ID: ${registered.qualifiedId}\n\n${registered.collector.description}`,
|
|
389
|
+
schema: registered.collector.config.schema,
|
|
390
|
+
conditions: [
|
|
391
|
+
{
|
|
392
|
+
fieldPath: "config",
|
|
393
|
+
variantIds: registered.collector.supportedPlugins.map(
|
|
394
|
+
(p) => p.pluginId,
|
|
395
|
+
),
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// 3. Register documentation for collector assertions (fieldPath: "collectors[].assertions")
|
|
401
|
+
const unwrapped = unwrapZodType(registered.collector.result.schema);
|
|
402
|
+
|
|
403
|
+
if (unwrapped instanceof z.ZodObject) {
|
|
404
|
+
const shape = unwrapped.shape;
|
|
405
|
+
|
|
406
|
+
for (const [key, prop] of Object.entries(shape)) {
|
|
407
|
+
const propUnwrapped = unwrapZodType(prop as z.ZodTypeAny);
|
|
408
|
+
|
|
409
|
+
let fieldSchema: z.ZodTypeAny;
|
|
410
|
+
|
|
411
|
+
if (propUnwrapped instanceof z.ZodNumber) {
|
|
412
|
+
fieldSchema = numericField(key);
|
|
413
|
+
} else if (propUnwrapped instanceof z.ZodString) {
|
|
414
|
+
fieldSchema = stringField(key);
|
|
415
|
+
} else if (propUnwrapped instanceof z.ZodBoolean) {
|
|
416
|
+
fieldSchema = booleanField(key);
|
|
417
|
+
} else if (propUnwrapped instanceof z.ZodArray) {
|
|
418
|
+
fieldSchema = arrayField(key);
|
|
419
|
+
} else if (propUnwrapped instanceof z.ZodEnum) {
|
|
420
|
+
const enumValues = propUnwrapped.options;
|
|
421
|
+
const stringValues = enumValues.filter((v: unknown) => typeof v === "string") as string[];
|
|
422
|
+
fieldSchema = stringValues.length > 0 ? enumField(key, stringValues) : stringField(key);
|
|
423
|
+
} else {
|
|
424
|
+
fieldSchema = stringField(key);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
kindRegistry.registerSpecSchemaDocumentation({
|
|
428
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
429
|
+
kind: "Healthcheck",
|
|
430
|
+
fieldPath: "collectors[].assertions",
|
|
431
|
+
variantId: `${registered.qualifiedId}.assert.${key}`,
|
|
432
|
+
label: `Assert: ${key}`,
|
|
433
|
+
description: `Assertion documentation for the '${key}' field of ${registered.collector.displayName}.`,
|
|
434
|
+
schema: z.array(fieldSchema).describe(`Assertions array`),
|
|
435
|
+
conditions: [
|
|
436
|
+
{
|
|
437
|
+
fieldPath: "collectors[].config",
|
|
438
|
+
variantIds: [registered.qualifiedId],
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
// Fallback if result schema is not an object
|
|
445
|
+
kindRegistry.registerSpecSchemaDocumentation({
|
|
446
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
447
|
+
kind: "Healthcheck",
|
|
448
|
+
fieldPath: "collectors[].assertions",
|
|
449
|
+
variantId: `${registered.qualifiedId}.assert.generic`,
|
|
450
|
+
label: `${registered.collector.displayName} Assertions`,
|
|
451
|
+
description: `Define assertions against the result of this collector.`,
|
|
452
|
+
schema: z.array(
|
|
453
|
+
z.object({
|
|
454
|
+
field: z.string(),
|
|
455
|
+
operator: DynamicOperators,
|
|
456
|
+
value: z.unknown().optional(),
|
|
457
|
+
})
|
|
458
|
+
).describe(`Assertions array`),
|
|
459
|
+
conditions: [
|
|
460
|
+
{
|
|
461
|
+
fieldPath: "collectors[].config",
|
|
462
|
+
variantIds: [registered.qualifiedId],
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function unwrapZodType(type: z.ZodTypeAny): z.ZodTypeAny {
|
|
471
|
+
let current = type;
|
|
472
|
+
while (current) {
|
|
473
|
+
if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
|
|
474
|
+
current = current.unwrap() as z.ZodTypeAny;
|
|
475
|
+
} else if (current instanceof z.ZodDefault) {
|
|
476
|
+
current = current._def.innerType as z.ZodTypeAny;
|
|
477
|
+
} else if ("innerType" in current && typeof current.innerType === "function") {
|
|
478
|
+
// Handles ZodEffects/ZodBranded generically without relying on removed types
|
|
479
|
+
current = current.innerType() as z.ZodTypeAny;
|
|
480
|
+
} else {
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return current;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
|
|
|
24
24
|
import { z } from "zod";
|
|
25
25
|
import { createHealthCheckRouter } from "./router";
|
|
26
26
|
import { HealthCheckService } from "./service";
|
|
27
|
-
import { registerHealthcheckGitOpsKinds } from "./healthcheck-gitops-kinds";
|
|
27
|
+
import { registerHealthcheckGitOpsKinds, registerHealthcheckGitOpsDocumentation } from "./healthcheck-gitops-kinds";
|
|
28
28
|
import { catalogHooks } from "@checkstack/catalog-backend";
|
|
29
29
|
import { satelliteHooks } from "@checkstack/satellite-backend";
|
|
30
30
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
@@ -239,6 +239,13 @@ export default createBackendPlugin({
|
|
|
239
239
|
logger,
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
// Register GitOps documentation now that registries are populated
|
|
243
|
+
registerHealthcheckGitOpsDocumentation({
|
|
244
|
+
kindRegistry,
|
|
245
|
+
healthCheckRegistry,
|
|
246
|
+
collectorRegistry,
|
|
247
|
+
});
|
|
248
|
+
|
|
242
249
|
// Subscribe to catalog system deletion to clean up associations
|
|
243
250
|
const service = new HealthCheckService(
|
|
244
251
|
database,
|