diff --git a/cloud/linode/client/client.go b/cloud/linode/client/client.go index 6599839b..def8097e 100644 --- a/cloud/linode/client/client.go +++ b/cloud/linode/client/client.go @@ -35,6 +35,7 @@ type Client interface { ListVPCs(context.Context, *linodego.ListOptions) ([]linodego.VPC, error) ListVPCIPAddresses(context.Context, int, *linodego.ListOptions) ([]linodego.VPCIP, error) + ListVPCSubnets(context.Context, int, *linodego.ListOptions) ([]linodego.VPCSubnet, error) CreateNodeBalancer(context.Context, linodego.NodeBalancerCreateOptions) (*linodego.NodeBalancer, error) GetNodeBalancer(context.Context, int) (*linodego.NodeBalancer, error) diff --git a/cloud/linode/client/client_with_metrics.go b/cloud/linode/client/client_with_metrics.go index d87e0bbd..4812e296 100644 --- a/cloud/linode/client/client_with_metrics.go +++ b/cloud/linode/client/client_with_metrics.go @@ -332,6 +332,19 @@ func (_d ClientWithPrometheus) ListVPCIPAddresses(ctx context.Context, i1 int, l return _d.base.ListVPCIPAddresses(ctx, i1, lp1) } +// ListVPCSubnets implements Client +func (_d ClientWithPrometheus) ListVPCSubnets(ctx context.Context, i1 int, lp1 *linodego.ListOptions) (va1 []linodego.VPCSubnet, err error) { + defer func() { + result := "ok" + if err != nil { + result = "error" + } + + ClientMethodCounterVec.WithLabelValues("ListVPCSubnets", result).Inc() + }() + return _d.base.ListVPCSubnets(ctx, i1, lp1) +} + // ListVPCs implements Client func (_d ClientWithPrometheus) ListVPCs(ctx context.Context, lp1 *linodego.ListOptions) (va1 []linodego.VPC, err error) { defer func() { diff --git a/cloud/linode/client/mocks/mock_client.go b/cloud/linode/client/mocks/mock_client.go index c986aef2..bea9ea02 100644 --- a/cloud/linode/client/mocks/mock_client.go +++ b/cloud/linode/client/mocks/mock_client.go @@ -375,6 +375,21 @@ func (mr *MockClientMockRecorder) ListVPCIPAddresses(arg0, arg1, arg2 interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListVPCIPAddresses", reflect.TypeOf((*MockClient)(nil).ListVPCIPAddresses), arg0, arg1, arg2) } +// ListVPCSubnets mocks base method. +func (m *MockClient) ListVPCSubnets(arg0 context.Context, arg1 int, arg2 *linodego.ListOptions) ([]linodego.VPCSubnet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListVPCSubnets", arg0, arg1, arg2) + ret0, _ := ret[0].([]linodego.VPCSubnet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListVPCSubnets indicates an expected call of ListVPCSubnets. +func (mr *MockClientMockRecorder) ListVPCSubnets(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListVPCSubnets", reflect.TypeOf((*MockClient)(nil).ListVPCSubnets), arg0, arg1, arg2) +} + // ListVPCs mocks base method. func (m *MockClient) ListVPCs(arg0 context.Context, arg1 *linodego.ListOptions) ([]linodego.VPC, error) { m.ctrl.T.Helper() diff --git a/cloud/linode/cloud.go b/cloud/linode/cloud.go index 8ed3a18e..7b82d50b 100644 --- a/cloud/linode/cloud.go +++ b/cloud/linode/cloud.go @@ -41,6 +41,7 @@ var Options struct { // Deprecated: use VPCNames instead VPCName string VPCNames string + SubnetNames string LoadBalancerType string BGPNodeSelector string IpHolderSuffix string @@ -132,6 +133,12 @@ func newCloud() (cloudprovider.Interface, error) { Options.VPCNames = Options.VPCName } + // SubnetNames can't be used without VPCNames also being set + if Options.SubnetNames != "" && Options.VPCNames == "" { + klog.Warningf("failed to set flag subnet-names: vpc-names must be set to a non-empty value") + Options.SubnetNames = "" + } + instanceCache = newInstances(linodeClient) routes, err := newRoutes(linodeClient, instanceCache) if err != nil { diff --git a/cloud/linode/vpc.go b/cloud/linode/vpc.go index 01c1ed14..ad72e346 100644 --- a/cloud/linode/vpc.go +++ b/cloud/linode/vpc.go @@ -2,8 +2,10 @@ package linode import ( "context" + "encoding/json" "fmt" "net/http" + "strconv" "strings" "sync" @@ -16,16 +18,30 @@ var ( Mu sync.RWMutex // vpcIDs map stores vpc id's for given vpc labels vpcIDs = make(map[string]int, 0) + // subnetIDs map stores subnet id's for given subnet labels + subnetIDs = make(map[string]int, 0) ) type vpcLookupError struct { value string } +type subnetLookupError struct { + value string +} + +type subnetFilter struct { + SubnetID string `json:"subnet_id"` +} + func (e vpcLookupError) Error() string { return fmt.Sprintf("failed to find VPC: %q", e.value) } +func (e subnetLookupError) Error() string { + return fmt.Sprintf("failed to find subnet: %q", e.value) +} + // GetAllVPCIDs returns vpc ids stored in map func GetAllVPCIDs() []int { Mu.Lock() @@ -59,13 +75,68 @@ func GetVPCID(ctx context.Context, client client.Client, vpcName string) (int, e return 0, vpcLookupError{vpcName} } +// GetSubnetID returns the subnet ID of given subnet label +func GetSubnetID(ctx context.Context, client client.Client, vpcID int, subnetName string) (int, error) { + Mu.Lock() + defer Mu.Unlock() + + // Check if map contains the id for the given label + if subnetid, ok := subnetIDs[subnetName]; ok { + return subnetid, nil + } + // Otherwise, get it from linodego.ListVPCSubnets() + subnets, err := client.ListVPCSubnets(ctx, vpcID, &linodego.ListOptions{}) + if err != nil { + return 0, err + } + for _, subnet := range subnets { + if subnet.Label == subnetName { + subnetIDs[subnetName] = subnet.ID + return subnet.ID, nil + } + } + + return 0, subnetLookupError{subnetName} +} + // GetVPCIPAddresses returns vpc ip's for given VPC label func GetVPCIPAddresses(ctx context.Context, client client.Client, vpcName string) ([]linodego.VPCIP, error) { vpcID, err := GetVPCID(ctx, client, strings.TrimSpace(vpcName)) if err != nil { return nil, err } - resp, err := client.ListVPCIPAddresses(ctx, vpcID, linodego.NewListOptions(0, "")) + + resultFilter := "" + + // Get subnet ID(s) from name(s) if subnet-names is specified + if Options.SubnetNames != "" { + // Get the IDs and store them + // subnetIDList is a slice of strings for ease of use with resultFilter + subnetNames := strings.Split(Options.SubnetNames, ",") + subnetIDList := []string{} + + for _, name := range subnetNames { + // For caching + subnetID, err := GetSubnetID(ctx, client, vpcID, name) + // Don't filter subnets we can't find + if err != nil { + klog.Errorf("subnet %s not found due to error: %v. Skipping.", name, err) + continue + } + + // For use with the JSON filter + subnetIDList = append(subnetIDList, strconv.Itoa(subnetID)) + } + + // Assign the list of IDs to a stringified JSON filter + filter, err := json.Marshal(subnetFilter{SubnetID: strings.Join(subnetIDList, ",")}) + if err != nil { + klog.Error("could not create JSON filter for subnet_id") + } + resultFilter = string(filter) + } + + resp, err := client.ListVPCIPAddresses(ctx, vpcID, linodego.NewListOptions(0, resultFilter)) if err != nil { if linodego.ErrHasStatus(err, http.StatusNotFound) { Mu.Lock() diff --git a/cloud/linode/vpc_test.go b/cloud/linode/vpc_test.go index 9e99b675..0197712d 100644 --- a/cloud/linode/vpc_test.go +++ b/cloud/linode/vpc_test.go @@ -146,4 +146,80 @@ func TestGetVPCIPAddresses(t *testing.T) { _, exists := vpcIDs["test10"] assert.True(t, exists, "test10 key should be present in vpcIDs map") }) + + t.Run("vpc id found and ip addresses found with subnet filtering", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + client := mocks.NewMockClient(ctrl) + sn := Options.SubnetNames + defer func() { Options.SubnetNames = sn }() + Options.SubnetNames = "subnet4" + vpcIDs = map[string]int{"test1": 1} + subnetIDs = map[string]int{"subnet1": 1} + client.EXPECT().ListVPCs(gomock.Any(), gomock.Any()).Times(1).Return([]linodego.VPC{{ID: 10, Label: "test10"}}, nil) + client.EXPECT().ListVPCSubnets(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return([]linodego.VPCSubnet{{ID: 4, Label: "subnet4"}}, nil) + client.EXPECT().ListVPCIPAddresses(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return([]linodego.VPCIP{}, nil) + _, err := GetVPCIPAddresses(context.TODO(), client, "test10") + assert.NoError(t, err) + _, exists := subnetIDs["subnet4"] + assert.True(t, exists, "subnet4 should be present in subnetIDs map") + }) +} + +func TestGetSubnetID(t *testing.T) { + t.Run("subnet in cache", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + client := mocks.NewMockClient(ctrl) + subnetIDs = map[string]int{"test1": 1, "test2": 2, "test3": 3} + got, err := GetSubnetID(context.TODO(), client, 0, "test3") + if err != nil { + t.Errorf("GetSubnetID() error = %v", err) + return + } + if got != subnetIDs["test3"] { + t.Errorf("GetSubnetID() = %v, want %v", got, subnetIDs["test3"]) + } + }) + + t.Run("subnetID not in cache and listVPCSubnets return error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + client := mocks.NewMockClient(ctrl) + subnetIDs = map[string]int{"test1": 1, "test2": 2, "test3": 3} + client.EXPECT().ListVPCSubnets(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return([]linodego.VPCSubnet{}, errors.New("error")) + got, err := GetSubnetID(context.TODO(), client, 0, "test4") + assert.Error(t, err) + if got != 0 { + t.Errorf("GetSubnetID() = %v, want %v", got, 0) + } + _, exists := subnetIDs["test4"] + assert.False(t, exists, "subnet4 should not be present in subnetIDs") + }) + + t.Run("subnetID not in cache and listVPCSubnets return nothing", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + client := mocks.NewMockClient(ctrl) + subnetIDs = map[string]int{"test1": 1, "test2": 2, "test3": 3} + client.EXPECT().ListVPCSubnets(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return([]linodego.VPCSubnet{}, nil) + got, err := GetSubnetID(context.TODO(), client, 0, "test4") + assert.ErrorIs(t, err, subnetLookupError{"test4"}) + if got != 0 { + t.Errorf("GetSubnetID() = %v, want %v", got, 0) + } + }) + + t.Run("subnetID not in cache and listVPCSubnets return subnet info", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + client := mocks.NewMockClient(ctrl) + subnetIDs = map[string]int{"test1": 1, "test2": 2, "test3": 3} + client.EXPECT().ListVPCSubnets(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return([]linodego.VPCSubnet{{ID: 4, Label: "test4"}}, nil) + got, err := GetSubnetID(context.TODO(), client, 0, "test4") + assert.NoError(t, err) + if got != 4 { + t.Errorf("GetSubnetID() = %v, want %v", got, 4) + } + }) } diff --git a/deploy/chart/templates/daemonset.yaml b/deploy/chart/templates/daemonset.yaml index 615329cd..a5f2ec0f 100644 --- a/deploy/chart/templates/daemonset.yaml +++ b/deploy/chart/templates/daemonset.yaml @@ -55,6 +55,10 @@ spec: {{- if and $vpcName $vpcNames }} {{- fail "Both vpcName and vpcNames are set. Please use only vpcNames." }} {{- end }} + {{- $subnetNames := .Values.subnetNames }} + {{- if and .Values.routeController .Values.routeController.subnetNames }} + {{- $subnetNames = .Values.routeController.subnetNames }} + {{- end }} {{- if .Values.routeController }} - --enable-route-controller=true {{- if not (or $vpcName $vpcNames) }} @@ -72,6 +76,9 @@ spec: {{- with $vpcName }} - --vpc-name={{ . }} {{- end }} + {{- with $subnetNames }} + - --subnet-names={{ . }} + {{ end }} {{- if .Values.sharedIPLoadBalancing }} {{- with .Values.sharedIPLoadBalancing.bgpNodeSelector }} - --bgp-node-selector={{ . }} diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 3873a465..ca1e4782 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -71,12 +71,14 @@ tolerations: # routeController: # vpcName: [Deprecated: use vpcNames instead] # vpcNames: +# subnetNames: # clusterCIDR: 10.0.0.0/8 # configureCloudRoutes: true -# vpcs that node internal IPs will be assigned from (not required if already specified in routeController) +# vpcs and subnets that node internal IPs will be assigned from (not required if already specified in routeController) # vpcName: [Deprecated: use vpcNames instead] # vpcNames: +# subnetNames: # Enable Linode token health checker # tokenHealthChecker: true diff --git a/main.go b/main.go index 6577277c..ee101840 100644 --- a/main.go +++ b/main.go @@ -84,6 +84,7 @@ func main() { command.Flags().BoolVar(&linode.Options.EnableTokenHealthChecker, "enable-token-health-checker", false, "enables Linode API token health checker") command.Flags().StringVar(&linode.Options.VPCName, "vpc-name", "", "[deprecated: use vpc-names instead] vpc name whose routes will be managed by route-controller") command.Flags().StringVar(&linode.Options.VPCNames, "vpc-names", "", "comma separated vpc names whose routes will be managed by route-controller") + command.Flags().StringVar(&linode.Options.SubnetNames, "subnet-names", "", "comma separated subnet names whose routes will be managed by route-controller (requires vpc-names flag to also be set)") command.Flags().StringVar(&linode.Options.LoadBalancerType, "load-balancer-type", "nodebalancer", "configures which type of load-balancing to use for LoadBalancer Services (options: nodebalancer, cilium-bgp)") command.Flags().StringVar(&linode.Options.BGPNodeSelector, "bgp-node-selector", "", "node selector to use to perform shared IP fail-over with BGP (e.g. cilium-bgp-peering=true") command.Flags().StringVar(&linode.Options.IpHolderSuffix, "ip-holder-suffix", "", "suffix to append to the ip holder name when using shared IP fail-over with BGP (e.g. ip-holder-suffix=my-cluster-name")