@thotischner/observability-mcp 1.8.1 → 3.0.1
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/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +9 -0
- package/dist/audit/log.js +20 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/endpoints.js +44 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +144 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +55 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +54 -0
- package/dist/federation/registry.js +122 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +206 -0
- package/dist/federation/upstream.d.ts +86 -0
- package/dist/federation/upstream.js +162 -0
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +1435 -126
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +215 -7
- package/dist/openapi.test.js +34 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +40 -0
- package/dist/scim/routes.js +395 -0
- package/dist/scim/store.d.ts +76 -0
- package/dist/scim/store.js +196 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +15 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +26 -5
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +33 -11
- package/dist/tools/topology.test.js +45 -0
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +2529 -145
- package/package.json +13 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
|
|
1
|
+
import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, TraceQuery, TraceResult, SourceConfig, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener } from "../types.js";
|
|
2
2
|
export interface ObservabilityConnector {
|
|
3
3
|
readonly name: string;
|
|
4
4
|
readonly type: string;
|
|
@@ -14,6 +14,10 @@ export interface ObservabilityConnector {
|
|
|
14
14
|
listAvailableMetrics?(service: string): Promise<MetricInfo[]>;
|
|
15
15
|
queryMetrics?(params: MetricQuery): Promise<MetricResult>;
|
|
16
16
|
queryLogs?(params: LogQuery): Promise<LogResult>;
|
|
17
|
+
/** Optional traces capability — Tempo / Jaeger / OTLP backends
|
|
18
|
+
* implement this. The MCP `query_traces` tool fans out to every
|
|
19
|
+
* connector that has it. */
|
|
20
|
+
queryTraces?(params: TraceQuery): Promise<TraceResult>;
|
|
17
21
|
/** Current in-memory resource list. Should be O(1) — backed by the watch cache. */
|
|
18
22
|
listResources?(): Promise<Resource[]>;
|
|
19
23
|
/** Current in-memory edge list. Should be O(1) — backed by the watch cache. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ObservabilityConnector } from "./interface.js";
|
|
2
2
|
import type { ConnectorFactory, ConnectorManifest } from "../sdk/index.js";
|
|
3
|
+
import type { HookRegistry } from "../sdk/hooks.js";
|
|
3
4
|
export interface LoadedConnector {
|
|
4
5
|
/** Connector type id, e.g. "prometheus". Matches `source.type` in sources.yaml. */
|
|
5
6
|
name: string;
|
|
@@ -26,11 +27,18 @@ export declare class PluginLoader {
|
|
|
26
27
|
private verify;
|
|
27
28
|
private trustRootPath?;
|
|
28
29
|
private trustRoot?;
|
|
30
|
+
/** Optional HookRegistry — when set, the loader auto-registers
|
|
31
|
+
* every entry in `manifest.hooks[]` after the plugin loads, and
|
|
32
|
+
* unregisters them when a same-name plugin replaces it. Hooks
|
|
33
|
+
* re-registered by name+kind on hot-reload (HookRegistry.register
|
|
34
|
+
* already deduplicates). */
|
|
35
|
+
private hookRegistry?;
|
|
29
36
|
constructor(opts?: {
|
|
30
37
|
pluginsDir?: string;
|
|
31
38
|
disabled?: string[];
|
|
32
39
|
verify?: boolean;
|
|
33
40
|
trustRoot?: string;
|
|
41
|
+
hookRegistry?: HookRegistry;
|
|
34
42
|
});
|
|
35
43
|
load(): Promise<void>;
|
|
36
44
|
list(): LoadedConnector[];
|
|
@@ -23,23 +23,32 @@ export class PluginLoader {
|
|
|
23
23
|
pluginsDir;
|
|
24
24
|
disabled;
|
|
25
25
|
// Fail-closed verification for filesystem plugins. Builtins are part
|
|
26
|
-
// of the trusted image and are never gated. Default
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// of the trusted image and are never gated. Default ON — operators
|
|
27
|
+
// who want to load unsigned filesystem plugins must opt out with
|
|
28
|
+
// VERIFY_PLUGINS=false. Without a trust root configured, no
|
|
29
|
+
// filesystem plugins load (only builtins), so the demo and any
|
|
30
|
+
// deployment without /app/plugins is unaffected.
|
|
29
31
|
verify;
|
|
30
32
|
trustRootPath;
|
|
31
33
|
trustRoot;
|
|
34
|
+
/** Optional HookRegistry — when set, the loader auto-registers
|
|
35
|
+
* every entry in `manifest.hooks[]` after the plugin loads, and
|
|
36
|
+
* unregisters them when a same-name plugin replaces it. Hooks
|
|
37
|
+
* re-registered by name+kind on hot-reload (HookRegistry.register
|
|
38
|
+
* already deduplicates). */
|
|
39
|
+
hookRegistry;
|
|
32
40
|
constructor(opts = {}) {
|
|
33
41
|
this.pluginsDir = opts.pluginsDir
|
|
34
42
|
?? process.env.PLUGINS_DIR
|
|
35
43
|
?? "/app/plugins";
|
|
44
|
+
this.hookRegistry = opts.hookRegistry;
|
|
36
45
|
// Per-plugin disable via env: PLUGINS_DISABLED="prometheus,loki"
|
|
37
46
|
const envDisabled = (process.env.PLUGINS_DISABLED ?? "")
|
|
38
47
|
.split(",")
|
|
39
48
|
.map((s) => s.trim())
|
|
40
49
|
.filter(Boolean);
|
|
41
50
|
this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
|
|
42
|
-
this.verify = opts.verify ??
|
|
51
|
+
this.verify = opts.verify ?? !/^(0|false|no|off)$/i.test(process.env.VERIFY_PLUGINS ?? "true");
|
|
43
52
|
this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
|
|
44
53
|
}
|
|
45
54
|
async load() {
|
|
@@ -200,6 +209,48 @@ export class PluginLoader {
|
|
|
200
209
|
manifest,
|
|
201
210
|
factory,
|
|
202
211
|
});
|
|
212
|
+
// Manifest-driven hook auto-registration (Q10). After the
|
|
213
|
+
// entry module loads (and is integrity/sig-verified above), walk
|
|
214
|
+
// manifest.hooks[] and resolve each entry's `module` against the
|
|
215
|
+
// plugin root. Default export is the handler. Errors during
|
|
216
|
+
// individual hook load are logged + skipped — they don't tear
|
|
217
|
+
// down the connector itself.
|
|
218
|
+
if (this.hookRegistry && manifest?.hooks?.length) {
|
|
219
|
+
// Drop any prior registrations for this plugin so a hot-reload
|
|
220
|
+
// doesn't leave stale entries side-by-side with new ones.
|
|
221
|
+
this.hookRegistry.unregisterPlugin(marker.name);
|
|
222
|
+
for (const hookEntry of manifest.hooks) {
|
|
223
|
+
const hookPath = resolve(pluginRoot, hookEntry.module);
|
|
224
|
+
const inside = hookPath.startsWith(resolve(pluginRoot) + "/") || hookPath === resolve(pluginRoot);
|
|
225
|
+
if (!inside) {
|
|
226
|
+
console.warn("Plugin %s hook module %s escapes the plugin root — skipping", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.module));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!existsSync(hookPath)) {
|
|
230
|
+
console.warn("Plugin %s hook module %s not found — skipping", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.module));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const hookMod = await import(pathToFileURL(hookPath).href);
|
|
235
|
+
const handler = hookMod.default ?? hookMod.handler;
|
|
236
|
+
if (typeof handler !== "function") {
|
|
237
|
+
console.warn("Plugin %s hook module %s has no default export — skipping", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.module));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
this.hookRegistry.register({
|
|
241
|
+
pluginName: marker.name,
|
|
242
|
+
kind: hookEntry.kind,
|
|
243
|
+
priority: hookEntry.priority,
|
|
244
|
+
mode: hookEntry.mode,
|
|
245
|
+
handler: handler,
|
|
246
|
+
});
|
|
247
|
+
console.log('Plugin "%s": registered %s hook from %s', sanitizeForLog(marker.name), sanitizeForLog(hookEntry.kind), sanitizeForLog(hookEntry.module));
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
console.warn("Plugin %s hook %s/%s failed to load: %s", sanitizeForLog(marker.name), sanitizeForLog(hookEntry.kind), sanitizeForLog(hookEntry.module), sanitizeForLog(err instanceof Error ? err.message : String(err)));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
203
254
|
console.log('Connector plugin "%s" loaded from %s', sanitizeForLog(marker.name), sanitizeForLog(pluginRoot));
|
|
204
255
|
}
|
|
205
256
|
register(entry) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { PluginLoader } from "./loader.js";
|
|
7
|
+
function tmp() {
|
|
8
|
+
return mkdtempSync(join(tmpdir(), "loader-default-"));
|
|
9
|
+
}
|
|
10
|
+
function withEnv(overrides, fn) {
|
|
11
|
+
const saved = {};
|
|
12
|
+
for (const k of Object.keys(overrides)) {
|
|
13
|
+
saved[k] = process.env[k];
|
|
14
|
+
if (overrides[k] === undefined)
|
|
15
|
+
delete process.env[k];
|
|
16
|
+
else
|
|
17
|
+
process.env[k] = overrides[k];
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
fn();
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
for (const k of Object.keys(saved)) {
|
|
24
|
+
if (saved[k] === undefined)
|
|
25
|
+
delete process.env[k];
|
|
26
|
+
else
|
|
27
|
+
process.env[k] = saved[k];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
test("PluginLoader: VERIFY_PLUGINS defaults to ON when env var unset", () => {
|
|
32
|
+
withEnv({ VERIFY_PLUGINS: undefined, PLUGIN_TRUST_ROOT: undefined }, () => {
|
|
33
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
34
|
+
assert.equal(loader["verify"], true, "verify default should be true (fail-closed)");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
test("PluginLoader: VERIFY_PLUGINS=false opts out explicitly", () => {
|
|
38
|
+
withEnv({ VERIFY_PLUGINS: "false" }, () => {
|
|
39
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
40
|
+
assert.equal(loader["verify"], false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
test("PluginLoader: VERIFY_PLUGINS=0 / no / off also opt out", () => {
|
|
44
|
+
for (const v of ["0", "no", "off", "FALSE", "Off"]) {
|
|
45
|
+
withEnv({ VERIFY_PLUGINS: v }, () => {
|
|
46
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
47
|
+
assert.equal(loader["verify"], false, `value ${v} should disable verify`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
test("PluginLoader: VERIFY_PLUGINS=true / 1 / yes keep verify on", () => {
|
|
52
|
+
for (const v of ["true", "1", "yes", "TRUE", "Yes"]) {
|
|
53
|
+
withEnv({ VERIFY_PLUGINS: v }, () => {
|
|
54
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
55
|
+
assert.equal(loader["verify"], true);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
test("PluginLoader: opts.verify overrides env var", () => {
|
|
60
|
+
withEnv({ VERIFY_PLUGINS: "false" }, () => {
|
|
61
|
+
const onLoader = new PluginLoader({ pluginsDir: tmp(), verify: true });
|
|
62
|
+
assert.equal(onLoader["verify"], true);
|
|
63
|
+
});
|
|
64
|
+
withEnv({ VERIFY_PLUGINS: "true" }, () => {
|
|
65
|
+
const offLoader = new PluginLoader({ pluginsDir: tmp(), verify: false });
|
|
66
|
+
assert.equal(offLoader["verify"], false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
test("PluginLoader.load(): with verify on + no trust root → builtins still load, filesystem skipped", async () => {
|
|
70
|
+
withEnv({ VERIFY_PLUGINS: undefined, PLUGIN_TRUST_ROOT: undefined }, async () => {
|
|
71
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
72
|
+
await loader.load();
|
|
73
|
+
const names = loader.supportedTypes();
|
|
74
|
+
assert.ok(names.includes("prometheus"), "prometheus builtin must remain available");
|
|
75
|
+
assert.ok(names.includes("loki"), "loki builtin must remain available");
|
|
76
|
+
assert.ok(names.includes("kubernetes"), "kubernetes builtin must remain available");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Manifest-driven hook auto-registration (Q10).
|
|
2
|
+
//
|
|
3
|
+
// Stages a synthetic plugin directory with:
|
|
4
|
+
// - package.json + manifest.json declaring hooks[]
|
|
5
|
+
// - index.js exporting a no-op connector factory
|
|
6
|
+
// - hooks/<kind>.js modules exporting handler defaults
|
|
7
|
+
// Runs PluginLoader (with VERIFY_PLUGINS off — we test the
|
|
8
|
+
// hook wiring, not the trust-root path) and asserts the
|
|
9
|
+
// HookRegistry now has the entries the manifest declared.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { PluginLoader } from "./loader.js";
|
|
16
|
+
import { HookRegistry } from "../sdk/hooks.js";
|
|
17
|
+
function stagePlugin(opts) {
|
|
18
|
+
const stage = mkdtempSync(join(tmpdir(), "omcp-plugin-hooks-"));
|
|
19
|
+
const root = join(stage, opts.name);
|
|
20
|
+
mkdirSync(root, { recursive: true });
|
|
21
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({
|
|
22
|
+
name: `@observability-mcp/connector-${opts.name}`,
|
|
23
|
+
observabilityMcp: { kind: "connector", name: opts.name, manifest: "./manifest.json" },
|
|
24
|
+
main: "./index.js",
|
|
25
|
+
}));
|
|
26
|
+
writeFileSync(join(root, "manifest.json"), JSON.stringify({
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
name: opts.name,
|
|
29
|
+
displayName: opts.name,
|
|
30
|
+
version: "1.0.0",
|
|
31
|
+
description: "test plugin",
|
|
32
|
+
signalTypes: ["topology"],
|
|
33
|
+
capabilities: { listServices: true },
|
|
34
|
+
compat: { serverVersion: ">=3.0.0" },
|
|
35
|
+
hooks: opts.hooks?.map((h) => ({
|
|
36
|
+
kind: h.kind,
|
|
37
|
+
module: h.module,
|
|
38
|
+
priority: h.priority,
|
|
39
|
+
mode: h.mode,
|
|
40
|
+
})),
|
|
41
|
+
}));
|
|
42
|
+
// Tiny no-op connector factory.
|
|
43
|
+
writeFileSync(join(root, "index.js"), opts.indexJs ??
|
|
44
|
+
`export default function create() {
|
|
45
|
+
return {
|
|
46
|
+
type: "${opts.name}",
|
|
47
|
+
signalType: "topology",
|
|
48
|
+
name: "${opts.name}",
|
|
49
|
+
async connect() {},
|
|
50
|
+
async healthCheck() { return { status: "down", latencyMs: 0 }; },
|
|
51
|
+
async disconnect() {},
|
|
52
|
+
getDefaultMetrics() { return []; },
|
|
53
|
+
getMetrics() { return []; },
|
|
54
|
+
async listServices() { return []; },
|
|
55
|
+
};
|
|
56
|
+
}`);
|
|
57
|
+
// Hook modules — each writes a marker into the global for assertions.
|
|
58
|
+
for (const h of opts.hooks ?? []) {
|
|
59
|
+
const hookPath = join(root, h.module);
|
|
60
|
+
mkdirSync(join(hookPath, ".."), { recursive: true });
|
|
61
|
+
writeFileSync(hookPath, h.body ??
|
|
62
|
+
`export default async function handler(ctx, payload) {
|
|
63
|
+
globalThis.__omcp_test_hook_calls = (globalThis.__omcp_test_hook_calls ?? []);
|
|
64
|
+
globalThis.__omcp_test_hook_calls.push({ plugin: ctx.principal, kind: ctx.kind, target: ctx.target });
|
|
65
|
+
return { allow: true, payload };
|
|
66
|
+
}`);
|
|
67
|
+
}
|
|
68
|
+
return stage;
|
|
69
|
+
}
|
|
70
|
+
test("PluginLoader: manifest hooks auto-register on plugin load", async () => {
|
|
71
|
+
const stage = stagePlugin({
|
|
72
|
+
name: "alpha",
|
|
73
|
+
hooks: [
|
|
74
|
+
{ kind: "tool_pre_invoke", module: "hooks/pre.mjs", priority: 50 },
|
|
75
|
+
{ kind: "tool_post_invoke", module: "hooks/post.mjs" },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
const registry = new HookRegistry();
|
|
79
|
+
const loader = new PluginLoader({
|
|
80
|
+
pluginsDir: stage,
|
|
81
|
+
verify: false,
|
|
82
|
+
hookRegistry: registry,
|
|
83
|
+
});
|
|
84
|
+
await loader.load();
|
|
85
|
+
const pre = registry.list("tool_pre_invoke");
|
|
86
|
+
const post = registry.list("tool_post_invoke");
|
|
87
|
+
assert.equal(pre.length, 1);
|
|
88
|
+
assert.equal(pre[0].pluginName, "alpha");
|
|
89
|
+
assert.equal(pre[0].priority, 50);
|
|
90
|
+
assert.equal(post.length, 1);
|
|
91
|
+
assert.equal(post[0].pluginName, "alpha");
|
|
92
|
+
assert.equal(post[0].priority, 100); // default
|
|
93
|
+
});
|
|
94
|
+
test("PluginLoader: hook handlers fire end-to-end through HookRegistry", async () => {
|
|
95
|
+
delete globalThis.__omcp_test_hook_calls;
|
|
96
|
+
const stage = stagePlugin({
|
|
97
|
+
name: "beta",
|
|
98
|
+
hooks: [{ kind: "tool_pre_invoke", module: "hooks/pre.mjs" }],
|
|
99
|
+
});
|
|
100
|
+
const registry = new HookRegistry();
|
|
101
|
+
const loader = new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry });
|
|
102
|
+
await loader.load();
|
|
103
|
+
const result = await registry.fire("tool_pre_invoke", { principal: "alice", tenant: "default", kind: "tool_pre_invoke", target: "tool.x" }, { args: { foo: 1 } });
|
|
104
|
+
assert.equal(result.allow, true);
|
|
105
|
+
const calls = globalThis.__omcp_test_hook_calls;
|
|
106
|
+
assert.ok(calls && calls.length === 1, "hook should have fired once");
|
|
107
|
+
});
|
|
108
|
+
test("PluginLoader: missing hook module is skipped with a warning, others still register", async () => {
|
|
109
|
+
// Stage one good hook normally, then rewrite the manifest to ALSO
|
|
110
|
+
// reference a sibling module path that doesn't exist on disk. The
|
|
111
|
+
// loader must skip the missing one (existsSync branch) and still
|
|
112
|
+
// register the good one.
|
|
113
|
+
const stage = stagePlugin({
|
|
114
|
+
name: "gamma",
|
|
115
|
+
hooks: [{ kind: "tool_post_invoke", module: "hooks/post.mjs" }],
|
|
116
|
+
});
|
|
117
|
+
const manifestPath = join(stage, "gamma", "manifest.json");
|
|
118
|
+
const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
119
|
+
raw.hooks = [
|
|
120
|
+
{ kind: "tool_pre_invoke", module: "hooks/genuinely-missing.mjs" },
|
|
121
|
+
{ kind: "tool_post_invoke", module: "hooks/post.mjs" },
|
|
122
|
+
];
|
|
123
|
+
writeFileSync(manifestPath, JSON.stringify(raw));
|
|
124
|
+
const registry = new HookRegistry();
|
|
125
|
+
await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
|
|
126
|
+
// The missing pre hook was skipped; the good post hook registered.
|
|
127
|
+
assert.equal(registry.list("tool_pre_invoke").length, 0);
|
|
128
|
+
assert.equal(registry.list("tool_post_invoke").length, 1);
|
|
129
|
+
});
|
|
130
|
+
test("PluginLoader: hook module path that escapes the plugin root is rejected", async () => {
|
|
131
|
+
// Stage a plugin whose manifest tries to reference a path with `..`
|
|
132
|
+
// — the loader must refuse to import it.
|
|
133
|
+
const stage = mkdtempSync(join(tmpdir(), "omcp-plugin-escape-"));
|
|
134
|
+
const root = join(stage, "evil");
|
|
135
|
+
mkdirSync(root, { recursive: true });
|
|
136
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({
|
|
137
|
+
name: "@observability-mcp/connector-evil",
|
|
138
|
+
observabilityMcp: { kind: "connector", name: "evil", manifest: "./manifest.json" },
|
|
139
|
+
main: "./index.js",
|
|
140
|
+
}));
|
|
141
|
+
writeFileSync(join(root, "manifest.json"), JSON.stringify({
|
|
142
|
+
schemaVersion: 1,
|
|
143
|
+
name: "evil",
|
|
144
|
+
displayName: "evil",
|
|
145
|
+
version: "1.0.0",
|
|
146
|
+
description: "x",
|
|
147
|
+
signalTypes: ["topology"],
|
|
148
|
+
capabilities: { listServices: true },
|
|
149
|
+
hooks: [{ kind: "tool_pre_invoke", module: "../escape.mjs" }],
|
|
150
|
+
}));
|
|
151
|
+
writeFileSync(join(root, "index.js"), `export default function create() {
|
|
152
|
+
return {
|
|
153
|
+
type: "evil", signalType: "topology", name: "evil",
|
|
154
|
+
async connect() {},
|
|
155
|
+
async healthCheck() { return { status: "down", latencyMs: 0 }; },
|
|
156
|
+
async disconnect() {},
|
|
157
|
+
getDefaultMetrics() { return []; },
|
|
158
|
+
getMetrics() { return []; },
|
|
159
|
+
async listServices() { return []; },
|
|
160
|
+
};
|
|
161
|
+
}`);
|
|
162
|
+
// Stage a file outside the plugin root the manifest references.
|
|
163
|
+
writeFileSync(join(stage, "escape.mjs"), `export default async () => ({ allow: false, reason: "should never run" });`);
|
|
164
|
+
const registry = new HookRegistry();
|
|
165
|
+
const loader = new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry });
|
|
166
|
+
await loader.load();
|
|
167
|
+
// The escape hook was refused; nothing in the registry.
|
|
168
|
+
assert.equal(registry.list("tool_pre_invoke").length, 0);
|
|
169
|
+
});
|
|
170
|
+
test("PluginLoader: hot-reload — re-loading replaces prior hook registrations", async () => {
|
|
171
|
+
const stage = stagePlugin({
|
|
172
|
+
name: "delta",
|
|
173
|
+
hooks: [{ kind: "tool_pre_invoke", module: "hooks/pre.mjs", priority: 10 }],
|
|
174
|
+
});
|
|
175
|
+
const registry = new HookRegistry();
|
|
176
|
+
await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
|
|
177
|
+
assert.equal(registry.list("tool_pre_invoke").length, 1);
|
|
178
|
+
// Re-load (same stage, same plugin) — registry should still hold
|
|
179
|
+
// exactly one entry for tool_pre_invoke owned by delta. The loader
|
|
180
|
+
// calls unregisterPlugin first, then re-registers.
|
|
181
|
+
await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
|
|
182
|
+
const after = registry.list("tool_pre_invoke");
|
|
183
|
+
assert.equal(after.length, 1);
|
|
184
|
+
assert.equal(after[0].pluginName, "delta");
|
|
185
|
+
});
|
|
186
|
+
test("PluginLoader: no hookRegistry passed → hooks are silently ignored (back-compat)", async () => {
|
|
187
|
+
const stage = stagePlugin({
|
|
188
|
+
name: "epsilon",
|
|
189
|
+
hooks: [{ kind: "tool_pre_invoke", module: "hooks/pre.mjs" }],
|
|
190
|
+
});
|
|
191
|
+
// No hookRegistry — load() must not throw.
|
|
192
|
+
const loader = new PluginLoader({ pluginsDir: stage, verify: false });
|
|
193
|
+
await loader.load();
|
|
194
|
+
assert.ok(loader.has("epsilon"));
|
|
195
|
+
});
|
|
196
|
+
test("PluginLoader: hook with no default export is skipped", async () => {
|
|
197
|
+
const stage = stagePlugin({
|
|
198
|
+
name: "zeta",
|
|
199
|
+
hooks: [{ kind: "tool_pre_invoke", module: "hooks/noexport.mjs" }],
|
|
200
|
+
});
|
|
201
|
+
// Overwrite the noexport.mjs file to remove default export.
|
|
202
|
+
writeFileSync(join(stage, "zeta", "hooks", "noexport.mjs"), "export const meta = 'no handler here';");
|
|
203
|
+
const registry = new HookRegistry();
|
|
204
|
+
await new PluginLoader({ pluginsDir: stage, verify: false, hookRegistry: registry }).load();
|
|
205
|
+
assert.equal(registry.list("tool_pre_invoke").length, 0);
|
|
206
|
+
});
|
|
@@ -77,27 +77,45 @@ describe("PrometheusConnector", () => {
|
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
describe("buildQuery", () => {
|
|
80
|
-
|
|
80
|
+
// buildQuery is private async and returns `{ promql, label, candidate }`.
|
|
81
|
+
// To keep these tests off the network, every case uses a user-
|
|
82
|
+
// override metric — that short-circuits the candidate-probe path
|
|
83
|
+
// (which would otherwise call Prometheus to pick the best variant
|
|
84
|
+
// and resolveServiceLabel to discover the right scoping label).
|
|
85
|
+
// The label / candidate fields are exercised via the public
|
|
86
|
+
// queryMetrics path elsewhere.
|
|
87
|
+
const fakeSource = { name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true };
|
|
88
|
+
it("replaces {{service}} placeholder in user-defined metrics", async () => {
|
|
81
89
|
const connector = new PrometheusConnector();
|
|
82
|
-
await connector.connect({
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
await connector.connect({
|
|
91
|
+
...fakeSource,
|
|
92
|
+
metrics: [{ name: "cpu", query: 'cpu_usage{svc="{{service}}"}', unit: "%", description: "CPU" }],
|
|
93
|
+
});
|
|
94
|
+
const { promql } = await proto.buildQuery.call(connector, "payment-service", "cpu");
|
|
95
|
+
assert.ok(promql.includes("payment-service"));
|
|
96
|
+
assert.ok(!promql.includes("{{service}}"));
|
|
97
|
+
});
|
|
98
|
+
it("respects an explicit {{service}} substitution outside the {{selector}} sugar", async () => {
|
|
99
|
+
// Different from the other two: the override here uses {{service}}
|
|
100
|
+
// directly inside a hand-written selector (no {{selector}} sugar).
|
|
101
|
+
// Confirms the substitution applies to the raw template, not only
|
|
102
|
+
// through the label-resolver path.
|
|
88
103
|
const connector = new PrometheusConnector();
|
|
89
|
-
await connector.connect({
|
|
90
|
-
|
|
91
|
-
|
|
104
|
+
await connector.connect({
|
|
105
|
+
...fakeSource,
|
|
106
|
+
metrics: [{ name: "explicit_selector", query: 'explicit_metric{job="{{service}}"}', unit: "", description: "" }],
|
|
107
|
+
});
|
|
108
|
+
const { promql } = await proto.buildQuery.call(connector, "my-svc", "explicit_selector");
|
|
109
|
+
assert.equal(promql, 'explicit_metric{job="my-svc"}');
|
|
92
110
|
});
|
|
93
111
|
it("uses custom metrics from source config", async () => {
|
|
94
112
|
const connector = new PrometheusConnector();
|
|
95
113
|
await connector.connect({
|
|
96
|
-
|
|
114
|
+
...fakeSource,
|
|
97
115
|
metrics: [{ name: "custom", query: 'my_custom_metric{svc="{{service}}"}', unit: "ops", description: "Custom" }],
|
|
98
116
|
});
|
|
99
|
-
const
|
|
100
|
-
assert.equal(
|
|
117
|
+
const { promql } = await proto.buildQuery.call(connector, "api", "custom");
|
|
118
|
+
assert.equal(promql, 'my_custom_metric{svc="api"}');
|
|
101
119
|
});
|
|
102
120
|
});
|
|
103
121
|
});
|
|
@@ -17,5 +17,18 @@ export declare class ConnectorRegistry {
|
|
|
17
17
|
getAll(): ObservabilityConnector[];
|
|
18
18
|
getByName(name: string): ObservabilityConnector | undefined;
|
|
19
19
|
getBySignal(signal: SignalType): ObservabilityConnector[];
|
|
20
|
+
/** Connectors visible to the named tenant: every source whose
|
|
21
|
+
* config.tenant matches OR is unset (global). Unset = available
|
|
22
|
+
* everywhere — keeps single-tenant deployments untouched.
|
|
23
|
+
* Anonymous traffic / the agent / internal callers can pass
|
|
24
|
+
* the DEFAULT_TENANT sentinel and see exactly what the default-
|
|
25
|
+
* tenant operator sees. */
|
|
26
|
+
getByTenant(tenant: string): ObservabilityConnector[];
|
|
27
|
+
/** Same as `getByName`, but enforces the tenant gate: a source
|
|
28
|
+
* whose config.tenant is set and differs from the calling tenant
|
|
29
|
+
* returns undefined — indistinguishable from "no such source",
|
|
30
|
+
* per the rest of the tenancy layer (no cross-tenant existence
|
|
31
|
+
* leak). Unset source tenant = global, always resolves. */
|
|
32
|
+
getByNameForTenant(name: string, tenant: string): ObservabilityConnector | undefined;
|
|
20
33
|
healthCheckAll(): Promise<Record<string, ConnectorHealth>>;
|
|
21
34
|
}
|
|
@@ -80,6 +80,36 @@ export class ConnectorRegistry {
|
|
|
80
80
|
getBySignal(signal) {
|
|
81
81
|
return this.getAll().filter((c) => c.signalType === signal);
|
|
82
82
|
}
|
|
83
|
+
/** Connectors visible to the named tenant: every source whose
|
|
84
|
+
* config.tenant matches OR is unset (global). Unset = available
|
|
85
|
+
* everywhere — keeps single-tenant deployments untouched.
|
|
86
|
+
* Anonymous traffic / the agent / internal callers can pass
|
|
87
|
+
* the DEFAULT_TENANT sentinel and see exactly what the default-
|
|
88
|
+
* tenant operator sees. */
|
|
89
|
+
getByTenant(tenant) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const [name, c] of this.connectors) {
|
|
92
|
+
const cfg = this.sourceConfigs.get(name);
|
|
93
|
+
const srcTenant = cfg?.tenant;
|
|
94
|
+
if (!srcTenant || srcTenant === tenant)
|
|
95
|
+
out.push(c);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** Same as `getByName`, but enforces the tenant gate: a source
|
|
100
|
+
* whose config.tenant is set and differs from the calling tenant
|
|
101
|
+
* returns undefined — indistinguishable from "no such source",
|
|
102
|
+
* per the rest of the tenancy layer (no cross-tenant existence
|
|
103
|
+
* leak). Unset source tenant = global, always resolves. */
|
|
104
|
+
getByNameForTenant(name, tenant) {
|
|
105
|
+
const c = this.connectors.get(name);
|
|
106
|
+
if (!c)
|
|
107
|
+
return undefined;
|
|
108
|
+
const cfg = this.sourceConfigs.get(name);
|
|
109
|
+
if (cfg?.tenant && cfg.tenant !== tenant)
|
|
110
|
+
return undefined;
|
|
111
|
+
return c;
|
|
112
|
+
}
|
|
83
113
|
async healthCheckAll() {
|
|
84
114
|
const results = {};
|
|
85
115
|
for (const [name, connector] of this.connectors) {
|
|
@@ -2,15 +2,21 @@ import { describe, it } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { getSupportedTypes, ConnectorRegistry } from "./registry.js";
|
|
4
4
|
import { DEFAULT_SETTINGS, DEFAULT_HEALTH_THRESHOLDS } from "../config/loader.js";
|
|
5
|
+
import { getPluginLoader } from "./loader.js";
|
|
5
6
|
function makeConfig(sources = []) {
|
|
6
7
|
return { sources, settings: DEFAULT_SETTINGS, healthThresholds: DEFAULT_HEALTH_THRESHOLDS };
|
|
7
8
|
}
|
|
8
9
|
describe("getSupportedTypes", () => {
|
|
9
|
-
it("returns prometheus
|
|
10
|
+
it("returns the builtins (prometheus, loki, kubernetes) after loader.load()", async () => {
|
|
11
|
+
// The PluginLoader registers builtins inside load(), not the
|
|
12
|
+
// constructor — at server boot index.ts awaits load() before any
|
|
13
|
+
// tool registration code runs. Mirror that here so the test
|
|
14
|
+
// reflects the real wiring rather than a transient empty state.
|
|
15
|
+
await getPluginLoader().load();
|
|
10
16
|
const types = getSupportedTypes();
|
|
11
17
|
assert.ok(types.includes("prometheus"));
|
|
12
18
|
assert.ok(types.includes("loki"));
|
|
13
|
-
assert.
|
|
19
|
+
assert.ok(types.includes("kubernetes"));
|
|
14
20
|
});
|
|
15
21
|
});
|
|
16
22
|
describe("ConnectorRegistry", () => {
|
|
@@ -90,4 +96,52 @@ describe("ConnectorRegistry", () => {
|
|
|
90
96
|
assert.deepEqual(results, {});
|
|
91
97
|
});
|
|
92
98
|
});
|
|
99
|
+
describe("getByTenant / getByNameForTenant", () => {
|
|
100
|
+
it("untagged sources are visible to every tenant (pre-E7 single-tenant default)", async () => {
|
|
101
|
+
await getPluginLoader().load();
|
|
102
|
+
const reg = new ConnectorRegistry();
|
|
103
|
+
await reg.initialize(makeConfig([
|
|
104
|
+
// No tenant on either source — both are "global".
|
|
105
|
+
{ name: "prom-global", type: "prometheus", url: "http://p:9090", enabled: true },
|
|
106
|
+
{ name: "loki-global", type: "loki", url: "http://l:3100", enabled: true },
|
|
107
|
+
]));
|
|
108
|
+
const acmeVisible = reg.getByTenant("acme").map((c) => c.name).sort();
|
|
109
|
+
const bigcoVisible = reg.getByTenant("bigco").map((c) => c.name).sort();
|
|
110
|
+
assert.deepEqual(acmeVisible, ["loki-global", "prom-global"]);
|
|
111
|
+
assert.deepEqual(bigcoVisible, ["loki-global", "prom-global"]);
|
|
112
|
+
});
|
|
113
|
+
it("tenant-tagged source is invisible to other tenants", async () => {
|
|
114
|
+
await getPluginLoader().load();
|
|
115
|
+
const reg = new ConnectorRegistry();
|
|
116
|
+
await reg.initialize(makeConfig([
|
|
117
|
+
{ name: "shared", type: "prometheus", url: "http://p:9090", enabled: true },
|
|
118
|
+
{ name: "acme-only", type: "loki", url: "http://l:3100", enabled: true, tenant: "acme" },
|
|
119
|
+
]));
|
|
120
|
+
assert.deepEqual(reg.getByTenant("acme").map((c) => c.name).sort(), ["acme-only", "shared"]);
|
|
121
|
+
// bigco sees only the shared source — the acme-only one is hidden.
|
|
122
|
+
assert.deepEqual(reg.getByTenant("bigco").map((c) => c.name).sort(), ["shared"]);
|
|
123
|
+
});
|
|
124
|
+
it("getByNameForTenant returns undefined on cross-tenant probe (no existence leak)", async () => {
|
|
125
|
+
await getPluginLoader().load();
|
|
126
|
+
const reg = new ConnectorRegistry();
|
|
127
|
+
await reg.initialize(makeConfig([
|
|
128
|
+
{ name: "acme-loki", type: "loki", url: "http://l:3100", enabled: true, tenant: "acme" },
|
|
129
|
+
]));
|
|
130
|
+
// Within tenant: resolves.
|
|
131
|
+
assert.ok(reg.getByNameForTenant("acme-loki", "acme"));
|
|
132
|
+
// Cross-tenant: undefined — indistinguishable from "no such source".
|
|
133
|
+
assert.equal(reg.getByNameForTenant("acme-loki", "bigco"), undefined);
|
|
134
|
+
// Unknown name in own tenant: also undefined.
|
|
135
|
+
assert.equal(reg.getByNameForTenant("nope", "acme"), undefined);
|
|
136
|
+
});
|
|
137
|
+
it("a source whose tenant is unset resolves for every tenant via getByNameForTenant", async () => {
|
|
138
|
+
await getPluginLoader().load();
|
|
139
|
+
const reg = new ConnectorRegistry();
|
|
140
|
+
await reg.initialize(makeConfig([
|
|
141
|
+
{ name: "global", type: "prometheus", url: "http://p:9090", enabled: true },
|
|
142
|
+
]));
|
|
143
|
+
assert.ok(reg.getByNameForTenant("global", "acme"));
|
|
144
|
+
assert.ok(reg.getByNameForTenant("global", "bigco"));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
93
147
|
});
|