@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,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import type { CollectorAssertion } from "@checkstack/healthcheck-common";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ASSERTION_ACTUAL_MAX_LENGTH,
|
|
5
|
+
computeAssertionKey,
|
|
6
|
+
} from "@checkstack/healthcheck-common";
|
|
7
|
+
import {
|
|
8
|
+
evaluateCollectorAssertionOutcomes,
|
|
9
|
+
evaluateCollectorAssertions,
|
|
10
|
+
} from "./collector-assertions";
|
|
4
11
|
|
|
5
12
|
/** The HTTP Request collector's result shape, as the executor sees it. */
|
|
6
13
|
const httpResult = (body: string): Record<string, unknown> => ({
|
|
@@ -274,3 +281,92 @@ describe("evaluateCollectorAssertions", () => {
|
|
|
274
281
|
});
|
|
275
282
|
});
|
|
276
283
|
});
|
|
284
|
+
|
|
285
|
+
describe("evaluateCollectorAssertionOutcomes", () => {
|
|
286
|
+
it("evaluates ALL assertions instead of short-circuiting on failure", () => {
|
|
287
|
+
const result = httpResult('{"status":"degraded"}');
|
|
288
|
+
const { outcomes, firstFailureMessage } =
|
|
289
|
+
evaluateCollectorAssertionOutcomes({
|
|
290
|
+
assertions: [
|
|
291
|
+
{ field: "statusCode", operator: "equals", value: 500 }, // fails
|
|
292
|
+
{ field: "success", operator: "isTrue" }, // passes
|
|
293
|
+
jsonPathAssertion("$.status", "equals", "ok"), // fails
|
|
294
|
+
],
|
|
295
|
+
result,
|
|
296
|
+
});
|
|
297
|
+
expect(outcomes.length).toBe(3);
|
|
298
|
+
expect(outcomes.map((o) => o.passed)).toEqual([false, true, false]);
|
|
299
|
+
// The legacy string still reports the FIRST failure only.
|
|
300
|
+
expect(firstFailureMessage).toBe("statusCode equals 500");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("captures observed values for plain and JSONPath assertions", () => {
|
|
304
|
+
const result = httpResult('{"status":"ok","items":[1,2]}');
|
|
305
|
+
const { outcomes } = evaluateCollectorAssertionOutcomes({
|
|
306
|
+
assertions: [
|
|
307
|
+
{ field: "statusCode", operator: "equals", value: 200 },
|
|
308
|
+
jsonPathAssertion("$.status", "equals", "ok"),
|
|
309
|
+
jsonPathAssertion("$.items", "lengthEquals", 2),
|
|
310
|
+
],
|
|
311
|
+
result,
|
|
312
|
+
});
|
|
313
|
+
expect(outcomes[0].actual).toBe("200");
|
|
314
|
+
expect(outcomes[1].actual).toBe("ok");
|
|
315
|
+
expect(outcomes[2].actual).toBe("[1,2]");
|
|
316
|
+
expect(outcomes.every((o) => o.passed)).toBe(true);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("truncates long observed values", () => {
|
|
320
|
+
const longBody = JSON.stringify({ blob: "x".repeat(5000) });
|
|
321
|
+
const { outcomes } = evaluateCollectorAssertionOutcomes({
|
|
322
|
+
assertions: [
|
|
323
|
+
{ field: "body", operator: "contains", value: "x" },
|
|
324
|
+
],
|
|
325
|
+
result: httpResult(longBody),
|
|
326
|
+
});
|
|
327
|
+
expect(outcomes[0].passed).toBe(true);
|
|
328
|
+
expect(outcomes[0].actual?.length).toBe(ASSERTION_ACTUAL_MAX_LENGTH);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("fail-closed diagnostics become failed outcomes with messages", () => {
|
|
332
|
+
const { outcomes, firstFailureMessage } =
|
|
333
|
+
evaluateCollectorAssertionOutcomes({
|
|
334
|
+
assertions: [
|
|
335
|
+
{ field: "body.$", jsonPath: " ", operator: "exists" },
|
|
336
|
+
jsonPathAssertion("$.status", "equals", "ok"),
|
|
337
|
+
],
|
|
338
|
+
result: httpResult("not json"),
|
|
339
|
+
});
|
|
340
|
+
expect(outcomes[0].passed).toBe(false);
|
|
341
|
+
expect(outcomes[0].message).toBe("missing JSONPath expression");
|
|
342
|
+
expect(outcomes[1].passed).toBe(false);
|
|
343
|
+
expect(outcomes[1].message).toBe('field "body" is not valid JSON');
|
|
344
|
+
expect(firstFailureMessage).toBe(
|
|
345
|
+
"body.$ exists (missing JSONPath expression)",
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("outcomes carry the canonical assertion identity key", () => {
|
|
350
|
+
const assertion: CollectorAssertion = {
|
|
351
|
+
field: "statusCode",
|
|
352
|
+
operator: "equals",
|
|
353
|
+
value: 200,
|
|
354
|
+
};
|
|
355
|
+
const { outcomes } = evaluateCollectorAssertionOutcomes({
|
|
356
|
+
assertions: [assertion],
|
|
357
|
+
result: httpResult("{}"),
|
|
358
|
+
});
|
|
359
|
+
expect(outcomes[0].key).toBe(computeAssertionKey({ assertion }));
|
|
360
|
+
expect(outcomes[0].value).toBe("200");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("no assertions yields an empty evaluation", () => {
|
|
364
|
+
const { outcomes, firstFailureMessage } =
|
|
365
|
+
evaluateCollectorAssertionOutcomes({
|
|
366
|
+
assertions: undefined,
|
|
367
|
+
result: httpResult("{}"),
|
|
368
|
+
});
|
|
369
|
+
expect(outcomes).toEqual([]);
|
|
370
|
+
expect(firstFailureMessage).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { evaluateAssertion } from "@checkstack/backend-api";
|
|
2
|
+
import type {
|
|
3
|
+
AssertionOutcome,
|
|
4
|
+
CollectorAssertion,
|
|
5
|
+
} from "@checkstack/healthcheck-common";
|
|
1
6
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "@checkstack/
|
|
5
|
-
import type { CollectorAssertion } from "@checkstack/healthcheck-common";
|
|
7
|
+
computeAssertionKey,
|
|
8
|
+
truncateActual,
|
|
9
|
+
} from "@checkstack/healthcheck-common";
|
|
6
10
|
import { extractErrorMessage } from "@checkstack/common";
|
|
7
11
|
import { JSONPath } from "jsonpath-plus";
|
|
8
12
|
|
|
@@ -69,9 +73,23 @@ function formatFailure({
|
|
|
69
73
|
return detail ? `${base} (${detail})` : base;
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
/** All outcomes of a collector's assertions plus the legacy failure string. */
|
|
77
|
+
export interface CollectorAssertionEvaluation {
|
|
78
|
+
/** One structured outcome per configured assertion, in config order. */
|
|
79
|
+
outcomes: AssertionOutcome[];
|
|
80
|
+
/**
|
|
81
|
+
* The FIRST failing assertion's message, formatted exactly like the legacy
|
|
82
|
+
* `_assertionFailed` string. Undefined when everything passed.
|
|
83
|
+
*/
|
|
84
|
+
firstFailureMessage?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
/**
|
|
73
|
-
* Evaluate a collector's assertions - plain field assertions AND
|
|
74
|
-
* assertions - against its result, in the order they were
|
|
88
|
+
* Evaluate ALL of a collector's assertions - plain field assertions AND
|
|
89
|
+
* JSONPath assertions - against its result, in the order they were
|
|
90
|
+
* configured, returning a structured outcome per assertion (pass AND fail;
|
|
91
|
+
* this is what makes assertions analyzable rather than only visible on
|
|
92
|
+
* failure).
|
|
75
93
|
*
|
|
76
94
|
* Plain assertions compare `result[field]` directly (unchanged behaviour).
|
|
77
95
|
* JSONPath assertions parse the SOURCE field (e.g. `body` for the field
|
|
@@ -79,18 +97,15 @@ function formatFailure({
|
|
|
79
97
|
* apply the operator to the extracted value. Fail-closed: a missing
|
|
80
98
|
* expression, a non-JSON source value, or an invalid/eval-blocked path fails
|
|
81
99
|
* the assertion (with a diagnostic suffix) - it never fails the collector.
|
|
82
|
-
*
|
|
83
|
-
* Returns the failure message of the FIRST failing assertion, or `undefined`
|
|
84
|
-
* when all pass.
|
|
85
100
|
*/
|
|
86
|
-
export function
|
|
101
|
+
export function evaluateCollectorAssertionOutcomes({
|
|
87
102
|
assertions,
|
|
88
103
|
result,
|
|
89
104
|
}: {
|
|
90
105
|
assertions: CollectorAssertion[] | undefined;
|
|
91
106
|
result: Record<string, unknown>;
|
|
92
|
-
}):
|
|
93
|
-
if (!assertions?.length) return
|
|
107
|
+
}): CollectorAssertionEvaluation {
|
|
108
|
+
if (!assertions?.length) return { outcomes: [] };
|
|
94
109
|
|
|
95
110
|
// Parse each JSON source field at most once, not once per assertion.
|
|
96
111
|
const parsedSources = new Map<string, { json?: unknown; error?: string }>();
|
|
@@ -116,19 +131,72 @@ export function evaluateCollectorAssertions({
|
|
|
116
131
|
return entry;
|
|
117
132
|
};
|
|
118
133
|
|
|
134
|
+
const outcomes: AssertionOutcome[] = [];
|
|
135
|
+
let firstFailureMessage: string | undefined;
|
|
136
|
+
|
|
137
|
+
const baseOutcome = (assertion: CollectorAssertion) => ({
|
|
138
|
+
key: computeAssertionKey({ assertion }),
|
|
139
|
+
field: assertion.field,
|
|
140
|
+
jsonPath: assertion.jsonPath?.trim() || undefined,
|
|
141
|
+
operator: assertion.operator,
|
|
142
|
+
value:
|
|
143
|
+
assertion.value === undefined ? undefined : String(assertion.value),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const recordFailure = ({
|
|
147
|
+
assertion,
|
|
148
|
+
actual,
|
|
149
|
+
message,
|
|
150
|
+
legacyMessage,
|
|
151
|
+
}: {
|
|
152
|
+
assertion: CollectorAssertion;
|
|
153
|
+
actual?: unknown;
|
|
154
|
+
message: string;
|
|
155
|
+
legacyMessage: string;
|
|
156
|
+
}) => {
|
|
157
|
+
outcomes.push({
|
|
158
|
+
...baseOutcome(assertion),
|
|
159
|
+
passed: false,
|
|
160
|
+
actual: actual === undefined ? undefined : truncateActual({ value: actual }),
|
|
161
|
+
message,
|
|
162
|
+
});
|
|
163
|
+
if (firstFailureMessage === undefined) firstFailureMessage = legacyMessage;
|
|
164
|
+
};
|
|
165
|
+
|
|
119
166
|
for (const assertion of assertions) {
|
|
120
167
|
if (!isJsonPathAssertion(assertion)) {
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
168
|
+
const evaluated = evaluateAssertion(assertion, result);
|
|
169
|
+
if (evaluated.passed) {
|
|
170
|
+
outcomes.push({
|
|
171
|
+
...baseOutcome(assertion),
|
|
172
|
+
passed: true,
|
|
173
|
+
actual:
|
|
174
|
+
evaluated.actual === undefined
|
|
175
|
+
? undefined
|
|
176
|
+
: truncateActual({ value: evaluated.actual }),
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
recordFailure({
|
|
180
|
+
assertion,
|
|
181
|
+
actual: evaluated.actual,
|
|
182
|
+
message: evaluated.message ?? formatFailure({ assertion }),
|
|
183
|
+
legacyMessage: formatFailure({ assertion }),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
123
186
|
continue;
|
|
124
187
|
}
|
|
125
188
|
|
|
126
189
|
const path = assertion.jsonPath?.trim();
|
|
127
190
|
if (!path) {
|
|
128
|
-
|
|
191
|
+
recordFailure({
|
|
129
192
|
assertion,
|
|
130
|
-
|
|
193
|
+
message: "missing JSONPath expression",
|
|
194
|
+
legacyMessage: formatFailure({
|
|
195
|
+
assertion,
|
|
196
|
+
detail: "missing JSONPath expression",
|
|
197
|
+
}),
|
|
131
198
|
});
|
|
199
|
+
continue;
|
|
132
200
|
}
|
|
133
201
|
|
|
134
202
|
const sourceField = assertion.field.endsWith(JSONPATH_FIELD_SUFFIX)
|
|
@@ -136,34 +204,73 @@ export function evaluateCollectorAssertions({
|
|
|
136
204
|
: assertion.field;
|
|
137
205
|
const source = parseSource(sourceField);
|
|
138
206
|
if (source.error) {
|
|
139
|
-
|
|
207
|
+
recordFailure({
|
|
208
|
+
assertion,
|
|
209
|
+
message: source.error,
|
|
210
|
+
legacyMessage: formatFailure({ assertion, detail: source.error }),
|
|
211
|
+
});
|
|
212
|
+
continue;
|
|
140
213
|
}
|
|
141
214
|
|
|
142
215
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
216
|
+
// Extract once, then reuse the shared operator engine on a synthetic
|
|
217
|
+
// one-field record so the outcome carries the observed value.
|
|
218
|
+
const extracted = extractJsonPath(path, source.json);
|
|
219
|
+
const evaluated = evaluateAssertion(
|
|
220
|
+
{
|
|
221
|
+
field: "__jsonpath__",
|
|
222
|
+
operator: assertion.operator,
|
|
223
|
+
value:
|
|
224
|
+
assertion.value === undefined
|
|
225
|
+
? undefined
|
|
226
|
+
: String(assertion.value),
|
|
227
|
+
},
|
|
228
|
+
{ __jsonpath__: extracted },
|
|
156
229
|
);
|
|
157
|
-
if (
|
|
230
|
+
if (evaluated.passed) {
|
|
231
|
+
outcomes.push({
|
|
232
|
+
...baseOutcome(assertion),
|
|
233
|
+
passed: true,
|
|
234
|
+
actual:
|
|
235
|
+
extracted === undefined
|
|
236
|
+
? undefined
|
|
237
|
+
: truncateActual({ value: extracted }),
|
|
238
|
+
});
|
|
239
|
+
} else {
|
|
240
|
+
recordFailure({
|
|
241
|
+
assertion,
|
|
242
|
+
actual: extracted,
|
|
243
|
+
message: evaluated.message ?? formatFailure({ assertion }),
|
|
244
|
+
legacyMessage: formatFailure({ assertion }),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
158
247
|
} catch (error) {
|
|
159
248
|
// jsonpath-plus rejects malformed paths and (with eval disabled)
|
|
160
249
|
// filter/script expressions by throwing.
|
|
161
|
-
|
|
250
|
+
const detail = `invalid JSONPath: ${extractErrorMessage(error)}`;
|
|
251
|
+
recordFailure({
|
|
162
252
|
assertion,
|
|
163
|
-
|
|
253
|
+
message: detail,
|
|
254
|
+
legacyMessage: formatFailure({ assertion, detail }),
|
|
164
255
|
});
|
|
165
256
|
}
|
|
166
257
|
}
|
|
167
258
|
|
|
168
|
-
return
|
|
259
|
+
return { outcomes, firstFailureMessage };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Legacy single-string view of {@link evaluateCollectorAssertionOutcomes}:
|
|
264
|
+
* the failure message of the FIRST failing assertion, or `undefined` when all
|
|
265
|
+
* pass. Kept for callers that only need the `_assertionFailed` string.
|
|
266
|
+
*/
|
|
267
|
+
export function evaluateCollectorAssertions({
|
|
268
|
+
assertions,
|
|
269
|
+
result,
|
|
270
|
+
}: {
|
|
271
|
+
assertions: CollectorAssertion[] | undefined;
|
|
272
|
+
result: Record<string, unknown>;
|
|
273
|
+
}): string | undefined {
|
|
274
|
+
return evaluateCollectorAssertionOutcomes({ assertions, result })
|
|
275
|
+
.firstFailureMessage;
|
|
169
276
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { configSecret, Versioned } from "@checkstack/backend-api";
|
|
4
|
+
import type { InternalSecretsService } from "@checkstack/secrets-backend";
|
|
5
|
+
import { internalSecretName } from "@checkstack/secrets-common";
|
|
6
|
+
import { backfillConfigSecrets } from "./config-secrets-backfill";
|
|
7
|
+
import {
|
|
8
|
+
healthcheckConfigLockKey,
|
|
9
|
+
isHealthcheckSecretMarker,
|
|
10
|
+
} from "./config-secrets";
|
|
11
|
+
|
|
12
|
+
const collectorSchema = z.object({
|
|
13
|
+
url: z.string(),
|
|
14
|
+
password: configSecret({ id: "password" }).optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function fakeInternalSecrets(): InternalSecretsService & {
|
|
18
|
+
store: Map<string, string>;
|
|
19
|
+
} {
|
|
20
|
+
const store = new Map<string, string>();
|
|
21
|
+
return {
|
|
22
|
+
store,
|
|
23
|
+
async set({ parts, value }) {
|
|
24
|
+
store.set(internalSecretName(...parts), value);
|
|
25
|
+
},
|
|
26
|
+
async get({ parts }) {
|
|
27
|
+
return store.get(internalSecretName(...parts));
|
|
28
|
+
},
|
|
29
|
+
async delete({ parts }) {
|
|
30
|
+
store.delete(internalSecretName(...parts));
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const noopLogger = {
|
|
36
|
+
debug: mock(() => {}),
|
|
37
|
+
info: mock(() => {}),
|
|
38
|
+
warn: mock(() => {}),
|
|
39
|
+
error: mock(() => {}),
|
|
40
|
+
} as never;
|
|
41
|
+
|
|
42
|
+
describe("backfillConfigSecrets", () => {
|
|
43
|
+
it("migrates a registered collector's inline secret even under an UNREGISTERED strategy, per config lock", async () => {
|
|
44
|
+
// One legacy row: strategy plugin is gone, but the collector plugin is
|
|
45
|
+
// present and its config carries an inline plaintext secret.
|
|
46
|
+
let row: Record<string, unknown> = {
|
|
47
|
+
id: "cfg-bf",
|
|
48
|
+
strategyId: "gone.strategy",
|
|
49
|
+
config: { url: "https://x", password: "strategy-plain" }, // no schema -> left as-is
|
|
50
|
+
collectors: [
|
|
51
|
+
{
|
|
52
|
+
id: "c1",
|
|
53
|
+
collectorId: "reg.collector",
|
|
54
|
+
config: { url: "https://y", password: "collector-plain" },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
const updates: Record<string, unknown>[] = [];
|
|
59
|
+
const db = {
|
|
60
|
+
// Initial scan: `db.select().from(table)` awaited directly.
|
|
61
|
+
// Per-row re-read: `db.select().from(table).where(eq(...))`.
|
|
62
|
+
select: () => ({
|
|
63
|
+
from: () =>
|
|
64
|
+
Object.assign(Promise.resolve([row]), {
|
|
65
|
+
where: () => Promise.resolve([row]),
|
|
66
|
+
}),
|
|
67
|
+
}),
|
|
68
|
+
update: () => ({
|
|
69
|
+
set: (set: Record<string, unknown>) => {
|
|
70
|
+
updates.push(set);
|
|
71
|
+
row = { ...row, ...set };
|
|
72
|
+
return { where: () => Promise.resolve(undefined) };
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const registry = { getStrategy: mock(() => undefined) };
|
|
78
|
+
const collectorRegistry = {
|
|
79
|
+
getCollector: mock((id: string) =>
|
|
80
|
+
id === "reg.collector"
|
|
81
|
+
? { collector: { config: new Versioned({ version: 1, schema: collectorSchema }) } }
|
|
82
|
+
: undefined,
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const lockKeys: string[] = [];
|
|
87
|
+
const advisoryLock = {
|
|
88
|
+
tryAcquire: mock(async () => ({ release: mock(async () => {}) })),
|
|
89
|
+
withXactLock: async ({ key, fn }: { key: string; fn: () => Promise<unknown> }) => {
|
|
90
|
+
lockKeys.push(key);
|
|
91
|
+
return fn();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const internalSecrets = fakeInternalSecrets();
|
|
96
|
+
|
|
97
|
+
await backfillConfigSecrets({
|
|
98
|
+
db: db as never,
|
|
99
|
+
registry: registry as never,
|
|
100
|
+
collectorRegistry: collectorRegistry as never,
|
|
101
|
+
internalSecrets,
|
|
102
|
+
advisoryLock: advisoryLock as never,
|
|
103
|
+
logger: noopLogger,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Per-config lock was taken for the row.
|
|
107
|
+
expect(lockKeys).toEqual([healthcheckConfigLockKey("cfg-bf")]);
|
|
108
|
+
|
|
109
|
+
// The collector's inline secret was migrated to a marker + the store.
|
|
110
|
+
const persistedCollectors = updates.at(-1)?.collectors as Array<{
|
|
111
|
+
config: Record<string, unknown>;
|
|
112
|
+
}>;
|
|
113
|
+
expect(
|
|
114
|
+
isHealthcheckSecretMarker(persistedCollectors[0].config.password as string),
|
|
115
|
+
).toBe(true);
|
|
116
|
+
expect([...internalSecrets.store.values()]).toContain("collector-plain");
|
|
117
|
+
|
|
118
|
+
// The strategy config (no schema to walk) is left untouched - never wiped,
|
|
119
|
+
// and its legacy plaintext is NOT migrated (nothing else we can do).
|
|
120
|
+
const persistedConfig = updates.at(-1)?.config as Record<string, unknown>;
|
|
121
|
+
expect(persistedConfig.password).toBe("strategy-plain");
|
|
122
|
+
expect([...internalSecrets.store.values()]).not.toContain("strategy-plain");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("skips the scan entirely when another pod holds the backfill lock", async () => {
|
|
126
|
+
const advisoryLock = {
|
|
127
|
+
tryAcquire: mock(async () => undefined), // lock not acquired
|
|
128
|
+
withXactLock: mock(async () => {
|
|
129
|
+
throw new Error("must not run when the global lock is unavailable");
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
await backfillConfigSecrets({
|
|
133
|
+
db: {} as never,
|
|
134
|
+
registry: {} as never,
|
|
135
|
+
collectorRegistry: {} as never,
|
|
136
|
+
internalSecrets: fakeInternalSecrets(),
|
|
137
|
+
advisoryLock: advisoryLock as never,
|
|
138
|
+
logger: noopLogger,
|
|
139
|
+
});
|
|
140
|
+
expect(advisoryLock.withXactLock).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import type {
|
|
3
|
+
AdvisoryLockService,
|
|
4
|
+
HealthCheckRegistry,
|
|
5
|
+
CollectorRegistry,
|
|
6
|
+
Logger,
|
|
7
|
+
SafeDatabase,
|
|
8
|
+
} from "@checkstack/backend-api";
|
|
9
|
+
import type { InternalSecretsService } from "@checkstack/secrets-backend";
|
|
10
|
+
import type { CollectorConfigEntry } from "@checkstack/healthcheck-common";
|
|
11
|
+
import { healthCheckConfigurations } from "./schema";
|
|
12
|
+
import * as schema from "./schema";
|
|
13
|
+
import {
|
|
14
|
+
extractConfigurationSecrets,
|
|
15
|
+
healthcheckConfigLockKey,
|
|
16
|
+
} from "./config-secrets";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One-time (idempotent) boot backfill: move inline `x-secret` values that
|
|
20
|
+
* pre-date the config-secrets channel out of stored health-check
|
|
21
|
+
* configurations and into internal secrets.
|
|
22
|
+
*
|
|
23
|
+
* Rows written after the channel shipped hold only markers / `${{ secrets.* }}`
|
|
24
|
+
* references, which the extraction walk skips - so re-running is a no-op and
|
|
25
|
+
* the job is safe to run on EVERY boot. A cross-pod advisory lock ensures a
|
|
26
|
+
* single pod performs the scan; the others skip (the winner's result is in
|
|
27
|
+
* the shared DB either way).
|
|
28
|
+
*
|
|
29
|
+
* Fail-open per row: one row with an unregistered strategy (plugin removed)
|
|
30
|
+
* or a broken shape must not wedge boot - it is logged and left as-is; the
|
|
31
|
+
* executor treats its bare literal exactly as before (legacy pass-through).
|
|
32
|
+
*/
|
|
33
|
+
export async function backfillConfigSecrets({
|
|
34
|
+
db,
|
|
35
|
+
registry,
|
|
36
|
+
collectorRegistry,
|
|
37
|
+
internalSecrets,
|
|
38
|
+
advisoryLock,
|
|
39
|
+
logger,
|
|
40
|
+
}: {
|
|
41
|
+
db: SafeDatabase<typeof schema>;
|
|
42
|
+
registry: HealthCheckRegistry;
|
|
43
|
+
collectorRegistry: CollectorRegistry;
|
|
44
|
+
internalSecrets: InternalSecretsService;
|
|
45
|
+
advisoryLock: AdvisoryLockService;
|
|
46
|
+
logger: Logger;
|
|
47
|
+
}): Promise<void> {
|
|
48
|
+
const lock = await advisoryLock.tryAcquire("healthcheck:config-secrets-backfill");
|
|
49
|
+
if (!lock) {
|
|
50
|
+
logger.debug("Config-secrets backfill already running on another pod, skipping");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const rows = await db.select().from(healthCheckConfigurations);
|
|
56
|
+
let migratedRows = 0;
|
|
57
|
+
let movedValues = 0;
|
|
58
|
+
|
|
59
|
+
for (const { id: rowId } of rows) {
|
|
60
|
+
// Serialize each row's read-modify-write under the SAME per-config lock
|
|
61
|
+
// updateConfiguration uses, and re-read the row INSIDE the lock. Without
|
|
62
|
+
// this the backfill could write back a pre-lock snapshot over a concurrent
|
|
63
|
+
// edit - resurrecting a just-rotated secret and reverting the edit.
|
|
64
|
+
await advisoryLock.withXactLock({
|
|
65
|
+
key: healthcheckConfigLockKey(rowId),
|
|
66
|
+
fn: async () => {
|
|
67
|
+
const [row] = await db
|
|
68
|
+
.select()
|
|
69
|
+
.from(healthCheckConfigurations)
|
|
70
|
+
.where(eq(healthCheckConfigurations.id, rowId));
|
|
71
|
+
if (!row) return; // deleted between the scan and the lock
|
|
72
|
+
|
|
73
|
+
// An unregistered strategy has no schema: its own strategy-level
|
|
74
|
+
// legacy secrets stay as-is (pass-through, as before), but we still
|
|
75
|
+
// migrate every REGISTERED collector's inline secrets rather than
|
|
76
|
+
// skipping the whole row - leaving those plaintext would defeat the
|
|
77
|
+
// backfill. extractConfigurationSecrets handles the undefined schema.
|
|
78
|
+
const strategySchema =
|
|
79
|
+
registry.getStrategy(row.strategyId)?.config.schema;
|
|
80
|
+
if (!strategySchema) {
|
|
81
|
+
logger.warn(
|
|
82
|
+
`Config-secrets backfill: strategy ${row.strategyId} not registered; leaving its strategy config as-is and migrating collector secrets for configuration ${row.id}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const collectors: CollectorConfigEntry[] | undefined =
|
|
88
|
+
row.collectors ?? undefined;
|
|
89
|
+
const extracted = await extractConfigurationSecrets({
|
|
90
|
+
configurationId: row.id,
|
|
91
|
+
strategySchema,
|
|
92
|
+
config: row.config as Record<string, unknown>,
|
|
93
|
+
collectors,
|
|
94
|
+
getCollectorSchema: (collectorId) =>
|
|
95
|
+
collectorRegistry.getCollector(collectorId)?.collector.config
|
|
96
|
+
.schema,
|
|
97
|
+
internalSecrets,
|
|
98
|
+
});
|
|
99
|
+
if (extracted.extracted === 0) return;
|
|
100
|
+
|
|
101
|
+
await db
|
|
102
|
+
.update(healthCheckConfigurations)
|
|
103
|
+
.set({
|
|
104
|
+
config: extracted.config,
|
|
105
|
+
collectors: extracted.collectors,
|
|
106
|
+
updatedAt: new Date(),
|
|
107
|
+
})
|
|
108
|
+
.where(eq(healthCheckConfigurations.id, row.id));
|
|
109
|
+
migratedRows++;
|
|
110
|
+
movedValues += extracted.extracted;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
logger.warn(
|
|
113
|
+
`Config-secrets backfill: failed for configuration ${row.id}, leaving as-is`,
|
|
114
|
+
error,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (migratedRows > 0) {
|
|
122
|
+
logger.info(
|
|
123
|
+
`Config-secrets backfill: moved ${movedValues} inline secret value(s) from ${migratedRows} configuration(s) into the internal secret store`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
await lock.release();
|
|
128
|
+
}
|
|
129
|
+
}
|