@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.
- 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 +53 -0
- package/dist/connectors/kubernetes.js +195 -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-vocabulary.d.ts +41 -0
- package/dist/connectors/topology-vocabulary.js +120 -0
- package/dist/connectors/topology-vocabulary.test.d.ts +1 -0
- package/dist/connectors/topology-vocabulary.test.js +63 -0
- package/dist/connectors/topology.test.d.ts +1 -0
- package/dist/connectors/topology.test.js +165 -0
- package/dist/index.js +74 -0
- 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/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 +823 -0
- package/package.json +2 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { TopologyStore, podResource, podEdges, nodeResource, deploymentResource, deploymentEdges, replicaSetResource, replicaSetEdges, namespaceResource, namespacedId, clusterScopedId, } from "./kubernetes-graph.js";
|
|
2
|
+
import { validateSnapshot } from "./topology-vocabulary.js";
|
|
3
|
+
// Default provider is loaded lazily so tests don't pay the
|
|
4
|
+
// @kubernetes/client-node import cost (and so the module is usable in
|
|
5
|
+
// environments where the SDK isn't installed yet — e.g. unit tests in CI
|
|
6
|
+
// before the dep lands).
|
|
7
|
+
let defaultProvider;
|
|
8
|
+
export function setDefaultInformerFactoryProvider(p) {
|
|
9
|
+
defaultProvider = p;
|
|
10
|
+
}
|
|
11
|
+
async function loadDefaultProvider() {
|
|
12
|
+
if (defaultProvider)
|
|
13
|
+
return defaultProvider;
|
|
14
|
+
const mod = await import("./kubernetes-client.js");
|
|
15
|
+
defaultProvider = mod.createInformerFactory;
|
|
16
|
+
return defaultProvider;
|
|
17
|
+
}
|
|
18
|
+
export class KubernetesConnector {
|
|
19
|
+
type = "kubernetes";
|
|
20
|
+
signalType = "topology";
|
|
21
|
+
name = "";
|
|
22
|
+
store;
|
|
23
|
+
warnedVocab = new Set();
|
|
24
|
+
factory;
|
|
25
|
+
informers = [];
|
|
26
|
+
providerOverride;
|
|
27
|
+
/** Constructor injection used by tests. */
|
|
28
|
+
constructor(provider) {
|
|
29
|
+
this.providerOverride = provider;
|
|
30
|
+
}
|
|
31
|
+
async connect(config) {
|
|
32
|
+
this.name = config.name;
|
|
33
|
+
this.store = new TopologyStore(config.name);
|
|
34
|
+
const provider = this.providerOverride ?? (await loadDefaultProvider());
|
|
35
|
+
this.factory = await provider(config);
|
|
36
|
+
// Wire each informer to the store. Pure builders translate Kube
|
|
37
|
+
// objects → Resource/Edge; the store dedupes and emits diffs.
|
|
38
|
+
const pods = this.factory.pods();
|
|
39
|
+
pods.on("add", (p) => this.applyPod(p));
|
|
40
|
+
pods.on("update", (p) => this.applyPod(p));
|
|
41
|
+
pods.on("delete", (p) => {
|
|
42
|
+
const id = idOfPod(p);
|
|
43
|
+
if (id)
|
|
44
|
+
this.store.removeResource(id);
|
|
45
|
+
});
|
|
46
|
+
pods.on("error", (err) => logWatchError(this.name, "pods", err));
|
|
47
|
+
const nodes = this.factory.nodes();
|
|
48
|
+
nodes.on("add", (n) => this.applyNode(n));
|
|
49
|
+
nodes.on("update", (n) => this.applyNode(n));
|
|
50
|
+
nodes.on("delete", (n) => {
|
|
51
|
+
const id = idOfNode(n);
|
|
52
|
+
if (id)
|
|
53
|
+
this.store.removeResource(id);
|
|
54
|
+
});
|
|
55
|
+
nodes.on("error", (err) => logWatchError(this.name, "nodes", err));
|
|
56
|
+
const deps = this.factory.deployments();
|
|
57
|
+
deps.on("add", (d) => this.applyDeployment(d));
|
|
58
|
+
deps.on("update", (d) => this.applyDeployment(d));
|
|
59
|
+
deps.on("delete", (d) => {
|
|
60
|
+
const id = idOfNamespaced("deployment", d);
|
|
61
|
+
if (id)
|
|
62
|
+
this.store.removeResource(id);
|
|
63
|
+
});
|
|
64
|
+
deps.on("error", (err) => logWatchError(this.name, "deployments", err));
|
|
65
|
+
const rs = this.factory.replicaSets();
|
|
66
|
+
rs.on("add", (r) => this.applyReplicaSet(r));
|
|
67
|
+
rs.on("update", (r) => this.applyReplicaSet(r));
|
|
68
|
+
rs.on("delete", (r) => {
|
|
69
|
+
const id = idOfNamespaced("replicaset", r);
|
|
70
|
+
if (id)
|
|
71
|
+
this.store.removeResource(id);
|
|
72
|
+
});
|
|
73
|
+
rs.on("error", (err) => logWatchError(this.name, "replicasets", err));
|
|
74
|
+
const ns = this.factory.namespaces();
|
|
75
|
+
ns.on("add", (n) => this.applyNamespace(n));
|
|
76
|
+
ns.on("update", (n) => this.applyNamespace(n));
|
|
77
|
+
ns.on("delete", (n) => {
|
|
78
|
+
const name = n.metadata?.name;
|
|
79
|
+
if (name)
|
|
80
|
+
this.store.removeResource(clusterScopedId("namespace", name));
|
|
81
|
+
});
|
|
82
|
+
ns.on("error", (err) => logWatchError(this.name, "namespaces", err));
|
|
83
|
+
this.informers = [pods, nodes, deps, rs, ns];
|
|
84
|
+
await Promise.all(this.informers.map((i) => i.start()));
|
|
85
|
+
}
|
|
86
|
+
applyPod(p) {
|
|
87
|
+
const r = podResource(this.name, p);
|
|
88
|
+
if (!r)
|
|
89
|
+
return;
|
|
90
|
+
this.store.upsertResource(r, podEdges(this.name, p));
|
|
91
|
+
}
|
|
92
|
+
applyNode(n) {
|
|
93
|
+
const r = nodeResource(this.name, n);
|
|
94
|
+
if (!r)
|
|
95
|
+
return;
|
|
96
|
+
this.store.upsertResource(r, []);
|
|
97
|
+
}
|
|
98
|
+
applyDeployment(d) {
|
|
99
|
+
const r = deploymentResource(this.name, d);
|
|
100
|
+
if (!r)
|
|
101
|
+
return;
|
|
102
|
+
this.store.upsertResource(r, deploymentEdges(this.name, d));
|
|
103
|
+
}
|
|
104
|
+
applyReplicaSet(rs) {
|
|
105
|
+
const r = replicaSetResource(this.name, rs);
|
|
106
|
+
if (!r)
|
|
107
|
+
return;
|
|
108
|
+
this.store.upsertResource(r, replicaSetEdges(this.name, rs));
|
|
109
|
+
}
|
|
110
|
+
applyNamespace(n) {
|
|
111
|
+
const r = namespaceResource(this.name, n);
|
|
112
|
+
if (!r)
|
|
113
|
+
return;
|
|
114
|
+
this.store.upsertResource(r, []);
|
|
115
|
+
}
|
|
116
|
+
async healthCheck() {
|
|
117
|
+
if (!this.factory)
|
|
118
|
+
return { status: "down", latencyMs: 0, message: "not connected" };
|
|
119
|
+
const r = await this.factory.healthCheck();
|
|
120
|
+
return { status: r.ok ? "up" : "down", latencyMs: r.latencyMs, message: r.message };
|
|
121
|
+
}
|
|
122
|
+
async disconnect() {
|
|
123
|
+
await Promise.all(this.informers.map((i) => i.stop().catch(() => { })));
|
|
124
|
+
this.informers = [];
|
|
125
|
+
await this.factory?.close().catch(() => { });
|
|
126
|
+
this.factory = undefined;
|
|
127
|
+
}
|
|
128
|
+
// Topology has no metric/service surface — these stay empty/inert.
|
|
129
|
+
getDefaultMetrics() {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
getMetrics() {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
async listServices() {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
// --- Topology capability ---
|
|
139
|
+
async listResources() {
|
|
140
|
+
return this.store?.listResources() ?? [];
|
|
141
|
+
}
|
|
142
|
+
async listEdges() {
|
|
143
|
+
return this.store?.listEdges() ?? [];
|
|
144
|
+
}
|
|
145
|
+
async getTopologySnapshot() {
|
|
146
|
+
const snap = this.store?.snapshot() ?? {
|
|
147
|
+
source: this.name,
|
|
148
|
+
resources: [],
|
|
149
|
+
edges: [],
|
|
150
|
+
revision: 0,
|
|
151
|
+
};
|
|
152
|
+
for (const w of validateSnapshot(snap.resources, snap.edges)) {
|
|
153
|
+
const key = `${w.kind}:${w.value}`;
|
|
154
|
+
if (this.warnedVocab.has(key))
|
|
155
|
+
continue;
|
|
156
|
+
this.warnedVocab.add(key);
|
|
157
|
+
console.warn("topology vocabulary warning (source=%s): %s", this.name, w.message);
|
|
158
|
+
}
|
|
159
|
+
return snap;
|
|
160
|
+
}
|
|
161
|
+
watchTopology(listener) {
|
|
162
|
+
if (!this.store)
|
|
163
|
+
return () => { };
|
|
164
|
+
// Initial resync so subscribers see the current state without
|
|
165
|
+
// racing the next watch event.
|
|
166
|
+
queueMicrotask(() => listener({ type: "resync", snapshot: this.store.snapshot() }));
|
|
167
|
+
return this.store.subscribe(listener);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// --- helpers ---
|
|
171
|
+
function idOfPod(p) {
|
|
172
|
+
const n = p.metadata?.name;
|
|
173
|
+
const ns = p.metadata?.namespace;
|
|
174
|
+
if (!n || !ns)
|
|
175
|
+
return undefined;
|
|
176
|
+
return namespacedId("pod", ns, n);
|
|
177
|
+
}
|
|
178
|
+
function idOfNode(n) {
|
|
179
|
+
return n.metadata?.name ? clusterScopedId("node", n.metadata.name) : undefined;
|
|
180
|
+
}
|
|
181
|
+
function idOfNamespaced(kind, obj) {
|
|
182
|
+
const n = obj.metadata?.name;
|
|
183
|
+
const ns = obj.metadata?.namespace;
|
|
184
|
+
if (!n || !ns)
|
|
185
|
+
return undefined;
|
|
186
|
+
return namespacedId(kind, ns, n);
|
|
187
|
+
}
|
|
188
|
+
function logWatchError(source, kind, err) {
|
|
189
|
+
// AbortError is what makeInformer emits when we cleanly stop the watch
|
|
190
|
+
// (disconnect, process shutdown) — not actually an error to surface.
|
|
191
|
+
const msg = String(err);
|
|
192
|
+
if (msg.includes("AbortError") || /aborted a request/i.test(msg))
|
|
193
|
+
return;
|
|
194
|
+
console.warn("k8s watch error: source=%s kind=%s err=%s", source, kind, msg);
|
|
195
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { KubernetesConnector } from "./kubernetes.js";
|
|
4
|
+
import { isTopologyProvider } from "./interface.js";
|
|
5
|
+
class FakeInformer {
|
|
6
|
+
handlers = {};
|
|
7
|
+
started = false;
|
|
8
|
+
stopped = false;
|
|
9
|
+
on(event, handler) {
|
|
10
|
+
(this.handlers[event] ??= []).push(handler);
|
|
11
|
+
}
|
|
12
|
+
async start() {
|
|
13
|
+
this.started = true;
|
|
14
|
+
}
|
|
15
|
+
async stop() {
|
|
16
|
+
this.stopped = true;
|
|
17
|
+
}
|
|
18
|
+
emit(event, obj) {
|
|
19
|
+
for (const h of this.handlers[event] ?? [])
|
|
20
|
+
h(obj);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function makeFakeFactory() {
|
|
24
|
+
const pods = new FakeInformer();
|
|
25
|
+
const nodes = new FakeInformer();
|
|
26
|
+
const deps = new FakeInformer();
|
|
27
|
+
const rs = new FakeInformer();
|
|
28
|
+
const ns = new FakeInformer();
|
|
29
|
+
const factory = {
|
|
30
|
+
pods: () => pods,
|
|
31
|
+
nodes: () => nodes,
|
|
32
|
+
deployments: () => deps,
|
|
33
|
+
replicaSets: () => rs,
|
|
34
|
+
namespaces: () => ns,
|
|
35
|
+
async healthCheck() {
|
|
36
|
+
return { ok: true, latencyMs: 1 };
|
|
37
|
+
},
|
|
38
|
+
async close() { },
|
|
39
|
+
};
|
|
40
|
+
return { factory, pods, nodes, deps, rs, ns };
|
|
41
|
+
}
|
|
42
|
+
const CFG = {
|
|
43
|
+
name: "test-cluster",
|
|
44
|
+
type: "kubernetes",
|
|
45
|
+
url: "",
|
|
46
|
+
enabled: true,
|
|
47
|
+
};
|
|
48
|
+
describe("KubernetesConnector", () => {
|
|
49
|
+
it("implements the TopologyProvider capability", async () => {
|
|
50
|
+
const { factory } = makeFakeFactory();
|
|
51
|
+
const conn = new KubernetesConnector(async () => factory);
|
|
52
|
+
await conn.connect(CFG);
|
|
53
|
+
assert.equal(isTopologyProvider(conn), true);
|
|
54
|
+
assert.equal(conn.signalType, "topology");
|
|
55
|
+
await conn.disconnect();
|
|
56
|
+
});
|
|
57
|
+
it("starts every informer on connect and stops them on disconnect", async () => {
|
|
58
|
+
const fake = makeFakeFactory();
|
|
59
|
+
const conn = new KubernetesConnector(async () => fake.factory);
|
|
60
|
+
await conn.connect(CFG);
|
|
61
|
+
for (const inf of [fake.pods, fake.nodes, fake.deps, fake.rs, fake.ns]) {
|
|
62
|
+
assert.equal(inf.started, true);
|
|
63
|
+
}
|
|
64
|
+
await conn.disconnect();
|
|
65
|
+
for (const inf of [fake.pods, fake.nodes, fake.deps, fake.rs, fake.ns]) {
|
|
66
|
+
assert.equal(inf.stopped, true);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
it("builds the graph from watch events", async () => {
|
|
70
|
+
const fake = makeFakeFactory();
|
|
71
|
+
const conn = new KubernetesConnector(async () => fake.factory);
|
|
72
|
+
await conn.connect(CFG);
|
|
73
|
+
fake.nodes.emit("add", { metadata: { name: "worker-1" } });
|
|
74
|
+
fake.ns.emit("add", { metadata: { name: "default" } });
|
|
75
|
+
fake.deps.emit("add", { metadata: { name: "checkout", namespace: "default" } });
|
|
76
|
+
fake.rs.emit("add", {
|
|
77
|
+
metadata: {
|
|
78
|
+
name: "checkout-7f89",
|
|
79
|
+
namespace: "default",
|
|
80
|
+
ownerReferences: [{ kind: "Deployment", name: "checkout" }],
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
fake.pods.emit("add", {
|
|
84
|
+
metadata: {
|
|
85
|
+
name: "checkout-7f89d",
|
|
86
|
+
namespace: "default",
|
|
87
|
+
ownerReferences: [{ kind: "ReplicaSet", name: "checkout-7f89" }],
|
|
88
|
+
},
|
|
89
|
+
spec: { nodeName: "worker-1" },
|
|
90
|
+
});
|
|
91
|
+
const snap = await conn.getTopologySnapshot();
|
|
92
|
+
const ids = snap.resources.map((r) => r.id).sort();
|
|
93
|
+
assert.deepEqual(ids, [
|
|
94
|
+
"k8s:deployment:default/checkout",
|
|
95
|
+
"k8s:namespace:default",
|
|
96
|
+
"k8s:node:worker-1",
|
|
97
|
+
"k8s:pod:default/checkout-7f89d",
|
|
98
|
+
"k8s:replicaset:default/checkout-7f89",
|
|
99
|
+
]);
|
|
100
|
+
// Full RCA chain present: pod → rs → deployment, pod → node, * → namespace.
|
|
101
|
+
const e = snap.edges;
|
|
102
|
+
assert.ok(e.some((x) => x.from === "k8s:pod:default/checkout-7f89d" && x.relation === "RUNS_ON"));
|
|
103
|
+
assert.ok(e.some((x) => x.from === "k8s:pod:default/checkout-7f89d" && x.relation === "OWNED_BY"));
|
|
104
|
+
assert.ok(e.some((x) => x.from === "k8s:replicaset:default/checkout-7f89" && x.relation === "OWNED_BY"));
|
|
105
|
+
await conn.disconnect();
|
|
106
|
+
});
|
|
107
|
+
it("removes a pod's edges when the pod is deleted", async () => {
|
|
108
|
+
const fake = makeFakeFactory();
|
|
109
|
+
const conn = new KubernetesConnector(async () => fake.factory);
|
|
110
|
+
await conn.connect(CFG);
|
|
111
|
+
const pod = {
|
|
112
|
+
metadata: { name: "p1", namespace: "default" },
|
|
113
|
+
spec: { nodeName: "n1" },
|
|
114
|
+
};
|
|
115
|
+
fake.pods.emit("add", pod);
|
|
116
|
+
assert.equal((await conn.listEdges()).length > 0, true);
|
|
117
|
+
fake.pods.emit("delete", pod);
|
|
118
|
+
assert.equal((await conn.listResources()).length, 0);
|
|
119
|
+
assert.equal((await conn.listEdges()).length, 0);
|
|
120
|
+
await conn.disconnect();
|
|
121
|
+
});
|
|
122
|
+
it("watchTopology delivers a resync then live diffs", async () => {
|
|
123
|
+
const fake = makeFakeFactory();
|
|
124
|
+
const conn = new KubernetesConnector(async () => fake.factory);
|
|
125
|
+
await conn.connect(CFG);
|
|
126
|
+
fake.nodes.emit("add", { metadata: { name: "n0" } });
|
|
127
|
+
const events = [];
|
|
128
|
+
const unsub = conn.watchTopology((e) => events.push(e));
|
|
129
|
+
await new Promise((r) => setImmediate(r));
|
|
130
|
+
assert.equal(events[0]?.type, "resync");
|
|
131
|
+
fake.nodes.emit("add", { metadata: { name: "n1" } });
|
|
132
|
+
assert.ok(events.some((e) => e.type === "resource_added"));
|
|
133
|
+
unsub();
|
|
134
|
+
await conn.disconnect();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
|
|
|
4
4
|
import { manifestSchema } from "../sdk/manifest-schema.js";
|
|
5
5
|
import { PrometheusConnector } from "./prometheus.js";
|
|
6
6
|
import { LokiConnector } from "./loki.js";
|
|
7
|
+
import { KubernetesConnector } from "./kubernetes.js";
|
|
7
8
|
import { sanitizeForLog } from "../util/sanitize.js";
|
|
8
9
|
import { instrumentConnector } from "../metrics/instrument-connector.js";
|
|
9
10
|
import { loadTrustRoot, verifyIntegrity, verifyManifestSignature, PluginVerificationError, } from "./verify.js";
|
|
@@ -96,6 +97,11 @@ export class PluginLoader {
|
|
|
96
97
|
source: "builtin",
|
|
97
98
|
factory: () => new LokiConnector(),
|
|
98
99
|
});
|
|
100
|
+
this.register({
|
|
101
|
+
name: "kubernetes",
|
|
102
|
+
source: "builtin",
|
|
103
|
+
factory: () => new KubernetesConnector(),
|
|
104
|
+
});
|
|
99
105
|
}
|
|
100
106
|
async loadFilesystem() {
|
|
101
107
|
const dir = this.pluginsDir;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical vocabulary for the connector-agnostic topology graph.
|
|
3
|
+
*
|
|
4
|
+
* The set is intentionally small: only values that are emitted by a shipped
|
|
5
|
+
* connector OR are reserved as the agreed name for a near-term connector.
|
|
6
|
+
* Adding a value is a documentation change in docs/topology-vocabulary.md
|
|
7
|
+
* plus an entry in the const arrays below; never invent a value at the call
|
|
8
|
+
* site without that paper trail.
|
|
9
|
+
*
|
|
10
|
+
* Validation is warn-only by design — a connector that emits an unknown
|
|
11
|
+
* value still works, but the warning shows up in logs and unit tests so
|
|
12
|
+
* vocabulary drift gets caught before it spreads.
|
|
13
|
+
*/
|
|
14
|
+
import type { Edge, Resource } from "../types.js";
|
|
15
|
+
export declare const KINDS: readonly ["pod", "node", "deployment", "replicaset", "namespace", "service", "container", "vm", "host", "hypervisor", "cluster"];
|
|
16
|
+
export type Kind = (typeof KINDS)[number];
|
|
17
|
+
export declare const RELATIONS: readonly ["RUNS_ON", "OWNED_BY", "IN_NAMESPACE", "CALLS", "CONTAINS", "DEPENDS_ON"];
|
|
18
|
+
export type Relation = (typeof RELATIONS)[number];
|
|
19
|
+
export declare function isKnownKind(k: string): k is Kind;
|
|
20
|
+
export declare function isKnownRelation(r: string): r is Relation;
|
|
21
|
+
export interface VocabularyWarning {
|
|
22
|
+
kind: "unknown_resource_kind" | "unknown_relation" | "case_mismatch";
|
|
23
|
+
message: string;
|
|
24
|
+
value: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Lint a single resource. Returns warnings for unknown or miscased `kind`
|
|
28
|
+
* values; an empty array means the resource passes the vocabulary.
|
|
29
|
+
*/
|
|
30
|
+
export declare function validateResource(r: Pick<Resource, "kind">): VocabularyWarning[];
|
|
31
|
+
/**
|
|
32
|
+
* Lint a single edge. Returns warnings for unknown or miscased `relation`
|
|
33
|
+
* values; an empty array means the edge passes the vocabulary.
|
|
34
|
+
*/
|
|
35
|
+
export declare function validateEdge(e: Pick<Edge, "relation">): VocabularyWarning[];
|
|
36
|
+
/**
|
|
37
|
+
* Convenience: lint a full snapshot. Returns the de-duplicated set of
|
|
38
|
+
* warnings (one per distinct value) so a noisy connector does not flood
|
|
39
|
+
* the log on each tick.
|
|
40
|
+
*/
|
|
41
|
+
export declare function validateSnapshot(resources: Pick<Resource, "kind">[], edges: Pick<Edge, "relation">[]): VocabularyWarning[];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical vocabulary for the connector-agnostic topology graph.
|
|
3
|
+
*
|
|
4
|
+
* The set is intentionally small: only values that are emitted by a shipped
|
|
5
|
+
* connector OR are reserved as the agreed name for a near-term connector.
|
|
6
|
+
* Adding a value is a documentation change in docs/topology-vocabulary.md
|
|
7
|
+
* plus an entry in the const arrays below; never invent a value at the call
|
|
8
|
+
* site without that paper trail.
|
|
9
|
+
*
|
|
10
|
+
* Validation is warn-only by design — a connector that emits an unknown
|
|
11
|
+
* value still works, but the warning shows up in logs and unit tests so
|
|
12
|
+
* vocabulary drift gets caught before it spreads.
|
|
13
|
+
*/
|
|
14
|
+
export const KINDS = [
|
|
15
|
+
"pod",
|
|
16
|
+
"node",
|
|
17
|
+
"deployment",
|
|
18
|
+
"replicaset",
|
|
19
|
+
"namespace",
|
|
20
|
+
"service",
|
|
21
|
+
"container",
|
|
22
|
+
"vm",
|
|
23
|
+
"host",
|
|
24
|
+
"hypervisor",
|
|
25
|
+
"cluster",
|
|
26
|
+
];
|
|
27
|
+
export const RELATIONS = [
|
|
28
|
+
"RUNS_ON",
|
|
29
|
+
"OWNED_BY",
|
|
30
|
+
"IN_NAMESPACE",
|
|
31
|
+
"CALLS",
|
|
32
|
+
"CONTAINS",
|
|
33
|
+
"DEPENDS_ON",
|
|
34
|
+
];
|
|
35
|
+
const KIND_SET = new Set(KINDS);
|
|
36
|
+
const RELATION_SET = new Set(RELATIONS);
|
|
37
|
+
export function isKnownKind(k) {
|
|
38
|
+
return KIND_SET.has(k);
|
|
39
|
+
}
|
|
40
|
+
export function isKnownRelation(r) {
|
|
41
|
+
return RELATION_SET.has(r);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Lint a single resource. Returns warnings for unknown or miscased `kind`
|
|
45
|
+
* values; an empty array means the resource passes the vocabulary.
|
|
46
|
+
*/
|
|
47
|
+
export function validateResource(r) {
|
|
48
|
+
const out = [];
|
|
49
|
+
if (!isKnownKind(r.kind)) {
|
|
50
|
+
const lower = r.kind.toLowerCase();
|
|
51
|
+
if (isKnownKind(lower)) {
|
|
52
|
+
out.push({
|
|
53
|
+
kind: "case_mismatch",
|
|
54
|
+
value: r.kind,
|
|
55
|
+
message: `resource kind "${r.kind}" should be lowercase "${lower}" — see docs/topology-vocabulary.md`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
out.push({
|
|
60
|
+
kind: "unknown_resource_kind",
|
|
61
|
+
value: r.kind,
|
|
62
|
+
message: `resource kind "${r.kind}" is not in the canonical vocabulary; either rename or extend docs/topology-vocabulary.md + KINDS`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lint a single edge. Returns warnings for unknown or miscased `relation`
|
|
70
|
+
* values; an empty array means the edge passes the vocabulary.
|
|
71
|
+
*/
|
|
72
|
+
export function validateEdge(e) {
|
|
73
|
+
const out = [];
|
|
74
|
+
if (!isKnownRelation(e.relation)) {
|
|
75
|
+
const upper = e.relation.toUpperCase();
|
|
76
|
+
if (isKnownRelation(upper)) {
|
|
77
|
+
out.push({
|
|
78
|
+
kind: "case_mismatch",
|
|
79
|
+
value: e.relation,
|
|
80
|
+
message: `relation "${e.relation}" should be UPPER_SNAKE "${upper}" — see docs/topology-vocabulary.md`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
out.push({
|
|
85
|
+
kind: "unknown_relation",
|
|
86
|
+
value: e.relation,
|
|
87
|
+
message: `relation "${e.relation}" is not in the canonical vocabulary; either rename or extend docs/topology-vocabulary.md + RELATIONS`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Convenience: lint a full snapshot. Returns the de-duplicated set of
|
|
95
|
+
* warnings (one per distinct value) so a noisy connector does not flood
|
|
96
|
+
* the log on each tick.
|
|
97
|
+
*/
|
|
98
|
+
export function validateSnapshot(resources, edges) {
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const r of resources) {
|
|
102
|
+
for (const w of validateResource(r)) {
|
|
103
|
+
const key = `r:${w.kind}:${w.value}`;
|
|
104
|
+
if (!seen.has(key)) {
|
|
105
|
+
seen.add(key);
|
|
106
|
+
out.push(w);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const e of edges) {
|
|
111
|
+
for (const w of validateEdge(e)) {
|
|
112
|
+
const key = `e:${w.kind}:${w.value}`;
|
|
113
|
+
if (!seen.has(key)) {
|
|
114
|
+
seen.add(key);
|
|
115
|
+
out.push(w);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { KINDS, RELATIONS, isKnownKind, isKnownRelation, validateResource, validateEdge, validateSnapshot, } from "./topology-vocabulary.js";
|
|
4
|
+
test("vocabulary — KINDS and RELATIONS contain what the kubernetes connector emits today", () => {
|
|
5
|
+
for (const k of ["pod", "node", "deployment", "replicaset", "namespace"]) {
|
|
6
|
+
assert.equal(isKnownKind(k), true, `kind "${k}" must be in the canonical vocabulary`);
|
|
7
|
+
}
|
|
8
|
+
for (const r of ["RUNS_ON", "OWNED_BY", "IN_NAMESPACE"]) {
|
|
9
|
+
assert.equal(isKnownRelation(r), true, `relation "${r}" must be in the canonical vocabulary`);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
test("vocabulary — CALLS is reserved for the upcoming trace connector", () => {
|
|
13
|
+
assert.equal(isKnownRelation("CALLS"), true);
|
|
14
|
+
});
|
|
15
|
+
test("validateResource — canonical kinds produce no warnings", () => {
|
|
16
|
+
for (const k of KINDS) {
|
|
17
|
+
assert.deepEqual(validateResource({ kind: k }), []);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
test("validateResource — unknown kind warns", () => {
|
|
21
|
+
const w = validateResource({ kind: "frobnicator" });
|
|
22
|
+
assert.equal(w.length, 1);
|
|
23
|
+
assert.equal(w[0].kind, "unknown_resource_kind");
|
|
24
|
+
assert.equal(w[0].value, "frobnicator");
|
|
25
|
+
});
|
|
26
|
+
test("validateResource — uppercase kind triggers a case-mismatch hint", () => {
|
|
27
|
+
const w = validateResource({ kind: "Pod" });
|
|
28
|
+
assert.equal(w.length, 1);
|
|
29
|
+
assert.equal(w[0].kind, "case_mismatch");
|
|
30
|
+
assert.match(w[0].message, /lowercase "pod"/);
|
|
31
|
+
});
|
|
32
|
+
test("validateEdge — canonical relations produce no warnings", () => {
|
|
33
|
+
for (const r of RELATIONS) {
|
|
34
|
+
assert.deepEqual(validateEdge({ relation: r }), []);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
test("validateEdge — unknown relation warns", () => {
|
|
38
|
+
const w = validateEdge({ relation: "FROBNICATES" });
|
|
39
|
+
assert.equal(w.length, 1);
|
|
40
|
+
assert.equal(w[0].kind, "unknown_relation");
|
|
41
|
+
});
|
|
42
|
+
test("validateEdge — lowercase relation triggers a case-mismatch hint", () => {
|
|
43
|
+
const w = validateEdge({ relation: "runs_on" });
|
|
44
|
+
assert.equal(w.length, 1);
|
|
45
|
+
assert.equal(w[0].kind, "case_mismatch");
|
|
46
|
+
assert.match(w[0].message, /UPPER_SNAKE "RUNS_ON"/);
|
|
47
|
+
});
|
|
48
|
+
test("validateSnapshot — de-duplicates repeated offenders", () => {
|
|
49
|
+
const warnings = validateSnapshot([
|
|
50
|
+
{ kind: "frobnicator" },
|
|
51
|
+
{ kind: "frobnicator" },
|
|
52
|
+
{ kind: "pod" },
|
|
53
|
+
], [
|
|
54
|
+
{ relation: "FROBNICATES" },
|
|
55
|
+
{ relation: "FROBNICATES" },
|
|
56
|
+
{ relation: "RUNS_ON" },
|
|
57
|
+
]);
|
|
58
|
+
assert.equal(warnings.length, 2, `expected one warning per distinct offender, got ${warnings.length}`);
|
|
59
|
+
});
|
|
60
|
+
test("validateSnapshot — a fully canonical snapshot is silent", () => {
|
|
61
|
+
const warnings = validateSnapshot([{ kind: "pod" }, { kind: "node" }, { kind: "deployment" }], [{ relation: "RUNS_ON" }, { relation: "OWNED_BY" }, { relation: "IN_NAMESPACE" }]);
|
|
62
|
+
assert.deepEqual(warnings, []);
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|