Skip to content

Commit 83588a6

Browse files
authored
[feat] Exclude node by an annotation (#407)
* Exclude node by an annotation * Fixup * add a doc
1 parent e129832 commit 83588a6

File tree

4 files changed

+288
-1
lines changed

4 files changed

+288
-1
lines changed

cloud/annotations/annotations.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const (
4242
AnnLinodeHostUUID = "node.k8s.linode.com/host-uuid"
4343

4444
AnnLinodeNodeIPSharingUpdated = "node.k8s.linode.com/ip-sharing-updated"
45+
AnnExcludeNodeFromNb = "node.k8s.linode.com/exclude-from-nb"
4546

4647
NodeBalancerBackendIPv4Range = "service.beta.kubernetes.io/linode-loadbalancer-backend-ipv4-range"
4748

cloud/linode/loadbalancers.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,10 @@ func (l *loadbalancers) updateNodeBalancer(
463463
subnetID = id
464464
}
465465
for _, node := range nodes {
466+
if _, ok := node.Annotations[annotations.AnnExcludeNodeFromNb]; ok {
467+
klog.Infof("Node %s is excluded from NodeBalancer by annotation, skipping", node.Name)
468+
continue
469+
}
466470
newNodeOpts := l.buildNodeBalancerNodeConfigRebuildOptions(node, port.NodePort, subnetID, newNBCfg.Protocol)
467471
oldNodeID, ok := oldNBNodeIDs[newNodeOpts.Address]
468472
if ok {
@@ -472,7 +476,6 @@ func (l *loadbalancers) updateNodeBalancer(
472476
}
473477
newNBNodes = append(newNBNodes, newNodeOpts)
474478
}
475-
476479
// If there's no existing config, create it
477480
var rebuildOpts linodego.NodeBalancerConfigRebuildOptions
478481
if currentNBCfg == nil {
@@ -1031,6 +1034,10 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam
10311034
createOpt := config.GetCreateOptions()
10321035

10331036
for _, n := range nodes {
1037+
if _, ok := n.Annotations[annotations.AnnExcludeNodeFromNb]; ok {
1038+
klog.Infof("Node %s is excluded from NodeBalancer by annotation, skipping", n.Name)
1039+
continue
1040+
}
10341041
createOpt.Nodes = append(createOpt.Nodes, l.buildNodeBalancerNodeConfigRebuildOptions(n, port.NodePort, subnetID, config.Protocol).NodeBalancerNodeCreateOptions)
10351042
}
10361043

cloud/linode/loadbalancers_test.go

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"os"
1414
"reflect"
1515
"regexp"
16+
"slices"
1617
"strconv"
1718
"strings"
1819
"testing"
@@ -297,6 +298,10 @@ func TestCCMLoadBalancers(t *testing.T) {
297298
name: "Update Load Balancer - No Nodes",
298299
f: testUpdateLoadBalancerNoNodes,
299300
},
301+
{
302+
name: "Update Load Balancer - Node excluded by annotation",
303+
f: testUpdateLoadBalancerNodeExcludedByAnnotation,
304+
},
300305
{
301306
name: "Create Load Balancer - Very long Service name",
302307
f: testVeryLongServiceName,
@@ -4356,6 +4361,28 @@ func testMakeLoadBalancerStatusEnvVar(t *testing.T, client *linodego.Client, _ *
43564361
os.Unsetenv("LINODE_HOSTNAME_ONLY_INGRESS")
43574362
}
43584363

4364+
func getLatestNbNodesForService(t *testing.T, client *linodego.Client, svc *v1.Service, lb *loadbalancers) []linodego.NodeBalancerNode {
4365+
t.Helper()
4366+
nb, err := lb.getNodeBalancerByStatus(t.Context(), svc)
4367+
if err != nil {
4368+
t.Fatalf("expected no error got %v", err)
4369+
}
4370+
cfgs, errConfigs := client.ListNodeBalancerConfigs(t.Context(), nb.ID, nil)
4371+
if errConfigs != nil {
4372+
t.Fatalf("expected no error getting configs, got %v", errConfigs)
4373+
}
4374+
slices.SortFunc(cfgs, func(a, b linodego.NodeBalancerConfig) int {
4375+
return a.ID - b.ID
4376+
})
4377+
4378+
// Verify nodes were created correctly (only non-excluded nodes)
4379+
nodeBalancerNodes, err := client.ListNodeBalancerNodes(t.Context(), nb.ID, cfgs[0].ID, nil)
4380+
if err != nil {
4381+
t.Fatalf("expected no error got %v", err)
4382+
}
4383+
return nodeBalancerNodes
4384+
}
4385+
43594386
func testCleanupDoesntCall(t *testing.T, client *linodego.Client, fakeAPI *fakeAPI) {
43604387
t.Helper()
43614388

@@ -4409,6 +4436,246 @@ func testCleanupDoesntCall(t *testing.T, client *linodego.Client, fakeAPI *fakeA
44094436
})
44104437
}
44114438

4439+
func testUpdateLoadBalancerNodeExcludedByAnnotation(t *testing.T, client *linodego.Client, _ *fakeAPI) {
4440+
t.Helper()
4441+
svc := &v1.Service{
4442+
ObjectMeta: metav1.ObjectMeta{
4443+
Name: randString(),
4444+
UID: "foobar123",
4445+
Annotations: map[string]string{},
4446+
},
4447+
Spec: v1.ServiceSpec{
4448+
Ports: []v1.ServicePort{
4449+
{
4450+
Name: randString(),
4451+
Protocol: "http",
4452+
Port: int32(80),
4453+
NodePort: int32(8080),
4454+
},
4455+
},
4456+
},
4457+
}
4458+
4459+
lb, assertion := newLoadbalancers(client, "us-west").(*loadbalancers)
4460+
if !assertion {
4461+
t.Error("type assertion failed")
4462+
}
4463+
defer func() {
4464+
_ = lb.EnsureLoadBalancerDeleted(t.Context(), "linodelb", svc)
4465+
}()
4466+
4467+
fakeClientset := fake.NewSimpleClientset()
4468+
lb.kubeClient = fakeClientset
4469+
4470+
nodeBalancer, err := client.CreateNodeBalancer(t.Context(), linodego.NodeBalancerCreateOptions{
4471+
Region: lb.zone,
4472+
})
4473+
if err != nil {
4474+
t.Fatalf("failed to create NodeBalancer: %s", err)
4475+
}
4476+
svc.Status.LoadBalancer = *makeLoadBalancerStatus(svc, nodeBalancer)
4477+
stubService(fakeClientset, svc)
4478+
svc.SetAnnotations(map[string]string{
4479+
annotations.AnnLinodeNodeBalancerID: strconv.Itoa(nodeBalancer.ID),
4480+
})
4481+
4482+
// setup done, test ensure/update
4483+
nodes := []*v1.Node{
4484+
{
4485+
ObjectMeta: metav1.ObjectMeta{
4486+
Name: "node-1",
4487+
},
4488+
Status: v1.NodeStatus{
4489+
Addresses: []v1.NodeAddress{
4490+
{
4491+
Type: v1.NodeInternalIP,
4492+
Address: "127.0.0.1",
4493+
},
4494+
},
4495+
},
4496+
},
4497+
{
4498+
ObjectMeta: metav1.ObjectMeta{
4499+
Name: "node-2",
4500+
Annotations: map[string]string{
4501+
annotations.AnnExcludeNodeFromNb: "true",
4502+
},
4503+
},
4504+
Status: v1.NodeStatus{
4505+
Addresses: []v1.NodeAddress{
4506+
{
4507+
Type: v1.NodeInternalIP,
4508+
Address: "127.0.0.2",
4509+
},
4510+
},
4511+
},
4512+
},
4513+
{
4514+
ObjectMeta: metav1.ObjectMeta{
4515+
Name: "node-3",
4516+
},
4517+
Status: v1.NodeStatus{
4518+
Addresses: []v1.NodeAddress{
4519+
{
4520+
Type: v1.NodeInternalIP,
4521+
Address: "127.0.0.3",
4522+
},
4523+
},
4524+
},
4525+
},
4526+
}
4527+
4528+
// Test initial creation - should only create nodes that aren't excluded
4529+
_, err = lb.EnsureLoadBalancer(t.Context(), "linodelb", svc, nodes)
4530+
if err != nil {
4531+
t.Fatalf("expected no error got %v", err)
4532+
}
4533+
nodeBalancerNodes := getLatestNbNodesForService(t, client, svc, lb)
4534+
// Should have only 2 nodes (node-1 and node-3), since node-2 is excluded
4535+
if len(nodeBalancerNodes) != 2 {
4536+
t.Errorf("expected 2 nodes, got %d", len(nodeBalancerNodes))
4537+
}
4538+
4539+
// Verify excluded node is not present
4540+
for _, nbNode := range nodeBalancerNodes {
4541+
if strings.Contains(nbNode.Label, "node-2") {
4542+
t.Errorf("excluded node 'node-2' should not be present in nodeBalancer nodes")
4543+
}
4544+
}
4545+
4546+
// Test Update operation
4547+
updatedNodes := []*v1.Node{
4548+
{
4549+
ObjectMeta: metav1.ObjectMeta{
4550+
Name: "node-1",
4551+
Annotations: map[string]string{
4552+
annotations.AnnExcludeNodeFromNb: "true", // Now exclude node-1
4553+
},
4554+
},
4555+
Status: v1.NodeStatus{
4556+
Addresses: []v1.NodeAddress{
4557+
{
4558+
Type: v1.NodeInternalIP,
4559+
Address: "127.0.0.1",
4560+
},
4561+
},
4562+
},
4563+
},
4564+
{
4565+
ObjectMeta: metav1.ObjectMeta{
4566+
Name: "node-2",
4567+
Annotations: map[string]string{
4568+
annotations.AnnExcludeNodeFromNb: "true", // Still excluded
4569+
},
4570+
},
4571+
Status: v1.NodeStatus{
4572+
Addresses: []v1.NodeAddress{
4573+
{
4574+
Type: v1.NodeInternalIP,
4575+
Address: "127.0.0.2",
4576+
},
4577+
},
4578+
},
4579+
},
4580+
{
4581+
ObjectMeta: metav1.ObjectMeta{
4582+
Name: "node-3",
4583+
},
4584+
Status: v1.NodeStatus{
4585+
Addresses: []v1.NodeAddress{
4586+
{
4587+
Type: v1.NodeInternalIP,
4588+
Address: "127.0.0.3",
4589+
},
4590+
},
4591+
},
4592+
},
4593+
}
4594+
4595+
// Update the load balancer with updated nodes
4596+
if err = lb.UpdateLoadBalancer(t.Context(), "linodelb", svc, updatedNodes); err != nil {
4597+
t.Fatalf("unexpected error updating LoadBalancer: %v", err)
4598+
}
4599+
4600+
// Verify nodes were updated correctly
4601+
nodeBalancerNodesAfterUpdate := getLatestNbNodesForService(t, client, svc, lb)
4602+
4603+
// Should have only 1 node (node-3), since both node-1 and node-2 are excluded
4604+
if len(nodeBalancerNodesAfterUpdate) != 1 {
4605+
t.Errorf("expected 1 node after update, got %d", len(nodeBalancerNodesAfterUpdate))
4606+
}
4607+
4608+
// Verify excluded nodes are not present
4609+
for _, nbNode := range nodeBalancerNodesAfterUpdate {
4610+
if strings.Contains(nbNode.Label, "node-1") || strings.Contains(nbNode.Label, "node-2") {
4611+
t.Errorf("excluded nodes should not be present in nodeBalancer nodes after update")
4612+
}
4613+
}
4614+
4615+
// Test edge case: all nodes excluded
4616+
allExcludedNodes := []*v1.Node{
4617+
{
4618+
ObjectMeta: metav1.ObjectMeta{
4619+
Name: "node-1",
4620+
Annotations: map[string]string{
4621+
annotations.AnnExcludeNodeFromNb: "true",
4622+
},
4623+
},
4624+
Status: v1.NodeStatus{
4625+
Addresses: []v1.NodeAddress{
4626+
{
4627+
Type: v1.NodeInternalIP,
4628+
Address: "127.0.0.1",
4629+
},
4630+
},
4631+
},
4632+
},
4633+
{
4634+
ObjectMeta: metav1.ObjectMeta{
4635+
Name: "node-2",
4636+
Annotations: map[string]string{
4637+
annotations.AnnExcludeNodeFromNb: "true",
4638+
},
4639+
},
4640+
Status: v1.NodeStatus{
4641+
Addresses: []v1.NodeAddress{
4642+
{
4643+
Type: v1.NodeInternalIP,
4644+
Address: "127.0.0.2",
4645+
},
4646+
},
4647+
},
4648+
},
4649+
{
4650+
ObjectMeta: metav1.ObjectMeta{
4651+
Name: "node-3",
4652+
Annotations: map[string]string{
4653+
annotations.AnnExcludeNodeFromNb: "true",
4654+
},
4655+
},
4656+
Status: v1.NodeStatus{
4657+
Addresses: []v1.NodeAddress{
4658+
{
4659+
Type: v1.NodeInternalIP,
4660+
Address: "127.0.0.3",
4661+
},
4662+
},
4663+
},
4664+
},
4665+
}
4666+
4667+
if err = lb.UpdateLoadBalancer(t.Context(), "linodelb", svc, allExcludedNodes); err != nil {
4668+
t.Errorf("expected no error when all nodes are excluded, got %v", err)
4669+
}
4670+
4671+
// Verify nodes were updated correctly
4672+
nodeBalancerNodesAfterUpdate = getLatestNbNodesForService(t, client, svc, lb)
4673+
// Should have only 0 node (node-3), since all nodes are excluded
4674+
if len(nodeBalancerNodesAfterUpdate) != 0 {
4675+
t.Errorf("expected 0 nodes after update, got %d", len(nodeBalancerNodesAfterUpdate))
4676+
}
4677+
}
4678+
44124679
func testUpdateLoadBalancerNoNodes(t *testing.T, client *linodego.Client, _ *fakeAPI) {
44134680
t.Helper()
44144681

docs/configuration/loadbalancer.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,18 @@ metadata:
243243
service.beta.kubernetes.io/linode-loadbalancer-tags: "production,web-tier"
244244
```
245245

246+
### Excluding nodes from nodebalancer
247+
Add the an annotation to the node object to exclude
248+
249+
```yaml
250+
apiVersion: v1
251+
kind: Node
252+
metadata:
253+
name: node-to-exclude
254+
annotations:
255+
node.k8s.linode.com/exclude-from-nb: "true"
256+
```
257+
246258
## Related Documentation
247259

248260
- [Service Annotations](annotations.md)

0 commit comments

Comments
 (0)