fix(operator): dynamically configure networkpolicy when a Kubernetes service is used for object storage (#20111)

Co-authored-by: Robert Jacob <rojacob@redhat.com>
This commit is contained in:
Joao Marcal
2026-02-23 15:23:00 +00:00
committed by GitHub
parent 174a3bb951
commit e8a998b86e
11 changed files with 615 additions and 6 deletions

View File

@@ -152,7 +152,7 @@ metadata:
categories: OpenShift Optional, Logging & Tracing
certified: "false"
containerImage: docker.io/grafana/loki-operator:0.9.0
createdAt: "2026-02-09T14:56:06Z"
createdAt: "2026-02-16T17:31:28Z"
description: The Community Loki Operator provides Kubernetes native deployment
and management of Loki and related logging components.
features.operators.openshift.io/disconnected: "true"
@@ -1746,6 +1746,14 @@ spec:
- create
- get
- update
- apiGroups:
- discovery.k8s.io
resources:
- endpointslices
verbs:
- get
- list
- watch
- apiGroups:
- loki.grafana.com
resources:

View File

@@ -152,7 +152,7 @@ metadata:
categories: OpenShift Optional, Logging & Tracing
certified: "false"
containerImage: docker.io/grafana/loki-operator:0.9.0
createdAt: "2026-02-09T14:56:04Z"
createdAt: "2026-02-16T17:31:26Z"
description: The Community Loki Operator provides Kubernetes native deployment
and management of Loki and related logging components.
operators.operatorframework.io/builder: operator-sdk-unknown
@@ -1726,6 +1726,14 @@ spec:
- create
- get
- update
- apiGroups:
- discovery.k8s.io
resources:
- endpointslices
verbs:
- get
- list
- watch
- apiGroups:
- loki.grafana.com
resources:

View File

@@ -152,7 +152,7 @@ metadata:
categories: OpenShift Optional, Logging & Tracing
certified: "false"
containerImage: quay.io/openshift-logging/loki-operator:0.1.0
createdAt: "2026-02-09T14:56:07Z"
createdAt: "2026-02-16T17:31:30Z"
description: |
The Loki Operator for OCP provides a means for configuring and managing a Loki stack for cluster logging.
## Prerequisites and Requirements
@@ -1731,6 +1731,14 @@ spec:
- create
- get
- update
- apiGroups:
- discovery.k8s.io
resources:
- endpointslices
verbs:
- get
- list
- watch
- apiGroups:
- loki.grafana.com
resources:

View File

@@ -77,6 +77,14 @@ rules:
- create
- get
- update
- apiGroups:
- discovery.k8s.io
resources:
- endpointslices
verbs:
- get
- list
- watch
- apiGroups:
- loki.grafana.com
resources:

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# usage: deploy-odf-storage-secret.sh <bucket-claim-name>
#
# This scripts deploys a LokiStack Secret resource holding the
# authentication credentials to access ODF S3 bucket provided by the bucket
# claim name.
#
# bucket_claim_name is the name of the bucket claim to be used by this script to
# fetch the credentials to access the bucket.
set -euo pipefail
readonly bucket_claim_name=${1-}
if [[ -z "${bucket_claim_name}" ]]; then
echo "Provide a bucket claim name"
exit 1
fi
readonly namespace=${NAMESPACE:-openshift-logging}
# static authentication from the current select AWS CLI profile.
bucket_name=${BUCKET_NAME:-$(oc -n "${namespace}" get configmap "${bucket_claim_name}" -o json | jq -r '.data.BUCKET_NAME')}
readonly bucket_name
access_key_id=${ACCESS_KEY_ID:-$(oc -n "${namespace}" get secret "${bucket_claim_name}" -o json | jq -r '.data.AWS_ACCESS_KEY_ID' | base64 -d)}
readonly access_key_id
secret_access_key=${SECRET_ACCESS_KEY:-$( oc -n "${namespace}" get secret "${bucket_claim_name}" -o json | jq -r '.data.AWS_SECRET_ACCESS_KEY' | base64 -d)}
readonly secret_access_key
create_secret_args=( \
--from-literal=bucketnames="$(echo -n "${bucket_name}")" \
--from-literal=access_key_id="$(echo -n "${access_key_id}")" \
--from-literal=access_key_secret="$(echo -n "${secret_access_key}")" \
--from-literal=endpoint="$(echo -n "https://s3.openshift-storage.svc")" \
)
kubectl --ignore-not-found=true -n "${namespace}" delete secret test
kubectl -n "${namespace}" create secret generic test "${create_secret_args[@]}"
cat << 'EOF'
Remember to update your LokiStack CR with:
spec:
storage:
tls:
caName: openshift-service-ca.crt
EOF

View File

@@ -130,6 +130,7 @@ type LokiStackReconciler struct {
// +kubebuilder:rbac:groups=config.openshift.io,resources=dnses;apiservers;proxies;clusterversions,verbs=get;list;watch
// +kubebuilder:rbac:groups=route.openshift.io,resources=routes,verbs=get;list;watch;create;update;delete
// +kubebuilder:rbac:groups=cloudcredential.openshift.io,resources=credentialsrequests,verbs=get;list;watch;create;update;delete
// +kubebuilder:rbac:groups=discovery.k8s.io,resources=endpointslices,verbs=get;list;watch
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.

View File

@@ -0,0 +1,147 @@
package networkpolicy
import (
"context"
"errors"
"net"
"net/url"
"strconv"
"strings"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/grafana/loki/operator/internal/external/k8s"
"github.com/grafana/loki/operator/internal/manifests/storage"
)
var (
errMissingEndpointSlices = errors.New("no endpoint slices found for target object storage service")
errMissingWantedPort = errors.New("couldn't resolve object storage service port to target Pod port")
)
func ServicePortToPodPort(ctx context.Context, log logr.Logger, k k8s.Client, objStore storage.Options) ([]int32, error) {
if objStore.S3 == nil {
return []int32{}, nil
}
endpoint := objStore.S3.Endpoint
// Check if endpoint is a Kubernetes Service DNS name
if !strings.Contains(endpoint, ".svc") {
return []int32{}, nil
}
serviceName, namespace, endpointPort, https := parseServiceEndpoint(endpoint)
if serviceName == "" || namespace == "" {
return []int32{}, nil // We do not error as the endpoint might not point to a Kubernetes Service
}
service := &corev1.Service{}
if err := k.Get(ctx, client.ObjectKey{Name: serviceName, Namespace: namespace}, service); err != nil {
log.Info("failed to get Service for object storage", "service", serviceName, "namespace", namespace)
return []int32{}, nil
}
// List EndpointSlices for the service using the standard label
endpointSlices := &discoveryv1.EndpointSliceList{}
if err := k.List(ctx, endpointSlices, client.InNamespace(namespace), client.MatchingLabels{discoveryv1.LabelServiceName: serviceName}); err != nil {
log.Error(err, "failed to list endpoint slices for target object storage service", "service", serviceName, "namespace", namespace)
return []int32{}, err
}
if len(endpointSlices.Items) == 0 {
log.Error(errMissingEndpointSlices, "found no endpoint slices for service", "service", serviceName, "namespace", namespace)
return []int32{}, errMissingEndpointSlices
}
var targetPort int32
wantedPort := int32(80)
if https {
wantedPort = 443
}
if endpointPort > 0 {
wantedPort = endpointPort // Override the wantedPort if specified in the endpoint
for _, slice := range endpointSlices.Items {
for _, p := range slice.Ports {
if p.Port != nil && *p.Port == endpointPort {
return []int32{*p.Port}, nil // If svc and pod have the same port then return it directly
}
}
}
}
targetPort = resolveTargetPort(service, endpointSlices, wantedPort)
if targetPort == 0 {
return []int32{}, errMissingWantedPort
}
return []int32{targetPort}, nil
}
func parseServiceEndpoint(endpoint string) (string, string, int32, bool) {
https := strings.HasPrefix(endpoint, "https://")
var host string
var portStr string
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
parsedURL, err := url.Parse(endpoint)
if err != nil {
return "", "", 0, false
}
host = parsedURL.Hostname()
portStr = parsedURL.Port()
} else {
// Bare hostname:port format
host = endpoint
sHost, sPort, err := net.SplitHostPort(endpoint)
if err == nil {
host, portStr = sHost, sPort
}
}
parts := strings.Split(host, ".")
if len(parts) < 3 {
return "", "", 0, false
}
serviceName := parts[0]
namespace := parts[1]
var port int32
if portStr != "" {
p, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return "", "", 0, false
}
port = int32(p)
}
return serviceName, namespace, port, https
}
func resolveTargetPort(service *corev1.Service, endpointSlices *discoveryv1.EndpointSliceList, endpointPort int32) int32 {
for _, svcPort := range service.Spec.Ports {
if svcPort.Port == endpointPort {
for _, slice := range endpointSlices.Items {
for _, p := range slice.Ports {
switch svcPort.TargetPort.Type {
case intstr.Int:
if p.Port != nil && *p.Port == svcPort.TargetPort.IntVal {
return *p.Port
}
case intstr.String:
if p.Name != nil && *p.Name == svcPort.TargetPort.StrVal {
return *p.Port
}
}
}
}
return 0
}
}
return 0
}

View File

@@ -0,0 +1,366 @@
package networkpolicy
import (
"context"
"testing"
"github.com/go-logr/logr"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
"k8s.io/apimachinery/pkg/apis/meta/internalversion/scheme"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"github.com/grafana/loki/operator/internal/manifests/storage"
)
var (
_ = corev1.AddToScheme(scheme.Scheme)
_ = discoveryv1.AddToScheme(scheme.Scheme)
)
func TestServicePortToPodPort(t *testing.T) {
for _, tt := range []struct {
name string
endpoint string
expectedPort int32
expectedError bool
}{
{
name: "shortest svc endpoint without port",
endpoint: "minio.test.svc",
expectedPort: 8080,
},
{
name: "svc endpoint without port",
endpoint: "minio.test.svc.cluster.local",
expectedPort: 8080,
},
{
name: "https shortest svc endpoint",
endpoint: "https://minio.test.svc",
expectedPort: 6443,
},
{
name: "https svc endpoint with port",
endpoint: "https://minio.test.svc.cluster.local:443",
expectedPort: 6443,
},
{
name: "https svc endpoint with name",
endpoint: "https://minio.test.svc.cluster.local:444",
expectedPort: 6443,
},
{
name: "https svc endpoint with invalid port",
endpoint: "https://minio.test.svc.cluster.local:9999",
expectedPort: 9999,
expectedError: true,
},
} {
t.Run(tt.name, func(t *testing.T) {
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "minio",
Namespace: "test",
},
Spec: corev1.ServiceSpec{
Ports: []corev1.ServicePort{
{
Port: 443,
TargetPort: intstr.IntOrString{
Type: intstr.Int,
IntVal: 6443,
},
},
{
Port: 444,
TargetPort: intstr.IntOrString{
Type: intstr.String,
StrVal: "https",
},
},
{
Port: 80,
TargetPort: intstr.IntOrString{
Type: intstr.Int,
IntVal: 8080,
},
},
},
},
}
endpointSlice := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "minio-endpoint-slice",
Namespace: "test",
Labels: map[string]string{
discoveryv1.LabelServiceName: "minio",
},
},
Ports: []discoveryv1.EndpointPort{
{
Port: ptr.To(int32(8080)),
},
{
Port: ptr.To(int32(6443)),
Name: ptr.To("https"),
},
},
}
k := fake.NewClientBuilder().WithObjects(service, endpointSlice).
WithScheme(scheme.Scheme).
Build()
gotPorts, err := ServicePortToPodPort(context.Background(), logr.Discard(), k, storage.Options{
S3: &storage.S3StorageConfig{
Endpoint: tt.endpoint,
},
})
if tt.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, []int32{tt.expectedPort}, gotPorts)
})
}
}
func TestParseServiceEndpoint(t *testing.T) {
for _, tt := range []struct {
name string
endpoint string
expectedServiceName string
expectedNamespace string
expectedPort int32
expectedHTTPS bool
}{
{
name: "shortest svc endpoint without port",
endpoint: "minio.test.svc",
expectedServiceName: "minio",
expectedNamespace: "test",
expectedPort: 0,
expectedHTTPS: false,
},
{
name: "shortest svc endpoint with port",
endpoint: "minio.test.svc:443",
expectedServiceName: "minio",
expectedNamespace: "test",
expectedPort: 443,
expectedHTTPS: false,
},
{
name: "svc endpoint with port",
endpoint: "minio.test.svc.cluster.local:443",
expectedServiceName: "minio",
expectedNamespace: "test",
expectedPort: 443,
expectedHTTPS: false,
},
{
name: "http svc endpoint without port",
endpoint: "http://minio.test.svc.cluster.local",
expectedServiceName: "minio",
expectedNamespace: "test",
expectedPort: 0,
expectedHTTPS: false,
},
{
name: "https svc endpoint without port",
endpoint: "https://minio.test.svc.cluster.local",
expectedServiceName: "minio",
expectedNamespace: "test",
expectedPort: 0,
expectedHTTPS: true,
},
{
name: "https svc endpoint with port",
endpoint: "https://minio.test.svc.cluster.local:443",
expectedServiceName: "minio",
expectedNamespace: "test",
expectedPort: 443,
expectedHTTPS: true,
},
{
name: "shortest https svc endpoint with port",
endpoint: "https://minio.test.svc:443",
expectedServiceName: "minio",
expectedNamespace: "test",
expectedPort: 443,
expectedHTTPS: true,
},
} {
t.Run(tt.name, func(t *testing.T) {
gotServiceName, gotNamespace, gotPort, gotHTTPS := parseServiceEndpoint(tt.endpoint)
require.Equal(t, tt.expectedServiceName, gotServiceName)
require.Equal(t, tt.expectedNamespace, gotNamespace)
require.Equal(t, tt.expectedPort, gotPort)
require.Equal(t, tt.expectedHTTPS, gotHTTPS)
})
}
}
func TestResolveTargetPort(t *testing.T) {
for _, tt := range []struct {
name string
service *corev1.Service
slices *discoveryv1.EndpointSliceList
endpointPort int32
expectedPort int32
}{
{
name: "simple matching port",
service: &corev1.Service{
Spec: corev1.ServiceSpec{
Ports: []corev1.ServicePort{
{
Port: 80,
TargetPort: intstr.IntOrString{
Type: intstr.Int,
IntVal: 80,
},
},
},
},
},
slices: &discoveryv1.EndpointSliceList{
Items: []discoveryv1.EndpointSlice{
{
Ports: []discoveryv1.EndpointPort{
{Port: ptr.To(int32(80))},
},
},
},
},
endpointPort: 80,
expectedPort: 80,
},
{
name: "targetPort diff from svc port but matching port",
service: &corev1.Service{
Spec: corev1.ServiceSpec{
Ports: []corev1.ServicePort{
{
Port: 80,
TargetPort: intstr.IntOrString{
Type: intstr.Int,
IntVal: 8080,
},
},
},
},
},
slices: &discoveryv1.EndpointSliceList{
Items: []discoveryv1.EndpointSlice{
{
Ports: []discoveryv1.EndpointPort{
{Port: ptr.To(int32(8080))},
},
},
},
},
endpointPort: 80,
expectedPort: 8080,
},
{
name: "targetPort diff from svc port but matching name",
service: &corev1.Service{
Spec: corev1.ServiceSpec{
Ports: []corev1.ServicePort{
{
Port: 80,
TargetPort: intstr.IntOrString{
Type: intstr.String,
StrVal: "http",
},
},
},
},
},
slices: &discoveryv1.EndpointSliceList{
Items: []discoveryv1.EndpointSlice{
{
Ports: []discoveryv1.EndpointPort{
{
Port: ptr.To(int32(8080)),
Name: ptr.To("http"),
},
},
},
},
},
endpointPort: 80,
expectedPort: 8080,
},
{
name: "no matching port or name",
service: &corev1.Service{
Spec: corev1.ServiceSpec{
Ports: []corev1.ServicePort{
{
Port: 80,
TargetPort: intstr.IntOrString{
Type: intstr.Int,
IntVal: 443,
},
},
},
},
},
slices: &discoveryv1.EndpointSliceList{
Items: []discoveryv1.EndpointSlice{
{
Ports: []discoveryv1.EndpointPort{
{
Port: ptr.To(int32(8080)),
Name: ptr.To("http"),
},
},
},
},
},
endpointPort: 80,
expectedPort: 0,
},
{
name: "no matching service port",
service: &corev1.Service{
Spec: corev1.ServiceSpec{
Ports: []corev1.ServicePort{
{
Port: 443,
TargetPort: intstr.IntOrString{
Type: intstr.Int,
IntVal: 6443,
},
},
},
},
},
slices: &discoveryv1.EndpointSliceList{
Items: []discoveryv1.EndpointSlice{
{
Ports: []discoveryv1.EndpointPort{
{Port: ptr.To(int32(6443))},
},
},
},
},
endpointPort: 80,
expectedPort: 0,
},
} {
t.Run(tt.name, func(t *testing.T) {
got := resolveTargetPort(tt.service, tt.slices, tt.endpointPort)
require.Equal(t, tt.expectedPort, got)
})
}
}

View File

@@ -133,6 +133,12 @@ func CreateOrUpdateLokiStack(
networkPolicyRuleSet := lokiv1.NetworkPolicyRuleSetNone
if stack.Spec.NetworkPolicies != nil {
networkPolicyRuleSet = stack.Spec.NetworkPolicies.RuleSet
ports, optErr := networkpolicy.ServicePortToPodPort(ctx, ll, k, objStore)
if optErr != nil {
return nil, optErr
}
opts.NetworkPolicyObjStorePorts = ports
}
tlsProfileType := configv1.TLSProfileType(fg.TLSProfile)

View File

@@ -326,9 +326,13 @@ func buildLokiAllowGatewayIngress(opts Options) *networkingv1.NetworkPolicy {
// components that need to access object storage to object storage
func buildLokiAllowBucketEgress(opts Options) *networkingv1.NetworkPolicy {
objstorePort := []int32{443} // Default HTTPS port
if ports := getEndpointPort(opts.ObjectStorage, opts.Gates.OpenShift.Enabled); len(ports) > 0 {
objstorePort = ports
switch {
case len(opts.NetworkPolicyObjStorePorts) > 0:
objstorePort = opts.NetworkPolicyObjStorePorts
default:
if ports := getEndpointPort(opts.ObjectStorage, opts.Gates.OpenShift.Enabled); len(ports) > 0 {
objstorePort = ports
}
}
if opts.Stack.Proxy != nil {

View File

@@ -41,6 +41,8 @@ type Options struct {
Tenants Tenants
TLSProfile TLSProfileSpec
NetworkPolicyObjStorePorts []int32
}
// GatewayTimeoutConfig contains the http server configuration options for all Loki components.