Skip to content

Commit 4c82112

Browse files
frits-vclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 1441fbe commit 4c82112

4 files changed

Lines changed: 175 additions & 1 deletion

File tree

cmd/nerdctl/network/network_create_linux_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,130 @@ func TestNetworkCreate(t *testing.T) {
156156
}
157157
},
158158
},
159+
{
160+
Description: "with static IPv4 address",
161+
Setup: func(data test.Data, helpers test.Helpers) {
162+
networkName := data.Identifier()
163+
staticIP := "172.19.0.100"
164+
data.Labels().Set("networkName", networkName)
165+
data.Labels().Set("staticIP", staticIP)
166+
helpers.Ensure("network", "create", networkName, "--driver", "bridge", "--subnet", "172.19.0.0/24")
167+
},
168+
Cleanup: func(data test.Data, helpers test.Helpers) {
169+
helpers.Anyhow("network", "rm", data.Labels().Get("networkName"))
170+
},
171+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
172+
return helpers.Command("run", "--rm", "--net", data.Labels().Get("networkName"), "--ip", data.Labels().Get("staticIP"), testutil.CommonImage, "ip", "addr", "show", "eth0")
173+
},
174+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
175+
return &test.Expected{
176+
ExitCode: 0,
177+
Output: func(stdout string, t tig.T) {
178+
assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet %s/24", data.Labels().Get("staticIP"))))
179+
},
180+
}
181+
},
182+
},
183+
{
184+
Description: "with static IPv6 address",
185+
Require: nerdtest.OnlyIPv6,
186+
Setup: func(data test.Data, helpers test.Helpers) {
187+
networkName := data.Identifier()
188+
staticIPv6 := "2001:db8:1::100"
189+
data.Labels().Set("networkName", networkName)
190+
data.Labels().Set("staticIPv6", staticIPv6)
191+
helpers.Ensure("network", "create", networkName, "--driver", "bridge", "--ipv6", "--subnet", "2001:db8:1::/64")
192+
},
193+
Cleanup: func(data test.Data, helpers test.Helpers) {
194+
helpers.Anyhow("network", "rm", data.Labels().Get("networkName"))
195+
},
196+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
197+
return helpers.Command("run", "--rm", "--net", data.Labels().Get("networkName"), "--ip6", data.Labels().Get("staticIPv6"), testutil.CommonImage, "ip", "addr", "show", "eth0")
198+
},
199+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
200+
return &test.Expected{
201+
ExitCode: 0,
202+
Output: func(stdout string, t tig.T) {
203+
assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet6 %s/64", data.Labels().Get("staticIPv6"))))
204+
},
205+
}
206+
},
207+
},
208+
{
209+
Description: "with dual-stack static IP addresses",
210+
Require: nerdtest.OnlyIPv6,
211+
Setup: func(data test.Data, helpers test.Helpers) {
212+
networkName := data.Identifier()
213+
staticIPv4 := "172.20.0.100"
214+
staticIPv6 := "2001:db8:2::100"
215+
data.Labels().Set("networkName", networkName)
216+
data.Labels().Set("staticIPv4", staticIPv4)
217+
data.Labels().Set("staticIPv6", staticIPv6)
218+
helpers.Ensure("network", "create", networkName, "--driver", "bridge", "--subnet", "172.20.0.0/24", "--ipv6", "--subnet", "2001:db8:2::/64")
219+
},
220+
Cleanup: func(data test.Data, helpers test.Helpers) {
221+
helpers.Anyhow("network", "rm", data.Labels().Get("networkName"))
222+
},
223+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
224+
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")
225+
},
226+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
227+
return &test.Expected{
228+
ExitCode: 0,
229+
Output: func(stdout string, t tig.T) {
230+
assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet %s/24", data.Labels().Get("staticIPv4"))))
231+
assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet6 %s/64", data.Labels().Get("staticIPv6"))))
232+
},
233+
}
234+
},
235+
},
236+
{
237+
Description: "with static IPv6 address on macvlan",
238+
Require: nerdtest.OnlyIPv6,
239+
Setup: func(data test.Data, helpers test.Helpers) {
240+
id := data.Identifier()
241+
if len(id) > 15 {
242+
id = strings.TrimRight(id[:15], "-")
243+
}
244+
dummyLinkName := id
245+
networkName := data.Identifier()
246+
staticIPv6 := "2001:db8:3::100"
247+
subnet := "2001:db8:3::/64"
248+
249+
data.Labels().Set("dummyLinkName", dummyLinkName)
250+
data.Labels().Set("networkName", networkName)
251+
data.Labels().Set("staticIPv6", staticIPv6)
252+
253+
// Create a dummy interface to be the parent of the macvlan network
254+
helpers.Custom("ip", "link", "add", dummyLinkName, "type", "dummy").Run(&test.Expected{ExitCode: 0})
255+
helpers.Custom("ip", "link", "set", dummyLinkName, "up").Run(&test.Expected{ExitCode: 0})
256+
257+
// Create the macvlan network
258+
helpers.Ensure("network", "create", networkName,
259+
"--driver", "macvlan",
260+
"--parent", dummyLinkName,
261+
"--ipv6",
262+
"--subnet", subnet)
263+
},
264+
Cleanup: func(data test.Data, helpers test.Helpers) {
265+
helpers.Anyhow("network", "rm", data.Labels().Get("networkName"))
266+
helpers.Custom("ip", "link", "del", data.Labels().Get("dummyLinkName")).Run(nil)
267+
},
268+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
269+
return helpers.Command("run", "--rm",
270+
"--net", data.Labels().Get("networkName"),
271+
"--ip6", data.Labels().Get("staticIPv6"),
272+
testutil.CommonImage, "ip", "addr", "show", "eth0")
273+
},
274+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
275+
return &test.Expected{
276+
ExitCode: 0,
277+
Output: func(stdout string, t tig.T) {
278+
assert.Assert(t, strings.Contains(stdout, fmt.Sprintf("inet6 %s/64", data.Labels().Get("staticIPv6"))))
279+
},
280+
}
281+
},
282+
},
159283
}
160284

161285
testCase.Run(t)

pkg/composer/serviceparser/serviceparser.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,9 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e
604604
if value != nil && value.Ipv4Address != "" {
605605
c.RunArgs = append(c.RunArgs, "--ip="+value.Ipv4Address)
606606
}
607+
if value != nil && value.Ipv6Address != "" {
608+
c.RunArgs = append(c.RunArgs, "--ip6="+value.Ipv6Address)
609+
}
607610
if value != nil && value.MacAddress != "" {
608611
c.RunArgs = append(c.RunArgs, "--mac-address="+value.MacAddress)
609612
}

pkg/composer/serviceparser/serviceparser_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,44 @@ services:
519519

520520
}
521521

522+
func TestParseDualStackAddress(t *testing.T) {
523+
t.Parallel()
524+
const dockerComposeYAML = `
525+
services:
526+
foo:
527+
image: nginx:alpine
528+
networks:
529+
default:
530+
ipv4_address: "172.30.0.100"
531+
ipv6_address: "2001:db8:abc:123::42"
532+
networks:
533+
default:
534+
driver: bridge
535+
ipam:
536+
driver: default
537+
config:
538+
- subnet: "172.30.0.0/24"
539+
- subnet: "2001:db8:abc:123::/64"
540+
`
541+
comp := testutil.NewComposeDir(t, dockerComposeYAML)
542+
defer comp.CleanUp()
543+
544+
project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil)
545+
assert.NilError(t, err)
546+
547+
fooSvc, err := project.GetService("foo")
548+
assert.NilError(t, err)
549+
550+
foo, err := Parse(project, fooSvc)
551+
assert.NilError(t, err)
552+
553+
t.Logf("foo: %+v", foo)
554+
for _, c := range foo.Containers {
555+
assert.Assert(t, in(c.RunArgs, "--ip=172.30.0.100"))
556+
assert.Assert(t, in(c.RunArgs, "--ip6=2001:db8:abc:123::42"))
557+
}
558+
}
559+
522560
func TestParseConfigs(t *testing.T) {
523561
t.Parallel()
524562
if runtime.GOOS == "windows" {

pkg/netutil/netutil_unix.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]
138138
bridge.HairpinMode = true
139139
if ipv6 {
140140
bridge.Capabilities["ips"] = true
141+
// Explicitly declare capabilities that are implicitly lost when
142+
// the bridge.Capabilities map becomes non-empty.
143+
bridge.Capabilities["dns"] = true
144+
bridge.Capabilities["portMappings"] = true
141145
}
142146

143147
// 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]
207211
vlan.IPAM = ipam
208212
if ipv6 {
209213
vlan.Capabilities["ips"] = true
214+
// Explicitly declare capabilities that are implicitly lost when
215+
// the vlan.Capabilities map becomes non-empty.
216+
vlan.Capabilities["dns"] = true
217+
vlan.Capabilities["portMappings"] = true
210218
}
211219
plugins = []CNIPlugin{vlan}
212220
default:
@@ -230,7 +238,8 @@ func (e *CNIEnv) generateIPAM(driver string, subnets []string, gatewayStr, ipRan
230238
return nil, err
231239
}
232240
ipamConf.Ranges = append(ipamConf.Ranges, ranges...)
233-
if !findIPv4 {
241+
// if no subnet is specified, an ipv4 subnet should be added automatically.
242+
if !findIPv4 && (len(subnets) == 1 && subnets[0] == "") {
234243
ranges, _, _ = e.parseIPAMRanges([]string{""}, gatewayStr, ipRangeStr, ipv6)
235244
ipamConf.Ranges = append(ipamConf.Ranges, ranges...)
236245
}

0 commit comments

Comments
 (0)