From 4c82112412ae7d5cf82d15a482a610b02e4098e8 Mon Sep 17 00:00:00 2001 From: Frits <4488681+frits-v@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:21:48 -0800 Subject: [PATCH] feat(network): add support for static IPv6 assignment - Add --ip6 compose support by parsing ipv6_address field in service network config and translating it to --ip6= run arg - Declare ips, dns, and portMappings capabilities on bridge and macvlan/ipvlan plugins when IPv6 is enabled, so that libcni passes static IP runtime config to the plugin while preserving DNS and port mapping functionality - Fix auto-IPv4 subnet injection: only add a default IPv4 subnet when no explicit subnet is provided, preventing unwanted IPv4 ranges on IPv6-only networks - Add integration tests for static IPv4, IPv6, dual-stack, and macvlan IPv6 address assignment - Add unit test for compose dual-stack address parsing Fixes #4597 Co-Authored-By: Claude Opus 4.6 --- .../network/network_create_linux_test.go | 124 ++++++++++++++++++ pkg/composer/serviceparser/serviceparser.go | 3 + .../serviceparser/serviceparser_test.go | 38 ++++++ pkg/netutil/netutil_unix.go | 11 +- 4 files changed, 175 insertions(+), 1 deletion(-) diff --git a/cmd/nerdctl/network/network_create_linux_test.go b/cmd/nerdctl/network/network_create_linux_test.go index f9c35c845aa..fbd124513ee 100644 --- a/cmd/nerdctl/network/network_create_linux_test.go +++ b/cmd/nerdctl/network/network_create_linux_test.go @@ -156,6 +156,130 @@ func TestNetworkCreate(t *testing.T) { } }, }, + { + Description: "with static IPv4 address", + Setup: func(data test.Data, helpers test.Helpers) { + networkName := data.Identifier() + staticIP := "172.19.0.100" + data.Labels().Set("networkName", networkName) + data.Labels().Set("staticIP", staticIP) + helpers.Ensure("network", "create", networkName, "--driver", "bridge", "--subnet", "172.19.0.0/24") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Labels().Get("networkName")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", "--net", data.Labels().Get("networkName"), "--ip", data.Labels().Get("staticIP"), testutil.CommonImage, "ip", "addr", "show", "eth0") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet %s/24", data.Labels().Get("staticIP")))) + }, + } + }, + }, + { + Description: "with static IPv6 address", + Require: nerdtest.OnlyIPv6, + Setup: func(data test.Data, helpers test.Helpers) { + networkName := data.Identifier() + staticIPv6 := "2001:db8:1::100" + data.Labels().Set("networkName", networkName) + data.Labels().Set("staticIPv6", staticIPv6) + helpers.Ensure("network", "create", networkName, "--driver", "bridge", "--ipv6", "--subnet", "2001:db8:1::/64") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Labels().Get("networkName")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", "--net", data.Labels().Get("networkName"), "--ip6", data.Labels().Get("staticIPv6"), testutil.CommonImage, "ip", "addr", "show", "eth0") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet6 %s/64", data.Labels().Get("staticIPv6")))) + }, + } + }, + }, + { + Description: "with dual-stack static IP addresses", + Require: nerdtest.OnlyIPv6, + Setup: func(data test.Data, helpers test.Helpers) { + networkName := data.Identifier() + staticIPv4 := "172.20.0.100" + staticIPv6 := "2001:db8:2::100" + data.Labels().Set("networkName", networkName) + data.Labels().Set("staticIPv4", staticIPv4) + data.Labels().Set("staticIPv6", staticIPv6) + helpers.Ensure("network", "create", networkName, "--driver", "bridge", "--subnet", "172.20.0.0/24", "--ipv6", "--subnet", "2001:db8:2::/64") + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Labels().Get("networkName")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", "--net", data.Labels().Get("networkName"), "--ip", data.Labels().Get("staticIPv4"), "--ip6", data.Labels().Get("staticIPv6"), testutil.CommonImage, "ip", "addr", "show", "eth0") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet %s/24", data.Labels().Get("staticIPv4")))) + assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet6 %s/64", data.Labels().Get("staticIPv6")))) + }, + } + }, + }, + { + Description: "with static IPv6 address on macvlan", + Require: nerdtest.OnlyIPv6, + Setup: func(data test.Data, helpers test.Helpers) { + id := data.Identifier() + if len(id) > 15 { + id = strings.TrimRight(id[:15], "-") + } + dummyLinkName := id + networkName := data.Identifier() + staticIPv6 := "2001:db8:3::100" + subnet := "2001:db8:3::/64" + + data.Labels().Set("dummyLinkName", dummyLinkName) + data.Labels().Set("networkName", networkName) + data.Labels().Set("staticIPv6", staticIPv6) + + // Create a dummy interface to be the parent of the macvlan network + helpers.Custom("ip", "link", "add", dummyLinkName, "type", "dummy").Run(&test.Expected{ExitCode: 0}) + helpers.Custom("ip", "link", "set", dummyLinkName, "up").Run(&test.Expected{ExitCode: 0}) + + // Create the macvlan network + helpers.Ensure("network", "create", networkName, + "--driver", "macvlan", + "--parent", dummyLinkName, + "--ipv6", + "--subnet", subnet) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Labels().Get("networkName")) + helpers.Custom("ip", "link", "del", data.Labels().Get("dummyLinkName")).Run(nil) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("run", "--rm", + "--net", data.Labels().Get("networkName"), + "--ip6", data.Labels().Get("staticIPv6"), + testutil.CommonImage, "ip", "addr", "show", "eth0") + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet6 %s/64", data.Labels().Get("staticIPv6")))) + }, + } + }, + }, } testCase.Run(t) diff --git a/pkg/composer/serviceparser/serviceparser.go b/pkg/composer/serviceparser/serviceparser.go index afd665fca6f..10e350ee2bc 100644 --- a/pkg/composer/serviceparser/serviceparser.go +++ b/pkg/composer/serviceparser/serviceparser.go @@ -604,6 +604,9 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e if value != nil && value.Ipv4Address != "" { c.RunArgs = append(c.RunArgs, "--ip="+value.Ipv4Address) } + if value != nil && value.Ipv6Address != "" { + c.RunArgs = append(c.RunArgs, "--ip6="+value.Ipv6Address) + } if value != nil && value.MacAddress != "" { c.RunArgs = append(c.RunArgs, "--mac-address="+value.MacAddress) } diff --git a/pkg/composer/serviceparser/serviceparser_test.go b/pkg/composer/serviceparser/serviceparser_test.go index 856d732cbf4..96834ac90cf 100644 --- a/pkg/composer/serviceparser/serviceparser_test.go +++ b/pkg/composer/serviceparser/serviceparser_test.go @@ -519,6 +519,44 @@ services: } +func TestParseDualStackAddress(t *testing.T) { + t.Parallel() + const dockerComposeYAML = ` +services: + foo: + image: nginx:alpine + networks: + default: + ipv4_address: "172.30.0.100" + ipv6_address: "2001:db8:abc:123::42" +networks: + default: + driver: bridge + ipam: + driver: default + config: + - subnet: "172.30.0.0/24" + - subnet: "2001:db8:abc:123::/64" +` + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) + assert.NilError(t, err) + + fooSvc, err := project.GetService("foo") + assert.NilError(t, err) + + foo, err := Parse(project, fooSvc) + assert.NilError(t, err) + + t.Logf("foo: %+v", foo) + for _, c := range foo.Containers { + assert.Assert(t, in(c.RunArgs, "--ip=172.30.0.100")) + assert.Assert(t, in(c.RunArgs, "--ip6=2001:db8:abc:123::42")) + } +} + func TestParseConfigs(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { diff --git a/pkg/netutil/netutil_unix.go b/pkg/netutil/netutil_unix.go index 046c173d122..168638a89fb 100644 --- a/pkg/netutil/netutil_unix.go +++ b/pkg/netutil/netutil_unix.go @@ -138,6 +138,10 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] bridge.HairpinMode = true if ipv6 { bridge.Capabilities["ips"] = true + // Explicitly declare capabilities that are implicitly lost when + // the bridge.Capabilities map becomes non-empty. + bridge.Capabilities["dns"] = true + bridge.Capabilities["portMappings"] = true } // Determine the appropriate firewall ingress policy based on icc setting @@ -207,6 +211,10 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string] vlan.IPAM = ipam if ipv6 { vlan.Capabilities["ips"] = true + // Explicitly declare capabilities that are implicitly lost when + // the vlan.Capabilities map becomes non-empty. + vlan.Capabilities["dns"] = true + vlan.Capabilities["portMappings"] = true } plugins = []CNIPlugin{vlan} default: @@ -230,7 +238,8 @@ func (e *CNIEnv) generateIPAM(driver string, subnets []string, gatewayStr, ipRan return nil, err } ipamConf.Ranges = append(ipamConf.Ranges, ranges...) - if !findIPv4 { + // if no subnet is specified, an ipv4 subnet should be added automatically. + if !findIPv4 && (len(subnets) == 1 && subnets[0] == "") { ranges, _, _ = e.parseIPAMRanges([]string{""}, gatewayStr, ipRangeStr, ipv6) ipamConf.Ranges = append(ipamConf.Ranges, ranges...) }