diff --git a/config/crd/bases/airship.airshipit.org_ippools.yaml b/config/crd/bases/airship.airshipit.org_ippools.yaml
index ecbe830..f49553c 100644
--- a/config/crd/bases/airship.airshipit.org_ippools.yaml
+++ b/config/crd/bases/airship.airshipit.org_ippools.yaml
@@ -32,14 +32,23 @@ spec:
metadata:
type: object
spec:
- description: IPPool tracks allocation ranges and statuses within a specific
+ description: IPPoolSpec tracks allocation ranges and statuses within a specific
subnet IPv4 or IPv6 subnet. It has a set of ranges of IPs within the
subnet from which IPs can be allocated by IPAM, and a set of IPs that
are currently allocated already.
properties:
allocatedIPs:
items:
- type: string
+ description: AllocatedIP Allocates an IP to an entity
+ properties:
+ allocatedTo:
+ type: string
+ ip:
+ type: string
+ required:
+ - allocatedTo
+ - ip
+ type: object
type: array
ranges:
items:
diff --git a/config/samples/ippool.yaml b/config/samples/ippool.yaml
index 742bac0..591134e 100644
--- a/config/samples/ippool.yaml
+++ b/config/samples/ippool.yaml
@@ -10,6 +10,9 @@ spec:
- start: 10.0.1.1
stop: 10.0.1.9
allocatedIPs:
- - 10.0.0.1
- - 10.0.0.2
- - 10.0.1.1
+ - allocatedTo: default-vino-test-cr-leviathan-worker-0
+ ip: 10.0.0.1
+ - allocatedTo: default-vino-test-cr-leviathan-worker-1
+ ip: 10.0.0.2
+ - allocatedTo: default-vino-test-cr-leviathan-worker-2
+ ip: 10.0.1.1
diff --git a/docs/api/vino.md b/docs/api/vino.md
index bbd6968..dfd0c34 100644
--- a/docs/api/vino.md
+++ b/docs/api/vino.md
@@ -9,6 +9,47 @@
Package v1 contains API Schema definitions for the airship v1 API group
Resource Types:
+AllocatedIP
+
+
+(Appears on:
+IPPoolSpec)
+
+AllocatedIP Allocates an IP to an entity
+
BMCCredentials
@@ -326,7 +367,9 @@ string
allocatedIPs
-[]string
+
+[]AllocatedIP
+
|
@@ -357,7 +400,7 @@ IPPoolStatus
(Appears on:
IPPool)
- IPPool tracks allocation ranges and statuses within a specific
+ IPPoolSpec tracks allocation ranges and statuses within a specific
subnet IPv4 or IPv6 subnet. It has a set of ranges of IPs
within the subnet from which IPs can be allocated by IPAM,
and a set of IPs that are currently allocated already.
@@ -397,7 +440,9 @@ string
|
allocatedIPs
-[]string
+
+[]AllocatedIP
+
|
diff --git a/pkg/api/v1/ippool_types.go b/pkg/api/v1/ippool_types.go
index 628bede..c867e84 100644
--- a/pkg/api/v1/ippool_types.go
+++ b/pkg/api/v1/ippool_types.go
@@ -21,14 +21,20 @@ import (
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
-// IPPool tracks allocation ranges and statuses within a specific
+// IPPoolSpec tracks allocation ranges and statuses within a specific
// subnet IPv4 or IPv6 subnet. It has a set of ranges of IPs
// within the subnet from which IPs can be allocated by IPAM,
// and a set of IPs that are currently allocated already.
type IPPoolSpec struct {
- Subnet string `json:"subnet"`
- Ranges []Range `json:"ranges"`
- AllocatedIPs []string `json:"allocatedIPs"`
+ Subnet string `json:"subnet"`
+ Ranges []Range `json:"ranges"`
+ AllocatedIPs []AllocatedIP `json:"allocatedIPs"`
+}
+
+// AllocatedIP Allocates an IP to an entity
+type AllocatedIP struct {
+ IP string `json:"ip"`
+ AllocatedTo string `json:"allocatedTo"`
}
// Range has (inclusive) bounds within a subnet from which IPs can be allocated
diff --git a/pkg/api/v1/zz_generated.deepcopy.go b/pkg/api/v1/zz_generated.deepcopy.go
index b3aef28..dd1c83a 100644
--- a/pkg/api/v1/zz_generated.deepcopy.go
+++ b/pkg/api/v1/zz_generated.deepcopy.go
@@ -25,6 +25,21 @@ import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AllocatedIP) DeepCopyInto(out *AllocatedIP) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AllocatedIP.
+func (in *AllocatedIP) DeepCopy() *AllocatedIP {
+ if in == nil {
+ return nil
+ }
+ out := new(AllocatedIP)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BMCCredentials) DeepCopyInto(out *BMCCredentials) {
*out = *in
@@ -175,7 +190,7 @@ func (in *IPPoolSpec) DeepCopyInto(out *IPPoolSpec) {
}
if in.AllocatedIPs != nil {
in, out := &in.AllocatedIPs, &out.AllocatedIPs
- *out = make([]string, len(*in))
+ *out = make([]AllocatedIP, len(*in))
copy(*out, *in)
}
}
diff --git a/pkg/ipam/ipam.go b/pkg/ipam/ipam.go
index 6ab79c1..4e1fc62 100644
--- a/pkg/ipam/ipam.go
+++ b/pkg/ipam/ipam.go
@@ -65,6 +65,9 @@ func NewRange(start string, stop string) (vinov1.Range, error) {
// AddSubnetRange adds a range within a subnet for IP allocation
// TODO error: range overlaps with existing range or subnet overlaps with existing subnet
+// NOTE: the above should only be an error if a subnet is re-added with a *different*
+// subnet range than what is already allocated -- i.e. this function should be idempotent
+// against allocating the exact same subnet+range multiple times.
// TODO error: invalid range for subnet
func (i *Ipam) AddSubnetRange(ctx context.Context, subnet string, subnetRange vinov1.Range) error {
logger := i.Log.WithValues("subnet", subnet, "subnetRange", subnetRange)
@@ -73,35 +76,43 @@ func (i *Ipam) AddSubnetRange(ctx context.Context, subnet string, subnetRange vi
if err != nil {
return err
}
+ // Add the IPAM subnet if it doesn't exist already
ippool, exists := ippools[subnet]
if !exists {
- logger.Info("IPAM creating subnet and adding range")
+ logger.Info("IPAM creating subnet")
ippool = &vinov1.IPPoolSpec{
Subnet: subnet,
Ranges: []vinov1.Range{},
- AllocatedIPs: []string{},
+ AllocatedIPs: []vinov1.AllocatedIP{},
}
ippools[subnet] = ippool
- } else {
- logger.Info("IPAM subnet already exists; adding range")
- // Does the subnet's requested range already exist? (this should fail)
- for _, r := range ippool.Ranges {
- if r == subnetRange {
- return ErrSubnetRangeOverlapsWithExistingRange{Subnet: subnet, SubnetRange: subnetRange}
- }
+ }
+ // Add the IPAM range to the subnet if it doesn't exist already
+ exists = false
+ for _, existingSubnetRange := range ippools[subnet].Ranges {
+ if existingSubnetRange == subnetRange {
+ exists = true
+ break
}
}
- ippool.Ranges = append(ippool.Ranges, subnetRange)
- err = i.applyIPPool(ctx, *ippool)
- if err != nil {
- return err
+ if !exists {
+ logger.Info("IPAM creating subnet")
+ ippool.Ranges = append(ippool.Ranges, subnetRange)
+ err = i.applyIPPool(ctx, *ippool)
+ if err != nil {
+ return err
+ }
}
return nil
}
// AllocateIP allocates an IP from a range and return it
-func (i *Ipam) AllocateIP(ctx context.Context, subnet string, subnetRange vinov1.Range) (string, error) {
+// allocatedTo: a unique identifier for the entity that is requesting / will own the
+// allocated IP. If the same entity requests another IP, it will be given
+// the same one. I.e. this function is idempotent for the same allocatedTo.
+func (i *Ipam) AllocateIP(ctx context.Context, subnet string, subnetRange vinov1.Range,
+ allocatedTo string) (string, error) {
ippools, err := i.getIPPools(ctx)
if err != nil {
return "", err
@@ -122,20 +133,38 @@ func (i *Ipam) AllocateIP(ctx context.Context, subnet string, subnetRange vinov1
return "", ErrSubnetRangeNotAllocated{Subnet: subnet, SubnetRange: subnetRange}
}
- ip, err := findFreeIPInRange(ippool, subnetRange)
- if err != nil {
- return "", err
- }
- i.Log.Info("Allocating IP", "ip", ip, "subnet", subnet, "subnetRange", subnetRange)
- ippool.AllocatedIPs = append(ippool.AllocatedIPs, ip)
- err = i.applyIPPool(ctx, *ippool)
- if err != nil {
- return "", err
+ // If an IP has already been allocated to this entity, return it
+ ip := findAlreadyAllocatedIP(ippool, allocatedTo)
+
+ // No IP already allocated, so allocate a new IP
+ if ip == "" {
+ ip, err = findFreeIPInRange(ippool, subnetRange)
+ if err != nil {
+ return "", err
+ }
+ i.Log.Info("Allocating IP", "ip", ip, "subnet", subnet, "subnetRange", subnetRange)
+ ippool.AllocatedIPs = append(ippool.AllocatedIPs, vinov1.AllocatedIP{IP: ip, AllocatedTo: allocatedTo})
+ err = i.applyIPPool(ctx, *ippool)
+ if err != nil {
+ return "", err
+ }
}
return ip, nil
}
+// This returns an IP already allocated to the entity specified by `allocatedTo`
+// if it exists within the requested ippool/subnet, and a blank string
+// if no IP is already allocated.
+func findAlreadyAllocatedIP(ippool *vinov1.IPPoolSpec, allocatedTo string) string {
+ for _, allocatedIP := range ippool.AllocatedIPs {
+ if allocatedIP.AllocatedTo == allocatedTo {
+ return allocatedIP.IP
+ }
+ }
+ return ""
+}
+
// This converts IP ranges/addresses into iterable ints,
// steps through them looking for one that that is not already
// in use, converts it back to a string and returns it.
@@ -170,12 +199,12 @@ func findFreeIPInRange(ippool *vinov1.IPPoolSpec, subnetRange vinov1.Range) (str
return "", ErrSubnetRangeExhausted{ippool.Subnet, subnetRange}
}
-// Create a map[uint64]struct{} representation of a string slice,
+// Create a map[uint64]struct{} representation of an AllocatedIP slice,
// for efficient set lookups
-func sliceToMap(slice []string) (map[uint64]struct{}, error) {
+func sliceToMap(slice []vinov1.AllocatedIP) (map[uint64]struct{}, error) {
m := map[uint64]struct{}{}
for _, s := range slice {
- i, err := ipStringToInt(s)
+ i, err := ipStringToInt(s.IP)
if err != nil {
return m, err
}
diff --git a/pkg/ipam/ipam_test.go b/pkg/ipam/ipam_test.go
index 29617a8..dda445f 100644
--- a/pkg/ipam/ipam_test.go
+++ b/pkg/ipam/ipam_test.go
@@ -59,7 +59,9 @@ func SetUpMockClient(ctx context.Context, ctrl *gomock.Controller) *test.MockCli
Ranges: []vinov1.Range{
{Start: "192.168.0.0", Stop: "192.168.0.0"},
},
- AllocatedIPs: []string{"192.168.0.0"},
+ AllocatedIPs: []vinov1.AllocatedIP{
+ {IP: "192.168.0.0", AllocatedTo: "old-vm-name"},
+ },
},
},
{
@@ -68,7 +70,9 @@ func SetUpMockClient(ctx context.Context, ctrl *gomock.Controller) *test.MockCli
Ranges: []vinov1.Range{
{Start: "2600:1700:b031:0000::", Stop: "2600:1700:b031:0000::"},
},
- AllocatedIPs: []string{"2600:1700:b031:0000::"},
+ AllocatedIPs: []vinov1.AllocatedIP{
+ {IP: "2600:1700:b031:0000::", AllocatedTo: "old-vm-name"},
+ },
},
},
},
@@ -83,36 +87,41 @@ func SetUpMockClient(ctx context.Context, ctrl *gomock.Controller) *test.MockCli
func TestAllocateIP(t *testing.T) {
tests := []struct {
- name, subnet, expectedErr string
- subnetRange vinov1.Range
+ name, subnet, allocatedTo, expectedErr string
+ subnetRange vinov1.Range
}{
{
name: "success ipv4",
subnet: "10.0.0.0/16",
subnetRange: vinov1.Range{Start: "10.0.1.0", Stop: "10.0.1.9"},
+ allocatedTo: "new-vm-name",
},
{
name: "success ipv6",
subnet: "2600:1700:b030:0000::/72",
subnetRange: vinov1.Range{Start: "2600:1700:b030:0000::", Stop: "2600:1700:b030:0009::"},
+ allocatedTo: "new-vm-name",
},
{
name: "error subnet not allocated ipv4",
subnet: "10.0.0.0/20",
subnetRange: vinov1.Range{Start: "10.0.1.0", Stop: "10.0.1.9"},
expectedErr: "IPAM subnet 10.0.0.0/20 not allocated",
+ allocatedTo: "new-vm-name",
},
{
name: "error subnet not allocated ipv6",
subnet: "2600:1700:b030:0000::/80",
subnetRange: vinov1.Range{Start: "2600:1700:b030:0000::", Stop: "2600:1700:b030:0009::"},
expectedErr: "IPAM subnet 2600:1700:b030:0000::/80 not allocated",
+ allocatedTo: "new-vm-name",
},
{
name: "error range not allocated ipv4",
subnet: "10.0.0.0/16",
subnetRange: vinov1.Range{Start: "10.0.2.0", Stop: "10.0.2.9"},
expectedErr: "IPAM range [10.0.2.0,10.0.2.9] in subnet 10.0.0.0/16 is not allocated",
+ allocatedTo: "new-vm-name",
},
{
name: "error range not allocated ipv6",
@@ -120,12 +129,20 @@ func TestAllocateIP(t *testing.T) {
subnetRange: vinov1.Range{Start: "2600:1700:b030:0000::", Stop: "2600:1700:b030:1111::"},
expectedErr: "IPAM range [2600:1700:b030:0000::,2600:1700:b030:1111::] " +
"in subnet 2600:1700:b030:0000::/72 is not allocated",
+ allocatedTo: "new-vm-name",
+ },
+ {
+ name: "success idempotency ipv4",
+ subnet: "192.168.0.0/1",
+ subnetRange: vinov1.Range{Start: "192.168.0.0", Stop: "192.168.0.0"},
+ allocatedTo: "old-vm-name",
},
{
name: "error range exhausted ipv4",
subnet: "192.168.0.0/1",
subnetRange: vinov1.Range{Start: "192.168.0.0", Stop: "192.168.0.0"},
expectedErr: "IPAM range [192.168.0.0,192.168.0.0] in subnet 192.168.0.0/1 is exhausted",
+ allocatedTo: "new-vm-name",
},
{
name: "error range exhausted ipv6",
@@ -133,6 +150,7 @@ func TestAllocateIP(t *testing.T) {
subnetRange: vinov1.Range{Start: "2600:1700:b031:0000::", Stop: "2600:1700:b031:0000::"},
expectedErr: "IPAM range [2600:1700:b031:0000::,2600:1700:b031:0000::] " +
"in subnet 2600:1700:b031:0000::/64 is exhausted",
+ allocatedTo: "new-vm-name",
},
}
@@ -147,7 +165,7 @@ func TestAllocateIP(t *testing.T) {
ipammer := NewIpam(log.Log, m, "vino-system")
ipammer.Log = log.Log
- ip, err := ipammer.AllocateIP(ctx, tt.subnet, tt.subnetRange)
+ ip, err := ipammer.AllocateIP(ctx, tt.subnet, tt.subnetRange, tt.allocatedTo)
if tt.expectedErr != "" {
require.Error(t, err)
assert.Equal(t, "", ip)
@@ -217,12 +235,6 @@ func TestAddSubnetRange(t *testing.T) {
subnetRange: vinov1.Range{Start: "10.0.2.0", Stop: "10.0.2.9"},
expectedErr: "",
},
- {
- name: "error range already exists",
- subnet: "10.0.0.0/16",
- subnetRange: vinov1.Range{Start: "10.0.1.0", Stop: "10.0.1.9"},
- expectedErr: "IPAM range [10.0.1.0,10.0.1.9] in subnet 10.0.0.0/16 overlaps",
- },
// TODO: check for partially overlapping ranges and subnets
}
@@ -294,7 +306,10 @@ func TestFindFreeIPInRange(t *testing.T) {
{Start: "2600:1700:b030:1001::", Stop: "2600:1700:b030:1009::"},
{Start: "2600:1700:b031::", Stop: "2600:1700:b031::"},
},
- AllocatedIPs: []string{"10.0.2.0", "2600:1700:b031::"},
+ AllocatedIPs: []vinov1.AllocatedIP{
+ {IP: "10.0.2.0", AllocatedTo: "old-vm-name"},
+ {IP: "2600:1700:b031::", AllocatedTo: "old-vm-name"},
+ },
}
actual, err := findFreeIPInRange(&ippool, tt.subnetRange)
if tt.expectedErr != "" {
@@ -311,23 +326,28 @@ func TestFindFreeIPInRange(t *testing.T) {
func TestSliceToMap(t *testing.T) {
tests := []struct {
name string
- in []string
+ in []vinov1.AllocatedIP
out map[uint64]struct{}
}{
{
name: "empty slice",
- in: []string{},
+ in: []vinov1.AllocatedIP{},
out: map[uint64]struct{}{},
},
{
name: "one-element slice",
- in: []string{"0.0.0.1"},
- out: map[uint64]struct{}{1: {}},
+ in: []vinov1.AllocatedIP{
+ {IP: "0.0.0.1", AllocatedTo: "old-vm-name"},
+ },
+ out: map[uint64]struct{}{1: {}},
},
{
name: "two-element slice",
- in: []string{"0.0.0.1", "0.0.0.2"},
- out: map[uint64]struct{}{1: {}, 2: {}},
+ in: []vinov1.AllocatedIP{
+ {IP: "0.0.0.1", AllocatedTo: "old-vm-name"},
+ {IP: "0.0.0.2", AllocatedTo: "old-vm-name"},
+ },
+ out: map[uint64]struct{}{1: {}, 2: {}},
},
}
|