diff --git a/api/v1/fluentbit_daemonset_types.go b/api/v1/fluentbit_daemonset_types.go new file mode 100644 index 0000000000..7922911e86 --- /dev/null +++ b/api/v1/fluentbit_daemonset_types.go @@ -0,0 +1,101 @@ +// Copyright (c) 2024-2026 Tigera, Inc. All rights reserved. +/* + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + v1 "k8s.io/api/core/v1" +) + +// FluentBitDaemonSet is the configuration for the Fluent Bit DaemonSet. +type FluentBitDaemonSet struct { + + // Spec is the specification of the Fluent Bit DaemonSet. + // +optional + Spec *FluentBitDaemonSetSpec `json:"spec,omitempty"` +} + +// FluentBitDaemonSetSpec defines configuration for the Fluent Bit DaemonSet. +type FluentBitDaemonSetSpec struct { + + // Template describes the Fluent Bit DaemonSet pod that will be created. + // +optional + Template *FluentBitDaemonSetPodTemplateSpec `json:"template,omitempty"` +} + +// FluentBitDaemonSetPodTemplateSpec is the Fluent Bit DaemonSet's PodTemplateSpec +type FluentBitDaemonSetPodTemplateSpec struct { + + // Spec is the Fluent Bit DaemonSet's PodSpec. + // +optional + Spec *FluentBitDaemonSetPodSpec `json:"spec,omitempty"` +} + +// FluentBitDaemonSetPodSpec is the Fluent Bit DaemonSet's PodSpec. +type FluentBitDaemonSetPodSpec struct { + // InitContainers is a list of Fluent Bit DaemonSet init containers. + // If specified, this overrides the specified Fluent Bit DaemonSet init containers. + // If omitted, the Fluent Bit DaemonSet will use its default values for its init containers. + // +optional + InitContainers []FluentBitDaemonSetInitContainer `json:"initContainers,omitempty"` + + // Containers is a list of Fluent Bit DaemonSet containers. + // If specified, this overrides the specified Fluent Bit DaemonSet containers. + // If omitted, the Fluent Bit DaemonSet will use its default values for its containers. + // +optional + Containers []FluentBitDaemonSetContainer `json:"containers,omitempty"` +} + +// FluentBitDaemonSetContainer is a Fluent Bit DaemonSet container. +type FluentBitDaemonSetContainer struct { + // Name is an enum which identifies the Fluent Bit DaemonSet container by name. + // Supported values are: calico-fluent-bit (or the deprecated alias fluentd, kept + // for one release so existing FluentdDaemonSet overrides continue to validate). + // +kubebuilder:validation:Enum=calico-fluent-bit;fluentd + Name string `json:"name"` + + // Resources allows customization of limits and requests for compute resources such as cpu and memory. + // If specified, this overrides the named Fluent Bit DaemonSet container's resources. + // If omitted, the Fluent Bit DaemonSet will use its default value for this container's resources. + // +optional + Resources *v1.ResourceRequirements `json:"resources,omitempty"` + + // ReadinessProbe allows customization of the readiness probe timing parameters. + // The probe handler is set by the operator and cannot be overridden. + // +optional + ReadinessProbe *ProbeOverride `json:"readinessProbe,omitempty"` + + // LivenessProbe allows customization of the liveness probe timing parameters. + // The probe handler is set by the operator and cannot be overridden. + // +optional + LivenessProbe *ProbeOverride `json:"livenessProbe,omitempty"` +} + +// FluentBitDaemonSetInitContainer is a Fluent Bit DaemonSet init container. +type FluentBitDaemonSetInitContainer struct { + // Name is an enum which identifies the Fluent Bit DaemonSet init container by name. + // Supported values are: calico-fluent-bit-tls-key-cert-provisioner (or the deprecated + // alias tigera-fluentd-prometheus-tls-key-cert-provisioner, kept for one release so + // existing FluentdDaemonSet overrides continue to validate). + // +kubebuilder:validation:Enum=calico-fluent-bit-tls-key-cert-provisioner;tigera-fluentd-prometheus-tls-key-cert-provisioner + Name string `json:"name"` + + // Resources allows customization of limits and requests for compute resources such as cpu and memory. + // If specified, this overrides the named Fluent Bit DaemonSet init container's resources. + // If omitted, the Fluent Bit DaemonSet will use its default value for this init container's resources. + // +optional + Resources *v1.ResourceRequirements `json:"resources,omitempty"` +} diff --git a/api/v1/fluentd_daemonset_types.go b/api/v1/fluentd_daemonset_types.go deleted file mode 100644 index 1db6e847be..0000000000 --- a/api/v1/fluentd_daemonset_types.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2024-2026 Tigera, Inc. All rights reserved. -/* - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package v1 - -import ( - v1 "k8s.io/api/core/v1" -) - -// FluentdDaemonSet is the configuration for the Fluentd DaemonSet. -type FluentdDaemonSet struct { - - // Spec is the specification of the Fluentd DaemonSet. - // +optional - Spec *FluentdDaemonSetSpec `json:"spec,omitempty"` -} - -// FluentdDaemonSetSpec defines configuration for the Fluentd DaemonSet. -type FluentdDaemonSetSpec struct { - - // Template describes the Fluentd DaemonSet pod that will be created. - // +optional - Template *FluentdDaemonSetPodTemplateSpec `json:"template,omitempty"` -} - -// FluentdDaemonSetPodTemplateSpec is the Fluentd DaemonSet's PodTemplateSpec -type FluentdDaemonSetPodTemplateSpec struct { - - // Spec is the Fluentd DaemonSet's PodSpec. - // +optional - Spec *FluentdDaemonSetPodSpec `json:"spec,omitempty"` -} - -// FluentdDaemonSetPodSpec is the Fluentd DaemonSet's PodSpec. -type FluentdDaemonSetPodSpec struct { - // InitContainers is a list of Fluentd DaemonSet init containers. - // If specified, this overrides the specified Fluentd DaemonSet init containers. - // If omitted, the Fluentd DaemonSet will use its default values for its init containers. - // +optional - InitContainers []FluentdDaemonSetInitContainer `json:"initContainers,omitempty"` - - // Containers is a list of Fluentd DaemonSet containers. - // If specified, this overrides the specified Fluentd DaemonSet containers. - // If omitted, the Fluentd DaemonSet will use its default values for its containers. - // +optional - Containers []FluentdDaemonSetContainer `json:"containers,omitempty"` -} - -// FluentdDaemonSetContainer is a Fluentd DaemonSet container. -type FluentdDaemonSetContainer struct { - // Name is an enum which identifies the Fluentd DaemonSet container by name. - // Supported values are: fluentd - // +kubebuilder:validation:Enum=fluentd - Name string `json:"name"` - - // Resources allows customization of limits and requests for compute resources such as cpu and memory. - // If specified, this overrides the named Fluentd DaemonSet container's resources. - // If omitted, the Fluentd DaemonSet will use its default value for this container's resources. - // +optional - Resources *v1.ResourceRequirements `json:"resources,omitempty"` - - // ReadinessProbe allows customization of the readiness probe timing parameters. - // The probe handler is set by the operator and cannot be overridden. - // +optional - ReadinessProbe *ProbeOverride `json:"readinessProbe,omitempty"` - - // LivenessProbe allows customization of the liveness probe timing parameters. - // The probe handler is set by the operator and cannot be overridden. - // +optional - LivenessProbe *ProbeOverride `json:"livenessProbe,omitempty"` -} - -// FluentdDaemonSetInitContainer is a Fluentd DaemonSet init container. -type FluentdDaemonSetInitContainer struct { - // Name is an enum which identifies the Fluentd DaemonSet init container by name. - // Supported values are: tigera-fluentd-prometheus-tls-key-cert-provisioner - // +kubebuilder:validation:Enum=tigera-fluentd-prometheus-tls-key-cert-provisioner - Name string `json:"name"` - - // Resources allows customization of limits and requests for compute resources such as cpu and memory. - // If specified, this overrides the named Fluentd DaemonSet init container's resources. - // If omitted, the Fluentd DaemonSet will use its default value for this init container's resources. - // +optional - Resources *v1.ResourceRequirements `json:"resources,omitempty"` -} diff --git a/api/v1/logcollector_types.go b/api/v1/logcollector_types.go index 0deffdadde..d5b7a3810f 100644 --- a/api/v1/logcollector_types.go +++ b/api/v1/logcollector_types.go @@ -42,12 +42,27 @@ type LogCollectorSpec struct { // +optional MultiTenantManagementClusterNamespace string `json:"multiTenantManagementClusterNamespace,omitempty"` - // FluentdDaemonSet configures the Fluentd DaemonSet. - FluentdDaemonSet *FluentdDaemonSet `json:"fluentdDaemonSet,omitempty"` + // FluentdDaemonSet configures the calico-fluent-bit DaemonSet (deprecated alias). + // + // Deprecated: use CalicoFluentBitDaemonSet instead. This field is retained + // as an alias for one release during the Fluentd → Fluent Bit migration; + // when both are set, CalicoFluentBitDaemonSet takes precedence. + // +optional + FluentdDaemonSet *FluentBitDaemonSet `json:"fluentdDaemonSet,omitempty"` + + // CalicoFluentBitDaemonSet configures the calico-fluent-bit DaemonSet, the + // Fluent Bit replacement for the Fluentd DaemonSet. Pod-template override + // semantics are unchanged from the deprecated FluentdDaemonSet field. + // +optional + CalicoFluentBitDaemonSet *FluentBitDaemonSet `json:"calicoFluentBitDaemonSet,omitempty"` // EKSLogForwarderDeployment configures the EKSLogForwarderDeployment Deployment. // +optional EKSLogForwarderDeployment *EKSLogForwarderDeployment `json:"eksLogForwarderDeployment,omitempty"` + + // OTelCollector configures the OpenTelemetry Collector for exporting logs and metrics via OTLP. + // +optional + OTelCollector *OTelCollectorSpec `json:"otelCollector,omitempty"` } type CollectProcessPathOption string @@ -222,7 +237,7 @@ type LogCollectorStatus struct { // +kubebuilder:resource:scope=Cluster // LogCollector installs the components required for Tigera flow and DNS log collection. At most one instance -// of this resource is supported. It must be named "tigera-secure". When created, this installs fluentd on all nodes +// of this resource is supported. It must be named "tigera-secure". When created, this installs fluent-bit on all nodes // configured to collect Tigera log data and export it to Tigera's Elasticsearch cluster as well as any additionally configured destinations. // // +kubebuilder:validation:XValidation:rule="self.metadata.name == 'tigera-secure'",message="resource name must be 'tigera-secure'" @@ -245,6 +260,25 @@ type LogCollectorList struct { Items []LogCollector `json:"items"` } +// OTelCollectorSpec defines the desired state of the OpenTelemetry Collector. +type OTelCollectorSpec struct { + // Logs configures which log types are exported via OTLP. + // +optional + Logs *OTelLogs `json:"logs,omitempty"` + + // Metrics configures whether Calico component metrics are exported via OTLP. + // +optional + Metrics *OTelMetrics `json:"metrics,omitempty"` + + // Exporters configures the OTLP export endpoints. + // +optional + Exporters []OTelExporter `json:"exporters,omitempty"` + + // OTelCollectorStatefulSet configures the OTel Collector StatefulSet. + // +optional + OTelCollectorStatefulSet *OTelCollectorStatefulSet `json:"openTelemetryCollectorStatefulSet,omitempty"` +} + func init() { SchemeBuilder.Register(&LogCollector{}, &LogCollectorList{}) } diff --git a/api/v1/otelcollector_types.go b/api/v1/otelcollector_types.go new file mode 100644 index 0000000000..2c6f0cfb87 --- /dev/null +++ b/api/v1/otelcollector_types.go @@ -0,0 +1,169 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + corev1 "k8s.io/api/core/v1" +) + +// OTelLogType represents the allowable log types for OTel export. +// +kubebuilder:validation:Enum=Audit;DNS;Flows +type OTelLogType string + +const ( + OTelAuditLog OTelLogType = "Audit" + OTelDNSLog OTelLogType = "DNS" + OTelFlowLog OTelLogType = "Flows" +) + +// OTelLogs configures log export. +type OTelLogs struct { + // Types specifies which log types to export. Supported values: Audit, DNS, Flows. + // +optional + Types []OTelLogType `json:"types,omitempty"` +} + +// OTelMetricsEnabled is the option to enable or disable metrics export. +// +kubebuilder:validation:Enum=Enabled;Disabled +type OTelMetricsEnabled string + +const ( + OTelMetricsEnable OTelMetricsEnabled = "Enabled" + OTelMetricsDisable OTelMetricsEnabled = "Disabled" +) + +// OTelMetrics configures metrics export. +type OTelMetrics struct { + // Enabled specifies whether to scrape and export Calico component metrics via OTLP. + // Default: Disabled + // +optional + Enabled *OTelMetricsEnabled `json:"enabled,omitempty"` +} + +// OTelExporterProtocol specifies the OTLP transport protocol. +// +kubebuilder:validation:Enum=grpc;http +type OTelExporterProtocol string + +const ( + OTelProtocolGRPC OTelExporterProtocol = "grpc" + OTelProtocolHTTP OTelExporterProtocol = "http" +) + +// OTelExporter defines an OTLP export endpoint. +type OTelExporter struct { + // Name is a unique identifier for this exporter. + Name string `json:"name"` + + // Endpoint is the OTLP endpoint URL. + Endpoint string `json:"endpoint"` + + // Protocol specifies the OTLP transport protocol. Default: grpc. + // +optional + // +kubebuilder:default=grpc + Protocol OTelExporterProtocol `json:"protocol,omitempty"` + + // TLSInsecure disables TLS verification for this exporter. Only use for trusted in-cluster targets. + // Default: false + // +optional + TLSInsecure *bool `json:"tlsInsecure,omitempty"` +} + +// OTelCollectorStatefulSet is the configuration for the OTel Collector StatefulSet. +type OTelCollectorStatefulSet struct { + // Metadata is a subset of a Kubernetes object's metadata that is added to the StatefulSet. + // +optional + Metadata *Metadata `json:"metadata,omitempty"` + // Spec is the specification of the OTel Collector StatefulSet. + // +optional + Spec *OTelCollectorStatefulSetSpec `json:"spec,omitempty"` +} + +// OTelCollectorStatefulSetSpec defines configuration for the OTel Collector StatefulSet. +type OTelCollectorStatefulSetSpec struct { + // MinReadySeconds is the minimum number of seconds for which a newly created StatefulSet pod should + // be ready without any of its container crashing, for it to be considered available. + // If specified, this overrides any minReadySeconds value that may be set on the OTel Collector StatefulSet. + // If omitted, the OTel Collector StatefulSet will use its default value for minReadySeconds. + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=2147483647 + MinReadySeconds *int32 `json:"minReadySeconds,omitempty"` + + // Template describes the OTel Collector StatefulSet pod that will be created. + // +optional + Template *OTelCollectorStatefulSetPodTemplateSpec `json:"template,omitempty"` +} + +// OTelCollectorStatefulSetPodTemplateSpec is the OTel Collector StatefulSet's PodTemplateSpec. +type OTelCollectorStatefulSetPodTemplateSpec struct { + // Metadata is a subset of a Kubernetes object's metadata that is added to the pod's metadata. + // +optional + Metadata *Metadata `json:"metadata,omitempty"` + // Spec is the OTel Collector StatefulSet's PodSpec. + // +optional + Spec *OTelCollectorStatefulSetPodSpec `json:"spec,omitempty"` +} + +// OTelCollectorStatefulSetPodSpec is the OTel Collector StatefulSet's PodSpec. +type OTelCollectorStatefulSetPodSpec struct { + // Affinity is a group of affinity scheduling rules for the OTel Collector pods. + // +optional + Affinity *corev1.Affinity `json:"affinity"` + // Containers is a list of OTel Collector containers. + // If specified, this overrides the specified OTel Collector StatefulSet containers. + // If omitted, the OTel Collector StatefulSet will use its default values for its containers. + // +optional + Containers []OTelCollectorStatefulSetContainer `json:"containers,omitempty"` + // NodeSelector gives more control over the nodes where the OTel Collector pods will run on. + // +optional + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + // TopologySpreadConstraints describes how a group of pods ought to spread across topology + // domains. Scheduler will schedule pods in a way which abides by the constraints. + // All topologySpreadConstraints are ANDed. + // +optional + TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + // Tolerations is the OTel Collector pod's tolerations. + // If specified, this overrides any tolerations that may be set on the OTel Collector StatefulSet. + // If omitted, the OTel Collector StatefulSet will use its default value for tolerations. + // +optional + Tolerations []corev1.Toleration `json:"tolerations"` + // PriorityClassName allows to specify a PriorityClass resource to be used. + // +optional + PriorityClassName string `json:"priorityClassName,omitempty"` +} + +// OTelCollectorStatefulSetContainer is an OTel Collector StatefulSet container. +type OTelCollectorStatefulSetContainer struct { + // Name is an enum which identifies the OTel Collector StatefulSet container by name. + // Supported values are: otel-collector + // +kubebuilder:validation:Enum=otel-collector + Name string `json:"name"` + + // Resources allows customization of limits and requests for compute resources such as cpu and memory. + // If specified, this overrides the named OTel Collector StatefulSet container's resources. + // If omitted, the OTel Collector StatefulSet will use its default value for this container's resources. + // +optional + Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + + // ReadinessProbe allows customization of the readiness probe timing parameters. + // The probe handler is set by the operator and cannot be overridden. + // +optional + ReadinessProbe *ProbeOverride `json:"readinessProbe,omitempty"` + + // LivenessProbe allows customization of the liveness probe timing parameters. + // The probe handler is set by the operator and cannot be overridden. + // +optional + LivenessProbe *ProbeOverride `json:"livenessProbe,omitempty"` +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 00e122a473..82add25018 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -4203,27 +4203,27 @@ func (in *ExternalPrometheus) DeepCopy() *ExternalPrometheus { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FluentdDaemonSet) DeepCopyInto(out *FluentdDaemonSet) { +func (in *FluentBitDaemonSet) DeepCopyInto(out *FluentBitDaemonSet) { *out = *in if in.Spec != nil { in, out := &in.Spec, &out.Spec - *out = new(FluentdDaemonSetSpec) + *out = new(FluentBitDaemonSetSpec) (*in).DeepCopyInto(*out) } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentdDaemonSet. -func (in *FluentdDaemonSet) DeepCopy() *FluentdDaemonSet { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentBitDaemonSet. +func (in *FluentBitDaemonSet) DeepCopy() *FluentBitDaemonSet { if in == nil { return nil } - out := new(FluentdDaemonSet) + out := new(FluentBitDaemonSet) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FluentdDaemonSetContainer) DeepCopyInto(out *FluentdDaemonSetContainer) { +func (in *FluentBitDaemonSetContainer) DeepCopyInto(out *FluentBitDaemonSetContainer) { *out = *in if in.Resources != nil { in, out := &in.Resources, &out.Resources @@ -4242,18 +4242,18 @@ func (in *FluentdDaemonSetContainer) DeepCopyInto(out *FluentdDaemonSetContainer } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentdDaemonSetContainer. -func (in *FluentdDaemonSetContainer) DeepCopy() *FluentdDaemonSetContainer { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentBitDaemonSetContainer. +func (in *FluentBitDaemonSetContainer) DeepCopy() *FluentBitDaemonSetContainer { if in == nil { return nil } - out := new(FluentdDaemonSetContainer) + out := new(FluentBitDaemonSetContainer) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FluentdDaemonSetInitContainer) DeepCopyInto(out *FluentdDaemonSetInitContainer) { +func (in *FluentBitDaemonSetInitContainer) DeepCopyInto(out *FluentBitDaemonSetInitContainer) { *out = *in if in.Resources != nil { in, out := &in.Resources, &out.Resources @@ -4262,81 +4262,81 @@ func (in *FluentdDaemonSetInitContainer) DeepCopyInto(out *FluentdDaemonSetInitC } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentdDaemonSetInitContainer. -func (in *FluentdDaemonSetInitContainer) DeepCopy() *FluentdDaemonSetInitContainer { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentBitDaemonSetInitContainer. +func (in *FluentBitDaemonSetInitContainer) DeepCopy() *FluentBitDaemonSetInitContainer { if in == nil { return nil } - out := new(FluentdDaemonSetInitContainer) + out := new(FluentBitDaemonSetInitContainer) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FluentdDaemonSetPodSpec) DeepCopyInto(out *FluentdDaemonSetPodSpec) { +func (in *FluentBitDaemonSetPodSpec) DeepCopyInto(out *FluentBitDaemonSetPodSpec) { *out = *in if in.InitContainers != nil { in, out := &in.InitContainers, &out.InitContainers - *out = make([]FluentdDaemonSetInitContainer, len(*in)) + *out = make([]FluentBitDaemonSetInitContainer, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } if in.Containers != nil { in, out := &in.Containers, &out.Containers - *out = make([]FluentdDaemonSetContainer, len(*in)) + *out = make([]FluentBitDaemonSetContainer, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentdDaemonSetPodSpec. -func (in *FluentdDaemonSetPodSpec) DeepCopy() *FluentdDaemonSetPodSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentBitDaemonSetPodSpec. +func (in *FluentBitDaemonSetPodSpec) DeepCopy() *FluentBitDaemonSetPodSpec { if in == nil { return nil } - out := new(FluentdDaemonSetPodSpec) + out := new(FluentBitDaemonSetPodSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FluentdDaemonSetPodTemplateSpec) DeepCopyInto(out *FluentdDaemonSetPodTemplateSpec) { +func (in *FluentBitDaemonSetPodTemplateSpec) DeepCopyInto(out *FluentBitDaemonSetPodTemplateSpec) { *out = *in if in.Spec != nil { in, out := &in.Spec, &out.Spec - *out = new(FluentdDaemonSetPodSpec) + *out = new(FluentBitDaemonSetPodSpec) (*in).DeepCopyInto(*out) } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentdDaemonSetPodTemplateSpec. -func (in *FluentdDaemonSetPodTemplateSpec) DeepCopy() *FluentdDaemonSetPodTemplateSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentBitDaemonSetPodTemplateSpec. +func (in *FluentBitDaemonSetPodTemplateSpec) DeepCopy() *FluentBitDaemonSetPodTemplateSpec { if in == nil { return nil } - out := new(FluentdDaemonSetPodTemplateSpec) + out := new(FluentBitDaemonSetPodTemplateSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FluentdDaemonSetSpec) DeepCopyInto(out *FluentdDaemonSetSpec) { +func (in *FluentBitDaemonSetSpec) DeepCopyInto(out *FluentBitDaemonSetSpec) { *out = *in if in.Template != nil { in, out := &in.Template, &out.Template - *out = new(FluentdDaemonSetPodTemplateSpec) + *out = new(FluentBitDaemonSetPodTemplateSpec) (*in).DeepCopyInto(*out) } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentdDaemonSetSpec. -func (in *FluentdDaemonSetSpec) DeepCopy() *FluentdDaemonSetSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluentBitDaemonSetSpec. +func (in *FluentBitDaemonSetSpec) DeepCopy() *FluentBitDaemonSetSpec { if in == nil { return nil } - out := new(FluentdDaemonSetSpec) + out := new(FluentBitDaemonSetSpec) in.DeepCopyInto(out) return out } @@ -7275,7 +7275,12 @@ func (in *LogCollectorSpec) DeepCopyInto(out *LogCollectorSpec) { } if in.FluentdDaemonSet != nil { in, out := &in.FluentdDaemonSet, &out.FluentdDaemonSet - *out = new(FluentdDaemonSet) + *out = new(FluentBitDaemonSet) + (*in).DeepCopyInto(*out) + } + if in.CalicoFluentBitDaemonSet != nil { + in, out := &in.CalicoFluentBitDaemonSet, &out.CalicoFluentBitDaemonSet + *out = new(FluentBitDaemonSet) (*in).DeepCopyInto(*out) } if in.EKSLogForwarderDeployment != nil { @@ -7283,6 +7288,11 @@ func (in *LogCollectorSpec) DeepCopyInto(out *LogCollectorSpec) { *out = new(EKSLogForwarderDeployment) (*in).DeepCopyInto(*out) } + if in.OTelCollector != nil { + in, out := &in.OTelCollector, &out.OTelCollector + *out = new(OTelCollectorSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LogCollectorSpec. @@ -8323,6 +8333,256 @@ func (in *NonClusterHostSpec) DeepCopy() *NonClusterHostSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OTelCollectorSpec) DeepCopyInto(out *OTelCollectorSpec) { + *out = *in + if in.Logs != nil { + in, out := &in.Logs, &out.Logs + *out = new(OTelLogs) + (*in).DeepCopyInto(*out) + } + if in.Metrics != nil { + in, out := &in.Metrics, &out.Metrics + *out = new(OTelMetrics) + (*in).DeepCopyInto(*out) + } + if in.Exporters != nil { + in, out := &in.Exporters, &out.Exporters + *out = make([]OTelExporter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.OTelCollectorStatefulSet != nil { + in, out := &in.OTelCollectorStatefulSet, &out.OTelCollectorStatefulSet + *out = new(OTelCollectorStatefulSet) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OTelCollectorSpec. +func (in *OTelCollectorSpec) DeepCopy() *OTelCollectorSpec { + if in == nil { + return nil + } + out := new(OTelCollectorSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OTelCollectorStatefulSet) DeepCopyInto(out *OTelCollectorStatefulSet) { + *out = *in + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new(Metadata) + (*in).DeepCopyInto(*out) + } + if in.Spec != nil { + in, out := &in.Spec, &out.Spec + *out = new(OTelCollectorStatefulSetSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OTelCollectorStatefulSet. +func (in *OTelCollectorStatefulSet) DeepCopy() *OTelCollectorStatefulSet { + if in == nil { + return nil + } + out := new(OTelCollectorStatefulSet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OTelCollectorStatefulSetContainer) DeepCopyInto(out *OTelCollectorStatefulSetContainer) { + *out = *in + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(corev1.ResourceRequirements) + (*in).DeepCopyInto(*out) + } + if in.ReadinessProbe != nil { + in, out := &in.ReadinessProbe, &out.ReadinessProbe + *out = new(ProbeOverride) + (*in).DeepCopyInto(*out) + } + if in.LivenessProbe != nil { + in, out := &in.LivenessProbe, &out.LivenessProbe + *out = new(ProbeOverride) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OTelCollectorStatefulSetContainer. +func (in *OTelCollectorStatefulSetContainer) DeepCopy() *OTelCollectorStatefulSetContainer { + if in == nil { + return nil + } + out := new(OTelCollectorStatefulSetContainer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OTelCollectorStatefulSetPodSpec) DeepCopyInto(out *OTelCollectorStatefulSetPodSpec) { + *out = *in + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(corev1.Affinity) + (*in).DeepCopyInto(*out) + } + if in.Containers != nil { + in, out := &in.Containers, &out.Containers + *out = make([]OTelCollectorStatefulSetContainer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]corev1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]corev1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OTelCollectorStatefulSetPodSpec. +func (in *OTelCollectorStatefulSetPodSpec) DeepCopy() *OTelCollectorStatefulSetPodSpec { + if in == nil { + return nil + } + out := new(OTelCollectorStatefulSetPodSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OTelCollectorStatefulSetPodTemplateSpec) DeepCopyInto(out *OTelCollectorStatefulSetPodTemplateSpec) { + *out = *in + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new(Metadata) + (*in).DeepCopyInto(*out) + } + if in.Spec != nil { + in, out := &in.Spec, &out.Spec + *out = new(OTelCollectorStatefulSetPodSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OTelCollectorStatefulSetPodTemplateSpec. +func (in *OTelCollectorStatefulSetPodTemplateSpec) DeepCopy() *OTelCollectorStatefulSetPodTemplateSpec { + if in == nil { + return nil + } + out := new(OTelCollectorStatefulSetPodTemplateSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OTelCollectorStatefulSetSpec) DeepCopyInto(out *OTelCollectorStatefulSetSpec) { + *out = *in + if in.MinReadySeconds != nil { + in, out := &in.MinReadySeconds, &out.MinReadySeconds + *out = new(int32) + **out = **in + } + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(OTelCollectorStatefulSetPodTemplateSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OTelCollectorStatefulSetSpec. +func (in *OTelCollectorStatefulSetSpec) DeepCopy() *OTelCollectorStatefulSetSpec { + if in == nil { + return nil + } + out := new(OTelCollectorStatefulSetSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OTelExporter) DeepCopyInto(out *OTelExporter) { + *out = *in + if in.TLSInsecure != nil { + in, out := &in.TLSInsecure, &out.TLSInsecure + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OTelExporter. +func (in *OTelExporter) DeepCopy() *OTelExporter { + if in == nil { + return nil + } + out := new(OTelExporter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OTelLogs) DeepCopyInto(out *OTelLogs) { + *out = *in + if in.Types != nil { + in, out := &in.Types, &out.Types + *out = make([]OTelLogType, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OTelLogs. +func (in *OTelLogs) DeepCopy() *OTelLogs { + if in == nil { + return nil + } + out := new(OTelLogs) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OTelMetrics) DeepCopyInto(out *OTelMetrics) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(OTelMetricsEnabled) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OTelMetrics. +func (in *OTelMetrics) DeepCopy() *OTelMetrics { + if in == nil { + return nil + } + out := new(OTelMetrics) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PacketCaptureAPI) DeepCopyInto(out *PacketCaptureAPI) { *out = *in diff --git a/config/enterprise_versions.yml b/config/enterprise_versions.yml index 73dc16af70..5c58077431 100644 --- a/config/enterprise_versions.yml +++ b/config/enterprise_versions.yml @@ -15,11 +15,11 @@ components: node-windows: image: node-windows version: master - fluentd: - image: fluentd + fluent-bit: + image: fluent-bit version: master - fluentd-windows: - image: fluentd-windows + fluent-bit-windows: + image: fluent-bit-windows version: master dex: image: dex diff --git a/hack/gen-versions/enterprise.go.tpl b/hack/gen-versions/enterprise.go.tpl index 1c43fd2dd9..9ba490da0a 100644 --- a/hack/gen-versions/enterprise.go.tpl +++ b/hack/gen-versions/enterprise.go.tpl @@ -91,8 +91,8 @@ var ( variant: enterpriseVariant, } {{- end }} -{{ with .Components.fluentd }} - ComponentFluentd = Component{ +{{ with index .Components "fluent-bit" }} + ComponentFluentBit = Component{ Version: "{{ .Version }}", Image: "{{ .Image }}", Registry: "{{ .Registry }}", @@ -100,8 +100,8 @@ var ( variant: enterpriseVariant, } {{- end }} -{{ with index .Components "fluentd-windows" }} - ComponentFluentdWindows = Component{ +{{ with index .Components "fluent-bit-windows" }} + ComponentFluentBitWindows = Component{ Version: "{{ .Version }}", Image: "{{ .Image }}", Registry: "{{ .Registry }}", @@ -315,8 +315,8 @@ var ( ComponentElasticTseeInstaller, ComponentElasticsearch, ComponentElasticsearchOperator, - ComponentFluentd, - ComponentFluentdWindows, + ComponentFluentBit, + ComponentFluentBitWindows, ComponentIntrusionDetectionController, ComponentKibana, ComponentManager, diff --git a/internal/controller/controllers.go b/internal/controller/controllers.go index 5f9f7d487e..c0b1a72343 100644 --- a/internal/controller/controllers.go +++ b/internal/controller/controllers.go @@ -202,6 +202,12 @@ func AddToManager(mgr ctrl.Manager, options options.ControllerOptions) error { }).SetupWithManager(mgr, options); err != nil { return fmt.Errorf("failed to create controller %s: %v", "PodIPRecovery", err) } + if err := (&OpenTelemetryCollectorReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr, options); err != nil { + return fmt.Errorf("failed to create controller %s: %v", "OpenTelemetryCollector", err) + } // +kubebuilder:scaffold:builder return nil } diff --git a/internal/controller/otelcollector_controller.go b/internal/controller/otelcollector_controller.go new file mode 100644 index 0000000000..218e757ff9 --- /dev/null +++ b/internal/controller/otelcollector_controller.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. +/* + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "github.com/go-logr/logr" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/tigera/operator/pkg/controller/options" + "github.com/tigera/operator/pkg/controller/otelcollector" +) + +type OpenTelemetryCollectorReconciler struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=operator.tigera.io,resources=logcollectors,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=operator.tigera.io,resources=logcollectors/status,verbs=get;update;patch + +func (r *OpenTelemetryCollectorReconciler) SetupWithManager(mgr ctrl.Manager, opts options.ControllerOptions) error { + return otelcollector.Add(mgr, opts) +} diff --git a/pkg/common/common.go b/pkg/common/common.go index 485046c415..139063488c 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -36,6 +36,8 @@ const ( EgressAccessControlFeature = "egress-access-control" // PolicyRecommendation feature name PolicyRecommendationFeature = "policy-recommendation" + // OTelCollector feature name + OTelCollectorFeature = "export-logs" // MultipleOwnersLabel used to indicate multiple owner references. // If the render code places this label on an object, the object mergeState machinery will merge owner // references with any that already exist on the object rather than replace the owner references. Further diff --git a/pkg/common/validation/otelcollector/validation.go b/pkg/common/validation/otelcollector/validation.go new file mode 100644 index 0000000000..dc18e5ff4a --- /dev/null +++ b/pkg/common/validation/otelcollector/validation.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package otelcollector + +import ( + corev1 "k8s.io/api/core/v1" + + "github.com/tigera/operator/pkg/common/k8svalidation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func ValidateOTelCollectorStatefulSetContainer(container corev1.Container) error { + errs := k8svalidation.ValidateResourceRequirements(&container.Resources, field.NewPath("spec", "template", "spec", "containers")) + return errs.ToAggregate() +} diff --git a/pkg/components/enterprise.go b/pkg/components/enterprise.go index 79263fc769..d2e1ca7bc7 100644 --- a/pkg/components/enterprise.go +++ b/pkg/components/enterprise.go @@ -83,17 +83,17 @@ var ( variant: enterpriseVariant, } - ComponentFluentd = Component{ + ComponentFluentBit = Component{ Version: "master", - Image: "fluentd", + Image: "fluent-bit", Registry: "", imagePath: "", variant: enterpriseVariant, } - ComponentFluentdWindows = Component{ + ComponentFluentBitWindows = Component{ Version: "master", - Image: "fluentd-windows", + Image: "fluent-bit-windows", Registry: "", imagePath: "", variant: enterpriseVariant, @@ -281,8 +281,8 @@ var ( ComponentElasticTseeInstaller, ComponentElasticsearch, ComponentElasticsearchOperator, - ComponentFluentd, - ComponentFluentdWindows, + ComponentFluentBit, + ComponentFluentBitWindows, ComponentIntrusionDetectionController, ComponentKibana, ComponentManager, diff --git a/pkg/controller/logcollector/logcollector_controller.go b/pkg/controller/logcollector/logcollector_controller.go index 60e88431fe..2b315900a1 100644 --- a/pkg/controller/logcollector/logcollector_controller.go +++ b/pkg/controller/logcollector/logcollector_controller.go @@ -44,9 +44,9 @@ import ( "github.com/tigera/operator/pkg/ctrlruntime" "github.com/tigera/operator/pkg/render" rcertificatemanagement "github.com/tigera/operator/pkg/render/certificatemanagement" - relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" rmeta "github.com/tigera/operator/pkg/render/common/meta" "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/tls/certificatemanagement" "github.com/tigera/operator/pkg/url" @@ -79,7 +79,7 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { go utils.WaitToAddLicenseKeyWatch(c, opts.K8sClientset, log, licenseAPIReady) go utils.WaitToAddTierWatch(networkpolicy.CalicoTierName, c, opts.K8sClientset, log, tierWatchReady) go utils.WaitToAddNetworkPolicyWatches(c, opts.K8sClientset, log, []types.NamespacedName{ - {Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}, + {Name: rlogcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}, }) if opts.MultiTenant { @@ -129,20 +129,17 @@ func add(mgr manager.Manager, c ctrlruntime.Controller) error { } for _, secretName := range []string{ - render.ElasticsearchEksLogForwarderUserSecret, - render.S3FluentdSecretName, render.EksLogForwarderSecret, - render.SplunkFluentdTokenSecretName, monitor.PrometheusClientTLSSecretName, - render.FluentdPrometheusTLSSecretName, render.TigeraLinseedSecret, render.VoltronLinseedPublicCert, render.EKSLogForwarderTLSSecretName, + rlogcollector.S3FluentBitSecretName, rlogcollector.EksLogForwarderSecret, + rlogcollector.SplunkFluentBitTokenSecretName, monitor.PrometheusClientTLSSecretName, + rlogcollector.FluentBitTLSSecretName, render.TigeraLinseedSecret, render.VoltronLinseedPublicCert, rlogcollector.EKSLogForwarderTLSSecretName, } { if err = utils.AddSecretsWatch(c, secretName, common.OperatorNamespace()); err != nil { return fmt.Errorf("log-collector-controller failed to watch the Secret resource(%s): %v", secretName, err) } } - for _, configMapName := range []string{render.FluentdFilterConfigMapName, relasticsearch.ClusterConfigConfigMapName} { - if err = utils.AddConfigMapWatch(c, configMapName, common.OperatorNamespace(), &handler.EnqueueRequestForObject{}); err != nil { - return fmt.Errorf("logcollector-controller failed to watch ConfigMap %s: %v", configMapName, err) - } + if err = utils.AddConfigMapWatch(c, rlogcollector.FluentBitFilterConfigMapName, common.OperatorNamespace(), &handler.EnqueueRequestForObject{}); err != nil { + return fmt.Errorf("logcollector-controller failed to watch ConfigMap %s: %v", rlogcollector.FluentBitFilterConfigMapName, err) } err = c.WatchObject(&corev1.Node{}, &handler.EnqueueRequestForObject{}) @@ -158,6 +155,7 @@ func add(mgr manager.Manager, c ctrlruntime.Controller) error { if err = c.WatchObject(&operatorv1.NonClusterHost{}, &handler.EnqueueRequestForObject{}); err != nil { return fmt.Errorf("logcollector-controller failed to watch resource: %w", err) } + return nil } @@ -369,9 +367,9 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile return reconcile.Result{}, err } - // fluentdKeyPair is the key pair fluentd presents to identify itself - httpInputServiceNames := dns.GetServiceDNSNames(render.FluentdInputService, render.LogCollectorNamespace, r.opts.ClusterDomain) - fluentdKeyPair, err := certificateManager.GetOrCreateKeyPair(r.client, render.FluentdPrometheusTLSSecretName, common.OperatorNamespace(), append([]string{render.FluentdPrometheusTLSSecretName}, httpInputServiceNames...)) + // fluentBitKeyPair is the key pair fluent-bit presents to identify itself + httpInputServiceNames := dns.GetServiceDNSNames(render.FluentBitInputService, render.LogCollectorNamespace, r.opts.ClusterDomain) + fluentBitKeyPair, err := certificateManager.GetOrCreateKeyPair(r.client, rlogcollector.FluentBitTLSSecretName, common.OperatorNamespace(), append([]string{rlogcollector.FluentBitTLSSecretName}, httpInputServiceNames...)) if err != nil { r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating TLS certificate", err, reqLogger) return reconcile.Result{}, err @@ -430,7 +428,7 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile return reconcile.Result{}, nil } - // Fluentd needs to mount system certificates in the case where Splunk, Syslog or AWS are used. + // Fluent Bit needs to mount system certificates in the case where Splunk, Syslog or AWS are used. trustedBundle, err := certificateManager.CreateTrustedBundleWithSystemRootCertificates(prometheusCertificate, linseedCertificate) if err != nil { r.status.SetDegraded(operatorv1.ResourceCreateError, "Unable to create tigera-ca-bundle configmap", err, reqLogger) @@ -457,7 +455,7 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile return reconcile.Result{}, err } - var s3Credential *render.S3Credential + var s3Credential *rlogcollector.S3Credential if instance.Spec.AdditionalStores != nil { if instance.Spec.AdditionalStores.S3 != nil { s3Credential, err = getS3Credential(r.client) @@ -472,7 +470,7 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile } } - var splunkCredential *render.SplunkCredential + var splunkCredential *rlogcollector.SplunkCredential if instance.Spec.AdditionalStores != nil { if instance.Spec.AdditionalStores.Splunk != nil { splunkCredential, err = getSplunkCredential(r.client) @@ -524,28 +522,35 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile } } - filters, err := getFluentdFilters(r.client) + filters, err := getFluentBitFilters(r.client) if err != nil { - r.status.SetDegraded(operatorv1.ResourceReadError, "Error retrieving Fluentd filters", err, reqLogger) + r.status.SetDegraded(operatorv1.ResourceReadError, "Error retrieving Fluent Bit filters", err, reqLogger) return reconcile.Result{}, err } - var eksConfig *render.EksCloudwatchLogConfig - var esClusterConfig *relasticsearch.ClusterConfig + // Surface (non-fatally) any user-provided filter content that is not valid fluent-bit YAML — + // e.g. a leftover fluentd block after an upgrade. The render skips such entries so + // logs keep flowing; raise a warning on the Available condition per key and clear it once the + // content parses, rather than degrading the whole LogCollector for an optional, malformed filter. + invalidFilters := map[string]bool{} + for _, key := range filters.InvalidKeys() { + invalidFilters[key] = true + } + for _, key := range []string{rlogcollector.FluentBitFilterFlowName, rlogcollector.FluentBitFilterDNSName} { + warningKey := "fluent-bit-filter-" + key + if invalidFilters[key] { + r.status.SetWarning(warningKey, fmt.Sprintf("Ignoring the %q entry in the %s ConfigMap: it is not valid fluent-bit YAML. Filters previously written in fluentd syntax must be rewritten as a fluent-bit YAML filter list.", key, rlogcollector.FluentBitFilterConfigMapName)) + } else { + r.status.ClearWarning(warningKey) + } + } + + var eksConfig *rlogcollector.EksCloudwatchLogConfig var eksLogForwarderKeyPair certificatemanagement.KeyPairInterface if installationSpec.KubernetesProvider.IsEKS() { log.Info("Managed kubernetes EKS found, getting necessary credentials and config") if instance.Spec.AdditionalSources != nil { if instance.Spec.AdditionalSources.EksCloudwatchLog != nil { - esClusterConfig, err = utils.GetElasticsearchClusterConfig(ctx, r.client) - if err != nil { - if errors.IsNotFound(err) { - r.status.SetDegraded(operatorv1.ResourceNotReady, "Elasticsearch cluster configuration is not available, waiting for it to become available", err, reqLogger) - return reconcile.Result{}, nil - } - r.status.SetDegraded(operatorv1.ResourceReadError, "Failed to get the elasticsearch cluster configuration", err, reqLogger) - return reconcile.Result{}, err - } eksConfig, err = getEksCloudwatchLogConfig(r.client, instance.Spec.AdditionalSources.EksCloudwatchLog.FetchInterval, instance.Spec.AdditionalSources.EksCloudwatchLog.Region, @@ -557,7 +562,7 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile } // eksLogForwarderKeyPair is the key pair eks-log-forwarder presents to identify itself - eksLogForwarderKeyPair, err = certificateManager.GetOrCreateKeyPair(r.client, render.EKSLogForwarderTLSSecretName, common.OperatorNamespace(), []string{render.EKSLogForwarderTLSSecretName}) + eksLogForwarderKeyPair, err = certificateManager.GetOrCreateKeyPair(r.client, rlogcollector.EKSLogForwarderTLSSecretName, common.OperatorNamespace(), []string{rlogcollector.EKSLogForwarderTLSSecretName}) if err != nil { r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating eks log forwarder TLS certificate", err, reqLogger) return reconcile.Result{}, err @@ -588,9 +593,8 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile // Create a component handler to manage the rendered component. handler := utils.NewComponentHandler(log, r.client, r.scheme, instance) - fluentdCfg := &render.FluentdConfiguration{ + fluentBitCfg := &rlogcollector.FluentBitConfiguration{ LogCollector: instance, - ESClusterConfig: esClusterConfig, S3Credential: s3Credential, SplkCredential: splunkCredential, Filters: filters, @@ -599,7 +603,7 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile Installation: installationSpec, ClusterDomain: r.opts.ClusterDomain, OSType: rmeta.OSTypeLinux, - FluentdKeyPair: fluentdKeyPair, + FluentBitKeyPair: fluentBitKeyPair, TrustedBundle: trustedBundle, ManagedCluster: managedCluster, UseSyslogCertificate: useSyslogCertificate, @@ -609,15 +613,16 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile PacketCapture: packetcaptureapi, NonClusterHost: nonclusterhost, LicenseExpired: licenseExpired, + OTelCollectorEnabled: instance.Spec.OTelCollector != nil, } - // Render the fluentd component for Linux - comp := render.Fluentd(fluentdCfg) + // Render the fluent-bit component for Linux + comp := rlogcollector.FluentBit(fluentBitCfg) certificateComponent := rcertificatemanagement.Config{ Namespace: render.LogCollectorNamespace, - ServiceAccounts: []string{render.FluentdNodeName}, + ServiceAccounts: []string{render.FluentBitNodeName}, KeyPairOptions: []rcertificatemanagement.KeyPairOption{ - rcertificatemanagement.NewKeyPairOption(fluentdKeyPair, true, true), + rcertificatemanagement.NewKeyPairOption(fluentBitKeyPair, true, true), }, TrustedBundle: trustedBundle, } @@ -632,12 +637,16 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile } setUp := render.NewSetup(&render.SetUpConfiguration{ - OpenShift: r.opts.DetectedProvider.IsOpenShift(), - Installation: installationSpec, - PullSecrets: pullSecrets, - Namespace: render.LogCollectorNamespace, - PSS: render.PSSPrivileged, - CreateNamespace: true, + OpenShift: r.opts.DetectedProvider.IsOpenShift(), + Installation: installationSpec, + PullSecrets: pullSecrets, + Namespace: render.LogCollectorNamespace, + PSS: render.PSSPrivileged, + // calico-system is created and owned by the core Installation + // controller. Owning it from the LogCollector CR would let a routine + // `kubectl delete logcollector` garbage-collect the entire namespace — + // calico-node included. + CreateNamespace: false, }) components := []render.Component{ setUp, @@ -657,16 +666,15 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile } } - // Render a fluentd component for Windows if the cluster has Windows nodes. + // Render a fluent-bit component for Windows if the cluster has Windows nodes. hasWindowsNodes, err := common.HasWindowsNodes(r.client) if err != nil { return reconcile.Result{}, err } if hasWindowsNodes { - fluentdCfg = &render.FluentdConfiguration{ + fluentBitCfg = &rlogcollector.FluentBitConfiguration{ LogCollector: instance, - ESClusterConfig: esClusterConfig, S3Credential: s3Credential, SplkCredential: splunkCredential, Filters: filters, @@ -678,11 +686,25 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile TrustedBundle: trustedBundle, ManagedCluster: managedCluster, UseSyslogCertificate: useSyslogCertificate, - FluentdKeyPair: fluentdKeyPair, + FluentBitKeyPair: fluentBitKeyPair, EKSLogForwarderKeyPair: eksLogForwarderKeyPair, LicenseExpired: licenseExpired, + // Must match the Linux configuration: both OS variants render the + // shared allow-calico-fluent-bit NetworkPolicy, whose non-cluster-host + // ingress rule (port 9880) is gated on NonClusterHost and whose + // manager source selector is tenant-aware. If only the Linux config + // sets these, the two renders disagree and the operator flaps the + // policy on every reconcile (dropping voltron's access to the http + // input). The non-cluster-host input/service themselves are still + // Linux-only (gated on OSType), so NonClusterHost only affects the + // policy; Tenant/ExternalElastic additionally select the Linseed + // endpoint and x-tenant-id header in the Windows rendered config. + NonClusterHost: nonclusterhost, + Tenant: tenant, + ExternalElastic: r.opts.ElasticExternal, + OTelCollectorEnabled: instance.Spec.OTelCollector != nil, } - comp = render.Fluentd(fluentdCfg) + comp = rlogcollector.FluentBit(fluentBitCfg) if err = imageset.ApplyImageSet(ctx, r.client, variant, comp); err != nil { r.status.SetDegraded(operatorv1.ResourceUpdateError, "Error with images from ImageSet", err, reqLogger) @@ -706,8 +728,8 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile // Check BYO certificate expiry warnings. certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ - render.FluentdPrometheusTLSSecretName: fluentdKeyPair, - render.EKSLogForwarderTLSSecretName: eksLogForwarderKeyPair, + rlogcollector.FluentBitTLSSecretName: fluentBitKeyPair, + rlogcollector.EKSLogForwarderTLSSecretName: eksLogForwarderKeyPair, }, r.status) // Clear the degraded bit if we've reached this far. @@ -727,81 +749,81 @@ func (r *ReconcileLogCollector) Reconcile(ctx context.Context, request reconcile return reconcile.Result{RequeueAfter: graceRequeueAfter}, nil } -func getS3Credential(client client.Client) (*render.S3Credential, error) { +func getS3Credential(client client.Client) (*rlogcollector.S3Credential, error) { secret := &corev1.Secret{} secretNamespacedName := types.NamespacedName{ - Name: render.S3FluentdSecretName, + Name: rlogcollector.S3FluentBitSecretName, Namespace: common.OperatorNamespace(), } if err := client.Get(context.Background(), secretNamespacedName, secret); err != nil { if errors.IsNotFound(err) { return nil, nil } - return nil, fmt.Errorf("failed to read secret %q: %s", render.S3FluentdSecretName, err) + return nil, fmt.Errorf("failed to read secret %q: %s", rlogcollector.S3FluentBitSecretName, err) } var ok bool var kId []byte - if kId, ok = secret.Data[render.S3KeyIdName]; !ok || len(kId) == 0 { + if kId, ok = secret.Data[rlogcollector.S3KeyIdName]; !ok || len(kId) == 0 { return nil, fmt.Errorf("expected secret %q to have a field named %q", - render.S3FluentdSecretName, render.S3KeyIdName) + rlogcollector.S3FluentBitSecretName, rlogcollector.S3KeyIdName) } var kSecret []byte - if kSecret, ok = secret.Data[render.S3KeySecretName]; !ok || len(kSecret) == 0 { + if kSecret, ok = secret.Data[rlogcollector.S3KeySecretName]; !ok || len(kSecret) == 0 { return nil, fmt.Errorf("expected secret %q to have a field named %q", - render.S3FluentdSecretName, render.S3KeySecretName) + rlogcollector.S3FluentBitSecretName, rlogcollector.S3KeySecretName) } - return &render.S3Credential{ + return &rlogcollector.S3Credential{ KeyId: kId, KeySecret: kSecret, }, nil } -func getSplunkCredential(client client.Client) (*render.SplunkCredential, error) { +func getSplunkCredential(client client.Client) (*rlogcollector.SplunkCredential, error) { tokenSecret := &corev1.Secret{} tokenNamespacedName := types.NamespacedName{ - Name: render.SplunkFluentdTokenSecretName, + Name: rlogcollector.SplunkFluentBitTokenSecretName, Namespace: common.OperatorNamespace(), } if err := client.Get(context.Background(), tokenNamespacedName, tokenSecret); err != nil { if errors.IsNotFound(err) { return nil, nil } - return nil, fmt.Errorf("failed to read secret %q: %s", render.SplunkFluentdTokenSecretName, err) + return nil, fmt.Errorf("failed to read secret %q: %s", rlogcollector.SplunkFluentBitTokenSecretName, err) } - token, ok := tokenSecret.Data[render.SplunkFluentdSecretTokenKey] + token, ok := tokenSecret.Data[rlogcollector.SplunkFluentBitSecretTokenKey] if !ok || len(token) == 0 { return nil, fmt.Errorf("expected secret %q to have a field named %q", - render.SplunkFluentdTokenSecretName, render.SplunkFluentdSecretTokenKey) + rlogcollector.SplunkFluentBitTokenSecretName, rlogcollector.SplunkFluentBitSecretTokenKey) } - return &render.SplunkCredential{ + return &rlogcollector.SplunkCredential{ Token: token, }, nil } -func getFluentdFilters(client client.Client) (*render.FluentdFilters, error) { +func getFluentBitFilters(client client.Client) (*rlogcollector.FluentBitFilters, error) { cm := &corev1.ConfigMap{} cmNamespacedName := types.NamespacedName{ - Name: render.FluentdFilterConfigMapName, + Name: rlogcollector.FluentBitFilterConfigMapName, Namespace: common.OperatorNamespace(), } if err := client.Get(context.Background(), cmNamespacedName, cm); err != nil { if errors.IsNotFound(err) { return nil, nil } - return nil, fmt.Errorf("failed to read ConfigMap %q: %s", render.FluentdFilterConfigMapName, err) + return nil, fmt.Errorf("failed to read ConfigMap %q: %s", rlogcollector.FluentBitFilterConfigMapName, err) } - return &render.FluentdFilters{ - Flow: cm.Data[render.FluentdFilterFlowName], - DNS: cm.Data[render.FluentdFilterDNSName], + return &rlogcollector.FluentBitFilters{ + Flow: cm.Data[rlogcollector.FluentBitFilterFlowName], + DNS: cm.Data[rlogcollector.FluentBitFilterDNSName], }, nil } -func getEksCloudwatchLogConfig(client client.Client, interval int32, region, group, prefix string) (*render.EksCloudwatchLogConfig, error) { +func getEksCloudwatchLogConfig(client client.Client, interval int32, region, group, prefix string) (*rlogcollector.EksCloudwatchLogConfig, error) { if region == "" { return nil, fmt.Errorf("missing AWS region info") } @@ -820,24 +842,24 @@ func getEksCloudwatchLogConfig(client client.Client, interval int32, region, gro secret := &corev1.Secret{} secretNamespacedName := types.NamespacedName{ - Name: render.EksLogForwarderSecret, + Name: rlogcollector.EksLogForwarderSecret, Namespace: common.OperatorNamespace(), } if err := client.Get(context.Background(), secretNamespacedName, secret); err != nil { if errors.IsNotFound(err) { return nil, nil } - return nil, fmt.Errorf("failed to read Secret %q: %s", render.EksLogForwarderSecret, err) + return nil, fmt.Errorf("failed to read Secret %q: %s", rlogcollector.EksLogForwarderSecret, err) } - if len(secret.Data[render.EksLogForwarderAwsId]) == 0 || - len(secret.Data[render.EksLogForwarderAwsKey]) == 0 { + if len(secret.Data[rlogcollector.EksLogForwarderAwsId]) == 0 || + len(secret.Data[rlogcollector.EksLogForwarderAwsKey]) == 0 { return nil, fmt.Errorf("incomplete Cloudwatch credentials") } - return &render.EksCloudwatchLogConfig{ - AwsId: secret.Data[render.EksLogForwarderAwsId], - AwsKey: secret.Data[render.EksLogForwarderAwsKey], + return &rlogcollector.EksCloudwatchLogConfig{ + AwsId: secret.Data[rlogcollector.EksLogForwarderAwsId], + AwsKey: secret.Data[rlogcollector.EksLogForwarderAwsKey], AwsRegion: region, GroupName: group, StreamPrefix: prefix, @@ -848,21 +870,21 @@ func getEksCloudwatchLogConfig(client client.Client, interval int32, region, gro func getSysLogCertificate(client client.Client) (certificatemanagement.CertificateInterface, error) { cm := &corev1.ConfigMap{} cmNamespacedName := types.NamespacedName{ - Name: render.SyslogCAConfigMapName, + Name: rlogcollector.SyslogCAConfigMapName, Namespace: common.OperatorNamespace(), } if err := client.Get(context.Background(), cmNamespacedName, cm); err != nil { if errors.IsNotFound(err) { - log.Info(fmt.Sprintf("ConfigMap %q is not found, assuming syslog's certificate is signed by publicly trusted CA", render.SyslogCAConfigMapName)) + log.Info(fmt.Sprintf("ConfigMap %q is not found, assuming syslog's certificate is signed by publicly trusted CA", rlogcollector.SyslogCAConfigMapName)) return nil, nil } - return nil, fmt.Errorf("failed to read ConfigMap %q: %s", render.SyslogCAConfigMapName, err) + return nil, fmt.Errorf("failed to read ConfigMap %q: %s", rlogcollector.SyslogCAConfigMapName, err) } if len(cm.Data[corev1.TLSCertKey]) == 0 { - log.Info(fmt.Sprintf("ConfigMap %q does not have a field named %q, assuming syslog's certificate is signed by publicly trusted CA", render.SyslogCAConfigMapName, corev1.TLSCertKey)) + log.Info(fmt.Sprintf("ConfigMap %q does not have a field named %q, assuming syslog's certificate is signed by publicly trusted CA", rlogcollector.SyslogCAConfigMapName, corev1.TLSCertKey)) return nil, nil } - syslogCert := certificatemanagement.NewCertificate(render.SyslogCAConfigMapName, common.OperatorNamespace(), []byte(cm.Data[corev1.TLSCertKey]), nil) + syslogCert := certificatemanagement.NewCertificate(rlogcollector.SyslogCAConfigMapName, common.OperatorNamespace(), []byte(cm.Data[corev1.TLSCertKey]), nil) return syslogCert, nil } diff --git a/pkg/controller/logcollector/logcollector_controller_test.go b/pkg/controller/logcollector/logcollector_controller_test.go index f97af34c4f..c0666173b2 100644 --- a/pkg/controller/logcollector/logcollector_controller_test.go +++ b/pkg/controller/logcollector/logcollector_controller_test.go @@ -28,6 +28,7 @@ import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -45,6 +46,7 @@ import ( "github.com/tigera/operator/pkg/controller/utils" ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" "github.com/tigera/operator/pkg/render" + rlogcollector "github.com/tigera/operator/pkg/render/logcollector" "github.com/tigera/operator/pkg/render/monitor" "github.com/tigera/operator/test" ) @@ -77,6 +79,7 @@ var _ = Describe("LogCollector controller tests", func() { mockStatus.On("AddCronJobs", mock.Anything) mockStatus.On("RemoveCertificateSigningRequests", mock.Anything).Return() mockStatus.On("RemoveDaemonsets", mock.Anything).Return() + mockStatus.On("RemoveDeployments", mock.Anything).Return() mockStatus.On("AddCertificateSigningRequests", mock.Anything).Return() mockStatus.On("IsAvailable").Return(true) mockStatus.On("OnCRFound").Return() @@ -141,13 +144,6 @@ var _ = Describe("LogCollector controller tests", func() { Expect(err).NotTo(HaveOccurred()) Expect(c.Create(ctx, certificateManager.KeyPair().Secret(common.OperatorNamespace()))) // Persist the root-ca in the operator namespace. - Expect(c.Create(ctx, &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: render.ElasticsearchEksLogForwarderUserSecret, - Namespace: "tigera-operator", - }, - })).NotTo(HaveOccurred()) - prometheusTLS, err := certificateManager.GetOrCreateKeyPair(c, monitor.PrometheusClientTLSSecretName, common.OperatorNamespace(), []string{monitor.PrometheusClientTLSSecretName}) Expect(err).NotTo(HaveOccurred()) Expect(c.Create(ctx, prometheusTLS.Secret(common.OperatorNamespace()))).NotTo(HaveOccurred()) @@ -174,7 +170,7 @@ var _ = Describe("LogCollector controller tests", func() { ds := appsv1.DaemonSet{ TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: "fluentd-node", + Name: "calico-fluent-bit", Namespace: render.LogCollectorNamespace, }, } @@ -185,16 +181,16 @@ var _ = Describe("LogCollector controller tests", func() { Expect(node.Image).To(Equal( fmt.Sprintf("some.registry.org/%s%s:%s", components.TigeraImagePath, - components.ComponentFluentd.Image, - components.ComponentFluentd.Version))) + components.ComponentFluentBit.Image, + components.ComponentFluentBit.Version))) }) It("should use images from imageset", func() { Expect(c.Create(ctx, &operatorv1.ImageSet{ ObjectMeta: metav1.ObjectMeta{Name: "enterprise-" + components.EnterpriseRelease}, Spec: operatorv1.ImageSetSpec{ Images: []operatorv1.Image{ - {Image: "tigera/fluentd", Digest: "sha256:fluentdhash"}, - {Image: "tigera/fluentd-windows", Digest: "sha256:fluentdwindowshash"}, + {Image: "tigera/fluent-bit", Digest: "sha256:fluentbithash"}, + {Image: "tigera/fluent-bit-windows", Digest: "sha256:fluentbitwindowshash"}, {Image: "tigera/calico", Digest: "sha256:deadbeef0123456789"}, }, }, @@ -215,7 +211,7 @@ var _ = Describe("LogCollector controller tests", func() { ds := appsv1.DaemonSet{ TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: "fluentd-node", + Name: "calico-fluent-bit", Namespace: render.LogCollectorNamespace, }, } @@ -226,10 +222,10 @@ var _ = Describe("LogCollector controller tests", func() { Expect(node.Image).To(Equal( fmt.Sprintf("some.registry.org/%s%s@%s", components.TigeraImagePath, - components.ComponentFluentd.Image, - "sha256:fluentdhash"))) + components.ComponentFluentBit.Image, + "sha256:fluentbithash"))) - ds.Name = "fluentd-node-windows" + ds.Name = "calico-fluent-bit-windows" Expect(test.GetResource(c, &ds)).To(BeNil()) Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) node = ds.Spec.Template.Spec.Containers[0] @@ -237,14 +233,45 @@ var _ = Describe("LogCollector controller tests", func() { Expect(node.Image).To(Equal( fmt.Sprintf("some.registry.org/%s%s@%s", components.TigeraImagePath, - components.ComponentFluentdWindows.Image, - "sha256:fluentdwindowshash"))) + components.ComponentFluentBitWindows.Image, + "sha256:fluentbitwindowshash"))) + }) + + It("should keep the non-cluster-host ingress rule on the fluent-bit policy when Windows nodes are present", func() { + // A Windows node makes the operator also render the Windows fluent-bit + // component, which shares the allow-calico-fluent-bit NetworkPolicy with + // the Linux component. The non-cluster-host ingress rule (port 9880, + // voltron -> http input) is gated on NonClusterHost, so if the Windows + // configuration does not carry NonClusterHost it overwrites the policy + // without that rule, making the rule flap on every reconcile. + Expect(c.Create(ctx, &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "windows-node", + Labels: map[string]string{"kubernetes.io/os": "windows"}, + }, + })).ToNot(HaveOccurred()) + Expect(c.Create(ctx, &operatorv1.NonClusterHost{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}, + Spec: operatorv1.NonClusterHostSpec{Endpoint: "https://1.2.3.4:5678"}, + })).ToNot(HaveOccurred()) + + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + policy := v3.NetworkPolicy{ + TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}, + ObjectMeta: metav1.ObjectMeta{Name: "calico-system.allow-calico-fluent-bit", Namespace: render.LogCollectorNamespace}, + } + Expect(test.GetResource(c, &policy)).To(BeNil()) + // Metrics rule (2020) + non-cluster-host rule (9880). Without the fix the + // Windows render (applied last) drops the 9880 rule, leaving only one. + Expect(policy.Spec.Ingress).To(HaveLen(2)) }) Context("Forward to S3", func() { s3Vars := []corev1.EnvVar{ { - Name: "AWS_KEY_ID", + Name: "AWS_ACCESS_KEY_ID", Value: "", ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ @@ -256,7 +283,7 @@ var _ = Describe("LogCollector controller tests", func() { }, }, { - Name: "AWS_SECRET_KEY", + Name: "AWS_SECRET_ACCESS_KEY", Value: "", ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ @@ -267,11 +294,6 @@ var _ = Describe("LogCollector controller tests", func() { }, }, }, - {Name: "S3_STORAGE", Value: "true"}, - {Name: "S3_BUCKET_NAME", Value: "s3Bucket"}, - {Name: "AWS_REGION", Value: "s3Region"}, - {Name: "S3_BUCKET_PATH", Value: "s3Path"}, - {Name: "S3_FLUSH_INTERVAL", Value: "5s"}, } BeforeEach(func() { @@ -314,7 +336,7 @@ var _ = Describe("LogCollector controller tests", func() { ds := appsv1.DaemonSet{ TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: "fluentd-node", + Name: "calico-fluent-bit", Namespace: render.LogCollectorNamespace, }, } @@ -323,6 +345,18 @@ var _ = Describe("LogCollector controller tests", func() { node := ds.Spec.Template.Spec.Containers[0] Expect(node).ToNot(BeNil()) Expect(node.Env).To(ContainElements(s3Vars)) + + // The bucket settings live in the rendered config rather than + // env vars. + cm := corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: rlogcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, + } + Expect(test.GetResource(c, &cm)).To(BeNil()) + conf := cm.Data["fluent-bit.yaml"] + Expect(conf).To(ContainSubstring(`"name": "s3"`)) + Expect(conf).To(ContainSubstring(`"bucket": "s3Bucket"`)) + Expect(conf).To(ContainSubstring(`"region": "s3Region"`)) }) Context("Disable feature via license", func() { @@ -341,7 +375,7 @@ var _ = Describe("LogCollector controller tests", func() { ds := appsv1.DaemonSet{ TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: "fluentd-node", + Name: "calico-fluent-bit", Namespace: render.LogCollectorNamespace, }, } @@ -370,13 +404,6 @@ var _ = Describe("LogCollector controller tests", func() { }, }, }, - {Name: "SPLUNK_FLOW_LOG", Value: "true"}, - {Name: "SPLUNK_AUDIT_LOG", Value: "true"}, - {Name: "SPLUNK_DNS_LOG", Value: "true"}, - {Name: "SPLUNK_HEC_HOST", Value: "localhost"}, - {Name: "SPLUNK_HEC_PORT", Value: "1234"}, - {Name: "SPLUNK_PROTOCOL", Value: "https"}, - {Name: "SPLUNK_FLUSH_INTERVAL", Value: "5s"}, } BeforeEach(func() { @@ -416,7 +443,7 @@ var _ = Describe("LogCollector controller tests", func() { ds := appsv1.DaemonSet{ TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: "fluentd-node", + Name: "calico-fluent-bit", Namespace: render.LogCollectorNamespace, }, } @@ -425,6 +452,18 @@ var _ = Describe("LogCollector controller tests", func() { node := ds.Spec.Template.Spec.Containers[0] Expect(node).ToNot(BeNil()) Expect(node.Env).To(ContainElements(splunkVars)) + + // The endpoint settings live in the rendered config rather than + // env vars. + cm := corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: rlogcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, + } + Expect(test.GetResource(c, &cm)).To(BeNil()) + conf := cm.Data["fluent-bit.yaml"] + Expect(conf).To(ContainSubstring(`"name": "splunk"`)) + Expect(conf).To(ContainSubstring(`"host": "localhost"`)) + Expect(conf).To(ContainSubstring(`"splunk_token": "${SPLUNK_HEC_TOKEN}"`)) }) Context("Disable feature via license", func() { @@ -444,7 +483,7 @@ var _ = Describe("LogCollector controller tests", func() { ds := appsv1.DaemonSet{ TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: "fluentd-node", + Name: "calico-fluent-bit", Namespace: render.LogCollectorNamespace, }, } @@ -461,30 +500,6 @@ var _ = Describe("LogCollector controller tests", func() { }) Context("Forward to Syslog", func() { - syslogVars := []corev1.EnvVar{ - {Name: "SYSLOG_HOST", Value: "localhost"}, - {Name: "SYSLOG_PORT", Value: "1234"}, - {Name: "SYSLOG_PROTOCOL", Value: "https"}, - {Name: "SYSLOG_FLUSH_INTERVAL", Value: "5s"}, - { - Name: "SYSLOG_HOSTNAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{ - FieldPath: "spec.nodeName", - }, - }, - }, - { - Name: "SYSLOG_PACKET_SIZE", - Value: "0", - }, - {Name: "SYSLOG_AUDIT_EE_LOG", Value: "true"}, - {Name: "SYSLOG_AUDIT_KUBE_LOG", Value: "true"}, - {Name: "SYSLOG_DNS_LOG", Value: "true"}, - {Name: "SYSLOG_FLOW_LOG", Value: "true"}, - {Name: "SYSLOG_IDS_EVENT_LOG", Value: "true"}, - } - BeforeEach(func() { By("Specify splunk log storage") Expect(c.Delete(ctx, &operatorv1.LogCollector{ @@ -495,7 +510,7 @@ var _ = Describe("LogCollector controller tests", func() { Spec: operatorv1.LogCollectorSpec{ AdditionalStores: &operatorv1.AdditionalLogStoreSpec{ Syslog: &operatorv1.SyslogStoreSpec{ - Endpoint: "https://localhost:1234", + Endpoint: "tcp://localhost:1234", PacketSize: new(int32), LogTypes: []operatorv1.SyslogLogType{ operatorv1.SyslogLogAudit, @@ -520,15 +535,25 @@ var _ = Describe("LogCollector controller tests", func() { ds := appsv1.DaemonSet{ TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: "fluentd-node", + Name: "calico-fluent-bit", Namespace: render.LogCollectorNamespace, }, } Expect(test.GetResource(c, &ds)).To(BeNil()) Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - node := ds.Spec.Template.Spec.Containers[0] - Expect(node).ToNot(BeNil()) - Expect(node.Env).To(ContainElements(syslogVars)) + + // Syslog forwarding is fully config-driven (no env contract). + cm := corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: rlogcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, + } + Expect(test.GetResource(c, &cm)).To(BeNil()) + conf := cm.Data["fluent-bit.yaml"] + Expect(conf).To(ContainSubstring(`"name": "syslog"`)) + Expect(conf).To(ContainSubstring(`"host": "localhost"`)) + Expect(conf).To(ContainSubstring(`"mode": "tcp"`)) + // The whole record ships as one JSON MSG via the lua packer. + Expect(conf).To(ContainSubstring(`"call": "syslog_pack"`)) }) Context("Disable feature via license", func() { @@ -548,7 +573,7 @@ var _ = Describe("LogCollector controller tests", func() { ds := appsv1.DaemonSet{ TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: "fluentd-node", + Name: "calico-fluent-bit", Namespace: render.LogCollectorNamespace, }, } @@ -769,6 +794,49 @@ var _ = Describe("LogCollector controller tests", func() { }) }) + Context("user filters validation", func() { + It("should warn (not degrade) on unparseable filter content and clear the warning once fixed", func() { + filtersCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: rlogcollector.FluentBitFilterConfigMapName, + Namespace: common.OperatorNamespace(), + }, + Data: map[string]string{ + // A leftover fluentd-syntax filter: not a fluent-bit YAML list. + "flow": "\n @type grep\n", + // A valid fluent-bit YAML filter list. + "dns": "- name: grep\n exclude: qname noisy.example.com", + }, + } + Expect(c.Create(ctx, filtersCM)).NotTo(HaveOccurred()) + + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + mockStatus.AssertCalled(GinkgoT(), "SetWarning", "fluent-bit-filter-flow", mock.Anything) + mockStatus.AssertNotCalled(GinkgoT(), "SetWarning", "fluent-bit-filter-dns", mock.Anything) + mockStatus.AssertCalled(GinkgoT(), "ClearWarning", "fluent-bit-filter-dns") + + // Rendering continued: the valid dns filter is inlined into the + // config while the invalid flow filter is skipped. + cm := corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: rlogcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, + } + Expect(test.GetResource(c, &cm)).To(BeNil()) + Expect(cm.Data["fluent-bit.yaml"]).To(ContainSubstring("noisy.example.com")) + Expect(cm.Data["fluent-bit.yaml"]).NotTo(ContainSubstring(" 0 + metricsEnabled := logCollector.Spec.OTelCollector.Metrics != nil && + logCollector.Spec.OTelCollector.Metrics.Enabled != nil && + *logCollector.Spec.OTelCollector.Metrics.Enabled == operatorv1.OTelMetricsEnable + + if hasLogs || metricsEnabled { + certMgr, err := certificatemanager.Create(r.cli, installationSpec, r.opts.ClusterDomain, common.OperatorNamespace()) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceCreateError, "Unable to create the Tigera CA", err, reqLogger) + return reconcile.Result{}, err + } + + trustedBundle = certMgr.CreateTrustedBundle() + + if hasLogs { + dnsNames := dns.GetServiceDNSNames(otelcollector.OTelCollectorServiceName, otelcollector.OTelCollectorNamespace, r.opts.ClusterDomain) + receiverTLSSecret, err = certMgr.GetOrCreateKeyPair(r.cli, otelcollector.OTelCollectorServerTLSSecretName, common.OperatorNamespace(), dnsNames) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating OTel receiver TLS certificate", err, reqLogger) + return reconcile.Result{}, err + } + } + + if metricsEnabled { + clientTLSSecret, err = certMgr.GetOrCreateKeyPair(r.cli, monitor.PrometheusClientTLSSecretName, common.OperatorNamespace(), []string{monitor.PrometheusClientTLSSecretName}) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating TLS certificate", err, reqLogger) + return reconcile.Result{}, err + } + } + + certMgr.AddToStatusManager(r.status, otelcollector.OTelCollectorNamespace) + } + + cfg := &otelcollector.Configuration{ + PullSecrets: pullSecrets, + OpenShift: r.opts.DetectedProvider.IsOpenShift(), + Installation: installationSpec, + OTelCollector: logCollector.Spec.OTelCollector, + ReceiverTLSSecret: receiverTLSSecret, + ClientTLSSecret: clientTLSSecret, + TrustedCertBundle: trustedBundle, + } + + var keyPairOptions []rcertificatemanagement.KeyPairOption + if receiverTLSSecret != nil { + keyPairOptions = append(keyPairOptions, rcertificatemanagement.NewKeyPairOption(receiverTLSSecret, true, true)) + } + if clientTLSSecret != nil { + keyPairOptions = append(keyPairOptions, rcertificatemanagement.NewKeyPairOption(clientTLSSecret, true, true)) + } + + components := []render.Component{ + otelcollector.OTelCollector(cfg), + } + if len(keyPairOptions) > 0 || trustedBundle != nil { + components = append(components, rcertificatemanagement.CertificateManagement(&rcertificatemanagement.Config{ + Namespace: otelcollector.OTelCollectorNamespace, + ServiceAccounts: []string{otelcollector.OTelCollectorServiceAccountName}, + KeyPairOptions: keyPairOptions, + TrustedBundle: trustedBundle, + })) + } + + ch := utils.NewComponentHandler(log, r.cli, r.scheme, logCollector) + if err = imageset.ApplyImageSet(ctx, r.cli, variant, components...); err != nil { + r.status.SetDegraded(operatorv1.ResourceUpdateError, "Error with images from ImageSet", err, reqLogger) + return reconcile.Result{}, err + } + + for _, component := range components { + if err := ch.CreateOrUpdateOrDelete(ctx, component, r.status); err != nil { + r.status.SetDegraded(operatorv1.ResourceUpdateError, "Error creating / updating resource", err, reqLogger) + return reconcile.Result{}, err + } + } + + r.status.ReadyToMonitor() + r.status.ClearDegraded() + + return reconcile.Result{}, nil +} diff --git a/pkg/controller/otelcollector/otelcollector_controller_test.go b/pkg/controller/otelcollector/otelcollector_controller_test.go new file mode 100644 index 0000000000..51084f1570 --- /dev/null +++ b/pkg/controller/otelcollector/otelcollector_controller_test.go @@ -0,0 +1,229 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package otelcollector + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + + appsv1 "k8s.io/api/apps/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/apis" + "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/controller/certificatemanager" + "github.com/tigera/operator/pkg/controller/options" + "github.com/tigera/operator/pkg/controller/status" + ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" + "github.com/tigera/operator/pkg/dns" + "github.com/tigera/operator/test" +) + +var _ = Describe("OTelCollector controller tests", func() { + var ( + cli client.Client + scheme *runtime.Scheme + ctx context.Context + mockStatus *status.MockStatus + r *Reconciler + install *operatorv1.Installation + ) + + BeforeEach(func() { + scheme = runtime.NewScheme() + Expect(apis.AddToScheme(scheme, false)).ShouldNot(HaveOccurred()) + Expect(appsv1.SchemeBuilder.AddToScheme(scheme)).ShouldNot(HaveOccurred()) + Expect(rbacv1.SchemeBuilder.AddToScheme(scheme)).ShouldNot(HaveOccurred()) + + ctx = context.Background() + cli = ctrlrfake.DefaultFakeClientBuilder(scheme).Build() + + replicas := int32(2) + install = &operatorv1.Installation{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Generation: 2}, + Status: operatorv1.InstallationStatus{ + Variant: operatorv1.CalicoEnterprise, + Computed: &operatorv1.InstallationSpec{}, + }, + Spec: operatorv1.InstallationSpec{ + ControlPlaneReplicas: &replicas, + Variant: operatorv1.CalicoEnterprise, + Registry: "some.registry.org/", + }, + } + Expect(cli.Create(ctx, install)).ToNot(HaveOccurred()) + + Expect(cli.Create(ctx, &v3.LicenseKey{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Status: v3.LicenseKeyStatus{ + Features: []string{common.OTelCollectorFeature}, + }, + })).ToNot(HaveOccurred()) + + // Create a CA secret so the certificate manager can issue keypairs. + cm, err := certificatemanager.Create(cli, &install.Spec, dns.DefaultClusterDomain, common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).ShouldNot(HaveOccurred()) + Expect(cli.Create(ctx, cm.KeyPair().Secret(common.OperatorNamespace()))).ShouldNot(HaveOccurred()) + + mockStatus = &status.MockStatus{} + mockStatus.On("AddStatefulSets", mock.Anything).Return() + mockStatus.On("AddCertificateSigningRequests", mock.Anything).Return() + mockStatus.On("RemoveCertificateSigningRequests", mock.Anything).Return() + mockStatus.On("IsAvailable").Return(true) + mockStatus.On("OnCRFound").Return() + mockStatus.On("OnCRNotFound").Return() + mockStatus.On("ClearDegraded") + mockStatus.On("ReadyToMonitor") + mockStatus.On("SetMetaData", mock.Anything).Return() + mockStatus.On("SetDegraded", mock.Anything, mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return().Maybe() + mockStatus.On("ClearWarning", mock.AnythingOfType("string")).Return().Maybe() + + r = &Reconciler{ + cli: cli, + scheme: scheme, + status: mockStatus, + opts: options.ControllerOptions{ + DetectedProvider: operatorv1.ProviderNone, + EnterpriseCRDExists: true, + ClusterDomain: dns.DefaultClusterDomain, + }, + } + }) + + Context("CR not found", func() { + It("should call OnCRNotFound and return without error", func() { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + mockStatus.AssertCalled(GinkgoT(), "OnCRNotFound") + }) + }) + + Context("LogCollector without OTelCollector", func() { + BeforeEach(func() { + Expect(cli.Create(ctx, &operatorv1.LogCollector{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}, + Spec: operatorv1.LogCollectorSpec{}, + })).ToNot(HaveOccurred()) + }) + + It("should call OnCRNotFound when OTelCollector is nil", func() { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + mockStatus.AssertCalled(GinkgoT(), "OnCRNotFound") + }) + }) + + Context("happy path", func() { + BeforeEach(func() { + Expect(cli.Create(ctx, &operatorv1.LogCollector{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}, + Spec: operatorv1.LogCollectorSpec{ + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelFlowLog}}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + }, + })).ToNot(HaveOccurred()) + }) + + It("should reconcile and create resources", func() { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + mockStatus.AssertCalled(GinkgoT(), "OnCRFound") + mockStatus.AssertCalled(GinkgoT(), "ReadyToMonitor") + mockStatus.AssertCalled(GinkgoT(), "ClearDegraded") + + ss := appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "otel-collector", Namespace: "calico-system"}} + Expect(test.GetResource(cli, &ss)).To(BeNil()) + Expect(ss.Spec.Template.Spec.Containers).To(HaveLen(1)) + }) + }) + + Context("license missing", func() { + BeforeEach(func() { + Expect(cli.Delete(ctx, &v3.LicenseKey{ObjectMeta: metav1.ObjectMeta{Name: "default"}})).ToNot(HaveOccurred()) + Expect(cli.Create(ctx, &operatorv1.LogCollector{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}, + Spec: operatorv1.LogCollectorSpec{ + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + }, + })).ToNot(HaveOccurred()) + }) + + It("should set degraded status", func() { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + mockStatus.AssertCalled(GinkgoT(), "SetDegraded", operatorv1.ResourceNotFound, mock.AnythingOfType("string"), mock.Anything, mock.Anything) + }) + }) + + Context("license feature inactive", func() { + BeforeEach(func() { + Expect(cli.Delete(ctx, &v3.LicenseKey{ObjectMeta: metav1.ObjectMeta{Name: "default"}})).ToNot(HaveOccurred()) + Expect(cli.Create(ctx, &v3.LicenseKey{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Status: v3.LicenseKeyStatus{ + Features: []string{"some-other-feature"}, + }, + })).ToNot(HaveOccurred()) + + Expect(cli.Create(ctx, &operatorv1.LogCollector{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}, + Spec: operatorv1.LogCollectorSpec{ + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + }, + })).ToNot(HaveOccurred()) + }) + + It("should set degraded status for inactive feature", func() { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + mockStatus.AssertCalled(GinkgoT(), "SetDegraded", operatorv1.ResourceValidationError, mock.AnythingOfType("string"), mock.Anything, mock.Anything) + }) + }) + + Context("installation missing", func() { + BeforeEach(func() { + Expect(cli.Delete(ctx, &operatorv1.Installation{ObjectMeta: metav1.ObjectMeta{Name: "default"}})).ToNot(HaveOccurred()) + Expect(cli.Create(ctx, &operatorv1.LogCollector{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}, + Spec: operatorv1.LogCollectorSpec{ + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + }, + })).ToNot(HaveOccurred()) + }) + + It("should return error when installation is missing", func() { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).Should(HaveOccurred()) + }) + }) +}) diff --git a/pkg/controller/otelcollector/otelcollector_suite_test.go b/pkg/controller/otelcollector/otelcollector_suite_test.go new file mode 100644 index 0000000000..860f89ef35 --- /dev/null +++ b/pkg/controller/otelcollector/otelcollector_suite_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package otelcollector + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + uzap "go.uber.org/zap" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func TestController(t *testing.T) { + logf.SetLogger(zap.New(zap.WriteTo(ginkgo.GinkgoWriter), zap.UseDevMode(true), zap.Level(uzap.NewAtomicLevelAt(uzap.DebugLevel)))) + gomega.RegisterFailHandler(ginkgo.Fail) + suiteConfig, reporterConfig := ginkgo.GinkgoConfiguration() + reporterConfig.JUnitReport = "../../../report/ut/otelcollector_controller_suite.xml" + ginkgo.RunSpecs(t, "pkg/controller/otelcollector Controller Suite", suiteConfig, reporterConfig) +} diff --git a/pkg/controller/tiers/tiers_controller.go b/pkg/controller/tiers/tiers_controller.go index e7e7bcbb43..0d6ab03f44 100644 --- a/pkg/controller/tiers/tiers_controller.go +++ b/pkg/controller/tiers/tiers_controller.go @@ -162,11 +162,12 @@ func (r *ReconcileTiers) prepareTiersConfig(ctx context.Context, reqLogger logr. common.CalicoNamespace, } if r.opts.EnterpriseCRDExists { + // The log collector (fluent-bit) runs in common.CalicoNamespace, which is + // already in the list. namespaces = append(namespaces, render.ComplianceNamespace, render.DexNamespace, render.ElasticsearchNamespace, - render.LogCollectorNamespace, render.IntrusionDetectionNamespace, kibana.Namespace, eck.OperatorNamespace, diff --git a/pkg/imports/crds/operator/operator.tigera.io_logcollectors.yaml b/pkg/imports/crds/operator/operator.tigera.io_logcollectors.yaml index f244015211..6cf0ea90dd 100644 --- a/pkg/imports/crds/operator/operator.tigera.io_logcollectors.yaml +++ b/pkg/imports/crds/operator/operator.tigera.io_logcollectors.yaml @@ -18,7 +18,7 @@ spec: openAPIV3Schema: description: |- LogCollector installs the components required for Tigera flow and DNS log collection. At most one instance - of this resource is supported. It must be named "tigera-secure". When created, this installs fluentd on all nodes + of this resource is supported. It must be named "tigera-secure". When created, this installs fluent-bit on all nodes configured to collect Tigera log data and export it to Tigera's Elasticsearch cluster as well as any additionally configured destinations. properties: apiVersion: @@ -183,6 +183,260 @@ spec: - logTypes type: object type: object + calicoFluentBitDaemonSet: + description: |- + CalicoFluentBitDaemonSet configures the calico-fluent-bit DaemonSet, the + Fluent Bit replacement for the Fluentd DaemonSet. Pod-template override + semantics are unchanged from the deprecated FluentdDaemonSet field. + properties: + spec: + description: Spec is the specification of the Fluent Bit DaemonSet. + properties: + template: + description: + Template describes the Fluent Bit DaemonSet pod + that will be created. + properties: + spec: + description: Spec is the Fluent Bit DaemonSet's PodSpec. + properties: + containers: + description: |- + Containers is a list of Fluent Bit DaemonSet containers. + If specified, this overrides the specified Fluent Bit DaemonSet containers. + If omitted, the Fluent Bit DaemonSet will use its default values for its containers. + items: + description: + FluentBitDaemonSetContainer is a Fluent + Bit DaemonSet container. + properties: + livenessProbe: + description: |- + LivenessProbe allows customization of the liveness probe timing parameters. + The probe handler is set by the operator and cannot be overridden. + properties: + failureThreshold: + description: |- + FailureThreshold is the minimum consecutive failures for the probe + to be considered failed after having succeeded. + format: int32 + type: integer + initialDelaySeconds: + description: |- + InitialDelaySeconds is the number of seconds after the container + starts before the probe is initiated. + format: int32 + type: integer + periodSeconds: + description: + PeriodSeconds is how often + (in seconds) to perform the probe. + format: int32 + type: integer + timeoutSeconds: + description: + TimeoutSeconds is the number + of seconds after which the probe times + out. + format: int32 + type: integer + type: object + name: + description: |- + Name is an enum which identifies the Fluent Bit DaemonSet container by name. + Supported values are: calico-fluent-bit (or the deprecated alias fluentd, kept + for one release so existing FluentdDaemonSet overrides continue to validate). + enum: + - calico-fluent-bit + - fluentd + type: string + readinessProbe: + description: |- + ReadinessProbe allows customization of the readiness probe timing parameters. + The probe handler is set by the operator and cannot be overridden. + properties: + failureThreshold: + description: |- + FailureThreshold is the minimum consecutive failures for the probe + to be considered failed after having succeeded. + format: int32 + type: integer + initialDelaySeconds: + description: |- + InitialDelaySeconds is the number of seconds after the container + starts before the probe is initiated. + format: int32 + type: integer + periodSeconds: + description: + PeriodSeconds is how often + (in seconds) to perform the probe. + format: int32 + type: integer + timeoutSeconds: + description: + TimeoutSeconds is the number + of seconds after which the probe times + out. + format: int32 + type: integer + type: object + resources: + description: |- + Resources allows customization of limits and requests for compute resources such as cpu and memory. + If specified, this overrides the named Fluent Bit DaemonSet container's resources. + If omitted, the Fluent Bit DaemonSet will use its default value for this container's resources. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + This field depends on the + DynamicResourceAllocation feature gate. + This field is immutable. It can only be set for containers. + items: + description: + ResourceClaim references + one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + required: + - name + type: object + type: array + initContainers: + description: |- + InitContainers is a list of Fluent Bit DaemonSet init containers. + If specified, this overrides the specified Fluent Bit DaemonSet init containers. + If omitted, the Fluent Bit DaemonSet will use its default values for its init containers. + items: + description: + FluentBitDaemonSetInitContainer is + a Fluent Bit DaemonSet init container. + properties: + name: + description: |- + Name is an enum which identifies the Fluent Bit DaemonSet init container by name. + Supported values are: calico-fluent-bit-tls-key-cert-provisioner (or the deprecated + alias tigera-fluentd-prometheus-tls-key-cert-provisioner, kept for one release so + existing FluentdDaemonSet overrides continue to validate). + enum: + - calico-fluent-bit-tls-key-cert-provisioner + - tigera-fluentd-prometheus-tls-key-cert-provisioner + type: string + resources: + description: |- + Resources allows customization of limits and requests for compute resources such as cpu and memory. + If specified, this overrides the named Fluent Bit DaemonSet init container's resources. + If omitted, the Fluent Bit DaemonSet will use its default value for this init container's resources. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + This field depends on the + DynamicResourceAllocation feature gate. + This field is immutable. It can only be set for containers. + items: + description: + ResourceClaim references + one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + required: + - name + type: object + type: array + type: object + type: object + type: object + type: object collectProcessPath: description: |- Configuration for enabling/disabling process path collection in flowlogs. @@ -445,28 +699,32 @@ spec: type: object type: object fluentdDaemonSet: - description: FluentdDaemonSet configures the Fluentd DaemonSet. + description: |- + FluentdDaemonSet configures the calico-fluent-bit DaemonSet (deprecated alias). + Deprecated: use CalicoFluentBitDaemonSet instead. This field is retained + as an alias for one release during the Fluentd → Fluent Bit migration; + when both are set, CalicoFluentBitDaemonSet takes precedence. properties: spec: - description: Spec is the specification of the Fluentd DaemonSet. + description: Spec is the specification of the Fluent Bit DaemonSet. properties: template: description: - Template describes the Fluentd DaemonSet pod + Template describes the Fluent Bit DaemonSet pod that will be created. properties: spec: - description: Spec is the Fluentd DaemonSet's PodSpec. + description: Spec is the Fluent Bit DaemonSet's PodSpec. properties: containers: description: |- - Containers is a list of Fluentd DaemonSet containers. - If specified, this overrides the specified Fluentd DaemonSet containers. - If omitted, the Fluentd DaemonSet will use its default values for its containers. + Containers is a list of Fluent Bit DaemonSet containers. + If specified, this overrides the specified Fluent Bit DaemonSet containers. + If omitted, the Fluent Bit DaemonSet will use its default values for its containers. items: description: - FluentdDaemonSetContainer is a Fluentd - DaemonSet container. + FluentBitDaemonSetContainer is a Fluent + Bit DaemonSet container. properties: livenessProbe: description: |- @@ -501,9 +759,11 @@ spec: type: object name: description: |- - Name is an enum which identifies the Fluentd DaemonSet container by name. - Supported values are: fluentd + Name is an enum which identifies the Fluent Bit DaemonSet container by name. + Supported values are: calico-fluent-bit (or the deprecated alias fluentd, kept + for one release so existing FluentdDaemonSet overrides continue to validate). enum: + - calico-fluent-bit - fluentd type: string readinessProbe: @@ -540,8 +800,8 @@ spec: resources: description: |- Resources allows customization of limits and requests for compute resources such as cpu and memory. - If specified, this overrides the named Fluentd DaemonSet container's resources. - If omitted, the Fluentd DaemonSet will use its default value for this container's resources. + If specified, this overrides the named Fluent Bit DaemonSet container's resources. + If omitted, the Fluent Bit DaemonSet will use its default value for this container's resources. properties: claims: description: |- @@ -605,26 +865,29 @@ spec: type: array initContainers: description: |- - InitContainers is a list of Fluentd DaemonSet init containers. - If specified, this overrides the specified Fluentd DaemonSet init containers. - If omitted, the Fluentd DaemonSet will use its default values for its init containers. + InitContainers is a list of Fluent Bit DaemonSet init containers. + If specified, this overrides the specified Fluent Bit DaemonSet init containers. + If omitted, the Fluent Bit DaemonSet will use its default values for its init containers. items: description: - FluentdDaemonSetInitContainer is a - Fluentd DaemonSet init container. + FluentBitDaemonSetInitContainer is + a Fluent Bit DaemonSet init container. properties: name: description: |- - Name is an enum which identifies the Fluentd DaemonSet init container by name. - Supported values are: tigera-fluentd-prometheus-tls-key-cert-provisioner + Name is an enum which identifies the Fluent Bit DaemonSet init container by name. + Supported values are: calico-fluent-bit-tls-key-cert-provisioner (or the deprecated + alias tigera-fluentd-prometheus-tls-key-cert-provisioner, kept for one release so + existing FluentdDaemonSet overrides continue to validate). enum: + - calico-fluent-bit-tls-key-cert-provisioner - tigera-fluentd-prometheus-tls-key-cert-provisioner type: string resources: description: |- Resources allows customization of limits and requests for compute resources such as cpu and memory. - If specified, this overrides the named Fluentd DaemonSet init container's resources. - If omitted, the Fluentd DaemonSet will use its default value for this init container's resources. + If specified, this overrides the named Fluent Bit DaemonSet init container's resources. + If omitted, the Fluent Bit DaemonSet will use its default value for this init container's resources. properties: claims: description: |- @@ -695,6 +958,1531 @@ spec: If running as a multi-tenant management cluster, the namespace in which the management cluster's tenant services are running. type: string + otelCollector: + description: + OTelCollector configures the OpenTelemetry Collector + for exporting logs and metrics via OTLP. + properties: + exporters: + description: Exporters configures the OTLP export endpoints. + items: + description: OTelExporter defines an OTLP export endpoint. + properties: + endpoint: + description: Endpoint is the OTLP endpoint URL. + type: string + name: + description: Name is a unique identifier for this exporter. + type: string + protocol: + default: grpc + description: + "Protocol specifies the OTLP transport protocol. + Default: grpc." + enum: + - grpc + - http + type: string + tlsInsecure: + description: |- + TLSInsecure disables TLS verification for this exporter. Only use for trusted in-cluster targets. + Default: false + type: boolean + required: + - endpoint + - name + type: object + type: array + logs: + description: + Logs configures which log types are exported via + OTLP. + properties: + types: + description: + "Types specifies which log types to export. Supported + values: Audit, DNS, Flows." + items: + description: + OTelLogType represents the allowable log types + for OTel export. + enum: + - Audit + - DNS + - Flows + type: string + type: array + type: object + metrics: + description: + Metrics configures whether Calico component metrics + are exported via OTLP. + properties: + enabled: + description: |- + Enabled specifies whether to scrape and export Calico component metrics via OTLP. + Default: Disabled + enum: + - Enabled + - Disabled + type: string + type: object + openTelemetryCollectorStatefulSet: + description: + OTelCollectorStatefulSet configures the OTel Collector + StatefulSet. + properties: + metadata: + description: + Metadata is a subset of a Kubernetes object's + metadata that is added to the StatefulSet. + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations is a map of arbitrary non-identifying metadata. Each of these + key/value pairs are added to the object's annotations provided the key does not + already exist in the object's annotations. + type: object + labels: + additionalProperties: + type: string + description: |- + Labels is a map of string keys and values that may match replicaset and + service selectors. Each of these key/value pairs are added to the + object's labels provided the key does not already exist in the object's labels. + type: object + type: object + spec: + description: + Spec is the specification of the OTel Collector + StatefulSet. + properties: + minReadySeconds: + description: |- + MinReadySeconds is the minimum number of seconds for which a newly created StatefulSet pod should + be ready without any of its container crashing, for it to be considered available. + If specified, this overrides any minReadySeconds value that may be set on the OTel Collector StatefulSet. + If omitted, the OTel Collector StatefulSet will use its default value for minReadySeconds. + format: int32 + maximum: 2147483647 + minimum: 0 + type: integer + template: + description: + Template describes the OTel Collector StatefulSet + pod that will be created. + properties: + metadata: + description: + Metadata is a subset of a Kubernetes + object's metadata that is added to the pod's metadata. + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations is a map of arbitrary non-identifying metadata. Each of these + key/value pairs are added to the object's annotations provided the key does not + already exist in the object's annotations. + type: object + labels: + additionalProperties: + type: string + description: |- + Labels is a map of string keys and values that may match replicaset and + service selectors. Each of these key/value pairs are added to the + object's labels provided the key does not already exist in the object's labels. + type: object + type: object + spec: + description: + Spec is the OTel Collector StatefulSet's + PodSpec. + properties: + affinity: + description: + Affinity is a group of affinity scheduling + rules for the OTel Collector pods. + properties: + nodeAffinity: + description: + Describes node affinity scheduling + rules for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: + A node selector term, + associated with the corresponding + weight. + properties: + matchExpressions: + description: + A list of node + selector requirements by node's + labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: + The label + key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: + A list of node + selector requirements by node's + fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: + The label + key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: + Weight associated with + matching the corresponding nodeSelectorTerm, + in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: + Required. A list of node + selector terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: + A list of node + selector requirements by node's + labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: + The label + key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: + A list of node + selector requirements by node's + fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: + The label + key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: + Describes pod affinity scheduling + rules (e.g. co-locate this pod in the same + node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: + The weights of all of the + matched WeightedPodAffinityTerm fields + are added per-node to find the most + preferred node(s) + properties: + podAffinityTerm: + description: + Required. A pod affinity + term, associated with the corresponding + weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is + the label key that + the selector applies + to. + type: string + operator: + description: + |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: + |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is + the label key that + the selector applies + to. + type: string + operator: + description: + |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: + |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the + label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the + label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: + Describes pod anti-affinity scheduling + rules (e.g. avoid putting this pod in the + same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: + The weights of all of the + matched WeightedPodAffinityTerm fields + are added per-node to find the most + preferred node(s) + properties: + podAffinityTerm: + description: + Required. A pod affinity + term, associated with the corresponding + weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is + the label key that + the selector applies + to. + type: string + operator: + description: + |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: + |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is + the label key that + the selector applies + to. + type: string + operator: + description: + |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: + |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the + label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the + label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + containers: + description: |- + Containers is a list of OTel Collector containers. + If specified, this overrides the specified OTel Collector StatefulSet containers. + If omitted, the OTel Collector StatefulSet will use its default values for its containers. + items: + description: + OTelCollectorStatefulSetContainer + is an OTel Collector StatefulSet container. + properties: + livenessProbe: + description: |- + LivenessProbe allows customization of the liveness probe timing parameters. + The probe handler is set by the operator and cannot be overridden. + properties: + failureThreshold: + description: |- + FailureThreshold is the minimum consecutive failures for the probe + to be considered failed after having succeeded. + format: int32 + type: integer + initialDelaySeconds: + description: |- + InitialDelaySeconds is the number of seconds after the container + starts before the probe is initiated. + format: int32 + type: integer + periodSeconds: + description: + PeriodSeconds is how often + (in seconds) to perform the probe. + format: int32 + type: integer + timeoutSeconds: + description: + TimeoutSeconds is the number + of seconds after which the probe times + out. + format: int32 + type: integer + type: object + name: + description: |- + Name is an enum which identifies the OTel Collector StatefulSet container by name. + Supported values are: otel-collector + enum: + - otel-collector + type: string + readinessProbe: + description: |- + ReadinessProbe allows customization of the readiness probe timing parameters. + The probe handler is set by the operator and cannot be overridden. + properties: + failureThreshold: + description: |- + FailureThreshold is the minimum consecutive failures for the probe + to be considered failed after having succeeded. + format: int32 + type: integer + initialDelaySeconds: + description: |- + InitialDelaySeconds is the number of seconds after the container + starts before the probe is initiated. + format: int32 + type: integer + periodSeconds: + description: + PeriodSeconds is how often + (in seconds) to perform the probe. + format: int32 + type: integer + timeoutSeconds: + description: + TimeoutSeconds is the number + of seconds after which the probe times + out. + format: int32 + type: integer + type: object + resources: + description: |- + Resources allows customization of limits and requests for compute resources such as cpu and memory. + If specified, this overrides the named OTel Collector StatefulSet container's resources. + If omitted, the OTel Collector StatefulSet will use its default value for this container's resources. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + This field depends on the + DynamicResourceAllocation feature gate. + This field is immutable. It can only be set for containers. + items: + description: + ResourceClaim references + one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + required: + - name + type: object + type: array + nodeSelector: + additionalProperties: + type: string + description: + NodeSelector gives more control over + the nodes where the OTel Collector pods will + run on. + type: object + priorityClassName: + description: + PriorityClassName allows to specify + a PriorityClass resource to be used. + type: string + tolerations: + description: |- + Tolerations is the OTel Collector pod's tolerations. + If specified, this overrides any tolerations that may be set on the OTel Collector StatefulSet. + If omitted, the OTel Collector StatefulSet will use its default value for tolerations. + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators). + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + topologySpreadConstraints: + description: |- + TopologySpreadConstraints describes how a group of pods ought to spread across topology + domains. Scheduler will schedule pods in a way which abides by the constraints. + All topologySpreadConstraints are ANDed. + items: + description: + TopologySpreadConstraint specifies + how to spread matching pods among the given + topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: + matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + type: object + type: object + type: object + type: object + type: object type: object status: description: Most recently observed state for Tigera log collection. diff --git a/pkg/imports/crds/operator/operator.tigera.io_opentelemetrycollectors.yaml b/pkg/imports/crds/operator/operator.tigera.io_opentelemetrycollectors.yaml new file mode 100644 index 0000000000..cbffc5481a --- /dev/null +++ b/pkg/imports/crds/operator/operator.tigera.io_opentelemetrycollectors.yaml @@ -0,0 +1,1650 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: opentelemetrycollectors.operator.tigera.io +spec: + group: operator.tigera.io + names: + kind: OpenTelemetryCollector + listKind: OpenTelemetryCollectorList + plural: opentelemetrycollectors + singular: opentelemetrycollector + scope: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + description: |- + OpenTelemetryCollector installs the OpenTelemetry Collector for exporting Calico logs and metrics via OTLP. + At most one instance of this resource is supported. It must be named "tigera-secure". + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: + OpenTelemetryCollectorSpec defines the desired state of the + OpenTelemetry Collector. + properties: + exporters: + description: Exporters configures the OTLP export endpoints. + items: + description: OTelExporter defines an OTLP export endpoint. + properties: + endpoint: + description: Endpoint is the OTLP endpoint URL. + type: string + name: + description: Name is a unique identifier for this exporter. + type: string + protocol: + default: grpc + description: + "Protocol specifies the OTLP transport protocol. + Default: grpc." + enum: + - grpc + - http + type: string + required: + - endpoint + - name + type: object + type: array + logs: + description: Logs configures which log types are exported via OTLP. + properties: + types: + description: + "Types specifies which log types to export. Supported + values: Audit, DNS, Flows." + items: + description: + OTelLogType represents the allowable log types + for OTel export. + enum: + - Audit + - DNS + - Flows + type: string + type: array + type: object + metrics: + description: + Metrics configures whether Calico component metrics are + exported via OTLP. + properties: + enabled: + description: |- + Enabled specifies whether to scrape and export Calico component metrics via OTLP. + Default: Disabled + enum: + - Enabled + - Disabled + type: string + type: object + openTelemetryCollectorDeployment: + description: + OpenTelemetryCollectorDeployment configures the OTel + Collector Deployment. + properties: + metadata: + description: + Metadata is a subset of a Kubernetes object's metadata + that is added to the Deployment. + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations is a map of arbitrary non-identifying metadata. Each of these + key/value pairs are added to the object's annotations provided the key does not + already exist in the object's annotations. + type: object + labels: + additionalProperties: + type: string + description: |- + Labels is a map of string keys and values that may match replicaset and + service selectors. Each of these key/value pairs are added to the + object's labels provided the key does not already exist in the object's labels. + type: object + type: object + spec: + description: Spec is the specification of the OTel Collector Deployment. + properties: + minReadySeconds: + description: |- + MinReadySeconds is the minimum number of seconds for which a newly created Deployment pod should + be ready without any of its container crashing, for it to be considered available. + If specified, this overrides any minReadySeconds value that may be set on the OTel Collector Deployment. + If omitted, the OTel Collector Deployment will use its default value for minReadySeconds. + format: int32 + maximum: 2147483647 + minimum: 0 + type: integer + strategy: + description: + The deployment strategy to use to replace existing + pods with new ones. + properties: + rollingUpdate: + description: + Rolling update config params. Present only + if DeploymentStrategyType = RollingUpdate. + properties: + maxSurge: + anyOf: + - type: integer + - type: string + description: |- + The maximum number of pods that can be scheduled above the desired number of + pods. + Value can be an absolute number (ex: 5) or a percentage of desired pods (ex: 10%). + This can not be 0 if MaxUnavailable is 0. + Absolute number is calculated from percentage by rounding up. + Defaults to 25%. + Example: when this is set to 30%, the new ReplicaSet can be scaled up immediately when + the rolling update starts, such that the total number of old and new pods do not exceed + 130% of desired pods. Once old pods have been killed, + new ReplicaSet can be scaled up further, ensuring that total number of pods running + at any time during the update is at most 130% of desired pods. + x-kubernetes-int-or-string: true + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + The maximum number of pods that can be unavailable during the update. + Value can be an absolute number (ex: 5) or a percentage of desired pods (ex: 10%). + Absolute number is calculated from percentage by rounding down. + This can not be 0 if MaxSurge is 0. + Defaults to 25%. + Example: when this is set to 30%, the old ReplicaSet can be scaled down to 70% of desired pods + immediately when the rolling update starts. Once new pods are ready, old ReplicaSet + can be scaled down further, followed by scaling up the new ReplicaSet, ensuring + that the total number of pods available at all times during the update is at + least 70% of desired pods. + x-kubernetes-int-or-string: true + type: object + type: object + template: + description: + Template describes the OTel Collector Deployment + pod that will be created. + properties: + metadata: + description: + Metadata is a subset of a Kubernetes object's + metadata that is added to the pod's metadata. + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations is a map of arbitrary non-identifying metadata. Each of these + key/value pairs are added to the object's annotations provided the key does not + already exist in the object's annotations. + type: object + labels: + additionalProperties: + type: string + description: |- + Labels is a map of string keys and values that may match replicaset and + service selectors. Each of these key/value pairs are added to the + object's labels provided the key does not already exist in the object's labels. + type: object + type: object + spec: + description: Spec is the OTel Collector Deployment's PodSpec. + properties: + affinity: + description: + Affinity is a group of affinity scheduling + rules for the OTel Collector pods. + properties: + nodeAffinity: + description: + Describes node affinity scheduling + rules for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: + A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: + A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: + The label key + that the selector applies + to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: + A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: + The label key + that the selector applies + to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: + Weight associated with + matching the corresponding nodeSelectorTerm, + in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: + Required. A list of node + selector terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: + A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: + The label key + that the selector applies + to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: + A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: + The label key + that the selector applies + to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: + Describes pod affinity scheduling + rules (e.g. co-locate this pod in the same node, + zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: + The weights of all of the matched + WeightedPodAffinityTerm fields are added + per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: + Required. A pod affinity + term, associated with the corresponding + weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the + label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the + label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: + matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: + matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: + Describes pod anti-affinity scheduling + rules (e.g. avoid putting this pod in the same + node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: + The weights of all of the matched + WeightedPodAffinityTerm fields are added + per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: + Required. A pod affinity + term, associated with the corresponding + weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the + label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: + matchExpressions + is a list of label selector + requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the + label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: + matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: + matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + containers: + description: |- + Containers is a list of OTel Collector containers. + If specified, this overrides the specified OTel Collector Deployment containers. + If omitted, the OTel Collector Deployment will use its default values for its containers. + items: + description: + OpenTelemetryCollectorDeploymentContainer + is an OTel Collector Deployment container. + properties: + livenessProbe: + description: |- + LivenessProbe allows customization of the liveness probe timing parameters. + The probe handler is set by the operator and cannot be overridden. + properties: + failureThreshold: + description: |- + FailureThreshold is the minimum consecutive failures for the probe + to be considered failed after having succeeded. + format: int32 + type: integer + initialDelaySeconds: + description: |- + InitialDelaySeconds is the number of seconds after the container + starts before the probe is initiated. + format: int32 + type: integer + periodSeconds: + description: + PeriodSeconds is how often + (in seconds) to perform the probe. + format: int32 + type: integer + timeoutSeconds: + description: + TimeoutSeconds is the number + of seconds after which the probe times + out. + format: int32 + type: integer + type: object + name: + description: |- + Name is an enum which identifies the OTel Collector Deployment container by name. + Supported values are: otel-collector + enum: + - otel-collector + type: string + readinessProbe: + description: |- + ReadinessProbe allows customization of the readiness probe timing parameters. + The probe handler is set by the operator and cannot be overridden. + properties: + failureThreshold: + description: |- + FailureThreshold is the minimum consecutive failures for the probe + to be considered failed after having succeeded. + format: int32 + type: integer + initialDelaySeconds: + description: |- + InitialDelaySeconds is the number of seconds after the container + starts before the probe is initiated. + format: int32 + type: integer + periodSeconds: + description: + PeriodSeconds is how often + (in seconds) to perform the probe. + format: int32 + type: integer + timeoutSeconds: + description: + TimeoutSeconds is the number + of seconds after which the probe times + out. + format: int32 + type: integer + type: object + resources: + description: |- + Resources allows customization of limits and requests for compute resources such as cpu and memory. + If specified, this overrides the named OTel Collector Deployment container's resources. + If omitted, the OTel Collector Deployment will use its default value for this container's resources. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + This field depends on the + DynamicResourceAllocation feature gate. + This field is immutable. It can only be set for containers. + items: + description: + ResourceClaim references + one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + required: + - name + type: object + type: array + nodeSelector: + additionalProperties: + type: string + description: + NodeSelector gives more control over + the nodes where the OTel Collector pods will run + on. + type: object + priorityClassName: + description: + PriorityClassName allows to specify a + PriorityClass resource to be used. + type: string + tolerations: + description: |- + Tolerations is the OTel Collector pod's tolerations. + If specified, this overrides any tolerations that may be set on the OTel Collector Deployment. + If omitted, the OTel Collector Deployment will use its default value for tolerations. + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators). + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + topologySpreadConstraints: + description: |- + TopologySpreadConstraints describes how a group of pods ought to spread across topology + domains. Scheduler will schedule pods in a way which abides by the constraints. + All topologySpreadConstraints are ANDed. + items: + description: + TopologySpreadConstraint specifies + how to spread matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: + matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: + key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + type: object + type: object + type: object + type: object + type: object + status: + description: + OpenTelemetryCollectorStatus defines the observed state of + the OpenTelemetry Collector. + properties: + conditions: + description: |- + Conditions represents the latest observed set of conditions for the component. A component may be one or more of + Ready, Progressing, Degraded or other customer types. + items: + description: + Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + state: + description: State provides user-readable status. + type: string + type: object + type: object + x-kubernetes-validations: + - message: resource name must be 'tigera-secure' + rule: self.metadata.name == 'tigera-secure' + served: true + storage: true + subresources: + status: {} diff --git a/pkg/render/applicationlayer/applicationlayer.go b/pkg/render/applicationlayer/applicationlayer.go index 822132dd1e..28546314aa 100644 --- a/pkg/render/applicationlayer/applicationlayer.go +++ b/pkg/render/applicationlayer/applicationlayer.go @@ -455,7 +455,7 @@ func (c *component) volumes() []corev1.Volume { // Needed for Coraza library - contains rule set. if c.config.PerHostWAFEnabled || c.config.SidecarInjectionEnabled { - // WAF logs need HostPath volume - logs to be consumed by fluentd. + // WAF logs need HostPath volume - logs to be consumed by fluent-bit. volumes = append(volumes, corev1.Volume{ Name: CalicoLogsVolumeName, VolumeSource: corev1.VolumeSource{ diff --git a/pkg/render/common/components/components.go b/pkg/render/common/components/components.go index bfb308f92e..d4bce6fd5d 100644 --- a/pkg/render/common/components/components.go +++ b/pkg/render/common/components/components.go @@ -52,6 +52,8 @@ var containerNameAliases = map[string]string{ "tigera-ui-apis": "calico-ui-apis", "tigera-es-proxy": "calico-ui-apis", "tigera-voltron-linseed-tls-key-cert-provisioner": "calico-voltron-linseed-tls-key-cert-provisioner", + "fluentd": "calico-fluent-bit", + "tigera-fluentd-prometheus-tls-key-cert-provisioner": "calico-fluent-bit-tls-key-cert-provisioner", } func resolveContainerName(name string) string { diff --git a/pkg/render/common/components/components_test.go b/pkg/render/common/components/components_test.go index 7a83935c5a..1ebb66195c 100644 --- a/pkg/render/common/components/components_test.go +++ b/pkg/render/common/components/components_test.go @@ -186,7 +186,7 @@ var _ = Describe("Common components render tests", func() { Entry("EKSLogForwarderDeployment", &v1.EKSLogForwarderDeployment{}, false), Entry("ElasticsearchMetricsDeployment", &v1.ElasticsearchMetricsDeployment{}, false), Entry("ESGatewayDeployment", &v1.ESGatewayDeployment{}, false), - Entry("FluentdDaemonSet", &v1.FluentdDaemonSet{}, false), + Entry("FluentBitDaemonSet", &v1.FluentBitDaemonSet{}, false), Entry("GatewayCertgenJob", &v1.GatewayCertgenJob{}, false), Entry("GatewayControllerDeployment", &v1.GatewayControllerDeployment{}, false), Entry("GatewayDeployment", &v1.GatewayDeployment{}, false), diff --git a/pkg/render/common/elasticsearch/service.go b/pkg/render/common/elasticsearch/service.go index bc2e70569b..ac88a1486a 100644 --- a/pkg/render/common/elasticsearch/service.go +++ b/pkg/render/common/elasticsearch/service.go @@ -25,16 +25,16 @@ const ( httpsFQDNEndpoint = "https://tigera-secure-es-gateway-http.%s.svc.%s:9200" ) -func LinseedEndpoint(osType rmeta.OSType, clusterDomain, namespace string, isManagedCluster bool, isFluentd bool) string { +func LinseedEndpoint(osType rmeta.OSType, clusterDomain, namespace string, isManagedCluster bool, isFluentBit bool) string { switch { // In a managed cluster, all Elasticsearch requests to Linseed are redirected via the Guardian service. // Clients using the Linseed client are automatically configured with the correct SNI for certificate validation. - // Since Fluentd doesn't use the Linseed client, we expose an external service named "tigera-linseed" that redirects to Guardian. + // Since Fluent Bit doesn't use the Linseed client, we expose an external service named "tigera-linseed" that redirects to Guardian. // The Linseed certificate is already configured to accept connections with SNI set to "tigera-linseed". - case isManagedCluster && isFluentd: + case isManagedCluster && isFluentBit: return "https://tigera-linseed" - // Non-Fluentd components in the managed cluster forward traffic to Guardian + // Non-Fluent-Bit components in the managed cluster forward traffic to Guardian case isManagedCluster && osType == rmeta.OSTypeWindows: return fmt.Sprintf("https://guardian.calico-system.svc.%s", clusterDomain) case isManagedCluster: diff --git a/pkg/render/csi.go b/pkg/render/csi.go index ead5147813..ea38140aad 100644 --- a/pkg/render/csi.go +++ b/pkg/render/csi.go @@ -306,7 +306,7 @@ func (c *csiComponent) csiDaemonset() *appsv1.DaemonSet { Template: c.csiTemplate(), } - setNodeCriticalPod(&(dsSpec.Template)) + SetNodeCriticalPod(&(dsSpec.Template)) ds := appsv1.DaemonSet{ TypeMeta: typeMeta, diff --git a/pkg/render/dex.go b/pkg/render/dex.go index b9a0675b40..7d02d1cfa4 100644 --- a/pkg/render/dex.go +++ b/pkg/render/dex.go @@ -334,7 +334,7 @@ func (c *dexComponent) deployment() client.Object { VolumeMounts: mounts, }, }, - Volumes: append(c.cfg.DexConfig.RequiredVolumes(), c.cfg.TLSKeyPair.Volume(), trustedBundleVolume(c.cfg.TrustedBundle)), + Volumes: append(c.cfg.DexConfig.RequiredVolumes(), c.cfg.TLSKeyPair.Volume(), TrustedBundleVolume(c.cfg.TrustedBundle)), }, }, }, diff --git a/pkg/render/fluentd.go b/pkg/render/fluentd.go deleted file mode 100644 index ac651f2db0..0000000000 --- a/pkg/render/fluentd.go +++ /dev/null @@ -1,1375 +0,0 @@ -// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package render - -import ( - "crypto/x509" - "fmt" - "strconv" - "strings" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" - "sigs.k8s.io/controller-runtime/pkg/client" - - v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" - - operatorv1 "github.com/tigera/operator/api/v1" - "github.com/tigera/operator/pkg/components" - rcomponents "github.com/tigera/operator/pkg/render/common/components" - relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" - rmeta "github.com/tigera/operator/pkg/render/common/meta" - "github.com/tigera/operator/pkg/render/common/networkpolicy" - "github.com/tigera/operator/pkg/render/common/resourcequota" - "github.com/tigera/operator/pkg/render/common/secret" - "github.com/tigera/operator/pkg/render/common/securitycontext" - "github.com/tigera/operator/pkg/render/common/securitycontextconstraints" - "github.com/tigera/operator/pkg/tls/certificatemanagement" - "github.com/tigera/operator/pkg/tls/certkeyusage" - "github.com/tigera/operator/pkg/url" -) - -type ForwardingDestination string - -const ( - LogCollectorNamespace = "tigera-fluentd" - FluentdFilterConfigMapName = "fluentd-filters" - FluentdFilterFlowName = "flow" - FluentdFilterDNSName = "dns" - S3FluentdSecretName = "log-collector-s3-credentials" - S3KeyIdName = "key-id" - S3KeySecretName = "key-secret" - - // FluentdPrometheusTLSSecretName is the name of the secret containing the key pair fluentd presents to identify itself. - // Somewhat confusingly, this is named the prometheus TLS key pair because that was the first - // use-case for this credential. However, it is used on all TLS connections served by fluentd. - FluentdPrometheusTLSSecretName = "tigera-fluentd-prometheus-tls" - FluentdMetricsService = "fluentd-metrics" - FluentdMetricsServiceWindows = "fluentd-metrics-windows" - FluentdInputService = "fluentd-http-input" - FluentdMetricsPortName = "fluentd-metrics-port" - FluentdMetricsPort = 9081 - FluentdInputPortName = "fluentd-http-input-port" - FluentdInputPort = 9880 - FluentdPolicyName = networkpolicy.CalicoComponentPolicyPrefix + "allow-fluentd-node" - filterHashAnnotation = "hash.operator.tigera.io/fluentd-filters" - s3CredentialHashAnnotation = "hash.operator.tigera.io/s3-credentials" - splunkCredentialHashAnnotation = "hash.operator.tigera.io/splunk-credentials" - eksCloudwatchLogCredentialHashAnnotation = "hash.operator.tigera.io/eks-cloudwatch-log-credentials" - fluentdDefaultFlush = "5s" - ElasticsearchEksLogForwarderUserSecret = "tigera-eks-log-forwarder-elasticsearch-access" - EksLogForwarderSecret = "tigera-eks-log-forwarder-secret" - EksLogForwarderAwsId = "aws-id" - EksLogForwarderAwsKey = "aws-key" - SplunkFluentdTokenSecretName = "logcollector-splunk-credentials" - SplunkFluentdSecretTokenKey = "token" - SplunkFluentdSecretCertificateKey = "ca.pem" - SysLogPublicCADir = "/etc/pki/tls/certs/" - SysLogPublicCertKey = "ca-bundle.crt" - SysLogPublicCAPath = SysLogPublicCADir + SysLogPublicCertKey - SyslogCAConfigMapName = "syslog-ca" - - // Constants for Linseed token volume mounting in managed clusters. - LinseedTokenVolumeName = "linseed-token" - LinseedTokenKey = "token" - LinseedTokenSubPath = "token" - LinseedTokenSecret = "%s-tigera-linseed-token" - LinseedVolumeMountPath = "/var/run/secrets/tigera.io/linseed/" - LinseedTokenPath = "/var/run/secrets/tigera.io/linseed/token" - - fluentdName = "tigera-fluentd" - fluentdWindowsName = "tigera-fluentd-windows" - - FluentdNodeName = "fluentd-node" - fluentdNodeWindowsName = "fluentd-node-windows" - - EKSLogForwarderName = "eks-log-forwarder" - EKSLogForwarderTLSSecretName = "tigera-eks-log-forwarder-tls" - - PacketCaptureAPIRole = "packetcapture-api-role" - PacketCaptureAPIRoleBinding = "packetcapture-api-role-binding" - - ForwardingDestinationS3 ForwardingDestination = "S3" - ForwardingDestinationSyslog ForwardingDestination = "Syslog" - ForwardingDestinationSplunk ForwardingDestination = "Splunk" -) - -var FluentdSourceEntityRule = v3.EntityRule{ - NamespaceSelector: fmt.Sprintf("name == '%s'", LogCollectorNamespace), - Selector: networkpolicy.KubernetesAppSelector(FluentdNodeName, fluentdNodeWindowsName), -} - -var EKSLogForwarderEntityRule = networkpolicy.CreateSourceEntityRule(LogCollectorNamespace, EKSLogForwarderName) - -// Register secret/certs that need Server and Client Key usage -func init() { - certkeyusage.SetCertKeyUsage(FluentdPrometheusTLSSecretName, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}) - certkeyusage.SetCertKeyUsage(EKSLogForwarderTLSSecretName, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}) -} - -type FluentdFilters struct { - Flow string - DNS string -} - -type S3Credential struct { - KeyId []byte - KeySecret []byte -} - -type SplunkCredential struct { - Token []byte -} - -func Fluentd(cfg *FluentdConfiguration) Component { - return &fluentdComponent{ - cfg: cfg, - probeTimeout: 10, - probePeriod: 60, - } -} - -type EksCloudwatchLogConfig struct { - AwsId []byte - AwsKey []byte - AwsRegion string - GroupName string - StreamPrefix string - FetchInterval int32 -} - -// FluentdConfiguration contains all the config information needed to render the component. -type FluentdConfiguration struct { - LogCollector *operatorv1.LogCollector - S3Credential *S3Credential - SplkCredential *SplunkCredential - Filters *FluentdFilters - // ESClusterConfig is only populated for when EKSConfig - // is also defined - ESClusterConfig *relasticsearch.ClusterConfig - EKSConfig *EksCloudwatchLogConfig - PullSecrets []*corev1.Secret - Installation *operatorv1.InstallationSpec - ClusterDomain string - OSType rmeta.OSType - FluentdKeyPair certificatemanagement.KeyPairInterface - TrustedBundle certificatemanagement.TrustedBundle - ManagedCluster bool - - // Set if running as a multi-tenant management cluster. Configures the management cluster's - // own fluentd daemonset. - Tenant *operatorv1.Tenant - ExternalElastic bool - - // Whether to use User provided certificate or not. - UseSyslogCertificate bool - - // EKSLogForwarderKeyPair contains the certificate presented by EKS LogForwarder when communicating with Linseed - EKSLogForwarderKeyPair certificatemanagement.KeyPairInterface - - PacketCapture *operatorv1.PacketCaptureAPI - - NonClusterHost *operatorv1.NonClusterHost - - // LicenseExpired indicates the license has expired and fluentd DaemonSet should be removed. - LicenseExpired bool -} - -type fluentdComponent struct { - cfg *FluentdConfiguration - image string - probeTimeout int32 - probePeriod int32 -} - -func (c *fluentdComponent) ResolveImages(is *operatorv1.ImageSet) error { - reg := c.cfg.Installation.Registry - path := c.cfg.Installation.ImagePath - prefix := c.cfg.Installation.ImagePrefix - - if c.cfg.OSType == rmeta.OSTypeWindows { - var err error - c.image, err = components.GetReference(components.ComponentFluentdWindows, reg, path, prefix, is) - return err - } - - var err error - c.image, err = components.GetReference(components.ComponentFluentd, reg, path, prefix, is) - if err != nil { - return err - } - return err -} - -func (c *fluentdComponent) SupportedOSType() rmeta.OSType { - return c.cfg.OSType -} - -func (c *fluentdComponent) fluentdName() string { - if c.cfg.OSType == rmeta.OSTypeWindows { - return fluentdWindowsName - } - return fluentdName -} - -func (c *fluentdComponent) fluentdNodeName() string { - if c.cfg.OSType == rmeta.OSTypeWindows { - return fluentdNodeWindowsName - } - return FluentdNodeName -} - -// Use different service names depending on the OS type ("fluentd-metrics" -// vs "fluentd-metrics-windows") in order to help identify which OS daemonset -// we are referring to. -func (c *fluentdComponent) fluentdMetricsServiceName() string { - if c.cfg.OSType == rmeta.OSTypeWindows { - return FluentdMetricsServiceWindows - } - return FluentdMetricsService -} - -func (c *fluentdComponent) readinessCmd() []string { - if c.cfg.OSType == rmeta.OSTypeWindows { - // On Windows, we rely on bash via msys2 installed by the fluentd base image. - return []string{`c:\ruby\msys64\usr\bin\bash.exe`, `-lc`, `/c/bin/readiness.sh`} - } - return []string{"sh", "-c", "/bin/readiness.sh"} -} - -func (c *fluentdComponent) livenessCmd() []string { - if c.cfg.OSType == rmeta.OSTypeWindows { - // On Windows, we rely on bash via msys2 installed by the fluentd base image. - return []string{`c:\ruby\msys64\usr\bin\bash.exe`, `-lc`, `/c/bin/liveness.sh`} - } - return []string{"sh", "-c", "/bin/liveness.sh"} -} - -func (c *fluentdComponent) securityContext(privileged bool) *corev1.SecurityContext { - if c.cfg.OSType == rmeta.OSTypeWindows { - return nil - } - return securitycontext.NewRootContext(privileged) -} - -func (c *fluentdComponent) volumeHostPath() string { - if c.cfg.OSType == rmeta.OSTypeWindows { - return "c:/TigeraCalico" - } - return "/var/log/calico" -} - -func (c *fluentdComponent) path(path string) string { - if c.cfg.OSType == rmeta.OSTypeWindows { - // Use c: path prefix for windows. - return "c:" + path - } - // For linux just leave the path as-is. - return path -} - -func (c *fluentdComponent) Objects() ([]client.Object, []client.Object) { - var objs, toDelete []client.Object - objs = append(objs, c.calicoSystemPolicy()) - objs = append(objs, c.metricsService()) - - // allow-tigera Tier was renamed to calico-system - toDelete = append(toDelete, networkpolicy.DeprecatedAllowTigeraNetworkPolicyObject("allow-fluentd-node", LogCollectorNamespace)) - - if c.cfg.Installation.KubernetesProvider.IsGKE() { - // We do this only for GKE as other providers don't (yet?) - // automatically add resource quota that constrains whether - // components that are marked cluster or node critical - // can be scheduled. - objs = append(objs, c.fluentdResourceQuota()) - } - if c.cfg.S3Credential != nil { - objs = append(objs, c.s3CredentialSecret()) - } - if c.cfg.SplkCredential != nil { - objs = append(objs, secret.ToRuntimeObjects(secret.CopyToNamespace(LogCollectorNamespace, c.splunkCredentialSecret()...)...)...) - } - if c.cfg.Filters != nil { - objs = append(objs, c.filtersConfigMap()) - } - if c.cfg.EKSConfig != nil && c.cfg.OSType == rmeta.OSTypeLinux { - objs = append(objs, - c.eksLogForwarderClusterRole(), - c.eksLogForwarderClusterRoleBinding()) - - objs = append(objs, c.eksLogForwarderServiceAccount(), - c.eksLogForwarderSecret(), - c.eksLogForwarderDeployment()) - } - - // Add in the cluster role and binding. - objs = append(objs, - c.fluentdClusterRole(), - c.fluentdClusterRoleBinding(), - ) - if c.cfg.ManagedCluster { - objs = append(objs, c.externalLinseedService()) - objs = append(objs, c.externalLinseedRoleBinding()) - } else { - toDelete = append(toDelete, c.externalLinseedService()) - toDelete = append(toDelete, c.externalLinseedRoleBinding()) - } - - objs = append(objs, c.fluentdServiceAccount()) - if c.cfg.PacketCapture != nil { - objs = append(objs, c.packetCaptureApiRole(), c.packetCaptureApiRoleBinding()) - } - - if c.cfg.LicenseExpired { - toDelete = append(toDelete, c.daemonset()) - } else { - objs = append(objs, c.daemonset()) - } - - if c.cfg.NonClusterHost != nil && c.cfg.OSType == rmeta.OSTypeLinux { - objs = append(objs, c.nonClusterHostInputService()) - } - - return objs, toDelete -} - -func (c *fluentdComponent) nonClusterHostInputService() *corev1.Service { - return &corev1.Service{ - TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: FluentdInputService, - Namespace: LogCollectorNamespace, - Labels: map[string]string{"k8s-app": c.fluentdNodeName()}, - }, - // We do not treat this service as a headless service, as we want to ensure traffic is load-balanced. This is because: - // - We have no guarantee that the client (voltron) will perform load balancing across the returned records. The - // golang dialer implementation appears to prefer the first record returned (see dialSerial in the go SDK) - // - We have no guarantee that the DNS server will perform load-balancing or randomize the order of records returned - Spec: corev1.ServiceSpec{ - Selector: map[string]string{"k8s-app": c.fluentdNodeName()}, - Ports: []corev1.ServicePort{ - { - Name: FluentdInputPortName, - Port: int32(FluentdInputPort), - TargetPort: intstr.FromInt(FluentdInputPort), - Protocol: corev1.ProtocolTCP, - }, - }, - }, - } -} - -func (c *fluentdComponent) externalLinseedRoleBinding() *rbacv1.RoleBinding { - // For managed clusters, we must create a role binding to allow Linseed to manage access token secrets - // in our namespace. - return &rbacv1.RoleBinding{ - TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "tigera-linseed", - Namespace: LogCollectorNamespace, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: TigeraLinseedSecretsClusterRole, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: GuardianServiceAccountName, - Namespace: GuardianNamespace, - }, - }, - } -} - -func (c *fluentdComponent) externalLinseedService() *corev1.Service { - // For managed clusters, we must create an external service for fluentd to forward the request to guardian. - return &corev1.Service{ - TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: "tigera-linseed", - Namespace: LogCollectorNamespace, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeExternalName, - ExternalName: fmt.Sprintf("%s.%s.svc.%s", GuardianServiceName, GuardianNamespace, c.cfg.ClusterDomain), - }, - } -} - -func (c *fluentdComponent) Ready() bool { - return true -} - -func (c *fluentdComponent) fluentdResourceQuota() *corev1.ResourceQuota { - criticalPriorityClasses := []string{NodePriorityClassName} - return resourcequota.ResourceQuotaForPriorityClassScope(resourcequota.TigeraCriticalResourceQuotaName, LogCollectorNamespace, criticalPriorityClasses) -} - -func (c *fluentdComponent) s3CredentialSecret() *corev1.Secret { - if c.cfg.S3Credential == nil { - return nil - } - return &corev1.Secret{ - TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: S3FluentdSecretName, - Namespace: LogCollectorNamespace, - }, - Data: map[string][]byte{ - S3KeyIdName: c.cfg.S3Credential.KeyId, - S3KeySecretName: c.cfg.S3Credential.KeySecret, - }, - } -} - -func (c *fluentdComponent) filtersConfigMap() *corev1.ConfigMap { - if c.cfg.Filters == nil { - return nil - } - return &corev1.ConfigMap{ - TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: FluentdFilterConfigMapName, - Namespace: LogCollectorNamespace, - }, - Data: map[string]string{ - FluentdFilterFlowName: c.cfg.Filters.Flow, - FluentdFilterDNSName: c.cfg.Filters.DNS, - }, - } -} - -func (c *fluentdComponent) splunkCredentialSecret() []*corev1.Secret { - if c.cfg.SplkCredential == nil { - return nil - } - return []*corev1.Secret{ - { - TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: SplunkFluentdTokenSecretName, - Namespace: LogCollectorNamespace, - }, - Data: map[string][]byte{ - SplunkFluentdSecretTokenKey: c.cfg.SplkCredential.Token, - }, - }, - } -} - -func (c *fluentdComponent) fluentdServiceAccount() *corev1.ServiceAccount { - return &corev1.ServiceAccount{ - TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{Name: c.fluentdNodeName(), Namespace: LogCollectorNamespace}, - } -} - -// packetCaptureApiRole creates a role in the tigera-fluentd namespace to allow pod/exec -// only from fluentd pods. This is being used by the PacketCapture API and created -// by the operator after the namespace tigera-fluentd is created. -func (c *fluentdComponent) packetCaptureApiRole() *rbacv1.Role { - return &rbacv1.Role{ - TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: PacketCaptureAPIRole, - Namespace: LogCollectorNamespace, - }, - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"pods/exec"}, - Verbs: []string{"create"}, - }, - { - APIGroups: []string{""}, - Resources: []string{"pods"}, - Verbs: []string{"list"}, - }, - }, - } -} - -// packetCaptureApiRoleBinding creates a role binding within the tigera-fluentd namespace between the pod/exec role -// the service account tigera-manager. This is being used by the PacketCapture API and created -// by the operator after the namespace tigera-fluentd is created -func (c *fluentdComponent) packetCaptureApiRoleBinding() *rbacv1.RoleBinding { - return &rbacv1.RoleBinding{ - TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: PacketCaptureAPIRoleBinding, - Namespace: LogCollectorNamespace, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "Role", - Name: PacketCaptureAPIRole, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: PacketCaptureServiceAccountName, - Namespace: PacketCaptureNamespace, - }, - }, - } -} - -// managerDeployment creates a deployment for the Tigera Secure manager component. -func (c *fluentdComponent) daemonset() *appsv1.DaemonSet { - var terminationGracePeriod int64 = 0 - // The rationale for this setting is that while there is no need for fluentd to be available, we want to avoid - // potentially negative consequences of an immediate roll-out on huge clusters. - maxUnavailable := intstr.FromInt(10) - - annots := c.cfg.TrustedBundle.HashAnnotations() - - if c.cfg.FluentdKeyPair != nil { - annots[c.cfg.FluentdKeyPair.HashAnnotationKey()] = c.cfg.FluentdKeyPair.HashAnnotationValue() - } - if c.cfg.S3Credential != nil { - annots[s3CredentialHashAnnotation] = rmeta.AnnotationHash(c.cfg.S3Credential) - } - if c.cfg.SplkCredential != nil { - annots[splunkCredentialHashAnnotation] = rmeta.AnnotationHash(c.cfg.SplkCredential) - } - if c.cfg.Filters != nil { - annots[filterHashAnnotation] = rmeta.AnnotationHash(c.cfg.Filters) - } - var initContainers []corev1.Container - if c.cfg.FluentdKeyPair != nil && c.cfg.FluentdKeyPair.UseCertificateManagement() { - initContainers = append(initContainers, c.cfg.FluentdKeyPair.InitContainer(LogCollectorNamespace, c.container().SecurityContext)) - } - - podTemplate := &corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: annots, - }, - Spec: corev1.PodSpec{ - NodeSelector: map[string]string{}, - Tolerations: rmeta.TolerateAll, - ImagePullSecrets: secret.GetReferenceList(c.cfg.PullSecrets), - TerminationGracePeriodSeconds: &terminationGracePeriod, - InitContainers: initContainers, - Containers: []corev1.Container{c.container()}, - Volumes: c.volumes(), - ServiceAccountName: c.fluentdNodeName(), - }, - } - - ds := &appsv1.DaemonSet{ - TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: c.fluentdNodeName(), - Namespace: LogCollectorNamespace, - }, - Spec: appsv1.DaemonSetSpec{ - Template: *podTemplate, - UpdateStrategy: appsv1.DaemonSetUpdateStrategy{ - RollingUpdate: &appsv1.RollingUpdateDaemonSet{ - MaxUnavailable: &maxUnavailable, - }, - }, - }, - } - if c.cfg.LogCollector != nil { - if overrides := c.cfg.LogCollector.Spec.FluentdDaemonSet; overrides != nil { - rcomponents.ApplyDaemonSetOverrides(ds, overrides) - } - } - setNodeCriticalPod(&(ds.Spec.Template)) - return ds -} - -// container creates the fluentd container. -func (c *fluentdComponent) container() corev1.Container { - // Determine environment to pass to the CNI init container. - envs := c.envvars() - volumeMounts := []corev1.VolumeMount{ - {MountPath: c.path("/var/log/calico"), Name: "var-log-calico"}, - {MountPath: c.path("/etc/fluentd/elastic"), Name: certificatemanagement.TrustedCertConfigMapName}, - } - if c.cfg.Filters != nil { - if c.cfg.Filters.Flow != "" { - volumeMounts = append(volumeMounts, - corev1.VolumeMount{ - Name: "fluentd-filters", - MountPath: c.path("/etc/fluentd/flow-filters.conf"), - SubPath: FluentdFilterFlowName, - }) - } - if c.cfg.Filters.DNS != "" { - volumeMounts = append(volumeMounts, - corev1.VolumeMount{ - Name: "fluentd-filters", - MountPath: c.path("/etc/fluentd/dns-filters.conf"), - SubPath: FluentdFilterDNSName, - }) - } - } - - volumeMounts = append(volumeMounts, c.cfg.TrustedBundle.VolumeMounts(c.SupportedOSType())...) - - if c.cfg.FluentdKeyPair != nil { - volumeMounts = append(volumeMounts, c.cfg.FluentdKeyPair.VolumeMount(c.SupportedOSType())) - } - - if c.cfg.ManagedCluster { - volumeMounts = append(volumeMounts, - corev1.VolumeMount{ - Name: LinseedTokenVolumeName, - MountPath: c.path(LinseedVolumeMountPath), - }) - } - - return corev1.Container{ - Name: "fluentd", - Image: c.image, - Env: envs, - // On OpenShift Fluentd needs privileged access to access logs on host path volume - SecurityContext: c.securityContext(c.cfg.Installation.KubernetesProvider.IsOpenShift()), - VolumeMounts: volumeMounts, - StartupProbe: c.startup(), - LivenessProbe: c.liveness(), - ReadinessProbe: c.readiness(), - Ports: []corev1.ContainerPort{{ - Name: "metrics-port", - ContainerPort: FluentdMetricsPort, - }}, - } -} - -func (c *fluentdComponent) metricsService() *corev1.Service { - return &corev1.Service{ - TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: c.fluentdMetricsServiceName(), - Namespace: LogCollectorNamespace, - Labels: map[string]string{"k8s-app": c.fluentdNodeName()}, - }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{"k8s-app": c.fluentdNodeName()}, - // Important: "None" tells Kubernetes that we want a headless service with - // no kube-proxy load balancer. If we omit this then kube-proxy will render - // a huge set of iptables rules for this service since there's an instance - // on every node. - ClusterIP: "None", - Ports: []corev1.ServicePort{ - { - Name: FluentdMetricsPortName, - Port: int32(FluentdMetricsPort), - TargetPort: intstr.FromInt(FluentdMetricsPort), - Protocol: corev1.ProtocolTCP, - }, - }, - }, - } -} - -func (c *fluentdComponent) envvars() []corev1.EnvVar { - envs := []corev1.EnvVar{ - {Name: "LINSEED_ENABLED", Value: "true"}, - // Determine the namespace in which Linseed is running. For managed and standalone clusters, this is always the elasticsearch - // namespace. For multi-tenant management clusters, this may vary. - {Name: "LINSEED_ENDPOINT", Value: relasticsearch.LinseedEndpoint(c.SupportedOSType(), c.cfg.ClusterDomain, LinseedNamespace(c.cfg.Tenant), c.cfg.ManagedCluster, true)}, - {Name: "LINSEED_CA_PATH", Value: c.trustedBundlePath()}, - {Name: "TLS_KEY_PATH", Value: c.keyPath()}, - {Name: "TLS_CRT_PATH", Value: c.certPath()}, - {Name: "FLUENT_UID", Value: "0"}, - {Name: "FLOW_LOG_FILE", Value: c.path("/var/log/calico/flowlogs/flows.log")}, - {Name: "DNS_LOG_FILE", Value: c.path("/var/log/calico/dnslogs/dns.log")}, - // WAF events are written by Felix (gated by FelixConfiguration.WAFEventLogsFileEnabled) and tailed by the - // fluentd-node WAF source. The path is always set; the file only exists when a WAF producer is enabled. - {Name: "WAF_LOG_FILE", Value: c.path("/var/log/calico/waf/waf.log")}, - {Name: "FLUENTD_ES_SECURE", Value: "true"}, - {Name: "NODENAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}}}, - {Name: "LINSEED_TOKEN", Value: c.path(GetLinseedTokenPath(c.cfg.ManagedCluster))}, - } - - if c.cfg.Tenant != nil && c.cfg.ExternalElastic { - envs = append(envs, corev1.EnvVar{Name: "TENANT_ID", Value: c.cfg.Tenant.Spec.ID}) - } - - if c.cfg.LogCollector.Spec.AdditionalStores != nil { - s3 := c.cfg.LogCollector.Spec.AdditionalStores.S3 - if s3 != nil { - envs = append(envs, - corev1.EnvVar{ - Name: "AWS_KEY_ID", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: S3FluentdSecretName, - }, - Key: S3KeyIdName, - }, - }, - }, - corev1.EnvVar{ - Name: "AWS_SECRET_KEY", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: S3FluentdSecretName, - }, - Key: S3KeySecretName, - }, - }, - }, - corev1.EnvVar{Name: "S3_STORAGE", Value: "true"}, - corev1.EnvVar{Name: "S3_BUCKET_NAME", Value: s3.BucketName}, - corev1.EnvVar{Name: "AWS_REGION", Value: s3.Region}, - corev1.EnvVar{Name: "S3_BUCKET_PATH", Value: s3.BucketPath}, - corev1.EnvVar{Name: "S3_FLUSH_INTERVAL", Value: fluentdDefaultFlush}, - ) - - hostScopeEnvVars := envVarsForHostScope(s3.HostScope, ForwardingDestinationS3) - envs = append(envs, hostScopeEnvVars...) - } - syslog := c.cfg.LogCollector.Spec.AdditionalStores.Syslog - if syslog != nil { - proto, host, port, _ := url.ParseEndpoint(syslog.Endpoint) - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_HOST", Value: host}, - corev1.EnvVar{Name: "SYSLOG_PORT", Value: port}, - corev1.EnvVar{Name: "SYSLOG_PROTOCOL", Value: proto}, - corev1.EnvVar{Name: "SYSLOG_FLUSH_INTERVAL", Value: fluentdDefaultFlush}, - corev1.EnvVar{ - Name: "SYSLOG_HOSTNAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{ - FieldPath: "spec.nodeName", - }, - }, - }, - ) - if syslog.PacketSize != nil { - envs = append(envs, - corev1.EnvVar{ - Name: "SYSLOG_PACKET_SIZE", - Value: fmt.Sprintf("%d", *syslog.PacketSize), - }, - ) - } - - if syslog.LogTypes != nil { - for _, t := range syslog.LogTypes { - switch t { - case operatorv1.SyslogLogAudit: - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_AUDIT_EE_LOG", Value: "true"}, - ) - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_AUDIT_KUBE_LOG", Value: "true"}, - ) - case operatorv1.SyslogLogDNS: - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_DNS_LOG", Value: "true"}, - ) - case operatorv1.SyslogLogFlows: - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_FLOW_LOG", Value: "true"}, - ) - case operatorv1.SyslogLogIDSEvents: - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_IDS_EVENT_LOG", Value: "true"}, - ) - } - } - } - - if syslog.Encryption == operatorv1.EncryptionTLS { - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_TLS", Value: "true"}, - ) - // By default, we would be using the secure verification mode OpenSSL::SSL::VERIFY_PEER(1) - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_VERIFY_MODE", Value: "1"}, - ) - if c.cfg.UseSyslogCertificate { - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_CA_FILE", Value: c.cfg.TrustedBundle.MountPath()}, - ) - } else { - envs = append(envs, - corev1.EnvVar{Name: "SYSLOG_CA_FILE", Value: SysLogPublicCAPath}, - ) - } - } - - hostScopeEnvVars := envVarsForHostScope(syslog.HostScope, ForwardingDestinationSyslog) - envs = append(envs, hostScopeEnvVars...) - } - splunk := c.cfg.LogCollector.Spec.AdditionalStores.Splunk - if splunk != nil { - proto, host, port, _ := url.ParseEndpoint(splunk.Endpoint) - envs = append(envs, - corev1.EnvVar{ - Name: "SPLUNK_HEC_TOKEN", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: SplunkFluentdTokenSecretName, - }, - Key: SplunkFluentdSecretTokenKey, - }, - }, - }, - corev1.EnvVar{Name: "SPLUNK_FLOW_LOG", Value: "true"}, - corev1.EnvVar{Name: "SPLUNK_AUDIT_LOG", Value: "true"}, - corev1.EnvVar{Name: "SPLUNK_DNS_LOG", Value: "true"}, - corev1.EnvVar{Name: "SPLUNK_HEC_HOST", Value: host}, - corev1.EnvVar{Name: "SPLUNK_HEC_PORT", Value: port}, - corev1.EnvVar{Name: "SPLUNK_PROTOCOL", Value: proto}, - corev1.EnvVar{Name: "SPLUNK_FLUSH_INTERVAL", Value: fluentdDefaultFlush}, - ) - - hostScopeEnvVars := envVarsForHostScope(splunk.HostScope, ForwardingDestinationSplunk) - envs = append(envs, hostScopeEnvVars...) - } - } - - if c.cfg.Filters != nil { - if c.cfg.Filters.Flow != "" { - envs = append(envs, - corev1.EnvVar{Name: "FLUENTD_FLOW_FILTERS", Value: "true"}) - } - if c.cfg.Filters.DNS != "" { - envs = append(envs, - corev1.EnvVar{Name: "FLUENTD_DNS_FILTERS", Value: "true"}) - } - } - - envs = append(envs, corev1.EnvVar{Name: "CA_CRT_PATH", Value: c.trustedBundlePath()}) - - return envs -} - -func (c *fluentdComponent) trustedBundlePath() string { - if c.cfg.OSType == rmeta.OSTypeWindows { - return certificatemanagement.TrustedCertBundleMountPathWindows - } - return c.cfg.TrustedBundle.MountPath() -} - -func (c *fluentdComponent) keyPath() string { - if c.cfg.OSType == rmeta.OSTypeWindows { - return fmt.Sprintf("c:/%s/%s", c.cfg.FluentdKeyPair.GetName(), corev1.TLSPrivateKeyKey) - } - return c.cfg.FluentdKeyPair.VolumeMountKeyFilePath() -} - -func (c *fluentdComponent) certPath() string { - if c.cfg.OSType == rmeta.OSTypeWindows { - return fmt.Sprintf("c:/%s/%s", c.cfg.FluentdKeyPair.GetName(), corev1.TLSCertKey) - } - return c.cfg.FluentdKeyPair.VolumeMountCertificateFilePath() -} - -// The startup probe uses the same action as the liveness probe, but with -// a higher failure threshold and double the timeout to account for slow -// networks. -func (c *fluentdComponent) startup() *corev1.Probe { - return &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - Exec: &corev1.ExecAction{ - Command: c.livenessCmd(), - }, - }, - TimeoutSeconds: c.probeTimeout, - PeriodSeconds: c.probePeriod, - // tolerate more failures for the startup probe - FailureThreshold: 10, - } -} - -func (c *fluentdComponent) liveness() *corev1.Probe { - return &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - Exec: &corev1.ExecAction{ - Command: c.livenessCmd(), - }, - }, - TimeoutSeconds: c.probeTimeout, - PeriodSeconds: c.probePeriod, - } -} - -func (c *fluentdComponent) readiness() *corev1.Probe { - return &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - Exec: &corev1.ExecAction{ - Command: c.readinessCmd(), - }, - }, - TimeoutSeconds: c.probeTimeout, - PeriodSeconds: c.probePeriod, - } -} - -func (c *fluentdComponent) volumes() []corev1.Volume { - dirOrCreate := corev1.HostPathDirectoryOrCreate - - volumes := []corev1.Volume{ - { - Name: "var-log-calico", - VolumeSource: corev1.VolumeSource{ - HostPath: &corev1.HostPathVolumeSource{ - Path: c.volumeHostPath(), - Type: &dirOrCreate, - }, - }, - }, - } - if c.cfg.Filters != nil { - volumes = append(volumes, - corev1.Volume{ - Name: "fluentd-filters", - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: FluentdFilterConfigMapName, - }, - }, - }, - }) - } - if c.cfg.FluentdKeyPair != nil { - volumes = append(volumes, c.cfg.FluentdKeyPair.Volume()) - } - if c.cfg.ManagedCluster { - volumes = append(volumes, - corev1.Volume{ - Name: LinseedTokenVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: fmt.Sprintf(LinseedTokenSecret, FluentdNodeName), - Items: []corev1.KeyToPath{{Key: LinseedTokenKey, Path: LinseedTokenSubPath}}, - }, - }, - }) - } - volumes = append(volumes, trustedBundleVolume(c.cfg.TrustedBundle)) - - return volumes -} - -func (c *fluentdComponent) fluentdClusterRoleBinding() *rbacv1.ClusterRoleBinding { - return &rbacv1.ClusterRoleBinding{ - TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: c.fluentdName(), - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: c.fluentdName(), - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: c.fluentdNodeName(), - Namespace: LogCollectorNamespace, - }, - }, - } -} - -func (c *fluentdComponent) fluentdClusterRole() *rbacv1.ClusterRole { - role := &rbacv1.ClusterRole{ - TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: c.fluentdName(), - }, - Rules: []rbacv1.PolicyRule{ - { - // Add write access to Linseed APIs. - APIGroups: []string{"linseed.tigera.io"}, - Resources: []string{ - "flowlogs", - "kube_auditlogs", - "ee_auditlogs", - "dnslogs", - "l7logs", - "events", - "bgplogs", - "waflogs", - "runtimereports", - "policyactivity", - }, - Verbs: []string{"create"}, - }, - }, - } - - if c.cfg.Installation.KubernetesProvider.IsOpenShift() { - role.Rules = append(role.Rules, rbacv1.PolicyRule{ - APIGroups: []string{"security.openshift.io"}, - Resources: []string{"securitycontextconstraints"}, - Verbs: []string{"use"}, - ResourceNames: []string{securitycontextconstraints.Privileged}, - }) - } - return role -} - -func (c *fluentdComponent) eksLogForwarderServiceAccount() *corev1.ServiceAccount { - return &corev1.ServiceAccount{ - TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{Name: EKSLogForwarderName, Namespace: LogCollectorNamespace}, - } -} - -func (c *fluentdComponent) eksLogForwarderSecret() *corev1.Secret { - return &corev1.Secret{ - TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: EksLogForwarderSecret, - Namespace: LogCollectorNamespace, - }, - Data: map[string][]byte{ - EksLogForwarderAwsId: c.cfg.EKSConfig.AwsId, - EksLogForwarderAwsKey: c.cfg.EKSConfig.AwsKey, - }, - } -} - -func (c *fluentdComponent) eksLogForwarderDeployment() *appsv1.Deployment { - annots := map[string]string{ - eksCloudwatchLogCredentialHashAnnotation: rmeta.AnnotationHash(c.cfg.EKSConfig), - } - - envVars := []corev1.EnvVar{ - // Meta flags. - {Name: "LOG_LEVEL", Value: "info"}, - {Name: "FLUENT_UID", Value: "0"}, - // Use fluentd for EKS log forwarder. - {Name: "MANAGED_K8S", Value: "true"}, - {Name: "K8S_PLATFORM", Value: "eks"}, - {Name: "FLUENTD_ES_SECURE", Value: "true"}, - // Cloudwatch config, credentials. - {Name: "EKS_CLOUDWATCH_LOG_GROUP", Value: c.cfg.EKSConfig.GroupName}, - {Name: "EKS_CLOUDWATCH_LOG_STREAM_PREFIX", Value: c.cfg.EKSConfig.StreamPrefix}, - {Name: "EKS_CLOUDWATCH_LOG_FETCH_INTERVAL", Value: fmt.Sprintf("%d", c.cfg.EKSConfig.FetchInterval)}, - {Name: "AWS_REGION", Value: c.cfg.EKSConfig.AwsRegion}, - {Name: "AWS_ACCESS_KEY_ID", ValueFrom: secret.GetEnvVarSource(EksLogForwarderSecret, EksLogForwarderAwsId, false)}, - {Name: "AWS_SECRET_ACCESS_KEY", ValueFrom: secret.GetEnvVarSource(EksLogForwarderSecret, EksLogForwarderAwsKey, false)}, - {Name: "LINSEED_ENABLED", Value: "true"}, - // Determine the namespace in which Linseed is running. For managed and standalone clusters, this is always the elasticsearch - // namespace. For multi-tenant management clusters, this may vary. - {Name: "LINSEED_ENDPOINT", Value: relasticsearch.LinseedEndpoint(c.SupportedOSType(), c.cfg.ClusterDomain, LinseedNamespace(c.cfg.Tenant), c.cfg.ManagedCluster, true)}, - {Name: "LINSEED_CA_PATH", Value: c.trustedBundlePath()}, - {Name: "TLS_CRT_PATH", Value: c.cfg.EKSLogForwarderKeyPair.VolumeMountCertificateFilePath()}, - {Name: "TLS_KEY_PATH", Value: c.cfg.EKSLogForwarderKeyPair.VolumeMountKeyFilePath()}, - {Name: "LINSEED_TOKEN", Value: c.path(GetLinseedTokenPath(c.cfg.ManagedCluster))}, - } - if c.cfg.Tenant != nil && c.cfg.ExternalElastic { - envVars = append(envVars, corev1.EnvVar{Name: "TENANT_ID", Value: c.cfg.Tenant.Spec.ID}) - } - - var eksLogForwarderReplicas int32 = 1 - - tolerations := c.cfg.Installation.ControlPlaneTolerations - if c.cfg.Installation.KubernetesProvider.IsGKE() { - tolerations = append(tolerations, rmeta.TolerateGKEARM64NoSchedule) - } - - d := &appsv1.Deployment{ - TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: EKSLogForwarderName, - Namespace: LogCollectorNamespace, - Labels: map[string]string{ - "k8s-app": EKSLogForwarderName, - }, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &eksLogForwarderReplicas, - Strategy: appsv1.DeploymentStrategy{ - Type: appsv1.RecreateDeploymentStrategyType, - }, - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "k8s-app": EKSLogForwarderName, - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Name: EKSLogForwarderName, - Namespace: LogCollectorNamespace, - Labels: map[string]string{ - "k8s-app": EKSLogForwarderName, - }, - Annotations: annots, - }, - Spec: corev1.PodSpec{ - Tolerations: tolerations, - ServiceAccountName: EKSLogForwarderName, - ImagePullSecrets: secret.GetReferenceList(c.cfg.PullSecrets), - InitContainers: []corev1.Container{{ - Name: EKSLogForwarderName + "-startup", - Image: c.image, - Command: []string{c.path("/bin/eks-log-forwarder-startup")}, - Env: envVars, - SecurityContext: c.securityContext(false), - VolumeMounts: c.eksLogForwarderVolumeMounts(), - }}, - Containers: []corev1.Container{{ - Name: EKSLogForwarderName, - Image: c.image, - Env: envVars, - SecurityContext: c.securityContext(false), - VolumeMounts: c.eksLogForwarderVolumeMounts(), - }}, - Volumes: c.eksLogForwarderVolumes(), - }, - }, - }, - } - - if c.cfg.LogCollector != nil { - if overrides := c.cfg.LogCollector.Spec.EKSLogForwarderDeployment; overrides != nil { - rcomponents.ApplyDeploymentOverrides(d, overrides) - } - } - - return d -} - -func trustedBundleVolume(bundle certificatemanagement.TrustedBundle) corev1.Volume { - volume := bundle.Volume() - // We mount the bundle under two names; the standard name and the name for the expected elastic cert. - volume.ConfigMap.Items = []corev1.KeyToPath{ - {Key: certificatemanagement.TrustedCertConfigMapKeyName, Path: certificatemanagement.TrustedCertConfigMapKeyName}, - //nolint:staticcheck // Ignore SA1019 deprecated - {Key: certificatemanagement.TrustedCertConfigMapKeyName, Path: certificatemanagement.LegacyTrustedCertConfigMapKeyName}, - {Key: certificatemanagement.TrustedCertConfigMapKeyName, Path: SplunkFluentdSecretCertificateKey}, - {Key: certificatemanagement.RHELRootCertificateBundleName, Path: certificatemanagement.RHELRootCertificateBundleName}, - } - return volume -} - -func (c *fluentdComponent) eksLogForwarderVolumeMounts() []corev1.VolumeMount { - volumeMounts := []corev1.VolumeMount{ - { - Name: "plugin-statefile-dir", - MountPath: c.path("/fluentd/cloudwatch-logs/"), - }, - { - Name: certificatemanagement.TrustedCertConfigMapName, - MountPath: c.path("/etc/fluentd/elastic/"), - }, - } - volumeMounts = append(volumeMounts, c.cfg.TrustedBundle.VolumeMounts(c.SupportedOSType())...) - if c.cfg.EKSLogForwarderKeyPair != nil { - volumeMounts = append(volumeMounts, c.cfg.EKSLogForwarderKeyPair.VolumeMount(c.SupportedOSType())) - } - - if c.cfg.ManagedCluster { - volumeMounts = append(volumeMounts, - corev1.VolumeMount{ - Name: LinseedTokenVolumeName, - MountPath: c.path(LinseedVolumeMountPath), - }) - } - return volumeMounts -} - -func (c *fluentdComponent) eksLogForwarderVolumes() []corev1.Volume { - volumes := []corev1.Volume{ - trustedBundleVolume(c.cfg.TrustedBundle), - { - Name: "plugin-statefile-dir", - VolumeSource: corev1.VolumeSource{ - EmptyDir: nil, - }, - }, - } - if c.cfg.EKSLogForwarderKeyPair != nil { - volumes = append(volumes, c.cfg.EKSLogForwarderKeyPair.Volume()) - } - - if c.cfg.ManagedCluster { - volumes = append(volumes, - corev1.Volume{ - Name: LinseedTokenVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: fmt.Sprintf(LinseedTokenSecret, EKSLogForwarderName), - Items: []corev1.KeyToPath{{Key: LinseedTokenKey, Path: LinseedTokenSubPath}}, - }, - }, - }) - } - return volumes -} - -func (c *fluentdComponent) eksLogForwarderClusterRoleBinding() *rbacv1.ClusterRoleBinding { - return &rbacv1.ClusterRoleBinding{ - TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: EKSLogForwarderName, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: EKSLogForwarderName, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: EKSLogForwarderName, - Namespace: LogCollectorNamespace, - }, - }, - } -} - -func (c *fluentdComponent) eksLogForwarderClusterRole() *rbacv1.ClusterRole { - rules := []rbacv1.PolicyRule{ - { - // Add read access to Linseed APIs. - APIGroups: []string{"linseed.tigera.io"}, - Resources: []string{ - "auditlogs", - }, - Verbs: []string{"get"}, - }, - { - // Add write access to Linseed APIs to flush eks kube audit logs. - APIGroups: []string{"linseed.tigera.io"}, - Resources: []string{ - "kube_auditlogs", - }, - Verbs: []string{"create"}, - }, - } - - return &rbacv1.ClusterRole{ - TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: EKSLogForwarderName, - }, - Rules: rules, - } -} - -func (c *fluentdComponent) calicoSystemPolicy() *v3.NetworkPolicy { - multiTenant := false - tenantNamespace := "" - if c.cfg.Tenant != nil { - multiTenant = true - tenantNamespace = c.cfg.Tenant.Namespace - } - policyHelper := networkpolicy.Helper(multiTenant, tenantNamespace) - - egressRules := []v3.Rule{} - if c.cfg.ManagedCluster { - egressRules = append(egressRules, v3.Rule{ - Action: v3.Deny, - Protocol: &networkpolicy.TCPProtocol, - Source: v3.EntityRule{}, - Destination: v3.EntityRule{ - NamespaceSelector: fmt.Sprintf("projectcalico.org/name == '%s'", GuardianNamespace), - Selector: networkpolicy.KubernetesAppSelector(GuardianServiceName), - NotPorts: networkpolicy.Ports(8080), - }, - }) - } else { - egressRules = append(egressRules, v3.Rule{ - Action: v3.Deny, - Protocol: &networkpolicy.TCPProtocol, - Source: v3.EntityRule{}, - Destination: v3.EntityRule{ - NamespaceSelector: fmt.Sprintf("projectcalico.org/name == '%s'", ElasticsearchNamespace), - Selector: networkpolicy.KubernetesAppSelector("tigera-secure-es-gateway"), - NotPorts: networkpolicy.Ports(5554), - }, - }) - egressRules = append(egressRules, v3.Rule{ - Action: v3.Deny, - Protocol: &networkpolicy.TCPProtocol, - Source: v3.EntityRule{}, - Destination: v3.EntityRule{ - NamespaceSelector: fmt.Sprintf("projectcalico.org/name == '%s'", ElasticsearchNamespace), - Selector: networkpolicy.KubernetesAppSelector("tigera-linseed"), - NotPorts: networkpolicy.Ports(8444), - }, - }) - egressRules = networkpolicy.AppendDNSEgressRules(egressRules, c.cfg.Installation.KubernetesProvider.IsOpenShift()) - } - egressRules = append(egressRules, v3.Rule{ - Action: v3.Allow, - }) - - ingressRules := []v3.Rule{ - { - Action: v3.Allow, - Protocol: &networkpolicy.TCPProtocol, - Source: networkpolicy.PrometheusSourceEntityRule, - Destination: v3.EntityRule{ - Ports: networkpolicy.Ports(FluentdMetricsPort), - }, - }, - } - - if c.cfg.NonClusterHost != nil { - ingressRules = append(ingressRules, v3.Rule{ - Action: v3.Allow, - Protocol: &networkpolicy.TCPProtocol, - Source: policyHelper.ManagerSourceEntityRule(), - Destination: v3.EntityRule{ - Ports: networkpolicy.Ports(FluentdInputPort), - }, - }) - } - - return &v3.NetworkPolicy{ - TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}, - ObjectMeta: metav1.ObjectMeta{ - Name: FluentdPolicyName, - Namespace: LogCollectorNamespace, - }, - Spec: v3.NetworkPolicySpec{ - Order: &networkpolicy.HighPrecedenceOrder, - Tier: networkpolicy.CalicoTierName, - Selector: networkpolicy.KubernetesAppSelector(FluentdNodeName, fluentdNodeWindowsName), - ServiceAccountSelector: "", - Types: []v3.PolicyType{v3.PolicyTypeIngress, v3.PolicyTypeEgress}, - Ingress: ingressRules, - Egress: egressRules, - }, - } -} - -func envVarsForHostScope(hostScope *operatorv1.HostScope, destination ForwardingDestination) []corev1.EnvVar { - var forwardClusterLogs, forwardNonClusterLogs bool - if hostScope == nil || *hostScope != operatorv1.HostScopeNonClusterOnly { - forwardClusterLogs = true - forwardNonClusterLogs = true - } else { - forwardClusterLogs = false - forwardNonClusterLogs = true - } - - return []corev1.EnvVar{ - {Name: fmt.Sprintf("FORWARD_CLUSTER_LOGS_TO_%s", strings.ToUpper(string(destination))), Value: strconv.FormatBool(forwardClusterLogs)}, - {Name: fmt.Sprintf("FORWARD_NON_CLUSTER_LOGS_TO_%s", strings.ToUpper(string(destination))), Value: strconv.FormatBool(forwardNonClusterLogs)}, - } -} diff --git a/pkg/render/fluentd_test.go b/pkg/render/fluentd_test.go deleted file mode 100644 index 66fc1c13ec..0000000000 --- a/pkg/render/fluentd_test.go +++ /dev/null @@ -1,1442 +0,0 @@ -// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package render_test - -import ( - "fmt" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/tigera/operator/pkg/render/common/networkpolicy" - "k8s.io/apimachinery/pkg/util/intstr" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" - - v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" - - operatorv1 "github.com/tigera/operator/api/v1" - "github.com/tigera/operator/pkg/apis" - "github.com/tigera/operator/pkg/common" - "github.com/tigera/operator/pkg/controller/certificatemanager" - ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" - "github.com/tigera/operator/pkg/dns" - "github.com/tigera/operator/pkg/render" - relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" - rmeta "github.com/tigera/operator/pkg/render/common/meta" - "github.com/tigera/operator/pkg/render/common/secret" - rtest "github.com/tigera/operator/pkg/render/common/test" - "github.com/tigera/operator/pkg/render/testutils" - "github.com/tigera/operator/pkg/tls" - "github.com/tigera/operator/pkg/tls/certificatemanagement" - "github.com/tigera/operator/test" -) - -var _ = Describe("Tigera Secure Fluentd rendering tests", func() { - var cfg *render.FluentdConfiguration - var cli client.Client - - expectedFluentdPolicyForUnmanaged := testutils.GetExpectedPolicyFromFile("testutils/expected_policies/fluentd_unmanaged.json") - expectedFluentdPolicyForUnmanagedOpenshift := testutils.GetExpectedPolicyFromFile("testutils/expected_policies/fluentd_unmanaged_ocp.json") - expectedFluentdPolicyForManaged := testutils.GetExpectedPolicyFromFile("testutils/expected_policies/fluentd_managed.json") - - BeforeEach(func() { - // Initialize a default instance to use. Each test can override this to its - // desired configuration. - scheme := runtime.NewScheme() - Expect(apis.AddToScheme(scheme, false)).NotTo(HaveOccurred()) - cli = ctrlrfake.DefaultFakeClientBuilder(scheme).Build() - - certificateManager, err := certificatemanager.Create(cli, nil, clusterDomain, common.OperatorNamespace(), certificatemanager.AllowCACreation()) - Expect(err).NotTo(HaveOccurred()) - - metricsSecret, err := certificateManager.GetOrCreateKeyPair(cli, render.FluentdPrometheusTLSSecretName, common.OperatorNamespace(), []string{""}) - Expect(err).NotTo(HaveOccurred()) - eksSecret, err := certificateManager.GetOrCreateKeyPair(cli, render.EKSLogForwarderTLSSecretName, common.OperatorNamespace(), []string{""}) - Expect(err).NotTo(HaveOccurred()) - cfg = &render.FluentdConfiguration{ - LogCollector: &operatorv1.LogCollector{}, - ClusterDomain: dns.DefaultClusterDomain, - OSType: rmeta.OSTypeLinux, - Installation: &operatorv1.InstallationSpec{ - KubernetesProvider: operatorv1.ProviderNone, - }, - FluentdKeyPair: metricsSecret, - EKSLogForwarderKeyPair: eksSecret, - TrustedBundle: certificateManager.CreateTrustedBundle(), - } - }) - - It("should render SecurityContextConstrains properly when provider is OpenShift", func() { - cfg.Installation.KubernetesProvider = operatorv1.ProviderOpenShift - component := render.Fluentd(cfg) - Expect(component.ResolveImages(nil)).To(BeNil()) - resources, _ := component.Objects() - - // tigera-fluentd clusterRole should have openshift securitycontextconstraints PolicyRule - fluentdRole := rtest.GetResource(resources, "tigera-fluentd", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(fluentdRole.Rules).To(ContainElement(rbacv1.PolicyRule{ - APIGroups: []string{"security.openshift.io"}, - Resources: []string{"securitycontextconstraints"}, - Verbs: []string{"use"}, - ResourceNames: []string{"privileged"}, - })) - }) - - It("should render with a default configuration", func() { - expectedResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, - &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: render.PacketCaptureAPIRole, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.PacketCaptureAPIRoleBinding, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, - } - - cfg.PacketCapture = &operatorv1.PacketCaptureAPI{ - ObjectMeta: metav1.ObjectMeta{ - Name: "tigera-secure", - }, - } - - // Should render the correct resources. - component := render.Fluentd(cfg) - resources, _ := component.Objects() - rtest.ExpectResources(resources, expectedResources) - - ds := rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Volumes[0].VolumeSource.HostPath.Path).To(Equal("/var/log/calico")) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - envs := ds.Spec.Template.Spec.Containers[0].Env - - Expect(envs).Should(ContainElements( - corev1.EnvVar{Name: "LINSEED_ENABLED", Value: "true"}, - corev1.EnvVar{Name: "LINSEED_ENDPOINT", Value: "https://tigera-linseed.tigera-elasticsearch.svc"}, - corev1.EnvVar{Name: "LINSEED_CA_PATH", Value: "/etc/pki/tls/certs/tigera-ca-bundle.crt"}, - corev1.EnvVar{Name: "TLS_KEY_PATH", Value: "/tigera-fluentd-prometheus-tls/tls.key"}, - corev1.EnvVar{Name: "TLS_CRT_PATH", Value: "/tigera-fluentd-prometheus-tls/tls.crt"}, - corev1.EnvVar{Name: "FLUENT_UID", Value: "0"}, - corev1.EnvVar{Name: "FLOW_LOG_FILE", Value: "/var/log/calico/flowlogs/flows.log"}, - corev1.EnvVar{Name: "DNS_LOG_FILE", Value: "/var/log/calico/dnslogs/dns.log"}, - corev1.EnvVar{Name: "WAF_LOG_FILE", Value: "/var/log/calico/waf/waf.log"}, - corev1.EnvVar{Name: "FLUENTD_ES_SECURE", Value: "true"}, - corev1.EnvVar{ - Name: "NODENAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, - }, - }, - corev1.EnvVar{Name: "LINSEED_TOKEN", Value: "/var/run/secrets/kubernetes.io/serviceaccount/token"}, - )) - - Expect(envs).ShouldNot(ContainElements( - corev1.EnvVar{Name: "ELASTIC_INDEX_SUFFIX", Value: "clusterTestName"}, - corev1.EnvVar{Name: "ELASTIC_SCHEME", Value: "https"}, - corev1.EnvVar{Name: "ELASTIC_HOST", Value: "tigera-secure-es-gateway-http.tigera-elasticsearch.svc"}, - corev1.EnvVar{Name: "ELASTIC_PORT", Value: "9200"}, - corev1.EnvVar{Name: "ELASTIC_USER", ValueFrom: secret.GetEnvVarSource("tigera-eks-log-forwarder-elasticsearch-access", "username", false)}, - corev1.EnvVar{Name: "ELASTIC_PASSWORD", ValueFrom: secret.GetEnvVarSource("tigera-eks-log-forwarder-elasticsearch-access", "password", false)}, - corev1.EnvVar{Name: "ELASTIC_CA", Value: "/etc/pki/tls/certs/tigera-ca-bundle.crt"}, - )) - - container := ds.Spec.Template.Spec.Containers[0] - - Expect(container.ReadinessProbe.Exec.Command).To(ConsistOf([]string{"sh", "-c", "/bin/readiness.sh"})) - Expect(container.ReadinessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.ReadinessProbe.PeriodSeconds).To(BeEquivalentTo(60)) - - Expect(container.LivenessProbe.Exec.Command).To(ConsistOf([]string{"sh", "-c", "/bin/liveness.sh"})) - Expect(container.LivenessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.LivenessProbe.PeriodSeconds).To(BeEquivalentTo(60)) - - Expect(container.StartupProbe.Exec.Command).To(ConsistOf([]string{"sh", "-c", "/bin/liveness.sh"})) - Expect(container.StartupProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.StartupProbe.PeriodSeconds).To(BeEquivalentTo(60)) - Expect(container.StartupProbe.FailureThreshold).To(BeEquivalentTo(10)) - - Expect(*container.SecurityContext.AllowPrivilegeEscalation).To(BeFalse()) - Expect(*container.SecurityContext.Privileged).To(BeFalse()) - Expect(*container.SecurityContext.RunAsGroup).To(BeEquivalentTo(0)) - Expect(*container.SecurityContext.RunAsNonRoot).To(BeFalse()) - Expect(*container.SecurityContext.RunAsUser).To(BeEquivalentTo(0)) - Expect(container.SecurityContext.Capabilities).To(Equal( - &corev1.Capabilities{ - Drop: []corev1.Capability{"ALL"}, - }, - )) - Expect(container.SecurityContext.SeccompProfile).To(Equal( - &corev1.SeccompProfile{ - Type: corev1.SeccompProfileTypeRuntimeDefault, - })) - - podExecRole := rtest.GetResource(resources, render.PacketCaptureAPIRole, render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "Role").(*rbacv1.Role) - Expect(podExecRole.Rules).To(ConsistOf([]rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"pods/exec"}, - Verbs: []string{"create"}, - }, - { - APIGroups: []string{""}, - Resources: []string{"pods"}, - Verbs: []string{"list"}, - }, - })) - podExecRoleBinding := rtest.GetResource(resources, render.PacketCaptureAPIRoleBinding, render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "RoleBinding").(*rbacv1.RoleBinding) - Expect(podExecRoleBinding.RoleRef.Name).To(Equal(render.PacketCaptureAPIRole)) - Expect(podExecRoleBinding.Subjects).To(ConsistOf([]rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: render.PacketCaptureServiceAccountName, - Namespace: render.PacketCaptureNamespace, - }, - })) - - // The metrics service should have the correct configuration. - ms := rtest.GetResource(resources, render.FluentdMetricsService, render.LogCollectorNamespace, "", "v1", "Service").(*corev1.Service) - Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless to prevent kube-proxy from rendering too many iptables rules") - }) - - It("should render fluentd Daemonset with resources requests/limits", func() { - ca, _ := tls.MakeCA(rmeta.DefaultOperatorCASignerName()) - cert, _, _ := ca.Config.GetPEMBytes() // create a valid pem block - cfg.Installation.CertificateManagement = &operatorv1.CertificateManagement{CACert: cert} - - certificateManager, err := certificatemanager.Create(cli, cfg.Installation, clusterDomain, common.OperatorNamespace(), certificatemanager.AllowCACreation()) - Expect(err).NotTo(HaveOccurred()) - - metricsSecret, err := certificateManager.GetOrCreateKeyPair(cli, render.FluentdPrometheusTLSSecretName, common.OperatorNamespace(), []string{""}) - Expect(err).NotTo(HaveOccurred()) - - cfg.FluentdKeyPair = metricsSecret - - fluentdResources := corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - "cpu": resource.MustParse("2"), - "memory": resource.MustParse("300Mi"), - "storage": resource.MustParse("20Gi"), - }, - Requests: corev1.ResourceList{ - "cpu": resource.MustParse("1"), - "memory": resource.MustParse("150Mi"), - "storage": resource.MustParse("10Gi"), - }, - } - - logCollectorcfg := operatorv1.LogCollector{ - Spec: operatorv1.LogCollectorSpec{ - FluentdDaemonSet: &operatorv1.FluentdDaemonSet{ - Spec: &operatorv1.FluentdDaemonSetSpec{ - Template: &operatorv1.FluentdDaemonSetPodTemplateSpec{ - Spec: &operatorv1.FluentdDaemonSetPodSpec{ - InitContainers: []operatorv1.FluentdDaemonSetInitContainer{{ - Name: "tigera-fluentd-prometheus-tls-key-cert-provisioner", - Resources: &fluentdResources, - }}, - Containers: []operatorv1.FluentdDaemonSetContainer{{ - Name: "fluentd", - Resources: &fluentdResources, - }}, - }, - }, - }, - }, - }, - } - - cfg.LogCollector = &logCollectorcfg - component := render.Fluentd(cfg) - resources, _ := component.Objects() - - ds := rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - - container := test.GetContainer(ds.Spec.Template.Spec.Containers, "fluentd") - Expect(container).NotTo(BeNil()) - Expect(container.Resources).To(Equal(fluentdResources)) - - Expect(ds.Spec.Template.Spec.InitContainers).To(HaveLen(1)) - initContainer := test.GetContainer(ds.Spec.Template.Spec.InitContainers, "tigera-fluentd-prometheus-tls-key-cert-provisioner") - Expect(initContainer).NotTo(BeNil()) - Expect(initContainer.Resources).To(Equal(fluentdResources)) - }) - - It("should render with a configuration for a managed cluster", func() { - expectedResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}}, - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsService, Namespace: render.LogCollectorNamespace}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdNodeName, Namespace: render.LogCollectorNamespace}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdNodeName, Namespace: render.LogCollectorNamespace}}, - &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}, - } - - expectedDeleteResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: "allow-tigera.allow-fluentd-node", Namespace: render.LogCollectorNamespace}}, - } - - // Should render the correct resources. - managedCfg := &render.FluentdConfiguration{ - LogCollector: cfg.LogCollector, - ClusterDomain: cfg.ClusterDomain, - OSType: cfg.OSType, - Installation: cfg.Installation, - FluentdKeyPair: cfg.FluentdKeyPair, - TrustedBundle: cfg.TrustedBundle, - ManagedCluster: true, - } - component := render.Fluentd(managedCfg) - createResources, deleteResources := component.Objects() - rtest.ExpectResources(createResources, expectedResources) - rtest.ExpectResources(deleteResources, expectedDeleteResources) - - ds := rtest.GetResource(createResources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Volumes[0].VolumeSource.HostPath.Path).To(Equal("/var/log/calico")) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - envs := ds.Spec.Template.Spec.Containers[0].Env - - Expect(envs).Should(ContainElements( - corev1.EnvVar{Name: "LINSEED_ENABLED", Value: "true"}, - corev1.EnvVar{Name: "LINSEED_ENDPOINT", Value: "https://tigera-linseed"}, - corev1.EnvVar{Name: "LINSEED_CA_PATH", Value: "/etc/pki/tls/certs/tigera-ca-bundle.crt"}, - corev1.EnvVar{Name: "TLS_KEY_PATH", Value: "/tigera-fluentd-prometheus-tls/tls.key"}, - corev1.EnvVar{Name: "TLS_CRT_PATH", Value: "/tigera-fluentd-prometheus-tls/tls.crt"}, - corev1.EnvVar{Name: "FLUENT_UID", Value: "0"}, - corev1.EnvVar{Name: "FLOW_LOG_FILE", Value: "/var/log/calico/flowlogs/flows.log"}, - corev1.EnvVar{Name: "DNS_LOG_FILE", Value: "/var/log/calico/dnslogs/dns.log"}, - corev1.EnvVar{Name: "WAF_LOG_FILE", Value: "/var/log/calico/waf/waf.log"}, - corev1.EnvVar{Name: "FLUENTD_ES_SECURE", Value: "true"}, - corev1.EnvVar{ - Name: "NODENAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, - }, - }, - corev1.EnvVar{Name: "LINSEED_TOKEN", Value: "/var/run/secrets/tigera.io/linseed/token"}, - )) - - container := ds.Spec.Template.Spec.Containers[0] - - Expect(container.ReadinessProbe.Exec.Command).To(ConsistOf([]string{"sh", "-c", "/bin/readiness.sh"})) - Expect(container.ReadinessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.ReadinessProbe.PeriodSeconds).To(BeEquivalentTo(60)) - - Expect(container.LivenessProbe.Exec.Command).To(ConsistOf([]string{"sh", "-c", "/bin/liveness.sh"})) - Expect(container.LivenessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.LivenessProbe.PeriodSeconds).To(BeEquivalentTo(60)) - - Expect(container.StartupProbe.Exec.Command).To(ConsistOf([]string{"sh", "-c", "/bin/liveness.sh"})) - Expect(container.StartupProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.StartupProbe.PeriodSeconds).To(BeEquivalentTo(60)) - Expect(container.StartupProbe.FailureThreshold).To(BeEquivalentTo(10)) - - Expect(*container.SecurityContext.AllowPrivilegeEscalation).To(BeFalse()) - Expect(*container.SecurityContext.Privileged).To(BeFalse()) - Expect(*container.SecurityContext.RunAsGroup).To(BeEquivalentTo(0)) - Expect(*container.SecurityContext.RunAsNonRoot).To(BeFalse()) - Expect(*container.SecurityContext.RunAsUser).To(BeEquivalentTo(0)) - Expect(container.SecurityContext.Capabilities).To(Equal( - &corev1.Capabilities{ - Drop: []corev1.Capability{"ALL"}, - }, - )) - Expect(container.SecurityContext.SeccompProfile).To(Equal( - &corev1.SeccompProfile{ - Type: corev1.SeccompProfileTypeRuntimeDefault, - })) - - linseedRoleBinding := rtest.GetResource(createResources, "tigera-linseed", render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "RoleBinding").(*rbacv1.RoleBinding) - Expect(linseedRoleBinding.RoleRef.Name).To(Equal("tigera-linseed-secrets")) - Expect(linseedRoleBinding.Subjects).To(ConsistOf([]rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: render.GuardianServiceAccountName, - Namespace: render.GuardianNamespace, - }, - })) - - // The metrics service should have the correct configuration. - ms := rtest.GetResource(createResources, render.FluentdMetricsService, render.LogCollectorNamespace, "", "v1", "Service").(*corev1.Service) - Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless to prevent kube-proxy from rendering too many iptables rules") - }) - - It("should render with a configuration for a managed cluster with packet capture", func() { - expectedResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}}, - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsService, Namespace: render.LogCollectorNamespace}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdNodeName, Namespace: render.LogCollectorNamespace}}, - &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: render.PacketCaptureAPIRole, Namespace: render.LogCollectorNamespace}}, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.PacketCaptureAPIRoleBinding, Namespace: render.LogCollectorNamespace}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdNodeName, Namespace: render.LogCollectorNamespace}}, - &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}, - } - - expectedDeleteResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: "allow-tigera.allow-fluentd-node", Namespace: render.LogCollectorNamespace}}, - } - - pc := &operatorv1.PacketCaptureAPI{ - ObjectMeta: metav1.ObjectMeta{ - Name: "tigera-secure", - }, - } - - // Should render the correct resources. - managedCfg := &render.FluentdConfiguration{ - LogCollector: cfg.LogCollector, - ClusterDomain: cfg.ClusterDomain, - OSType: cfg.OSType, - Installation: cfg.Installation, - FluentdKeyPair: cfg.FluentdKeyPair, - TrustedBundle: cfg.TrustedBundle, - ManagedCluster: true, - PacketCapture: pc, - } - component := render.Fluentd(managedCfg) - createResources, deleteResources := component.Objects() - rtest.ExpectResources(createResources, expectedResources) - rtest.ExpectResources(deleteResources, expectedDeleteResources) - - ds := rtest.GetResource(createResources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Volumes[0].VolumeSource.HostPath.Path).To(Equal("/var/log/calico")) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - envs := ds.Spec.Template.Spec.Containers[0].Env - - Expect(envs).Should(ContainElements( - corev1.EnvVar{Name: "LINSEED_ENABLED", Value: "true"}, - corev1.EnvVar{Name: "LINSEED_ENDPOINT", Value: "https://tigera-linseed"}, - corev1.EnvVar{Name: "LINSEED_CA_PATH", Value: "/etc/pki/tls/certs/tigera-ca-bundle.crt"}, - corev1.EnvVar{Name: "TLS_KEY_PATH", Value: "/tigera-fluentd-prometheus-tls/tls.key"}, - corev1.EnvVar{Name: "TLS_CRT_PATH", Value: "/tigera-fluentd-prometheus-tls/tls.crt"}, - corev1.EnvVar{Name: "FLUENT_UID", Value: "0"}, - corev1.EnvVar{Name: "FLOW_LOG_FILE", Value: "/var/log/calico/flowlogs/flows.log"}, - corev1.EnvVar{Name: "DNS_LOG_FILE", Value: "/var/log/calico/dnslogs/dns.log"}, - corev1.EnvVar{Name: "WAF_LOG_FILE", Value: "/var/log/calico/waf/waf.log"}, - corev1.EnvVar{Name: "FLUENTD_ES_SECURE", Value: "true"}, - corev1.EnvVar{ - Name: "NODENAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, - }, - }, - corev1.EnvVar{Name: "LINSEED_TOKEN", Value: "/var/run/secrets/tigera.io/linseed/token"}, - )) - - container := ds.Spec.Template.Spec.Containers[0] - - Expect(container.ReadinessProbe.Exec.Command).To(ConsistOf([]string{"sh", "-c", "/bin/readiness.sh"})) - Expect(container.ReadinessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.ReadinessProbe.PeriodSeconds).To(BeEquivalentTo(60)) - - Expect(container.LivenessProbe.Exec.Command).To(ConsistOf([]string{"sh", "-c", "/bin/liveness.sh"})) - Expect(container.LivenessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.LivenessProbe.PeriodSeconds).To(BeEquivalentTo(60)) - - Expect(container.StartupProbe.Exec.Command).To(ConsistOf([]string{"sh", "-c", "/bin/liveness.sh"})) - Expect(container.StartupProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.StartupProbe.PeriodSeconds).To(BeEquivalentTo(60)) - Expect(container.StartupProbe.FailureThreshold).To(BeEquivalentTo(10)) - - Expect(*container.SecurityContext.AllowPrivilegeEscalation).To(BeFalse()) - Expect(*container.SecurityContext.Privileged).To(BeFalse()) - Expect(*container.SecurityContext.RunAsGroup).To(BeEquivalentTo(0)) - Expect(*container.SecurityContext.RunAsNonRoot).To(BeFalse()) - Expect(*container.SecurityContext.RunAsUser).To(BeEquivalentTo(0)) - Expect(container.SecurityContext.Capabilities).To(Equal( - &corev1.Capabilities{ - Drop: []corev1.Capability{"ALL"}, - }, - )) - Expect(container.SecurityContext.SeccompProfile).To(Equal( - &corev1.SeccompProfile{ - Type: corev1.SeccompProfileTypeRuntimeDefault, - })) - - linseedRoleBinding := rtest.GetResource(createResources, "tigera-linseed", render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "RoleBinding").(*rbacv1.RoleBinding) - Expect(linseedRoleBinding.RoleRef.Name).To(Equal("tigera-linseed-secrets")) - Expect(linseedRoleBinding.Subjects).To(ConsistOf([]rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: render.GuardianServiceAccountName, - Namespace: render.GuardianNamespace, - }, - })) - - podExecRole := rtest.GetResource(createResources, render.PacketCaptureAPIRole, render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "Role").(*rbacv1.Role) - Expect(podExecRole.Rules).To(ConsistOf([]rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"pods/exec"}, - Verbs: []string{"create"}, - }, - { - APIGroups: []string{""}, - Resources: []string{"pods"}, - Verbs: []string{"list"}, - }, - })) - podExecRoleBinding := rtest.GetResource(createResources, render.PacketCaptureAPIRoleBinding, render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "RoleBinding").(*rbacv1.RoleBinding) - Expect(podExecRoleBinding.RoleRef.Name).To(Equal(render.PacketCaptureAPIRole)) - Expect(podExecRoleBinding.Subjects).To(ConsistOf([]rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: render.PacketCaptureServiceAccountName, - Namespace: render.PacketCaptureNamespace, - }, - })) - - // The metrics service should have the correct configuration. - ms := rtest.GetResource(createResources, render.FluentdMetricsService, render.LogCollectorNamespace, "", "v1", "Service").(*corev1.Service) - Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless to prevent kube-proxy from rendering too many iptables rules") - }) - - It("should render with a resource quota for provider GKE", func() { - cfg.Installation.KubernetesProvider = operatorv1.ProviderGKE - - // Should render the correct resources. - component := render.Fluentd(cfg) - resources, _ := component.Objects() - - // Should render resource quota - Expect(rtest.GetResource(resources, "tigera-critical-pods", "tigera-fluentd", "", "v1", "ResourceQuota")).ToNot(BeNil()) - }) - - It("should render for Windows nodes", func() { - expectedResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsServiceWindows, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd-windows"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd-windows"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node-windows", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node-windows", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, - } - - cfg.OSType = rmeta.OSTypeWindows - // Should render the correct resources. - component := render.Fluentd(cfg) - resources, _ := component.Objects() - rtest.ExpectResources(resources, expectedResources) - - ds := rtest.GetResource(resources, "fluentd-node-windows", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Volumes[0].VolumeSource.HostPath.Path).To(Equal("c:/TigeraCalico")) - - envs := ds.Spec.Template.Spec.Containers[0].Env - - expectedEnvs := []corev1.EnvVar{ - {Name: "LINSEED_ENABLED", Value: "true"}, - {Name: "LINSEED_ENDPOINT", Value: "https://tigera-linseed.tigera-elasticsearch.svc.cluster.local"}, - {Name: "LINSEED_CA_PATH", Value: certificatemanagement.TrustedCertBundleMountPathWindows}, - {Name: "TLS_KEY_PATH", Value: "c:/tigera-fluentd-prometheus-tls/tls.key"}, - {Name: "TLS_CRT_PATH", Value: "c:/tigera-fluentd-prometheus-tls/tls.crt"}, - {Name: "FLUENT_UID", Value: "0"}, - {Name: "FLOW_LOG_FILE", Value: "c:/var/log/calico/flowlogs/flows.log"}, - {Name: "DNS_LOG_FILE", Value: "c:/var/log/calico/dnslogs/dns.log"}, - {Name: "WAF_LOG_FILE", Value: "c:/var/log/calico/waf/waf.log"}, - {Name: "FLUENTD_ES_SECURE", Value: "true"}, - { - Name: "NODENAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, - }, - }, - {Name: "LINSEED_TOKEN", Value: "c:/var/run/secrets/kubernetes.io/serviceaccount/token"}, - } - for _, expected := range expectedEnvs { - Expect(envs).To(ContainElement(expected)) - } - - ds = rtest.GetResource(resources, "fluentd-node-windows", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - envs = ds.Spec.Template.Spec.Containers[0].Env - - expectedEnvs = []corev1.EnvVar{ - {Name: "FLUENT_UID", Value: "0"}, - {Name: "FLOW_LOG_FILE", Value: "c:/var/log/calico/flowlogs/flows.log"}, - {Name: "DNS_LOG_FILE", Value: "c:/var/log/calico/dnslogs/dns.log"}, - {Name: "WAF_LOG_FILE", Value: "c:/var/log/calico/waf/waf.log"}, - {Name: "FLUENTD_ES_SECURE", Value: "true"}, - { - Name: "NODENAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, - }, - }, - } - for _, expected := range expectedEnvs { - Expect(envs).To(ContainElement(expected)) - } - - container := ds.Spec.Template.Spec.Containers[0] - - Expect(container.ReadinessProbe.Exec.Command).To(ConsistOf([]string{`c:\ruby\msys64\usr\bin\bash.exe`, `-lc`, `/c/bin/readiness.sh`})) - Expect(container.ReadinessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.ReadinessProbe.PeriodSeconds).To(BeEquivalentTo(60)) - - Expect(container.LivenessProbe.Exec.Command).To(ConsistOf([]string{`c:\ruby\msys64\usr\bin\bash.exe`, `-lc`, `/c/bin/liveness.sh`})) - Expect(container.LivenessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.LivenessProbe.PeriodSeconds).To(BeEquivalentTo(60)) - - Expect(container.StartupProbe.Exec.Command).To(ConsistOf([]string{`c:\ruby\msys64\usr\bin\bash.exe`, `-lc`, `/c/bin/liveness.sh`})) - Expect(container.StartupProbe.TimeoutSeconds).To(BeEquivalentTo(10)) - Expect(container.StartupProbe.PeriodSeconds).To(BeEquivalentTo(60)) - Expect(container.StartupProbe.FailureThreshold).To(BeEquivalentTo(10)) - - Expect(container.SecurityContext).To(BeNil()) - }) - - It("should render with S3 configuration", func() { - cfg.S3Credential = &render.S3Credential{ - KeyId: []byte("IdForTheKey"), - KeySecret: []byte("SecretForTheKey"), - } - cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ - S3: &operatorv1.S3StoreSpec{ - Region: "anyplace", - BucketName: "thebucket", - BucketPath: "bucketpath", - }, - } - - expectedResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, - &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "log-collector-s3-credentials", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, - } - - // Should render the correct resources. - component := render.Fluentd(cfg) - resources, _ := component.Objects() - rtest.ExpectResources(resources, expectedResources) - - ds := rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(ds.Spec.Template.Annotations).To(HaveKey("hash.operator.tigera.io/s3-credentials")) - envs := ds.Spec.Template.Spec.Containers[0].Env - - expectedEnvs := []struct { - name string - val string - secretName string - secretKey string - }{ - {"S3_STORAGE", "true", "", ""}, - {"S3_BUCKET_NAME", "thebucket", "", ""}, - {"AWS_REGION", "anyplace", "", ""}, - {"S3_BUCKET_PATH", "bucketpath", "", ""}, - {"S3_FLUSH_INTERVAL", "5s", "", ""}, - {"AWS_KEY_ID", "", "log-collector-s3-credentials", "key-id"}, - {"AWS_SECRET_KEY", "", "log-collector-s3-credentials", "key-secret"}, - } - for _, expected := range expectedEnvs { - if expected.val != "" { - Expect(envs).To(ContainElement(corev1.EnvVar{Name: expected.name, Value: expected.val})) - } else { - Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: expected.name, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: expected.secretName}, - Key: expected.secretKey, - }, - }, - })) - } - } - }) - - It("should render with Syslog configuration", func() { - expectedResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, - } - - var ps int32 = 180 - cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ - Syslog: &operatorv1.SyslogStoreSpec{ - Endpoint: "tcp://1.2.3.4:80", - PacketSize: &ps, - LogTypes: []operatorv1.SyslogLogType{ - operatorv1.SyslogLogDNS, - operatorv1.SyslogLogFlows, - operatorv1.SyslogLogIDSEvents, - }, - }, - } - component := render.Fluentd(cfg) - resources, _ := component.Objects() - rtest.ExpectResources(resources, expectedResources) - - ds := rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(ds.Spec.Template.Spec.Volumes).To(HaveLen(3)) - envs := ds.Spec.Template.Spec.Containers[0].Env - - expectedEnvs := []struct { - name string - val string - secretName string - secretKey string - }{ - {"SYSLOG_HOST", "1.2.3.4", "", ""}, - {"SYSLOG_PORT", "80", "", ""}, - {"SYSLOG_PROTOCOL", "tcp", "", ""}, - {"SYSLOG_FLUSH_INTERVAL", "5s", "", ""}, - {"SYSLOG_PACKET_SIZE", "180", "", ""}, - {"SYSLOG_DNS_LOG", "true", "", ""}, - {"SYSLOG_FLOW_LOG", "true", "", ""}, - {"SYSLOG_IDS_EVENT_LOG", "true", "", ""}, - } - for _, expected := range expectedEnvs { - if expected.val != "" { - Expect(envs).To(ContainElement(corev1.EnvVar{Name: expected.name, Value: expected.val})) - } else { - Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: expected.name, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: expected.secretName}, - Key: expected.secretKey, - }, - }, - })) - } - } - Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "SYSLOG_HOSTNAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{ - FieldPath: "spec.nodeName", - }, - }, - })) - }) - - It("should render with Syslog configuration with TLS and user's corporate CA", func() { - cfg.UseSyslogCertificate = true - var ps int32 = 180 - cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ - Syslog: &operatorv1.SyslogStoreSpec{ - Endpoint: "tcp://1.2.3.4:80", - Encryption: operatorv1.EncryptionTLS, - PacketSize: &ps, - LogTypes: []operatorv1.SyslogLogType{ - operatorv1.SyslogLogDNS, - operatorv1.SyslogLogFlows, - operatorv1.SyslogLogIDSEvents, - }, - }, - } - component := render.Fluentd(cfg) - resources, _ := component.Objects() - - ds := rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(ds.Spec.Template.Spec.Volumes).To(HaveLen(3)) - - var volnames []string - for _, vol := range ds.Spec.Template.Spec.Volumes { - volnames = append(volnames, vol.Name) - } - Expect(volnames).To(ContainElement("tigera-ca-bundle")) - - envs := ds.Spec.Template.Spec.Containers[0].Env - - Expect(envs).To(ContainElements([]corev1.EnvVar{ - {Name: "SYSLOG_HOST", Value: "1.2.3.4", ValueFrom: nil}, - {Name: "SYSLOG_PORT", Value: "80", ValueFrom: nil}, - {Name: "SYSLOG_PROTOCOL", Value: "tcp", ValueFrom: nil}, - {Name: "SYSLOG_FLUSH_INTERVAL", Value: "5s", ValueFrom: nil}, - {Name: "SYSLOG_PACKET_SIZE", Value: "180", ValueFrom: nil}, - {Name: "SYSLOG_DNS_LOG", Value: "true", ValueFrom: nil}, - {Name: "SYSLOG_FLOW_LOG", Value: "true", ValueFrom: nil}, - {Name: "SYSLOG_IDS_EVENT_LOG", Value: "true", ValueFrom: nil}, - {Name: "SYSLOG_TLS", Value: "true", ValueFrom: nil}, - {Name: "SYSLOG_VERIFY_MODE", Value: "1", ValueFrom: nil}, - {Name: "SYSLOG_CA_FILE", Value: cfg.TrustedBundle.MountPath(), ValueFrom: nil}, - })) - }) - - It("should render with Syslog configuration with TLS and Internet CA", func() { - cfg.UseSyslogCertificate = false - var ps int32 = 180 - cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ - Syslog: &operatorv1.SyslogStoreSpec{ - Endpoint: "tcp://1.2.3.4:80", - Encryption: operatorv1.EncryptionTLS, - PacketSize: &ps, - LogTypes: []operatorv1.SyslogLogType{ - operatorv1.SyslogLogDNS, - operatorv1.SyslogLogFlows, - operatorv1.SyslogLogIDSEvents, - }, - }, - } - component := render.Fluentd(cfg) - resources, _ := component.Objects() - - ds := rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(ds.Spec.Template.Spec.Volumes).To(HaveLen(3)) - - envs := ds.Spec.Template.Spec.Containers[0].Env - - Expect(envs).To(ContainElements([]corev1.EnvVar{ - {Name: "SYSLOG_HOST", Value: "1.2.3.4", ValueFrom: nil}, - {Name: "SYSLOG_PORT", Value: "80", ValueFrom: nil}, - {Name: "SYSLOG_PROTOCOL", Value: "tcp", ValueFrom: nil}, - {Name: "SYSLOG_FLUSH_INTERVAL", Value: "5s", ValueFrom: nil}, - {Name: "SYSLOG_PACKET_SIZE", Value: "180", ValueFrom: nil}, - {Name: "SYSLOG_DNS_LOG", Value: "true", ValueFrom: nil}, - {Name: "SYSLOG_FLOW_LOG", Value: "true", ValueFrom: nil}, - {Name: "SYSLOG_IDS_EVENT_LOG", Value: "true", ValueFrom: nil}, - {Name: "SYSLOG_TLS", Value: "true", ValueFrom: nil}, - {Name: "SYSLOG_VERIFY_MODE", Value: "1", ValueFrom: nil}, - {Name: "SYSLOG_CA_FILE", Value: render.SysLogPublicCAPath, ValueFrom: nil}, - })) - }) - - It("should render with splunk configuration", func() { - cfg.SplkCredential = &render.SplunkCredential{ - Token: []byte("TokenForHEC"), - } - cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ - Splunk: &operatorv1.SplunkStoreSpec{ - Endpoint: "https://1.2.3.4:8088", - }, - } - - expectedResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, - &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "logcollector-splunk-credentials", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, - } - - // Should render the correct resources. - component := render.Fluentd(cfg) - resources, _ := component.Objects() - rtest.ExpectResources(resources, expectedResources) - - ds := rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(ds.Spec.Template.Spec.Volumes).To(HaveLen(3)) - - envs := ds.Spec.Template.Spec.Containers[0].Env - - expectedEnvs := []struct { - name string - val string - secretName string - secretKey string - }{ - {"SPLUNK_FLOW_LOG", "true", "", ""}, - {"SPLUNK_AUDIT_LOG", "true", "", ""}, - {"SPLUNK_DNS_LOG", "true", "", ""}, - {"SPLUNK_HEC_HOST", "1.2.3.4", "", ""}, - {"SPLUNK_HEC_PORT", "8088", "", ""}, - {"SPLUNK_PROTOCOL", "https", "", ""}, - {"SPLUNK_FLUSH_INTERVAL", "5s", "", ""}, - {"SPLUNK_HEC_TOKEN", "", "logcollector-splunk-credentials", "token"}, - } - for _, expected := range expectedEnvs { - if expected.val != "" { - Expect(envs).To(ContainElement(corev1.EnvVar{Name: expected.name, Value: expected.val})) - } else { - Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: expected.name, - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: expected.secretName}, - Key: expected.secretKey, - }, - }, - })) - } - } - }) - - It("should render with filter", func() { - cfg.Filters = &render.FluentdFilters{ - Flow: "flow-filter", - } - - expectedResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, - &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-filters", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, - } - - // Should render the correct resources. - component := render.Fluentd(cfg) - resources, _ := component.Objects() - - rtest.ExpectResources(resources, expectedResources) - - ds := rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(ds.Spec.Template.Annotations).To(HaveKey("hash.operator.tigera.io/fluentd-filters")) - envs := ds.Spec.Template.Spec.Containers[0].Env - Expect(envs).To(ContainElement(corev1.EnvVar{Name: "FLUENTD_FLOW_FILTERS", Value: "true"})) - Expect(envs).ToNot(ContainElement(corev1.EnvVar{Name: "FLUENTD_DNS_FILTERS", Value: "true"})) - }) - - It("should render with EKS Cloudwatch Log", func() { - expectedResources := getExpectedResourcesForEKS(false) - cfg.EKSConfig = setupEKSCloudwatchLogConfig() - cfg.ESClusterConfig = relasticsearch.NewClusterConfig("clusterTestName", 1, 1, 1) - t := corev1.Toleration{ - Key: "foo", - Operator: corev1.TolerationOpEqual, - Value: "bar", - } - cfg.Installation = &operatorv1.InstallationSpec{ - KubernetesProvider: operatorv1.ProviderEKS, - ControlPlaneTolerations: []corev1.Toleration{t}, - } - component := render.Fluentd(cfg) - resources, _ := component.Objects() - Expect(len(resources)).To(Equal(len(expectedResources))) - - // Should render the correct resources. - rtest.ExpectResources(resources, expectedResources) - deploy := rtest.GetResource(resources, "eks-log-forwarder", "tigera-fluentd", "apps", "v1", "Deployment").(*appsv1.Deployment) - - Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) - Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(deploy.Spec.Template.Annotations).To(HaveKey("hash.operator.tigera.io/eks-cloudwatch-log-credentials")) - Expect(deploy.Spec.Template.Spec.Tolerations).To(ContainElement(t)) - Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) - Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) - - envs := deploy.Spec.Template.Spec.Containers[0].Env - Expect(envs).To(ContainElement(corev1.EnvVar{Name: "K8S_PLATFORM", Value: "eks"})) - Expect(envs).To(ContainElement(corev1.EnvVar{Name: "AWS_REGION", Value: cfg.EKSConfig.AwsRegion})) - - Expect(*deploy.Spec.Template.Spec.InitContainers[0].SecurityContext.AllowPrivilegeEscalation).To(BeFalse()) - Expect(*deploy.Spec.Template.Spec.InitContainers[0].SecurityContext.Privileged).To(BeFalse()) - Expect(*deploy.Spec.Template.Spec.InitContainers[0].SecurityContext.RunAsGroup).To(BeEquivalentTo(0)) - Expect(*deploy.Spec.Template.Spec.InitContainers[0].SecurityContext.RunAsNonRoot).To(BeFalse()) - Expect(*deploy.Spec.Template.Spec.InitContainers[0].SecurityContext.RunAsUser).To(BeEquivalentTo(0)) - Expect(deploy.Spec.Template.Spec.InitContainers[0].SecurityContext.Capabilities).To(Equal( - &corev1.Capabilities{ - Drop: []corev1.Capability{"ALL"}, - }, - )) - Expect(deploy.Spec.Template.Spec.InitContainers[0].SecurityContext.SeccompProfile).To(Equal( - &corev1.SeccompProfile{ - Type: corev1.SeccompProfileTypeRuntimeDefault, - })) - - Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation).To(BeFalse()) - Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.Privileged).To(BeFalse()) - Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.RunAsGroup).To(BeEquivalentTo(0)) - Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.RunAsNonRoot).To(BeFalse()) - Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.RunAsUser).To(BeEquivalentTo(0)) - Expect(deploy.Spec.Template.Spec.Containers[0].SecurityContext.Capabilities).To(Equal( - &corev1.Capabilities{ - Drop: []corev1.Capability{"ALL"}, - }, - )) - Expect(deploy.Spec.Template.Spec.Containers[0].SecurityContext.SeccompProfile).To(Equal( - &corev1.SeccompProfile{ - Type: corev1.SeccompProfileTypeRuntimeDefault, - })) - - expectedEnvVars := []corev1.EnvVar{ - {Name: "LOG_LEVEL", Value: "info", ValueFrom: nil}, - {Name: "FLUENT_UID", Value: "0", ValueFrom: nil}, - {Name: "MANAGED_K8S", Value: "true", ValueFrom: nil}, - {Name: "K8S_PLATFORM", Value: "eks", ValueFrom: nil}, - {Name: "FLUENTD_ES_SECURE", Value: "true"}, - {Name: "EKS_CLOUDWATCH_LOG_GROUP", Value: "dummy-eks-cluster-cloudwatch-log-group"}, - {Name: "EKS_CLOUDWATCH_LOG_STREAM_PREFIX", Value: ""}, - {Name: "EKS_CLOUDWATCH_LOG_FETCH_INTERVAL", Value: "900"}, - {Name: "AWS_REGION", Value: "us-west-1", ValueFrom: nil}, - { - Name: "AWS_ACCESS_KEY_ID", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "tigera-eks-log-forwarder-secret", - }, - Key: "aws-id", - }, - }, - }, - { - Name: "AWS_SECRET_ACCESS_KEY", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "tigera-eks-log-forwarder-secret", - }, - Key: "aws-key", - Optional: nil, - }, - }, - }, - {Name: "LINSEED_ENABLED", Value: "true"}, - {Name: "LINSEED_ENDPOINT", Value: "https://tigera-linseed.tigera-elasticsearch.svc"}, - {Name: "LINSEED_CA_PATH", Value: "/etc/pki/tls/certs/tigera-ca-bundle.crt"}, - {Name: "TLS_CRT_PATH", Value: "/tigera-eks-log-forwarder-tls/tls.crt"}, - {Name: "TLS_KEY_PATH", Value: "/tigera-eks-log-forwarder-tls/tls.key"}, - {Name: "LINSEED_TOKEN", Value: "/var/run/secrets/kubernetes.io/serviceaccount/token"}, - } - - Expect(envs).To(Equal(expectedEnvVars)) - }) - - It("should render EKS Cloudwatch Log toleration on GKE", func() { - cfg.EKSConfig = setupEKSCloudwatchLogConfig() - cfg.ESClusterConfig = relasticsearch.NewClusterConfig("clusterTestName", 1, 1, 1) - cfg.Installation.KubernetesProvider = operatorv1.ProviderGKE - - component := render.Fluentd(cfg) - resources, _ := component.Objects() - deploy := rtest.GetResource(resources, "eks-log-forwarder", "tigera-fluentd", "apps", "v1", "Deployment").(*appsv1.Deployment) - Expect(deploy).NotTo(BeNil()) - Expect(deploy.Spec.Template.Spec.Tolerations).To(ContainElements(corev1.Toleration{ - Key: "kubernetes.io/arch", - Operator: corev1.TolerationOpEqual, - Value: "arm64", - Effect: corev1.TaintEffectNoSchedule, - })) - }) - - It("should render with EKS Cloudwatch Log with resources", func() { - cfg.EKSConfig = setupEKSCloudwatchLogConfig() - cfg.ESClusterConfig = relasticsearch.NewClusterConfig("clusterTestName", 1, 1, 1) - cfg.Installation = &operatorv1.InstallationSpec{ - KubernetesProvider: operatorv1.ProviderEKS, - } - - eksResources := corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - "cpu": resource.MustParse("2"), - "memory": resource.MustParse("300Mi"), - "storage": resource.MustParse("20Gi"), - }, - Requests: corev1.ResourceList{ - "cpu": resource.MustParse("1"), - "memory": resource.MustParse("150Mi"), - "storage": resource.MustParse("10Gi"), - }, - } - - logCollectorcfg := operatorv1.LogCollector{ - Spec: operatorv1.LogCollectorSpec{ - EKSLogForwarderDeployment: &operatorv1.EKSLogForwarderDeployment{ - Spec: &operatorv1.EKSLogForwarderDeploymentSpec{ - Template: &operatorv1.EKSLogForwarderDeploymentPodTemplateSpec{ - Spec: &operatorv1.EKSLogForwarderDeploymentPodSpec{ - Containers: []operatorv1.EKSLogForwarderDeploymentContainer{{ - Name: "eks-log-forwarder", - Resources: &eksResources, - }}, - }, - }, - }, - }, - }, - } - - cfg.LogCollector = &logCollectorcfg - component := render.Fluentd(cfg) - resources, _ := component.Objects() - deploy := rtest.GetResource(resources, "eks-log-forwarder", "tigera-fluentd", "apps", "v1", "Deployment").(*appsv1.Deployment) - - Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) - container := test.GetContainer(deploy.Spec.Template.Spec.Containers, "eks-log-forwarder") - Expect(container).NotTo(BeNil()) - Expect(container.Resources).To(Equal(eksResources)) - - initContainer := test.GetContainer(deploy.Spec.Template.Spec.InitContainers, "eks-log-forwarder-startup") - Expect(initContainer).NotTo(BeNil()) - Expect(initContainer.Resources).To(Equal(corev1.ResourceRequirements{})) - }) - - It("should render with EKS Cloudwatch Log with multi tenant envvars", func() { - expectedResources := getExpectedResourcesForEKS(false) - cfg.EKSConfig = setupEKSCloudwatchLogConfig() - cfg.ESClusterConfig = relasticsearch.NewClusterConfig("clusterTestName", 1, 1, 1) - t := corev1.Toleration{ - Key: "foo", - Operator: corev1.TolerationOpEqual, - Value: "bar", - } - cfg.Installation = &operatorv1.InstallationSpec{ - KubernetesProvider: operatorv1.ProviderEKS, - ControlPlaneTolerations: []corev1.Toleration{t}, - } - cfg.ExternalElastic = true - - // Create the Tenant object. - tenant := &operatorv1.Tenant{} - tenant.Name = "default" - tenant.Namespace = "tenant-namespace" - tenant.Spec.ID = "test-tenant-id" - cfg.Tenant = tenant - - component := render.Fluentd(cfg) - resources, _ := component.Objects() - Expect(len(resources)).To(Equal(len(expectedResources))) - - // Should render the correct resources. - rtest.ExpectResources(resources, expectedResources) - - deploy := rtest.GetResource(resources, "eks-log-forwarder", "tigera-fluentd", "apps", "v1", "Deployment").(*appsv1.Deployment) - envs := deploy.Spec.Template.Spec.Containers[0].Env - Expect(envs).To(ContainElement(corev1.EnvVar{Name: "LINSEED_ENDPOINT", Value: "https://tigera-linseed.tenant-namespace.svc"})) - Expect(envs).To(ContainElement(corev1.EnvVar{Name: "TENANT_ID", Value: "test-tenant-id"})) - Expect(envs).To(ContainElement(corev1.EnvVar{Name: "LINSEED_TOKEN", Value: "/var/run/secrets/kubernetes.io/serviceaccount/token"})) - }) - - It("should render with EKS Cloudwatch Log for managed cluster with linseed token volume", func() { - expectedResources := getExpectedResourcesForEKS(true) - - expectedResources = append(expectedResources, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}) - - cfg.EKSConfig = setupEKSCloudwatchLogConfig() - cfg.ESClusterConfig = relasticsearch.NewClusterConfig("clusterTestName", 1, 1, 1) - t := corev1.Toleration{ - Key: "foo", - Operator: corev1.TolerationOpEqual, - Value: "bar", - } - cfg.Installation = &operatorv1.InstallationSpec{ - KubernetesProvider: operatorv1.ProviderEKS, - ControlPlaneTolerations: []corev1.Toleration{t}, - } - cfg.ManagedCluster = true - component := render.Fluentd(cfg) - resources, _ := component.Objects() - Expect(len(resources)).To(Equal(len(expectedResources))) - - rtest.ExpectResources(resources, expectedResources) - - deploy := rtest.GetResource(resources, "eks-log-forwarder", "tigera-fluentd", "apps", "v1", "Deployment").(*appsv1.Deployment) - envs := deploy.Spec.Template.Spec.Containers[0].Env - Expect(envs).To(ContainElement(corev1.EnvVar{Name: "LINSEED_TOKEN", Value: "/var/run/secrets/tigera.io/linseed/token"})) - - volumeMounts := deploy.Spec.Template.Spec.Containers[0].VolumeMounts - Expect(volumeMounts).To(ContainElement(corev1.VolumeMount{Name: "linseed-token", MountPath: "/var/run/secrets/tigera.io/linseed/"})) - }) - - DescribeTable("should render with a valid configuration for non-cluster host and forwarding enabled", - func(destination render.ForwardingDestination) { - additionalStoreSpecAllHosts := additionalStoreSpecForDestinationAndScope(destination, operatorv1.HostScopeAll) - additionalStoreSpecNonClusterHosts := additionalStoreSpecForDestinationAndScope(destination, operatorv1.HostScopeNonClusterOnly) - clusterLogEnvVarName := "FORWARD_CLUSTER_LOGS_TO_" + strings.ToUpper(string(destination)) - nonClusterLogEnvVarName := "FORWARD_NON_CLUSTER_LOGS_TO_" + strings.ToUpper(string(destination)) - - By("establishing the base case with no non-cluster hosts or forwarding options") - expectedResources := []client.Object{ - &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, - &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: render.PacketCaptureAPIRole, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.PacketCaptureAPIRoleBinding, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, - } - cfg.PacketCapture = &operatorv1.PacketCaptureAPI{ - ObjectMeta: metav1.ObjectMeta{ - Name: "tigera-secure", - }, - } - - resources, _ := render.Fluentd(cfg).Objects() - rtest.ExpectResources(resources, expectedResources) - ds := rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - envs := ds.Spec.Template.Spec.Containers[0].Env - Expect(forwardingEnvVarCount(envs)).To(Equal(0)) - - By("enabling non-cluster hosts and forwarding from all hosts") - cfg.NonClusterHost = &operatorv1.NonClusterHost{ - ObjectMeta: metav1.ObjectMeta{ - Name: "tigera-secure", - }, - Spec: operatorv1.NonClusterHostSpec{ - Endpoint: "https://1.2.3.4:5678", - }, - } - cfg.LogCollector.Spec.AdditionalStores = additionalStoreSpecAllHosts - expectedResources = append(expectedResources, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdInputService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}) - - // Should render the correct resources. - resources, _ = render.Fluentd(cfg).Objects() - rtest.ExpectResources(resources, expectedResources) - - // Service is rendered as expected. - ms := rtest.GetResource(resources, render.FluentdInputService, render.LogCollectorNamespace, "", "v1", "Service").(*corev1.Service) - Expect(ms.Spec.Selector).To(Equal(map[string]string{"k8s-app": render.FluentdNodeName})) - Expect(ms.Spec.Ports).To(HaveLen(1)) - Expect(ms.Spec.Ports[0].Port).To(BeNumerically("==", render.FluentdInputPort)) - Expect(ms.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt32(render.FluentdInputPort))) - Expect(ms.Spec.Ports[0].Protocol).To(Equal(corev1.ProtocolTCP)) - - // Should contain the env vars with all forwarding enabled. - rtest.ExpectResources(resources, expectedResources) - ds = rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - envs = ds.Spec.Template.Spec.Containers[0].Env - Expect(forwardingEnvVarCount(envs)).To(Equal(2)) - Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: clusterLogEnvVarName, - Value: "true", - })) - Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: nonClusterLogEnvVarName, - Value: "true", - })) - - By("enabling forwarding of only non-cluster logs") - cfg.LogCollector.Spec.AdditionalStores = additionalStoreSpecNonClusterHosts - resources, _ = render.Fluentd(cfg).Objects() - rtest.ExpectResources(resources, expectedResources) - - // Should contain the env vars with only non-cluster forwarding enabled. - rtest.ExpectResources(resources, expectedResources) - ds = rtest.GetResource(resources, "fluentd-node", "tigera-fluentd", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) - Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) - envs = ds.Spec.Template.Spec.Containers[0].Env - Expect(forwardingEnvVarCount(envs)).To(Equal(2)) - Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: clusterLogEnvVarName, - Value: "false", - })) - Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: nonClusterLogEnvVarName, - Value: "true", - })) - }, - Entry("S3", render.ForwardingDestinationS3), - Entry("Syslog", render.ForwardingDestinationSyslog), - Entry("Splunk", render.ForwardingDestinationSplunk)) - - Context("calico-system rendering", func() { - policyName := types.NamespacedName{Name: "calico-system.allow-fluentd-node", Namespace: "tigera-fluentd"} - - getExpectedPolicy := func(scenario testutils.CalicoSystemScenario) *v3.NetworkPolicy { - if scenario.ManagedCluster { - return expectedFluentdPolicyForManaged - } else { - return testutils.SelectPolicyByProvider(scenario, expectedFluentdPolicyForUnmanaged, expectedFluentdPolicyForUnmanagedOpenshift) - } - } - - DescribeTable("should render calico-system policy", - func(scenario testutils.CalicoSystemScenario) { - if scenario.OpenShift { - cfg.Installation.KubernetesProvider = operatorv1.ProviderOpenShift - } else { - cfg.Installation.KubernetesProvider = operatorv1.ProviderNone - } - cfg.ManagedCluster = scenario.ManagedCluster - - component := render.Fluentd(cfg) - resources, _ := component.Objects() - - policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) - expectedPolicy := getExpectedPolicy(scenario) - Expect(policy).To(Equal(expectedPolicy)) - }, - Entry("for management/standalone, kube-dns", testutils.CalicoSystemScenario{ManagedCluster: false, OpenShift: false}), - Entry("for management/standalone, openshift-dns", testutils.CalicoSystemScenario{ManagedCluster: false, OpenShift: true}), - Entry("for managed, kube-dns", testutils.CalicoSystemScenario{ManagedCluster: true, OpenShift: false}), - Entry("for managed, openshift-dns", testutils.CalicoSystemScenario{ManagedCluster: true, OpenShift: true}), - ) - - It("should render calico-system policy for the non-cluster-host scenario", func() { - resourcesWithoutNonClusterHosts, _ := render.Fluentd(cfg).Objects() - policyWithoutNonClusterHosts := testutils.GetCalicoSystemPolicyFromResources(policyName, resourcesWithoutNonClusterHosts) - cfg.NonClusterHost = &operatorv1.NonClusterHost{ - ObjectMeta: metav1.ObjectMeta{ - Name: "tigera-secure", - }, - Spec: operatorv1.NonClusterHostSpec{ - Endpoint: "https://1.2.3.4:5678", - }, - } - resourcesWithNonClusterHosts, _ := render.Fluentd(cfg).Objects() - policyWithNonClusterHosts := testutils.GetCalicoSystemPolicyFromResources(policyName, resourcesWithNonClusterHosts) - - // Validate that we have a single ingress rule added for the fluentd service. - Expect(policyWithoutNonClusterHosts.Spec.Egress).To(Equal(policyWithNonClusterHosts.Spec.Egress)) - Expect(len(policyWithoutNonClusterHosts.Spec.Ingress)).To(Equal(len(policyWithNonClusterHosts.Spec.Ingress) - 1)) - Expect(len(policyWithNonClusterHosts.Spec.Ingress)).To(Equal(2)) - Expect(policyWithNonClusterHosts.Spec.Ingress[1]).To(Equal(v3.Rule{ - Action: v3.Allow, - Protocol: &networkpolicy.TCPProtocol, - Source: v3.EntityRule{ - Selector: fmt.Sprintf("k8s-app == '%s'", render.ManagerDeploymentName), - NamespaceSelector: fmt.Sprintf("projectcalico.org/name == '%s'", render.ManagerNamespace), - }, - Destination: v3.EntityRule{ - Ports: networkpolicy.Ports(render.FluentdInputPort), - }, - })) - }) - }) - - It("should move DaemonSet to toDelete when LicenseExpired is true", func() { - cfg.LicenseExpired = true - component := render.Fluentd(cfg) - Expect(component.ResolveImages(nil)).To(BeNil()) - toCreate, toDelete := component.Objects() - - // DaemonSet should not be in toCreate. - for _, obj := range toCreate { - if ds, ok := obj.(*appsv1.DaemonSet); ok { - Fail("DaemonSet should not be in toCreate when license is expired, but found: " + ds.Name) - } - } - - // DaemonSet should be in toDelete. - found := false - for _, obj := range toDelete { - if ds, ok := obj.(*appsv1.DaemonSet); ok && ds.Name == "fluentd-node" { - found = true - break - } - } - Expect(found).To(BeTrue(), "Expected fluentd-node DaemonSet to be in toDelete") - }) - - It("should include DaemonSet in toCreate when LicenseExpired is false", func() { - cfg.LicenseExpired = false - component := render.Fluentd(cfg) - Expect(component.ResolveImages(nil)).To(BeNil()) - toCreate, _ := component.Objects() - - found := false - for _, obj := range toCreate { - if ds, ok := obj.(*appsv1.DaemonSet); ok && ds.Name == "fluentd-node" { - found = true - break - } - } - Expect(found).To(BeTrue(), "Expected fluentd-node DaemonSet to be in toCreate") - }) -}) - -func setupEKSCloudwatchLogConfig() *render.EksCloudwatchLogConfig { - fetchInterval := int32(900) - return &render.EksCloudwatchLogConfig{ - AwsId: []byte("aws-id"), - AwsKey: []byte("aws-key"), - AwsRegion: "us-west-1", - GroupName: "dummy-eks-cluster-cloudwatch-log-group", - FetchInterval: fetchInterval, - } -} - -func getExpectedResourcesForEKS(isManagedcluster bool) []client.Object { - expectedResources := []client.Object{ - &v3.NetworkPolicy{ - ObjectMeta: metav1.ObjectMeta{Name: render.FluentdPolicyName, Namespace: render.LogCollectorNamespace}, - TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}, - }, - - &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentdMetricsService, Namespace: render.LogCollectorNamespace}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder"}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder", Namespace: "tigera-fluentd"}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, - &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, - &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: "tigera-fluentd"}}, - &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder", Namespace: render.LogCollectorNamespace}}, - &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "tigera-eks-log-forwarder-secret", Namespace: render.LogCollectorNamespace}}, - &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: render.LogCollectorNamespace}}, - } - - if isManagedcluster { - expectedResources = append(expectedResources, - &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}) - } - return expectedResources -} - -func forwardingEnvVarCount(envVars []corev1.EnvVar) (count int) { - for _, envVar := range envVars { - if strings.HasPrefix(envVar.Name, "FORWARD_") { - count++ - } - } - return count -} - -func additionalStoreSpecForDestinationAndScope(destination render.ForwardingDestination, scope operatorv1.HostScope) *operatorv1.AdditionalLogStoreSpec { - var spec operatorv1.AdditionalLogStoreSpec - switch destination { - case render.ForwardingDestinationS3: - spec.S3 = &operatorv1.S3StoreSpec{ - Region: "anyplace", - BucketName: "thebucket", - BucketPath: "bucketpath", - HostScope: &scope, - } - case render.ForwardingDestinationSyslog: - var ps int32 = 180 - spec.Syslog = &operatorv1.SyslogStoreSpec{ - Endpoint: "tcp://1.2.3.4:80", - PacketSize: &ps, - LogTypes: []operatorv1.SyslogLogType{ - operatorv1.SyslogLogDNS, - operatorv1.SyslogLogFlows, - operatorv1.SyslogLogIDSEvents, - }, - HostScope: &scope, - } - case render.ForwardingDestinationSplunk: - spec.Splunk = &operatorv1.SplunkStoreSpec{ - Endpoint: "https://1.2.3.4:8088", - HostScope: &scope, - } - } - - return &spec -} diff --git a/pkg/render/guardian.go b/pkg/render/guardian.go index f90bf3402a..65ca3ac86c 100644 --- a/pkg/render/guardian.go +++ b/pkg/render/guardian.go @@ -711,7 +711,7 @@ func guardianCalicoSystemPolicy(cfg *GuardianConfiguration) (*v3.NetworkPolicy, { Action: v3.Allow, Protocol: &networkpolicy.TCPProtocol, - Source: FluentdSourceEntityRule, + Source: FluentBitSourceEntityRule, Destination: guardianIngressDestinationEntityRule, }, { diff --git a/pkg/render/intrusion_detection.go b/pkg/render/intrusion_detection.go index f0aedbad0a..4730cb65b1 100644 --- a/pkg/render/intrusion_detection.go +++ b/pkg/render/intrusion_detection.go @@ -579,7 +579,7 @@ func (c *intrusionDetectionComponent) deploymentPodTemplate() *corev1.PodTemplat c.cfg.IntrusionDetectionCertSecret.Volume(), } // If syslog forwarding is enabled then set the necessary hostpath volume to write - // logs for Fluentd to access. + // logs for Fluent Bit to access. if c.cfg.SyslogForwardingIsEnabled { dirOrCreate := corev1.HostPathDirectoryOrCreate volumes = append(volumes, corev1.Volume{ @@ -741,7 +741,7 @@ func (c *intrusionDetectionComponent) intrusionDetectionControllerContainer() co sc := securitycontext.NewNonRootContext() // If syslog forwarding is enabled then set the necessary ENV var and volume mount to - // write logs for Fluentd. + // write logs for Fluent Bit. volumeMounts := c.cfg.TrustedCertBundle.VolumeMounts(c.SupportedOSType()) volumeMounts = append(volumeMounts, c.cfg.IntrusionDetectionCertSecret.VolumeMount(c.SupportedOSType())) if c.cfg.SyslogForwardingIsEnabled { diff --git a/pkg/render/logcollector.go b/pkg/render/logcollector.go new file mode 100644 index 0000000000..0b63b6b297 --- /dev/null +++ b/pkg/render/logcollector.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package render + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + + v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" + + "github.com/tigera/operator/pkg/render/common/networkpolicy" + "github.com/tigera/operator/pkg/tls/certificatemanagement" +) + +// This file holds log-collector symbols that must remain in the render package +// because other render components depend on them. The bulk of the log-collector +// (fluent-bit / EKS log-forwarder) rendering lives in pkg/render/logcollector; +// these symbols stay here to avoid a render -> render/logcollector import cycle. +// The logcollector package aliases the constants below for its own use. + +const ( + LogCollectorNamespace = "calico-system" + + // FluentBitNodeName / FluentBitNodeWindowsName are the k8s-app label values of the + // fluent-bit DaemonSet pods, used to select them as a NetworkPolicy source. + FluentBitNodeName = "calico-fluent-bit" + FluentBitNodeWindowsName = "calico-fluent-bit-windows" + + // FluentBitInputService is the Service fronting fluent-bit's HTTP input, which + // Manager/Voltron egresses to when forwarding non-cluster-host logs. + FluentBitInputService = "calico-fluent-bit-http-input" + + EKSLogForwarderName = "eks-log-forwarder" + + // SplunkFluentBitSecretCertificateKey is the key under which the Splunk CA cert is + // mounted; the shared TrustedBundleVolume below also exposes the trusted bundle at + // this path, so it lives here alongside that helper. + SplunkFluentBitSecretCertificateKey = "ca.pem" + + // Linseed token volume mounting constants, shared by several components + // (compliance, apiserver, intrusion detection, policy recommendation, fluent-bit). + LinseedTokenVolumeName = "linseed-token" + LinseedTokenKey = "token" + LinseedTokenSubPath = "token" + LinseedTokenSecret = "%s-tigera-linseed-token" + LinseedVolumeMountPath = "/var/run/secrets/tigera.io/linseed/" + LinseedTokenPath = "/var/run/secrets/tigera.io/linseed/token" +) + +// FluentBitSourceEntityRule selects the fluent-bit pods as a NetworkPolicy source. +var FluentBitSourceEntityRule = v3.EntityRule{ + NamespaceSelector: fmt.Sprintf("name == '%s'", LogCollectorNamespace), + Selector: networkpolicy.KubernetesAppSelector(FluentBitNodeName, FluentBitNodeWindowsName), +} + +// EKSLogForwarderEntityRule selects the EKS log-forwarder pods as a NetworkPolicy source. +var EKSLogForwarderEntityRule = networkpolicy.CreateSourceEntityRule(LogCollectorNamespace, EKSLogForwarderName) + +// TrustedBundleVolume mounts the trusted CA bundle under the standard name plus a few +// legacy/compatibility paths (including the Elastic and Splunk cert keys). It is shared +// by Dex and the log-collector components. +func TrustedBundleVolume(bundle certificatemanagement.TrustedBundle) corev1.Volume { + volume := bundle.Volume() + // We mount the bundle under two names; the standard name and the name for the expected elastic cert. + volume.ConfigMap.Items = []corev1.KeyToPath{ + {Key: certificatemanagement.TrustedCertConfigMapKeyName, Path: certificatemanagement.TrustedCertConfigMapKeyName}, + //nolint:staticcheck // Ignore SA1019 deprecated + {Key: certificatemanagement.TrustedCertConfigMapKeyName, Path: certificatemanagement.LegacyTrustedCertConfigMapKeyName}, + {Key: certificatemanagement.TrustedCertConfigMapKeyName, Path: SplunkFluentBitSecretCertificateKey}, + {Key: certificatemanagement.RHELRootCertificateBundleName, Path: certificatemanagement.RHELRootCertificateBundleName}, + } + return volume +} diff --git a/pkg/render/logcollector/config.go b/pkg/render/logcollector/config.go new file mode 100644 index 0000000000..e16f42690e --- /dev/null +++ b/pkg/render/logcollector/config.go @@ -0,0 +1,320 @@ +// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logcollector + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/render" + relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/tls/certificatemanagement" +) + +func (c *fluentBitComponent) fluentBitConfigMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: c.fluentBitConfConfigMapName(), Namespace: LogCollectorNamespace}, + Data: map[string]string{"fluent-bit.yaml": c.renderFluentBitConf()}, + } +} + +func (c *fluentBitComponent) logInputs() []logInput { + if c.cfg.OSType == rmeta.OSTypeWindows { + return windowsLogInputs + } + return linuxLogInputs +} + +// logDirsCSV lists the tailed log directories (comma-separated) for the +// pos-migrator init container to pre-create: glob tail inputs (compliance) log +// a scan error on every refresh while their parent directory is missing, e.g. +// on clusters where the producing feature isn't enabled yet. Deriving the list +// from logInputs keeps a single source of truth for the tailed paths. +func (c *fluentBitComponent) logDirsCSV() string { + var dirs []string + seen := map[string]bool{} + for _, in := range c.logInputs() { + dir := c.path(in.path[:strings.LastIndex(in.path, "/")]) + if !seen[dir] { + seen[dir] = true + dirs = append(dirs, dir) + } + } + return strings.Join(dirs, ",") +} + +// linseedTags lists the tags shipped to Linseed: every tailed tag except +// ids.events and compliance.reports — those are deliberately not +// Linseed-bound (IDS events use a different ingestion path; compliance +// reports are S3-only). The non_cluster_* tags are produced by the +// voltron-facing http input relaying non-cluster host posts; hosts ship +// flow, DNS and policy activity logs. +func (c *fluentBitComponent) linseedTags() []string { + var tags []string + for _, in := range c.logInputs() { + if in.tag == "ids.events" || in.tag == "compliance.reports" { + continue + } + tags = append(tags, in.tag) + } + if c.cfg.NonClusterHost != nil && c.cfg.OSType == rmeta.OSTypeLinux { + tags = append(tags, "non_cluster_flows", "non_cluster_dns", "non_cluster_policy_activity") + } + return tags +} + +// linseedBulkURI maps a tag to its Linseed bulk-ingestion URI. Voltron-relayed +// non_cluster_* tags post to the same path as their base tag. +func linseedBulkURI(tag string) string { + tag = strings.TrimPrefix(tag, "non_cluster_") + switch tag { + case "runtime": + return "/api/v1/runtime/reports/bulk" + case "audit.tsee": + return "/api/v1/audit/logs/ee/bulk" + case "audit.kube": + return "/api/v1/audit/logs/kube/bulk" + case "bird", "bird6": + return "/api/v1/bgp/logs/bulk" + default: + // flows, dns, l7, waf, policy_activity + return fmt.Sprintf("/api/v1/%s/logs/bulk", tag) + } +} + +// splitEndpoint splits an https:// endpoint into the host and port fields +// fluent-bit's native net layer expects (the port defaults to 443). Plain +// string handling: pkg/url's ParseEndpoint rejects endpoints without an +// explicit port, and Linseed endpoints usually carry none. +func splitEndpoint(endpoint string) (string, int) { + host := strings.TrimPrefix(endpoint, "https://") + host = strings.TrimSuffix(host, "/") + port := 443 + if i := strings.LastIndex(host, ":"); i >= 0 { + if n, err := strconv.Atoi(host[i+1:]); err == nil { + host, port = host[:i], n + } + } + return host, port +} + +// linseedHTTPOutput renders one built-in http output block shipping a tag's +// chunks to its Linseed bulk endpoint. The http output is plain C compiled +// into fluent-bit — no Go proxy plugin is involved — and `format json_lines` +// with the date key disabled produces exactly the NDJSON body Linseed's bulk +// APIs expect. The bearer token file is re-read on every request (a Tigera +// patch carried by the fluent-bit base build), so kubelet-rotated +// ServiceAccount tokens and operator-refreshed managed-cluster tokens are +// picked up without a restart. certPath/keyPath are the mTLS client keypair; +// storageLimit, when non-empty, caps this output's filesystem retry backlog. +func (c *fluentBitComponent) linseedHTTPOutput(tag, certPath, keyPath, storageLimit string) map[string]interface{} { + host, port := splitEndpoint(relasticsearch.LinseedEndpoint(c.SupportedOSType(), c.cfg.ClusterDomain, render.LinseedNamespace(c.cfg.Tenant), c.cfg.ManagedCluster, true)) + out := map[string]interface{}{ + "name": "http", + "match": tag, + "host": host, + "port": port, + "uri": linseedBulkURI(tag), + "format": "json_lines", + // One record per line, nothing else: Linseed parses each line as the + // log document itself, so no synthetic date field is added. + "json_date_key": false, + "tls": "on", + "tls.verify": "on", + // tls.verify only checks the chain; hostname/SAN verification is a + // separate knob that defaults off in fluent-bit. The Go plugin this + // replaces verified hostnames (crypto/tls default), so keep parity. + "tls.verify_hostname": "on", + "tls.ca_file": c.trustedBundlePath(), + "tls.crt_file": certPath, + "tls.key_file": keyPath, + "bearer_token_file": c.path(render.GetLinseedTokenPath(c.cfg.ManagedCluster)), + // Retry failed chunks until they send instead of dropping them after + // the default single retry; the filesystem storage bounds what can + // accumulate during a Linseed outage. + "retry_limit": "no_limits", + } + if storageLimit != "" { + out["storage.total_limit_size"] = storageLimit + } + if c.cfg.Tenant != nil && c.cfg.ExternalElastic { + out["header"] = fmt.Sprintf("x-tenant-id %s", c.cfg.Tenant.Spec.ID) + } + return out +} + +// linseedStorageLimit sizes a tag's filesystem retry backlog: flow logs are +// the dominant volume and keep the budget the single shared output used to +// have; everything else is low-volume. +func linseedStorageLimit(tag string) string { + if tag == "flows" || tag == "non_cluster_flows" { + return "500M" + } + return "100M" +} + +func (c *fluentBitComponent) renderFluentBitConf() string { + caPath := c.trustedBundlePath() + keyPath := c.keyPath() + certPath := c.certPath() + + cfg := fluentBitConfig{ + Service: map[string]interface{}{ + "flush": 5, + "log_level": "info", + "http_server": true, + "http_port": FluentBitMetricsPort, + // Enable the /api/v1/health endpoint that the liveness/readiness/ + // startup probes hit (without this it returns 404 and pods never + // become Ready). + "health_check": true, + // Filesystem buffering under the same hostPath-backed state dir as + // the tail offset DBs, so buffered-but-unsent chunks survive pod + // restarts (fluentd buffered to disk for up to 72h). + "storage.path": c.path("/var/log/calico/calico-fluent-bit/storage"), + }, + // Parsers referenced by the tail inputs. Defined inline so the config is + // self-contained and does not depend on the image's parsers.conf. + Parsers: []map[string]interface{}{ + {"name": "json", "format": "json"}, + // Audit events carry their event time in the `time` key; parse it so + // time-partitioned sinks (S3 day keys, syslog timestamps) use event + // time, matching fluentd's `time_key time` on the audit sources. Like + // fluentd (no keep_time_key), the key is consumed by the parser. + {"name": "json_audit", "format": "json", "time_key": "time", "time_format": "%Y-%m-%dT%H:%M:%S.%L%z"}, + // IDS events carry a unix-seconds `time` key; fluentd parsed it with + // `time_type unixtime` and kept the key in the record. + {"name": "json_ids_events", "format": "json", "time_key": "time", "time_format": "%s", "time_keep": true}, + {"name": "bird_regex", "format": "regex", "regex": `^(?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.\d{5} bird: (?.*)`}, + }, + } + + for _, in := range c.logInputs() { + cfg.Pipeline.Inputs = append(cfg.Pipeline.Inputs, map[string]interface{}{ + "name": "tail", + "path": c.path(in.path), + "tag": in.tag, + // Persist read offsets in SQLite under /var/log/calico/calico-fluent-bit + // — the same directory the host rpm/deb package uses, and a subdir of the + // already-mounted var-log-calico volume — so the tail resumes across + // restarts instead of re-shipping from the head. The pos-migrator init + // container seeds these DBs from the legacy fluentd .pos files at cutover; + // read_from_head only applies to files with no prior offset (first install). + "db": c.path(fmt.Sprintf("/var/log/calico/calico-fluent-bit/in_tail_%s.db", in.tag)), + "parser": in.parser, + "read_from_head": true, + "storage.type": "filesystem", + }) + } + + if c.cfg.NonClusterHost != nil && c.cfg.OSType == rmeta.OSTypeLinux { + cfg.Pipeline.Inputs = append(cfg.Pipeline.Inputs, map[string]interface{}{ + "name": "http", + "listen": "0.0.0.0", + "port": FluentBitInputPort, + "tls": "on", + "tls.ca_file": caPath, + "tls.crt_file": certPath, + "tls.key_file": keyPath, + // Require a Tigera-CA-signed client certificate, like fluentd's http + // source did (client_cert_auth true) — voltron presents its internal + // client certificate on this hop. + "tls.verify_client_cert": "on", + "storage.type": "filesystem", + }) + } + + // Per-log-type transforms (host injection, flows @timestamp, audit name + // derivation, BIRD ip_version + noise drop, etc.) are implemented in the + // record_transformer.lua filter shipped in the image, keyed by tag. + cfg.Pipeline.Filters = append(cfg.Pipeline.Filters, map[string]interface{}{ + "name": "lua", + "match": "*", + "script": c.luaScriptPath(), + "call": "record_transformer", + }) + + // User-provided flow/dns filters (from the fluent-bit-filters ConfigMap) are + // inlined into the pipeline, replacing fluentd's config-include mechanism. + // Each ConfigMap key holds a YAML list of fluent-bit filter entries. + c.addUserFilters(&cfg) + + // One built-in http output per Linseed-bound tag: chunks are per-tag, so + // an exact match per block routes every record to its bulk endpoint. The + // per-tag split replaces the single out_linseed Go proxy output — the C + // http output keeps the container free of Go proxy plugins. + for _, tag := range c.linseedTags() { + cfg.Pipeline.Outputs = append(cfg.Pipeline.Outputs, + c.linseedHTTPOutput(tag, certPath, keyPath, linseedStorageLimit(tag))) + } + + if c.cfg.OTelCollectorEnabled { + cfg.Pipeline.Outputs = append(cfg.Pipeline.Outputs, map[string]interface{}{ + "name": "opentelemetry", + "match": "*", + "host": fmt.Sprintf("otel-collector.%s.svc", common.CalicoNamespace), + "port": 4318, + "logs_uri": "/v1/logs", + "tls": "on", + "tls.verify": "on", + "tls.ca_file": caPath, + "tls.crt_file": certPath, + "tls.key_file": keyPath, + }) + } + + // Additional stores are Linux-only, matching the fluentd Windows variant + // (Linseed only). + if c.cfg.LogCollector.Spec.AdditionalStores != nil && c.cfg.OSType == rmeta.OSTypeLinux { + c.addS3Outputs(&cfg) + c.addSyslogOutputs(&cfg) + c.addSplunkOutputs(&cfg) + } + + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Sprintf("# error rendering config: %v\n", err) + } + return string(out) +} + +func (c *fluentBitComponent) trustedBundlePath() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return certificatemanagement.TrustedCertBundleMountPathWindows + } + return c.cfg.TrustedBundle.MountPath() +} + +func (c *fluentBitComponent) keyPath() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return fmt.Sprintf("c:/%s/%s", c.cfg.FluentBitKeyPair.GetName(), corev1.TLSPrivateKeyKey) + } + return c.cfg.FluentBitKeyPair.VolumeMountKeyFilePath() +} + +func (c *fluentBitComponent) certPath() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return fmt.Sprintf("c:/%s/%s", c.cfg.FluentBitKeyPair.GetName(), corev1.TLSCertKey) + } + return c.cfg.FluentBitKeyPair.VolumeMountCertificateFilePath() +} diff --git a/pkg/render/logcollector/daemonset.go b/pkg/render/logcollector/daemonset.go new file mode 100644 index 0000000000..f3d520d385 --- /dev/null +++ b/pkg/render/logcollector/daemonset.go @@ -0,0 +1,361 @@ +// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logcollector + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/tigera/operator/pkg/render" + rcomponents "github.com/tigera/operator/pkg/render/common/components" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/render/common/secret" + "github.com/tigera/operator/pkg/render/common/securitycontext" +) + +func (c *fluentBitComponent) healthProbeHandler() corev1.ProbeHandler { + return corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/api/v1/health", + Port: intstr.FromInt(FluentBitMetricsPort), + }, + } +} + +func (c *fluentBitComponent) securityContext(privileged bool) *corev1.SecurityContext { + if c.cfg.OSType == rmeta.OSTypeWindows { + return nil + } + return securitycontext.NewRootContext(privileged) +} + +func (c *fluentBitComponent) daemonset() *appsv1.DaemonSet { + var terminationGracePeriod int64 = 0 + // The rationale for this setting is that while there is no need for fluent-bit to be available, we want to avoid + // potentially negative consequences of an immediate roll-out on huge clusters. + maxUnavailable := intstr.FromInt(10) + + annots := c.cfg.TrustedBundle.HashAnnotations() + + if c.cfg.FluentBitKeyPair != nil { + annots[c.cfg.FluentBitKeyPair.HashAnnotationKey()] = c.cfg.FluentBitKeyPair.HashAnnotationValue() + } + if c.cfg.S3Credential != nil { + annots[s3CredentialHashAnnotation] = rmeta.AnnotationHash(c.cfg.S3Credential) + } + if c.cfg.SplkCredential != nil { + annots[splunkCredentialHashAnnotation] = rmeta.AnnotationHash(c.cfg.SplkCredential) + } + // Most LogCollector spec changes only alter the rendered ConfigMap (and the + // config is subPath-mounted, which kubelet never live-updates), so hash the + // rendered config into the pod template to force a rollout on change. This + // also covers user filters, which are inlined into the config. + annots[configHashAnnotation] = rmeta.AnnotationHash(c.renderFluentBitConf()) + var initContainers []corev1.Container + if c.cfg.OSType == rmeta.OSTypeLinux { + initContainers = append(initContainers, corev1.Container{ + Name: "pos-migrator", + Image: c.image, + Command: []string{"/usr/bin/pos-migrator"}, + Env: []corev1.EnvVar{ + {Name: "LOG_DIRS", Value: c.logDirsCSV()}, + }, + SecurityContext: c.securityContext(false), + VolumeMounts: []corev1.VolumeMount{ + {MountPath: "/var/log/calico", Name: "var-log-calico"}, + }, + }) + } else { + // Windows fluentd also kept tail positions (.pos files under the same + // mounted log dir), so the cutover migration applies there too. The + // Windows image ships the cross-compiled migrator; env overrides point + // it at the c:-prefixed mounts. + initContainers = append(initContainers, corev1.Container{ + Name: "pos-migrator", + Image: c.image, + Command: []string{c.path("/fluent-bit/pos-migrator.exe")}, + Env: []corev1.EnvVar{ + {Name: "POS_DIR", Value: c.path("/var/log/calico")}, + {Name: "DB_DIR", Value: c.path("/var/log/calico/calico-fluent-bit")}, + {Name: "LOG_DIRS", Value: c.logDirsCSV()}, + }, + SecurityContext: c.securityContext(false), + VolumeMounts: []corev1.VolumeMount{ + {MountPath: c.path("/var/log/calico"), Name: "var-log-calico"}, + }, + }) + } + if c.cfg.FluentBitKeyPair != nil && c.cfg.FluentBitKeyPair.UseCertificateManagement() { + initContainers = append(initContainers, c.cfg.FluentBitKeyPair.InitContainer(LogCollectorNamespace, c.container().SecurityContext)) + } + + podTemplate := &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: annots, + }, + Spec: corev1.PodSpec{ + NodeSelector: map[string]string{}, + Tolerations: rmeta.TolerateAll, + ImagePullSecrets: secret.GetReferenceList(c.cfg.PullSecrets), + TerminationGracePeriodSeconds: &terminationGracePeriod, + InitContainers: initContainers, + Containers: []corev1.Container{c.container()}, + Volumes: c.volumes(), + ServiceAccountName: c.fluentBitNodeName(), + }, + } + + ds := &appsv1.DaemonSet{ + TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: c.fluentBitNodeName(), + Namespace: LogCollectorNamespace, + }, + Spec: appsv1.DaemonSetSpec{ + Template: *podTemplate, + UpdateStrategy: appsv1.DaemonSetUpdateStrategy{ + RollingUpdate: &appsv1.RollingUpdateDaemonSet{ + MaxUnavailable: &maxUnavailable, + }, + }, + }, + } + if c.cfg.LogCollector != nil { + overrides := c.cfg.LogCollector.Spec.CalicoFluentBitDaemonSet + if overrides == nil { + // Deprecated alias: fluentdDaemonSet is honored for one release when + // the new field is unset, per the API contract. Container entries + // stored under the legacy fluentd-era names are matched against the + // renamed containers via the shared containerNameAliases mechanism + // in pkg/render/common/components. + overrides = c.cfg.LogCollector.Spec.FluentdDaemonSet //nolint:staticcheck // deliberate use of the deprecated alias field + } + if overrides != nil { + rcomponents.ApplyDaemonSetOverrides(ds, overrides) + } + } + render.SetNodeCriticalPod(&(ds.Spec.Template)) + return ds +} + +func (c *fluentBitComponent) container() corev1.Container { + envs := c.envvars() + volumeMounts := []corev1.VolumeMount{ + {MountPath: c.path("/var/log/calico"), Name: "var-log-calico"}, + } + if c.cfg.OSType == rmeta.OSTypeWindows { + // Windows containers cannot mount a single file (no subPath file + // mounts), so mount the whole ConfigMap as a directory. The Windows + // image keeps its own files under C:\fluent-bit, so c:\etc\fluent-bit + // shadows nothing. + volumeMounts = append(volumeMounts, + corev1.VolumeMount{MountPath: c.path("/etc/fluent-bit/conf"), Name: "fluent-bit-conf", ReadOnly: true}) + } else { + // Mount only the rendered config as a single file (SubPath) so it does + // not shadow the image's /etc/fluent-bit directory, which ships + // plugins.conf, record_transformer.lua and the in_eks plugin. + volumeMounts = append(volumeMounts, + corev1.VolumeMount{MountPath: "/etc/fluent-bit/fluent-bit.yaml", Name: "fluent-bit-conf", SubPath: "fluent-bit.yaml", ReadOnly: true}) + } + + volumeMounts = append(volumeMounts, c.cfg.TrustedBundle.VolumeMounts(c.SupportedOSType())...) + + if c.cfg.FluentBitKeyPair != nil { + volumeMounts = append(volumeMounts, c.cfg.FluentBitKeyPair.VolumeMount(c.SupportedOSType())) + } + + if c.cfg.ManagedCluster { + volumeMounts = append(volumeMounts, + corev1.VolumeMount{ + Name: LinseedTokenVolumeName, + MountPath: c.path(LinseedVolumeMountPath), + }) + } + + return corev1.Container{ + Name: "calico-fluent-bit", + Image: c.image, + Command: []string{c.binPath()}, + Args: []string{"-c", c.configPath()}, + Env: envs, + // On OpenShift Fluent Bit needs privileged access to access logs on host path volume + SecurityContext: c.securityContext(c.cfg.Installation.KubernetesProvider.IsOpenShift()), + VolumeMounts: volumeMounts, + StartupProbe: c.startup(), + LivenessProbe: c.liveness(), + ReadinessProbe: c.readiness(), + Ports: []corev1.ContainerPort{{ + Name: "metrics-port", + ContainerPort: FluentBitMetricsPort, + }}, + } +} + +func (c *fluentBitComponent) metricsService() *corev1.Service { + return &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: c.fluentBitMetricsServiceName(), + Namespace: LogCollectorNamespace, + Labels: map[string]string{"k8s-app": c.fluentBitNodeName()}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"k8s-app": c.fluentBitNodeName()}, + // Important: "None" tells Kubernetes that we want a headless service with + // no kube-proxy load balancer. If we omit this then kube-proxy will render + // a huge set of iptables rules for this service since there's an instance + // on every node. + ClusterIP: "None", + Ports: []corev1.ServicePort{ + { + Name: FluentBitMetricsPortName, + Port: int32(FluentBitMetricsPort), + TargetPort: intstr.FromInt(FluentBitMetricsPort), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } +} + +func (c *fluentBitComponent) envvars() []corev1.EnvVar { + envs := []corev1.EnvVar{ + {Name: "NODENAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}}}, + } + // Additional stores are Linux-only (the Windows pipeline ships to Linseed + // only, matching the fluentd Windows variant), so their credentials are too. + if c.cfg.LogCollector.Spec.AdditionalStores != nil && c.cfg.OSType == rmeta.OSTypeLinux { + if s3 := c.cfg.LogCollector.Spec.AdditionalStores.S3; s3 != nil { + // The standard AWS credential env vars, which fluent-bit's native s3 + // output reads via the AWS credential chain (the legacy AWS_KEY_ID / + // AWS_SECRET_KEY names were fluentd-config-only and are read by + // nothing in fluent-bit). + envs = append(envs, + corev1.EnvVar{ + Name: "AWS_ACCESS_KEY_ID", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: S3FluentBitSecretName}, + Key: S3KeyIdName, + }, + }, + }, + corev1.EnvVar{ + Name: "AWS_SECRET_ACCESS_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: S3FluentBitSecretName}, + Key: S3KeySecretName, + }, + }, + }, + ) + } + if splunk := c.cfg.LogCollector.Spec.AdditionalStores.Splunk; splunk != nil { + envs = append(envs, + corev1.EnvVar{ + Name: "SPLUNK_HEC_TOKEN", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: SplunkFluentBitTokenSecretName}, + Key: SplunkFluentBitSecretTokenKey, + }, + }, + }, + ) + } + } + + return envs +} + +// The startup probe uses the same action as the liveness probe, but with +// a higher failure threshold and double the timeout to account for slow +// networks. +func (c *fluentBitComponent) startup() *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: c.healthProbeHandler(), + TimeoutSeconds: c.probeTimeout, + PeriodSeconds: c.probePeriod, + FailureThreshold: 10, + } +} + +func (c *fluentBitComponent) liveness() *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: c.healthProbeHandler(), + TimeoutSeconds: c.probeTimeout, + PeriodSeconds: c.probePeriod, + } +} + +func (c *fluentBitComponent) readiness() *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: c.healthProbeHandler(), + TimeoutSeconds: c.probeTimeout, + PeriodSeconds: c.probePeriod, + } +} + +func (c *fluentBitComponent) volumes() []corev1.Volume { + dirOrCreate := corev1.HostPathDirectoryOrCreate + + volumes := []corev1.Volume{ + { + Name: "var-log-calico", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: c.volumeHostPath(), + Type: &dirOrCreate, + }, + }, + }, + { + Name: "fluent-bit-conf", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: c.fluentBitConfConfigMapName(), + }, + }, + }, + }, + } + if c.cfg.FluentBitKeyPair != nil { + volumes = append(volumes, c.cfg.FluentBitKeyPair.Volume()) + } + if c.cfg.ManagedCluster { + volumes = append(volumes, + corev1.Volume{ + Name: LinseedTokenVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + // Per-OS: the token controller provisions a secret per + // ServiceAccount (calico-fluent-bit / + // calico-fluent-bit-windows). + SecretName: fmt.Sprintf(LinseedTokenSecret, c.fluentBitNodeName()), + Items: []corev1.KeyToPath{{Key: LinseedTokenKey, Path: LinseedTokenSubPath}}, + }, + }, + }) + } + volumes = append(volumes, render.TrustedBundleVolume(c.cfg.TrustedBundle)) + + return volumes +} diff --git a/pkg/render/logcollector/eks_log_forwarder.go b/pkg/render/logcollector/eks_log_forwarder.go new file mode 100644 index 0000000000..534524fc42 --- /dev/null +++ b/pkg/render/logcollector/eks_log_forwarder.go @@ -0,0 +1,312 @@ +// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logcollector + +import ( + "encoding/json" + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/tigera/operator/pkg/render" + rcomponents "github.com/tigera/operator/pkg/render/common/components" + relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/render/common/secret" +) + +func (c *fluentBitComponent) eksLogForwarderServiceAccount() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: EKSLogForwarderName, Namespace: LogCollectorNamespace}, + } +} + +func (c *fluentBitComponent) eksLogForwarderSecret() *corev1.Secret { + return &corev1.Secret{ + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: EksLogForwarderSecret, + Namespace: LogCollectorNamespace, + }, + Data: map[string][]byte{ + EksLogForwarderAwsId: c.cfg.EKSConfig.AwsId, + EksLogForwarderAwsKey: c.cfg.EKSConfig.AwsKey, + }, + } +} + +// renderEKSFluentBitConf renders the fluent-bit config for the eks-log-forwarder +// Deployment: a single in_eks input (the Go plugin polls CloudWatch, applies +// the EKS audit shaping itself, and resumes from the last Linseed-ingested +// timestamp on every process start) feeding the built-in http output that +// ships to Linseed. The in_eks plugin reads its CloudWatch/Linseed settings +// from the Deployment's env vars rather than plugin properties. +func (c *fluentBitComponent) renderEKSFluentBitConf() string { + cfg := fluentBitConfig{ + Service: map[string]interface{}{ + "flush": 5, + "log_level": "info", + "http_server": true, + "http_port": FluentBitMetricsPort, + "health_check": true, + // Load the custom in_eks Go plugin shipped in the image. Without + // this the `in_eks` input is an unknown plugin. + "plugins_file": c.pluginsFilePath(), + }, + } + cfg.Pipeline.Inputs = append(cfg.Pipeline.Inputs, map[string]interface{}{ + "name": "in_eks", + "tag": "audit.kube", + }) + cfg.Pipeline.Outputs = append(cfg.Pipeline.Outputs, c.linseedHTTPOutput( + "audit.kube", + c.cfg.EKSLogForwarderKeyPair.VolumeMountCertificateFilePath(), + c.cfg.EKSLogForwarderKeyPair.VolumeMountKeyFilePath(), + // No filesystem storage on this Deployment, so no backlog cap applies. + "")) + + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Sprintf("# error rendering config: %v\n", err) + } + return string(out) +} + +func (c *fluentBitComponent) eksConfigMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: EKSLogForwarderConfConfigMapName, Namespace: LogCollectorNamespace}, + Data: map[string]string{"fluent-bit.yaml": c.renderEKSFluentBitConf()}, + } +} + +func (c *fluentBitComponent) eksLogForwarderDeployment() *appsv1.Deployment { + annots := map[string]string{ + eksCloudwatchLogCredentialHashAnnotation: rmeta.AnnotationHash(c.cfg.EKSConfig), + configHashAnnotation: rmeta.AnnotationHash(c.renderEKSFluentBitConf()), + } + + envVars := []corev1.EnvVar{ + {Name: "LOG_LEVEL", Value: "info"}, + // CloudWatch config, credentials — consumed by the in_eks input plugin + // (fluent-bit/plugins/in_eks/pkg/config) and the AWS SDK credential chain. + {Name: "EKS_CLOUDWATCH_LOG_GROUP", Value: c.cfg.EKSConfig.GroupName}, + {Name: "AWS_REGION", Value: c.cfg.EKSConfig.AwsRegion}, + {Name: "AWS_ACCESS_KEY_ID", ValueFrom: secret.GetEnvVarSource(EksLogForwarderSecret, EksLogForwarderAwsId, false)}, + {Name: "AWS_SECRET_ACCESS_KEY", ValueFrom: secret.GetEnvVarSource(EksLogForwarderSecret, EksLogForwarderAwsKey, false)}, + // Linseed connection for the plugin's resume-point query (it asks + // Linseed for the last ingested audit timestamp on startup). + // Determine the namespace in which Linseed is running. For managed and standalone clusters, this is always the elasticsearch + // namespace. For multi-tenant management clusters, this may vary. + {Name: "LINSEED_ENDPOINT", Value: relasticsearch.LinseedEndpoint(c.SupportedOSType(), c.cfg.ClusterDomain, render.LinseedNamespace(c.cfg.Tenant), c.cfg.ManagedCluster, true)}, + {Name: "LINSEED_CA_PATH", Value: c.trustedBundlePath()}, + {Name: "TLS_CRT_PATH", Value: c.cfg.EKSLogForwarderKeyPair.VolumeMountCertificateFilePath()}, + {Name: "TLS_KEY_PATH", Value: c.cfg.EKSLogForwarderKeyPair.VolumeMountKeyFilePath()}, + {Name: "LINSEED_TOKEN", Value: c.path(render.GetLinseedTokenPath(c.cfg.ManagedCluster))}, + } + // The logcollector controller defaults these before render + // (getEksCloudwatchLogConfig: prefix kube-apiserver-audit-, interval + // 60), so in practice both env vars are always set. The guards are + // defense in depth for other callers: rendering an empty prefix or a + // zero interval would override the plugin's own defaults with a broken + // setting (an empty prefix matches every stream in the group). + if c.cfg.EKSConfig.StreamPrefix != "" { + envVars = append(envVars, corev1.EnvVar{Name: "EKS_CLOUDWATCH_LOG_STREAM_PREFIX", Value: c.cfg.EKSConfig.StreamPrefix}) + } + if c.cfg.EKSConfig.FetchInterval > 0 { + envVars = append(envVars, corev1.EnvVar{Name: "EKS_CLOUDWATCH_POLL_INTERVAL", Value: fmt.Sprintf("%ds", c.cfg.EKSConfig.FetchInterval)}) + } + if c.cfg.Tenant != nil && c.cfg.ExternalElastic { + envVars = append(envVars, corev1.EnvVar{Name: "TENANT_ID", Value: c.cfg.Tenant.Spec.ID}) + } + + var eksLogForwarderReplicas int32 = 1 + + tolerations := c.cfg.Installation.ControlPlaneTolerations + if c.cfg.Installation.KubernetesProvider.IsGKE() { + tolerations = append(tolerations, rmeta.TolerateGKEARM64NoSchedule) + } + + d := &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: EKSLogForwarderName, + Namespace: LogCollectorNamespace, + Labels: map[string]string{ + "k8s-app": EKSLogForwarderName, + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &eksLogForwarderReplicas, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RecreateDeploymentStrategyType, + }, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "k8s-app": EKSLogForwarderName, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: EKSLogForwarderName, + Namespace: LogCollectorNamespace, + Labels: map[string]string{ + "k8s-app": EKSLogForwarderName, + }, + Annotations: annots, + }, + Spec: corev1.PodSpec{ + Tolerations: tolerations, + ServiceAccountName: EKSLogForwarderName, + ImagePullSecrets: secret.GetReferenceList(c.cfg.PullSecrets), + Containers: []corev1.Container{{ + Name: EKSLogForwarderName, + Image: c.image, + Command: []string{c.binPath()}, + Args: []string{"-c", c.configPath()}, + Env: envVars, + SecurityContext: c.securityContext(false), + VolumeMounts: c.eksLogForwarderVolumeMounts(), + StartupProbe: c.startup(), + LivenessProbe: c.liveness(), + ReadinessProbe: c.readiness(), + }}, + Volumes: c.eksLogForwarderVolumes(), + }, + }, + }, + } + + if c.cfg.LogCollector != nil { + if overrides := c.cfg.LogCollector.Spec.EKSLogForwarderDeployment; overrides != nil { + rcomponents.ApplyDeploymentOverrides(d, overrides) + } + } + + return d +} + +func (c *fluentBitComponent) eksLogForwarderVolumeMounts() []corev1.VolumeMount { + volumeMounts := []corev1.VolumeMount{ + // Mount only the rendered config file (SubPath) so it does not shadow + // the image's /etc/fluent-bit directory (plugins.conf etc.). + { + Name: "fluent-bit-conf", + MountPath: c.configPath(), + SubPath: "fluent-bit.yaml", + ReadOnly: true, + }, + } + volumeMounts = append(volumeMounts, c.cfg.TrustedBundle.VolumeMounts(c.SupportedOSType())...) + if c.cfg.EKSLogForwarderKeyPair != nil { + volumeMounts = append(volumeMounts, c.cfg.EKSLogForwarderKeyPair.VolumeMount(c.SupportedOSType())) + } + + if c.cfg.ManagedCluster { + volumeMounts = append(volumeMounts, + corev1.VolumeMount{ + Name: LinseedTokenVolumeName, + MountPath: c.path(LinseedVolumeMountPath), + }) + } + return volumeMounts +} + +func (c *fluentBitComponent) eksLogForwarderVolumes() []corev1.Volume { + volumes := []corev1.Volume{ + render.TrustedBundleVolume(c.cfg.TrustedBundle), + { + Name: "fluent-bit-conf", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: EKSLogForwarderConfConfigMapName, + }, + }, + }, + }, + } + if c.cfg.EKSLogForwarderKeyPair != nil { + volumes = append(volumes, c.cfg.EKSLogForwarderKeyPair.Volume()) + } + + if c.cfg.ManagedCluster { + volumes = append(volumes, + corev1.Volume{ + Name: LinseedTokenVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: fmt.Sprintf(LinseedTokenSecret, EKSLogForwarderName), + Items: []corev1.KeyToPath{{Key: LinseedTokenKey, Path: LinseedTokenSubPath}}, + }, + }, + }) + } + return volumes +} + +func (c *fluentBitComponent) eksLogForwarderClusterRoleBinding() *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: EKSLogForwarderName, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: EKSLogForwarderName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: EKSLogForwarderName, + Namespace: LogCollectorNamespace, + }, + }, + } +} + +func (c *fluentBitComponent) eksLogForwarderClusterRole() *rbacv1.ClusterRole { + rules := []rbacv1.PolicyRule{ + { + // Add read access to Linseed APIs. + APIGroups: []string{"linseed.tigera.io"}, + Resources: []string{ + "auditlogs", + }, + Verbs: []string{"get"}, + }, + { + // Add write access to Linseed APIs to flush eks kube audit logs. + APIGroups: []string{"linseed.tigera.io"}, + Resources: []string{ + "kube_auditlogs", + }, + Verbs: []string{"create"}, + }, + } + + return &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: EKSLogForwarderName, + }, + Rules: rules, + } +} diff --git a/pkg/render/logcollector/fluentbit_test.go b/pkg/render/logcollector/fluentbit_test.go new file mode 100644 index 0000000000..7b39b11b89 --- /dev/null +++ b/pkg/render/logcollector/fluentbit_test.go @@ -0,0 +1,1659 @@ +// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logcollector_test + +import ( + "encoding/json" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/tigera/operator/pkg/render/common/networkpolicy" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" + + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/apis" + "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/controller/certificatemanager" + ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" + "github.com/tigera/operator/pkg/dns" + "github.com/tigera/operator/pkg/render" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/render/common/resourcequota" + rtest "github.com/tigera/operator/pkg/render/common/test" + "github.com/tigera/operator/pkg/render/logcollector" + "github.com/tigera/operator/pkg/render/testutils" + "github.com/tigera/operator/pkg/tls" + "github.com/tigera/operator/test" +) + +var _ = Describe("Tigera Secure Fluent Bit rendering tests", func() { + var cfg *logcollector.FluentBitConfiguration + var cli client.Client + + expectedFluentBitPolicyForUnmanaged := testutils.GetExpectedPolicyFromFile("../testutils/expected_policies/fluentbit_unmanaged.json") + expectedFluentBitPolicyForUnmanagedOpenshift := testutils.GetExpectedPolicyFromFile("../testutils/expected_policies/fluentbit_unmanaged_ocp.json") + expectedFluentBitPolicyForManaged := testutils.GetExpectedPolicyFromFile("../testutils/expected_policies/fluentbit_managed.json") + + BeforeEach(func() { + // Initialize a default instance to use. Each test can override this to its + // desired configuration. + scheme := runtime.NewScheme() + Expect(apis.AddToScheme(scheme, false)).NotTo(HaveOccurred()) + cli = ctrlrfake.DefaultFakeClientBuilder(scheme).Build() + + certificateManager, err := certificatemanager.Create(cli, nil, clusterDomain, common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).NotTo(HaveOccurred()) + + metricsSecret, err := certificateManager.GetOrCreateKeyPair(cli, logcollector.FluentBitTLSSecretName, common.OperatorNamespace(), []string{""}) + Expect(err).NotTo(HaveOccurred()) + eksSecret, err := certificateManager.GetOrCreateKeyPair(cli, logcollector.EKSLogForwarderTLSSecretName, common.OperatorNamespace(), []string{""}) + Expect(err).NotTo(HaveOccurred()) + cfg = &logcollector.FluentBitConfiguration{ + LogCollector: &operatorv1.LogCollector{}, + ClusterDomain: dns.DefaultClusterDomain, + OSType: rmeta.OSTypeLinux, + Installation: &operatorv1.InstallationSpec{ + KubernetesProvider: operatorv1.ProviderNone, + }, + FluentBitKeyPair: metricsSecret, + EKSLogForwarderKeyPair: eksSecret, + TrustedBundle: certificateManager.CreateTrustedBundle(), + } + }) + + It("should render SecurityContextConstrains properly when provider is OpenShift", func() { + cfg.Installation.KubernetesProvider = operatorv1.ProviderOpenShift + component := logcollector.FluentBit(cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ := component.Objects() + + // calico-fluent-bit clusterRole should have openshift securitycontextconstraints PolicyRule + fluentBitRole := rtest.GetResource(resources, "calico-fluent-bit", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) + Expect(fluentBitRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"security.openshift.io"}, + Resources: []string{"securitycontextconstraints"}, + Verbs: []string{"use"}, + ResourceNames: []string{"privileged"}, + })) + }) + + It("should render with a default configuration", func() { + expectedResources := []client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, + &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: logcollector.PacketCaptureAPIRole, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: logcollector.PacketCaptureAPIRoleBinding, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, + } + + cfg.PacketCapture = &operatorv1.PacketCaptureAPI{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tigera-secure", + }, + } + + // Should render the correct resources. + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + rtest.ExpectResources(resources, expectedResources) + + ds := rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Volumes[0].VolumeSource.HostPath.Path).To(Equal("/var/log/calico")) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + + // The pos-migrator init container pre-creates the tailed log dirs so + // glob inputs (compliance) don't error while a feature's dir is absent. + initContainers := ds.Spec.Template.Spec.InitContainers + Expect(initContainers).NotTo(BeEmpty()) + Expect(initContainers[0].Name).To(Equal("pos-migrator")) + var logDirs string + for _, env := range initContainers[0].Env { + if env.Name == "LOG_DIRS" { + logDirs = env.Value + } + } + Expect(logDirs).To(ContainSubstring("/var/log/calico/compliance")) + Expect(logDirs).To(ContainSubstring("/var/log/calico/waf")) + envs := ds.Spec.Template.Spec.Containers[0].Env + + Expect(envs).Should(ContainElement( + corev1.EnvVar{ + Name: "NODENAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, + }, + }, + )) + + // Linseed/TLS config is now in the ConfigMap, not env vars. + Expect(envs).ShouldNot(ContainElements( + corev1.EnvVar{Name: "LINSEED_ENABLED", Value: "true"}, + corev1.EnvVar{Name: "LINSEED_ENDPOINT", Value: "https://tigera-linseed.tigera-elasticsearch.svc"}, + corev1.EnvVar{Name: "TLS_KEY_PATH", Value: "/calico-fluent-bit-tls/tls.key"}, + corev1.EnvVar{Name: "TLS_CRT_PATH", Value: "/calico-fluent-bit-tls/tls.crt"}, + )) + + // Verify the ConfigMap contains the expected config. + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + Expect(cm.Data).To(HaveKey("fluent-bit.yaml")) + fluentBitConf := cm.Data["fluent-bit.yaml"] + // Linseed shipping uses the built-in http output (no Go proxy + // plugins): one block per tag, NDJSON body, mTLS plus a bearer token + // re-read from file on every request. + Expect(fluentBitConf).To(ContainSubstring(`"name": "http"`)) + Expect(fluentBitConf).To(ContainSubstring(`"host": "tigera-linseed.tigera-elasticsearch.svc"`)) + Expect(fluentBitConf).To(ContainSubstring(`"uri": "/api/v1/flows/logs/bulk"`)) + Expect(fluentBitConf).To(ContainSubstring(`"uri": "/api/v1/dns/logs/bulk"`)) + Expect(fluentBitConf).To(ContainSubstring(`"uri": "/api/v1/audit/logs/ee/bulk"`)) + Expect(fluentBitConf).To(ContainSubstring(`"uri": "/api/v1/audit/logs/kube/bulk"`)) + Expect(fluentBitConf).To(ContainSubstring(`"uri": "/api/v1/bgp/logs/bulk"`)) + Expect(fluentBitConf).To(ContainSubstring(`"format": "json_lines"`)) + Expect(fluentBitConf).To(ContainSubstring(`"json_date_key": false`)) + Expect(fluentBitConf).To(ContainSubstring(`"bearer_token_file": "/var/run/secrets/kubernetes.io/serviceaccount/token"`)) + // Per-tag filesystem retry caps: flows is the dominant volume and + // keeps the budget the single shared output used to have. + Expect(fluentBitConf).To(ContainSubstring(`"storage.total_limit_size": "500M"`)) + Expect(fluentBitConf).To(ContainSubstring(`"storage.total_limit_size": "100M"`)) + // No Go proxy plugins are loaded. + Expect(fluentBitConf).NotTo(ContainSubstring("plugins_file")) + Expect(fluentBitConf).NotTo(ContainSubstring(`"name": "linseed"`)) + + container := ds.Spec.Template.Spec.Containers[0] + + Expect(container.Command).To(Equal([]string{"/usr/bin/fluent-bit"})) + Expect(container.Args).To(Equal([]string{"-c", "/etc/fluent-bit/fluent-bit.yaml"})) + + Expect(container.ReadinessProbe.HTTPGet).NotTo(BeNil()) + Expect(container.ReadinessProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.ReadinessProbe.HTTPGet.Port).To(Equal(intstr.FromInt(logcollector.FluentBitMetricsPort))) + Expect(container.ReadinessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.ReadinessProbe.PeriodSeconds).To(BeEquivalentTo(60)) + + Expect(container.LivenessProbe.HTTPGet).NotTo(BeNil()) + Expect(container.LivenessProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.LivenessProbe.HTTPGet.Port).To(Equal(intstr.FromInt(logcollector.FluentBitMetricsPort))) + Expect(container.LivenessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.LivenessProbe.PeriodSeconds).To(BeEquivalentTo(60)) + + Expect(container.StartupProbe.HTTPGet).NotTo(BeNil()) + Expect(container.StartupProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.StartupProbe.HTTPGet.Port).To(Equal(intstr.FromInt(logcollector.FluentBitMetricsPort))) + Expect(container.StartupProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.StartupProbe.PeriodSeconds).To(BeEquivalentTo(60)) + Expect(container.StartupProbe.FailureThreshold).To(BeEquivalentTo(10)) + + Expect(*container.SecurityContext.AllowPrivilegeEscalation).To(BeFalse()) + Expect(*container.SecurityContext.Privileged).To(BeFalse()) + Expect(*container.SecurityContext.RunAsGroup).To(BeEquivalentTo(0)) + Expect(*container.SecurityContext.RunAsNonRoot).To(BeFalse()) + Expect(*container.SecurityContext.RunAsUser).To(BeEquivalentTo(0)) + Expect(container.SecurityContext.Capabilities).To(Equal( + &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + )) + Expect(container.SecurityContext.SeccompProfile).To(Equal( + &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + })) + + podExecRole := rtest.GetResource(resources, logcollector.PacketCaptureAPIRole, render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "Role").(*rbacv1.Role) + Expect(podExecRole.Rules).To(ConsistOf([]rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods/exec"}, + Verbs: []string{"create"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"list"}, + }, + })) + podExecRoleBinding := rtest.GetResource(resources, logcollector.PacketCaptureAPIRoleBinding, render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "RoleBinding").(*rbacv1.RoleBinding) + Expect(podExecRoleBinding.RoleRef.Name).To(Equal(logcollector.PacketCaptureAPIRole)) + Expect(podExecRoleBinding.Subjects).To(ConsistOf([]rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: render.PacketCaptureServiceAccountName, + Namespace: render.PacketCaptureNamespace, + }, + })) + + // The metrics service should have the correct configuration. + ms := rtest.GetResource(resources, logcollector.FluentBitMetricsService, render.LogCollectorNamespace, "", "v1", "Service").(*corev1.Service) + Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless to prevent kube-proxy from rendering too many iptables rules") + }) + + It("should render fluent-bit DaemonSet with resources requests/limits", func() { + ca, _ := tls.MakeCA(rmeta.DefaultOperatorCASignerName()) + cert, _, _ := ca.Config.GetPEMBytes() // create a valid pem block + cfg.Installation.CertificateManagement = &operatorv1.CertificateManagement{CACert: cert} + + certificateManager, err := certificatemanager.Create(cli, cfg.Installation, clusterDomain, common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).NotTo(HaveOccurred()) + + metricsSecret, err := certificateManager.GetOrCreateKeyPair(cli, logcollector.FluentBitTLSSecretName, common.OperatorNamespace(), []string{""}) + Expect(err).NotTo(HaveOccurred()) + + cfg.FluentBitKeyPair = metricsSecret + + fluentBitResources := corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + "cpu": resource.MustParse("2"), + "memory": resource.MustParse("300Mi"), + "storage": resource.MustParse("20Gi"), + }, + Requests: corev1.ResourceList{ + "cpu": resource.MustParse("1"), + "memory": resource.MustParse("150Mi"), + "storage": resource.MustParse("10Gi"), + }, + } + + logCollectorcfg := operatorv1.LogCollector{ + Spec: operatorv1.LogCollectorSpec{ + CalicoFluentBitDaemonSet: &operatorv1.FluentBitDaemonSet{ + Spec: &operatorv1.FluentBitDaemonSetSpec{ + Template: &operatorv1.FluentBitDaemonSetPodTemplateSpec{ + Spec: &operatorv1.FluentBitDaemonSetPodSpec{ + InitContainers: []operatorv1.FluentBitDaemonSetInitContainer{{ + Name: "calico-fluent-bit-tls-key-cert-provisioner", + Resources: &fluentBitResources, + }}, + Containers: []operatorv1.FluentBitDaemonSetContainer{{ + Name: "calico-fluent-bit", + Resources: &fluentBitResources, + }}, + }, + }, + }, + }, + }, + } + + cfg.LogCollector = &logCollectorcfg + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + + ds := rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + + container := test.GetContainer(ds.Spec.Template.Spec.Containers, "calico-fluent-bit") + Expect(container).NotTo(BeNil()) + Expect(container.Resources).To(Equal(fluentBitResources)) + + Expect(ds.Spec.Template.Spec.InitContainers).To(HaveLen(2)) + initContainer := test.GetContainer(ds.Spec.Template.Spec.InitContainers, "calico-fluent-bit-tls-key-cert-provisioner") + Expect(initContainer).NotTo(BeNil()) + Expect(initContainer.Resources).To(Equal(fluentBitResources)) + }) + + It("should honor the deprecated fluentdDaemonSet override alias, including legacy container names", func() { + ca, _ := tls.MakeCA(rmeta.DefaultOperatorCASignerName()) + cert, _, _ := ca.Config.GetPEMBytes() // create a valid pem block + cfg.Installation.CertificateManagement = &operatorv1.CertificateManagement{CACert: cert} + + certificateManager, err := certificatemanager.Create(cli, cfg.Installation, clusterDomain, common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).NotTo(HaveOccurred()) + + metricsSecret, err := certificateManager.GetOrCreateKeyPair(cli, logcollector.FluentBitTLSSecretName, common.OperatorNamespace(), []string{""}) + Expect(err).NotTo(HaveOccurred()) + + cfg.FluentBitKeyPair = metricsSecret + + legacyResources := corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + "cpu": resource.MustParse("2"), + "memory": resource.MustParse("300Mi"), + }, + Requests: corev1.ResourceList{ + "cpu": resource.MustParse("1"), + "memory": resource.MustParse("150Mi"), + }, + } + + // A pre-migration LogCollector: overrides stored under the deprecated + // fluentdDaemonSet field, using the fluentd-era container names. + cfg.LogCollector = &operatorv1.LogCollector{ + Spec: operatorv1.LogCollectorSpec{ + FluentdDaemonSet: &operatorv1.FluentBitDaemonSet{ + Spec: &operatorv1.FluentBitDaemonSetSpec{ + Template: &operatorv1.FluentBitDaemonSetPodTemplateSpec{ + Spec: &operatorv1.FluentBitDaemonSetPodSpec{ + InitContainers: []operatorv1.FluentBitDaemonSetInitContainer{{ + Name: "tigera-fluentd-prometheus-tls-key-cert-provisioner", + Resources: &legacyResources, + }}, + Containers: []operatorv1.FluentBitDaemonSetContainer{{ + Name: "fluentd", + Resources: &legacyResources, + }}, + }, + }, + }, + }, + }, + } + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + + ds := rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + container := test.GetContainer(ds.Spec.Template.Spec.Containers, "calico-fluent-bit") + Expect(container).NotTo(BeNil()) + Expect(container.Resources).To(Equal(legacyResources)) + + initContainer := test.GetContainer(ds.Spec.Template.Spec.InitContainers, "calico-fluent-bit-tls-key-cert-provisioner") + Expect(initContainer).NotTo(BeNil()) + Expect(initContainer.Resources).To(Equal(legacyResources)) + + // When both the deprecated alias and the new field are set, the new + // field takes precedence. + newResources := corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + "cpu": resource.MustParse("4"), + "memory": resource.MustParse("600Mi"), + }, + } + cfg.LogCollector.Spec.CalicoFluentBitDaemonSet = &operatorv1.FluentBitDaemonSet{ + Spec: &operatorv1.FluentBitDaemonSetSpec{ + Template: &operatorv1.FluentBitDaemonSetPodTemplateSpec{ + Spec: &operatorv1.FluentBitDaemonSetPodSpec{ + Containers: []operatorv1.FluentBitDaemonSetContainer{{ + Name: "calico-fluent-bit", + Resources: &newResources, + }}, + }, + }, + }, + } + resources, _ = logcollector.FluentBit(cfg).Objects() + ds = rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + container = test.GetContainer(ds.Spec.Template.Spec.Containers, "calico-fluent-bit") + Expect(container).NotTo(BeNil()) + Expect(container.Resources).To(Equal(newResources)) + }) + + It("should render with a configuration for a managed cluster", func() { + expectedResources := []client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsService, Namespace: render.LogCollectorNamespace}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: render.FluentBitNodeName, Namespace: render.LogCollectorNamespace}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: render.FluentBitNodeName, Namespace: render.LogCollectorNamespace}}, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}, + } + + expectedDeleteResources := append([]client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: "allow-tigera.allow-calico-fluent-bit", Namespace: render.LogCollectorNamespace}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentBitInputService, Namespace: render.LogCollectorNamespace}}, + }, legacyFluentdDeleteResources()...) + + // Should render the correct resources. + managedCfg := &logcollector.FluentBitConfiguration{ + LogCollector: cfg.LogCollector, + ClusterDomain: cfg.ClusterDomain, + OSType: cfg.OSType, + Installation: cfg.Installation, + FluentBitKeyPair: cfg.FluentBitKeyPair, + TrustedBundle: cfg.TrustedBundle, + ManagedCluster: true, + } + component := logcollector.FluentBit(managedCfg) + createResources, deleteResources := component.Objects() + rtest.ExpectResources(createResources, expectedResources) + rtest.ExpectResources(deleteResources, expectedDeleteResources) + + ds := rtest.GetResource(createResources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Volumes[0].VolumeSource.HostPath.Path).To(Equal("/var/log/calico")) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + envs := ds.Spec.Template.Spec.Containers[0].Env + + Expect(envs).Should(ContainElement( + corev1.EnvVar{ + Name: "NODENAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, + }, + }, + )) + + // Verify the ConfigMap contains the linseed config for the managed cluster. + cm := rtest.GetResource(createResources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + Expect(cm.Data).To(HaveKey("fluent-bit.yaml")) + fluentBitConf := cm.Data["fluent-bit.yaml"] + Expect(fluentBitConf).To(ContainSubstring(`"name": "http"`)) + // Managed clusters post to the external tigera-linseed service (which + // redirects to Guardian) with the operator-provisioned token, not the + // pod's ServiceAccount token. + Expect(fluentBitConf).To(ContainSubstring(`"host": "tigera-linseed"`)) + Expect(fluentBitConf).To(ContainSubstring(`"bearer_token_file": "/var/run/secrets/tigera.io/linseed/token"`)) + + container := ds.Spec.Template.Spec.Containers[0] + + Expect(container.ReadinessProbe.HTTPGet).NotTo(BeNil()) + Expect(container.ReadinessProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.ReadinessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.ReadinessProbe.PeriodSeconds).To(BeEquivalentTo(60)) + + Expect(container.LivenessProbe.HTTPGet).NotTo(BeNil()) + Expect(container.LivenessProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.LivenessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.LivenessProbe.PeriodSeconds).To(BeEquivalentTo(60)) + + Expect(container.StartupProbe.HTTPGet).NotTo(BeNil()) + Expect(container.StartupProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.StartupProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.StartupProbe.PeriodSeconds).To(BeEquivalentTo(60)) + Expect(container.StartupProbe.FailureThreshold).To(BeEquivalentTo(10)) + + Expect(*container.SecurityContext.AllowPrivilegeEscalation).To(BeFalse()) + Expect(*container.SecurityContext.Privileged).To(BeFalse()) + Expect(*container.SecurityContext.RunAsGroup).To(BeEquivalentTo(0)) + Expect(*container.SecurityContext.RunAsNonRoot).To(BeFalse()) + Expect(*container.SecurityContext.RunAsUser).To(BeEquivalentTo(0)) + Expect(container.SecurityContext.Capabilities).To(Equal( + &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + )) + Expect(container.SecurityContext.SeccompProfile).To(Equal( + &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + })) + + linseedRoleBinding := rtest.GetResource(createResources, "tigera-linseed", render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "RoleBinding").(*rbacv1.RoleBinding) + Expect(linseedRoleBinding.RoleRef.Name).To(Equal("tigera-linseed-secrets")) + Expect(linseedRoleBinding.Subjects).To(ConsistOf([]rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: render.GuardianServiceAccountName, + Namespace: render.GuardianNamespace, + }, + })) + + // The metrics service should have the correct configuration. + ms := rtest.GetResource(createResources, logcollector.FluentBitMetricsService, render.LogCollectorNamespace, "", "v1", "Service").(*corev1.Service) + Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless to prevent kube-proxy from rendering too many iptables rules") + }) + + It("should render with a configuration for a managed cluster with packet capture", func() { + expectedResources := []client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsService, Namespace: render.LogCollectorNamespace}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: render.FluentBitNodeName, Namespace: render.LogCollectorNamespace}}, + &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: logcollector.PacketCaptureAPIRole, Namespace: render.LogCollectorNamespace}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: logcollector.PacketCaptureAPIRoleBinding, Namespace: render.LogCollectorNamespace}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: render.FluentBitNodeName, Namespace: render.LogCollectorNamespace}}, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}, + } + + expectedDeleteResources := append([]client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: "allow-tigera.allow-calico-fluent-bit", Namespace: render.LogCollectorNamespace}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentBitInputService, Namespace: render.LogCollectorNamespace}}, + }, legacyFluentdDeleteResources()...) + + pc := &operatorv1.PacketCaptureAPI{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tigera-secure", + }, + } + + // Should render the correct resources. + managedCfg := &logcollector.FluentBitConfiguration{ + LogCollector: cfg.LogCollector, + ClusterDomain: cfg.ClusterDomain, + OSType: cfg.OSType, + Installation: cfg.Installation, + FluentBitKeyPair: cfg.FluentBitKeyPair, + TrustedBundle: cfg.TrustedBundle, + ManagedCluster: true, + PacketCapture: pc, + } + component := logcollector.FluentBit(managedCfg) + createResources, deleteResources := component.Objects() + rtest.ExpectResources(createResources, expectedResources) + rtest.ExpectResources(deleteResources, expectedDeleteResources) + + ds := rtest.GetResource(createResources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Volumes[0].VolumeSource.HostPath.Path).To(Equal("/var/log/calico")) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + envs := ds.Spec.Template.Spec.Containers[0].Env + + Expect(envs).Should(ContainElement( + corev1.EnvVar{ + Name: "NODENAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, + }, + }, + )) + + container := ds.Spec.Template.Spec.Containers[0] + + Expect(container.ReadinessProbe.HTTPGet).NotTo(BeNil()) + Expect(container.ReadinessProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.ReadinessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.ReadinessProbe.PeriodSeconds).To(BeEquivalentTo(60)) + + Expect(container.LivenessProbe.HTTPGet).NotTo(BeNil()) + Expect(container.LivenessProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.LivenessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.LivenessProbe.PeriodSeconds).To(BeEquivalentTo(60)) + + Expect(container.StartupProbe.HTTPGet).NotTo(BeNil()) + Expect(container.StartupProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.StartupProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.StartupProbe.PeriodSeconds).To(BeEquivalentTo(60)) + Expect(container.StartupProbe.FailureThreshold).To(BeEquivalentTo(10)) + + Expect(*container.SecurityContext.AllowPrivilegeEscalation).To(BeFalse()) + Expect(*container.SecurityContext.Privileged).To(BeFalse()) + Expect(*container.SecurityContext.RunAsGroup).To(BeEquivalentTo(0)) + Expect(*container.SecurityContext.RunAsNonRoot).To(BeFalse()) + Expect(*container.SecurityContext.RunAsUser).To(BeEquivalentTo(0)) + Expect(container.SecurityContext.Capabilities).To(Equal( + &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + )) + Expect(container.SecurityContext.SeccompProfile).To(Equal( + &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + })) + + linseedRoleBinding := rtest.GetResource(createResources, "tigera-linseed", render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "RoleBinding").(*rbacv1.RoleBinding) + Expect(linseedRoleBinding.RoleRef.Name).To(Equal("tigera-linseed-secrets")) + Expect(linseedRoleBinding.Subjects).To(ConsistOf([]rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: render.GuardianServiceAccountName, + Namespace: render.GuardianNamespace, + }, + })) + + podExecRole := rtest.GetResource(createResources, logcollector.PacketCaptureAPIRole, render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "Role").(*rbacv1.Role) + Expect(podExecRole.Rules).To(ConsistOf([]rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods/exec"}, + Verbs: []string{"create"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"list"}, + }, + })) + podExecRoleBinding := rtest.GetResource(createResources, logcollector.PacketCaptureAPIRoleBinding, render.LogCollectorNamespace, "rbac.authorization.k8s.io", "v1", "RoleBinding").(*rbacv1.RoleBinding) + Expect(podExecRoleBinding.RoleRef.Name).To(Equal(logcollector.PacketCaptureAPIRole)) + Expect(podExecRoleBinding.Subjects).To(ConsistOf([]rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: render.PacketCaptureServiceAccountName, + Namespace: render.PacketCaptureNamespace, + }, + })) + + // The metrics service should have the correct configuration. + ms := rtest.GetResource(createResources, logcollector.FluentBitMetricsService, render.LogCollectorNamespace, "", "v1", "Service").(*corev1.Service) + Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless to prevent kube-proxy from rendering too many iptables rules") + }) + + It("should render with a resource quota for provider GKE", func() { + cfg.Installation.KubernetesProvider = operatorv1.ProviderGKE + + // Should render the correct resources. + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + + // Should render resource quota + Expect(rtest.GetResource(resources, "tigera-critical-pods", "calico-system", "", "v1", "ResourceQuota")).ToNot(BeNil()) + }) + + It("should render for Windows nodes", func() { + expectedResources := []client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsServiceWindows, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName + "-windows", Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit-windows"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit-windows"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit-windows", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit-windows", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, + } + + cfg.OSType = rmeta.OSTypeWindows + // Should render the correct resources. + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + rtest.ExpectResources(resources, expectedResources) + + ds := rtest.GetResource(resources, "calico-fluent-bit-windows", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Volumes[0].VolumeSource.HostPath.Path).To(Equal("c:/TigeraCalico")) + + envs := ds.Spec.Template.Spec.Containers[0].Env + + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "NODENAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}, + }, + })) + + // Verify the ConfigMap contains expected config for Windows paths. The + // Windows component renders its own OS-suffixed ConfigMap so it cannot + // fight the Linux one on mixed clusters. + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName+"-windows", render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + Expect(cm.Data).To(HaveKey("fluent-bit.yaml")) + fluentBitConf := cm.Data["fluent-bit.yaml"] + // Windows ships through the built-in http output too — the image + // carries no plugin DLLs and no Go runtime at all. + Expect(fluentBitConf).To(ContainSubstring(`"name": "http"`)) + Expect(fluentBitConf).To(ContainSubstring(`"uri": "/api/v1/flows/logs/bulk"`)) + Expect(fluentBitConf).To(ContainSubstring(`"uri": "/api/v1/audit/logs/ee/bulk"`)) + Expect(fluentBitConf).To(ContainSubstring(`"uri": "/api/v1/audit/logs/kube/bulk"`)) + Expect(fluentBitConf).To(ContainSubstring(`"bearer_token_file": "c:/var/run/secrets/kubernetes.io/serviceaccount/token"`)) + Expect(fluentBitConf).NotTo(ContainSubstring("plugins_file")) + // The Windows image lays everything out under C:\fluent-bit. + Expect(fluentBitConf).To(ContainSubstring(`"script": "c:/fluent-bit/record_transformer.lua"`)) + // Windows tails only the log types the fluentd Windows variant shipped. + Expect(fluentBitConf).To(ContainSubstring("c:/var/log/calico/flowlogs/flows.log")) + Expect(fluentBitConf).NotTo(ContainSubstring("dnslogs")) + Expect(ds.Spec.Template.Spec.Containers[0].Command).To(Equal([]string{"c:/fluent-bit/fluent-bit.exe"})) + Expect(ds.Spec.Template.Spec.Containers[0].Args).To(Equal([]string{"-c", "c:/etc/fluent-bit/conf/fluent-bit.yaml"})) + // The Windows pos-migrator runs with c:-prefixed dirs. + initContainers := ds.Spec.Template.Spec.InitContainers + Expect(initContainers).To(HaveLen(1)) + Expect(initContainers[0].Command).To(Equal([]string{"c:/fluent-bit/pos-migrator.exe"})) + Expect(initContainers[0].Env).To(ContainElement(corev1.EnvVar{Name: "DB_DIR", Value: "c:/var/log/calico/calico-fluent-bit"})) + + container := ds.Spec.Template.Spec.Containers[0] + + Expect(container.ReadinessProbe.HTTPGet).NotTo(BeNil()) + Expect(container.ReadinessProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.ReadinessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.ReadinessProbe.PeriodSeconds).To(BeEquivalentTo(60)) + + Expect(container.LivenessProbe.HTTPGet).NotTo(BeNil()) + Expect(container.LivenessProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.LivenessProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.LivenessProbe.PeriodSeconds).To(BeEquivalentTo(60)) + + Expect(container.StartupProbe.HTTPGet).NotTo(BeNil()) + Expect(container.StartupProbe.HTTPGet.Path).To(Equal("/api/v1/health")) + Expect(container.StartupProbe.TimeoutSeconds).To(BeEquivalentTo(10)) + Expect(container.StartupProbe.PeriodSeconds).To(BeEquivalentTo(60)) + Expect(container.StartupProbe.FailureThreshold).To(BeEquivalentTo(10)) + + Expect(container.SecurityContext).To(BeNil()) + }) + + It("should render with S3 configuration", func() { + cfg.S3Credential = &logcollector.S3Credential{ + KeyId: []byte("IdForTheKey"), + KeySecret: []byte("SecretForTheKey"), + } + cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ + S3: &operatorv1.S3StoreSpec{ + Region: "anyplace", + BucketName: "thebucket", + BucketPath: "bucketpath", + }, + } + + expectedResources := []client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "log-collector-s3-credentials", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, + } + + // Should render the correct resources. + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + rtest.ExpectResources(resources, expectedResources) + + ds := rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(ds.Spec.Template.Annotations).To(HaveKey("hash.operator.tigera.io/s3-credentials")) + + // S3 credential env vars are still set for the container to consume. + envs := ds.Spec.Template.Spec.Containers[0].Env + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "AWS_ACCESS_KEY_ID", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "log-collector-s3-credentials"}, + Key: "key-id", + }, + }, + })) + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "AWS_SECRET_ACCESS_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "log-collector-s3-credentials"}, + Key: "key-secret", + }, + }, + })) + + // S3 output configuration is now in the ConfigMap. + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + Expect(cm.Data).To(HaveKey("fluent-bit.yaml")) + fluentBitConf := cm.Data["fluent-bit.yaml"] + Expect(fluentBitConf).To(ContainSubstring("s3")) + Expect(fluentBitConf).To(ContainSubstring("thebucket")) + Expect(fluentBitConf).To(ContainSubstring("anyplace")) + Expect(fluentBitConf).To(ContainSubstring("bucketpath")) + }) + + It("should render with Syslog configuration", func() { + expectedResources := []client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, + } + + var ps int32 = 180 + cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ + Syslog: &operatorv1.SyslogStoreSpec{ + Endpoint: "tcp://1.2.3.4:80", + PacketSize: &ps, + LogTypes: []operatorv1.SyslogLogType{ + operatorv1.SyslogLogDNS, + operatorv1.SyslogLogFlows, + operatorv1.SyslogLogIDSEvents, + }, + }, + } + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + rtest.ExpectResources(resources, expectedResources) + + ds := rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(ds.Spec.Template.Spec.Volumes).To(HaveLen(4)) + + // Syslog configuration is now in the ConfigMap. + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + Expect(cm.Data).To(HaveKey("fluent-bit.yaml")) + fluentBitConf := cm.Data["fluent-bit.yaml"] + Expect(fluentBitConf).To(ContainSubstring("syslog")) + Expect(fluentBitConf).To(ContainSubstring("1.2.3.4")) + Expect(fluentBitConf).To(ContainSubstring("80")) + }) + + It("should render with Syslog configuration with TLS and user's corporate CA", func() { + cfg.UseSyslogCertificate = true + var ps int32 = 180 + cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ + Syslog: &operatorv1.SyslogStoreSpec{ + Endpoint: "tcp://1.2.3.4:80", + Encryption: operatorv1.EncryptionTLS, + PacketSize: &ps, + LogTypes: []operatorv1.SyslogLogType{ + operatorv1.SyslogLogDNS, + operatorv1.SyslogLogFlows, + operatorv1.SyslogLogIDSEvents, + }, + }, + } + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + + ds := rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(ds.Spec.Template.Spec.Volumes).To(HaveLen(4)) + + var volnames []string + for _, vol := range ds.Spec.Template.Spec.Volumes { + volnames = append(volnames, vol.Name) + } + Expect(volnames).To(ContainElement("tigera-ca-bundle")) + + // Syslog TLS configuration is in the ConfigMap. + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + fluentBitConf := cm.Data["fluent-bit.yaml"] + Expect(fluentBitConf).To(ContainSubstring("syslog")) + Expect(fluentBitConf).To(ContainSubstring("1.2.3.4")) + Expect(fluentBitConf).To(ContainSubstring("tls")) + }) + + It("should render with Syslog configuration with TLS and Internet CA", func() { + cfg.UseSyslogCertificate = false + var ps int32 = 180 + cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ + Syslog: &operatorv1.SyslogStoreSpec{ + Endpoint: "tcp://1.2.3.4:80", + Encryption: operatorv1.EncryptionTLS, + PacketSize: &ps, + LogTypes: []operatorv1.SyslogLogType{ + operatorv1.SyslogLogDNS, + operatorv1.SyslogLogFlows, + operatorv1.SyslogLogIDSEvents, + }, + }, + } + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + + ds := rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(ds.Spec.Template.Spec.Volumes).To(HaveLen(4)) + + // Syslog TLS configuration is in the ConfigMap. + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + fluentBitConf := cm.Data["fluent-bit.yaml"] + Expect(fluentBitConf).To(ContainSubstring("syslog")) + Expect(fluentBitConf).To(ContainSubstring("1.2.3.4")) + Expect(fluentBitConf).To(ContainSubstring("tls")) + }) + + It("should render with splunk configuration", func() { + cfg.SplkCredential = &logcollector.SplunkCredential{ + Token: []byte("TokenForHEC"), + } + cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ + Splunk: &operatorv1.SplunkStoreSpec{ + Endpoint: "https://1.2.3.4:8088", + }, + } + + expectedResources := []client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "logcollector-splunk-credentials", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, + } + + // Should render the correct resources. + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + rtest.ExpectResources(resources, expectedResources) + + ds := rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(ds.Spec.Template.Spec.Volumes).To(HaveLen(4)) + + // Splunk HEC token credential env var is still set. + envs := ds.Spec.Template.Spec.Containers[0].Env + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "SPLUNK_HEC_TOKEN", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "logcollector-splunk-credentials"}, + Key: "token", + }, + }, + })) + + // Splunk output configuration is now in the ConfigMap. + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + Expect(cm.Data).To(HaveKey("fluent-bit.yaml")) + fluentBitConf := cm.Data["fluent-bit.yaml"] + Expect(fluentBitConf).To(ContainSubstring("splunk")) + Expect(fluentBitConf).To(ContainSubstring("1.2.3.4")) + Expect(fluentBitConf).To(ContainSubstring("8088")) + }) + + It("should honor hostScope and preserve legacy store semantics for the additional stores", func() { + // Parses the rendered config and returns the match tags per output + // plugin, plus the raw output maps for property assertions. + renderOutputs := func() map[string][]map[string]interface{} { + resources, _ := logcollector.FluentBit(cfg).Objects() + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + var conf struct { + Pipeline struct { + Outputs []map[string]interface{} `json:"outputs"` + } `json:"pipeline"` + } + Expect(json.Unmarshal([]byte(cm.Data["fluent-bit.yaml"]), &conf)).NotTo(HaveOccurred()) + byName := map[string][]map[string]interface{}{} + for _, out := range conf.Pipeline.Outputs { + name := out["name"].(string) + byName[name] = append(byName[name], out) + } + return byName + } + matchTags := func(outs []map[string]interface{}) []string { + var tags []string + for _, out := range outs { + tags = append(tags, out["match"].(string)) + } + return tags + } + + cfg.LogCollector.Spec.AdditionalStores = &operatorv1.AdditionalLogStoreSpec{ + S3: &operatorv1.S3StoreSpec{Region: "anyplace", BucketName: "thebucket", BucketPath: "bucketpath"}, + Syslog: &operatorv1.SyslogStoreSpec{ + Endpoint: "tcp://1.2.3.4:80", + LogTypes: []operatorv1.SyslogLogType{operatorv1.SyslogLogFlows, operatorv1.SyslogLogDNS}, + }, + Splunk: &operatorv1.SplunkStoreSpec{Endpoint: "https://1.2.3.4:8088"}, + } + + // Default (hostScope All): cluster logs plus non-cluster flows. + byName := renderOutputs() + + // S3 archives the fluentd set — never WAF, BGP, IDS events or policy + // activity — and non-cluster flows ship alongside cluster logs. + Expect(matchTags(byName["s3"])).To(ConsistOf( + "flows", "dns", "l7", "runtime", "audit.tsee", "audit.kube", "compliance.reports", "non_cluster_flows")) + for _, out := range byName["s3"] { + // Legacy archives were gzipped (fluent-plugin-s3 default store_as). + Expect(out).To(HaveKeyWithValue("compression", "gzip"), "s3 output %v", out["match"]) + } + // The S3 prefixes preserve fluentd's archive layout (audit_tsee, not the + // audit.tsee tag). + for _, out := range byName["s3"] { + if out["match"] == "audit.tsee" { + Expect(out["s3_key_format"]).To(HavePrefix("bucketpath/audit_tsee/")) + } + } + + Expect(matchTags(byName["syslog"])).To(ConsistOf("flows", "non_cluster_flows", "dns")) + for _, out := range byName["syslog"] { + // syslog_severity_preset is an integer property; rendering "info" + // would atoi to 0/Emergency. The default (6) already is info. + Expect(out).NotTo(HaveKey("syslog_severity_preset")) + // Node-name fallback for tags whose records carry no host key. + Expect(out).To(HaveKeyWithValue("syslog_hostname_preset", "${NODENAME}")) + } + + Expect(matchTags(byName["splunk"])).To(ConsistOf( + "flows", "dns", "l7", "audit.tsee", "audit.kube", "non_cluster_flows")) + + // NonClusterOnly: cluster *flows* are the only type the scope gates — + // the other cluster types keep shipping, and non-cluster flows always do. + nonClusterOnly := operatorv1.HostScopeNonClusterOnly + cfg.LogCollector.Spec.AdditionalStores.S3.HostScope = &nonClusterOnly + cfg.LogCollector.Spec.AdditionalStores.Syslog.HostScope = &nonClusterOnly + cfg.LogCollector.Spec.AdditionalStores.Splunk.HostScope = &nonClusterOnly + byName = renderOutputs() + Expect(matchTags(byName["s3"])).To(ConsistOf( + "dns", "l7", "runtime", "audit.tsee", "audit.kube", "compliance.reports", "non_cluster_flows")) + Expect(matchTags(byName["syslog"])).To(ConsistOf("non_cluster_flows", "dns")) + Expect(matchTags(byName["splunk"])).To(ConsistOf( + "dns", "l7", "audit.tsee", "audit.kube", "non_cluster_flows")) + }) + + It("should render with filter", func() { + cfg.Filters = &logcollector.FluentBitFilters{ + Flow: "- name: grep\n exclude: dest_namespace noisy\n", + } + + expectedResources := []client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, + } + + // Should render the correct resources. + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + + rtest.ExpectResources(resources, expectedResources) + + // User filters are inlined into the rendered config, scoped to the log + // type's tag, and roll the pods via the config hash annotation. + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, "calico-system", "", "v1", "ConfigMap").(*corev1.ConfigMap) + conf := cm.Data["fluent-bit.yaml"] + Expect(conf).To(ContainSubstring(`"name": "grep"`)) + Expect(conf).To(ContainSubstring(`"exclude": "dest_namespace noisy"`)) + Expect(conf).To(ContainSubstring(`"match": "flows"`)) + + ds := rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet").(*appsv1.DaemonSet) + Expect(ds.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(ds.Spec.Template.Annotations).To(HaveKey("hash.operator.tigera.io/fluent-bit-config")) + }) + + It("flags filter ConfigMap keys that are not valid fluent-bit YAML", func() { + var nilFilters *logcollector.FluentBitFilters + Expect(nilFilters.InvalidKeys()).To(BeNil()) + + Expect((&logcollector.FluentBitFilters{}).InvalidKeys()).To(BeEmpty()) + + valid := &logcollector.FluentBitFilters{ + Flow: "- name: grep\n exclude: dest_namespace noisy\n", + DNS: "- name: grep\n exclude: qname foo\n", + } + Expect(valid.InvalidKeys()).To(BeEmpty()) + + // A leftover fluentd block does not parse as a fluent-bit YAML list. + mixed := &logcollector.FluentBitFilters{ + Flow: "\n @type grep\n\n", + DNS: "- name: grep\n exclude: qname foo\n", + } + Expect(mixed.InvalidKeys()).To(ConsistOf(logcollector.FluentBitFilterFlowName)) + }) + + It("skips invalid user filter content during render and still renders the daemonset", func() { + cfg.Filters = &logcollector.FluentBitFilters{ + Flow: "\n @type grep\n\n", // invalid fluent-bit YAML (fluentd syntax) + DNS: "- name: grep\n exclude: qname foo\n", // valid + } + + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, "calico-system", "", "v1", "ConfigMap").(*corev1.ConfigMap) + conf := cm.Data["fluent-bit.yaml"] + // The valid DNS filter is inlined and scoped to its tag. + Expect(conf).To(ContainSubstring(`"exclude": "qname foo"`)) + Expect(conf).To(ContainSubstring(`"match": "dns"`)) + // The invalid fluentd-syntax flow filter is dropped, not inlined. + Expect(conf).NotTo(ContainSubstring("@type")) + // The daemonset still renders despite the bad filter. + Expect(rtest.GetResource(resources, "calico-fluent-bit", "calico-system", "apps", "v1", "DaemonSet")).NotTo(BeNil()) + }) + + It("should render with EKS Cloudwatch Log", func() { + expectedResources := getExpectedResourcesForEKS(false) + cfg.EKSConfig = setupEKSCloudwatchLogConfig() + t := corev1.Toleration{ + Key: "foo", + Operator: corev1.TolerationOpEqual, + Value: "bar", + } + cfg.Installation = &operatorv1.InstallationSpec{ + KubernetesProvider: operatorv1.ProviderEKS, + ControlPlaneTolerations: []corev1.Toleration{t}, + } + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + Expect(len(resources)).To(Equal(len(expectedResources))) + + // Should render the correct resources. + rtest.ExpectResources(resources, expectedResources) + deploy := rtest.GetResource(resources, "eks-log-forwarder", "calico-system", "apps", "v1", "Deployment").(*appsv1.Deployment) + + // The fluentd-era startup init container is gone: the in_eks input + // plugin resolves its own resume point from Linseed on every start. + Expect(deploy.Spec.Template.Spec.InitContainers).To(BeEmpty()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(deploy.Spec.Template.Annotations).To(HaveKey("hash.operator.tigera.io/eks-cloudwatch-log-credentials")) + Expect(deploy.Spec.Template.Annotations).To(HaveKey("hash.operator.tigera.io/fluent-bit-config")) + Expect(deploy.Spec.Template.Spec.Tolerations).To(ContainElement(t)) + + Expect(deploy.Spec.Template.Spec.Containers[0].Command).To(Equal([]string{"/usr/bin/fluent-bit"})) + Expect(deploy.Spec.Template.Spec.Containers[0].Args).To(Equal([]string{"-c", "/etc/fluent-bit/fluent-bit.yaml"})) + + envs := deploy.Spec.Template.Spec.Containers[0].Env + Expect(envs).To(ContainElement(corev1.EnvVar{Name: "AWS_REGION", Value: cfg.EKSConfig.AwsRegion})) + + Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation).To(BeFalse()) + Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.Privileged).To(BeFalse()) + Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.RunAsGroup).To(BeEquivalentTo(0)) + Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.RunAsNonRoot).To(BeFalse()) + Expect(*deploy.Spec.Template.Spec.Containers[0].SecurityContext.RunAsUser).To(BeEquivalentTo(0)) + Expect(deploy.Spec.Template.Spec.Containers[0].SecurityContext.Capabilities).To(Equal( + &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + )) + Expect(deploy.Spec.Template.Spec.Containers[0].SecurityContext.SeccompProfile).To(Equal( + &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + })) + + expectedEnvVars := []corev1.EnvVar{ + {Name: "LOG_LEVEL", Value: "info", ValueFrom: nil}, + {Name: "EKS_CLOUDWATCH_LOG_GROUP", Value: "dummy-eks-cluster-cloudwatch-log-group"}, + {Name: "AWS_REGION", Value: "us-west-1", ValueFrom: nil}, + { + Name: "AWS_ACCESS_KEY_ID", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "tigera-eks-log-forwarder-secret", + }, + Key: "aws-id", + }, + }, + }, + { + Name: "AWS_SECRET_ACCESS_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "tigera-eks-log-forwarder-secret", + }, + Key: "aws-key", + Optional: nil, + }, + }, + }, + {Name: "LINSEED_ENDPOINT", Value: "https://tigera-linseed.tigera-elasticsearch.svc"}, + {Name: "LINSEED_CA_PATH", Value: "/etc/pki/tls/certs/tigera-ca-bundle.crt"}, + {Name: "TLS_CRT_PATH", Value: "/tigera-eks-log-forwarder-tls/tls.crt"}, + {Name: "TLS_KEY_PATH", Value: "/tigera-eks-log-forwarder-tls/tls.key"}, + {Name: "LINSEED_TOKEN", Value: "/var/run/secrets/kubernetes.io/serviceaccount/token"}, + // streamPrefix is unset in this render config (the controller + // would have defaulted it), so the env var is omitted and the + // plugin's kube-apiserver-audit- default applies; fetchInterval + // is set (900) so it is rendered. + {Name: "EKS_CLOUDWATCH_POLL_INTERVAL", Value: "900s"}, + } + + Expect(envs).To(Equal(expectedEnvVars)) + + // The rendered config wires the in_eks input into the Linseed http + // output. + cm := rtest.GetResource(resources, logcollector.EKSLogForwarderConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + eksConf := cm.Data["fluent-bit.yaml"] + Expect(eksConf).To(ContainSubstring(`"name": "in_eks"`)) + Expect(eksConf).To(ContainSubstring(`"plugins_file": "/etc/fluent-bit/plugins.conf"`)) + Expect(eksConf).To(ContainSubstring(`"name": "http"`)) + Expect(eksConf).To(ContainSubstring(`"uri": "/api/v1/audit/logs/kube/bulk"`)) + Expect(eksConf).To(ContainSubstring(`"tls.verify_hostname": "on"`)) + }) + + It("should omit unset EKS CloudWatch settings so plugin defaults apply", func() { + cfg.EKSConfig = setupEKSCloudwatchLogConfig() + cfg.EKSConfig.FetchInterval = 0 + cfg.Installation = &operatorv1.InstallationSpec{ + KubernetesProvider: operatorv1.ProviderEKS, + } + + resources, _ := logcollector.FluentBit(cfg).Objects() + deploy := rtest.GetResource(resources, "eks-log-forwarder", "calico-system", "apps", "v1", "Deployment").(*appsv1.Deployment) + // The controller defaults these before render in production; this + // pins the render-level defense in depth — an empty prefix or "0s" + // interval must never reach the plugin, which would override its + // envconfig defaults with broken settings. + for _, env := range deploy.Spec.Template.Spec.Containers[0].Env { + Expect(env.Name).NotTo(Equal("EKS_CLOUDWATCH_LOG_STREAM_PREFIX")) + Expect(env.Name).NotTo(Equal("EKS_CLOUDWATCH_POLL_INTERVAL")) + } + }) + + It("should render EKS Cloudwatch Log toleration on GKE", func() { + cfg.EKSConfig = setupEKSCloudwatchLogConfig() + cfg.Installation.KubernetesProvider = operatorv1.ProviderGKE + + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + deploy := rtest.GetResource(resources, "eks-log-forwarder", "calico-system", "apps", "v1", "Deployment").(*appsv1.Deployment) + Expect(deploy).NotTo(BeNil()) + Expect(deploy.Spec.Template.Spec.Tolerations).To(ContainElements(corev1.Toleration{ + Key: "kubernetes.io/arch", + Operator: corev1.TolerationOpEqual, + Value: "arm64", + Effect: corev1.TaintEffectNoSchedule, + })) + }) + + It("should render with EKS Cloudwatch Log with resources", func() { + cfg.EKSConfig = setupEKSCloudwatchLogConfig() + cfg.Installation = &operatorv1.InstallationSpec{ + KubernetesProvider: operatorv1.ProviderEKS, + } + + eksResources := corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + "cpu": resource.MustParse("2"), + "memory": resource.MustParse("300Mi"), + "storage": resource.MustParse("20Gi"), + }, + Requests: corev1.ResourceList{ + "cpu": resource.MustParse("1"), + "memory": resource.MustParse("150Mi"), + "storage": resource.MustParse("10Gi"), + }, + } + + logCollectorcfg := operatorv1.LogCollector{ + Spec: operatorv1.LogCollectorSpec{ + EKSLogForwarderDeployment: &operatorv1.EKSLogForwarderDeployment{ + Spec: &operatorv1.EKSLogForwarderDeploymentSpec{ + Template: &operatorv1.EKSLogForwarderDeploymentPodTemplateSpec{ + Spec: &operatorv1.EKSLogForwarderDeploymentPodSpec{ + Containers: []operatorv1.EKSLogForwarderDeploymentContainer{{ + Name: "eks-log-forwarder", + Resources: &eksResources, + }}, + }, + }, + }, + }, + }, + } + + cfg.LogCollector = &logCollectorcfg + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + deploy := rtest.GetResource(resources, "eks-log-forwarder", "calico-system", "apps", "v1", "Deployment").(*appsv1.Deployment) + + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) + container := test.GetContainer(deploy.Spec.Template.Spec.Containers, "eks-log-forwarder") + Expect(container).NotTo(BeNil()) + Expect(container.Resources).To(Equal(eksResources)) + + Expect(deploy.Spec.Template.Spec.InitContainers).To(BeEmpty()) + }) + + It("should render with EKS Cloudwatch Log with multi tenant envvars", func() { + expectedResources := getExpectedResourcesForEKS(false) + cfg.EKSConfig = setupEKSCloudwatchLogConfig() + t := corev1.Toleration{ + Key: "foo", + Operator: corev1.TolerationOpEqual, + Value: "bar", + } + cfg.Installation = &operatorv1.InstallationSpec{ + KubernetesProvider: operatorv1.ProviderEKS, + ControlPlaneTolerations: []corev1.Toleration{t}, + } + cfg.ExternalElastic = true + + // Create the Tenant object. + tenant := &operatorv1.Tenant{} + tenant.Name = "default" + tenant.Namespace = "tenant-namespace" + tenant.Spec.ID = "test-tenant-id" + cfg.Tenant = tenant + + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + Expect(len(resources)).To(Equal(len(expectedResources))) + + // Should render the correct resources. + rtest.ExpectResources(resources, expectedResources) + + deploy := rtest.GetResource(resources, "eks-log-forwarder", "calico-system", "apps", "v1", "Deployment").(*appsv1.Deployment) + envs := deploy.Spec.Template.Spec.Containers[0].Env + Expect(envs).To(ContainElement(corev1.EnvVar{Name: "LINSEED_ENDPOINT", Value: "https://tigera-linseed.tenant-namespace.svc"})) + Expect(envs).To(ContainElement(corev1.EnvVar{Name: "TENANT_ID", Value: "test-tenant-id"})) + Expect(envs).To(ContainElement(corev1.EnvVar{Name: "LINSEED_TOKEN", Value: "/var/run/secrets/kubernetes.io/serviceaccount/token"})) + + // The tenant header rides on the http output config. + cm := rtest.GetResource(resources, logcollector.EKSLogForwarderConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + Expect(cm.Data["fluent-bit.yaml"]).To(ContainSubstring(`"header": "x-tenant-id test-tenant-id"`)) + }) + + It("should render with EKS Cloudwatch Log for managed cluster with linseed token volume", func() { + expectedResources := getExpectedResourcesForEKS(true) + + expectedResources = append(expectedResources, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}) + + cfg.EKSConfig = setupEKSCloudwatchLogConfig() + t := corev1.Toleration{ + Key: "foo", + Operator: corev1.TolerationOpEqual, + Value: "bar", + } + cfg.Installation = &operatorv1.InstallationSpec{ + KubernetesProvider: operatorv1.ProviderEKS, + ControlPlaneTolerations: []corev1.Toleration{t}, + } + cfg.ManagedCluster = true + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + Expect(len(resources)).To(Equal(len(expectedResources))) + + rtest.ExpectResources(resources, expectedResources) + + deploy := rtest.GetResource(resources, "eks-log-forwarder", "calico-system", "apps", "v1", "Deployment").(*appsv1.Deployment) + envs := deploy.Spec.Template.Spec.Containers[0].Env + Expect(envs).To(ContainElement(corev1.EnvVar{Name: "LINSEED_TOKEN", Value: "/var/run/secrets/tigera.io/linseed/token"})) + + // The container mounts the operator-provisioned token for both the + // in_eks resume-point query and the http output's bearer_token_file, + // which re-reads it on every request. + volumeMounts := deploy.Spec.Template.Spec.Containers[0].VolumeMounts + Expect(volumeMounts).To(ContainElement(corev1.VolumeMount{Name: "linseed-token", MountPath: "/var/run/secrets/tigera.io/linseed/"})) + + cm := rtest.GetResource(resources, logcollector.EKSLogForwarderConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + Expect(cm.Data["fluent-bit.yaml"]).To(ContainSubstring(`"bearer_token_file": "/var/run/secrets/tigera.io/linseed/token"`)) + }) + + DescribeTable("should render with a valid configuration for non-cluster host and forwarding enabled", + func(destination string) { + additionalStoreSpecAllHosts := additionalStoreSpecForDestinationAndScope(destination, operatorv1.HostScopeAll) + additionalStoreSpecNonClusterHosts := additionalStoreSpecForDestinationAndScope(destination, operatorv1.HostScopeNonClusterOnly) + + By("establishing the base case with no non-cluster hosts or forwarding options") + expectedResources := []client.Object{ + &v3.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}, + &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: logcollector.PacketCaptureAPIRole, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: logcollector.PacketCaptureAPIRoleBinding, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}, TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}}, + } + cfg.PacketCapture = &operatorv1.PacketCaptureAPI{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tigera-secure", + }, + } + + resources, _ := logcollector.FluentBit(cfg).Objects() + rtest.ExpectResources(resources, expectedResources) + + // Base case: no additional store outputs in ConfigMap besides linseed. + cm := rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + baseConf := cm.Data["fluent-bit.yaml"] + Expect(baseConf).To(ContainSubstring("linseed")) + Expect(baseConf).NotTo(ContainSubstring(strings.ToLower(destination))) + + By("enabling non-cluster hosts and forwarding from all hosts") + cfg.NonClusterHost = &operatorv1.NonClusterHost{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tigera-secure", + }, + Spec: operatorv1.NonClusterHostSpec{ + Endpoint: "https://1.2.3.4:5678", + }, + } + cfg.LogCollector.Spec.AdditionalStores = additionalStoreSpecAllHosts + expectedResources = append(expectedResources, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.FluentBitInputService, Namespace: render.LogCollectorNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}) + + // Should render the correct resources. + resources, _ = logcollector.FluentBit(cfg).Objects() + rtest.ExpectResources(resources, expectedResources) + + // Service is rendered as expected. + ms := rtest.GetResource(resources, render.FluentBitInputService, render.LogCollectorNamespace, "", "v1", "Service").(*corev1.Service) + Expect(ms.Spec.Selector).To(Equal(map[string]string{"k8s-app": render.FluentBitNodeName})) + Expect(ms.Spec.Ports).To(HaveLen(1)) + Expect(ms.Spec.Ports[0].Port).To(BeNumerically("==", logcollector.FluentBitInputPort)) + Expect(ms.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt32(logcollector.FluentBitInputPort))) + Expect(ms.Spec.Ports[0].Protocol).To(Equal(corev1.ProtocolTCP)) + + // ConfigMap should contain the destination output section (all hosts scope). + cm = rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + allHostsConf := cm.Data["fluent-bit.yaml"] + Expect(allHostsConf).To(ContainSubstring(strings.ToLower(destination))) + + // The voltron-relayed non-cluster host tags each get their own http + // output posting to the base tag's bulk URI; a tag without a + // matching output would be silently dropped by the router. + Expect(allHostsConf).To(ContainSubstring(`"match": "non_cluster_flows"`)) + Expect(allHostsConf).To(ContainSubstring(`"match": "non_cluster_dns"`)) + Expect(allHostsConf).To(ContainSubstring(`"match": "non_cluster_policy_activity"`)) + Expect(allHostsConf).To(ContainSubstring(`"uri": "/api/v1/policy_activity/logs/bulk"`)) + + By("enabling forwarding of only non-cluster logs") + cfg.LogCollector.Spec.AdditionalStores = additionalStoreSpecNonClusterHosts + resources, _ = logcollector.FluentBit(cfg).Objects() + rtest.ExpectResources(resources, expectedResources) + + // ConfigMap should still contain the destination output section (non-cluster scope). + cm = rtest.GetResource(resources, logcollector.FluentBitConfConfigMapName, render.LogCollectorNamespace, "", "v1", "ConfigMap").(*corev1.ConfigMap) + nonClusterConf := cm.Data["fluent-bit.yaml"] + Expect(nonClusterConf).To(ContainSubstring(strings.ToLower(destination))) + }, + Entry("S3", "S3"), + Entry("Syslog", "Syslog"), + Entry("Splunk", "Splunk")) + + Context("calico-system rendering", func() { + policyName := types.NamespacedName{Name: "calico-system.allow-calico-fluent-bit", Namespace: "calico-system"} + + getExpectedPolicy := func(scenario testutils.CalicoSystemScenario) *v3.NetworkPolicy { + if scenario.ManagedCluster { + return expectedFluentBitPolicyForManaged + } else { + return testutils.SelectPolicyByProvider(scenario, expectedFluentBitPolicyForUnmanaged, expectedFluentBitPolicyForUnmanagedOpenshift) + } + } + + DescribeTable("should render calico-system policy", + func(scenario testutils.CalicoSystemScenario) { + if scenario.OpenShift { + cfg.Installation.KubernetesProvider = operatorv1.ProviderOpenShift + } else { + cfg.Installation.KubernetesProvider = operatorv1.ProviderNone + } + cfg.ManagedCluster = scenario.ManagedCluster + + component := logcollector.FluentBit(cfg) + resources, _ := component.Objects() + + policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) + expectedPolicy := getExpectedPolicy(scenario) + Expect(policy).To(Equal(expectedPolicy)) + }, + Entry("for management/standalone, kube-dns", testutils.CalicoSystemScenario{ManagedCluster: false, OpenShift: false}), + Entry("for management/standalone, openshift-dns", testutils.CalicoSystemScenario{ManagedCluster: false, OpenShift: true}), + Entry("for managed, kube-dns", testutils.CalicoSystemScenario{ManagedCluster: true, OpenShift: false}), + Entry("for managed, openshift-dns", testutils.CalicoSystemScenario{ManagedCluster: true, OpenShift: true}), + ) + + It("should render calico-system policy for the non-cluster-host scenario", func() { + resourcesWithoutNonClusterHosts, _ := logcollector.FluentBit(cfg).Objects() + policyWithoutNonClusterHosts := testutils.GetCalicoSystemPolicyFromResources(policyName, resourcesWithoutNonClusterHosts) + cfg.NonClusterHost = &operatorv1.NonClusterHost{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tigera-secure", + }, + Spec: operatorv1.NonClusterHostSpec{ + Endpoint: "https://1.2.3.4:5678", + }, + } + resourcesWithNonClusterHosts, _ := logcollector.FluentBit(cfg).Objects() + policyWithNonClusterHosts := testutils.GetCalicoSystemPolicyFromResources(policyName, resourcesWithNonClusterHosts) + + // Validate that we have a single ingress rule added for the fluent-bit service. + Expect(policyWithoutNonClusterHosts.Spec.Egress).To(Equal(policyWithNonClusterHosts.Spec.Egress)) + Expect(len(policyWithoutNonClusterHosts.Spec.Ingress)).To(Equal(len(policyWithNonClusterHosts.Spec.Ingress) - 1)) + Expect(len(policyWithNonClusterHosts.Spec.Ingress)).To(Equal(2)) + Expect(policyWithNonClusterHosts.Spec.Ingress[1]).To(Equal(v3.Rule{ + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Source: v3.EntityRule{ + Selector: fmt.Sprintf("k8s-app == '%s'", render.ManagerDeploymentName), + NamespaceSelector: fmt.Sprintf("projectcalico.org/name == '%s'", render.ManagerNamespace), + }, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(logcollector.FluentBitInputPort), + }, + })) + }) + }) + + It("should move DaemonSet to toDelete when LicenseExpired is true", func() { + cfg.LicenseExpired = true + component := logcollector.FluentBit(cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + toCreate, toDelete := component.Objects() + + // DaemonSet should not be in toCreate. + for _, obj := range toCreate { + if ds, ok := obj.(*appsv1.DaemonSet); ok { + Fail("DaemonSet should not be in toCreate when license is expired, but found: " + ds.Name) + } + } + + // DaemonSet should be in toDelete. + found := false + for _, obj := range toDelete { + if ds, ok := obj.(*appsv1.DaemonSet); ok && ds.Name == "calico-fluent-bit" { + found = true + break + } + } + Expect(found).To(BeTrue(), "Expected fluent-bit-node DaemonSet to be in toDelete") + }) + + It("should include DaemonSet in toCreate when LicenseExpired is false", func() { + cfg.LicenseExpired = false + component := logcollector.FluentBit(cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + toCreate, _ := component.Objects() + + found := false + for _, obj := range toCreate { + if ds, ok := obj.(*appsv1.DaemonSet); ok && ds.Name == "calico-fluent-bit" { + found = true + break + } + } + Expect(found).To(BeTrue(), "Expected fluent-bit-node DaemonSet to be in toCreate") + }) +}) + +func setupEKSCloudwatchLogConfig() *logcollector.EksCloudwatchLogConfig { + fetchInterval := int32(900) + return &logcollector.EksCloudwatchLogConfig{ + AwsId: []byte("aws-id"), + AwsKey: []byte("aws-key"), + AwsRegion: "us-west-1", + GroupName: "dummy-eks-cluster-cloudwatch-log-group", + FetchInterval: fetchInterval, + } +} + +func getExpectedResourcesForEKS(isManagedcluster bool) []client.Object { + expectedResources := []client.Object{ + &v3.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitPolicyName, Namespace: render.LogCollectorNamespace}, + TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}, + }, + + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitMetricsService, Namespace: render.LogCollectorNamespace}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitConfConfigMapName, Namespace: render.LogCollectorNamespace}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder", Namespace: "calico-system"}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: "calico-system"}}, + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: logcollector.EKSLogForwarderConfConfigMapName, Namespace: render.LogCollectorNamespace}}, + &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder", Namespace: render.LogCollectorNamespace}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "tigera-eks-log-forwarder-secret", Namespace: render.LogCollectorNamespace}}, + &appsv1.DaemonSet{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit", Namespace: render.LogCollectorNamespace}}, + } + + if isManagedcluster { + expectedResources = append(expectedResources, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: render.LogCollectorNamespace}}) + } + return expectedResources +} + +func additionalStoreSpecForDestinationAndScope(destination string, scope operatorv1.HostScope) *operatorv1.AdditionalLogStoreSpec { + var spec operatorv1.AdditionalLogStoreSpec + switch destination { + case "S3": + spec.S3 = &operatorv1.S3StoreSpec{ + Region: "anyplace", + BucketName: "thebucket", + BucketPath: "bucketpath", + HostScope: &scope, + } + case "Syslog": + var ps int32 = 180 + spec.Syslog = &operatorv1.SyslogStoreSpec{ + Endpoint: "tcp://1.2.3.4:80", + PacketSize: &ps, + LogTypes: []operatorv1.SyslogLogType{ + operatorv1.SyslogLogDNS, + operatorv1.SyslogLogFlows, + operatorv1.SyslogLogIDSEvents, + }, + HostScope: &scope, + } + case "Splunk": + spec.Splunk = &operatorv1.SplunkStoreSpec{ + Endpoint: "https://1.2.3.4:8088", + HostScope: &scope, + } + } + + return &spec +} + +func legacyFluentdDeleteResources() []client.Object { + ns := "tigera-fluentd" + return []client.Object{ + &appsv1.DaemonSet{TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: ns}}, + &appsv1.DaemonSet{TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node-windows", Namespace: ns}}, + &appsv1.Deployment{TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder", Namespace: ns}}, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-metrics", Namespace: ns}}, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-http-input", Namespace: ns}}, + &corev1.ServiceAccount{TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: ns}}, + &corev1.ServiceAccount{TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node-windows", Namespace: ns}}, + &corev1.ServiceAccount{TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder", Namespace: ns}}, + &rbacv1.ClusterRole{TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, + &rbacv1.ClusterRole{TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd-windows"}}, + &rbacv1.ClusterRoleBinding{TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, + &rbacv1.ClusterRoleBinding{TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd-windows"}}, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-metrics-windows", Namespace: ns}}, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: ns}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd-prometheus-tls", Namespace: ns}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: logcollector.EKSLogForwarderTLSSecretName, Namespace: ns}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: logcollector.EksLogForwarderSecret, Namespace: ns}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: logcollector.S3FluentBitSecretName, Namespace: ns}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: logcollector.SplunkFluentBitTokenSecretName, Namespace: ns}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node-tigera-linseed-token", Namespace: ns}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node-windows-tigera-linseed-token", Namespace: ns}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder-tigera-linseed-token", Namespace: ns}}, + &corev1.ConfigMap{TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-filters", Namespace: ns}}, + &rbacv1.Role{TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: logcollector.PacketCaptureAPIRole, Namespace: ns}}, + &rbacv1.RoleBinding{TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: logcollector.PacketCaptureAPIRoleBinding, Namespace: ns}}, + &corev1.ResourceQuota{TypeMeta: metav1.TypeMeta{Kind: "ResourceQuota", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: resourcequota.TigeraCriticalResourceQuotaName, Namespace: ns}}, + &corev1.Namespace{TypeMeta: metav1.TypeMeta{Kind: "Namespace", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: ns}}, + // The rendered copy of the user filters ConfigMap is superseded by + // inlining the filters into the rendered config. + &corev1.ConfigMap{TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: logcollector.FluentBitFilterConfigMapName, Namespace: render.LogCollectorNamespace}}, + } +} diff --git a/pkg/render/logcollector/logcollector.go b/pkg/render/logcollector/logcollector.go new file mode 100644 index 0000000000..335c5dddd3 --- /dev/null +++ b/pkg/render/logcollector/logcollector.go @@ -0,0 +1,456 @@ +// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logcollector + +import ( + "crypto/x509" + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/components" + "github.com/tigera/operator/pkg/render" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/render/common/networkpolicy" + "github.com/tigera/operator/pkg/render/common/resourcequota" + "github.com/tigera/operator/pkg/render/common/secret" + "github.com/tigera/operator/pkg/tls/certificatemanagement" + "github.com/tigera/operator/pkg/tls/certkeyusage" +) + +var log = logf.Log.WithName("render_logcollector") + +const ( + // LogCollectorNamespace and the fluent-bit/EKS network-identity symbols below are + // aliased from the render package, where they live to avoid a render -> logcollector + // import cycle (Guardian/Manager reference them when building NetworkPolicies). + LogCollectorNamespace = render.LogCollectorNamespace + FluentBitFilterConfigMapName = "fluent-bit-filters" + FluentBitFilterFlowName = "flow" + FluentBitFilterDNSName = "dns" + S3FluentBitSecretName = "log-collector-s3-credentials" + S3KeyIdName = "key-id" + S3KeySecretName = "key-secret" + + // FluentBitTLSSecretName is the TLS secret used on all connections served by fluent-bit. + FluentBitTLSSecretName = "calico-fluent-bit-tls" + FluentBitMetricsService = "calico-fluent-bit-metrics" + FluentBitMetricsServiceWindows = "calico-fluent-bit-metrics-windows" + FluentBitInputService = render.FluentBitInputService + FluentBitMetricsPortName = "fluent-bit-metrics-port" + FluentBitMetricsPort = 2020 + FluentBitInputPortName = "fluent-bit-input-port" + FluentBitInputPort = 9880 + FluentBitPolicyName = networkpolicy.CalicoComponentPolicyPrefix + "allow-calico-fluent-bit" + configHashAnnotation = "hash.operator.tigera.io/fluent-bit-config" + s3CredentialHashAnnotation = "hash.operator.tigera.io/s3-credentials" + splunkCredentialHashAnnotation = "hash.operator.tigera.io/splunk-credentials" + eksCloudwatchLogCredentialHashAnnotation = "hash.operator.tigera.io/eks-cloudwatch-log-credentials" + fluentBitDefaultFlush = "5s" + EksLogForwarderSecret = "tigera-eks-log-forwarder-secret" + EksLogForwarderAwsId = "aws-id" + EksLogForwarderAwsKey = "aws-key" + SplunkFluentBitTokenSecretName = "logcollector-splunk-credentials" + SplunkFluentBitSecretTokenKey = "token" + SplunkFluentBitSecretCertificateKey = render.SplunkFluentBitSecretCertificateKey + SysLogPublicCADir = "/etc/pki/tls/certs/" + SysLogPublicCertKey = "ca-bundle.crt" + SysLogPublicCAPath = SysLogPublicCADir + SysLogPublicCertKey + SyslogCAConfigMapName = "syslog-ca" + + // Constants for Linseed token volume mounting in managed clusters. + LinseedTokenVolumeName = render.LinseedTokenVolumeName + LinseedTokenKey = render.LinseedTokenKey + LinseedTokenSubPath = render.LinseedTokenSubPath + LinseedTokenSecret = render.LinseedTokenSecret + LinseedVolumeMountPath = render.LinseedVolumeMountPath + LinseedTokenPath = render.LinseedTokenPath + + FluentBitConfConfigMapName = "calico-fluent-bit-conf" + EKSLogForwarderConfConfigMapName = "eks-log-forwarder-conf" + + legacyFluentdNamespace = "tigera-fluentd" + + fluentBitName = "calico-fluent-bit" + fluentBitWindowsName = "calico-fluent-bit-windows" + + FluentBitNodeName = render.FluentBitNodeName + fluentBitNodeWindowsName = render.FluentBitNodeWindowsName + + EKSLogForwarderName = render.EKSLogForwarderName + EKSLogForwarderTLSSecretName = "tigera-eks-log-forwarder-tls" + + PacketCaptureAPIRole = "packetcapture-api-role" + PacketCaptureAPIRoleBinding = "packetcapture-api-role-binding" +) + +// Register secret/certs that need Server and Client Key usage +func init() { + certkeyusage.SetCertKeyUsage(FluentBitTLSSecretName, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}) + certkeyusage.SetCertKeyUsage(EKSLogForwarderTLSSecretName, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}) +} + +type FluentBitFilters struct { + Flow string + DNS string +} + +type S3Credential struct { + KeyId []byte + KeySecret []byte +} + +type SplunkCredential struct { + Token []byte +} + +func FluentBit(cfg *FluentBitConfiguration) render.Component { + return &fluentBitComponent{ + cfg: cfg, + probeTimeout: 10, + probePeriod: 60, + } +} + +type EksCloudwatchLogConfig struct { + AwsId []byte + AwsKey []byte + AwsRegion string + GroupName string + StreamPrefix string + FetchInterval int32 +} + +// FluentBitConfiguration contains all the config information needed to render the component. +type FluentBitConfiguration struct { + LogCollector *operatorv1.LogCollector + S3Credential *S3Credential + SplkCredential *SplunkCredential + Filters *FluentBitFilters + EKSConfig *EksCloudwatchLogConfig + PullSecrets []*corev1.Secret + Installation *operatorv1.InstallationSpec + ClusterDomain string + OSType rmeta.OSType + FluentBitKeyPair certificatemanagement.KeyPairInterface + TrustedBundle certificatemanagement.TrustedBundle + ManagedCluster bool + + // Set if running as a multi-tenant management cluster. Configures the management cluster's + // own fluent-bit daemonset. + Tenant *operatorv1.Tenant + ExternalElastic bool + + // Whether to use User provided certificate or not. + UseSyslogCertificate bool + + // EKSLogForwarderKeyPair contains the certificate presented by EKS LogForwarder when communicating with Linseed + EKSLogForwarderKeyPair certificatemanagement.KeyPairInterface + + PacketCapture *operatorv1.PacketCaptureAPI + + NonClusterHost *operatorv1.NonClusterHost + + // LicenseExpired indicates the license has expired and fluent-bit DaemonSet should be removed. + LicenseExpired bool + + OTelCollectorEnabled bool +} + +type fluentBitComponent struct { + cfg *FluentBitConfiguration + image string + probeTimeout int32 + probePeriod int32 +} + +func (c *fluentBitComponent) ResolveImages(is *operatorv1.ImageSet) error { + reg := c.cfg.Installation.Registry + path := c.cfg.Installation.ImagePath + prefix := c.cfg.Installation.ImagePrefix + + if c.cfg.OSType == rmeta.OSTypeWindows { + var err error + c.image, err = components.GetReference(components.ComponentFluentBitWindows, reg, path, prefix, is) + return err + } + + var err error + c.image, err = components.GetReference(components.ComponentFluentBit, reg, path, prefix, is) + if err != nil { + return err + } + return err +} + +func (c *fluentBitComponent) SupportedOSType() rmeta.OSType { + return c.cfg.OSType +} + +func (c *fluentBitComponent) fluentBitName() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return fluentBitWindowsName + } + return fluentBitName +} + +func (c *fluentBitComponent) fluentBitNodeName() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return fluentBitNodeWindowsName + } + return FluentBitNodeName +} + +// Use different service names depending on the OS type ("calico-fluent-bit-metrics" +// vs "calico-fluent-bit-metrics-windows") in order to help identify which OS daemonset +// we are referring to. +func (c *fluentBitComponent) fluentBitMetricsServiceName() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return FluentBitMetricsServiceWindows + } + return FluentBitMetricsService +} + +func (c *fluentBitComponent) volumeHostPath() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return "c:/TigeraCalico" + } + return "/var/log/calico" +} + +func (c *fluentBitComponent) path(path string) string { + if c.cfg.OSType == rmeta.OSTypeWindows { + // Use c: path prefix for windows. + return "c:" + path + } + // For linux just leave the path as-is. + return path +} + +// Image-layout paths. The Linux image ships fluent-bit at /usr/bin with its +// support files under /etc/fluent-bit; the Windows image (Dockerfile-windows) +// ships everything under C:\fluent-bit. +func (c *fluentBitComponent) binPath() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return "c:/fluent-bit/fluent-bit.exe" + } + return "/usr/bin/fluent-bit" +} + +// pluginsFilePath is the loader config for the in_eks Go plugin shipped in +// the Linux image. The Windows image carries no Go plugins, so the Windows +// configs never reference it. +func (c *fluentBitComponent) pluginsFilePath() string { + return "/etc/fluent-bit/plugins.conf" +} + +func (c *fluentBitComponent) luaScriptPath() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return "c:/fluent-bit/record_transformer.lua" + } + return "/etc/fluent-bit/record_transformer.lua" +} + +// configPath is where the container reads the rendered config: a subPath file +// mount on Linux, a directory mount on Windows (which cannot mount single +// files). +func (c *fluentBitComponent) configPath() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return "c:/etc/fluent-bit/conf/fluent-bit.yaml" + } + return "/etc/fluent-bit/fluent-bit.yaml" +} + +// fluentBitConfConfigMapName is OS-suffixed: on mixed clusters the Linux and +// Windows components each render their own config (different paths and input +// sets), and a shared name would make the two renders overwrite each other. +func (c *fluentBitComponent) fluentBitConfConfigMapName() string { + if c.cfg.OSType == rmeta.OSTypeWindows { + return FluentBitConfConfigMapName + "-windows" + } + return FluentBitConfConfigMapName +} + +func (c *fluentBitComponent) Objects() ([]client.Object, []client.Object) { + var objs, toDelete []client.Object + objs = append(objs, c.calicoSystemPolicy()) + objs = append(objs, c.metricsService()) + objs = append(objs, c.fluentBitConfigMap()) + + // allow-tigera Tier was renamed to calico-system + toDelete = append(toDelete, networkpolicy.DeprecatedAllowTigeraNetworkPolicyObject("allow-calico-fluent-bit", LogCollectorNamespace)) + + // Clean up legacy fluentd resources from the old namespace. + toDelete = append(toDelete, + &appsv1.DaemonSet{TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: legacyFluentdNamespace}}, + &appsv1.DaemonSet{TypeMeta: metav1.TypeMeta{Kind: "DaemonSet", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node-windows", Namespace: legacyFluentdNamespace}}, + &appsv1.Deployment{TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder", Namespace: legacyFluentdNamespace}}, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-metrics", Namespace: legacyFluentdNamespace}}, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-http-input", Namespace: legacyFluentdNamespace}}, + &corev1.ServiceAccount{TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node", Namespace: legacyFluentdNamespace}}, + &corev1.ServiceAccount{TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-node-windows", Namespace: legacyFluentdNamespace}}, + &corev1.ServiceAccount{TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "eks-log-forwarder", Namespace: legacyFluentdNamespace}}, + &rbacv1.ClusterRole{TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, + &rbacv1.ClusterRole{TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd-windows"}}, + &rbacv1.ClusterRoleBinding{TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd"}}, + &rbacv1.ClusterRoleBinding{TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd-windows"}}, + // Note: the eks-log-forwarder ClusterRole/ClusterRoleBinding are NOT + // deleted here — they are cluster-scoped, keep their fluentd-era names, + // and are reused (updated in place) by the fluent-bit EKS forwarder + // render. Deleting them would fight the create processed earlier in the + // same reconcile and leave the forwarder without Linseed RBAC. + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-metrics-windows", Namespace: legacyFluentdNamespace}}, + &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-linseed", Namespace: legacyFluentdNamespace}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "tigera-fluentd-prometheus-tls", Namespace: legacyFluentdNamespace}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: EKSLogForwarderTLSSecretName, Namespace: legacyFluentdNamespace}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: EksLogForwarderSecret, Namespace: legacyFluentdNamespace}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: S3FluentBitSecretName, Namespace: legacyFluentdNamespace}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: SplunkFluentBitTokenSecretName, Namespace: legacyFluentdNamespace}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf(LinseedTokenSecret, "fluentd-node"), Namespace: legacyFluentdNamespace}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf(LinseedTokenSecret, "fluentd-node-windows"), Namespace: legacyFluentdNamespace}}, + &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf(LinseedTokenSecret, "eks-log-forwarder"), Namespace: legacyFluentdNamespace}}, + &corev1.ConfigMap{TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: "fluentd-filters", Namespace: legacyFluentdNamespace}}, + &rbacv1.Role{TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: PacketCaptureAPIRole, Namespace: legacyFluentdNamespace}}, + &rbacv1.RoleBinding{TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{Name: PacketCaptureAPIRoleBinding, Namespace: legacyFluentdNamespace}}, + &corev1.ResourceQuota{TypeMeta: metav1.TypeMeta{Kind: "ResourceQuota", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: resourcequota.TigeraCriticalResourceQuotaName, Namespace: legacyFluentdNamespace}}, + // The namespace itself goes last so the resources above are removed + // individually first (no finalizer surprises) on clusters upgrading from + // the fluentd era. + &corev1.Namespace{TypeMeta: metav1.TypeMeta{Kind: "Namespace", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: legacyFluentdNamespace}}, + ) + + if c.cfg.Installation.KubernetesProvider.IsGKE() { + // We do this only for GKE as other providers don't (yet?) + // automatically add resource quota that constrains whether + // components that are marked cluster or node critical + // can be scheduled. + objs = append(objs, c.fluentBitResourceQuota()) + } + if c.cfg.S3Credential != nil { + objs = append(objs, c.s3CredentialSecret()) + } + if c.cfg.SplkCredential != nil { + objs = append(objs, secret.ToRuntimeObjects(secret.CopyToNamespace(LogCollectorNamespace, c.splunkCredentialSecret()...)...)...) + } + // User filters are inlined into the rendered config (addUserFilters); the + // copy of the filters ConfigMap an earlier iteration rendered into + // calico-system is no longer used. + toDelete = append(toDelete, + &corev1.ConfigMap{TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: FluentBitFilterConfigMapName, Namespace: LogCollectorNamespace}}) + if c.cfg.EKSConfig != nil && c.cfg.OSType == rmeta.OSTypeLinux { + objs = append(objs, + c.eksLogForwarderClusterRole(), + c.eksLogForwarderClusterRoleBinding()) + + objs = append(objs, c.eksLogForwarderServiceAccount(), + c.eksLogForwarderSecret(), + c.eksConfigMap(), + c.eksLogForwarderDeployment()) + } + + // Add in the cluster role and binding. + objs = append(objs, + c.fluentBitClusterRole(), + c.fluentBitClusterRoleBinding(), + ) + if c.cfg.ManagedCluster { + objs = append(objs, c.externalLinseedService()) + objs = append(objs, c.externalLinseedRoleBinding()) + } else { + toDelete = append(toDelete, c.externalLinseedService()) + toDelete = append(toDelete, c.externalLinseedRoleBinding()) + } + + objs = append(objs, c.fluentBitServiceAccount()) + if c.cfg.PacketCapture != nil { + objs = append(objs, c.packetCaptureApiRole(), c.packetCaptureApiRoleBinding()) + } + + if c.cfg.LicenseExpired { + toDelete = append(toDelete, c.daemonset()) + } else { + objs = append(objs, c.daemonset()) + } + + if c.cfg.OSType == rmeta.OSTypeLinux { + if c.cfg.NonClusterHost != nil { + objs = append(objs, c.nonClusterHostInputService()) + } else { + // Clean up the input service when the NonClusterHost resource is + // removed; the rendered config drops the http input at the same time. + toDelete = append(toDelete, c.nonClusterHostInputService()) + } + } + + return objs, toDelete +} + +func (c *fluentBitComponent) Ready() bool { + return true +} + +type fluentBitConfig struct { + Service map[string]interface{} `json:"service"` + Parsers []map[string]interface{} `json:"parsers,omitempty"` + Pipeline fluentBitPipeline `json:"pipeline"` + Plugins []map[string]interface{} `json:"plugins,omitempty"` +} + +type fluentBitPipeline struct { + Inputs []map[string]interface{} `json:"inputs"` + Filters []map[string]interface{} `json:"filters,omitempty"` + Outputs []map[string]interface{} `json:"outputs"` +} + +// logInput is one tail input: the canonical tag and the file the producing +// component writes. +type logInput struct { + tag, path, parser string +} + +// linuxLogInputs are the log files the Linux daemonset tails. The paths are +// the producers' output paths, matching the authoritative defaults the fluentd +// image carried (fluentd/Dockerfile ENV *_LOG_FILE) — felix, BIRD, the +// apiserver audit policy, intrusion detection, compliance and the runtime +// security agent all keep writing to the same locations. +var linuxLogInputs = []logInput{ + {"flows", "/var/log/calico/flowlogs/flows.log", "json"}, + {"dns", "/var/log/calico/dnslogs/dns.log", "json"}, + {"l7", "/var/log/calico/l7logs/l7.log", "json"}, + {"waf", "/var/log/calico/waf/waf.log", "json"}, + {"runtime", "/var/log/calico/runtime-security/report.log", "json"}, + {"audit.tsee", "/var/log/calico/audit/tsee-audit.log", "json_audit"}, + {"audit.kube", "/var/log/calico/audit/kube-audit.log", "json_audit"}, + {"bird", "/var/log/calico/bird/current", "bird_regex"}, + {"bird6", "/var/log/calico/bird6/current", "bird_regex"}, + {"ids.events", "/var/log/calico/ids/events.log", "json_ids_events"}, + {"compliance.reports", "/var/log/calico/compliance/compliance.*.reports.log", "json"}, + {"policy_activity", "/var/log/calico/policy/policy_activity.log", "json"}, +} + +// windowsLogInputs match the fluentd Windows variant +// (fluentd/fluent_sources.conf.windows), which tails only flows and the audit +// logs. +var windowsLogInputs = []logInput{ + {"flows", "/var/log/calico/flowlogs/flows.log", "json"}, + {"audit.tsee", "/var/log/calico/audit/tsee-audit.log", "json_audit"}, + {"audit.kube", "/var/log/calico/audit/kube-audit.log", "json_audit"}, +} diff --git a/pkg/render/logcollector/logcollector_suite_test.go b/pkg/render/logcollector/logcollector_suite_test.go new file mode 100644 index 0000000000..3ebaabd8a9 --- /dev/null +++ b/pkg/render/logcollector/logcollector_suite_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logcollector_test + +import ( + "testing" + + uzap "go.uber.org/zap" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +// clusterDomain mirrors the shared render_test constant; the log-collector specs were +// moved out of pkg/render so they keep their own copy. +const clusterDomain = "cluster.local" + +func TestLogCollector(t *testing.T) { + logf.SetLogger(zap.New(zap.WriteTo(ginkgo.GinkgoWriter), zap.UseDevMode(true), zap.Level(uzap.NewAtomicLevelAt(uzap.DebugLevel)))) + gomega.RegisterFailHandler(ginkgo.Fail) + suiteConfig, reporterConfig := ginkgo.GinkgoConfiguration() + reporterConfig.JUnitReport = "../../../report/ut/logcollector_suite.xml" + ginkgo.RunSpecs(t, "pkg/render/logcollector Suite", suiteConfig, reporterConfig) +} diff --git a/pkg/render/logcollector/networkpolicy.go b/pkg/render/logcollector/networkpolicy.go new file mode 100644 index 0000000000..a4ea51b83a --- /dev/null +++ b/pkg/render/logcollector/networkpolicy.go @@ -0,0 +1,223 @@ +// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logcollector + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" + + "github.com/tigera/operator/pkg/render" + "github.com/tigera/operator/pkg/render/common/networkpolicy" + "github.com/tigera/operator/pkg/render/common/resourcequota" +) + +func (c *fluentBitComponent) nonClusterHostInputService() *corev1.Service { + return &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: FluentBitInputService, + Namespace: LogCollectorNamespace, + Labels: map[string]string{"k8s-app": c.fluentBitNodeName()}, + }, + // We do not treat this service as a headless service, as we want to ensure traffic is load-balanced. This is because: + // - We have no guarantee that the client (voltron) will perform load balancing across the returned records. The + // golang dialer implementation appears to prefer the first record returned (see dialSerial in the go SDK) + // - We have no guarantee that the DNS server will perform load-balancing or randomize the order of records returned + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"k8s-app": c.fluentBitNodeName()}, + Ports: []corev1.ServicePort{ + { + Name: FluentBitInputPortName, + Port: int32(FluentBitInputPort), + TargetPort: intstr.FromInt(FluentBitInputPort), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } +} + +func (c *fluentBitComponent) externalLinseedRoleBinding() *rbacv1.RoleBinding { + // For managed clusters, we must create a role binding to allow Linseed to manage access token secrets + // in our namespace. + return &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "tigera-linseed", + Namespace: LogCollectorNamespace, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: render.TigeraLinseedSecretsClusterRole, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: render.GuardianServiceAccountName, + Namespace: render.GuardianNamespace, + }, + }, + } +} + +func (c *fluentBitComponent) externalLinseedService() *corev1.Service { + // For managed clusters, we must create an external service for fluent-bit to forward requests to guardian. + return &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "tigera-linseed", + Namespace: LogCollectorNamespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: fmt.Sprintf("%s.%s.svc.%s", render.GuardianServiceName, render.GuardianNamespace, c.cfg.ClusterDomain), + }, + } +} + +func (c *fluentBitComponent) fluentBitResourceQuota() *corev1.ResourceQuota { + criticalPriorityClasses := []string{render.NodePriorityClassName} + return resourcequota.ResourceQuotaForPriorityClassScope(resourcequota.TigeraCriticalResourceQuotaName, LogCollectorNamespace, criticalPriorityClasses) +} + +func (c *fluentBitComponent) s3CredentialSecret() *corev1.Secret { + if c.cfg.S3Credential == nil { + return nil + } + return &corev1.Secret{ + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: S3FluentBitSecretName, + Namespace: LogCollectorNamespace, + }, + Data: map[string][]byte{ + S3KeyIdName: c.cfg.S3Credential.KeyId, + S3KeySecretName: c.cfg.S3Credential.KeySecret, + }, + } +} + +func (c *fluentBitComponent) splunkCredentialSecret() []*corev1.Secret { + if c.cfg.SplkCredential == nil { + return nil + } + return []*corev1.Secret{ + { + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: SplunkFluentBitTokenSecretName, + Namespace: LogCollectorNamespace, + }, + Data: map[string][]byte{ + SplunkFluentBitSecretTokenKey: c.cfg.SplkCredential.Token, + }, + }, + } +} + +func (c *fluentBitComponent) calicoSystemPolicy() *v3.NetworkPolicy { + multiTenant := false + tenantNamespace := "" + if c.cfg.Tenant != nil { + multiTenant = true + tenantNamespace = c.cfg.Tenant.Namespace + } + policyHelper := networkpolicy.Helper(multiTenant, tenantNamespace) + + egressRules := []v3.Rule{} + if c.cfg.ManagedCluster { + egressRules = append(egressRules, v3.Rule{ + Action: v3.Deny, + Protocol: &networkpolicy.TCPProtocol, + Source: v3.EntityRule{}, + Destination: v3.EntityRule{ + NamespaceSelector: fmt.Sprintf("projectcalico.org/name == '%s'", render.GuardianNamespace), + Selector: networkpolicy.KubernetesAppSelector(render.GuardianServiceName), + NotPorts: networkpolicy.Ports(8080), + }, + }) + } else { + egressRules = append(egressRules, v3.Rule{ + Action: v3.Deny, + Protocol: &networkpolicy.TCPProtocol, + Source: v3.EntityRule{}, + Destination: v3.EntityRule{ + NamespaceSelector: fmt.Sprintf("projectcalico.org/name == '%s'", render.ElasticsearchNamespace), + Selector: networkpolicy.KubernetesAppSelector("tigera-secure-es-gateway"), + NotPorts: networkpolicy.Ports(5554), + }, + }) + egressRules = append(egressRules, v3.Rule{ + Action: v3.Deny, + Protocol: &networkpolicy.TCPProtocol, + Source: v3.EntityRule{}, + Destination: v3.EntityRule{ + NamespaceSelector: fmt.Sprintf("projectcalico.org/name == '%s'", render.ElasticsearchNamespace), + Selector: networkpolicy.KubernetesAppSelector("tigera-linseed"), + NotPorts: networkpolicy.Ports(8444), + }, + }) + egressRules = networkpolicy.AppendDNSEgressRules(egressRules, c.cfg.Installation.KubernetesProvider.IsOpenShift()) + } + egressRules = append(egressRules, v3.Rule{ + Action: v3.Allow, + }) + + ingressRules := []v3.Rule{ + { + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Source: networkpolicy.PrometheusSourceEntityRule, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(FluentBitMetricsPort), + }, + }, + } + + if c.cfg.NonClusterHost != nil { + ingressRules = append(ingressRules, v3.Rule{ + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Source: policyHelper.ManagerSourceEntityRule(), + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(FluentBitInputPort), + }, + }) + } + + return &v3.NetworkPolicy{ + TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}, + ObjectMeta: metav1.ObjectMeta{ + Name: FluentBitPolicyName, + Namespace: LogCollectorNamespace, + }, + Spec: v3.NetworkPolicySpec{ + Order: &networkpolicy.HighPrecedenceOrder, + Tier: networkpolicy.CalicoTierName, + Selector: networkpolicy.KubernetesAppSelector(FluentBitNodeName, fluentBitNodeWindowsName), + ServiceAccountSelector: "", + Types: []v3.PolicyType{v3.PolicyTypeIngress, v3.PolicyTypeEgress}, + Ingress: ingressRules, + Egress: egressRules, + }, + } +} diff --git a/pkg/render/logcollector/outputs.go b/pkg/render/logcollector/outputs.go new file mode 100644 index 0000000000..ec65888eea --- /dev/null +++ b/pkg/render/logcollector/outputs.go @@ -0,0 +1,270 @@ +// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logcollector + +import ( + "fmt" + + "sigs.k8s.io/yaml" + + operatorv1 "github.com/tigera/operator/api/v1" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/url" +) + +// parseUserFilter parses a user-provided filter snippet (the content of a +// fluent-bit-filters ConfigMap key) as a YAML list of fluent-bit filter maps. +func parseUserFilter(content string) ([]map[string]interface{}, error) { + var filters []map[string]interface{} + if err := yaml.Unmarshal([]byte(content), &filters); err != nil { + return nil, err + } + return filters, nil +} + +// InvalidKeys returns the names of the fluent-bit-filters ConfigMap keys whose +// content is non-empty but does not parse as a fluent-bit YAML filter list — for +// example a leftover fluentd block after an upgrade. addUserFilters skips +// these during render so the pipeline still starts; callers use this to surface the +// misconfiguration to the user without failing the whole LogCollector. +func (f *FluentBitFilters) InvalidKeys() []string { + if f == nil { + return nil + } + var bad []string + for _, uf := range []struct{ name, content string }{ + {FluentBitFilterFlowName, f.Flow}, + {FluentBitFilterDNSName, f.DNS}, + } { + if uf.content == "" { + continue + } + if _, err := parseUserFilter(uf.content); err != nil { + bad = append(bad, uf.name) + } + } + return bad +} + +// addUserFilters inlines the user-provided filter snippets into the pipeline. +// The fluent-bit-filters ConfigMap keys (flow, dns) each hold a YAML list of +// fluent-bit filter maps; entries without an explicit match are scoped to the +// key's log tag. Invalid YAML is skipped (and logged) rather than breaking the +// whole pipeline; the controller surfaces it as a TigeraStatus warning (see +// InvalidKeys). +func (c *fluentBitComponent) addUserFilters(cfg *fluentBitConfig) { + if c.cfg.Filters == nil || c.cfg.OSType != rmeta.OSTypeLinux { + return + } + for _, uf := range []struct{ content, tag string }{ + {c.cfg.Filters.Flow, "flows"}, + {c.cfg.Filters.DNS, "dns"}, + } { + if uf.content == "" { + continue + } + filters, err := parseUserFilter(uf.content) + if err != nil { + log.Error(err, "skipping invalid user filter content", "tag", uf.tag) + continue + } + for _, f := range filters { + if _, ok := f["match"]; !ok { + if _, ok := f["match_regex"]; !ok { + f["match"] = uf.tag + } + } + cfg.Pipeline.Filters = append(cfg.Pipeline.Filters, f) + } + } +} + +// hostScopeIncludesCluster reports whether a store's hostScope includes +// cluster logs. Fluentd's envVarsForHostScope semantics: cluster *flow* logs +// are the only cluster type the scope gates (FORWARD_CLUSTER_LOGS_TO_), +// and non-cluster flows always ship when the store is enabled +// (FORWARD_NON_CLUSTER_LOGS_TO_ was true for both All and +// NonClusterOnly). +func hostScopeIncludesCluster(hostScope *operatorv1.HostScope) bool { + return hostScope == nil || *hostScope != operatorv1.HostScopeNonClusterOnly +} + +func (c *fluentBitComponent) addS3Outputs(cfg *fluentBitConfig) { + s3 := c.cfg.LogCollector.Spec.AdditionalStores.S3 + if s3 == nil { + return + } + // tag → S3 path segment. The segments preserve fluentd's archive layout + // (the `path` directives in fluentd/outputs/out-s3-*.conf) so existing + // downstream consumers keep reading the same prefixes. The cluster types + // below always shipped when S3 was enabled — ee_entrypoint.sh copied their + // output confs unconditionally under S3_STORAGE=true; only cluster *flows* + // honored the hostScope gate. WAF, BGP, IDS events and policy activity were + // never S3-archived (out-s3-waf.conf existed but was never copied). + type s3Output struct{ tag, path string } + outputs := []s3Output{ + {"dns", "dns"}, + {"l7", "l7"}, + {"runtime", "runtime"}, + {"audit.tsee", "audit_tsee"}, + {"audit.kube", "audit_kube"}, + {"compliance.reports", "compliance_reports"}, + // Non-cluster flows ship whatever the hostScope. Deliberate delta from + // fluentd: they land under their own non_cluster_flows/ prefix instead + // of sharing flows/ — fluent-bit's $INDEX is tracked per output, so two + // outputs writing one prefix would overwrite each other's objects + // (fluent-plugin-s3 checked object existence before upload; out_s3 + // does not). + {"non_cluster_flows", "non_cluster_flows"}, + } + if hostScopeIncludesCluster(s3.HostScope) { + outputs = append([]s3Output{{"flows", "flows"}}, outputs...) + } + for _, o := range outputs { + cfg.Pipeline.Outputs = append(cfg.Pipeline.Outputs, map[string]interface{}{ + "name": "s3", + "match": o.tag, + "bucket": s3.BucketName, + "region": s3.Region, + "s3_key_format": fmt.Sprintf("%s/%s/%%Y%%m%%d_$INDEX.gz", s3.BucketPath, o.path), + // fluent-plugin-s3's default store_as was gzip, so the legacy + // archives were gzipped; keep the objects compressed to match + // their .gz suffix. + "compression": "gzip", + "total_file_size": "10M", + "upload_timeout": fluentBitDefaultFlush, + "retry_limit": "no_limits", + "storage.total_limit_size": "500M", + }) + } +} + +func (c *fluentBitComponent) addSyslogOutputs(cfg *fluentBitConfig) { + syslog := c.cfg.LogCollector.Spec.AdditionalStores.Syslog + if syslog == nil { + return + } + proto, host, port, _ := url.ParseEndpoint(syslog.Endpoint) + mode := proto + var syslogTags []string + for _, t := range syslog.LogTypes { + switch t { + case operatorv1.SyslogLogAudit: + syslogTags = append(syslogTags, "audit.tsee", "audit.kube") + case operatorv1.SyslogLogDNS: + syslogTags = append(syslogTags, "dns") + case operatorv1.SyslogLogFlows: + // Cluster flows are the only type the hostScope gates + // (FORWARD_CLUSTER_LOGS_TO_SYSLOG); non-cluster flows always ship + // when the Flows type is enabled — fluentd copied the NCH syslog + // output (out-syslog-nch.conf) for both All and NonClusterOnly. + if hostScopeIncludesCluster(syslog.HostScope) { + syslogTags = append(syslogTags, "flows") + } + syslogTags = append(syslogTags, "non_cluster_flows") + case operatorv1.SyslogLogIDSEvents: + syslogTags = append(syslogTags, "ids.events") + } + } + for _, tag := range syslogTags { + out := map[string]interface{}{ + "name": "syslog", + "match": tag, + "host": host, + "port": port, + "mode": mode, + "syslog_format": "rfc5424", + "syslog_hostname_key": "host", + "syslog_appname_preset": "tigera_secure", + // The record's host key wins when present (flows/dns/l7/runtime/waf, + // stamped by record_transformer.lua, and non-cluster flows, stamped + // by the sending host); the preset is the fallback for tags without + // it (audit.*, ids.events), matching fluentd's static + // `hostname SYSLOG_HOSTNAME` (the node name, injected here through + // fluent-bit's env-var substitution of the NODENAME pod env var). + "syslog_hostname_preset": "${NODENAME}", + // No syslog_severity_preset: it is an integer property (a string + // like "info" would atoi() to 0 = Emergency); the default, 6, is + // already info — what fluentd's `severity info` sent. + // + // The whole record is shipped as one JSON MSG, preserving fluentd + // remote_syslog's ` @type json` wire format: the per-output + // lua processor below packs the record into the `log` key, so other + // outputs still see the unpacked record. + "syslog_message_key": "log", + "processors": map[string]interface{}{ + "logs": []map[string]interface{}{{ + "name": "lua", + "script": c.luaScriptPath(), + "call": "syslog_pack", + }}, + }, + "retry_limit": "no_limits", + "storage.total_limit_size": "500M", + } + if syslog.Encryption == operatorv1.EncryptionTLS { + out["mode"] = "tls" + // `mode tls` only selects the framing; the tls property is what + // actually enables TLS on the upstream connection. + out["tls"] = "on" + out["tls.verify"] = "on" + if c.cfg.UseSyslogCertificate { + // The user-provided syslog CA is part of the trusted bundle + // (fluentd pointed SYSLOG_CA_FILE at the same bundle). + out["tls.ca_file"] = c.trustedBundlePath() + } + } + if syslog.PacketSize != nil { + out["syslog_maxsize"] = *syslog.PacketSize + } + cfg.Pipeline.Outputs = append(cfg.Pipeline.Outputs, out) + } +} + +func (c *fluentBitComponent) addSplunkOutputs(cfg *fluentBitConfig) { + splunk := c.cfg.LogCollector.Spec.AdditionalStores.Splunk + if splunk == nil { + return + } + proto, host, port, _ := url.ParseEndpoint(splunk.Endpoint) + // The log types forwarded to Splunk HEC per the design's mapping table: + // flows, dns, l7, audit.tsee, audit.kube. As with the other stores, + // cluster flows are the only type the hostScope gates + // (FORWARD_CLUSTER_LOGS_TO_SPLUNK), and non-cluster flows always ship. + tags := []string{"dns", "l7", "audit.tsee", "audit.kube", "non_cluster_flows"} + if hostScopeIncludesCluster(splunk.HostScope) { + tags = append([]string{"flows"}, tags...) + } + for _, tag := range tags { + out := map[string]interface{}{ + "name": "splunk", + "match": tag, + "host": host, + "port": port, + "splunk_token": "${SPLUNK_HEC_TOKEN}", + "retry_limit": "no_limits", + "storage.total_limit_size": "500M", + } + // Honor the endpoint scheme like fluentd's SPLUNK_PROTOCOL did: an + // http:// HEC endpoint stays plaintext, https:// gets verified TLS + // against the trusted bundle (which carries any private CA). + if proto != "http" { + out["tls"] = "on" + out["tls.verify"] = "on" + out["tls.ca_file"] = c.trustedBundlePath() + } + cfg.Pipeline.Outputs = append(cfg.Pipeline.Outputs, out) + } +} diff --git a/pkg/render/logcollector/rbac.go b/pkg/render/logcollector/rbac.go new file mode 100644 index 0000000000..8639250f44 --- /dev/null +++ b/pkg/render/logcollector/rbac.go @@ -0,0 +1,137 @@ +// Copyright (c) 2019-2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logcollector + +import ( + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/tigera/operator/pkg/render" + "github.com/tigera/operator/pkg/render/common/securitycontextconstraints" +) + +func (c *fluentBitComponent) fluentBitServiceAccount() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: c.fluentBitNodeName(), Namespace: LogCollectorNamespace}, + } +} + +// packetCaptureApiRole creates a role in calico-system to allow pod/exec +// only from fluent-bit pods. Created by the operator for the PacketCapture API. +func (c *fluentBitComponent) packetCaptureApiRole() *rbacv1.Role { + return &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: PacketCaptureAPIRole, + Namespace: LogCollectorNamespace, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods/exec"}, + Verbs: []string{"create"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"list"}, + }, + }, + } +} + +// packetCaptureApiRoleBinding creates a role binding in calico-system for the PacketCapture API. +func (c *fluentBitComponent) packetCaptureApiRoleBinding() *rbacv1.RoleBinding { + return &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: PacketCaptureAPIRoleBinding, + Namespace: LogCollectorNamespace, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: PacketCaptureAPIRole, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: render.PacketCaptureServiceAccountName, + Namespace: render.PacketCaptureNamespace, + }, + }, + } +} + +func (c *fluentBitComponent) fluentBitClusterRoleBinding() *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: c.fluentBitName(), + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: c.fluentBitName(), + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: c.fluentBitNodeName(), + Namespace: LogCollectorNamespace, + }, + }, + } +} + +func (c *fluentBitComponent) fluentBitClusterRole() *rbacv1.ClusterRole { + role := &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: c.fluentBitName(), + }, + Rules: []rbacv1.PolicyRule{ + { + // Add write access to Linseed APIs. + APIGroups: []string{"linseed.tigera.io"}, + Resources: []string{ + "flowlogs", + "kube_auditlogs", + "ee_auditlogs", + "dnslogs", + "l7logs", + "events", + "bgplogs", + "waflogs", + "runtimereports", + "policyactivity", + }, + Verbs: []string{"create"}, + }, + }, + } + + if c.cfg.Installation.KubernetesProvider.IsOpenShift() { + role.Rules = append(role.Rules, rbacv1.PolicyRule{ + APIGroups: []string{"security.openshift.io"}, + Resources: []string{"securitycontextconstraints"}, + Verbs: []string{"use"}, + ResourceNames: []string{securitycontextconstraints.Privileged}, + }) + } + return role +} diff --git a/pkg/render/logstorage/esgateway/esgateway.go b/pkg/render/logstorage/esgateway/esgateway.go index d8673fae21..8b63adc641 100644 --- a/pkg/render/logstorage/esgateway/esgateway.go +++ b/pkg/render/logstorage/esgateway/esgateway.go @@ -390,7 +390,7 @@ func (e *esGateway) esGatewayCalicoSystemPolicy() *v3.NetworkPolicy { { Action: v3.Allow, Protocol: &networkpolicy.TCPProtocol, - Source: render.FluentdSourceEntityRule, + Source: render.FluentBitSourceEntityRule, Destination: esgatewayIngressDestinationEntityRule, }, { diff --git a/pkg/render/logstorage/linseed/linseed.go b/pkg/render/logstorage/linseed/linseed.go index 7f976280f4..90f1362571 100644 --- a/pkg/render/logstorage/linseed/linseed.go +++ b/pkg/render/logstorage/linseed/linseed.go @@ -596,7 +596,7 @@ func (l *linseed) linseedCalicoSystemPolicy() *v3.NetworkPolicy { { Action: v3.Allow, Protocol: &networkpolicy.TCPProtocol, - Source: render.FluentdSourceEntityRule, + Source: render.FluentBitSourceEntityRule, Destination: linseedIngressDestinationEntityRule, }, { diff --git a/pkg/render/manager.go b/pkg/render/manager.go index d634e22478..215838b483 100644 --- a/pkg/render/manager.go +++ b/pkg/render/manager.go @@ -576,6 +576,11 @@ func (c *managerComponent) voltronContainer() corev1.Container { {Name: "VOLTRON_PROMETHEUS_CA_BUNDLE_PATH", Value: c.cfg.TrustedCertBundle.MountPath()}, {Name: "VOLTRON_COMPLIANCE_CA_BUNDLE_PATH", Value: c.cfg.TrustedCertBundle.MountPath()}, {Name: "VOLTRON_DEX_CA_BUNDLE_PATH", Value: c.cfg.TrustedCertBundle.MountPath()}, + // Voltron verifies the in-cluster fluent-bit http input (non-cluster-host + // log ingestion) against the same trusted bundle. Without this the config + // default (/etc/pki/tls/certs/ca.crt) is used, which is not mounted, so the + // mTLS handshake to calico-fluent-bit-http-input fails. + {Name: "VOLTRON_LOG_COLLECTOR_CA_BUNDLE_PATH", Value: c.cfg.TrustedCertBundle.MountPath()}, {Name: "VOLTRON_QUERYSERVER_ENDPOINT", Value: fmt.Sprintf("https://%s.%s.svc:%d", QueryserverServiceName, QueryserverNamespace, QueryServerPort)}, {Name: "VOLTRON_QUERYSERVER_BASE_PATH", Value: fmt.Sprintf("/api/v1/namespaces/%s/services/https:%s:%d/proxy/", QueryserverNamespace, QueryserverServiceName, QueryServerPort)}, {Name: "VOLTRON_QUERYSERVER_CA_BUNDLE_PATH", Value: c.cfg.TrustedCertBundle.MountPath()}, @@ -1344,7 +1349,7 @@ func (c *managerComponent) managerCalicoSystemNetworkPolicy() *v3.NetworkPolicy Destination: v3.EntityRule{ Services: &v3.ServiceMatch{ Namespace: LogCollectorNamespace, - Name: FluentdInputService, + Name: FluentBitInputService, }, }, }) @@ -1524,7 +1529,6 @@ func managerClusterWideTigeraLayer() *v3.UISettings { "tigera-dpi", "tigera-eck-operator", "tigera-elasticsearch", - "tigera-fluentd", "tigera-intrusion-detection", "tigera-kibana", "tigera-manager", diff --git a/pkg/render/manager_test.go b/pkg/render/manager_test.go index 9c3177e94b..e893aa7c35 100644 --- a/pkg/render/manager_test.go +++ b/pkg/render/manager_test.go @@ -217,6 +217,7 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { Expect(voltron.Env).To(ContainElements([]corev1.EnvVar{ {Name: "VOLTRON_ENABLE_COMPLIANCE", Value: "true"}, {Name: "VOLTRON_ENABLE_NONCLUSTER_HOST", Value: "true"}, + {Name: "VOLTRON_LOG_COLLECTOR_CA_BUNDLE_PATH", Value: "/etc/pki/tls/certs/tigera-ca-bundle.crt"}, {Name: "VOLTRON_QUERYSERVER_ENDPOINT", Value: "https://calico-api.calico-system.svc:8080"}, {Name: "VOLTRON_QUERYSERVER_BASE_PATH", Value: "/api/v1/namespaces/calico-system/services/https:calico-api:8080/proxy/"}, {Name: "VOLTRON_QUERYSERVER_CA_BUNDLE_PATH", Value: "/etc/pki/tls/certs/tigera-ca-bundle.crt"}, @@ -1239,7 +1240,7 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { policyWithNonClusterHosts := testutils.GetCalicoSystemPolicyFromResources(policyName, resourcesWithNonClusterHosts) policyWithoutNonClusterHosts := testutils.GetCalicoSystemPolicyFromResources(policyName, resourcesWithoutNonClusterHosts) - // Validate that we have a single egress rule added for the fluentd service. + // Validate that we have a single egress rule added for the fluent-bit service. Expect(policyWithoutNonClusterHosts.Spec.Ingress).To(Equal(policyWithNonClusterHosts.Spec.Ingress)) Expect(len(policyWithoutNonClusterHosts.Spec.Egress)).To(Equal(len(policyWithNonClusterHosts.Spec.Egress) - 1)) Expect(len(policyWithNonClusterHosts.Spec.Egress)).To(Equal(11)) @@ -1249,7 +1250,7 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { Destination: v3.EntityRule{ Services: &v3.ServiceMatch{ Namespace: render.LogCollectorNamespace, - Name: render.FluentdInputService, + Name: render.FluentBitInputService, }, }, })) diff --git a/pkg/render/monitor/monitor.go b/pkg/render/monitor/monitor.go index f9a59de790..a0784a7e12 100644 --- a/pkg/render/monitor/monitor.go +++ b/pkg/render/monitor/monitor.go @@ -45,6 +45,7 @@ import ( "github.com/tigera/operator/pkg/render/common/secret" "github.com/tigera/operator/pkg/render/common/securitycontext" "github.com/tigera/operator/pkg/render/common/securitycontextconstraints" + "github.com/tigera/operator/pkg/render/logcollector" "github.com/tigera/operator/pkg/render/logstorage/esmetrics" "github.com/tigera/operator/pkg/tls/certificatemanagement" "github.com/tigera/operator/pkg/tls/certkeyusage" @@ -84,7 +85,7 @@ const ( MeshAlertmanagerPolicyName = AlertmanagerPolicyName + "-mesh" ElasticsearchMetrics = "elasticsearch-metrics" - FluentdMetrics = "fluentd-metrics" + FluentBitMetrics = "calico-fluent-bit-metrics" calicoNodePrometheusServiceName = "calico-node-prometheus" tigeraPrometheusServiceHealthEndpoint = "/health" @@ -279,7 +280,7 @@ func (mc *monitorComponent) Objects() ([]client.Object, []client.Object) { serviceMonitors := []client.Object{ mc.serviceMonitorCalicoNode(), mc.serviceMonitorElasticsearch(), - mc.serviceMonitorFluentd(), + mc.serviceMonitorFluentBit(), mc.serviceMonitorQueryServer(), mc.serviceMonitorCalicoKubeControllers(), } @@ -324,8 +325,11 @@ func (mc *monitorComponent) Objects() ([]client.Object, []client.Object) { } toDelete = append(toDelete, - // Remove the pod monitor that existed prior to v1.25. - &monitoringv1.PodMonitor{ObjectMeta: metav1.ObjectMeta{Name: FluentdMetrics, Namespace: common.TigeraPrometheusNamespace}}, + // Remove the pod monitor that existed prior to v1.25 and the + // fluentd-era monitors replaced by serviceMonitorFluentBit. + &monitoringv1.PodMonitor{ObjectMeta: metav1.ObjectMeta{Name: FluentBitMetrics, Namespace: common.TigeraPrometheusNamespace}}, + &monitoringv1.PodMonitor{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-metrics", Namespace: common.TigeraPrometheusNamespace}}, + &monitoringv1.ServiceMonitor{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-metrics", Namespace: common.TigeraPrometheusNamespace}}, // Remove the tigera-prometheus-api deployment that was part of release-v1.23, but has been removed since. &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "tigera-prometheus-api", Namespace: common.TigeraPrometheusNamespace}}, ) @@ -1121,14 +1125,15 @@ func (mc *monitorComponent) serviceMonitorElasticsearch() *monitoringv1.ServiceM } } -// serviceMonitorFluentd creates a service monitor to make Prometheus watch Fluentd. Previously, a pod monitor was used. -// However, the pod monitor does not have all the tls configuration options that we need, namely reading them from the -// file system, as opposed to getting them from watching kubernetes secrets. -func (mc *monitorComponent) serviceMonitorFluentd() *monitoringv1.ServiceMonitor { +// serviceMonitorFluentBit creates a service monitor to make Prometheus scrape +// Fluent Bit's built-in monitoring server. The endpoint is plain HTTP (the +// server has no TLS support); access to the port is restricted by the +// allow-calico-fluent-bit NetworkPolicy instead. +func (mc *monitorComponent) serviceMonitorFluentBit() *monitoringv1.ServiceMonitor { return &monitoringv1.ServiceMonitor{ TypeMeta: metav1.TypeMeta{Kind: monitoringv1.ServiceMonitorsKind, APIVersion: MonitoringAPIVersion}, ObjectMeta: metav1.ObjectMeta{ - Name: render.FluentdMetricsService, + Name: logcollector.FluentBitMetricsService, Namespace: common.TigeraPrometheusNamespace, }, Spec: monitoringv1.ServiceMonitorSpec{ @@ -1137,7 +1142,7 @@ func (mc *monitorComponent) serviceMonitorFluentd() *monitoringv1.ServiceMonitor { Key: "k8s-app", Operator: metav1.LabelSelectorOpIn, - Values: []string{"fluentd-node", "fluentd-node-windows"}, + Values: []string{"calico-fluent-bit", "calico-fluent-bit-windows"}, }, }, }, @@ -1146,19 +1151,14 @@ func (mc *monitorComponent) serviceMonitorFluentd() *monitoringv1.ServiceMonitor { HonorLabels: true, Interval: "5s", - Port: render.FluentdMetricsPortName, + Port: logcollector.FluentBitMetricsPortName, + Path: "/api/v2/metrics/prometheus", ScrapeTimeout: "5s", - HTTPConfigWithProxyAndTLSFiles: monitoringv1.HTTPConfigWithProxyAndTLSFiles{ - HTTPConfigWithTLSFiles: monitoringv1.HTTPConfigWithTLSFiles{ - TLSConfig: mc.tlsConfig(render.FluentdPrometheusTLSSecretName), - }, - }, - RelabelConfigs: []monitoringv1.RelabelConfig{ - { - TargetLabel: "__scheme__", - Replacement: ptr.To("https"), - }, - }, + // fluent-bit's built-in monitoring server (:2020) is plain + // HTTP — it has no TLS support, unlike fluentd's Ruby + // prometheus exporter which terminated mTLS on :9081. Access + // to the port is restricted by the allow-calico-fluent-bit + // NetworkPolicy (prometheus ingress only). }, }, }, @@ -1407,10 +1407,19 @@ func calicoSystemPrometheusPolicy(cfg *Config) *v3.NetworkPolicy { Action: v3.Allow, Protocol: &networkpolicy.TCPProtocol, Destination: v3.EntityRule{ - // Egress access for Felix metrics + // Egress access for Elasticsearch (9081) and Felix (9091) metrics Ports: networkpolicy.Ports(9081, 9091), }, }, + { + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: v3.EntityRule{ + // Egress access for fluent-bit metrics, scraped over plain HTTP + // from fluent-bit's built-in monitoring server. + Ports: networkpolicy.Ports(logcollector.FluentBitMetricsPort), + }, + }, { Action: v3.Allow, Protocol: &networkpolicy.TCPProtocol, diff --git a/pkg/render/monitor/monitor_test.go b/pkg/render/monitor/monitor_test.go index 48809ede67..f52170a36e 100644 --- a/pkg/render/monitor/monitor_test.go +++ b/pkg/render/monitor/monitor_test.go @@ -120,7 +120,7 @@ var _ = Describe("monitor rendering tests", func() { expectedResources := expectedBaseResources() rtest.ExpectResources(toCreate, expectedResources) - Expect(toDelete).To(HaveLen(5)) + Expect(toDelete).To(HaveLen(7)) // Check the namespace. namespace := rtest.GetResource(toCreate, "tigera-prometheus", "", "", "v1", "Namespace").(*corev1.Namespace) @@ -177,7 +177,7 @@ var _ = Describe("monitor rendering tests", func() { component := monitor.Monitor(cfg) Expect(component.ResolveImages(nil)).NotTo(HaveOccurred()) toCreate, toDelete := component.Objects() - Expect(toDelete).To(HaveLen(5)) + Expect(toDelete).To(HaveLen(7)) // Prometheus prometheusObj, ok := rtest.GetResource(toCreate, monitor.CalicoNodePrometheus, common.TigeraPrometheusNamespace, "monitoring.coreos.com", "v1", monitoringv1.PrometheusesKind).(*monitoringv1.Prometheus) @@ -457,7 +457,7 @@ var _ = Describe("monitor rendering tests", func() { Expect(prometheusServiceObj.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt(9095))) // PodMonitor - servicemonitorObj, ok := rtest.GetResource(toCreate, monitor.FluentdMetrics, common.TigeraPrometheusNamespace, "monitoring.coreos.com", "v1", monitoringv1.ServiceMonitorsKind).(*monitoringv1.ServiceMonitor) + servicemonitorObj, ok := rtest.GetResource(toCreate, monitor.FluentBitMetrics, common.TigeraPrometheusNamespace, "monitoring.coreos.com", "v1", monitoringv1.ServiceMonitorsKind).(*monitoringv1.ServiceMonitor) Expect(ok).To(BeTrue()) Expect(servicemonitorObj.Spec.Selector.MatchLabels).To(HaveLen(0)) Expect(servicemonitorObj.Spec.Selector.MatchExpressions).To(HaveLen(1)) @@ -465,15 +465,15 @@ var _ = Describe("monitor rendering tests", func() { { Key: "k8s-app", Operator: metav1.LabelSelectorOpIn, - Values: []string{"fluentd-node", "fluentd-node-windows"}, + Values: []string{"calico-fluent-bit", "calico-fluent-bit-windows"}, }, })) Expect(servicemonitorObj.Spec.NamespaceSelector.MatchNames).To(HaveLen(1)) - Expect(servicemonitorObj.Spec.NamespaceSelector.MatchNames[0]).To(Equal("tigera-fluentd")) + Expect(servicemonitorObj.Spec.NamespaceSelector.MatchNames[0]).To(Equal("calico-system")) Expect(servicemonitorObj.Spec.Endpoints).To(HaveLen(1)) Expect(servicemonitorObj.Spec.Endpoints[0].HonorLabels).To(BeTrue()) Expect(servicemonitorObj.Spec.Endpoints[0].Interval).To(BeEquivalentTo("5s")) - Expect(servicemonitorObj.Spec.Endpoints[0].Port).To(Equal("fluentd-metrics-port")) + Expect(servicemonitorObj.Spec.Endpoints[0].Port).To(Equal("fluent-bit-metrics-port")) Expect(servicemonitorObj.Spec.Endpoints[0].ScrapeTimeout).To(BeEquivalentTo("5s")) // PrometheusRule @@ -530,7 +530,7 @@ var _ = Describe("monitor rendering tests", func() { Expect(servicemonitorObj.Spec.Endpoints[0].ScrapeTimeout).To(BeEquivalentTo("5s")) Expect(*servicemonitorObj.Spec.Endpoints[0].RelabelConfigs[0].Replacement).To(Equal("https")) - servicemonitorObj, ok = rtest.GetResource(toCreate, "fluentd-metrics", common.TigeraPrometheusNamespace, "monitoring.coreos.com", "v1", monitoringv1.ServiceMonitorsKind).(*monitoringv1.ServiceMonitor) + servicemonitorObj, ok = rtest.GetResource(toCreate, "calico-fluent-bit-metrics", common.TigeraPrometheusNamespace, "monitoring.coreos.com", "v1", monitoringv1.ServiceMonitorsKind).(*monitoringv1.ServiceMonitor) Expect(ok).To(BeTrue()) Expect(servicemonitorObj.Spec.Selector.MatchLabels).To(HaveLen(0)) Expect(servicemonitorObj.Spec.Selector.MatchExpressions).To(HaveLen(1)) @@ -538,17 +538,20 @@ var _ = Describe("monitor rendering tests", func() { { Key: "k8s-app", Operator: metav1.LabelSelectorOpIn, - Values: []string{"fluentd-node", "fluentd-node-windows"}, + Values: []string{"calico-fluent-bit", "calico-fluent-bit-windows"}, }, })) Expect(servicemonitorObj.Spec.NamespaceSelector.MatchNames).To(HaveLen(1)) - Expect(servicemonitorObj.Spec.NamespaceSelector.MatchNames[0]).To(Equal("tigera-fluentd")) + Expect(servicemonitorObj.Spec.NamespaceSelector.MatchNames[0]).To(Equal("calico-system")) Expect(servicemonitorObj.Spec.Endpoints).To(HaveLen(1)) Expect(servicemonitorObj.Spec.Endpoints[0].HonorLabels).To(BeTrue()) Expect(servicemonitorObj.Spec.Endpoints[0].Interval).To(BeEquivalentTo("5s")) - Expect(servicemonitorObj.Spec.Endpoints[0].Port).To(Equal("fluentd-metrics-port")) + Expect(servicemonitorObj.Spec.Endpoints[0].Port).To(Equal("fluent-bit-metrics-port")) Expect(servicemonitorObj.Spec.Endpoints[0].ScrapeTimeout).To(BeEquivalentTo("5s")) - Expect(*servicemonitorObj.Spec.Endpoints[0].RelabelConfigs[0].Replacement).To(Equal("https")) + // fluent-bit's monitoring server is plain HTTP (no TLS support), unlike + // fluentd's mTLS prometheus exporter. + Expect(servicemonitorObj.Spec.Endpoints[0].RelabelConfigs).To(BeEmpty()) + Expect(servicemonitorObj.Spec.Endpoints[0].TLSConfig).To(BeNil()) servicemonitorObj, ok = rtest.GetResource(toCreate, "calico-api", common.TigeraPrometheusNamespace, "monitoring.coreos.com", "v1", monitoringv1.ServiceMonitorsKind).(*monitoringv1.ServiceMonitor) Expect(ok).To(BeTrue()) @@ -679,7 +682,7 @@ var _ = Describe("monitor rendering tests", func() { expectedResources := expectedBaseResources() rtest.ExpectResources(toCreate, expectedResources) - Expect(toDelete).To(HaveLen(5)) + Expect(toDelete).To(HaveLen(7)) // Prometheus prometheusObj, ok := rtest.GetResource(toCreate, monitor.CalicoNodePrometheus, common.TigeraPrometheusNamespace, "monitoring.coreos.com", "v1", monitoringv1.PrometheusesKind).(*monitoringv1.Prometheus) @@ -869,7 +872,7 @@ var _ = Describe("monitor rendering tests", func() { ) rtest.ExpectResources(toCreate, expectedResources) - Expect(toDelete).To(HaveLen(5)) + Expect(toDelete).To(HaveLen(7)) }) It("Should render external prometheus resources with service monitor and custom token", func() { @@ -895,7 +898,7 @@ var _ = Describe("monitor rendering tests", func() { ) rtest.ExpectResources(toCreate, expectedResources) - Expect(toDelete).To(HaveLen(5)) + Expect(toDelete).To(HaveLen(7)) }) It("Should render external prometheus resources without service monitor", func() { @@ -911,7 +914,7 @@ var _ = Describe("monitor rendering tests", func() { ) rtest.ExpectResources(toCreate, expectedResources) - Expect(toDelete).To(HaveLen(5)) + Expect(toDelete).To(HaveLen(7)) }) It("Should render typha service monitor if typha metrics are enabled", func() { @@ -925,7 +928,7 @@ var _ = Describe("monitor rendering tests", func() { ) rtest.ExpectResources(toCreate, expectedResources) - Expect(toDelete).To(HaveLen(4)) + Expect(toDelete).To(HaveLen(6)) sm := rtest.GetResource(toCreate, "calico-typha-metrics", "tigera-prometheus", "monitoring.coreos.com", "v1", "ServiceMonitor").(*monitoringv1.ServiceMonitor) Expect(sm).To(Equal(&monitoringv1.ServiceMonitor{ TypeMeta: metav1.TypeMeta{Kind: monitoringv1.ServiceMonitorsKind, APIVersion: "monitoring.coreos.com/v1"}, @@ -1002,7 +1005,7 @@ var _ = Describe("monitor rendering tests", func() { serviceMonitorNames := []string{ monitor.CalicoNodeMonitor, monitor.ElasticsearchMetrics, - monitor.FluentdMetrics, + monitor.FluentBitMetrics, "calico-api", "calico-kube-controllers-metrics", } @@ -1027,7 +1030,7 @@ var _ = Describe("monitor rendering tests", func() { serviceMonitorNames := []string{ monitor.CalicoNodeMonitor, monitor.ElasticsearchMetrics, - monitor.FluentdMetrics, + monitor.FluentBitMetrics, "calico-api", "calico-kube-controllers-metrics", } @@ -1064,8 +1067,8 @@ var _ = Describe("monitor rendering tests", func() { serviceMonitor := sm.(*monitoringv1.ServiceMonitor) Expect(serviceMonitor.Spec.Endpoints[0].Port).To(Equal(monitor.OperatorMetricsPortName)) - // Neither should be in toDelete (only PodMonitor, Deployment, typhaServiceMonitor). - Expect(toDelete).To(HaveLen(3)) + // Neither should be in toDelete (only the legacy monitors, Deployment, typhaServiceMonitor). + Expect(toDelete).To(HaveLen(5)) }) It("Should include operator alert rules in PrometheusRule when OperatorMetricsEnabled is true", func() { @@ -1188,7 +1191,7 @@ func expectedBaseResources() []client.Object { &monitoringv1.PrometheusRule{ObjectMeta: metav1.ObjectMeta{Name: monitor.TigeraPrometheusRule, Namespace: common.TigeraPrometheusNamespace}, TypeMeta: metav1.TypeMeta{Kind: "PrometheusRule", APIVersion: "monitoring.coreos.com/v1"}}, &monitoringv1.ServiceMonitor{ObjectMeta: metav1.ObjectMeta{Name: "calico-node-monitor", Namespace: common.TigeraPrometheusNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ServiceMonitor", APIVersion: "monitoring.coreos.com/v1"}}, &monitoringv1.ServiceMonitor{ObjectMeta: metav1.ObjectMeta{Name: "elasticsearch-metrics", Namespace: common.TigeraPrometheusNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ServiceMonitor", APIVersion: "monitoring.coreos.com/v1"}}, - &monitoringv1.ServiceMonitor{ObjectMeta: metav1.ObjectMeta{Name: "fluentd-metrics", Namespace: common.TigeraPrometheusNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ServiceMonitor", APIVersion: "monitoring.coreos.com/v1"}}, + &monitoringv1.ServiceMonitor{ObjectMeta: metav1.ObjectMeta{Name: "calico-fluent-bit-metrics", Namespace: common.TigeraPrometheusNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ServiceMonitor", APIVersion: "monitoring.coreos.com/v1"}}, &monitoringv1.ServiceMonitor{ObjectMeta: metav1.ObjectMeta{Name: "calico-api", Namespace: common.TigeraPrometheusNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ServiceMonitor", APIVersion: "monitoring.coreos.com/v1"}}, &monitoringv1.ServiceMonitor{ObjectMeta: metav1.ObjectMeta{Name: "calico-kube-controllers-metrics", Namespace: common.TigeraPrometheusNamespace}, TypeMeta: metav1.TypeMeta{Kind: "ServiceMonitor", APIVersion: "monitoring.coreos.com/v1"}}, &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.TigeraOperatorSecrets, Namespace: common.TigeraPrometheusNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, diff --git a/pkg/render/node.go b/pkg/render/node.go index 06282683f4..14e4854dfe 100644 --- a/pkg/render/node.go +++ b/pkg/render/node.go @@ -1082,7 +1082,7 @@ func (c *nodeComponent) nodeDaemonset(cniCfgMap *corev1.ConfigMap) *appsv1.Daemo ds.Spec.Template.Spec.HostPID = true } - setNodeCriticalPod(&(ds.Spec.Template)) + SetNodeCriticalPod(&(ds.Spec.Template)) if c.cfg.MigrateNamespaces { migration.LimitDaemonSetToMigratedNodes(&ds) } diff --git a/pkg/render/nonclusterhost/nonclusterhost.go b/pkg/render/nonclusterhost/nonclusterhost.go index 30d64f95fd..7e0eb19a1b 100644 --- a/pkg/render/nonclusterhost/nonclusterhost.go +++ b/pkg/render/nonclusterhost/nonclusterhost.go @@ -192,9 +192,9 @@ func (c *nonClusterHostComponent) clusterRole() *rbacv1.ClusterRole { Verbs: []string{"get", "list", "watch"}, }, { - // Allow posting flow logs and policy activity logs to linseed. + // Allow posting flow logs, DNS logs, and policy activity logs to linseed. APIGroups: []string{"linseed.tigera.io"}, - Resources: []string{"flowlogs", "policyactivity"}, + Resources: []string{"flowlogs", "dnslogs", "policyactivity"}, Verbs: []string{"create"}, }, }...) diff --git a/pkg/render/nonclusterhost/nonclusterhost_test.go b/pkg/render/nonclusterhost/nonclusterhost_test.go index 91f31a2876..52649c420f 100644 --- a/pkg/render/nonclusterhost/nonclusterhost_test.go +++ b/pkg/render/nonclusterhost/nonclusterhost_test.go @@ -149,7 +149,7 @@ var _ = Describe("NonClusterHost rendering tests", func() { }, rbacv1.PolicyRule{ APIGroups: []string{"linseed.tigera.io"}, - Resources: []string{"flowlogs", "policyactivity"}, + Resources: []string{"flowlogs", "dnslogs", "policyactivity"}, Verbs: []string{"create"}, }, rbacv1.PolicyRule{ diff --git a/pkg/render/otelcollector/component.go b/pkg/render/otelcollector/component.go new file mode 100644 index 0000000000..5a808c8bd9 --- /dev/null +++ b/pkg/render/otelcollector/component.go @@ -0,0 +1,604 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package otelcollector + +import ( + "bytes" + "fmt" + "net/url" + "strconv" + "strings" + "text/template" + + v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/components" + "github.com/tigera/operator/pkg/render" + rcomp "github.com/tigera/operator/pkg/render/common/components" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/render/common/networkpolicy" + "github.com/tigera/operator/pkg/render/common/secret" + "github.com/tigera/operator/pkg/render/common/securitycontext" + "github.com/tigera/operator/pkg/tls/certificatemanagement" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + OTelCollectorName = "otel-collector" + OTelCollectorNamespace = common.CalicoNamespace + OTelCollectorServiceAccountName = OTelCollectorName + OTelCollectorStatefulSetName = OTelCollectorName + OTelCollectorServiceName = OTelCollectorName + OTelCollectorConfigMapName = OTelCollectorName + OTelCollectorContainerName = "otel-collector" + OTelCollectorPolicyName = networkpolicy.CalicoComponentPolicyPrefix + OTelCollectorName + OTelCollectorClusterRoleName = OTelCollectorName + OTelCollectorServerTLSSecretName = "otel-collector-tls" + + OTLPGRPCPort = 4317 + OTLPHTTPPort = 4318 + HealthCheckPort = 13133 + InternalMetricsPort = 8888 + + MetricsTLSServerName = "calico-node-metrics" + + DefaultMemoryLimit = "512Mi" + DefaultMemoryRequest = "128Mi" + DefaultMemoryLimitMiB = 409 // 80% of 512Mi + DefaultMemorySpikeLimitMiB = 100 // ~25% of limit_mib +) + +type Configuration struct { + PullSecrets []*corev1.Secret + OpenShift bool + Installation *operatorv1.InstallationSpec + OTelCollector *operatorv1.OTelCollectorSpec + // ReceiverTLSSecret is the server keypair for the OTLP receiver (mTLS termination). + ReceiverTLSSecret certificatemanagement.KeyPairInterface + // ClientTLSSecret is the client keypair for outbound prometheus scraping. + ClientTLSSecret certificatemanagement.KeyPairInterface + TrustedCertBundle certificatemanagement.TrustedBundleRO +} + +type component struct { + cfg *Configuration + image string +} + +func OTelCollector(cfg *Configuration) render.Component { + return &component{cfg: cfg} +} + +func (c *component) ResolveImages(is *operatorv1.ImageSet) error { + reg := c.cfg.Installation.Registry + path := c.cfg.Installation.ImagePath + prefix := c.cfg.Installation.ImagePrefix + + var err error + c.image, err = components.GetReference(components.CombinedCalicoImage(c.cfg.Installation), reg, path, prefix, is) + return err +} + +func (c *component) SupportedOSType() rmeta.OSType { + return rmeta.OSTypeLinux +} + +func (c *component) Objects() ([]client.Object, []client.Object) { + statefulSet := c.statefulSet() + if c.cfg.OTelCollector.OTelCollectorStatefulSet != nil { + rcomp.ApplyStatefulSetOverrides(statefulSet, c.cfg.OTelCollector.OTelCollectorStatefulSet) + } + + objs := []client.Object{ + c.serviceAccount(), + c.clusterRole(), + c.clusterRoleBinding(), + c.configMap(), + c.service(), + statefulSet, + c.networkPolicy(), + } + + objs = append(objs, secret.ToRuntimeObjects(secret.CopyToNamespace(OTelCollectorNamespace, c.cfg.PullSecrets...)...)...) + + return objs, nil +} + +func (c *component) Ready() bool { + return true +} + +func (c *component) serviceAccount() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: OTelCollectorServiceAccountName, Namespace: OTelCollectorNamespace}, + } +} + +func (c *component) clusterRole() *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: OTelCollectorClusterRoleName, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"nodes", "pods", "services", "endpoints"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + } +} + +func (c *component) clusterRoleBinding() *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: OTelCollectorClusterRoleName, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: OTelCollectorClusterRoleName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: OTelCollectorServiceAccountName, + Namespace: OTelCollectorNamespace, + }, + }, + } +} + +func (c *component) configMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: OTelCollectorConfigMapName, Namespace: OTelCollectorNamespace}, + Data: map[string]string{ + "config.yaml": c.collectorConfig(), + }, + } +} + +func (c *component) metricsEnabled() bool { + return c.cfg.OTelCollector.Metrics != nil && + c.cfg.OTelCollector.Metrics.Enabled != nil && + *c.cfg.OTelCollector.Metrics.Enabled == operatorv1.OTelMetricsEnable +} + +func (c *component) hasLogs() bool { + return c.cfg.OTelCollector.Logs != nil && len(c.cfg.OTelCollector.Logs.Types) > 0 +} + +type configTemplateData struct { + HasLogs bool + ReceiverTLS bool + ReceiverCertFile string + ReceiverKeyFile string + ReceiverClientCA string + MetricsEnabled bool + MetricsCAFile string + MetricsCertFile string + MetricsKeyFile string + MetricsServerName string + MetricsNamespace string + Exporters []exporterEntry + ExporterNames string + HealthCheckPort int + InternalMetricsPort int + MemoryLimitMiB int + MemorySpikeLimitMiB int +} + +type exporterEntry struct { + Prefix string + Name string + Endpoint string + TLSInsecure bool +} + +var collectorConfigTmpl = template.Must(template.New("config").Parse(`receivers: +{{- if .HasLogs}} + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 +{{- if .ReceiverTLS}} + tls: + cert_file: {{.ReceiverCertFile}} + key_file: {{.ReceiverKeyFile}} + client_ca_file: {{.ReceiverClientCA}} +{{- end}} +{{- end}} +{{- if .MetricsEnabled}} + prometheus: + config: + scrape_configs: + - job_name: 'calico-metrics' + scheme: https + tls_config: + ca_file: {{.MetricsCAFile}} + cert_file: {{.MetricsCertFile}} + key_file: {{.MetricsKeyFile}} + server_name: {{.MetricsServerName}} + kubernetes_sd_configs: + - role: endpoints + namespaces: + names: + - {{.MetricsNamespace}} + relabel_configs: + - source_labels: [__meta_kubernetes_service_label_k8s_app] + regex: calico-node + action: keep + - source_labels: [__meta_kubernetes_endpoint_port_name] + regex: calico-metrics-port|calico-bgp-metrics-port + action: keep +{{- end}} + +exporters: +{{- range .Exporters}} + {{.Prefix}}/{{.Name}}: + endpoint: {{.Endpoint}} +{{- if .TLSInsecure}} + tls: + insecure: true +{{- end}} +{{- end}} + +processors: + memory_limiter: + check_interval: 1s + limit_mib: {{.MemoryLimitMiB}} + spike_limit_mib: {{.MemorySpikeLimitMiB}} +{{- if .HasLogs}} + transform/service_name: + log_statements: + - context: log + statements: + - set(resource.attributes["service.name"], "audit") where IsMap(body) and body["auditID"] != nil + - set(resource.attributes["service.name"], "dns") where IsMap(body) and body["qname"] != nil + - set(resource.attributes["service.name"], "flows") where IsMap(body) and body["bytes_in"] != nil + - set(resource.attributes["service.name"], "unknown") where resource.attributes["service.name"] == nil or resource.attributes["service.name"] == "unknown_service" +{{- end}} + +extensions: + health_check: + endpoint: 0.0.0.0:{{.HealthCheckPort}} + +service: + telemetry: + metrics: + readers: + - pull: + exporter: + prometheus: + host: "0.0.0.0" + port: {{.InternalMetricsPort}} + extensions: [health_check] + pipelines: +{{- if .HasLogs}} + logs: + receivers: [otlp] + processors: [memory_limiter, transform/service_name] + exporters: [{{.ExporterNames}}] +{{- end}} +{{- if .MetricsEnabled}} + metrics: + receivers: [prometheus] + processors: [memory_limiter] + exporters: [{{.ExporterNames}}] +{{- end}} +`)) + +func (c *component) collectorConfig() string { + var exporters []exporterEntry + var exporterNames []string + for _, exp := range c.cfg.OTelCollector.Exporters { + var prefix string + if exp.Protocol == operatorv1.OTelProtocolHTTP { + prefix = "otlphttp" + } else { + prefix = "otlp" + } + exporters = append(exporters, exporterEntry{ + Prefix: prefix, + Name: exp.Name, + Endpoint: exp.Endpoint, + TLSInsecure: exp.TLSInsecure != nil && *exp.TLSInsecure, + }) + exporterNames = append(exporterNames, fmt.Sprintf("%s/%s", prefix, exp.Name)) + } + + data := configTemplateData{ + HasLogs: c.hasLogs(), + MetricsEnabled: c.metricsEnabled(), + Exporters: exporters, + ExporterNames: strings.Join(exporterNames, ", "), + HealthCheckPort: HealthCheckPort, + InternalMetricsPort: InternalMetricsPort, + MemoryLimitMiB: DefaultMemoryLimitMiB, + MemorySpikeLimitMiB: DefaultMemorySpikeLimitMiB, + } + + if c.hasLogs() && c.cfg.ReceiverTLSSecret != nil && c.cfg.TrustedCertBundle != nil { + data.ReceiverTLS = true + data.ReceiverCertFile = c.cfg.ReceiverTLSSecret.VolumeMountCertificateFilePath() + data.ReceiverKeyFile = c.cfg.ReceiverTLSSecret.VolumeMountKeyFilePath() + data.ReceiverClientCA = c.cfg.TrustedCertBundle.MountPath() + } + + if c.metricsEnabled() && c.cfg.TrustedCertBundle != nil && c.cfg.ClientTLSSecret != nil { + data.MetricsCAFile = c.cfg.TrustedCertBundle.MountPath() + data.MetricsCertFile = c.cfg.ClientTLSSecret.VolumeMountCertificateFilePath() + data.MetricsKeyFile = c.cfg.ClientTLSSecret.VolumeMountKeyFilePath() + data.MetricsServerName = MetricsTLSServerName + data.MetricsNamespace = OTelCollectorNamespace + } + + var buf bytes.Buffer + if err := collectorConfigTmpl.Execute(&buf, data); err != nil { + panic(fmt.Sprintf("failed to render otel collector config: %v", err)) + } + return buf.String() +} + +func (c *component) service() *corev1.Service { + ports := []corev1.ServicePort{ + { + Name: "otlp-http", + Port: OTLPHTTPPort, + TargetPort: intstr.FromInt32(OTLPHTTPPort), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "metrics", + Port: InternalMetricsPort, + TargetPort: intstr.FromInt32(InternalMetricsPort), + Protocol: corev1.ProtocolTCP, + }, + } + + return &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: OTelCollectorServiceName, + Namespace: OTelCollectorNamespace, + Labels: map[string]string{"k8s-app": OTelCollectorStatefulSetName}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"k8s-app": OTelCollectorStatefulSetName}, + Ports: ports, + }, + } +} + +func (c *component) container() corev1.Container { + volumeMounts := []corev1.VolumeMount{ + { + Name: "config", + MountPath: "/etc/otel", + ReadOnly: true, + }, + } + + if c.cfg.TrustedCertBundle != nil { + volumeMounts = append(volumeMounts, + c.cfg.TrustedCertBundle.VolumeMounts(rmeta.OSTypeLinux)..., + ) + } + + if c.cfg.ReceiverTLSSecret != nil { + volumeMounts = append(volumeMounts, + c.cfg.ReceiverTLSSecret.VolumeMount(rmeta.OSTypeLinux), + ) + } + + if c.cfg.ClientTLSSecret != nil { + volumeMounts = append(volumeMounts, + c.cfg.ClientTLSSecret.VolumeMount(rmeta.OSTypeLinux), + ) + } + + return corev1.Container{ + Name: OTelCollectorContainerName, + Image: c.image, + Command: []string{"/usr/bin/otelcol", "--config=/etc/otel/config.yaml"}, + Ports: []corev1.ContainerPort{ + {Name: "otlp-grpc", ContainerPort: OTLPGRPCPort, Protocol: corev1.ProtocolTCP}, + {Name: "otlp-http", ContainerPort: OTLPHTTPPort, Protocol: corev1.ProtocolTCP}, + {Name: "health", ContainerPort: HealthCheckPort, Protocol: corev1.ProtocolTCP}, + {Name: "metrics", ContainerPort: InternalMetricsPort, Protocol: corev1.ProtocolTCP}, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse(DefaultMemoryLimit), + }, + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse(DefaultMemoryRequest), + }, + }, + SecurityContext: securitycontext.NewNonRootContext(), + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/", + Port: intstr.FromInt32(HealthCheckPort), + }, + }, + PeriodSeconds: 10, + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/", + Port: intstr.FromInt32(HealthCheckPort), + }, + }, + PeriodSeconds: 10, + }, + VolumeMounts: volumeMounts, + } +} + +func (c *component) statefulSet() *appsv1.StatefulSet { + tolerations := append(c.cfg.Installation.ControlPlaneTolerations, rmeta.TolerateCriticalAddonsAndControlPlane...) + if c.cfg.Installation.KubernetesProvider.IsGKE() { + tolerations = append(tolerations, rmeta.TolerateGKEARM64NoSchedule) + } + + volumes := []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: OTelCollectorConfigMapName}, + }, + }, + }, + } + + if c.cfg.TrustedCertBundle != nil { + volumes = append(volumes, c.cfg.TrustedCertBundle.Volume()) + } + + if c.cfg.ReceiverTLSSecret != nil { + volumes = append(volumes, c.cfg.ReceiverTLSSecret.Volume()) + } + + if c.cfg.ClientTLSSecret != nil { + volumes = append(volumes, c.cfg.ClientTLSSecret.Volume()) + } + + return &appsv1.StatefulSet{ + TypeMeta: metav1.TypeMeta{Kind: "StatefulSet", APIVersion: "apps/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: OTelCollectorStatefulSetName, + Namespace: OTelCollectorNamespace, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: c.cfg.Installation.ControlPlaneReplicas, + ServiceName: OTelCollectorServiceName, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"k8s-app": OTelCollectorStatefulSetName}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: OTelCollectorStatefulSetName, + Labels: map[string]string{"k8s-app": OTelCollectorStatefulSetName}, + }, + Spec: corev1.PodSpec{ + NodeSelector: c.cfg.Installation.ControlPlaneNodeSelector, + ServiceAccountName: OTelCollectorServiceAccountName, + Tolerations: tolerations, + ImagePullSecrets: secret.GetReferenceList(c.cfg.PullSecrets), + Containers: []corev1.Container{c.container()}, + Volumes: volumes, + }, + }, + }, + } +} + +func (c *component) networkPolicy() *v3.NetworkPolicy { + ingressRules := []v3.Rule{ + { + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(OTLPHTTPPort), + }, + }, + { + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(InternalMetricsPort), + }, + }, + } + + egressRules := []v3.Rule{ + { + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(OTLPGRPCPort, OTLPHTTPPort), + }, + }, + } + + for _, exp := range c.cfg.OTelCollector.Exporters { + if u, err := url.Parse(exp.Endpoint); err == nil { + portStr := u.Port() + if portStr == "" { + if u.Scheme == "https" { + portStr = "443" + } else { + portStr = "80" + } + } + if p, err := strconv.Atoi(portStr); err == nil { + egressRules = append(egressRules, v3.Rule{ + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(uint16(p)), + }, + }) + } + } + } + + if c.metricsEnabled() { + egressRules = append(egressRules, + v3.Rule{ + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: networkpolicy.KubeAPIServerEntityRule, + }, + v3.Rule{ + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(9081, 9900), + }, + }, + ) + } + + egressRules = networkpolicy.AppendDNSEgressRules(egressRules, c.cfg.OpenShift) + + return &v3.NetworkPolicy{ + TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}, + ObjectMeta: metav1.ObjectMeta{Name: OTelCollectorPolicyName, Namespace: OTelCollectorNamespace}, + Spec: v3.NetworkPolicySpec{ + Tier: networkpolicy.CalicoTierName, + Selector: networkpolicy.KubernetesAppSelector(OTelCollectorStatefulSetName), + Types: []v3.PolicyType{v3.PolicyTypeIngress, v3.PolicyTypeEgress}, + Ingress: ingressRules, + Egress: egressRules, + }, + } +} diff --git a/pkg/render/otelcollector/component_test.go b/pkg/render/otelcollector/component_test.go new file mode 100644 index 0000000000..37842eb083 --- /dev/null +++ b/pkg/render/otelcollector/component_test.go @@ -0,0 +1,602 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package otelcollector_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/google/go-cmp/cmp" + + v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + + operatorv1 "github.com/tigera/operator/api/v1" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/render/common/securitycontext" + rtest "github.com/tigera/operator/pkg/render/common/test" + "github.com/tigera/operator/pkg/render/otelcollector" + "github.com/tigera/operator/pkg/tls/certificatemanagement" +) + +var _ = Describe("OTelCollector rendering", func() { + var defaultInstallation *operatorv1.InstallationSpec + + BeforeEach(func() { + defaultInstallation = &operatorv1.InstallationSpec{ + Variant: operatorv1.CalicoEnterprise, + Registry: "testregistry.com/", + KubernetesProvider: operatorv1.ProviderGKE, + ControlPlaneReplicas: ptr.To(int32(2)), + } + }) + + DescribeTable("Object counts", + func(cfg *otelcollector.Configuration, createCount, deleteCount int) { + component := otelcollector.OTelCollector(cfg) + toCreate, toDelete := component.Objects() + Expect(toCreate).To(HaveLen(createCount)) + Expect(toDelete).To(HaveLen(deleteCount)) + }, + Entry("logs and metrics enabled", + &otelcollector.Configuration{ + Installation: &operatorv1.InstallationSpec{KubernetesProvider: operatorv1.ProviderGKE}, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelFlowLog}}, + Metrics: &operatorv1.OTelMetrics{Enabled: ptr.To(operatorv1.OTelMetricsEnable)}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + }, + 7, 0, + ), + Entry("logs only", + &otelcollector.Configuration{ + Installation: &operatorv1.InstallationSpec{KubernetesProvider: operatorv1.ProviderGKE}, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelAuditLog}}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + }, + 7, 0, + ), + Entry("metrics only", + &otelcollector.Configuration{ + Installation: &operatorv1.InstallationSpec{KubernetesProvider: operatorv1.ProviderGKE}, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Metrics: &operatorv1.OTelMetrics{Enabled: ptr.To(operatorv1.OTelMetricsEnable)}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + }, + 7, 0, + ), + Entry("no logs, no metrics", + &otelcollector.Configuration{ + Installation: &operatorv1.InstallationSpec{KubernetesProvider: operatorv1.ProviderGKE}, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + }, + 7, 0, + ), + ) + + Context("StatefulSet rendering", func() { + It("should render the expected statefulset", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelFlowLog}}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + } + component := otelcollector.OTelCollector(cfg) + Expect(component.ResolveImages(nil)).NotTo(HaveOccurred()) + objs, _ := component.Objects() + + expected := &appsv1.StatefulSet{ + TypeMeta: metav1.TypeMeta{Kind: "StatefulSet", APIVersion: "apps/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: otelcollector.OTelCollectorStatefulSetName, + Namespace: otelcollector.OTelCollectorNamespace, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(2)), + ServiceName: otelcollector.OTelCollectorServiceName, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"k8s-app": otelcollector.OTelCollectorStatefulSetName}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: otelcollector.OTelCollectorStatefulSetName, + Labels: map[string]string{"k8s-app": otelcollector.OTelCollectorStatefulSetName}, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: otelcollector.OTelCollectorServiceAccountName, + Tolerations: append(rmeta.TolerateCriticalAddonsAndControlPlane, rmeta.TolerateGKEARM64NoSchedule), + Containers: []corev1.Container{ + { + Name: otelcollector.OTelCollectorContainerName, + Image: "testregistry.com/tigera/calico:master", + Command: []string{"/usr/bin/otelcol", "--config=/etc/otel/config.yaml"}, + Ports: []corev1.ContainerPort{ + {Name: "otlp-grpc", ContainerPort: otelcollector.OTLPGRPCPort, Protocol: corev1.ProtocolTCP}, + {Name: "otlp-http", ContainerPort: otelcollector.OTLPHTTPPort, Protocol: corev1.ProtocolTCP}, + {Name: "health", ContainerPort: otelcollector.HealthCheckPort, Protocol: corev1.ProtocolTCP}, + {Name: "metrics", ContainerPort: otelcollector.InternalMetricsPort, Protocol: corev1.ProtocolTCP}, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse(otelcollector.DefaultMemoryLimit), + }, + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse(otelcollector.DefaultMemoryRequest), + }, + }, + SecurityContext: securitycontext.NewNonRootContext(), + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/", + Port: intstr.FromInt32(otelcollector.HealthCheckPort), + }, + }, + PeriodSeconds: 10, + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/", + Port: intstr.FromInt32(otelcollector.HealthCheckPort), + }, + }, + PeriodSeconds: 10, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: "config", MountPath: "/etc/otel", ReadOnly: true}, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: otelcollector.OTelCollectorConfigMapName}, + }, + }, + }, + }, + }, + }, + }, + } + + statefulSet, err := rtest.GetResourceOfType[*appsv1.StatefulSet](objs, otelcollector.OTelCollectorStatefulSetName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(statefulSet.Spec.Template.Spec.Containers[0].Ports).To(ConsistOf(expected.Spec.Template.Spec.Containers[0].Ports)) + Expect(statefulSet.Spec.Template.Spec.Containers[0].VolumeMounts).To(ConsistOf(expected.Spec.Template.Spec.Containers[0].VolumeMounts)) + Expect(statefulSet.Spec.Template.Spec.Volumes).To(ConsistOf(expected.Spec.Template.Spec.Volumes)) + Expect(statefulSet).To(Equal(expected), cmp.Diff(statefulSet, expected)) + }) + + It("should include TLS volumes and mounts when metrics with certs are enabled", func() { + tlsKeyPair := certificatemanagement.NewKeyPair(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "client-tls"}}, nil, "") + trustedBundle := certificatemanagement.CreateTrustedBundle(nil) + + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Metrics: &operatorv1.OTelMetrics{Enabled: ptr.To(operatorv1.OTelMetricsEnable)}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + ClientTLSSecret: tlsKeyPair, + TrustedCertBundle: trustedBundle, + } + component := otelcollector.OTelCollector(cfg) + Expect(component.ResolveImages(nil)).NotTo(HaveOccurred()) + objs, _ := component.Objects() + + statefulSet, err := rtest.GetResourceOfType[*appsv1.StatefulSet](objs, otelcollector.OTelCollectorStatefulSetName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(len(statefulSet.Spec.Template.Spec.Volumes)).To(BeNumerically(">", 1)) + Expect(len(statefulSet.Spec.Template.Spec.Containers[0].VolumeMounts)).To(BeNumerically(">", 1)) + }) + + It("should include receiver TLS volumes and mounts when logs with certs are enabled", func() { + receiverKeyPair := certificatemanagement.NewKeyPair(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "otel-collector-tls"}}, nil, "") + trustedBundle := certificatemanagement.CreateTrustedBundle(nil) + + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelFlowLog}}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + ReceiverTLSSecret: receiverKeyPair, + TrustedCertBundle: trustedBundle, + } + component := otelcollector.OTelCollector(cfg) + Expect(component.ResolveImages(nil)).NotTo(HaveOccurred()) + objs, _ := component.Objects() + + statefulSet, err := rtest.GetResourceOfType[*appsv1.StatefulSet](objs, otelcollector.OTelCollectorStatefulSetName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(len(statefulSet.Spec.Template.Spec.Volumes)).To(BeNumerically(">", 1)) + Expect(len(statefulSet.Spec.Template.Spec.Containers[0].VolumeMounts)).To(BeNumerically(">", 1)) + }) + }) + + Context("ConfigMap content", func() { + It("should include otlp receiver when logs are enabled", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelFlowLog}}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + cm, err := rtest.GetResourceOfType[*corev1.ConfigMap](objs, otelcollector.OTelCollectorConfigMapName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + config := cm.Data["config.yaml"] + Expect(config).To(ContainSubstring("otlp:")) + Expect(config).To(ContainSubstring("0.0.0.0:4318")) + Expect(config).To(ContainSubstring("logs:")) + Expect(config).To(ContainSubstring("receivers: [otlp]")) + }) + + It("should include receiver TLS config when logs with certs are enabled", func() { + receiverKeyPair := certificatemanagement.NewKeyPair(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "otel-collector-tls"}}, nil, "") + trustedBundle := certificatemanagement.CreateTrustedBundle(nil) + + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelFlowLog}}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + ReceiverTLSSecret: receiverKeyPair, + TrustedCertBundle: trustedBundle, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + cm, err := rtest.GetResourceOfType[*corev1.ConfigMap](objs, otelcollector.OTelCollectorConfigMapName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + config := cm.Data["config.yaml"] + Expect(config).To(ContainSubstring("otlp:")) + Expect(config).To(ContainSubstring("cert_file:")) + Expect(config).To(ContainSubstring("key_file:")) + Expect(config).To(ContainSubstring("client_ca_file:")) + }) + + It("should include prometheus receiver when metrics are enabled", func() { + tlsKeyPair := certificatemanagement.NewKeyPair(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "client-tls"}}, nil, "") + trustedBundle := certificatemanagement.CreateTrustedBundle(nil) + + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Metrics: &operatorv1.OTelMetrics{Enabled: ptr.To(operatorv1.OTelMetricsEnable)}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + ClientTLSSecret: tlsKeyPair, + TrustedCertBundle: trustedBundle, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + cm, err := rtest.GetResourceOfType[*corev1.ConfigMap](objs, otelcollector.OTelCollectorConfigMapName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + config := cm.Data["config.yaml"] + Expect(config).To(ContainSubstring("prometheus:")) + Expect(config).To(ContainSubstring("kubernetes_sd_configs")) + Expect(config).To(ContainSubstring("tls_config:")) + Expect(config).To(ContainSubstring("server_name: calico-node-metrics")) + Expect(config).To(ContainSubstring("calico-metrics-port|calico-bgp-metrics-port")) + Expect(config).To(ContainSubstring("metrics:")) + Expect(config).To(ContainSubstring("receivers: [prometheus]")) + Expect(config).To(ContainSubstring("exporters: [otlp/backend]")) + }) + + It("should not include receivers or pipelines when logs and metrics are disabled", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + cm, err := rtest.GetResourceOfType[*corev1.ConfigMap](objs, otelcollector.OTelCollectorConfigMapName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + config := cm.Data["config.yaml"] + Expect(config).NotTo(ContainSubstring("otlp:")) + Expect(config).NotTo(ContainSubstring("scrape_configs:")) + Expect(config).NotTo(ContainSubstring("logs:")) + Expect(config).NotTo(ContainSubstring("receivers: [prometheus]")) + }) + + It("should use otlphttp prefix for HTTP exporters", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelFlowLog}}, + Exporters: []operatorv1.OTelExporter{ + {Name: "httpbackend", Endpoint: "https://otlp.example.com:443", Protocol: operatorv1.OTelProtocolHTTP}, + }, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + cm, err := rtest.GetResourceOfType[*corev1.ConfigMap](objs, otelcollector.OTelCollectorConfigMapName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + config := cm.Data["config.yaml"] + Expect(config).To(ContainSubstring("otlphttp/httpbackend:")) + Expect(config).To(ContainSubstring("exporters: [otlphttp/httpbackend]")) + }) + + It("should use otlp prefix for gRPC exporters", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelFlowLog}}, + Exporters: []operatorv1.OTelExporter{ + {Name: "grpcbackend", Endpoint: "otlp.example.com:4317", Protocol: operatorv1.OTelProtocolGRPC}, + }, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + cm, err := rtest.GetResourceOfType[*corev1.ConfigMap](objs, otelcollector.OTelCollectorConfigMapName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + config := cm.Data["config.yaml"] + Expect(config).To(ContainSubstring("otlp/grpcbackend:")) + Expect(config).To(ContainSubstring("exporters: [otlp/grpcbackend]")) + }) + + It("should list multiple exporters in pipelines", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Logs: &operatorv1.OTelLogs{Types: []operatorv1.OTelLogType{operatorv1.OTelFlowLog}}, + Exporters: []operatorv1.OTelExporter{ + {Name: "first", Endpoint: "first.example.com:4317"}, + {Name: "second", Endpoint: "https://second.example.com", Protocol: operatorv1.OTelProtocolHTTP}, + }, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + cm, err := rtest.GetResourceOfType[*corev1.ConfigMap](objs, otelcollector.OTelCollectorConfigMapName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + config := cm.Data["config.yaml"] + Expect(config).To(ContainSubstring("exporters: [otlp/first, otlphttp/second]")) + }) + + It("should always include the health_check extension", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + cm, err := rtest.GetResourceOfType[*corev1.ConfigMap](objs, otelcollector.OTelCollectorConfigMapName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + config := cm.Data["config.yaml"] + Expect(config).To(ContainSubstring("extensions:")) + Expect(config).To(ContainSubstring("health_check:")) + Expect(config).To(ContainSubstring("0.0.0.0:13133")) + Expect(config).To(ContainSubstring("extensions: [health_check]")) + }) + }) + + Context("Service", func() { + It("should expose the OTLP HTTP port", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + svc, err := rtest.GetResourceOfType[*corev1.Service](objs, otelcollector.OTelCollectorServiceName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + Expect(svc.Spec.Ports).To(HaveLen(2)) + Expect(svc.Spec.Ports[0].Port).To(Equal(int32(otelcollector.OTLPHTTPPort))) + Expect(svc.Spec.Ports[0].Name).To(Equal("otlp-http")) + Expect(svc.Spec.Ports[1].Port).To(Equal(int32(otelcollector.InternalMetricsPort))) + Expect(svc.Spec.Ports[1].Name).To(Equal("metrics")) + Expect(svc.Spec.Selector).To(Equal(map[string]string{"k8s-app": otelcollector.OTelCollectorStatefulSetName})) + }) + }) + + Context("RBAC", func() { + It("should render the expected service account, cluster role, and cluster role binding", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + + sa, err := rtest.GetResourceOfType[*corev1.ServiceAccount](objs, otelcollector.OTelCollectorServiceAccountName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + Expect(sa).NotTo(BeNil()) + + cr, err := rtest.GetResourceOfType[*rbacv1.ClusterRole](objs, otelcollector.OTelCollectorClusterRoleName, "") + Expect(err).ShouldNot(HaveOccurred()) + Expect(cr.Rules).To(HaveLen(1)) + Expect(cr.Rules[0].Resources).To(ConsistOf("nodes", "pods", "services", "endpoints")) + + crb, err := rtest.GetResourceOfType[*rbacv1.ClusterRoleBinding](objs, otelcollector.OTelCollectorClusterRoleName, "") + Expect(err).ShouldNot(HaveOccurred()) + Expect(crb.RoleRef.Name).To(Equal(otelcollector.OTelCollectorClusterRoleName)) + Expect(crb.Subjects).To(HaveLen(1)) + Expect(crb.Subjects[0].Name).To(Equal(otelcollector.OTelCollectorServiceAccountName)) + }) + }) + + Context("NetworkPolicy", func() { + It("should allow ingress on the OTLP HTTP port", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + + np, err := rtest.GetResourceOfType[*v3.NetworkPolicy](objs, otelcollector.OTelCollectorPolicyName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + Expect(np.Spec.Ingress).To(HaveLen(2)) + Expect(np.Spec.Ingress[0].Action).To(Equal(v3.Allow)) + Expect(np.Spec.Ingress[1].Action).To(Equal(v3.Allow)) + Expect(np.Spec.Types).To(ConsistOf(v3.PolicyTypeIngress, v3.PolicyTypeEgress)) + }) + + It("should add kube API server and prometheus egress rules when metrics are enabled", func() { + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Metrics: &operatorv1.OTelMetrics{Enabled: ptr.To(operatorv1.OTelMetricsEnable)}, + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + + np, err := rtest.GetResourceOfType[*v3.NetworkPolicy](objs, otelcollector.OTelCollectorPolicyName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + Expect(len(np.Spec.Egress)).To(BeNumerically(">=", 4)) + }) + }) + + Context("StatefulSet overrides", func() { + It("should apply overrides from the CR", func() { + affinity := &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "custom-key", + Operator: corev1.NodeSelectorOpExists, + }}, + }}, + }, + }, + } + containerResources := &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{"cpu": resource.MustParse("500m")}, + Requests: corev1.ResourceList{"cpu": resource.MustParse("100m")}, + } + nodeSelector := map[string]string{"zone": "us-west-2a"} + tolerations := []corev1.Toleration{{Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "otel"}} + topologyConstraints := []corev1.TopologySpreadConstraint{{ + MaxSkew: 1, + TopologyKey: "topology.kubernetes.io/zone", + WhenUnsatisfiable: corev1.ScheduleAnyway, + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"k8s-app": "otel-collector"}}, + }} + podLabels := map[string]string{"extra-label": "value"} + podAnnotations := map[string]string{"extra-annotation": "value"} + priorityClassName := "system-cluster-critical" + + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + OTelCollectorStatefulSet: &operatorv1.OTelCollectorStatefulSet{ + Spec: &operatorv1.OTelCollectorStatefulSetSpec{ + Template: &operatorv1.OTelCollectorStatefulSetPodTemplateSpec{ + Metadata: &operatorv1.Metadata{ + Labels: podLabels, + Annotations: podAnnotations, + }, + Spec: &operatorv1.OTelCollectorStatefulSetPodSpec{ + Affinity: affinity, + Containers: []operatorv1.OTelCollectorStatefulSetContainer{{ + Name: "otel-collector", + Resources: containerResources, + }}, + NodeSelector: nodeSelector, + Tolerations: tolerations, + TopologySpreadConstraints: topologyConstraints, + PriorityClassName: priorityClassName, + }, + }, + }, + }, + }, + } + + component := otelcollector.OTelCollector(cfg) + Expect(component.ResolveImages(nil)).NotTo(HaveOccurred()) + objs, _ := component.Objects() + + statefulSet, err := rtest.GetResourceOfType[*appsv1.StatefulSet](objs, otelcollector.OTelCollectorStatefulSetName, otelcollector.OTelCollectorNamespace) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(statefulSet.Spec.Template.ObjectMeta.Labels).To(HaveKeyWithValue("extra-label", "value")) + Expect(statefulSet.Spec.Template.ObjectMeta.Labels).To(HaveKeyWithValue("k8s-app", otelcollector.OTelCollectorStatefulSetName)) + Expect(statefulSet.Spec.Template.ObjectMeta.Annotations).To(Equal(podAnnotations)) + Expect(statefulSet.Spec.Template.Spec.Affinity).To(Equal(affinity)) + Expect(statefulSet.Spec.Template.Spec.NodeSelector).To(Equal(nodeSelector)) + Expect(statefulSet.Spec.Template.Spec.Tolerations).To(Equal(tolerations)) + Expect(statefulSet.Spec.Template.Spec.TopologySpreadConstraints).To(Equal(topologyConstraints)) + Expect(statefulSet.Spec.Template.Spec.PriorityClassName).To(Equal(priorityClassName)) + Expect(statefulSet.Spec.Template.Spec.Containers[0].Resources).To(Equal(*containerResources)) + }) + }) + + Context("Pull secrets", func() { + It("should include pull secrets when configured", func() { + pullSecrets := []*corev1.Secret{ + {ObjectMeta: metav1.ObjectMeta{Name: "my-pull-secret", Namespace: "tigera-operator"}}, + } + cfg := &otelcollector.Configuration{ + Installation: defaultInstallation, + PullSecrets: pullSecrets, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + } + objs, _ := otelcollector.OTelCollector(cfg).Objects() + // 7 base objects + 1 copied pull secret + Expect(objs).To(HaveLen(8)) + }) + }) + + It("should support Linux OS type", func() { + component := otelcollector.OTelCollector(&otelcollector.Configuration{ + Installation: defaultInstallation, + OTelCollector: &operatorv1.OTelCollectorSpec{ + Exporters: []operatorv1.OTelExporter{{Name: "backend", Endpoint: "otlp.example.com:4317"}}, + }, + }) + Expect(component.SupportedOSType()).To(Equal(rmeta.OSTypeLinux)) + }) + +}) diff --git a/pkg/render/otelcollector/suite_test.go b/pkg/render/otelcollector/suite_test.go new file mode 100644 index 0000000000..f2768136b1 --- /dev/null +++ b/pkg/render/otelcollector/suite_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package otelcollector_test + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestRender(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + suiteConfig, reporterConfig := ginkgo.GinkgoConfiguration() + reporterConfig.JUnitReport = "../../../report/ut/otelcollector_render_suite.xml" + ginkgo.RunSpecs(t, "pkg/render/otelcollector Suite", suiteConfig, reporterConfig) +} diff --git a/pkg/render/render.go b/pkg/render/render.go index 28fe4537c2..8d55c2cbd6 100644 --- a/pkg/render/render.go +++ b/pkg/render/render.go @@ -37,7 +37,7 @@ func SetTestLogger(l logr.Logger) { log = l } -func setNodeCriticalPod(t *corev1.PodTemplateSpec) { +func SetNodeCriticalPod(t *corev1.PodTemplateSpec) { t.Spec.PriorityClassName = NodePriorityClassName } diff --git a/pkg/render/testutils/expected_policies/dns.json b/pkg/render/testutils/expected_policies/dns.json index 1a6562ee4b..56aea31484 100644 --- a/pkg/render/testutils/expected_policies/dns.json +++ b/pkg/render/testutils/expected_policies/dns.json @@ -12,7 +12,7 @@ { "action": "Allow", "source": { - "selector": "projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-fluentd','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", + "selector": "projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", "namespaceSelector": "all()" }, "destination": {} diff --git a/pkg/render/testutils/expected_policies/dns_ocp.json b/pkg/render/testutils/expected_policies/dns_ocp.json index eb05c51a24..f26fab6166 100644 --- a/pkg/render/testutils/expected_policies/dns_ocp.json +++ b/pkg/render/testutils/expected_policies/dns_ocp.json @@ -12,7 +12,7 @@ { "action":"Allow", "source":{ - "selector":"projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-fluentd','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", + "selector":"projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", "namespaceSelector":"all()" }, "destination":{} diff --git a/pkg/render/testutils/expected_policies/es-gateway.json b/pkg/render/testutils/expected_policies/es-gateway.json index 8d7b439c8a..84a847b013 100644 --- a/pkg/render/testutils/expected_policies/es-gateway.json +++ b/pkg/render/testutils/expected_policies/es-gateway.json @@ -18,8 +18,8 @@ "action": "Allow", "protocol": "TCP", "source": { - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'", - "namespaceSelector": "name == 'tigera-fluentd'" + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'", + "namespaceSelector": "name == 'calico-system'" }, "destination": { "ports": [ @@ -32,7 +32,7 @@ "protocol": "TCP", "source": { "selector": "k8s-app == 'eks-log-forwarder'", - "namespaceSelector": "projectcalico.org/name == 'tigera-fluentd'" + "namespaceSelector": "projectcalico.org/name == 'calico-system'" }, "destination": { "ports": [ diff --git a/pkg/render/testutils/expected_policies/es-gateway_ocp.json b/pkg/render/testutils/expected_policies/es-gateway_ocp.json index 10a40d03c5..70abdefa00 100644 --- a/pkg/render/testutils/expected_policies/es-gateway_ocp.json +++ b/pkg/render/testutils/expected_policies/es-gateway_ocp.json @@ -18,8 +18,8 @@ "action": "Allow", "protocol": "TCP", "source": { - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'", - "namespaceSelector": "name == 'tigera-fluentd'" + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'", + "namespaceSelector": "name == 'calico-system'" }, "destination": { "ports": [ @@ -32,7 +32,7 @@ "protocol": "TCP", "source": { "selector": "k8s-app == 'eks-log-forwarder'", - "namespaceSelector": "projectcalico.org/name == 'tigera-fluentd'" + "namespaceSelector": "projectcalico.org/name == 'calico-system'" }, "destination": { "ports": [ diff --git a/pkg/render/testutils/expected_policies/fluentd_managed.json b/pkg/render/testutils/expected_policies/fluentbit_managed.json similarity index 81% rename from pkg/render/testutils/expected_policies/fluentd_managed.json rename to pkg/render/testutils/expected_policies/fluentbit_managed.json index eaab9c5b1c..aae1dac0e5 100644 --- a/pkg/render/testutils/expected_policies/fluentd_managed.json +++ b/pkg/render/testutils/expected_policies/fluentbit_managed.json @@ -2,13 +2,13 @@ "apiVersion": "projectcalico.org/v3", "kind": "NetworkPolicy", "metadata": { - "name": "calico-system.allow-fluentd-node", - "namespace": "tigera-fluentd" + "name": "calico-system.allow-calico-fluent-bit", + "namespace": "calico-system" }, "spec": { "tier": "calico-system", "order": 1, - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'", + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'", "types": [ "Ingress", "Egress" @@ -23,7 +23,7 @@ }, "destination": { "ports": [ - "9081" + "2020" ] } } diff --git a/pkg/render/testutils/expected_policies/fluentd_unmanaged.json b/pkg/render/testutils/expected_policies/fluentbit_unmanaged.json similarity index 88% rename from pkg/render/testutils/expected_policies/fluentd_unmanaged.json rename to pkg/render/testutils/expected_policies/fluentbit_unmanaged.json index 39ef72b426..290d673e1b 100644 --- a/pkg/render/testutils/expected_policies/fluentd_unmanaged.json +++ b/pkg/render/testutils/expected_policies/fluentbit_unmanaged.json @@ -2,13 +2,13 @@ "apiVersion": "projectcalico.org/v3", "kind": "NetworkPolicy", "metadata": { - "name": "calico-system.allow-fluentd-node", - "namespace": "tigera-fluentd" + "name": "calico-system.allow-calico-fluent-bit", + "namespace": "calico-system" }, "spec": { "tier": "calico-system", "order": 1, - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'", + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'", "serviceAccountSelector": "", "types": [ "Ingress", @@ -24,7 +24,7 @@ }, "destination": { "ports": [ - "9081" + "2020" ] } } diff --git a/pkg/render/testutils/expected_policies/fluentd_unmanaged_ocp.json b/pkg/render/testutils/expected_policies/fluentbit_unmanaged_ocp.json similarity index 90% rename from pkg/render/testutils/expected_policies/fluentd_unmanaged_ocp.json rename to pkg/render/testutils/expected_policies/fluentbit_unmanaged_ocp.json index 6fb8be3a12..bf6d782cd2 100644 --- a/pkg/render/testutils/expected_policies/fluentd_unmanaged_ocp.json +++ b/pkg/render/testutils/expected_policies/fluentbit_unmanaged_ocp.json @@ -2,13 +2,13 @@ "apiVersion": "projectcalico.org/v3", "kind": "NetworkPolicy", "metadata": { - "name": "calico-system.allow-fluentd-node", - "namespace": "tigera-fluentd" + "name": "calico-system.allow-calico-fluent-bit", + "namespace": "calico-system" }, "spec": { "tier": "calico-system", "order": 1, - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'", + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'", "serviceAccountSelector": "", "types": [ "Ingress", @@ -24,7 +24,7 @@ }, "destination": { "ports": [ - "9081" + "2020" ] } } diff --git a/pkg/render/testutils/expected_policies/guardian.json b/pkg/render/testutils/expected_policies/guardian.json index 05cc65475e..8c80cd1049 100644 --- a/pkg/render/testutils/expected_policies/guardian.json +++ b/pkg/render/testutils/expected_policies/guardian.json @@ -23,8 +23,8 @@ }, "protocol": "TCP", "source": { - "namespaceSelector": "name == 'tigera-fluentd'", - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'" + "namespaceSelector": "name == 'calico-system'", + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'" } }, { diff --git a/pkg/render/testutils/expected_policies/guardian_ocp.json b/pkg/render/testutils/expected_policies/guardian_ocp.json index 542351d5f6..c866003892 100644 --- a/pkg/render/testutils/expected_policies/guardian_ocp.json +++ b/pkg/render/testutils/expected_policies/guardian_ocp.json @@ -23,8 +23,8 @@ }, "protocol": "TCP", "source": { - "namespaceSelector": "name == 'tigera-fluentd'", - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'" + "namespaceSelector": "name == 'calico-system'", + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'" } }, { diff --git a/pkg/render/testutils/expected_policies/linseed.json b/pkg/render/testutils/expected_policies/linseed.json index 6d75c7caa3..0cf23d1bc5 100644 --- a/pkg/render/testutils/expected_policies/linseed.json +++ b/pkg/render/testutils/expected_policies/linseed.json @@ -18,8 +18,8 @@ "action": "Allow", "protocol": "TCP", "source": { - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'", - "namespaceSelector": "name == 'tigera-fluentd'" + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'", + "namespaceSelector": "name == 'calico-system'" }, "destination": { "ports": [ @@ -32,7 +32,7 @@ "protocol": "TCP", "source": { "selector": "k8s-app == 'eks-log-forwarder'", - "namespaceSelector": "projectcalico.org/name == 'tigera-fluentd'" + "namespaceSelector": "projectcalico.org/name == 'calico-system'" }, "destination": { "ports": [ diff --git a/pkg/render/testutils/expected_policies/linseed_dpi_enabled.json b/pkg/render/testutils/expected_policies/linseed_dpi_enabled.json index c7ab55cfd1..023474b338 100644 --- a/pkg/render/testutils/expected_policies/linseed_dpi_enabled.json +++ b/pkg/render/testutils/expected_policies/linseed_dpi_enabled.json @@ -18,8 +18,8 @@ "action": "Allow", "protocol": "TCP", "source": { - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'", - "namespaceSelector": "name == 'tigera-fluentd'" + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'", + "namespaceSelector": "name == 'calico-system'" }, "destination": { "ports": [ @@ -32,7 +32,7 @@ "protocol": "TCP", "source": { "selector": "k8s-app == 'eks-log-forwarder'", - "namespaceSelector": "projectcalico.org/name == 'tigera-fluentd'" + "namespaceSelector": "projectcalico.org/name == 'calico-system'" }, "destination": { "ports": [ diff --git a/pkg/render/testutils/expected_policies/linseed_ocp.json b/pkg/render/testutils/expected_policies/linseed_ocp.json index f28838a0ed..241f2fe126 100644 --- a/pkg/render/testutils/expected_policies/linseed_ocp.json +++ b/pkg/render/testutils/expected_policies/linseed_ocp.json @@ -18,8 +18,8 @@ "action": "Allow", "protocol": "TCP", "source": { - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'", - "namespaceSelector": "name == 'tigera-fluentd'" + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'", + "namespaceSelector": "name == 'calico-system'" }, "destination": { "ports": [ @@ -32,7 +32,7 @@ "protocol": "TCP", "source": { "selector": "k8s-app == 'eks-log-forwarder'", - "namespaceSelector": "projectcalico.org/name == 'tigera-fluentd'" + "namespaceSelector": "projectcalico.org/name == 'calico-system'" }, "destination": { "ports": [ diff --git a/pkg/render/testutils/expected_policies/linseed_ocp_dpi_enabled.json b/pkg/render/testutils/expected_policies/linseed_ocp_dpi_enabled.json index 97ae80ffba..82478056c1 100644 --- a/pkg/render/testutils/expected_policies/linseed_ocp_dpi_enabled.json +++ b/pkg/render/testutils/expected_policies/linseed_ocp_dpi_enabled.json @@ -18,8 +18,8 @@ "action": "Allow", "protocol": "TCP", "source": { - "selector": "k8s-app == 'fluentd-node' || k8s-app == 'fluentd-node-windows'", - "namespaceSelector": "name == 'tigera-fluentd'" + "selector": "k8s-app == 'calico-fluent-bit' || k8s-app == 'calico-fluent-bit-windows'", + "namespaceSelector": "name == 'calico-system'" }, "destination": { "ports": [ @@ -32,7 +32,7 @@ "protocol": "TCP", "source": { "selector": "k8s-app == 'eks-log-forwarder'", - "namespaceSelector": "projectcalico.org/name == 'tigera-fluentd'" + "namespaceSelector": "projectcalico.org/name == 'calico-system'" }, "destination": { "ports": [ diff --git a/pkg/render/testutils/expected_policies/node_local_dns_dual.json b/pkg/render/testutils/expected_policies/node_local_dns_dual.json index aa1e962285..71371ed0e2 100644 --- a/pkg/render/testutils/expected_policies/node_local_dns_dual.json +++ b/pkg/render/testutils/expected_policies/node_local_dns_dual.json @@ -7,7 +7,7 @@ "spec": { "tier":"calico-system", "order":10, - "selector": "projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-fluentd','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", + "selector": "projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", "egress":[ { "action":"Allow", diff --git a/pkg/render/testutils/expected_policies/node_local_dns_ipv4.json b/pkg/render/testutils/expected_policies/node_local_dns_ipv4.json index fe3c073a88..bf482c16eb 100644 --- a/pkg/render/testutils/expected_policies/node_local_dns_ipv4.json +++ b/pkg/render/testutils/expected_policies/node_local_dns_ipv4.json @@ -7,7 +7,7 @@ "spec": { "tier":"calico-system", "order":10, - "selector": "projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-fluentd','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", + "selector": "projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", "egress":[ { "action":"Allow", diff --git a/pkg/render/testutils/expected_policies/node_local_dns_ipv6.json b/pkg/render/testutils/expected_policies/node_local_dns_ipv6.json index 8a2b2d376d..e5e3ecd1a4 100644 --- a/pkg/render/testutils/expected_policies/node_local_dns_ipv6.json +++ b/pkg/render/testutils/expected_policies/node_local_dns_ipv6.json @@ -7,7 +7,7 @@ "spec": { "tier":"calico-system", "order":10, - "selector": "projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-fluentd','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", + "selector": "projectcalico.org/namespace in {'calico-system','tigera-compliance','tigera-dex','tigera-elasticsearch','tigera-intrusion-detection','tigera-kibana','tigera-eck-operator','tigera-packetcapture','tigera-prometheus','tigera-skraper'}", "egress":[ { "action":"Allow", diff --git a/pkg/render/testutils/expected_policies/prometheus.json b/pkg/render/testutils/expected_policies/prometheus.json index d47931760e..98041b84cf 100644 --- a/pkg/render/testutils/expected_policies/prometheus.json +++ b/pkg/render/testutils/expected_policies/prometheus.json @@ -56,6 +56,15 @@ ] } }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "ports": [ + 2020 + ] + } + }, { "action": "Allow", "protocol": "TCP", diff --git a/pkg/render/testutils/expected_policies/prometheus_ocp.json b/pkg/render/testutils/expected_policies/prometheus_ocp.json index 9b02c50bdc..96188a8b42 100644 --- a/pkg/render/testutils/expected_policies/prometheus_ocp.json +++ b/pkg/render/testutils/expected_policies/prometheus_ocp.json @@ -67,6 +67,15 @@ ] } }, + { + "action": "Allow", + "protocol": "TCP", + "destination": { + "ports": [ + 2020 + ] + } + }, { "action": "Allow", "protocol": "TCP", diff --git a/pkg/render/tiers/tiers_test.go b/pkg/render/tiers/tiers_test.go index dbf936bf4c..ed0e135a32 100644 --- a/pkg/render/tiers/tiers_test.go +++ b/pkg/render/tiers/tiers_test.go @@ -71,7 +71,6 @@ var _ = Describe("Tiers rendering tests", func() { render.ComplianceNamespace, render.DexNamespace, render.ElasticsearchNamespace, - render.LogCollectorNamespace, render.IntrusionDetectionNamespace, kibana.Namespace, eck.OperatorNamespace, diff --git a/pkg/render/windows.go b/pkg/render/windows.go index 28df89c78d..03b171b49a 100644 --- a/pkg/render/windows.go +++ b/pkg/render/windows.go @@ -887,7 +887,7 @@ func (c *windowsComponent) windowsDaemonset(cniCfgMap *corev1.ConfigMap) *appsv1 ds.Spec.Template.Spec.InitContainers = append(ds.Spec.Template.Spec.InitContainers, c.cniContainer()) } - setNodeCriticalPod(&(ds.Spec.Template)) + SetNodeCriticalPod(&(ds.Spec.Template)) if overrides := c.cfg.Installation.CalicoNodeWindowsDaemonSet; overrides != nil { rcomp.ApplyDaemonSetOverrides(&ds, overrides)