Relax checking for vxlan network type.
[nfvbench.git] / nfvbench / chaining.py
1 #!/usr/bin/env python
2 # Copyright 2018 Cisco Systems, Inc.  All rights reserved.
3 #
4 #    Licensed under the Apache License, Version 2.0 (the "License"); you may
5 #    not use this file except in compliance with the License. You may obtain
6 #    a copy of the License at
7 #
8 #         http://www.apache.org/licenses/LICENSE-2.0
9 #
10 #    Unless required by applicable law or agreed to in writing, software
11 #    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 #    License for the specific language governing permissions and limitations
14 #    under the License.
15 #
16
17 # This module takes care of chaining networks, ports and vms
18 #
19 """NFVBENCH CHAIN DISCOVERY/STAGING.
20
21 This module takes care of staging/discovering all resources that are participating in a
22 benchmarking session: flavors, networks, ports, VNF instances.
23 If a resource is discovered with the same name, it will be reused.
24 Otherwise it will be created.
25
26 ChainManager: manages VM image, flavor, the staging discovery of all chains
27               has 1 or more chains
28 Chain: manages one chain, has 2 or more networks and 1 or more instances
29 ChainNetwork: manages 1 network in a chain
30 ChainVnf: manages 1 VNF instance in a chain, has 2 ports
31 ChainVnfPort: manages 1 instance port
32
33 ChainManager-->Chain(*)
34 Chain-->ChainNetwork(*),ChainVnf(*)
35 ChainVnf-->ChainVnfPort(2)
36
37 Once created/discovered, instances are checked to be in the active state (ready to pass traffic)
38 Configuration parameters that will influence how these resources are staged/related:
39 - openstack or no openstack
40 - chain type
41 - number of chains
42 - number of VNF in each chain (PVP, PVVP)
43 - SRIOV and middle port SRIOV for port types
44 - whether networks are shared across chains or not
45
46 There is not traffic generation involved in this module.
47 """
48 import os
49 import re
50 import time
51
52 from glanceclient.v2 import client as glanceclient
53 from neutronclient.neutron import client as neutronclient
54 from novaclient.client import Client
55
56 from attrdict import AttrDict
57 import compute
58 from log import LOG
59 from specs import ChainType
60
61 # Left and right index for network and port lists
62 LEFT = 0
63 RIGHT = 1
64 # Name of the VM config file
65 NFVBENCH_CFG_FILENAME = 'nfvbenchvm.conf'
66 # full pathame of the VM config in the VM
67 NFVBENCH_CFG_VM_PATHNAME = os.path.join('/etc/', NFVBENCH_CFG_FILENAME)
68 # full path of the boot shell script template file on the server where nfvbench runs
69 BOOT_SCRIPT_PATHNAME = os.path.join(os.path.dirname(os.path.abspath(__file__)),
70                                     'nfvbenchvm',
71                                     NFVBENCH_CFG_FILENAME)
72
73
74 class ChainException(Exception):
75     """Exception while operating the chains."""
76
77     pass
78
79 class NetworkEncaps(object):
80     """Network encapsulation."""
81
82
83 class ChainFlavor(object):
84     """Class to manage the chain flavor."""
85
86     def __init__(self, flavor_name, flavor_dict, comp):
87         """Create a flavor."""
88         self.name = flavor_name
89         self.comp = comp
90         self.flavor = self.comp.find_flavor(flavor_name)
91         self.reuse = False
92         if self.flavor:
93             self.reuse = True
94             LOG.info("Reused flavor '%s'", flavor_name)
95         else:
96             extra_specs = flavor_dict.pop('extra_specs', None)
97
98             self.flavor = comp.create_flavor(flavor_name,
99                                              **flavor_dict)
100
101             LOG.info("Created flavor '%s'", flavor_name)
102             if extra_specs:
103                 self.flavor.set_keys(extra_specs)
104
105     def delete(self):
106         """Delete this flavor."""
107         if not self.reuse and self.flavor:
108             self.flavor.delete()
109             LOG.info("Flavor '%s' deleted", self.name)
110
111
112 class ChainVnfPort(object):
113     """A port associated to one VNF in the chain."""
114
115     def __init__(self, name, vnf, chain_network, vnic_type):
116         """Create or reuse a port on a given network.
117
118         if vnf.instance is None the VNF instance is not reused and this ChainVnfPort instance must
119         create a new port.
120         Otherwise vnf.instance is a reused VNF instance and this ChainVnfPort instance must
121         find an existing port to reuse that matches the port requirements: same attached network,
122         instance, name, vnic type
123
124         name: name for this port
125         vnf: ChainVNf instance that owns this port
126         chain_network: ChainNetwork instance where this port should attach
127         vnic_type: required vnic type for this port
128         """
129         self.name = name
130         self.vnf = vnf
131         self.manager = vnf.manager
132         self.reuse = False
133         self.port = None
134         if vnf.instance:
135             # VNF instance is reused, we need to find an existing port that matches this instance
136             # and network
137             # discover ports attached to this instance
138             port_list = self.manager.get_ports_from_network(chain_network)
139             for port in port_list:
140                 if port['name'] != name:
141                     continue
142                 if port['binding:vnic_type'] != vnic_type:
143                     continue
144                 if port['device_id'] == vnf.get_uuid():
145                     self.port = port
146                     LOG.info('Reusing existing port %s mac=%s', name, port['mac_address'])
147                     break
148             else:
149                 raise ChainException('Cannot find matching port')
150         else:
151             # VNF instance is not created yet, we need to create a new port
152             body = {
153                 "port": {
154                     'name': name,
155                     'network_id': chain_network.get_uuid(),
156                     'binding:vnic_type': vnic_type
157                 }
158             }
159             port = self.manager.neutron_client.create_port(body)
160             self.port = port['port']
161             LOG.info('Created port %s', name)
162             try:
163                 self.manager.neutron_client.update_port(self.port['id'], {
164                     'port': {
165                         'security_groups': [],
166                         'port_security_enabled': False,
167                     }
168                 })
169                 LOG.info('Security disabled on port %s', name)
170             except Exception:
171                 LOG.info('Failed to disable security on port %s (ignored)', name)
172
173     def get_mac(self):
174         """Get the MAC address for this port."""
175         return self.port['mac_address']
176
177     def delete(self):
178         """Delete this port instance."""
179         if self.reuse or not self.port:
180             return
181         retry = 0
182         while retry < self.manager.config.generic_retry_count:
183             try:
184                 self.manager.neutron_client.delete_port(self.port['id'])
185                 LOG.info("Deleted port %s", self.name)
186                 return
187             except Exception:
188                 retry += 1
189                 time.sleep(self.manager.config.generic_poll_sec)
190         LOG.error('Unable to delete port: %s', self.name)
191
192
193 class ChainNetwork(object):
194     """Could be a shared network across all chains or a chain private network."""
195
196     def __init__(self, manager, network_config, chain_id=None, lookup_only=False):
197         """Create a network for given chain.
198
199         network_config: a dict containing the network properties
200                         (segmentation_id and physical_network)
201         chain_id: to which chain the networks belong.
202                   a None value will mean that these networks are shared by all chains
203         """
204         self.manager = manager
205         self.name = network_config.name
206         self.segmentation_id = self._get_item(network_config.segmentation_id,
207                                               chain_id, auto_index=True)
208         self.physical_network = self._get_item(network_config.physical_network, chain_id)
209         if chain_id is not None:
210             self.name += str(chain_id)
211         self.reuse = False
212         self.network = None
213         self.vlan = None
214         try:
215             self._setup(network_config, lookup_only)
216         except Exception:
217             if lookup_only:
218                 LOG.error("Cannot find network %s", self.name)
219             else:
220                 LOG.error("Error creating network %s", self.name)
221             self.delete()
222             raise
223
224     def _get_item(self, item_field, index, auto_index=False):
225         """Retrieve an item from a list or a single value.
226
227         item_field: can be None, a tuple of a single value
228         index: if None is same as 0, else is the index for a chain
229         auto_index: if true will automatically get the final value by adding the
230                     index to the base value (if full list not provided)
231
232         If the item_field is not a tuple, it is considered same as a tuple with same value at any
233         index.
234         If a list is provided, its length must be > index
235         """
236         if not item_field:
237             return None
238         if index is None:
239             index = 0
240         if isinstance(item_field, tuple):
241             try:
242                 return item_field[index]
243             except IndexError:
244                 raise ChainException("List %s is too short for chain index %d" %
245                                      (str(item_field), index))
246         # single value is configured
247         if auto_index:
248             return item_field + index
249         return item_field
250
251     def _setup(self, network_config, lookup_only):
252         # Lookup if there is a matching network with same name
253         networks = self.manager.neutron_client.list_networks(name=self.name)
254         if networks['networks']:
255             network = networks['networks'][0]
256             # a network of same name already exists, we need to verify it has the same
257             # characteristics
258             if self.segmentation_id:
259                 if network['provider:segmentation_id'] != self.segmentation_id:
260                     raise ChainException("Mismatch of 'segmentation_id' for reused "
261                                          "network '{net}'. Network has id '{seg_id1}', "
262                                          "configuration requires '{seg_id2}'."
263                                          .format(net=self.name,
264                                                  seg_id1=network['provider:segmentation_id'],
265                                                  seg_id2=self.segmentation_id))
266
267             if self.physical_network:
268                 if network['provider:physical_network'] != self.physical_network:
269                     raise ChainException("Mismatch of 'physical_network' for reused "
270                                          "network '{net}'. Network has '{phys1}', "
271                                          "configuration requires '{phys2}'."
272                                          .format(net=self.name,
273                                                  phys1=network['provider:physical_network'],
274                                                  phys2=self.physical_network))
275
276             LOG.info('Reusing existing network %s', self.name)
277             self.reuse = True
278             self.network = network
279         else:
280             if lookup_only:
281                 raise ChainException('Network %s not found' % self.name)
282             body = {
283                 'network': {
284                     'name': self.name,
285                     'admin_state_up': True
286                     }
287             }
288             if network_config.network_type:
289                 body['network']['provider:network_type'] = network_config.network_type
290             if self.segmentation_id:
291                 body['network']['provider:segmentation_id'] = self.segmentation_id
292             if self.physical_network:
293                 body['network']['provider:physical_network'] = self.physical_network
294             self.network = self.manager.neutron_client.create_network(body)['network']
295             body = {
296                 'subnet': {'name': network_config.subnet,
297                            'cidr': network_config.cidr,
298                            'network_id': self.network['id'],
299                            'enable_dhcp': False,
300                            'ip_version': 4,
301                            'dns_nameservers': []}
302             }
303             subnet = self.manager.neutron_client.create_subnet(body)['subnet']
304             # add subnet id to the network dict since it has just been added
305             self.network['subnets'] = [subnet['id']]
306             LOG.info('Created network: %s', self.name)
307
308     def get_uuid(self):
309         """
310         Extract UUID of this network.
311
312         :return: UUID of this network
313         """
314         return self.network['id']
315
316     def get_vlan(self):
317         """
318         Extract vlan for this network.
319
320         :return: vlan ID for this network
321         """
322         if self.network['provider:network_type'] != 'vlan':
323             raise ChainException('Trying to retrieve VLAN id for non VLAN network')
324         return self.network['provider:segmentation_id']
325
326     def get_vxlan(self):
327         """
328         Extract VNI for this network.
329
330         :return: VNI ID for this network
331         """
332         if 'vxlan' not in self.network['provider:network_type']:
333             raise ChainException('Trying to retrieve VNI for non VXLAN network')
334         return self.network['provider:segmentation_id']
335
336     def delete(self):
337         """Delete this network."""
338         if not self.reuse and self.network:
339             retry = 0
340             while retry < self.manager.config.generic_retry_count:
341                 try:
342                     self.manager.neutron_client.delete_network(self.network['id'])
343                     LOG.info("Deleted network: %s", self.name)
344                     return
345                 except Exception:
346                     retry += 1
347                     LOG.info('Error deleting network %s (retry %d/%d)...',
348                              self.name,
349                              retry,
350                              self.manager.config.generic_retry_count)
351                     time.sleep(self.manager.config.generic_poll_sec)
352             LOG.error('Unable to delete network: %s', self.name)
353
354
355 class ChainVnf(object):
356     """A class to represent a VNF in a chain."""
357
358     def __init__(self, chain, vnf_id, networks):
359         """Reuse a VNF instance with same characteristics or create a new VNF instance.
360
361         chain: the chain where this vnf belongs
362         vnf_id: indicates the index of this vnf in its chain (first vnf=0)
363         networks: the list of all networks (ChainNetwork) of the current chain
364         """
365         self.manager = chain.manager
366         self.chain = chain
367         self.vnf_id = vnf_id
368         self.name = self.manager.config.loop_vm_name + str(chain.chain_id)
369         if len(networks) > 2:
370             # we will have more than 1 VM in each chain
371             self.name += '-' + str(vnf_id)
372         self.ports = []
373         self.status = None
374         self.instance = None
375         self.reuse = False
376         self.host_ip = None
377         try:
378             # the vnf_id is conveniently also the starting index in networks
379             # for the left and right networks associated to this VNF
380             self._setup(networks[vnf_id:vnf_id + 2])
381         except Exception:
382             LOG.error("Error creating VNF %s", self.name)
383             self.delete()
384             raise
385
386     def _get_vm_config(self, remote_mac_pair):
387         config = self.manager.config
388         devices = self.manager.generator_config.devices
389         with open(BOOT_SCRIPT_PATHNAME, 'r') as boot_script:
390             content = boot_script.read()
391         g1cidr = devices[LEFT].get_gw_ip(self.chain.chain_id) + '/8'
392         g2cidr = devices[RIGHT].get_gw_ip(self.chain.chain_id) + '/8'
393         vm_config = {
394             'forwarder': config.vm_forwarder,
395             'intf_mac1': self.ports[LEFT].get_mac(),
396             'intf_mac2': self.ports[RIGHT].get_mac(),
397             'tg_gateway1_ip': devices[LEFT].tg_gateway_ip_addrs,
398             'tg_gateway2_ip': devices[RIGHT].tg_gateway_ip_addrs,
399             'tg_net1': devices[LEFT].ip_addrs,
400             'tg_net2': devices[RIGHT].ip_addrs,
401             'vnf_gateway1_cidr': g1cidr,
402             'vnf_gateway2_cidr': g2cidr,
403             'tg_mac1': remote_mac_pair[0],
404             'tg_mac2': remote_mac_pair[1]
405         }
406         return content.format(**vm_config)
407
408     def _get_vnic_type(self, port_index):
409         """Get the right vnic type for given port indexself.
410
411         If SR-IOV is speficied, middle ports in multi-VNF chains
412         can use vswitch or SR-IOV based on config.use_sriov_middle_net
413         """
414         if self.manager.config.sriov:
415             chain_length = self.chain.get_length()
416             if self.manager.config.use_sriov_middle_net or chain_length == 1:
417                 return 'direct'
418             if self.vnf_id == 0 and port_index == 0:
419                 # first VNF in chain must use sriov for left port
420                 return 'direct'
421             if (self.vnf_id == chain_length - 1) and (port_index == 1):
422                 # last VNF in chain must use sriov for right port
423                 return 'direct'
424         return 'normal'
425
426     def _setup(self, networks):
427         flavor_id = self.manager.flavor.flavor.id
428         # Check if we can reuse an instance with same name
429         for instance in self.manager.existing_instances:
430             if instance.name == self.name:
431                 # Verify that other instance characteristics match
432                 if instance.flavor['id'] != flavor_id:
433                     self._reuse_exception('Flavor mismatch')
434                 if instance.status != "ACTIVE":
435                     self._reuse_exception('Matching instance is not in ACTIVE state')
436                 # The 2 networks for this instance must also be reused
437                 if not networks[LEFT].reuse:
438                     self._reuse_exception('network %s is new' % networks[LEFT].name)
439                 if not networks[RIGHT].reuse:
440                     self._reuse_exception('network %s is new' % networks[RIGHT].name)
441                 # instance.networks have the network names as keys:
442                 # {'nfvbench-rnet0': ['192.168.2.10'], 'nfvbench-lnet0': ['192.168.1.8']}
443                 if networks[LEFT].name not in instance.networks:
444                     self._reuse_exception('Left network mismatch')
445                 if networks[RIGHT].name not in instance.networks:
446                     self._reuse_exception('Right network mismatch')
447
448                 self.reuse = True
449                 self.instance = instance
450                 LOG.info('Reusing existing instance %s on %s',
451                          self.name, self.get_hypervisor_name())
452         # create or reuse/discover 2 ports per instance
453         self.ports = [ChainVnfPort(self.name + '-' + str(index),
454                                    self,
455                                    networks[index],
456                                    self._get_vnic_type(index)) for index in [0, 1]]
457         # if no reuse, actual vm creation is deferred after all ports in the chain are created
458         # since we need to know the next mac in a multi-vnf chain
459
460     def create_vnf(self, remote_mac_pair):
461         """Create the VNF instance if it does not already exist."""
462         if self.instance is None:
463             port_ids = [{'port-id': vnf_port.port['id']}
464                         for vnf_port in self.ports]
465             vm_config = self._get_vm_config(remote_mac_pair)
466             az = self.manager.placer.get_required_az()
467             server = self.manager.comp.create_server(self.name,
468                                                      self.manager.image_instance,
469                                                      self.manager.flavor.flavor,
470                                                      None,
471                                                      port_ids,
472                                                      None,
473                                                      avail_zone=az,
474                                                      user_data=None,
475                                                      config_drive=True,
476                                                      files={NFVBENCH_CFG_VM_PATHNAME: vm_config})
477             if server:
478                 self.instance = server
479                 if self.manager.placer.is_resolved():
480                     LOG.info('Created instance %s on %s', self.name, az)
481                 else:
482                     # the location is undetermined at this point
483                     # self.get_hypervisor_name() will return None
484                     LOG.info('Created instance %s - waiting for placement resolution...', self.name)
485                     # here we MUST wait until this instance is resolved otherwise subsequent
486                     # VNF creation can be placed in other hypervisors!
487                     config = self.manager.config
488                     max_retries = (config.check_traffic_time_sec +
489                                    config.generic_poll_sec - 1) / config.generic_poll_sec
490                     retry = 0
491                     for retry in range(max_retries):
492                         status = self.get_status()
493                         if status == 'ACTIVE':
494                             hyp_name = self.get_hypervisor_name()
495                             LOG.info('Instance %s is active and has been placed on %s',
496                                      self.name, hyp_name)
497                             self.manager.placer.register_full_name(hyp_name)
498                             break
499                         if status == 'ERROR':
500                             raise ChainException('Instance %s creation error: %s' %
501                                                  (self.name,
502                                                   self.instance.fault['message']))
503                         LOG.info('Waiting for instance %s to become active (retry %d/%d)...',
504                                  self.name, retry + 1, max_retries + 1)
505                         time.sleep(config.generic_poll_sec)
506                     else:
507                         # timing out
508                         LOG.error('Instance %s creation timed out', self.name)
509                         raise ChainException('Instance %s creation timed out' % self.name)
510                 self.reuse = False
511             else:
512                 raise ChainException('Unable to create instance: %s' % (self.name))
513
514     def _reuse_exception(self, reason):
515         raise ChainException('Instance %s cannot be reused (%s)' % (self.name, reason))
516
517     def get_status(self):
518         """Get the statis of this instance."""
519         if self.instance.status != 'ACTIVE':
520             self.instance = self.manager.comp.poll_server(self.instance)
521         return self.instance.status
522
523     def get_hostname(self):
524         """Get the hypervisor host name running this VNF instance."""
525         return getattr(self.instance, 'OS-EXT-SRV-ATTR:hypervisor_hostname')
526
527     def get_host_ip(self):
528         """Get the IP address of the host where this instance runs.
529
530         return: the IP address
531         """
532         if not self.host_ip:
533             self.host_ip = self.manager.comp.get_hypervisor(self.get_hostname()).host_ip
534         return self.host_ip
535
536     def get_hypervisor_name(self):
537         """Get hypervisor name (az:hostname) for this VNF instance."""
538         if self.instance:
539             az = getattr(self.instance, 'OS-EXT-AZ:availability_zone')
540             hostname = self.get_hostname()
541             if az:
542                 return az + ':' + hostname
543             return hostname
544         return None
545
546     def get_uuid(self):
547         """Get the uuid for this instance."""
548         return self.instance.id
549
550     def delete(self, forced=False):
551         """Delete this VNF instance."""
552         if self.reuse:
553             LOG.info("Instance %s not deleted (reused)", self.name)
554         else:
555             if self.instance:
556                 self.manager.comp.delete_server(self.instance)
557                 LOG.info("Deleted instance %s", self.name)
558             for port in self.ports:
559                 port.delete()
560
561 class Chain(object):
562     """A class to manage a single chain.
563
564     Can handle any type of chain (EXT, PVP, PVVP)
565     """
566
567     def __init__(self, chain_id, manager):
568         """Create a new chain.
569
570         chain_id: chain index (first chain is 0)
571         manager: the chain manager that owns all chains
572         """
573         self.chain_id = chain_id
574         self.manager = manager
575         self.encaps = manager.encaps
576         self.networks = []
577         self.instances = []
578         try:
579             self.networks = manager.get_networks(chain_id)
580             # For external chain VNFs can only be discovered from their MAC addresses
581             # either from config or from ARP
582             if manager.config.service_chain != ChainType.EXT:
583                 for chain_instance_index in range(self.get_length()):
584                     self.instances.append(ChainVnf(self,
585                                                    chain_instance_index,
586                                                    self.networks))
587                 # at this point new VNFs are not created yet but
588                 # verify that all discovered VNFs are on the same hypervisor
589                 self._check_hypervisors()
590                 # now that all VNF ports are created we need to calculate the
591                 # left/right remote MAC for each VNF in the chain
592                 # before actually creating the VNF itself
593                 rem_mac_pairs = self._get_remote_mac_pairs()
594                 for instance in self.instances:
595                     rem_mac_pair = rem_mac_pairs.pop(0)
596                     instance.create_vnf(rem_mac_pair)
597         except Exception:
598             self.delete()
599             raise
600
601     def _check_hypervisors(self):
602         common_hypervisor = None
603         for instance in self.instances:
604             # get the full hypervizor name (az:compute)
605             hname = instance.get_hypervisor_name()
606             if hname:
607                 if common_hypervisor:
608                     if hname != common_hypervisor:
609                         raise ChainException('Discovered instances on different hypervisors:'
610                                              ' %s and %s' % (hname, common_hypervisor))
611                 else:
612                     common_hypervisor = hname
613         if common_hypervisor:
614             # check that the common hypervisor name matchs the requested hypervisor name
615             # and set the name to be used by all future instances (if any)
616             if not self.manager.placer.register_full_name(common_hypervisor):
617                 raise ChainException('Discovered hypervisor placement %s is incompatible' %
618                                      common_hypervisor)
619
620     def get_length(self):
621         """Get the number of VNF in the chain."""
622         return len(self.networks) - 1
623
624     def _get_remote_mac_pairs(self):
625         """Get the list of remote mac pairs for every VNF in the chain.
626
627         Traverse the chain from left to right and establish the
628         left/right remote MAC for each VNF in the chainself.
629
630         PVP case is simpler:
631         mac sequence: tg_src_mac, vm0-mac0, vm0-mac1, tg_dst_mac
632         must produce [[tg_src_mac, tg_dst_mac]] or looking at index in mac sequence: [[0, 3]]
633         the mac pair is what the VNF at that position (index 0) sees as next hop mac left and right
634
635         PVVP:
636         tg_src_mac, vm0-mac0, vm0-mac1, vm1-mac0, vm1-mac1, tg_dst_mac
637         Must produce the following list:
638         [[tg_src_mac, vm1-mac0], [vm0-mac1, tg_dst_mac]] or index: [[0, 3], [2, 5]]
639
640         General case with 3 VMs in chain, the list of consecutive macs (left to right):
641         tg_src_mac, vm0-mac0, vm0-mac1, vm1-mac0, vm1-mac1, vm2-mac0, vm2-mac1, tg_dst_mac
642         Must produce the following list:
643         [[tg_src_mac, vm1-mac0], [vm0-mac1, vm2-mac0], [vm1-mac1, tg_dst_mac]]
644         or index: [[0, 3], [2, 5], [4, 7]]
645
646         The series pattern is pretty clear: [[n, n+3],... ] where n is multiple of 2
647         """
648         # line up all mac from left to right
649         mac_seq = [self.manager.generator_config.devices[LEFT].mac]
650         for instance in self.instances:
651             mac_seq.append(instance.ports[0].get_mac())
652             mac_seq.append(instance.ports[1].get_mac())
653         mac_seq.append(self.manager.generator_config.devices[RIGHT].mac)
654         base = 0
655         rem_mac_pairs = []
656         for _ in self.instances:
657             rem_mac_pairs.append([mac_seq[base], mac_seq[base + 3]])
658             base += 2
659         return rem_mac_pairs
660
661     def get_instances(self):
662         """Return all instances for this chain."""
663         return self.instances
664
665     def get_vlan(self, port_index):
666         """Get the VLAN id on a given port.
667
668         port_index: left port is 0, right port is 1
669         return: the vlan_id or None if there is no vlan tagging
670         """
671         # for port 1 we need to return the VLAN of the last network in the chain
672         # The networks array contains 2 networks for PVP [left, right]
673         # and 3 networks in the case of PVVP [left.middle,right]
674         if port_index:
675             # this will pick the last item in array
676             port_index = -1
677         return self.networks[port_index].get_vlan()
678
679     def get_vxlan(self, port_index):
680         """Get the VXLAN id on a given port.
681
682         port_index: left port is 0, right port is 1
683         return: the vxlan_id or None if there is no vxlan
684         """
685         # for port 1 we need to return the VLAN of the last network in the chain
686         # The networks array contains 2 networks for PVP [left, right]
687         # and 3 networks in the case of PVVP [left.middle,right]
688         if port_index:
689             # this will pick the last item in array
690             port_index = -1
691         return self.networks[port_index].get_vxlan()
692
693     def get_dest_mac(self, port_index):
694         """Get the dest MAC on a given port.
695
696         port_index: left port is 0, right port is 1
697         return: the dest MAC
698         """
699         if port_index:
700             # for right port, use the right port MAC of the last (right most) VNF In chain
701             return self.instances[-1].ports[1].get_mac()
702         # for left port use the left port MAC of the first (left most) VNF in chain
703         return self.instances[0].ports[0].get_mac()
704
705     def get_network_uuids(self):
706         """Get UUID of networks in this chain from left to right (order is important).
707
708         :return: list of UUIDs of networks (2 or 3 elements)
709         """
710         return [net['id'] for net in self.networks]
711
712     def get_host_ips(self):
713         """Return the IP adresss(es) of the host compute nodes used for this chain.
714
715         :return: a list of 1 or 2 IP addresses
716         """
717         return [vnf.get_host_ip() for vnf in self.instances]
718
719     def get_compute_nodes(self):
720         """Return the name of the host compute nodes used for this chain.
721
722         :return: a list of 1 host name in the az:host format
723         """
724         # Since all chains go through the same compute node(s) we can just retrieve the
725         # compute node name(s) for the first chain
726         return [vnf.get_hypervisor_name() for vnf in self.instances]
727
728     def delete(self):
729         """Delete this chain."""
730         for instance in self.instances:
731             instance.delete()
732         # only delete if these are chain private networks (not shared)
733         if not self.manager.config.service_chain_shared_net:
734             for network in self.networks:
735                 network.delete()
736
737
738 class InstancePlacer(object):
739     """A class to manage instance placement for all VNFs in all chains.
740
741     A full az string is made of 2 parts AZ and hypervisor.
742     The placement is resolved when both parts az and hypervisor names are known.
743     """
744
745     def __init__(self, req_az, req_hyp):
746         """Create a new instance placer.
747
748         req_az: requested AZ (can be None or empty if no preference)
749         req_hyp: requested hypervisor name (can be None of empty if no preference)
750                  can be any of 'nova:', 'comp1', 'nova:comp1'
751                  if it is a list, only the first item is used (backward compatibility in config)
752
753         req_az is ignored if req_hyp has an az part
754         all other parts beyond the first 2 are ignored in req_hyp
755         """
756         # if passed a list just pick the first item
757         if req_hyp and isinstance(req_hyp, list):
758             req_hyp = req_hyp[0]
759         # only pick first part of az
760         if req_az and ':' in req_az:
761             req_az = req_az.split(':')[0]
762         if req_hyp:
763             # check if requested hypervisor string has an AZ part
764             split_hyp = req_hyp.split(':')
765             if len(split_hyp) > 1:
766                 # override the AZ part and hypervisor part
767                 req_az = split_hyp[0]
768                 req_hyp = split_hyp[1]
769         self.requested_az = req_az if req_az else ''
770         self.requested_hyp = req_hyp if req_hyp else ''
771         # Nova can accept AZ only (e.g. 'nova:', use any hypervisor in that AZ)
772         # or hypervisor only (e.g. ':comp1')
773         # or both (e.g. 'nova:comp1')
774         if req_az:
775             self.required_az = req_az + ':' + self.requested_hyp
776         else:
777             # need to insert a ':' so nova knows this is the hypervisor name
778             self.required_az = ':' + self.requested_hyp if req_hyp else ''
779         # placement is resolved when both AZ and hypervisor names are known and set
780         self.resolved = self.requested_az != '' and self.requested_hyp != ''
781
782     def get_required_az(self):
783         """Return the required az (can be resolved or not)."""
784         return self.required_az
785
786     def register_full_name(self, discovered_az):
787         """Verify compatibility and register a discovered hypervisor full name.
788
789         discovered_az: a discovered AZ in az:hypervisor format
790         return: True if discovered_az is compatible and set
791                 False if discovered_az is not compatible
792         """
793         if self.resolved:
794             return discovered_az == self.required_az
795
796         # must be in full az format
797         split_daz = discovered_az.split(':')
798         if len(split_daz) != 2:
799             return False
800         if self.requested_az and self.requested_az != split_daz[0]:
801             return False
802         if self.requested_hyp and self.requested_hyp != split_daz[1]:
803             return False
804         self.required_az = discovered_az
805         self.resolved = True
806         return True
807
808     def is_resolved(self):
809         """Check if the full AZ is resolved.
810
811         return: True if resolved
812         """
813         return self.resolved
814
815
816 class ChainManager(object):
817     """A class for managing all chains for a given run.
818
819     Supports openstack or no openstack.
820     Supports EXT, PVP and PVVP chains.
821     """
822
823     def __init__(self, chain_runner):
824         """Create a chain manager to take care of discovering or bringing up the requested chains.
825
826         A new instance must be created every time a new config is used.
827         config: the nfvbench config to use
828         cred: openstack credentials to use of None if there is no openstack
829         """
830         self.chain_runner = chain_runner
831         self.config = chain_runner.config
832         self.generator_config = chain_runner.traffic_client.generator_config
833         self.chains = []
834         self.image_instance = None
835         self.image_name = None
836         # Left and right networks shared across all chains (only if shared)
837         self.networks = []
838         self.encaps = None
839         self.flavor = None
840         self.comp = None
841         self.nova_client = None
842         self.neutron_client = None
843         self.glance_client = None
844         self.existing_instances = []
845         # existing ports keyed by the network uuid they belong to
846         self._existing_ports = {}
847         config = self.config
848         self.openstack = (chain_runner.cred is not None) and not config.l2_loopback
849         self.chain_count = config.service_chain_count
850         self.az = None
851         if self.openstack:
852             # openstack only
853             session = chain_runner.cred.get_session()
854             self.nova_client = Client(2, session=session)
855             self.neutron_client = neutronclient.Client('2.0', session=session)
856             self.glance_client = glanceclient.Client('2', session=session)
857             self.comp = compute.Compute(self.nova_client,
858                                         self.glance_client,
859                                         config)
860             try:
861                 if config.service_chain != ChainType.EXT:
862                     self.placer = InstancePlacer(config.availability_zone, config.compute_nodes)
863                     self._setup_image()
864                     self.flavor = ChainFlavor(config.flavor_type, config.flavor, self.comp)
865                     # Get list of all existing instances to check if some instances can be reused
866                     self.existing_instances = self.comp.get_server_list()
867                 # If networks are shared across chains, get the list of networks
868                 if config.service_chain_shared_net:
869                     self.networks = self.get_networks()
870                 # Reuse/create chains
871                 for chain_id in range(self.chain_count):
872                     self.chains.append(Chain(chain_id, self))
873                 if config.service_chain == ChainType.EXT:
874                     # if EXT and no ARP we need to read dest MACs from config
875                     if config.no_arp:
876                         self._get_dest_macs_from_config()
877                 else:
878                     # Make sure all instances are active before proceeding
879                     self._ensure_instances_active()
880             except Exception:
881                 self.delete()
882                 raise
883         else:
884             # no openstack, no need to create chains
885
886             if not config.l2_loopback and config.no_arp:
887                 self._get_dest_macs_from_config()
888             if config.vlan_tagging:
889                 # make sure there at least as many entries as chains in each left/right list
890                 if len(config.vlans) != 2:
891                     raise ChainException('The config vlans property must be a list '
892                                          'with 2 lists of VLAN IDs')
893                 re_vlan = "[0-9]*$"
894                 self.vlans = [self._check_list('vlans[0]', config.vlans[0], re_vlan),
895                               self._check_list('vlans[1]', config.vlans[1], re_vlan)]
896             if config.vxlan:
897                 raise ChainException('VxLAN is only supported with OpenStack')
898
899     def _get_dest_macs_from_config(self):
900         re_mac = "[0-9a-fA-F]{2}([-:])[0-9a-fA-F]{2}(\\1[0-9a-fA-F]{2}){4}$"
901         tg_config = self.config.traffic_generator
902         self.dest_macs = [self._check_list("mac_addrs_left",
903                                            tg_config.mac_addrs_left, re_mac),
904                           self._check_list("mac_addrs_right",
905                                            tg_config.mac_addrs_right, re_mac)]
906
907     def _check_list(self, list_name, ll, pattern):
908         # if it is a single int or mac, make it a list of 1 int
909         if isinstance(ll, (int, str)):
910             ll = [ll]
911         for item in ll:
912             if not re.match(pattern, str(item)):
913                 raise ChainException("Invalid format '{item}' specified in {fname}"
914                                      .format(item=item, fname=list_name))
915         # must have at least 1 element
916         if not ll:
917             raise ChainException('%s cannot be empty' % (list_name))
918         # for shared network, if 1 element is passed, replicate it as many times
919         # as chains
920         if self.config.service_chain_shared_net and len(ll) == 1:
921             ll = [ll[0]] * self.chain_count
922
923         # number of elements musty be the number of chains
924         elif len(ll) < self.chain_count:
925             raise ChainException('%s=%s must be a list with %d elements per chain' %
926                                  (list_name, ll, self.chain_count))
927         return ll
928
929     def _setup_image(self):
930         # To avoid reuploading image in server mode, check whether image_name is set or not
931         if self.image_name:
932             self.image_instance = self.comp.find_image(self.image_name)
933         if self.image_instance:
934             LOG.info("Reusing image %s", self.image_name)
935         else:
936             image_name_search_pattern = r'(nfvbenchvm-\d+(\.\d+)*).qcow2'
937             if self.config.vm_image_file:
938                 match = re.search(image_name_search_pattern, self.config.vm_image_file)
939                 if match:
940                     self.image_name = match.group(1)
941                     LOG.info('Using provided VM image file %s', self.config.vm_image_file)
942                 else:
943                     raise ChainException('Provided VM image file name %s must start with '
944                                          '"nfvbenchvm-<version>"' % self.config.vm_image_file)
945             else:
946                 pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
947                 for f in os.listdir(pkg_root):
948                     if re.search(image_name_search_pattern, f):
949                         self.config.vm_image_file = pkg_root + '/' + f
950                         self.image_name = f.replace('.qcow2', '')
951                         LOG.info('Found built-in VM image file %s', f)
952                         break
953                 else:
954                     raise ChainException('Cannot find any built-in VM image file.')
955             if self.image_name:
956                 self.image_instance = self.comp.find_image(self.image_name)
957             if not self.image_instance:
958                 LOG.info('Uploading %s', self.image_name)
959                 res = self.comp.upload_image_via_url(self.image_name,
960                                                      self.config.vm_image_file)
961
962                 if not res:
963                     raise ChainException('Error uploading image %s from %s. ABORTING.' %
964                                          (self.image_name, self.config.vm_image_file))
965                 LOG.info('Image %s successfully uploaded.', self.image_name)
966                 self.image_instance = self.comp.find_image(self.image_name)
967
968     def _ensure_instances_active(self):
969         instances = []
970         for chain in self.chains:
971             instances.extend(chain.get_instances())
972         initial_instance_count = len(instances)
973         max_retries = (self.config.check_traffic_time_sec +
974                        self.config.generic_poll_sec - 1) / self.config.generic_poll_sec
975         retry = 0
976         while instances:
977             remaining_instances = []
978             for instance in instances:
979                 status = instance.get_status()
980                 if status == 'ACTIVE':
981                     LOG.info('Instance %s is ACTIVE on %s',
982                              instance.name, instance.get_hypervisor_name())
983                     continue
984                 if status == 'ERROR':
985                     raise ChainException('Instance %s creation error: %s' %
986                                          (instance.name,
987                                           instance.instance.fault['message']))
988                 remaining_instances.append(instance)
989             if not remaining_instances:
990                 break
991             retry += 1
992             if retry >= max_retries:
993                 raise ChainException('Time-out: %d/%d instances still not active' %
994                                      (len(remaining_instances), initial_instance_count))
995             LOG.info('Waiting for %d/%d instance to become active (retry %d/%d)...',
996                      len(remaining_instances), initial_instance_count,
997                      retry, max_retries)
998             instances = remaining_instances
999             time.sleep(self.config.generic_poll_sec)
1000         if initial_instance_count:
1001             LOG.info('All instances are active')
1002
1003     def get_networks(self, chain_id=None):
1004         """Get the networks for given EXT, PVP or PVVP chain.
1005
1006         For EXT packet path, these networks must pre-exist.
1007         For PVP, PVVP these networks will be created if they do not exist.
1008         chain_id: to which chain the networks belong.
1009                   a None value will mean that these networks are shared by all chains
1010         """
1011         if self.networks:
1012             # the only case where self.networks exists is when the networks are shared
1013             # across all chains
1014             return self.networks
1015         if self.config.service_chain == ChainType.EXT:
1016             lookup_only = True
1017             ext_net = self.config.external_networks
1018             net_cfg = [AttrDict({'name': name,
1019                                  'segmentation_id': None,
1020                                  'physical_network': None})
1021                        for name in [ext_net.left, ext_net.right]]
1022             # segmentation id and subnet should be discovered from neutron
1023         else:
1024             lookup_only = False
1025             int_nets = self.config.internal_networks
1026             # VLAN and VxLAN
1027             if self.config.service_chain == ChainType.PVP:
1028                 net_cfg = [int_nets.left, int_nets.right]
1029             else:
1030                 net_cfg = [int_nets.left, int_nets.middle, int_nets.right]
1031         networks = []
1032         try:
1033             for cfg in net_cfg:
1034                 networks.append(ChainNetwork(self, cfg, chain_id, lookup_only=lookup_only))
1035         except Exception:
1036             # need to cleanup all successful networks prior to bailing out
1037             for net in networks:
1038                 net.delete()
1039             raise
1040         return networks
1041
1042     def get_existing_ports(self):
1043         """Get the list of existing ports.
1044
1045         Lazy retrieval of ports as this can be costly if there are lots of ports and
1046         is only needed when VM and network are being reused.
1047
1048         return: a dict of list of neutron ports indexed by the network uuid they are attached to
1049
1050         Each port is a dict with fields such as below:
1051         {'allowed_address_pairs': [], 'extra_dhcp_opts': [],
1052          'updated_at': '2018-10-06T07:15:35Z', 'device_owner': 'compute:nova',
1053          'revision_number': 10, 'port_security_enabled': False, 'binding:profile': {},
1054          'fixed_ips': [{'subnet_id': '6903a3b3-49a1-4ba4-8259-4a90e7a44b21',
1055          'ip_address': '192.168.1.4'}], 'id': '3dcb9cfa-d82a-4dd1-85a1-fd8284b52d72',
1056          'security_groups': [],
1057          'binding:vif_details': {'vhostuser_socket': '/tmp/3dcb9cfa-d82a-4dd1-85a1-fd8284b52d72',
1058                                  'vhostuser_mode': 'server'},
1059          'binding:vif_type': 'vhostuser',
1060          'mac_address': 'fa:16:3e:3c:63:04',
1061          'project_id': '977ac76a63d7492f927fa80e86baff4c',
1062          'status': 'ACTIVE',
1063          'binding:host_id': 'a20-champagne-compute-1',
1064          'description': '',
1065          'device_id': 'a98e2ad2-5371-4aa5-a356-8264a970ce4b',
1066          'name': 'nfvbench-loop-vm0-0', 'admin_state_up': True,
1067          'network_id': '3ea5fd88-278f-4d9d-b24d-1e443791a055',
1068          'tenant_id': '977ac76a63d7492f927fa80e86baff4c',
1069          'created_at': '2018-10-06T07:15:10Z',
1070          'binding:vnic_type': 'normal'}
1071         """
1072         if not self._existing_ports:
1073             LOG.info('Loading list of all ports...')
1074             existing_ports = self.neutron_client.list_ports()['ports']
1075             # place all ports in the dict keyed by the port network uuid
1076             for port in existing_ports:
1077                 port_list = self._existing_ports.setdefault(port['network_id'], [])
1078                 port_list.append(port)
1079             LOG.info("Loaded %d ports attached to %d networks",
1080                      len(existing_ports), len(self._existing_ports))
1081         return self._existing_ports
1082
1083     def get_ports_from_network(self, chain_network):
1084         """Get the list of existing ports that belong to a network.
1085
1086         Lazy retrieval of ports as this can be costly if there are lots of ports and
1087         is only needed when VM and network are being reused.
1088
1089         chain_network: a ChainNetwork instance for which attached ports neeed to be retrieved
1090         return: list of neutron ports attached to requested network
1091         """
1092         return self.get_existing_ports().get(chain_network.get_uuid(), None)
1093
1094     def get_host_ip_from_mac(self, mac):
1095         """Get the host IP address matching a MAC.
1096
1097         mac: MAC address to look for
1098         return: the IP address of the host where the matching port runs or None if not found
1099         """
1100         # _existing_ports is a dict of list of ports indexed by network id
1101         for port_list in self.get_existing_ports().values():
1102             for port in port_list:
1103                 try:
1104                     if port['mac_address'] == mac:
1105                         host_id = port['binding:host_id']
1106                         return self.comp.get_hypervisor(host_id).host_ip
1107                 except KeyError:
1108                     pass
1109         return None
1110
1111     def get_chain_vlans(self, port_index):
1112         """Get the list of per chain VLAN id on a given port.
1113
1114         port_index: left port is 0, right port is 1
1115         return: a VLAN ID list indexed by the chain index or None if no vlan tagging
1116         """
1117         if self.chains:
1118             return [self.chains[chain_index].get_vlan(port_index)
1119                     for chain_index in range(self.chain_count)]
1120         # no openstack
1121         return self.vlans[port_index]
1122
1123     def get_chain_vxlans(self, port_index):
1124         """Get the list of per chain VNIs id on a given port.
1125
1126         port_index: left port is 0, right port is 1
1127         return: a VNIs ID list indexed by the chain index or None if no vlan tagging
1128         """
1129         if self.chains:
1130             return [self.chains[chain_index].get_vxlan(port_index)
1131                     for chain_index in range(self.chain_count)]
1132         # no openstack
1133         raise ChainException('VxLAN is only supported with OpenStack')
1134
1135     def get_dest_macs(self, port_index):
1136         """Get the list of per chain dest MACs on a given port.
1137
1138         Should not be called if EXT+ARP is used (in that case the traffic gen will
1139         have the ARP responses back from VNFs with the dest MAC to use).
1140
1141         port_index: left port is 0, right port is 1
1142         return: a list of dest MACs indexed by the chain index
1143         """
1144         if self.chains and self.config.service_chain != ChainType.EXT:
1145             return [self.chains[chain_index].get_dest_mac(port_index)
1146                     for chain_index in range(self.chain_count)]
1147         # no openstack or EXT+no-arp
1148         return self.dest_macs[port_index]
1149
1150     def get_host_ips(self):
1151         """Return the IP adresss(es) of the host compute nodes used for this run.
1152
1153         :return: a list of 1 IP address
1154         """
1155         # Since all chains go through the same compute node(s) we can just retrieve the
1156         # compute node(s) for the first chain
1157         if self.chains:
1158             if self.config.service_chain != ChainType.EXT:
1159                 return self.chains[0].get_host_ips()
1160             # in the case of EXT, the compute node must be retrieved from the port
1161             # associated to any of the dest MACs
1162             dst_macs = self.generator_config.get_dest_macs()
1163             # dest MAC on port 0, chain 0
1164             dst_mac = dst_macs[0][0]
1165             host_ip = self.get_host_ip_from_mac(dst_mac)
1166             if host_ip:
1167                 LOG.info('Found compute node IP for EXT chain: %s', host_ip)
1168                 return [host_ip]
1169         return []
1170
1171     def get_compute_nodes(self):
1172         """Return the name of the host compute nodes used for this run.
1173
1174         :return: a list of 0 or 1 host name in the az:host format
1175         """
1176         # Since all chains go through the same compute node(s) we can just retrieve the
1177         # compute node name(s) for the first chain
1178         if self.chains:
1179             # in the case of EXT, the compute node must be retrieved from the port
1180             # associated to any of the dest MACs
1181             return self.chains[0].get_compute_nodes()
1182         # no openstack = no chains
1183         return []
1184
1185     def delete(self):
1186         """Delete resources for all chains."""
1187         for chain in self.chains:
1188             chain.delete()
1189         for network in self.networks:
1190             network.delete()
1191         if self.flavor:
1192             self.flavor.delete()