@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
package/src/service.ts
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
type CollectorConfigEntry,
|
|
13
13
|
type HealthcheckSignalStatuses,
|
|
14
14
|
type RunStats,
|
|
15
|
+
stripEphemeralFields,
|
|
15
16
|
} from "@checkstack/healthcheck-common";
|
|
17
|
+
import { evaluateCollectorAssertionOutcomes } from "./collector-assertions";
|
|
16
18
|
import { summarizeRuns, type StatRun } from "./run-stats.logic";
|
|
17
19
|
import type { ConfigService } from "@checkstack/backend-api";
|
|
18
20
|
import type { InferClient } from "@checkstack/common";
|
|
@@ -57,12 +59,25 @@ import type {
|
|
|
57
59
|
import {
|
|
58
60
|
aggregateCollectorData,
|
|
59
61
|
extractLatencies,
|
|
62
|
+
foldRunAssertionStats,
|
|
60
63
|
mergeTieredBuckets,
|
|
61
64
|
reaggregateBuckets,
|
|
62
65
|
countStatuses,
|
|
63
66
|
calculateLatencyStats,
|
|
64
67
|
type NormalizedBucket,
|
|
65
68
|
} from "./aggregation-utils";
|
|
69
|
+
import { ASSERTIONS_AGG_KEY } from "@checkstack/healthcheck-common";
|
|
70
|
+
import {
|
|
71
|
+
extractConfigurationSecrets,
|
|
72
|
+
mergeConfigurationSecrets,
|
|
73
|
+
deleteConfigurationSecrets,
|
|
74
|
+
pruneOrphanedConfigurationSecrets,
|
|
75
|
+
redactSecretFields,
|
|
76
|
+
listPopulatedSecretKeys,
|
|
77
|
+
healthcheckConfigLockKey,
|
|
78
|
+
type GetCollectorSchema,
|
|
79
|
+
type HealthCheckSecretsDeps,
|
|
80
|
+
} from "./config-secrets";
|
|
66
81
|
|
|
67
82
|
// Drizzle type helper - uses SafeDatabase to prevent relational query API usage
|
|
68
83
|
type Db = SafeDatabase<typeof schema>;
|
|
@@ -118,8 +133,26 @@ export class HealthCheckService {
|
|
|
118
133
|
* test constructions), `systemName` falls back to the `systemId`.
|
|
119
134
|
*/
|
|
120
135
|
private catalogClient?: CatalogClient,
|
|
136
|
+
/**
|
|
137
|
+
* Optional — the secrets channel for config credentials. When present,
|
|
138
|
+
* every write extracts inline `x-secret` values into internal secrets
|
|
139
|
+
* (the stored row holds only markers / `${{ secrets.* }}` references)
|
|
140
|
+
* and updates merge blank/absent secrets from the stored row. MUST be
|
|
141
|
+
* provided on every construction that serves writes (router, gitops);
|
|
142
|
+
* kept optional only for read-path and test constructions.
|
|
143
|
+
*/
|
|
144
|
+
private secrets?: HealthCheckSecretsDeps,
|
|
121
145
|
) {}
|
|
122
146
|
|
|
147
|
+
/** Schema lookup for a collector entry, or undefined when unregistered. */
|
|
148
|
+
private getCollectorSchema: GetCollectorSchema = (collectorId) =>
|
|
149
|
+
this.collectorRegistry.getCollector(collectorId)?.collector.config.schema;
|
|
150
|
+
|
|
151
|
+
/** Schema lookup for a strategy, or undefined when unregistered. */
|
|
152
|
+
private getStrategySchema(strategyId: string) {
|
|
153
|
+
return this.registry.getStrategy(strategyId)?.config.schema;
|
|
154
|
+
}
|
|
155
|
+
|
|
123
156
|
/**
|
|
124
157
|
* Resolve the platform-wide notification policy defaults. Returns
|
|
125
158
|
* the compile-time defaults when no `configService` was provided or
|
|
@@ -163,18 +196,57 @@ export class HealthCheckService {
|
|
|
163
196
|
async createConfiguration(
|
|
164
197
|
data: CreateHealthCheckConfiguration,
|
|
165
198
|
): Promise<HealthCheckConfiguration> {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
199
|
+
// Generate the id up front: secret extraction keys internal secrets by
|
|
200
|
+
// configuration id, and the raw values must never reach the row insert.
|
|
201
|
+
const id = crypto.randomUUID();
|
|
202
|
+
let configToStore = data.config;
|
|
203
|
+
let collectorsToStore = data.collectors;
|
|
204
|
+
|
|
205
|
+
// Gate extraction on `this.secrets` alone, NOT on the strategy being
|
|
206
|
+
// registered: extractConfigurationSecrets handles an undefined strategy
|
|
207
|
+
// schema (passing the strategy config through untouched) while STILL
|
|
208
|
+
// extracting every registered collector's secrets. Gating on strategySchema
|
|
209
|
+
// would store a registered collector's inline secret as plaintext at rest
|
|
210
|
+
// when the strategy is unregistered - the leak updateConfiguration avoids.
|
|
211
|
+
const strategySchema = this.getStrategySchema(data.strategyId);
|
|
212
|
+
if (this.secrets) {
|
|
213
|
+
const extracted = await extractConfigurationSecrets({
|
|
214
|
+
configurationId: id,
|
|
215
|
+
strategySchema,
|
|
171
216
|
config: data.config,
|
|
172
|
-
collectors: data.collectors
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
176
|
-
.
|
|
177
|
-
|
|
217
|
+
collectors: data.collectors,
|
|
218
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
219
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
220
|
+
});
|
|
221
|
+
configToStore = extracted.config;
|
|
222
|
+
collectorsToStore = extracted.collectors;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const [config] = await this.db
|
|
227
|
+
.insert(healthCheckConfigurations)
|
|
228
|
+
.values({
|
|
229
|
+
id,
|
|
230
|
+
name: data.name,
|
|
231
|
+
strategyId: data.strategyId,
|
|
232
|
+
config: configToStore,
|
|
233
|
+
collectors: collectorsToStore ?? undefined,
|
|
234
|
+
intervalSeconds: data.intervalSeconds,
|
|
235
|
+
isTemplate: false, // Defaulting for now
|
|
236
|
+
})
|
|
237
|
+
.returning();
|
|
238
|
+
return this.mapConfig(config);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
// The insert never committed, so the just-extracted internal secrets are
|
|
241
|
+
// now orphaned (a config id that owns no row). Delete them so a failed
|
|
242
|
+
// create leaves nothing dangling in the store.
|
|
243
|
+
await this.cleanupExtractedSecrets({
|
|
244
|
+
id,
|
|
245
|
+
config: configToStore,
|
|
246
|
+
collectors: collectorsToStore,
|
|
247
|
+
});
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
178
250
|
}
|
|
179
251
|
|
|
180
252
|
async getConfiguration(
|
|
@@ -187,25 +259,303 @@ export class HealthCheckService {
|
|
|
187
259
|
return config ? this.mapConfig(config) : undefined;
|
|
188
260
|
}
|
|
189
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Redact a configuration for a UI/AI read: every `x-secret` field is
|
|
264
|
+
* removed from the strategy config and each collector config. Stored
|
|
265
|
+
* values, `${{ secrets.* }}` references, and even internal markers never
|
|
266
|
+
* leave the backend; the editor treats the absent field as
|
|
267
|
+
* "keep existing" (`keepExistingSecretFields`).
|
|
268
|
+
*/
|
|
269
|
+
redactConfiguration(
|
|
270
|
+
configuration: HealthCheckConfiguration,
|
|
271
|
+
): HealthCheckConfiguration {
|
|
272
|
+
// FAIL CLOSED on an unregistered strategy/collector (plugin uninstalled):
|
|
273
|
+
// without its schema we cannot know which fields are secret, so return an
|
|
274
|
+
// empty config rather than a possibly-secret-bearing one.
|
|
275
|
+
const strategySchema = this.getStrategySchema(configuration.strategyId);
|
|
276
|
+
const config = strategySchema
|
|
277
|
+
? redactSecretFields({ schema: strategySchema, config: configuration.config })
|
|
278
|
+
: {};
|
|
279
|
+
const collectors = configuration.collectors?.map((entry) => {
|
|
280
|
+
const schema = this.getCollectorSchema(entry.collectorId);
|
|
281
|
+
if (!schema) return { ...entry, config: {} };
|
|
282
|
+
return { ...entry, config: redactSecretFields({ schema, config: entry.config }) };
|
|
283
|
+
});
|
|
284
|
+
// Tell the editor which secret fields ACTUALLY have a stored value (computed
|
|
285
|
+
// from the pre-redaction config), so a never-set optional secret does not
|
|
286
|
+
// show a misleading "a secret is stored" hint / Clear affordance.
|
|
287
|
+
const configuredSecrets = {
|
|
288
|
+
strategy: strategySchema
|
|
289
|
+
? listPopulatedSecretKeys({
|
|
290
|
+
schema: strategySchema,
|
|
291
|
+
config: configuration.config,
|
|
292
|
+
})
|
|
293
|
+
: [],
|
|
294
|
+
collectors: Object.fromEntries(
|
|
295
|
+
(configuration.collectors ?? []).map((entry) => {
|
|
296
|
+
const schema = this.getCollectorSchema(entry.collectorId);
|
|
297
|
+
return [
|
|
298
|
+
entry.id,
|
|
299
|
+
schema
|
|
300
|
+
? listPopulatedSecretKeys({ schema, config: entry.config })
|
|
301
|
+
: [],
|
|
302
|
+
];
|
|
303
|
+
}),
|
|
304
|
+
),
|
|
305
|
+
};
|
|
306
|
+
return { ...configuration, config, collectors, configuredSecrets };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async getConfigurationRedacted(
|
|
310
|
+
id: string,
|
|
311
|
+
): Promise<HealthCheckConfiguration | undefined> {
|
|
312
|
+
const configuration = await this.getConfiguration(id);
|
|
313
|
+
return configuration ? this.redactConfiguration(configuration) : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async getConfigurationsRedacted(): Promise<HealthCheckConfiguration[]> {
|
|
317
|
+
const configurations = await this.getConfigurations();
|
|
318
|
+
return configurations.map((c) => this.redactConfiguration(c));
|
|
319
|
+
}
|
|
320
|
+
|
|
190
321
|
async updateConfiguration(
|
|
191
322
|
id: string,
|
|
192
323
|
data: UpdateHealthCheckConfiguration,
|
|
324
|
+
options?: {
|
|
325
|
+
/**
|
|
326
|
+
* Whether a blank/absent `x-secret` field means "keep the stored value"
|
|
327
|
+
* (the UI editor round-trips the REDACTED config, so this is `true` by
|
|
328
|
+
* default). GitOps is DECLARATIVE - the authored source is the whole
|
|
329
|
+
* truth, so an omitted secret field means "not set". Pass `false` there
|
|
330
|
+
* so absent secrets are removed rather than silently re-restored.
|
|
331
|
+
*/
|
|
332
|
+
mergeSecrets?: boolean;
|
|
333
|
+
},
|
|
193
334
|
): Promise<HealthCheckConfiguration | undefined> {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
335
|
+
const mergeSecrets = options?.mergeSecrets ?? true;
|
|
336
|
+
|
|
337
|
+
const applyUpdate = async (): Promise<
|
|
338
|
+
HealthCheckConfiguration | undefined
|
|
339
|
+
> => {
|
|
340
|
+
let body = data;
|
|
341
|
+
// Deferred until AFTER the row is written: deletes internal secrets
|
|
342
|
+
// orphaned by this edit (a cleared / declaratively-removed field, an
|
|
343
|
+
// inline secret swapped for a reference, a removed collector). Post-write
|
|
344
|
+
// means a crash can never leave the row pointing at a marker whose secret
|
|
345
|
+
// we already deleted (a dangling marker fails closed at run time).
|
|
346
|
+
let pruneOrphans: (() => Promise<void>) | undefined;
|
|
347
|
+
|
|
348
|
+
if (this.secrets && (data.config || data.collectors)) {
|
|
349
|
+
// The editor round-trips the REDACTED config, so blank/absent secrets
|
|
350
|
+
// mean "keep existing": restore them from the stored row BEFORE
|
|
351
|
+
// extraction (restored markers pass through extraction untouched).
|
|
352
|
+
const stored = await this.getConfiguration(id);
|
|
353
|
+
if (stored) {
|
|
354
|
+
const strategyId = data.strategyId ?? stored.strategyId;
|
|
355
|
+
const strategySchema = this.getStrategySchema(strategyId);
|
|
356
|
+
|
|
357
|
+
// Preserve an UNREGISTERED collector entry's stored config: a read
|
|
358
|
+
// redacts it to `{}` (fail-closed), and the editor round-trips that
|
|
359
|
+
// back - never persist the `{}` over the stored config.
|
|
360
|
+
const storedById = new Map(
|
|
361
|
+
(stored.collectors ?? []).map((entry) => [entry.id, entry]),
|
|
362
|
+
);
|
|
363
|
+
const sanitizedCollectors = data.collectors?.map((entry) => {
|
|
364
|
+
if (this.getCollectorSchema(entry.collectorId)) return entry;
|
|
365
|
+
const storedEntry = storedById.get(entry.id);
|
|
366
|
+
return storedEntry
|
|
367
|
+
? { ...entry, config: storedEntry.config }
|
|
368
|
+
: entry;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Strategy config: registered -> merge/extract (or wholesale for
|
|
372
|
+
// gitops); UNREGISTERED -> preserve the stored config (we have no
|
|
373
|
+
// schema to merge/extract/validate it). REGISTERED COLLECTORS are
|
|
374
|
+
// processed via their OWN schemas regardless of strategy
|
|
375
|
+
// registration, so a registered collector under an unregistered
|
|
376
|
+
// strategy still extracts its secrets (no plaintext-at-rest, no wipe).
|
|
377
|
+
const incomingStrategyConfig = strategySchema
|
|
378
|
+
? (data.config ?? stored.config)
|
|
379
|
+
: stored.config;
|
|
380
|
+
|
|
381
|
+
// UI keeps existing secrets (blank = keep); GitOps applies the
|
|
382
|
+
// authored config wholesale so an omitted secret is genuinely removed.
|
|
383
|
+
const merged = mergeSecrets
|
|
384
|
+
? mergeConfigurationSecrets({
|
|
385
|
+
strategySchema,
|
|
386
|
+
incomingConfig: incomingStrategyConfig,
|
|
387
|
+
storedConfig: stored.config,
|
|
388
|
+
incomingCollectors: sanitizedCollectors,
|
|
389
|
+
storedCollectors: stored.collectors,
|
|
390
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
391
|
+
})
|
|
392
|
+
: { config: incomingStrategyConfig, collectors: sanitizedCollectors };
|
|
393
|
+
|
|
394
|
+
const extracted = await extractConfigurationSecrets({
|
|
395
|
+
configurationId: id,
|
|
396
|
+
strategySchema,
|
|
397
|
+
config: merged.config,
|
|
398
|
+
collectors: merged.collectors,
|
|
399
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
400
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
401
|
+
});
|
|
402
|
+
body = {
|
|
403
|
+
...data,
|
|
404
|
+
...(data.config ? { config: extracted.config } : {}),
|
|
405
|
+
...(data.collectors ? { collectors: extracted.collectors } : {}),
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// A partial update only changes provided fields, so the effective new
|
|
409
|
+
// state of an omitted field is the stored one (unchanged, no orphan).
|
|
410
|
+
const newConfig = data.config ? extracted.config : stored.config;
|
|
411
|
+
const newCollectors = data.collectors
|
|
412
|
+
? extracted.collectors
|
|
413
|
+
: stored.collectors;
|
|
414
|
+
const secrets = this.secrets;
|
|
415
|
+
pruneOrphans = async () => {
|
|
416
|
+
await pruneOrphanedConfigurationSecrets({
|
|
417
|
+
configurationId: id,
|
|
418
|
+
oldConfig: stored.config,
|
|
419
|
+
newConfig,
|
|
420
|
+
oldCollectors: stored.collectors,
|
|
421
|
+
newCollectors,
|
|
422
|
+
internalSecrets: secrets.internalSecrets,
|
|
423
|
+
});
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const [config] = await this.db
|
|
429
|
+
.update(healthCheckConfigurations)
|
|
430
|
+
.set({
|
|
431
|
+
...body,
|
|
432
|
+
updatedAt: new Date(),
|
|
433
|
+
})
|
|
434
|
+
.where(eq(healthCheckConfigurations.id, id))
|
|
435
|
+
.returning();
|
|
436
|
+
|
|
437
|
+
// The row now holds the new markers; delete the internal secrets this
|
|
438
|
+
// edit orphaned. Post-write so a dangling marker can never outlive its
|
|
439
|
+
// secret.
|
|
440
|
+
if (config) await pruneOrphans?.();
|
|
441
|
+
|
|
442
|
+
return config ? this.mapConfig(config) : undefined;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// Serialize the read-modify-write-prune per config id so a concurrent
|
|
446
|
+
// writer to the SAME id (e.g. a UI edit racing a GitOps reconcile across
|
|
447
|
+
// pods) cannot have its just-written secret deleted by this writer's stale
|
|
448
|
+
// orphan-prune, which would leave a dangling marker. Only needed on the
|
|
449
|
+
// secret-bearing path and only when a cluster-wide lock is wired.
|
|
450
|
+
const lock = this.secrets?.advisoryLock;
|
|
451
|
+
if (lock && (data.config || data.collectors)) {
|
|
452
|
+
return lock.withXactLock({
|
|
453
|
+
key: healthcheckConfigLockKey(id),
|
|
454
|
+
fn: applyUpdate,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return applyUpdate();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Restore a stored config's secrets into a proposed (redacted) config so it
|
|
462
|
+
* can be deep-validated as an UPDATE without spuriously failing a
|
|
463
|
+
* required-secret check. Mirrors the merge `updateConfiguration` does
|
|
464
|
+
* before persisting, so validate and apply agree; the restored values are
|
|
465
|
+
* used only for validation and never returned to the caller. Returns the
|
|
466
|
+
* proposed config unchanged when the id is unknown or secrets are not wired.
|
|
467
|
+
*/
|
|
468
|
+
async restoreSecretsForValidation({
|
|
469
|
+
existingConfigurationId,
|
|
470
|
+
strategyId,
|
|
471
|
+
config,
|
|
472
|
+
collectors,
|
|
473
|
+
}: {
|
|
474
|
+
existingConfigurationId: string;
|
|
475
|
+
strategyId: string;
|
|
476
|
+
config: Record<string, unknown>;
|
|
477
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
478
|
+
}): Promise<{
|
|
479
|
+
config: Record<string, unknown>;
|
|
480
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
481
|
+
}> {
|
|
482
|
+
const strategySchema = this.getStrategySchema(strategyId);
|
|
483
|
+
const stored = await this.getConfiguration(existingConfigurationId);
|
|
484
|
+
if (!strategySchema || !stored) return { config, collectors };
|
|
485
|
+
return mergeConfigurationSecrets({
|
|
486
|
+
strategySchema,
|
|
487
|
+
incomingConfig: config,
|
|
488
|
+
storedConfig: stored.config,
|
|
489
|
+
incomingCollectors: collectors,
|
|
490
|
+
storedCollectors: stored.collectors,
|
|
491
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
492
|
+
});
|
|
203
493
|
}
|
|
204
494
|
|
|
205
495
|
async deleteConfiguration(id: string): Promise<void> {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
496
|
+
const applyDelete = async (): Promise<void> => {
|
|
497
|
+
// Clean up the internal secrets the stored markers point at BEFORE the
|
|
498
|
+
// row disappears (the markers are the only index into them). Schema-free,
|
|
499
|
+
// so an uninstalled strategy/collector plugin does NOT leave its secrets
|
|
500
|
+
// orphaned - marker enumeration does not depend on the plugin being loaded.
|
|
501
|
+
if (this.secrets) {
|
|
502
|
+
const stored = await this.getConfiguration(id);
|
|
503
|
+
if (stored) {
|
|
504
|
+
await deleteConfigurationSecrets({
|
|
505
|
+
configurationId: id,
|
|
506
|
+
config: stored.config,
|
|
507
|
+
collectors: stored.collectors,
|
|
508
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
await this.db
|
|
513
|
+
.delete(healthCheckConfigurations)
|
|
514
|
+
.where(eq(healthCheckConfigurations.id, id));
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Serialize delete under the SAME per-config lock updateConfiguration uses,
|
|
518
|
+
// so a concurrent update on this id cannot interleave: a delete reading the
|
|
519
|
+
// pre-update row would clean up the OLD markers and drop the row while the
|
|
520
|
+
// update writes a fresh internal secret, orphaning it. Delete and update of
|
|
521
|
+
// one id must be mutually exclusive.
|
|
522
|
+
const lock = this.secrets?.advisoryLock;
|
|
523
|
+
if (lock) {
|
|
524
|
+
return lock.withXactLock({
|
|
525
|
+
key: healthcheckConfigLockKey(id),
|
|
526
|
+
fn: applyDelete,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return applyDelete();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Delete internal secrets extracted for a config whose row insert did NOT
|
|
534
|
+
* commit (a rolled-back create), so a failed create never orphans the
|
|
535
|
+
* secrets it extracted. Best-effort: swallows cleanup errors so the original
|
|
536
|
+
* insert error is the one surfaced.
|
|
537
|
+
*/
|
|
538
|
+
private async cleanupExtractedSecrets({
|
|
539
|
+
id,
|
|
540
|
+
config,
|
|
541
|
+
collectors,
|
|
542
|
+
}: {
|
|
543
|
+
id: string;
|
|
544
|
+
config: Record<string, unknown>;
|
|
545
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
546
|
+
}): Promise<void> {
|
|
547
|
+
if (!this.secrets) return;
|
|
548
|
+
try {
|
|
549
|
+
await deleteConfigurationSecrets({
|
|
550
|
+
configurationId: id,
|
|
551
|
+
config,
|
|
552
|
+
collectors,
|
|
553
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
554
|
+
});
|
|
555
|
+
} catch {
|
|
556
|
+
// Ignore: the create already failed; surfacing a cleanup error would mask
|
|
557
|
+
// the real cause. The orphan (if any) is a harmless encrypted blob.
|
|
558
|
+
}
|
|
209
559
|
}
|
|
210
560
|
|
|
211
561
|
async pauseConfiguration(id: string): Promise<void> {
|
|
@@ -330,32 +680,70 @@ export class HealthCheckService {
|
|
|
330
680
|
const versionedThresholds: VersionedStateThresholds | undefined =
|
|
331
681
|
stateThresholds_ ? stateThresholds.create(stateThresholds_) : undefined;
|
|
332
682
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
await
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
includeLocal,
|
|
354
|
-
notificationPolicy: notificationPolicy ?? undefined,
|
|
683
|
+
// Extract inline secrets into the encrypted internal store BEFORE insert -
|
|
684
|
+
// identical to createConfiguration. Without this, the first-check wizard
|
|
685
|
+
// and the AI propose tool (both createAndAssign callers) would persist
|
|
686
|
+
// credentials as plaintext. The id is generated up front so the internal
|
|
687
|
+
// secrets can be keyed by it.
|
|
688
|
+
const id = crypto.randomUUID();
|
|
689
|
+
let configToStore = configuration.config;
|
|
690
|
+
let collectorsToStore = configuration.collectors;
|
|
691
|
+
// Gate on `this.secrets` alone (see createConfiguration): gating on
|
|
692
|
+
// strategySchema would leave a registered collector's inline secret as
|
|
693
|
+
// plaintext at rest when the strategy is unregistered.
|
|
694
|
+
const strategySchema = this.getStrategySchema(configuration.strategyId);
|
|
695
|
+
if (this.secrets) {
|
|
696
|
+
const extracted = await extractConfigurationSecrets({
|
|
697
|
+
configurationId: id,
|
|
698
|
+
strategySchema,
|
|
699
|
+
config: configuration.config,
|
|
700
|
+
collectors: configuration.collectors,
|
|
701
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
702
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
355
703
|
});
|
|
704
|
+
configToStore = extracted.config;
|
|
705
|
+
collectorsToStore = extracted.collectors;
|
|
706
|
+
}
|
|
356
707
|
|
|
357
|
-
|
|
358
|
-
|
|
708
|
+
let created: typeof healthCheckConfigurations.$inferSelect;
|
|
709
|
+
try {
|
|
710
|
+
created = await this.db.transaction(async (tx) => {
|
|
711
|
+
const [config] = await tx
|
|
712
|
+
.insert(healthCheckConfigurations)
|
|
713
|
+
.values({
|
|
714
|
+
id,
|
|
715
|
+
name: configuration.name,
|
|
716
|
+
strategyId: configuration.strategyId,
|
|
717
|
+
config: configToStore,
|
|
718
|
+
collectors: collectorsToStore ?? undefined,
|
|
719
|
+
intervalSeconds: configuration.intervalSeconds,
|
|
720
|
+
isTemplate: false,
|
|
721
|
+
})
|
|
722
|
+
.returning();
|
|
723
|
+
|
|
724
|
+
await tx.insert(systemHealthChecks).values({
|
|
725
|
+
systemId,
|
|
726
|
+
configurationId: config.id,
|
|
727
|
+
enabled,
|
|
728
|
+
stateThresholds: versionedThresholds,
|
|
729
|
+
satelliteIds: satelliteIds ?? undefined,
|
|
730
|
+
environmentIds: environmentIdsValue,
|
|
731
|
+
includeLocal,
|
|
732
|
+
notificationPolicy: notificationPolicy ?? undefined,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
return config;
|
|
736
|
+
});
|
|
737
|
+
} catch (error) {
|
|
738
|
+
// The transaction rolled back, so no row owns the secrets we just
|
|
739
|
+
// extracted; delete them so a failed create-and-assign never orphans them.
|
|
740
|
+
await this.cleanupExtractedSecrets({
|
|
741
|
+
id,
|
|
742
|
+
config: configToStore,
|
|
743
|
+
collectors: collectorsToStore,
|
|
744
|
+
});
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
359
747
|
|
|
360
748
|
return this.mapConfig(created);
|
|
361
749
|
}
|
|
@@ -487,6 +875,18 @@ export class HealthCheckService {
|
|
|
487
875
|
return Promise.all(rows.map((r) => this.mapConfig(r.config)));
|
|
488
876
|
}
|
|
489
877
|
|
|
878
|
+
/**
|
|
879
|
+
* System-scoped configuration list for UI reads - REDACTED, like
|
|
880
|
+
* `getConfigurationsRedacted`. Any `x-secret` field is stripped before the
|
|
881
|
+
* config leaves the backend.
|
|
882
|
+
*/
|
|
883
|
+
async getSystemConfigurationsRedacted(
|
|
884
|
+
systemId: string,
|
|
885
|
+
): Promise<HealthCheckConfiguration[]> {
|
|
886
|
+
const configurations = await this.getSystemConfigurations(systemId);
|
|
887
|
+
return configurations.map((c) => this.redactConfiguration(c));
|
|
888
|
+
}
|
|
889
|
+
|
|
490
890
|
/**
|
|
491
891
|
* Get system associations with their threshold configurations.
|
|
492
892
|
*/
|
|
@@ -1755,9 +2155,16 @@ export class HealthCheckService {
|
|
|
1755
2155
|
);
|
|
1756
2156
|
}
|
|
1757
2157
|
|
|
2158
|
+
// Per-assertion pass/fail counts (platform-owned, sibling of
|
|
2159
|
+
// `collectors` — see assertion-analytics in healthcheck-common).
|
|
2160
|
+
const assertionStats = foldRunAssertionStats(bucket.runs);
|
|
2161
|
+
|
|
1758
2162
|
aggregatedResult = {
|
|
1759
2163
|
...strategyResult,
|
|
1760
2164
|
...(collectorsAggregated ? { collectors: collectorsAggregated } : {}),
|
|
2165
|
+
...(assertionStats === undefined
|
|
2166
|
+
? {}
|
|
2167
|
+
: { [ASSERTIONS_AGG_KEY]: assertionStats }),
|
|
1761
2168
|
};
|
|
1762
2169
|
}
|
|
1763
2170
|
|
|
@@ -2025,6 +2432,15 @@ export class HealthCheckService {
|
|
|
2025
2432
|
* Ingest a health check result from a satellite.
|
|
2026
2433
|
* Stores the run with source attribution (sourceId + sourceLabel)
|
|
2027
2434
|
* and triggers incremental aggregation to keep charts/availability current.
|
|
2435
|
+
*
|
|
2436
|
+
* Assertions are evaluated HERE, on the core, not on the satellite: the
|
|
2437
|
+
* satellite never held the assertion semantics, so historically
|
|
2438
|
+
* satellite-executed checks silently skipped assertions entirely.
|
|
2439
|
+
* Evaluating at ingest fixes that for every satellite version with no
|
|
2440
|
+
* wire-protocol change. Caveat: buffered results are evaluated against the
|
|
2441
|
+
* configuration CURRENT at ingest time. Ephemeral result fields (e.g. raw
|
|
2442
|
+
* HTTP bodies) are needed for JSONPath assertions and are stripped right
|
|
2443
|
+
* after evaluation, matching what the local executor stores.
|
|
2028
2444
|
*/
|
|
2029
2445
|
async ingestSatelliteResult(props: {
|
|
2030
2446
|
configId: string;
|
|
@@ -2036,20 +2452,77 @@ export class HealthCheckService {
|
|
|
2036
2452
|
sourceId: string;
|
|
2037
2453
|
sourceLabel: string;
|
|
2038
2454
|
}) {
|
|
2039
|
-
const {
|
|
2040
|
-
|
|
2041
|
-
systemId,
|
|
2042
|
-
status,
|
|
2043
|
-
latencyMs,
|
|
2044
|
-
result,
|
|
2045
|
-
sourceId,
|
|
2046
|
-
sourceLabel,
|
|
2047
|
-
} = props;
|
|
2455
|
+
const { configId, systemId, latencyMs, result, sourceId, sourceLabel } =
|
|
2456
|
+
props;
|
|
2048
2457
|
|
|
2049
2458
|
const resultRecord = result
|
|
2050
2459
|
? ({ ...result } as Record<string, unknown>)
|
|
2051
2460
|
: {};
|
|
2052
2461
|
|
|
2462
|
+
let status = props.status;
|
|
2463
|
+
const metadata = resultRecord.metadata as
|
|
2464
|
+
| Record<string, unknown>
|
|
2465
|
+
| undefined;
|
|
2466
|
+
const collectorsMeta = metadata?.collectors as
|
|
2467
|
+
| Record<string, Record<string, unknown>>
|
|
2468
|
+
| undefined;
|
|
2469
|
+
if (collectorsMeta && Object.keys(collectorsMeta).length > 0) {
|
|
2470
|
+
const [configRow] = await this.db
|
|
2471
|
+
.select({ collectors: healthCheckConfigurations.collectors })
|
|
2472
|
+
.from(healthCheckConfigurations)
|
|
2473
|
+
.where(eq(healthCheckConfigurations.id, configId));
|
|
2474
|
+
const entries: CollectorConfigEntry[] = configRow?.collectors ?? [];
|
|
2475
|
+
|
|
2476
|
+
let firstFailure: string | undefined;
|
|
2477
|
+
const nextCollectorsMeta: Record<string, Record<string, unknown>> = {
|
|
2478
|
+
...collectorsMeta,
|
|
2479
|
+
};
|
|
2480
|
+
for (const entry of entries) {
|
|
2481
|
+
const entryResult = nextCollectorsMeta[entry.id];
|
|
2482
|
+
if (!entryResult || typeof entryResult !== "object") continue;
|
|
2483
|
+
|
|
2484
|
+
let evaluated: Record<string, unknown> = { ...entryResult };
|
|
2485
|
+
if (entry.assertions?.length) {
|
|
2486
|
+
const evaluation = evaluateCollectorAssertionOutcomes({
|
|
2487
|
+
assertions: entry.assertions,
|
|
2488
|
+
result: evaluated,
|
|
2489
|
+
});
|
|
2490
|
+
evaluated._assertions = evaluation.outcomes;
|
|
2491
|
+
evaluated._assertionFailed = evaluation.firstFailureMessage;
|
|
2492
|
+
if (
|
|
2493
|
+
evaluation.firstFailureMessage !== undefined &&
|
|
2494
|
+
firstFailure === undefined
|
|
2495
|
+
) {
|
|
2496
|
+
firstFailure = evaluation.firstFailureMessage;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// Parity with the local executor: satellites send raw results, so
|
|
2501
|
+
// ephemeral fields (assertable but never persisted) get stripped
|
|
2502
|
+
// here, AFTER assertions ran against them.
|
|
2503
|
+
const registered = this.collectorRegistry.getCollector(
|
|
2504
|
+
entry.collectorId,
|
|
2505
|
+
);
|
|
2506
|
+
if (registered) {
|
|
2507
|
+
evaluated = stripEphemeralFields(
|
|
2508
|
+
evaluated,
|
|
2509
|
+
registered.collector.result.schema,
|
|
2510
|
+
);
|
|
2511
|
+
}
|
|
2512
|
+
nextCollectorsMeta[entry.id] = evaluated;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
resultRecord.metadata = { ...metadata, collectors: nextCollectorsMeta };
|
|
2516
|
+
|
|
2517
|
+
// Mirror the local executor: a failed assertion downgrades a run the
|
|
2518
|
+
// satellite reported healthy.
|
|
2519
|
+
if (firstFailure !== undefined && status === "healthy") {
|
|
2520
|
+
status = "unhealthy";
|
|
2521
|
+
resultRecord.status = status;
|
|
2522
|
+
resultRecord.message = `Check failed: Assertion failed: ${firstFailure}`;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2053
2526
|
// Atomic: the run row and the hourly-aggregate increment it feeds must
|
|
2054
2527
|
// commit together. Without the transaction a failure on the (non-idempotent
|
|
2055
2528
|
// `runCount + 1`) aggregate left a committed run that the aggregate never
|