@intentius/chant-lexicon-k8s 0.95.1 → 0.97.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,260 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { load, loadAll } from "js-yaml";
3
+ import { expandComposite } from "@intentius/chant";
4
+ import type { Declarable } from "@intentius/chant/declarable";
5
+ import {
6
+ DebugExporter,
7
+ LoadBalancingExporter,
8
+ OtlpReceiver,
9
+ Pipeline,
10
+ TailSamplingProcessor,
11
+ collectorTopology,
12
+ validateCollectorConfig,
13
+ type CollectorConfig,
14
+ } from "@intentius/chant-lexicon-otel";
15
+ import { k8sSerializer } from "../serializer";
16
+ import { OtelCollector } from "./otel-collector";
17
+ import { OtelCollectorGateway, gatewayExporter } from "./otel-collector-gateway";
18
+ import { OTEL_COLLECTOR_ANNOTATIONS as A } from "./otel-collector-shape";
19
+
20
+ function p(member: unknown): any {
21
+ return (member as { props: unknown }).props;
22
+ }
23
+
24
+ function config(result: { configMap: unknown }): CollectorConfig {
25
+ return load(p(result.configMap).data["config.yaml"]) as CollectorConfig;
26
+ }
27
+
28
+ function agentOf(exporter: ReturnType<typeof gatewayExporter>, props: Record<string, unknown> = {}) {
29
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
30
+ return OtelCollector({
31
+ ...props,
32
+ config: [new Pipeline({ signal: "traces", receivers: [otlp], exporters: [exporter] })],
33
+ });
34
+ }
35
+
36
+ function manifests(instances: Record<string, any>): Array<Record<string, any>> {
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)) entities.set(k, v);
40
+ }
41
+ const out = k8sSerializer.serialize(entities);
42
+ return loadAll(typeof out === "string" ? out : out.primary) as Array<Record<string, any>>;
43
+ }
44
+
45
+ describe("OtelCollectorGateway", () => {
46
+ test("returns a Deployment, two Services, a ServiceAccount and a ConfigMap, and no RBAC by default", () => {
47
+ const gw = OtelCollectorGateway({});
48
+ for (const key of ["deployment", "service", "headlessService", "serviceAccount", "configMap"]) {
49
+ expect((gw as any)[key], key).toBeDefined();
50
+ }
51
+ expect(gw.clusterRole).toBeUndefined();
52
+ expect(gw.clusterRoleBinding).toBeUndefined();
53
+ });
54
+
55
+ test("defaults to two replicas of otel-gateway in observability, running the default config", () => {
56
+ const gw = OtelCollectorGateway({});
57
+ const d = p(gw.deployment);
58
+ expect(d.metadata).toMatchObject({ name: "otel-gateway", namespace: "observability" });
59
+ expect(d.spec.replicas).toBe(2);
60
+ expect(d.spec.template.spec.tolerations).toBeUndefined();
61
+ expect(d.spec.template.spec.serviceAccountName).toBe("otel-gateway-sa");
62
+ expect(validateCollectorConfig(config(gw))).toEqual([]);
63
+ });
64
+
65
+ test("the ClusterIP Service and the headless Service expose the config's receiver ports", () => {
66
+ const gw = OtelCollectorGateway({});
67
+ const ports = [
68
+ { name: "otlp-grpc", port: 4317, targetPort: "otlp-grpc", protocol: "TCP" },
69
+ { name: "otlp-http", port: 4318, targetPort: "otlp-http", protocol: "TCP" },
70
+ ];
71
+ expect(p(gw.service).spec).toEqual({ type: "ClusterIP", selector: { "app.kubernetes.io/name": "otel-gateway" }, ports });
72
+ expect(p(gw.headlessService).metadata.name).toBe("otel-gateway-headless");
73
+ expect(p(gw.headlessService).spec).toEqual({ clusterIP: "None", selector: { "app.kubernetes.io/name": "otel-gateway" }, ports });
74
+ const container = p(gw.deployment).spec.template.spec.containers[0];
75
+ expect(container.ports.map((x: any) => x.containerPort)).toEqual([4317, 4318, 13133]);
76
+ expect(container.readinessProbe).toEqual({ httpGet: { path: "/", port: "health" } });
77
+ expect(container.securityContext.capabilities).toEqual({ drop: ["ALL"] });
78
+ });
79
+
80
+ test("clusterRules adds a ClusterRole bound to the gateway's ServiceAccount", () => {
81
+ const rules = [{ apiGroups: [""], resources: ["pods", "nodes"], verbs: ["get", "list", "watch"] }];
82
+ const gw = OtelCollectorGateway({ name: "gw", namespace: "obs", clusterRules: rules });
83
+ expect(p(gw.clusterRole).rules).toEqual(rules);
84
+ expect(p(gw.clusterRoleBinding).subjects).toEqual([{ kind: "ServiceAccount", name: "gw-sa", namespace: "obs" }]);
85
+ expect(p(gw.clusterRoleBinding).roleRef.name).toBe("gw-role");
86
+ });
87
+
88
+ test("the Deployment and ConfigMap say they are a gateway and name each other", () => {
89
+ const gw = OtelCollectorGateway({ name: "gw" });
90
+ expect(p(gw.deployment).metadata.annotations).toEqual({ [A.role]: "gateway", [A.config]: "gw-config" });
91
+ expect(p(gw.configMap).metadata.annotations).toEqual({ [A.role]: "gateway", [A.workload]: "Deployment/gw" });
92
+ expect(p(gw.deployment).metadata.labels["app.kubernetes.io/component"]).toBe("gateway");
93
+ });
94
+
95
+ test("defaults override a member", () => {
96
+ const gw = OtelCollectorGateway({ defaults: { deployment: { spec: { replicas: 5 } }, headlessService: { spec: { publishNotReadyAddresses: true } } } });
97
+ expect(p(gw.deployment).spec.replicas).toBe(5);
98
+ expect(p(gw.headlessService).spec.publishNotReadyAddresses).toBe(true);
99
+ });
100
+ });
101
+
102
+ describe("gatewayExporter", () => {
103
+ test("by default, an otlp exporter at the gateway's ClusterIP Service", () => {
104
+ const exporter = gatewayExporter(OtelCollectorGateway({}));
105
+ expect(exporter.componentId).toBe("otlp/gateway");
106
+ expect(p(exporter)).toEqual({ name: "gateway", endpoint: "otel-gateway.observability.svc:4317", tls: { insecure: true } });
107
+ });
108
+
109
+ test("with loadBalance, a loadbalancing exporter on the headless Service through the k8s resolver", () => {
110
+ const exporter = gatewayExporter(OtelCollectorGateway({}), { loadBalance: true });
111
+ expect(exporter.componentId).toBe("loadbalancing/gateway");
112
+ expect(p(exporter)).toEqual({
113
+ name: "gateway",
114
+ routing_key: "traceID",
115
+ protocol: { otlp: { tls: { insecure: true } } },
116
+ resolver: { k8s: { service: "otel-gateway-headless.observability", ports: [4317] } },
117
+ });
118
+ });
119
+
120
+ test("the dns resolver resolves the headless Service's name", () => {
121
+ const exporter = gatewayExporter(OtelCollectorGateway({}), { loadBalance: true, resolver: "dns" });
122
+ expect(p(exporter).resolver).toEqual({ dns: { hostname: "otel-gateway-headless.observability.svc", port: "4317" } });
123
+ });
124
+
125
+ test.each([
126
+ [{ name: "tail", namespace: "observability" }, "tail.observability.svc:4317", "tail-headless.observability"],
127
+ [{ name: "otel-gateway", namespace: "telemetry" }, "otel-gateway.telemetry.svc:4317", "otel-gateway-headless.telemetry"],
128
+ [{ name: "edge", namespace: "prod-obs" }, "edge.prod-obs.svc:4317", "edge-headless.prod-obs"],
129
+ ])("renaming the gateway or moving its namespace moves the agent's endpoint (%o)", (props, endpoint, service) => {
130
+ const gw = OtelCollectorGateway(props);
131
+ const direct = config(agentOf(gatewayExporter(gw)));
132
+ expect(direct.exporters?.["otlp/gateway"]).toMatchObject({ endpoint });
133
+ const balanced = config(agentOf(gatewayExporter(gw, { loadBalance: true })));
134
+ expect(balanced.exporters?.["loadbalancing/gateway"]).toMatchObject({ resolver: { k8s: { service } } });
135
+ });
136
+
137
+ test("the port follows the gateway's receiver", () => {
138
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:14317" } } });
139
+ const gw = OtelCollectorGateway({ config: [new Pipeline({ signal: "traces", receivers: [otlp], exporters: [new DebugExporter({})] })] });
140
+ expect(p(gatewayExporter(gw)).endpoint).toBe("otel-gateway.observability.svc:14317");
141
+ expect(p(gatewayExporter(gw, { loadBalance: true })).resolver.k8s.ports).toEqual([14317]);
142
+ });
143
+
144
+ test("a gateway without an OTLP gRPC port is refused with the ports it has", () => {
145
+ const otlp = new OtlpReceiver({ protocols: { http: { endpoint: "0.0.0.0:4318" } } });
146
+ const gw = OtelCollectorGateway({ config: [new Pipeline({ signal: "traces", receivers: [otlp], exporters: [new DebugExporter({})] })] });
147
+ expect(() => gatewayExporter(gw)).toThrow(/no port named "otlp-grpc" \(it has: otlp-http\)/);
148
+ });
149
+
150
+ test("the otel topology reports the gateway as the exporter's endpoint", () => {
151
+ const agent = agentOf(gatewayExporter(OtelCollectorGateway({ namespace: "obs" }), { loadBalance: true }));
152
+ const exporters = collectorTopology(config(agent)).exporters;
153
+ expect(exporters.map((e: any) => e.endpoints)).toEqual([["otel-gateway-headless.obs:4317"]]);
154
+ });
155
+ });
156
+
157
+ describe("OtelCollector wired to a gateway", () => {
158
+ test("through loadbalancing with the k8s resolver: a Role in the gateway's namespace for the agent's ServiceAccount", () => {
159
+ const gw = OtelCollectorGateway({ namespace: "gateways" });
160
+ const agent = agentOf(gatewayExporter(gw, { loadBalance: true }), { name: "agent", namespace: "agents" });
161
+ expect(p(agent.endpointsRole).metadata).toMatchObject({ name: "agent-endpoints", namespace: "gateways" });
162
+ expect(p(agent.endpointsRole).rules).toEqual([
163
+ { apiGroups: [""], resources: ["endpoints"], verbs: ["get", "list", "watch"] },
164
+ { apiGroups: ["discovery.k8s.io"], resources: ["endpointslices"], verbs: ["get", "list", "watch"] },
165
+ ]);
166
+ expect(p(agent.endpointsRoleBinding).metadata.namespace).toBe("gateways");
167
+ expect(p(agent.endpointsRoleBinding).roleRef).toEqual({ apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "agent-endpoints" });
168
+ expect(p(agent.endpointsRoleBinding).subjects).toEqual([{ kind: "ServiceAccount", name: "agent-sa", namespace: "agents" }]);
169
+ });
170
+
171
+ test("through the Service or the dns resolver: no Role", () => {
172
+ const gw = OtelCollectorGateway({});
173
+ expect(agentOf(gatewayExporter(gw)).endpointsRole).toBeUndefined();
174
+ expect(agentOf(gatewayExporter(gw, { loadBalance: true, resolver: "dns" })).endpointsRole).toBeUndefined();
175
+ expect(OtelCollector({}).endpointsRole).toBeUndefined();
176
+ });
177
+
178
+ test("a hand-written loadbalancing exporter with the k8s resolver gets the Role too", () => {
179
+ const lb = new LoadBalancingExporter({ name: "gw", resolver: { k8s: { service: "gw-headless" } } });
180
+ const agent = agentOf(lb, { namespace: "obs" });
181
+ expect(p(agent.endpointsRole).metadata.namespace).toBe("obs");
182
+ expect(p(agent.configMap).metadata.annotations[A.gateways]).toBeUndefined();
183
+ });
184
+
185
+ test("resolvers in two namespaces are refused", () => {
186
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
187
+ const a = gatewayExporter(OtelCollectorGateway({ namespace: "one" }), { loadBalance: true, name: "a" });
188
+ const b = gatewayExporter(OtelCollectorGateway({ namespace: "two" }), { loadBalance: true, name: "b" });
189
+ expect(() =>
190
+ OtelCollector({ config: [new Pipeline({ signal: "traces", receivers: [otlp], exporters: [a, b] })] }),
191
+ ).toThrow(/resolve Services in one and two/);
192
+ });
193
+
194
+ test("the agent's ConfigMap and DaemonSet name the gateway and how they reach it", () => {
195
+ const gw = OtelCollectorGateway({ name: "gw", namespace: "obs" });
196
+ const balanced = agentOf(gatewayExporter(gw, { loadBalance: true }), { name: "agent" });
197
+ expect(p(balanced.configMap).metadata.annotations).toEqual({
198
+ [A.role]: "agent",
199
+ [A.workload]: "DaemonSet/agent",
200
+ [A.gateways]: "obs/gw=loadbalancing",
201
+ });
202
+ expect(p(balanced.daemonSet).metadata.annotations).toEqual({
203
+ [A.role]: "agent",
204
+ [A.config]: "agent-config",
205
+ [A.gateways]: "obs/gw=loadbalancing",
206
+ });
207
+ const direct = agentOf(gatewayExporter(gw), { name: "agent" });
208
+ expect(p(direct.configMap).metadata.annotations[A.gateways]).toBe("obs/gw=service");
209
+ });
210
+
211
+ test("an unwired agent says it is an agent and nothing more", () => {
212
+ const agent = OtelCollector({});
213
+ expect(p(agent.configMap).metadata.annotations).toEqual({ [A.role]: "agent", [A.workload]: "DaemonSet/otel-collector" });
214
+ });
215
+ });
216
+
217
+ // What a post-synth check sees: only the built YAML. Starting from each
218
+ // collector ConfigMap, the annotations reach its workload and the gateway it
219
+ // sends to, with no knowledge of the composites.
220
+ describe("deployment shape in the built manifests", () => {
221
+ test("a check can join each collector config to its workload, replicas and gateway routing", () => {
222
+ const tail = new TailSamplingProcessor({
223
+ decision_wait: "5s",
224
+ policies: [{ name: "errors", type: "status_code", status_code: { status_codes: ["ERROR"] } }],
225
+ });
226
+ const gwOtlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
227
+ const gateway = OtelCollectorGateway({
228
+ replicas: 3,
229
+ config: [new Pipeline({ signal: "traces", receivers: [gwOtlp], processors: [tail], exporters: [new DebugExporter({})] })],
230
+ });
231
+ const agent = agentOf(gatewayExporter(gateway, { loadBalance: true }), { name: "agent" });
232
+ const docs = manifests({ gateway, agent });
233
+
234
+ const find = (kind: string, ns: string, name: string) =>
235
+ docs.find((d) => d.kind === kind && d.metadata.namespace === ns && d.metadata.name === name);
236
+
237
+ const shapes = docs
238
+ .filter((d) => d.kind === "ConfigMap" && d.metadata.annotations?.[A.role])
239
+ .map((cm) => {
240
+ const [kind, name] = cm.metadata.annotations[A.workload].split("/");
241
+ const workload = find(kind, cm.metadata.namespace, name)!;
242
+ expect(workload.metadata.annotations[A.config]).toBe(cm.metadata.name);
243
+ const cfg = load(cm.data["config.yaml"]) as CollectorConfig;
244
+ return {
245
+ role: cm.metadata.annotations[A.role],
246
+ kind: workload.kind,
247
+ replicas: workload.spec.replicas,
248
+ tailSampling: Object.keys(cfg.processors ?? {}).includes("tail_sampling"),
249
+ gateways: cm.metadata.annotations[A.gateways],
250
+ };
251
+ });
252
+
253
+ expect(shapes).toEqual([
254
+ { role: "gateway", kind: "Deployment", replicas: 3, tailSampling: true, gateways: undefined },
255
+ { role: "agent", kind: "DaemonSet", replicas: undefined, tailSampling: false, gateways: "observability/otel-gateway=loadbalancing" },
256
+ ]);
257
+ // The Role the k8s resolver needs is in the build, next to the gateway.
258
+ expect(find("Role", "observability", "agent-endpoints")).toBeDefined();
259
+ });
260
+ });
@@ -0,0 +1,325 @@
1
+ /**
2
+ * OtelCollectorGateway composite: an OpenTelemetry Collector gateway, the
3
+ * central tier that per-node agents export to.
4
+ *
5
+ * A Deployment runs the collector with `replicas` pods, its config mounted
6
+ * from a ConfigMap. A ClusterIP Service takes the agents' OTLP traffic, and a
7
+ * headless Service lists the individual pods, which is what the agents'
8
+ * `loadbalancing` exporter resolves when every span of a trace has to reach
9
+ * the same replica (tail sampling, spanmetrics). The config is declared with
10
+ * the otel lexicon; ports and probes are read back from it, as `OtelCollector`
11
+ * does.
12
+ *
13
+ * `gatewayExporter()` builds the agent side: an exporter whose endpoint comes
14
+ * from this composite's Services, so renaming the gateway or moving it to
15
+ * another namespace moves the agents with it.
16
+ */
17
+
18
+ import { Composite, mergeDefaults } from "@intentius/chant";
19
+ import type { Declarable } from "@intentius/chant/declarable";
20
+ import {
21
+ otlpCollector,
22
+ LoadBalancingExporter,
23
+ OtlpExporter,
24
+ COLLECTOR_IMAGE,
25
+ type LoadBalancingRoutingKey,
26
+ type OTelComponent,
27
+ type Signal,
28
+ type TLSClientSettings,
29
+ } from "@intentius/chant-lexicon-otel";
30
+ import { Deployment, Service, ServiceAccount, ClusterRole, ClusterRoleBinding, ConfigMap, PodDisruptionBudget } from "../generated";
31
+ import { collectorConfigMap, collectorContainer } from "./otel-collector-agent";
32
+ import {
33
+ collectorRuntime,
34
+ markGatewayExporter,
35
+ OTEL_COLLECTOR_ANNOTATIONS,
36
+ type GatewayRouting,
37
+ } from "./otel-collector-shape";
38
+
39
+ /** One RBAC rule, as a ClusterRole lists it. */
40
+ export interface CollectorPolicyRule {
41
+ apiGroups: string[];
42
+ resources: string[];
43
+ verbs: string[];
44
+ resourceNames?: string[];
45
+ }
46
+
47
+ export interface OtelCollectorGatewayProps {
48
+ /** Gateway name (default: "otel-gateway"). */
49
+ name?: string;
50
+ /** Namespace (default: "observability"). */
51
+ namespace?: string;
52
+ /** Number of collector pods (default: 2). */
53
+ replicas?: number;
54
+ /** Collector image (default: the contrib image at the otel lexicon's pinned collector version). */
55
+ image?: string;
56
+ /**
57
+ * The collector config, as otel lexicon entities (components and
58
+ * pipelines). Replaces the default config, and `exporters` and `signals`
59
+ * are then ignored.
60
+ */
61
+ config?: Iterable<Declarable>;
62
+ /** Exporters for the default config (default: one `debug` exporter). */
63
+ exporters?: OTelComponent<"exporter", string, any>[];
64
+ /** Signals the default config has pipelines for (default: traces, metrics and logs). */
65
+ signals?: Signal[];
66
+ /**
67
+ * Cluster-wide read access the config needs, e.g. for a `k8s_cluster`
68
+ * receiver or a `k8sattributes` processor. When given, the composite adds a
69
+ * ClusterRole with these rules bound to the gateway's ServiceAccount.
70
+ * Default: none.
71
+ */
72
+ clusterRules?: CollectorPolicyRule[];
73
+ /** Additional labels. */
74
+ labels?: Record<string, string>;
75
+ /** CPU request (default: "200m"). */
76
+ cpuRequest?: string;
77
+ /** Memory request (default: "512Mi"). */
78
+ memoryRequest?: string;
79
+ /** CPU limit (default: "1"). */
80
+ cpuLimit?: string;
81
+ /** Memory limit (default: "1Gi"). */
82
+ memoryLimit?: string;
83
+ /** Per-member defaults for fine-grained overrides. */
84
+ defaults?: {
85
+ deployment?: Partial<Record<string, unknown>>;
86
+ service?: Partial<Record<string, unknown>>;
87
+ headlessService?: Partial<Record<string, unknown>>;
88
+ serviceAccount?: Partial<Record<string, unknown>>;
89
+ configMap?: Partial<Record<string, unknown>>;
90
+ podDisruptionBudget?: Partial<Record<string, unknown>>;
91
+ clusterRole?: Partial<Record<string, unknown>>;
92
+ clusterRoleBinding?: Partial<Record<string, unknown>>;
93
+ };
94
+ }
95
+
96
+ export type OtelCollectorGatewayResult = {
97
+ deployment: InstanceType<typeof Deployment>;
98
+ service: InstanceType<typeof Service>;
99
+ headlessService: InstanceType<typeof Service>;
100
+ serviceAccount: InstanceType<typeof ServiceAccount>;
101
+ configMap: InstanceType<typeof ConfigMap>;
102
+ /** With more than one replica: at most one pod down at a time during voluntary disruptions. */
103
+ podDisruptionBudget?: InstanceType<typeof PodDisruptionBudget>;
104
+ clusterRole?: InstanceType<typeof ClusterRole>;
105
+ clusterRoleBinding?: InstanceType<typeof ClusterRoleBinding>;
106
+ };
107
+
108
+ /**
109
+ * Create an OtelCollectorGateway composite. Returns a Deployment, a ClusterIP
110
+ * Service, a headless Service, a ServiceAccount and a ConfigMap, a
111
+ * PodDisruptionBudget when there is more than one replica, and a ClusterRole
112
+ * and binding when `clusterRules` is set.
113
+ *
114
+ * @example
115
+ * ```ts
116
+ * import { OtelCollector, OtelCollectorGateway, gatewayExporter } from "@intentius/chant-lexicon-k8s";
117
+ *
118
+ * export const gateway = OtelCollectorGateway({ replicas: 3 });
119
+ * export const agent = OtelCollector({ exporters: [gatewayExporter(gateway, { loadBalance: true })], signals: ["traces"] });
120
+ * ```
121
+ */
122
+ export const OtelCollectorGateway = Composite((props: OtelCollectorGatewayProps) => {
123
+ const {
124
+ name = "otel-gateway",
125
+ namespace = "observability",
126
+ replicas = 2,
127
+ image = COLLECTOR_IMAGE,
128
+ labels: extraLabels = {},
129
+ cpuRequest = "200m",
130
+ memoryRequest = "512Mi",
131
+ cpuLimit = "1",
132
+ memoryLimit = "1Gi",
133
+ defaults: defs,
134
+ } = props;
135
+
136
+ const commonLabels: Record<string, string> = {
137
+ "app.kubernetes.io/name": name,
138
+ "app.kubernetes.io/managed-by": "chant",
139
+ ...extraLabels,
140
+ };
141
+ const gatewayLabels = { ...commonLabels, "app.kubernetes.io/component": "gateway" };
142
+ const saName = `${name}-sa`;
143
+ const configMapName = `${name}-config`;
144
+
145
+ const entities = props.config ? [...props.config] : otlpCollector({ exporters: props.exporters, signals: props.signals });
146
+ const runtime = collectorRuntime(entities);
147
+
148
+ const container = collectorContainer({
149
+ name,
150
+ image,
151
+ configDir: runtime.configDir,
152
+ ports: runtime.containerPorts,
153
+ cpuRequest,
154
+ memoryRequest,
155
+ cpuLimit,
156
+ memoryLimit,
157
+ containerExtra: runtime.containerExtra,
158
+ });
159
+
160
+ const deployment = new Deployment(mergeDefaults({
161
+ metadata: {
162
+ name,
163
+ namespace,
164
+ labels: gatewayLabels,
165
+ annotations: {
166
+ [OTEL_COLLECTOR_ANNOTATIONS.role]: "gateway",
167
+ [OTEL_COLLECTOR_ANNOTATIONS.config]: configMapName,
168
+ },
169
+ },
170
+ spec: {
171
+ replicas,
172
+ selector: { matchLabels: { "app.kubernetes.io/name": name } },
173
+ template: {
174
+ metadata: { labels: { "app.kubernetes.io/name": name, "app.kubernetes.io/component": "gateway", ...extraLabels } },
175
+ spec: {
176
+ serviceAccountName: saName,
177
+ containers: [container],
178
+ volumes: [{ name: "config", configMap: { name: configMapName } }],
179
+ },
180
+ },
181
+ },
182
+ }, defs?.deployment));
183
+
184
+ const servicePorts = runtime.servicePorts.map((p) => ({ name: p.name, port: p.port, targetPort: p.name, protocol: "TCP" }));
185
+
186
+ const service = new Service(mergeDefaults({
187
+ metadata: { name, namespace, labels: gatewayLabels },
188
+ spec: {
189
+ type: "ClusterIP",
190
+ selector: { "app.kubernetes.io/name": name },
191
+ ports: servicePorts,
192
+ },
193
+ }, defs?.service));
194
+
195
+ // A headless Service's DNS name and Endpoints list every ready pod, which is
196
+ // what the agents' loadbalancing exporter hashes traces across.
197
+ const headlessService = new Service(mergeDefaults({
198
+ metadata: { name: `${name}-headless`, namespace, labels: gatewayLabels },
199
+ spec: {
200
+ clusterIP: "None",
201
+ selector: { "app.kubernetes.io/name": name },
202
+ ports: servicePorts,
203
+ },
204
+ }, defs?.headlessService));
205
+
206
+ const serviceAccount = new ServiceAccount(mergeDefaults({
207
+ metadata: { name: saName, namespace, labels: gatewayLabels },
208
+ }, defs?.serviceAccount));
209
+
210
+ const configMap = collectorConfigMap(
211
+ { namespace, commonLabels, configYaml: runtime.configYaml, defaults: defs },
212
+ configMapName,
213
+ {
214
+ [OTEL_COLLECTOR_ANNOTATIONS.role]: "gateway",
215
+ [OTEL_COLLECTOR_ANNOTATIONS.workload]: `Deployment/${name}`,
216
+ },
217
+ );
218
+
219
+ const pdb = replicas > 1
220
+ ? {
221
+ podDisruptionBudget: new PodDisruptionBudget(mergeDefaults({
222
+ metadata: { name, namespace, labels: gatewayLabels },
223
+ spec: { maxUnavailable: 1, selector: { matchLabels: { "app.kubernetes.io/name": name } } },
224
+ }, defs?.podDisruptionBudget)),
225
+ }
226
+ : {};
227
+
228
+ const rbac = props.clusterRules?.length
229
+ ? {
230
+ clusterRole: new ClusterRole(mergeDefaults({
231
+ metadata: { name: `${name}-role`, labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" } },
232
+ rules: props.clusterRules,
233
+ }, defs?.clusterRole)),
234
+ clusterRoleBinding: new ClusterRoleBinding(mergeDefaults({
235
+ metadata: { name: `${name}-binding`, labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" } },
236
+ roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "ClusterRole", name: `${name}-role` },
237
+ subjects: [{ kind: "ServiceAccount", name: saName, namespace }],
238
+ }, defs?.clusterRoleBinding)),
239
+ }
240
+ : {};
241
+
242
+ return { deployment, service, headlessService, serviceAccount, configMap, ...pdb, ...rbac };
243
+ }, "OtelCollectorGateway");
244
+
245
+ // ── Agent side ───────────────────────────────────────────────────────
246
+
247
+ export interface GatewayExporterOptions {
248
+ /** Exporter name, so the id is `otlp/<name>` or `loadbalancing/<name>` (default: "gateway"). */
249
+ name?: string;
250
+ /**
251
+ * Send each trace to one gateway replica through the headless Service with
252
+ * a `loadbalancing` exporter, instead of to the ClusterIP Service with an
253
+ * `otlp` exporter. Needed in front of a multi-replica gateway that runs
254
+ * `tail_sampling` or `spanmetrics`. Default: false.
255
+ */
256
+ loadBalance?: boolean;
257
+ /**
258
+ * How the `loadbalancing` exporter finds the replicas: `k8s` watches the
259
+ * headless Service's Endpoints (the agent composite then adds the RBAC for
260
+ * it), `dns` resolves its name. Default: "k8s".
261
+ */
262
+ resolver?: "k8s" | "dns";
263
+ /** What the `loadbalancing` exporter hashes on (default: "traceID"). */
264
+ routingKey?: LoadBalancingRoutingKey;
265
+ /** The gateway Service port to send to, by name (default: "otlp-grpc"). */
266
+ port?: string;
267
+ /** TLS for the connection to the gateway (default: `{ insecure: true }`, plaintext inside the cluster). */
268
+ tls?: TLSClientSettings;
269
+ }
270
+
271
+ interface ServiceMeta {
272
+ name: string;
273
+ namespace: string;
274
+ ports: Array<{ name?: string; port: number }>;
275
+ }
276
+
277
+ function serviceMeta(service: InstanceType<typeof Service>): ServiceMeta {
278
+ const p = (service as unknown as { props: { metadata: { name: string; namespace: string }; spec: { ports?: ServiceMeta["ports"] } } }).props;
279
+ return { name: p.metadata.name, namespace: p.metadata.namespace, ports: p.spec.ports ?? [] };
280
+ }
281
+
282
+ /**
283
+ * The exporter an agent uses to send to a gateway, built from the gateway's
284
+ * own declaration: its Services' names, its namespace, and the port its
285
+ * config listens on.
286
+ *
287
+ * By default an `otlp` exporter at `<service>.<namespace>.svc:<port>`. With
288
+ * `loadBalance`, a `loadbalancing` exporter routing by trace id to the
289
+ * headless Service, through the `k8s` resolver (`<headless>.<namespace>`) or
290
+ * the `dns` resolver (`<headless>.<namespace>.svc`). Put it in the agent's
291
+ * pipelines like any other exporter; `OtelCollector` records the link in its
292
+ * annotations and adds the RBAC the `k8s` resolver needs.
293
+ */
294
+ export function gatewayExporter(
295
+ gateway: OtelCollectorGatewayResult,
296
+ options: GatewayExporterOptions = {},
297
+ ): InstanceType<typeof OtlpExporter> | InstanceType<typeof LoadBalancingExporter> {
298
+ const { name = "gateway", loadBalance = false, resolver = "k8s", routingKey = "traceID", port: portName = "otlp-grpc", tls = { insecure: true } } = options;
299
+ const deploymentName = (gateway.deployment as unknown as { props: { metadata: { name: string } } }).props.metadata.name;
300
+ const target = serviceMeta(loadBalance ? gateway.headlessService : gateway.service);
301
+ const port = target.ports.find((p) => p.name === portName)?.port;
302
+ if (port === undefined) {
303
+ const names = target.ports.map((p) => p.name).join(", ") || "none";
304
+ throw new Error(
305
+ `gatewayExporter: Service ${target.namespace}/${target.name} has no port named "${portName}" (it has: ${names}); ` +
306
+ `give the gateway an OTLP gRPC receiver or pass the port name`,
307
+ );
308
+ }
309
+ const routing: GatewayRouting = loadBalance ? "loadbalancing" : "service";
310
+
311
+ const exporter = loadBalance
312
+ ? new LoadBalancingExporter({
313
+ name,
314
+ routing_key: routingKey,
315
+ protocol: { otlp: { tls } },
316
+ resolver:
317
+ resolver === "dns"
318
+ ? { dns: { hostname: `${target.name}.${target.namespace}.svc`, port: String(port) } }
319
+ : { k8s: { service: `${target.name}.${target.namespace}`, ports: [port] } },
320
+ })
321
+ : new OtlpExporter({ name, endpoint: `${target.name}.${target.namespace}.svc:${port}`, tls });
322
+
323
+ markGatewayExporter(exporter, { name: deploymentName, namespace: target.namespace, routing });
324
+ return exporter;
325
+ }