@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.
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/gke-otel-collector.d.ts.map +1 -1
- package/dist/composites/otel-collector-agent.d.ts +5 -1
- package/dist/composites/otel-collector-agent.d.ts.map +1 -1
- package/dist/composites/otel-collector-gateway.d.ts +37 -52
- package/dist/composites/otel-collector-gateway.d.ts.map +1 -1
- package/dist/composites/otel-collector-rbac.d.ts +74 -0
- package/dist/composites/otel-collector-rbac.d.ts.map +1 -0
- package/dist/composites/otel-collector-shape.d.ts +7 -0
- package/dist/composites/otel-collector-shape.d.ts.map +1 -1
- package/dist/composites/otel-collector.d.ts +8 -20
- package/dist/composites/otel-collector.d.ts.map +1 -1
- package/dist/import/live-export.d.ts +5 -0
- package/dist/import/live-export.d.ts.map +1 -1
- package/dist/integrity.json +4 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/otel-placement-helpers.d.ts +5 -1
- package/dist/lint/post-synth/otel-placement-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8604.d.ts +19 -0
- package/dist/lint/post-synth/wk8604.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/WK8604.md +11 -0
- package/dist/rules/otel-placement-helpers.ts +7 -11
- package/dist/rules/wk8604.ts +29 -0
- package/package.json +3 -3
- package/src/composites/__snapshots__/gke-otel-collector.snapshot.test.ts.snap +2 -68
- package/src/composites/catalog.ts +6 -0
- package/src/composites/gke-otel-collector.snapshot.test.ts +3 -1
- package/src/composites/gke-otel-collector.ts +8 -2
- package/src/composites/otel-collector-agent.ts +6 -9
- package/src/composites/otel-collector-gateway.test.ts +102 -5
- package/src/composites/otel-collector-gateway.ts +96 -18
- package/src/composites/otel-collector-rbac.test.ts +114 -0
- package/src/composites/otel-collector-rbac.ts +144 -0
- package/src/composites/otel-collector-shape.ts +19 -0
- package/src/composites/otel-collector.ts +25 -34
- package/src/import/embedded-live.test.ts +138 -0
- package/src/import/live-export.ts +10 -2
- package/src/lint/audit-catalog.ts +1 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/otel-placement-helpers.ts +7 -11
- package/src/lint/post-synth/otel-placement.test.ts +35 -0
- 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
|
-
/**
|
|
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)
|
|
169
|
-
|
|
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 =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
|