@checkstack/healthcheck-backend 1.20.1 → 1.21.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 +155 -0
- package/package.json +22 -22
- package/src/ai/shell-env-table.test.ts +4 -2
- package/src/assignment-access.test.ts +332 -0
- package/src/assignment-access.ts +205 -0
- package/src/collector-script-test.test.ts +37 -0
- package/src/collector-script-test.ts +54 -16
- package/src/queue-executor.test.ts +57 -3
- package/src/queue-executor.ts +6 -2
- package/src/router-assignment-access.test.ts +260 -0
- package/src/router.ts +102 -3
- package/src/service.ts +82 -11
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { createHealthCheckRouter } from "./router";
|
|
3
|
+
import { createMockRpcContext } from "@checkstack/backend-api";
|
|
4
|
+
import type { RpcContext } from "@checkstack/backend-api";
|
|
5
|
+
import { call } from "@orpc/server";
|
|
6
|
+
import { createStubHealthCheckCache } from "./cache-test-stub";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Router-level tests for the HANDLER-side authorization of the
|
|
10
|
+
* configuration-centric assignment reads (`getConfigurationAssignments`) and
|
|
11
|
+
* the relaxed single-configuration read (`getConfiguration`). Their contract
|
|
12
|
+
* `access` is deliberately empty (see assignment-access.ts), so these tests
|
|
13
|
+
* are the regression guard that the router actually enforces the rule.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const passthroughCache = createStubHealthCheckCache();
|
|
17
|
+
|
|
18
|
+
const CONFIG_ID = "12345678-1234-4234-8234-123456789012";
|
|
19
|
+
|
|
20
|
+
const CONFIG_ROW = {
|
|
21
|
+
id: CONFIG_ID,
|
|
22
|
+
name: "Payments API root",
|
|
23
|
+
strategyId: "healthcheck-http.http",
|
|
24
|
+
config: { url: "https://api.example.com/healthz" },
|
|
25
|
+
collectors: null,
|
|
26
|
+
intervalSeconds: 60,
|
|
27
|
+
paused: false,
|
|
28
|
+
createdAt: new Date(),
|
|
29
|
+
updatedAt: new Date(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ASSIGNMENT_ROWS = [
|
|
33
|
+
{
|
|
34
|
+
systemId: "sys-1",
|
|
35
|
+
enabled: true,
|
|
36
|
+
stateThresholds: null,
|
|
37
|
+
satelliteIds: null,
|
|
38
|
+
environmentIds: null,
|
|
39
|
+
includeLocal: true,
|
|
40
|
+
notificationPolicy: null,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
systemId: "sys-2",
|
|
44
|
+
enabled: false,
|
|
45
|
+
stateThresholds: null,
|
|
46
|
+
satelliteIds: ["sat-1"],
|
|
47
|
+
environmentIds: [],
|
|
48
|
+
includeLocal: false,
|
|
49
|
+
notificationPolicy: null,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A DB serving the three selects these handlers issue, disambiguated by the
|
|
55
|
+
* projected columns: `select()` (no projection) resolves the configuration
|
|
56
|
+
* row; a projection containing `enabled` resolves the full assignment rows; a
|
|
57
|
+
* projection of only `systemId` resolves the assigned-id list.
|
|
58
|
+
*/
|
|
59
|
+
function createReadOnlyDb() {
|
|
60
|
+
return {
|
|
61
|
+
select: (projection?: Record<string, unknown>) => ({
|
|
62
|
+
from: () => {
|
|
63
|
+
const rows =
|
|
64
|
+
projection === undefined
|
|
65
|
+
? [CONFIG_ROW]
|
|
66
|
+
: "enabled" in projection
|
|
67
|
+
? ASSIGNMENT_ROWS
|
|
68
|
+
: ASSIGNMENT_ROWS.map((row) => ({ systemId: row.systemId }));
|
|
69
|
+
return Object.assign(Promise.resolve(rows), {
|
|
70
|
+
where: () => Promise.resolve(rows),
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildRouter() {
|
|
78
|
+
return createHealthCheckRouter({
|
|
79
|
+
database: createReadOnlyDb() as never,
|
|
80
|
+
registry: { getStrategy: mock(() => undefined) } as never,
|
|
81
|
+
collectorRegistry: { getCollector: mock(() => undefined) } as never,
|
|
82
|
+
gitOpsClient: {
|
|
83
|
+
getProvenance: mock(() => Promise.resolve(null)),
|
|
84
|
+
} as never,
|
|
85
|
+
getEmitHook: () => undefined,
|
|
86
|
+
cache: passthroughCache,
|
|
87
|
+
configService: {
|
|
88
|
+
get: mock(async () => undefined),
|
|
89
|
+
set: mock(async () => {}),
|
|
90
|
+
} as never,
|
|
91
|
+
catalogClient: {
|
|
92
|
+
getSystems: mock(async () => ({
|
|
93
|
+
systems: [
|
|
94
|
+
{ id: "sys-1", name: "Payments" },
|
|
95
|
+
{ id: "sys-2", name: "Checkout" },
|
|
96
|
+
],
|
|
97
|
+
})),
|
|
98
|
+
} as never,
|
|
99
|
+
maintenanceClient: {
|
|
100
|
+
hasActiveMaintenance: mock(async () => ({ active: false })),
|
|
101
|
+
} as never,
|
|
102
|
+
logger: {
|
|
103
|
+
debug: mock(() => {}),
|
|
104
|
+
info: mock(() => {}),
|
|
105
|
+
warn: mock(() => {}),
|
|
106
|
+
error: mock(() => {}),
|
|
107
|
+
} as never,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const teamUser = {
|
|
112
|
+
type: "user" as const,
|
|
113
|
+
id: "team-user",
|
|
114
|
+
accessRules: [] as string[],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** An auth override where the caller holds the given grants and nothing else. */
|
|
118
|
+
function authOverride({
|
|
119
|
+
configGrant,
|
|
120
|
+
readableSystemIds,
|
|
121
|
+
}: {
|
|
122
|
+
configGrant: boolean;
|
|
123
|
+
readableSystemIds: string[];
|
|
124
|
+
}): Partial<RpcContext> {
|
|
125
|
+
return {
|
|
126
|
+
auth: {
|
|
127
|
+
check: mock(async ({ objectType }: { objectType: string }) => ({
|
|
128
|
+
hasAccess: objectType === "healthcheck.healthcheck" && configGrant,
|
|
129
|
+
})),
|
|
130
|
+
listAccessibleObjectIds: mock(
|
|
131
|
+
async ({ objectIds }: { objectIds: string[] }) =>
|
|
132
|
+
objectIds.filter((id) => readableSystemIds.includes(id)),
|
|
133
|
+
),
|
|
134
|
+
} as unknown as RpcContext["auth"],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe("getConfigurationAssignments authorization", () => {
|
|
139
|
+
it("global configuration.read sees every row with resolved system names", async () => {
|
|
140
|
+
const router = buildRouter();
|
|
141
|
+
const context = createMockRpcContext({
|
|
142
|
+
user: {
|
|
143
|
+
type: "user",
|
|
144
|
+
id: "global-reader",
|
|
145
|
+
accessRules: ["healthcheck.healthcheck.read"],
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const rows = await call(
|
|
150
|
+
router.getConfigurationAssignments,
|
|
151
|
+
{ configId: CONFIG_ID },
|
|
152
|
+
{ context },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(rows.map((r) => r.systemName).toSorted()).toEqual([
|
|
156
|
+
"Checkout",
|
|
157
|
+
"Payments",
|
|
158
|
+
]);
|
|
159
|
+
// null environmentIds (= all envs) must survive the trip untouched.
|
|
160
|
+
expect(rows.find((r) => r.systemId === "sys-1")?.environmentIds).toBeNull();
|
|
161
|
+
expect(rows.find((r) => r.systemId === "sys-2")?.environmentIds).toEqual(
|
|
162
|
+
[],
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("a team grant on the CONFIGURATION sees every row", async () => {
|
|
167
|
+
const router = buildRouter();
|
|
168
|
+
const context = createMockRpcContext({
|
|
169
|
+
user: teamUser,
|
|
170
|
+
...authOverride({ configGrant: true, readableSystemIds: [] }),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const rows = await call(
|
|
174
|
+
router.getConfigurationAssignments,
|
|
175
|
+
{ configId: CONFIG_ID },
|
|
176
|
+
{ context },
|
|
177
|
+
);
|
|
178
|
+
expect(rows).toHaveLength(2);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("a system-only caller sees ONLY their systems' rows", async () => {
|
|
182
|
+
const router = buildRouter();
|
|
183
|
+
const context = createMockRpcContext({
|
|
184
|
+
user: teamUser,
|
|
185
|
+
...authOverride({ configGrant: false, readableSystemIds: ["sys-2"] }),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const rows = await call(
|
|
189
|
+
router.getConfigurationAssignments,
|
|
190
|
+
{ configId: CONFIG_ID },
|
|
191
|
+
{ context },
|
|
192
|
+
);
|
|
193
|
+
expect(rows).toHaveLength(1);
|
|
194
|
+
expect(rows[0].systemId).toBe("sys-2");
|
|
195
|
+
expect(rows[0].systemName).toBe("Checkout");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("a caller with no grant of either kind is FORBIDDEN", async () => {
|
|
199
|
+
const router = buildRouter();
|
|
200
|
+
const context = createMockRpcContext({
|
|
201
|
+
user: teamUser,
|
|
202
|
+
...authOverride({ configGrant: false, readableSystemIds: [] }),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await expect(
|
|
206
|
+
call(
|
|
207
|
+
router.getConfigurationAssignments,
|
|
208
|
+
{ configId: CONFIG_ID },
|
|
209
|
+
{ context },
|
|
210
|
+
),
|
|
211
|
+
).rejects.toThrow(/FORBIDDEN|read access/i);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("getConfiguration relaxed authorization", () => {
|
|
216
|
+
it("a team grant on the configuration reads it", async () => {
|
|
217
|
+
const router = buildRouter();
|
|
218
|
+
const context = createMockRpcContext({
|
|
219
|
+
user: teamUser,
|
|
220
|
+
...authOverride({ configGrant: true, readableSystemIds: [] }),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const config = await call(
|
|
224
|
+
router.getConfiguration,
|
|
225
|
+
{ id: CONFIG_ID },
|
|
226
|
+
{ context },
|
|
227
|
+
);
|
|
228
|
+
expect(config?.name).toBe("Payments API root");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("a reader of an ASSIGNED system reads it", async () => {
|
|
232
|
+
const router = buildRouter();
|
|
233
|
+
const context = createMockRpcContext({
|
|
234
|
+
user: teamUser,
|
|
235
|
+
...authOverride({ configGrant: false, readableSystemIds: ["sys-1"] }),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const config = await call(
|
|
239
|
+
router.getConfiguration,
|
|
240
|
+
{ id: CONFIG_ID },
|
|
241
|
+
{ context },
|
|
242
|
+
);
|
|
243
|
+
expect(config?.name).toBe("Payments API root");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("an unrelated caller gets the same `undefined` as a missing id (no existence leak)", async () => {
|
|
247
|
+
const router = buildRouter();
|
|
248
|
+
const context = createMockRpcContext({
|
|
249
|
+
user: teamUser,
|
|
250
|
+
...authOverride({ configGrant: false, readableSystemIds: [] }),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const config = await call(
|
|
254
|
+
router.getConfiguration,
|
|
255
|
+
{ id: CONFIG_ID },
|
|
256
|
+
{ context },
|
|
257
|
+
);
|
|
258
|
+
expect(config).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
});
|
package/src/router.ts
CHANGED
|
@@ -28,6 +28,13 @@ import {
|
|
|
28
28
|
listTeamManageableConfigurationIds,
|
|
29
29
|
resolveHistoryScope,
|
|
30
30
|
} from "./history-access";
|
|
31
|
+
import {
|
|
32
|
+
canReadConfigurationScope,
|
|
33
|
+
hasConfigurationReadGrant,
|
|
34
|
+
hasGlobalConfigurationRead,
|
|
35
|
+
listReadableSystemIds,
|
|
36
|
+
resolveAssignmentRowScope,
|
|
37
|
+
} from "./assignment-access";
|
|
31
38
|
import { collectConfigurationIssues } from "./validate-configuration";
|
|
32
39
|
import { runCollectorScriptTest } from "./collector-script-test";
|
|
33
40
|
import { healthCheckHooks } from "./hooks";
|
|
@@ -340,9 +347,26 @@ export const createHealthCheckRouter = (opts: {
|
|
|
340
347
|
return { configurations: await service.getConfigurationsRedacted() };
|
|
341
348
|
}),
|
|
342
349
|
|
|
343
|
-
getConfiguration: os.getConfiguration.handler(
|
|
344
|
-
|
|
345
|
-
|
|
350
|
+
getConfiguration: os.getConfiguration.handler(
|
|
351
|
+
async ({ input, context }) => {
|
|
352
|
+
// Handler-side authorization (the contract's `access` is deliberately
|
|
353
|
+
// empty - see the contract doc): global configuration read, a team
|
|
354
|
+
// grant on the configuration, or read access to an ASSIGNED system.
|
|
355
|
+
// An unauthorized caller gets the same `undefined` as a missing id,
|
|
356
|
+
// so configuration ids don't leak existence. Fail-closed via
|
|
357
|
+
// assignment-access.ts.
|
|
358
|
+
const configuration = await service.getConfigurationRedacted(input.id);
|
|
359
|
+
if (!configuration) return;
|
|
360
|
+
const allowed = await canReadConfigurationScope({
|
|
361
|
+
auth: context.auth,
|
|
362
|
+
user: context.user,
|
|
363
|
+
configurationId: input.id,
|
|
364
|
+
getAssignedSystemIds: () => service.getAssignedSystemIds(input.id),
|
|
365
|
+
});
|
|
366
|
+
if (!allowed) return;
|
|
367
|
+
return configuration;
|
|
368
|
+
},
|
|
369
|
+
),
|
|
346
370
|
|
|
347
371
|
createConfiguration: os.createConfiguration.handler(async ({ input }) => {
|
|
348
372
|
const created = await service.createConfiguration(input);
|
|
@@ -494,6 +518,66 @@ export const createHealthCheckRouter = (opts: {
|
|
|
494
518
|
},
|
|
495
519
|
),
|
|
496
520
|
|
|
521
|
+
getConfigurationAssignments: os.getConfigurationAssignments.handler(
|
|
522
|
+
async ({ input, context }) => {
|
|
523
|
+
// Handler-side authorization (the contract's `access` is deliberately
|
|
524
|
+
// empty - see the contract doc): global configuration read or a team
|
|
525
|
+
// grant on the CONFIGURATION sees every row; otherwise rows are
|
|
526
|
+
// filtered to the systems the caller may read; neither is forbidden.
|
|
527
|
+
// Fail-closed via assignment-access.ts.
|
|
528
|
+
const user = context.user;
|
|
529
|
+
const rows = await service.getConfigurationAssignments(input.configId);
|
|
530
|
+
|
|
531
|
+
let visibleRows = rows;
|
|
532
|
+
if (!hasGlobalConfigurationRead(user)) {
|
|
533
|
+
if (!user || (user.type !== "user" && user.type !== "application")) {
|
|
534
|
+
throw new ORPCError("FORBIDDEN", {
|
|
535
|
+
message:
|
|
536
|
+
"Assignment details require health check read access (globally, or via a team grant on the configuration or an assigned system)",
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const hasConfigGrant = await hasConfigurationReadGrant({
|
|
540
|
+
auth: context.auth,
|
|
541
|
+
user,
|
|
542
|
+
configurationId: input.configId,
|
|
543
|
+
});
|
|
544
|
+
const readableSystemIds = hasConfigGrant
|
|
545
|
+
? []
|
|
546
|
+
: await listReadableSystemIds({
|
|
547
|
+
auth: context.auth,
|
|
548
|
+
user,
|
|
549
|
+
allSystemIds: rows.map((row) => row.systemId),
|
|
550
|
+
});
|
|
551
|
+
const scope = resolveAssignmentRowScope({
|
|
552
|
+
user,
|
|
553
|
+
hasConfigurationGrant: hasConfigGrant,
|
|
554
|
+
readableSystemIds,
|
|
555
|
+
});
|
|
556
|
+
if (scope.kind === "forbidden") {
|
|
557
|
+
throw new ORPCError("FORBIDDEN", {
|
|
558
|
+
message:
|
|
559
|
+
"Assignment details require health check read access (globally, or via a team grant on the configuration or an assigned system)",
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (scope.kind === "scoped") {
|
|
563
|
+
const readable = new Set(scope.systemIds);
|
|
564
|
+
visibleRows = rows.filter((row) => readable.has(row.systemId));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (visibleRows.length === 0) return [];
|
|
569
|
+
|
|
570
|
+
// Resolve system display names in ONE trusted S2S call (names live in
|
|
571
|
+
// the catalog plugin); fall back to the id if a system vanished.
|
|
572
|
+
const { systems } = await catalogClient.getSystems();
|
|
573
|
+
const nameById = new Map(systems.map((s) => [s.id, s.name]));
|
|
574
|
+
return visibleRows.map((row) => ({
|
|
575
|
+
...row,
|
|
576
|
+
systemName: nameById.get(row.systemId) ?? row.systemId,
|
|
577
|
+
}));
|
|
578
|
+
},
|
|
579
|
+
),
|
|
580
|
+
|
|
497
581
|
getBulkAssignedHealthCheckCounts:
|
|
498
582
|
os.getBulkAssignedHealthCheckCounts.handler(async ({ input }) => {
|
|
499
583
|
// ONE grouped query for the whole visible system list (replaces the
|
|
@@ -505,6 +589,21 @@ export const createHealthCheckRouter = (opts: {
|
|
|
505
589
|
return { counts };
|
|
506
590
|
}),
|
|
507
591
|
|
|
592
|
+
resolveEnqueueEnvironments: os.resolveEnqueueEnvironments.handler(
|
|
593
|
+
async ({ input }) => {
|
|
594
|
+
// Delegates to the same resolution the run_now automation + scheduler
|
|
595
|
+
// use, so a cross-plugin one-off run (e.g. logstream fast-path) targets
|
|
596
|
+
// exactly the environment slices the executor accepts.
|
|
597
|
+
const environmentIds = await service.resolveEnqueueEnvironmentIds({
|
|
598
|
+
systemId: input.systemId,
|
|
599
|
+
configurationId: input.configId,
|
|
600
|
+
catalogClient,
|
|
601
|
+
logger,
|
|
602
|
+
});
|
|
603
|
+
return { environmentIds };
|
|
604
|
+
},
|
|
605
|
+
),
|
|
606
|
+
|
|
508
607
|
associateSystem: os.associateSystem.handler(async ({ input, context }) => {
|
|
509
608
|
await enforceNotGitOpsLocked("System", input.systemId);
|
|
510
609
|
await service.associateSystem({
|
package/src/service.ts
CHANGED
|
@@ -989,6 +989,63 @@ export class HealthCheckService {
|
|
|
989
989
|
return results;
|
|
990
990
|
}
|
|
991
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Configuration-centric inverse of {@link getSystemAssociations}: the
|
|
994
|
+
* per-system assignment rows of ONE configuration. System NAMES are
|
|
995
|
+
* resolved by the router via the catalog S2S client (they live in the
|
|
996
|
+
* catalog plugin's storage); rows here carry ids only.
|
|
997
|
+
*/
|
|
998
|
+
async getConfigurationAssignments(configurationId: string) {
|
|
999
|
+
const rows = await this.db
|
|
1000
|
+
.select({
|
|
1001
|
+
systemId: systemHealthChecks.systemId,
|
|
1002
|
+
enabled: systemHealthChecks.enabled,
|
|
1003
|
+
stateThresholds: systemHealthChecks.stateThresholds,
|
|
1004
|
+
satelliteIds: systemHealthChecks.satelliteIds,
|
|
1005
|
+
environmentIds: systemHealthChecks.environmentIds,
|
|
1006
|
+
includeLocal: systemHealthChecks.includeLocal,
|
|
1007
|
+
notificationPolicy: systemHealthChecks.notificationPolicy,
|
|
1008
|
+
})
|
|
1009
|
+
.from(systemHealthChecks)
|
|
1010
|
+
.where(eq(systemHealthChecks.configurationId, configurationId));
|
|
1011
|
+
|
|
1012
|
+
// Migrate and extract thresholds for each assignment (same treatment as
|
|
1013
|
+
// getSystemAssociations).
|
|
1014
|
+
const results = [];
|
|
1015
|
+
for (const row of rows) {
|
|
1016
|
+
let thresholds: StateThresholds | undefined;
|
|
1017
|
+
if (row.stateThresholds) {
|
|
1018
|
+
thresholds = await stateThresholds.parse(row.stateThresholds);
|
|
1019
|
+
}
|
|
1020
|
+
results.push({
|
|
1021
|
+
systemId: row.systemId,
|
|
1022
|
+
enabled: row.enabled,
|
|
1023
|
+
stateThresholds: thresholds,
|
|
1024
|
+
satelliteIds: row.satelliteIds ?? undefined,
|
|
1025
|
+
// Preserve the null/[]/list distinction (null = all envs, [] = opt
|
|
1026
|
+
// out). Do NOT collapse null to undefined via `??`.
|
|
1027
|
+
environmentIds: row.environmentIds,
|
|
1028
|
+
includeLocal: row.includeLocal,
|
|
1029
|
+
notificationPolicy: row.notificationPolicy ?? undefined,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
return results;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* The system ids a configuration is assigned to. Authorization helper for
|
|
1037
|
+
* the relaxed `getConfiguration` read (see assignment-access.ts) - kept as
|
|
1038
|
+
* a bare id select so the auth path never pays for the full assignment
|
|
1039
|
+
* rows.
|
|
1040
|
+
*/
|
|
1041
|
+
async getAssignedSystemIds(configurationId: string): Promise<string[]> {
|
|
1042
|
+
const rows = await this.db
|
|
1043
|
+
.select({ systemId: systemHealthChecks.systemId })
|
|
1044
|
+
.from(systemHealthChecks)
|
|
1045
|
+
.where(eq(systemHealthChecks.configurationId, configurationId));
|
|
1046
|
+
return rows.map((row) => row.systemId);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
992
1049
|
/**
|
|
993
1050
|
* Count of health-check assignments for each of `systemIds`, keyed by
|
|
994
1051
|
* systemId. ONE grouped query (no per-system fan-out) so the catalog manager
|
|
@@ -2659,25 +2716,37 @@ export class HealthCheckService {
|
|
|
2659
2716
|
|
|
2660
2717
|
if (matchingAssociations.length === 0) return [];
|
|
2661
2718
|
|
|
2662
|
-
// Resolve human-readable system
|
|
2663
|
-
// Falls back to the systemId when no catalog
|
|
2664
|
-
// lookup fails, mirroring the queue-executor's
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2719
|
+
// Resolve human-readable system name + free-form metadata once per distinct
|
|
2720
|
+
// systemId. Falls back to the systemId (and empty metadata) when no catalog
|
|
2721
|
+
// client is wired or the lookup fails, mirroring the queue-executor's
|
|
2722
|
+
// resolution behaviour. The metadata rides the assignment so satellite runs
|
|
2723
|
+
// template `{{ system.metadata.<key> }}` identically to local runs.
|
|
2724
|
+
const systemCache = new Map<
|
|
2725
|
+
string,
|
|
2726
|
+
{ name: string; metadata: Record<string, unknown> }
|
|
2727
|
+
>();
|
|
2728
|
+
const resolveSystem = async (
|
|
2729
|
+
systemId: string,
|
|
2730
|
+
): Promise<{ name: string; metadata: Record<string, unknown> }> => {
|
|
2731
|
+
const cached = systemCache.get(systemId);
|
|
2668
2732
|
if (cached !== undefined) return cached;
|
|
2669
2733
|
|
|
2670
|
-
let
|
|
2734
|
+
let resolved: { name: string; metadata: Record<string, unknown> } = {
|
|
2735
|
+
name: systemId,
|
|
2736
|
+
metadata: {},
|
|
2737
|
+
};
|
|
2671
2738
|
if (this.catalogClient) {
|
|
2672
2739
|
try {
|
|
2673
2740
|
const system = await this.catalogClient.getSystem({ systemId });
|
|
2674
|
-
if (system)
|
|
2741
|
+
if (system) {
|
|
2742
|
+
resolved = { name: system.name, metadata: system.metadata ?? {} };
|
|
2743
|
+
}
|
|
2675
2744
|
} catch {
|
|
2676
2745
|
// Fall back to systemId if catalog lookup fails.
|
|
2677
2746
|
}
|
|
2678
2747
|
}
|
|
2679
|
-
|
|
2680
|
-
return
|
|
2748
|
+
systemCache.set(systemId, resolved);
|
|
2749
|
+
return resolved;
|
|
2681
2750
|
};
|
|
2682
2751
|
|
|
2683
2752
|
// Get configurations for each matching association
|
|
@@ -2690,6 +2759,7 @@ export class HealthCheckService {
|
|
|
2690
2759
|
|
|
2691
2760
|
if (!config || config.paused) continue;
|
|
2692
2761
|
|
|
2762
|
+
const system = await resolveSystem(assoc.systemId);
|
|
2693
2763
|
assignments.push({
|
|
2694
2764
|
configId: config.id,
|
|
2695
2765
|
systemId: assoc.systemId,
|
|
@@ -2699,7 +2769,8 @@ export class HealthCheckService {
|
|
|
2699
2769
|
intervalSeconds: config.intervalSeconds,
|
|
2700
2770
|
// Curated run-context metadata exposed to satellite collectors.
|
|
2701
2771
|
configName: config.name,
|
|
2702
|
-
systemName:
|
|
2772
|
+
systemName: system.name,
|
|
2773
|
+
systemMetadata: system.metadata,
|
|
2703
2774
|
});
|
|
2704
2775
|
}
|
|
2705
2776
|
|