-
Notifications
You must be signed in to change notification settings - Fork 4.8k
OCPCLOUD-3074: Validate Azure dual-stack load balancers #31452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
bandrade
wants to merge
3
commits into
openshift:main
Choose a base branch
from
bandrade:ocpcloud-3074-azure-dualstack-ccm-e2e
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+342
−0
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
342 changes: 342 additions & 0 deletions
342
test/extended/cloud_controller_manager/azure_dualstack.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,342 @@ | ||
| package cloud_controller_manager | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "net" | ||
| "strconv" | ||
| "time" | ||
|
|
||
| g "github.com/onsi/ginkgo/v2" | ||
| o "github.com/onsi/gomega" | ||
| configv1 "github.com/openshift/api/config/v1" | ||
| exutil "github.com/openshift/origin/test/extended/util" | ||
| appsv1 "k8s.io/api/apps/v1" | ||
| corev1 "k8s.io/api/core/v1" | ||
| apierrors "k8s.io/apimachinery/pkg/api/errors" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/apimachinery/pkg/util/intstr" | ||
| utilnet "k8s.io/apimachinery/pkg/util/net" | ||
| "k8s.io/apimachinery/pkg/util/wait" | ||
| "k8s.io/client-go/kubernetes" | ||
| e2e "k8s.io/kubernetes/test/e2e/framework" | ||
| e2epod "k8s.io/kubernetes/test/e2e/framework/pod" | ||
| e2epodoutput "k8s.io/kubernetes/test/e2e/framework/pod/output" | ||
| e2eservice "k8s.io/kubernetes/test/e2e/framework/service" | ||
| admissionapi "k8s.io/pod-security-admission/api" | ||
| ) | ||
|
|
||
| const ( | ||
| azureInternalLoadBalancerAnnotation = "service.beta.kubernetes.io/azure-load-balancer-internal" | ||
| azureLoadBalancerServicePort = 18080 | ||
| azureLoadBalancerBackendPort = 8080 | ||
| ) | ||
|
|
||
| var _ = g.Describe("[sig-cloud-provider][Feature:OpenShiftCloudControllerManager][Feature:IPv6DualStack][OCPFeatureGate:AzureDualStackInstall][Late] Azure dual-stack load balancers", func() { | ||
| defer g.GinkgoRecover() | ||
|
|
||
| oc := exutil.NewCLIWithPodSecurityLevel("ccm-azure-dualstack", admissionapi.LevelPrivileged) | ||
|
|
||
| g.It("should expose public and internal services over IPv4 and IPv6 [Timeout:45m][apigroup:config.openshift.io]", func(ctx context.Context) { | ||
| exutil.SkipIfNotPlatform(oc, configv1.AzurePlatformType) | ||
|
|
||
| infra, err := oc.AdminConfigClient().ConfigV1().Infrastructures().Get(ctx, "cluster", metav1.GetOptions{}) | ||
| o.Expect(err).NotTo(o.HaveOccurred()) | ||
| o.Expect(infra.Status.PlatformStatus).NotTo(o.BeNil(), "infrastructure platformStatus is required") | ||
| o.Expect(infra.Status.PlatformStatus.Azure).NotTo(o.BeNil(), "Azure platformStatus is required") | ||
| if infra.Status.ControlPlaneTopology == configv1.ExternalTopologyMode { | ||
| g.Skip("hosted control-plane clusters do not expose a guest control-plane node for safe load-balancer probes") | ||
| } | ||
|
|
||
| ipFamily := infra.Status.PlatformStatus.Azure.IPFamily | ||
| if ipFamily != configv1.DualStackIPv4Primary && ipFamily != configv1.DualStackIPv6Primary { | ||
| g.Skip(fmt.Sprintf("Azure cluster is not dual-stack: ipFamily=%q", ipFamily)) | ||
| } | ||
| e2e.Logf("Testing Azure cluster with ipFamily=%s", ipFamily) | ||
|
|
||
| client := oc.AdminKubeClient() | ||
| namespace := oc.Namespace() | ||
|
|
||
| g.By("creating a dual-stack service backend") | ||
| backend := e2eservice.NewTestJig(client, namespace, "azure-dualstack-backend") | ||
| _, err = backend.Run(ctx, func(deployment *appsv1.Deployment) { | ||
| deployment.Spec.Template.Spec.SecurityContext = e2epod.GetRestrictedPodSecurityContext() | ||
| for i := range deployment.Spec.Template.Spec.Containers { | ||
| container := &deployment.Spec.Template.Spec.Containers[i] | ||
| container.Args = []string{ | ||
| "netexec", | ||
| fmt.Sprintf("--http-port=%d", azureLoadBalancerBackendPort), | ||
| fmt.Sprintf("--udp-port=%d", azureLoadBalancerBackendPort), | ||
| } | ||
| container.SecurityContext = e2epod.GetRestrictedContainerSecurityContext() | ||
| o.Expect(container.ReadinessProbe).NotTo(o.BeNil(), "jig deployment is expected to define a readiness probe") | ||
| o.Expect(container.ReadinessProbe.HTTPGet).NotTo(o.BeNil(), "jig readiness probe is expected to use HTTP GET") | ||
| container.ReadinessProbe.HTTPGet.Port = intstr.FromInt32(azureLoadBalancerBackendPort) | ||
| } | ||
| }) | ||
| o.Expect(err).NotTo(o.HaveOccurred(), "failed to create the load-balancer backend") | ||
| g.DeferCleanup(func(ctx context.Context) { | ||
| err := client.AppsV1().Deployments(namespace).Delete(ctx, backend.Name, metav1.DeleteOptions{}) | ||
| o.Expect(apierrors.IsNotFound(err) || err == nil).To(o.BeTrue(), "failed to delete backend deployment: %v", err) | ||
| }) | ||
|
|
||
| testCases := []struct { | ||
| name string | ||
| internal bool | ||
| }{ | ||
| {name: "azure-dualstack-public"}, | ||
| {name: "azure-dualstack-internal", internal: true}, | ||
| } | ||
|
|
||
| services := make([]*corev1.Service, 0, len(testCases)) | ||
| serviceNames := make([]string, 0, len(testCases)) | ||
| g.DeferCleanup(func(ctx context.Context) { | ||
| err := deleteAzureLoadBalancerServicesAndWait(ctx, client, namespace, serviceNames...) | ||
| o.Expect(err).NotTo(o.HaveOccurred()) | ||
| }) | ||
|
|
||
| for _, testCase := range testCases { | ||
| g.By(fmt.Sprintf("creating the %s load-balancer service", testCase.name)) | ||
| service := newAzureDualStackLoadBalancerService(namespace, testCase.name, backend.Labels, testCase.internal) | ||
| service, err = client.CoreV1().Services(namespace).Create(ctx, service, metav1.CreateOptions{}) | ||
| o.Expect(err).NotTo(o.HaveOccurred(), "failed to create service %s", testCase.name) | ||
| services = append(services, service) | ||
| serviceNames = append(serviceNames, service.Name) | ||
| } | ||
|
|
||
| g.By("creating a host-networked client on a node excluded from service load balancers") | ||
| clientNode, err := azureLoadBalancerClientNode(ctx, client) | ||
| o.Expect(err).NotTo(o.HaveOccurred()) | ||
|
|
||
| probe := exutil.CreateExecPodOrFail(client, namespace, "azure-dualstack-probe", func(pod *corev1.Pod) { | ||
| pod.Spec.NodeName = clientNode | ||
| // Azure Load Balancer does not support hairpin traffic from a backend node. | ||
| // Probe from the host network of an excluded control-plane node instead. | ||
| pod.Spec.HostNetwork = true | ||
| pod.Spec.DNSPolicy = corev1.DNSClusterFirstWithHostNet | ||
| pod.Spec.Tolerations = []corev1.Toleration{{Operator: corev1.TolerationOpExists}} | ||
| }) | ||
| g.DeferCleanup(func(ctx context.Context) { | ||
| err := client.CoreV1().Pods(namespace).Delete(ctx, probe.Name, metav1.DeleteOptions{}) | ||
| o.Expect(apierrors.IsNotFound(err) || err == nil).To(o.BeTrue(), "failed to delete probe pod: %v", err) | ||
| }) | ||
|
|
||
| for i, service := range services { | ||
| g.By(fmt.Sprintf("waiting for %s to publish IPv4 and IPv6 ingress addresses", service.Name)) | ||
| service, err = waitForAzureDualStackLoadBalancer(ctx, client, namespace, service.Name) | ||
| o.Expect(err).NotTo(o.HaveOccurred()) | ||
| services[i] = service | ||
| } | ||
|
|
||
| for _, service := range services { | ||
| o.Expect(service.Spec.IPFamilyPolicy).NotTo(o.BeNil()) | ||
| o.Expect(*service.Spec.IPFamilyPolicy).To(o.Equal(corev1.IPFamilyPolicyRequireDualStack)) | ||
| o.Expect(service.Spec.IPFamilies).To(o.ConsistOf(corev1.IPv4Protocol, corev1.IPv6Protocol)) | ||
|
|
||
| ipv4, ipv6 := loadBalancerIngressIPs(service) | ||
| o.Expect(ipv4).To(o.HaveLen(1), "service %s should have exactly one IPv4 ingress", service.Name) | ||
| o.Expect(ipv6).To(o.HaveLen(1), "service %s should have exactly one IPv6 ingress", service.Name) | ||
| o.Expect(service.Status.LoadBalancer.Ingress).To(o.HaveLen(2), "service %s should have exactly two ingress entries", service.Name) | ||
|
|
||
| for _, addressFamily := range []struct { | ||
| name string | ||
| addresses []string | ||
| }{ | ||
| {name: "IPv4", addresses: ipv4}, | ||
| {name: "IPv6", addresses: ipv6}, | ||
| } { | ||
| for _, ingressIP := range addressFamily.addresses { | ||
| g.By(fmt.Sprintf("checking %s connectivity to %s", addressFamily.name, service.Name)) | ||
| err := probeAzureLoadBalancer(ctx, namespace, probe.Name, ingressIP, service.Spec.Ports[0].Port) | ||
| o.Expect(err).NotTo(o.HaveOccurred(), "service %s is not reachable over %s", service.Name, addressFamily.name) | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| func newAzureDualStackLoadBalancerService(namespace, name string, selector map[string]string, internal bool) *corev1.Service { | ||
| ipFamilyPolicy := corev1.IPFamilyPolicyRequireDualStack | ||
| service := &corev1.Service{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Namespace: namespace, | ||
| Name: name, | ||
| }, | ||
| Spec: corev1.ServiceSpec{ | ||
| Type: corev1.ServiceTypeLoadBalancer, | ||
| Selector: selector, | ||
| IPFamilyPolicy: &ipFamilyPolicy, | ||
| Ports: []corev1.ServicePort{{ | ||
| Name: "http", | ||
| Protocol: corev1.ProtocolTCP, | ||
| Port: azureLoadBalancerServicePort, | ||
| TargetPort: intstr.FromInt32(azureLoadBalancerBackendPort), | ||
| }}, | ||
| }, | ||
| } | ||
|
|
||
| if internal { | ||
| service.Annotations = map[string]string{azureInternalLoadBalancerAnnotation: "true"} | ||
| } | ||
|
|
||
| return service | ||
| } | ||
|
|
||
| func waitForAzureDualStackLoadBalancer(ctx context.Context, client kubernetes.Interface, namespace, name string) (*corev1.Service, error) { | ||
| var service *corev1.Service | ||
| timeout := e2eservice.GetServiceLoadBalancerCreationTimeout(ctx, client) | ||
|
|
||
| err := wait.PollUntilContextTimeout(ctx, 10*time.Second, timeout, true, func(ctx context.Context) (bool, error) { | ||
| current, err := client.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{}) | ||
| if err != nil { | ||
| if isRetryableKubernetesAPIError(err) { | ||
| e2e.Logf("Retrying transient error while reading service %s/%s: %v", namespace, name, err) | ||
| return false, nil | ||
| } | ||
| return false, err | ||
| } | ||
|
|
||
| service = current | ||
| ipv4, ipv6 := loadBalancerIngressIPs(current) | ||
| e2e.Logf("Service %s/%s ingress counts: IPv4=%d IPv6=%d", namespace, name, len(ipv4), len(ipv6)) | ||
| return len(ipv4) == 1 && len(ipv6) == 1 && len(current.Status.LoadBalancer.Ingress) == 2, nil | ||
| }) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("timed out waiting for service %s/%s to publish one IPv4 and one IPv6 ingress: %w", namespace, name, err) | ||
| } | ||
|
|
||
| return service, nil | ||
| } | ||
|
|
||
| func loadBalancerIngressIPs(service *corev1.Service) (ipv4, ipv6 []string) { | ||
| for _, ingress := range service.Status.LoadBalancer.Ingress { | ||
| ip := net.ParseIP(ingress.IP) | ||
| if ip == nil { | ||
| continue | ||
| } | ||
| if ip.To4() != nil { | ||
| ipv4 = append(ipv4, ingress.IP) | ||
| continue | ||
| } | ||
| ipv6 = append(ipv6, ingress.IP) | ||
| } | ||
| return ipv4, ipv6 | ||
| } | ||
|
|
||
| func azureLoadBalancerClientNode(ctx context.Context, client kubernetes.Interface) (string, error) { | ||
| nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| for _, node := range nodes.Items { | ||
| _, isMaster := node.Labels["node-role.kubernetes.io/master"] | ||
| _, isControlPlane := node.Labels["node-role.kubernetes.io/control-plane"] | ||
| _, excludedFromLoadBalancers := node.Labels[corev1.LabelNodeExcludeBalancers] | ||
| if (isMaster || isControlPlane) && excludedFromLoadBalancers && nodeIsReady(&node) { | ||
| return node.Name, nil | ||
| } | ||
| } | ||
|
|
||
| return "", fmt.Errorf("no ready control-plane node excluded from external load balancers was found") | ||
| } | ||
|
|
||
| func nodeIsReady(node *corev1.Node) bool { | ||
| for _, condition := range node.Status.Conditions { | ||
| if condition.Type == corev1.NodeReady { | ||
| return condition.Status == corev1.ConditionTrue | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| func probeAzureLoadBalancer(ctx context.Context, namespace, podName, ingressIP string, port int32) error { | ||
| target := net.JoinHostPort(ingressIP, strconv.Itoa(int(port))) | ||
| url := fmt.Sprintf("http://%s/hostname", target) | ||
| command := fmt.Sprintf("curl -g --fail --silent --show-error --noproxy '*' --connect-timeout 5 --max-time 10 --output /dev/null %q", url) | ||
|
|
||
| var lastErr error | ||
| err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 5*time.Minute, true, func(context.Context) (bool, error) { | ||
| _, lastErr = e2epodoutput.RunHostCmd(namespace, podName, command) | ||
| if lastErr != nil { | ||
| e2e.Logf("Waiting to retry failed load-balancer probe") | ||
| return false, nil | ||
| } | ||
| return true, nil | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("load-balancer probe did not succeed: %w", errors.Join(err, lastErr)) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func deleteAzureLoadBalancerServicesAndWait(ctx context.Context, client kubernetes.Interface, namespace string, names ...string) error { | ||
| ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Minute) | ||
| defer cancel() | ||
|
|
||
| pendingDeletion := make(map[string]struct{}, len(names)) | ||
| for _, name := range names { | ||
| pendingDeletion[name] = struct{}{} | ||
| } | ||
|
|
||
| err := wait.PollUntilContextTimeout(ctx, 5*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { | ||
| for name := range pendingDeletion { | ||
| err := client.CoreV1().Services(namespace).Delete(ctx, name, metav1.DeleteOptions{}) | ||
| if err == nil || apierrors.IsNotFound(err) { | ||
| delete(pendingDeletion, name) | ||
| continue | ||
| } | ||
| if isRetryableKubernetesAPIError(err) { | ||
| e2e.Logf("Retrying transient error while deleting service %s/%s: %v", namespace, name, err) | ||
| continue | ||
| } | ||
| return false, fmt.Errorf("failed to delete service %s/%s: %w", namespace, name, err) | ||
| } | ||
| return len(pendingDeletion) == 0, nil | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to request deletion of Azure load-balancer services: %w", err) | ||
| } | ||
|
|
||
| pendingRemoval := make(map[string]struct{}, len(names)) | ||
| for _, name := range names { | ||
| pendingRemoval[name] = struct{}{} | ||
| } | ||
|
|
||
| err = wait.PollUntilContextTimeout(ctx, 10*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { | ||
| for name := range pendingRemoval { | ||
| _, err := client.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{}) | ||
| if apierrors.IsNotFound(err) { | ||
| delete(pendingRemoval, name) | ||
| continue | ||
| } | ||
| if err != nil { | ||
| if isRetryableKubernetesAPIError(err) { | ||
| e2e.Logf("Retrying transient error while waiting for service %s/%s deletion: %v", namespace, name, err) | ||
| continue | ||
| } | ||
| return false, err | ||
| } | ||
| } | ||
| return len(pendingRemoval) == 0, nil | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if err != nil { | ||
| return fmt.Errorf("timed out waiting for Azure load-balancer services to be deleted: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func isRetryableKubernetesAPIError(err error) bool { | ||
| return errors.Is(err, context.DeadlineExceeded) || | ||
| apierrors.IsTimeout(err) || | ||
| apierrors.IsServerTimeout(err) || | ||
| apierrors.IsTooManyRequests(err) || | ||
| apierrors.IsServiceUnavailable(err) || | ||
| utilnet.IsConnectionRefused(err) || | ||
| utilnet.IsConnectionReset(err) || | ||
| utilnet.IsTimeout(err) || | ||
| utilnet.IsHTTP2ConnectionLost(err) || | ||
| utilnet.IsProbableEOF(err) | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.