Skip to content

Commit 7a5d9e7

Browse files
hcwagnerHenry Wagner
andauthored
[feat] Add IP Reservation support for Services backed by NodeBalancers (#437)
* add annotation for Service reserved IP * Add creation/update handling for Service LB reserved IP annotation * Add testcases for reserved IP support to CCM * Test fails when curl command is ran against the service ip. Added sleep delay for the service to come up * Added firewall in lb-created-with-reserved-ip-linode-range and lb-created-with-reserved-ip-nb-range testcases. Removed the liveness check in lb-created-with-specified-nb-id-reserved and lb-created-with-reserved-ip-and-nb-id-annotations. * add unit tests and mocks * add reserve IP fake API endpoint * add unit test for reserved IP LB creation/update * add annotation for Service reserved IP * Add creation/update handling for Service LB reserved IP annotation * Add testcases for reserved IP support to CCM * Added firewall in lb-created-with-reserved-ip-linode-range and lb-created-with-reserved-ip-nb-range testcases. Removed the liveness check in lb-created-with-specified-nb-id-reserved and lb-created-with-reserved-ip-and-nb-id-annotations. * add initial unit tests and mocks * add reserve IP fake API endpoint * add unit test for reserved IP LB creation/update * update lb docs --------- Co-authored-by: Henry Wagner <[email protected]>
1 parent 39a1d7b commit 7a5d9e7

File tree

35 files changed

+2774
-5
lines changed

35 files changed

+2774
-5
lines changed

cloud/annotations/annotations.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ const (
2525
// same client IP. Options are a number between 1-20, or 0 to disable. Defaults to 20.
2626
AnnLinodeThrottle = "service.beta.kubernetes.io/linode-loadbalancer-throttle"
2727

28+
// AnnLinodeLoadBalancerIPv4 is the annotation used to specify a reserved IPv4 address
29+
// for the NodeBalancer. If not specified, Linode will automatically assign an IPv4 address.
30+
AnnLinodeLoadBalancerReservedIPv4 = "service.beta.kubernetes.io/linode-loadbalancer-reserved-ipv4"
31+
2832
AnnLinodeLoadBalancerPreserve = "service.beta.kubernetes.io/linode-loadbalancer-preserve"
2933
AnnLinodeNodeBalancerID = "service.beta.kubernetes.io/linode-loadbalancer-nodebalancer-id"
3034
AnnLinodeNodeBalancerType = "service.beta.kubernetes.io/linode-loadbalancer-nodebalancer-type"

cloud/linode/client/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ type Client interface {
6666
GetFirewall(context.Context, int) (*linodego.Firewall, error)
6767
UpdateFirewallRules(context.Context, int, linodego.FirewallRuleSet) (*linodego.FirewallRuleSet, error)
6868

69+
ReserveIPAddress(ctx context.Context, opts linodego.ReserveIPOptions) (*linodego.InstanceIP, error)
70+
DeleteReservedIPAddress(ctx context.Context, ipAddress string) error
71+
6972
GetProfile(ctx context.Context) (*linodego.Profile, error)
7073
}
7174

cloud/linode/client/client_with_metrics.go

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cloud/linode/client/mocks/mock_client.go

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cloud/linode/fake_linode_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,34 @@ func (f *fakeAPI) setupRoutes() {
305305
_, _ = w.Write(rr)
306306
})
307307

308+
f.mux.HandleFunc("POST /v4/networking/reserved/ips", func(w http.ResponseWriter, r *http.Request) {
309+
rico := linodego.ReserveIPOptions{}
310+
if err := json.NewDecoder(r.Body).Decode(&rico); err != nil {
311+
f.t.Fatal(err)
312+
}
313+
314+
ip := net.IPv4(byte(rand.Intn(100)), byte(rand.Intn(100)), byte(rand.Intn(100)), byte(rand.Intn(100))).String()
315+
316+
rip := linodego.InstanceIP{
317+
Address: ip,
318+
SubnetMask: "32",
319+
Prefix: 0,
320+
Type: linodego.IPTypeIPv4,
321+
Public: true,
322+
RDNS: "",
323+
LinodeID: 0,
324+
Region: rico.Region,
325+
VPCNAT1To1: nil,
326+
Reserved: true,
327+
}
328+
329+
resp, err := json.Marshal(rip)
330+
if err != nil {
331+
f.t.Fatal(err)
332+
}
333+
_, _ = w.Write(resp)
334+
})
335+
308336
f.mux.HandleFunc("POST /v4/nodebalancers", func(w http.ResponseWriter, r *http.Request) {
309337
nbco := linodego.NodeBalancerCreateOptions{}
310338
if err := json.NewDecoder(r.Body).Decode(&nbco); err != nil {

cloud/linode/loadbalancers.go

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ import (
3434
)
3535

3636
var (
37-
errNoNodesAvailable = errors.New("no nodes available for nodebalancer")
38-
maxConnThrottleStringLen int = 20
37+
errNoNodesAvailable = errors.New("no nodes available for nodebalancer")
38+
maxConnThrottleStringLen int = 20
39+
eventIPChangeIgnoredWarning = "nodebalancer-ipv4-change-ignored"
3940

4041
// validProtocols is a map of valid protocols
4142
validProtocols = map[string]bool{
@@ -362,6 +363,30 @@ func (l *loadbalancers) EnsureLoadBalancer(ctx context.Context, clusterName stri
362363
return lbStatus, nil
363364
}
364365

366+
func (l *loadbalancers) createIPChangeWarningEvent(ctx context.Context, service *v1.Service, nb *linodego.NodeBalancer, newIP string) {
367+
_, err := l.kubeClient.CoreV1().Events(service.Namespace).Create(ctx, &v1.Event{
368+
ObjectMeta: metav1.ObjectMeta{
369+
Name: fmt.Sprintf("%s-%d", eventIPChangeIgnoredWarning, time.Now().Unix()),
370+
Namespace: service.Namespace,
371+
},
372+
InvolvedObject: v1.ObjectReference{
373+
Kind: "Service",
374+
Namespace: service.Namespace,
375+
Name: service.Name,
376+
UID: service.UID,
377+
},
378+
Type: "Warning",
379+
Reason: "NodeBalancerIPChangeIgnored",
380+
Message: fmt.Sprintf("IPv4 annotation changed to %s, but NodeBalancer (%d) IP cannot be updated after creation. It will remain %s", newIP, nb.ID, *nb.IPv4),
381+
Source: v1.EventSource{
382+
Component: "linode-cloud-controller-manager",
383+
},
384+
}, metav1.CreateOptions{})
385+
if err != nil {
386+
klog.Errorf("failed to create NodeBalancerIPChangeIgnored event for service %s: %s", getServiceNn(service), err)
387+
}
388+
}
389+
365390
func (l *loadbalancers) updateNodeBalancer(
366391
ctx context.Context,
367392
clusterName string,
@@ -373,6 +398,16 @@ func (l *loadbalancers) updateNodeBalancer(
373398
return fmt.Errorf("%w: service %s", errNoNodesAvailable, getServiceNn(service))
374399
}
375400

401+
// Check for IPv4 annotation change
402+
if ipv4, ok := service.GetAnnotations()[annotations.AnnLinodeLoadBalancerReservedIPv4]; ok && ipv4 != *nb.IPv4 {
403+
// Log the error in the CCM's logfile
404+
klog.Warningf("IPv4 annotation has changed for service (%s) from %s to %s, but NodeBalancer (%d) IP cannot be updated after creation",
405+
getServiceNn(service), *nb.IPv4, ipv4, nb.ID)
406+
407+
// Issue a k8s cluster event warning
408+
l.createIPChangeWarningEvent(ctx, service, nb, ipv4)
409+
}
410+
376411
connThrottle := getConnectionThrottle(service)
377412
if connThrottle != nb.ClientConnThrottle {
378413
update := nb.GetUpdateOptions()
@@ -839,6 +874,11 @@ func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName stri
839874
}
840875
}
841876

877+
// Check for static IPv4 address annotation
878+
if ipv4, ok := service.GetAnnotations()[annotations.AnnLinodeLoadBalancerReservedIPv4]; ok {
879+
createOpts.IPv4 = &ipv4
880+
}
881+
842882
fwid, ok := service.GetAnnotations()[annotations.AnnLinodeCloudFirewallID]
843883
if ok {
844884
firewallID, err := strconv.Atoi(fwid)

cloud/linode/loadbalancers_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ func TestCCMLoadBalancers(t *testing.T) {
195195
name: "Create Load Balancer With Global Tags set",
196196
f: testCreateNodeBalancerWithGlobalTags,
197197
},
198+
{
199+
name: "Create Load Balancer With Reserved IP",
200+
f: testCreateNodeBalancerWithReservedIP,
201+
},
198202
{
199203
name: "Update Load Balancer - Add Node",
200204
f: testUpdateLoadBalancerAddNode,
@@ -255,6 +259,10 @@ func TestCCMLoadBalancers(t *testing.T) {
255259
name: "Update Load Balancer - Add a new Firewall ACL",
256260
f: testUpdateLoadBalancerAddNewFirewallACL,
257261
},
262+
{
263+
name: "Update Load Balancer - Add Reserved IP",
264+
f: testUpdateLoadBalancerAddReservedIP,
265+
},
258266
{
259267
name: "Build Load Balancer Request",
260268
f: testBuildLoadBalancerRequest,
@@ -447,6 +455,18 @@ func testCreateNodeBalancer(t *testing.T, client *linodego.Client, _ *fakeAPI, a
447455
return nil
448456
}
449457

458+
func testCreateNodeBalancerWithReservedIP(t *testing.T, client *linodego.Client, f *fakeAPI) {
459+
t.Helper()
460+
461+
annMap := map[string]string{
462+
annotations.AnnLinodeLoadBalancerReservedIPv4: "156.1.1.101",
463+
}
464+
err := testCreateNodeBalancer(t, client, f, annMap, nil)
465+
if err != nil {
466+
t.Fatalf("expected a nil error, got %v", err)
467+
}
468+
}
469+
450470
func testCreateNodeBalancerWithOutFirewall(t *testing.T, client *linodego.Client, f *fakeAPI) {
451471
t.Helper()
452472

@@ -2987,6 +3007,93 @@ func testUpdateLoadBalancerDeleteFirewallRemoveID(t *testing.T, client *linodego
29873007
}
29883008
}
29893009

3010+
func testUpdateLoadBalancerAddReservedIP(t *testing.T, client *linodego.Client, fakeAPI *fakeAPI) {
3011+
t.Helper()
3012+
clusterName := "linodelb"
3013+
region := "us-west"
3014+
3015+
svc := &v1.Service{
3016+
ObjectMeta: metav1.ObjectMeta{
3017+
Name: randString(),
3018+
UID: "foobar123",
3019+
Annotations: map[string]string{},
3020+
},
3021+
Spec: v1.ServiceSpec{
3022+
Ports: []v1.ServicePort{
3023+
{
3024+
Name: randString(),
3025+
Protocol: "http",
3026+
Port: int32(80),
3027+
NodePort: int32(8080),
3028+
},
3029+
},
3030+
},
3031+
}
3032+
3033+
nodes := []*v1.Node{
3034+
{
3035+
Status: v1.NodeStatus{
3036+
Addresses: []v1.NodeAddress{
3037+
{
3038+
Type: v1.NodeInternalIP,
3039+
Address: "127.0.0.1",
3040+
},
3041+
},
3042+
},
3043+
},
3044+
}
3045+
3046+
lb, assertion := newLoadbalancers(client, region).(*loadbalancers)
3047+
if !assertion {
3048+
t.Error("type assertion failed")
3049+
}
3050+
defer func() {
3051+
_ = lb.EnsureLoadBalancerDeleted(t.Context(), clusterName, svc)
3052+
}()
3053+
3054+
fakeClientset := fake.NewSimpleClientset()
3055+
lb.kubeClient = fakeClientset
3056+
3057+
nodeBalancer, err := client.CreateNodeBalancer(t.Context(), linodego.NodeBalancerCreateOptions{
3058+
Region: lb.zone,
3059+
})
3060+
if err != nil {
3061+
t.Fatalf("failed to create NodeBalancer: %s", err)
3062+
}
3063+
3064+
initialIP := *nodeBalancer.IPv4
3065+
svc.Status.LoadBalancer = *makeLoadBalancerStatus(svc, nodeBalancer)
3066+
3067+
ipaddr, err := client.ReserveIPAddress(t.Context(), linodego.ReserveIPOptions{
3068+
Region: lb.zone,
3069+
})
3070+
if err != nil {
3071+
t.Fatalf("failed to reserve IP address: %s", err)
3072+
}
3073+
3074+
stubService(fakeClientset, svc)
3075+
svc.SetAnnotations(map[string]string{
3076+
annotations.AnnLinodeLoadBalancerReservedIPv4: ipaddr.Address,
3077+
})
3078+
3079+
err = lb.UpdateLoadBalancer(t.Context(), "", svc, nodes)
3080+
if err != nil {
3081+
t.Errorf("UpdateLoadBalancer returned an error while updated annotations: %s", err)
3082+
}
3083+
3084+
status, _, err := lb.GetLoadBalancer(t.Context(), clusterName, svc)
3085+
if status.Ingress[0].IP != initialIP {
3086+
t.Fatalf("IP should not have changed in service status: %s", err)
3087+
}
3088+
3089+
event, _ := fakeClientset.CoreV1().Events("").Get(t.Context(),
3090+
eventIPChangeIgnoredWarning,
3091+
metav1.GetOptions{})
3092+
if event == nil {
3093+
t.Fatalf("failed to generate %s event: %s", eventIPChangeIgnoredWarning, err)
3094+
}
3095+
}
3096+
29903097
func testUpdateLoadBalancerAddNodeBalancerID(t *testing.T, client *linodego.Client, fakeAPI *fakeAPI) {
29913098
t.Helper()
29923099

cloud/nodeipam/ipam/cloud_allocator.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ type cloudAllocator struct {
7373
}
7474

7575
const (
76-
providerIDPrefix = "linode://"
77-
ipv6BitLen = 128
78-
ipv6PodCIDRMaskSize = 112
76+
providerIDPrefix = "linode://"
77+
ipv6BitLen = 128
78+
ipv6PodCIDRMaskSize = 112
7979
)
8080

8181
var _ CIDRAllocator = &cloudAllocator{}

docs/configuration/annotations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ The keys and the values in [annotations must be strings](https://kubernetes.io/d
4343
| `backend-ipv4-range` | string | | The IPv4 range from VPC subnet to be applied to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) |
4444
| `backend-vpc-name` | string | | VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) |
4545
| `backend-subnet-name` | string | | Subnet within VPC which is connected to the NodeBalancer backend. See [Nodebalancer VPC Configuration](#nodebalancer-vpc-configuration) |
46+
| `reserved-ipv4` | string | | An existing Reserved IPv4 address that wil be used to initialize the NodeBalancer instance. See [LoadBalancer Configuration](loadbalancer.md#reserved-ipv4-addresses)) |
4647

4748
### Port Specific Configuration
4849

docs/configuration/loadbalancer.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,17 @@ metadata:
211211
service.beta.kubernetes.io/linode-loadbalancer-nodebalancer-id: "12345"
212212
```
213213

214+
### Reserved IPv4 addresses
215+
216+
Create an new NodeBalancer with an existing Reserved IPv4 Address:
217+
```
218+
metadata:
219+
annotations:
220+
service.beta.kubernetes.io/linode-loadbalancer-reserved-ipv4: "100.100.100.100"
221+
```
222+
The annotation must be present when the Service is created in order to take effect.
223+
224+
214225
### NodeBalancer Preservation
215226

216227
Prevent NodeBalancer deletion when service is deleted:

0 commit comments

Comments
 (0)