@checkstack/healthcheck-backend 1.20.0 → 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 +179 -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,205 @@
|
|
|
1
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
2
|
+
import type { AuthUser, AuthService } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
healthCheckAccess,
|
|
5
|
+
healthCheckResourceTypes,
|
|
6
|
+
pluginMetadata,
|
|
7
|
+
} from "@checkstack/healthcheck-common";
|
|
8
|
+
import { catalogResourceTypes } from "@checkstack/catalog-common";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Authorization for the configuration-centric assignment reads
|
|
12
|
+
* (`getConfigurationAssignments`, and the relaxed `getConfiguration`). Their
|
|
13
|
+
* contract `access` is deliberately empty - the rule is an OR across TWO grant
|
|
14
|
+
* planes (a team grant on the CONFIGURATION, or read access to an assigned
|
|
15
|
+
* SYSTEM) that the middleware's instanceAccess modes cannot express - so THIS
|
|
16
|
+
* module is the authorization for them. Sibling of `history-access.ts`, which
|
|
17
|
+
* does the same for the run-history procs at manage level.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const QUALIFIED_READ_RULE = qualifyAccessRuleId(
|
|
21
|
+
pluginMetadata,
|
|
22
|
+
healthCheckAccess.configuration.read,
|
|
23
|
+
);
|
|
24
|
+
const QUALIFIED_MANAGE_RULE = qualifyAccessRuleId(
|
|
25
|
+
pluginMetadata,
|
|
26
|
+
healthCheckAccess.configuration.manage,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The parent (catalog.system) global rules. Follows the middleware's
|
|
31
|
+
* parentScope convention: authorizing via a parent consults the parent's own
|
|
32
|
+
* grants AND its global `{resourceType}.{action}` rule. Manage implies read.
|
|
33
|
+
*/
|
|
34
|
+
const QUALIFIED_SYSTEM_READ_RULE = `${catalogResourceTypes.system}.read`;
|
|
35
|
+
const QUALIFIED_SYSTEM_MANAGE_RULE = `${catalogResourceTypes.system}.manage`;
|
|
36
|
+
|
|
37
|
+
/** A user/application principal (the only kinds that can hold team grants). */
|
|
38
|
+
type GrantHolder = AuthUser & { type: "user" | "application" };
|
|
39
|
+
|
|
40
|
+
const isGrantHolder = (user: AuthUser): user is GrantHolder =>
|
|
41
|
+
user.type === "user" || user.type === "application";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Does the caller hold UNRESTRICTED configuration read access? True for
|
|
45
|
+
* wildcard admins, global `configuration.read` OR `configuration.manage`
|
|
46
|
+
* holders (manage implies read), and trusted services (same stance as the
|
|
47
|
+
* middleware).
|
|
48
|
+
*/
|
|
49
|
+
export function hasGlobalConfigurationRead(
|
|
50
|
+
user: AuthUser | undefined,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (!user) return false;
|
|
53
|
+
if (user.type === "service") return true;
|
|
54
|
+
const rules = user.accessRules ?? [];
|
|
55
|
+
return (
|
|
56
|
+
rules.includes("*") ||
|
|
57
|
+
rules.includes(QUALIFIED_READ_RULE) ||
|
|
58
|
+
rules.includes(QUALIFIED_MANAGE_RULE)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The outcome of authorizing the assignment-row read for the caller. */
|
|
63
|
+
export type AssignmentRowScope =
|
|
64
|
+
/** Global read (or wildcard/service/config grant): every row is visible. */
|
|
65
|
+
| { kind: "all" }
|
|
66
|
+
/**
|
|
67
|
+
* System-scoped caller: restrict rows to the systems their teams may READ
|
|
68
|
+
* (a system's team sees which checks run on their system).
|
|
69
|
+
*/
|
|
70
|
+
| { kind: "scoped"; systemIds: string[] }
|
|
71
|
+
/** No global rule and no grant of either kind: the read is forbidden. */
|
|
72
|
+
| { kind: "forbidden" };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pure decision half of the row authorization: given the caller, whether they
|
|
76
|
+
* hold a team grant on the configuration, and the system ids their teams may
|
|
77
|
+
* read, decide the row scope. Split from the S2S lookups so the branching is
|
|
78
|
+
* unit-testable without an auth service.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveAssignmentRowScope({
|
|
81
|
+
user,
|
|
82
|
+
hasConfigurationGrant,
|
|
83
|
+
readableSystemIds,
|
|
84
|
+
}: {
|
|
85
|
+
user: AuthUser | undefined;
|
|
86
|
+
hasConfigurationGrant: boolean;
|
|
87
|
+
readableSystemIds: string[];
|
|
88
|
+
}): AssignmentRowScope {
|
|
89
|
+
if (!user) return { kind: "forbidden" };
|
|
90
|
+
if (hasGlobalConfigurationRead(user)) return { kind: "all" };
|
|
91
|
+
if (hasConfigurationGrant) return { kind: "all" };
|
|
92
|
+
if (readableSystemIds.length === 0) return { kind: "forbidden" };
|
|
93
|
+
return { kind: "scoped", systemIds: readableSystemIds };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Does the caller's team hold a READ grant on the configuration itself?
|
|
98
|
+
* FAILS CLOSED: an S2S error yields `false`, i.e. narrower access, never
|
|
99
|
+
* wider.
|
|
100
|
+
*/
|
|
101
|
+
export async function hasConfigurationReadGrant({
|
|
102
|
+
auth,
|
|
103
|
+
user,
|
|
104
|
+
configurationId,
|
|
105
|
+
}: {
|
|
106
|
+
auth: AuthService;
|
|
107
|
+
user: AuthUser;
|
|
108
|
+
configurationId: string;
|
|
109
|
+
}): Promise<boolean> {
|
|
110
|
+
if (!isGrantHolder(user)) return false;
|
|
111
|
+
try {
|
|
112
|
+
const result = await auth.check({
|
|
113
|
+
userId: user.id,
|
|
114
|
+
userType: user.type,
|
|
115
|
+
objectType: healthCheckResourceTypes.configuration,
|
|
116
|
+
objectId: configurationId,
|
|
117
|
+
action: "read",
|
|
118
|
+
hasGlobalAccess: false,
|
|
119
|
+
});
|
|
120
|
+
return result.hasAccess;
|
|
121
|
+
} catch {
|
|
122
|
+
// SECURITY: fail closed - an auth outage must not widen access.
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve which of `allSystemIds` the caller may READ - via a team grant on
|
|
129
|
+
* the system, or via the global `catalog.system.read`/`.manage` rule (the
|
|
130
|
+
* parentScope convention). FAILS CLOSED on S2S errors.
|
|
131
|
+
*/
|
|
132
|
+
export async function listReadableSystemIds({
|
|
133
|
+
auth,
|
|
134
|
+
user,
|
|
135
|
+
allSystemIds,
|
|
136
|
+
}: {
|
|
137
|
+
auth: AuthService;
|
|
138
|
+
user: AuthUser;
|
|
139
|
+
allSystemIds: string[];
|
|
140
|
+
}): Promise<string[]> {
|
|
141
|
+
if (!isGrantHolder(user)) return [];
|
|
142
|
+
if (allSystemIds.length === 0) return [];
|
|
143
|
+
const rules = user.accessRules ?? [];
|
|
144
|
+
if (
|
|
145
|
+
rules.includes(QUALIFIED_SYSTEM_READ_RULE) ||
|
|
146
|
+
rules.includes(QUALIFIED_SYSTEM_MANAGE_RULE)
|
|
147
|
+
) {
|
|
148
|
+
return allSystemIds;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
return await auth.listAccessibleObjectIds({
|
|
152
|
+
userId: user.id,
|
|
153
|
+
userType: user.type,
|
|
154
|
+
objectType: catalogResourceTypes.system,
|
|
155
|
+
objectIds: allSystemIds,
|
|
156
|
+
action: "read",
|
|
157
|
+
hasGlobalAccess: false,
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
// SECURITY: fail closed - an auth outage must not widen access.
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Per-object check for the relaxed single-configuration read: the caller may
|
|
167
|
+
* read the configuration iff they hold global configuration read, a team READ
|
|
168
|
+
* grant on the configuration, or READ access to at least one system the
|
|
169
|
+
* configuration is assigned to (a system's team may inspect the checks that
|
|
170
|
+
* run on their system - the same exposure `getSystemConfigurations` already
|
|
171
|
+
* allows). `getAssignedSystemIds` is lazy so the assignment lookup only runs
|
|
172
|
+
* when the cheaper checks did not already decide. FAILS CLOSED on S2S errors.
|
|
173
|
+
*/
|
|
174
|
+
export async function canReadConfigurationScope({
|
|
175
|
+
auth,
|
|
176
|
+
user,
|
|
177
|
+
configurationId,
|
|
178
|
+
getAssignedSystemIds,
|
|
179
|
+
}: {
|
|
180
|
+
auth: AuthService;
|
|
181
|
+
user: AuthUser | undefined;
|
|
182
|
+
configurationId: string;
|
|
183
|
+
getAssignedSystemIds: () => Promise<string[]>;
|
|
184
|
+
}): Promise<boolean> {
|
|
185
|
+
if (!user) return false;
|
|
186
|
+
if (hasGlobalConfigurationRead(user)) return true;
|
|
187
|
+
if (!isGrantHolder(user)) return false;
|
|
188
|
+
|
|
189
|
+
if (await hasConfigurationReadGrant({ auth, user, configurationId })) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const assignedSystemIds = await getAssignedSystemIds();
|
|
195
|
+
const readable = await listReadableSystemIds({
|
|
196
|
+
auth,
|
|
197
|
+
user,
|
|
198
|
+
allSystemIds: assignedSystemIds,
|
|
199
|
+
});
|
|
200
|
+
return readable.length > 0;
|
|
201
|
+
} catch {
|
|
202
|
+
// SECURITY: fail closed - an auth outage must not widen access.
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -83,6 +83,29 @@ describe("buildShellRunContextEnv", () => {
|
|
|
83
83
|
});
|
|
84
84
|
expect(env.CHECKSTACK_ENV_BASE_URL).toBe("first");
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
test("emits CHECKSTACK_SYSTEM_<FIELD> vars for the system's custom fields", () => {
|
|
88
|
+
const env = buildShellRunContextEnv({
|
|
89
|
+
system: {
|
|
90
|
+
id: "s1",
|
|
91
|
+
name: "web-1",
|
|
92
|
+
metadata: { baseUrl: "https://sys.example.com", tier: "1" },
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
expect(env).toEqual({
|
|
96
|
+
CHECKSTACK_SYSTEM_ID: "s1",
|
|
97
|
+
CHECKSTACK_SYSTEM_NAME: "web-1",
|
|
98
|
+
CHECKSTACK_SYSTEM_BASE_URL: "https://sys.example.com",
|
|
99
|
+
CHECKSTACK_SYSTEM_TIER: "1",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a system field named id/name cannot clobber the structural var", () => {
|
|
104
|
+
const env = buildShellRunContextEnv({
|
|
105
|
+
system: { id: "s1", name: "web-1", metadata: { name: "nope" } },
|
|
106
|
+
});
|
|
107
|
+
expect(env.CHECKSTACK_SYSTEM_NAME).toBe("web-1");
|
|
108
|
+
});
|
|
86
109
|
});
|
|
87
110
|
|
|
88
111
|
describe("buildCollectorContext", () => {
|
|
@@ -126,6 +149,20 @@ describe("buildCollectorContext", () => {
|
|
|
126
149
|
},
|
|
127
150
|
});
|
|
128
151
|
});
|
|
152
|
+
|
|
153
|
+
test("passes the system's metadata through to context.system", () => {
|
|
154
|
+
const ctx = buildCollectorContext({
|
|
155
|
+
config: {},
|
|
156
|
+
runContext: {
|
|
157
|
+
system: { id: "s1", name: "web-1", metadata: { baseUrl: "https://s" } },
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
expect(ctx.system).toEqual({
|
|
161
|
+
id: "s1",
|
|
162
|
+
name: "web-1",
|
|
163
|
+
metadata: { baseUrl: "https://s" },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
129
166
|
});
|
|
130
167
|
|
|
131
168
|
describe("runCollectorScriptTest — typescript", () => {
|
|
@@ -32,7 +32,13 @@ export type CollectorScriptTestKind = "typescript" | "shell";
|
|
|
32
32
|
/** Curated check/system/environment metadata a collector script can read. */
|
|
33
33
|
export interface CollectorTestRunContext {
|
|
34
34
|
check?: { id: string; name: string; intervalSeconds: number };
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The system being checked. `metadata` is its free-form custom fields,
|
|
37
|
+
* mirroring the runtime `CollectorRunContext.system` so the test panel
|
|
38
|
+
* previews the `CHECKSTACK_SYSTEM_*` / `context.system.metadata` surface the
|
|
39
|
+
* real run exposes.
|
|
40
|
+
*/
|
|
41
|
+
system?: { id: string; name: string; metadata?: Record<string, unknown> };
|
|
36
42
|
/**
|
|
37
43
|
* The resolved environment for the previewed run. `fields` is the
|
|
38
44
|
* environment's free-form custom metadata. Mirrors the runtime
|
|
@@ -85,22 +91,49 @@ export interface CollectorScriptTestDeps {
|
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
const CHECKSTACK_ENV_PREFIX = "CHECKSTACK_ENV_";
|
|
94
|
+
const CHECKSTACK_SYSTEM_PREFIX = "CHECKSTACK_SYSTEM_";
|
|
88
95
|
|
|
89
96
|
/**
|
|
90
|
-
* Derive the
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
97
|
+
* Derive the `<prefix><KEY>` shell var name for a custom field key. Mirrors
|
|
98
|
+
* `toFieldShellKey` in `@checkstack/healthcheck-script-backend` (kept local -
|
|
99
|
+
* we don't import across plugins) so the test panel and the real run produce
|
|
100
|
+
* identical var names. Splits camelCase, uppercases, collapses non-alphanumeric
|
|
101
|
+
* runs to `_`, trims leading/trailing `_` using a ReDoS-safe negative
|
|
102
|
+
* look-behind.
|
|
96
103
|
*/
|
|
97
|
-
function
|
|
104
|
+
function toFieldShellKey(key: string, prefix: string): string {
|
|
98
105
|
const normalized = key
|
|
99
106
|
.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
100
107
|
.toUpperCase()
|
|
101
108
|
.replaceAll(/[^A-Z0-9]+/g, "_")
|
|
102
109
|
.replaceAll(/^_+|(?<!_)_+$/g, "");
|
|
103
|
-
return `${
|
|
110
|
+
return `${prefix}${normalized}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Expand a set of custom fields into `<prefix><KEY>` shell vars, mirroring
|
|
115
|
+
* `buildFieldShellEnv` in the script plugin: skip empty-normalized names, skip
|
|
116
|
+
* the reserved structural id/name vars, first-key-wins on collisions.
|
|
117
|
+
*/
|
|
118
|
+
function addFieldShellVars({
|
|
119
|
+
env,
|
|
120
|
+
fields,
|
|
121
|
+
prefix,
|
|
122
|
+
}: {
|
|
123
|
+
env: Record<string, string>;
|
|
124
|
+
fields: Record<string, unknown>;
|
|
125
|
+
prefix: string;
|
|
126
|
+
}): void {
|
|
127
|
+
const reserved = new Set([`${prefix}ID`, `${prefix}NAME`]);
|
|
128
|
+
const claimed = new Set<string>();
|
|
129
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
130
|
+
const shellKey = toFieldShellKey(key, prefix);
|
|
131
|
+
if (shellKey === prefix || reserved.has(shellKey) || claimed.has(shellKey)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
env[shellKey] = stringifyFieldValue(value);
|
|
135
|
+
claimed.add(shellKey);
|
|
136
|
+
}
|
|
104
137
|
}
|
|
105
138
|
|
|
106
139
|
/** Stringify a custom-field value for a shell env var. */
|
|
@@ -135,17 +168,22 @@ export function buildShellRunContextEnv(
|
|
|
135
168
|
if (runContext?.system) {
|
|
136
169
|
env.CHECKSTACK_SYSTEM_ID = runContext.system.id;
|
|
137
170
|
env.CHECKSTACK_SYSTEM_NAME = runContext.system.name;
|
|
171
|
+
if (runContext.system.metadata) {
|
|
172
|
+
addFieldShellVars({
|
|
173
|
+
env,
|
|
174
|
+
fields: runContext.system.metadata,
|
|
175
|
+
prefix: CHECKSTACK_SYSTEM_PREFIX,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
138
178
|
}
|
|
139
179
|
if (runContext?.environment) {
|
|
140
180
|
env.CHECKSTACK_ENV_ID = runContext.environment.id;
|
|
141
181
|
env.CHECKSTACK_ENV_NAME = runContext.environment.name;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
claimed.add(shellKey);
|
|
148
|
-
}
|
|
182
|
+
addFieldShellVars({
|
|
183
|
+
env,
|
|
184
|
+
fields: runContext.environment.fields,
|
|
185
|
+
prefix: CHECKSTACK_ENV_PREFIX,
|
|
186
|
+
});
|
|
149
187
|
}
|
|
150
188
|
return env;
|
|
151
189
|
}
|
|
@@ -347,10 +347,11 @@ describe("Queue-Based Health Check Executor", () => {
|
|
|
347
347
|
const mockIncidentClient = createMockIncidentClient();
|
|
348
348
|
const mockSignalService = createMockSignalService();
|
|
349
349
|
|
|
350
|
-
// Catalog resolves the system name.
|
|
350
|
+
// Catalog resolves the system name + free-form metadata.
|
|
351
351
|
(mockCatalogClient.getSystem as any) = mock(async () => ({
|
|
352
352
|
id: "system-1",
|
|
353
353
|
name: "web-01",
|
|
354
|
+
metadata: { baseUrl: "https://web-01.example.com" },
|
|
354
355
|
}));
|
|
355
356
|
|
|
356
357
|
// configName is null -> run-context check.name must fall back to id.
|
|
@@ -470,7 +471,11 @@ describe("Queue-Based Health Check Executor", () => {
|
|
|
470
471
|
expect(collectorExecute).toHaveBeenCalled();
|
|
471
472
|
expect(capturedRunContext).toEqual({
|
|
472
473
|
check: { id: "config-1", name: "config-1", intervalSeconds: 45 },
|
|
473
|
-
system: {
|
|
474
|
+
system: {
|
|
475
|
+
id: "system-1",
|
|
476
|
+
name: "web-01",
|
|
477
|
+
metadata: { baseUrl: "https://web-01.example.com" },
|
|
478
|
+
},
|
|
474
479
|
});
|
|
475
480
|
});
|
|
476
481
|
|
|
@@ -694,6 +699,7 @@ describe("Queue-Based Health Check Executor", () => {
|
|
|
694
699
|
collectorConfig = {},
|
|
695
700
|
collectorConfigSchema = z.object({}),
|
|
696
701
|
failCatalogResolution = false,
|
|
702
|
+
systemMetadata = {},
|
|
697
703
|
}: {
|
|
698
704
|
/** The env this job runs (`null` = an env-less job). */
|
|
699
705
|
payloadEnvironmentId: string | null;
|
|
@@ -710,9 +716,11 @@ describe("Queue-Based Health Check Executor", () => {
|
|
|
710
716
|
collectorConfigSchema?: z.ZodType<unknown>;
|
|
711
717
|
/** When true, the catalog membership read REJECTS (fail-open path). */
|
|
712
718
|
failCatalogResolution?: boolean;
|
|
719
|
+
/** The system's free-form catalog metadata (`{{ system.metadata.* }}`). */
|
|
720
|
+
systemMetadata?: Record<string, unknown>;
|
|
713
721
|
}): Promise<{
|
|
714
722
|
/** Run-context captured for the single run (empty when skipped). */
|
|
715
|
-
runs: Array<{ environment?: unknown; config?: unknown }>;
|
|
723
|
+
runs: Array<{ environment?: unknown; system?: unknown; config?: unknown }>;
|
|
716
724
|
/** Payloads broadcast on `healthcheck.run.completed`, in order. */
|
|
717
725
|
runCompletedPayloads: Array<Record<string, unknown>>;
|
|
718
726
|
}> {
|
|
@@ -728,6 +736,7 @@ describe("Queue-Based Health Check Executor", () => {
|
|
|
728
736
|
(mockCatalogClient.getSystem as any) = mock(async () => ({
|
|
729
737
|
id: "system-1",
|
|
730
738
|
name: "web-01",
|
|
739
|
+
metadata: systemMetadata,
|
|
731
740
|
}));
|
|
732
741
|
(mockCatalogClient as any).resolveSystemEnvironments = mock(async () => {
|
|
733
742
|
if (failCatalogResolution) throw new Error("catalog unavailable");
|
|
@@ -972,6 +981,51 @@ describe("Queue-Based Health Check Executor", () => {
|
|
|
972
981
|
expect((captured[0]?.config as { url: string }).url).toBe("/healthz");
|
|
973
982
|
});
|
|
974
983
|
|
|
984
|
+
it("renders {{ system.metadata.<key> }} from the system's catalog custom fields", async () => {
|
|
985
|
+
const { runs: captured } = await runSingleEnv({
|
|
986
|
+
payloadEnvironmentId: null,
|
|
987
|
+
environmentIds: [],
|
|
988
|
+
membership: [{ id: "prod", name: "Production", metadata: {} }],
|
|
989
|
+
systemMetadata: { baseUrl: "https://sys.example.com" },
|
|
990
|
+
collectorConfig: { url: "{{ system.metadata.baseUrl }}/healthz" },
|
|
991
|
+
collectorConfigSchema: z.object({
|
|
992
|
+
url: configString({ "x-templatable": true }),
|
|
993
|
+
}),
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
expect(captured).toHaveLength(1);
|
|
997
|
+
expect((captured[0]?.config as { url: string }).url).toBe(
|
|
998
|
+
"https://sys.example.com/healthz",
|
|
999
|
+
);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("namespaces metadata under .metadata so a custom field cannot shadow system.name", async () => {
|
|
1003
|
+
// A metadata key literally named `name` must NOT override the structural
|
|
1004
|
+
// `{{ system.name }}`; it is only reachable at `{{ system.metadata.name }}`.
|
|
1005
|
+
const { runs: captured } = await runSingleEnv({
|
|
1006
|
+
payloadEnvironmentId: null,
|
|
1007
|
+
environmentIds: [],
|
|
1008
|
+
membership: [{ id: "prod", name: "Production", metadata: {} }],
|
|
1009
|
+
systemMetadata: { name: "shadow-attempt" },
|
|
1010
|
+
collectorConfig: {
|
|
1011
|
+
structural: "{{ system.name }}",
|
|
1012
|
+
custom: "{{ system.metadata.name }}",
|
|
1013
|
+
},
|
|
1014
|
+
collectorConfigSchema: z.object({
|
|
1015
|
+
structural: configString({ "x-templatable": true }),
|
|
1016
|
+
custom: configString({ "x-templatable": true }),
|
|
1017
|
+
}),
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
expect(captured).toHaveLength(1);
|
|
1021
|
+
const config = captured[0]?.config as {
|
|
1022
|
+
structural: string;
|
|
1023
|
+
custom: string;
|
|
1024
|
+
};
|
|
1025
|
+
expect(config.structural).toBe("web-01");
|
|
1026
|
+
expect(config.custom).toBe("shadow-attempt");
|
|
1027
|
+
});
|
|
1028
|
+
|
|
975
1029
|
it("runs the explicit-subset environment the payload targets", async () => {
|
|
976
1030
|
const { runs: captured } = await runSingleEnv({
|
|
977
1031
|
payloadEnvironmentId: "staging",
|
package/src/queue-executor.ts
CHANGED
|
@@ -767,12 +767,16 @@ async function executeHealthCheckJob(props: {
|
|
|
767
767
|
return;
|
|
768
768
|
}
|
|
769
769
|
|
|
770
|
-
// Fetch system name for signal payload
|
|
770
|
+
// Fetch system name + metadata for signal payload and run-context. The
|
|
771
|
+
// metadata is the system's free-form catalog custom fields, surfaced to
|
|
772
|
+
// config templating as `{{ system.metadata.<key> }}`.
|
|
771
773
|
let systemName = systemId;
|
|
774
|
+
let systemMetadata: Record<string, unknown> = {};
|
|
772
775
|
try {
|
|
773
776
|
const system = await catalogClient.getSystem({ systemId });
|
|
774
777
|
if (system) {
|
|
775
778
|
systemName = system.name;
|
|
779
|
+
systemMetadata = system.metadata ?? {};
|
|
776
780
|
}
|
|
777
781
|
} catch {
|
|
778
782
|
// Fall back to systemId if catalog lookup fails
|
|
@@ -980,7 +984,7 @@ async function executeHealthCheckJob(props: {
|
|
|
980
984
|
name: configRow.configName || configId,
|
|
981
985
|
intervalSeconds: configRow.interval,
|
|
982
986
|
},
|
|
983
|
-
system: { id: systemId, name: systemName },
|
|
987
|
+
system: { id: systemId, name: systemName, metadata: systemMetadata },
|
|
984
988
|
...(environment
|
|
985
989
|
? {
|
|
986
990
|
environment: {
|