Skip to content

Commit dcaae71

Browse files
authored
[feat] : add support for configuring nodebalancers with VPC (#337)
* intial commit for nb-vpc support * fix loadbalancer update and add documentation * add unittests for changes * add e2e test
1 parent b56ba08 commit dcaae71

File tree

12 files changed

+469
-29
lines changed

12 files changed

+469
-29
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ create-capl-cluster:
162162
.PHONY: patch-linode-ccm
163163
patch-linode-ccm:
164164
KUBECONFIG=$(KUBECONFIG_PATH) kubectl patch -n kube-system daemonset ccm-linode --type='json' -p="[{'op': 'replace', 'path': '/spec/template/spec/containers/0/image', 'value': '${IMG}'}]"
165+
KUBECONFIG=$(KUBECONFIG_PATH) kubectl patch -n kube-system daemonset ccm-linode --type='json' -p='[{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "LINODE_API_VERSION", "value": "v4beta"}}]'
165166
KUBECONFIG=$(KUBECONFIG_PATH) kubectl rollout status -n kube-system daemonset/ccm-linode --timeout=600s
166167
KUBECONFIG=$(KUBECONFIG_PATH) kubectl -n kube-system get daemonset/ccm-linode -o yaml
167168

cloud/annotations/annotations.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,9 @@ const (
3434
AnnLinodeHostUUID = "node.k8s.linode.com/host-uuid"
3535

3636
AnnLinodeNodeIPSharingUpdated = "node.k8s.linode.com/ip-sharing-updated"
37+
38+
NodeBalancerBackendIPv4Range = "service.beta.kubernetes.io/linode-loadbalancer-backend-ipv4-range"
39+
40+
NodeBalancerBackendVPCName = "service.beta.kubernetes.io/linode-loadbalancer-backend-vpc-name"
41+
NodeBalancerBackendSubnetName = "service.beta.kubernetes.io/linode-loadbalancer-backend-subnet-name"
3742
)

cloud/linode/fake_linode_test.go

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@ import (
1919
const apiVersion = "v4"
2020

2121
type fakeAPI struct {
22-
t *testing.T
23-
nb map[string]*linodego.NodeBalancer
24-
nbc map[string]*linodego.NodeBalancerConfig
25-
nbn map[string]*linodego.NodeBalancerNode
26-
fw map[int]*linodego.Firewall // map of firewallID -> firewall
27-
fwd map[int]map[int]*linodego.FirewallDevice // map of firewallID -> firewallDeviceID:FirewallDevice
22+
t *testing.T
23+
nb map[string]*linodego.NodeBalancer
24+
nbc map[string]*linodego.NodeBalancerConfig
25+
nbn map[string]*linodego.NodeBalancerNode
26+
fw map[int]*linodego.Firewall // map of firewallID -> firewall
27+
fwd map[int]map[int]*linodego.FirewallDevice // map of firewallID -> firewallDeviceID:FirewallDevice
28+
nbvpcc map[string]*linodego.NodeBalancerVPCConfig
29+
vpc map[int]*linodego.VPC
30+
subnet map[int]*linodego.VPCSubnet
2831

2932
requests map[fakeRequest]struct{}
3033
mux *http.ServeMux
@@ -44,6 +47,9 @@ func newFake(t *testing.T) *fakeAPI {
4447
nbn: make(map[string]*linodego.NodeBalancerNode),
4548
fw: make(map[int]*linodego.Firewall),
4649
fwd: make(map[int]map[int]*linodego.FirewallDevice),
50+
nbvpcc: make(map[string]*linodego.NodeBalancerVPCConfig),
51+
vpc: make(map[int]*linodego.VPC),
52+
subnet: make(map[int]*linodego.VPCSubnet),
4753
requests: make(map[fakeRequest]struct{}),
4854
mux: http.NewServeMux(),
4955
}
@@ -117,6 +123,54 @@ func (f *fakeAPI) setupRoutes() {
117123
_, _ = w.Write(rr)
118124
})
119125

126+
f.mux.HandleFunc("GET /v4/vpcs", func(w http.ResponseWriter, r *http.Request) {
127+
res := 0
128+
data := []linodego.VPC{}
129+
filter := r.Header.Get("X-Filter")
130+
if filter == "" {
131+
for _, v := range f.vpc {
132+
data = append(data, *v)
133+
}
134+
} else {
135+
var fs map[string]string
136+
err := json.Unmarshal([]byte(filter), &fs)
137+
if err != nil {
138+
f.t.Fatal(err)
139+
}
140+
for _, v := range f.vpc {
141+
if v.Label != "" && fs["label"] != "" && v.Label == fs["label"] {
142+
data = append(data, *v)
143+
}
144+
}
145+
}
146+
147+
resp := paginatedResponse[linodego.VPC]{
148+
Page: 1,
149+
Pages: 1,
150+
Results: res,
151+
Data: data,
152+
}
153+
rr, _ := json.Marshal(resp)
154+
_, _ = w.Write(rr)
155+
})
156+
157+
f.mux.HandleFunc("GET /v4/vpcs/{vpcId}/subnets", func(w http.ResponseWriter, r *http.Request) {
158+
res := 0
159+
vpcID, err := strconv.Atoi(r.PathValue("vpcId"))
160+
if err != nil {
161+
f.t.Fatal(err)
162+
}
163+
164+
resp := paginatedResponse[linodego.VPCSubnet]{
165+
Page: 1,
166+
Pages: 1,
167+
Results: res,
168+
Data: f.vpc[vpcID].Subnets,
169+
}
170+
rr, _ := json.Marshal(resp)
171+
_, _ = w.Write(rr)
172+
})
173+
120174
f.mux.HandleFunc("GET /v4/nodebalancers/{nodeBalancerId}", func(w http.ResponseWriter, r *http.Request) {
121175
nb, found := f.nb[r.PathValue("nodeBalancerId")]
122176
if !found {
@@ -462,6 +516,54 @@ func (f *fakeAPI) setupRoutes() {
462516
_, _ = w.Write(resp)
463517
})
464518

519+
f.mux.HandleFunc("POST /v4/vpcs", func(w http.ResponseWriter, r *http.Request) {
520+
vco := linodego.VPCCreateOptions{}
521+
if err := json.NewDecoder(r.Body).Decode(&vco); err != nil {
522+
f.t.Fatal(err)
523+
}
524+
525+
subnets := []linodego.VPCSubnet{}
526+
for _, s := range vco.Subnets {
527+
subnet := linodego.VPCSubnet{
528+
ID: rand.Intn(9999),
529+
IPv4: s.IPv4,
530+
Label: s.Label,
531+
}
532+
subnets = append(subnets, subnet)
533+
f.subnet[subnet.ID] = &subnet
534+
}
535+
vpc := linodego.VPC{
536+
ID: rand.Intn(9999),
537+
Label: vco.Label,
538+
Description: vco.Description,
539+
Region: vco.Region,
540+
Subnets: subnets,
541+
}
542+
543+
f.vpc[vpc.ID] = &vpc
544+
resp, err := json.Marshal(vpc)
545+
if err != nil {
546+
f.t.Fatal(err)
547+
}
548+
_, _ = w.Write(resp)
549+
})
550+
551+
f.mux.HandleFunc("DELETE /v4/vpcs/{vpcId}", func(w http.ResponseWriter, r *http.Request) {
552+
vpcid, err := strconv.Atoi(r.PathValue("vpcId"))
553+
if err != nil {
554+
f.t.Fatal(err)
555+
}
556+
557+
for k, v := range f.vpc {
558+
if v.ID == vpcid {
559+
for _, s := range v.Subnets {
560+
delete(f.subnet, s.ID)
561+
}
562+
delete(f.vpc, k)
563+
}
564+
}
565+
})
566+
465567
f.mux.HandleFunc("POST /v4/networking/firewalls/{firewallId}/devices", func(w http.ResponseWriter, r *http.Request) {
466568
fdco := linodego.FirewallDeviceCreateOptions{}
467569
if err := json.NewDecoder(r.Body).Decode(&fdco); err != nil {

cloud/linode/loadbalancers.go

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,18 @@ func (l *loadbalancers) updateNodeBalancer(
383383
}
384384
// Add all of the Nodes to the config
385385
newNBNodes := make([]linodego.NodeBalancerConfigRebuildNodeOptions, 0, len(nodes))
386+
subnetID := 0
387+
_, ok := service.GetAnnotations()[annotations.NodeBalancerBackendIPv4Range]
388+
if ok {
389+
id, err := l.getSubnetIDForSVC(ctx, service)
390+
if err != nil {
391+
sentry.CaptureError(ctx, err)
392+
return fmt.Errorf("Error getting subnet ID for service %s: %v", service.Name, err)
393+
}
394+
subnetID = id
395+
}
386396
for _, node := range nodes {
387-
newNodeOpts := l.buildNodeBalancerNodeConfigRebuildOptions(node, port.NodePort)
397+
newNodeOpts := l.buildNodeBalancerNodeConfigRebuildOptions(node, port.NodePort, subnetID)
388398
oldNodeID, ok := oldNBNodeIDs[newNodeOpts.Address]
389399
if ok {
390400
newNodeOpts.ID = oldNodeID
@@ -652,6 +662,20 @@ func (l *loadbalancers) createNodeBalancer(ctx context.Context, clusterName stri
652662
Type: nbType,
653663
}
654664

665+
backendIPv4Range, ok := service.GetAnnotations()[annotations.NodeBalancerBackendIPv4Range]
666+
if ok {
667+
subnetID, err := l.getSubnetIDForSVC(ctx, service)
668+
if err != nil {
669+
return nil, err
670+
}
671+
createOpts.VPCs = []linodego.NodeBalancerVPCOptions{
672+
{
673+
SubnetID: subnetID,
674+
IPv4Range: backendIPv4Range,
675+
},
676+
}
677+
}
678+
655679
fwid, ok := service.GetAnnotations()[annotations.AnnLinodeCloudFirewallID]
656680
if ok {
657681
firewallID, err := strconv.Atoi(fwid)
@@ -768,6 +792,28 @@ func (l *loadbalancers) addTLSCert(ctx context.Context, service *v1.Service, nbC
768792
return nil
769793
}
770794

795+
// getSubnetIDForSVC returns the subnet ID for the service's VPC and subnet.
796+
// By default, first VPCName and SubnetName are used to calculate subnet id for the service.
797+
// If the service has annotations specifying VPCName and SubnetName, they are used instead.
798+
func (l *loadbalancers) getSubnetIDForSVC(ctx context.Context, service *v1.Service) (int, error) {
799+
if Options.VPCNames == "" {
800+
return 0, fmt.Errorf("CCM not configured with VPC, cannot create NodeBalancer with specified annotation")
801+
}
802+
vpcName := strings.Split(Options.VPCNames, ",")[0]
803+
if specifiedVPCName, ok := service.GetAnnotations()[annotations.NodeBalancerBackendVPCName]; ok {
804+
vpcName = specifiedVPCName
805+
}
806+
vpcID, err := GetVPCID(ctx, l.client, vpcName)
807+
if err != nil {
808+
return 0, err
809+
}
810+
subnetName := strings.Split(Options.SubnetNames, ",")[0]
811+
if specifiedSubnetName, ok := service.GetAnnotations()[annotations.NodeBalancerBackendSubnetName]; ok {
812+
subnetName = specifiedSubnetName
813+
}
814+
return GetSubnetID(ctx, l.client, vpcID, subnetName)
815+
}
816+
771817
// buildLoadBalancerRequest returns a linodego.NodeBalancer
772818
// requests for service across nodes.
773819
func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterName string, service *v1.Service, nodes []*v1.Node) (*linodego.NodeBalancer, error) {
@@ -777,6 +823,16 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam
777823
ports := service.Spec.Ports
778824
configs := make([]*linodego.NodeBalancerConfigCreateOptions, 0, len(ports))
779825

826+
subnetID := 0
827+
_, ok := service.GetAnnotations()[annotations.NodeBalancerBackendIPv4Range]
828+
if ok {
829+
id, err := l.getSubnetIDForSVC(ctx, service)
830+
if err != nil {
831+
return nil, err
832+
}
833+
subnetID = id
834+
}
835+
780836
for _, port := range ports {
781837
if port.Protocol == v1.ProtocolUDP {
782838
return nil, fmt.Errorf("error creating NodeBalancer Config: ports with the UDP protocol are not supported")
@@ -789,7 +845,7 @@ func (l *loadbalancers) buildLoadBalancerRequest(ctx context.Context, clusterNam
789845
createOpt := config.GetCreateOptions()
790846

791847
for _, n := range nodes {
792-
createOpt.Nodes = append(createOpt.Nodes, l.buildNodeBalancerNodeConfigRebuildOptions(n, port.NodePort).NodeBalancerNodeCreateOptions)
848+
createOpt.Nodes = append(createOpt.Nodes, l.buildNodeBalancerNodeConfigRebuildOptions(n, port.NodePort, subnetID).NodeBalancerNodeCreateOptions)
793849
}
794850

795851
configs = append(configs, &createOpt)
@@ -809,17 +865,21 @@ func coerceString(s string, minLen, maxLen int, padding string) string {
809865
return s
810866
}
811867

812-
func (l *loadbalancers) buildNodeBalancerNodeConfigRebuildOptions(node *v1.Node, nodePort int32) linodego.NodeBalancerConfigRebuildNodeOptions {
813-
return linodego.NodeBalancerConfigRebuildNodeOptions{
868+
func (l *loadbalancers) buildNodeBalancerNodeConfigRebuildOptions(node *v1.Node, nodePort int32, subnetID int) linodego.NodeBalancerConfigRebuildNodeOptions {
869+
nodeOptions := linodego.NodeBalancerConfigRebuildNodeOptions{
814870
NodeBalancerNodeCreateOptions: linodego.NodeBalancerNodeCreateOptions{
815-
Address: fmt.Sprintf("%v:%v", getNodePrivateIP(node), nodePort),
871+
Address: fmt.Sprintf("%v:%v", getNodePrivateIP(node, subnetID), nodePort),
816872
// NodeBalancer backends must be 3-32 chars in length
817873
// If < 3 chars, pad node name with "node-" prefix
818874
Label: coerceString(node.Name, 3, 32, "node-"),
819875
Mode: "accept",
820876
Weight: 100,
821877
},
822878
}
879+
if subnetID != 0 {
880+
nodeOptions.NodeBalancerNodeCreateOptions.SubnetID = subnetID
881+
}
882+
return nodeOptions
823883
}
824884

825885
func (l *loadbalancers) retrieveKubeClient() error {
@@ -926,13 +986,17 @@ func getPortConfigAnnotation(service *v1.Service, port int) (portConfigAnnotatio
926986
return annotation, nil
927987
}
928988

929-
// getNodePrivateIP should provide the Linode Private IP the NodeBalance
930-
// will communicate with. When using a VLAN or VPC for the Kubernetes cluster
931-
// network, this will not be the NodeInternalIP, so this prefers an annotation
932-
// cluster operators may specify in such a situation.
933-
func getNodePrivateIP(node *v1.Node) string {
934-
if address, exists := node.Annotations[annotations.AnnLinodeNodePrivateIP]; exists {
935-
return address
989+
// getNodePrivateIP provides the Linode Backend IP the NodeBalancer will communicate with.
990+
// If a service specifies NodeBalancerBackendIPv4Range annotation, it will
991+
// use NodeInternalIP of node.
992+
// For services which don't have NodeBalancerBackendIPv4Range annotation,
993+
// Backend IP can be overwritten to the one specified using AnnLinodeNodePrivateIP
994+
// annotation over the NodeInternalIP.
995+
func getNodePrivateIP(node *v1.Node, subnetID int) string {
996+
if subnetID == 0 {
997+
if address, exists := node.Annotations[annotations.AnnLinodeNodePrivateIP]; exists {
998+
return address
999+
}
9361000
}
9371001

9381002
klog.Infof("Node %s, assigned IP addresses: %v", node.Name, node.Status.Addresses)

0 commit comments

Comments
 (0)