@intentius/chant-lexicon-k8s 0.95.1 → 0.96.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.
Files changed (61) hide show
  1. package/dist/composites/catalog.d.ts.map +1 -1
  2. package/dist/composites/index.d.ts +4 -0
  3. package/dist/composites/index.d.ts.map +1 -1
  4. package/dist/composites/monitored-service.d.ts +19 -0
  5. package/dist/composites/monitored-service.d.ts.map +1 -1
  6. package/dist/composites/otel-collector-agent.d.ts +22 -0
  7. package/dist/composites/otel-collector-agent.d.ts.map +1 -1
  8. package/dist/composites/otel-collector-gateway.d.ts +173 -0
  9. package/dist/composites/otel-collector-gateway.d.ts.map +1 -0
  10. package/dist/composites/otel-collector-shape.d.ts +73 -0
  11. package/dist/composites/otel-collector-shape.d.ts.map +1 -0
  12. package/dist/composites/otel-collector.d.ts +19 -1
  13. package/dist/composites/otel-collector.d.ts.map +1 -1
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/integrity.json +7 -3
  17. package/dist/lint/audit-catalog.d.ts.map +1 -1
  18. package/dist/lint/post-synth/index.d.ts.map +1 -1
  19. package/dist/lint/post-synth/otel-placement-helpers.d.ts +103 -0
  20. package/dist/lint/post-synth/otel-placement-helpers.d.ts.map +1 -0
  21. package/dist/lint/post-synth/wk8601.d.ts +17 -0
  22. package/dist/lint/post-synth/wk8601.d.ts.map +1 -0
  23. package/dist/lint/post-synth/wk8602.d.ts +23 -0
  24. package/dist/lint/post-synth/wk8602.d.ts.map +1 -0
  25. package/dist/lint/post-synth/wk8603.d.ts +19 -0
  26. package/dist/lint/post-synth/wk8603.d.ts.map +1 -0
  27. package/dist/manifest.json +1 -1
  28. package/dist/okf/index.md +3 -0
  29. package/dist/okf/rules/WK8601.md +15 -0
  30. package/dist/okf/rules/WK8602.md +16 -0
  31. package/dist/okf/rules/WK8603.md +15 -0
  32. package/dist/okf/types/DaemonSet.md +3 -0
  33. package/dist/okf/types/Service.md +1 -0
  34. package/dist/rules/otel-placement-helpers.ts +277 -0
  35. package/dist/rules/wk8601.ts +44 -0
  36. package/dist/rules/wk8602.ts +133 -0
  37. package/dist/rules/wk8603.ts +66 -0
  38. package/dist/serializer.d.ts.map +1 -1
  39. package/dist/skills/chant-k8s.md +2 -0
  40. package/package.json +4 -3
  41. package/src/composites/__snapshots__/gke-otel-collector.snapshot.test.ts.snap +401 -0
  42. package/src/composites/catalog.ts +109 -0
  43. package/src/composites/gke-otel-collector.snapshot.test.ts +51 -0
  44. package/src/composites/index.ts +9 -0
  45. package/src/composites/monitored-service.ts +40 -16
  46. package/src/composites/otel-collector-agent.ts +62 -26
  47. package/src/composites/otel-collector-gateway.test.ts +260 -0
  48. package/src/composites/otel-collector-gateway.ts +325 -0
  49. package/src/composites/otel-collector-shape.ts +141 -0
  50. package/src/composites/otel-collector.ts +74 -31
  51. package/src/index.ts +3 -0
  52. package/src/lint/audit-catalog.ts +3 -0
  53. package/src/lint/post-synth/index.ts +6 -0
  54. package/src/lint/post-synth/otel-placement-helpers.ts +277 -0
  55. package/src/lint/post-synth/otel-placement.test.ts +401 -0
  56. package/src/lint/post-synth/wk8601.ts +44 -0
  57. package/src/lint/post-synth/wk8602.ts +133 -0
  58. package/src/lint/post-synth/wk8603.ts +66 -0
  59. package/src/prometheus-rule-groups.test.ts +135 -0
  60. package/src/serializer.ts +9 -1
  61. package/src/skills/chant-k8s.md +2 -0
@@ -0,0 +1,401 @@
1
+ /**
2
+ * WK8601, WK8602 and WK8603: where a collector config runs (chant #2899).
3
+ *
4
+ * Two kinds of fixture. Composite-built ones go through `OtelCollector`,
5
+ * `OtelCollectorGateway` and the k8s serializer, so they join through the
6
+ * `otel.chant.dev/*` annotations. Hand-written YAML has no annotations and
7
+ * joins through the workload's ConfigMap volumes, the way `GkeOtelCollector`
8
+ * and plain manifests do.
9
+ */
10
+ import { describe, expect, test } from "vitest";
11
+ import { dump } from "js-yaml";
12
+ import { expandComposite } from "@intentius/chant";
13
+ import type { Declarable } from "@intentius/chant/declarable";
14
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
15
+ import { makePostSynthCtx } from "@intentius/chant-test-utils";
16
+ import {
17
+ DebugExporter,
18
+ K8sClusterReceiver,
19
+ OtlpReceiver,
20
+ Pipeline,
21
+ TailSamplingProcessor,
22
+ } from "@intentius/chant-lexicon-otel";
23
+ import { k8sSerializer } from "../../serializer";
24
+ import { OtelCollector } from "../../composites/otel-collector";
25
+ import { OtelCollectorGateway, gatewayExporter } from "../../composites/otel-collector-gateway";
26
+ import { GkeOtelCollector } from "../../composites/gke-otel-collector";
27
+ import { OTEL_COLLECTOR_ANNOTATIONS } from "../../composites/otel-collector-shape";
28
+ import { PLACEMENT_ANNOTATIONS, collectorPlacements, hostOfEndpoint, serviceOfHost } from "./otel-placement-helpers";
29
+ import { docsToManifests } from "./k8s-helpers";
30
+ import { wk8601 } from "./wk8601";
31
+ import { wk8602 } from "./wk8602";
32
+ import { wk8603 } from "./wk8603";
33
+
34
+ // ── Fixture builders ────────────────────────────────────────────────
35
+
36
+ function built(instances: Record<string, unknown>): PostSynthContext {
37
+ const entities = new Map<string, Declarable>();
38
+ for (const [name, instance] of Object.entries(instances)) {
39
+ for (const [k, v] of expandComposite(name, instance as never)) entities.set(k, v);
40
+ }
41
+ const out = k8sSerializer.serialize(entities);
42
+ return makePostSynthCtx("k8s", typeof out === "string" ? out : out.primary);
43
+ }
44
+
45
+ const otlp = () => new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
46
+ const tail = () =>
47
+ new TailSamplingProcessor({
48
+ decision_wait: "5s",
49
+ policies: [{ name: "errors", type: "status_code", status_code: { status_codes: ["ERROR"] } }],
50
+ });
51
+
52
+ function samplingGateway(replicas: number) {
53
+ return OtelCollectorGateway({
54
+ replicas,
55
+ config: [new Pipeline({ signal: "traces", receivers: [otlp()], processors: [tail()], exporters: [new DebugExporter({})] })],
56
+ });
57
+ }
58
+
59
+ function agentSending(traces: ReturnType<typeof gatewayExporter>, metrics?: ReturnType<typeof gatewayExporter>) {
60
+ const rx = otlp();
61
+ return OtelCollector({
62
+ name: "agent",
63
+ config: [
64
+ new Pipeline({ signal: "traces", receivers: [rx], exporters: [traces] }),
65
+ ...(metrics ? [new Pipeline({ signal: "metrics", receivers: [rx], exporters: [metrics] })] : []),
66
+ ],
67
+ });
68
+ }
69
+
70
+ /** A ConfigMap holding collector configs under the given keys. */
71
+ function configMap(name: string, data: Record<string, string>, namespace = "obs"): object {
72
+ return { apiVersion: "v1", kind: "ConfigMap", metadata: { name, namespace }, data };
73
+ }
74
+
75
+ /** A plain collector workload mounting one ConfigMap, with no chant annotations. */
76
+ function workload(opts: { kind: string; name: string; configMap: string; replicas?: number; args?: string[] }): object {
77
+ return {
78
+ apiVersion: "apps/v1",
79
+ kind: opts.kind,
80
+ metadata: { name: opts.name, namespace: "obs" },
81
+ spec: {
82
+ ...(opts.replicas === undefined ? {} : { replicas: opts.replicas }),
83
+ selector: { matchLabels: { app: opts.name } },
84
+ template: {
85
+ metadata: { labels: { app: opts.name } },
86
+ spec: {
87
+ containers: [
88
+ {
89
+ name: "otelcol",
90
+ image: "otel/opentelemetry-collector-contrib:0.130.0",
91
+ ...(opts.args ? { args: opts.args } : {}),
92
+ volumeMounts: [{ name: "conf", mountPath: "/conf" }],
93
+ },
94
+ ],
95
+ volumes: [{ name: "conf", configMap: { name: opts.configMap } }],
96
+ },
97
+ },
98
+ },
99
+ };
100
+ }
101
+
102
+ function service(name: string, app: string, headless = false): object {
103
+ return {
104
+ apiVersion: "v1",
105
+ kind: "Service",
106
+ metadata: { name, namespace: "obs" },
107
+ spec: { ...(headless ? { clusterIP: "None" } : {}), selector: { app }, ports: [{ name: "otlp-grpc", port: 4317 }] },
108
+ };
109
+ }
110
+
111
+ function hpa(name: string, maxReplicas: number): object {
112
+ return {
113
+ apiVersion: "autoscaling/v2",
114
+ kind: "HorizontalPodAutoscaler",
115
+ metadata: { name, namespace: "obs" },
116
+ spec: { scaleTargetRef: { apiVersion: "apps/v1", kind: "Deployment", name }, minReplicas: 1, maxReplicas },
117
+ };
118
+ }
119
+
120
+ /** Serialize hand-written manifests as a multi-document stream, the way a build emits them. */
121
+ const yaml = (...docs: object[]) => makePostSynthCtx("k8s", docs.map((d) => dump(d, { lineWidth: -1 })).join("---\n"));
122
+
123
+ const TAIL_CONFIG = `
124
+ receivers: { otlp: { protocols: { grpc: {} } } }
125
+ processors:
126
+ tail_sampling:
127
+ policies: [{ name: errors, type: status_code, status_code: { status_codes: [ERROR] } }]
128
+ exporters: { debug: {} }
129
+ service:
130
+ pipelines:
131
+ traces: { receivers: [otlp], processors: [tail_sampling], exporters: [debug] }
132
+ `;
133
+
134
+ const PLAIN_CONFIG = `
135
+ receivers: { otlp: { protocols: { grpc: {} } } }
136
+ exporters: { debug: {} }
137
+ service:
138
+ pipelines:
139
+ traces: { receivers: [otlp], exporters: [debug] }
140
+ `;
141
+
142
+ /** An agent config whose traces exporter is given as YAML. */
143
+ function agentConfig(exporterId: string, exporter: string): string {
144
+ return `
145
+ receivers: { otlp: { protocols: { grpc: {} } } }
146
+ exporters:
147
+ ${exporterId}: ${exporter}
148
+ service:
149
+ pipelines:
150
+ traces: { receivers: [otlp], exporters: [${exporterId}] }
151
+ `;
152
+ }
153
+
154
+ /** A 3-replica gateway running tail_sampling, with a ClusterIP and a headless Service. */
155
+ const GATEWAY = [
156
+ configMap("gw-config", { "config.yaml": TAIL_CONFIG }),
157
+ workload({ kind: "Deployment", name: "gw", configMap: "gw-config", replicas: 3 }),
158
+ service("gw", "gw"),
159
+ service("gw-headless", "gw", true),
160
+ ];
161
+
162
+ function withAgent(exporterId: string, exporter: string, gateway = GATEWAY): PostSynthContext {
163
+ return yaml(
164
+ ...gateway,
165
+ configMap("agent-config", { "config.yaml": agentConfig(exporterId, exporter) }),
166
+ workload({ kind: "DaemonSet", name: "agent", configMap: "agent-config" }),
167
+ );
168
+ }
169
+
170
+ // ── The join ────────────────────────────────────────────────────────
171
+
172
+ describe("collectorPlacements", () => {
173
+ test("uses the same annotation keys the composites write", () => {
174
+ expect(PLACEMENT_ANNOTATIONS).toEqual(OTEL_COLLECTOR_ANNOTATIONS);
175
+ });
176
+
177
+ test("joins composite output through the annotations, with replicas", () => {
178
+ const ctx = built({ gateway: samplingGateway(3), agent: agentSending(gatewayExporter(samplingGateway(3), { loadBalance: true })) });
179
+ const shapes = collectorPlacements(docsToManifests(ctx)).map((p) => [p.kind, p.name, p.replicas, p.configMap, p.key]);
180
+ expect(shapes).toEqual(
181
+ expect.arrayContaining([
182
+ ["Deployment", "otel-gateway", 3, "otel-gateway-config", "config.yaml"],
183
+ ["DaemonSet", "agent", undefined, "agent-config", "config.yaml"],
184
+ ]),
185
+ );
186
+ });
187
+
188
+ test("joins plain manifests through ConfigMap volumes, skipping data that isn't a collector config", () => {
189
+ const ctx = yaml(
190
+ configMap("mixed", { "config.yaml": TAIL_CONFIG, "notes.yaml": "a: 1\n" }),
191
+ workload({ kind: "DaemonSet", name: "agent", configMap: "mixed" }),
192
+ );
193
+ expect(collectorPlacements(docsToManifests(ctx)).map((p) => p.key)).toEqual(["config.yaml"]);
194
+ });
195
+
196
+ test("a --config argument picks the key when a ConfigMap holds several configs", () => {
197
+ const docs = (arg: string) =>
198
+ docsToManifests(
199
+ yaml(
200
+ configMap("both", { "agent.yaml": PLAIN_CONFIG, "gateway.yaml": TAIL_CONFIG }),
201
+ workload({ kind: "DaemonSet", name: "agent", configMap: "both", args: [`--config=/conf/${arg}`] }),
202
+ ),
203
+ );
204
+ expect(collectorPlacements(docs("agent.yaml")).map((p) => p.key)).toEqual(["agent.yaml"]);
205
+ expect(collectorPlacements(docs("gateway.yaml")).map((p) => p.key)).toEqual(["gateway.yaml"]);
206
+ });
207
+
208
+ test("an HPA raises the replica count to its maxReplicas", () => {
209
+ const ctx = yaml(configMap("gw-config", { "config.yaml": TAIL_CONFIG }), workload({ kind: "Deployment", name: "gw", configMap: "gw-config" }), hpa("gw", 4));
210
+ expect(collectorPlacements(docsToManifests(ctx))[0].replicas).toBe(4);
211
+ });
212
+
213
+ test("reads cluster DNS names and endpoints", () => {
214
+ expect(serviceOfHost("gw", "obs")).toBe("gw.obs");
215
+ expect(serviceOfHost("gw.obs", "x")).toBe("gw.obs");
216
+ expect(serviceOfHost("gw.obs.svc.cluster.local", "x")).toBe("gw.obs");
217
+ expect(serviceOfHost("collector.example.com", "x")).toBeUndefined();
218
+ expect(hostOfEndpoint("gw.obs.svc:4317")).toBe("gw.obs.svc");
219
+ expect(hostOfEndpoint("http://gw.obs.svc:4318/v1/traces")).toBe("gw.obs.svc");
220
+ expect(hostOfEndpoint("dns:///gw.obs.svc:4317")).toBe("gw.obs.svc");
221
+ });
222
+ });
223
+
224
+ // ── WK8601 ──────────────────────────────────────────────────────────
225
+
226
+ describe("WK8601: tail_sampling in a per-node collector", () => {
227
+ test("flags an OtelCollector agent running tail_sampling", () => {
228
+ const agent = OtelCollector({
229
+ name: "agent",
230
+ config: [new Pipeline({ signal: "traces", receivers: [otlp()], processors: [tail()], exporters: [new DebugExporter({})] })],
231
+ });
232
+ const diags = wk8601.check(built({ agent }));
233
+ expect(diags).toHaveLength(1);
234
+ expect(diags[0]).toMatchObject({ checkId: "WK8601", severity: "warning", entity: "agent", lexicon: "k8s" });
235
+ expect(diags[0].message).toContain("DaemonSet observability/agent runs tail_sampling");
236
+ });
237
+
238
+ test("flags a hand-written DaemonSet mounting a tail sampling config", () => {
239
+ const diags = wk8601.check(yaml(configMap("c", { "config.yaml": TAIL_CONFIG }), workload({ kind: "DaemonSet", name: "agent", configMap: "c" })));
240
+ expect(diags).toHaveLength(1);
241
+ expect(diags[0].message).toContain("DaemonSet obs/agent");
242
+ });
243
+
244
+ test("passes tail_sampling on a gateway Deployment", () => {
245
+ expect(wk8601.check(built({ gateway: samplingGateway(2) }))).toEqual([]);
246
+ });
247
+
248
+ test("passes a declared tail_sampling no pipeline runs", () => {
249
+ const unused = TAIL_CONFIG.replace("processors: [tail_sampling], ", "");
250
+ expect(wk8601.check(yaml(configMap("c", { "config.yaml": unused }), workload({ kind: "DaemonSet", name: "agent", configMap: "c" })))).toEqual([]);
251
+ });
252
+
253
+ test("stays silent when the ConfigMap is not in the build", () => {
254
+ expect(wk8601.check(yaml(workload({ kind: "DaemonSet", name: "agent", configMap: "elsewhere" })))).toEqual([]);
255
+ });
256
+
257
+ test("passes GkeOtelCollector, which carries no annotations", () => {
258
+ const gke = GkeOtelCollector({ clusterName: "c", projectId: "p", gcpServiceAccountEmail: "o@p.iam.gserviceaccount.com" });
259
+ const ctx = built({ gke });
260
+ expect(collectorPlacements(docsToManifests(ctx)).map((p) => p.kind)).toEqual(["DaemonSet"]);
261
+ for (const check of [wk8601, wk8602, wk8603]) expect(check.check(ctx)).toEqual([]);
262
+ });
263
+ });
264
+
265
+ // ── WK8602 ──────────────────────────────────────────────────────────
266
+
267
+ describe("WK8602: multi-replica tail sampling gateway without trace-aware routing", () => {
268
+ test("flags agents sending traces to the gateway's ClusterIP Service", () => {
269
+ const gateway = samplingGateway(3);
270
+ const diags = wk8602.check(built({ gateway, agent: agentSending(gatewayExporter(gateway)) }));
271
+ expect(diags).toHaveLength(1);
272
+ expect(diags[0]).toMatchObject({ checkId: "WK8602", severity: "warning", entity: "otel-gateway" });
273
+ expect(diags[0].message).toContain("otlp/gateway sends to Service otel-gateway.observability directly");
274
+ });
275
+
276
+ test("passes agents that route traces by traceID through loadbalancing", () => {
277
+ const gateway = samplingGateway(3);
278
+ const ctx = built({ gateway, agent: agentSending(gatewayExporter(gateway, { loadBalance: true })) });
279
+ for (const check of [wk8601, wk8602, wk8603]) expect(check.check(ctx)).toEqual([]);
280
+ });
281
+
282
+ test("passes metrics sent to the Service when traces go through loadbalancing", () => {
283
+ const gateway = samplingGateway(3);
284
+ const agent = agentSending(gatewayExporter(gateway, { loadBalance: true }), gatewayExporter(gateway, { name: "metrics" }));
285
+ expect(wk8602.check(built({ gateway, agent }))).toEqual([]);
286
+ });
287
+
288
+ test("passes a single-replica gateway reached through its Service", () => {
289
+ const gateway = samplingGateway(1);
290
+ expect(wk8602.check(built({ gateway, agent: agentSending(gatewayExporter(gateway)) }))).toEqual([]);
291
+ });
292
+
293
+ test("flags loadbalancing routed by service rather than traceID", () => {
294
+ const gateway = samplingGateway(2);
295
+ const diags = wk8602.check(built({ gateway, agent: agentSending(gatewayExporter(gateway, { loadBalance: true, routingKey: "service" })) }));
296
+ expect(diags).toHaveLength(1);
297
+ expect(diags[0].message).toContain("routes by service, not traceID");
298
+ });
299
+
300
+ test("flags a hand-written otlp exporter at the ClusterIP Service", () => {
301
+ expect(wk8602.check(withAgent("otlp", "{ endpoint: gw.obs.svc.cluster.local:4317 }"))).toHaveLength(1);
302
+ });
303
+
304
+ test("flags a hand-written otlphttp exporter at the Service", () => {
305
+ expect(wk8602.check(withAgent("otlphttp", "{ endpoint: 'http://gw:4318' }"))).toHaveLength(1);
306
+ });
307
+
308
+ test("flags the dns resolver on the ClusterIP Service", () => {
309
+ const diags = wk8602.check(withAgent("loadbalancing", "{ protocol: { otlp: {} }, resolver: { dns: { hostname: gw.obs.svc } } }"));
310
+ expect(diags).toHaveLength(1);
311
+ expect(diags[0].message).toContain("one virtual IP");
312
+ });
313
+
314
+ test("passes hand-written loadbalancing through the dns resolver on the headless Service", () => {
315
+ expect(wk8602.check(withAgent("loadbalancing", "{ protocol: { otlp: {} }, resolver: { dns: { hostname: gw-headless.obs.svc } } }"))).toEqual([]);
316
+ });
317
+
318
+ test("passes hand-written loadbalancing through the k8s resolver with routing_key traceID", () => {
319
+ expect(wk8602.check(withAgent("loadbalancing", "{ routing_key: traceID, protocol: { otlp: {} }, resolver: { k8s: { service: gw-headless.obs } } }"))).toEqual([]);
320
+ });
321
+
322
+ test("flags a gateway scaled past one replica by an HPA", () => {
323
+ const gateway = [configMap("gw-config", { "config.yaml": TAIL_CONFIG }), workload({ kind: "Deployment", name: "gw", configMap: "gw-config", replicas: 1 }), service("gw", "gw"), hpa("gw", 3)];
324
+ expect(wk8602.check(withAgent("otlp", "{ endpoint: gw.obs.svc:4317 }", gateway))).toHaveLength(1);
325
+ });
326
+
327
+ test("stays silent with no sender in the build", () => {
328
+ expect(wk8602.check(yaml(...GATEWAY))).toEqual([]);
329
+ });
330
+
331
+ test("ignores exporters to somewhere else", () => {
332
+ expect(wk8602.check(withAgent("otlp", "{ endpoint: tempo.tracing.svc:4317 }"))).toEqual([]);
333
+ });
334
+ });
335
+
336
+ // ── WK8603 ──────────────────────────────────────────────────────────
337
+
338
+ /** A config running k8s_cluster in a metrics pipeline, with the given receiver settings, extensions and service.extensions. */
339
+ function clusterConfig(receiver: string, extensions: string, enabled: string): string {
340
+ return `
341
+ receivers:
342
+ k8s_cluster: ${receiver}
343
+ exporters: { debug: {} }
344
+ extensions: ${extensions}
345
+ service:
346
+ extensions: ${enabled}
347
+ pipelines:
348
+ metrics: { receivers: [k8s_cluster], exporters: [debug] }
349
+ `;
350
+ }
351
+
352
+ const ELECTED = "{ k8s_leader_elector: k8s_leader_elector }";
353
+ const LEADER = "{ k8s_leader_elector: { lease_name: otel, lease_namespace: obs } }";
354
+
355
+ describe("WK8603: k8s_cluster receiver in every collector copy", () => {
356
+ const clusterPipeline = () => new Pipeline({ signal: "metrics", receivers: [new K8sClusterReceiver({})], exporters: [new DebugExporter({})] });
357
+
358
+ test("flags an OtelCollector agent running k8s_cluster", () => {
359
+ const diags = wk8603.check(built({ agent: OtelCollector({ name: "agent", config: [clusterPipeline()] }) }));
360
+ expect(diags).toHaveLength(1);
361
+ expect(diags[0]).toMatchObject({ checkId: "WK8603", severity: "warning", entity: "agent" });
362
+ expect(diags[0].message).toContain("on every node");
363
+ expect(diags[0].message).toContain("k8s_cluster has no k8s_leader_elector");
364
+ });
365
+
366
+ test("flags a two-replica gateway running k8s_cluster", () => {
367
+ const diags = wk8603.check(built({ gateway: OtelCollectorGateway({ replicas: 2, config: [clusterPipeline()] }) }));
368
+ expect(diags).toHaveLength(1);
369
+ expect(diags[0].message).toContain("each of up to 2 replicas");
370
+ });
371
+
372
+ test("passes a single-replica gateway running k8s_cluster", () => {
373
+ expect(wk8603.check(built({ gateway: OtelCollectorGateway({ replicas: 1, config: [clusterPipeline()] }) }))).toEqual([]);
374
+ });
375
+
376
+ test("passes a DaemonSet whose k8s_cluster names an enabled k8s_leader_elector", () => {
377
+ const ctx = yaml(configMap("c", { "config.yaml": clusterConfig(ELECTED, LEADER, "[k8s_leader_elector]") }), workload({ kind: "DaemonSet", name: "agent", configMap: "c" }));
378
+ expect(wk8603.check(ctx)).toEqual([]);
379
+ });
380
+
381
+ test("flags a k8s_leader_elector that is declared but not enabled", () => {
382
+ const ctx = yaml(configMap("c", { "config.yaml": clusterConfig(ELECTED, LEADER, "[]") }), workload({ kind: "DaemonSet", name: "agent", configMap: "c" }));
383
+ const diags = wk8603.check(ctx);
384
+ expect(diags).toHaveLength(1);
385
+ expect(diags[0].message).toContain("not enabled in service.extensions");
386
+ });
387
+
388
+ test("flags a k8s_leader_elector reference to nothing", () => {
389
+ const ctx = yaml(configMap("c", { "config.yaml": clusterConfig(ELECTED, "{}", "[k8s_leader_elector]") }), workload({ kind: "DaemonSet", name: "agent", configMap: "c" }));
390
+ expect(wk8603.check(ctx)[0].message).toContain("not a declared k8s_leader_elector extension");
391
+ });
392
+
393
+ test("passes a k8s_cluster receiver no pipeline uses", () => {
394
+ const unused = `
395
+ receivers: { k8s_cluster: {}, otlp: { protocols: { grpc: {} } } }
396
+ exporters: { debug: {} }
397
+ service: { pipelines: { metrics: { receivers: [otlp], exporters: [debug] } } }
398
+ `;
399
+ expect(wk8603.check(yaml(configMap("c", { "config.yaml": unused }), workload({ kind: "DaemonSet", name: "agent", configMap: "c" })))).toEqual([]);
400
+ });
401
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * WK8601: `tail_sampling` in a collector that runs once per node
3
+ *
4
+ * A DaemonSet collector sees the spans produced on its own node. A trace
5
+ * whose services run on several nodes arrives in pieces, one piece per
6
+ * agent, and `tail_sampling` decides on each piece alone: an error span on
7
+ * node A doesn't keep the rest of the trace on node B, and latency policies
8
+ * measure a fragment. Nothing fails; the sampled traces are just incomplete.
9
+ * Tail sampling belongs on a gateway that receives whole traces (see WK8602
10
+ * for the multi-replica gateway case).
11
+ *
12
+ * Reads the collector config from the ConfigMap the DaemonSet runs, joined
13
+ * through `otel-placement-helpers.ts`. One build root at a time (chant #1939).
14
+ */
15
+
16
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
17
+ import { docsToManifests } from "./k8s-helpers";
18
+ import { collectorPlacements, describePlacement, tracePipelineProcessors } from "./otel-placement-helpers";
19
+
20
+ export const wk8601: PostSynthCheck = {
21
+ id: "WK8601",
22
+ description:
23
+ "OpenTelemetry Collector runs tail_sampling as a per-node DaemonSet; each agent sees only its node's spans, so decisions are made on partial traces. Sees one build root at a time (chant #1939).",
24
+
25
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
26
+ const diagnostics: PostSynthDiagnostic[] = [];
27
+ for (const p of collectorPlacements(docsToManifests(ctx))) {
28
+ if (p.kind !== "DaemonSet") continue;
29
+ const ids = tracePipelineProcessors(p.config, "tail_sampling");
30
+ if (ids.length === 0) continue;
31
+ diagnostics.push({
32
+ checkId: "WK8601",
33
+ severity: "warning",
34
+ message:
35
+ `${describePlacement(p)} runs ${ids.join(", ")} (ConfigMap ${p.configMap}, ${p.key}) on every node. ` +
36
+ `Each agent sees only its own node's spans, so sampling decisions are made on partial traces. ` +
37
+ `Move tail sampling to a gateway (OtelCollectorGateway) and send traces to it through a loadbalancing exporter routed by traceID.`,
38
+ entity: p.name,
39
+ lexicon: "k8s",
40
+ });
41
+ }
42
+ return diagnostics;
43
+ },
44
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * WK8602: `tail_sampling` on a multi-replica gateway that collectors reach
3
+ * without trace-aware load balancing
4
+ *
5
+ * Tail sampling needs every span of a trace in the same process. Behind a
6
+ * ClusterIP Service, connections are spread across the gateway replicas with
7
+ * no regard for trace ids, so the spans of one trace land on different
8
+ * replicas and each samples a fragment. The fix is a `loadbalancing`
9
+ * exporter in front, routing by `traceID` to the replicas themselves (the
10
+ * `k8s` resolver on any Service that selects them, or the `dns` resolver on
11
+ * a headless one). A single-replica gateway passes.
12
+ *
13
+ * The check fires on evidence, not on absence: another collector in the
14
+ * build whose traces pipeline exports to one of the gateway's Services
15
+ * through `otlp`/`otlphttp`, through a `loadbalancing` exporter routed by
16
+ * something other than `traceID`, or through the `dns` resolver on a
17
+ * ClusterIP Service (one virtual IP, so one backend per connection). When
18
+ * the senders live in another build root, or are applications talking to
19
+ * the gateway directly, it stays silent (chant #1939).
20
+ */
21
+
22
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
23
+ import { docsToManifests } from "./k8s-helpers";
24
+ import {
25
+ collectorPlacements,
26
+ componentType,
27
+ describePlacement,
28
+ hostOfEndpoint,
29
+ pipelinesOf,
30
+ serviceOfHost,
31
+ servicesSelecting,
32
+ tracePipelineProcessors,
33
+ type CollectorPlacement,
34
+ } from "./otel-placement-helpers";
35
+
36
+ const TRACE_ID_KEYS = new Set([undefined, null, "", "traceID"]);
37
+
38
+ function record(v: unknown): Record<string, unknown> {
39
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
40
+ }
41
+
42
+ /** Why each traces exporter of `sender` that reaches `services` does so without trace-aware routing. */
43
+ function unroutedExporters(sender: CollectorPlacement, services: Map<string, boolean>): string[] {
44
+ const reasons: string[] = [];
45
+ const seen = new Set<string>();
46
+ for (const [, pipeline] of pipelinesOf(sender.config, "traces")) {
47
+ for (const id of pipeline.exporters) {
48
+ if (seen.has(id)) continue;
49
+ seen.add(id);
50
+ const type = componentType(id);
51
+ const cfg = record(sender.config.exporters?.[id]);
52
+
53
+ if (type === "loadbalancing") {
54
+ const resolver = record(cfg.resolver);
55
+ const k8sService = record(resolver.k8s).service;
56
+ const dnsHost = record(resolver.dns).hostname;
57
+ let target: string | undefined;
58
+ if (typeof k8sService === "string") {
59
+ const svc = serviceOfHost(k8sService, sender.namespace);
60
+ if (svc && services.has(svc)) target = svc;
61
+ } else if (typeof dnsHost === "string") {
62
+ const svc = serviceOfHost(dnsHost, sender.namespace);
63
+ if (svc && services.has(svc)) {
64
+ target = svc;
65
+ if (!services.get(svc)) {
66
+ reasons.push(`${id} resolves ClusterIP Service ${svc} through dns, which returns one virtual IP rather than the replicas`);
67
+ continue;
68
+ }
69
+ }
70
+ }
71
+ if (target && !TRACE_ID_KEYS.has(cfg.routing_key as string | undefined)) {
72
+ reasons.push(`${id} routes by ${String(cfg.routing_key)}, not traceID`);
73
+ }
74
+ continue;
75
+ }
76
+
77
+ if (type === "otlp" || type === "otlphttp") {
78
+ for (const field of ["endpoint", "traces_endpoint"]) {
79
+ const host = hostOfEndpoint(cfg[field]);
80
+ const svc = host ? serviceOfHost(host, sender.namespace) : undefined;
81
+ if (svc && services.has(svc)) {
82
+ reasons.push(`${id} sends to Service ${svc} directly`);
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return reasons;
90
+ }
91
+
92
+ export const wk8602: PostSynthCheck = {
93
+ id: "WK8602",
94
+ description:
95
+ "OpenTelemetry Collector gateway runs tail_sampling on more than one replica, but collectors send it traces without a loadbalancing exporter routed by traceID. Sees one build root at a time (chant #1939).",
96
+
97
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
98
+ const manifests = docsToManifests(ctx);
99
+ const placements = collectorPlacements(manifests);
100
+ const diagnostics: PostSynthDiagnostic[] = [];
101
+
102
+ for (const gateway of placements) {
103
+ if (gateway.kind === "DaemonSet" || gateway.replicas === undefined || gateway.replicas <= 1) continue;
104
+ const tail = tracePipelineProcessors(gateway.config, "tail_sampling");
105
+ if (tail.length === 0) continue;
106
+
107
+ const services = new Map<string, boolean>();
108
+ for (const s of servicesSelecting(manifests, gateway.workload)) services.set(`${s.name}.${s.namespace}`, s.headless);
109
+ if (services.size === 0) continue;
110
+
111
+ const senders = new Set<string>();
112
+ for (const sender of placements) {
113
+ if (sender.workload === gateway.workload) continue;
114
+ const key = `${sender.kind}/${sender.namespace}/${sender.name}/${sender.configMap}/${sender.key}`;
115
+ if (senders.has(key)) continue;
116
+ senders.add(key);
117
+ const reasons = unroutedExporters(sender, services);
118
+ if (reasons.length === 0) continue;
119
+ diagnostics.push({
120
+ checkId: "WK8602",
121
+ severity: "warning",
122
+ message:
123
+ `${describePlacement(gateway)} runs ${tail.join(", ")} on up to ${gateway.replicas} replicas, and ${describePlacement(sender)} sends it traces without trace-aware routing: ${reasons.join("; ")}. ` +
124
+ `Spans of one trace land on different replicas and each samples a fragment. ` +
125
+ `Export traces through a loadbalancing exporter with routing_key traceID (gatewayExporter(gateway, { loadBalance: true })), or run one replica.`,
126
+ entity: gateway.name,
127
+ lexicon: "k8s",
128
+ });
129
+ }
130
+ }
131
+ return diagnostics;
132
+ },
133
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * WK8603: `k8s_cluster` receiver in a collector that runs more than one copy
3
+ * without a leader elector
4
+ *
5
+ * The `k8s_cluster` receiver reports cluster-level metrics and entity events
6
+ * (nodes, deployments, pods, resource quotas) by watching the API server.
7
+ * Every copy of the collector reports every object, so in a DaemonSet the
8
+ * cluster is reported once per node, and in a Deployment once per replica:
9
+ * duplicated series and N watches against the API server. Run it in a
10
+ * single-replica Deployment, or set the receiver's `k8s_leader_elector` to a
11
+ * `k8s_leader_elector` extension enabled in `service.extensions` (both ship in
12
+ * otelcol-contrib and otelcol-k8s at the pinned v0.130.0), so only the
13
+ * leader collects.
14
+ *
15
+ * One build root at a time (chant #1939).
16
+ */
17
+
18
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
19
+ import { docsToManifests } from "./k8s-helpers";
20
+ import { allPipelines, collectorPlacements, componentType, describePlacement, type CollectorConfigShape } from "./otel-placement-helpers";
21
+
22
+ /** Why a `k8s_cluster` receiver is not behind a working leader elector, or undefined when it is. */
23
+ function missingLeader(config: CollectorConfigShape, id: string): string | undefined {
24
+ const cfg = config.receivers?.[id];
25
+ const ref = typeof cfg === "object" && cfg !== null ? (cfg as Record<string, unknown>).k8s_leader_elector : undefined;
26
+ if (typeof ref !== "string" || ref.length === 0) return `${id} has no k8s_leader_elector`;
27
+ if (componentType(ref) !== "k8s_leader_elector" || !(config.extensions && ref in config.extensions)) {
28
+ return `${id} names k8s_leader_elector "${ref}", which is not a declared k8s_leader_elector extension`;
29
+ }
30
+ const enabled = Array.isArray(config.service.extensions) && config.service.extensions.includes(ref);
31
+ if (!enabled) return `${id} names k8s_leader_elector "${ref}", which is not enabled in service.extensions`;
32
+ return undefined;
33
+ }
34
+
35
+ export const wk8603: PostSynthCheck = {
36
+ id: "WK8603",
37
+ description:
38
+ "OpenTelemetry Collector runs the k8s_cluster receiver in a DaemonSet or multi-replica workload without a k8s_leader_elector; every copy reports every cluster object. Sees one build root at a time (chant #1939).",
39
+
40
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
41
+ const diagnostics: PostSynthDiagnostic[] = [];
42
+ for (const p of collectorPlacements(docsToManifests(ctx))) {
43
+ const copies = p.kind === "DaemonSet" ? "every node" : p.replicas !== undefined && p.replicas > 1 ? `each of up to ${p.replicas} replicas` : undefined;
44
+ if (!copies) continue;
45
+
46
+ const used = new Set<string>();
47
+ for (const [, pipeline] of allPipelines(p.config)) {
48
+ for (const id of pipeline.receivers) if (componentType(id) === "k8s_cluster") used.add(id);
49
+ }
50
+ const reasons = [...used].map((id) => missingLeader(p.config, id)).filter((r): r is string => r !== undefined);
51
+ if (reasons.length === 0) continue;
52
+
53
+ diagnostics.push({
54
+ checkId: "WK8603",
55
+ severity: "warning",
56
+ message:
57
+ `${describePlacement(p)} runs the k8s_cluster receiver on ${copies} (ConfigMap ${p.configMap}, ${p.key}): ${reasons.join("; ")}. ` +
58
+ `Each copy reports every cluster object, duplicating series and API server watches. ` +
59
+ `Run it in a single-replica Deployment, or reference a k8s_leader_elector extension from the receiver and enable it in service.extensions.`,
60
+ entity: p.name,
61
+ lexicon: "k8s",
62
+ });
63
+ }
64
+ return diagnostics;
65
+ },
66
+ };