@intentius/chant-lexicon-k8s 0.99.0 → 0.101.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 (45) hide show
  1. package/dist/composites/catalog.d.ts.map +1 -1
  2. package/dist/composites/gke-otel-collector.d.ts.map +1 -1
  3. package/dist/composites/otel-collector-agent.d.ts +5 -1
  4. package/dist/composites/otel-collector-agent.d.ts.map +1 -1
  5. package/dist/composites/otel-collector-gateway.d.ts +37 -52
  6. package/dist/composites/otel-collector-gateway.d.ts.map +1 -1
  7. package/dist/composites/otel-collector-rbac.d.ts +74 -0
  8. package/dist/composites/otel-collector-rbac.d.ts.map +1 -0
  9. package/dist/composites/otel-collector-shape.d.ts +7 -0
  10. package/dist/composites/otel-collector-shape.d.ts.map +1 -1
  11. package/dist/composites/otel-collector.d.ts +8 -20
  12. package/dist/composites/otel-collector.d.ts.map +1 -1
  13. package/dist/import/live-export.d.ts +5 -0
  14. package/dist/import/live-export.d.ts.map +1 -1
  15. package/dist/integrity.json +4 -3
  16. package/dist/lint/audit-catalog.d.ts.map +1 -1
  17. package/dist/lint/post-synth/index.d.ts.map +1 -1
  18. package/dist/lint/post-synth/otel-placement-helpers.d.ts +5 -1
  19. package/dist/lint/post-synth/otel-placement-helpers.d.ts.map +1 -1
  20. package/dist/lint/post-synth/wk8604.d.ts +19 -0
  21. package/dist/lint/post-synth/wk8604.d.ts.map +1 -0
  22. package/dist/manifest.json +1 -1
  23. package/dist/okf/index.md +1 -0
  24. package/dist/okf/rules/WK8604.md +11 -0
  25. package/dist/rules/otel-placement-helpers.ts +7 -11
  26. package/dist/rules/wk8604.ts +29 -0
  27. package/package.json +3 -3
  28. package/src/composites/__snapshots__/gke-otel-collector.snapshot.test.ts.snap +2 -68
  29. package/src/composites/catalog.ts +6 -0
  30. package/src/composites/gke-otel-collector.snapshot.test.ts +3 -1
  31. package/src/composites/gke-otel-collector.ts +8 -2
  32. package/src/composites/otel-collector-agent.ts +6 -9
  33. package/src/composites/otel-collector-gateway.test.ts +102 -5
  34. package/src/composites/otel-collector-gateway.ts +96 -18
  35. package/src/composites/otel-collector-rbac.test.ts +114 -0
  36. package/src/composites/otel-collector-rbac.ts +144 -0
  37. package/src/composites/otel-collector-shape.ts +19 -0
  38. package/src/composites/otel-collector.ts +25 -34
  39. package/src/import/embedded-live.test.ts +138 -0
  40. package/src/import/live-export.ts +10 -2
  41. package/src/lint/audit-catalog.ts +1 -0
  42. package/src/lint/post-synth/index.ts +2 -0
  43. package/src/lint/post-synth/otel-placement-helpers.ts +7 -11
  44. package/src/lint/post-synth/otel-placement.test.ts +35 -0
  45. package/src/lint/post-synth/wk8604.ts +29 -0
@@ -80,8 +80,8 @@ rules:
80
80
  - ''
81
81
  resources:
82
82
  - pods
83
+ - namespaces
83
84
  - nodes
84
- - endpoints
85
85
  verbs:
86
86
  - get
87
87
  - list
@@ -94,39 +94,6 @@ rules:
94
94
  - get
95
95
  - list
96
96
  - watch
97
- - apiGroups:
98
- - batch
99
- resources:
100
- - jobs
101
- verbs:
102
- - get
103
- - list
104
- - watch
105
- - apiGroups:
106
- - ''
107
- resources:
108
- - nodes/proxy
109
- verbs:
110
- - get
111
- - apiGroups:
112
- - ''
113
- resources:
114
- - nodes/stats
115
- - configmaps
116
- - events
117
- verbs:
118
- - create
119
- - get
120
- - apiGroups:
121
- - ''
122
- resources:
123
- - configmaps
124
- verbs:
125
- - get
126
- - update
127
- - create
128
- resourceNames:
129
- - otel-container-insight-clusterleader
130
97
 
131
98
  ---
132
99
  apiVersion: rbac.authorization.k8s.io/v1
@@ -281,8 +248,8 @@ rules:
281
248
  - ''
282
249
  resources:
283
250
  - pods
251
+ - namespaces
284
252
  - nodes
285
- - endpoints
286
253
  verbs:
287
254
  - get
288
255
  - list
@@ -295,39 +262,6 @@ rules:
295
262
  - get
296
263
  - list
297
264
  - watch
298
- - apiGroups:
299
- - batch
300
- resources:
301
- - jobs
302
- verbs:
303
- - get
304
- - list
305
- - watch
306
- - apiGroups:
307
- - ''
308
- resources:
309
- - nodes/proxy
310
- verbs:
311
- - get
312
- - apiGroups:
313
- - ''
314
- resources:
315
- - nodes/stats
316
- - configmaps
317
- - events
318
- verbs:
319
- - create
320
- - get
321
- - apiGroups:
322
- - ''
323
- resources:
324
- - configmaps
325
- verbs:
326
- - get
327
- - update
328
- - create
329
- resourceNames:
330
- - otel-container-insight-clusterleader
331
265
 
332
266
  ---
333
267
  apiVersion: rbac.authorization.k8s.io/v1
@@ -2149,11 +2149,15 @@ export const compositeCatalog: CompositeEntry[] = [
2149
2149
  "lexicon": "k8s",
2150
2150
  "description": "Create a GkeOtelCollector composite — returns prop objects for a DaemonSet, ServiceAccount, ClusterRole, ClusterRoleBinding, and ConfigMap.",
2151
2151
  "bundles": [
2152
+ "BatchProcessor",
2152
2153
  "ClusterRole",
2153
2154
  "ClusterRoleBinding",
2154
2155
  "ConfigMap",
2155
2156
  "DaemonSet",
2157
+ "GoogleCloudExporter",
2158
+ "OtlpReceiver",
2156
2159
  "Pipeline",
2160
+ "ResourceDetectionProcessor",
2157
2161
  "ServiceAccount"
2158
2162
  ],
2159
2163
  "params": [
@@ -2991,6 +2995,8 @@ export const compositeCatalog: CompositeEntry[] = [
2991
2995
  "ConfigMap",
2992
2996
  "Deployment",
2993
2997
  "PodDisruptionBudget",
2998
+ "Role",
2999
+ "RoleBinding",
2994
3000
  "Service",
2995
3001
  "ServiceAccount"
2996
3002
  ],
@@ -4,7 +4,9 @@
4
4
  * `OtelCollector`, `OtelCollectorGateway` and `GkeOtelCollector` share the
5
5
  * collector resources in `otel-collector-agent.ts`. The snapshot was recorded
6
6
  * before the gateway work touched that file, so any change to the GKE output
7
- * made through the shared code fails here.
7
+ * made through the shared code fails here. #2923 changed it on purpose: the
8
+ * shared ClusterRole was narrowed to what k8sattributes and the config's
9
+ * receivers need (see `agentClusterRules`).
8
10
  */
9
11
  import { describe, expect, test } from "vitest";
10
12
  import { expandComposite } from "@intentius/chant";
@@ -16,9 +16,11 @@ import {
16
16
  ResourceDetectionProcessor,
17
17
  GoogleCloudExporter,
18
18
  Pipeline,
19
+ buildCollectorConfig,
19
20
  collectorYaml,
20
21
  } from "@intentius/chant-lexicon-otel";
21
22
  import { collectorAgentResources, type CollectorAgentResources } from "./otel-collector-agent";
23
+ import { agentClusterRules } from "./otel-collector-rbac";
22
24
 
23
25
  export interface GkeOtelCollectorProps {
24
26
  /** GKE cluster name. */
@@ -110,14 +112,15 @@ export const GkeOtelCollector = Composite((props: GkeOtelCollectorProps) => {
110
112
  attribute_mappings: [{ key: "service.name", replacement: "g.co/r/service/name" }],
111
113
  },
112
114
  });
113
- const otelConfig = collectorYaml([
115
+ const entities = [
114
116
  otlp,
115
117
  batch,
116
118
  resourceDetection,
117
119
  googleCloud,
118
120
  new Pipeline({ signal: "metrics", receivers: [otlp], processors: [batch, resourceDetection], exporters: [googleCloud] }),
119
121
  new Pipeline({ signal: "traces", receivers: [otlp], processors: [batch, resourceDetection], exporters: [googleCloud] }),
120
- ]);
122
+ ];
123
+ const otelConfig = collectorYaml(entities);
121
124
 
122
125
  // The DaemonSet, RBAC and ConfigMap are the ones OtelCollector builds too.
123
126
  return collectorAgentResources({
@@ -128,6 +131,9 @@ export const GkeOtelCollector = Composite((props: GkeOtelCollectorProps) => {
128
131
  extraLabels,
129
132
  configYaml: otelConfig,
130
133
  configDir: "/etc/otel",
134
+ // The ClusterRole OtelCollector shares: k8sattributes' rules, and none
135
+ // for receivers, since the config's only receiver is otlp (#2923).
136
+ clusterRules: agentClusterRules(buildCollectorConfig(entities).config),
131
137
  ports: [
132
138
  { containerPort: 4317, name: "otlp-grpc" },
133
139
  { containerPort: 4318, name: "otlp-http" },
@@ -2,7 +2,8 @@
2
2
  * The Kubernetes resources an OpenTelemetry Collector agent needs, shared by
3
3
  * `OtelCollector` and `GkeOtelCollector`: a DaemonSet running the collector
4
4
  * with its config mounted from a ConfigMap, and a ServiceAccount bound to a
5
- * ClusterRole that reads pods, nodes and workloads.
5
+ * ClusterRole with the rules the caller works out from its config (see
6
+ * `agentClusterRules` in `otel-collector-rbac.ts`).
6
7
  *
7
8
  * The caller renders the collector config (with the otel lexicon's
8
9
  * `collectorYaml`) and says which ports the container exposes. Everything
@@ -15,6 +16,7 @@
15
16
 
16
17
  import { mergeDefaults } from "@intentius/chant";
17
18
  import { DaemonSet, ServiceAccount, ClusterRole, ClusterRoleBinding, ConfigMap } from "../generated";
19
+ import type { CollectorPolicyRule } from "./otel-collector-rbac";
18
20
 
19
21
  export interface CollectorAgentOptions {
20
22
  name: string;
@@ -29,6 +31,8 @@ export interface CollectorAgentOptions {
29
31
  /** Directory the config is mounted in. The file is `<dir>/config.yaml`. */
30
32
  configDir: string;
31
33
  ports: Array<{ containerPort: number; name: string }>;
34
+ /** The ClusterRole's rules, from `agentClusterRules`. */
35
+ clusterRules: CollectorPolicyRule[];
32
36
  cpuRequest: string;
33
37
  memoryRequest: string;
34
38
  cpuLimit: string;
@@ -104,14 +108,7 @@ export function collectorAgentResources(opts: CollectorAgentOptions): CollectorA
104
108
  name: clusterRoleName,
105
109
  labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
106
110
  },
107
- rules: [
108
- { apiGroups: [""], resources: ["pods", "nodes", "endpoints"], verbs: ["get", "list", "watch"] },
109
- { apiGroups: ["apps"], resources: ["replicasets"], verbs: ["get", "list", "watch"] },
110
- { apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch"] },
111
- { apiGroups: [""], resources: ["nodes/proxy"], verbs: ["get"] },
112
- { apiGroups: [""], resources: ["nodes/stats", "configmaps", "events"], verbs: ["create", "get"] },
113
- { apiGroups: [""], resources: ["configmaps"], verbs: ["get", "update", "create"], resourceNames: ["otel-container-insight-clusterleader"] },
114
- ],
111
+ rules: opts.clusterRules,
115
112
  }, defs?.clusterRole));
116
113
 
117
114
  const clusterRoleBinding = new ClusterRoleBinding(mergeDefaults({
@@ -4,9 +4,12 @@ import { expandComposite } from "@intentius/chant";
4
4
  import type { Declarable } from "@intentius/chant/declarable";
5
5
  import {
6
6
  DebugExporter,
7
+ K8sClusterReceiver,
8
+ K8sLeaderElectorExtension,
7
9
  LoadBalancingExporter,
8
10
  OtlpReceiver,
9
11
  Pipeline,
12
+ Service as CollectorService,
10
13
  TailSamplingProcessor,
11
14
  collectorTopology,
12
15
  validateCollectorConfig,
@@ -85,6 +88,48 @@ describe("OtelCollectorGateway", () => {
85
88
  expect(p(gw.clusterRoleBinding).roleRef.name).toBe("gw-role");
86
89
  });
87
90
 
91
+ test("an enabled k8s_leader_elector adds a Role for Leases in its lease_namespace", () => {
92
+ const elector = new K8sLeaderElectorExtension({ lease_name: "otel-cluster", lease_namespace: "leases" });
93
+ const cluster = new K8sClusterReceiver({ k8s_leader_elector: elector.componentId });
94
+ const debug = new DebugExporter({});
95
+ const pipeline = new Pipeline({ signal: "metrics", receivers: [cluster], exporters: [debug] });
96
+ const gw = OtelCollectorGateway({ name: "gw", namespace: "obs", config: [pipeline, new CollectorService({ extensions: [elector] })] });
97
+ expect(p(gw.leaseRole).metadata).toMatchObject({ name: "gw-leases", namespace: "leases" });
98
+ expect(p(gw.leaseRole).rules).toEqual([
99
+ { apiGroups: ["coordination.k8s.io"], resources: ["leases"], verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] },
100
+ ]);
101
+ expect(p(gw.leaseRoleBinding).roleRef).toEqual({ apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "gw-leases" });
102
+ expect(p(gw.leaseRoleBinding).subjects).toEqual([{ kind: "ServiceAccount", name: "gw-sa", namespace: "obs" }]);
103
+
104
+ // Declared but left out of service.extensions: the collector never starts it, so no Role.
105
+ const idle = OtelCollectorGateway({ config: [pipeline, elector, new CollectorService({ extensions: [] })] });
106
+ expect(config(idle).extensions).toHaveProperty(["k8s_leader_elector"]);
107
+ expect(idle.leaseRole).toBeUndefined();
108
+ expect(OtelCollectorGateway({}).leaseRole).toBeUndefined();
109
+ });
110
+
111
+ test("k8s_leader_elector extensions in two lease namespaces get a Role and RoleBinding in each", () => {
112
+ const a = new K8sLeaderElectorExtension({ name: "a", lease_name: "a", lease_namespace: "one" });
113
+ const b = new K8sLeaderElectorExtension({ name: "b", lease_name: "b", lease_namespace: "team-two" });
114
+ const debug = new DebugExporter({});
115
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
116
+ const config = [new Pipeline({ signal: "traces", receivers: [otlp], exporters: [debug] }), new CollectorService({ extensions: [a, b] })];
117
+ const gw = OtelCollectorGateway({ name: "gw", namespace: "obs", config });
118
+ expect(p(gw.leaseRole).metadata).toMatchObject({ name: "gw-leases", namespace: "one" });
119
+ expect(p(gw.leaseRoleBinding).metadata).toMatchObject({ name: "gw-leases", namespace: "one" });
120
+ expect(p(gw.leaseRoleInTeamTwo).metadata).toMatchObject({ name: "gw-leases", namespace: "team-two" });
121
+ expect(p(gw.leaseRoleInTeamTwo).rules).toEqual(p(gw.leaseRole).rules);
122
+ expect(p(gw.leaseRoleBindingInTeamTwo).metadata).toMatchObject({ name: "gw-leases", namespace: "team-two" });
123
+ expect(p(gw.leaseRoleBindingInTeamTwo).subjects).toEqual([{ kind: "ServiceAccount", name: "gw-sa", namespace: "obs" }]);
124
+ const roles = manifests({ gw }).filter((d) => d.kind === "Role" || d.kind === "RoleBinding");
125
+ expect(roles.map((d) => `${d.kind}/${d.metadata.namespace}/${d.metadata.name}`).sort()).toEqual([
126
+ "Role/one/gw-leases",
127
+ "Role/team-two/gw-leases",
128
+ "RoleBinding/one/gw-leases",
129
+ "RoleBinding/team-two/gw-leases",
130
+ ]);
131
+ });
132
+
88
133
  test("the Deployment and ConfigMap say they are a gateway and name each other", () => {
89
134
  const gw = OtelCollectorGateway({ name: "gw" });
90
135
  expect(p(gw.deployment).metadata.annotations).toEqual({ [A.role]: "gateway", [A.config]: "gw-config" });
@@ -147,6 +192,36 @@ describe("gatewayExporter", () => {
147
192
  expect(() => gatewayExporter(gw)).toThrow(/no port named "otlp-grpc" \(it has: otlp-http\)/);
148
193
  });
149
194
 
195
+ test("with protocol http, an otlphttp exporter at the gateway's OTLP HTTP port", () => {
196
+ const exporter = gatewayExporter(OtelCollectorGateway({}), { protocol: "http" });
197
+ expect(exporter.componentId).toBe("otlphttp/gateway");
198
+ expect(p(exporter)).toEqual({ name: "gateway", endpoint: "http://otel-gateway.observability.svc:4318" });
199
+ const agent = agentOf(exporter, { name: "agent" });
200
+ expect(config(agent).exporters?.["otlphttp/gateway"]).toEqual({ endpoint: "http://otel-gateway.observability.svc:4318" });
201
+ expect(p(agent.configMap).metadata.annotations[A.gateways]).toBe("observability/otel-gateway=service");
202
+ expect(validateCollectorConfig(config(agent))).toEqual([]);
203
+ });
204
+
205
+ test("with protocol http and TLS, https and the TLS settings without insecure", () => {
206
+ const exporter = gatewayExporter(OtelCollectorGateway({ name: "gw", namespace: "obs" }), { protocol: "http", tls: { ca_file: "/certs/ca.pem" } });
207
+ expect(p(exporter)).toEqual({ name: "gateway", endpoint: "https://gw.obs.svc:4318", tls: { ca_file: "/certs/ca.pem" } });
208
+ });
209
+
210
+ test("with protocol grpc, the default otlp exporter", () => {
211
+ const gw = OtelCollectorGateway({});
212
+ expect(p(gatewayExporter(gw, { protocol: "grpc" }))).toEqual(p(gatewayExporter(gw)));
213
+ });
214
+
215
+ test("protocol http with loadBalance is refused", () => {
216
+ expect(() => gatewayExporter(OtelCollectorGateway({}), { protocol: "http", loadBalance: true })).toThrow(/gRPC only/);
217
+ });
218
+
219
+ test("a gateway without an OTLP HTTP port is refused for protocol http", () => {
220
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
221
+ const gw = OtelCollectorGateway({ config: [new Pipeline({ signal: "traces", receivers: [otlp], exporters: [new DebugExporter({})] })] });
222
+ expect(() => gatewayExporter(gw, { protocol: "http" })).toThrow(/no port named "otlp-http" \(it has: otlp-grpc\)/);
223
+ });
224
+
150
225
  test("the otel topology reports the gateway as the exporter's endpoint", () => {
151
226
  const agent = agentOf(gatewayExporter(OtelCollectorGateway({ namespace: "obs" }), { loadBalance: true }));
152
227
  const exporters = collectorTopology(config(agent)).exporters;
@@ -182,13 +257,35 @@ describe("OtelCollector wired to a gateway", () => {
182
257
  expect(p(agent.configMap).metadata.annotations[A.gateways]).toBeUndefined();
183
258
  });
184
259
 
185
- test("resolvers in two namespaces are refused", () => {
260
+ test("resolvers in two namespaces get a Role and RoleBinding in each", () => {
186
261
  const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
187
262
  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/);
263
+ const b = gatewayExporter(OtelCollectorGateway({ namespace: "team-two" }), { loadBalance: true, name: "b" });
264
+ const agent = OtelCollector({ name: "agent", namespace: "agents", config: [new Pipeline({ signal: "traces", receivers: [otlp], exporters: [a, b] })] });
265
+ expect(p(agent.endpointsRole).metadata).toMatchObject({ name: "agent-endpoints", namespace: "one" });
266
+ expect(p(agent.endpointsRoleInTeamTwo).metadata).toMatchObject({ name: "agent-endpoints", namespace: "team-two" });
267
+ expect(p(agent.endpointsRoleInTeamTwo).rules).toEqual(p(agent.endpointsRole).rules);
268
+ for (const binding of [agent.endpointsRoleBinding, agent.endpointsRoleBindingInTeamTwo]) {
269
+ expect(p(binding).roleRef).toEqual({ apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "agent-endpoints" });
270
+ expect(p(binding).subjects).toEqual([{ kind: "ServiceAccount", name: "agent-sa", namespace: "agents" }]);
271
+ }
272
+ expect(p(agent.endpointsRoleBindingInTeamTwo).metadata.namespace).toBe("team-two");
273
+ const roles = manifests({ agent }).filter((d) => d.kind === "Role" || d.kind === "RoleBinding");
274
+ expect(roles.map((d) => `${d.kind}/${d.metadata.namespace}/${d.metadata.name}`).sort()).toEqual([
275
+ "Role/one/agent-endpoints",
276
+ "Role/team-two/agent-endpoints",
277
+ "RoleBinding/one/agent-endpoints",
278
+ "RoleBinding/team-two/agent-endpoints",
279
+ ]);
280
+ });
281
+
282
+ test("resolvers in one namespace keep a single Role", () => {
283
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
284
+ const gw = OtelCollectorGateway({ namespace: "one" });
285
+ const a = gatewayExporter(gw, { loadBalance: true, name: "a" });
286
+ const b = gatewayExporter(gw, { loadBalance: true, name: "b", routingKey: "service" });
287
+ const agent = OtelCollector({ config: [new Pipeline({ signal: "traces", receivers: [otlp], exporters: [a, b] })] });
288
+ expect(Object.keys((agent as any).members).filter((k) => k.startsWith("endpoints")).sort()).toEqual(["endpointsRole", "endpointsRoleBinding"]);
192
289
  });
193
290
 
194
291
  test("the agent's ConfigMap and DaemonSet name the gateway and how they reach it", () => {
@@ -21,28 +21,35 @@ import {
21
21
  otlpCollector,
22
22
  LoadBalancingExporter,
23
23
  OtlpExporter,
24
+ OtlpHttpExporter,
24
25
  COLLECTOR_IMAGE,
25
26
  type LoadBalancingRoutingKey,
26
27
  type OTelComponent,
27
28
  type Signal,
28
29
  type TLSClientSettings,
29
30
  } from "@intentius/chant-lexicon-otel";
30
- import { Deployment, Service, ServiceAccount, ClusterRole, ClusterRoleBinding, ConfigMap, PodDisruptionBudget } from "../generated";
31
+ import {
32
+ Deployment,
33
+ Service,
34
+ ServiceAccount,
35
+ ClusterRole,
36
+ ClusterRoleBinding,
37
+ ConfigMap,
38
+ PodDisruptionBudget,
39
+ Role,
40
+ RoleBinding,
41
+ } from "../generated";
31
42
  import { collectorConfigMap, collectorContainer } from "./otel-collector-agent";
43
+ import { namespacedRoles, type CollectorPolicyRule } from "./otel-collector-rbac";
32
44
  import {
33
45
  collectorRuntime,
46
+ leaderElectorLeaseNamespaces,
34
47
  markGatewayExporter,
35
48
  OTEL_COLLECTOR_ANNOTATIONS,
36
49
  type GatewayRouting,
37
50
  } from "./otel-collector-shape";
38
51
 
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
- }
52
+ export type { CollectorPolicyRule };
46
53
 
47
54
  export interface OtelCollectorGatewayProps {
48
55
  /** Gateway name (default: "otel-gateway"). */
@@ -90,6 +97,8 @@ export interface OtelCollectorGatewayProps {
90
97
  podDisruptionBudget?: Partial<Record<string, unknown>>;
91
98
  clusterRole?: Partial<Record<string, unknown>>;
92
99
  clusterRoleBinding?: Partial<Record<string, unknown>>;
100
+ leaseRole?: Partial<Record<string, unknown>>;
101
+ leaseRoleBinding?: Partial<Record<string, unknown>>;
93
102
  };
94
103
  }
95
104
 
@@ -103,13 +112,23 @@ export type OtelCollectorGatewayResult = {
103
112
  podDisruptionBudget?: InstanceType<typeof PodDisruptionBudget>;
104
113
  clusterRole?: InstanceType<typeof ClusterRole>;
105
114
  clusterRoleBinding?: InstanceType<typeof ClusterRoleBinding>;
115
+ /**
116
+ * Access to Leases for the `k8s_leader_elector` extensions the config
117
+ * enables, in the first `lease_namespace`. Each further namespace gets
118
+ * `leaseRoleIn<Namespace>` and `leaseRoleBindingIn<Namespace>`.
119
+ */
120
+ leaseRole?: InstanceType<typeof Role>;
121
+ leaseRoleBinding?: InstanceType<typeof RoleBinding>;
122
+ [member: `leaseRoleIn${string}`]: InstanceType<typeof Role>;
123
+ [member: `leaseRoleBindingIn${string}`]: InstanceType<typeof RoleBinding>;
106
124
  };
107
125
 
108
126
  /**
109
127
  * Create an OtelCollectorGateway composite. Returns a Deployment, a ClusterIP
110
128
  * 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.
129
+ * PodDisruptionBudget when there is more than one replica, a ClusterRole
130
+ * and binding when `clusterRules` is set, and a Role and binding for Leases
131
+ * when the config enables a `k8s_leader_elector` extension.
113
132
  *
114
133
  * @example
115
134
  * ```ts
@@ -239,14 +258,42 @@ export const OtelCollectorGateway = Composite((props: OtelCollectorGatewayProps)
239
258
  }
240
259
  : {};
241
260
 
242
- return { deployment, service, headlessService, serviceAccount, configMap, ...pdb, ...rbac };
261
+ // A k8s_leader_elector extension takes a Lease in its lease_namespace, so
262
+ // the gateway's ServiceAccount gets the access the extension's README
263
+ // suggests (collector-contrib v0.130.0, extension/k8sleaderelector), with
264
+ // one Role and RoleBinding in each namespace the electors use.
265
+ const leaseRbac = namespacedRoles({
266
+ key: "lease",
267
+ name: `${name}-leases`,
268
+ namespaces: leaderElectorLeaseNamespaces(runtime.built),
269
+ rules: [
270
+ {
271
+ apiGroups: ["coordination.k8s.io"],
272
+ resources: ["leases"],
273
+ verbs: ["get", "list", "watch", "create", "update", "patch", "delete"],
274
+ },
275
+ ],
276
+ labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
277
+ serviceAccount: { name: saName, namespace },
278
+ roleDefaults: defs?.leaseRole,
279
+ roleBindingDefaults: defs?.leaseRoleBinding,
280
+ });
281
+
282
+ return { deployment, service, headlessService, serviceAccount, configMap, ...pdb, ...rbac, ...leaseRbac } as OtelCollectorGatewayResult;
243
283
  }, "OtelCollectorGateway");
244
284
 
245
285
  // ── Agent side ───────────────────────────────────────────────────────
246
286
 
247
287
  export interface GatewayExporterOptions {
248
- /** Exporter name, so the id is `otlp/<name>` or `loadbalancing/<name>` (default: "gateway"). */
288
+ /** Exporter name, so the id is `otlp/<name>`, `otlphttp/<name>` or `loadbalancing/<name>` (default: "gateway"). */
249
289
  name?: string;
290
+ /**
291
+ * OTLP over gRPC (an `otlp` exporter) or over HTTP (an `otlphttp`
292
+ * exporter). The `loadbalancing` exporter sends OTLP over gRPC only at the
293
+ * pinned collector version, so `http` cannot be combined with
294
+ * `loadBalance`. Default: "grpc".
295
+ */
296
+ protocol?: "grpc" | "http";
250
297
  /**
251
298
  * Send each trace to one gateway replica through the headless Service with
252
299
  * a `loadbalancing` exporter, instead of to the ClusterIP Service with an
@@ -262,9 +309,13 @@ export interface GatewayExporterOptions {
262
309
  resolver?: "k8s" | "dns";
263
310
  /** What the `loadbalancing` exporter hashes on (default: "traceID"). */
264
311
  routingKey?: LoadBalancingRoutingKey;
265
- /** The gateway Service port to send to, by name (default: "otlp-grpc"). */
312
+ /** The gateway Service port to send to, by name (default: "otlp-grpc", or "otlp-http" with `protocol: "http"`). */
266
313
  port?: string;
267
- /** TLS for the connection to the gateway (default: `{ insecure: true }`, plaintext inside the cluster). */
314
+ /**
315
+ * TLS for the connection to the gateway (default: `{ insecure: true }`,
316
+ * plaintext inside the cluster). Over HTTP, `insecure` picks the scheme
317
+ * (`http://`, or `https://` without it) and the other settings are passed on.
318
+ */
268
319
  tls?: TLSClientSettings;
269
320
  }
270
321
 
@@ -285,6 +336,9 @@ function serviceMeta(service: InstanceType<typeof Service>): ServiceMeta {
285
336
  * config listens on.
286
337
  *
287
338
  * By default an `otlp` exporter at `<service>.<namespace>.svc:<port>`. With
339
+ * `protocol: "http"`, an `otlphttp` exporter at
340
+ * `http://<service>.<namespace>.svc:<port>` (`https://` when `tls` is not
341
+ * `insecure`), sending to the gateway's OTLP HTTP port. With
288
342
  * `loadBalance`, a `loadbalancing` exporter routing by trace id to the
289
343
  * headless Service, through the `k8s` resolver (`<headless>.<namespace>`) or
290
344
  * the `dns` resolver (`<headless>.<namespace>.svc`). Put it in the agent's
@@ -294,8 +348,15 @@ function serviceMeta(service: InstanceType<typeof Service>): ServiceMeta {
294
348
  export function gatewayExporter(
295
349
  gateway: OtelCollectorGatewayResult,
296
350
  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;
351
+ ): InstanceType<typeof OtlpExporter> | InstanceType<typeof OtlpHttpExporter> | InstanceType<typeof LoadBalancingExporter> {
352
+ const { name = "gateway", protocol = "grpc", loadBalance = false, resolver = "k8s", routingKey = "traceID", tls = { insecure: true } } = options;
353
+ const portName = options.port ?? (protocol === "http" ? "otlp-http" : "otlp-grpc");
354
+ if (protocol === "http" && loadBalance) {
355
+ throw new Error(
356
+ `gatewayExporter: the loadbalancing exporter sends OTLP over gRPC only; ` +
357
+ `use protocol "grpc" with loadBalance, or drop loadBalance to send OTLP over HTTP to the gateway's Service`,
358
+ );
359
+ }
299
360
  const deploymentName = (gateway.deployment as unknown as { props: { metadata: { name: string } } }).props.metadata.name;
300
361
  const target = serviceMeta(loadBalance ? gateway.headlessService : gateway.service);
301
362
  const port = target.ports.find((p) => p.name === portName)?.port;
@@ -303,7 +364,7 @@ export function gatewayExporter(
303
364
  const names = target.ports.map((p) => p.name).join(", ") || "none";
304
365
  throw new Error(
305
366
  `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`,
367
+ `give the gateway an OTLP ${protocol === "http" ? "HTTP" : "gRPC"} receiver or pass the port name`,
307
368
  );
308
369
  }
309
370
  const routing: GatewayRouting = loadBalance ? "loadbalancing" : "service";
@@ -318,8 +379,25 @@ export function gatewayExporter(
318
379
  ? { dns: { hostname: `${target.name}.${target.namespace}.svc`, port: String(port) } }
319
380
  : { k8s: { service: `${target.name}.${target.namespace}`, ports: [port] } },
320
381
  })
321
- : new OtlpExporter({ name, endpoint: `${target.name}.${target.namespace}.svc:${port}`, tls });
382
+ : protocol === "http"
383
+ ? otlpHttpExporter(name, `${target.name}.${target.namespace}.svc:${port}`, tls)
384
+ : new OtlpExporter({ name, endpoint: `${target.name}.${target.namespace}.svc:${port}`, tls });
322
385
 
323
386
  markGatewayExporter(exporter, { name: deploymentName, namespace: target.namespace, routing });
324
387
  return exporter;
325
388
  }
389
+
390
+ /**
391
+ * An `otlphttp` exporter to `host:port`. Its endpoint is a URL, so TLS is the
392
+ * scheme: `insecure` gives `http://`, anything else `https://` with the
393
+ * remaining TLS settings (a CA, a client certificate) kept.
394
+ */
395
+ function otlpHttpExporter(name: string, hostPort: string, tls: TLSClientSettings): InstanceType<typeof OtlpHttpExporter> {
396
+ const { insecure, ...rest } = tls;
397
+ const scheme = insecure ? "http" : "https";
398
+ return new OtlpHttpExporter({
399
+ name,
400
+ endpoint: `${scheme}://${hostPort}`,
401
+ ...(Object.keys(rest).length ? { tls: rest } : {}),
402
+ });
403
+ }