@thotischner/observability-mcp 1.5.1 → 1.7.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/config/sources.yaml +10 -0
- package/dist/connectors/interface.d.ts +11 -1
- package/dist/connectors/interface.js +7 -1
- package/dist/connectors/kubernetes-client.d.ts +3 -0
- package/dist/connectors/kubernetes-client.js +90 -0
- package/dist/connectors/kubernetes-graph.d.ts +73 -0
- package/dist/connectors/kubernetes-graph.js +257 -0
- package/dist/connectors/kubernetes-graph.test.d.ts +1 -0
- package/dist/connectors/kubernetes-graph.test.js +150 -0
- package/dist/connectors/kubernetes.d.ts +52 -0
- package/dist/connectors/kubernetes.js +185 -0
- package/dist/connectors/kubernetes.test.d.ts +1 -0
- package/dist/connectors/kubernetes.test.js +136 -0
- package/dist/connectors/loader.js +6 -0
- package/dist/connectors/topology.test.d.ts +1 -0
- package/dist/connectors/topology.test.js +165 -0
- package/dist/enterprise-gate.d.ts +132 -0
- package/dist/enterprise-gate.js +510 -0
- package/dist/enterprise-gate.test.d.ts +1 -0
- package/dist/enterprise-gate.test.js +178 -0
- package/dist/index.js +152 -6
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/manifest-schema.d.ts +1 -0
- package/dist/sdk/manifest-schema.js +1 -1
- package/dist/tools/get-service-health.js +11 -8
- package/dist/tools/handlers.test.js +31 -0
- package/dist/tools/topology.d.ts +64 -0
- package/dist/tools/topology.js +233 -0
- package/dist/tools/topology.test.d.ts +1 -0
- package/dist/tools/topology.test.js +210 -0
- package/dist/types.d.ts +67 -1
- package/dist/ui/index.html +2333 -67
- package/package.json +3 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ConnectorRegistry } from "../connectors/registry.js";
|
|
4
|
+
import { PluginLoader } from "../connectors/loader.js";
|
|
5
|
+
import { getTopologyHandler, getBlastRadiusHandler, resolveResource, } from "./topology.js";
|
|
6
|
+
// --- A minimal stand-alone topology connector used as test fixture -----
|
|
7
|
+
// Lets the suite drive the tool handlers without a real K8s cluster.
|
|
8
|
+
class FakeTopologyConnector {
|
|
9
|
+
type = "fake";
|
|
10
|
+
signalType = "topology";
|
|
11
|
+
name = "fake-cluster";
|
|
12
|
+
resources;
|
|
13
|
+
edges;
|
|
14
|
+
constructor(resources, edges) {
|
|
15
|
+
this.resources = resources;
|
|
16
|
+
this.edges = edges;
|
|
17
|
+
}
|
|
18
|
+
async connect(c) { this.name = c.name; }
|
|
19
|
+
async healthCheck() { return { status: "up", latencyMs: 1 }; }
|
|
20
|
+
async disconnect() { }
|
|
21
|
+
getDefaultMetrics() { return []; }
|
|
22
|
+
getMetrics() { return []; }
|
|
23
|
+
async listServices() { return []; }
|
|
24
|
+
async listResources() { return this.resources; }
|
|
25
|
+
async listEdges() { return this.edges; }
|
|
26
|
+
async getTopologySnapshot() {
|
|
27
|
+
return { source: this.name, resources: this.resources, edges: this.edges, revision: 1 };
|
|
28
|
+
}
|
|
29
|
+
watchTopology(_l) { return () => { }; }
|
|
30
|
+
}
|
|
31
|
+
// Build a small but realistic topology covering both happy and edge cases:
|
|
32
|
+
// two services, two hosts, ownership chain, one orphan, one cross-kind link.
|
|
33
|
+
function fixture() {
|
|
34
|
+
const r = [
|
|
35
|
+
// Hosts
|
|
36
|
+
{ id: "k8s:node:n1", kind: "node", name: "n1", source: "fake", labels: {} },
|
|
37
|
+
{ id: "k8s:node:n2", kind: "node", name: "n2", source: "fake", labels: {} },
|
|
38
|
+
// Scope
|
|
39
|
+
{ id: "k8s:namespace:prod", kind: "namespace", name: "prod", source: "fake", labels: {} },
|
|
40
|
+
{ id: "k8s:namespace:staging", kind: "namespace", name: "staging", source: "fake", labels: {} },
|
|
41
|
+
// Ownership roots (deployments)
|
|
42
|
+
{ id: "k8s:deployment:prod/api", kind: "deployment", name: "api", source: "fake", labels: {} },
|
|
43
|
+
{ id: "k8s:deployment:prod/db", kind: "deployment", name: "db", source: "fake", labels: {} },
|
|
44
|
+
// Intermediate
|
|
45
|
+
{ id: "k8s:replicaset:prod/api-1", kind: "replicaset", name: "api-1", source: "fake", labels: {} },
|
|
46
|
+
// Workloads
|
|
47
|
+
{ id: "k8s:pod:prod/api-aaa", kind: "pod", name: "api-aaa", source: "fake", labels: { app: "api" } },
|
|
48
|
+
{ id: "k8s:pod:prod/api-bbb", kind: "pod", name: "api-bbb", source: "fake", labels: { app: "api" } },
|
|
49
|
+
{ id: "k8s:pod:prod/db-aaa", kind: "pod", name: "db-aaa", source: "fake", labels: { app: "db" } },
|
|
50
|
+
// Orphan with no RUNS_ON (e.g. pending)
|
|
51
|
+
{ id: "k8s:pod:staging/pending-1", kind: "pod", name: "pending-1", source: "fake", labels: {} },
|
|
52
|
+
];
|
|
53
|
+
const e = [
|
|
54
|
+
// ownership chain
|
|
55
|
+
{ from: "k8s:pod:prod/api-aaa", to: "k8s:replicaset:prod/api-1", relation: "OWNED_BY", source: "fake", confidence: 1 },
|
|
56
|
+
{ from: "k8s:pod:prod/api-bbb", to: "k8s:replicaset:prod/api-1", relation: "OWNED_BY", source: "fake", confidence: 1 },
|
|
57
|
+
{ from: "k8s:replicaset:prod/api-1", to: "k8s:deployment:prod/api", relation: "OWNED_BY", source: "fake", confidence: 1 },
|
|
58
|
+
{ from: "k8s:pod:prod/db-aaa", to: "k8s:deployment:prod/db", relation: "OWNED_BY", source: "fake", confidence: 1 },
|
|
59
|
+
// RUNS_ON — api-aaa shares n1 with db-aaa (blast-radius case);
|
|
60
|
+
// api-bbb alone on n2 (no shared-host case)
|
|
61
|
+
{ from: "k8s:pod:prod/api-aaa", to: "k8s:node:n1", relation: "RUNS_ON", source: "fake", confidence: 1 },
|
|
62
|
+
{ from: "k8s:pod:prod/db-aaa", to: "k8s:node:n1", relation: "RUNS_ON", source: "fake", confidence: 1 },
|
|
63
|
+
{ from: "k8s:pod:prod/api-bbb", to: "k8s:node:n2", relation: "RUNS_ON", source: "fake", confidence: 1 },
|
|
64
|
+
// IN_NAMESPACE
|
|
65
|
+
{ from: "k8s:pod:prod/api-aaa", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
|
|
66
|
+
{ from: "k8s:pod:prod/api-bbb", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
|
|
67
|
+
{ from: "k8s:pod:prod/db-aaa", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
|
|
68
|
+
{ from: "k8s:deployment:prod/api", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
|
|
69
|
+
{ from: "k8s:deployment:prod/db", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
|
|
70
|
+
{ from: "k8s:replicaset:prod/api-1", to: "k8s:namespace:prod", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
|
|
71
|
+
{ from: "k8s:pod:staging/pending-1", to: "k8s:namespace:staging", relation: "IN_NAMESPACE", source: "fake", confidence: 1 },
|
|
72
|
+
];
|
|
73
|
+
return { resources: r, edges: e };
|
|
74
|
+
}
|
|
75
|
+
async function makeRegistry() {
|
|
76
|
+
const { resources, edges } = fixture();
|
|
77
|
+
const loader = new PluginLoader();
|
|
78
|
+
// Don't load builtins/filesystem — we plug a single fake connector in
|
|
79
|
+
// directly so the suite is hermetic and fast.
|
|
80
|
+
const reg = new ConnectorRegistry(loader);
|
|
81
|
+
const conn = new FakeTopologyConnector(resources, edges);
|
|
82
|
+
await conn.connect({ name: "fake-cluster", type: "fake", url: "", enabled: true });
|
|
83
|
+
// ConnectorRegistry has no public "register a live instance" method,
|
|
84
|
+
// so we install via the documented addSource path with a fake loader.
|
|
85
|
+
loader.connectors.set("fake", {
|
|
86
|
+
name: "fake",
|
|
87
|
+
source: "builtin",
|
|
88
|
+
factory: () => conn,
|
|
89
|
+
});
|
|
90
|
+
await reg.addSource({ name: "fake-cluster", type: "fake", url: "", enabled: true });
|
|
91
|
+
return reg;
|
|
92
|
+
}
|
|
93
|
+
function parseTool(result) {
|
|
94
|
+
return JSON.parse(result.content[0].text);
|
|
95
|
+
}
|
|
96
|
+
describe("resolveResource", () => {
|
|
97
|
+
const { resources } = fixture();
|
|
98
|
+
it("matches an exact id", () => {
|
|
99
|
+
const r = resolveResource("k8s:node:n1", resources);
|
|
100
|
+
assert.ok(!("error" in r));
|
|
101
|
+
if (!("error" in r))
|
|
102
|
+
assert.equal(r.kind, "node");
|
|
103
|
+
});
|
|
104
|
+
it("matches a unique exact name", () => {
|
|
105
|
+
const r = resolveResource("api-aaa", resources);
|
|
106
|
+
assert.ok(!("error" in r));
|
|
107
|
+
});
|
|
108
|
+
it("returns candidates for an ambiguous fuzzy match", () => {
|
|
109
|
+
// "aaa" doesn't exactly match any name, but fuzzy-matches api-aaa and db-aaa
|
|
110
|
+
const r = resolveResource("aaa", resources);
|
|
111
|
+
assert.ok("error" in r);
|
|
112
|
+
if ("error" in r) {
|
|
113
|
+
assert.ok((r.candidates?.length ?? 0) >= 2);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
it("returns a clean error for no match", () => {
|
|
117
|
+
const r = resolveResource("does-not-exist", resources);
|
|
118
|
+
assert.ok("error" in r);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
describe("get_topology tool", () => {
|
|
122
|
+
it("returns the full graph by default", async () => {
|
|
123
|
+
const reg = await makeRegistry();
|
|
124
|
+
const out = parseTool(await getTopologyHandler(reg, {}));
|
|
125
|
+
assert.equal(out.sources.length, 1);
|
|
126
|
+
assert.equal(out.resources.length, fixture().resources.length);
|
|
127
|
+
assert.equal(out.edges.length, fixture().edges.length);
|
|
128
|
+
assert.equal(out.truncated, false);
|
|
129
|
+
});
|
|
130
|
+
it("filters by kind", async () => {
|
|
131
|
+
const reg = await makeRegistry();
|
|
132
|
+
const out = parseTool(await getTopologyHandler(reg, { kind: "pod" }));
|
|
133
|
+
for (const r of out.resources)
|
|
134
|
+
assert.equal(r.kind, "pod");
|
|
135
|
+
// edges must reference only the kept resources
|
|
136
|
+
const ids = new Set(out.resources.map((r) => r.id));
|
|
137
|
+
for (const e of out.edges) {
|
|
138
|
+
assert.ok(ids.has(e.from) && ids.has(e.to));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
it("filters by scope name or id", async () => {
|
|
142
|
+
const reg = await makeRegistry();
|
|
143
|
+
const byName = parseTool(await getTopologyHandler(reg, { scope: "prod" }));
|
|
144
|
+
const byId = parseTool(await getTopologyHandler(reg, { scope: "k8s:namespace:prod" }));
|
|
145
|
+
assert.equal(byName.resources.length, byId.resources.length);
|
|
146
|
+
// staging pod must not appear
|
|
147
|
+
assert.ok(!byName.resources.some((r) => r.name === "pending-1"));
|
|
148
|
+
});
|
|
149
|
+
it("respects the limit and reports truncation", async () => {
|
|
150
|
+
const reg = await makeRegistry();
|
|
151
|
+
const out = parseTool(await getTopologyHandler(reg, { limit: 3 }));
|
|
152
|
+
assert.equal(out.resources.length, 3);
|
|
153
|
+
assert.equal(out.truncated, true);
|
|
154
|
+
assert.equal(out.total.resources, fixture().resources.length);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
describe("get_blast_radius tool", () => {
|
|
158
|
+
it("reports shared-host blast radius for a co-located pod", async () => {
|
|
159
|
+
const reg = await makeRegistry();
|
|
160
|
+
const out = parseTool(await getBlastRadiusHandler(reg, { resource: "api-aaa" }));
|
|
161
|
+
assert.equal(out.target.name, "api-aaa");
|
|
162
|
+
assert.equal(out.hosts.length, 1);
|
|
163
|
+
const host = out.hosts[0];
|
|
164
|
+
assert.equal(host.host.name, "n1");
|
|
165
|
+
// Two ownership roots on n1: deployment/api and deployment/db
|
|
166
|
+
assert.equal(host.ownershipRoots, 2);
|
|
167
|
+
const rootNames = new Set(host.coTenants.map((c) => c.ownershipRootName));
|
|
168
|
+
assert.ok(rootNames.has("api"));
|
|
169
|
+
assert.ok(rootNames.has("db"));
|
|
170
|
+
assert.match(out.summary, /blast-radius candidate/i);
|
|
171
|
+
});
|
|
172
|
+
it("reports no shared-host for a pod alone on its node", async () => {
|
|
173
|
+
const reg = await makeRegistry();
|
|
174
|
+
const out = parseTool(await getBlastRadiusHandler(reg, { resource: "api-bbb" }));
|
|
175
|
+
assert.equal(out.hosts.length, 1);
|
|
176
|
+
assert.equal(out.hosts[0].host.name, "n2");
|
|
177
|
+
assert.equal(out.hosts[0].ownershipRoots, 1);
|
|
178
|
+
assert.match(out.summary, /limited shared-host/i);
|
|
179
|
+
});
|
|
180
|
+
it("treats a host resource as its own host (incoming RUNS_ON pivot)", async () => {
|
|
181
|
+
const reg = await makeRegistry();
|
|
182
|
+
const out = parseTool(await getBlastRadiusHandler(reg, { resource: "k8s:node:n1" }));
|
|
183
|
+
assert.equal(out.target.kind, "node");
|
|
184
|
+
assert.equal(out.hosts.length, 1);
|
|
185
|
+
assert.equal(out.hosts[0].host.id, "k8s:node:n1");
|
|
186
|
+
});
|
|
187
|
+
it("returns a note when a resource has no RUNS_ON edges", async () => {
|
|
188
|
+
const reg = await makeRegistry();
|
|
189
|
+
const out = parseTool(await getBlastRadiusHandler(reg, { resource: "pending-1" }));
|
|
190
|
+
assert.equal(out.hosts.length, 0);
|
|
191
|
+
assert.match(out.note, /no RUNS_ON/i);
|
|
192
|
+
});
|
|
193
|
+
it("surfaces a structured error for unknown resources", async () => {
|
|
194
|
+
const reg = await makeRegistry();
|
|
195
|
+
const result = await getBlastRadiusHandler(reg, { resource: "totally-not-here" });
|
|
196
|
+
assert.equal(result.isError, true);
|
|
197
|
+
const out = parseTool(result);
|
|
198
|
+
assert.match(out.error, /No resource found/);
|
|
199
|
+
});
|
|
200
|
+
it("uses ownership root, not direct owner, when grouping co-tenants", async () => {
|
|
201
|
+
// api-aaa is OWNED_BY a ReplicaSet which is OWNED_BY a Deployment.
|
|
202
|
+
// The blast-radius should bucket api-aaa under the Deployment, not the RS.
|
|
203
|
+
const reg = await makeRegistry();
|
|
204
|
+
const out = parseTool(await getBlastRadiusHandler(reg, { resource: "api-aaa" }));
|
|
205
|
+
const onN1 = out.hosts[0];
|
|
206
|
+
const apiBucket = onN1.coTenants.find((c) => c.ownershipRootName === "api");
|
|
207
|
+
assert.ok(apiBucket, "expected an 'api' deployment bucket on n1");
|
|
208
|
+
assert.equal(apiBucket.ownershipRootKind, "deployment");
|
|
209
|
+
});
|
|
210
|
+
});
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SignalType = "metrics" | "logs" | "traces";
|
|
1
|
+
export type SignalType = "metrics" | "logs" | "traces" | "topology";
|
|
2
2
|
export type HealthStatus = "healthy" | "degraded" | "critical";
|
|
3
3
|
export type Trend = "rising" | "falling" | "stable";
|
|
4
4
|
export type AnomalySeverity = "low" | "medium" | "high";
|
|
@@ -151,6 +151,72 @@ export interface LogResult {
|
|
|
151
151
|
entries: LogEntry[];
|
|
152
152
|
summary: LogSummary;
|
|
153
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* A discrete infrastructure entity discovered by a topology-aware connector.
|
|
156
|
+
*
|
|
157
|
+
* `kind` and the relation strings on Edge are intentionally open (not unions):
|
|
158
|
+
* future connectors (vCenter, NetBox, SNMP, ...) will introduce new kinds and
|
|
159
|
+
* relations. Document common values here, but do not hard-restrict the type.
|
|
160
|
+
*
|
|
161
|
+
* `id` is a stable, human-readable canonical key. For Kubernetes we use
|
|
162
|
+
* `k8s:<kind>:<namespace>/<name>` for namespaced kinds, `k8s:<kind>:<name>`
|
|
163
|
+
* for cluster-scoped kinds. Pod names are ephemeral by design — that's
|
|
164
|
+
* acceptable since pods are short-lived; deployments/nodes/services are
|
|
165
|
+
* stable. Backend identifiers (e.g. K8s metadata.uid) belong in `attributes`.
|
|
166
|
+
*
|
|
167
|
+
* `source` is mandatory so a future entity-resolution layer can merge views
|
|
168
|
+
* from multiple connectors without ambiguity.
|
|
169
|
+
*
|
|
170
|
+
* Common kinds (Kubernetes): "pod", "node", "deployment", "service", "namespace".
|
|
171
|
+
*/
|
|
172
|
+
export interface Resource {
|
|
173
|
+
id: string;
|
|
174
|
+
kind: string;
|
|
175
|
+
name: string;
|
|
176
|
+
source: string;
|
|
177
|
+
labels: Record<string, string>;
|
|
178
|
+
attributes?: Record<string, unknown>;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* A directed relationship between two Resources.
|
|
182
|
+
*
|
|
183
|
+
* Common relations (Kubernetes):
|
|
184
|
+
* - "RUNS_ON" pod -> node, container -> host, vm -> hypervisor
|
|
185
|
+
* - "OWNED_BY" pod -> replicaset/deployment
|
|
186
|
+
* - "ROUTES_TO" service -> pod
|
|
187
|
+
* - "IN_NAMESPACE" pod/service/deployment -> namespace
|
|
188
|
+
*
|
|
189
|
+
* `confidence` is 0..1. For data that comes straight from an authoritative
|
|
190
|
+
* source (K8s API), use 1.0. Inferred relations (e.g. label-based matching)
|
|
191
|
+
* should report lower values.
|
|
192
|
+
*/
|
|
193
|
+
export interface Edge {
|
|
194
|
+
from: string;
|
|
195
|
+
to: string;
|
|
196
|
+
relation: string;
|
|
197
|
+
source: string;
|
|
198
|
+
confidence: number;
|
|
199
|
+
}
|
|
200
|
+
/** Snapshot of the topology graph as known by a single connector. */
|
|
201
|
+
export interface TopologySnapshot {
|
|
202
|
+
source: string;
|
|
203
|
+
resources: Resource[];
|
|
204
|
+
edges: Edge[];
|
|
205
|
+
/** Monotonic counter; bumped on each successful watch event apply. */
|
|
206
|
+
revision: number;
|
|
207
|
+
}
|
|
208
|
+
/** Event emitted by a watching connector when its in-memory graph changes. */
|
|
209
|
+
export type TopologyChangeEvent = {
|
|
210
|
+
type: "resource_added" | "resource_updated" | "resource_removed";
|
|
211
|
+
resource: Resource;
|
|
212
|
+
} | {
|
|
213
|
+
type: "edge_added" | "edge_removed";
|
|
214
|
+
edge: Edge;
|
|
215
|
+
} | {
|
|
216
|
+
type: "resync";
|
|
217
|
+
snapshot: TopologySnapshot;
|
|
218
|
+
};
|
|
219
|
+
export type TopologyChangeListener = (event: TopologyChangeEvent) => void;
|
|
154
220
|
export interface AnomalyReport {
|
|
155
221
|
metric: string;
|
|
156
222
|
severity: AnomalySeverity;
|