From 2e9e87732f8475d6c8a2261d4f28ec2057c8aaef Mon Sep 17 00:00:00 2001 From: Casey Callendrello Date: Thu, 11 May 2017 17:01:20 +0200 Subject: [PATCH] ipam/host-local: support multiple IP ranges This change allows the host-local allocator to allocate multiple IPs. This is intended to enable dual-stack, but is not limited to only two subnets or separate address families. --- pkg/ip/cidr.go | 10 + plugins/ipam/host-local/README.md | 82 +++-- .../host-local/backend/allocator/allocator.go | 320 ++++++---------- .../backend/allocator/allocator_test.go | 342 ++++++++---------- .../host-local/backend/allocator/config.go | 114 ++++-- .../backend/allocator/config_test.go | 309 ++++++++++++++++ .../host-local/backend/allocator/range.go | 159 ++++++++ .../backend/allocator/range_test.go | 215 +++++++++++ .../ipam/host-local/backend/disk/backend.go | 14 +- plugins/ipam/host-local/backend/store.go | 4 +- .../host-local/backend/testing/fake_store.go | 23 +- plugins/ipam/host-local/host_local_test.go | 268 +++++++++++++- plugins/ipam/host-local/main.go | 78 +++- 13 files changed, 1454 insertions(+), 484 deletions(-) create mode 100644 plugins/ipam/host-local/backend/allocator/config_test.go create mode 100644 plugins/ipam/host-local/backend/allocator/range.go create mode 100644 plugins/ipam/host-local/backend/allocator/range_test.go diff --git a/pkg/ip/cidr.go b/pkg/ip/cidr.go index dae2c4d0..7acc2d47 100644 --- a/pkg/ip/cidr.go +++ b/pkg/ip/cidr.go @@ -31,6 +31,16 @@ func PrevIP(ip net.IP) net.IP { return intToIP(i.Sub(i, big.NewInt(1))) } +// Cmp compares two IPs, returning the usual ordering: +// a < b : -1 +// a == b : 0 +// a > b : 1 +func Cmp(a, b net.IP) int { + aa := ipToInt(a) + bb := ipToInt(b) + return aa.Cmp(bb) +} + func ipToInt(ip net.IP) *big.Int { if v := ip.To4(); v != nil { return big.NewInt(0).SetBytes(v) diff --git a/plugins/ipam/host-local/README.md b/plugins/ipam/host-local/README.md index 073e369a..7ce4a686 100644 --- a/plugins/ipam/host-local/README.md +++ b/plugins/ipam/host-local/README.md @@ -5,30 +5,41 @@ it can include a DNS configuration from a `resolv.conf` file on the host. ## Overview -host-local IPAM plugin allocates IPv4 addresses out of a specified address range. +host-local IPAM plugin allocates ip addresses out of a set of address ranges. It stores the state locally on the host filesystem, therefore ensuring uniqueness of IP addresses on a single host. ## Example configurations -IPv4: ```json { "ipam": { "type": "host-local", - "subnet": "10.10.0.0/16", - "rangeStart": "10.10.1.20", - "rangeEnd": "10.10.3.50", - "gateway": "10.10.0.254", + "ranges": [ + { + "subnet": "10.10.0.0/16", + "rangeStart": "10.10.1.20", + "rangeEnd": "10.10.3.50", + "gateway": "10.10.0.254" + }, + { + "subnet": "3ffe:ffff:0:01ff::/64", + "rangeStart": "3ffe:ffff:0:01ff::0010", + "rangeEnd": "3ffe:ffff:0:01ff::0020" + } + ], "routes": [ { "dst": "0.0.0.0/0" }, - { "dst": "192.168.0.0/16", "gw": "10.10.5.1" } + { "dst": "192.168.0.0/16", "gw": "10.10.5.1" }, + { "dst": "3ffe:ffff:0:01ff::1/64" } ], - "dataDir": "/var/my-orchestrator/container-ipam-state" + "dataDir": "/run/my-orchestrator/container-ipam-state" } } ``` -IPv6: +Previous versions of the `host-local` allocator did not support the `ranges` +property, and instead expected a single range on the top level. This is +deprecated but still supported. ```json { "ipam": { @@ -47,36 +58,65 @@ IPv6: We can test it out on the command-line: ```bash -$ export CNI_COMMAND=ADD -$ export CNI_CONTAINERID=f81d4fae-7dec-11d0-a765-00a0c91e6bf6 -$ echo '{ "name": "default", "ipam": { "type": "host-local", "subnet": "203.0.113.0/24" } }' | ./host-local +$ echo '{ "cniVersion": "0.3.1", "name": "examplenet", "ipam": { "type": "host-local", "ranges": [ {"subnet": "203.0.113.0/24"}, {"subnet": "2001:db8:1::/64"}], "dataDir": "/tmp/cni-example" } }' | CNI_COMMAND=ADD CNI_CONTAINERID=example CNI_NETNS=/dev/null CNI_IFNAME=dummy0 CNI_PATH=. ./host-local + ``` ```json { - "ip4": { - "ip": "203.0.113.1/24" - } + "ips": [ + { + "version": "4", + "address": "203.0.113.2/24", + "gateway": "203.0.113.1" + }, + { + "version": "6", + "address": "2001:db8:1::2/64", + "gateway": "2001:db8:1::1" + } + ], + "dns": {} } ``` ## Network configuration reference * `type` (string, required): "host-local". -* `subnet` (string, required): CIDR block to allocate out of. -* `rangeStart` (string, optional): IP inside of "subnet" from which to start allocating addresses. Defaults to ".2" IP inside of the "subnet" block. -* `rangeEnd` (string, optional): IP inside of "subnet" with which to end allocating addresses. Defaults to ".254" IP inside of the "subnet" block. -* `gateway` (string, optional): IP inside of "subnet" to designate as the gateway. Defaults to ".1" IP inside of the "subnet" block. * `routes` (string, optional): list of routes to add to the container namespace. Each route is a dictionary with "dst" and optional "gw" fields. If "gw" is omitted, value of "gateway" will be used. * `resolvConf` (string, optional): Path to a `resolv.conf` on the host to parse and return as the DNS configuration * `dataDir` (string, optional): Path to a directory to use for maintaining state, e.g. which IPs have been allocated to which containers +* `ranges`, (array, required, nonempty) an array of range objects: + * `subnet` (string, required): CIDR block to allocate out of. + * `rangeStart` (string, optional): IP inside of "subnet" from which to start allocating addresses. Defaults to ".2" IP inside of the "subnet" block. + * `rangeEnd` (string, optional): IP inside of "subnet" with which to end allocating addresses. Defaults to ".254" IP inside of the "subnet" block for ipv4, ".255" for IPv6 + * `gateway` (string, optional): IP inside of "subnet" to designate as the gateway. Defaults to ".1" IP inside of the "subnet" block. +Older versions of the `host-local` plugin did not support the `ranges` array. Instead, +all the properties in the `range` object were top-level. This is still supported but deprecated. ## Supported arguments The following [CNI_ARGS](https://github.com/containernetworking/cni/blob/master/SPEC.md#parameters) are supported: -* `ip`: request a specific IP address from the subnet. If it's not available, the plugin will exit with an error +* `ip`: request a specific IP address from a subnet. + +The following [args conventions](https://github.com/containernetworking/cni/blob/master/CONVENTIONS.md) are supported: + +* `ips` (array of strings): A list of custom IPs to attempt to allocate + +### Custom IP allocation +For every requested custom IP, the `host-local` allocator will request that IP +if it falls within one of the `range` objects. Thus it is possible to specify +multiple custom IPs and multiple ranges. + +If any requested IPs cannot be reserved, either because they are already in use +or are not part of a specified range, the plugin will return an error. + ## Files -Allocated IP addresses are stored as files in `/var/lib/cni/networks/$NETWORK_NAME`. The prefix can be customized with the `dataDir` option listed above. +Allocated IP addresses are stored as files in `/var/lib/cni/networks/$NETWORK_NAME`. +The path can be customized with the `dataDir` option listed above. Environments +where IPs are released automatically on reboot (e.g. running containers are not +restored) may wish to specify `/var/run/cni` or another tmpfs mounted directory +instead. diff --git a/plugins/ipam/host-local/backend/allocator/allocator.go b/plugins/ipam/host-local/backend/allocator/allocator.go index 56b8bdf0..d4c27979 100644 --- a/plugins/ipam/host-local/backend/allocator/allocator.go +++ b/plugins/ipam/host-local/backend/allocator/allocator.go @@ -15,196 +15,115 @@ package allocator import ( + "encoding/base64" "fmt" "log" "net" "os" - "github.com/containernetworking/cni/pkg/types" "github.com/containernetworking/cni/pkg/types/current" "github.com/containernetworking/plugins/pkg/ip" "github.com/containernetworking/plugins/plugins/ipam/host-local/backend" ) type IPAllocator struct { - // start is inclusive and may be allocated + netName string + ipRange Range + store backend.Store + rangeID string // Used for tracking last reserved ip +} + +type RangeIter struct { + low net.IP + high net.IP + cur net.IP start net.IP - // end is inclusive and may be allocated - end net.IP - conf *IPAMConfig - store backend.Store } -func NewIPAllocator(conf *IPAMConfig, store backend.Store) (*IPAllocator, error) { - // Can't create an allocator for a network with no addresses, eg - // a /32 or /31 - ones, masklen := conf.Subnet.Mask.Size() - if ones > masklen-2 { - return nil, fmt.Errorf("Network %v too small to allocate from", conf.Subnet) - } +func NewIPAllocator(netName string, r Range, store backend.Store) *IPAllocator { + // The range name (last allocated ip suffix) is just the base64 + // encoding of the bytes of the first IP + rangeID := base64.URLEncoding.EncodeToString(r.RangeStart) - var ( - start net.IP - end net.IP - err error - ) - start, end, err = networkRange((*net.IPNet)(&conf.Subnet)) - if err != nil { - return nil, err + return &IPAllocator{ + netName: netName, + ipRange: r, + store: store, + rangeID: rangeID, } - - // skip the .0 address - start = ip.NextIP(start) - - if conf.RangeStart != nil { - if err := validateRangeIP(conf.RangeStart, (*net.IPNet)(&conf.Subnet), start, end); err != nil { - return nil, err - } - start = conf.RangeStart - } - if conf.RangeEnd != nil { - if err := validateRangeIP(conf.RangeEnd, (*net.IPNet)(&conf.Subnet), start, end); err != nil { - return nil, err - } - end = conf.RangeEnd - } - return &IPAllocator{start, end, conf, store}, nil } -func canonicalizeIP(ip net.IP) (net.IP, error) { - if ip.To4() != nil { - return ip.To4(), nil - } else if ip.To16() != nil { - return ip.To16(), nil - } - return nil, fmt.Errorf("IP %s not v4 nor v6", ip) -} - -// Ensures @ip is within @ipnet, and (if given) inclusive of @start and @end -func validateRangeIP(ip net.IP, ipnet *net.IPNet, start net.IP, end net.IP) error { - var err error - - // Make sure we can compare IPv4 addresses directly - ip, err = canonicalizeIP(ip) - if err != nil { - return err - } - - if !ipnet.Contains(ip) { - return fmt.Errorf("%s not in network: %s", ip, ipnet) - } - - if start != nil { - start, err = canonicalizeIP(start) - if err != nil { - return err - } - if len(ip) != len(start) { - return fmt.Errorf("%s %d not same size IP address as start %s %d", ip, len(ip), start, len(start)) - } - for i := 0; i < len(ip); i++ { - if ip[i] > start[i] { - break - } else if ip[i] < start[i] { - return fmt.Errorf("%s outside of network %s with start %s", ip, ipnet, start) - } - } - } - - if end != nil { - end, err = canonicalizeIP(end) - if err != nil { - return err - } - if len(ip) != len(end) { - return fmt.Errorf("%s %d not same size IP address as end %s %d", ip, len(ip), end, len(end)) - } - for i := 0; i < len(ip); i++ { - if ip[i] < end[i] { - break - } else if ip[i] > end[i] { - return fmt.Errorf("%s outside of network %s with end %s", ip, ipnet, end) - } - } - } - return nil -} - -// Returns newly allocated IP along with its config -func (a *IPAllocator) Get(id string) (*current.IPConfig, []*types.Route, error) { +// Get alocates an IP +func (a *IPAllocator) Get(id string, requestedIP net.IP) (*current.IPConfig, error) { a.store.Lock() defer a.store.Unlock() - gw := a.conf.Gateway - if gw == nil { - gw = ip.NextIP(a.conf.Subnet.IP) - } + gw := a.ipRange.Gateway - var requestedIP net.IP - if a.conf.Args != nil { - requestedIP = a.conf.Args.IP - } + var reservedIP net.IP if requestedIP != nil { - if gw != nil && gw.Equal(a.conf.Args.IP) { - return nil, nil, fmt.Errorf("requested IP must differ gateway IP") + if gw != nil && gw.Equal(requestedIP) { + return nil, fmt.Errorf("requested IP must differ from gateway IP") } - subnet := net.IPNet{ - IP: a.conf.Subnet.IP, - Mask: a.conf.Subnet.Mask, + if err := a.ipRange.IPInRange(requestedIP); err != nil { + return nil, err } - err := validateRangeIP(requestedIP, &subnet, a.start, a.end) + + reserved, err := a.store.Reserve(id, requestedIP, a.rangeID) if err != nil { - return nil, nil, err + return nil, err } + if !reserved { + return nil, fmt.Errorf("requested IP address %q is not available in network: %s %s", requestedIP, a.netName, (*net.IPNet)(&a.ipRange.Subnet).String()) + } + reservedIP = requestedIP - reserved, err := a.store.Reserve(id, requestedIP) + } else { + iter, err := a.GetIter() if err != nil { - return nil, nil, err + return nil, err } - - if reserved { - ipConfig := ¤t.IPConfig{ - Version: "4", - Address: net.IPNet{IP: requestedIP, Mask: a.conf.Subnet.Mask}, - Gateway: gw, + for { + cur := iter.Next() + if cur == nil { + break + } + + // don't allocate gateway IP + if gw != nil && cur.Equal(gw) { + continue + } + + reserved, err := a.store.Reserve(id, cur, a.rangeID) + if err != nil { + return nil, err + } + + if reserved { + reservedIP = cur + break } - routes := convertRoutesToCurrent(a.conf.Routes) - return ipConfig, routes, nil } - return nil, nil, fmt.Errorf("requested IP address %q is not available in network: %s", requestedIP, a.conf.Name) } - startIP, endIP := a.getSearchRange() - for cur := startIP; ; cur = a.nextIP(cur) { - // don't allocate gateway IP - if gw != nil && cur.Equal(gw) { - continue - } - - reserved, err := a.store.Reserve(id, cur) - if err != nil { - return nil, nil, err - } - if reserved { - ipConfig := ¤t.IPConfig{ - Version: "4", - Address: net.IPNet{IP: cur, Mask: a.conf.Subnet.Mask}, - Gateway: gw, - } - routes := convertRoutesToCurrent(a.conf.Routes) - return ipConfig, routes, nil - } - // break here to complete the loop - if cur.Equal(endIP) { - break - } + if reservedIP == nil { + return nil, fmt.Errorf("no IP addresses available in network: %s %s", a.netName, (*net.IPNet)(&a.ipRange.Subnet).String()) } - return nil, nil, fmt.Errorf("no IP addresses available in network: %s", a.conf.Name) + version := "4" + if reservedIP.To4() == nil { + version = "6" + } + + return ¤t.IPConfig{ + Version: version, + Address: net.IPNet{IP: reservedIP, Mask: a.ipRange.Subnet.Mask}, + Gateway: gw, + }, nil } -// Releases all IPs allocated for the container with given ID +// Release clears all IPs allocated for the container with given ID func (a *IPAllocator) Release(id string) error { a.store.Lock() defer a.store.Unlock() @@ -212,66 +131,59 @@ func (a *IPAllocator) Release(id string) error { return a.store.ReleaseByID(id) } -// Return the start and end IP addresses of a given subnet, excluding -// the broadcast address (eg, 192.168.1.255) -func networkRange(ipnet *net.IPNet) (net.IP, net.IP, error) { - if ipnet.IP == nil { - return nil, nil, fmt.Errorf("missing field %q in IPAM configuration", "subnet") - } - ip, err := canonicalizeIP(ipnet.IP) - if err != nil { - return nil, nil, fmt.Errorf("IP not v4 nor v6") +// GetIter encapsulates the strategy for this allocator. +// We use a round-robin strategy, attempting to evenly use the whole subnet. +// More specifically, a crash-looping container will not see the same IP until +// the entire range has been run through. +// We may wish to consider avoiding recently-released IPs in the future. +func (a *IPAllocator) GetIter() (*RangeIter, error) { + i := RangeIter{ + low: a.ipRange.RangeStart, + high: a.ipRange.RangeEnd, } - if len(ip) != len(ipnet.Mask) { - return nil, nil, fmt.Errorf("IPNet IP and Mask version mismatch") - } - - var end net.IP - for i := 0; i < len(ip); i++ { - end = append(end, ip[i]|^ipnet.Mask[i]) - } - - // Exclude the broadcast address for IPv4 - if ip.To4() != nil { - end[3]-- - } - - return ipnet.IP, end, nil -} - -// nextIP returns the next ip of curIP within ipallocator's subnet -func (a *IPAllocator) nextIP(curIP net.IP) net.IP { - if curIP.Equal(a.end) { - return a.start - } - return ip.NextIP(curIP) -} - -// getSearchRange returns the start and end ip based on the last reserved ip -func (a *IPAllocator) getSearchRange() (net.IP, net.IP) { - var startIP net.IP - var endIP net.IP + // Round-robin by trying to allocate from the last reserved IP + 1 startFromLastReservedIP := false - lastReservedIP, err := a.store.LastReservedIP() + + // We might get a last reserved IP that is wrong if the range indexes changed. + // This is not critical, we just lose round-robin this one time. + lastReservedIP, err := a.store.LastReservedIP(a.rangeID) if err != nil && !os.IsNotExist(err) { - log.Printf("Error retriving last reserved ip: %v", err) + log.Printf("Error retrieving last reserved ip: %v", err) } else if lastReservedIP != nil { - subnet := net.IPNet{ - IP: a.conf.Subnet.IP, - Mask: a.conf.Subnet.Mask, - } - err := validateRangeIP(lastReservedIP, &subnet, a.start, a.end) - if err == nil { - startFromLastReservedIP = true - } + startFromLastReservedIP = a.ipRange.IPInRange(lastReservedIP) == nil } + if startFromLastReservedIP { - startIP = a.nextIP(lastReservedIP) - endIP = lastReservedIP + if i.high.Equal(lastReservedIP) { + i.start = i.low + } else { + i.start = ip.NextIP(lastReservedIP) + } } else { - startIP = a.start - endIP = a.end + i.start = a.ipRange.RangeStart } - return startIP, endIP + return &i, nil +} + +// Next returns the next IP in the iterator, or nil if end is reached +func (i *RangeIter) Next() net.IP { + // If we're at the beginning, time to start + if i.cur == nil { + i.cur = i.start + return i.cur + } + // we returned .high last time, since we're inclusive + if i.cur.Equal(i.high) { + i.cur = i.low + } else { + i.cur = ip.NextIP(i.cur) + } + + // If we've looped back to where we started, exit + if i.cur.Equal(i.start) { + return nil + } + + return i.cur } diff --git a/plugins/ipam/host-local/backend/allocator/allocator_test.go b/plugins/ipam/host-local/backend/allocator/allocator_test.go index 54ead09e..2c258a9b 100644 --- a/plugins/ipam/host-local/backend/allocator/allocator_test.go +++ b/plugins/ipam/host-local/backend/allocator/allocator_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 CNI authors +// Copyright 2017 CNI authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,12 +16,13 @@ package allocator import ( "fmt" + "net" + "github.com/containernetworking/cni/pkg/types" "github.com/containernetworking/cni/pkg/types/current" fakestore "github.com/containernetworking/plugins/plugins/ipam/host-local/backend/testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" - "net" ) type AllocatorTestCase struct { @@ -31,31 +32,99 @@ type AllocatorTestCase struct { lastIP string } -func (t AllocatorTestCase) run() (*current.IPConfig, []*types.Route, error) { +func mkalloc() IPAllocator { + ipnet, _ := types.ParseCIDR("192.168.1.0/24") + + r := Range{ + Subnet: types.IPNet(*ipnet), + } + r.Canonicalize() + store := fakestore.NewFakeStore(map[string]string{}, map[string]net.IP{}) + + alloc := IPAllocator{ + netName: "netname", + ipRange: r, + store: store, + rangeID: "rangeid", + } + + return alloc +} + +func (t AllocatorTestCase) run(idx int) (*current.IPConfig, error) { + fmt.Fprintln(GinkgoWriter, "Index:", idx) subnet, err := types.ParseCIDR(t.subnet) if err != nil { - return nil, nil, err + return nil, err } - conf := IPAMConfig{ - Name: "test", - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - } - store := fakestore.NewFakeStore(t.ipmap, net.ParseIP(t.lastIP)) - alloc, err := NewIPAllocator(&conf, store) - if err != nil { - return nil, nil, err - } - res, routes, err := alloc.Get("ID") - if err != nil { - return nil, nil, err + conf := Range{ + Subnet: types.IPNet(*subnet), } - return res, routes, nil + Expect(conf.Canonicalize()).To(BeNil()) + + store := fakestore.NewFakeStore(t.ipmap, map[string]net.IP{"rangeid": net.ParseIP(t.lastIP)}) + + alloc := IPAllocator{ + "netname", + conf, + store, + "rangeid", + } + + return alloc.Get("ID", nil) } var _ = Describe("host-local ip allocator", func() { + Context("RangeIter", func() { + It("should loop correctly from the beginning", func() { + r := RangeIter{ + start: net.IP{10, 0, 0, 0}, + low: net.IP{10, 0, 0, 0}, + high: net.IP{10, 0, 0, 5}, + } + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 0})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 1})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 2})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 3})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 4})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 5})) + Expect(r.Next()).To(BeNil()) + }) + + It("should loop correctly from the end", func() { + r := RangeIter{ + start: net.IP{10, 0, 0, 5}, + low: net.IP{10, 0, 0, 0}, + high: net.IP{10, 0, 0, 5}, + } + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 5})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 0})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 1})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 2})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 3})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 4})) + Expect(r.Next()).To(BeNil()) + }) + + It("should loop correctly from the middle", func() { + r := RangeIter{ + start: net.IP{10, 0, 0, 3}, + low: net.IP{10, 0, 0, 0}, + high: net.IP{10, 0, 0, 5}, + } + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 3})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 4})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 5})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 0})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 1})) + Expect(r.Next()).To(Equal(net.IP{10, 0, 0, 2})) + Expect(r.Next()).To(BeNil()) + }) + + }) + Context("when has free ip", func() { It("should allocate ips in round robin", func() { testCases := []AllocatorTestCase{ @@ -66,6 +135,12 @@ var _ = Describe("host-local ip allocator", func() { expectResult: "10.0.0.2", lastIP: "", }, + { + subnet: "2001:db8:1::0/64", + ipmap: map[string]string{}, + expectResult: "2001:db8:1::2", + lastIP: "", + }, { subnet: "10.0.0.0/30", ipmap: map[string]string{}, @@ -128,209 +203,108 @@ var _ = Describe("host-local ip allocator", func() { }, } - for _, tc := range testCases { - res, _, err := tc.run() + for idx, tc := range testCases { + res, err := tc.run(idx) Expect(err).ToNot(HaveOccurred()) Expect(res.Address.IP.String()).To(Equal(tc.expectResult)) } }) It("should not allocate the broadcast address", func() { - subnet, err := types.ParseCIDR("192.168.1.0/24") - Expect(err).ToNot(HaveOccurred()) - - conf := IPAMConfig{ - Name: "test", - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - } - store := fakestore.NewFakeStore(map[string]string{}, net.ParseIP("")) - alloc, err := NewIPAllocator(&conf, store) - Expect(err).ToNot(HaveOccurred()) - - for i := 1; i < 254; i++ { - res, _, err := alloc.Get("ID") + alloc := mkalloc() + for i := 2; i < 255; i++ { + res, err := alloc.Get("ID", nil) Expect(err).ToNot(HaveOccurred()) - // i+1 because the gateway address is skipped - s := fmt.Sprintf("192.168.1.%d/24", i+1) + s := fmt.Sprintf("192.168.1.%d/24", i) Expect(s).To(Equal(res.Address.String())) + fmt.Fprintln(GinkgoWriter, "got ip", res.Address.String()) } - _, _, err = alloc.Get("ID") + x, err := alloc.Get("ID", nil) + fmt.Fprintln(GinkgoWriter, "got ip", x) Expect(err).To(HaveOccurred()) }) + It("should allocate in a round-robin fashion", func() { + alloc := mkalloc() + res, err := alloc.Get("ID", nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.Address.String()).To(Equal("192.168.1.2/24")) + + err = alloc.Release("ID") + Expect(err).ToNot(HaveOccurred()) + + res, err = alloc.Get("ID", nil) + Expect(err).ToNot(HaveOccurred()) + Expect(res.Address.String()).To(Equal("192.168.1.3/24")) + + }) + It("should allocate RangeStart first", func() { - subnet, err := types.ParseCIDR("192.168.1.0/24") - Expect(err).ToNot(HaveOccurred()) - - conf := IPAMConfig{ - Name: "test", - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - RangeStart: net.ParseIP("192.168.1.10"), - } - store := fakestore.NewFakeStore(map[string]string{}, net.ParseIP("")) - alloc, err := NewIPAllocator(&conf, store) - Expect(err).ToNot(HaveOccurred()) - - res, _, err := alloc.Get("ID") + alloc := mkalloc() + alloc.ipRange.RangeStart = net.IP{192, 168, 1, 10} + res, err := alloc.Get("ID", nil) Expect(err).ToNot(HaveOccurred()) Expect(res.Address.String()).To(Equal("192.168.1.10/24")) - res, _, err = alloc.Get("ID") + res, err = alloc.Get("ID", nil) Expect(err).ToNot(HaveOccurred()) Expect(res.Address.String()).To(Equal("192.168.1.11/24")) }) It("should allocate RangeEnd but not past RangeEnd", func() { - subnet, err := types.ParseCIDR("192.168.1.0/24") - Expect(err).ToNot(HaveOccurred()) - - conf := IPAMConfig{ - Name: "test", - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - RangeEnd: net.ParseIP("192.168.1.5"), - } - store := fakestore.NewFakeStore(map[string]string{}, net.ParseIP("")) - alloc, err := NewIPAllocator(&conf, store) - Expect(err).ToNot(HaveOccurred()) + alloc := mkalloc() + alloc.ipRange.RangeEnd = net.IP{192, 168, 1, 5} for i := 1; i < 5; i++ { - res, _, err := alloc.Get("ID") + res, err := alloc.Get("ID", nil) Expect(err).ToNot(HaveOccurred()) // i+1 because the gateway address is skipped Expect(res.Address.String()).To(Equal(fmt.Sprintf("192.168.1.%d/24", i+1))) } - _, _, err = alloc.Get("ID") + _, err := alloc.Get("ID", nil) Expect(err).To(HaveOccurred()) }) Context("when requesting a specific IP", func() { It("must allocate the requested IP", func() { - subnet, err := types.ParseCIDR("10.0.0.0/29") - Expect(err).ToNot(HaveOccurred()) - requestedIP := net.ParseIP("10.0.0.2") - ipmap := map[string]string{} - conf := IPAMConfig{ - Name: "test", - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - Args: &IPAMArgs{IP: requestedIP}, - } - store := fakestore.NewFakeStore(ipmap, nil) - alloc, _ := NewIPAllocator(&conf, store) - res, _, err := alloc.Get("ID") + alloc := mkalloc() + requestedIP := net.IP{192, 168, 1, 5} + res, err := alloc.Get("ID", requestedIP) Expect(err).ToNot(HaveOccurred()) Expect(res.Address.IP.String()).To(Equal(requestedIP.String())) }) - It("must return an error when the requested IP is after RangeEnd", func() { - subnet, err := types.ParseCIDR("192.168.1.0/24") + It("must fail when the requested IP is allocated", func() { + alloc := mkalloc() + requestedIP := net.IP{192, 168, 1, 5} + res, err := alloc.Get("ID", requestedIP) Expect(err).ToNot(HaveOccurred()) - ipmap := map[string]string{} - conf := IPAMConfig{ - Name: "test", - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - Args: &IPAMArgs{IP: net.ParseIP("192.168.1.50")}, - RangeEnd: net.ParseIP("192.168.1.20"), - } - store := fakestore.NewFakeStore(ipmap, nil) - alloc, _ := NewIPAllocator(&conf, store) - _, _, err = alloc.Get("ID") + Expect(res.Address.IP.String()).To(Equal(requestedIP.String())) + + _, err = alloc.Get("ID", requestedIP) + Expect(err).To(MatchError(`requested IP address "192.168.1.5" is not available in network: netname 192.168.1.0/24`)) + }) + + It("must return an error when the requested IP is after RangeEnd", func() { + alloc := mkalloc() + alloc.ipRange.RangeEnd = net.IP{192, 168, 1, 5} + requestedIP := net.IP{192, 168, 1, 6} + _, err := alloc.Get("ID", requestedIP) Expect(err).To(HaveOccurred()) }) It("must return an error when the requested IP is before RangeStart", func() { - subnet, err := types.ParseCIDR("192.168.1.0/24") - Expect(err).ToNot(HaveOccurred()) - ipmap := map[string]string{} - conf := IPAMConfig{ - Name: "test", - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - Args: &IPAMArgs{IP: net.ParseIP("192.168.1.3")}, - RangeStart: net.ParseIP("192.168.1.10"), - } - store := fakestore.NewFakeStore(ipmap, nil) - alloc, _ := NewIPAllocator(&conf, store) - _, _, err = alloc.Get("ID") + alloc := mkalloc() + alloc.ipRange.RangeStart = net.IP{192, 168, 1, 6} + requestedIP := net.IP{192, 168, 1, 5} + _, err := alloc.Get("ID", requestedIP) Expect(err).To(HaveOccurred()) }) }) - It("RangeStart must be in the given subnet", func() { - testcases := []struct { - name string - ipnet string - start string - }{ - {"outside-subnet", "192.168.1.0/24", "10.0.0.1"}, - {"zero-ip", "10.1.0.0/16", "10.1.0.0"}, - } - - for _, tc := range testcases { - subnet, err := types.ParseCIDR(tc.ipnet) - Expect(err).ToNot(HaveOccurred()) - - conf := IPAMConfig{ - Name: tc.name, - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - RangeStart: net.ParseIP(tc.start), - } - store := fakestore.NewFakeStore(map[string]string{}, net.ParseIP("")) - _, err = NewIPAllocator(&conf, store) - Expect(err).To(HaveOccurred()) - } - }) - - It("RangeEnd must be in the given subnet", func() { - testcases := []struct { - name string - ipnet string - end string - }{ - {"outside-subnet", "192.168.1.0/24", "10.0.0.1"}, - {"broadcast-ip", "10.1.0.0/16", "10.1.255.255"}, - } - - for _, tc := range testcases { - subnet, err := types.ParseCIDR(tc.ipnet) - Expect(err).ToNot(HaveOccurred()) - - conf := IPAMConfig{ - Name: tc.name, - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - RangeEnd: net.ParseIP(tc.end), - } - store := fakestore.NewFakeStore(map[string]string{}, net.ParseIP("")) - _, err = NewIPAllocator(&conf, store) - Expect(err).To(HaveOccurred()) - } - }) - - It("RangeEnd must be after RangeStart in the given subnet", func() { - subnet, err := types.ParseCIDR("192.168.1.0/24") - Expect(err).ToNot(HaveOccurred()) - - conf := IPAMConfig{ - Name: "test", - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - RangeStart: net.ParseIP("192.168.1.10"), - RangeEnd: net.ParseIP("192.168.1.3"), - } - store := fakestore.NewFakeStore(map[string]string{}, net.ParseIP("")) - _, err = NewIPAllocator(&conf, store) - Expect(err).To(HaveOccurred()) - }) }) - Context("when out of ips", func() { It("returns a meaningful error", func() { testCases := []AllocatorTestCase{ @@ -353,26 +327,10 @@ var _ = Describe("host-local ip allocator", func() { }, }, } - for _, tc := range testCases { - _, _, err := tc.run() - Expect(err).To(MatchError("no IP addresses available in network: test")) + for idx, tc := range testCases { + _, err := tc.run(idx) + Expect(err).To(MatchError("no IP addresses available in network: netname " + tc.subnet)) } }) }) - - Context("when given an invalid subnet", func() { - It("returns a meaningful error", func() { - subnet, err := types.ParseCIDR("192.168.1.0/31") - Expect(err).ToNot(HaveOccurred()) - - conf := IPAMConfig{ - Name: "test", - Type: "host-local", - Subnet: types.IPNet{IP: subnet.IP, Mask: subnet.Mask}, - } - store := fakestore.NewFakeStore(map[string]string{}, net.ParseIP("")) - _, err = NewIPAllocator(&conf, store) - Expect(err).To(HaveOccurred()) - }) - }) }) diff --git a/plugins/ipam/host-local/backend/allocator/config.go b/plugins/ipam/host-local/backend/allocator/config.go index 8c004489..19e2f3ce 100644 --- a/plugins/ipam/host-local/backend/allocator/config.go +++ b/plugins/ipam/host-local/backend/allocator/config.go @@ -20,35 +20,51 @@ import ( "net" "github.com/containernetworking/cni/pkg/types" + types020 "github.com/containernetworking/cni/pkg/types/020" ) // IPAMConfig represents the IP related network configuration. +// This nests Range because we initially only supported a single +// range directly, and wish to preserve backwards compatability type IPAMConfig struct { + *Range Name string - Type string `json:"type"` - RangeStart net.IP `json:"rangeStart"` - RangeEnd net.IP `json:"rangeEnd"` - Subnet types.IPNet `json:"subnet"` - Gateway net.IP `json:"gateway"` - Routes []types.Route `json:"routes"` - DataDir string `json:"dataDir"` - ResolvConf string `json:"resolvConf"` - Args *IPAMArgs `json:"-"` + Type string `json:"type"` + Routes []*types.Route `json:"routes"` + DataDir string `json:"dataDir"` + ResolvConf string `json:"resolvConf"` + Ranges []Range `json:"ranges"` + IPArgs []net.IP `json:"-"` // Requested IPs from CNI_ARGS and args } -type IPAMArgs struct { +type IPAMEnvArgs struct { types.CommonArgs IP net.IP `json:"ip,omitempty"` } +type IPAMArgs struct { + IPs []net.IP `json:"ips"` +} + +// The top-level network config, just so we can get the IPAM block type Net struct { Name string `json:"name"` CNIVersion string `json:"cniVersion"` IPAM *IPAMConfig `json:"ipam"` + Args *struct { + A *IPAMArgs `json:"cni"` + } `json:"args"` +} + +type Range struct { + RangeStart net.IP `json:"rangeStart,omitempty"` // The first ip, inclusive + RangeEnd net.IP `json:"rangeEnd,omitempty"` // The last ip, inclusive + Subnet types.IPNet `json:"subnet"` + Gateway net.IP `json:"gateway,omitempty"` } // NewIPAMConfig creates a NetworkConfig from the given network name. -func LoadIPAMConfig(bytes []byte, args string) (*IPAMConfig, string, error) { +func LoadIPAMConfig(bytes []byte, envArgs string) (*IPAMConfig, string, error) { n := Net{} if err := json.Unmarshal(bytes, &n); err != nil { return nil, "", err @@ -58,12 +74,71 @@ func LoadIPAMConfig(bytes []byte, args string) (*IPAMConfig, string, error) { return nil, "", fmt.Errorf("IPAM config missing 'ipam' key") } - if args != "" { - n.IPAM.Args = &IPAMArgs{} - err := types.LoadArgs(args, n.IPAM.Args) + // Parse custom IP from both env args *and* the top-level args config + if envArgs != "" { + e := IPAMEnvArgs{} + err := types.LoadArgs(envArgs, &e) if err != nil { return nil, "", err } + + if e.IP != nil { + n.IPAM.IPArgs = []net.IP{e.IP} + } + } + + if n.Args != nil && n.Args.A != nil && len(n.Args.A.IPs) != 0 { + n.IPAM.IPArgs = append(n.IPAM.IPArgs, n.Args.A.IPs...) + } + + for idx, _ := range n.IPAM.IPArgs { + if err := canonicalizeIP(&n.IPAM.IPArgs[idx]); err != nil { + return nil, "", fmt.Errorf("cannot understand ip: %v", err) + } + } + + // If a single range (old-style config) is specified, move it to + // the Ranges array + if n.IPAM.Range != nil && n.IPAM.Range.Subnet.IP != nil { + n.IPAM.Ranges = append([]Range{*n.IPAM.Range}, n.IPAM.Ranges...) + } + n.IPAM.Range = nil + + if len(n.IPAM.Ranges) == 0 { + return nil, "", fmt.Errorf("no IP ranges specified") + } + + // Validate all ranges + numV4 := 0 + numV6 := 0 + for i, _ := range n.IPAM.Ranges { + if err := n.IPAM.Ranges[i].Canonicalize(); err != nil { + return nil, "", fmt.Errorf("Cannot understand range %d: %v", i, err) + } + if len(n.IPAM.Ranges[i].RangeStart) == 4 { + numV4++ + } else { + numV6++ + } + } + + // CNI spec 0.2.0 and below supported only one v4 and v6 address + if numV4 > 1 || numV6 > 1 { + for _, v := range types020.SupportedVersions { + if n.CNIVersion == v { + return nil, "", fmt.Errorf("CNI version %v does not support more than 1 range per address family", n.CNIVersion) + } + } + } + + // Check for overlaps + l := len(n.IPAM.Ranges) + for i, r1 := range n.IPAM.Ranges[:l-1] { + for j, r2 := range n.IPAM.Ranges[i+1:] { + if r1.Overlaps(&r2) { + return nil, "", fmt.Errorf("Range %d overlaps with range %d", i, (i + j + 1)) + } + } } // Copy net name into IPAM so not to drag Net struct around @@ -71,14 +146,3 @@ func LoadIPAMConfig(bytes []byte, args string) (*IPAMConfig, string, error) { return n.IPAM, n.CNIVersion, nil } - -func convertRoutesToCurrent(routes []types.Route) []*types.Route { - var currentRoutes []*types.Route - for _, r := range routes { - currentRoutes = append(currentRoutes, &types.Route{ - Dst: r.Dst, - GW: r.GW, - }) - } - return currentRoutes -} diff --git a/plugins/ipam/host-local/backend/allocator/config_test.go b/plugins/ipam/host-local/backend/allocator/config_test.go new file mode 100644 index 00000000..b162ab43 --- /dev/null +++ b/plugins/ipam/host-local/backend/allocator/config_test.go @@ -0,0 +1,309 @@ +// Copyright 2016 CNI authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package allocator + +import ( + "net" + + "github.com/containernetworking/cni/pkg/types" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("IPAM config", func() { + It("Should parse an old-style config", func() { + input := `{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "subnet": "10.1.2.0/24", + "rangeStart": "10.1.2.9", + "rangeEnd": "10.1.2.20", + "gateway": "10.1.2.30" + } +}` + conf, version, err := LoadIPAMConfig([]byte(input), "") + Expect(err).NotTo(HaveOccurred()) + Expect(version).Should(Equal("0.3.1")) + + Expect(conf).To(Equal(&IPAMConfig{ + Name: "mynet", + Type: "host-local", + Ranges: []Range{ + { + RangeStart: net.IP{10, 1, 2, 9}, + RangeEnd: net.IP{10, 1, 2, 20}, + Gateway: net.IP{10, 1, 2, 30}, + Subnet: types.IPNet{ + IP: net.IP{10, 1, 2, 0}, + Mask: net.CIDRMask(24, 32), + }, + }, + }, + })) + }) + It("Should parse a new-style config", func() { + input := `{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "ranges": [ + { + "subnet": "10.1.2.0/24", + "rangeStart": "10.1.2.9", + "rangeEnd": "10.1.2.20", + "gateway": "10.1.2.30" + }, + { + "subnet": "11.1.2.0/24", + "rangeStart": "11.1.2.9", + "rangeEnd": "11.1.2.20", + "gateway": "11.1.2.30" + } + ] + } +}` + conf, version, err := LoadIPAMConfig([]byte(input), "") + Expect(err).NotTo(HaveOccurred()) + Expect(version).Should(Equal("0.3.1")) + + Expect(conf).To(Equal(&IPAMConfig{ + Name: "mynet", + Type: "host-local", + Ranges: []Range{ + { + RangeStart: net.IP{10, 1, 2, 9}, + RangeEnd: net.IP{10, 1, 2, 20}, + Gateway: net.IP{10, 1, 2, 30}, + Subnet: types.IPNet{ + IP: net.IP{10, 1, 2, 0}, + Mask: net.CIDRMask(24, 32), + }, + }, + { + RangeStart: net.IP{11, 1, 2, 9}, + RangeEnd: net.IP{11, 1, 2, 20}, + Gateway: net.IP{11, 1, 2, 30}, + Subnet: types.IPNet{ + IP: net.IP{11, 1, 2, 0}, + Mask: net.CIDRMask(24, 32), + }, + }, + }, + })) + }) + + It("Should parse a mixed config", func() { + input := `{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "subnet": "10.1.2.0/24", + "rangeStart": "10.1.2.9", + "rangeEnd": "10.1.2.20", + "gateway": "10.1.2.30", + "ranges": [ + { + "subnet": "11.1.2.0/24", + "rangeStart": "11.1.2.9", + "rangeEnd": "11.1.2.20", + "gateway": "11.1.2.30" + } + ] + } +}` + conf, version, err := LoadIPAMConfig([]byte(input), "") + Expect(err).NotTo(HaveOccurred()) + Expect(version).Should(Equal("0.3.1")) + + Expect(conf).To(Equal(&IPAMConfig{ + Name: "mynet", + Type: "host-local", + Ranges: []Range{ + { + RangeStart: net.IP{10, 1, 2, 9}, + RangeEnd: net.IP{10, 1, 2, 20}, + Gateway: net.IP{10, 1, 2, 30}, + Subnet: types.IPNet{ + IP: net.IP{10, 1, 2, 0}, + Mask: net.CIDRMask(24, 32), + }, + }, + { + RangeStart: net.IP{11, 1, 2, 9}, + RangeEnd: net.IP{11, 1, 2, 20}, + Gateway: net.IP{11, 1, 2, 30}, + Subnet: types.IPNet{ + IP: net.IP{11, 1, 2, 0}, + Mask: net.CIDRMask(24, 32), + }, + }, + }, + })) + }) + + It("Should parse CNI_ARGS env", func() { + input := `{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "ranges": [ + { + "subnet": "10.1.2.0/24", + "rangeStart": "10.1.2.9", + "rangeEnd": "10.1.2.20", + "gateway": "10.1.2.30" + }, + { + "subnet": "11.1.2.0/24", + "rangeStart": "11.1.2.9", + "rangeEnd": "11.1.2.20", + "gateway": "11.1.2.30" + } + ] + } +}` + + envArgs := "IP=10.1.2.10" + + conf, _, err := LoadIPAMConfig([]byte(input), envArgs) + Expect(err).NotTo(HaveOccurred()) + Expect(conf.IPArgs).To(Equal([]net.IP{{10, 1, 2, 10}})) + + }) + It("Should parse config args", func() { + input := `{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "args": { + "cni": { + "ips": [ "10.1.2.11", "11.11.11.11", "2001:db8:1::11"] + } + }, + "ipam": { + "type": "host-local", + "ranges": [ + { + "subnet": "10.1.2.0/24", + "rangeStart": "10.1.2.9", + "rangeEnd": "10.1.2.20", + "gateway": "10.1.2.30" + }, + { + "subnet": "11.1.2.0/24", + "rangeStart": "11.1.2.9", + "rangeEnd": "11.1.2.20", + "gateway": "11.1.2.30" + }, + { + "subnet": "2001:db8:1::/64" + } + ] + } +}` + + envArgs := "IP=10.1.2.10" + + conf, _, err := LoadIPAMConfig([]byte(input), envArgs) + Expect(err).NotTo(HaveOccurred()) + Expect(conf.IPArgs).To(Equal([]net.IP{ + {10, 1, 2, 10}, + {10, 1, 2, 11}, + {11, 11, 11, 11}, + net.ParseIP("2001:db8:1::11"), + })) + }) + It("Should detect overlap", func() { + input := `{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "ranges": [ + { + "subnet": "10.1.2.0/24", + "rangeEnd": "10.1.2.128" + }, + { + "subnet": "10.1.2.0/24", + "rangeStart": "10.1.2.15" + } + ] + } +}` + _, _, err := LoadIPAMConfig([]byte(input), "") + Expect(err).To(MatchError("Range 0 overlaps with range 1")) + }) + + It("Should should error on too many ranges", func() { + input := `{ + "cniVersion": "0.2.0", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "ranges": [ + { + "subnet": "10.1.2.0/24" + }, + { + "subnet": "11.1.2.0/24" + } + ] + } +}` + _, _, err := LoadIPAMConfig([]byte(input), "") + Expect(err).To(MatchError("CNI version 0.2.0 does not support more than 1 range per address family")) + }) + + It("Should allow one v4 and v6 range for 0.2.0", func() { + input := `{ + "cniVersion": "0.2.0", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "ranges": [ + { + "subnet": "10.1.2.0/24" + }, + { + "subnet": "2001:db8:1::/24" + } + ] + } +}` + _, _, err := LoadIPAMConfig([]byte(input), "") + Expect(err).NotTo(HaveOccurred()) + }) +}) diff --git a/plugins/ipam/host-local/backend/allocator/range.go b/plugins/ipam/host-local/backend/allocator/range.go new file mode 100644 index 00000000..5c7a1dda --- /dev/null +++ b/plugins/ipam/host-local/backend/allocator/range.go @@ -0,0 +1,159 @@ +// Copyright 2017 CNI authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package allocator + +import ( + "fmt" + "net" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/plugins/pkg/ip" +) + +// Canonicalize takes a given range and ensures that all information is consistent, +// filling out Start, End, and Gateway with sane values if missing +func (r *Range) Canonicalize() error { + if err := canonicalizeIP(&r.Subnet.IP); err != nil { + return err + } + + // Can't create an allocator for a network with no addresses, eg + // a /32 or /31 + ones, masklen := r.Subnet.Mask.Size() + if ones > masklen-2 { + return fmt.Errorf("Network %s too small to allocate from", (*net.IPNet)(&r.Subnet).String()) + } + + if len(r.Subnet.IP) != len(r.Subnet.Mask) { + return fmt.Errorf("IPNet IP and Mask version mismatch") + } + + // If the gateway is nil, claim .1 + if r.Gateway == nil { + r.Gateway = ip.NextIP(r.Subnet.IP) + } else { + if err := canonicalizeIP(&r.Gateway); err != nil { + return err + } + subnet := (net.IPNet)(r.Subnet) + if !subnet.Contains(r.Gateway) { + return fmt.Errorf("gateway %s not in network %s", r.Gateway.String(), subnet.String()) + } + } + + // RangeStart: If specified, make sure it's sane (inside the subnet), + // otherwise use the first free IP (i.e. .1) - this will conflict with the + // gateway but we skip it in the iterator + if r.RangeStart != nil { + if err := canonicalizeIP(&r.RangeStart); err != nil { + return err + } + + if err := r.IPInRange(r.RangeStart); err != nil { + return err + } + } else { + r.RangeStart = ip.NextIP(r.Subnet.IP) + } + + // RangeEnd: If specified, verify sanity. Otherwise, add a sensible default + // (e.g. for a /24: .254 if IPv4, ::255 if IPv6) + if r.RangeEnd != nil { + if err := canonicalizeIP(&r.RangeEnd); err != nil { + return err + } + + if err := r.IPInRange(r.RangeEnd); err != nil { + return err + } + } else { + r.RangeEnd = lastIP(r.Subnet) + } + + return nil +} + +// IsValidIP checks if a given ip is a valid, allocatable address in a given Range +func (r *Range) IPInRange(addr net.IP) error { + if err := canonicalizeIP(&addr); err != nil { + return err + } + + subnet := (net.IPNet)(r.Subnet) + + if len(addr) != len(r.Subnet.IP) { + return fmt.Errorf("IP %s is not the same protocol as subnet %s", + addr, subnet.String()) + } + + if !subnet.Contains(addr) { + return fmt.Errorf("%s not in network %s", addr, subnet.String()) + } + + // We ignore nils here so we can use this function as we initialize the range. + if r.RangeStart != nil { + if ip.Cmp(addr, r.RangeStart) < 0 { + return fmt.Errorf("%s is in network %s but before start %s", + addr, (*net.IPNet)(&r.Subnet).String(), r.RangeStart) + } + } + + if r.RangeEnd != nil { + if ip.Cmp(addr, r.RangeEnd) > 0 { + return fmt.Errorf("%s is in network %s but after end %s", + addr, (*net.IPNet)(&r.Subnet).String(), r.RangeEnd) + } + } + + return nil +} + +// Overlaps returns true if there is any overlap between ranges +func (r *Range) Overlaps(r1 *Range) bool { + // different familes + if len(r.RangeStart) != len(r1.RangeStart) { + return false + } + + return r.IPInRange(r1.RangeStart) == nil || + r.IPInRange(r1.RangeEnd) == nil || + r1.IPInRange(r.RangeStart) == nil || + r1.IPInRange(r.RangeEnd) == nil +} + +// canonicalizeIP makes sure a provided ip is in standard form +func canonicalizeIP(ip *net.IP) error { + if ip.To4() != nil { + *ip = ip.To4() + return nil + } else if ip.To16() != nil { + *ip = ip.To16() + return nil + } + return fmt.Errorf("IP %s not v4 nor v6", *ip) +} + +// Determine the last IP of a subnet, excluding the broadcast if IPv4 +func lastIP(subnet types.IPNet) net.IP { + var end net.IP + for i := 0; i < len(subnet.IP); i++ { + end = append(end, subnet.IP[i]|^subnet.Mask[i]) + } + if subnet.IP.To4() != nil { + end[3]-- + } + + return end +} diff --git a/plugins/ipam/host-local/backend/allocator/range_test.go b/plugins/ipam/host-local/backend/allocator/range_test.go new file mode 100644 index 00000000..4b61ca5d --- /dev/null +++ b/plugins/ipam/host-local/backend/allocator/range_test.go @@ -0,0 +1,215 @@ +// Copyright 2017 CNI authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package allocator + +import ( + "net" + + "github.com/containernetworking/cni/pkg/types" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +var _ = Describe("IP ranges", func() { + It("should generate sane defaults for ipv4", func() { + snstr := "192.0.2.0/24" + r := Range{Subnet: mustSubnet(snstr)} + + err := r.Canonicalize() + Expect(err).NotTo(HaveOccurred()) + + Expect(r).To(Equal(Range{ + Subnet: mustSubnet(snstr), + RangeStart: net.IP{192, 0, 2, 1}, + RangeEnd: net.IP{192, 0, 2, 254}, + Gateway: net.IP{192, 0, 2, 1}, + })) + }) + It("should generate sane defaults for a smaller ipv4 subnet", func() { + snstr := "192.0.2.0/25" + r := Range{Subnet: mustSubnet(snstr)} + + err := r.Canonicalize() + Expect(err).NotTo(HaveOccurred()) + + Expect(r).To(Equal(Range{ + Subnet: mustSubnet(snstr), + RangeStart: net.IP{192, 0, 2, 1}, + RangeEnd: net.IP{192, 0, 2, 126}, + Gateway: net.IP{192, 0, 2, 1}, + })) + }) + It("should generate sane defaults for ipv6", func() { + snstr := "2001:DB8:1::/64" + r := Range{Subnet: mustSubnet(snstr)} + + err := r.Canonicalize() + Expect(err).NotTo(HaveOccurred()) + + Expect(r).To(Equal(Range{ + Subnet: mustSubnet(snstr), + RangeStart: net.ParseIP("2001:DB8:1::1"), + RangeEnd: net.ParseIP("2001:DB8:1::ffff:ffff:ffff:ffff"), + Gateway: net.ParseIP("2001:DB8:1::1"), + })) + }) + + It("Should reject a network that's too small", func() { + r := Range{Subnet: mustSubnet("192.0.2.0/31")} + err := r.Canonicalize() + Expect(err).Should(MatchError("Network 192.0.2.0/31 too small to allocate from")) + }) + + It("should reject invalid RangeStart and RangeEnd specifications", func() { + r := Range{Subnet: mustSubnet("192.0.2.0/24"), RangeStart: net.ParseIP("192.0.3.0")} + err := r.Canonicalize() + Expect(err).Should(MatchError("192.0.3.0 not in network 192.0.2.0/24")) + + r = Range{Subnet: mustSubnet("192.0.2.0/24"), RangeEnd: net.ParseIP("192.0.4.0")} + err = r.Canonicalize() + Expect(err).Should(MatchError("192.0.4.0 not in network 192.0.2.0/24")) + + r = Range{ + Subnet: mustSubnet("192.0.2.0/24"), + RangeStart: net.ParseIP("192.0.2.50"), + RangeEnd: net.ParseIP("192.0.2.40"), + } + err = r.Canonicalize() + Expect(err).Should(MatchError("192.0.2.50 is in network 192.0.2.0/24 but after end 192.0.2.40")) + }) + + It("should reject invalid gateways", func() { + r := Range{Subnet: mustSubnet("192.0.2.0/24"), Gateway: net.ParseIP("192.0.3.0")} + err := r.Canonicalize() + Expect(err).Should(MatchError("gateway 192.0.3.0 not in network 192.0.2.0/24")) + }) + + It("should parse all fields correctly", func() { + r := Range{ + Subnet: mustSubnet("192.0.2.0/24"), + RangeStart: net.ParseIP("192.0.2.40"), + RangeEnd: net.ParseIP("192.0.2.50"), + Gateway: net.ParseIP("192.0.2.254"), + } + err := r.Canonicalize() + Expect(err).NotTo(HaveOccurred()) + + Expect(r).To(Equal(Range{ + Subnet: mustSubnet("192.0.2.0/24"), + RangeStart: net.IP{192, 0, 2, 40}, + RangeEnd: net.IP{192, 0, 2, 50}, + Gateway: net.IP{192, 0, 2, 254}, + })) + }) + + It("should accept v4 IPs in range and reject IPs out of range", func() { + r := Range{ + Subnet: mustSubnet("192.0.2.0/24"), + RangeStart: net.ParseIP("192.0.2.40"), + RangeEnd: net.ParseIP("192.0.2.50"), + Gateway: net.ParseIP("192.0.2.254"), + } + err := r.Canonicalize() + Expect(err).NotTo(HaveOccurred()) + + Expect(r.IPInRange(net.ParseIP("192.0.3.0"))).Should(MatchError( + "192.0.3.0 not in network 192.0.2.0/24")) + + Expect(r.IPInRange(net.ParseIP("192.0.2.39"))).Should(MatchError( + "192.0.2.39 is in network 192.0.2.0/24 but before start 192.0.2.40")) + Expect(r.IPInRange(net.ParseIP("192.0.2.40"))).Should(BeNil()) + Expect(r.IPInRange(net.ParseIP("192.0.2.50"))).Should(BeNil()) + Expect(r.IPInRange(net.ParseIP("192.0.2.51"))).Should(MatchError( + "192.0.2.51 is in network 192.0.2.0/24 but after end 192.0.2.50")) + }) + + It("should accept v6 IPs in range and reject IPs out of range", func() { + r := Range{ + Subnet: mustSubnet("2001:DB8:1::/64"), + RangeStart: net.ParseIP("2001:db8:1::40"), + RangeEnd: net.ParseIP("2001:db8:1::50"), + } + err := r.Canonicalize() + Expect(err).NotTo(HaveOccurred()) + Expect(r.IPInRange(net.ParseIP("2001:db8:2::"))).Should(MatchError( + "2001:db8:2:: not in network 2001:db8:1::/64")) + + Expect(r.IPInRange(net.ParseIP("2001:db8:1::39"))).Should(MatchError( + "2001:db8:1::39 is in network 2001:db8:1::/64 but before start 2001:db8:1::40")) + Expect(r.IPInRange(net.ParseIP("2001:db8:1::40"))).Should(BeNil()) + Expect(r.IPInRange(net.ParseIP("2001:db8:1::50"))).Should(BeNil()) + Expect(r.IPInRange(net.ParseIP("2001:db8:1::51"))).Should(MatchError( + "2001:db8:1::51 is in network 2001:db8:1::/64 but after end 2001:db8:1::50")) + }) + + DescribeTable("Detecting overlap", + func(r1 Range, r2 Range, expected bool) { + r1.Canonicalize() + r2.Canonicalize() + + // operation should be commutative + Expect(r1.Overlaps(&r2)).To(Equal(expected)) + Expect(r2.Overlaps(&r1)).To(Equal(expected)) + }, + Entry("non-overlapping", + Range{Subnet: mustSubnet("10.0.0.0/24")}, + Range{Subnet: mustSubnet("10.0.1.0/24")}, + false), + Entry("different families", + // Note that the bits overlap + Range{Subnet: mustSubnet("0.0.0.0/24")}, + Range{Subnet: mustSubnet("::/24")}, + false), + Entry("Identical", + Range{Subnet: mustSubnet("10.0.0.0/24")}, + Range{Subnet: mustSubnet("10.0.0.0/24")}, + true), + Entry("Containing", + Range{Subnet: mustSubnet("10.0.0.0/20")}, + Range{Subnet: mustSubnet("10.0.1.0/24")}, + true), + Entry("same subnet, non overlapping start + end", + Range{ + Subnet: mustSubnet("10.0.0.0/24"), + RangeEnd: net.ParseIP("10.0.0.127"), + }, + Range{ + Subnet: mustSubnet("10.0.0.0/24"), + RangeStart: net.ParseIP("10.0.0.128"), + }, + false), + Entry("same subnet, overlapping start + end", + Range{ + Subnet: mustSubnet("10.0.0.0/24"), + RangeEnd: net.ParseIP("10.0.0.127"), + }, + Range{ + Subnet: mustSubnet("10.0.0.0/24"), + RangeStart: net.ParseIP("10.0.0.127"), + }, + true), + ) +}) + +func mustSubnet(s string) types.IPNet { + n, err := types.ParseCIDR(s) + if err != nil { + Fail(err.Error()) + } + canonicalizeIP(&n.IP) + return types.IPNet(*n) +} diff --git a/plugins/ipam/host-local/backend/disk/backend.go b/plugins/ipam/host-local/backend/disk/backend.go index 483103ac..82c3a8a9 100644 --- a/plugins/ipam/host-local/backend/disk/backend.go +++ b/plugins/ipam/host-local/backend/disk/backend.go @@ -24,10 +24,12 @@ import ( "github.com/containernetworking/plugins/plugins/ipam/host-local/backend" ) -const lastIPFile = "last_reserved_ip" +const lastIPFilePrefix = "last_reserved_ip." var defaultDataDir = "/var/lib/cni/networks" +// Store is a simple disk-backed store that creates one file per IP +// address in a given directory. The contents of the file are the container ID. type Store struct { FileLock dataDir string @@ -41,7 +43,7 @@ func New(network, dataDir string) (*Store, error) { dataDir = defaultDataDir } dir := filepath.Join(dataDir, network) - if err := os.MkdirAll(dir, 0644); err != nil { + if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } @@ -52,7 +54,7 @@ func New(network, dataDir string) (*Store, error) { return &Store{*lk, dir}, nil } -func (s *Store) Reserve(id string, ip net.IP) (bool, error) { +func (s *Store) Reserve(id string, ip net.IP, rangeID string) (bool, error) { fname := filepath.Join(s.dataDir, ip.String()) f, err := os.OpenFile(fname, os.O_RDWR|os.O_EXCL|os.O_CREATE, 0644) if os.IsExist(err) { @@ -71,7 +73,7 @@ func (s *Store) Reserve(id string, ip net.IP) (bool, error) { return false, err } // store the reserved ip in lastIPFile - ipfile := filepath.Join(s.dataDir, lastIPFile) + ipfile := filepath.Join(s.dataDir, lastIPFilePrefix+rangeID) err = ioutil.WriteFile(ipfile, []byte(ip.String()), 0644) if err != nil { return false, err @@ -80,8 +82,8 @@ func (s *Store) Reserve(id string, ip net.IP) (bool, error) { } // LastReservedIP returns the last reserved IP if exists -func (s *Store) LastReservedIP() (net.IP, error) { - ipfile := filepath.Join(s.dataDir, lastIPFile) +func (s *Store) LastReservedIP(rangeID string) (net.IP, error) { + ipfile := filepath.Join(s.dataDir, lastIPFilePrefix+rangeID) data, err := ioutil.ReadFile(ipfile) if err != nil { return nil, err diff --git a/plugins/ipam/host-local/backend/store.go b/plugins/ipam/host-local/backend/store.go index 82ba8693..3d695847 100644 --- a/plugins/ipam/host-local/backend/store.go +++ b/plugins/ipam/host-local/backend/store.go @@ -20,8 +20,8 @@ type Store interface { Lock() error Unlock() error Close() error - Reserve(id string, ip net.IP) (bool, error) - LastReservedIP() (net.IP, error) + Reserve(id string, ip net.IP, rangeID string) (bool, error) + LastReservedIP(rangeID string) (net.IP, error) Release(ip net.IP) error ReleaseByID(id string) error } diff --git a/plugins/ipam/host-local/backend/testing/fake_store.go b/plugins/ipam/host-local/backend/testing/fake_store.go index c35a130c..49a0f554 100644 --- a/plugins/ipam/host-local/backend/testing/fake_store.go +++ b/plugins/ipam/host-local/backend/testing/fake_store.go @@ -16,20 +16,21 @@ package testing import ( "net" + "os" "github.com/containernetworking/plugins/plugins/ipam/host-local/backend" ) type FakeStore struct { ipMap map[string]string - lastReservedIP net.IP + lastReservedIP map[string]net.IP } // FakeStore implements the Store interface var _ backend.Store = &FakeStore{} -func NewFakeStore(ipmap map[string]string, lastIP net.IP) *FakeStore { - return &FakeStore{ipmap, lastIP} +func NewFakeStore(ipmap map[string]string, lastIPs map[string]net.IP) *FakeStore { + return &FakeStore{ipmap, lastIPs} } func (s *FakeStore) Lock() error { @@ -44,18 +45,22 @@ func (s *FakeStore) Close() error { return nil } -func (s *FakeStore) Reserve(id string, ip net.IP) (bool, error) { +func (s *FakeStore) Reserve(id string, ip net.IP, rangeID string) (bool, error) { key := ip.String() if _, ok := s.ipMap[key]; !ok { s.ipMap[key] = id - s.lastReservedIP = ip + s.lastReservedIP[rangeID] = ip return true, nil } return false, nil } -func (s *FakeStore) LastReservedIP() (net.IP, error) { - return s.lastReservedIP, nil +func (s *FakeStore) LastReservedIP(rangeID string) (net.IP, error) { + ip, ok := s.lastReservedIP[rangeID] + if !ok { + return nil, os.ErrNotExist + } + return ip, nil } func (s *FakeStore) Release(ip net.IP) error { @@ -75,3 +80,7 @@ func (s *FakeStore) ReleaseByID(id string) error { } return nil } + +func (s *FakeStore) SetIPMap(m map[string]string) { + s.ipMap = m +} diff --git a/plugins/ipam/host-local/host_local_test.go b/plugins/ipam/host-local/host_local_test.go index 5e2c69c4..68942ea1 100644 --- a/plugins/ipam/host-local/host_local_test.go +++ b/plugins/ipam/host-local/host_local_test.go @@ -33,7 +33,7 @@ import ( ) var _ = Describe("host-local Operations", func() { - It("allocates and releases an address with ADD/DEL", func() { + It("allocates and releases addresses with ADD/DEL", func() { const ifname string = "eth0" const nspath string = "/some/where" @@ -51,9 +51,18 @@ var _ = Describe("host-local Operations", func() { "master": "foo0", "ipam": { "type": "host-local", - "subnet": "10.1.2.0/24", "dataDir": "%s", - "resolvConf": "%s/resolv.conf" + "resolvConf": "%s/resolv.conf", + "ranges": [ + { "subnet": "10.1.2.0/24" }, + { "subnet": "2001:db8:1::0/64" } + ], + "routes": [ + {"dst": "0.0.0.0/0"}, + {"dst": "::/0"}, + {"dst": "192.168.0.0/16", "gw": "1.1.1.1"}, + {"dst": "2001:db8:2::0/64", "gw": "2001:db8:3::1"} + ] } }`, tmpDir, tmpDir) @@ -74,30 +83,60 @@ var _ = Describe("host-local Operations", func() { result, err := current.GetResult(r) Expect(err).NotTo(HaveOccurred()) - expectedAddress, err := types.ParseCIDR("10.1.2.2/24") - Expect(err).NotTo(HaveOccurred()) - Expect(len(result.IPs)).To(Equal(1)) - expectedAddress.IP = expectedAddress.IP.To16() - Expect(result.IPs[0].Address).To(Equal(*expectedAddress)) - Expect(result.IPs[0].Gateway).To(Equal(net.ParseIP("10.1.2.1"))) + // Gomega is cranky about slices with different caps + Expect(*result.IPs[0]).To(Equal( + current.IPConfig{ + Version: "4", + Interface: 0, + Address: mustCIDR("10.1.2.2/24"), + Gateway: net.ParseIP("10.1.2.1"), + })) - ipFilePath := filepath.Join(tmpDir, "mynet", "10.1.2.2") - contents, err := ioutil.ReadFile(ipFilePath) + Expect(*result.IPs[1]).To(Equal( + current.IPConfig{ + Version: "6", + Interface: 0, + Address: mustCIDR("2001:db8:1::2/64"), + Gateway: net.ParseIP("2001:db8:1::1"), + }, + )) + Expect(len(result.IPs)).To(Equal(2)) + + Expect(result.Routes).To(Equal([]*types.Route{ + &types.Route{Dst: mustCIDR("0.0.0.0/0"), GW: nil}, + &types.Route{Dst: mustCIDR("::/0"), GW: nil}, + &types.Route{Dst: mustCIDR("192.168.0.0/16"), GW: net.ParseIP("1.1.1.1")}, + &types.Route{Dst: mustCIDR("2001:db8:2::0/64"), GW: net.ParseIP("2001:db8:3::1")}, + })) + + ipFilePath1 := filepath.Join(tmpDir, "mynet", "10.1.2.2") + contents, err := ioutil.ReadFile(ipFilePath1) Expect(err).NotTo(HaveOccurred()) Expect(string(contents)).To(Equal("dummy")) - lastFilePath := filepath.Join(tmpDir, "mynet", "last_reserved_ip") - contents, err = ioutil.ReadFile(lastFilePath) + ipFilePath2 := filepath.Join(tmpDir, "mynet", "2001:db8:1::2") + contents, err = ioutil.ReadFile(ipFilePath2) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).To(Equal("dummy")) + + lastFilePath1 := filepath.Join(tmpDir, "mynet", "last_reserved_ip.CgECAQ==") + contents, err = ioutil.ReadFile(lastFilePath1) Expect(err).NotTo(HaveOccurred()) Expect(string(contents)).To(Equal("10.1.2.2")) + lastFilePath2 := filepath.Join(tmpDir, "mynet", "last_reserved_ip.IAENuAABAAAAAAAAAAAAAQ==") + contents, err = ioutil.ReadFile(lastFilePath2) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).To(Equal("2001:db8:1::2")) // Release the IP err = testutils.CmdDelWithResult(nspath, ifname, func() error { return cmdDel(args) }) Expect(err).NotTo(HaveOccurred()) - _, err = os.Stat(ipFilePath) + _, err = os.Stat(ipFilePath1) + Expect(err).To(HaveOccurred()) + _, err = os.Stat(ipFilePath2) Expect(err).To(HaveOccurred()) }) @@ -187,7 +226,7 @@ var _ = Describe("host-local Operations", func() { Expect(err).NotTo(HaveOccurred()) Expect(string(contents)).To(Equal("dummy")) - lastFilePath := filepath.Join(tmpDir, "mynet", "last_reserved_ip") + lastFilePath := filepath.Join(tmpDir, "mynet", "last_reserved_ip.CgECAQ==") contents, err = ioutil.ReadFile(lastFilePath) Expect(err).NotTo(HaveOccurred()) Expect(string(contents)).To(Equal("10.1.2.2")) @@ -289,4 +328,203 @@ var _ = Describe("host-local Operations", func() { Expect(err).NotTo(HaveOccurred()) Expect(strings.Index(string(out), "Error retriving last reserved ip")).To(Equal(-1)) }) + + It("allocates a custom IP when requested by config args", func() { + const ifname string = "eth0" + const nspath string = "/some/where" + + tmpDir, err := ioutil.TempDir("", "host_local_artifacts") + Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(tmpDir) + + conf := fmt.Sprintf(`{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "dataDir": "%s", + "ranges": [ + { "subnet": "10.1.2.0/24" } + ] + }, + "args": { + "cni": { + "ips": ["10.1.2.88"] + } + } +}`, tmpDir) + + args := &skel.CmdArgs{ + ContainerID: "dummy", + Netns: nspath, + IfName: ifname, + StdinData: []byte(conf), + } + + // Allocate the IP + r, _, err := testutils.CmdAddWithResult(nspath, ifname, []byte(conf), func() error { + return cmdAdd(args) + }) + Expect(err).NotTo(HaveOccurred()) + result, err := current.GetResult(r) + Expect(err).NotTo(HaveOccurred()) + Expect(result.IPs).To(HaveLen(1)) + Expect(result.IPs[0].Address.IP).To(Equal(net.ParseIP("10.1.2.88"))) + }) + + It("allocates custom IPs from multiple ranges", func() { + const ifname string = "eth0" + const nspath string = "/some/where" + + tmpDir, err := ioutil.TempDir("", "host_local_artifacts") + Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(tmpDir) + + err = ioutil.WriteFile(filepath.Join(tmpDir, "resolv.conf"), []byte("nameserver 192.0.2.3"), 0644) + Expect(err).NotTo(HaveOccurred()) + + conf := fmt.Sprintf(`{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "dataDir": "%s", + "ranges": [ + { "subnet": "10.1.2.0/24" }, + { "subnet": "10.1.3.0/24" } + ] + }, + "args": { + "cni": { + "ips": ["10.1.2.88", "10.1.3.77"] + } + } +}`, tmpDir) + + args := &skel.CmdArgs{ + ContainerID: "dummy", + Netns: nspath, + IfName: ifname, + StdinData: []byte(conf), + } + + // Allocate the IP + r, _, err := testutils.CmdAddWithResult(nspath, ifname, []byte(conf), func() error { + return cmdAdd(args) + }) + Expect(err).NotTo(HaveOccurred()) + result, err := current.GetResult(r) + Expect(err).NotTo(HaveOccurred()) + Expect(result.IPs).To(HaveLen(2)) + Expect(result.IPs[0].Address.IP).To(Equal(net.ParseIP("10.1.2.88"))) + Expect(result.IPs[1].Address.IP).To(Equal(net.ParseIP("10.1.3.77"))) + }) + + It("allocates custom IPs from multiple protocols", func() { + const ifname string = "eth0" + const nspath string = "/some/where" + + tmpDir, err := ioutil.TempDir("", "host_local_artifacts") + Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(tmpDir) + + err = ioutil.WriteFile(filepath.Join(tmpDir, "resolv.conf"), []byte("nameserver 192.0.2.3"), 0644) + Expect(err).NotTo(HaveOccurred()) + + conf := fmt.Sprintf(`{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "dataDir": "%s", + "ranges": [ + { "subnet": "10.1.2.0/24" }, + { "subnet": "2001:db8:1::/24" } + ] + }, + "args": { + "cni": { + "ips": ["10.1.2.88", "2001:db8:1::999"] + } + } +}`, tmpDir) + + args := &skel.CmdArgs{ + ContainerID: "dummy", + Netns: nspath, + IfName: ifname, + StdinData: []byte(conf), + } + + // Allocate the IP + r, _, err := testutils.CmdAddWithResult(nspath, ifname, []byte(conf), func() error { + return cmdAdd(args) + }) + Expect(err).NotTo(HaveOccurred()) + result, err := current.GetResult(r) + Expect(err).NotTo(HaveOccurred()) + Expect(result.IPs).To(HaveLen(2)) + Expect(result.IPs[0].Address.IP).To(Equal(net.ParseIP("10.1.2.88"))) + Expect(result.IPs[1].Address.IP).To(Equal(net.ParseIP("2001:db8:1::999"))) + }) + + It("fails if a requested custom IP is not used", func() { + const ifname string = "eth0" + const nspath string = "/some/where" + + tmpDir, err := ioutil.TempDir("", "host_local_artifacts") + Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(tmpDir) + + conf := fmt.Sprintf(`{ + "cniVersion": "0.3.1", + "name": "mynet", + "type": "ipvlan", + "master": "foo0", + "ipam": { + "type": "host-local", + "dataDir": "%s", + "ranges": [ + { "subnet": "10.1.2.0/24" }, + { "subnet": "10.1.3.0/24" } + ] + }, + "args": { + "cni": { + "ips": ["10.1.2.88", "10.1.2.77"] + } + } +}`, tmpDir) + + args := &skel.CmdArgs{ + ContainerID: "dummy", + Netns: nspath, + IfName: ifname, + StdinData: []byte(conf), + } + + // Allocate the IP + _, _, err = testutils.CmdAddWithResult(nspath, ifname, []byte(conf), func() error { + return cmdAdd(args) + }) + Expect(err).To(HaveOccurred()) + // Need to match prefix, because ordering is not guaranteed + Expect(err.Error()).To(HavePrefix("failed to allocate all requested IPs: 10.1.2.")) + }) }) + +func mustCIDR(s string) net.IPNet { + ip, n, err := net.ParseCIDR(s) + n.IP = ip + if err != nil { + Fail(err.Error()) + } + + return *n +} diff --git a/plugins/ipam/host-local/main.go b/plugins/ipam/host-local/main.go index 4db9a071..e554a707 100644 --- a/plugins/ipam/host-local/main.go +++ b/plugins/ipam/host-local/main.go @@ -15,6 +15,10 @@ package main import ( + "fmt" + "net" + "strings" + "github.com/containernetworking/plugins/plugins/ipam/host-local/backend/allocator" "github.com/containernetworking/plugins/plugins/ipam/host-local/backend/disk" @@ -50,17 +54,58 @@ func cmdAdd(args *skel.CmdArgs) error { } defer store.Close() - allocator, err := allocator.NewIPAllocator(ipamConf, store) - if err != nil { - return err + // Keep the allocators we used, so we can release all IPs if an error + // occurs after we start allocating + allocs := []*allocator.IPAllocator{} + + // Store all requested IPs in a map, so we can easily remove ones we use + // and error if some remain + requestedIPs := map[string]net.IP{} //net.IP cannot be a key + + for _, ip := range ipamConf.IPArgs { + requestedIPs[ip.String()] = ip } - ipConf, routes, err := allocator.Get(args.ContainerID) - if err != nil { - return err + for idx, ipRange := range ipamConf.Ranges { + allocator := allocator.NewIPAllocator(ipamConf.Name, ipRange, store) + + // Check to see if there are any custom IPs requested in this range. + var requestedIP net.IP + for k, ip := range requestedIPs { + if ipRange.IPInRange(ip) == nil { + requestedIP = ip + delete(requestedIPs, k) + break + } + } + + ipConf, err := allocator.Get(args.ContainerID, requestedIP) + if err != nil { + // Deallocate all already allocated IPs + for _, alloc := range allocs { + _ = alloc.Release(args.ContainerID) + } + return fmt.Errorf("failed to allocate for range %d: %v", idx, err) + } + + allocs = append(allocs, allocator) + + result.IPs = append(result.IPs, ipConf) } - result.IPs = []*current.IPConfig{ipConf} - result.Routes = routes + + // If an IP was requested that wasn't fulfilled, fail + if len(requestedIPs) != 0 { + for _, alloc := range allocs { + _ = alloc.Release(args.ContainerID) + } + errstr := "failed to allocate all requested IPs:" + for _, ip := range requestedIPs { + errstr = errstr + " " + ip.String() + } + return fmt.Errorf(errstr) + } + + result.Routes = ipamConf.Routes return types.PrintResult(result, confVersion) } @@ -77,10 +122,19 @@ func cmdDel(args *skel.CmdArgs) error { } defer store.Close() - ipAllocator, err := allocator.NewIPAllocator(ipamConf, store) - if err != nil { - return err + // Loop through all ranges, releasing all IPs, even if an error occurs + var errors []string + for _, ipRange := range ipamConf.Ranges { + ipAllocator := allocator.NewIPAllocator(ipamConf.Name, ipRange, store) + + err := ipAllocator.Release(args.ContainerID) + if err != nil { + errors = append(errors, err.Error()) + } } - return ipAllocator.Release(args.ContainerID) + if errors != nil { + return fmt.Errorf(strings.Join(errors, ";")) + } + return nil }