diff --git a/docs/openstack-cloud-controller-manager/expose-applications-using-loadbalancer-type-service.md b/docs/openstack-cloud-controller-manager/expose-applications-using-loadbalancer-type-service.md index 5fa8577006..0866043ce8 100644 --- a/docs/openstack-cloud-controller-manager/expose-applications-using-loadbalancer-type-service.md +++ b/docs/openstack-cloud-controller-manager/expose-applications-using-loadbalancer-type-service.md @@ -198,6 +198,22 @@ Request Body: Defines the health monitor retry count for the loadbalancer pool members to be marked down. +- `loadbalancer.openstack.org/metrics-enable` + + If 'true', enable the Prometheus listener on the loadbalancer. (default: 'false') + + The Kubernetes service must be the [owner of the LoadBalancer](#sharing-load-balancer-with-multiple-services) + + Not supported when `lb-provider=ovn` is configured in openstack-cloud-controller-manager. + +- `loadbalancer.openstack.org/metrics-port` + + Defines the Prometheus listener's port. If `metric-enable` is 'true', the annotation is automatically added to the service. Default: `9100` + +- `loadbalancer.openstack.org/metrics-allow-cidrs` + + Defines the Prometheus listener's allowed cirds. __Warning__: [security recommendations](#metric-listener-allowed-cird-security-recommendation). Default: none + - `loadbalancer.openstack.org/flavor-id` The id of the flavor that is used for creating the loadbalancer. @@ -236,6 +252,10 @@ Request Body: This annotation is automatically added and it contains the floating ip address of the load balancer service. When using `loadbalancer.openstack.org/hostname` annotation it is the only place to see the real address of the load balancer. +- `loadbalancer.openstack.org/load-balancer-vip-address` + + This annotation is automatically added and it contains the Octavia's Virtual-IP (VIP). + - `loadbalancer.openstack.org/node-selector` A set of key=value annotations used to filter nodes for targeting by the load balancer. When defined, only nodes that match all the specified key=value annotations will be targeted. If an annotation includes only a key without a value, the filter will check only for the existence of the key on the node. If the value is not set, the `node-selector` value defined in the OCCM configuration is applied. @@ -628,3 +648,64 @@ is not yet supported by OCCM. Internally, OCCM would automatically look for IPv4 or IPv6 subnet to allocate the load balancer address from based on the service's address family preference. If the subnet with preferred address family is not available, load balancer can not be created. + +### Metric endpoint configuration + +Since Octavia v2.25, Octavia proposes to expose an HTTP Prometheus endpoint. Using the annotation `loadbalancer.openstack.org/metrics-enable`, you will be able to configure this endpoint on the LoadBalancer: + +```yaml +kind: Service +apiVersion: v1 +metadata: + name: service-with-metric + namespace: default + annotations: + loadbalancer.openstack.org/metrics-enable: "true" # Enable the listener endpoint on the Octavia LoadBalancer (default false) + loadbalancer.openstack.org/metrics-port: "9100" # Listener's port (default 9100) + loadbalancer.openstack.org/metrics-allow-cidrs: "10.0.0.0/8, fe80::/10" # Listener's allowed cidrs (default none) +spec: + type: LoadBalancer +``` + +Then, you can configure a Prometheus scrapper like to get metrics from the LoadBalancer. + +e.g. Prometheus Operator configuration: + +```yaml +apiVersion: monitoring.coreos.com/v1alpha1 +kind: ScrapeConfig +metadata: + name: octavia-sd-config + labels: + release: prometheus # adapt it to your Prometheus deployment configuration +spec: + kubernetesSDConfigs: + - role: Service + relabelings: + - sourceLabels: [__meta_kubernetes_namespace] + targetLabel: namespace + action: replace + - sourceLabels: [__meta_kubernetes_service_name] + targetLabel: job + action: replace + - sourceLabels: + - __meta_kubernetes_service_annotation_loadbalancer_openstack_org_load_balancer_vip_address + - __meta_kubernetes_service_annotation_loadbalancer_openstack_org_metrics_port + separator: ":" + targetLabel: __address__ + action: replace + - sourceLabels: + - __meta_kubernetes_service_annotation_loadbalancer_openstack_org_metrics_enable + regex: "true" + action: keep +``` + +> This configuration use the `loadbalancer.openstack.org/load-balancer-vip-address` annotation that will use the Octavia's VIP to fetch the metric endpoint. Adapt it to your Octavia deployment. + +For more information: https://docs.openstack.org/octavia/latest/user/guides/monitoring.html#monitoring-with-prometheus + +Grafana dashboard for Octavia Amphora: https://grafana.com/grafana/dashboards/15828-openstack-octavia-amphora-load-balancer/ + +#### Metric listener allowed CIRD security recommendation + +If the Octavia LoadBalancer is exposed with a public IP, the Prometheus listener is also exposed (at least for Amphora). Even if no critical data are exposed by this endpoint, __it's strongly recommended to apply an allowed cidrs on the listener__ via the annotation `loadbalancer.openstack.org/metrics-allow-cidrs`. diff --git a/go.mod b/go.mod index 5c139510de..74913b064b 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/spf13/viper v1.15.0 github.com/stretchr/testify v1.8.4 go.uber.org/goleak v1.3.0 + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 golang.org/x/sys v0.21.0 golang.org/x/term v0.21.0 google.golang.org/grpc v1.58.3 @@ -138,7 +139,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/crypto v0.24.0 // indirect - golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/oauth2 v0.10.0 // indirect golang.org/x/sync v0.7.0 // indirect diff --git a/pkg/openstack/events.go b/pkg/openstack/events.go index 60dd5cc649..998d629e79 100644 --- a/pkg/openstack/events.go +++ b/pkg/openstack/events.go @@ -23,4 +23,5 @@ const ( eventLBAZIgnored = "LoadBalancerAvailabilityZonesIgnored" eventLBFloatingIPSkipped = "LoadBalancerFloatingIPSkipped" eventLBRename = "LoadBalancerRename" + eventLBMetricListenerIgnored = "LoadBalancerMetricListenerIgnored" ) diff --git a/pkg/openstack/loadbalancer.go b/pkg/openstack/loadbalancer.go index 2a82cba807..6792cc82fc 100644 --- a/pkg/openstack/loadbalancer.go +++ b/pkg/openstack/loadbalancer.go @@ -71,6 +71,9 @@ const ( ServiceAnnotationLoadBalancerSubnetID = "loadbalancer.openstack.org/subnet-id" ServiceAnnotationLoadBalancerNetworkID = "loadbalancer.openstack.org/network-id" ServiceAnnotationLoadBalancerMemberSubnetID = "loadbalancer.openstack.org/member-subnet-id" + ServiceAnnotationLoadBalancerMetricsEnabled = "loadbalancer.openstack.org/metrics-enable" + ServiceAnnotationLoadBalancerMetricsPort = "loadbalancer.openstack.org/metrics-port" + ServiceAnnotationLoadBalancerMetricsAllowCidrs = "loadbalancer.openstack.org/metrics-allow-cidrs" ServiceAnnotationLoadBalancerTimeoutClientData = "loadbalancer.openstack.org/timeout-client-data" ServiceAnnotationLoadBalancerTimeoutMemberConnect = "loadbalancer.openstack.org/timeout-member-connect" ServiceAnnotationLoadBalancerTimeoutMemberData = "loadbalancer.openstack.org/timeout-member-data" @@ -87,6 +90,7 @@ const ( ServiceAnnotationLoadBalancerHealthMonitorMaxRetriesDown = "loadbalancer.openstack.org/health-monitor-max-retries-down" ServiceAnnotationLoadBalancerLoadbalancerHostname = "loadbalancer.openstack.org/hostname" ServiceAnnotationLoadBalancerAddress = "loadbalancer.openstack.org/load-balancer-address" + ServiceAnnotationLoadBalancerVIPAddress = "loadbalancer.openstack.org/load-balancer-vip-address" // revive:disable:var-naming ServiceAnnotationTlsContainerRef = "loadbalancer.openstack.org/default-tls-container-ref" // revive:enable:var-naming @@ -95,14 +99,15 @@ const ( ServiceAnnotationLoadBalancerID = "loadbalancer.openstack.org/load-balancer-id" // Octavia resources name formats - servicePrefix = "kube_service_" - lbFormat = "%s%s_%s_%s" - listenerPrefix = "listener_" - listenerFormat = listenerPrefix + "%d_%s" - poolPrefix = "pool_" - poolFormat = poolPrefix + "%d_%s" - monitorPrefix = "monitor_" - monitorFormat = monitorPrefix + "%d_%s" + servicePrefix = "kube_service_" + lbFormat = "%s%s_%s_%s" + listenerPrefix = "listener_" + listenerFormat = listenerPrefix + "%d_%s" + listenerFormatMetric = listenerPrefix + "metric_%s" + poolPrefix = "pool_" + poolFormat = poolPrefix + "%d_%s" + monitorPrefix = "monitor_" + monitorFormat = monitorPrefix + "%d_%s" ) // LbaasV2 is a LoadBalancer implementation based on Octavia @@ -142,6 +147,9 @@ type serviceConfig struct { healthMonitorTimeout int healthMonitorMaxRetries int healthMonitorMaxRetriesDown int + metricAllowedCIDRs []string + metricEnabled bool + metricPort int preferredIPFamily corev1.IPFamily // preferred (the first) IP family indicated in service's `spec.ipFamilies` } @@ -451,6 +459,32 @@ func getIntFromServiceAnnotation(service *corev1.Service, annotationKey string, return defaultSetting } +// getStringArrayFromServiceAnnotationSeparatedByComma searches a given v1.Service for a specific annotationKey +// and either returns the annotation's string array value (using comma as separator), or the specified defaultSetting. +// Each value of the array is TrimSpaced. After the trim, if the string is empty, remove it. +func getStringArrayFromServiceAnnotationSeparatedByComma(service *corev1.Service, annotationKey string, defaultSetting []string) []string { + klog.V(4).Infof("getStringArrayFromServiceAnnotationSeparatedByComma(%s/%s, %v, %q)", service.Namespace, service.Name, annotationKey, defaultSetting) + if annotationValue, ok := service.Annotations[annotationKey]; ok { + returnValue := []string{} + splitAnnotation := strings.FieldsFunc( // avoid empty string by using this func instead of strings.split() + annotationValue, func(r rune) bool { + return r == ',' + }, + ) + for _, value := range splitAnnotation { + trimmedValue := strings.TrimSpace(value) + if len(trimmedValue) == 0 { + continue + } + returnValue = append(returnValue, trimmedValue) + } + klog.V(4).Infof("Found a Service Annotation: %v = %q", annotationKey, returnValue) + return returnValue + } + klog.V(4).Infof("Could not find a Service Annotation; falling back to default setting: %v = %q", annotationKey, defaultSetting) + return defaultSetting +} + // getBoolFromServiceAnnotation searches a given v1.Service for a specific annotationKey and either returns the annotation's boolean value or a specified defaultSetting // If the annotation is not found or is not a valid boolean ("true" or "false"), it falls back to the defaultSetting and logs a message accordingly. func getBoolFromServiceAnnotation(service *corev1.Service, annotationKey string, defaultSetting bool) bool { @@ -1122,6 +1156,58 @@ func (lbaas *LbaasV2) ensureOctaviaListener(lbID string, name string, curListene return listener, nil } +func (lbaas *LbaasV2) ensurePrometheusListener(lbID string, name string, curListenerMapping map[listenerKey]*listeners.Listener, _ corev1.ServicePort, svcConf *serviceConfig, _ *corev1.Service) (*listeners.Listener, error) { + listener, isPresent := curListenerMapping[listenerKey{ + Protocol: listeners.ProtocolPrometheus, + Port: svcConf.metricPort, + }] + if !isPresent { + listenerCreateOpt := listeners.CreateOpts{ + Name: name, + Protocol: listeners.ProtocolPrometheus, + ProtocolPort: svcConf.metricPort, + AllowedCIDRs: svcConf.metricAllowedCIDRs, + LoadbalancerID: lbID, + Tags: []string{svcConf.lbName}, + } + + var err error + listener, err = openstackutil.CreateListener(lbaas.lb, lbID, listenerCreateOpt) + if err != nil { + return nil, fmt.Errorf("failed to create metric listener for loadbalancer %s: %v", lbID, err) + } + + klog.V(2).Infof("Metric listener %s created for loadbalancer %s", listener.ID, lbID) + } else { + listenerChanged := false + updateOpts := listeners.UpdateOpts{} + + if svcConf.supportLBTags { + if !cpoutil.Contains(listener.Tags, svcConf.lbName) { + var newTags []string + copy(newTags, listener.Tags) + newTags = append(newTags, svcConf.lbName) + updateOpts.Tags = &newTags + listenerChanged = true + } + } + + if !cpoutil.StringListEqual(svcConf.metricAllowedCIDRs, listener.AllowedCIDRs) { + updateOpts.AllowedCIDRs = &svcConf.metricAllowedCIDRs + listenerChanged = true + } + + if listenerChanged { + klog.InfoS("Updating metric listener", "listenerID", listener.ID, "lbID", lbID, "updateOpts", updateOpts) + if err := openstackutil.UpdateListener(lbaas.lb, lbID, listener.ID, updateOpts); err != nil { + return nil, fmt.Errorf("failed to update metric listener %s of loadbalancer %s: %v", listener.ID, lbID, err) + } + klog.InfoS("Updated metric listener", "listenerID", listener.ID, "lbID", lbID) + } + } + return listener, nil +} + // buildListenerCreateOpt returns listeners.CreateOpts for a specific Service port and configuration func (lbaas *LbaasV2) buildListenerCreateOpt(port corev1.ServicePort, svcConf *serviceConfig, name string) listeners.CreateOpts { listenerCreateOpt := listeners.CreateOpts{ @@ -1787,6 +1873,25 @@ func (lbaas *LbaasV2) ensureOctaviaLoadBalancer(ctx context.Context, clusterName curListeners = popListener(curListeners, listener.ID) } + // Check if we need to expose the metric endpoint + svcConf.metricEnabled = getBoolFromServiceAnnotation(service, ServiceAnnotationLoadBalancerMetricsEnabled, false) + if svcConf.metricEnabled && openstackutil.IsOctaviaFeatureSupported(lbaas.lb, openstackutil.OctaviaFeaturePrometheusListener, lbaas.opts.LBProvider) { + // Only a LB owner can add the prometheus listener (to avoid conflict with a shared loadbalancer) + if isLBOwner { + svcConf.metricPort = getIntFromServiceAnnotation(service, ServiceAnnotationLoadBalancerMetricsPort, 9100) + lbaas.updateServiceAnnotation(service, ServiceAnnotationLoadBalancerMetricsPort, strconv.Itoa(svcConf.metricPort)) + svcConf.metricAllowedCIDRs = getStringArrayFromServiceAnnotationSeparatedByComma(service, ServiceAnnotationLoadBalancerMetricsAllowCidrs, []string{}) + listener, err := lbaas.ensurePrometheusListener(loadbalancer.ID, cpoutil.Sprintf255(listenerFormatMetric, lbName), curListenerMapping, corev1.ServicePort{}, svcConf, service) + if err != nil { + return nil, err + } + curListeners = popListener(curListeners, listener.ID) + } else { + msg := "Metric Listener cannot be deployed on Service %s, only owner Service can do that" + lbaas.eventRecorder.Eventf(service, corev1.EventTypeWarning, eventLBMetricListenerIgnored, msg, serviceName) + klog.Infof(msg, serviceName) + } + } // Deal with the remaining listeners, delete the listener if it was created by this Service previously. if err := lbaas.deleteOctaviaListeners(loadbalancer.ID, curListeners, isLBOwner, lbName); err != nil { return nil, err @@ -1806,8 +1911,9 @@ func (lbaas *LbaasV2) ensureOctaviaLoadBalancer(ctx context.Context, clusterName } } - // save address into the annotation + // save addresses into the annotations lbaas.updateServiceAnnotation(service, ServiceAnnotationLoadBalancerAddress, addr) + lbaas.updateServiceAnnotation(service, ServiceAnnotationLoadBalancerVIPAddress, loadbalancer.VipAddress) // add LB name to load balancer tags. if svcConf.supportLBTags { diff --git a/pkg/openstack/loadbalancer_test.go b/pkg/openstack/loadbalancer_test.go index a696282dea..206895b597 100644 --- a/pkg/openstack/loadbalancer_test.go +++ b/pkg/openstack/loadbalancer_test.go @@ -2553,3 +2553,114 @@ func TestFilterNodes(t *testing.T) { }) } } + +func Test_getStringArrayFromServiceAnnotationSeparateByComma(t *testing.T) { + type args struct { + service *corev1.Service + annotationKey string + defaultSetting []string + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "ensure string is well split", + args: struct { + service *corev1.Service + annotationKey string + defaultSetting []string + }{ + service: &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{"my-csv-annotation": "10.0.0.0/8, my string data"}, + }, + }, + annotationKey: "my-csv-annotation", + defaultSetting: []string{"10.0.0.0/8"}}, + want: []string{"10.0.0.0/8", "my string data"}, + }, + { + name: "ensure string is well split (if no comma)", + args: struct { + service *corev1.Service + annotationKey string + defaultSetting []string + }{ + service: &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{"my-csv-annotation": "192.168.0.0/24"}, + }, + }, + annotationKey: "my-csv-annotation", + defaultSetting: []string{"10.0.0.0/8"}}, + want: []string{"192.168.0.0/24"}, + }, + { + name: "ensure array has no empty string due to several commas", + args: struct { + service *corev1.Service + annotationKey string + defaultSetting []string + }{ + service: &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{"my-csv-annotation": "10.0.0.0/8, 192.168.0.0/24,,"}, + }, + }, + annotationKey: "my-csv-annotation", + defaultSetting: []string{"10.0.0.0/8"}}, + want: []string{"10.0.0.0/8", "192.168.0.0/24"}, + }, + { + name: "ensure default is return when annotation doesn't exist", + args: struct { + service *corev1.Service + annotationKey string + defaultSetting []string + }{ + service: &corev1.Service{}, + annotationKey: "my-csv-annotation", + defaultSetting: []string{"10.0.0.0/8"}}, + want: []string{"10.0.0.0/8"}, + }, + { + name: "ensure empty array is returned when annotation has blank chars", + args: struct { + service *corev1.Service + annotationKey string + defaultSetting []string + }{ + service: &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{"my-csv-annotation": " , "}, + }, + }, + annotationKey: "my-csv-annotation", + defaultSetting: []string{"10.0.0.0/8"}}, + want: []string{}, + }, + { + name: "ensure empty array is returned when annotation is empty", + args: struct { + service *corev1.Service + annotationKey string + defaultSetting []string + }{ + service: &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{"my-csv-annotation": ""}, + }, + }, + annotationKey: "my-csv-annotation", + defaultSetting: []string{"10.0.0.0/8"}}, + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, getStringArrayFromServiceAnnotationSeparatedByComma(tt.args.service, tt.args.annotationKey, tt.args.defaultSetting), "getStringArrayFromServiceAnnotationSeparatedByComma(%v, %v, %v)", tt.args.service, tt.args.annotationKey, tt.args.defaultSetting) + }) + } +} diff --git a/pkg/util/openstack/loadbalancer.go b/pkg/util/openstack/loadbalancer.go index 41654ab681..3748d73039 100644 --- a/pkg/util/openstack/loadbalancer.go +++ b/pkg/util/openstack/loadbalancer.go @@ -40,12 +40,13 @@ import ( ) const ( - OctaviaFeatureTags = 0 - OctaviaFeatureVIPACL = 1 - OctaviaFeatureFlavors = 2 - OctaviaFeatureTimeout = 3 - OctaviaFeatureAvailabilityZones = 4 - OctaviaFeatureHTTPMonitorsOnUDP = 5 + OctaviaFeatureTags = 0 + OctaviaFeatureVIPACL = 1 + OctaviaFeatureFlavors = 2 + OctaviaFeatureTimeout = 3 + OctaviaFeatureAvailabilityZones = 4 + OctaviaFeatureHTTPMonitorsOnUDP = 5 + OctaviaFeaturePrometheusListener = 6 waitLoadbalancerInitDelay = 1 * time.Second waitLoadbalancerFactor = 1.2 @@ -145,6 +146,14 @@ func IsOctaviaFeatureSupported(client *gophercloud.ServiceClient, feature int, l if currentVer.GreaterThanOrEqual(verHTTPMonitorsOnUDP) { return true } + case OctaviaFeaturePrometheusListener: + if lbProvider == "ovn" { + return false + } + verACL, _ := version.NewVersion("v2.25") + if currentVer.GreaterThanOrEqual(verACL) { + return true + } default: klog.Warningf("Feature %d not recognized", feature) } diff --git a/tests/e2e/cloudprovider/test-lb-service.sh b/tests/e2e/cloudprovider/test-lb-service.sh index cb57d6ec8c..b701943383 100755 --- a/tests/e2e/cloudprovider/test-lb-service.sh +++ b/tests/e2e/cloudprovider/test-lb-service.sh @@ -801,6 +801,74 @@ EOF fi } +######################################################################## +## Name: test_metric_endpoint +## Desc: Create a k8s service and check the metric endpoint exposition +## Params: None +######################################################################## +function test_metric_endpoint { + local service="test-metric-endpoint" + local metric_port="9101" + local allowed_cidrs_expected='"0.0.0.0/0"' + + if [[ ${OCTAVIA_PROVIDER} == "ovn" ]]; then + printf "\n>>>>>>> Skipping Service ${service} test for OVN provider\n" + return 0 + fi + + printf "\n>>>>>>> Create Service ${service}\n" + cat <>>>>>> Waiting for the Service ${service} creation finished\n" + wait_for_service_address ${service} + wait_address_accessible $ipaddr + + lbid=$(openstack loadbalancer list -c id -c name | grep "octavia-lb-test_${service}" | awk '{print $2}') + + printf "\n>>>>>>> Sending request to the Metric endpoint ${service}\n" + metricFetch=$(curl -sS http://${ipaddr}:${metric_port}/metrics) + # ensure a metric is returned by the endpoint + if [[ "$metricFetch" == *"octavia_loadbalancer_cpu"* ]]; then + printf "\n>>>>>>> Expected: Get correct response from Service ${service}\n" + else + printf "\n>>>>>>> FAIL: Get incorrect response from Service ${service}, expected: octavia_loadbalancer_cpu, actual: ${metricFetch}\n" + curl -sSv http://${ipaddr}:${metric_port}/metrics + exit 1 + fi + + printf "\n>>>>>>> Checking Metric endpoint's configuration (allowed cidrs)\n" + metricListenerId=$(openstack loadbalancer status show ${lbid} | jq -r '.loadbalancer.listeners[] | select(.name | startswith("listener_metric_kube_service")) | .id') + cidrs=$(openstack loadbalancer listener show ${metricListenerId} -f json | jq '.allowed_cidrs') + # ensure allowed cidrs are well filled on octavia side + if [[ "$cidrs" == "$allowed_cidrs_expected" ]] ; then + printf "\n>>>>>>> Expected: Get correct response from Metric endpoint's configuration\n" + else + printf "\n>>>>>>> FAIL: Get incorrect Metric's configuration, expected: ${allowed_cidrs_expected}, actual: ${cidrs}\n" + exit 1 + fi + + printf "\n>>>>>>> Delete Service ${service}\n" + kubectl -n $NAMESPACE delete service ${service} +} + create_namespace create_deployment set_openstack_credentials @@ -810,3 +878,4 @@ test_forwarded test_update_port test_shared_lb test_shared_user_lb +test_metric_endpoint