@intentius/chant-lexicon-k8s 0.95.0 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/index.d.ts +4 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/monitored-service.d.ts +19 -0
- package/dist/composites/monitored-service.d.ts.map +1 -1
- package/dist/composites/otel-collector-agent.d.ts +22 -0
- package/dist/composites/otel-collector-agent.d.ts.map +1 -1
- package/dist/composites/otel-collector-gateway.d.ts +173 -0
- package/dist/composites/otel-collector-gateway.d.ts.map +1 -0
- package/dist/composites/otel-collector-shape.d.ts +73 -0
- package/dist/composites/otel-collector-shape.d.ts.map +1 -0
- package/dist/composites/otel-collector.d.ts +19 -1
- package/dist/composites/otel-collector.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +7 -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 +103 -0
- package/dist/lint/post-synth/otel-placement-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8601.d.ts +17 -0
- package/dist/lint/post-synth/wk8601.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8602.d.ts +23 -0
- package/dist/lint/post-synth/wk8602.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8603.d.ts +19 -0
- package/dist/lint/post-synth/wk8603.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +3 -0
- package/dist/okf/rules/WK8601.md +15 -0
- package/dist/okf/rules/WK8602.md +16 -0
- package/dist/okf/rules/WK8603.md +15 -0
- package/dist/okf/types/DaemonSet.md +3 -0
- package/dist/okf/types/Service.md +1 -0
- package/dist/rules/otel-placement-helpers.ts +277 -0
- package/dist/rules/wk8601.ts +44 -0
- package/dist/rules/wk8602.ts +133 -0
- package/dist/rules/wk8603.ts +66 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-k8s.md +2 -0
- package/package.json +4 -3
- package/src/composites/__snapshots__/gke-otel-collector.snapshot.test.ts.snap +401 -0
- package/src/composites/catalog.ts +109 -0
- package/src/composites/gke-otel-collector.snapshot.test.ts +51 -0
- package/src/composites/index.ts +9 -0
- package/src/composites/monitored-service.ts +40 -16
- package/src/composites/otel-collector-agent.ts +62 -26
- package/src/composites/otel-collector-gateway.test.ts +260 -0
- package/src/composites/otel-collector-gateway.ts +325 -0
- package/src/composites/otel-collector-shape.ts +141 -0
- package/src/composites/otel-collector.ts +74 -31
- package/src/index.ts +3 -0
- package/src/lint/audit-catalog.ts +3 -0
- package/src/lint/post-synth/index.ts +6 -0
- package/src/lint/post-synth/otel-placement-helpers.ts +277 -0
- package/src/lint/post-synth/otel-placement.test.ts +401 -0
- package/src/lint/post-synth/wk8601.ts +44 -0
- package/src/lint/post-synth/wk8602.ts +133 -0
- package/src/lint/post-synth/wk8603.ts +66 -0
- package/src/prometheus-rule-groups.test.ts +135 -0
- package/src/serializer.ts +9 -1
- package/src/skills/chant-k8s.md +2 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `OtelCollector` and `OtelCollectorGateway` share beyond the container:
|
|
3
|
+
* reading ports and probes back from a declared config, and the annotations
|
|
4
|
+
* that record, in the built manifests, how each collector config is deployed.
|
|
5
|
+
*
|
|
6
|
+
* A post-synth check sees only the rendered YAML. The collector config is a
|
|
7
|
+
* string inside a ConfigMap, and whether it runs once per node or as N
|
|
8
|
+
* replicas behind a Service lives on another document. The annotations below
|
|
9
|
+
* join the two, so a check can take a ConfigMap, find its workload, and know
|
|
10
|
+
* the collector's role, its replica count, and (for an agent) which gateway
|
|
11
|
+
* it exports to and whether through `loadbalancing`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
15
|
+
import {
|
|
16
|
+
buildCollectorConfig,
|
|
17
|
+
collectorEndpoints,
|
|
18
|
+
collectorYaml,
|
|
19
|
+
COLLECTOR_CONFIG_PATH,
|
|
20
|
+
type BuiltCollector,
|
|
21
|
+
} from "@intentius/chant-lexicon-otel";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Annotation keys the collector composites write on their ConfigMap and
|
|
25
|
+
* workload. `GkeOtelCollector` writes none of them; its output is fixed.
|
|
26
|
+
*
|
|
27
|
+
* | Key | On | Value |
|
|
28
|
+
* |---|---|---|
|
|
29
|
+
* | `role` | ConfigMap, workload | `agent` (a DaemonSet, one collector per node) or `gateway` (a Deployment) |
|
|
30
|
+
* | `workload` | ConfigMap | the workload that mounts it, `DaemonSet/<name>` or `Deployment/<name>`, in the ConfigMap's namespace |
|
|
31
|
+
* | `config` | workload | the name of the ConfigMap holding its `config.yaml` |
|
|
32
|
+
* | `gateways` | agent ConfigMap and DaemonSet | comma-separated `<namespace>/<gateway name>=<routing>`, where routing is `loadbalancing` or `service` |
|
|
33
|
+
*
|
|
34
|
+
* The replica count is the workload's own `spec.replicas`.
|
|
35
|
+
*/
|
|
36
|
+
export const OTEL_COLLECTOR_ANNOTATIONS = {
|
|
37
|
+
role: "otel.chant.dev/role",
|
|
38
|
+
workload: "otel.chant.dev/workload",
|
|
39
|
+
config: "otel.chant.dev/config",
|
|
40
|
+
gateways: "otel.chant.dev/gateways",
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export type CollectorRole = "agent" | "gateway";
|
|
44
|
+
|
|
45
|
+
/** How an agent reaches a gateway: its ClusterIP Service, or each replica through `loadbalancing`. */
|
|
46
|
+
export type GatewayRouting = "service" | "loadbalancing";
|
|
47
|
+
|
|
48
|
+
/** The gateway an exporter built by `gatewayExporter()` points at. */
|
|
49
|
+
export interface GatewayTarget {
|
|
50
|
+
name: string;
|
|
51
|
+
namespace: string;
|
|
52
|
+
routing: GatewayRouting;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const gatewayTargets = new WeakMap<object, GatewayTarget>();
|
|
56
|
+
|
|
57
|
+
/** Record which gateway an exporter entity was built for. */
|
|
58
|
+
export function markGatewayExporter(exporter: object, target: GatewayTarget): void {
|
|
59
|
+
gatewayTargets.set(exporter, target);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The gateways a built config's exporters were built for, in declaration order. */
|
|
63
|
+
export function gatewayTargetsOf(built: BuiltCollector): GatewayTarget[] {
|
|
64
|
+
const out: GatewayTarget[] = [];
|
|
65
|
+
for (const c of built.components) {
|
|
66
|
+
const t = gatewayTargets.get(c);
|
|
67
|
+
if (t && !out.some((o) => o.name === t.name && o.namespace === t.namespace && o.routing === t.routing)) out.push(t);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The `gateways` annotation value for a list of targets. */
|
|
73
|
+
export function gatewaysAnnotation(targets: GatewayTarget[]): string {
|
|
74
|
+
return targets.map((t) => `${t.namespace}/${t.name}=${t.routing}`).join(",");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A declared collector config, rendered, with the container and Service ports and probes read back from it. */
|
|
78
|
+
export interface CollectorRuntime {
|
|
79
|
+
built: BuiltCollector;
|
|
80
|
+
configYaml: string;
|
|
81
|
+
configDir: string;
|
|
82
|
+
containerPorts: Array<{ containerPort: number; name: string }>;
|
|
83
|
+
/** The receiver ports, which the Services expose. The health port is left out. */
|
|
84
|
+
servicePorts: Array<{ name: string; port: number }>;
|
|
85
|
+
/** Security context and, when the config enables `health_check`, the probes. */
|
|
86
|
+
containerExtra: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function collectorRuntime(entities: Declarable[]): CollectorRuntime {
|
|
90
|
+
const built = buildCollectorConfig(entities);
|
|
91
|
+
const { ports, healthCheck } = collectorEndpoints(built.config);
|
|
92
|
+
const configDir = COLLECTOR_CONFIG_PATH.slice(0, COLLECTOR_CONFIG_PATH.lastIndexOf("/"));
|
|
93
|
+
|
|
94
|
+
// The probe names the port. A health_check on a receiver's port reuses that
|
|
95
|
+
// port's name, since a container port is declared once.
|
|
96
|
+
const healthPortName = healthCheck ? (ports.find((p) => p.port === healthCheck.port)?.name ?? "health") : undefined;
|
|
97
|
+
const probe = healthCheck ? { httpGet: { path: healthCheck.path, port: healthPortName } } : undefined;
|
|
98
|
+
const containerPorts = [
|
|
99
|
+
...ports.map((p) => ({ containerPort: p.port, name: p.name })),
|
|
100
|
+
...(healthCheck && !ports.some((p) => p.port === healthCheck.port)
|
|
101
|
+
? [{ containerPort: healthCheck.port, name: "health" }]
|
|
102
|
+
: []),
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
built,
|
|
107
|
+
configYaml: collectorYaml(entities),
|
|
108
|
+
configDir,
|
|
109
|
+
containerPorts,
|
|
110
|
+
servicePorts: ports.map((p) => ({ name: p.name, port: p.port })),
|
|
111
|
+
containerExtra: {
|
|
112
|
+
imagePullPolicy: "IfNotPresent",
|
|
113
|
+
securityContext: {
|
|
114
|
+
runAsNonRoot: true,
|
|
115
|
+
runAsUser: 10001,
|
|
116
|
+
readOnlyRootFilesystem: true,
|
|
117
|
+
allowPrivilegeEscalation: false,
|
|
118
|
+
capabilities: { drop: ["ALL"] },
|
|
119
|
+
},
|
|
120
|
+
...(probe ? { livenessProbe: probe, readinessProbe: probe } : {}),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The namespaces whose Endpoints a config's `loadbalancing` exporters watch
|
|
127
|
+
* through the `k8s` resolver. A service given as `name` resolves in the
|
|
128
|
+
* collector's own namespace, `name.namespace` in that one.
|
|
129
|
+
*/
|
|
130
|
+
export function k8sResolverNamespaces(built: BuiltCollector, ownNamespace: string): string[] {
|
|
131
|
+
const out: string[] = [];
|
|
132
|
+
for (const [id, cfg] of Object.entries(built.config.exporters ?? {})) {
|
|
133
|
+
if (id !== "loadbalancing" && !id.startsWith("loadbalancing/")) continue;
|
|
134
|
+
const service = (cfg as { resolver?: { k8s?: { service?: string } } })?.resolver?.k8s?.service;
|
|
135
|
+
if (!service) continue;
|
|
136
|
+
const dot = service.indexOf(".");
|
|
137
|
+
const ns = dot === -1 ? ownNamespace : service.slice(dot + 1);
|
|
138
|
+
if (!out.includes(ns)) out.push(ns);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
@@ -10,22 +10,27 @@
|
|
|
10
10
|
* read back from that config, so they follow whatever config is passed in.
|
|
11
11
|
*
|
|
12
12
|
* `GkeOtelCollector` builds the same DaemonSet, RBAC and ConfigMap for GKE.
|
|
13
|
+
* `OtelCollectorGateway` is the central tier agents export to; give this
|
|
14
|
+
* composite an exporter from `gatewayExporter()` to point it there.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import { Composite, mergeDefaults } from "@intentius/chant";
|
|
16
18
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
17
19
|
import {
|
|
18
|
-
buildCollectorConfig,
|
|
19
|
-
collectorEndpoints,
|
|
20
|
-
collectorYaml,
|
|
21
20
|
otlpCollector,
|
|
22
|
-
COLLECTOR_CONFIG_PATH,
|
|
23
21
|
COLLECTOR_IMAGE,
|
|
24
22
|
type OTelComponent,
|
|
25
23
|
type Signal,
|
|
26
24
|
} from "@intentius/chant-lexicon-otel";
|
|
27
|
-
import { Service } from "../generated";
|
|
25
|
+
import { Service, Role, RoleBinding } from "../generated";
|
|
28
26
|
import { collectorAgentResources, type CollectorAgentResources } from "./otel-collector-agent";
|
|
27
|
+
import {
|
|
28
|
+
collectorRuntime,
|
|
29
|
+
gatewaysAnnotation,
|
|
30
|
+
gatewayTargetsOf,
|
|
31
|
+
k8sResolverNamespaces,
|
|
32
|
+
OTEL_COLLECTOR_ANNOTATIONS,
|
|
33
|
+
} from "./otel-collector-shape";
|
|
29
34
|
|
|
30
35
|
export interface OtelCollectorProps {
|
|
31
36
|
/** Agent name (default: "otel-collector"). */
|
|
@@ -62,11 +67,16 @@ export interface OtelCollectorProps {
|
|
|
62
67
|
clusterRole?: Partial<Record<string, unknown>>;
|
|
63
68
|
clusterRoleBinding?: Partial<Record<string, unknown>>;
|
|
64
69
|
configMap?: Partial<Record<string, unknown>>;
|
|
70
|
+
endpointsRole?: Partial<Record<string, unknown>>;
|
|
71
|
+
endpointsRoleBinding?: Partial<Record<string, unknown>>;
|
|
65
72
|
};
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
export type OtelCollectorResult = CollectorAgentResources & {
|
|
69
76
|
service: InstanceType<typeof Service>;
|
|
77
|
+
/** Read access to Endpoints for a `loadbalancing` exporter's `k8s` resolver, in the resolved Service's namespace. */
|
|
78
|
+
endpointsRole?: InstanceType<typeof Role>;
|
|
79
|
+
endpointsRoleBinding?: InstanceType<typeof RoleBinding>;
|
|
70
80
|
};
|
|
71
81
|
|
|
72
82
|
/**
|
|
@@ -103,19 +113,14 @@ export const OtelCollector = Composite((props: OtelCollectorProps) => {
|
|
|
103
113
|
};
|
|
104
114
|
|
|
105
115
|
const entities = props.config ? [...props.config] : otlpCollector({ exporters: props.exporters, signals: props.signals });
|
|
106
|
-
const
|
|
107
|
-
const
|
|
116
|
+
const runtime = collectorRuntime(entities);
|
|
117
|
+
const configMapName = `${name}-config`;
|
|
108
118
|
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
...ports.map((p) => ({ containerPort: p.port, name: p.name })),
|
|
115
|
-
...(healthCheck && !ports.some((p) => p.port === healthCheck.port)
|
|
116
|
-
? [{ containerPort: healthCheck.port, name: "health" }]
|
|
117
|
-
: []),
|
|
118
|
-
];
|
|
119
|
+
// How this agent is deployed, and which gateways it sends to, recorded on
|
|
120
|
+
// the ConfigMap and the DaemonSet for post-synth checks (see
|
|
121
|
+
// OTEL_COLLECTOR_ANNOTATIONS).
|
|
122
|
+
const gateways = gatewayTargetsOf(runtime.built);
|
|
123
|
+
const shape: Record<string, string> = gateways.length ? { [OTEL_COLLECTOR_ANNOTATIONS.gateways]: gatewaysAnnotation(gateways) } : {};
|
|
119
124
|
|
|
120
125
|
const resources = collectorAgentResources({
|
|
121
126
|
name,
|
|
@@ -123,22 +128,23 @@ export const OtelCollector = Composite((props: OtelCollectorProps) => {
|
|
|
123
128
|
image,
|
|
124
129
|
commonLabels,
|
|
125
130
|
extraLabels,
|
|
126
|
-
configYaml:
|
|
127
|
-
configDir,
|
|
128
|
-
ports: containerPorts,
|
|
131
|
+
configYaml: runtime.configYaml,
|
|
132
|
+
configDir: runtime.configDir,
|
|
133
|
+
ports: runtime.containerPorts,
|
|
129
134
|
cpuRequest,
|
|
130
135
|
memoryRequest,
|
|
131
136
|
cpuLimit,
|
|
132
137
|
memoryLimit,
|
|
133
|
-
containerExtra:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
containerExtra: runtime.containerExtra,
|
|
139
|
+
workloadAnnotations: {
|
|
140
|
+
[OTEL_COLLECTOR_ANNOTATIONS.role]: "agent",
|
|
141
|
+
[OTEL_COLLECTOR_ANNOTATIONS.config]: configMapName,
|
|
142
|
+
...shape,
|
|
143
|
+
},
|
|
144
|
+
configMapAnnotations: {
|
|
145
|
+
[OTEL_COLLECTOR_ANNOTATIONS.role]: "agent",
|
|
146
|
+
[OTEL_COLLECTOR_ANNOTATIONS.workload]: `DaemonSet/${name}`,
|
|
147
|
+
...shape,
|
|
142
148
|
},
|
|
143
149
|
defaults: defs,
|
|
144
150
|
});
|
|
@@ -152,9 +158,46 @@ export const OtelCollector = Composite((props: OtelCollectorProps) => {
|
|
|
152
158
|
spec: {
|
|
153
159
|
selector: { "app.kubernetes.io/name": name },
|
|
154
160
|
internalTrafficPolicy: "Local",
|
|
155
|
-
ports:
|
|
161
|
+
ports: runtime.servicePorts.map((p) => ({ name: p.name, port: p.port, targetPort: p.name, protocol: "TCP" })),
|
|
156
162
|
},
|
|
157
163
|
}, defs?.service));
|
|
158
164
|
|
|
159
|
-
|
|
165
|
+
// The loadbalancing exporter's k8s resolver runs in this agent and watches
|
|
166
|
+
// the gateway's headless Service, so the agent's ServiceAccount gets read
|
|
167
|
+
// 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
|
+
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
|
+
: {};
|
|
201
|
+
|
|
202
|
+
return { ...resources, service, ...endpointsRbac };
|
|
160
203
|
}, "OtelCollector");
|
package/src/index.ts
CHANGED
|
@@ -70,6 +70,7 @@ export {
|
|
|
70
70
|
WebApp, StatefulApp, CronWorkload, AutoscaledService, WorkerPool, NamespaceEnv, NodeAgent,
|
|
71
71
|
BatchJob, SecureIngress, ConfiguredApp, SidecarApp, MonitoredService, NetworkIsolatedApp,
|
|
72
72
|
IrsaServiceAccount, AlbIngress, EbsStorageClass, EfsStorageClass, FluentBitAgent, ExternalDnsAgent, AdotCollector, OtelCollector,
|
|
73
|
+
OtelCollectorGateway, gatewayExporter, OTEL_COLLECTOR_ANNOTATIONS,
|
|
73
74
|
MetricsServer, WorkloadIdentityServiceAccount, GcePdStorageClass, FilestoreStorageClass, GkeGateway, ConfigConnectorContext,
|
|
74
75
|
GceIngress, CockroachDbCluster, CockroachDbRegionStack,
|
|
75
76
|
RayCluster, RayJob, RayService,
|
|
@@ -93,6 +94,8 @@ export type {
|
|
|
93
94
|
FluentBitAgentProps, FluentBitAgentResult, ExternalDnsAgentProps, ExternalDnsAgentResult,
|
|
94
95
|
AdotCollectorProps, AdotCollectorResult,
|
|
95
96
|
OtelCollectorProps, OtelCollectorResult,
|
|
97
|
+
OtelCollectorGatewayProps, OtelCollectorGatewayResult, GatewayExporterOptions, CollectorPolicyRule,
|
|
98
|
+
CollectorRole, GatewayRouting,
|
|
96
99
|
MetricsServerProps, MetricsServerResult,
|
|
97
100
|
WorkloadIdentityServiceAccountProps, WorkloadIdentityServiceAccountResult,
|
|
98
101
|
GcePdStorageClassProps, GcePdStorageClassResult,
|
|
@@ -46,6 +46,9 @@ export const k8sAuditCatalog: Record<string, RuleMeta> = {
|
|
|
46
46
|
WK8503: auditRule("WK8503", "merge-worthy", "guidance", "Workload consumes a Secret nothing in the output produces", "Produce the Secret in the same build (Secret, ExternalSecret, InfisicalSecret, cert-manager Certificate), or declare its provenance with declareSecret() if it exists out of band.", { category: "correctness" }),
|
|
47
47
|
WK8504: auditRule("WK8504", "merge-worthy", "guidance", "committed-encrypted secret declaration does not resolve", "Point `file` at the committed ciphertext, make its metadata.name match the declaration, and re-run `sops -e -i` so every data/stringData value is ENC[...].", { authority: [K8S_SECRETS] }),
|
|
48
48
|
WK8505: auditRule("WK8505", "report-only", "guidance", "committed-encrypted secret with no Flux decryption wiring", "Add decryption: \"sops\" to the FluxAppFor reconciling the path that carries the ciphertext sidecar, or ignore if that Kustomization is declared in a different build. This check sees one build root at a time, so it goes silent once the secret and the Kustomization live in different build roots (chant #1939).", { category: "correctness" }),
|
|
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
|
+
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
|
+
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" }),
|
|
49
52
|
};
|
|
50
53
|
|
|
51
54
|
// Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
|
|
@@ -40,6 +40,9 @@ import { wk8502 } from "./wk8502";
|
|
|
40
40
|
import { wk8503 } from "./wk8503";
|
|
41
41
|
import { wk8504 } from "./wk8504";
|
|
42
42
|
import { wk8505 } from "./wk8505";
|
|
43
|
+
import { wk8601 } from "./wk8601";
|
|
44
|
+
import { wk8602 } from "./wk8602";
|
|
45
|
+
import { wk8603 } from "./wk8603";
|
|
43
46
|
|
|
44
47
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
45
48
|
argo002,
|
|
@@ -82,4 +85,7 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
82
85
|
wk8503,
|
|
83
86
|
wk8504,
|
|
84
87
|
wk8505,
|
|
88
|
+
wk8601,
|
|
89
|
+
wk8602,
|
|
90
|
+
wk8603,
|
|
85
91
|
];
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where each OpenTelemetry Collector config in a k8s build runs: the join
|
|
3
|
+
* WK8601, WK8602 and WK8603 read (chant #2899).
|
|
4
|
+
*
|
|
5
|
+
* The collector config is a YAML string inside a ConfigMap, and whether it
|
|
6
|
+
* runs once per node or as N replicas lives on another document, the workload
|
|
7
|
+
* that mounts it. A config is joined to its workload in this order:
|
|
8
|
+
*
|
|
9
|
+
* 1. The workload's `otel.chant.dev/config` annotation, written by
|
|
10
|
+
* `OtelCollector` and `OtelCollectorGateway`, naming the ConfigMap whose
|
|
11
|
+
* `config.yaml` it runs.
|
|
12
|
+
* 2. Otherwise the ConfigMaps the pod spec mounts as volumes (how
|
|
13
|
+
* `GkeOtelCollector` and hand-written manifests link them), keeping only
|
|
14
|
+
* data keys that parse as a collector config (a `service.pipelines` map).
|
|
15
|
+
* When a container passes `--config=<path>`, only keys named by those
|
|
16
|
+
* paths count.
|
|
17
|
+
*
|
|
18
|
+
* Anything this can't resolve (a ConfigMap or workload outside the build, a
|
|
19
|
+
* replica count that isn't a number) is left out, so the checks stay silent
|
|
20
|
+
* rather than guess. Like every bundle-join check they see one build root at
|
|
21
|
+
* a time (chant #1939).
|
|
22
|
+
*
|
|
23
|
+
* The annotation keys are repeated here rather than imported from the
|
|
24
|
+
* composites, whose module pulls in the otel lexicon; a test pins them to
|
|
25
|
+
* `OTEL_COLLECTOR_ANNOTATIONS`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { load } from "js-yaml";
|
|
29
|
+
import { extractContainers, extractPodSpec, type K8sManifest } from "./k8s-helpers";
|
|
30
|
+
|
|
31
|
+
export const PLACEMENT_ANNOTATIONS = {
|
|
32
|
+
role: "otel.chant.dev/role",
|
|
33
|
+
workload: "otel.chant.dev/workload",
|
|
34
|
+
config: "otel.chant.dev/config",
|
|
35
|
+
gateways: "otel.chant.dev/gateways",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
/** The part of a collector config the placement checks read. */
|
|
39
|
+
export interface CollectorConfigShape {
|
|
40
|
+
receivers?: Record<string, unknown>;
|
|
41
|
+
processors?: Record<string, unknown>;
|
|
42
|
+
exporters?: Record<string, unknown>;
|
|
43
|
+
extensions?: Record<string, unknown>;
|
|
44
|
+
service: {
|
|
45
|
+
extensions?: unknown;
|
|
46
|
+
pipelines: Record<string, { receivers?: unknown; processors?: unknown; exporters?: unknown }>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One collector config and the workload that runs it. */
|
|
51
|
+
export interface CollectorPlacement {
|
|
52
|
+
workload: K8sManifest;
|
|
53
|
+
kind: "DaemonSet" | "Deployment" | "StatefulSet";
|
|
54
|
+
name: string;
|
|
55
|
+
namespace: string;
|
|
56
|
+
/**
|
|
57
|
+
* How many copies run at most: `spec.replicas` (1 when unset), raised to a
|
|
58
|
+
* HorizontalPodAutoscaler's `maxReplicas` when one targets the workload.
|
|
59
|
+
* Undefined for a DaemonSet, or when the count isn't a number.
|
|
60
|
+
*/
|
|
61
|
+
replicas: number | undefined;
|
|
62
|
+
configMap: string;
|
|
63
|
+
key: string;
|
|
64
|
+
config: CollectorConfigShape;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PLACED_KINDS = new Set(["DaemonSet", "Deployment", "StatefulSet"]);
|
|
68
|
+
|
|
69
|
+
function ns(m: K8sManifest): string {
|
|
70
|
+
const n = m.metadata?.namespace;
|
|
71
|
+
return typeof n === "string" && n.length > 0 ? n : "default";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function annotations(m: K8sManifest): Record<string, unknown> {
|
|
75
|
+
const a = (m.metadata as { annotations?: unknown } | undefined)?.annotations;
|
|
76
|
+
return a && typeof a === "object" ? (a as Record<string, unknown>) : {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
80
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Parse a ConfigMap value as a collector config, or undefined when it isn't one. */
|
|
84
|
+
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;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The `type` of a component id: `tail_sampling/errors` is `tail_sampling`. */
|
|
97
|
+
export function componentType(id: string): string {
|
|
98
|
+
const slash = id.indexOf("/");
|
|
99
|
+
return slash === -1 ? id : id.slice(0, slash);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stringList(v: unknown): string[] {
|
|
103
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The pipelines of one signal (`traces`, `traces/sampled`, ...) as [id, pipeline] pairs. */
|
|
107
|
+
export function pipelinesOf(config: CollectorConfigShape, signal: string): Array<[string, { receivers: string[]; processors: string[]; exporters: string[] }]> {
|
|
108
|
+
const out: Array<[string, { receivers: string[]; processors: string[]; exporters: string[] }]> = [];
|
|
109
|
+
for (const [id, p] of Object.entries(config.service.pipelines)) {
|
|
110
|
+
if (componentType(id) !== signal || !isRecord(p)) continue;
|
|
111
|
+
out.push([id, { receivers: stringList(p.receivers), processors: stringList(p.processors), exporters: stringList(p.exporters) }]);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Every pipeline, whatever its signal. */
|
|
117
|
+
export function allPipelines(config: CollectorConfigShape): Array<[string, { receivers: string[]; processors: string[]; exporters: string[] }]> {
|
|
118
|
+
return ["traces", "metrics", "logs"].flatMap((s) => pipelinesOf(config, s));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Processor ids of `type` that a traces pipeline runs. */
|
|
122
|
+
export function tracePipelineProcessors(config: CollectorConfigShape, type: string): string[] {
|
|
123
|
+
const out = new Set<string>();
|
|
124
|
+
for (const [, p] of pipelinesOf(config, "traces")) {
|
|
125
|
+
for (const id of p.processors) if (componentType(id) === type) out.add(id);
|
|
126
|
+
}
|
|
127
|
+
return [...out];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The ConfigMap file names a workload's containers pass with `--config`. */
|
|
131
|
+
function configArgNames(workload: K8sManifest): string[] {
|
|
132
|
+
const names: string[] = [];
|
|
133
|
+
for (const c of extractContainers(workload)) {
|
|
134
|
+
const argv = [...stringList(c.command), ...stringList(c.args)];
|
|
135
|
+
for (let i = 0; i < argv.length; i++) {
|
|
136
|
+
let path: string | undefined;
|
|
137
|
+
if (argv[i].startsWith("--config=")) path = argv[i].slice("--config=".length);
|
|
138
|
+
else if (argv[i] === "--config") path = argv[i + 1];
|
|
139
|
+
if (!path) continue;
|
|
140
|
+
// `--config=env:...`, `--config=yaml:...` and URLs are not files.
|
|
141
|
+
if (/^[a-z]+:/.test(path)) continue;
|
|
142
|
+
names.push(path.slice(path.lastIndexOf("/") + 1));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return names;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** ConfigMap names mounted by a workload's pod spec, directly or through a projected volume. */
|
|
149
|
+
function mountedConfigMaps(workload: K8sManifest): string[] {
|
|
150
|
+
const pod = extractPodSpec(workload);
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
for (const v of Array.isArray(pod?.volumes) ? pod.volumes : []) {
|
|
153
|
+
if (!isRecord(v)) continue;
|
|
154
|
+
const cm = isRecord(v.configMap) ? v.configMap.name : undefined;
|
|
155
|
+
if (typeof cm === "string") out.push(cm);
|
|
156
|
+
const sources = isRecord(v.projected) && Array.isArray(v.projected.sources) ? v.projected.sources : [];
|
|
157
|
+
for (const s of sources) {
|
|
158
|
+
const name = isRecord(s) && isRecord(s.configMap) ? s.configMap.name : undefined;
|
|
159
|
+
if (typeof name === "string") out.push(name);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The max replica count an HPA allows for kind/name in a namespace, if one targets it. */
|
|
166
|
+
function hpaMax(manifests: K8sManifest[], kind: string, name: string, namespace: string): number | undefined {
|
|
167
|
+
let max: number | undefined;
|
|
168
|
+
for (const m of manifests) {
|
|
169
|
+
if (m.kind !== "HorizontalPodAutoscaler" || ns(m) !== namespace) continue;
|
|
170
|
+
const target = isRecord(m.spec?.scaleTargetRef) ? m.spec.scaleTargetRef : undefined;
|
|
171
|
+
if (!target || target.kind !== kind || target.name !== name) continue;
|
|
172
|
+
const n = m.spec?.maxReplicas;
|
|
173
|
+
if (typeof n === "number") max = Math.max(max ?? 0, n);
|
|
174
|
+
}
|
|
175
|
+
return max;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Join every collector config in the build to the workload that runs it. */
|
|
179
|
+
export function collectorPlacements(manifests: K8sManifest[]): CollectorPlacement[] {
|
|
180
|
+
const configMaps = new Map<string, K8sManifest>();
|
|
181
|
+
for (const m of manifests) {
|
|
182
|
+
if (m.kind === "ConfigMap" && typeof m.metadata?.name === "string") configMaps.set(`${ns(m)}/${m.metadata.name}`, m);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const out: CollectorPlacement[] = [];
|
|
186
|
+
for (const workload of manifests) {
|
|
187
|
+
if (!workload.kind || !PLACED_KINDS.has(workload.kind)) continue;
|
|
188
|
+
const name = workload.metadata?.name;
|
|
189
|
+
if (typeof name !== "string") continue;
|
|
190
|
+
const namespace = ns(workload);
|
|
191
|
+
const kind = workload.kind as CollectorPlacement["kind"];
|
|
192
|
+
|
|
193
|
+
let replicas: number | undefined;
|
|
194
|
+
if (kind !== "DaemonSet") {
|
|
195
|
+
const r = workload.spec?.replicas;
|
|
196
|
+
const base = r === undefined || r === null ? 1 : typeof r === "number" ? r : undefined;
|
|
197
|
+
const hpa = hpaMax(manifests, kind, name, namespace);
|
|
198
|
+
replicas = base === undefined ? undefined : Math.max(base, hpa ?? 0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const annotated = annotations(workload)[PLACEMENT_ANNOTATIONS.config];
|
|
202
|
+
const candidates: Array<{ configMap: string; keys?: string[] }> =
|
|
203
|
+
typeof annotated === "string"
|
|
204
|
+
? [{ configMap: annotated, keys: ["config.yaml"] }]
|
|
205
|
+
: mountedConfigMaps(workload).map((configMap) => ({ configMap }));
|
|
206
|
+
const argNames = configArgNames(workload);
|
|
207
|
+
|
|
208
|
+
for (const { configMap, keys } of candidates) {
|
|
209
|
+
const cm = configMaps.get(`${namespace}/${configMap}`);
|
|
210
|
+
if (!cm || !isRecord(cm.data)) continue;
|
|
211
|
+
const parsed: Array<[string, CollectorConfigShape]> = [];
|
|
212
|
+
for (const [key, text] of Object.entries(cm.data)) {
|
|
213
|
+
if (keys && !keys.includes(key)) continue;
|
|
214
|
+
const config = parseCollectorConfig(text);
|
|
215
|
+
if (config) parsed.push([key, config]);
|
|
216
|
+
}
|
|
217
|
+
const named = parsed.filter(([key]) => argNames.includes(key));
|
|
218
|
+
for (const [key, config] of named.length > 0 ? named : parsed) {
|
|
219
|
+
out.push({ workload, kind, name, namespace, replicas, configMap, key, config });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** How a placement is described in a message: `DaemonSet observability/otel-agent`. */
|
|
227
|
+
export function describePlacement(p: CollectorPlacement): string {
|
|
228
|
+
return `${p.kind} ${p.namespace}/${p.name}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Services and exporters (WK8602) ─────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/** A Service that selects a workload's pods. */
|
|
234
|
+
export interface SelectingService {
|
|
235
|
+
name: string;
|
|
236
|
+
namespace: string;
|
|
237
|
+
headless: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The Services in a workload's namespace whose selector matches its pod template labels. */
|
|
241
|
+
export function servicesSelecting(manifests: K8sManifest[], workload: K8sManifest): SelectingService[] {
|
|
242
|
+
const template = isRecord(workload.spec?.template) ? workload.spec.template : undefined;
|
|
243
|
+
const labels = isRecord(template?.metadata) && isRecord(template.metadata.labels) ? template.metadata.labels : {};
|
|
244
|
+
const out: SelectingService[] = [];
|
|
245
|
+
for (const m of manifests) {
|
|
246
|
+
if (m.kind !== "Service" || ns(m) !== ns(workload) || typeof m.metadata?.name !== "string") continue;
|
|
247
|
+
const selector = isRecord(m.spec?.selector) ? m.spec.selector : undefined;
|
|
248
|
+
if (!selector || Object.keys(selector).length === 0) continue;
|
|
249
|
+
if (!Object.entries(selector).every(([k, v]) => labels[k] === v)) continue;
|
|
250
|
+
out.push({ name: m.metadata.name, namespace: ns(m), headless: m.spec?.clusterIP === "None" });
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The Service a hostname names, as `name.namespace`, when it has the shape of
|
|
257
|
+
* cluster DNS: `name`, `name.ns`, `name.ns.svc` or `name.ns.svc.<domain>`.
|
|
258
|
+
* A bare `name` resolves in the caller's namespace.
|
|
259
|
+
*/
|
|
260
|
+
export function serviceOfHost(host: string, callerNamespace: string): string | undefined {
|
|
261
|
+
const parts = host.split(".");
|
|
262
|
+
if (parts.some((p) => p.length === 0)) return undefined;
|
|
263
|
+
if (parts.length === 1) return `${parts[0]}.${callerNamespace}`;
|
|
264
|
+
if (parts.length === 2) return `${parts[0]}.${parts[1]}`;
|
|
265
|
+
if (parts[2] === "svc") return `${parts[0]}.${parts[1]}`;
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The host of an exporter endpoint: `host:port`, `scheme://host:port/path` or `dns:///host:port`. */
|
|
270
|
+
export function hostOfEndpoint(endpoint: unknown): string | undefined {
|
|
271
|
+
if (typeof endpoint !== "string" || endpoint.length === 0) return undefined;
|
|
272
|
+
let rest = endpoint.replace(/^[a-z][a-z0-9+.-]*:\/\/\/?/i, "");
|
|
273
|
+
rest = rest.split("/")[0];
|
|
274
|
+
if (rest.startsWith("[")) return undefined;
|
|
275
|
+
const colon = rest.lastIndexOf(":");
|
|
276
|
+
return colon === -1 ? rest : rest.slice(0, colon);
|
|
277
|
+
}
|