@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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical list of MCP tool names exposed by createMcpServer().
|
|
3
|
+
*
|
|
4
|
+
* Used by:
|
|
5
|
+
* - the Product validator (typo guard): a Product's `tools` allow-
|
|
6
|
+
* list must reference names that actually register, otherwise a
|
|
7
|
+
* bound credential opens an /mcp session with an empty tool set
|
|
8
|
+
* and the agent silently fails.
|
|
9
|
+
* - the keystone integration test in registry-names.test.ts that
|
|
10
|
+
* reads index.ts and asserts the registerTool() call sites match
|
|
11
|
+
* this list 1:1 — a missing entry or an extra one trips the test.
|
|
12
|
+
*
|
|
13
|
+
* Keep this list and the registerTool("name", ...) calls in
|
|
14
|
+
* createMcpServer in sync. The test enforces it.
|
|
15
|
+
*/
|
|
16
|
+
export const REGISTERED_TOOL_NAMES = [
|
|
17
|
+
"list_sources",
|
|
18
|
+
"list_services",
|
|
19
|
+
"query_metrics",
|
|
20
|
+
"query_logs",
|
|
21
|
+
"query_traces",
|
|
22
|
+
"get_service_health",
|
|
23
|
+
"detect_anomalies",
|
|
24
|
+
"get_anomaly_history",
|
|
25
|
+
"generate_postmortem",
|
|
26
|
+
"get_topology",
|
|
27
|
+
"get_blast_radius",
|
|
28
|
+
];
|
|
29
|
+
export const REGISTERED_TOOLS = [
|
|
30
|
+
{ name: "list_sources", category: "discovery", summary: "List configured observability backends + reachability." },
|
|
31
|
+
{ name: "list_services", category: "discovery", summary: "Discover service names across every connected backend." },
|
|
32
|
+
{ name: "query_metrics", category: "query", summary: "Fetch the raw time-series for one metric of one service over a window." },
|
|
33
|
+
{ name: "query_logs", category: "query", summary: "Fetch matching log lines for one service over a window." },
|
|
34
|
+
{ name: "query_traces", category: "query", summary: "Fetch ranked trace summaries for one service over a window." },
|
|
35
|
+
{ name: "get_service_health", category: "diagnose", summary: "Aggregated health verdict for one service (metrics + logs)." },
|
|
36
|
+
{ name: "detect_anomalies", category: "diagnose", summary: "Scan for anomalous services using z-score / heuristics." },
|
|
37
|
+
{ name: "get_anomaly_history", category: "diagnose", summary: "Replay historical anomaly scores for a service (post-mortem context)." },
|
|
38
|
+
{ name: "generate_postmortem", category: "diagnose", summary: "One-shot markdown post-mortem stitching anomaly history + traces + blast-radius + logs for a service." },
|
|
39
|
+
{ name: "get_topology", category: "topology", summary: "Return the infrastructure topology graph (resources + edges)." },
|
|
40
|
+
{ name: "get_blast_radius", category: "topology", summary: "Given a resource, return the impact set if its host(s) fail." },
|
|
41
|
+
];
|
|
42
|
+
/** Validate a candidate Product tools[] array. Returns the unknown
|
|
43
|
+
* names (empty array = all OK). Pure helper — the caller decides
|
|
44
|
+
* how to surface the rejection (the API handler emits a 422 with a
|
|
45
|
+
* hint of valid names; the YAML loader could decide to warn). */
|
|
46
|
+
export function unknownToolNames(tools) {
|
|
47
|
+
const known = new Set(REGISTERED_TOOL_NAMES);
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const t of tools) {
|
|
50
|
+
if (!known.has(t))
|
|
51
|
+
out.push(t);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { REGISTERED_TOOL_NAMES, REGISTERED_TOOLS, unknownToolNames } from "./registry-names.js";
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const INDEX_TS = join(here, "..", "index.ts");
|
|
9
|
+
test("REGISTERED_TOOL_NAMES — 1:1 with createMcpServer's registerTool() calls", () => {
|
|
10
|
+
// This is the integration-test-shaped guard: if a future PR adds
|
|
11
|
+
// or removes a tool registration in index.ts without updating
|
|
12
|
+
// REGISTERED_TOOL_NAMES, the Product validator will silently
|
|
13
|
+
// accept or reject the wrong names. We parse index.ts as text and
|
|
14
|
+
// assert the registered set matches the constant exactly.
|
|
15
|
+
const src = readFileSync(INDEX_TS, "utf8");
|
|
16
|
+
// registerTool("name", → captures the first argument of every call site.
|
|
17
|
+
const re = /\bregisterTool\(\s*"([a-z_][a-z0-9_]*)"/g;
|
|
18
|
+
const found = [];
|
|
19
|
+
for (const m of src.matchAll(re))
|
|
20
|
+
found.push(m[1]);
|
|
21
|
+
found.sort();
|
|
22
|
+
const expected = [...REGISTERED_TOOL_NAMES].sort();
|
|
23
|
+
assert.deepEqual(found, expected, `registerTool() call sites in index.ts don't match REGISTERED_TOOL_NAMES. ` +
|
|
24
|
+
`Add or remove names in src/tools/registry-names.ts to match the actual ` +
|
|
25
|
+
`registrations.\n found: ${JSON.stringify(found)}\n expected: ${JSON.stringify(expected)}`);
|
|
26
|
+
});
|
|
27
|
+
test("unknownToolNames — empty input → no unknowns", () => {
|
|
28
|
+
assert.deepEqual(unknownToolNames([]), []);
|
|
29
|
+
});
|
|
30
|
+
test("unknownToolNames — every registered name is accepted", () => {
|
|
31
|
+
assert.deepEqual(unknownToolNames([...REGISTERED_TOOL_NAMES]), []);
|
|
32
|
+
});
|
|
33
|
+
test("unknownToolNames — surfaces typos and unknown names verbatim", () => {
|
|
34
|
+
const r = unknownToolNames(["list_sources", "query_logz", "get_topologyy"]);
|
|
35
|
+
assert.deepEqual(r, ["query_logz", "get_topologyy"]);
|
|
36
|
+
});
|
|
37
|
+
test("unknownToolNames — case-sensitive (MCP spec)", () => {
|
|
38
|
+
// The spec requires exact name match; "List_Sources" is not the
|
|
39
|
+
// same tool as "list_sources" and silently accepting it would be a
|
|
40
|
+
// worse failure mode than rejecting (mismatched casing wouldn't
|
|
41
|
+
// route to a real tool).
|
|
42
|
+
assert.deepEqual(unknownToolNames(["List_Sources"]), ["List_Sources"]);
|
|
43
|
+
});
|
|
44
|
+
test("REGISTERED_TOOLS — every name has a category + summary", () => {
|
|
45
|
+
for (const t of REGISTERED_TOOLS) {
|
|
46
|
+
assert.ok(t.name, "name required");
|
|
47
|
+
assert.ok(t.category, `category required on ${t.name}`);
|
|
48
|
+
assert.ok(t.summary && t.summary.length > 10, `summary required on ${t.name}`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
test("REGISTERED_TOOLS — matches REGISTERED_TOOL_NAMES 1:1 (no drift)", () => {
|
|
52
|
+
const a = REGISTERED_TOOLS.map((t) => t.name).sort();
|
|
53
|
+
const b = [...REGISTERED_TOOL_NAMES].sort();
|
|
54
|
+
assert.deepEqual(a, b, "REGISTERED_TOOLS and REGISTERED_TOOL_NAMES must agree");
|
|
55
|
+
});
|
|
56
|
+
test("REGISTERED_TOOLS — every category is one of the four valid values", () => {
|
|
57
|
+
const valid = new Set(["discovery", "query", "diagnose", "topology"]);
|
|
58
|
+
for (const t of REGISTERED_TOOLS) {
|
|
59
|
+
assert.ok(valid.has(t.category), `unknown category '${t.category}' on ${t.name}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
package/dist/tools/topology.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ interface AggregatedTopology {
|
|
|
12
12
|
resources: Resource[];
|
|
13
13
|
edges: Edge[];
|
|
14
14
|
}
|
|
15
|
-
export declare function aggregateTopology(registry: ConnectorRegistry): Promise<AggregatedTopology>;
|
|
15
|
+
export declare function aggregateTopology(registry: ConnectorRegistry, tenant?: string): Promise<AggregatedTopology>;
|
|
16
16
|
/**
|
|
17
17
|
* Resolve a caller-supplied identifier to a Resource. Accepts:
|
|
18
18
|
* - exact canonical id (e.g. "k8s:pod:default/checkout-7f89d")
|
|
@@ -35,7 +35,7 @@ export interface GetTopologyArgs {
|
|
|
35
35
|
scope?: string;
|
|
36
36
|
limit?: number;
|
|
37
37
|
}
|
|
38
|
-
export declare function getTopologyHandler(registry: ConnectorRegistry, args?: GetTopologyArgs,
|
|
38
|
+
export declare function getTopologyHandler(registry: ConnectorRegistry, args?: GetTopologyArgs, ctx?: RequestContext): Promise<{
|
|
39
39
|
content: {
|
|
40
40
|
type: "text";
|
|
41
41
|
text: string;
|
|
@@ -48,7 +48,7 @@ export declare const getBlastRadiusDefinition: {
|
|
|
48
48
|
export interface GetBlastRadiusArgs {
|
|
49
49
|
resource: string;
|
|
50
50
|
}
|
|
51
|
-
export declare function getBlastRadiusHandler(registry: ConnectorRegistry, args: GetBlastRadiusArgs,
|
|
51
|
+
export declare function getBlastRadiusHandler(registry: ConnectorRegistry, args: GetBlastRadiusArgs, ctx?: RequestContext): Promise<{
|
|
52
52
|
isError: boolean;
|
|
53
53
|
content: {
|
|
54
54
|
type: "text";
|
package/dist/tools/topology.js
CHANGED
|
@@ -14,11 +14,15 @@
|
|
|
14
14
|
// connector later requires zero changes here.
|
|
15
15
|
import { isTopologyProvider } from "../connectors/interface.js";
|
|
16
16
|
import { defaultContext } from "../context.js";
|
|
17
|
-
|
|
17
|
+
import { mergeTopologies } from "../topology/merge.js";
|
|
18
|
+
export async function aggregateTopology(registry, tenant) {
|
|
18
19
|
const sources = [];
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const snapshots = [];
|
|
21
|
+
// Tenant-scoped when a tenant is supplied (call sites at the MCP
|
|
22
|
+
// tool layer pass ctx.tenant); undefined preserves the original
|
|
23
|
+
// global behaviour for internal / non-request callers.
|
|
24
|
+
const connectors = tenant ? registry.getByTenant(tenant) : registry.getAll();
|
|
25
|
+
for (const c of connectors) {
|
|
22
26
|
if (!isTopologyProvider(c))
|
|
23
27
|
continue;
|
|
24
28
|
try {
|
|
@@ -30,14 +34,32 @@ export async function aggregateTopology(registry) {
|
|
|
30
34
|
resources: snap.resources.length,
|
|
31
35
|
edges: snap.edges.length,
|
|
32
36
|
});
|
|
33
|
-
|
|
34
|
-
edges.push(...snap.edges);
|
|
37
|
+
snapshots.push(snap);
|
|
35
38
|
}
|
|
36
39
|
catch {
|
|
37
40
|
// A misbehaving connector must not poison the agent's view of the graph.
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
|
-
|
|
43
|
+
// P1: run the snapshots through mergeTopologies so workloads
|
|
44
|
+
// surfaced by more than one provider (e.g. the same Deployment
|
|
45
|
+
// observed by both Kubernetes + a service-mesh connector) collapse
|
|
46
|
+
// into a single canonical node and edges are rewritten to match.
|
|
47
|
+
//
|
|
48
|
+
// ONLY engages for multi-source topologies — with a single snapshot
|
|
49
|
+
// the merger would mis-group intra-source siblings that happen to
|
|
50
|
+
// share a canonical label (e.g. two pod replicas with
|
|
51
|
+
// `app.kubernetes.io/name=api`). The merger is designed for
|
|
52
|
+
// cross-provider de-duplication, not intra-provider.
|
|
53
|
+
if (snapshots.length <= 1) {
|
|
54
|
+
const only = snapshots[0];
|
|
55
|
+
return {
|
|
56
|
+
sources,
|
|
57
|
+
resources: only?.resources ?? [],
|
|
58
|
+
edges: only?.edges ?? [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const merged = mergeTopologies(snapshots);
|
|
62
|
+
return { sources, resources: merged.resources, edges: merged.edges };
|
|
41
63
|
}
|
|
42
64
|
/**
|
|
43
65
|
* Resolve a caller-supplied identifier to a Resource. Accepts:
|
|
@@ -79,8 +101,8 @@ export const getTopologyDefinition = {
|
|
|
79
101
|
name: "get_topology",
|
|
80
102
|
description: "Return the infrastructure topology graph as Resources and Edges. Use this when an agent needs to reason about which workload runs where, who owns whom, or which scope (namespace/project/folder) a resource belongs to.",
|
|
81
103
|
};
|
|
82
|
-
export async function getTopologyHandler(registry, args = {},
|
|
83
|
-
const agg = await aggregateTopology(registry);
|
|
104
|
+
export async function getTopologyHandler(registry, args = {}, ctx = defaultContext()) {
|
|
105
|
+
const agg = await aggregateTopology(registry, ctx.tenant);
|
|
84
106
|
// Filtering — all optional. Filters compose conjunctively.
|
|
85
107
|
let resources = agg.resources;
|
|
86
108
|
let edges = agg.edges;
|
|
@@ -133,8 +155,8 @@ export const getBlastRadiusDefinition = {
|
|
|
133
155
|
name: "get_blast_radius",
|
|
134
156
|
description: "Given a resource, return the impact set if its underlying host(s) fail. Pivots on the generic RUNS_ON relation, so it works for pod→node, vm→hypervisor, container→host alike. Use this for cross-cutting RCA when several services degrade together.",
|
|
135
157
|
};
|
|
136
|
-
export async function getBlastRadiusHandler(registry, args,
|
|
137
|
-
const agg = await aggregateTopology(registry);
|
|
158
|
+
export async function getBlastRadiusHandler(registry, args, ctx = defaultContext()) {
|
|
159
|
+
const agg = await aggregateTopology(registry, ctx.tenant);
|
|
138
160
|
const found = resolveResource(args.resource, agg.resources);
|
|
139
161
|
if ("error" in found) {
|
|
140
162
|
return {
|
|
@@ -208,3 +208,48 @@ describe("get_blast_radius tool", () => {
|
|
|
208
208
|
assert.equal(apiBucket.ownershipRootKind, "deployment");
|
|
209
209
|
});
|
|
210
210
|
});
|
|
211
|
+
// --- Multi-source merge (Phase P1 wiring) ----------------------------
|
|
212
|
+
// `aggregateTopology` now delegates to `mergeTopologies` when 2+
|
|
213
|
+
// snapshots are present so the same logical workload reported by
|
|
214
|
+
// e.g. Kubernetes + a cloud connector collapses into one node.
|
|
215
|
+
// Single-snapshot calls pass through unchanged (guarded so we don't
|
|
216
|
+
// mis-merge intra-source siblings that share an `app:` label).
|
|
217
|
+
describe("aggregateTopology — multi-source merger (P1 wire)", () => {
|
|
218
|
+
it("collapses cross-source duplicates that share a canonical label", async () => {
|
|
219
|
+
// Source A (k8s): one Deployment "checkout" in prod
|
|
220
|
+
const aRes = [
|
|
221
|
+
{ id: "k8s:deployment:prod/checkout", kind: "deployment", name: "checkout", source: "k8s",
|
|
222
|
+
labels: { "app.kubernetes.io/name": "checkout" } },
|
|
223
|
+
];
|
|
224
|
+
// Source B (trace provider): the same logical service
|
|
225
|
+
const bRes = [
|
|
226
|
+
{ id: "tempo:service:checkout", kind: "trace_service", name: "checkout", source: "tempo",
|
|
227
|
+
labels: { "service.name": "checkout" } },
|
|
228
|
+
];
|
|
229
|
+
const loader = new PluginLoader();
|
|
230
|
+
const reg = new ConnectorRegistry(loader);
|
|
231
|
+
const connA = new FakeTopologyConnector(aRes, []);
|
|
232
|
+
const connB = new FakeTopologyConnector(bRes, []);
|
|
233
|
+
await connA.connect({ name: "k8s", type: "fake", url: "", enabled: true });
|
|
234
|
+
await connB.connect({ name: "tempo", type: "fake", url: "", enabled: true });
|
|
235
|
+
const loaderInternal = loader;
|
|
236
|
+
loaderInternal.connectors.set("fake-a", { name: "fake-a", source: "builtin", factory: () => connA });
|
|
237
|
+
loaderInternal.connectors.set("fake-b", { name: "fake-b", source: "builtin", factory: () => connB });
|
|
238
|
+
await reg.addSource({ name: "k8s", type: "fake-a", url: "", enabled: true });
|
|
239
|
+
await reg.addSource({ name: "tempo", type: "fake-b", url: "", enabled: true });
|
|
240
|
+
const out = parseTool(await getTopologyHandler(reg, {}));
|
|
241
|
+
// 2 sources reported in summary
|
|
242
|
+
assert.equal(out.sources.length, 2);
|
|
243
|
+
// But ONE resource after merge (deployment + trace_service of the
|
|
244
|
+
// same canonical name collapse via MERGEABLE_KIND_PAIRS).
|
|
245
|
+
assert.equal(out.resources.length, 1);
|
|
246
|
+
assert.equal(out.resources[0].name, "checkout");
|
|
247
|
+
});
|
|
248
|
+
it("single-source passes through unchanged (no intra-source merging)", async () => {
|
|
249
|
+
// The existing 4-pod fixture has two pods sharing `app: api`.
|
|
250
|
+
// With a single snapshot the merger must NOT collapse them.
|
|
251
|
+
const reg = await makeRegistry();
|
|
252
|
+
const out = parseTool(await getTopologyHandler(reg, {}));
|
|
253
|
+
assert.equal(out.resources.length, fixture().resources.length);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Resource, Edge, TopologySnapshot } from "../types.js";
|
|
2
|
+
/** Labels we treat as canonical-name carriers. First-match wins. */
|
|
3
|
+
export declare const CANONICAL_LABEL_KEYS: readonly ["app.kubernetes.io/name", "app.kubernetes.io/instance", "app", "service", "service.name", "k8s-app"];
|
|
4
|
+
/** Lower-cased label-key lookup; first match in CANONICAL_LABEL_KEYS
|
|
5
|
+
* ordering wins so two providers with different label conventions
|
|
6
|
+
* still converge if they both ship a known label. */
|
|
7
|
+
export declare function canonicalNameFor(r: Resource): string | undefined;
|
|
8
|
+
export interface MergeResult {
|
|
9
|
+
resources: Resource[];
|
|
10
|
+
edges: Edge[];
|
|
11
|
+
/** Map from original (source-scoped) id → merged canonical id. Used
|
|
12
|
+
* to rewrite edges that referenced one of the collapsed nodes. */
|
|
13
|
+
idMap: Map<string, string>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Merge a set of provider snapshots into one unified graph. The
|
|
17
|
+
* input is the flat union of every provider's resources+edges; the
|
|
18
|
+
* output collapses every group of nodes that share a canonical name
|
|
19
|
+
* (and a compatible kind) into a single node, then rewrites the
|
|
20
|
+
* edge endpoints accordingly.
|
|
21
|
+
*/
|
|
22
|
+
export declare function mergeTopologies(snapshots: TopologySnapshot[]): MergeResult;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Topology merger — collapses Resources/Edges that come from
|
|
2
|
+
// multiple providers into a single deduped graph.
|
|
3
|
+
//
|
|
4
|
+
// The default unified-view is the union of every provider's
|
|
5
|
+
// snapshot, with no dedup. That's fine for unrelated providers
|
|
6
|
+
// (k8s + AWS in different accounts) but breaks down once two
|
|
7
|
+
// providers describe the same logical service (k8s `payment`
|
|
8
|
+
// Deployment + ECS `payment-service` + Tempo `trace_service`
|
|
9
|
+
// `payment`). Without a merger, get_blast_radius walks them as
|
|
10
|
+
// three independent nodes and the LLM sees ghost relationships
|
|
11
|
+
// instead of one real chain.
|
|
12
|
+
//
|
|
13
|
+
// Reconciliation rules, in priority order:
|
|
14
|
+
// 1. Explicit override via attributes.canonicalName
|
|
15
|
+
// 2. Match on any CANONICAL_LABEL_KEYS entry (case-insensitive)
|
|
16
|
+
// 3. Name + kind-compatibility table
|
|
17
|
+
//
|
|
18
|
+
// See docs/topology-vocabulary.md for the rationale.
|
|
19
|
+
/** Labels we treat as canonical-name carriers. First-match wins. */
|
|
20
|
+
export const CANONICAL_LABEL_KEYS = [
|
|
21
|
+
"app.kubernetes.io/name",
|
|
22
|
+
"app.kubernetes.io/instance",
|
|
23
|
+
"app",
|
|
24
|
+
"service",
|
|
25
|
+
"service.name",
|
|
26
|
+
"k8s-app",
|
|
27
|
+
];
|
|
28
|
+
/** Pairs of kinds that are allowed to merge when their canonical
|
|
29
|
+
* names match. Order doesn't matter (we normalise to sorted pair). */
|
|
30
|
+
const MERGEABLE_KIND_PAIRS = new Set([
|
|
31
|
+
["deployment", "cloud_service"],
|
|
32
|
+
["deployment", "trace_service"],
|
|
33
|
+
["cloud_service", "trace_service"],
|
|
34
|
+
["pod", "container"],
|
|
35
|
+
].map((p) => p.slice().sort().join("|")));
|
|
36
|
+
function kindsMergeable(a, b) {
|
|
37
|
+
if (a === b)
|
|
38
|
+
return true;
|
|
39
|
+
return MERGEABLE_KIND_PAIRS.has([a, b].sort().join("|"));
|
|
40
|
+
}
|
|
41
|
+
/** Lower-cased label-key lookup; first match in CANONICAL_LABEL_KEYS
|
|
42
|
+
* ordering wins so two providers with different label conventions
|
|
43
|
+
* still converge if they both ship a known label. */
|
|
44
|
+
export function canonicalNameFor(r) {
|
|
45
|
+
const override = typeof r.attributes?.canonicalName === "string"
|
|
46
|
+
? r.attributes.canonicalName
|
|
47
|
+
: undefined;
|
|
48
|
+
if (override)
|
|
49
|
+
return override.toLowerCase();
|
|
50
|
+
if (!r.labels)
|
|
51
|
+
return undefined;
|
|
52
|
+
const lower = new Map();
|
|
53
|
+
for (const [k, v] of Object.entries(r.labels)) {
|
|
54
|
+
lower.set(k.toLowerCase(), v);
|
|
55
|
+
}
|
|
56
|
+
for (const key of CANONICAL_LABEL_KEYS) {
|
|
57
|
+
const v = lower.get(key.toLowerCase());
|
|
58
|
+
if (typeof v === "string" && v.length > 0)
|
|
59
|
+
return v.toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Merge a set of provider snapshots into one unified graph. The
|
|
65
|
+
* input is the flat union of every provider's resources+edges; the
|
|
66
|
+
* output collapses every group of nodes that share a canonical name
|
|
67
|
+
* (and a compatible kind) into a single node, then rewrites the
|
|
68
|
+
* edge endpoints accordingly.
|
|
69
|
+
*/
|
|
70
|
+
export function mergeTopologies(snapshots) {
|
|
71
|
+
const allResources = [];
|
|
72
|
+
const allEdges = [];
|
|
73
|
+
for (const s of snapshots) {
|
|
74
|
+
allResources.push(...s.resources);
|
|
75
|
+
allEdges.push(...s.edges);
|
|
76
|
+
}
|
|
77
|
+
// Group by (canonical-name + kind-bucket). Resources without a
|
|
78
|
+
// canonical name are passed through unchanged (one bucket each).
|
|
79
|
+
const groups = new Map();
|
|
80
|
+
const passthrough = [];
|
|
81
|
+
for (const r of allResources) {
|
|
82
|
+
const canonical = canonicalNameFor(r);
|
|
83
|
+
if (!canonical) {
|
|
84
|
+
passthrough.push(r);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Bucket key tries to merge across compatible kinds: we group on
|
|
88
|
+
// canonical-name alone, then verify pairwise compatibility when
|
|
89
|
+
// collapsing.
|
|
90
|
+
const key = canonical;
|
|
91
|
+
const existing = groups.get(key);
|
|
92
|
+
if (existing)
|
|
93
|
+
existing.push(r);
|
|
94
|
+
else
|
|
95
|
+
groups.set(key, [r]);
|
|
96
|
+
}
|
|
97
|
+
const merged = [...passthrough];
|
|
98
|
+
const idMap = new Map();
|
|
99
|
+
for (const bucket of groups.values()) {
|
|
100
|
+
if (bucket.length === 1) {
|
|
101
|
+
merged.push(bucket[0]);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Verify all kinds in the bucket are pairwise mergeable. If any
|
|
105
|
+
// pair isn't, fall back to passing every member through
|
|
106
|
+
// unchanged — better a slightly verbose graph than a wrong join.
|
|
107
|
+
let allCompatible = true;
|
|
108
|
+
for (let i = 0; i < bucket.length && allCompatible; i++) {
|
|
109
|
+
for (let j = i + 1; j < bucket.length && allCompatible; j++) {
|
|
110
|
+
if (!kindsMergeable(bucket[i].kind, bucket[j].kind)) {
|
|
111
|
+
allCompatible = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!allCompatible) {
|
|
116
|
+
merged.push(...bucket);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const collapsed = collapseBucket(bucket);
|
|
120
|
+
merged.push(collapsed);
|
|
121
|
+
for (const r of bucket) {
|
|
122
|
+
if (r.id !== collapsed.id)
|
|
123
|
+
idMap.set(r.id, collapsed.id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Rewrite edge endpoints + dedupe identical (from,to,relation)
|
|
127
|
+
// tuples that arose from the collapse.
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const edges = [];
|
|
130
|
+
for (const e of allEdges) {
|
|
131
|
+
const from = idMap.get(e.from) ?? e.from;
|
|
132
|
+
const to = idMap.get(e.to) ?? e.to;
|
|
133
|
+
if (from === to)
|
|
134
|
+
continue; // self-loop after collapse → drop
|
|
135
|
+
const key = `${from}->${to}|${e.relation}`;
|
|
136
|
+
if (seen.has(key))
|
|
137
|
+
continue;
|
|
138
|
+
seen.add(key);
|
|
139
|
+
edges.push({ ...e, from, to });
|
|
140
|
+
}
|
|
141
|
+
return { resources: merged, edges, idMap };
|
|
142
|
+
}
|
|
143
|
+
function collapseBucket(bucket) {
|
|
144
|
+
// Stable choice for the canonical id: pick the first resource
|
|
145
|
+
// sorted lexicographically by source then id. Source rather than
|
|
146
|
+
// alphabetical so the choice doesn't flip when a label changes.
|
|
147
|
+
const sorted = [...bucket].sort((a, b) => {
|
|
148
|
+
const s = a.source.localeCompare(b.source);
|
|
149
|
+
return s !== 0 ? s : a.id.localeCompare(b.id);
|
|
150
|
+
});
|
|
151
|
+
const primary = sorted[0];
|
|
152
|
+
const labels = { ...primary.labels };
|
|
153
|
+
const attributes = { ...(primary.attributes ?? {}) };
|
|
154
|
+
const mergedFrom = [];
|
|
155
|
+
for (const r of sorted) {
|
|
156
|
+
if (r.labels)
|
|
157
|
+
for (const [k, v] of Object.entries(r.labels))
|
|
158
|
+
labels[k] = v;
|
|
159
|
+
if (r.attributes)
|
|
160
|
+
for (const [k, v] of Object.entries(r.attributes))
|
|
161
|
+
attributes[k] = v;
|
|
162
|
+
mergedFrom.push(`${r.source}:${r.id}`);
|
|
163
|
+
}
|
|
164
|
+
attributes.mergedFrom = mergedFrom;
|
|
165
|
+
// Pick the most-specific kind for the merged node: cloud_service
|
|
166
|
+
// > deployment > pod > trace_service > anything else. The merge
|
|
167
|
+
// rules above already capped the bucket to compatible kinds.
|
|
168
|
+
const kindPriority = ["cloud_service", "deployment", "pod", "trace_service", "container"];
|
|
169
|
+
const kind = kindPriority.find((k) => sorted.some((r) => r.kind === k)) ?? primary.kind;
|
|
170
|
+
return {
|
|
171
|
+
id: primary.id,
|
|
172
|
+
kind,
|
|
173
|
+
name: primary.name,
|
|
174
|
+
source: primary.source,
|
|
175
|
+
labels,
|
|
176
|
+
attributes,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mergeTopologies, canonicalNameFor } from "./merge.js";
|
|
4
|
+
function r(id, kind, opts = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id,
|
|
7
|
+
kind,
|
|
8
|
+
name: opts.name ?? id,
|
|
9
|
+
source: opts.source ?? "test",
|
|
10
|
+
labels: opts.labels ?? {},
|
|
11
|
+
attributes: opts.canonical ? { canonicalName: opts.canonical } : undefined,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function e(from, to, relation = "RUNS_ON", source = "test") {
|
|
15
|
+
return { from, to, relation, source, confidence: 1.0 };
|
|
16
|
+
}
|
|
17
|
+
function snap(resources, edges = []) {
|
|
18
|
+
return { source: "test", resources, edges, revision: 1 };
|
|
19
|
+
}
|
|
20
|
+
test("canonicalNameFor: attributes.canonicalName wins, lowercased", () => {
|
|
21
|
+
const got = canonicalNameFor(r("x", "deployment", { canonical: "Payment-Service" }));
|
|
22
|
+
assert.equal(got, "payment-service");
|
|
23
|
+
});
|
|
24
|
+
test("canonicalNameFor: first matching CANONICAL_LABEL_KEYS entry wins", () => {
|
|
25
|
+
// app.kubernetes.io/name beats app
|
|
26
|
+
assert.equal(canonicalNameFor(r("x", "deployment", { labels: { "app.kubernetes.io/name": "wins", app: "loses" } })), "wins");
|
|
27
|
+
// app beats service when app.kubernetes.io/name absent
|
|
28
|
+
assert.equal(canonicalNameFor(r("x", "deployment", { labels: { app: "wins", service: "loses" } })), "wins");
|
|
29
|
+
});
|
|
30
|
+
test("canonicalNameFor: case-insensitive label-key match", () => {
|
|
31
|
+
assert.equal(canonicalNameFor(r("x", "deployment", { labels: { "App": "Yes" } })), "yes");
|
|
32
|
+
});
|
|
33
|
+
test("canonicalNameFor: returns undefined when no canonical signal present", () => {
|
|
34
|
+
assert.equal(canonicalNameFor(r("x", "deployment")), undefined);
|
|
35
|
+
assert.equal(canonicalNameFor(r("x", "deployment", { labels: { tier: "frontend" } })), undefined);
|
|
36
|
+
});
|
|
37
|
+
test("mergeTopologies: empty input returns empty result", () => {
|
|
38
|
+
const m = mergeTopologies([]);
|
|
39
|
+
assert.deepEqual(m.resources, []);
|
|
40
|
+
assert.deepEqual(m.edges, []);
|
|
41
|
+
assert.equal(m.idMap.size, 0);
|
|
42
|
+
});
|
|
43
|
+
test("mergeTopologies: passes resources without canonical name unchanged", () => {
|
|
44
|
+
const s = snap([r("a", "node", { source: "k8s" }), r("b", "namespace", { source: "k8s" })]);
|
|
45
|
+
const m = mergeTopologies([s]);
|
|
46
|
+
assert.equal(m.resources.length, 2);
|
|
47
|
+
assert.equal(m.idMap.size, 0);
|
|
48
|
+
});
|
|
49
|
+
test("mergeTopologies: collapses k8s Deployment + cloud_service with same canonical name", () => {
|
|
50
|
+
const k8s = snap([r("dep-payment", "deployment", { source: "k8s", labels: { app: "payment" } })]);
|
|
51
|
+
const aws = snap([r("ecs-payment", "cloud_service", { source: "aws", canonical: "payment" })]);
|
|
52
|
+
const m = mergeTopologies([k8s, aws]);
|
|
53
|
+
assert.equal(m.resources.length, 1, "two providers, one canonical service");
|
|
54
|
+
const merged = m.resources[0];
|
|
55
|
+
// Higher-priority kind wins (cloud_service > deployment)
|
|
56
|
+
assert.equal(merged.kind, "cloud_service");
|
|
57
|
+
assert.deepEqual((merged.attributes?.mergedFrom).sort(), [
|
|
58
|
+
"aws:ecs-payment",
|
|
59
|
+
"k8s:dep-payment",
|
|
60
|
+
]);
|
|
61
|
+
// idMap maps the non-canonical id to the canonical one (first by
|
|
62
|
+
// source asc, then id asc: aws < k8s, so aws's id wins).
|
|
63
|
+
assert.equal(merged.id, "ecs-payment");
|
|
64
|
+
assert.equal(m.idMap.get("dep-payment"), "ecs-payment");
|
|
65
|
+
});
|
|
66
|
+
test("mergeTopologies: incompatible kinds in the bucket → no merge (graph stays verbose)", () => {
|
|
67
|
+
// `pod` and `function` are not in MERGEABLE_KIND_PAIRS — even if
|
|
68
|
+
// the names collide we keep both.
|
|
69
|
+
const k8s = snap([r("p1", "pod", { source: "k8s", labels: { app: "payment" } })]);
|
|
70
|
+
const aws = snap([r("fn1", "function", { source: "aws", canonical: "payment" })]);
|
|
71
|
+
const m = mergeTopologies([k8s, aws]);
|
|
72
|
+
assert.equal(m.resources.length, 2);
|
|
73
|
+
assert.equal(m.idMap.size, 0);
|
|
74
|
+
});
|
|
75
|
+
test("mergeTopologies: rewrites edges that referenced a collapsed id", () => {
|
|
76
|
+
const k8s = snap([r("dep-payment", "deployment", { source: "k8s", labels: { app: "payment" } })], [e("pod-1", "dep-payment", "RUNS_AS", "k8s")]);
|
|
77
|
+
const aws = snap([r("ecs-payment", "cloud_service", { source: "aws", canonical: "payment" })], [e("ecs-payment", "rds-1", "READS_FROM", "aws")]);
|
|
78
|
+
const m = mergeTopologies([k8s, aws]);
|
|
79
|
+
// dep-payment was collapsed into ecs-payment
|
|
80
|
+
const edges = m.edges;
|
|
81
|
+
// The k8s edge's TO endpoint must be rewritten to ecs-payment.
|
|
82
|
+
assert.ok(edges.some((x) => x.from === "pod-1" && x.to === "ecs-payment"));
|
|
83
|
+
// The aws edge stays put.
|
|
84
|
+
assert.ok(edges.some((x) => x.from === "ecs-payment" && x.to === "rds-1"));
|
|
85
|
+
});
|
|
86
|
+
test("mergeTopologies: self-loops created by the collapse are dropped", () => {
|
|
87
|
+
// Two resources merge into one. An edge that pointed from A to B
|
|
88
|
+
// becomes A→A after rewrite; drop it.
|
|
89
|
+
const k8s = snap([
|
|
90
|
+
r("dep-payment", "deployment", { source: "k8s", labels: { app: "payment" } }),
|
|
91
|
+
r("ecs-payment", "cloud_service", { source: "aws", canonical: "payment" }),
|
|
92
|
+
], [e("dep-payment", "ecs-payment", "ALIAS_OF", "synthetic")]);
|
|
93
|
+
const m = mergeTopologies([k8s]);
|
|
94
|
+
assert.equal(m.resources.length, 1);
|
|
95
|
+
assert.equal(m.edges.filter((x) => x.from === x.to).length, 0, "self-loops after collapse must be removed");
|
|
96
|
+
});
|
|
97
|
+
test("mergeTopologies: duplicate (from,to,relation) tuples deduped after rewrite", () => {
|
|
98
|
+
const k8s = snap([
|
|
99
|
+
r("a", "deployment", { source: "k8s", labels: { app: "payment" } }),
|
|
100
|
+
r("b", "cloud_service", { source: "aws", canonical: "payment" }),
|
|
101
|
+
r("client", "deployment", { source: "k8s" }),
|
|
102
|
+
], [
|
|
103
|
+
e("client", "a", "CALLS", "k8s"),
|
|
104
|
+
e("client", "b", "CALLS", "aws"),
|
|
105
|
+
]);
|
|
106
|
+
const m = mergeTopologies([k8s]);
|
|
107
|
+
// After collapse, both edges become client→b CALLS — dedup to one.
|
|
108
|
+
const callEdges = m.edges.filter((x) => x.relation === "CALLS");
|
|
109
|
+
assert.equal(callEdges.length, 1);
|
|
110
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface SessionStore {
|
|
2
|
+
/** Stable backend identifier (used in /api/info diagnostics). */
|
|
3
|
+
readonly backend: string;
|
|
4
|
+
/** Get a JSON-serialisable value by key. */
|
|
5
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
6
|
+
/** Set a value with no expiry. */
|
|
7
|
+
set<T = unknown>(key: string, value: T): Promise<void>;
|
|
8
|
+
/** Set a value that auto-expires after `ttlSeconds`. */
|
|
9
|
+
setEx<T = unknown>(key: string, ttlSeconds: number, value: T): Promise<void>;
|
|
10
|
+
/** Remove a key. No-op if missing. */
|
|
11
|
+
del(key: string): Promise<void>;
|
|
12
|
+
/** List all keys matching a glob-style prefix (used by SCIM /
|
|
13
|
+
* DCR enumeration). Not required to be efficient; backends MAY
|
|
14
|
+
* impose a soft cap (and document it). */
|
|
15
|
+
keys(prefix: string): Promise<string[]>;
|
|
16
|
+
/** Best-effort shutdown — flush + disconnect any pooled clients. */
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** Single-process, in-memory store. Default when OMCP_REDIS_URL is
|
|
20
|
+
* unset. Lazy expiry — entries are evicted on the next access. */
|
|
21
|
+
export declare class InMemorySessionStore implements SessionStore {
|
|
22
|
+
readonly backend = "memory";
|
|
23
|
+
private readonly map;
|
|
24
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
25
|
+
set<T = unknown>(key: string, value: T): Promise<void>;
|
|
26
|
+
setEx<T = unknown>(key: string, ttlSeconds: number, value: T): Promise<void>;
|
|
27
|
+
del(key: string): Promise<void>;
|
|
28
|
+
keys(prefix: string): Promise<string[]>;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
/** Test-only: introspect size. */
|
|
31
|
+
size(): number;
|
|
32
|
+
}
|
|
33
|
+
/** Redis-backed store. Constructed lazily via `connectRedisStore` so
|
|
34
|
+
* the `redis` driver only loads when actually used. */
|
|
35
|
+
export declare class RedisSessionStore implements SessionStore {
|
|
36
|
+
readonly backend = "redis";
|
|
37
|
+
private readonly client;
|
|
38
|
+
private readonly prefix;
|
|
39
|
+
constructor(client: RedisClientLike, prefix?: string);
|
|
40
|
+
private k;
|
|
41
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
42
|
+
set<T = unknown>(key: string, value: T): Promise<void>;
|
|
43
|
+
setEx<T = unknown>(key: string, ttlSeconds: number, value: T): Promise<void>;
|
|
44
|
+
del(key: string): Promise<void>;
|
|
45
|
+
keys(prefix: string): Promise<string[]>;
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
/** Minimum surface a Redis client must implement. Lets us inject a
|
|
49
|
+
* fake in tests and stays compatible across the `redis` / `ioredis`
|
|
50
|
+
* package shapes. */
|
|
51
|
+
export interface RedisClientLike {
|
|
52
|
+
get(key: string): Promise<string | null>;
|
|
53
|
+
set(key: string, value: string, opts?: {
|
|
54
|
+
EX?: number;
|
|
55
|
+
}): Promise<string | null | undefined>;
|
|
56
|
+
del(key: string): Promise<number>;
|
|
57
|
+
keys(pattern: string): Promise<string[]>;
|
|
58
|
+
quit(): Promise<unknown>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the session store from env. Default: InMemorySessionStore.
|
|
62
|
+
* When OMCP_REDIS_URL is set: load `redis` dynamically, connect, and
|
|
63
|
+
* return a RedisSessionStore. On any connect failure, log + fall back
|
|
64
|
+
* to InMemory (the gateway must boot even when Redis is down).
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveSessionStore(env?: NodeJS.ProcessEnv): Promise<SessionStore>;
|