Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cmd/nerdctl/network/network_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func createCommand() *cobra.Command {
cmd.Flags().StringArray("ipam-opt", nil, "Set IPAM driver specific options")
cmd.Flags().StringArray("subnet", nil, `Subnet in CIDR format that represents a network segment, e.g. "10.5.0.0/16"`)
cmd.Flags().StringArray("gateway", nil, "IPv4 or IPv6 Gateway for the master subnet")
cmd.Flags().String("ip-range", "", `Allocate container ip from a sub-range`)
cmd.Flags().StringArray("ip-range", nil, `Allocate container ip from a sub-range`)
cmd.Flags().StringArray("label", nil, "Set metadata for a network")
cmd.Flags().Bool("ipv6", false, "Enable IPv6 networking")
cmd.Flags().Bool("internal", false, "Restrict external access to the network")
Expand Down Expand Up @@ -88,7 +88,7 @@ func createAction(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
ipRangeStr, err := cmd.Flags().GetString("ip-range")
ipRanges, err := cmd.Flags().GetStringArray("ip-range")
if err != nil {
return err
}
Expand All @@ -115,7 +115,7 @@ func createAction(cmd *cobra.Command, args []string) error {
IPAMOptions: strutil.ConvertKVStringsToMap(ipamOpts),
Subnets: subnets,
Gateway: gateways,
IPRange: ipRangeStr,
IPRange: ipRanges,
Labels: labels,
IPv6: ipv6,
Internal: internal,
Expand Down
35 changes: 35 additions & 0 deletions cmd/nerdctl/network/network_create_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,41 @@ func TestNetworkCreate(t *testing.T) {
}
},
},
{
Description: "dual-stack with explicit ip-ranges",
Require: nerdtest.OnlyIPv6,
Setup: func(data test.Data, helpers test.Helpers) {
// Before the fix the IPv4 ip-range was checked against the IPv6
// subnet and creation failed.
helpers.Ensure("network", "create", data.Identifier(),
"--ipv6",
"--subnet", "10.6.0.0/16",
"--subnet", "2001:db8:6::/64",
"--ip-range", "10.6.1.0/24",
"--ip-range", "2001:db8:6::/80",
)
},
Cleanup: func(data test.Data, helpers test.Helpers) {
helpers.Anyhow("network", "rm", data.Identifier())
},
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("network", "inspect", data.Identifier())
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: expect.ExitCodeSuccess,
Output: func(stdout string, t tig.T) {
netw := nerdtest.InspectNetwork(helpers, data.Identifier())
ranges := map[string]string{}
for _, c := range netw.IPAM.Config {
ranges[c.Subnet] = c.IPRange
}
assert.Equal(t, ranges["10.6.0.0/16"], "10.6.1.0/24")
assert.Equal(t, ranges["2001:db8:6::/64"], "2001:db8:6::/80")
},
}
},
},
{
Description: "internal enabled",
Setup: func(data test.Data, helpers test.Helpers) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/api/types/network_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type NetworkCreateOptions struct {
IPAMOptions map[string]string
Subnets []string
Gateway []string
IPRange string
IPRange []string
Labels []string
IPv6 bool
Internal bool
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/network/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (

func Create(options types.NetworkCreateOptions, stdout io.Writer) error {
if len(options.Subnets) == 0 {
if len(options.Gateway) > 0 || options.IPRange != "" {
if len(options.Gateway) > 0 || len(options.IPRange) > 0 {
return fmt.Errorf("cannot set gateway or ip-range without subnet, specify --subnet manually")
}
options.Subnets = []string{""}
Expand Down
72 changes: 56 additions & 16 deletions pkg/netutil/netutil_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]
return plugins, nil
}

func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string, ipRangeStr string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) {
func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string, ipRanges []string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) {
var ipamConfig interface{}
switch driver {
case "default", "host-local":
Expand All @@ -225,14 +225,15 @@ func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string
{Dst: "0.0.0.0/0"},
}
}
ranges, findIPv4, err := e.parseIPAMRanges(subnets, gateways, ipRangeStr, ipv6)
ranges, findIPv4, err := e.parseIPAMRanges(subnets, gateways, ipRanges, ipv6)
if err != nil {
return nil, err
}
ipamConf.Ranges = append(ipamConf.Ranges, ranges...)
if !findIPv4 {
// The default IPv4 range uses a computed gateway; pass none.
ranges, _, _ = e.parseIPAMRanges([]string{""}, nil, ipRangeStr, ipv6)
// The default IPv4 range uses a computed gateway and no ip-range;
// any user-supplied gateway or ip-range belongs to an explicit subnet.
ranges, _, _ = e.parseIPAMRanges([]string{""}, nil, nil, ipv6)
ipamConf.Ranges = append(ipamConf.Ranges, ranges...)
}
ipamConfig = ipamConf
Expand Down Expand Up @@ -289,7 +290,25 @@ func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string
return ipam, nil
}

func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange string, ipv6 bool) ([][]IPAMRange, bool, error) {
func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRanges []string, ipv6 bool) ([][]IPAMRange, bool, error) {
// Resolve every requested subnet first; parseSubnet also rejects overlaps
// with existing networks. The pairing below then works purely on the parsed
// CIDRs, so it can be unit-tested without probing the host's networks.
parsedSubnets := make([]*net.IPNet, len(subnets))
for i := range subnets {
subnet, err := e.parseSubnet(subnets[i])
if err != nil {
return nil, false, err
}
parsedSubnets[i] = subnet
}
return pairIPAMRanges(parsedSubnets, gateways, ipRanges, ipv6)
}

// pairIPAMRanges matches each gateway and ip-range to the subnet that contains
// it and builds the per-subnet IPAM ranges. It is split out from subnet
// resolution so the matching can be tested without touching live networks.
func pairIPAMRanges(subnets []*net.IPNet, gateways []string, ipRanges []string, ipv6 bool) ([][]IPAMRange, bool, error) {
// Parse the gateways once up front; matching them to subnets below is then
// just a containment check, with no parse error mixed into the loop.
parsedGateways := make([]net.IP, len(gateways))
Expand All @@ -300,15 +319,22 @@ func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange st
}
parsedGateways[i] = gw
}
// Parse the ip-ranges the same way, keyed by network so each can be matched
// to the subnet that contains it.
parsedRanges := make([]*net.IPNet, len(ipRanges))
for i, r := range ipRanges {
_, ipNet, err := net.ParseCIDR(r)
if err != nil {
return nil, false, fmt.Errorf("failed to parse ip-range %q", r)
}
parsedRanges[i] = ipNet
}

findIPv4 := false
ranges := make([][]IPAMRange, 0, len(subnets))
used := make([]bool, len(gateways))
for i := range subnets {
subnet, err := e.parseSubnet(subnets[i])
if err != nil {
return nil, findIPv4, err
}
usedGateways := make([]bool, len(gateways))
usedRanges := make([]bool, len(ipRanges))
for _, subnet := range subnets {
// if ipv6 flag is not set, subnets of ipv6 should be excluded
if !ipv6 && subnet.IP.To4() == nil {
continue
Expand All @@ -320,8 +346,17 @@ func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange st
// the v4 gateway to the v4 subnet and v6 to v6.
gateway := ""
for j, gw := range parsedGateways {
if !used[j] && subnet.Contains(gw) {
gateway, used[j] = gateways[j], true
if !usedGateways[j] && subnet.Contains(gw) {
gateway, usedGateways[j] = gateways[j], true
break
}
}
// Pair the subnet with the ip-range it contains, the same way, so a
// dual-stack network does not check the v4 range against the v6 subnet.
ipRange := ""
for j, r := range parsedRanges {
if !usedRanges[j] && subnet.Contains(r.IP) {
ipRange, usedRanges[j] = ipRanges[j], true
break
}
}
Expand All @@ -331,13 +366,18 @@ func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange st
}
ranges = append(ranges, []IPAMRange{*ipamRange})
}
// Only known after every subnet is seen: a gateway that matched none is a
// user error, same as Docker.
for j, ok := range used {
// Only known after every subnet is seen: a gateway or ip-range that matched
// none is a user error, same as Docker.
for j, ok := range usedGateways {
if !ok {
return nil, findIPv4, fmt.Errorf("no matching subnet for gateway %q", gateways[j])
}
}
for j, ok := range usedRanges {
if !ok {
return nil, findIPv4, fmt.Errorf("no matching subnet for ip-range %q", ipRanges[j])
}
}
return ranges, findIPv4, nil
}

Expand Down
54 changes: 54 additions & 0 deletions pkg/netutil/netutil_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
package netutil

import (
"net"
"testing"

"github.com/Masterminds/semver/v3"
Expand Down Expand Up @@ -69,3 +70,56 @@ func TestGuessFirewallPluginVersion(t *testing.T) {
}
}
}

// TestPairIPAMRangesIPRange covers matching repeatable --ip-range values to the
// subnet that contains each, and the errors for an unmatched or malformed range.
func TestPairIPAMRangesIPRange(t *testing.T) {
t.Parallel()
// parse turns the CIDR strings into the already-resolved subnets that
// pairIPAMRanges takes, keeping each subtest readable.
parse := func(t *testing.T, cidrs ...string) []*net.IPNet {
t.Helper()
subnets := make([]*net.IPNet, len(cidrs))
for i, c := range cidrs {
_, n, err := net.ParseCIDR(c)
assert.NilError(t, err)
subnets[i] = n
}
return subnets
}

t.Run("each ip-range pairs with its subnet regardless of order", func(t *testing.T) {
subnets := parse(t, "10.6.0.0/16", "2001:db8:6::/64")
// Given v6-first to prove the pairing is by containment, not by index.
ipRanges := []string{"2001:db8:6::/80", "10.6.1.0/24"}
ranges, findIPv4, err := pairIPAMRanges(subnets, nil, ipRanges, true)
assert.NilError(t, err)
assert.Equal(t, true, findIPv4)
got := map[string]string{}
for _, r := range ranges {
got[r[0].Subnet] = r[0].IPRange
}
assert.Equal(t, "10.6.1.0/24", got["10.6.0.0/16"])
assert.Equal(t, "2001:db8:6::/80", got["2001:db8:6::/64"])
})

t.Run("an ip-range matching no subnet errors", func(t *testing.T) {
_, _, err := pairIPAMRanges(parse(t, "10.6.0.0/16"), nil, []string{"192.168.1.0/24"}, false)
assert.ErrorContains(t, err, `no matching subnet for ip-range "192.168.1.0/24"`)
})

t.Run("an IPv4 ip-range with only an IPv6 subnet errors", func(t *testing.T) {
_, _, err := pairIPAMRanges(parse(t, "2001:db8:6::/64"), nil, []string{"10.6.1.0/24"}, true)
assert.ErrorContains(t, err, `no matching subnet for ip-range "10.6.1.0/24"`)
})

t.Run("a second ip-range claiming the same subnet errors", func(t *testing.T) {
_, _, err := pairIPAMRanges(parse(t, "10.6.0.0/16"), nil, []string{"10.6.1.0/24", "10.6.2.0/24"}, false)
assert.ErrorContains(t, err, `no matching subnet for ip-range "10.6.2.0/24"`)
})

t.Run("a malformed ip-range errors", func(t *testing.T) {
_, _, err := pairIPAMRanges(parse(t, "10.6.0.0/16"), nil, []string{"bogus"}, false)
assert.ErrorContains(t, err, `failed to parse ip-range "bogus"`)
})
}
8 changes: 6 additions & 2 deletions pkg/netutil/netutil_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,22 @@ func (e *CNIEnv) generateCNIPlugins(driver string, name string, ipam map[string]
return plugins, nil
}

func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string, ipRangeStr string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) {
func (e *CNIEnv) generateIPAM(driver string, subnets []string, gateways []string, ipRanges []string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) {
switch driver {
case "default":
default:
return nil, fmt.Errorf("unsupported ipam driver %q", driver)
}

// Windows is single-subnet, so use at most one gateway.
// Windows is single-subnet, so use at most one gateway and one ip-range.
gatewayStr := ""
if len(gateways) > 0 {
gatewayStr = gateways[0]
}
ipRangeStr := ""
if len(ipRanges) > 0 {
ipRangeStr = ipRanges[0]
}

ipamConfig := newWindowsIPAMConfig()
subnet, err := e.parseSubnet(subnets[0])
Expand Down
Loading