@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { computeAssertionKey } from "@checkstack/healthcheck-common";
|
|
2
3
|
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
3
4
|
import { healthCheckAggregates } from "./schema";
|
|
4
5
|
import {
|
|
@@ -116,3 +117,71 @@ describe("DAILY_AGGREGATE_CONFLICT_TARGET", () => {
|
|
|
116
117
|
expect(targetCols).toEqual(constraintCols);
|
|
117
118
|
});
|
|
118
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("buildDailyAggregates - assertion stats", () => {
|
|
122
|
+
const KEY = computeAssertionKey({
|
|
123
|
+
assertion: { field: "statusCode", operator: "equals", value: 200 },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("sums per-assertion counts across the day's hourly buckets", () => {
|
|
127
|
+
const daily = buildDailyAggregates([
|
|
128
|
+
hourly({
|
|
129
|
+
bucketStart: new Date("2026-01-01T03:00:00.000Z"),
|
|
130
|
+
aggregatedResult: {
|
|
131
|
+
collectors: {},
|
|
132
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 50, failCount: 2 } } },
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
hourly({
|
|
136
|
+
bucketStart: new Date("2026-01-01T04:00:00.000Z"),
|
|
137
|
+
aggregatedResult: {
|
|
138
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 60, failCount: 0 } } },
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
// Pre-feature hourly bucket without stats is tolerated.
|
|
142
|
+
hourly({
|
|
143
|
+
bucketStart: new Date("2026-01-01T05:00:00.000Z"),
|
|
144
|
+
aggregatedResult: null,
|
|
145
|
+
}),
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
expect(daily.length).toBe(1);
|
|
149
|
+
expect(daily[0].assertionStats).toEqual({
|
|
150
|
+
"uuid-1": { [KEY]: { passCount: 110, failCount: 2 } },
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps assertion stats scoped to their (env, source) series", () => {
|
|
155
|
+
const daily = buildDailyAggregates([
|
|
156
|
+
hourly({
|
|
157
|
+
environmentId: "prod",
|
|
158
|
+
aggregatedResult: {
|
|
159
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 1, failCount: 0 } } },
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
hourly({
|
|
163
|
+
environmentId: "staging",
|
|
164
|
+
aggregatedResult: {
|
|
165
|
+
assertions: { "uuid-1": { [KEY]: { passCount: 0, failCount: 1 } } },
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
expect(daily.length).toBe(2);
|
|
171
|
+
const prod = daily.find((d) => d.environmentId === "prod");
|
|
172
|
+
const staging = daily.find((d) => d.environmentId === "staging");
|
|
173
|
+
expect(prod?.assertionStats?.["uuid-1"][KEY]).toEqual({
|
|
174
|
+
passCount: 1,
|
|
175
|
+
failCount: 0,
|
|
176
|
+
});
|
|
177
|
+
expect(staging?.assertionStats?.["uuid-1"][KEY]).toEqual({
|
|
178
|
+
passCount: 0,
|
|
179
|
+
failCount: 1,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("buckets without stats yield undefined assertionStats", () => {
|
|
184
|
+
const daily = buildDailyAggregates([hourly({})]);
|
|
185
|
+
expect(daily[0].assertionStats).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -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 }) => {
|