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