@thotischner/observability-mcp 1.6.0 → 1.7.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.
@@ -24,6 +24,16 @@ sources:
24
24
  url: http://loki:3100
25
25
  enabled: true
26
26
 
27
+ # Kubernetes topology source. Reads $KUBECONFIG which the compose file
28
+ # points at the in-network kubeconfig written by the k3s-init
29
+ # container. In a non-demo run the file is absent and the connector
30
+ # reports "down" cleanly. url/auth are unused — the connector reads
31
+ # server + credentials from kubeconfig.
32
+ - name: kubernetes
33
+ type: kubernetes
34
+ url: ""
35
+ enabled: true
36
+
27
37
  settings:
28
38
  checkIntervalMs: 30000
29
39
  defaultSensitivity: medium
@@ -1,4 +1,4 @@
1
- import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, SourceConfig, MetricDefinition } from "../types.js";
1
+ import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, 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,4 +14,14 @@ 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
+ /** Current in-memory resource list. Should be O(1) — backed by the watch cache. */
18
+ listResources?(): Promise<Resource[]>;
19
+ /** Current in-memory edge list. Should be O(1) — backed by the watch cache. */
20
+ listEdges?(): Promise<Edge[]>;
21
+ /** Atomic snapshot of resources+edges with a monotonic revision counter. */
22
+ getTopologySnapshot?(): Promise<TopologySnapshot>;
23
+ /** Subscribe to incremental changes. Returns an unsubscribe function. */
24
+ watchTopology?(listener: TopologyChangeListener): () => void;
17
25
  }
26
+ /** Narrowing guard: connectors that implement the topology capability. */
27
+ export declare function isTopologyProvider(c: ObservabilityConnector): c is ObservabilityConnector & Required<Pick<ObservabilityConnector, "listResources" | "listEdges" | "getTopologySnapshot" | "watchTopology">>;
@@ -1 +1,7 @@
1
- export {};
1
+ /** Narrowing guard: connectors that implement the topology capability. */
2
+ export function isTopologyProvider(c) {
3
+ return (typeof c.listResources === "function" &&
4
+ typeof c.listEdges === "function" &&
5
+ typeof c.getTopologySnapshot === "function" &&
6
+ typeof c.watchTopology === "function");
7
+ }
@@ -0,0 +1,3 @@
1
+ import type { SourceConfig } from "../types.js";
2
+ import type { InformerFactory } from "./kubernetes.js";
3
+ export declare function createInformerFactory(config: SourceConfig): Promise<InformerFactory>;
@@ -0,0 +1,90 @@
1
+ // Thin adapter around @kubernetes/client-node. Kept in its own file so
2
+ // the connector class itself stays SDK-free and unit-testable. The
3
+ // loader imports this lazily so installations that don't configure a
4
+ // kubernetes source don't pay the import cost.
5
+ import { KubeConfig, CoreV1Api, AppsV1Api, makeInformer, } from "@kubernetes/client-node";
6
+ function buildKubeConfig(config) {
7
+ const kc = new KubeConfig();
8
+ if (config.auth?.type === "bearer" && config.auth.token && config.url) {
9
+ // Explicit cluster + bearer-token config (e.g. remote cluster).
10
+ kc.loadFromOptions({
11
+ clusters: [
12
+ {
13
+ name: config.name,
14
+ server: config.url,
15
+ skipTLSVerify: !!(config.tls?.skipVerify ?? config.tlsSkipVerify),
16
+ caFile: config.tls?.caCert,
17
+ },
18
+ ],
19
+ users: [{ name: config.name, token: config.auth.token }],
20
+ contexts: [{ name: config.name, cluster: config.name, user: config.name }],
21
+ currentContext: config.name,
22
+ });
23
+ return kc;
24
+ }
25
+ // Fall through: in-cluster (ServiceAccount) or KUBECONFIG/~/.kube/config.
26
+ // In @kubernetes/client-node v1.x, loadFromCluster() no longer throws
27
+ // when env+files are missing — it silently produces "https://undefined:undefined".
28
+ // Detect in-cluster context by the well-known env vars instead.
29
+ const inCluster = !!process.env.KUBERNETES_SERVICE_HOST && !!process.env.KUBERNETES_SERVICE_PORT;
30
+ if (inCluster) {
31
+ kc.loadFromCluster();
32
+ }
33
+ else {
34
+ kc.loadFromDefault();
35
+ }
36
+ return kc;
37
+ }
38
+ function wrapInformer(inf) {
39
+ return {
40
+ on(event, handler) {
41
+ // @kubernetes/client-node Informer emits 'add' | 'update' | 'delete' | 'error'.
42
+ inf.on(event, handler);
43
+ },
44
+ async start() {
45
+ await inf.start();
46
+ },
47
+ async stop() {
48
+ await inf.stop();
49
+ },
50
+ };
51
+ }
52
+ export async function createInformerFactory(config) {
53
+ const kc = buildKubeConfig(config);
54
+ const core = kc.makeApiClient(CoreV1Api);
55
+ const apps = kc.makeApiClient(AppsV1Api);
56
+ // In @kubernetes/client-node v1+, list*() resolves directly to the
57
+ // KubernetesListObject (with `items`), not the v0.x `{ body, response }`.
58
+ const podInformer = makeInformer(kc, "/api/v1/pods", () => core.listPodForAllNamespaces());
59
+ const nodeInformer = makeInformer(kc, "/api/v1/nodes", () => core.listNode());
60
+ const nsInformer = makeInformer(kc, "/api/v1/namespaces", () => core.listNamespace());
61
+ const depInformer = makeInformer(kc, "/apis/apps/v1/deployments", () => apps.listDeploymentForAllNamespaces());
62
+ const rsInformer = makeInformer(kc, "/apis/apps/v1/replicasets", () => apps.listReplicaSetForAllNamespaces());
63
+ return {
64
+ pods: () => wrapInformer(podInformer),
65
+ nodes: () => wrapInformer(nodeInformer),
66
+ deployments: () => wrapInformer(depInformer),
67
+ replicaSets: () => wrapInformer(rsInformer),
68
+ namespaces: () => wrapInformer(nsInformer),
69
+ async healthCheck() {
70
+ const start = Date.now();
71
+ try {
72
+ // /version is unauthenticated on most clusters and exists on all.
73
+ await core.getAPIResources();
74
+ return { ok: true, latencyMs: Date.now() - start };
75
+ }
76
+ catch (err) {
77
+ return { ok: false, latencyMs: Date.now() - start, message: String(err) };
78
+ }
79
+ },
80
+ async close() {
81
+ await Promise.all([
82
+ podInformer.stop().catch(() => { }),
83
+ nodeInformer.stop().catch(() => { }),
84
+ nsInformer.stop().catch(() => { }),
85
+ depInformer.stop().catch(() => { }),
86
+ rsInformer.stop().catch(() => { }),
87
+ ]);
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,73 @@
1
+ import type { Resource, Edge, TopologySnapshot, TopologyChangeEvent } from "../types.js";
2
+ export interface KubeObjectMeta {
3
+ name?: string;
4
+ namespace?: string;
5
+ uid?: string;
6
+ labels?: Record<string, string>;
7
+ ownerReferences?: Array<{
8
+ kind: string;
9
+ name: string;
10
+ uid?: string;
11
+ }>;
12
+ }
13
+ export interface KubePod {
14
+ metadata?: KubeObjectMeta;
15
+ spec?: {
16
+ nodeName?: string;
17
+ };
18
+ status?: {
19
+ phase?: string;
20
+ };
21
+ }
22
+ export interface KubeNode {
23
+ metadata?: KubeObjectMeta;
24
+ status?: {
25
+ conditions?: Array<{
26
+ type: string;
27
+ status: string;
28
+ }>;
29
+ };
30
+ }
31
+ export interface KubeDeployment {
32
+ metadata?: KubeObjectMeta;
33
+ }
34
+ export interface KubeReplicaSet {
35
+ metadata?: KubeObjectMeta;
36
+ }
37
+ export interface KubeNamespace {
38
+ metadata?: KubeObjectMeta;
39
+ }
40
+ export declare function namespacedId(kind: string, namespace: string, name: string): string;
41
+ export declare function clusterScopedId(kind: string, name: string): string;
42
+ export declare function podResource(source: string, pod: KubePod): Resource | undefined;
43
+ export declare function nodeResource(source: string, node: KubeNode): Resource | undefined;
44
+ export declare function deploymentResource(source: string, d: KubeDeployment): Resource | undefined;
45
+ export declare function replicaSetResource(source: string, rs: KubeReplicaSet): Resource | undefined;
46
+ export declare function namespaceResource(source: string, ns: KubeNamespace): Resource | undefined;
47
+ export declare function podEdges(source: string, pod: KubePod): Edge[];
48
+ export declare function replicaSetEdges(source: string, rs: KubeReplicaSet): Edge[];
49
+ export declare function deploymentEdges(source: string, d: KubeDeployment): Edge[];
50
+ /**
51
+ * In-memory store of the connector's current view of the cluster. The
52
+ * watch event handlers call add/remove and the store emits incremental
53
+ * change events to subscribers.
54
+ *
55
+ * Edges are tracked per-owner-resource so when a pod is deleted we can
56
+ * cleanly remove its outgoing edges without scanning the whole edge map.
57
+ */
58
+ export declare class TopologyStore {
59
+ private readonly source;
60
+ private resources;
61
+ private edgesByOwner;
62
+ private rev;
63
+ private listeners;
64
+ constructor(source: string);
65
+ get revision(): number;
66
+ subscribe(listener: (e: TopologyChangeEvent) => void): () => void;
67
+ private emit;
68
+ upsertResource(r: Resource, ownedEdges: Edge[]): void;
69
+ removeResource(id: string): void;
70
+ listResources(): Resource[];
71
+ listEdges(): Edge[];
72
+ snapshot(): TopologySnapshot;
73
+ }
@@ -0,0 +1,257 @@
1
+ // Pure graph-building helpers for the Kubernetes connector.
2
+ //
3
+ // All Kubernetes object → Resource/Edge translation lives here so it can
4
+ // be unit-tested without a live API server. The connector class
5
+ // (kubernetes.ts) wires these into the watch event handlers.
6
+ // --- Canonical IDs ------------------------------------------------------
7
+ export function namespacedId(kind, namespace, name) {
8
+ return `k8s:${kind}:${namespace}/${name}`;
9
+ }
10
+ export function clusterScopedId(kind, name) {
11
+ return `k8s:${kind}:${name}`;
12
+ }
13
+ // --- Resource builders --------------------------------------------------
14
+ const baseLabels = (m) => ({ ...(m?.labels ?? {}) });
15
+ const baseAttrs = (m) => m?.uid ? { uid: m.uid } : {};
16
+ export function podResource(source, pod) {
17
+ const name = pod.metadata?.name;
18
+ const namespace = pod.metadata?.namespace;
19
+ if (!name || !namespace)
20
+ return undefined;
21
+ return {
22
+ id: namespacedId("pod", namespace, name),
23
+ kind: "pod",
24
+ name,
25
+ source,
26
+ labels: baseLabels(pod.metadata),
27
+ attributes: {
28
+ ...baseAttrs(pod.metadata),
29
+ ...(pod.status?.phase ? { phase: pod.status.phase } : {}),
30
+ ...(pod.spec?.nodeName ? { nodeName: pod.spec.nodeName } : {}),
31
+ },
32
+ };
33
+ }
34
+ export function nodeResource(source, node) {
35
+ const name = node.metadata?.name;
36
+ if (!name)
37
+ return undefined;
38
+ const ready = node.status?.conditions?.find((c) => c.type === "Ready")?.status;
39
+ return {
40
+ id: clusterScopedId("node", name),
41
+ kind: "node",
42
+ name,
43
+ source,
44
+ labels: baseLabels(node.metadata),
45
+ attributes: { ...baseAttrs(node.metadata), ...(ready ? { ready } : {}) },
46
+ };
47
+ }
48
+ export function deploymentResource(source, d) {
49
+ const name = d.metadata?.name;
50
+ const namespace = d.metadata?.namespace;
51
+ if (!name || !namespace)
52
+ return undefined;
53
+ return {
54
+ id: namespacedId("deployment", namespace, name),
55
+ kind: "deployment",
56
+ name,
57
+ source,
58
+ labels: baseLabels(d.metadata),
59
+ attributes: baseAttrs(d.metadata),
60
+ };
61
+ }
62
+ export function replicaSetResource(source, rs) {
63
+ const name = rs.metadata?.name;
64
+ const namespace = rs.metadata?.namespace;
65
+ if (!name || !namespace)
66
+ return undefined;
67
+ return {
68
+ id: namespacedId("replicaset", namespace, name),
69
+ kind: "replicaset",
70
+ name,
71
+ source,
72
+ labels: baseLabels(rs.metadata),
73
+ attributes: baseAttrs(rs.metadata),
74
+ };
75
+ }
76
+ export function namespaceResource(source, ns) {
77
+ const name = ns.metadata?.name;
78
+ if (!name)
79
+ return undefined;
80
+ return {
81
+ id: clusterScopedId("namespace", name),
82
+ kind: "namespace",
83
+ name,
84
+ source,
85
+ labels: baseLabels(ns.metadata),
86
+ attributes: baseAttrs(ns.metadata),
87
+ };
88
+ }
89
+ // --- Edge builders ------------------------------------------------------
90
+ const KNOWN_OWNER_KINDS = {
91
+ Deployment: "deployment",
92
+ ReplicaSet: "replicaset",
93
+ StatefulSet: "statefulset",
94
+ DaemonSet: "daemonset",
95
+ Job: "job",
96
+ CronJob: "cronjob",
97
+ };
98
+ function ownerEdges(source, fromId, meta, namespace) {
99
+ const out = [];
100
+ for (const ref of meta?.ownerReferences ?? []) {
101
+ const kind = KNOWN_OWNER_KINDS[ref.kind];
102
+ if (!kind)
103
+ continue;
104
+ out.push({
105
+ from: fromId,
106
+ to: namespacedId(kind, namespace, ref.name),
107
+ relation: "OWNED_BY",
108
+ source,
109
+ confidence: 1.0,
110
+ });
111
+ }
112
+ return out;
113
+ }
114
+ export function podEdges(source, pod) {
115
+ const name = pod.metadata?.name;
116
+ const namespace = pod.metadata?.namespace;
117
+ if (!name || !namespace)
118
+ return [];
119
+ const fromId = namespacedId("pod", namespace, name);
120
+ const edges = [
121
+ {
122
+ from: fromId,
123
+ to: clusterScopedId("namespace", namespace),
124
+ relation: "IN_NAMESPACE",
125
+ source,
126
+ confidence: 1.0,
127
+ },
128
+ ];
129
+ if (pod.spec?.nodeName) {
130
+ edges.push({
131
+ from: fromId,
132
+ to: clusterScopedId("node", pod.spec.nodeName),
133
+ relation: "RUNS_ON",
134
+ source,
135
+ confidence: 1.0,
136
+ });
137
+ }
138
+ edges.push(...ownerEdges(source, fromId, pod.metadata, namespace));
139
+ return edges;
140
+ }
141
+ export function replicaSetEdges(source, rs) {
142
+ const name = rs.metadata?.name;
143
+ const namespace = rs.metadata?.namespace;
144
+ if (!name || !namespace)
145
+ return [];
146
+ const fromId = namespacedId("replicaset", namespace, name);
147
+ return [
148
+ {
149
+ from: fromId,
150
+ to: clusterScopedId("namespace", namespace),
151
+ relation: "IN_NAMESPACE",
152
+ source,
153
+ confidence: 1.0,
154
+ },
155
+ ...ownerEdges(source, fromId, rs.metadata, namespace),
156
+ ];
157
+ }
158
+ export function deploymentEdges(source, d) {
159
+ const name = d.metadata?.name;
160
+ const namespace = d.metadata?.namespace;
161
+ if (!name || !namespace)
162
+ return [];
163
+ return [
164
+ {
165
+ from: namespacedId("deployment", namespace, name),
166
+ to: clusterScopedId("namespace", namespace),
167
+ relation: "IN_NAMESPACE",
168
+ source,
169
+ confidence: 1.0,
170
+ },
171
+ ];
172
+ }
173
+ // --- Graph store --------------------------------------------------------
174
+ /**
175
+ * In-memory store of the connector's current view of the cluster. The
176
+ * watch event handlers call add/remove and the store emits incremental
177
+ * change events to subscribers.
178
+ *
179
+ * Edges are tracked per-owner-resource so when a pod is deleted we can
180
+ * cleanly remove its outgoing edges without scanning the whole edge map.
181
+ */
182
+ export class TopologyStore {
183
+ source;
184
+ resources = new Map();
185
+ edgesByOwner = new Map(); // ownerId → outgoing edges
186
+ rev = 0;
187
+ listeners = new Set();
188
+ constructor(source) {
189
+ this.source = source;
190
+ }
191
+ get revision() {
192
+ return this.rev;
193
+ }
194
+ subscribe(listener) {
195
+ this.listeners.add(listener);
196
+ return () => this.listeners.delete(listener);
197
+ }
198
+ emit(e) {
199
+ this.rev++;
200
+ for (const l of this.listeners) {
201
+ try {
202
+ l(e);
203
+ }
204
+ catch {
205
+ // listener errors must not poison the watch loop
206
+ }
207
+ }
208
+ }
209
+ upsertResource(r, ownedEdges) {
210
+ const existed = this.resources.has(r.id);
211
+ this.resources.set(r.id, r);
212
+ // Replace edges originating from this resource atomically.
213
+ const prev = this.edgesByOwner.get(r.id) ?? [];
214
+ this.edgesByOwner.set(r.id, ownedEdges);
215
+ this.emit({ type: existed ? "resource_updated" : "resource_added", resource: r });
216
+ // Diff edges so subscribers see precise add/remove.
217
+ const prevKeys = new Set(prev.map(edgeKey));
218
+ const nextKeys = new Set(ownedEdges.map(edgeKey));
219
+ for (const e of prev)
220
+ if (!nextKeys.has(edgeKey(e)))
221
+ this.emit({ type: "edge_removed", edge: e });
222
+ for (const e of ownedEdges)
223
+ if (!prevKeys.has(edgeKey(e)))
224
+ this.emit({ type: "edge_added", edge: e });
225
+ }
226
+ removeResource(id) {
227
+ const r = this.resources.get(id);
228
+ if (!r)
229
+ return;
230
+ this.resources.delete(id);
231
+ const prev = this.edgesByOwner.get(id) ?? [];
232
+ this.edgesByOwner.delete(id);
233
+ for (const e of prev)
234
+ this.emit({ type: "edge_removed", edge: e });
235
+ this.emit({ type: "resource_removed", resource: r });
236
+ }
237
+ listResources() {
238
+ return Array.from(this.resources.values());
239
+ }
240
+ listEdges() {
241
+ const out = [];
242
+ for (const arr of this.edgesByOwner.values())
243
+ out.push(...arr);
244
+ return out;
245
+ }
246
+ snapshot() {
247
+ return {
248
+ source: this.source,
249
+ resources: this.listResources(),
250
+ edges: this.listEdges(),
251
+ revision: this.rev,
252
+ };
253
+ }
254
+ }
255
+ function edgeKey(e) {
256
+ return `${e.from}|${e.relation}|${e.to}`;
257
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,150 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { TopologyStore, podResource, podEdges, nodeResource, deploymentResource, replicaSetResource, replicaSetEdges, namespacedId, clusterScopedId, } from "./kubernetes-graph.js";
4
+ const SRC = "test-cluster";
5
+ const samplePod = {
6
+ metadata: {
7
+ name: "checkout-7f89d",
8
+ namespace: "default",
9
+ uid: "pod-uid-1",
10
+ labels: { app: "checkout" },
11
+ ownerReferences: [{ kind: "ReplicaSet", name: "checkout-7f89", uid: "rs-uid-1" }],
12
+ },
13
+ spec: { nodeName: "worker-1" },
14
+ status: { phase: "Running" },
15
+ };
16
+ describe("kubernetes-graph: resource builders", () => {
17
+ it("podResource produces a stable namespaced ID and preserves labels/uid", () => {
18
+ const r = podResource(SRC, samplePod);
19
+ assert.ok(r);
20
+ assert.equal(r.id, "k8s:pod:default/checkout-7f89d");
21
+ assert.equal(r.kind, "pod");
22
+ assert.equal(r.source, SRC);
23
+ assert.deepEqual(r.labels, { app: "checkout" });
24
+ assert.equal(r.attributes.uid, "pod-uid-1");
25
+ assert.equal(r.attributes.phase, "Running");
26
+ });
27
+ it("returns undefined when metadata is missing", () => {
28
+ assert.equal(podResource(SRC, {}), undefined);
29
+ assert.equal(podResource(SRC, { metadata: { name: "x" } }), undefined); // no namespace
30
+ });
31
+ it("nodeResource is cluster-scoped (no namespace in ID)", () => {
32
+ const r = nodeResource(SRC, {
33
+ metadata: { name: "worker-1" },
34
+ status: { conditions: [{ type: "Ready", status: "True" }] },
35
+ });
36
+ assert.equal(r.id, "k8s:node:worker-1");
37
+ assert.equal(r.attributes.ready, "True");
38
+ });
39
+ it("deploymentResource builds a namespaced ID", () => {
40
+ const r = deploymentResource(SRC, {
41
+ metadata: { name: "checkout", namespace: "default" },
42
+ });
43
+ assert.equal(r.id, "k8s:deployment:default/checkout");
44
+ });
45
+ it("replicaSetResource builds a namespaced ID", () => {
46
+ const r = replicaSetResource(SRC, {
47
+ metadata: { name: "checkout-7f89", namespace: "default" },
48
+ });
49
+ assert.equal(r.id, "k8s:replicaset:default/checkout-7f89");
50
+ });
51
+ });
52
+ describe("kubernetes-graph: edge builders", () => {
53
+ it("podEdges emits RUNS_ON, IN_NAMESPACE and OWNED_BY", () => {
54
+ const edges = podEdges(SRC, samplePod);
55
+ const rels = edges.map((e) => e.relation).sort();
56
+ assert.deepEqual(rels, ["IN_NAMESPACE", "OWNED_BY", "RUNS_ON"]);
57
+ const runs = edges.find((e) => e.relation === "RUNS_ON");
58
+ assert.equal(runs.to, "k8s:node:worker-1");
59
+ assert.equal(runs.confidence, 1.0);
60
+ assert.equal(runs.source, SRC);
61
+ const owned = edges.find((e) => e.relation === "OWNED_BY");
62
+ assert.equal(owned.to, "k8s:replicaset:default/checkout-7f89");
63
+ });
64
+ it("podEdges skips RUNS_ON when the pod has no nodeName yet (Pending)", () => {
65
+ const pending = {
66
+ metadata: { name: "p", namespace: "default" },
67
+ status: { phase: "Pending" },
68
+ };
69
+ const edges = podEdges(SRC, pending);
70
+ assert.equal(edges.find((e) => e.relation === "RUNS_ON"), undefined);
71
+ assert.ok(edges.find((e) => e.relation === "IN_NAMESPACE"));
72
+ });
73
+ it("replicaSetEdges chains OWNED_BY to a Deployment", () => {
74
+ const edges = replicaSetEdges(SRC, {
75
+ metadata: {
76
+ name: "checkout-7f89",
77
+ namespace: "default",
78
+ ownerReferences: [{ kind: "Deployment", name: "checkout" }],
79
+ },
80
+ });
81
+ const owned = edges.find((e) => e.relation === "OWNED_BY");
82
+ assert.ok(owned);
83
+ assert.equal(owned.to, "k8s:deployment:default/checkout");
84
+ });
85
+ it("ignores unknown owner kinds (forward-compat)", () => {
86
+ const edges = podEdges(SRC, {
87
+ metadata: {
88
+ name: "p",
89
+ namespace: "default",
90
+ ownerReferences: [{ kind: "FrobnicatorSet", name: "x" }],
91
+ },
92
+ });
93
+ assert.equal(edges.find((e) => e.relation === "OWNED_BY"), undefined);
94
+ });
95
+ });
96
+ describe("TopologyStore", () => {
97
+ it("upsertResource adds resource + edges and emits diffs", () => {
98
+ const store = new TopologyStore(SRC);
99
+ const events = [];
100
+ store.subscribe((e) => events.push(e.type));
101
+ const r = podResource(SRC, samplePod);
102
+ store.upsertResource(r, podEdges(SRC, samplePod));
103
+ assert.equal(store.listResources().length, 1);
104
+ assert.equal(store.listEdges().length, 3);
105
+ assert.ok(events.includes("resource_added"));
106
+ assert.equal(events.filter((t) => t === "edge_added").length, 3);
107
+ assert.ok(store.revision > 0);
108
+ });
109
+ it("update replaces edges atomically — emits removed/added for diff only", () => {
110
+ const store = new TopologyStore(SRC);
111
+ store.upsertResource(podResource(SRC, samplePod), podEdges(SRC, samplePod));
112
+ const events = [];
113
+ store.subscribe((e) => events.push(e.type));
114
+ // Pod moved to a new node — RUNS_ON edge should be replaced, others stable.
115
+ const moved = { ...samplePod, spec: { nodeName: "worker-2" } };
116
+ store.upsertResource(podResource(SRC, moved), podEdges(SRC, moved));
117
+ assert.equal(events.filter((t) => t === "edge_added").length, 1);
118
+ assert.equal(events.filter((t) => t === "edge_removed").length, 1);
119
+ assert.equal(events.filter((t) => t === "resource_updated").length, 1);
120
+ const runs = store.listEdges().find((e) => e.relation === "RUNS_ON");
121
+ assert.equal(runs.to, "k8s:node:worker-2");
122
+ });
123
+ it("removeResource drops the resource and all its outgoing edges", () => {
124
+ const store = new TopologyStore(SRC);
125
+ store.upsertResource(podResource(SRC, samplePod), podEdges(SRC, samplePod));
126
+ const id = namespacedId("pod", "default", "checkout-7f89d");
127
+ store.removeResource(id);
128
+ assert.equal(store.listResources().length, 0);
129
+ assert.equal(store.listEdges().length, 0);
130
+ });
131
+ it("snapshot() carries the current revision counter", () => {
132
+ const store = new TopologyStore(SRC);
133
+ const r0 = store.revision;
134
+ store.upsertResource(nodeResource(SRC, { metadata: { name: "n1" } }), []);
135
+ const snap = store.snapshot();
136
+ assert.ok(snap.revision > r0);
137
+ assert.equal(snap.source, SRC);
138
+ assert.equal(snap.resources[0].id, clusterScopedId("node", "n1"));
139
+ });
140
+ it("subscriber errors don't kill the store", () => {
141
+ const store = new TopologyStore(SRC);
142
+ store.subscribe(() => {
143
+ throw new Error("boom");
144
+ });
145
+ let saw = 0;
146
+ store.subscribe(() => saw++);
147
+ store.upsertResource(nodeResource(SRC, { metadata: { name: "n1" } }), []);
148
+ assert.ok(saw > 0);
149
+ });
150
+ });
@@ -0,0 +1,53 @@
1
+ import type { ObservabilityConnector } from "./interface.js";
2
+ import type { SourceConfig, ConnectorHealth, ServiceInfo, MetricDefinition, Resource, Edge, TopologySnapshot, TopologyChangeListener, SignalType } from "../types.js";
3
+ import { type KubePod, type KubeNode, type KubeDeployment, type KubeReplicaSet, type KubeNamespace } from "./kubernetes-graph.js";
4
+ export interface Informer<T> {
5
+ on(event: "add" | "update", handler: (obj: T) => void): void;
6
+ on(event: "delete", handler: (obj: T) => void): void;
7
+ on(event: "error", handler: (err: unknown) => void): void;
8
+ start(): Promise<void>;
9
+ stop(): Promise<void>;
10
+ }
11
+ export interface InformerFactory {
12
+ pods(): Informer<KubePod>;
13
+ nodes(): Informer<KubeNode>;
14
+ deployments(): Informer<KubeDeployment>;
15
+ replicaSets(): Informer<KubeReplicaSet>;
16
+ namespaces(): Informer<KubeNamespace>;
17
+ /** Cheap health probe — should hit /version or /healthz. */
18
+ healthCheck(): Promise<{
19
+ ok: boolean;
20
+ latencyMs: number;
21
+ message?: string;
22
+ }>;
23
+ close(): Promise<void>;
24
+ }
25
+ export type InformerFactoryProvider = (config: SourceConfig) => Promise<InformerFactory>;
26
+ export declare function setDefaultInformerFactoryProvider(p: InformerFactoryProvider): void;
27
+ export declare class KubernetesConnector implements ObservabilityConnector {
28
+ readonly type = "kubernetes";
29
+ readonly signalType: SignalType;
30
+ name: string;
31
+ private store;
32
+ private warnedVocab;
33
+ private factory?;
34
+ private informers;
35
+ private providerOverride?;
36
+ /** Constructor injection used by tests. */
37
+ constructor(provider?: InformerFactoryProvider);
38
+ connect(config: SourceConfig): Promise<void>;
39
+ private applyPod;
40
+ private applyNode;
41
+ private applyDeployment;
42
+ private applyReplicaSet;
43
+ private applyNamespace;
44
+ healthCheck(): Promise<ConnectorHealth>;
45
+ disconnect(): Promise<void>;
46
+ getDefaultMetrics(): MetricDefinition[];
47
+ getMetrics(): MetricDefinition[];
48
+ listServices(): Promise<ServiceInfo[]>;
49
+ listResources(): Promise<Resource[]>;
50
+ listEdges(): Promise<Edge[]>;
51
+ getTopologySnapshot(): Promise<TopologySnapshot>;
52
+ watchTopology(listener: TopologyChangeListener): () => void;
53
+ }