Skip to content

Commit

Permalink
OPNET-197: Extend logic for detecting Node IP
Browse files Browse the repository at this point in the history
When generating keepalived.conf we are relying on the logic to gather
IPs of all the cluster nodes for the IP stack used by the specific VIP.
This logic currently relies only on the addresses reported as part of
Node.Status.Addresses.

In some scenarios it may be that the node is not reporting all its IPs
via kubelet but still have those available. If we detect such a scenario
(e.g. kubelet reporting only IPv4, but VIP being IPv6), we will check
for Node annotations created by OVN as those use different source of
truth so kubelet not reporting IPs is not affecting it.

The newly introduced behaviour is just a fallback in case
Node.Status.Addresses does not contain an IP of a requested stack,
therefore not changing the behaviour for currently working scenarios.

Contributes-to: OPNET-197
  • Loading branch information
mkowalski committed Jan 31, 2023
1 parent 2905c04 commit 661f51e
Show file tree
Hide file tree
Showing 46 changed files with 6,467 additions and 24 deletions.
1 change: 1 addition & 0 deletions go.mod
Expand Up @@ -47,6 +47,7 @@ require (
github.com/nxadm/tail v1.4.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/thoas/go-funk v0.9.3 // indirect
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Expand Up @@ -275,6 +275,8 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2 h1:b6uOv7YOFK0TYG7HtkIgExQo+2RdLuwRft63jn2HWj8=
github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw=
github.com/thoas/go-funk v0.9.3/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/net.go
Expand Up @@ -41,7 +41,7 @@ func getInterfaceAndNonVIPAddrFromFile(vip net.IP) (*net.Interface, *net.IPNet,
if err != nil {
return nil, nil, err
}
return utils.GetInterfaceWithCidrByIP(ip)
return utils.GetInterfaceWithCidrByIP(ip, true)
}

// NOTE(bnemec): All addresses in the vips array must be the same ip version
Expand Down
110 changes: 92 additions & 18 deletions pkg/config/node.go
Expand Up @@ -3,6 +3,7 @@ package config
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
Expand Down Expand Up @@ -306,23 +307,92 @@ func GetIngressConfig(kubeconfigPath string, filterIpType string) (ingressConfig
return ingressConfig, err
}

// As it is not possible to get cluster's Machine Network directly, we are using a workaround
// by detecting which of the local interfaces belongs to the same subnet as requested VIP.
// This interface can be used to detect what was the original machine network as it contains
// the subnet mask that we need.
//
// For IPv6 we change /128 to /64 because this is how it should be done.
_, machineNetwork, err := utils.GetInterfaceWithCidrByIP(net.ParseIP(filterIpType), false)
if err != nil {
return ingressConfig, err
}
machineNetworkOffset := strings.Replace(machineNetwork.String(), "/128", "/64", 1)

for _, node := range nodes.Items {
for _, address := range node.Status.Addresses {
if address.Type == v1.NodeInternalIP {
if filterIpType != "" {
if (net.ParseIP(filterIpType).To4() != nil && net.ParseIP(address.Address).To4() == nil) ||
(net.ParseIP(filterIpType).To4() == nil && net.ParseIP(address.Address).To4() != nil) {
continue
}
}
ingressConfig.Peers = append(ingressConfig.Peers, address.Address)
}
addr := getNodeIpForRequestedIpStack(node, filterIpType, machineNetworkOffset)
if addr != "" {
ingressConfig.Peers = append(ingressConfig.Peers, addr)
}
}

return ingressConfig, nil
}

func getNodeIpForRequestedIpStack(node v1.Node, ipFilter string, machineNetwork string) string {
log.SetLevel(logrus.DebugLevel)

isFilterV4 := utils.IsIPv4Addr(ipFilter)
isFilterV6 := utils.IsIPv6Addr(ipFilter)

if !isFilterV4 && !isFilterV6 {
return ""
}

// We need to collect IP address of a matching IP stack for every node that is part of the
// cluster. We need to account for a scenario where Node.Status.Addresses list is incomplete
// and use different source of the address.
//
// We will use here the following sources:
// 1) Node.Status.Addresses list
// 2) Node annotation "k8s.ovn.org/host-addresses" in combination with Machine Networks
//
// If none of those returns a conclusive result, we don't return an IP for this node. This is
// not a desired outcome, but can be extended in the future if desired.

var addr string
for _, address := range node.Status.Addresses {
if address.Type == v1.NodeInternalIP {
if (utils.IsIPv4Addr(address.Address) && isFilterV4) || (utils.IsIPv6Addr(address.Address) && isFilterV6) {
addr = address.Address
log.Debugf("For node %s selected peer address %s", node.Name, addr)
}
}
}
if addr == "" {
log.Debugf("For node %s can't find address in status. Fallback to OVN annotation. Using %s as machine network.", node.Name, machineNetwork)

var ovnHostAddresses []string
if err := json.Unmarshal([]byte(node.Annotations["k8s.ovn.org/host-addresses"]), &ovnHostAddresses); err != nil {
log.Debugf("Couldn't unmarshall OVN annotations: '%s'. Skipping.", node.Annotations["k8s.ovn.org/host-addresses"])
return ""
}

for _, hostAddr := range ovnHostAddresses {
if hostAddr == ipFilter {
log.Debugf("Address %s is VIP. Skipping.", hostAddr)
continue
}
if (utils.IsIPv4Addr(hostAddr) && !isFilterV4) || (utils.IsIPv6Addr(hostAddr) && !isFilterV6) {
log.Debugf("Address %s doesn't match requested IP stack. Skipping.", hostAddr)
continue
}

match, err := utils.IpInCidr(hostAddr, machineNetwork)
if err != nil {
log.Debugf("Address '%s' and subnet '%s' couldn't be parsed. Skipping.", hostAddr, machineNetwork)
continue
}
if match {
addr = hostAddr
log.Debugf("Found IP %s for node %s using OVN annotations.", addr, node.Name)
}
}
}

return addr
}

// Returns a Node object populated with the configuration specified by the parameters
// to the function.
// kubeconfigPath: The path to a kubeconfig that can be used to read cluster status
Expand Down Expand Up @@ -483,15 +553,19 @@ func getSortedBackends(kubeconfigPath string, readFromLocalAPI bool, apiVip net.
}).Info("Failed to get master Nodes list")
return []Backend{}, err
}
apiVipv6 := utils.IsIPv6(apiVip)

// As it is not possible to get cluster's Machine Network directly, we are using a workaround
// by detecting which of the local interfaces belongs to the same subnet as requested VIP.
// This interface can be used to detect what was the original machine network as it contains
// the subnet mask that we need.
_, machineNetwork, err := utils.GetInterfaceWithCidrByIP(apiVip, false)
if err != nil {
log.Warnf("Could not retrieve subnet for IP %s", apiVip.String())
}
machineNetworkOffset := strings.Replace(machineNetwork.String(), "/128", "/64", 1)

for _, node := range nodes.Items {
masterIp := ""
for _, address := range node.Status.Addresses {
if address.Type == v1.NodeInternalIP && utils.IsIPv6(net.ParseIP(address.Address)) == apiVipv6 {
masterIp = address.Address
break
}
}
masterIp := getNodeIpForRequestedIpStack(node, apiVip.String(), machineNetworkOffset)
if masterIp != "" {
backends = append(backends, Backend{Host: node.ObjectMeta.Name, Address: masterIp})
} else {
Expand Down
123 changes: 123 additions & 0 deletions pkg/config/node_test.go
@@ -0,0 +1,123 @@
package config

import (
"testing"

. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

var (
testOvnHostAddressesAnnotation = map[string]string{
"k8s.ovn.org/host-addresses": "[\"192.168.1.99\",\"192.168.1.101\",\"2001:db8::49a\",\"fd00::5\",\"fd69::2\"]",
}

testNodeDualStack1 = v1.Node{Status: v1.NodeStatus{Addresses: []v1.NodeAddress{
{Type: "InternalIP", Address: "192.168.1.99"},
{Type: "InternalIP", Address: "fd00::5"},
{Type: "ExternalIP", Address: "172.16.1.99"},
}}}
testNodeDualStack2 = v1.Node{
Status: v1.NodeStatus{Addresses: []v1.NodeAddress{
{Type: "InternalIP", Address: "192.168.1.99"},
{Type: "ExternalIP", Address: "172.16.1.99"},
}},
ObjectMeta: metav1.ObjectMeta{
Annotations: testOvnHostAddressesAnnotation,
},
}
testNodeDualStack3 = v1.Node{
ObjectMeta: metav1.ObjectMeta{
Annotations: testOvnHostAddressesAnnotation,
},
}
testNodeSingleStackV4 = v1.Node{Status: v1.NodeStatus{Addresses: []v1.NodeAddress{
{Type: "InternalIP", Address: "192.168.1.99"},
{Type: "ExternalIP", Address: "172.16.1.99"},
}}}
testNodeSingleStackV6 = v1.Node{Status: v1.NodeStatus{Addresses: []v1.NodeAddress{
{Type: "InternalIP", Address: "fd00::5"},
{Type: "ExternalIP", Address: "2001:db8::49a"},
}}}

testMachineNetworkV4 = "192.168.1.0/24"
testMachineNetworkV6 = "fd00::5/64"
testVipV4 = "192.168.1.101"
testVipV6 = "fd00::101"
)

var _ = Describe("getNodePeersForIpStack", func() {
Context("for dual-stack node", func() {
Context("with address only in status", func() {
It("matches an IPv4 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeDualStack1, testVipV4, testMachineNetworkV4)
Expect(res).To(Equal("192.168.1.99"))
})
It("matches an IPv6 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeDualStack1, testVipV6, testMachineNetworkV6)
Expect(res).To(Equal("fd00::5"))
})
})

Context("with address only in OVN annotation", func() {
It("matches an IPv4 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeDualStack3, testVipV4, testMachineNetworkV4)
Expect(res).To(Equal("192.168.1.99"))
})
It("matches an IPv6 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeDualStack3, testVipV6, testMachineNetworkV6)
Expect(res).To(Equal("fd00::5"))
})
})

Context("with address in status and OVN annotation", func() {
It("matches an IPv4 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeDualStack2, testVipV4, testMachineNetworkV4)
Expect(res).To(Equal("192.168.1.99"))
})
It("matches an IPv6 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeDualStack2, testVipV6, testMachineNetworkV6)
Expect(res).To(Equal("fd00::5"))
})
})
})

Context("for single-stack v4 node", func() {
It("matches an IPv4 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeSingleStackV4, testVipV4, testMachineNetworkV4)
Expect(res).To(Equal("192.168.1.99"))
})
It("empty for IPv6 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeSingleStackV4, testVipV6, testMachineNetworkV6)
Expect(res).To(Equal(""))
})
})

Context("for single-stack v6 node", func() {
It("empty for IPv4 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeSingleStackV6, testVipV4, testMachineNetworkV4)
Expect(res).To(Equal(""))
})
It("matches an IPv6 VIP", func() {
res := getNodeIpForRequestedIpStack(testNodeSingleStackV6, testVipV6, testMachineNetworkV6)
Expect(res).To(Equal("fd00::5"))
})
})

It("empty for empty node", func() {
res := getNodeIpForRequestedIpStack(v1.Node{}, testVipV4, testMachineNetworkV4)
Expect(res).To(Equal(""))
})

It("empty for node with IPs and empty VIP requested", func() {
res := getNodeIpForRequestedIpStack(testNodeSingleStackV4, "", testMachineNetworkV4)
Expect(res).To(Equal(""))
})
})

func Test(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Config tests")
}
18 changes: 15 additions & 3 deletions pkg/utils/addresses.go
Expand Up @@ -290,7 +290,13 @@ func addressesDefaultInternal(preferIPv6 bool, af AddressFilter, getAddrs addres
return matches, nil
}

func GetInterfaceWithCidrByIP(ip net.IP) (*net.Interface, *net.IPNet, error) {
// GetInterfaceWithCidrByIP returns the interface and network that has the passed IP address
// configured. It allows to run in a non-strict mode in which it's not required to match the
// exact IP address but only a subnet.
//
// E.g. for interface configured as "192.168.1.1/24" strict mode asked about "192.168.1.2" returns
// FALSE whereas in non-strict mode it returns TRUE.
func GetInterfaceWithCidrByIP(ip net.IP, strictMatch bool) (*net.Interface, *net.IPNet, error) {
interfaces, err := net.Interfaces()
if err != nil {
return nil, nil, err
Expand All @@ -304,12 +310,18 @@ func GetInterfaceWithCidrByIP(ip net.IP) (*net.Interface, *net.IPNet, error) {
for _, addr := range addrs {
switch n := addr.(type) {
case *net.IPNet:
ifaceIp, _, err := net.ParseCIDR(strings.Replace(addr.String(), "/128", "/64", 1))
addrOffset := strings.Replace(addr.String(), "/128", "/64", 1)
ifaceIp, _, err := net.ParseCIDR(addrOffset)
if err == nil {
if ifaceIp.Equal(ip) {
return &iface, n, nil
}

if !strictMatch {
match, _ := IpInCidr(ip.String(), addrOffset)
if match {
return &iface, n, nil
}
}
}
default:
fmt.Println("not supported addr")
Expand Down
37 changes: 37 additions & 0 deletions pkg/utils/network.go
@@ -0,0 +1,37 @@
package utils

import (
"errors"
"fmt"
"net"
"strings"
)

func SplitCIDR(s string) (string, string, error) {
split := strings.Split(s, "/")
if len(split) != 2 {
return "", "", fmt.Errorf("not a CIDR")
}

return split[0], split[1], nil
}

func IsIPv4Addr(ip string) bool {
return strings.Contains(ip, ".") && net.ParseIP(ip) != nil
}

func IsIPv6Addr(ip string) bool {
return strings.Contains(ip, ":") && net.ParseIP(ip) != nil
}

func IpInCidr(ipAddr, cidr string) (bool, error) {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return false, err
}
ip := net.ParseIP(ipAddr)
if ip == nil {
return false, errors.New("IP is nil")
}
return ipNet.Contains(ip), nil
}

0 comments on commit 661f51e

Please sign in to comment.