@intentius/chant-lexicon-k8s 0.100.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
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { DebugExporter, KubeletStatsReceiver, OtlpReceiver, Pipeline, type CollectorConfig } from "@intentius/chant-lexicon-otel";
3
+ import { agentClusterRules, namespacedRoles } from "./otel-collector-rbac";
4
+ import { OtelCollector } from "./otel-collector";
5
+ import { GkeOtelCollector } from "./gke-otel-collector";
6
+
7
+ const READ = ["get", "list", "watch"];
8
+ const BASE = [
9
+ { apiGroups: [""], resources: ["pods", "namespaces", "nodes"], verbs: READ },
10
+ { apiGroups: ["apps"], resources: ["replicasets"], verbs: READ },
11
+ ];
12
+
13
+ function cfg(c: Partial<CollectorConfig>): CollectorConfig {
14
+ return { service: { pipelines: {} }, ...c } as CollectorConfig;
15
+ }
16
+
17
+ describe("agentClusterRules", () => {
18
+ test("an OTLP-only config gets k8sattributes' rules and nothing else", () => {
19
+ expect(agentClusterRules(cfg({ receivers: { otlp: {} } }))).toEqual(BASE);
20
+ });
21
+
22
+ test("k8sattributes extracting from a deployment adds deployments", () => {
23
+ const rules = agentClusterRules(
24
+ cfg({ processors: { "k8sattributes/x": { extract: { labels: [{ key: "team", from: "deployment" }] } } } as any }),
25
+ );
26
+ expect(rules[1]).toEqual({ apiGroups: ["apps"], resources: ["replicasets", "deployments"], verbs: READ });
27
+ });
28
+
29
+ test("kubeletstats gets nodes/stats", () => {
30
+ expect(agentClusterRules(cfg({ receivers: { kubeletstats: { auth_type: "serviceAccount" } } as any }))).toEqual([
31
+ ...BASE,
32
+ { apiGroups: [""], resources: ["nodes/stats"], verbs: ["get"] },
33
+ ]);
34
+ });
35
+
36
+ test.each([
37
+ [{ extra_metadata_labels: ["container.id"] }],
38
+ [{ metrics: { "k8s.pod.cpu_limit_utilization": { enabled: true } } }],
39
+ [{ metrics: { "k8s.container.memory_request_utilization": { enabled: true } } }],
40
+ ])("kubeletstats needing the kubelet's /pods gets nodes/proxy (%o)", (extra) => {
41
+ const rules = agentClusterRules(cfg({ receivers: { "kubeletstats/node": { auth_type: "serviceAccount", ...extra } } as any }));
42
+ expect(rules[2]).toEqual({ apiGroups: [""], resources: ["nodes/stats", "nodes/proxy"], verbs: ["get"] });
43
+ });
44
+
45
+ test("a disabled utilization metric does not add nodes/proxy", () => {
46
+ const rules = agentClusterRules(
47
+ cfg({ receivers: { kubeletstats: { metrics: { "k8s.pod.cpu_limit_utilization": { enabled: false } } } } as any }),
48
+ );
49
+ expect(rules[2].resources).toEqual(["nodes/stats"]);
50
+ });
51
+
52
+ test("kubeletstats with k8s_api_config reads persistent volumes and claims", () => {
53
+ const rules = agentClusterRules(cfg({ receivers: { kubeletstats: { k8s_api_config: { auth_type: "serviceAccount" } } } as any }));
54
+ expect(rules.at(-1)).toEqual({ apiGroups: [""], resources: ["persistentvolumeclaims", "persistentvolumes"], verbs: ["get"] });
55
+ });
56
+ });
57
+
58
+ describe("the agent ClusterRole", () => {
59
+ const rulesOf = (r: { clusterRole: unknown }) => (r.clusterRole as { props: { rules: unknown } }).props.rules;
60
+
61
+ test("OtelCollector and GkeOtelCollector grant k8sattributes' rules by default", () => {
62
+ expect(rulesOf(OtelCollector({}))).toEqual(BASE);
63
+ expect(rulesOf(GkeOtelCollector({ clusterName: "c", projectId: "p" }))).toEqual(BASE);
64
+ });
65
+
66
+ test("an OtelCollector config with kubeletstats gets its rules", () => {
67
+ const kubelet = new KubeletStatsReceiver({ auth_type: "serviceAccount", extra_metadata_labels: ["container.id"] });
68
+ const otlp = new OtlpReceiver({ protocols: { grpc: { endpoint: "0.0.0.0:4317" } } });
69
+ const debug = new DebugExporter({});
70
+ const agent = OtelCollector({ config: [new Pipeline({ signal: "metrics", receivers: [otlp, kubelet], exporters: [debug] })] });
71
+ expect(rulesOf(agent)).toEqual([...BASE, { apiGroups: [""], resources: ["nodes/stats", "nodes/proxy"], verbs: ["get"] }]);
72
+ });
73
+
74
+ test("defaults.clusterRole.rules are appended", () => {
75
+ const extra = { apiGroups: [""], resources: ["services"], verbs: READ };
76
+ expect(rulesOf(OtelCollector({ defaults: { clusterRole: { rules: [extra] } } }))).toEqual([...BASE, extra]);
77
+ });
78
+ });
79
+
80
+ describe("namespacedRoles", () => {
81
+ const base = {
82
+ key: "endpoints",
83
+ name: "agent-endpoints",
84
+ rules: [{ apiGroups: [""], resources: ["endpoints"], verbs: READ }],
85
+ labels: { app: "x" },
86
+ serviceAccount: { name: "agent-sa", namespace: "agents" },
87
+ };
88
+
89
+ test("no namespaces, no members", () => {
90
+ expect(namespacedRoles({ ...base, namespaces: [] })).toEqual({});
91
+ });
92
+
93
+ test("the first namespace keeps the plain member names, the rest are suffixed", () => {
94
+ expect(Object.keys(namespacedRoles({ ...base, namespaces: ["obs", "team-a", "b2"] }))).toEqual([
95
+ "endpointsRole",
96
+ "endpointsRoleBinding",
97
+ "endpointsRoleInTeamA",
98
+ "endpointsRoleBindingInTeamA",
99
+ "endpointsRoleInB2",
100
+ "endpointsRoleBindingInB2",
101
+ ]);
102
+ });
103
+
104
+ test("namespaces that give the same member name are refused", () => {
105
+ expect(() => namespacedRoles({ ...base, namespaces: ["obs", "a-b", "a--b"] })).toThrow(/a-b and a--b/);
106
+ });
107
+
108
+ test("defaults apply to each Role", () => {
109
+ const out = namespacedRoles({ ...base, namespaces: ["one", "two"], roleDefaults: { metadata: { annotations: { x: "y" } } } });
110
+ for (const key of ["endpointsRole", "endpointsRoleInTwo"]) {
111
+ expect((out[key] as any).props.metadata.annotations).toEqual({ x: "y" });
112
+ }
113
+ });
114
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The RBAC the collector composites grant, worked out from the collector
3
+ * config they deploy.
4
+ *
5
+ * `agentClusterRules` is the ClusterRole `OtelCollector` and
6
+ * `GkeOtelCollector` share: what the `k8sattributes` processor needs, plus
7
+ * what the config's Kubernetes receivers need. Each rule cites the
8
+ * component's RBAC requirements at collector-contrib v0.130.0, the version the
9
+ * otel lexicon pins.
10
+ *
11
+ * `namespacedRoles` builds one Role and RoleBinding per namespace, for access
12
+ * a collector needs in namespaces other than its own: Endpoints for a
13
+ * `loadbalancing` exporter's `k8s` resolver, Leases for a
14
+ * `k8s_leader_elector` extension.
15
+ */
16
+
17
+ import { mergeDefaults } from "@intentius/chant";
18
+ import type { CollectorConfig } from "@intentius/chant-lexicon-otel";
19
+ import { Role, RoleBinding } from "../generated";
20
+
21
+ /** One RBAC rule, as a Role or ClusterRole lists it. */
22
+ export interface CollectorPolicyRule {
23
+ apiGroups: string[];
24
+ resources: string[];
25
+ verbs: string[];
26
+ resourceNames?: string[];
27
+ }
28
+
29
+ const READ = ["get", "list", "watch"];
30
+
31
+ function componentsOfType(section: Record<string, unknown> | undefined, type: string): Array<Record<string, unknown>> {
32
+ return Object.entries(section ?? {})
33
+ .filter(([id]) => id === type || id.startsWith(`${type}/`))
34
+ .map(([, cfg]) => (cfg && typeof cfg === "object" ? (cfg as Record<string, unknown>) : {}));
35
+ }
36
+
37
+ /**
38
+ * The agent ClusterRole's rules for a collector config.
39
+ *
40
+ * Always, for `k8sattributes` (processor/k8sattributesprocessor README,
41
+ * "Cluster-scoped RBAC"): `get`, `list` and `watch` on pods, namespaces and
42
+ * nodes, and on `apps` replicasets for `k8s.deployment.name`, which is
43
+ * extracted by default. The processor lists ReplicaSets through `apps/v1`
44
+ * only, so the README's `extensions` group is left out. When a
45
+ * `k8sattributes` processor extracts labels or annotations `from: deployment`,
46
+ * `apps` deployments are added.
47
+ *
48
+ * For each `kubeletstats` receiver (receiver/kubeletstatsreceiver README,
49
+ * "Role-based access control"): `get` on `nodes/stats`; `get` on
50
+ * `nodes/proxy` when it sets `extra_metadata_labels` or enables a
51
+ * `*_request_utilization` or `*_limit_utilization` metric; and, when it sets
52
+ * `k8s_api_config`, `get` on persistentvolumeclaims and persistentvolumes,
53
+ * which it reads for volume metadata (scraper.go).
54
+ *
55
+ * Other components that read the Kubernetes API, such as `k8s_cluster`
56
+ * (which belongs on a gateway, see WK8603), add their rules through
57
+ * `defaults.clusterRole.rules`, which are appended.
58
+ */
59
+ export function agentClusterRules(config: CollectorConfig): CollectorPolicyRule[] {
60
+ const apps = ["replicasets"];
61
+ const k8sattributes = componentsOfType(config.processors as Record<string, unknown> | undefined, "k8sattributes");
62
+ const fromDeployment = k8sattributes.some((p) => {
63
+ const extract = p.extract as { labels?: Array<{ from?: string }>; annotations?: Array<{ from?: string }> } | undefined;
64
+ return [...(extract?.labels ?? []), ...(extract?.annotations ?? [])].some((f) => f?.from === "deployment");
65
+ });
66
+ if (fromDeployment) apps.push("deployments");
67
+
68
+ const rules: CollectorPolicyRule[] = [
69
+ { apiGroups: [""], resources: ["pods", "namespaces", "nodes"], verbs: READ },
70
+ { apiGroups: ["apps"], resources: apps, verbs: READ },
71
+ ];
72
+
73
+ const kubeletstats = componentsOfType(config.receivers as Record<string, unknown> | undefined, "kubeletstats");
74
+ if (kubeletstats.length) {
75
+ const nodeSubresources = ["nodes/stats"];
76
+ const needsProxy = kubeletstats.some((r) => {
77
+ const labels = r.extra_metadata_labels;
78
+ if (Array.isArray(labels) && labels.length) return true;
79
+ const metrics = (r.metrics ?? {}) as Record<string, { enabled?: boolean } | undefined>;
80
+ return Object.entries(metrics).some(([metric, toggle]) => /_(request|limit)_utilization$/.test(metric) && toggle?.enabled === true);
81
+ });
82
+ if (needsProxy) nodeSubresources.push("nodes/proxy");
83
+ rules.push({ apiGroups: [""], resources: nodeSubresources, verbs: ["get"] });
84
+ if (kubeletstats.some((r) => r.k8s_api_config)) {
85
+ rules.push({ apiGroups: [""], resources: ["persistentvolumeclaims", "persistentvolumes"], verbs: ["get"] });
86
+ }
87
+ }
88
+
89
+ return rules;
90
+ }
91
+
92
+ function pascal(namespace: string): string {
93
+ return namespace
94
+ .split(/[^a-zA-Z0-9]+/)
95
+ .filter(Boolean)
96
+ .map((part) => part[0].toUpperCase() + part.slice(1))
97
+ .join("");
98
+ }
99
+
100
+ export interface NamespacedRolesOptions {
101
+ /** Prefix of the member names: `<key>Role` and `<key>RoleBinding`. */
102
+ key: string;
103
+ /** Name of each Role and RoleBinding. They live in different namespaces, so one name serves all. */
104
+ name: string;
105
+ /** Namespaces to grant in, in declaration order. */
106
+ namespaces: string[];
107
+ rules: CollectorPolicyRule[];
108
+ labels: Record<string, string>;
109
+ /** The ServiceAccount every RoleBinding binds. */
110
+ serviceAccount: { name: string; namespace: string };
111
+ /** Defaults laid over each Role and each RoleBinding. */
112
+ roleDefaults?: Partial<Record<string, unknown>>;
113
+ roleBindingDefaults?: Partial<Record<string, unknown>>;
114
+ }
115
+
116
+ /**
117
+ * One Role and RoleBinding per namespace, as composite members. The first
118
+ * namespace's are `<key>Role` and `<key>RoleBinding`, so a single-namespace
119
+ * config keeps the member names it had before; each further namespace's are
120
+ * `<key>RoleIn<Namespace>` and `<key>RoleBindingIn<Namespace>`, with the
121
+ * namespace in PascalCase (`team-a` gives `endpointsRoleInTeamA`).
122
+ */
123
+ export function namespacedRoles(opts: NamespacedRolesOptions): Record<string, InstanceType<typeof Role> | InstanceType<typeof RoleBinding>> {
124
+ const out: Record<string, InstanceType<typeof Role> | InstanceType<typeof RoleBinding>> = {};
125
+ const seen = new Map<string, string>();
126
+ opts.namespaces.forEach((namespace, i) => {
127
+ const suffix = i === 0 ? "" : `In${pascal(namespace)}`;
128
+ const clash = seen.get(suffix);
129
+ if (clash !== undefined) {
130
+ throw new Error(`${opts.name}: namespaces ${clash} and ${namespace} give the same member name ${opts.key}Role${suffix}`);
131
+ }
132
+ seen.set(suffix, namespace);
133
+ out[`${opts.key}Role${suffix}`] = new Role(mergeDefaults({
134
+ metadata: { name: opts.name, namespace, labels: opts.labels },
135
+ rules: opts.rules,
136
+ }, opts.roleDefaults));
137
+ out[`${opts.key}RoleBinding${suffix}`] = new RoleBinding(mergeDefaults({
138
+ metadata: { name: opts.name, namespace, labels: opts.labels },
139
+ roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: opts.name },
140
+ subjects: [{ kind: "ServiceAccount", name: opts.serviceAccount.name, namespace: opts.serviceAccount.namespace }],
141
+ }, opts.roleBindingDefaults));
142
+ });
143
+ return out;
144
+ }
@@ -139,3 +139,22 @@ export function k8sResolverNamespaces(built: BuiltCollector, ownNamespace: strin
139
139
  }
140
140
  return out;
141
141
  }
142
+
143
+ /**
144
+ * The namespaces of the Leases a config's `k8s_leader_elector` extensions
145
+ * take, for those `service.extensions` enables (the collector starts no
146
+ * other). An elector without `lease_namespace` fails the otel lexicon's
147
+ * OTEL107 and is skipped here.
148
+ */
149
+ export function leaderElectorLeaseNamespaces(built: BuiltCollector): string[] {
150
+ const out: string[] = [];
151
+ const enabled = built.config.service?.extensions ?? [];
152
+ for (const [id, cfg] of Object.entries(built.config.extensions ?? {})) {
153
+ if (id !== "k8s_leader_elector" && !id.startsWith("k8s_leader_elector/")) continue;
154
+ if (!enabled.includes(id)) continue;
155
+ const ns = (cfg as { lease_namespace?: unknown } | null)?.lease_namespace;
156
+ if (typeof ns !== "string" || ns === "") continue;
157
+ if (!out.includes(ns)) out.push(ns);
158
+ }
159
+ return out;
160
+ }
@@ -24,6 +24,7 @@ import {
24
24
  } from "@intentius/chant-lexicon-otel";
25
25
  import { Service, Role, RoleBinding } from "../generated";
26
26
  import { collectorAgentResources, type CollectorAgentResources } from "./otel-collector-agent";
27
+ import { agentClusterRules, namespacedRoles } from "./otel-collector-rbac";
27
28
  import {
28
29
  collectorRuntime,
29
30
  gatewaysAnnotation,
@@ -74,9 +75,15 @@ export interface OtelCollectorProps {
74
75
 
75
76
  export type OtelCollectorResult = CollectorAgentResources & {
76
77
  service: InstanceType<typeof Service>;
77
- /** Read access to Endpoints for a `loadbalancing` exporter's `k8s` resolver, in the resolved Service's namespace. */
78
+ /**
79
+ * Read access to Endpoints for a `loadbalancing` exporter's `k8s` resolver,
80
+ * in the first namespace the resolvers point at. Each further namespace
81
+ * gets `endpointsRoleIn<Namespace>` and `endpointsRoleBindingIn<Namespace>`.
82
+ */
78
83
  endpointsRole?: InstanceType<typeof Role>;
79
84
  endpointsRoleBinding?: InstanceType<typeof RoleBinding>;
85
+ [member: `endpointsRoleIn${string}`]: InstanceType<typeof Role>;
86
+ [member: `endpointsRoleBindingIn${string}`]: InstanceType<typeof RoleBinding>;
80
87
  };
81
88
 
82
89
  /**
@@ -131,6 +138,7 @@ export const OtelCollector = Composite((props: OtelCollectorProps) => {
131
138
  configYaml: runtime.configYaml,
132
139
  configDir: runtime.configDir,
133
140
  ports: runtime.containerPorts,
141
+ clusterRules: agentClusterRules(runtime.built.config),
134
142
  cpuRequest,
135
143
  memoryRequest,
136
144
  cpuLimit,
@@ -165,39 +173,22 @@ export const OtelCollector = Composite((props: OtelCollectorProps) => {
165
173
  // The loadbalancing exporter's k8s resolver runs in this agent and watches
166
174
  // the gateway's headless Service, so the agent's ServiceAccount gets read
167
175
  // access to Endpoints (what the resolver watches at the pinned collector
168
- // version) and EndpointSlices (what later versions watch) in that namespace.
169
- const resolverNamespaces = k8sResolverNamespaces(runtime.built, namespace);
170
- if (resolverNamespaces.length > 1) {
171
- throw new Error(
172
- `OtelCollector ${name}: loadbalancing exporters resolve Services in ${resolverNamespaces.join(" and ")}; ` +
173
- `one agent can watch Endpoints in one namespace. Use one gateway namespace, or grant the rest through defaults.clusterRole`,
174
- );
175
- }
176
+ // version) and EndpointSlices (what later versions watch), with one Role
177
+ // and RoleBinding in each namespace the resolvers point at.
176
178
  const saName = (resources.serviceAccount as unknown as { props: { metadata: { name: string } } }).props.metadata.name;
177
- const endpointsRbac = resolverNamespaces.length
178
- ? {
179
- endpointsRole: new Role(mergeDefaults({
180
- metadata: {
181
- name: `${name}-endpoints`,
182
- namespace: resolverNamespaces[0],
183
- labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
184
- },
185
- rules: [
186
- { apiGroups: [""], resources: ["endpoints"], verbs: ["get", "list", "watch"] },
187
- { apiGroups: ["discovery.k8s.io"], resources: ["endpointslices"], verbs: ["get", "list", "watch"] },
188
- ],
189
- }, defs?.endpointsRole)),
190
- endpointsRoleBinding: new RoleBinding(mergeDefaults({
191
- metadata: {
192
- name: `${name}-endpoints`,
193
- namespace: resolverNamespaces[0],
194
- labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
195
- },
196
- roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: `${name}-endpoints` },
197
- subjects: [{ kind: "ServiceAccount", name: saName, namespace }],
198
- }, defs?.endpointsRoleBinding)),
199
- }
200
- : {};
179
+ const endpointsRbac = namespacedRoles({
180
+ key: "endpoints",
181
+ name: `${name}-endpoints`,
182
+ namespaces: k8sResolverNamespaces(runtime.built, namespace),
183
+ rules: [
184
+ { apiGroups: [""], resources: ["endpoints"], verbs: ["get", "list", "watch"] },
185
+ { apiGroups: ["discovery.k8s.io"], resources: ["endpointslices"], verbs: ["get", "list", "watch"] },
186
+ ],
187
+ labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
188
+ serviceAccount: { name: saName, namespace },
189
+ roleDefaults: defs?.endpointsRole,
190
+ roleBindingDefaults: defs?.endpointsRoleBinding,
191
+ });
201
192
 
202
- return { ...resources, service, ...endpointsRbac };
193
+ return { ...resources, service, ...endpointsRbac } as OtelCollectorResult;
203
194
  }, "OtelCollector");
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Embedded content through `chant import --from <env>` (#2995): a ConfigMap
3
+ * holding a collector config, or a PrometheusRule's groups, read from a
4
+ * (fake) cluster imports as the owning lexicon's typed declarations, exactly
5
+ * as `chant import <manifest>` does (embedded-roundtrip.test.ts).
6
+ *
7
+ * The cluster is `fakeCluster`: a real k8s client with the socket replaced.
8
+ * The objects are the round trip's fixtures with the fields a server adds.
9
+ */
10
+
11
+ import { describe, expect, test } from "vitest";
12
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
13
+ import { join, relative } from "path";
14
+ import { load, loadAll } from "js-yaml";
15
+ import { liveImportFromPlugins } from "@intentius/chant/cli/commands/import";
16
+ import type { LexiconPlugin, ResourceSelector } from "@intentius/chant/lexicon";
17
+ import { build } from "@intentius/chant/build";
18
+ import type { K8sObject } from "@intentius/chant-k8s-client";
19
+ import { otelSerializer } from "@intentius/chant-lexicon-otel";
20
+ import { prometheusSerializer } from "@intentius/chant-lexicon-prometheus";
21
+ import { k8sPlugin } from "../plugin";
22
+ import { k8sSerializer } from "../serializer";
23
+ import { exportResources } from "../export-resources";
24
+ import { fakeCluster, objectKey } from "../api/fake-cluster";
25
+ import { pkgDir, read, removeDir } from "./testdata/embedded/fixtures";
26
+
27
+ type Json = Record<string, unknown>;
28
+
29
+ /** The fixture's documents as the API server returns them. */
30
+ function liveObjects(fixture: string): Record<string, K8sObject> {
31
+ const objects: Record<string, K8sObject> = {};
32
+ for (const doc of loadAll(read(fixture)) as Json[]) {
33
+ if (!doc) continue;
34
+ const metadata = doc.metadata as Json;
35
+ const live = {
36
+ ...doc,
37
+ metadata: { ...metadata, uid: `uid-${metadata.name}`, resourceVersion: "7", managedFields: [{ manager: "helm" }] },
38
+ } as K8sObject;
39
+ const key = objectKey(doc.apiVersion as string, doc.kind as string, metadata.name as string, metadata.namespace as string | undefined);
40
+ objects[key] = live;
41
+ }
42
+ return objects;
43
+ }
44
+
45
+ function walk(dir: string, root: string, out: Record<string, string>): void {
46
+ for (const entry of readdirSync(dir)) {
47
+ const path = join(dir, entry);
48
+ if (statSync(path).isDirectory()) walk(path, root, out);
49
+ else out[relative(root, path)] = readFileSync(path, "utf-8");
50
+ }
51
+ }
52
+
53
+ /** `chant import --from test` against a cluster holding the fixture's objects, in a project inside this package. */
54
+ async function importLive(fixture: string, selector?: ResourceSelector) {
55
+ const cluster = fakeCluster({ objects: liveObjects(fixture) });
56
+ const plugin: LexiconPlugin = {
57
+ ...k8sPlugin,
58
+ exportResources: (options) => exportResources(options, cluster.connector),
59
+ };
60
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-live-"));
61
+ writeFileSync(join(dir, "package.json"), '{ "name": "k8s-live-embedded", "private": true, "type": "module" }\n');
62
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["k8s"] };\n');
63
+ const srcDir = join(dir, "src");
64
+ mkdirSync(srcDir);
65
+ const result = await liveImportFromPlugins([plugin], { environment: "test", output: srcDir, selector, force: true });
66
+ const files: Record<string, string> = {};
67
+ walk(srcDir, srcDir, files);
68
+ return { result, dir, srcDir, files };
69
+ }
70
+
71
+ function primary(out: unknown): string {
72
+ return typeof out === "string" ? out : ((out as { primary?: string } | undefined)?.primary ?? "");
73
+ }
74
+
75
+ function find(docs: Json[], kind: string, name: string): Json {
76
+ const doc = docs.find((d) => d.kind === kind && (d.metadata as Json | undefined)?.name === name);
77
+ if (!doc) throw new Error(`no ${kind} ${name}`);
78
+ return doc;
79
+ }
80
+
81
+ /** A collector config as the collector reads it: an empty component is `{}` whether written `{}` or left null. */
82
+ function collectorConfig(text: string): Json {
83
+ const doc = (load(text) ?? {}) as Json;
84
+ for (const section of ["receivers", "processors", "exporters", "connectors", "extensions"]) {
85
+ const s = doc[section] as Json | undefined;
86
+ if (!s) continue;
87
+ for (const id of Object.keys(s)) if (s[id] === null) s[id] = {};
88
+ }
89
+ return doc;
90
+ }
91
+
92
+ describe("chant import --from: embedded content imported by its owner (#2995)", () => {
93
+ test("a live collector ConfigMap becomes typed otel declarations and builds back to the same config", async () => {
94
+ const out = await importLive("otel-collector-daemonset.yaml");
95
+ try {
96
+ expect(out.result.error).toBeUndefined();
97
+ expect(out.result.success).toBe(true);
98
+ expect(out.result.warnings.filter((w) => w.includes("kept as written"))).toEqual([]);
99
+
100
+ const name = "example-opentelemetry-collector-agent";
101
+ const host = Object.entries(out.files).find(([p]) => !p.includes("/") && p !== "index.ts" && out.files[p].includes("collectorYaml("));
102
+ expect(host, Object.keys(out.files).join(", ")).toBeDefined();
103
+ expect(host![1]).toContain('import { collectorYaml } from "@intentius/chant-lexicon-otel";');
104
+ expect(out.files[`${name}/receivers.ts`]).toContain("new OtlpReceiver(");
105
+ expect(out.result.generatedFiles).toContain(`${name}/pipelines.ts`);
106
+
107
+ const result = await build(out.srcDir, [k8sSerializer, otelSerializer, prometheusSerializer]);
108
+ expect(result.errors).toEqual([]);
109
+ const rebuilt = (loadAll(primary(result.outputs.get("k8s"))) as Json[]).filter((d) => d);
110
+ const source = (loadAll(read("otel-collector-daemonset.yaml")) as Json[]).filter((d) => d);
111
+ const before = find(source, "ConfigMap", name).data as Json;
112
+ const after = find(rebuilt, "ConfigMap", name).data as Json;
113
+ expect(collectorConfig(after.relay as string)).toEqual(collectorConfig(before.relay as string));
114
+ } finally {
115
+ removeDir(out.dir);
116
+ }
117
+ });
118
+
119
+ test("a live PrometheusRule's spec.groups become prometheus RuleGroups", async () => {
120
+ const out = await importLive("node-exporter-prometheusrule.yaml", { type: "K8s::Monitoring::PrometheusRule" });
121
+ try {
122
+ expect(out.result.error).toBeUndefined();
123
+ expect(out.result.generatedFiles).toEqual(["main.ts", "node-exporter-rules/rules.ts"]);
124
+ expect(out.files["main.ts"]).toContain('from "./node-exporter-rules/rules";');
125
+ expect(out.files["node-exporter-rules/rules.ts"]).toContain("new RuleGroup(");
126
+ } finally {
127
+ removeDir(out.dir);
128
+ }
129
+ });
130
+
131
+ test("the export keeps embedded content as read when no context is given", async () => {
132
+ const cluster = fakeCluster({ objects: liveObjects("otel-collector-daemonset.yaml") });
133
+ const ir = await exportResources({ environment: "test", selector: { type: "K8s::Core::ConfigMap" } }, cluster.connector);
134
+ const [configMap] = ir.resources;
135
+ expect(typeof (configMap.properties.data as Json).relay).toBe("string");
136
+ expect(typeof ir.reparse).toBe("function");
137
+ });
138
+ });
@@ -1,5 +1,6 @@
1
1
  import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
2
2
  import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
3
+ import type { ParseContext, TemplateIR } from "@intentius/chant/import/parser";
3
4
  import { K8sParser } from "./parser";
4
5
 
5
6
  /**
@@ -52,6 +53,11 @@ export function stripServerFields(obj: Record<string, unknown>): Record<string,
52
53
  * `items` of a `kubectl get -o json` List). Reuses the import K8sParser by
53
54
  * feeding it the cleaned objects as JSON documents — JSON is valid YAML, so no
54
55
  * separate serializer is needed. Pure: all I/O stays in the caller.
56
+ *
57
+ * The result's `reparse(context)` parses the same objects again with a
58
+ * `ParseContext`, so `chant import --from` hands embedded content (a
59
+ * collector config in a ConfigMap, a PrometheusRule's groups) to the lexicon
60
+ * that owns it, as file import does (#2995).
55
61
  */
56
62
  export function buildExportFromObjects(
57
63
  objects: unknown[],
@@ -70,9 +76,11 @@ export function buildExportFromObjects(
70
76
  .map((o) => (opts.verbatim ? o : stripServerFields(o)));
71
77
 
72
78
  const content = cleaned.map((o) => JSON.stringify(o, null, 2)).join("\n---\n");
73
- const ir = new K8sParser().parse(content);
79
+ const parse = (context?: ParseContext): TemplateIR => select(new K8sParser().parse(content, context), opts.selector);
80
+ return { ...parse(), reparse: (context) => parse(context) };
81
+ }
74
82
 
75
- const selector = opts.selector;
83
+ function select(ir: TemplateIR, selector: ResourceSelector | undefined): TemplateIR {
76
84
  if (!selector || (selector.type === undefined && selector.name === undefined)) {
77
85
  return ir;
78
86
  }
@@ -49,6 +49,7 @@ export const k8sAuditCatalog: Record<string, RuleMeta> = {
49
49
  WK8601: auditRule("WK8601", "merge-worthy", "guidance", "OpenTelemetry Collector runs tail_sampling on every node", "Move tail_sampling from the DaemonSet agent to a gateway Deployment, and send traces to it through a loadbalancing exporter routed by traceID.", { category: "correctness" }),
50
50
  WK8602: auditRule("WK8602", "merge-worthy", "guidance", "Multi-replica tail sampling gateway reached without trace-aware load balancing", "Send traces to the gateway through a loadbalancing exporter with routing_key traceID (k8s resolver, or dns on a headless Service), or run the gateway with one replica. It goes silent when the senders are in another build root (chant #1939).", { category: "correctness" }),
51
51
  WK8603: auditRule("WK8603", "merge-worthy", "guidance", "k8s_cluster receiver runs in every collector copy", "Run the k8s_cluster receiver in a single-replica Deployment, or set its k8s_leader_elector to a k8s_leader_elector extension enabled in service.extensions.", { category: "correctness" }),
52
+ WK8604: auditRule("WK8604", "merge-worthy", "guidance", "Collector config in a ConfigMap fails the otel config checks", "Fix the collector config the ConfigMap carries: each finding keeps its OTEL1xx id and names the ConfigMap and key. Change the declared components and pipelines, not the rendered YAML.", { category: "correctness" }),
52
53
  };
53
54
 
54
55
  // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
@@ -43,6 +43,7 @@ import { wk8505 } from "./wk8505";
43
43
  import { wk8601 } from "./wk8601";
44
44
  import { wk8602 } from "./wk8602";
45
45
  import { wk8603 } from "./wk8603";
46
+ import { wk8604 } from "./wk8604";
46
47
 
47
48
  export const postSynthChecks: PostSynthCheck[] = [
48
49
  argo002,
@@ -88,4 +89,5 @@ export const postSynthChecks: PostSynthCheck[] = [
88
89
  wk8601,
89
90
  wk8602,
90
91
  wk8603,
92
+ wk8604,
91
93
  ];
@@ -25,7 +25,7 @@
25
25
  * `OTEL_COLLECTOR_ANNOTATIONS`.
26
26
  */
27
27
 
28
- import { load } from "js-yaml";
28
+ import { parseCollectorConfig as parseOtelCollectorConfig } from "@intentius/chant-lexicon-otel/configmap";
29
29
  import { extractContainers, extractPodSpec, type K8sManifest } from "./k8s-helpers";
30
30
 
31
31
  export const PLACEMENT_ANNOTATIONS = {
@@ -80,17 +80,13 @@ function isRecord(v: unknown): v is Record<string, unknown> {
80
80
  return typeof v === "object" && v !== null && !Array.isArray(v);
81
81
  }
82
82
 
83
- /** Parse a ConfigMap value as a collector config, or undefined when it isn't one. */
83
+ /**
84
+ * Parse a ConfigMap value as a collector config, or undefined when it isn't
85
+ * one. The otel lexicon's parser, shared with its config checks (OTEL101 and
86
+ * the rest) and WK8604 so every check agrees on what counts as a config.
87
+ */
84
88
  export function parseCollectorConfig(text: unknown): CollectorConfigShape | undefined {
85
- if (typeof text !== "string") return undefined;
86
- let value: unknown;
87
- try {
88
- value = load(text);
89
- } catch {
90
- return undefined;
91
- }
92
- if (!isRecord(value) || !isRecord(value.service) || !isRecord(value.service.pipelines)) return undefined;
93
- return value as unknown as CollectorConfigShape;
89
+ return parseOtelCollectorConfig(text) as CollectorConfigShape | undefined;
94
90
  }
95
91
 
96
92
  /** The `type` of a component id: `tail_sampling/errors` is `tail_sampling`. */
@@ -30,6 +30,7 @@ import { docsToManifests } from "./k8s-helpers";
30
30
  import { wk8601 } from "./wk8601";
31
31
  import { wk8602 } from "./wk8602";
32
32
  import { wk8603 } from "./wk8603";
33
+ import { wk8604 } from "./wk8604";
33
34
 
34
35
  // ── Fixture builders ────────────────────────────────────────────────
35
36
 
@@ -399,3 +400,37 @@ service: { pipelines: { metrics: { receivers: [otlp], exporters: [debug] } } }
399
400
  expect(wk8603.check(yaml(configMap("c", { "config.yaml": unused }), workload({ kind: "DaemonSet", name: "agent", configMap: "c" })))).toEqual([]);
400
401
  });
401
402
  });
403
+
404
+ // ── WK8604 ──────────────────────────────────────────────────────────
405
+
406
+ describe("WK8604: the otel config checks over ConfigMap configs", () => {
407
+ test("reports an undeclared exporter under OTEL101, naming the ConfigMap and key", () => {
408
+ const broken = PLAIN_CONFIG.replace("exporters: [debug]", "exporters: [debug, otlp/tempo]");
409
+ const diags = wk8604.check(yaml(configMap("agent-config", { "config.yaml": broken })));
410
+ expect(diags).toHaveLength(1);
411
+ expect(diags[0]).toMatchObject({ checkId: "OTEL101", severity: "error", lexicon: "otel" });
412
+ expect(diags[0].message).toMatch(/^ConfigMap obs\/agent-config, key config\.yaml: /);
413
+ expect(diags[0].message).toContain("otlp/tempo");
414
+ });
415
+
416
+ test("checks every key that holds a config, defaulting the namespace", () => {
417
+ const unused = PLAIN_CONFIG.replace("exporters: { debug: {} }", "exporters: { debug: {} }\nprocessors: { batch: {} }");
418
+ const cm = { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "c" }, data: { "a.yaml": PLAIN_CONFIG, "b.yaml": unused } };
419
+ const diags = wk8604.check(yaml(cm));
420
+ expect(diags.map((d) => [d.checkId, d.entity])).toEqual([["OTEL103", "batch"]]);
421
+ expect(diags[0].message).toContain("ConfigMap default/c, key b.yaml");
422
+ });
423
+
424
+ test("ignores ConfigMap values that are not collector configs", () => {
425
+ const ctx = yaml(configMap("prometheus-config", { "prometheus.yml": "scrape_configs: []\n", "rules.yml": "groups: [ {" }));
426
+ expect(wk8604.check(ctx)).toEqual([]);
427
+ });
428
+
429
+ test("passes what OtelCollector, OtelCollectorGateway and GkeOtelCollector render", () => {
430
+ const gke = GkeOtelCollector({ clusterName: "c", projectId: "p", gcpServiceAccountEmail: "o@p.iam.gserviceaccount.com" });
431
+ const gateway = samplingGateway(2);
432
+ const agent = agentSending(gatewayExporter(gateway, { loadBalance: true }));
433
+ expect(wk8604.check(built({ gke, gateway, agent }))).toEqual([]);
434
+ });
435
+ });
436
+