Repository: metallb/metallb
Pull request #1417 (2022-06-07 12:50:21)

Add support for LoadBalancerClass
Loadbalancer class enable different loadbalancers to co-exist. We add a parameter to the speaker and the controller to process only the services consistent with the loadbalancer class provided to metallb.

Fixes https://github.com/metallb/metallb/issues/685 and https://github.com/metallb/metallb/issues/996


Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b60588f1..8371223c 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -245,7 +245,7 @@ jobs:
         with:
           repository: metallb/metallb-operator
           path: metallboperator
-          ref: e48fa81670c63807f338bed9b38377d9d436d4d5
+          ref: 705b767ef27731b89c851fdb762d2cc00d5136e2
 
       - name: Create multi-node K8s Kind Cluster
         run: |
diff --git a/charts/metallb/README.md b/charts/metallb/README.md
index 7e735531..0f421bb2 100644
--- a/charts/metallb/README.md
+++ b/charts/metallb/README.md
@@ -45,6 +45,7 @@ A network load-balancer implementation for Kubernetes using standard routing pro
 | controller.tolerations | list | `[]` |  |
 | fullnameOverride | string | `""` |  |
 | imagePullSecrets | list | `[]` |  |
+| loadBalancerClass | string | `""` |  |
 | nameOverride | string | `""` |  |
 | prometheus.metricsPort | int | `7472` |  |
 | prometheus.namespace | string | `""` |  |
diff --git a/charts/metallb/templates/controller.yaml b/charts/metallb/templates/controller.yaml
index 5171f08c..c93219b6 100644
--- a/charts/metallb/templates/controller.yaml
+++ b/charts/metallb/templates/controller.yaml
@@ -49,6 +49,9 @@ spec:
         - --log-level={{ . }}
         {{- end }}
         - --cert-service-name={{ template "metallb.fullname" . }}-webhook-service
+        {{- if .Values.loadBalancerClass }}
+        - --lb-class={{ .Values.loadBalancerClass }}
+        {{- end }}
         env:
         {{- if and .Values.speaker.enabled .Values.speaker.memberlist.enabled }}
         - name: METALLB_ML_SECRET_NAME
diff --git a/charts/metallb/templates/speaker.yaml b/charts/metallb/templates/speaker.yaml
index cfa9f5c8..cfbf65d2 100644
--- a/charts/metallb/templates/speaker.yaml
+++ b/charts/metallb/templates/speaker.yaml
@@ -196,6 +196,9 @@ spec:
         {{- with .Values.speaker.logLevel }}
         - --log-level={{ . }}
         {{- end }}
+        {{- if .Values.loadBalancerClass }}
+        - --lb-class={{ .Values.loadBalancerClass }}
+        {{- end }}
         env:
         - name: METALLB_NODE_NAME
           valueFrom:
diff --git a/charts/metallb/values.schema.json b/charts/metallb/values.schema.json
index 6b305dda..4c158adb 100644
--- a/charts/metallb/values.schema.json
+++ b/charts/metallb/values.schema.json
@@ -156,6 +156,9 @@
       "description": "MetalLB configuration",
       "type": "object"
     },
+    "loadBalancerClass": {
+      "type":"string"
+    },
     "rbac": {
       "description": "RBAC configuration",
       "type": "object",
diff --git a/charts/metallb/values.yaml b/charts/metallb/values.yaml
index ecbfb262..16a69898 100644
--- a/charts/metallb/values.yaml
+++ b/charts/metallb/values.yaml
@@ -5,6 +5,7 @@
 imagePullSecrets: []
 nameOverride: ""
 fullnameOverride: ""
+loadBalancerClass: ""
 
 # To configure MetalLB, you must specify ONE of the following two
 # options.
diff --git a/controller/main.go b/controller/main.go
index 5bc43624..551788fb 100644
--- a/controller/main.go
+++ b/controller/main.go
@@ -127,6 +127,7 @@ func main() {
 		disableCertRotation = flag.Bool("disable-cert-rotation", false, "disable automatic generation and rotation of webhook TLS certificates/keys")
 		certDir             = flag.String("cert-dir", "/tmp/k8s-webhook-server/serving-certs", "The directory where certs are stored")
 		certServiceName     = flag.String("cert-service-name", "webhook-service", "The service name used to generate the TLS cert's hostname")
+		loadBalancerClass   = flag.String("lb-class", "", "load balancer class. When enabled, metallb will handle only services whose spec.loadBalancerClass matches the given lb class")
 	)
 	flag.Parse()
 
@@ -175,6 +176,7 @@ func main() {
 		DisableCertRotation: *disableCertRotation,
 		CertDir:             *certDir,
 		CertServiceName:     *certServiceName,
+		LoadBalancerClass:   *loadBalancerClass,
 	})
 	if err != nil {
 		level.Error(logger).Log("op", "startup", "error", err, "msg", "failed to create k8s client")
diff --git a/internal/k8s/controllers/service_controller.go b/internal/k8s/controllers/service_controller.go
index 4172adb0..64a2fbdc 100644
--- a/internal/k8s/controllers/service_controller.go
+++ b/internal/k8s/controllers/service_controller.go
@@ -40,12 +40,13 @@ import (
 
 type ServiceReconciler struct {
 	client.Client
-	Logger    log.Logger
-	Scheme    *runtime.Scheme
-	Namespace string
-	Handler   func(log.Logger, string, *v1.Service, epslices.EpsOrSlices) SyncState
-	Endpoints NeedEndPoints
-	Reload    chan event.GenericEvent
+	Logger            log.Logger
+	Scheme            *runtime.Scheme
+	Namespace         string
+	Handler           func(log.Logger, string, *v1.Service, epslices.EpsOrSlices) SyncState
+	Endpoints         NeedEndPoints
+	LoadBalancerClass string
+	Reload            chan event.GenericEvent
 }
 
 func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -67,6 +68,11 @@ func (r *ServiceReconciler) reconcileService(ctx context.Context, req ctrl.Reque
 		return ctrl.Result{}, err
 	}
 
+	if filterByLoadBalancerClass(service, r.LoadBalancerClass) {
+		level.Debug(r.Logger).Log("controller", "ServiceReconciler", "filtered service", req.NamespacedName)
+		return ctrl.Result{}, nil
+	}
+
 	epSlices, err := epsOrSlicesForServices(ctx, r, req.NamespacedName, r.Endpoints)
 	if err != nil {
 		level.Error(r.Logger).Log("controller", "ServiceReconciler", "message", "failed to get endpoints", "service", req.NamespacedName, "error", err)
@@ -132,7 +138,6 @@ func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
 				})).
 			Watches(&source.Channel{Source: r.Reload}, &handler.EnqueueRequestForObject{}).
 			Complete(r)
-
 	}
 
 	return ctrl.NewControllerManagedBy(mgr).
@@ -152,3 +157,22 @@ func (r *ServiceReconciler) serviceFor(ctx context.Context, name types.Namespace
 	}
 	return &res, nil
 }
+
+func filterByLoadBalancerClass(service *v1.Service, loadBalancerClass string) bool {
+	// When receiving a delete, we can't make logic on the service so we
+	// rely on the application logic that will receive a delete on a service it
+	// did not handle and discard it.
+	if service == nil {
+		return false
+	}
+	if service.Spec.LoadBalancerClass == nil && loadBalancerClass != "" {
+		return true
+	}
+	if service.Spec.LoadBalancerClass == nil && loadBalancerClass == "" {
+		return false
+	}
+	if *service.Spec.LoadBalancerClass != loadBalancerClass {
+		return true
+	}
+	return false
+}
diff --git a/internal/k8s/controllers/service_controller_reload.go b/internal/k8s/controllers/service_controller_reload.go
index 65f69805..9f537359 100644
--- a/internal/k8s/controllers/service_controller_reload.go
+++ b/internal/k8s/controllers/service_controller_reload.go
@@ -69,6 +69,11 @@ func (r *ServiceReconciler) reprocessAll(ctx context.Context, req ctrl.Request)
 
 	retry := false
 	for _, service := range services.Items {
+		if filterByLoadBalancerClass(&service, r.LoadBalancerClass) {
+			level.Debug(r.Logger).Log("controller", "ServiceReconciler", "filtered service", req.NamespacedName)
+			continue
+		}
+
 		serviceName := types.NamespacedName{Namespace: service.Namespace, Name: service.Name}
 		eps, err := epsOrSlicesForServices(ctx, r, serviceName, r.Endpoints)
 		if err != nil {
diff --git a/internal/k8s/k8s.go b/internal/k8s/k8s.go
index c9a3808e..10de6999 100644
--- a/internal/k8s/k8s.go
+++ b/internal/k8s/k8s.go
@@ -109,6 +109,7 @@ type Config struct {
 	DisableCertRotation bool
 	CertDir             string
 	CertServiceName     string
+	LoadBalancerClass   string
 	Listener
 }
 
@@ -245,12 +246,13 @@ func New(cfg *Config) (*Client, error) {
 
 	if cfg.ServiceChanged != nil {
 		if err = (&controllers.ServiceReconciler{
-			Client:    mgr.GetClient(),
-			Logger:    cfg.Logger,
-			Scheme:    mgr.GetScheme(),
-			Handler:   cfg.ServiceHandler,
-			Endpoints: needEndpoints,
-			Reload:    reloadChan,
+			Client:            mgr.GetClient(),
+			Logger:            cfg.Logger,
+			Scheme:            mgr.GetScheme(),
+			Handler:           cfg.ServiceHandler,
+			Endpoints:         needEndpoints,
+			Reload:            reloadChan,
+			LoadBalancerClass: cfg.LoadBalancerClass,
 		}).SetupWithManager(mgr); err != nil {
 			level.Error(c.logger).Log("error", err, "unable to create controller", "service")
 			return nil, errors.Wrap(err, "failed to create service reconciler")
diff --git a/speaker/main.go b/speaker/main.go
index 4d4f031e..63b79c66 100644
--- a/speaker/main.go
+++ b/speaker/main.go
@@ -62,17 +62,18 @@ func main() {
 	prometheus.MustRegister(announcing)
 
 	var (
-		namespace       = flag.String("namespace", os.Getenv("METALLB_NAMESPACE"), "config file and speakers namespace")
-		host            = flag.String("host", os.Getenv("METALLB_HOST"), "HTTP host address")
-		mlBindAddr      = flag.String("ml-bindaddr", os.Getenv("METALLB_ML_BIND_ADDR"), "Bind addr for MemberList (fast dead node detection)")
-		mlBindPort      = flag.String("ml-bindport", os.Getenv("METALLB_ML_BIND_PORT"), "Bind port for MemberList (fast dead node detection)")
-		mlLabels        = flag.String("ml-labels", os.Getenv("METALLB_ML_LABELS"), "Labels to match the speakers (for MemberList / fast dead node detection)")
-		mlSecret        = flag.String("ml-secret-key", os.Getenv("METALLB_ML_SECRET_KEY"), "Secret key for MemberList (fast dead node detection)")
-		myNode          = flag.String("node-name", os.Getenv("METALLB_NODE_NAME"), "name of this Kubernetes node (spec.nodeName)")
-		port            = flag.Int("port", 7472, "HTTP listening port")
-		logLevel        = flag.String("log-level", "info", fmt.Sprintf("log level. must be one of: [%s]", logging.Levels.String()))
-		disableEpSlices = flag.Bool("disable-epslices", false, "Disable the usage of EndpointSlices and default to Endpoints instead of relying on the autodiscovery mechanism")
-		enablePprof     = flag.Bool("enable-pprof", false, "Enable pprof profiling")
+		namespace         = flag.String("namespace", os.Getenv("METALLB_NAMESPACE"), "config file and speakers namespace")
+		host              = flag.String("host", os.Getenv("METALLB_HOST"), "HTTP host address")
+		mlBindAddr        = flag.String("ml-bindaddr", os.Getenv("METALLB_ML_BIND_ADDR"), "Bind addr for MemberList (fast dead node detection)")
+		mlBindPort        = flag.String("ml-bindport", os.Getenv("METALLB_ML_BIND_PORT"), "Bind port for MemberList (fast dead node detection)")
+		mlLabels          = flag.String("ml-labels", os.Getenv("METALLB_ML_LABELS"), "Labels to match the speakers (for MemberList / fast dead node detection)")
+		mlSecret          = flag.String("ml-secret-key", os.Getenv("METALLB_ML_SECRET_KEY"), "Secret key for MemberList (fast dead node detection)")
+		myNode            = flag.String("node-name", os.Getenv("METALLB_NODE_NAME"), "name of this Kubernetes node (spec.nodeName)")
+		port              = flag.Int("port", 7472, "HTTP listening port")
+		logLevel          = flag.String("log-level", "info", fmt.Sprintf("log level. must be one of: [%s]", logging.Levels.String()))
+		disableEpSlices   = flag.Bool("disable-epslices", false, "Disable the usage of EndpointSlices and default to Endpoints instead of relying on the autodiscovery mechanism")
+		enablePprof       = flag.Bool("enable-pprof", false, "Enable pprof profiling")
+		loadBalancerClass = flag.String("lb-class", "", "load balancer class. When enabled, metallb will handle only services whose spec.loadBalancerClass matches the given lb class")
 	)
 	flag.Parse()
 
@@ -158,7 +159,8 @@ func main() {
 			ConfigChanged:  ctrl.SetConfig,
 			NodeChanged:    ctrl.SetNode,
 		},
-		ValidateConfig: validateConfig,
+		ValidateConfig:    validateConfig,
+		LoadBalancerClass: *loadBalancerClass,
 	})
 	if err != nil {
 		level.Error(logger).Log("op", "startup", "error", err, "msg", "failed to create k8s client")
diff --git a/website/content/installation/_index.md b/website/content/installation/_index.md
index d6069f86..ba80a05c 100644
--- a/website/content/installation/_index.md
+++ b/website/content/installation/_index.md
@@ -187,3 +187,11 @@ described above.
 Please take the known limitations for [layer2](https://metallb.universe.tf/concepts/layer2/#limitations)
 and [bgp](https://metallb.universe.tf/concepts/bgp/#limitations) into account when performing an
 upgrade.
+
+## Setting the LoadBalancer Class
+
+MetalLB supports [LoadBalancerClass](https://kubernetes.io/docs/concepts/services-networking/service/#load-balancer-class),
+which allows multiple load balancer implementations to co-exist. In order to set the loadbalancer class MetalLB should be listening
+for, the `--lb-class=<CLASS_NAME>` parameter must be provided to both the speaker and the controller.
+
+The helm charts support it via the `loadBalancerClass` parameter.


Test patch:
diff --git a/e2etest/l2tests/loadbalancer_class.go b/e2etest/l2tests/loadbalancer_class.go
new file mode 100644
index 00000000..3899047d
--- /dev/null
+++ b/e2etest/l2tests/loadbalancer_class.go
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package l2tests
+
+import (
+	"time"
+
+	"github.com/onsi/ginkgo"
+	"github.com/onsi/gomega"
+	metallbv1beta1 "go.universe.tf/metallb/api/v1beta1"
+	"go.universe.tf/metallb/e2etest/pkg/k8s"
+	"go.universe.tf/metallb/e2etest/pkg/service"
+	internalconfig "go.universe.tf/metallb/internal/config"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	clientset "k8s.io/client-go/kubernetes"
+	"k8s.io/kubernetes/test/e2e/framework"
+	e2eservice "k8s.io/kubernetes/test/e2e/framework/service"
+	admissionapi "k8s.io/pod-security-admission/api"
+)
+
+var _ = ginkgo.Describe("LoadBalancer class", func() {
+	var cs clientset.Interface
+
+	var f *framework.Framework
+	ginkgo.AfterEach(func() {
+		if ginkgo.CurrentGinkgoTestDescription().Failed {
+			k8s.DumpInfo(Reporter, ginkgo.CurrentGinkgoTestDescription().TestText)
+		}
+
+		// Clean previous configuration.
+		err := ConfigUpdater.Clean()
+		framework.ExpectNoError(err)
+	})
+
+	f = framework.NewDefaultFramework("lbclass")
+	f.NamespacePodSecurityEnforceLevel = admissionapi.LevelPrivileged
+
+	ginkgo.BeforeEach(func() {
+		cs = f.ClientSet
+
+		ginkgo.By("Clearing any previous configuration")
+		err := ConfigUpdater.Clean()
+		framework.ExpectNoError(err)
+	})
+
+	ginkgo.Context("A service with loadbalancer class", func() {
+		ginkgo.It("should not get an ip", func() {
+			resources := internalconfig.ClusterResources{
+				Pools: []metallbv1beta1.IPAddressPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "l2-test",
+						},
+						Spec: metallbv1beta1.IPAddressPoolSpec{
+							Addresses: []string{
+								IPV4ServiceRange,
+								IPV6ServiceRange},
+						},
+					},
+				},
+				L2Advs: []metallbv1beta1.L2Advertisement{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "empty",
+						},
+					},
+				},
+			}
+			err := ConfigUpdater.Update(resources)
+			framework.ExpectNoError(err)
+
+			jig := e2eservice.NewTestJig(cs, f.Namespace.Name, "lbclass")
+			svc, err := jig.CreateLoadBalancerService(10*time.Second, service.WithLoadbalancerClass("foo"))
+			gomega.Expect(err).Should(gomega.MatchError(gomega.ContainSubstring("timed out waiting for the condition")))
+			gomega.Expect(svc).To(gomega.BeNil())
+		})
+	})
+})
diff --git a/e2etest/pkg/service/tweak.go b/e2etest/pkg/service/tweak.go
index 3505b4aa..91b173b6 100644
--- a/e2etest/pkg/service/tweak.go
+++ b/e2etest/pkg/service/tweak.go
@@ -5,6 +5,7 @@ package service
 import (
 	"strings"
 
+	"go.universe.tf/metallb/internal/pointer"
 	corev1 "k8s.io/api/core/v1"
 )
 
@@ -46,3 +47,9 @@ func WithSpecificPool(poolName string) func(*corev1.Service) {
 		}
 	}
 }
+
+func WithLoadbalancerClass(loadBalancerClass string) func(*corev1.Service) {
+	return func(svc *corev1.Service) {
+		svc.Spec.LoadBalancerClass = pointer.StrPtr(loadBalancerClass)
+	}
+}
diff --git a/internal/k8s/controllers/service_controller_test.go b/internal/k8s/controllers/service_controller_test.go
index 983f0abf..12618e32 100644
--- a/internal/k8s/controllers/service_controller_test.go
+++ b/internal/k8s/controllers/service_controller_test.go
@@ -25,6 +25,7 @@ import (
 	"github.com/go-kit/log"
 	"github.com/google/go-cmp/cmp"
 	"go.universe.tf/metallb/internal/k8s/epslices"
+	"go.universe.tf/metallb/internal/pointer"
 	corev1 "k8s.io/api/core/v1"
 	discovery "k8s.io/api/discovery/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -252,3 +253,56 @@ func TestServiceController(t *testing.T) {
 		}
 	}
 }
+
+func TestLBClass(t *testing.T) {
+
+	tests := []struct {
+		desc           string
+		serviceLBClass *string
+		metallLBClass  string
+		shouldFilter   bool
+	}{
+		{
+			desc:           "Empty serviceclass, metallb default",
+			serviceLBClass: nil,
+			metallLBClass:  "",
+			shouldFilter:   false,
+		},
+		{
+			desc:           "Empty serviceclass, metallb specific",
+			serviceLBClass: nil,
+			metallLBClass:  "foo",
+			shouldFilter:   true,
+		},
+		{
+			desc:           "Set serviceclass, metallb default",
+			serviceLBClass: pointer.StrPtr("foo"),
+			metallLBClass:  "",
+			shouldFilter:   true,
+		},
+		{
+			desc:           "Set serviceclass, metallb different",
+			serviceLBClass: pointer.StrPtr("foo"),
+			metallLBClass:  "bar",
+			shouldFilter:   true,
+		},
+		{
+			desc:           "Set serviceclass, metallb same",
+			serviceLBClass: pointer.StrPtr("foo"),
+			metallLBClass:  "foo",
+			shouldFilter:   false,
+		},
+	}
+	for _, test := range tests {
+		svc := &corev1.Service{
+			Spec: corev1.ServiceSpec{
+				LoadBalancerClass: test.serviceLBClass,
+			},
+		}
+		filters := filterByLoadBalancerClass(svc, test.metallLBClass)
+		if filters != test.shouldFilter {
+			t.Errorf("test %s failed: expected filter: %v, got: %v",
+				test.desc, test.shouldFilter, filters)
+		}
+	}
+}