From 08a26ea37694a26a7716422a1ee80a493a7923fc Mon Sep 17 00:00:00 2001 From: Mayur Das Date: Fri, 3 Jul 2026 10:26:14 +0530 Subject: [PATCH] feat(network): support --aux-address on network create Docker's network create reserves auxiliary addresses so IPAM never hands them out to containers. nerdctl had no equivalent. host-local has no exclude list, but it allocates across every range in a set, so each reserved IP is carved out by splitting the subnet range around it. The reserved name=IP pairs are matched to the subnet that contains them (so dual-stack picks the right family), rejected when they hit the network or gateway address or fall outside every subnet, and recorded so network inspect reports AuxiliaryAddresses the same way Docker does. Signed-off-by: Mayur Das --- cmd/nerdctl/network/network_create.go | 30 ++-- .../network/network_create_linux_test.go | 51 ++++++ docs/command-reference.md | 3 +- pkg/api/types/network_types.go | 9 +- pkg/cmd/network/create.go | 10 ++ pkg/inspecttypes/dockercompat/dockercompat.go | 15 +- pkg/netutil/cni_plugin.go | 5 + pkg/netutil/netutil.go | 122 ++++++++++++++- pkg/netutil/netutil_test.go | 146 +++++++++++++++++- pkg/netutil/netutil_unix.go | 65 +++++++- pkg/netutil/netutil_windows.go | 6 +- 11 files changed, 431 insertions(+), 31 deletions(-) diff --git a/cmd/nerdctl/network/network_create.go b/cmd/nerdctl/network/network_create.go index 7d6c02cb123..b57748005da 100644 --- a/cmd/nerdctl/network/network_create.go +++ b/cmd/nerdctl/network/network_create.go @@ -49,6 +49,7 @@ func createCommand() *cobra.Command { 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("aux-address", nil, "Auxiliary IPv4 or IPv6 addresses used by Network driver, as name=IP pairs. The IPs are reserved and never assigned to containers") 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") @@ -92,6 +93,10 @@ func createAction(cmd *cobra.Command, args []string) error { if err != nil { return err } + auxAddresses, err := cmd.Flags().GetStringArray("aux-address") + if err != nil { + return err + } labels, err := cmd.Flags().GetStringArray("label") if err != nil { return err @@ -107,17 +112,18 @@ func createAction(cmd *cobra.Command, args []string) error { } return network.Create(types.NetworkCreateOptions{ - GOptions: globalOptions, - Name: name, - Driver: driver, - Options: strutil.ConvertKVStringsToMap(opts), - IPAMDriver: ipamDriver, - IPAMOptions: strutil.ConvertKVStringsToMap(ipamOpts), - Subnets: subnets, - Gateway: gateways, - IPRange: ipRangeStr, - Labels: labels, - IPv6: ipv6, - Internal: internal, + GOptions: globalOptions, + Name: name, + Driver: driver, + Options: strutil.ConvertKVStringsToMap(opts), + IPAMDriver: ipamDriver, + IPAMOptions: strutil.ConvertKVStringsToMap(ipamOpts), + Subnets: subnets, + Gateway: gateways, + IPRange: ipRangeStr, + AuxAddresses: auxAddresses, + Labels: labels, + IPv6: ipv6, + Internal: internal, }, cmd.OutOrStdout()) } diff --git a/cmd/nerdctl/network/network_create_linux_test.go b/cmd/nerdctl/network/network_create_linux_test.go index e43bbddf983..66107bdce8d 100644 --- a/cmd/nerdctl/network/network_create_linux_test.go +++ b/cmd/nerdctl/network/network_create_linux_test.go @@ -191,6 +191,57 @@ func TestNetworkCreate(t *testing.T) { } }, }, + { + Description: "with aux-address", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier(), + "--subnet", "10.6.0.0/24", + "--gateway", "10.6.0.1", + "--aux-address", "router=10.6.0.5", + "--aux-address", "dns=10.6.0.6", + ) + }, + 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()) + var aux map[string]string + for _, c := range netw.IPAM.Config { + if c.Subnet == "10.6.0.0/24" { + aux = c.AuxiliaryAddresses + } + } + assert.Equal(t, aux["router"], "10.6.0.5") + assert.Equal(t, aux["dns"], "10.6.0.6") + }, + } + }, + }, + { + Description: "aux-address is reserved", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("network", "create", data.Identifier(), + "--subnet", "10.6.1.0/24", + "--aux-address", "reserved=10.6.1.5", + ) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("network", "rm", data.Identifier()) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + // The reserved address is carved out of the range, so requesting + // it explicitly must fail just as it does on Docker. + return helpers.Command("run", "--rm", "--net", data.Identifier(), "--ip", "10.6.1.5", testutil.CommonImage, "true") + }, + Expected: test.Expects(expect.ExitCodeGenericFail, nil, nil), + }, } testCase.Run(t) diff --git a/docs/command-reference.md b/docs/command-reference.md index 3eb172bae10..ce3a345f4db 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1268,11 +1268,12 @@ Flags: - :whale: `--subnet`: Subnet in CIDR format that represents a network segment, e.g. "10.5.0.0/16" - :whale: `--gateway`: IPv4 or IPv6 Gateway for the master subnet - :whale: `--ip-range`: Allocate container ip from a sub-range +- :whale: `--aux-address`: Auxiliary IPv4 or IPv6 addresses, as `name=IP` pairs. Each IP is reserved and never assigned to a container. Repeatable, and matched to the subnet that contains it. - :whale: `--label`: Set metadata on a network - :whale: `--ipv6`: Enable IPv6. Should be used with a valid subnet. - :whale: `--internal`: Restrict external access to the network. -Unimplemented `docker network create` flags: `--attachable`, `--aux-address`, `--config-from`, `--config-only`, `--ingress`, `--scope` +Unimplemented `docker network create` flags: `--attachable`, `--config-from`, `--config-only`, `--ingress`, `--scope` ### :whale: nerdctl network ls diff --git a/pkg/api/types/network_types.go b/pkg/api/types/network_types.go index 17ca78a4cf6..a4fe63b51dc 100644 --- a/pkg/api/types/network_types.go +++ b/pkg/api/types/network_types.go @@ -33,9 +33,12 @@ type NetworkCreateOptions struct { Subnets []string Gateway []string IPRange string - Labels []string - IPv6 bool - Internal bool + // AuxAddresses holds "name=IP" auxiliary addresses (docker --aux-address). + // Each IP is reserved so IPAM never hands it out to a container. + AuxAddresses []string + Labels []string + IPv6 bool + Internal bool } // NetworkInspectOptions specifies options for `nerdctl network inspect`. diff --git a/pkg/cmd/network/create.go b/pkg/cmd/network/create.go index 69f4b80a2c8..6d91dc5fe1b 100644 --- a/pkg/cmd/network/create.go +++ b/pkg/cmd/network/create.go @@ -28,6 +28,16 @@ import ( func Create(options types.NetworkCreateOptions, stdout io.Writer) error { if len(options.Subnets) == 0 { + // Docker matches each aux-address to a subnet that contains it, so + // without any subnet there is nothing to match. Surface the same + // "no matching subnet for aux-address " error Docker returns. + aux, err := netutil.ParseAuxAddresses(options.AuxAddresses) + if err != nil { + return err + } + for _, ip := range aux { + return fmt.Errorf("no matching subnet for aux-address %s", ip) + } if len(options.Gateway) > 0 || options.IPRange != "" { return fmt.Errorf("cannot set gateway or ip-range without subnet, specify --subnet manually") } diff --git a/pkg/inspecttypes/dockercompat/dockercompat.go b/pkg/inspecttypes/dockercompat/dockercompat.go index 7626c3f5b92..8b896c84119 100644 --- a/pkg/inspecttypes/dockercompat/dockercompat.go +++ b/pkg/inspecttypes/dockercompat/dockercompat.go @@ -1044,9 +1044,10 @@ func getUlimitsFromNative(sp *specs.Spec) ([]*units.Ulimit, error) { } type IPAMConfig struct { - Subnet string `json:"Subnet,omitempty"` - Gateway string `json:"Gateway,omitempty"` - IPRange string `json:"IPRange,omitempty"` + Subnet string `json:"Subnet,omitempty"` + Gateway string `json:"Gateway,omitempty"` + IPRange string `json:"IPRange,omitempty"` + AuxiliaryAddresses map[string]string `json:"AuxiliaryAddresses,omitempty"` } type IPAM struct { @@ -1188,7 +1189,13 @@ func NetworkFromNative(n *native.Network) (*Network, error) { res.Name = sCNI.Name for _, plugin := range sCNI.Plugins { for _, ranges := range plugin.Ipam.Ranges { - res.IPAM.Config = append(res.IPAM.Config, ranges...) + // Each range-set describes one subnet. An aux-address reservation + // splits that subnet into several sub-ranges, but the first entry + // carries the subnet, gateway, ip-range and aux-addresses, so report + // just that one to keep one IPAM.Config entry per subnet like Docker. + if len(ranges) > 0 { + res.IPAM.Config = append(res.IPAM.Config, ranges[0]) + } } } diff --git a/pkg/netutil/cni_plugin.go b/pkg/netutil/cni_plugin.go index b44e76042e2..26dc76cb738 100644 --- a/pkg/netutil/cni_plugin.go +++ b/pkg/netutil/cni_plugin.go @@ -34,6 +34,11 @@ type IPAMRange struct { RangeEnd string `json:"rangeEnd,omitempty"` Gateway string `json:"gateway,omitempty"` IPRange string `json:"ipRange,omitempty"` + // AuxiliaryAddresses records the reserved name=IP pairs for this subnet. + // host-local does not read it (reservation is done by splitting the range + // around each IP); it is nerdctl bookkeeping so `network inspect` can + // report AuxiliaryAddresses the way Docker does. + AuxiliaryAddresses map[string]string `json:"auxiliaryAddresses,omitempty"` } type IPAMRoute struct { diff --git a/pkg/netutil/netutil.go b/pkg/netutil/netutil.go index e95c17fbb4d..d2807647065 100644 --- a/pkg/netutil/netutil.go +++ b/pkg/netutil/netutil.go @@ -17,6 +17,7 @@ package netutil import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -28,6 +29,7 @@ import ( "path/filepath" "sort" "strconv" + "strings" "github.com/containernetworking/cni/libcni" @@ -339,7 +341,7 @@ func (e *CNIEnv) CreateNetwork(opts types.NetworkCreateOptions) (*NetworkConfig, if _, ok := netMap[opts.Name]; ok { return nil, errdefs.ErrAlreadyExists } - ipam, err := e.generateIPAM(opts.IPAMDriver, opts.Subnets, opts.Gateway, opts.IPRange, opts.IPAMOptions, opts.IPv6, opts.Internal) + ipam, err := e.generateIPAM(opts.IPAMDriver, opts.Subnets, opts.Gateway, opts.IPRange, opts.AuxAddresses, opts.IPAMOptions, opts.IPv6, opts.Internal) if err != nil { return nil, err } @@ -619,6 +621,124 @@ func parseIPAMRange(subnet *net.IPNet, gatewayStr, ipRangeStr string) (*IPAMRang return res, nil } +// ParseAuxAddresses parses Docker-style "name=IP" auxiliary-address pairs into a +// name-to-IP map. Matching Docker, an entry with an empty IP (including one with +// no "=") is dropped, a later entry overrides an earlier one with the same name, +// and a non-empty but unparsable IP is an error. +func ParseAuxAddresses(raw []string) (map[string]string, error) { + if len(raw) == 0 { + return nil, nil + } + aux := make(map[string]string, len(raw)) + for _, kv := range raw { + name, ip, _ := strings.Cut(kv, "=") + if ip == "" { + continue + } + if net.ParseIP(ip) == nil { + return nil, fmt.Errorf("invalid aux-address %q", ip) + } + aux[name] = ip + } + if len(aux) == 0 { + return nil, nil + } + return aux, nil +} + +// splitIPAMRange reserves the given IPs inside a subnet's allocation range by +// carving them out. host-local has no exclude list, but it does allocate across +// every range in a set, so the reserved IPs become gaps between sub-ranges and +// are never handed out. Reserved IPs outside the allocation window need no split +// (host-local cannot reach them anyway). The base range's gateway and ip-range +// are kept on the first sub-range so the rest of the pipeline and `network +// inspect` behave exactly as the un-split case. +func splitIPAMRange(subnet *net.IPNet, base *IPAMRange, reserved []net.IP) ([]IPAMRange, error) { + if len(reserved) == 0 { + return []IPAMRange{*base}, nil + } + + // Resolve the allocation window. With an ip-range the base already carries + // its bounds; otherwise it is the whole subnet minus the all-ones address. + start := net.ParseIP(base.RangeStart) + if start == nil { + start, _ = subnetutil.FirstIPInSubnet(subnet) + } + end := net.ParseIP(base.RangeEnd) + if end == nil { + last, _ := subnetutil.LastIPInSubnet(subnet) + end = ipDec(last) + } + + // Keep only the reserved IPs that fall within the window, sorted ascending. + inWindow := make([]net.IP, 0, len(reserved)) + for _, ip := range reserved { + if bytes.Compare(ip.To16(), start.To16()) >= 0 && bytes.Compare(ip.To16(), end.To16()) <= 0 { + inWindow = append(inWindow, ip) + } + } + if len(inWindow) == 0 { + return []IPAMRange{*base}, nil + } + sort.Slice(inWindow, func(i, j int) bool { + return bytes.Compare(inWindow[i].To16(), inWindow[j].To16()) < 0 + }) + + // Walk the window left to right, emitting a sub-range for each gap between + // reserved IPs. + var out []IPAMRange + cur := start + for _, r := range inWindow { + hi := ipDec(r) + if bytes.Compare(cur.To16(), hi.To16()) <= 0 { + out = append(out, IPAMRange{Subnet: subnet.String(), RangeStart: cur.String(), RangeEnd: hi.String()}) + } + cur = ipInc(r) + } + if bytes.Compare(cur.To16(), end.To16()) <= 0 { + out = append(out, IPAMRange{Subnet: subnet.String(), RangeStart: cur.String(), RangeEnd: end.String()}) + } + if len(out) == 0 { + return nil, fmt.Errorf("aux-address reservations leave no allocatable IPs in subnet %s", subnet) + } + + // host-local reserves the gateway only when it is set on the range it lands + // in, and after splitting the gateway can be in any sub-range, so set it on + // all of them. The original ip-range is nerdctl-only bookkeeping for inspect, + // so keep it on the first sub-range alone. + for i := range out { + out[i].Gateway = base.Gateway + } + out[0].IPRange = base.IPRange + return out, nil +} + +// ipInc returns ip+1 and ipDec returns ip-1, both in 16-byte form and on a copy +// so the caller's IP is left untouched. +func ipInc(ip net.IP) net.IP { + out := make(net.IP, net.IPv6len) + copy(out, ip.To16()) + for i := len(out) - 1; i >= 0; i-- { + out[i]++ + if out[i] != 0 { + break + } + } + return out +} + +func ipDec(ip net.IP) net.IP { + out := make(net.IP, net.IPv6len) + copy(out, ip.To16()) + for i := len(out) - 1; i >= 0; i-- { + out[i]-- + if out[i] != 0xff { + break + } + } + return out +} + // convert the struct to a map func structToMap(in interface{}) (map[string]interface{}, error) { out := make(map[string]interface{}) diff --git a/pkg/netutil/netutil_test.go b/pkg/netutil/netutil_test.go index f818c59a1b2..0fd92ac50bf 100644 --- a/pkg/netutil/netutil_test.go +++ b/pkg/netutil/netutil_test.go @@ -128,11 +128,155 @@ func TestParseIPAMRange(t *testing.T) { assert.ErrorContains(t, err, tc.err) } else { assert.NilError(t, err) - assert.Equal(t, *tc.expected, *got) + assert.DeepEqual(t, *tc.expected, *got) } } } +func TestParseAuxAddresses(t *testing.T) { + t.Parallel() + type testCase struct { + raw []string + expected map[string]string + err string + } + testCases := []testCase{ + { + raw: nil, + expected: nil, + }, + { + raw: []string{"router=10.1.100.5", "dns=10.1.100.6"}, + expected: map[string]string{"router": "10.1.100.5", "dns": "10.1.100.6"}, + }, + { + // An empty name is allowed, matching Docker. + raw: []string{"=10.1.100.5"}, + expected: map[string]string{"": "10.1.100.5"}, + }, + { + // An entry with no "=" has an empty IP and is dropped, matching Docker. + raw: []string{"10.1.100.5"}, + expected: nil, + }, + { + // A later value overrides an earlier one with the same name. + raw: []string{"a=10.1.100.5", "a=10.1.100.6"}, + expected: map[string]string{"a": "10.1.100.6"}, + }, + { + raw: []string{"v6=2001:db8::5"}, + expected: map[string]string{"v6": "2001:db8::5"}, + }, + { + raw: []string{"bad=not-an-ip"}, + err: "invalid aux-address", + }, + } + for _, tc := range testCases { + got, err := ParseAuxAddresses(tc.raw) + if tc.err != "" { + assert.ErrorContains(t, err, tc.err) + continue + } + assert.NilError(t, err) + assert.DeepEqual(t, tc.expected, got) + } +} + +func TestSplitIPAMRange(t *testing.T) { + t.Parallel() + ips := func(addrs ...string) []net.IP { + out := make([]net.IP, len(addrs)) + for i, a := range addrs { + out[i] = net.ParseIP(a) + } + return out + } + type testCase struct { + name string + subnet string + base *IPAMRange + reserved []net.IP + expected []IPAMRange + err string + } + testCases := []testCase{ + { + name: "no reservation leaves the range untouched", + subnet: "10.1.100.0/24", + base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1"}, + reserved: nil, + expected: []IPAMRange{{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1"}}, + }, + { + name: "a mid-subnet reservation splits the range in two", + subnet: "10.1.100.0/24", + base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1"}, + reserved: ips("10.1.100.5"), + expected: []IPAMRange{ + {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.4", Gateway: "10.1.100.1"}, + {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.6", RangeEnd: "10.1.100.254", Gateway: "10.1.100.1"}, + }, + }, + { + name: "two reservations produce three sub-ranges", + subnet: "10.1.100.0/24", + base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1"}, + reserved: ips("10.1.100.6", "10.1.100.5"), + expected: []IPAMRange{ + {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.4", Gateway: "10.1.100.1"}, + {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.7", RangeEnd: "10.1.100.254", Gateway: "10.1.100.1"}, + }, + }, + { + // The gateway is the first usable address, so reserving the next one + // leaves a gateway-only sub-range; host-local reserves the gateway, so + // allocation still starts after the reservation. + name: "a reservation right after the gateway leaves a gateway-only range", + subnet: "10.1.100.0/24", + base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1"}, + reserved: ips("10.1.100.2"), + expected: []IPAMRange{ + {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.1", Gateway: "10.1.100.1"}, + {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.3", RangeEnd: "10.1.100.254", Gateway: "10.1.100.1"}, + }, + }, + { + name: "a reservation inside an ip-range splits within its bounds", + subnet: "10.1.100.0/24", + base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1", IPRange: "10.1.100.0/28", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.15"}, + reserved: ips("10.1.100.5"), + expected: []IPAMRange{ + {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.4", Gateway: "10.1.100.1", IPRange: "10.1.100.0/28"}, + {Subnet: "10.1.100.0/24", RangeStart: "10.1.100.6", RangeEnd: "10.1.100.15", Gateway: "10.1.100.1"}, + }, + }, + { + name: "a reservation outside the ip-range needs no split", + subnet: "10.1.100.0/24", + base: &IPAMRange{Subnet: "10.1.100.0/24", Gateway: "10.1.100.1", IPRange: "10.1.100.0/28", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.15"}, + reserved: ips("10.1.100.200"), + expected: []IPAMRange{ + {Subnet: "10.1.100.0/24", Gateway: "10.1.100.1", IPRange: "10.1.100.0/28", RangeStart: "10.1.100.1", RangeEnd: "10.1.100.15"}, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, subnet, err := net.ParseCIDR(tc.subnet) + assert.NilError(t, err) + got, err := splitIPAMRange(subnet, tc.base, tc.reserved) + if tc.err != "" { + assert.ErrorContains(t, err, tc.err) + return + } + assert.NilError(t, err) + assert.DeepEqual(t, tc.expected, got) + }) + } +} + // Tests whether nerdctl properly creates the default network when required. // Note that this test will require a CNI driver bearing the same name as // the type of the default network. (denoted by netutil.DefaultNetworkName, diff --git a/pkg/netutil/netutil_unix.go b/pkg/netutil/netutil_unix.go index 4a5d1c0c50f..524642984e6 100644 --- a/pkg/netutil/netutil_unix.go +++ b/pkg/netutil/netutil_unix.go @@ -215,24 +215,31 @@ 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, ipRangeStr string, auxAddresses []string, opts map[string]string, ipv6 bool, internal bool) (map[string]interface{}, error) { var ipamConfig interface{} switch driver { case "default", "host-local": + // Reserved auxiliary addresses are only meaningful for host-local, where + // they are enforced by carving the reserved IPs out of the range below. + aux, err := ParseAuxAddresses(auxAddresses) + if err != nil { + return nil, err + } ipamConf := newHostLocalIPAMConfig() if !internal { ipamConf.Routes = []IPAMRoute{ {Dst: "0.0.0.0/0"}, } } - ranges, findIPv4, err := e.parseIPAMRanges(subnets, gateways, ipRangeStr, ipv6) + ranges, findIPv4, err := e.parseIPAMRanges(subnets, gateways, ipRangeStr, aux, 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; pass none. It also + // has no user subnet, so no aux-address can match it. + ranges, _, _ = e.parseIPAMRanges([]string{""}, nil, ipRangeStr, nil, ipv6) ipamConf.Ranges = append(ipamConf.Ranges, ranges...) } ipamConfig = ipamConf @@ -289,7 +296,7 @@ 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, ipRange string, aux map[string]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)) @@ -301,6 +308,19 @@ func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange st parsedGateways[i] = gw } + // Parse the aux-addresses once too, so the per-subnet loop only tests + // containment. matchedAux records which ones landed in a subnet; a subnet's + // non-overlap guarantee means an aux IP belongs to at most one subnet. + parsedAux := make(map[string]net.IP, len(aux)) + for name, ipStr := range aux { + ip := net.ParseIP(ipStr) + if ip == nil { + return nil, false, fmt.Errorf("failed to parse aux-address %q", ipStr) + } + parsedAux[name] = ip + } + matchedAux := make(map[string]bool, len(parsedAux)) + findIPv4 := false ranges := make([][]IPAMRange, 0, len(subnets)) used := make([]bool, len(gateways)) @@ -329,15 +349,44 @@ func (e *CNIEnv) parseIPAMRanges(subnets []string, gateways []string, ipRange st if err != nil { return nil, findIPv4, err } - ranges = append(ranges, []IPAMRange{*ipamRange}) + // Collect the aux-addresses that fall inside this subnet, rejecting the + // ones Docker also rejects (the network or gateway address), then reserve + // them by splitting the range. + gatewayIP := net.ParseIP(ipamRange.Gateway) + subnetAux := map[string]string{} + var reserved []net.IP + for name, ip := range parsedAux { + if !subnet.Contains(ip) { + continue + } + matchedAux[name] = true + if ip.Equal(subnet.IP) || (gatewayIP != nil && ip.Equal(gatewayIP)) { + return nil, findIPv4, fmt.Errorf("failed to allocate secondary ip address (%s:%s): Address already in use", name, ip) + } + subnetAux[name] = ip.String() + reserved = append(reserved, ip) + } + rangeSet, err := splitIPAMRange(subnet, ipamRange, reserved) + if err != nil { + return nil, findIPv4, err + } + if len(subnetAux) > 0 { + rangeSet[0].AuxiliaryAddresses = subnetAux + } + ranges = append(ranges, rangeSet) } - // Only known after every subnet is seen: a gateway that matched none is a - // user error, same as Docker. + // Only known after every subnet is seen: a gateway or aux-address that + // matched no subnet is a user error, same as Docker. for j, ok := range used { if !ok { return nil, findIPv4, fmt.Errorf("no matching subnet for gateway %q", gateways[j]) } } + for name, ip := range parsedAux { + if !matchedAux[name] { + return nil, findIPv4, fmt.Errorf("no matching subnet for aux-address %s", ip) + } + } return ranges, findIPv4, nil } diff --git a/pkg/netutil/netutil_windows.go b/pkg/netutil/netutil_windows.go index 71d9a76ce6c..95914b27366 100644 --- a/pkg/netutil/netutil_windows.go +++ b/pkg/netutil/netutil_windows.go @@ -72,12 +72,16 @@ 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, ipRangeStr string, auxAddresses []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) } + // The Windows nat IPAM has no way to reserve individual addresses. + if len(auxAddresses) > 0 { + return nil, fmt.Errorf("--aux-address is not supported on Windows") + } // Windows is single-subnet, so use at most one gateway. gatewayStr := ""