@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/queue-executor.ts
CHANGED
|
@@ -46,7 +46,11 @@ import { NotificationApi } from "@checkstack/notification-common";
|
|
|
46
46
|
import { healthcheckSystemSubscription } from "@checkstack/healthcheck-common";
|
|
47
47
|
import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
|
|
48
48
|
import { secretEnvMappingSchema } from "@checkstack/secrets-common";
|
|
49
|
-
import type {
|
|
49
|
+
import type {
|
|
50
|
+
SecretResolverService,
|
|
51
|
+
InternalSecretsService,
|
|
52
|
+
} from "@checkstack/secrets-backend";
|
|
53
|
+
import { inflateConfigSecrets } from "./config-secrets";
|
|
50
54
|
import { HealthCheckService } from "./service";
|
|
51
55
|
import { healthCheckHooks } from "./hooks";
|
|
52
56
|
import { incrementHourlyAggregate } from "./realtime-aggregation";
|
|
@@ -538,6 +542,14 @@ async function executeHealthCheckJob(props: {
|
|
|
538
542
|
* / test isolation.
|
|
539
543
|
*/
|
|
540
544
|
secretResolver?: SecretResolverService;
|
|
545
|
+
/**
|
|
546
|
+
* Internal secret store. When set (together with `secretResolver`), stored
|
|
547
|
+
* strategy/collector config `x-secret` fields - internal markers and
|
|
548
|
+
* `${{ secrets.* }}` references - are INFLATED to their real values just
|
|
549
|
+
* before use, in memory only. Optional for version-skew / test isolation;
|
|
550
|
+
* without it, marker-bearing configs fail their runs clearly.
|
|
551
|
+
*/
|
|
552
|
+
internalSecrets?: InternalSecretsService;
|
|
541
553
|
}): Promise<void> {
|
|
542
554
|
const {
|
|
543
555
|
payload,
|
|
@@ -555,6 +567,7 @@ async function executeHealthCheckJob(props: {
|
|
|
555
567
|
getHealthEntity,
|
|
556
568
|
cache,
|
|
557
569
|
secretResolver,
|
|
570
|
+
internalSecrets,
|
|
558
571
|
} = props;
|
|
559
572
|
const { configId, systemId } = payload;
|
|
560
573
|
|
|
@@ -656,6 +669,22 @@ async function executeHealthCheckJob(props: {
|
|
|
656
669
|
return;
|
|
657
670
|
}
|
|
658
671
|
|
|
672
|
+
// Inflate stored secret markers / `${{ secrets.* }}` references to their
|
|
673
|
+
// real values ONCE, memory-only, BEFORE migrate+validate - so validation
|
|
674
|
+
// sees real values. Old-shape rows (whose current-schema secret keys do
|
|
675
|
+
// not exist yet) and legacy bare literals pass through untouched.
|
|
676
|
+
let rawStrategyConfig = configRow.config;
|
|
677
|
+
if (internalSecrets && secretResolver) {
|
|
678
|
+
const inflated = await inflateConfigSecrets({
|
|
679
|
+
configurationId: configId,
|
|
680
|
+
scope: { kind: "strategy" },
|
|
681
|
+
schema: strategy.config.schema,
|
|
682
|
+
config: configRow.config,
|
|
683
|
+
deps: { internalSecrets, secretResolver },
|
|
684
|
+
});
|
|
685
|
+
rawStrategyConfig = inflated.config;
|
|
686
|
+
}
|
|
687
|
+
|
|
659
688
|
// Migrate the stored (UNVERSIONED) strategy config ONCE, before the
|
|
660
689
|
// per-environment render loop, so every env renders from the same
|
|
661
690
|
// migrated shape. Stored configs predate explicit versioning and may be
|
|
@@ -663,7 +692,7 @@ async function executeHealthCheckJob(props: {
|
|
|
663
692
|
// -on-read runs the declared migration chain, then validates. The
|
|
664
693
|
// migrations are idempotent, so an already-current config is a no-op.
|
|
665
694
|
const strategyConfig: BaseStrategyConfig =
|
|
666
|
-
await strategy.config.parseAssumingV1(
|
|
695
|
+
await strategy.config.parseAssumingV1(rawStrategyConfig);
|
|
667
696
|
const executionTimeout = strategyConfig.timeout ?? 60_000;
|
|
668
697
|
|
|
669
698
|
// ── Per-environment fan-out (§7) ────────────────────────────────────────
|
|
@@ -872,9 +901,26 @@ async function executeHealthCheckJob(props: {
|
|
|
872
901
|
// reads the raw `secretEnv` mapping (a constant string field
|
|
873
902
|
// unaffected by the strategy/collector reshapes), keeping the
|
|
874
903
|
// migrate -> secret resolve -> render -> execute order intact.
|
|
904
|
+
// Inflate this entry's secret markers / references (memory
|
|
905
|
+
// only) before its migrate+validate parse, mirroring the
|
|
906
|
+
// strategy-config inflation above.
|
|
907
|
+
let rawCollectorConfig = collectorEntry.config;
|
|
908
|
+
if (internalSecrets && secretResolver) {
|
|
909
|
+
const inflated = await inflateConfigSecrets({
|
|
910
|
+
configurationId: configId,
|
|
911
|
+
scope: {
|
|
912
|
+
kind: "collector",
|
|
913
|
+
entryId: collectorEntry.id,
|
|
914
|
+
},
|
|
915
|
+
schema: registered.collector.config.schema,
|
|
916
|
+
config: collectorEntry.config,
|
|
917
|
+
deps: { internalSecrets, secretResolver },
|
|
918
|
+
});
|
|
919
|
+
rawCollectorConfig = inflated.config;
|
|
920
|
+
}
|
|
875
921
|
const migratedCollectorConfig =
|
|
876
922
|
await registered.collector.config.parseAssumingV1(
|
|
877
|
-
|
|
923
|
+
rawCollectorConfig,
|
|
878
924
|
);
|
|
879
925
|
|
|
880
926
|
// (2) Environment/templating pass for the collector config -
|
|
@@ -1518,6 +1564,7 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1518
1564
|
getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
|
|
1519
1565
|
cache: HealthCheckCache;
|
|
1520
1566
|
secretResolver?: SecretResolverService;
|
|
1567
|
+
internalSecrets?: InternalSecretsService;
|
|
1521
1568
|
}): Promise<void> {
|
|
1522
1569
|
const {
|
|
1523
1570
|
db,
|
|
@@ -1535,6 +1582,7 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1535
1582
|
getHealthEntity,
|
|
1536
1583
|
cache,
|
|
1537
1584
|
secretResolver,
|
|
1585
|
+
internalSecrets,
|
|
1538
1586
|
} = props;
|
|
1539
1587
|
|
|
1540
1588
|
const queue =
|
|
@@ -1559,6 +1607,7 @@ export async function setupHealthCheckWorker(props: {
|
|
|
1559
1607
|
getHealthEntity,
|
|
1560
1608
|
cache,
|
|
1561
1609
|
secretResolver,
|
|
1610
|
+
internalSecrets,
|
|
1562
1611
|
});
|
|
1563
1612
|
},
|
|
1564
1613
|
{
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { call } from "@orpc/server";
|
|
4
|
+
import {
|
|
5
|
+
createMockRpcContext,
|
|
6
|
+
configSecret,
|
|
7
|
+
Versioned,
|
|
8
|
+
} from "@checkstack/backend-api";
|
|
9
|
+
import { internalSecretName } from "@checkstack/secrets-common";
|
|
10
|
+
import type {
|
|
11
|
+
InternalSecretsService,
|
|
12
|
+
SecretResolverService,
|
|
13
|
+
} from "@checkstack/secrets-backend";
|
|
14
|
+
import { createHealthCheckRouter } from "./router";
|
|
15
|
+
import {
|
|
16
|
+
healthcheckSecretMarker,
|
|
17
|
+
isHealthcheckSecretMarker,
|
|
18
|
+
} from "./config-secrets";
|
|
19
|
+
import type { HealthCheckCache } from "./cache";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Guards the SEC-1 fix: `createAndAssign` (the first-check wizard / AI propose
|
|
23
|
+
* creation path) MUST extract inline `x-secret` values into the internal store
|
|
24
|
+
* and return a REDACTED config - never persist or echo a plaintext credential.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const passthroughCache: HealthCheckCache = {
|
|
28
|
+
wrapSystemHealthStatus: (_systemId, loader) => loader(),
|
|
29
|
+
invalidateSystem: async () => {},
|
|
30
|
+
invalidateAllSystems: async () => 0,
|
|
31
|
+
scope: {} as HealthCheckCache["scope"],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const mockUser = {
|
|
35
|
+
type: "user" as const,
|
|
36
|
+
id: "test-user",
|
|
37
|
+
accessRules: ["*"],
|
|
38
|
+
roles: ["admin"],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// A strategy config with one plain field and one x-secret field.
|
|
42
|
+
const strategySchema = z.object({
|
|
43
|
+
url: z.string(),
|
|
44
|
+
password: configSecret({ id: "password" }).optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function fakeInternalSecrets(): InternalSecretsService & {
|
|
48
|
+
store: Map<string, string>;
|
|
49
|
+
} {
|
|
50
|
+
const store = new Map<string, string>();
|
|
51
|
+
return {
|
|
52
|
+
store,
|
|
53
|
+
async set({ parts, value }) {
|
|
54
|
+
store.set(internalSecretName(...parts), value);
|
|
55
|
+
},
|
|
56
|
+
async get({ parts }) {
|
|
57
|
+
return store.get(internalSecretName(...parts));
|
|
58
|
+
},
|
|
59
|
+
async delete({ parts }) {
|
|
60
|
+
store.delete(internalSecretName(...parts));
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fakeResolver = {
|
|
66
|
+
resolveForRun: mock(async () => ({ env: {}, masking: undefined })),
|
|
67
|
+
} as unknown as SecretResolverService;
|
|
68
|
+
|
|
69
|
+
interface CapturedInsert {
|
|
70
|
+
values: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createCapturingDb(captured: CapturedInsert[]) {
|
|
74
|
+
const insert = () => ({
|
|
75
|
+
values: (values: Record<string, unknown>) => {
|
|
76
|
+
captured.push({ values });
|
|
77
|
+
return Object.assign(Promise.resolve(undefined), {
|
|
78
|
+
returning: () =>
|
|
79
|
+
Promise.resolve([
|
|
80
|
+
{
|
|
81
|
+
paused: false,
|
|
82
|
+
createdAt: new Date(),
|
|
83
|
+
updatedAt: new Date(),
|
|
84
|
+
collectors: null,
|
|
85
|
+
...values,
|
|
86
|
+
},
|
|
87
|
+
]),
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const emptyWhere = Object.assign(Promise.resolve([]), {
|
|
92
|
+
where: () => Promise.resolve([]),
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
insert,
|
|
96
|
+
select: () => ({ from: () => emptyWhere }),
|
|
97
|
+
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
|
98
|
+
fn({ insert }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildRouter(captured: CapturedInsert[]) {
|
|
103
|
+
const internalSecrets = fakeInternalSecrets();
|
|
104
|
+
const strategy = { config: new Versioned({ version: 1, schema: strategySchema }) };
|
|
105
|
+
const router = createHealthCheckRouter({
|
|
106
|
+
database: createCapturingDb(captured) as never,
|
|
107
|
+
registry: { getStrategy: mock(() => strategy) } as never,
|
|
108
|
+
collectorRegistry: { getCollector: mock(() => undefined) } as never,
|
|
109
|
+
gitOpsClient: { getProvenance: mock(() => Promise.resolve(null)) } as never,
|
|
110
|
+
getEmitHook: () => undefined,
|
|
111
|
+
cache: passthroughCache,
|
|
112
|
+
configService: { get: mock(async () => undefined), set: mock(async () => {}) } as never,
|
|
113
|
+
catalogClient: { getSystem: mock(async () => null) } as never,
|
|
114
|
+
maintenanceClient: {
|
|
115
|
+
hasActiveMaintenance: mock(async () => ({ active: false })),
|
|
116
|
+
} as never,
|
|
117
|
+
logger: {
|
|
118
|
+
debug: mock(() => {}),
|
|
119
|
+
info: mock(() => {}),
|
|
120
|
+
warn: mock(() => {}),
|
|
121
|
+
error: mock(() => {}),
|
|
122
|
+
} as never,
|
|
123
|
+
configSecrets: { internalSecrets, secretResolver: fakeResolver },
|
|
124
|
+
});
|
|
125
|
+
return { router, internalSecrets };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("createAndAssign secret extraction (SEC-1 regression)", () => {
|
|
129
|
+
it("extracts an inline secret into the internal store and never persists or returns it", async () => {
|
|
130
|
+
const captured: CapturedInsert[] = [];
|
|
131
|
+
const { router, internalSecrets } = buildRouter(captured);
|
|
132
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
133
|
+
|
|
134
|
+
const result = await call(
|
|
135
|
+
router.createAndAssign,
|
|
136
|
+
{
|
|
137
|
+
systemId: "sys-1",
|
|
138
|
+
configuration: {
|
|
139
|
+
name: "DB check",
|
|
140
|
+
strategyId: "healthcheck-http.http",
|
|
141
|
+
config: { url: "https://x", password: "super-secret" },
|
|
142
|
+
intervalSeconds: 60,
|
|
143
|
+
},
|
|
144
|
+
enabled: false,
|
|
145
|
+
includeLocal: true,
|
|
146
|
+
environmentIds: null,
|
|
147
|
+
},
|
|
148
|
+
{ context },
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// The persisted config row holds a MARKER, never the plaintext.
|
|
152
|
+
const configInsert = captured.find((c) => c.values.name === "DB check");
|
|
153
|
+
expect(configInsert).toBeDefined();
|
|
154
|
+
const storedConfig = configInsert?.values.config as Record<string, unknown>;
|
|
155
|
+
expect(isHealthcheckSecretMarker(storedConfig.password as string)).toBe(true);
|
|
156
|
+
expect(JSON.stringify(captured)).not.toContain("super-secret");
|
|
157
|
+
|
|
158
|
+
// The plaintext lives ONLY in the internal (encrypted) store.
|
|
159
|
+
expect([...internalSecrets.store.values()]).toContain("super-secret");
|
|
160
|
+
|
|
161
|
+
// The response is redacted - the secret field is absent.
|
|
162
|
+
expect(result.config.password).toBeUndefined();
|
|
163
|
+
expect(result.config.url).toBe("https://x");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -184,7 +184,11 @@ describe("createAndAssign router handler", () => {
|
|
|
184
184
|
expect(assignmentInsert?.values.environmentIds).toBeNull();
|
|
185
185
|
// Returns the created configuration.
|
|
186
186
|
expect(result.name).toBe("Payments API root");
|
|
187
|
-
|
|
187
|
+
// The id is generated up front (SEC-1: needed to key extracted secrets
|
|
188
|
+
// before insert), persisted on the config row, and used to link the
|
|
189
|
+
// assignment - so all three must agree.
|
|
190
|
+
expect(configInsert?.values.id).toBe(result.id);
|
|
191
|
+
expect(assignmentInsert?.values.configurationId).toBe(result.id);
|
|
188
192
|
});
|
|
189
193
|
|
|
190
194
|
it("broadcasts healthcheck.config.changed so open clients refresh", async () => {
|
package/src/router.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
resolveScriptPackagesDir,
|
|
21
21
|
} from "@checkstack/script-packages-backend";
|
|
22
22
|
import { HealthCheckService } from "./service";
|
|
23
|
+
import type { HealthCheckSecretsDeps } from "./config-secrets";
|
|
23
24
|
import {
|
|
24
25
|
canReadRunScope,
|
|
25
26
|
hasGlobalHistoryAccess,
|
|
@@ -74,6 +75,12 @@ export const createHealthCheckRouter = (opts: {
|
|
|
74
75
|
* run / the SLO self-heal converge the rollup lazily.
|
|
75
76
|
*/
|
|
76
77
|
recomputeSystemRollupHealth?: (systemId: string) => Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Secrets channel for config credentials (extract-on-write, redact-on-read,
|
|
80
|
+
* blank-keeps-existing on update). Optional only for tests; the real
|
|
81
|
+
* router MUST receive it or writes would store inline secrets verbatim.
|
|
82
|
+
*/
|
|
83
|
+
configSecrets?: HealthCheckSecretsDeps;
|
|
77
84
|
}) => {
|
|
78
85
|
const {
|
|
79
86
|
database,
|
|
@@ -95,6 +102,7 @@ export const createHealthCheckRouter = (opts: {
|
|
|
95
102
|
collectorRegistry,
|
|
96
103
|
configService,
|
|
97
104
|
catalogClient,
|
|
105
|
+
opts.configSecrets,
|
|
98
106
|
);
|
|
99
107
|
|
|
100
108
|
// Create contract implementer with context type AND auto auth middleware
|
|
@@ -252,12 +260,15 @@ export const createHealthCheckRouter = (opts: {
|
|
|
252
260
|
return runCollectorScriptTest({ input, deps: { resolutionRoot } });
|
|
253
261
|
}),
|
|
254
262
|
|
|
263
|
+
// UI/AI reads are ALWAYS redacted: `x-secret` fields (values, references,
|
|
264
|
+
// internal markers alike) are stripped server-side. The editor renders a
|
|
265
|
+
// blank secret input and blank-on-save means "keep existing".
|
|
255
266
|
getConfigurations: os.getConfigurations.handler(async () => {
|
|
256
|
-
return { configurations: await service.
|
|
267
|
+
return { configurations: await service.getConfigurationsRedacted() };
|
|
257
268
|
}),
|
|
258
269
|
|
|
259
270
|
getConfiguration: os.getConfiguration.handler(async ({ input }) => {
|
|
260
|
-
return service.
|
|
271
|
+
return service.getConfigurationRedacted(input.id);
|
|
261
272
|
}),
|
|
262
273
|
|
|
263
274
|
createConfiguration: os.createConfiguration.handler(async ({ input }) => {
|
|
@@ -271,7 +282,8 @@ export const createHealthCheckRouter = (opts: {
|
|
|
271
282
|
action: "created",
|
|
272
283
|
configurationId: created.id,
|
|
273
284
|
});
|
|
274
|
-
|
|
285
|
+
// The response goes back to the editor: keep it redacted like reads.
|
|
286
|
+
return service.redactConfiguration(created);
|
|
275
287
|
}),
|
|
276
288
|
|
|
277
289
|
validateConfiguration: os.validateConfiguration.handler(
|
|
@@ -283,8 +295,24 @@ export const createHealthCheckRouter = (opts: {
|
|
|
283
295
|
// `z.record(z.unknown())` on the input) is validated against each
|
|
284
296
|
// registered schema, surfacing wrong types, missing required fields,
|
|
285
297
|
// and unknown keys - not just missing-field presence.
|
|
298
|
+
//
|
|
299
|
+
// For an UPDATE (existingConfigurationId set), restore the stored
|
|
300
|
+
// config's secrets into the proposed body first: reads are redacted,
|
|
301
|
+
// so a kept secret arrives blank/absent and would otherwise fail a
|
|
302
|
+
// required-secret check even though the apply path would preserve it.
|
|
303
|
+
// The restored values are used ONLY to validate and never returned.
|
|
304
|
+
let toValidate = input;
|
|
305
|
+
if (input.existingConfigurationId) {
|
|
306
|
+
const restored = await service.restoreSecretsForValidation({
|
|
307
|
+
existingConfigurationId: input.existingConfigurationId,
|
|
308
|
+
strategyId: input.strategyId,
|
|
309
|
+
config: input.config,
|
|
310
|
+
collectors: input.collectors,
|
|
311
|
+
});
|
|
312
|
+
toValidate = { ...input, ...restored };
|
|
313
|
+
}
|
|
286
314
|
const errors = await collectConfigurationIssues({
|
|
287
|
-
input,
|
|
315
|
+
input: toValidate,
|
|
288
316
|
registry: context.healthCheckRegistry,
|
|
289
317
|
collectorRegistry: context.collectorRegistry,
|
|
290
318
|
});
|
|
@@ -307,7 +335,8 @@ export const createHealthCheckRouter = (opts: {
|
|
|
307
335
|
action: "updated",
|
|
308
336
|
configurationId: config.id,
|
|
309
337
|
});
|
|
310
|
-
|
|
338
|
+
// The response goes back to the editor: keep it redacted like reads.
|
|
339
|
+
return service.redactConfiguration(config);
|
|
311
340
|
}),
|
|
312
341
|
|
|
313
342
|
deleteConfiguration: os.deleteConfiguration.handler(async ({ input }) => {
|
|
@@ -381,7 +410,8 @@ export const createHealthCheckRouter = (opts: {
|
|
|
381
410
|
|
|
382
411
|
getSystemConfigurations: os.getSystemConfigurations.handler(
|
|
383
412
|
async ({ input }) => {
|
|
384
|
-
|
|
413
|
+
// Redacted like every other UI config read - x-secret fields stripped.
|
|
414
|
+
return service.getSystemConfigurationsRedacted(input.systemId);
|
|
385
415
|
},
|
|
386
416
|
),
|
|
387
417
|
|
|
@@ -431,7 +461,9 @@ export const createHealthCheckRouter = (opts: {
|
|
|
431
461
|
enabled: input.enabled,
|
|
432
462
|
queueManager: context.queueManager,
|
|
433
463
|
});
|
|
434
|
-
|
|
464
|
+
// The response goes back to the caller (wizard / AI tool): redact it
|
|
465
|
+
// like every other config read/write response.
|
|
466
|
+
return service.redactConfiguration(configuration);
|
|
435
467
|
}),
|
|
436
468
|
|
|
437
469
|
disassociateSystem: os.disassociateSystem.handler(async ({ input }) => {
|