Skip to content

Commit 761dfe8

Browse files
authored
Merge pull request #10 from cloudscale-ch/denis/floating-ips
Add support for Floating IPs
2 parents 13c5782 + 6968c48 commit 761dfe8

File tree

11 files changed

+491
-12
lines changed

11 files changed

+491
-12
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ test:
1717
./pkg/internal/compare
1818

1919
integration:
20-
K8TEST_PATH=${PWD}/k8test go test -count=1 -tags=integration ./pkg/internal/integration -v
20+
K8TEST_PATH=${PWD}/k8test go test -count=1 -tags=integration ./pkg/internal/integration -v -timeout 30m
2121

2222
coverage: test
2323
go tool cover -html=cover.out

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ kind: Service
211211
metadata:
212212
annotations:
213213
k8s.cloudscale.ch/loadbalancer-listener-allowed-cidrs: '["1.2.3.0/24"]'
214+
k8s.cloudscale.ch/loadbalancer-floating-ips: '["1.2.3.4/32"]'
214215
```
215216

216217
The full set of configuration toggles can be found in the [`pkg/cloudscale_ccm/loadbalancer.go`](pkg/cloudscale/ccm/loadbalancer.go) file.

examples/nginx-hello.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Deploys the nginxdemos/hello:plain-text container and creates a
2+
# loadbalancer service for it:
3+
#
4+
# export KUBECONFIG=path/to/kubeconfig
5+
# kubectl apply -f nginx-hello.yml
6+
#
7+
# Wait for `kubectl describe service hello` to show "Loadbalancer Ensured",
8+
# then use the IP address found under "LoadBalancer Ingress" to connect to the
9+
# service.
10+
#
11+
# You can also use the following shortcut:
12+
#
13+
# curl http://$(kubectl get service hello -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
14+
#
15+
---
16+
apiVersion: apps/v1
17+
kind: Deployment
18+
metadata:
19+
name: hello
20+
spec:
21+
replicas: 2
22+
selector:
23+
matchLabels:
24+
app: hello
25+
template:
26+
metadata:
27+
labels:
28+
app: hello
29+
spec:
30+
containers:
31+
- name: hello
32+
image: nginxdemos/hello:plain-text
33+
34+
# Spread the containers across nodes
35+
topologySpreadConstraints:
36+
- maxSkew: 1
37+
topologyKey: kubernetes.io/hostname
38+
whenUnsatisfiable: DoNotSchedule
39+
labelSelector:
40+
matchLabels:
41+
app: hello
42+
---
43+
apiVersion: v1
44+
kind: Service
45+
metadata:
46+
labels:
47+
app: hello
48+
name: hello
49+
spec:
50+
ports:
51+
- port: 80
52+
protocol: TCP
53+
targetPort: 80
54+
name: primary
55+
selector:
56+
app: hello
57+
type: LoadBalancer

pkg/cloudscale_ccm/loadbalancer.go

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package cloudscale_ccm
33
import (
44
"context"
55
"fmt"
6+
"strings"
67

78
"github.com/cloudscale-ch/cloudscale-cloud-controller-manager/pkg/internal/kubeutil"
89
"github.com/cloudscale-ch/cloudscale-go-sdk/v4"
10+
"golang.org/x/exp/slices"
911
v1 "k8s.io/api/core/v1"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1013
"k8s.io/client-go/kubernetes"
1114
"k8s.io/klog/v2"
1215
)
@@ -73,6 +76,33 @@ const (
7376
// resources instead.
7477
LoadBalancerVIPAddresses = "k8s.cloudscale.ch/loadbalancer-vip-addresses"
7578

79+
// LoadBalancerFloatingIPs assigns the given Floating IPs to the
80+
// load balancer. The expected value is a list of addresses of the
81+
// Floating IPs in CIDR notation. For example:
82+
//
83+
// ["5.102.150.123/32", "2a06:c01::123/128"]
84+
//
85+
// If any Floating IP address is assigned to multiple services via this
86+
// annotation, the CCM will refuse to update the associated services, as
87+
// this is considered a serious configuration issue that has to first be
88+
// resolved by the operator.
89+
//
90+
// While the service being handled needs to have a parseable Floating IP
91+
// config, the services it is compared to for conflict detection do not.
92+
//
93+
// Such services are skipped during conflict detection with the goal
94+
// of limiting the impact of config parse errors to the service being
95+
// processed.
96+
//
97+
// Floating IPs already assigned to the loadbalancer, but no longer
98+
// present in the annotations, stay on the loadbalancer until another
99+
// service requests them. This is due to the fact that it is not possible
100+
// to unassign Floating IPs to point to nowhere.
101+
//
102+
// The Floating IPs are only assigned to the LoadBalancer once it has
103+
// been fully created.
104+
LoadBalancerFloatingIPs = "k8s.cloudscale.ch/loadbalancer-floating-ips"
105+
76106
// LoadBalancerPoolAlgorithm defines the load balancing algorithm used
77107
// by the loadbalancer. See the API documentation for more information:
78108
//
@@ -279,9 +309,9 @@ func (l *loadbalancer) EnsureLoadBalancer(
279309
nodes []*v1.Node,
280310
) (*v1.LoadBalancerStatus, error) {
281311

282-
// Skip if the service is not supported by this CCM
312+
// Detect configuration issues and abort if they are found
283313
serviceInfo := newServiceInfo(service, clusterName)
284-
if supported, err := serviceInfo.isSupported(); !supported {
314+
if err := l.ensureValidConfig(ctx, serviceInfo); err != nil {
285315
return nil, err
286316
}
287317

@@ -347,9 +377,9 @@ func (l *loadbalancer) UpdateLoadBalancer(
347377
nodes []*v1.Node,
348378
) error {
349379

350-
// Skip if the service is not supported by this CCM
380+
// Detect configuration issues and abort if they are found
351381
serviceInfo := newServiceInfo(service, clusterName)
352-
if supported, err := serviceInfo.isSupported(); !supported {
382+
if err := l.ensureValidConfig(ctx, serviceInfo); err != nil {
353383
return err
354384
}
355385

@@ -388,9 +418,9 @@ func (l *loadbalancer) EnsureLoadBalancerDeleted(
388418
service *v1.Service,
389419
) error {
390420

391-
// Skip if the service is not supported by this CCM
421+
// Detect configuration issues and abort if they are found
392422
serviceInfo := newServiceInfo(service, clusterName)
393-
if supported, err := serviceInfo.isSupported(); !supported {
423+
if err := l.ensureValidConfig(ctx, serviceInfo); err != nil {
394424
return err
395425
}
396426

@@ -402,6 +432,120 @@ func (l *loadbalancer) EnsureLoadBalancerDeleted(
402432
})
403433
}
404434

435+
// ensureValidConfig ensures that the configuration can be applied at all,
436+
// acting as a gate that ensures certain invariants before any changes are
437+
// made.
438+
//
439+
// The general idea is that it's better to not make any chanages if the config
440+
// is bad, rather than throwing errors later when some changes have already
441+
// been made.
442+
func (l *loadbalancer) ensureValidConfig(
443+
ctx context.Context, serviceInfo *serviceInfo) error {
444+
445+
// Skip if the service is not supported by this CCM
446+
if supported, err := serviceInfo.isSupported(); !supported {
447+
return err
448+
}
449+
450+
// If Floating IPs are used, make sure there are no conflicting
451+
// assignment across services.
452+
ips, err := l.findIPsAssignedElsewhere(ctx, serviceInfo)
453+
if err != nil {
454+
return fmt.Errorf("could not parse %s", LoadBalancerFloatingIPs)
455+
}
456+
457+
if len(ips) > 0 {
458+
459+
info := make([]string, 0, len(ips))
460+
for ip, service := range ips {
461+
info = append(info, fmt.Sprintf("%s->%s", ip, service))
462+
}
463+
464+
return fmt.Errorf(
465+
"at least one Floating IP assigned to service %s is also "+
466+
"assigned to another service. Refusing to continue to avoid "+
467+
"flapping: %s",
468+
serviceInfo.Service.Name,
469+
strings.Join(info, ", "),
470+
)
471+
}
472+
473+
return nil
474+
}
475+
476+
// findIPsAssignedElsewhere lists other services and compares their Floating
477+
// IPs with the ones found on the given service. If an IP is found to be
478+
// assigned to two services, the IP and the name of the service are returned.
479+
func (l *loadbalancer) findIPsAssignedElsewhere(
480+
ctx context.Context, serviceInfo *serviceInfo) (map[string]string, error) {
481+
482+
ips, err := serviceInfo.annotationList(LoadBalancerFloatingIPs)
483+
if err != nil {
484+
return nil, err
485+
}
486+
487+
if len(ips) == 0 {
488+
return nil, nil
489+
}
490+
491+
conflicts := make(map[string]string, 0)
492+
493+
// Unfortuantely, there's no way to filter for the services that matter
494+
// here. The only available field selectors for services are
495+
// `metadata.name` and `metadata.namespace`.
496+
//
497+
// To support larger clusters, ensure to not load all services in a
498+
// single call.
499+
opts := metav1.ListOptions{
500+
Continue: "",
501+
Limit: 250,
502+
}
503+
504+
svcs := l.k8s.CoreV1().Services("")
505+
for {
506+
services, err := svcs.List(ctx, opts)
507+
if err != nil {
508+
return nil, fmt.Errorf("failed to retrieve services: %w", err)
509+
}
510+
511+
for _, service := range services.Items {
512+
if service.Spec.Type != "LoadBalancer" {
513+
continue
514+
}
515+
if service.UID == serviceInfo.Service.UID {
516+
continue
517+
}
518+
519+
otherInfo := newServiceInfo(&service, serviceInfo.clusterName)
520+
other, err := otherInfo.annotationList(LoadBalancerFloatingIPs)
521+
522+
// Ignore errors loading the IPs of other services, as they would
523+
// not be configured either, if the current service is otherwise
524+
// okay, it should be able to continue.
525+
//
526+
// If this is not done, a single configuration error on a service
527+
// causes this function to err on all other services.
528+
if err != nil {
529+
continue
530+
}
531+
532+
for _, ip := range other {
533+
if slices.Contains(ips, ip) {
534+
conflicts[ip] = service.Name
535+
}
536+
}
537+
}
538+
539+
if services.Continue == "" {
540+
break
541+
}
542+
543+
opts.Continue = services.Continue
544+
}
545+
546+
return conflicts, nil
547+
}
548+
405549
// loadBalancerStatus generates the v1.LoadBalancerStatus for the given
406550
// loadbalancer, as required by Kubernetes.
407551
func loadBalancerStatus(lb *cloudscale.LoadBalancer) *v1.LoadBalancerStatus {

pkg/cloudscale_ccm/reconcile.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ type lbState struct {
3232
// necessarily bound to any given pool.
3333
listeners map[*cloudscale.LoadBalancerPool][]cloudscale.
3434
LoadBalancerListener
35+
36+
// The assigned floating IPs
37+
floatingIPs []string
3538
}
3639

3740
func newLbState(lb *cloudscale.LoadBalancer) *lbState {
@@ -44,6 +47,7 @@ func newLbState(lb *cloudscale.LoadBalancer) *lbState {
4447
map[*cloudscale.LoadBalancerPool][]cloudscale.LoadBalancerHealthMonitor),
4548
listeners: make(
4649
map[*cloudscale.LoadBalancerPool][]cloudscale.LoadBalancerListener),
50+
floatingIPs: make([]string, 0),
4751
}
4852
}
4953

@@ -104,6 +108,14 @@ func desiredLbState(
104108
},
105109
})
106110

111+
// Get list of floating IPs if possible
112+
ips, err := serviceInfo.annotationList(LoadBalancerFloatingIPs)
113+
if err != nil {
114+
return nil, fmt.Errorf("could not parse %s", LoadBalancerFloatingIPs)
115+
}
116+
117+
s.floatingIPs = ips
118+
107119
// Each service port gets its own pool
108120
algorithm := serviceInfo.annotation(LoadBalancerPoolAlgorithm)
109121
protocol := serviceInfo.annotation(LoadBalancerPoolProtocol)
@@ -297,6 +309,19 @@ func actualLbState(
297309
s.listeners[nil] = append(s.listeners[nil], l)
298310
}
299311

312+
// Find all floating IPs assigned to the loadbalancer
313+
ips, err := l.client.FloatingIPs.List(ctx)
314+
if err != nil {
315+
return nil, fmt.Errorf(
316+
"lbstate: failed to load floating ips: %w", err)
317+
}
318+
319+
for _, ip := range ips {
320+
if ip.LoadBalancer != nil && ip.LoadBalancer.UUID == lb.UUID {
321+
s.floatingIPs = append(s.floatingIPs, ip.Network)
322+
}
323+
}
324+
300325
return s, nil
301326
}
302327

@@ -673,6 +698,20 @@ func nextLbActions(
673698
}
674699
}
675700

701+
// Find the Floating IPs that need to be changed
702+
_, assign := compare.Diff(
703+
desired.floatingIPs, actual.floatingIPs, func(ip string) string {
704+
return ip
705+
},
706+
)
707+
708+
for _, ip := range assign {
709+
next = append(next, actions.AssignFloatingIP(
710+
ip,
711+
actual.lb.UUID,
712+
))
713+
}
714+
676715
return next, nil
677716
}
678717

pkg/cloudscale_ccm/reconcile_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,9 @@ func TestActualState(t *testing.T) {
231231
},
232232
},
233233
)
234+
server.On("/v1/floating-ips", 200,
235+
[]cloudscale.FloatingIP{},
236+
)
234237
server.Start()
235238
defer server.Close()
236239

pkg/cloudscale_ccm/service_info.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ func (s serviceInfo) annotation(key string) string {
8686
return s.annotationOrDefault(key, "lb-standard")
8787
case LoadBalancerVIPAddresses:
8888
return s.annotationOrDefault(key, "[]")
89+
case LoadBalancerFloatingIPs:
90+
return s.annotationOrDefault(key, "[]")
8991
case LoadBalancerPoolAlgorithm:
9092
return s.annotationOrDefault(key, "round_robin")
9193
case LoadBalancerHealthMonitorDelayS:

0 commit comments

Comments
 (0)