diff --git a/config/crd/bases/airship.airshipit.org_ippools.yaml b/config/crd/bases/airship.airshipit.org_ippools.yaml index f49553c..aed6633 100644 --- a/config/crd/bases/airship.airshipit.org_ippools.yaml +++ b/config/crd/bases/airship.airshipit.org_ippools.yaml @@ -39,17 +39,27 @@ spec: properties: allocatedIPs: items: - description: AllocatedIP Allocates an IP to an entity + description: AllocatedIP Allocates an IP and MAC address to an entity properties: allocatedTo: type: string ip: type: string + mac: + type: string required: - allocatedTo - ip + - mac type: object type: array + macPrefix: + description: MACPrefix defines the MAC prefix to use for VM mac addresses + type: string + nextMAC: + description: NextMAC indicates the next MAC address (in sequence) that + will be provisioned to a VM in this Subnet + type: string ranges: items: description: Range has (inclusive) bounds within a subnet from which @@ -68,6 +78,8 @@ spec: type: string required: - allocatedIPs + - macPrefix + - nextMAC - ranges - subnet type: object diff --git a/config/crd/bases/airship.airshipit.org_vinoes.yaml b/config/crd/bases/airship.airshipit.org_vinoes.yaml index 6908505..ce9f5f0 100644 --- a/config/crd/bases/airship.airshipit.org_vinoes.yaml +++ b/config/crd/bases/airship.airshipit.org_vinoes.yaml @@ -90,6 +90,13 @@ spec: items: type: string type: array + macPrefix: + description: MACPrefix defines the zero-padded MAC prefix to use + for VM mac addresses, and is the first address that will be + allocated sequentially to VMs in this network. If omitted, a + default private MAC prefix will be used. The prefix should be + specified in full MAC notation, e.g. 06:42:42:00:00:00 + type: string name: description: Network Parameter defined type: string diff --git a/config/samples/ippool.yaml b/config/samples/ippool.yaml index 5237152..3ff1ac8 100644 --- a/config/samples/ippool.yaml +++ b/config/samples/ippool.yaml @@ -7,6 +7,8 @@ metadata: name: ippool-sample spec: subnet: 10.0.0.0/16 + macPrefix: "02:00:00:00:00:00" + nextMAC: "02:00:00:00:00:03" ranges: - start: 10.0.0.1 stop: 10.0.0.9 @@ -15,7 +17,10 @@ spec: allocatedIPs: - allocatedTo: default-vino-test-cr-leviathan-worker-0 ip: 10.0.0.1 + mac: "02:00:00:00:00:00" - allocatedTo: default-vino-test-cr-leviathan-worker-1 ip: 10.0.0.2 + mac: "02:00:00:00:00:01" - allocatedTo: default-vino-test-cr-leviathan-worker-2 ip: 10.0.1.1 + mac: "02:00:00:00:00:02" diff --git a/config/samples/network-template-secret.yaml b/config/samples/network-template-secret.yaml index ab51754..384de24 100644 --- a/config/samples/network-template-secret.yaml +++ b/config/samples/network-template-secret.yaml @@ -19,7 +19,7 @@ stringData: name: {{ .Name }} type: {{ .Type }} mtu: {{ .MTU }} - # ethernet_mac_address: ?? + ethernet_mac_address: {{ index $.Generated.MACAddresses .Name }} {{- if .Options -}} {{ range $key, $val := .Options }} {{ $key }}: {{ $val }} diff --git a/config/samples/vino_cr.yaml b/config/samples/vino_cr.yaml index 400ce91..42089f9 100644 --- a/config/samples/vino_cr.yaml +++ b/config/samples/vino_cr.yaml @@ -31,6 +31,7 @@ spec: gateway: 169.0.0.1 allocationStart: 169.0.0.10 allocationStop: 169.0.0.254 + macPrefix: "0A:00:00:00:00:00" vmBridge: lo nodes: diff --git a/docs/api/vino.md b/docs/api/vino.md index de7de98..f1dbe02 100644 --- a/docs/api/vino.md +++ b/docs/api/vino.md @@ -15,7 +15,7 @@ Resource Types: (Appears on: IPPoolSpec)

-

AllocatedIP Allocates an IP to an entity

+

AllocatedIP Allocates an IP and MAC address to an entity

@@ -38,6 +38,16 @@ string + + + + + + + + + + + +
+mac
+ +string + +
+
allocatedTo
string @@ -375,6 +385,29 @@ string
+macPrefix
+ +string + +
+

MACPrefix defines the MAC prefix to use for VM mac addresses

+
+nextMAC
+ +string + +
+

NextMAC indicates the next MAC address (in sequence) that +will be provisioned to a VM in this Subnet

+
@@ -448,6 +481,29 @@ string + + +macPrefix
+ +string + + + +

MACPrefix defines the MAC prefix to use for VM mac addresses

+ + + + +nextMAC
+ +string + + + +

NextMAC indicates the next MAC address (in sequence) that +will be provisioned to a VM in this Subnet

+ +
@@ -591,6 +647,22 @@ string + + +macPrefix
+ +string + + + +

MACPrefix defines the zero-padded MAC prefix to use for +VM mac addresses, and is the first address that will be +allocated sequentially to VMs in this network. +If omitted, a default private MAC prefix will be used. +The prefix should be specified in full MAC notation, e.g. +06:42:42:00:00:00

+ +
diff --git a/pkg/api/v1/ippool_types.go b/pkg/api/v1/ippool_types.go index c867e84..491b2b4 100644 --- a/pkg/api/v1/ippool_types.go +++ b/pkg/api/v1/ippool_types.go @@ -29,11 +29,17 @@ type IPPoolSpec struct { Subnet string `json:"subnet"` Ranges []Range `json:"ranges"` AllocatedIPs []AllocatedIP `json:"allocatedIPs"` + // MACPrefix defines the MAC prefix to use for VM mac addresses + MACPrefix string `json:"macPrefix"` + // NextMAC indicates the next MAC address (in sequence) that + // will be provisioned to a VM in this Subnet + NextMAC string `json:"nextMAC"` } -// AllocatedIP Allocates an IP to an entity +// AllocatedIP Allocates an IP and MAC address to an entity type AllocatedIP struct { IP string `json:"ip"` + MAC string `json:"mac"` AllocatedTo string `json:"allocatedTo"` } diff --git a/pkg/api/v1/vino_types.go b/pkg/api/v1/vino_types.go index 175579a..89e910e 100644 --- a/pkg/api/v1/vino_types.go +++ b/pkg/api/v1/vino_types.go @@ -83,6 +83,13 @@ type Network struct { AllocationStop string `json:"allocationStop,omitempty"` DNSServers []string `json:"dns_servers,omitempty"` Routes []VMRoutes `json:"routes,omitempty"` + // MACPrefix defines the zero-padded MAC prefix to use for + // VM mac addresses, and is the first address that will be + // allocated sequentially to VMs in this network. + // If omitted, a default private MAC prefix will be used. + // The prefix should be specified in full MAC notation, e.g. + // 06:42:42:00:00:00 + MACPrefix string `json:"macPrefix,omitempty"` } // VMRoutes defined diff --git a/pkg/controllers/bmh.go b/pkg/controllers/bmh.go index 305f7e6..ad6b5a8 100644 --- a/pkg/controllers/bmh.go +++ b/pkg/controllers/bmh.go @@ -34,6 +34,12 @@ import ( "vino/pkg/ipam" ) +const ( + // DefaultMACPrefix is a private RFC 1918 MAC range used if + // no MACPrefix is specified for a network in the ViNO CR + DefaultMACPrefix = "02:00:00:00:00:00" +) + type networkTemplateValues struct { Node vinov1.NodeSet // the specific node type to be templated BMHName string @@ -42,7 +48,8 @@ type networkTemplateValues struct { } type generatedValues struct { - IPAddresses map[string]string // a map of network names to IP addresses + IPAddresses map[string]string // a map of network names to IP addresses + MACAddresses map[string]string // a map of network interface (link) names to MACs } func (r *VinoReconciler) ensureBMHs(ctx context.Context, vino *vinov1.Vino) error { @@ -117,12 +124,18 @@ func (r *VinoReconciler) reconcileBMHs(ctx context.Context, vino *vinov1.Vino) e } func (r *VinoReconciler) createIpamNetworks(ctx context.Context, vino *vinov1.Vino) error { + logger := logr.FromContext(ctx) for _, network := range vino.Spec.Networks { subnetRange, err := ipam.NewRange(network.AllocationStart, network.AllocationStop) if err != nil { return err } - err = r.Ipam.AddSubnetRange(ctx, network.SubNet, subnetRange) + if network.MACPrefix == "" { + logger.Info("No MACPrefix provided; using default MACPrefix %s for network %s", + DefaultMACPrefix, network.Name) + network.MACPrefix = DefaultMACPrefix + } + err = r.Ipam.AddSubnetRange(ctx, network.SubNet, subnetRange, network.MACPrefix) if err != nil { return err } @@ -131,8 +144,8 @@ func (r *VinoReconciler) createIpamNetworks(ctx context.Context, vino *vinov1.Vi } func (r *VinoReconciler) createBMHperPod(ctx context.Context, vino *vinov1.Vino, pod corev1.Pod) error { + logger := logr.FromContext(ctx) for _, node := range vino.Spec.Nodes { - logger := logr.FromContext(ctx) logger.Info("Creating BMHs for vino node", "node name", node.Name, "count", node.Count) prefix := r.getBMHNodePrefix(vino, pod) for i := 0; i < node.Count; i++ { @@ -146,6 +159,7 @@ func (r *VinoReconciler) createBMHperPod(ctx context.Context, vino *vinov1.Vino, // Allocate an IP for each of this BMH's network interfaces ipAddresses := map[string]string{} + macAddresses := map[string]string{} for _, iface := range node.NetworkInterfaces { networkName := iface.NetworkName subnet := "" @@ -165,11 +179,12 @@ func (r *VinoReconciler) createBMHperPod(ctx context.Context, vino *vinov1.Vino, return fmt.Errorf("Interface %s doesn't have a matching network defined", networkName) } ipAllocatedTo := fmt.Sprintf("%s/%s", bmhName, iface.NetworkName) - ipAddress, er := r.Ipam.AllocateIP(ctx, subnet, subnetRange, ipAllocatedTo) + ipAddress, macAddress, er := r.Ipam.AllocateIP(ctx, subnet, subnetRange, ipAllocatedTo) if er != nil { return er } ipAddresses[networkName] = ipAddress + macAddresses[iface.Name] = macAddress } values := networkTemplateValues{ @@ -177,7 +192,8 @@ func (r *VinoReconciler) createBMHperPod(ctx context.Context, vino *vinov1.Vino, BMHName: bmhName, Networks: vino.Spec.Networks, Generated: generatedValues{ - IPAddresses: ipAddresses, + IPAddresses: ipAddresses, + MACAddresses: macAddresses, }, } netData, netDataNs, err := r.reconcileBMHNetworkData(ctx, node, vino, values) diff --git a/pkg/ipam/errors.go b/pkg/ipam/errors.go index fc9c0a1..34bf4d1 100644 --- a/pkg/ipam/errors.go +++ b/pkg/ipam/errors.go @@ -54,7 +54,13 @@ type ErrInvalidIPAddress struct { IP string } -// ErrNotSupported returned if unsupported address types are used +// ErrInvalidMACAddress returned if a MAC address string is malformed +type ErrInvalidMACAddress struct { + MAC string +} + +// ErrNotSupported returned if unsupported address types are used, +// or if a change to immutable fields is attempted type ErrNotSupported struct { Message string } @@ -87,6 +93,10 @@ func (e ErrInvalidIPAddress) Error() string { return fmt.Sprintf("IP address %s is invalid", e.IP) } +func (e ErrInvalidMACAddress) Error() string { + return fmt.Sprintf("MAC address %s is invalid", e.MAC) +} + func (e ErrNotSupported) Error() string { return fmt.Sprintf("%s", e.Message) } diff --git a/pkg/ipam/ipam.go b/pkg/ipam/ipam.go index 4e1fc62..6f5b19d 100644 --- a/pkg/ipam/ipam.go +++ b/pkg/ipam/ipam.go @@ -46,7 +46,7 @@ func NewIpam(logger logr.Logger, client client.Client, namespace string) *Ipam { } } -// Create a new Range, validating its input +// NewRange creates a new Range, validating its input func NewRange(start string, stop string) (vinov1.Range, error) { r := vinov1.Range{Start: start, Stop: stop} a, e := ipStringToInt(start) @@ -69,8 +69,9 @@ func NewRange(start string, stop string) (vinov1.Range, error) { // 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) +func (i *Ipam) AddSubnetRange(ctx context.Context, subnet string, subnetRange vinov1.Range, + macPrefix string) error { + logger := i.Log.WithValues("subnet", subnet, "subnetRange", subnetRange, "macPrefix", macPrefix) // Does the subnet already exist? (this is fine) ippools, err := i.getIPPools(ctx) if err != nil { @@ -80,13 +81,22 @@ func (i *Ipam) AddSubnetRange(ctx context.Context, subnet string, subnetRange vi ippool, exists := ippools[subnet] if !exists { logger.Info("IPAM creating subnet") + _, err = macStringToInt(macPrefix) // mac format validation + if err != nil { + return err + } ippool = &vinov1.IPPoolSpec{ Subnet: subnet, Ranges: []vinov1.Range{}, AllocatedIPs: []vinov1.AllocatedIP{}, + MACPrefix: macPrefix, + NextMAC: macPrefix, } ippools[subnet] = ippool + } else if ippool.MACPrefix != macPrefix { + return ErrNotSupported{Message: "Cannot change immutable field `macPrefix`"} } + // Add the IPAM range to the subnet if it doesn't exist already exists = false for _, existingSubnetRange := range ippools[subnet].Ranges { @@ -112,14 +122,14 @@ func (i *Ipam) AddSubnetRange(ctx context.Context, subnet string, subnetRange vi // 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) { + allocatedTo string) (allocatedIP string, allocatedMAC string, err error) { ippools, err := i.getIPPools(ctx) if err != nil { - return "", err + return "", "", err } ippool, exists := ippools[subnet] if !exists { - return "", ErrSubnetNotAllocated{Subnet: subnet} + return "", "", ErrSubnetNotAllocated{Subnet: subnet} } // Make sure the range has been allocated within the subnet var match bool @@ -130,39 +140,50 @@ func (i *Ipam) AllocateIP(ctx context.Context, subnet string, subnetRange vinov1 } } if !match { - return "", ErrSubnetRangeNotAllocated{Subnet: subnet, SubnetRange: subnetRange} + return "", "", ErrSubnetRangeNotAllocated{Subnet: subnet, SubnetRange: subnetRange} } // If an IP has already been allocated to this entity, return it - ip := findAlreadyAllocatedIP(ippool, allocatedTo) + ip, mac := findAlreadyAllocatedIP(ippool, allocatedTo) // No IP already allocated, so allocate a new IP if ip == "" { + // Find an IP ip, err = findFreeIPInRange(ippool, subnetRange) if err != nil { - return "", err + return "", "", err } i.Log.Info("Allocating IP", "ip", ip, "subnet", subnet, "subnetRange", subnetRange) ippool.AllocatedIPs = append(ippool.AllocatedIPs, vinov1.AllocatedIP{IP: ip, AllocatedTo: allocatedTo}) + + // Find a MAC + mac = ippool.NextMAC + macInt, err := macStringToInt(ippool.NextMAC) + if err != nil { + return "", "", err + } + ippool.NextMAC = intToMACString(macInt + 1) + + // Save the updated IPPool err = i.applyIPPool(ctx, *ippool) if err != nil { - return "", err + return "", "", err } } - return ip, nil + return ip, mac, 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 { +func findAlreadyAllocatedIP(ippool *vinov1.IPPoolSpec, allocatedTo string) (ip string, mac string) { for _, allocatedIP := range ippool.AllocatedIPs { if allocatedIP.AllocatedTo == allocatedTo { - return allocatedIP.IP + return allocatedIP.IP, allocatedIP.MAC } } - return "" + return "", "" } // This converts IP ranges/addresses into iterable ints, @@ -235,6 +256,24 @@ func ipStringToInt(ipString string) (uint64, error) { return byteArrayToInt(bytes), nil } +// Convert a MAC address in xx:xx:xx:xx:xx:xx format to an easily iterable uint64. +func macStringToInt(macString string) (uint64, error) { + // ParseMAC parses various flavors of macs; we restrict to vanilla ethernet + regex := regexp.MustCompile(`[..:..:..:..:..:..]`) + if !regex.MatchString(macString) { + return 0, ErrInvalidMACAddress{macString} + } + + bytes, err := net.ParseMAC(macString) + if err != nil { + return 0, ErrInvalidMACAddress{macString} + } + + // Pad to 8 bytes for the uint64 conversion + bytes = append(make([]byte, 2), bytes...) + return byteArrayToInt(bytes), nil +} + func intToIPv4String(i uint64) string { bytes := intToByteArray(i) ip := net.IPv4(bytes[4], bytes[5], bytes[6], bytes[7]) @@ -249,6 +288,13 @@ func intToIPv6String(i uint64) string { return ip.String() } +func intToMACString(i uint64) string { + bytes := intToByteArray(i) + // lop off the first two bytes to get a 6-byte array + var hardwareAddress net.HardwareAddr = bytes[2:] + return hardwareAddress.String() +} + // Convert an uint64 into 8 bytes, with most significant byte first // Based on https://gist.github.com/ecoshub/5be18dc63ac64f3792693bb94f00662f func intToByteArray(num uint64) []byte { diff --git a/pkg/ipam/ipam_test.go b/pkg/ipam/ipam_test.go index dda445f..61f9666 100644 --- a/pkg/ipam/ipam_test.go +++ b/pkg/ipam/ipam_test.go @@ -43,6 +43,8 @@ func SetUpMockClient(ctx context.Context, ctrl *gomock.Controller) *test.MockCli Ranges: []vinov1.Range{ {Start: "10.0.1.0", Stop: "10.0.1.9"}, }, + MACPrefix: "02:00:00:00:00:00", + NextMAC: "02:00:00:00:00:00", }, }, { @@ -51,6 +53,8 @@ func SetUpMockClient(ctx context.Context, ctrl *gomock.Controller) *test.MockCli Ranges: []vinov1.Range{ {Start: "2600:1700:b030:0000::", Stop: "2600:1700:b030:0009::"}, }, + MACPrefix: "06:00:00:00:00:00", + NextMAC: "06:00:00:00:00:00", }, }, { @@ -60,8 +64,10 @@ func SetUpMockClient(ctx context.Context, ctrl *gomock.Controller) *test.MockCli {Start: "192.168.0.0", Stop: "192.168.0.0"}, }, AllocatedIPs: []vinov1.AllocatedIP{ - {IP: "192.168.0.0", AllocatedTo: "old-vm-name"}, + {IP: "192.168.0.0", MAC: "02:00:00:00:00:00", AllocatedTo: "old-vm-name"}, }, + MACPrefix: "02:00:00:00:00:00", + NextMAC: "02:00:00:00:00:01", }, }, { @@ -71,8 +77,10 @@ func SetUpMockClient(ctx context.Context, ctrl *gomock.Controller) *test.MockCli {Start: "2600:1700:b031:0000::", Stop: "2600:1700:b031:0000::"}, }, AllocatedIPs: []vinov1.AllocatedIP{ - {IP: "2600:1700:b031:0000::", AllocatedTo: "old-vm-name"}, + {IP: "2600:1700:b031:0000::", MAC: "06:00:00:00:00:00", AllocatedTo: "old-vm-name"}, }, + MACPrefix: "06:00:00:00:00:00", + NextMAC: "06:00:00:00:00:01", }, }, }, @@ -87,20 +95,22 @@ func SetUpMockClient(ctx context.Context, ctrl *gomock.Controller) *test.MockCli func TestAllocateIP(t *testing.T) { tests := []struct { - name, subnet, allocatedTo, expectedErr string - subnetRange vinov1.Range + name, subnet, allocatedTo, expectedErr, expectedMAC 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", + expectedMAC: "02:00:00:00:00:00", }, { 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", + expectedMAC: "06:00:00:00:00:00", }, { name: "error subnet not allocated ipv4", @@ -136,6 +146,7 @@ func TestAllocateIP(t *testing.T) { subnet: "192.168.0.0/1", subnetRange: vinov1.Range{Start: "192.168.0.0", Stop: "192.168.0.0"}, allocatedTo: "old-vm-name", + expectedMAC: "02:00:00:00:00:00", }, { name: "error range exhausted ipv4", @@ -165,14 +176,16 @@ 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, tt.allocatedTo) + ip, mac, err := ipammer.AllocateIP(ctx, tt.subnet, tt.subnetRange, tt.allocatedTo) if tt.expectedErr != "" { require.Error(t, err) assert.Equal(t, "", ip) + assert.Equal(t, "", mac) assert.Contains(t, err.Error(), tt.expectedErr) } else { require.NoError(t, err) assert.NotEmpty(t, ip) + assert.Equal(t, tt.expectedMAC, mac) } }) } @@ -192,19 +205,19 @@ func TestNewRange(t *testing.T) { name: "error stop less than start", start: "10.0.0.2", stop: "10.0.0.1", - expectedErr: "is invalid", + expectedErr: "IPAM range", }, { name: "error bad start", start: "10.0.0.2.x", stop: "10.0.0.1", - expectedErr: "is invalid", + expectedErr: "IP address", }, { name: "error bad stop", start: "10.0.0.2", stop: "10.0.0.1.x", - expectedErr: "is invalid", + expectedErr: "IP address", }, } for _, tt := range tests { @@ -226,15 +239,30 @@ func TestNewRange(t *testing.T) { // Test some error handling that is not captured by TestAllocateIP func TestAddSubnetRange(t *testing.T) { tests := []struct { - name, subnet, expectedErr string - subnetRange vinov1.Range + name, subnet, macPrefix, expectedErr string + subnetRange vinov1.Range }{ { name: "success", - subnet: "10.0.0.0/16", - subnetRange: vinov1.Range{Start: "10.0.2.0", Stop: "10.0.2.9"}, + subnet: "20.0.0.0/16", + subnetRange: vinov1.Range{Start: "20.0.2.0", Stop: "20.0.2.9"}, + macPrefix: "02:00:00:00:00:00", expectedErr: "", }, + { + name: "error bad mac", + subnet: "20.0.0.0/16", + subnetRange: vinov1.Range{Start: "20.0.2.0", Stop: "20.0.2.9"}, + macPrefix: "", + expectedErr: "MAC address", + }, + { + name: "error macPrefix is immutable", + subnet: "10.0.0.0/16", + subnetRange: vinov1.Range{Start: "10.0.1.0", Stop: "10.0.1.9"}, + macPrefix: "02:00:00:00:00:0`", + expectedErr: "immutable", + }, // TODO: check for partially overlapping ranges and subnets } @@ -248,7 +276,7 @@ func TestAddSubnetRange(t *testing.T) { m := SetUpMockClient(ctx, ctrl) ipammer := NewIpam(log.Log, m, "vino-system") - err := ipammer.AddSubnetRange(ctx, tt.subnet, tt.subnetRange) + err := ipammer.AddSubnetRange(ctx, tt.subnet, tt.subnetRange, tt.macPrefix) if tt.expectedErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.expectedErr) @@ -408,6 +436,48 @@ func TestIPStringToInt(t *testing.T) { } } +func TestMACStringToInt(t *testing.T) { + tests := []struct { + name string + in string + out uint64 + expectedErr string + }{ + { + name: "valid MAC address", + in: "00:00:00:00:01:01", + out: 0x101, + }, + { + name: "invalid MAC address", + in: "00:00:00:00:01:01:00", + out: 0, + expectedErr: " is invalid", + }, + { + name: "blank MAC address", + in: "", + out: 0, + expectedErr: " is invalid", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + actual, err := macStringToInt(tt.in) + if tt.expectedErr != "" { + require.Error(t, err) + assert.Empty(t, tt.out) + assert.Contains(t, err.Error(), tt.expectedErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.out, actual) + } + }) + } +} + func TestIntToByteArray(t *testing.T) { tests := []struct { name string