feat(otelcollector): render OTel Collector - #5106
Conversation
8124957 to
222f3f4
Compare
6dd36c8 to
a8c2d9e
Compare
c612900 to
fca9047
Compare
pasanw
left a comment
There was a problem hiding this comment.
Thanks for addressing my comments! I let Claude lead the re-review and reviewed everything it said was addressed along with what it flagged - it helped flag some things that weren't fully addressed but it also flagged some new issues (sorry about that!)
I'll let you and @rene-dekker work through them together and decide the best way to proceed.
…lCollector Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Renames the API surface from OTel to OpenTelemetry, and reworks exporter TLS, certificate rotation, validation and network policy. API: - OTel* types become OpenTelemetry*; LogCollector.spec.otelCollector becomes spec.openTelemetry. The file moves to api/v1/opentelemetry_types.go. - Replace tlsInsecure with mutualTLS. tlsInsecure rendered tls.insecure, which disables TLS entirely rather than skipping verification, so it could never do what it documented. A private CA is now supplied out of band via the otel-collector-ca ConfigMap and a client keypair via the otel-collector-client-certs Secret, following the syslog/Splunk and external Elasticsearch conventions. Plaintext is selected by an http:// endpoint, as with Splunk. No input disables verification. - Add listType/listMapKey, required and default markers on exporters. Certificate rotation: - Roll the pod on certificate change via HashAnnotations. The config hash alone never changes on rotation because the config holds paths, not PEM, so a rotated CA would silently break fluent-bit ingest until something else restarted the collector. - Watch tigera-ca-private, and reload the receiver's CA and keypair in place so rotation usually costs no restart. Validation: - Reject specs the collector cannot start from (no exporters, no data sources, duplicate exporter names) instead of rendering a config it rejects at boot and leaving status on Progressing with no reason. - Degrade when the exporter CA or client keypair exists but is empty. - Degrade rather than return silently when the Installation is unreadable. Network policy: - Pin egress to each exporter's destination and drop the blanket rule that allowed any host on the OTLP ports. In-cluster destinations match by Service, since Calico resolves Domains rules from observed DNS answers and those never match a ClusterIP reached via the cluster domain. - Set Source on both ingress rules. The internal metrics port serves without TLS or authentication and was reachable by any pod. Lifecycle: - Remove the collector's resources when the feature is disabled, the license loses the feature, or the license expires. Nothing owns them, so they previously lingered, and the expiry message claimed forwarding had stopped while the collector kept exporting. - Pin to a single replica. Each replica federates the same Prometheus targets under its own service.instance.id, duplicating every series. Also register the receiver keypair's key usages, give the probes a boot grace, derive the memory limiter from the container's effective memory limit so overrides take effect, and use the current otlp_grpc/otlp_http exporter type names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a ServiceMonitor for the collector so a silently failing export pipeline is visible: queue depth, export failures and dropped records are otherwise only in the pod's logs. Gated on the collector actually being configured, and removed when it is not. The ServiceMonitor selects a Service that only exists while the collector does, so rendering it unconditionally leaves one behind matching nothing once OpenTelemetry export is switched off. The monitor controller reads LogCollector to decide, mirroring how LicenseExpired already gates the other ServiceMonitors. The collector's identity constants move to pkg/render so both packages can share them: pkg/render/otelcollector imports pkg/render/monitor for the Prometheus federation target, so monitor cannot import it back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e Collector The field name was changed to OpenTelemetry but its comment still opened with "configures the OpenTelemetry Collector", which is the framing the review pushed back on: it implies the struct mirrors upstream Collector configuration, which it does not. Describe it as configuring OpenTelemetry export, which the operator happens to implement with a Collector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Hash exporter CA and client TLS in pod annotations so cert rotation triggers a StatefulSet roll - Gate OTel fluent-bit output on osType==Linux, matching other non-Linseed outputs - Mirror IsFeatureActive check in monitor controller so ServiceMonitor is deleted when the license lacks the otel-collector feature - Reject mutualTLS on http:// endpoints instead of silently ignoring the client cert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The ServiceMonitor rendered a target the tigera-prometheus default-deny blocked, so the scrape reported up=0 with nothing naming the cause. Add the matching egress rule under the same gate. Also track the collector's real deploy conditions: requeue on an unreadable LogCollector rather than treating it as disabled, which flapped both resources on an apiserver blip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trusted bundle: the collector rendered the shared calico-system/tigera-ca-bundle with a CA-only set, which the Installation controller owns and fills with a different certificate set. The component handler replaces ConfigMap data wholesale, so the two overwrote each other every reconcile. Carry our own named bundle, as fluent-bit already does, and add the Prometheus serving certificate so the federation scrape verifies a BYO cert. System roots stay out: this bundle backs the receiver's client_ca_file. Gating: fluent-bit shipped to the collector whenever spec.openTelemetry was set, even when the license or an invalid spec meant it was never deployed. All three controllers now share OpenTelemetrySpec.Deployable, and the monitor controller watches LogCollector so enabling export actually wakes it. Egress: an endpoint with no parseable port emitted no rule at all, so the default-deny silently dropped every export. Fall back to the protocol's default OTLP port. Non-cluster-host flow and DNS records carry their own tags and were never matched, so they reached Linseed but never the OTLP backend. Teardown probed only the StatefulSet, stranding the RBAC, ConfigMap, Service and policy if it had already gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One shared client certificate meant one identity for every backend, and a single CA for all of them. This feature fans out to several external backends that generally want different client certs and different trust anchors, so both move onto the exporter. Each exporter now takes its own tls.caConfigMapName and tls.clientCertSecretName, plus auth.headers for backends that authenticate with a token or a vendor-specific header instead. Header values are read from Secrets and reach the collector as environment variables, so credentials never land in the rendered ConfigMap. The operator gathers the user's per-exporter material into one object per kind, keyed by exporter. The teardown cannot enumerate the spec once the feature is off, so the names it has to delete stay fixed. Drops the mutualTLS boolean, which is not the repo idiom for a toggle, and renames metrics.enabled to metrics.state so the value no longer reads "enabled: Disabled". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the per-exporter TLS/auth restructure turned up several ways a credential could go to the wrong place or be silently dropped. Header credentials reach the collector as environment variables whose names were derived by collapsing anything outside [A-Z0-9_] to an underscore. Two exporters differing only in such a character produced the same variable, so one backend was sent the other's token. Exporter names are now restricted to a DNS label - which they had to be anyway, since they key ConfigMap and Secret data the API server would otherwise reject - and Validate rejects any header pair that still collides. The derivation moves to api/v1 so validation and rendering cannot drift apart. Auth headers on an http:// endpoint were accepted and put the credential on the wire in the clear; the equivalent mutualTLS case was already rejected. They are now rejected too. Exporter Secrets were deduped by name before validation, so a second header reading a different key of the same Secret was never checked and rendered an empty credential. Dedupe the fetch, not the validation. fluent-bit verified the collector against a bundle that never received its serving certificate. An operator-minted cert is covered by the CA, but a user-supplied one is honoured as-is and every connection would fail with an unknown authority while both statuses reported Available. Also: delete the exporter aggregates when a spec no longer names any credential rather than leaving them behind; name the certificatemanagement component's objects in the teardown, which only rendered the otel component; apply the StatefulSet after the volumes it mounts; and share the collector's port instead of hardcoding it in the fluent-bit output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
calicoLibHostPath was committed out of alphabetical order, so controller-gen relocates it and dirty-check fails for any PR that rebases onto master. Not related to this PR's changes; included so CI can pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ba5aeb9 to
e8a86d5
Compare
|
Ran Claude Code over this branch as a review pass; it flagged one thing that looks like it would stop the collector from starting at all, so raising it ahead of the smaller notes. Exporter component IDs may not be valid otelcol types
exporterPrefixHTTP = "otlp_http"
exporterPrefixGRPC = "otlp_grpc"These are used as the component-ID type in two places — the exporters block ( exporters:
otlp_grpc/backend:
endpoint: ...
service:
pipelines:
logs:
exporters: [otlp_grpc/backend]In an otelcol config the part before the If that applies here, the StatefulSet crash-loops on every install with an exporter configured, the health check never passes, and the TigeraStatus sits on Progressing with nothing pointing at the generated config as the cause. The render tests ( What I couldn't verify: the container runs
Either way it'd be good to have one test that feeds a rendered config to the actual collector binary — the current tests can't distinguish the two cases. |
| "github.com/tigera/operator/pkg/render/common/networkpolicy" | ||
| rlogcollector "github.com/tigera/operator/pkg/render/logcollector" | ||
| "github.com/tigera/operator/pkg/render/monitor" | ||
| "github.com/tigera/operator/pkg/render/otelcollector" |
There was a problem hiding this comment.
I believe we need a watch in this controller for the new secret otel-collector-tls
There was a problem hiding this comment.
Added to the secrets watch loop in add():
operator/pkg/controller/logcollector/logcollector_controller.go
Lines 136 to 138 in 83bfa6b
and consumed in Reconcile, where the cert joins fluent-bit's trusted bundle when logs are exported:
operator/pkg/controller/logcollector/logcollector_controller.go
Lines 491 to 501 in 83bfa6b
|
Another one from the same Claude Code review pass, much smaller than the last:
// +optional
Types []OpenTelemetryLogType `json:"types,omitempty"`The generated CRD ( outputs:
- name: opentelemetry
match: flows
...
- name: opentelemetry
match: flows
...fluent-bit runs both instances, so every flow record is shipped to the collector — and on to the customer's OTLP backend — twice. For a usage-priced backend that is a duplicated bill as well as duplicated data. With Fix is a marker: // +optional
// +listType=set
Types []OpenTelemetryLogType `json:"types,omitempty"`
|
Trust: a pinned CA is now the only thing trusted for that exporter. Loading it alongside the system roots meant any public authority still satisfied the connection, which is what pinning is meant to prevent. The plaintext check was case-sensitive, so HTTP:// was treated as encrypted and credentials on it were accepted. Schemes are case-insensitive; the check now lives on the exporter so validation and rendering cannot diverge, and the endpoint carries a Pattern requiring a scheme. fluent-bit's controller now watches otel-collector-tls. Its serving certificate goes into fluent-bit's trusted bundle, so creating or rotating it has to rebuild that bundle rather than wait for an unrelated reconcile. Documents that a header value is sent verbatim, so a bearer token needs the "Bearer " prefix in the Secret - an easy configuration error to make. Merges opentelemetry_types.go into logcollector_types.go; the split was arbitrary. Trims the field comments to what an end user needs, rather than rationale aimed at a reviewer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turning logs off while keeping metrics stops the keypair being rendered, but nothing removed the copy already in calico-system, leaving a server key nothing serves with. Only a full teardown collected it. Also drops OpenTelemetryExporter.MutualTLS(), which nothing called; ClientCertSecret() is what the controller and render actually use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A valid, licensed spec was enough to render the ServiceMonitor and the Prometheus egress rule, but the otel controller stops short of rendering when material an exporter names is missing, leaving a ServiceMonitor selecting a Service that was never created. Follow the Service instead, watched so it does not wait for the periodic reconcile. Hash the exporter client keypair and header credentials with SHA-256 rather than the SHA-1 in rmeta.AnnotationHash; the digest lands in a pod annotation anyone who can read pods can read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rene's review asked for API docs that explain the field rather than motivate the choice behind it. Drop the internal rationale from exporters, exporter name and caConfigMapName, and regenerate the CRD descriptions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
caseydavenport
left a comment
There was a problem hiding this comment.
Most of these comments are just me scolding Claude for being way too verbose in its code comments....
| // (StatefulSet, ConfigMap, RBAC, certs) with its own lifecycle, so this | ||
| // lives at the top level rather than under AdditionalStores. | ||
| // +optional | ||
| OpenTelemetry *OpenTelemetrySpec `json:"openTelemetry,omitempty"` |
There was a problem hiding this comment.
A bit of a high level question here, but is LogCollector really the right place for this?
We're exporting more than just logs ( e.g., prometheus metrics ) - so I wonder if this really wants its own CRD?
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package otelcollector |
There was a problem hiding this comment.
nit, but I think we have a new enterprise subdir in a few places for utilities - we should try to keep any utils we're adding within those directories where appropriate (this is sort of more general for the whole PR).
| // The collector's serving certificate goes into fluent-bit's trusted | ||
| // bundle, so creating or rotating it has to rebuild that bundle. |
| // Fluent Bit needs to mount system certificates in the case where Splunk, Syslog or AWS are used. | ||
| // The bundle carries fluent-bit's own name: calico-system's shared tigera-ca-bundle is rendered by | ||
| // the core Installation controller with a different certificate set, and the component handler | ||
| // replaces ConfigMap data wholesale — an unnamed bundle here would fight it, and additions like | ||
| // the syslog user CA would be lost to whichever controller wrote last. | ||
| trustedBundle := certificatemanagement.CreateNamedTrustedBundle(render.FluentBitNodeName, certificateManager.KeyPair(), true, prometheusCertificate, linseedCertificate) | ||
| // The collector's serving certificate has to be trusted for the OTLP output to | ||
| // verify it. The operator CA covers an operator-minted cert, but a | ||
| // user-supplied otel-collector-tls is honoured as-is, and fluent-bit would then | ||
| // reject every connection with an unknown-authority error while both statuses | ||
| // still reported Available. |
There was a problem hiding this comment.
This massive comment block also looks like AI over-commenting to me.
I think this should be as simple as:
// FluentBit needs system certificates to talk to external tools like Splunk, Syslog, or AWS. We give
// it its own trusted bundle as a result to avoid contamination with other services in this namespace.
//
// We must also trust the OTEL collector's server certificate, if enabled.| // Same predicate the otel controller deploys on. Pointing fluent-bit at a | ||
| // collector that is never rendered — unlicensed, or an invalid spec — just | ||
| // fails every chunk and fills the storage buffer. | ||
| OpenTelemetryCollectorEnabled: instance.Spec.OpenTelemetry.Deployable( | ||
| utils.IsFeatureActive(license, common.OpenTelemetryCollectorFeature)), |
There was a problem hiding this comment.
| // Same predicate the otel controller deploys on. Pointing fluent-bit at a | |
| // collector that is never rendered — unlicensed, or an invalid spec — just | |
| // fails every chunk and fills the storage buffer. | |
| OpenTelemetryCollectorEnabled: instance.Spec.OpenTelemetry.Deployable( | |
| utils.IsFeatureActive(license, common.OpenTelemetryCollectorFeature)), | |
| OpenTelemetryCollectorEnabled: instance.Spec.OpenTelemetry.Deployable(utils.IsFeatureActive(license, common.OpenTelemetryCollectorFeature)), |
| } | ||
|
|
||
| // fetchExporterMaterial reads the CA, client keypair and header credentials each | ||
| // exporter names, keyed for the render. The second return reports that the |
There was a problem hiding this comment.
I think this comment is wrong / out of date? Second return is a map of Secrets.
|
|
||
| // clusterServiceHost matches a Kubernetes service DNS name, with or without the | ||
| // cluster domain suffix: <service>.<namespace>.svc[.cluster.local] | ||
| var clusterServiceHost = regexp.MustCompile(`^([a-z0-9]([-a-z0-9]*[a-z0-9])?)\.([a-z0-9]([-a-z0-9]*[a-z0-9])?)\.svc(\.[a-z0-9.-]+)?$`) |
There was a problem hiding this comment.
Hm, is there not an upstream regex we could import for this?
| // OpenTelemetryEnabled reports whether the OTel Collector is configured. Its | ||
| // ServiceMonitor is created and removed with the collector; without this the | ||
| // monitor render cannot tell, and would leave one behind selecting a Service | ||
| // that no longer exists. |
There was a problem hiding this comment.
Again, self-explanatory from the code, Claude.
| // OpenTelemetryEnabled reports whether the OTel Collector is configured. Its | |
| // ServiceMonitor is created and removed with the collector; without this the | |
| // monitor render cannot tell, and would leave one behind selecting a Service | |
| // that no longer exists. |
|
|
||
| func (c *component) Objects() ([]client.Object, []client.Object) { | ||
| if c.cfg.Disabled { | ||
| // Nothing is owned by a CR we can rely on being deleted, so switching the |
There was a problem hiding this comment.
| // Nothing is owned by a CR we can rely on being deleted, so switching the | |
| // Objects are owned by a sub-struct of the LogCollector CR rather than the CR as a whole, so switching the |
| // Plaintext is set for http:// endpoints. OTLP backends are commonly exposed | ||
| // without TLS in-cluster, and the scheme is how the user asks for that — | ||
| // mirroring the Splunk output, where a plain-http endpoint does no TLS. There | ||
| // is deliberately no field for "TLS on, verification off". | ||
| Plaintext bool |
There was a problem hiding this comment.
Hm, this is sketchy - we have a policy of disallowing HTTP in our product. I don't think we should allow this - it's a bad security posture that we are enabling.
Summary
Render the OTel Collector as a StatefulSet in
calico-system, configured viaLogCollector.spec.openTelemetry.third_party/The collector receives logs from fluent-bit via OTLP and optionally federates Prometheus metrics, forwarding both to user-configured OTLP endpoints. It is added to the
LogCollectorCR rather thanAdditionalStoresbecause it is operator-managed infrastructure (StatefulSet, ConfigMap, RBAC, certs) with its own lifecycle, not a pointer to an external system.API
TLS and auth are configured per exporter rather than once for all of them: this fans out to several external backends, which generally expect different client identities and different trust anchors.
tls.clientCertSecretNameturns on mutual TLS for that exporter alone;tls.caConfigMapNamepins that exporter to the named CA alone — the system roots are not trusted as well, since keeping them would let any public authority satisfy the connection, which is what pinning is for. An exporter that names no CA verifies against the system roots. Verification is never disabled. Header credentials are read from Secrets into environment variables and referenced as${env:...}, so they never appear in the rendered config.Exporter CAs are deliberately kept out of the operator's trusted bundle — that bundle backs the receiver's
client_ca_file, so folding them in would make them valid signers for inbound client certificates. The collector carries its own named bundle rather than the sharedcalico-system/tigera-ca-bundle, which the Installation controller owns.Release Note
Test plan
Exercised on a cluster since: a pinned
caConfigMapNamerendersca_filewith no system roots and is mounted and loaded, and a user-suppliedotel-collector-tlsreaches fluent-bit's trust bundle and is accepted with no verification errors.Still unit-tested only:
clientCertSecretNameandauth.headersagainst a real TLS-terminating backend — no external OTLP endpoint demanding mutual TLS or a credential header was available to test against.Follow-up: moving Dex and Guardian onto the shared
ParseExternalDestinationhelper (r3717124352) is going into its own PR.EV-6862