Compare commits

...

5 Commits

Author SHA1 Message Date
Dmitrii Andreev
4f1e7f56d1 client: enhance CNI address handling for backward compatibility
Add fallback logic in cniToAllocNet to populate the Address field with the IPv6 address when no IPv4 address is available. This change ensures compatibility with existing code that relies on the Address field for service registration, particularly in scenarios where only IPv6 addresses are present.
2025-10-09 12:59:25 +03:00
Dmitrii Andreev
b9bda8f2c1 client: enhance CNI allocation tests for IPv6 and dualstack scenarios
Add multiple test cases to validate the behavior of CNI allocation when handling IPv6-only and dualstack configurations. The new tests ensure that the first address is selected correctly from multiple addresses across interfaces, maintaining consistent behavior with IPv4. This improves coverage for edge cases in CNI result processing and reinforces the recent fixes for IPv6 support.
2025-10-09 12:58:55 +03:00
Dmitrii Andreev
c588527ae4 added changelog entry 2025-10-09 12:56:29 +03:00
Dmitrii Andreev
1136fd342c client: fix IPv6-only CNI interface support
Fix a regression introduced in Nomad 1.9.0 where CNI bridge networking with
IPv6-only interfaces would fail with 'no interface with an address' error.

The issue was that while the code correctly populated the AddressIPv6 field
for IPv6 addresses, several validation checks only examined the Address field
(IPv4), causing IPv6-only configurations to be rejected.

Changes:
- Update interface selection logic in cniToAllocNet to accept interfaces with
  either IPv4 or IPv6 addresses (not just IPv4)
- Update fallback logic to check both Address and AddressIPv6 fields
- Update error check to only fail when both IPv4 and IPv6 are missing
- Update AllocNetworkStatus.IsZero() to check AddressIPv6 field

This allows CNI configurations with IPv6-only interfaces to work correctly,
restoring functionality from Nomad 1.8.x.

Fixes #26905
2025-10-08 18:12:14 +03:00
Dmitrii Andreev
d058761dc7 client: add test for IPv6-only CNI interfaces
Add a test case to verify that CNI results containing only IPv6 addresses
are handled correctly. This is a regression test for a bug introduced in
GH-23882 where IPv6-only interfaces would fail with 'no interface with an
address' error.

The test verifies that when a CNI plugin returns only an IPv6 address
(without IPv4), the allocation network status should be properly populated
with the IPv6 address in the AddressIPv6 field.
2025-10-08 18:12:13 +03:00
4 changed files with 172 additions and 6 deletions

3
.changelog/26910.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:bug
networking: Fixed network interface detection failure with bridge or CNI mode on IPv6-only interfaces
```

View File

@@ -480,8 +480,8 @@ func (c *cniNetworkConfigurator) cniToAllocNet(res *cni.Result) (*structs.AllocN
}
}
// found a good interface, so we're done
if netStatus.Address != "" {
// found a good interface (with either IPv4 or IPv6), so we're done
if netStatus.Address != "" || netStatus.AddressIPv6 != "" {
netStatus.InterfaceName = name
return
}
@@ -493,7 +493,7 @@ func (c *cniNetworkConfigurator) cniToAllocNet(res *cni.Result) (*structs.AllocN
// If no IP address was found, use the first interface with an address
// found as a fallback
if netStatus.Address == "" {
if netStatus.Address == "" && netStatus.AddressIPv6 == "" {
setStatus(false)
c.logger.Debug("no sandbox interface with an address found CNI result, using first available",
"interface", netStatus.InterfaceName,
@@ -501,12 +501,19 @@ func (c *cniNetworkConfigurator) cniToAllocNet(res *cni.Result) (*structs.AllocN
)
}
// If no IP address could be found, return an error
if netStatus.Address == "" {
// If no IP address (IPv4 or IPv6) could be found, return an error
if netStatus.Address == "" && netStatus.AddressIPv6 == "" {
return nil, fmt.Errorf("failed to configure network: no interface with an address")
}
// Fallback: if no IPv4 address but we have IPv6, copy it to Address field
// for backward compatibility with code that only checks Address field
// (e.g. service registration with address_mode="alloc")
if netStatus.Address == "" && netStatus.AddressIPv6 != "" {
netStatus.Address = netStatus.AddressIPv6
}
// Use the first DNS results, if non-empty
if len(res.DNS) > 0 {
cniDNS := res.DNS[0]

View File

@@ -498,6 +498,162 @@ func TestCNI_cniToAllocNet_Dualstack(t *testing.T) {
test.Eq(t, "eth0", allocNet.InterfaceName)
}
// TestCNI_cniToAllocNet_IPv6Only asserts that CNI results containing only IPv6
// addresses work correctly. This is a regression test for a bug introduced in
// GH-23882 where IPv6-only interfaces would fail with "no interface with an address".
func TestCNI_cniToAllocNet_IPv6Only(t *testing.T) {
ci.Parallel(t)
cniResult := &cni.Result{
Interfaces: map[string]*cni.Config{
"eth0": {
Sandbox: "nomad-sandbox",
IPConfigs: []*cni.IPConfig{
{IP: net.ParseIP("fd00:a110:c8::b")}, // only IPv6
},
},
},
}
c := &cniNetworkConfigurator{
logger: testlog.HCLogger(t),
}
allocNet, err := c.cniToAllocNet(cniResult)
must.NoError(t, err)
must.NotNil(t, allocNet)
test.Eq(t, "fd00:a110:c8::b", allocNet.Address) // fallback to IPv6
test.Eq(t, "fd00:a110:c8::b", allocNet.AddressIPv6)
test.Eq(t, "eth0", allocNet.InterfaceName)
}
// TestCNI_cniToAllocNet_IPv6Only_MultipleAddresses asserts that when a CNI result
// contains multiple IPv6 addresses on a single interface, the first address is selected.
// This ensures consistent behavior with the IPv4 case.
func TestCNI_cniToAllocNet_IPv6Only_MultipleAddresses(t *testing.T) {
ci.Parallel(t)
cniResult := &cni.Result{
Interfaces: map[string]*cni.Config{
"eth0": {
Sandbox: "nomad-sandbox",
IPConfigs: []*cni.IPConfig{
{IP: net.ParseIP("fd00:a110:c8::1")}, // first IPv6 - should be selected
{IP: net.ParseIP("fd00:a110:c8::2")}, // second IPv6
{IP: net.ParseIP("fd00:a110:c8::3")}, // third IPv6
},
},
},
}
c := &cniNetworkConfigurator{
logger: testlog.HCLogger(t),
}
allocNet, err := c.cniToAllocNet(cniResult)
must.NoError(t, err)
must.NotNil(t, allocNet)
test.Eq(t, "fd00:a110:c8::1", allocNet.Address) // fallback to IPv6
test.Eq(t, "fd00:a110:c8::1", allocNet.AddressIPv6) // should select first IPv6 address
test.Eq(t, "eth0", allocNet.InterfaceName)
}
// TestCNI_cniToAllocNet_Dualstack_MultipleAddresses asserts that when a CNI result
// contains multiple IPv4 and IPv6 addresses, the first address of each type is selected.
func TestCNI_cniToAllocNet_Dualstack_MultipleAddresses(t *testing.T) {
ci.Parallel(t)
cniResult := &cni.Result{
Interfaces: map[string]*cni.Config{
"eth0": {
Sandbox: "nomad-sandbox",
IPConfigs: []*cni.IPConfig{
{IP: net.IPv4(192, 168, 1, 10)}, // first IPv4 - should be selected
{IP: net.ParseIP("fd00:a110:c8::1")}, // first IPv6 - should be selected
{IP: net.IPv4(192, 168, 1, 11)}, // second IPv4
{IP: net.ParseIP("fd00:a110:c8::2")}, // second IPv6
},
},
},
}
c := &cniNetworkConfigurator{
logger: testlog.HCLogger(t),
}
allocNet, err := c.cniToAllocNet(cniResult)
must.NoError(t, err)
must.NotNil(t, allocNet)
test.Eq(t, "192.168.1.10", allocNet.Address) // should select first IPv4 address
test.Eq(t, "fd00:a110:c8::1", allocNet.AddressIPv6) // should select first IPv6 address
test.Eq(t, "eth0", allocNet.InterfaceName)
}
// TestCNI_cniToAllocNet_MultipleInterfaces_IPv6First asserts that when multiple
// interfaces exist, the first interface (lexicographically) with an address is selected,
// even if it only has IPv6 and a later interface has IPv4.
func TestCNI_cniToAllocNet_MultipleInterfaces_IPv6First(t *testing.T) {
ci.Parallel(t)
cniResult := &cni.Result{
Interfaces: map[string]*cni.Config{
"eth0": {
Sandbox: "nomad-sandbox",
IPConfigs: []*cni.IPConfig{
{IP: net.ParseIP("fd00:a110:c8::1")}, // IPv6 only on first interface
},
},
"eth1": {
Sandbox: "nomad-sandbox",
IPConfigs: []*cni.IPConfig{
{IP: net.IPv4(192, 168, 1, 10)}, // IPv4 on second interface
},
},
},
}
c := &cniNetworkConfigurator{
logger: testlog.HCLogger(t),
}
allocNet, err := c.cniToAllocNet(cniResult)
must.NoError(t, err)
must.NotNil(t, allocNet)
test.Eq(t, "fd00:a110:c8::1", allocNet.Address) // fallback to IPv6
test.Eq(t, "fd00:a110:c8::1", allocNet.AddressIPv6) // IPv6 from first interface
test.Eq(t, "eth0", allocNet.InterfaceName) // first interface should be selected
}
// TestCNI_cniToAllocNet_MultipleInterfaces_IPv6OnMultiple asserts that when multiple
// interfaces each have IPv6 addresses, the first interface is selected.
func TestCNI_cniToAllocNet_MultipleInterfaces_IPv6OnMultiple(t *testing.T) {
ci.Parallel(t)
cniResult := &cni.Result{
Interfaces: map[string]*cni.Config{
"eth0": {
Sandbox: "nomad-sandbox",
IPConfigs: []*cni.IPConfig{
{IP: net.ParseIP("fd00:a110:c8::1")},
{IP: net.ParseIP("fd00:a110:c8::2")}, // multiple on first interface
},
},
"eth1": {
Sandbox: "nomad-sandbox",
IPConfigs: []*cni.IPConfig{
{IP: net.ParseIP("fd00:b220:c8::1")}, // different IPv6 on second interface
},
},
},
}
c := &cniNetworkConfigurator{
logger: testlog.HCLogger(t),
}
allocNet, err := c.cniToAllocNet(cniResult)
must.NoError(t, err)
must.NotNil(t, allocNet)
test.Eq(t, "fd00:a110:c8::1", allocNet.Address) // fallback to IPv6
test.Eq(t, "fd00:a110:c8::1", allocNet.AddressIPv6) // first address from first interface
test.Eq(t, "eth0", allocNet.InterfaceName) // first interface
}
func TestCNI_addCustomCNIArgs(t *testing.T) {
ci.Parallel(t)
cniArgs := map[string]string{

View File

@@ -12292,7 +12292,7 @@ func (a *AllocNetworkStatus) IsZero() bool {
if a == nil {
return true
}
if a.InterfaceName != "" || a.Address != "" {
if a.InterfaceName != "" || a.Address != "" || a.AddressIPv6 != "" {
return false
}
if !a.DNS.IsZero() {