@checkstack/healthcheck-backend 1.14.0 → 1.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 +94 -0
- package/package.json +29 -29
- package/src/ai/healthcheck-propose.ts +9 -0
- package/src/ai/healthcheck-update.ts +5 -1
- 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.ts +52 -3
- 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.ts +443 -47
package/src/service.ts
CHANGED
|
@@ -63,6 +63,17 @@ import {
|
|
|
63
63
|
calculateLatencyStats,
|
|
64
64
|
type NormalizedBucket,
|
|
65
65
|
} from "./aggregation-utils";
|
|
66
|
+
import {
|
|
67
|
+
extractConfigurationSecrets,
|
|
68
|
+
mergeConfigurationSecrets,
|
|
69
|
+
deleteConfigurationSecrets,
|
|
70
|
+
pruneOrphanedConfigurationSecrets,
|
|
71
|
+
redactSecretFields,
|
|
72
|
+
listPopulatedSecretKeys,
|
|
73
|
+
healthcheckConfigLockKey,
|
|
74
|
+
type GetCollectorSchema,
|
|
75
|
+
type HealthCheckSecretsDeps,
|
|
76
|
+
} from "./config-secrets";
|
|
66
77
|
|
|
67
78
|
// Drizzle type helper - uses SafeDatabase to prevent relational query API usage
|
|
68
79
|
type Db = SafeDatabase<typeof schema>;
|
|
@@ -118,8 +129,26 @@ export class HealthCheckService {
|
|
|
118
129
|
* test constructions), `systemName` falls back to the `systemId`.
|
|
119
130
|
*/
|
|
120
131
|
private catalogClient?: CatalogClient,
|
|
132
|
+
/**
|
|
133
|
+
* Optional — the secrets channel for config credentials. When present,
|
|
134
|
+
* every write extracts inline `x-secret` values into internal secrets
|
|
135
|
+
* (the stored row holds only markers / `${{ secrets.* }}` references)
|
|
136
|
+
* and updates merge blank/absent secrets from the stored row. MUST be
|
|
137
|
+
* provided on every construction that serves writes (router, gitops);
|
|
138
|
+
* kept optional only for read-path and test constructions.
|
|
139
|
+
*/
|
|
140
|
+
private secrets?: HealthCheckSecretsDeps,
|
|
121
141
|
) {}
|
|
122
142
|
|
|
143
|
+
/** Schema lookup for a collector entry, or undefined when unregistered. */
|
|
144
|
+
private getCollectorSchema: GetCollectorSchema = (collectorId) =>
|
|
145
|
+
this.collectorRegistry.getCollector(collectorId)?.collector.config.schema;
|
|
146
|
+
|
|
147
|
+
/** Schema lookup for a strategy, or undefined when unregistered. */
|
|
148
|
+
private getStrategySchema(strategyId: string) {
|
|
149
|
+
return this.registry.getStrategy(strategyId)?.config.schema;
|
|
150
|
+
}
|
|
151
|
+
|
|
123
152
|
/**
|
|
124
153
|
* Resolve the platform-wide notification policy defaults. Returns
|
|
125
154
|
* the compile-time defaults when no `configService` was provided or
|
|
@@ -163,18 +192,57 @@ export class HealthCheckService {
|
|
|
163
192
|
async createConfiguration(
|
|
164
193
|
data: CreateHealthCheckConfiguration,
|
|
165
194
|
): Promise<HealthCheckConfiguration> {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
195
|
+
// Generate the id up front: secret extraction keys internal secrets by
|
|
196
|
+
// configuration id, and the raw values must never reach the row insert.
|
|
197
|
+
const id = crypto.randomUUID();
|
|
198
|
+
let configToStore = data.config;
|
|
199
|
+
let collectorsToStore = data.collectors;
|
|
200
|
+
|
|
201
|
+
// Gate extraction on `this.secrets` alone, NOT on the strategy being
|
|
202
|
+
// registered: extractConfigurationSecrets handles an undefined strategy
|
|
203
|
+
// schema (passing the strategy config through untouched) while STILL
|
|
204
|
+
// extracting every registered collector's secrets. Gating on strategySchema
|
|
205
|
+
// would store a registered collector's inline secret as plaintext at rest
|
|
206
|
+
// when the strategy is unregistered - the leak updateConfiguration avoids.
|
|
207
|
+
const strategySchema = this.getStrategySchema(data.strategyId);
|
|
208
|
+
if (this.secrets) {
|
|
209
|
+
const extracted = await extractConfigurationSecrets({
|
|
210
|
+
configurationId: id,
|
|
211
|
+
strategySchema,
|
|
171
212
|
config: data.config,
|
|
172
|
-
collectors: data.collectors
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
176
|
-
.
|
|
177
|
-
|
|
213
|
+
collectors: data.collectors,
|
|
214
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
215
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
216
|
+
});
|
|
217
|
+
configToStore = extracted.config;
|
|
218
|
+
collectorsToStore = extracted.collectors;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const [config] = await this.db
|
|
223
|
+
.insert(healthCheckConfigurations)
|
|
224
|
+
.values({
|
|
225
|
+
id,
|
|
226
|
+
name: data.name,
|
|
227
|
+
strategyId: data.strategyId,
|
|
228
|
+
config: configToStore,
|
|
229
|
+
collectors: collectorsToStore ?? undefined,
|
|
230
|
+
intervalSeconds: data.intervalSeconds,
|
|
231
|
+
isTemplate: false, // Defaulting for now
|
|
232
|
+
})
|
|
233
|
+
.returning();
|
|
234
|
+
return this.mapConfig(config);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
// The insert never committed, so the just-extracted internal secrets are
|
|
237
|
+
// now orphaned (a config id that owns no row). Delete them so a failed
|
|
238
|
+
// create leaves nothing dangling in the store.
|
|
239
|
+
await this.cleanupExtractedSecrets({
|
|
240
|
+
id,
|
|
241
|
+
config: configToStore,
|
|
242
|
+
collectors: collectorsToStore,
|
|
243
|
+
});
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
178
246
|
}
|
|
179
247
|
|
|
180
248
|
async getConfiguration(
|
|
@@ -187,25 +255,303 @@ export class HealthCheckService {
|
|
|
187
255
|
return config ? this.mapConfig(config) : undefined;
|
|
188
256
|
}
|
|
189
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Redact a configuration for a UI/AI read: every `x-secret` field is
|
|
260
|
+
* removed from the strategy config and each collector config. Stored
|
|
261
|
+
* values, `${{ secrets.* }}` references, and even internal markers never
|
|
262
|
+
* leave the backend; the editor treats the absent field as
|
|
263
|
+
* "keep existing" (`keepExistingSecretFields`).
|
|
264
|
+
*/
|
|
265
|
+
redactConfiguration(
|
|
266
|
+
configuration: HealthCheckConfiguration,
|
|
267
|
+
): HealthCheckConfiguration {
|
|
268
|
+
// FAIL CLOSED on an unregistered strategy/collector (plugin uninstalled):
|
|
269
|
+
// without its schema we cannot know which fields are secret, so return an
|
|
270
|
+
// empty config rather than a possibly-secret-bearing one.
|
|
271
|
+
const strategySchema = this.getStrategySchema(configuration.strategyId);
|
|
272
|
+
const config = strategySchema
|
|
273
|
+
? redactSecretFields({ schema: strategySchema, config: configuration.config })
|
|
274
|
+
: {};
|
|
275
|
+
const collectors = configuration.collectors?.map((entry) => {
|
|
276
|
+
const schema = this.getCollectorSchema(entry.collectorId);
|
|
277
|
+
if (!schema) return { ...entry, config: {} };
|
|
278
|
+
return { ...entry, config: redactSecretFields({ schema, config: entry.config }) };
|
|
279
|
+
});
|
|
280
|
+
// Tell the editor which secret fields ACTUALLY have a stored value (computed
|
|
281
|
+
// from the pre-redaction config), so a never-set optional secret does not
|
|
282
|
+
// show a misleading "a secret is stored" hint / Clear affordance.
|
|
283
|
+
const configuredSecrets = {
|
|
284
|
+
strategy: strategySchema
|
|
285
|
+
? listPopulatedSecretKeys({
|
|
286
|
+
schema: strategySchema,
|
|
287
|
+
config: configuration.config,
|
|
288
|
+
})
|
|
289
|
+
: [],
|
|
290
|
+
collectors: Object.fromEntries(
|
|
291
|
+
(configuration.collectors ?? []).map((entry) => {
|
|
292
|
+
const schema = this.getCollectorSchema(entry.collectorId);
|
|
293
|
+
return [
|
|
294
|
+
entry.id,
|
|
295
|
+
schema
|
|
296
|
+
? listPopulatedSecretKeys({ schema, config: entry.config })
|
|
297
|
+
: [],
|
|
298
|
+
];
|
|
299
|
+
}),
|
|
300
|
+
),
|
|
301
|
+
};
|
|
302
|
+
return { ...configuration, config, collectors, configuredSecrets };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async getConfigurationRedacted(
|
|
306
|
+
id: string,
|
|
307
|
+
): Promise<HealthCheckConfiguration | undefined> {
|
|
308
|
+
const configuration = await this.getConfiguration(id);
|
|
309
|
+
return configuration ? this.redactConfiguration(configuration) : undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async getConfigurationsRedacted(): Promise<HealthCheckConfiguration[]> {
|
|
313
|
+
const configurations = await this.getConfigurations();
|
|
314
|
+
return configurations.map((c) => this.redactConfiguration(c));
|
|
315
|
+
}
|
|
316
|
+
|
|
190
317
|
async updateConfiguration(
|
|
191
318
|
id: string,
|
|
192
319
|
data: UpdateHealthCheckConfiguration,
|
|
320
|
+
options?: {
|
|
321
|
+
/**
|
|
322
|
+
* Whether a blank/absent `x-secret` field means "keep the stored value"
|
|
323
|
+
* (the UI editor round-trips the REDACTED config, so this is `true` by
|
|
324
|
+
* default). GitOps is DECLARATIVE - the authored source is the whole
|
|
325
|
+
* truth, so an omitted secret field means "not set". Pass `false` there
|
|
326
|
+
* so absent secrets are removed rather than silently re-restored.
|
|
327
|
+
*/
|
|
328
|
+
mergeSecrets?: boolean;
|
|
329
|
+
},
|
|
193
330
|
): Promise<HealthCheckConfiguration | undefined> {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
331
|
+
const mergeSecrets = options?.mergeSecrets ?? true;
|
|
332
|
+
|
|
333
|
+
const applyUpdate = async (): Promise<
|
|
334
|
+
HealthCheckConfiguration | undefined
|
|
335
|
+
> => {
|
|
336
|
+
let body = data;
|
|
337
|
+
// Deferred until AFTER the row is written: deletes internal secrets
|
|
338
|
+
// orphaned by this edit (a cleared / declaratively-removed field, an
|
|
339
|
+
// inline secret swapped for a reference, a removed collector). Post-write
|
|
340
|
+
// means a crash can never leave the row pointing at a marker whose secret
|
|
341
|
+
// we already deleted (a dangling marker fails closed at run time).
|
|
342
|
+
let pruneOrphans: (() => Promise<void>) | undefined;
|
|
343
|
+
|
|
344
|
+
if (this.secrets && (data.config || data.collectors)) {
|
|
345
|
+
// The editor round-trips the REDACTED config, so blank/absent secrets
|
|
346
|
+
// mean "keep existing": restore them from the stored row BEFORE
|
|
347
|
+
// extraction (restored markers pass through extraction untouched).
|
|
348
|
+
const stored = await this.getConfiguration(id);
|
|
349
|
+
if (stored) {
|
|
350
|
+
const strategyId = data.strategyId ?? stored.strategyId;
|
|
351
|
+
const strategySchema = this.getStrategySchema(strategyId);
|
|
352
|
+
|
|
353
|
+
// Preserve an UNREGISTERED collector entry's stored config: a read
|
|
354
|
+
// redacts it to `{}` (fail-closed), and the editor round-trips that
|
|
355
|
+
// back - never persist the `{}` over the stored config.
|
|
356
|
+
const storedById = new Map(
|
|
357
|
+
(stored.collectors ?? []).map((entry) => [entry.id, entry]),
|
|
358
|
+
);
|
|
359
|
+
const sanitizedCollectors = data.collectors?.map((entry) => {
|
|
360
|
+
if (this.getCollectorSchema(entry.collectorId)) return entry;
|
|
361
|
+
const storedEntry = storedById.get(entry.id);
|
|
362
|
+
return storedEntry
|
|
363
|
+
? { ...entry, config: storedEntry.config }
|
|
364
|
+
: entry;
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Strategy config: registered -> merge/extract (or wholesale for
|
|
368
|
+
// gitops); UNREGISTERED -> preserve the stored config (we have no
|
|
369
|
+
// schema to merge/extract/validate it). REGISTERED COLLECTORS are
|
|
370
|
+
// processed via their OWN schemas regardless of strategy
|
|
371
|
+
// registration, so a registered collector under an unregistered
|
|
372
|
+
// strategy still extracts its secrets (no plaintext-at-rest, no wipe).
|
|
373
|
+
const incomingStrategyConfig = strategySchema
|
|
374
|
+
? (data.config ?? stored.config)
|
|
375
|
+
: stored.config;
|
|
376
|
+
|
|
377
|
+
// UI keeps existing secrets (blank = keep); GitOps applies the
|
|
378
|
+
// authored config wholesale so an omitted secret is genuinely removed.
|
|
379
|
+
const merged = mergeSecrets
|
|
380
|
+
? mergeConfigurationSecrets({
|
|
381
|
+
strategySchema,
|
|
382
|
+
incomingConfig: incomingStrategyConfig,
|
|
383
|
+
storedConfig: stored.config,
|
|
384
|
+
incomingCollectors: sanitizedCollectors,
|
|
385
|
+
storedCollectors: stored.collectors,
|
|
386
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
387
|
+
})
|
|
388
|
+
: { config: incomingStrategyConfig, collectors: sanitizedCollectors };
|
|
389
|
+
|
|
390
|
+
const extracted = await extractConfigurationSecrets({
|
|
391
|
+
configurationId: id,
|
|
392
|
+
strategySchema,
|
|
393
|
+
config: merged.config,
|
|
394
|
+
collectors: merged.collectors,
|
|
395
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
396
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
397
|
+
});
|
|
398
|
+
body = {
|
|
399
|
+
...data,
|
|
400
|
+
...(data.config ? { config: extracted.config } : {}),
|
|
401
|
+
...(data.collectors ? { collectors: extracted.collectors } : {}),
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// A partial update only changes provided fields, so the effective new
|
|
405
|
+
// state of an omitted field is the stored one (unchanged, no orphan).
|
|
406
|
+
const newConfig = data.config ? extracted.config : stored.config;
|
|
407
|
+
const newCollectors = data.collectors
|
|
408
|
+
? extracted.collectors
|
|
409
|
+
: stored.collectors;
|
|
410
|
+
const secrets = this.secrets;
|
|
411
|
+
pruneOrphans = async () => {
|
|
412
|
+
await pruneOrphanedConfigurationSecrets({
|
|
413
|
+
configurationId: id,
|
|
414
|
+
oldConfig: stored.config,
|
|
415
|
+
newConfig,
|
|
416
|
+
oldCollectors: stored.collectors,
|
|
417
|
+
newCollectors,
|
|
418
|
+
internalSecrets: secrets.internalSecrets,
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const [config] = await this.db
|
|
425
|
+
.update(healthCheckConfigurations)
|
|
426
|
+
.set({
|
|
427
|
+
...body,
|
|
428
|
+
updatedAt: new Date(),
|
|
429
|
+
})
|
|
430
|
+
.where(eq(healthCheckConfigurations.id, id))
|
|
431
|
+
.returning();
|
|
432
|
+
|
|
433
|
+
// The row now holds the new markers; delete the internal secrets this
|
|
434
|
+
// edit orphaned. Post-write so a dangling marker can never outlive its
|
|
435
|
+
// secret.
|
|
436
|
+
if (config) await pruneOrphans?.();
|
|
437
|
+
|
|
438
|
+
return config ? this.mapConfig(config) : undefined;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// Serialize the read-modify-write-prune per config id so a concurrent
|
|
442
|
+
// writer to the SAME id (e.g. a UI edit racing a GitOps reconcile across
|
|
443
|
+
// pods) cannot have its just-written secret deleted by this writer's stale
|
|
444
|
+
// orphan-prune, which would leave a dangling marker. Only needed on the
|
|
445
|
+
// secret-bearing path and only when a cluster-wide lock is wired.
|
|
446
|
+
const lock = this.secrets?.advisoryLock;
|
|
447
|
+
if (lock && (data.config || data.collectors)) {
|
|
448
|
+
return lock.withXactLock({
|
|
449
|
+
key: healthcheckConfigLockKey(id),
|
|
450
|
+
fn: applyUpdate,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return applyUpdate();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Restore a stored config's secrets into a proposed (redacted) config so it
|
|
458
|
+
* can be deep-validated as an UPDATE without spuriously failing a
|
|
459
|
+
* required-secret check. Mirrors the merge `updateConfiguration` does
|
|
460
|
+
* before persisting, so validate and apply agree; the restored values are
|
|
461
|
+
* used only for validation and never returned to the caller. Returns the
|
|
462
|
+
* proposed config unchanged when the id is unknown or secrets are not wired.
|
|
463
|
+
*/
|
|
464
|
+
async restoreSecretsForValidation({
|
|
465
|
+
existingConfigurationId,
|
|
466
|
+
strategyId,
|
|
467
|
+
config,
|
|
468
|
+
collectors,
|
|
469
|
+
}: {
|
|
470
|
+
existingConfigurationId: string;
|
|
471
|
+
strategyId: string;
|
|
472
|
+
config: Record<string, unknown>;
|
|
473
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
474
|
+
}): Promise<{
|
|
475
|
+
config: Record<string, unknown>;
|
|
476
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
477
|
+
}> {
|
|
478
|
+
const strategySchema = this.getStrategySchema(strategyId);
|
|
479
|
+
const stored = await this.getConfiguration(existingConfigurationId);
|
|
480
|
+
if (!strategySchema || !stored) return { config, collectors };
|
|
481
|
+
return mergeConfigurationSecrets({
|
|
482
|
+
strategySchema,
|
|
483
|
+
incomingConfig: config,
|
|
484
|
+
storedConfig: stored.config,
|
|
485
|
+
incomingCollectors: collectors,
|
|
486
|
+
storedCollectors: stored.collectors,
|
|
487
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
488
|
+
});
|
|
203
489
|
}
|
|
204
490
|
|
|
205
491
|
async deleteConfiguration(id: string): Promise<void> {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
492
|
+
const applyDelete = async (): Promise<void> => {
|
|
493
|
+
// Clean up the internal secrets the stored markers point at BEFORE the
|
|
494
|
+
// row disappears (the markers are the only index into them). Schema-free,
|
|
495
|
+
// so an uninstalled strategy/collector plugin does NOT leave its secrets
|
|
496
|
+
// orphaned - marker enumeration does not depend on the plugin being loaded.
|
|
497
|
+
if (this.secrets) {
|
|
498
|
+
const stored = await this.getConfiguration(id);
|
|
499
|
+
if (stored) {
|
|
500
|
+
await deleteConfigurationSecrets({
|
|
501
|
+
configurationId: id,
|
|
502
|
+
config: stored.config,
|
|
503
|
+
collectors: stored.collectors,
|
|
504
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
await this.db
|
|
509
|
+
.delete(healthCheckConfigurations)
|
|
510
|
+
.where(eq(healthCheckConfigurations.id, id));
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// Serialize delete under the SAME per-config lock updateConfiguration uses,
|
|
514
|
+
// so a concurrent update on this id cannot interleave: a delete reading the
|
|
515
|
+
// pre-update row would clean up the OLD markers and drop the row while the
|
|
516
|
+
// update writes a fresh internal secret, orphaning it. Delete and update of
|
|
517
|
+
// one id must be mutually exclusive.
|
|
518
|
+
const lock = this.secrets?.advisoryLock;
|
|
519
|
+
if (lock) {
|
|
520
|
+
return lock.withXactLock({
|
|
521
|
+
key: healthcheckConfigLockKey(id),
|
|
522
|
+
fn: applyDelete,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return applyDelete();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Delete internal secrets extracted for a config whose row insert did NOT
|
|
530
|
+
* commit (a rolled-back create), so a failed create never orphans the
|
|
531
|
+
* secrets it extracted. Best-effort: swallows cleanup errors so the original
|
|
532
|
+
* insert error is the one surfaced.
|
|
533
|
+
*/
|
|
534
|
+
private async cleanupExtractedSecrets({
|
|
535
|
+
id,
|
|
536
|
+
config,
|
|
537
|
+
collectors,
|
|
538
|
+
}: {
|
|
539
|
+
id: string;
|
|
540
|
+
config: Record<string, unknown>;
|
|
541
|
+
collectors: CollectorConfigEntry[] | undefined;
|
|
542
|
+
}): Promise<void> {
|
|
543
|
+
if (!this.secrets) return;
|
|
544
|
+
try {
|
|
545
|
+
await deleteConfigurationSecrets({
|
|
546
|
+
configurationId: id,
|
|
547
|
+
config,
|
|
548
|
+
collectors,
|
|
549
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
550
|
+
});
|
|
551
|
+
} catch {
|
|
552
|
+
// Ignore: the create already failed; surfacing a cleanup error would mask
|
|
553
|
+
// the real cause. The orphan (if any) is a harmless encrypted blob.
|
|
554
|
+
}
|
|
209
555
|
}
|
|
210
556
|
|
|
211
557
|
async pauseConfiguration(id: string): Promise<void> {
|
|
@@ -330,32 +676,70 @@ export class HealthCheckService {
|
|
|
330
676
|
const versionedThresholds: VersionedStateThresholds | undefined =
|
|
331
677
|
stateThresholds_ ? stateThresholds.create(stateThresholds_) : undefined;
|
|
332
678
|
|
|
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,
|
|
679
|
+
// Extract inline secrets into the encrypted internal store BEFORE insert -
|
|
680
|
+
// identical to createConfiguration. Without this, the first-check wizard
|
|
681
|
+
// and the AI propose tool (both createAndAssign callers) would persist
|
|
682
|
+
// credentials as plaintext. The id is generated up front so the internal
|
|
683
|
+
// secrets can be keyed by it.
|
|
684
|
+
const id = crypto.randomUUID();
|
|
685
|
+
let configToStore = configuration.config;
|
|
686
|
+
let collectorsToStore = configuration.collectors;
|
|
687
|
+
// Gate on `this.secrets` alone (see createConfiguration): gating on
|
|
688
|
+
// strategySchema would leave a registered collector's inline secret as
|
|
689
|
+
// plaintext at rest when the strategy is unregistered.
|
|
690
|
+
const strategySchema = this.getStrategySchema(configuration.strategyId);
|
|
691
|
+
if (this.secrets) {
|
|
692
|
+
const extracted = await extractConfigurationSecrets({
|
|
693
|
+
configurationId: id,
|
|
694
|
+
strategySchema,
|
|
695
|
+
config: configuration.config,
|
|
696
|
+
collectors: configuration.collectors,
|
|
697
|
+
getCollectorSchema: this.getCollectorSchema,
|
|
698
|
+
internalSecrets: this.secrets.internalSecrets,
|
|
355
699
|
});
|
|
700
|
+
configToStore = extracted.config;
|
|
701
|
+
collectorsToStore = extracted.collectors;
|
|
702
|
+
}
|
|
356
703
|
|
|
357
|
-
|
|
358
|
-
|
|
704
|
+
let created: typeof healthCheckConfigurations.$inferSelect;
|
|
705
|
+
try {
|
|
706
|
+
created = await this.db.transaction(async (tx) => {
|
|
707
|
+
const [config] = await tx
|
|
708
|
+
.insert(healthCheckConfigurations)
|
|
709
|
+
.values({
|
|
710
|
+
id,
|
|
711
|
+
name: configuration.name,
|
|
712
|
+
strategyId: configuration.strategyId,
|
|
713
|
+
config: configToStore,
|
|
714
|
+
collectors: collectorsToStore ?? undefined,
|
|
715
|
+
intervalSeconds: configuration.intervalSeconds,
|
|
716
|
+
isTemplate: false,
|
|
717
|
+
})
|
|
718
|
+
.returning();
|
|
719
|
+
|
|
720
|
+
await tx.insert(systemHealthChecks).values({
|
|
721
|
+
systemId,
|
|
722
|
+
configurationId: config.id,
|
|
723
|
+
enabled,
|
|
724
|
+
stateThresholds: versionedThresholds,
|
|
725
|
+
satelliteIds: satelliteIds ?? undefined,
|
|
726
|
+
environmentIds: environmentIdsValue,
|
|
727
|
+
includeLocal,
|
|
728
|
+
notificationPolicy: notificationPolicy ?? undefined,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
return config;
|
|
732
|
+
});
|
|
733
|
+
} catch (error) {
|
|
734
|
+
// The transaction rolled back, so no row owns the secrets we just
|
|
735
|
+
// extracted; delete them so a failed create-and-assign never orphans them.
|
|
736
|
+
await this.cleanupExtractedSecrets({
|
|
737
|
+
id,
|
|
738
|
+
config: configToStore,
|
|
739
|
+
collectors: collectorsToStore,
|
|
740
|
+
});
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
359
743
|
|
|
360
744
|
return this.mapConfig(created);
|
|
361
745
|
}
|
|
@@ -487,6 +871,18 @@ export class HealthCheckService {
|
|
|
487
871
|
return Promise.all(rows.map((r) => this.mapConfig(r.config)));
|
|
488
872
|
}
|
|
489
873
|
|
|
874
|
+
/**
|
|
875
|
+
* System-scoped configuration list for UI reads - REDACTED, like
|
|
876
|
+
* `getConfigurationsRedacted`. Any `x-secret` field is stripped before the
|
|
877
|
+
* config leaves the backend.
|
|
878
|
+
*/
|
|
879
|
+
async getSystemConfigurationsRedacted(
|
|
880
|
+
systemId: string,
|
|
881
|
+
): Promise<HealthCheckConfiguration[]> {
|
|
882
|
+
const configurations = await this.getSystemConfigurations(systemId);
|
|
883
|
+
return configurations.map((c) => this.redactConfiguration(c));
|
|
884
|
+
}
|
|
885
|
+
|
|
490
886
|
/**
|
|
491
887
|
* Get system associations with their threshold configurations.
|
|
492
888
|
*/
|