ce1b5cec4611e50cdb239860b6b4d890ba378141
[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
295             self.network = self.manager.neutron_client.create_network(body)['network']
296             body = {
297                 'subnet': {'name': network_config.subnet,
298                            'cidr': network_config.cidr,
299                            'network_id': self.network['id'],
300                            'enable_dhcp': False,
301                            'ip_version': 4,
302                            'dns_nameservers': []}
303             }
304             subnet = self.manager.neutron_client.create_subnet(body)['subnet']
305             # add subnet id to the network dict since it has just been added
306             self.network['subnets'] = [subnet['id']]
307             LOG.info('Created network: %s', self.name)
308
309     def get_uuid(self):
310         """
311         Extract UUID of this network.
312
313         :return: UUID of this network
314         """
315         return self.network['id']
316
317     def get_vlan(self):
318         """
319         Extract vlan for this network.
320
321         :return: vlan ID for this network
322         """
323         if self.network['provider:network_type'] != 'vlan':
324             raise ChainException('Trying to retrieve VLAN id for non VLAN network')
325         return self.network['provider:segmentation_id']
326
327     def get_vxlan(self):
328         """
329         Extract VNI for this network.
330
331         :return: VNI ID for this network
332         """
333         if self.network['provider:network_type'] != 'vxlan':
334             raise ChainException('Trying to retrieve VNI for non VXLAN network')
335         return self.network['provider:segmentation_id']
336
337     def delete(self):
338         """Delete this network."""
339         if not self.reuse and self.network:
340             retry = 0
341             while retry < self.manager.config.generic_retry_count:
342                 try:
343                     self.manager.neutron_client.delete_network(self.network['id'])
344                     LOG.info("Deleted network: %s", self.name)
345                     return
346                 except Exception:
347                     retry += 1
348                     LOG.info('Error deleting network %s (retry %d/%d)...',
349                              self.name,
350                              retry,
351                              self.manager.config.generic_retry_count)
352                     time.sleep(self.manager.config.generic_poll_sec)
353             LOG.error('Unable to delete network: %s', self.name)
354
355
356 class ChainVnf(object):
357     """A class to represent a VNF in a chain."""
358
359     def __init__(self, chain, vnf_id, networks):
360         """Reuse a VNF instance with same characteristics or create a new VNF instance.
361
362         chain: the chain where this vnf belongs
363         vnf_id: indicates the index of this vnf in its chain (first vnf=0)
364         networks: the list of all networks (ChainNetwork) of the current chain
365         """
366         self.manager = chain.manager
367         self.chain = chain
368         self.vnf_id = vnf_id
369         self.name = self.manager.config.loop_vm_name + str(chain.chain_id)
370         if len(networks) > 2:
371             # we will have more than 1 VM in each chain
372             self.name += '-' + str(vnf_id)
373         self.ports = []
374         self.status = None
375         self.instance = None
376         self.reuse = False
377         self.host_ip = None
378         try:
379             # the vnf_id is conveniently also the starting index in networks
380             # for the left and right networks associated to this VNF
381             self._setup(networks[vnf_id:vnf_id + 2])
382         except Exception:
383             LOG.error("Error creating VNF %s", self.name)
384             self.delete()
385             raise
386
387     def _get_vm_config(self, remote_mac_pair):
388         config = self.manager.config
389         devices = self.manager.generator_config.devices
390         with open(BOOT_SCRIPT_PATHNAME, 'r') as boot_script:
391             content = boot_script.read()
392         g1cidr = devices[LEFT].get_gw_ip(self.chain.chain_id) + '/8'
393         g2cidr = devices[RIGHT].get_gw_ip(self.chain.chain_id) + '/8'
394         vm_config = {
395             'forwarder': config.vm_forwarder,
396             'intf_mac1': self.ports[LEFT].get_mac(),
397             'intf_mac2': self.ports[RIGHT].get_mac(),
398             'tg_gateway1_ip': devices[LEFT].tg_gateway_ip_addrs,
399             'tg_gateway2_ip': devices[RIGHT].tg_gateway_ip_addrs,
400             'tg_net1': devices[LEFT].ip_addrs,
401             'tg_net2': devices[RIGHT].ip_addrs,
402             'vnf_gateway1_cidr': g1cidr,
403             'vnf_gateway2_cidr': g2cidr,
404             'tg_mac1': remote_mac_pair[0],
405             'tg_mac2': remote_mac_pair[1]
406         }
407         return content.format(**vm_config)
408
409     def _get_vnic_type(self, port_index):
410         """Get the right vnic type for given port indexself.
411
412         If SR-IOV is speficied, middle ports in multi-VNF chains
413         can use vswitch or SR-IOV based on config.use_sriov_middle_net
414         """
415         if self.manager.config.sriov:
416             chain_length = self.chain.get_length()
417             if self.manager.config.use_sriov_middle_net or chain_length == 1:
418                 return 'direct'
419             if self.vnf_id == 0 and port_index == 0:
420                 # first VNF in chain must use sriov for left port
421                 return 'direct'
422             if (self.vnf_id == chain_length - 1) and (port_index == 1):
423                 # last VNF in chain must use sriov for right port
424                 return 'direct'
425         return 'normal'
426
427     def _setup(self, networks):
428         flavor_id = self.manager.flavor.flavor.id
429         # Check if we can reuse an instance with same name
430         for instance in self.manager.existing_instances:
431             if instance.name == self.name:
432                 # Verify that other instance characteristics match
433                 if instance.flavor['id'] != flavor_id:
434                     self._reuse_exception('Flavor mismatch')
435                 if instance.status != "ACTIVE":
436                     self._reuse_exception('Matching instance is not in ACTIVE state')
437                 # The 2 networks for this instance must also be reused
438                 if not networks[LEFT].reuse:
439                     self._reuse_exception('network %s is new' % networks[LEFT].name)
440                 if not networks[RIGHT].reuse:
441                     self._reuse_exception('network %s is new' % networks[RIGHT].name)
442                 # instance.networks have the network names as keys:
443                 # {'nfvbench-rnet0': ['192.168.2.10'], 'nfvbench-lnet0': ['192.168.1.8']}
444                 if networks[LEFT].name not in instance.networks:
445                     self._reuse_exception('Left network mismatch')
446                 if networks[RIGHT].name not in instance.networks:
447                     self._reuse_exception('Right network mismatch')
448
449                 self.reuse = True
450                 self.instance = instance
451                 LOG.info('Reusing existing instance %s on %s',
452                          self.name, self.get_hypervisor_name())
453         # create or reuse/discover 2 ports per instance
454         self.ports = [ChainVnfPort(self.name + '-' + str(index),
455                                    self,
456                                    networks[index],
457                                    self._get_vnic_type(index)) for index in [0, 1]]
458         # if no reuse, actual vm creation is deferred after all ports in the chain are created
459         # since we need to know the next mac in a multi-vnf chain
460
461     def create_vnf(self, remote_mac_pair):
462         """Create the VNF instance if it does not already exist."""
463         if self.instance is None:
464             port_ids = [{'port-id': vnf_port.port['id']}
465                         for vnf_port in self.ports]
466             vm_config = self._get_vm_config(remote_mac_pair)
467             az = self.manager.placer.get_required_az()
468             server = self.manager.comp.create_server(self.name,
469                                                      self.manager.image_instance,
470                                                      self.manager.flavor.flavor,
471                                                      None,
472                                                      port_ids,
473                                                      None,
474                                                      avail_zone=az,
475                                                      user_data=None,
476                                                      config_drive=True,
477                                                      files={NFVBENCH_CFG_VM_PATHNAME: vm_config})
478             if server:
479                 self.instance = server
480                 if self.manager.placer.is_resolved():
481                     LOG.info('Created instance %s on %s', self.name, az)
482                 else:
483                     # the location is undetermined at this point
484                     # self.get_hypervisor_name() will return None
485                     LOG.info('Created instance %s - waiting for placement resolution...', self.name)
486                     # here we MUST wait until this instance is resolved otherwise subsequent
487                     # VNF creation can be placed in other hypervisors!
488                     config = self.manager.config
489                     max_retries = (config.check_traffic_time_sec +
490                                    config.generic_poll_sec - 1) / config.generic_poll_sec
491                     retry = 0
492                     for retry in range(max_retries):
493                         status = self.get_status()
494                         if status == 'ACTIVE':
495                             hyp_name = self.get_hypervisor_name()
496                             LOG.info('Instance %s is active and has been placed on %s',
497                                      self.name, hyp_name)
498                             self.manager.placer.register_full_name(hyp_name)
499                             break
500                         if status == 'ERROR':
501                             raise ChainException('Instance %s creation error: %s' %
502                                                  (self.name,
503                                                   self.instance.fault['message']))
504                         LOG.info('Waiting for instance %s to become active (retry %d/%d)...',
505                                  self.name, retry + 1, max_retries + 1)
506                         time.sleep(config.generic_poll_sec)
507                     else:
508                         # timing out
509                         LOG.error('Instance %s creation timed out', self.name)
510                         raise ChainException('Instance %s creation timed out' % self.name)
511                 self.reuse = False
512             else:
513                 raise ChainException('Unable to create instance: %s' % (self.name))
514
515     def _reuse_exception(self, reason):
516         raise ChainException('Instance %s cannot be reused (%s)' % (self.name, reason))
517
518     def get_status(self):
519         """Get the statis of this instance."""
520         if self.instance.status != 'ACTIVE':
521             self.instance = self.manager.comp.poll_server(self.instance)
522         return self.instance.status
523
524     def get_hostname(self):
525         """Get the hypervisor host name running this VNF instance."""
526         return getattr(self.instance, 'OS-EXT-SRV-ATTR:hypervisor_hostname')
527
528     def get_host_ip(self):
529         """Get the IP address of the host where this instance runs.
530
531         return: the IP address
532         """
533         if not self.host_ip:
534             self.host_ip = self.manager.comp.get_hypervisor(self.get_hostname()).host_ip
535         return self.host_ip
536
537     def get_hypervisor_name(self):
538         """Get hypervisor name (az:hostname) for this VNF instance."""
539         if self.instance:
540             az = getattr(self.instance, 'OS-EXT-AZ:availability_zone')
541             hostname = self.get_hostname()
542             if az:
543                 return az + ':' + hostname
544             return hostname
545         return None
546
547     def get_uuid(self):
548         """Get the uuid for this instance."""
549         return self.instance.id
550
551     def delete(self, forced=False):
552         """Delete this VNF instance."""
553         if self.reuse:
554             LOG.info("Instance %s not deleted (reused)", self.name)
555         else:
556             if self.instance:
557                 self.manager.comp.delete_server(self.instance)
558                 LOG.info("Deleted instance %s", self.name)
559             for port in self.ports:
560                 port.delete()
561
562 class Chain(object):
563     """A class to manage a single chain.
564
565     Can handle any type of chain (EXT, PVP, PVVP)
566     """
567
568     def __init__(self, chain_id, manager):
569         """Create a new chain.
570
571         chain_id: chain index (first chain is 0)
572         manager: the chain manager that owns all chains
573         """
574         self.chain_id = chain_id
575         self.manager = manager
576         self.encaps = manager.encaps
577         self.networks = []
578         self.instances = []
579         try:
580             self.networks = manager.get_networks(chain_id)
581             # For external chain VNFs can only be discovered from their MAC addresses
582             # either from config or from ARP
583             if manager.config.service_chain != ChainType.EXT:
584                 for chain_instance_index in range(self.get_length()):
585                     self.instances.append(ChainVnf(self,
586                                                    chain_instance_index,
587                                                    self.networks))
588                 # at this point new VNFs are not created yet but
589                 # verify that all discovered VNFs are on the same hypervisor
590                 self._check_hypervisors()
591                 # now that all VNF ports are created we need to calculate the
592                 # left/right remote MAC for each VNF in the chain
593                 # before actually creating the VNF itself
594                 rem_mac_pairs = self._get_remote_mac_pairs()
595                 for instance in self.instances:
596                     rem_mac_pair = rem_mac_pairs.pop(0)
597                     instance.create_vnf(rem_mac_pair)
598         except Exception:
599             self.delete()
600             raise
601
602     def _check_hypervisors(self):
603         common_hypervisor = None
604         for instance in self.instances:
605             # get the full hypervizor name (az:compute)
606             hname = instance.get_hypervisor_name()
607             if hname:
608                 if common_hypervisor:
609                     if hname != common_hypervisor:
610                         raise ChainException('Discovered instances on different hypervisors:'
611                                              ' %s and %s' % (hname, common_hypervisor))
612                 else:
613                     common_hypervisor = hname
614         if common_hypervisor:
615             # check that the common hypervisor name matchs the requested hypervisor name
616             # and set the name to be used by all future instances (if any)
617             if not self.manager.placer.register_full_name(common_hypervisor):
618                 raise ChainException('Discovered hypervisor placement %s is incompatible' %
619                                      common_hypervisor)
620
621     def get_length(self):
622         """Get the number of VNF in the chain."""
623         return len(self.networks) - 1
624
625     def _get_remote_mac_pairs(self):
626         """Get the list of remote mac pairs for every VNF in the chain.
627
628         Traverse the chain from left to right and establish the
629         left/right remote MAC for each VNF in the chainself.
630
631         PVP case is simpler:
632         mac sequence: tg_src_mac, vm0-mac0, vm0-mac1, tg_dst_mac
633         must produce [[tg_src_mac, tg_dst_mac]] or looking at index in mac sequence: [[0, 3]]
634         the mac pair is what the VNF at that position (index 0) sees as next hop mac left and right
635
636         PVVP:
637         tg_src_mac, vm0-mac0, vm0-mac1, vm1-mac0, vm1-mac1, tg_dst_mac
638         Must produce the following list:
639         [[tg_src_mac, vm1-mac0], [vm0-mac1, tg_dst_mac]] or index: [[0, 3], [2, 5]]
640
641         General case with 3 VMs in chain, the list of consecutive macs (left to right):
642         tg_src_mac, vm0-mac0, vm0-mac1, vm1-mac0, vm1-mac1, vm2-mac0, vm2-mac1, tg_dst_mac
643         Must produce the following list:
644         [[tg_src_mac, vm1-mac0], [vm0-mac1, vm2-mac0], [vm1-mac1, tg_dst_mac]]
645         or index: [[0, 3], [2, 5], [4, 7]]
646
647         The series pattern is pretty clear: [[n, n+3],... ] where n is multiple of 2
648         """
649         # line up all mac from left to right
650         mac_seq = [self.manager.generator_config.devices[LEFT].mac]
651         for instance in self.instances:
652             mac_seq.append(instance.ports[0].get_mac())
653             mac_seq.append(instance.ports[1].get_mac())
654         mac_seq.append(self.manager.generator_config.devices[RIGHT].mac)
655         base = 0
656         rem_mac_pairs = []
657         for _ in self.instances:
658             rem_mac_pairs.append([mac_seq[base], mac_seq[base + 3]])
659             base += 2
660         return rem_mac_pairs
661
662     def get_instances(self):
663         """Return all instances for this chain."""
664         return self.instances
665
666     def get_vlan(self, port_index):
667         """Get the VLAN id on a given port.
668
669         port_index: left port is 0, right port is 1
670         return: the vlan_id or None if there is no vlan tagging
671         """
672         # for port 1 we need to return the VLAN of the last network in the chain
673         # The networks array contains 2 networks for PVP [left, right]
674         # and 3 networks in the case of PVVP [left.middle,right]
675         if port_index:
676             # this will pick the last item in array
677             port_index = -1
678         return self.networks[port_index].get_vlan()
679
680     def get_vxlan(self, port_index):
681         """Get the VXLAN id on a given port.
682
683         port_index: left port is 0, right port is 1
684         return: the vxlan_id or None if there is no vxlan
685         """
686         # for port 1 we need to return the VLAN of the last network in the chain
687         # The networks array contains 2 networks for PVP [left, right]
688         # and 3 networks in the case of PVVP [left.middle,right]
689         if port_index:
690             # this will pick the last item in array
691             port_index = -1
692         return self.networks[port_index].get_vxlan()
693
694     def get_dest_mac(self, port_index):
695         """Get the dest MAC on a given port.
696
697         port_index: left port is 0, right port is 1
698         return: the dest MAC
699         """
700         if port_index:
701             # for right port, use the right port MAC of the last (right most) VNF In chain
702             return self.instances[-1].ports[1].get_mac()
703         # for left port use the left port MAC of the first (left most) VNF in chain
704         return self.instances[0].ports[0].get_mac()
705
706     def get_network_uuids(self):
707         """Get UUID of networks in this chain from left to right (order is important).
708
709         :return: list of UUIDs of networks (2 or 3 elements)
710         """
711         return [net['id'] for net in self.networks]
712
713     def get_host_ips(self):
714         """Return the IP adresss(es) of the host compute nodes used for this chain.
715
716         :return: a list of 1 or 2 IP addresses
717         """
718         return [vnf.get_host_ip() for vnf in self.instances]
719
720     def get_compute_nodes(self):
721         """Return the name of the host compute nodes used for this chain.
722
723         :return: a list of 1 host name in the az:host format
724         """
725         # Since all chains go through the same compute node(s) we can just retrieve the
726         # compute node name(s) for the first chain
727         return [vnf.get_hypervisor_name() for vnf in self.instances]
728
729     def delete(self):
730         """Delete this chain."""
731         for instance in self.instances:
732             instance.delete()
733         # only delete if these are chain private networks (not shared)
734         if not self.manager.config.service_chain_shared_net:
735             for network in self.networks:
736                 network.delete()
737
738
739 class InstancePlacer(object):
740     """A class to manage instance placement for all VNFs in all chains.
741
742     A full az string is made of 2 parts AZ and hypervisor.
743     The placement is resolved when both parts az and hypervisor names are known.
744     """
745
746     def __init__(self, req_az, req_hyp):
747         """Create a new instance placer.
748
749         req_az: requested AZ (can be None or empty if no preference)
750         req_hyp: requested hypervisor name (can be None of empty if no preference)
751                  can be any of 'nova:', 'comp1', 'nova:comp1'
752                  if it is a list, only the first item is used (backward compatibility in config)
753
754         req_az is ignored if req_hyp has an az part
755         all other parts beyond the first 2 are ignored in req_hyp
756         """
757         # if passed a list just pick the first item
758         if req_hyp and isinstance(req_hyp, list):
759             req_hyp = req_hyp[0]
760         # only pick first part of az
761         if req_az and ':' in req_az:
762             req_az = req_az.split(':')[0]
763         if req_hyp:
764             # check if requested hypervisor string has an AZ part
765             split_hyp = req_hyp.split(':')
766             if len(split_hyp) > 1:
767                 # override the AZ part and hypervisor part
768                 req_az = split_hyp[0]
769                 req_hyp = split_hyp[1]
770         self.requested_az = req_az if req_az else ''
771         self.requested_hyp = req_hyp if req_hyp else ''
772         # Nova can accept AZ only (e.g. 'nova:', use any hypervisor in that AZ)
773         # or hypervisor only (e.g. ':comp1')
774         # or both (e.g. 'nova:comp1')
775         if req_az:
776             self.required_az = req_az + ':' + self.requested_hyp
777         else:
778             # need to insert a ':' so nova knows this is the hypervisor name
779             self.required_az = ':' + self.requested_hyp if req_hyp else ''
780         # placement is resolved when both AZ and hypervisor names are known and set
781         self.resolved = self.requested_az != '' and self.requested_hyp != ''
782
783     def get_required_az(self):
784         """Return the required az (can be resolved or not)."""
785         return self.required_az
786
787     def register_full_name(self, discovered_az):
788         """Verify compatibility and register a discovered hypervisor full name.
789
790         discovered_az: a discovered AZ in az:hypervisor format
791         return: True if discovered_az is compatible and set
792                 False if discovered_az is not compatible
793         """
794         if self.resolved:
795             return discovered_az == self.required_az
796
797         # must be in full az format
798         split_daz = discovered_az.split(':')
799         if len(split_daz) != 2:
800             return False
801         if self.requested_az and self.requested_az != split_daz[0]:
802             return False
803         if self.requested_hyp and self.requested_hyp != split_daz[1]:
804             return False
805         self.required_az = discovered_az
806         self.resolved = True
807         return True
808
809     def is_resolved(self):
810         """Check if the full AZ is resolved.
811
812         return: True if resolved
813         """
814         return self.resolved
815
816
817 class ChainManager(object):
818     """A class for managing all chains for a given run.
819
820     Supports openstack or no openstack.
821     Supports EXT, PVP and PVVP chains.
822     """
823
824     def __init__(self, chain_runner):
825         """Create a chain manager to take care of discovering or bringing up the requested chains.
826
827         A new instance must be created every time a new config is used.
828         config: the nfvbench config to use
829         cred: openstack credentials to use of None if there is no openstack
830         """
831         self.chain_runner = chain_runner
832         self.config = chain_runner.config
833         self.generator_config = chain_runner.traffic_client.generator_config
834         self.chains = []
835         self.image_instance = None
836         self.image_name = None
837         # Left and right networks shared across all chains (only if shared)
838         self.networks = []
839         self.encaps = None
840         self.flavor = None
841         self.comp = None
842         self.nova_client = None
843         self.neutron_client = None
844         self.glance_client = None
845         self.existing_instances = []
846         # existing ports keyed by the network uuid they belong to
847         self._existing_ports = {}
848         config = self.config
849         self.openstack = (chain_runner.cred is not None) and not config.l2_loopback
850         self.chain_count = config.service_chain_count
851         self.az = None
852         if self.openstack:
853             # openstack only
854             session = chain_runner.cred.get_session()
855             self.nova_client = Client(2, session=session)
856             self.neutron_client = neutronclient.Client('2.0', session=session)
857             self.glance_client = glanceclient.Client('2', session=session)
858             self.comp = compute.Compute(self.nova_client,
859                                         self.glance_client,
860                                         config)
861             try:
862                 if config.service_chain != ChainType.EXT:
863                     self.placer = InstancePlacer(config.availability_zone, config.compute_nodes)
864                     self._setup_image()
865                     self.flavor = ChainFlavor(config.flavor_type, config.flavor, self.comp)
866                     # Get list of all existing instances to check if some instances can be reused
867                     self.existing_instances = self.comp.get_server_list()
868                 # If networks are shared across chains, get the list of networks
869                 if config.service_chain_shared_net:
870                     self.networks = self.get_networks()
871                 # Reuse/create chains
872                 for chain_id in range(self.chain_count):
873                     self.chains.append(Chain(chain_id, self))
874                 if config.service_chain == ChainType.EXT:
875                     # if EXT and no ARP we need to read dest MACs from config
876                     if config.no_arp:
877                         self._get_dest_macs_from_config()
878                 else:
879                     # Make sure all instances are active before proceeding
880                     self._ensure_instances_active()
881             except Exception:
882                 self.delete()
883                 raise
884         else:
885             # no openstack, no need to create chains
886
887             if not config.l2_loopback and config.no_arp:
888                 self._get_dest_macs_from_config()
889             if config.vlan_tagging:
890                 # make sure there at least as many entries as chains in each left/right list
891                 if len(config.vlans) != 2:
892                     raise ChainException('The config vlans property must be a list '
893                                          'with 2 lists of VLAN IDs')
894                 re_vlan = "[0-9]*$"
895                 self.vlans = [self._check_list('vlans[0]', config.vlans[0], re_vlan),
896                               self._check_list('vlans[1]', config.vlans[1], re_vlan)]
897             if config.vxlan:
898                 raise ChainException('VxLAN is only supported with OpenStack')
899
900     def _get_dest_macs_from_config(self):
901         re_mac = "[0-9a-fA-F]{2}([-:])[0-9a-fA-F]{2}(\\1[0-9a-fA-F]{2}){4}$"
902         tg_config = self.config.traffic_generator
903         self.dest_macs = [self._check_list("mac_addrs_left",
904                                            tg_config.mac_addrs_left, re_mac),
905                           self._check_list("mac_addrs_right",
906                                            tg_config.mac_addrs_right, re_mac)]
907
908     def _check_list(self, list_name, ll, pattern):
909         # if it is a single int or mac, make it a list of 1 int
910         if isinstance(ll, (int, str)):
911             ll = [ll]
912         if not ll or len(ll) < self.chain_count:
913             raise ChainException('%s=%s must be a list with %d elements per chain' %
914                                  (list_name, ll, self.chain_count))
915         for item in ll:
916             if not re.match(pattern, str(item)):
917                 raise ChainException("Invalid format '{item}' specified in {fname}"
918                                      .format(item=item, fname=list_name))
919         return ll
920
921     def _setup_image(self):
922         # To avoid reuploading image in server mode, check whether image_name is set or not
923         if self.image_name:
924             self.image_instance = self.comp.find_image(self.image_name)
925         if self.image_instance:
926             LOG.info("Reusing image %s", self.image_name)
927         else:
928             image_name_search_pattern = r'(nfvbenchvm-\d+(\.\d+)*).qcow2'
929             if self.config.vm_image_file:
930                 match = re.search(image_name_search_pattern, self.config.vm_image_file)
931                 if match:
932                     self.image_name = match.group(1)
933                     LOG.info('Using provided VM image file %s', self.config.vm_image_file)
934                 else:
935                     raise ChainException('Provided VM image file name %s must start with '
936                                          '"nfvbenchvm-<version>"' % self.config.vm_image_file)
937             else:
938                 pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
939                 for f in os.listdir(pkg_root):
940                     if re.search(image_name_search_pattern, f):
941                         self.config.vm_image_file = pkg_root + '/' + f
942                         self.image_name = f.replace('.qcow2', '')
943                         LOG.info('Found built-in VM image file %s', f)
944                         break
945                 else:
946                     raise ChainException('Cannot find any built-in VM image file.')
947             if self.image_name:
948                 self.image_instance = self.comp.find_image(self.image_name)
949             if not self.image_instance:
950                 LOG.info('Uploading %s', self.image_name)
951                 res = self.comp.upload_image_via_url(self.image_name,
952                                                      self.config.vm_image_file)
953
954                 if not res:
955                     raise ChainException('Error uploading image %s from %s. ABORTING.' %
956                                          (self.image_name, self.config.vm_image_file))
957                 LOG.info('Image %s successfully uploaded.', self.image_name)
958                 self.image_instance = self.comp.find_image(self.image_name)
959
960     def _ensure_instances_active(self):
961         instances = []
962         for chain in self.chains:
963             instances.extend(chain.get_instances())
964         initial_instance_count = len(instances)
965         max_retries = (self.config.check_traffic_time_sec +
966                        self.config.generic_poll_sec - 1) / self.config.generic_poll_sec
967         retry = 0
968         while instances:
969             remaining_instances = []
970             for instance in instances:
971                 status = instance.get_status()
972                 if status == 'ACTIVE':
973                     LOG.info('Instance %s is ACTIVE on %s',
974                              instance.name, instance.get_hypervisor_name())
975                     continue
976                 if status == 'ERROR':
977                     raise ChainException('Instance %s creation error: %s' %
978                                          (instance.name,
979                                           instance.instance.fault['message']))
980                 remaining_instances.append(instance)
981             if not remaining_instances:
982                 break
983             retry += 1
984             if retry >= max_retries:
985                 raise ChainException('Time-out: %d/%d instances still not active' %
986                                      (len(remaining_instances), initial_instance_count))
987             LOG.info('Waiting for %d/%d instance to become active (retry %d/%d)...',
988                      len(remaining_instances), initial_instance_count,
989                      retry, max_retries)
990             instances = remaining_instances
991             time.sleep(self.config.generic_poll_sec)
992         if initial_instance_count:
993             LOG.info('All instances are active')
994
995     def get_networks(self, chain_id=None):
996         """Get the networks for given EXT, PVP or PVVP chain.
997
998         For EXT packet path, these networks must pre-exist.
999         For PVP, PVVP these networks will be created if they do not exist.
1000         chain_id: to which chain the networks belong.
1001                   a None value will mean that these networks are shared by all chains
1002         """
1003         if self.networks:
1004             # the only case where self.networks exists is when the networks are shared
1005             # across all chains
1006             return self.networks
1007         if self.config.service_chain == ChainType.EXT:
1008             lookup_only = True
1009             ext_net = self.config.external_networks
1010             net_cfg = [AttrDict({'name': name,
1011                                  'segmentation_id': None,
1012                                  'physical_network': None})
1013                        for name in [ext_net.left, ext_net.right]]
1014             # segmentation id and subnet should be discovered from neutron
1015         else:
1016             lookup_only = False
1017             int_nets = self.config.internal_networks
1018             # VLAN and VxLAN
1019             if self.config.service_chain == ChainType.PVP:
1020                 net_cfg = [int_nets.left, int_nets.right]
1021             else:
1022                 net_cfg = [int_nets.left, int_nets.middle, int_nets.right]
1023         networks = []
1024         try:
1025             for cfg in net_cfg:
1026                 networks.append(ChainNetwork(self, cfg, chain_id, lookup_only=lookup_only))
1027         except Exception:
1028             # need to cleanup all successful networks prior to bailing out
1029             for net in networks:
1030                 net.delete()
1031             raise
1032         return networks
1033
1034     def get_existing_ports(self):
1035         """Get the list of existing ports.
1036
1037         Lazy retrieval of ports as this can be costly if there are lots of ports and
1038         is only needed when VM and network are being reused.
1039
1040         return: a dict of list of neutron ports indexed by the network uuid they are attached to
1041
1042         Each port is a dict with fields such as below:
1043         {'allowed_address_pairs': [], 'extra_dhcp_opts': [],
1044          'updated_at': '2018-10-06T07:15:35Z', 'device_owner': 'compute:nova',
1045          'revision_number': 10, 'port_security_enabled': False, 'binding:profile': {},
1046          'fixed_ips': [{'subnet_id': '6903a3b3-49a1-4ba4-8259-4a90e7a44b21',
1047          'ip_address': '192.168.1.4'}], 'id': '3dcb9cfa-d82a-4dd1-85a1-fd8284b52d72',
1048          'security_groups': [],
1049          'binding:vif_details': {'vhostuser_socket': '/tmp/3dcb9cfa-d82a-4dd1-85a1-fd8284b52d72',
1050                                  'vhostuser_mode': 'server'},
1051          'binding:vif_type': 'vhostuser',
1052          'mac_address': 'fa:16:3e:3c:63:04',
1053          'project_id': '977ac76a63d7492f927fa80e86baff4c',
1054          'status': 'ACTIVE',
1055          'binding:host_id': 'a20-champagne-compute-1',
1056          'description': '',
1057          'device_id': 'a98e2ad2-5371-4aa5-a356-8264a970ce4b',
1058          'name': 'nfvbench-loop-vm0-0', 'admin_state_up': True,
1059          'network_id': '3ea5fd88-278f-4d9d-b24d-1e443791a055',
1060          'tenant_id': '977ac76a63d7492f927fa80e86baff4c',
1061          'created_at': '2018-10-06T07:15:10Z',
1062          'binding:vnic_type': 'normal'}
1063         """
1064         if not self._existing_ports:
1065             LOG.info('Loading list of all ports...')
1066             existing_ports = self.neutron_client.list_ports()['ports']
1067             # place all ports in the dict keyed by the port network uuid
1068             for port in existing_ports:
1069                 port_list = self._existing_ports.setdefault(port['network_id'], [])
1070                 port_list.append(port)
1071             LOG.info("Loaded %d ports attached to %d networks",
1072                      len(existing_ports), len(self._existing_ports))
1073         return self._existing_ports
1074
1075     def get_ports_from_network(self, chain_network):
1076         """Get the list of existing ports that belong to a network.
1077
1078         Lazy retrieval of ports as this can be costly if there are lots of ports and
1079         is only needed when VM and network are being reused.
1080
1081         chain_network: a ChainNetwork instance for which attached ports neeed to be retrieved
1082         return: list of neutron ports attached to requested network
1083         """
1084         return self.get_existing_ports().get(chain_network.get_uuid(), None)
1085
1086     def get_host_ip_from_mac(self, mac):
1087         """Get the host IP address matching a MAC.
1088
1089         mac: MAC address to look for
1090         return: the IP address of the host where the matching port runs or None if not found
1091         """
1092         # _existing_ports is a dict of list of ports indexed by network id
1093         for port_list in self.get_existing_ports().values():
1094             for port in port_list:
1095                 try:
1096                     if port['mac_address'] == mac:
1097                         host_id = port['binding:host_id']
1098                         return self.comp.get_hypervisor(host_id).host_ip
1099                 except KeyError:
1100                     pass
1101         return None
1102
1103     def get_chain_vlans(self, port_index):
1104         """Get the list of per chain VLAN id on a given port.
1105
1106         port_index: left port is 0, right port is 1
1107         return: a VLAN ID list indexed by the chain index or None if no vlan tagging
1108         """
1109         if self.chains:
1110             return [self.chains[chain_index].get_vlan(port_index)
1111                     for chain_index in range(self.chain_count)]
1112         # no openstack
1113         return self.vlans[port_index]
1114
1115     def get_chain_vxlans(self, port_index):
1116         """Get the list of per chain VNIs id on a given port.
1117
1118         port_index: left port is 0, right port is 1
1119         return: a VNIs ID list indexed by the chain index or None if no vlan tagging
1120         """
1121         if self.chains:
1122             return [self.chains[chain_index].get_vxlan(port_index)
1123                     for chain_index in range(self.chain_count)]
1124         # no openstack
1125         raise ChainException('VxLAN is only supported with OpenStack')
1126
1127     def get_dest_macs(self, port_index):
1128         """Get the list of per chain dest MACs on a given port.
1129
1130         Should not be called if EXT+ARP is used (in that case the traffic gen will
1131         have the ARP responses back from VNFs with the dest MAC to use).
1132
1133         port_index: left port is 0, right port is 1
1134         return: a list of dest MACs indexed by the chain index
1135         """
1136         if self.chains and self.config.service_chain != ChainType.EXT:
1137             return [self.chains[chain_index].get_dest_mac(port_index)
1138                     for chain_index in range(self.chain_count)]
1139         # no openstack or EXT+no-arp
1140         return self.dest_macs[port_index]
1141
1142     def get_host_ips(self):
1143         """Return the IP adresss(es) of the host compute nodes used for this run.
1144
1145         :return: a list of 1 IP address
1146         """
1147         # Since all chains go through the same compute node(s) we can just retrieve the
1148         # compute node(s) for the first chain
1149         if self.chains:
1150             if self.config.service_chain != ChainType.EXT:
1151                 return self.chains[0].get_host_ips()
1152             # in the case of EXT, the compute node must be retrieved from the port
1153             # associated to any of the dest MACs
1154             dst_macs = self.generator_config.get_dest_macs()
1155             # dest MAC on port 0, chain 0
1156             dst_mac = dst_macs[0][0]
1157             host_ip = self.get_host_ip_from_mac(dst_mac)
1158             if host_ip:
1159                 LOG.info('Found compute node IP for EXT chain: %s', host_ip)
1160                 return [host_ip]
1161         return []
1162
1163     def get_compute_nodes(self):
1164         """Return the name of the host compute nodes used for this run.
1165
1166         :return: a list of 0 or 1 host name in the az:host format
1167         """
1168         # Since all chains go through the same compute node(s) we can just retrieve the
1169         # compute node name(s) for the first chain
1170         if self.chains:
1171             # in the case of EXT, the compute node must be retrieved from the port
1172             # associated to any of the dest MACs
1173             return self.chains[0].get_compute_nodes()
1174         # no openstack = no chains
1175         return []
1176
1177     def delete(self):
1178         """Delete resources for all chains."""
1179         for chain in self.chains:
1180             chain.delete()
1181         for network in self.networks:
1182             network.delete()
1183         if self.flavor:
1184             self.flavor.delete()