@checkstack/healthcheck-backend 1.14.0 → 1.16.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 +171 -0
- package/package.json +29 -29
- package/src/aggregation-utils.test.ts +132 -0
- package/src/aggregation-utils.ts +70 -6
- package/src/ai/healthcheck-propose.ts +9 -0
- package/src/ai/healthcheck-update.ts +5 -1
- package/src/collector-assertions.test.ts +97 -1
- package/src/collector-assertions.ts +141 -34
- package/src/config-secrets-backfill.test.ts +142 -0
- package/src/config-secrets-backfill.ts +129 -0
- package/src/config-secrets.test.ts +771 -0
- package/src/config-secrets.ts +361 -0
- package/src/healthcheck-gitops-kinds.ts +42 -31
- package/src/index.ts +47 -1
- package/src/queue-executor.test.ts +182 -0
- package/src/queue-executor.ts +64 -6
- package/src/realtime-aggregation.test.ts +136 -0
- package/src/realtime-aggregation.ts +39 -2
- package/src/retention-job.ts +64 -1
- package/src/retention-rollup.test.ts +69 -0
- package/src/router-config-secrets.test.ts +165 -0
- package/src/router-create-and-assign.test.ts +5 -1
- package/src/router.ts +39 -7
- package/src/service-config-secrets.test.ts +680 -0
- package/src/service-ingest-assertions.test.ts +213 -0
- package/src/service.ts +529 -56
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type AdvisoryLockService } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
type InternalSecretsService,
|
|
5
|
+
type SecretResolverService,
|
|
6
|
+
type ConfigSecretChannel,
|
|
7
|
+
extractScopeSecrets,
|
|
8
|
+
inflateScopeSecrets,
|
|
9
|
+
collectScopeSecretValues,
|
|
10
|
+
deleteScopeSecrets,
|
|
11
|
+
pruneScopeSecrets,
|
|
12
|
+
mergeSecretFields,
|
|
13
|
+
} from "@checkstack/secrets-backend";
|
|
14
|
+
import {
|
|
15
|
+
HEALTHCHECK_SECRET_MARKER_PREFIX,
|
|
16
|
+
type CollectorConfigEntry,
|
|
17
|
+
} from "@checkstack/healthcheck-common";
|
|
18
|
+
|
|
19
|
+
// Re-export the marker vocabulary (defined in healthcheck-common so the
|
|
20
|
+
// satellite runtime shares it) and the schema-only channel helpers (defined in
|
|
21
|
+
// secrets-backend, the ONE extraction channel) for this package + its tests.
|
|
22
|
+
export {
|
|
23
|
+
healthcheckSecretMarker,
|
|
24
|
+
isHealthcheckSecretMarker,
|
|
25
|
+
} from "@checkstack/healthcheck-common";
|
|
26
|
+
// `mergeSecretFields` is also imported above for local use in this module;
|
|
27
|
+
// this re-export surfaces the channel helpers for the package + its tests.
|
|
28
|
+
export {
|
|
29
|
+
redactSecretFields,
|
|
30
|
+
mergeSecretFields,
|
|
31
|
+
listPopulatedSecretKeys,
|
|
32
|
+
} from "@checkstack/secrets-backend";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Health-check config credentials ride the platform's ONE config-secret
|
|
36
|
+
* extraction channel ({@link ConfigSecretChannel} in `@checkstack/secrets-backend`,
|
|
37
|
+
* shared with integration connections). This module only binds that channel to a
|
|
38
|
+
* health-check scope (strategy config, or one collector entry) and orchestrates
|
|
39
|
+
* the strategy + collectors of a whole configuration. Fields are declared with
|
|
40
|
+
* `configSecret({ id })`; the internal secret is keyed by the stable id.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Scope of a secret within a configuration: the strategy config itself, or one
|
|
45
|
+
* collector entry's config (keyed by the entry's stable UUID, which survives
|
|
46
|
+
* collector re-ordering).
|
|
47
|
+
*/
|
|
48
|
+
export type SecretScope =
|
|
49
|
+
| { kind: "strategy" }
|
|
50
|
+
| { kind: "collector"; entryId: string };
|
|
51
|
+
|
|
52
|
+
/** Internal-secret name parts for a health-check config credential field. */
|
|
53
|
+
export function healthcheckSecretParts({
|
|
54
|
+
configurationId,
|
|
55
|
+
scope,
|
|
56
|
+
secretId,
|
|
57
|
+
}: {
|
|
58
|
+
configurationId: string;
|
|
59
|
+
scope: SecretScope;
|
|
60
|
+
secretId: string;
|
|
61
|
+
}): string[] {
|
|
62
|
+
return scope.kind === "strategy"
|
|
63
|
+
? ["healthcheck", configurationId, "strategy", secretId]
|
|
64
|
+
: ["healthcheck", configurationId, "collector", scope.entryId, secretId];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The extraction channel bound to one health-check scope. */
|
|
68
|
+
function scopeChannel(
|
|
69
|
+
configurationId: string,
|
|
70
|
+
scope: SecretScope,
|
|
71
|
+
): ConfigSecretChannel {
|
|
72
|
+
return {
|
|
73
|
+
markerPrefix: HEALTHCHECK_SECRET_MARKER_PREFIX,
|
|
74
|
+
keyParts: (secretId) =>
|
|
75
|
+
healthcheckSecretParts({ configurationId, scope, secretId }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface HealthCheckSecretsDeps {
|
|
80
|
+
internalSecrets: InternalSecretsService;
|
|
81
|
+
/** Only the per-run resolution surface is needed (narrows test fakes). */
|
|
82
|
+
secretResolver: Pick<SecretResolverService, "resolveForRun">;
|
|
83
|
+
/**
|
|
84
|
+
* Optional cluster-wide mutex. When present, `updateConfiguration` and
|
|
85
|
+
* `deleteConfiguration` serialize their read-modify-write-prune for a given
|
|
86
|
+
* config id under a per-id advisory lock, so a concurrent writer to the SAME
|
|
87
|
+
* id cannot have its just-written secret deleted by another writer's stale
|
|
88
|
+
* orphan-prune (which would leave a dangling marker).
|
|
89
|
+
*/
|
|
90
|
+
advisoryLock?: AdvisoryLockService;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Advisory-lock key serializing writes to ONE health-check configuration. */
|
|
94
|
+
export function healthcheckConfigLockKey(configurationId: string): string {
|
|
95
|
+
return `healthcheck:config:${configurationId}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve a collector entry's config schema, or undefined when unknown. */
|
|
99
|
+
export type GetCollectorSchema = (
|
|
100
|
+
collectorId: string,
|
|
101
|
+
) => z.ZodTypeAny | undefined;
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// EXTRACT (write path)
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract inline secrets from a configuration's strategy config AND every
|
|
109
|
+
* collector entry's config into internal secrets, replacing each with a marker.
|
|
110
|
+
* An UNREGISTERED strategy has no schema, so its config passes through unchanged
|
|
111
|
+
* while REGISTERED collectors still extract their own secrets.
|
|
112
|
+
*/
|
|
113
|
+
export async function extractConfigurationSecrets({
|
|
114
|
+
configurationId,
|
|
115
|
+
strategySchema,
|
|
116
|
+
config,
|
|
117
|
+
collectors,
|
|
118
|
+
getCollectorSchema,
|
|
119
|
+
internalSecrets,
|
|
120
|
+
}: {
|
|
121
|
+
strategySchema: z.ZodTypeAny | undefined;
|
|
122
|
+
configurationId: string;
|
|
123
|
+
config: Record<string, unknown>;
|
|
124
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
125
|
+
getCollectorSchema: GetCollectorSchema;
|
|
126
|
+
internalSecrets: InternalSecretsService;
|
|
127
|
+
}): Promise<{
|
|
128
|
+
config: Record<string, unknown>;
|
|
129
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
130
|
+
extracted: number;
|
|
131
|
+
}> {
|
|
132
|
+
let extracted = 0;
|
|
133
|
+
|
|
134
|
+
let strategyConfig = config;
|
|
135
|
+
if (strategySchema) {
|
|
136
|
+
const result = await extractScopeSecrets({
|
|
137
|
+
channel: scopeChannel(configurationId, { kind: "strategy" }),
|
|
138
|
+
schema: strategySchema,
|
|
139
|
+
config,
|
|
140
|
+
internalSecrets,
|
|
141
|
+
});
|
|
142
|
+
strategyConfig = result.config;
|
|
143
|
+
extracted += result.extracted;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let rewrittenCollectors = collectors;
|
|
147
|
+
if (collectors?.length) {
|
|
148
|
+
rewrittenCollectors = await Promise.all(
|
|
149
|
+
collectors.map(async (entry) => {
|
|
150
|
+
const schema = getCollectorSchema(entry.collectorId);
|
|
151
|
+
if (!schema) return entry;
|
|
152
|
+
const result = await extractScopeSecrets({
|
|
153
|
+
channel: scopeChannel(configurationId, {
|
|
154
|
+
kind: "collector",
|
|
155
|
+
entryId: entry.id,
|
|
156
|
+
}),
|
|
157
|
+
schema,
|
|
158
|
+
config: entry.config,
|
|
159
|
+
internalSecrets,
|
|
160
|
+
});
|
|
161
|
+
extracted += result.extracted;
|
|
162
|
+
return { ...entry, config: result.config };
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { config: strategyConfig, collectors: rewrittenCollectors, extracted };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// INFLATE / COLLECT (run path)
|
|
172
|
+
// ============================================================================
|
|
173
|
+
|
|
174
|
+
/** Inflate ONE stored config's secret fields to real values (in memory). */
|
|
175
|
+
export async function inflateConfigSecrets({
|
|
176
|
+
configurationId,
|
|
177
|
+
scope,
|
|
178
|
+
schema,
|
|
179
|
+
config,
|
|
180
|
+
deps,
|
|
181
|
+
}: {
|
|
182
|
+
configurationId: string;
|
|
183
|
+
scope: SecretScope;
|
|
184
|
+
schema: z.ZodTypeAny;
|
|
185
|
+
config: Record<string, unknown>;
|
|
186
|
+
deps: HealthCheckSecretsDeps;
|
|
187
|
+
}): Promise<{ config: Record<string, unknown>; values: string[] }> {
|
|
188
|
+
return inflateScopeSecrets({
|
|
189
|
+
channel: scopeChannel(configurationId, scope),
|
|
190
|
+
schema,
|
|
191
|
+
config,
|
|
192
|
+
internalSecrets: deps.internalSecrets,
|
|
193
|
+
secretResolver: deps.secretResolver,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Resolve a config's secret fields to a `path -> value` map (satellite JIT). */
|
|
198
|
+
export async function collectConfigSecretValues({
|
|
199
|
+
configurationId,
|
|
200
|
+
scope,
|
|
201
|
+
schema,
|
|
202
|
+
config,
|
|
203
|
+
deps,
|
|
204
|
+
}: {
|
|
205
|
+
configurationId: string;
|
|
206
|
+
scope: SecretScope;
|
|
207
|
+
schema: z.ZodTypeAny;
|
|
208
|
+
config: Record<string, unknown>;
|
|
209
|
+
deps: HealthCheckSecretsDeps;
|
|
210
|
+
}): Promise<Record<string, string>> {
|
|
211
|
+
return collectScopeSecretValues({
|
|
212
|
+
channel: scopeChannel(configurationId, scope),
|
|
213
|
+
schema,
|
|
214
|
+
config,
|
|
215
|
+
internalSecrets: deps.internalSecrets,
|
|
216
|
+
secretResolver: deps.secretResolver,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ============================================================================
|
|
221
|
+
// MERGE (update path)
|
|
222
|
+
// ============================================================================
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Merge stored secrets into an incoming configuration update: the strategy
|
|
226
|
+
* config plus each collector entry (paired with the stored entry of the SAME
|
|
227
|
+
* id - a brand-new entry has nothing to restore). An unregistered strategy has
|
|
228
|
+
* no schema, so its config passes through unchanged.
|
|
229
|
+
*/
|
|
230
|
+
export function mergeConfigurationSecrets({
|
|
231
|
+
strategySchema,
|
|
232
|
+
incomingConfig,
|
|
233
|
+
storedConfig,
|
|
234
|
+
incomingCollectors,
|
|
235
|
+
storedCollectors,
|
|
236
|
+
getCollectorSchema,
|
|
237
|
+
}: {
|
|
238
|
+
strategySchema: z.ZodTypeAny | undefined;
|
|
239
|
+
incomingConfig: Record<string, unknown>;
|
|
240
|
+
storedConfig: Record<string, unknown> | undefined;
|
|
241
|
+
incomingCollectors: CollectorConfigEntry[] | undefined;
|
|
242
|
+
storedCollectors: CollectorConfigEntry[] | undefined;
|
|
243
|
+
getCollectorSchema: GetCollectorSchema;
|
|
244
|
+
}): {
|
|
245
|
+
config: Record<string, unknown>;
|
|
246
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
247
|
+
} {
|
|
248
|
+
// Imported lazily-free: mergeSecretFields is re-exported above.
|
|
249
|
+
const config = strategySchema
|
|
250
|
+
? mergeSecretFields({
|
|
251
|
+
schema: strategySchema,
|
|
252
|
+
incoming: incomingConfig,
|
|
253
|
+
stored: storedConfig,
|
|
254
|
+
})
|
|
255
|
+
: incomingConfig;
|
|
256
|
+
|
|
257
|
+
const storedById = new Map(
|
|
258
|
+
(storedCollectors ?? []).map((entry) => [entry.id, entry]),
|
|
259
|
+
);
|
|
260
|
+
const collectors = incomingCollectors?.map((entry) => {
|
|
261
|
+
const schema = getCollectorSchema(entry.collectorId);
|
|
262
|
+
const stored = storedById.get(entry.id);
|
|
263
|
+
if (!schema) return entry;
|
|
264
|
+
return {
|
|
265
|
+
...entry,
|
|
266
|
+
config: mergeSecretFields({
|
|
267
|
+
schema,
|
|
268
|
+
incoming: entry.config,
|
|
269
|
+
stored: stored?.config,
|
|
270
|
+
}),
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return { config, collectors };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// CLEANUP (schema-free delete + orphan prune)
|
|
279
|
+
// ============================================================================
|
|
280
|
+
|
|
281
|
+
/** Collector scopes present across an old + new collector list, deduped. */
|
|
282
|
+
function collectorScopes(
|
|
283
|
+
...lists: (CollectorConfigEntry[] | undefined)[]
|
|
284
|
+
): { kind: "collector"; entryId: string }[] {
|
|
285
|
+
const ids = new Set<string>();
|
|
286
|
+
for (const list of lists) for (const entry of list ?? []) ids.add(entry.id);
|
|
287
|
+
return [...ids].map((entryId) => ({ kind: "collector", entryId }));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Delete every internal secret a stored configuration's markers point at (the
|
|
292
|
+
* strategy config plus every collector entry). Schema-free, so it cleans up even
|
|
293
|
+
* when the strategy/collector plugin is uninstalled - a deleted check never
|
|
294
|
+
* orphans its secrets.
|
|
295
|
+
*/
|
|
296
|
+
export async function deleteConfigurationSecrets({
|
|
297
|
+
configurationId,
|
|
298
|
+
config,
|
|
299
|
+
collectors,
|
|
300
|
+
internalSecrets,
|
|
301
|
+
}: {
|
|
302
|
+
configurationId: string;
|
|
303
|
+
config: Record<string, unknown>;
|
|
304
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
305
|
+
internalSecrets: InternalSecretsService;
|
|
306
|
+
}): Promise<void> {
|
|
307
|
+
await deleteScopeSecrets({
|
|
308
|
+
channel: scopeChannel(configurationId, { kind: "strategy" }),
|
|
309
|
+
config,
|
|
310
|
+
internalSecrets,
|
|
311
|
+
});
|
|
312
|
+
const byId = new Map((collectors ?? []).map((e) => [e.id, e]));
|
|
313
|
+
for (const scope of collectorScopes(collectors)) {
|
|
314
|
+
await deleteScopeSecrets({
|
|
315
|
+
channel: scopeChannel(configurationId, scope),
|
|
316
|
+
config: byId.get(scope.entryId)?.config ?? {},
|
|
317
|
+
internalSecrets,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Delete internal secrets ORPHANED by an update: a cleared/removed field, an
|
|
324
|
+
* inline secret swapped for a reference, or a removed collector. Prunes the
|
|
325
|
+
* strategy scope and every collector scope (old ∪ new), comparing markers by
|
|
326
|
+
* exact internal-secret coordinates. Returns the number deleted.
|
|
327
|
+
*/
|
|
328
|
+
export async function pruneOrphanedConfigurationSecrets({
|
|
329
|
+
configurationId,
|
|
330
|
+
oldConfig,
|
|
331
|
+
newConfig,
|
|
332
|
+
oldCollectors,
|
|
333
|
+
newCollectors,
|
|
334
|
+
internalSecrets,
|
|
335
|
+
}: {
|
|
336
|
+
configurationId: string;
|
|
337
|
+
oldConfig: Record<string, unknown>;
|
|
338
|
+
newConfig: Record<string, unknown>;
|
|
339
|
+
oldCollectors: CollectorConfigEntry[] | undefined;
|
|
340
|
+
newCollectors: CollectorConfigEntry[] | undefined;
|
|
341
|
+
internalSecrets: InternalSecretsService;
|
|
342
|
+
}): Promise<number> {
|
|
343
|
+
let deleted = await pruneScopeSecrets({
|
|
344
|
+
channel: scopeChannel(configurationId, { kind: "strategy" }),
|
|
345
|
+
oldConfig,
|
|
346
|
+
newConfig: newConfig ?? {},
|
|
347
|
+
internalSecrets,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const oldById = new Map((oldCollectors ?? []).map((e) => [e.id, e]));
|
|
351
|
+
const newById = new Map((newCollectors ?? []).map((e) => [e.id, e]));
|
|
352
|
+
for (const scope of collectorScopes(oldCollectors, newCollectors)) {
|
|
353
|
+
deleted += await pruneScopeSecrets({
|
|
354
|
+
channel: scopeChannel(configurationId, scope),
|
|
355
|
+
oldConfig: oldById.get(scope.entryId)?.config ?? {},
|
|
356
|
+
newConfig: newById.get(scope.entryId)?.config ?? {},
|
|
357
|
+
internalSecrets,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return deleted;
|
|
361
|
+
}
|
|
@@ -146,14 +146,16 @@ export function buildHealthcheckKind(
|
|
|
146
146
|
|
|
147
147
|
const strategy = matchStrategy.strategy;
|
|
148
148
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
149
|
+
// Resolvability check ONLY: resolving `${{ secrets.* }}` references in
|
|
150
|
+
// `x-secret` fields surfaces a missing/undeclared secret as a clear
|
|
151
|
+
// apply-time error. The RESOLVED VALUES ARE DISCARDED - the ORIGINAL
|
|
152
|
+
// spec (references intact) is what gets validated and persisted, so a
|
|
153
|
+
// resolved secret never lands in the stored row. The executor resolves
|
|
154
|
+
// references just-in-time at run.
|
|
155
|
+
await context.resolveSecretsBySchema({
|
|
156
|
+
value: spec.config,
|
|
157
|
+
schema: strategy.config.schema,
|
|
158
|
+
});
|
|
157
159
|
|
|
158
160
|
// Migrate-then-validate-strict: authored gitops YAML may be in an OLD
|
|
159
161
|
// config shape, so run the migration chain (assume-v1-on-read) before
|
|
@@ -162,10 +164,11 @@ export function buildHealthcheckKind(
|
|
|
162
164
|
// exact strict-validate path the `validateConfiguration` RPC uses, so the
|
|
163
165
|
// two agree on what counts as valid. A strategy config is always a plain
|
|
164
166
|
// object validated by the strategy's own schema, so narrowing the
|
|
165
|
-
// `unknown` result to the stored `Record` shape is safe.
|
|
167
|
+
// `unknown` result to the stored `Record` shape is safe. Secret fields
|
|
168
|
+
// hold reference strings here, which validate as ordinary strings.
|
|
166
169
|
const strategyResult = await validateVersionedConfigStrict({
|
|
167
170
|
config: strategy.config,
|
|
168
|
-
value:
|
|
171
|
+
value: spec.config,
|
|
169
172
|
basePath: ["config"],
|
|
170
173
|
});
|
|
171
174
|
if (!strategyResult.ok) {
|
|
@@ -176,7 +179,7 @@ export function buildHealthcheckKind(
|
|
|
176
179
|
const migratedConfig = strategyResult.value as Record<string, unknown>;
|
|
177
180
|
|
|
178
181
|
// Resolve and validate collector configs using their registry schemas
|
|
179
|
-
const
|
|
182
|
+
const validatedCollectors = spec.collectors
|
|
180
183
|
? await Promise.all(
|
|
181
184
|
spec.collectors.map(async (c) => {
|
|
182
185
|
// Look up collector using strictly the fully qualified ID
|
|
@@ -196,12 +199,13 @@ export function buildHealthcheckKind(
|
|
|
196
199
|
}
|
|
197
200
|
const registered = matchCollector;
|
|
198
201
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
// Resolvability check ONLY (see the strategy-config note): the
|
|
203
|
+
// resolved values are discarded and the ORIGINAL config (with
|
|
204
|
+
// references intact) is validated and persisted.
|
|
205
|
+
await context.resolveSecretsBySchema({
|
|
206
|
+
value: c.config,
|
|
207
|
+
schema: registered.collector.config.schema,
|
|
208
|
+
});
|
|
205
209
|
|
|
206
210
|
// Migrate-then-validate-strict: authored gitops YAML may use an
|
|
207
211
|
// OLD collector config shape. Run the migration chain before
|
|
@@ -213,7 +217,7 @@ export function buildHealthcheckKind(
|
|
|
213
217
|
// `Record` shape is safe.
|
|
214
218
|
const collectorResult = await validateVersionedConfigStrict({
|
|
215
219
|
config: registered.collector.config,
|
|
216
|
-
value:
|
|
220
|
+
value: c.config,
|
|
217
221
|
basePath: ["config"],
|
|
218
222
|
});
|
|
219
223
|
if (!collectorResult.ok) {
|
|
@@ -235,18 +239,25 @@ export function buildHealthcheckKind(
|
|
|
235
239
|
const displayName = entity.metadata.title ?? entity.metadata.name;
|
|
236
240
|
|
|
237
241
|
if (existingEntityId && !existingEntityId.startsWith("pending-")) {
|
|
238
|
-
await service.updateConfiguration(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
242
|
+
await service.updateConfiguration(
|
|
243
|
+
existingEntityId,
|
|
244
|
+
{
|
|
245
|
+
name: displayName,
|
|
246
|
+
strategyId: spec.strategy,
|
|
247
|
+
config: migratedConfig,
|
|
248
|
+
intervalSeconds: spec.intervalSeconds,
|
|
249
|
+
collectors: validatedCollectors?.map((c) => ({
|
|
250
|
+
id: c.collectorId,
|
|
251
|
+
collectorId: c.collectorId,
|
|
252
|
+
config: c.config,
|
|
253
|
+
assertions: c.assertions,
|
|
254
|
+
})),
|
|
255
|
+
},
|
|
256
|
+
// GitOps is declarative: the authored YAML is the whole truth, so an
|
|
257
|
+
// omitted secret field means "not set" and must be removed, not
|
|
258
|
+
// silently restored from the stored row (keep-existing is UI-only).
|
|
259
|
+
{ mergeSecrets: false },
|
|
260
|
+
);
|
|
250
261
|
context.logger.info(
|
|
251
262
|
`GitOps: updated Healthcheck "${displayName}" (id: ${existingEntityId})`,
|
|
252
263
|
);
|
|
@@ -258,7 +269,7 @@ export function buildHealthcheckKind(
|
|
|
258
269
|
strategyId: spec.strategy,
|
|
259
270
|
config: migratedConfig,
|
|
260
271
|
intervalSeconds: spec.intervalSeconds,
|
|
261
|
-
collectors:
|
|
272
|
+
collectors: validatedCollectors?.map((c) => ({
|
|
262
273
|
id: c.collectorId,
|
|
263
274
|
collectorId: c.collectorId,
|
|
264
275
|
config: c.config,
|
package/src/index.ts
CHANGED
|
@@ -56,7 +56,9 @@ import {
|
|
|
56
56
|
type HealthEntityState,
|
|
57
57
|
} from "./health-entity";
|
|
58
58
|
import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
|
|
59
|
-
import { secretResolverRef } from "@checkstack/secrets-backend";
|
|
59
|
+
import { secretResolverRef, internalSecretsRef } from "@checkstack/secrets-backend";
|
|
60
|
+
import type { HealthCheckSecretsDeps } from "./config-secrets";
|
|
61
|
+
import { backfillConfigSecrets } from "./config-secrets-backfill";
|
|
60
62
|
import { createHealthCheckRouter } from "./router";
|
|
61
63
|
import { HealthCheckService } from "./service";
|
|
62
64
|
import {
|
|
@@ -171,6 +173,7 @@ export default createBackendPlugin({
|
|
|
171
173
|
let gitopsHealthCheckRegistry: HealthCheckRegistry | undefined;
|
|
172
174
|
let gitopsCollectorRegistry: CollectorRegistry | undefined;
|
|
173
175
|
let gitopsQueueManager: QueueManager | undefined;
|
|
176
|
+
let gitopsConfigSecrets: HealthCheckSecretsDeps | undefined;
|
|
174
177
|
let healthCheckCache:
|
|
175
178
|
| ReturnType<typeof createHealthCheckCache>
|
|
176
179
|
| undefined;
|
|
@@ -188,6 +191,9 @@ export default createBackendPlugin({
|
|
|
188
191
|
gitopsDb,
|
|
189
192
|
gitopsHealthCheckRegistry,
|
|
190
193
|
gitopsCollectorRegistry,
|
|
194
|
+
undefined,
|
|
195
|
+
undefined,
|
|
196
|
+
gitopsConfigSecrets,
|
|
191
197
|
);
|
|
192
198
|
},
|
|
193
199
|
getHealthCheckRegistry: () => {
|
|
@@ -220,6 +226,7 @@ export default createBackendPlugin({
|
|
|
220
226
|
cacheManager: coreServices.cacheManager,
|
|
221
227
|
config: coreServices.config,
|
|
222
228
|
secretResolver: secretResolverRef,
|
|
229
|
+
internalSecrets: internalSecretsRef,
|
|
223
230
|
advisoryLock: coreServices.advisoryLock,
|
|
224
231
|
resourceResolverRegistry: coreServices.resourceResolverRegistry,
|
|
225
232
|
},
|
|
@@ -236,6 +243,7 @@ export default createBackendPlugin({
|
|
|
236
243
|
cacheManager,
|
|
237
244
|
config,
|
|
238
245
|
secretResolver,
|
|
246
|
+
internalSecrets,
|
|
239
247
|
advisoryLock,
|
|
240
248
|
resourceResolverRegistry,
|
|
241
249
|
}) => {
|
|
@@ -243,6 +251,34 @@ export default createBackendPlugin({
|
|
|
243
251
|
|
|
244
252
|
const typedDb = database as SafeDatabase<typeof schema>;
|
|
245
253
|
|
|
254
|
+
// Secrets channel for config credentials: extract-on-write /
|
|
255
|
+
// redact-on-read / inflate-at-run. Shared by the router, the gitops
|
|
256
|
+
// reconcile service, the executor, and the boot backfill.
|
|
257
|
+
const configSecrets: HealthCheckSecretsDeps = {
|
|
258
|
+
internalSecrets,
|
|
259
|
+
secretResolver,
|
|
260
|
+
// Serializes concurrent updateConfiguration writes to the SAME config
|
|
261
|
+
// id so one writer's orphan-prune cannot delete a secret a concurrent
|
|
262
|
+
// writer just set (which would leave a dangling marker).
|
|
263
|
+
advisoryLock,
|
|
264
|
+
};
|
|
265
|
+
gitopsConfigSecrets = configSecrets;
|
|
266
|
+
|
|
267
|
+
// Move any pre-channel inline secrets out of stored rows (idempotent,
|
|
268
|
+
// advisory-locked, fail-open: a backfill failure must not block boot).
|
|
269
|
+
try {
|
|
270
|
+
await backfillConfigSecrets({
|
|
271
|
+
db: typedDb,
|
|
272
|
+
registry: healthCheckRegistry,
|
|
273
|
+
collectorRegistry,
|
|
274
|
+
internalSecrets,
|
|
275
|
+
advisoryLock,
|
|
276
|
+
logger,
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
logger.warn("Config-secrets backfill failed; continuing boot", error);
|
|
280
|
+
}
|
|
281
|
+
|
|
246
282
|
// Resolve/search health-check configurations by name for the Teams admin
|
|
247
283
|
// UI (team grants are stored as opaque `<type>:<configId>` rows, where
|
|
248
284
|
// <type> is `healthCheckResourceTypes.configuration` — i.e.
|
|
@@ -458,6 +494,7 @@ export default createBackendPlugin({
|
|
|
458
494
|
getHealthEntity: () => healthEntity,
|
|
459
495
|
cache,
|
|
460
496
|
secretResolver,
|
|
497
|
+
internalSecrets,
|
|
461
498
|
});
|
|
462
499
|
|
|
463
500
|
// Setup retention job for tiered storage (daily aggregation)
|
|
@@ -486,6 +523,7 @@ export default createBackendPlugin({
|
|
|
486
523
|
maintenanceClient,
|
|
487
524
|
logger,
|
|
488
525
|
signalService,
|
|
526
|
+
configSecrets,
|
|
489
527
|
recomputeSystemRollupHealth: (systemId) =>
|
|
490
528
|
recomputeSystemRollupHealth({
|
|
491
529
|
systemId,
|
|
@@ -629,6 +667,14 @@ export default createBackendPlugin({
|
|
|
629
667
|
// Re-export hooks for other plugins to use
|
|
630
668
|
export { healthCheckHooks } from "./hooks";
|
|
631
669
|
|
|
670
|
+
// Re-export the config-secrets channel so satellite-backend can resolve a
|
|
671
|
+
// satellite's assignment config secrets just-in-time (the same walk +
|
|
672
|
+
// marker/reference semantics the core executor uses).
|
|
673
|
+
export {
|
|
674
|
+
collectConfigSecretValues,
|
|
675
|
+
type HealthCheckSecretsDeps,
|
|
676
|
+
} from "./config-secrets";
|
|
677
|
+
|
|
632
678
|
// Re-export the reactive `health` entity surface so cross-plugin consumers
|
|
633
679
|
// (slo, dependency) can subscribe via onEntityChanged + classify changes
|
|
634
680
|
// without duplicating the kind id / transition predicate (§10.3).
|