aaf850c1d3473220e6155b822131ca91fd78e799
[yardstick.git] / yardstick / benchmark / scenarios / networking / vnf_generic.py
1 # Copyright (c) 2016-2017 Intel Corporation
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 #      http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14 """ NSPerf specific scenario definition """
15
16 from __future__ import absolute_import
17
18 import logging
19 import errno
20
21 import ipaddress
22 import os
23 import sys
24 import re
25 from itertools import chain
26
27 import six
28 from operator import itemgetter
29 from collections import defaultdict
30
31 from yardstick.benchmark.scenarios import base
32 from yardstick.common.utils import import_modules_from_package, itersubclasses
33 from yardstick.common.yaml_loader import yaml_load
34 from yardstick.network_services.collector.subscriber import Collector
35 from yardstick.network_services.vnf_generic import vnfdgen
36 from yardstick.network_services.vnf_generic.vnf.base import GenericVNF
37 from yardstick.network_services.traffic_profile.base import TrafficProfile
38 from yardstick.network_services.utils import get_nsb_option
39 from yardstick import ssh
40
41
42 LOG = logging.getLogger(__name__)
43
44
45 class SSHError(Exception):
46     """Class handles ssh connection error exception"""
47     pass
48
49
50 class SSHTimeout(SSHError):
51     """Class handles ssh connection timeout exception"""
52     pass
53
54
55 class IncorrectConfig(Exception):
56     """Class handles incorrect configuration during setup"""
57     pass
58
59
60 class IncorrectSetup(Exception):
61     """Class handles incorrect setup during setup"""
62     pass
63
64
65 class SshManager(object):
66     def __init__(self, node):
67         super(SshManager, self).__init__()
68         self.node = node
69         self.conn = None
70
71     def __enter__(self):
72         """
73         args -> network device mappings
74         returns -> ssh connection ready to be used
75         """
76         try:
77             self.conn = ssh.SSH.from_node(self.node)
78             self.conn.wait()
79         except SSHError as error:
80             LOG.info("connect failed to %s, due to %s", self.node["ip"], error)
81         # self.conn defaults to None
82         return self.conn
83
84     def __exit__(self, exc_type, exc_val, exc_tb):
85         if self.conn:
86             self.conn.close()
87
88
89 def find_relative_file(path, task_path):
90     """
91     Find file in one of places: in abs of path or
92     relative to TC scenario file. In this order.
93
94     :param path:
95     :param task_path:
96     :return str: full path to file
97     """
98     # fixme: create schema to validate all fields have been provided
99     for lookup in [os.path.abspath(path), os.path.join(task_path, path)]:
100         try:
101             with open(lookup):
102                 return lookup
103         except IOError:
104             pass
105     raise IOError(errno.ENOENT, 'Unable to find {} file'.format(path))
106
107
108 def open_relative_file(path, task_path):
109     try:
110         return open(path)
111     except IOError as e:
112         if e.errno == errno.ENOENT:
113             return open(os.path.join(task_path, path))
114         raise
115
116
117 class NetworkServiceTestCase(base.Scenario):
118     """Class handles Generic framework to do pre-deployment VNF &
119        Network service testing  """
120
121     __scenario_type__ = "NSPerf"
122
123     def __init__(self, scenario_cfg, context_cfg):  # Yardstick API
124         super(NetworkServiceTestCase, self).__init__()
125         self.scenario_cfg = scenario_cfg
126         self.context_cfg = context_cfg
127
128         # fixme: create schema to validate all fields have been provided
129         with open_relative_file(scenario_cfg["topology"],
130                                 scenario_cfg['task_path']) as stream:
131             topology_yaml = yaml_load(stream)
132
133         self.topology = topology_yaml["nsd:nsd-catalog"]["nsd"][0]
134         self.vnfs = []
135         self.collector = None
136         self.traffic_profile = None
137
138     def _get_ip_flow_range(self, ip_start_range):
139
140         node_name, range_or_interface = next(iter(ip_start_range.items()), (None, '0.0.0.0'))
141         if node_name is not None:
142             node = self.context_cfg["nodes"].get(node_name, {})
143             try:
144                 # the ip_range is the interface name
145                 interface = node.get("interfaces", {})[range_or_interface]
146             except KeyError:
147                 ip = "0.0.0.0"
148                 mask = "255.255.255.0"
149             else:
150                 ip = interface["local_ip"]
151                 # we can't default these values, they must both exist to be valid
152                 mask = interface["netmask"]
153
154             ipaddr = ipaddress.ip_network(six.text_type('{}/{}'.format(ip, mask)), strict=False)
155             hosts = list(ipaddr.hosts())
156             if len(hosts) > 2:
157                 # skip the first host in case of gateway
158                 ip_addr_range = "{}-{}".format(hosts[1], hosts[-1])
159             else:
160                 LOG.warning("Only single IP in range %s", ipaddr)
161                 # fall back to single IP range
162                 ip_addr_range = ip
163         else:
164             # we are manually specifying the range
165             ip_addr_range = range_or_interface
166         return ip_addr_range
167
168     def _get_traffic_flow(self):
169         flow = {}
170         try:
171             fflow = self.scenario_cfg["options"]["flow"]
172             for index, src in enumerate(fflow.get("src_ip", [])):
173                 flow["src_ip{}".format(index)] = self._get_ip_flow_range(src)
174
175             for index, dst in enumerate(fflow.get("dst_ip", [])):
176                 flow["dst_ip{}".format(index)] = self._get_ip_flow_range(dst)
177
178             for index, publicip in enumerate(fflow.get("publicip", [])):
179                 flow["public_ip{}".format(index)] = publicip
180         except KeyError:
181             flow = {}
182         return {"flow": flow}
183
184     def _get_traffic_imix(self):
185         try:
186             imix = {"imix": self.scenario_cfg['options']['framesize']}
187         except KeyError:
188             imix = {}
189         return imix
190
191     def _get_traffic_profile(self):
192         profile = self.scenario_cfg["traffic_profile"]
193         path = self.scenario_cfg["task_path"]
194         with open_relative_file(profile, path) as infile:
195             return infile.read()
196
197     def _fill_traffic_profile(self):
198         traffic_mapping = self._get_traffic_profile()
199         traffic_map_data = {
200             'flow': self._get_traffic_flow(),
201             'imix': self._get_traffic_imix(),
202             'private': {},
203             'public': {},
204         }
205
206         traffic_vnfd = vnfdgen.generate_vnfd(traffic_mapping, traffic_map_data)
207         self.traffic_profile = TrafficProfile.get(traffic_vnfd)
208         return self.traffic_profile
209
210     def _find_vnf_name_from_id(self, vnf_id):
211         return next((vnfd["vnfd-id-ref"]
212                      for vnfd in self.topology["constituent-vnfd"]
213                      if vnf_id == vnfd["member-vnf-index"]), None)
214
215     @staticmethod
216     def get_vld_networks(networks):
217         return {n['vld_id']: n for n in networks.values()}
218
219     def _resolve_topology(self):
220         for vld in self.topology["vld"]:
221             try:
222                 node0_data, node1_data = vld["vnfd-connection-point-ref"]
223             except (ValueError, TypeError):
224                 raise IncorrectConfig("Topology file corrupted, "
225                                       "wrong endpoint count for connection")
226
227             node0_name = self._find_vnf_name_from_id(node0_data["member-vnf-index-ref"])
228             node1_name = self._find_vnf_name_from_id(node1_data["member-vnf-index-ref"])
229
230             node0_if_name = node0_data["vnfd-connection-point-ref"]
231             node1_if_name = node1_data["vnfd-connection-point-ref"]
232
233             try:
234                 nodes = self.context_cfg["nodes"]
235                 node0_if = nodes[node0_name]["interfaces"][node0_if_name]
236                 node1_if = nodes[node1_name]["interfaces"][node1_if_name]
237
238                 # names so we can do reverse lookups
239                 node0_if["ifname"] = node0_if_name
240                 node1_if["ifname"] = node1_if_name
241
242                 node0_if["node_name"] = node0_name
243                 node1_if["node_name"] = node1_name
244
245                 vld_networks = self.get_vld_networks(self.context_cfg["networks"])
246                 node0_if["vld_id"] = vld["id"]
247                 node1_if["vld_id"] = vld["id"]
248
249                 # set peer name
250                 node0_if["peer_name"] = node1_name
251                 node1_if["peer_name"] = node0_name
252
253                 # set peer interface name
254                 node0_if["peer_ifname"] = node1_if_name
255                 node1_if["peer_ifname"] = node0_if_name
256
257                 # just load the network
258                 node0_if["network"] = vld_networks.get(vld["id"], {})
259                 node1_if["network"] = vld_networks.get(vld["id"], {})
260
261                 node0_if["dst_mac"] = node1_if["local_mac"]
262                 node0_if["dst_ip"] = node1_if["local_ip"]
263
264                 node1_if["dst_mac"] = node0_if["local_mac"]
265                 node1_if["dst_ip"] = node0_if["local_ip"]
266
267             except KeyError:
268                 LOG.exception("")
269                 raise IncorrectConfig("Required interface not found, "
270                                       "topology file corrupted")
271
272         for vld in self.topology['vld']:
273             try:
274                 node0_data, node1_data = vld["vnfd-connection-point-ref"]
275             except (ValueError, TypeError):
276                 raise IncorrectConfig("Topology file corrupted, "
277                                       "wrong endpoint count for connection")
278
279             node0_name = self._find_vnf_name_from_id(node0_data["member-vnf-index-ref"])
280             node1_name = self._find_vnf_name_from_id(node1_data["member-vnf-index-ref"])
281
282             node0_if_name = node0_data["vnfd-connection-point-ref"]
283             node1_if_name = node1_data["vnfd-connection-point-ref"]
284
285             nodes = self.context_cfg["nodes"]
286             node0_if = nodes[node0_name]["interfaces"][node0_if_name]
287             node1_if = nodes[node1_name]["interfaces"][node1_if_name]
288
289             # add peer interface dict, but remove circular link
290             # TODO: don't waste memory
291             node0_copy = node0_if.copy()
292             node1_copy = node1_if.copy()
293             node0_if["peer_intf"] = node1_copy
294             node1_if["peer_intf"] = node0_copy
295
296     def _find_vnfd_from_vnf_idx(self, vnf_idx):
297         return next((vnfd for vnfd in self.topology["constituent-vnfd"]
298                      if vnf_idx == vnfd["member-vnf-index"]), None)
299
300     def _update_context_with_topology(self):
301         for vnfd in self.topology["constituent-vnfd"]:
302             vnf_idx = vnfd["member-vnf-index"]
303             vnf_name = self._find_vnf_name_from_id(vnf_idx)
304             vnfd = self._find_vnfd_from_vnf_idx(vnf_idx)
305             self.context_cfg["nodes"][vnf_name].update(vnfd)
306
307     @staticmethod
308     def _sort_dpdk_port_num(netdevs):
309         # dpdk_port_num is PCI BUS ID ordering, lowest first
310         s = sorted(netdevs.values(), key=itemgetter('pci_bus_id'))
311         for dpdk_port_num, netdev in enumerate(s):
312             netdev['dpdk_port_num'] = dpdk_port_num
313
314     def _probe_netdevs(self, node, node_dict):
315         cmd = "PATH=$PATH:/sbin:/usr/sbin ip addr show"
316         netdevs = {}
317         with SshManager(node_dict) as conn:
318             if conn:
319                 exit_status = conn.execute(cmd)[0]
320                 if exit_status != 0:
321                     raise IncorrectSetup("Node's %s lacks ip tool." % node)
322                 exit_status, stdout, _ = conn.execute(
323                     self.FIND_NETDEVICE_STRING)
324                 if exit_status != 0:
325                     raise IncorrectSetup(
326                         "Cannot find netdev info in sysfs" % node)
327                 netdevs = node_dict['netdevs'] = self.parse_netdev_info(stdout)
328         return netdevs
329
330     @classmethod
331     def _probe_missing_values(cls, netdevs, network):
332
333         mac_lower = network['local_mac'].lower()
334         for netdev in netdevs.values():
335             if netdev['address'].lower() != mac_lower:
336                 continue
337             network.update({
338                 'driver': netdev['driver'],
339                 'vpci': netdev['pci_bus_id'],
340                 'ifindex': netdev['ifindex'],
341             })
342
343     TOPOLOGY_REQUIRED_KEYS = frozenset({
344         "vpci", "local_ip", "netmask", "local_mac", "driver"})
345
346     def map_topology_to_infrastructure(self):
347         """ This method should verify if the available resources defined in pod.yaml
348         match the topology.yaml file.
349
350         :return: None. Side effect: context_cfg is updated
351         """
352         for node, node_dict in self.context_cfg["nodes"].items():
353
354             for network in node_dict["interfaces"].values():
355                 missing = self.TOPOLOGY_REQUIRED_KEYS.difference(network)
356                 if not missing:
357                     continue
358
359                 # only ssh probe if there are missing values
360                 # ssh probe won't work on Ixia, so we had better define all our values
361                 try:
362                     netdevs = self._probe_netdevs(node, node_dict)
363                 except (SSHError, SSHTimeout):
364                     raise IncorrectConfig(
365                         "Unable to probe missing interface fields '%s', on node %s "
366                         "SSH Error" % (', '.join(missing), node))
367                 try:
368                     self._probe_missing_values(netdevs, network)
369                 except KeyError:
370                     pass
371                 else:
372                     missing = self.TOPOLOGY_REQUIRED_KEYS.difference(
373                         network)
374                 if missing:
375                     raise IncorrectConfig(
376                         "Require interface fields '%s' not found, topology file "
377                         "corrupted" % ', '.join(missing))
378
379         # 3. Use topology file to find connections & resolve dest address
380         self._resolve_topology()
381         self._update_context_with_topology()
382
383     FIND_NETDEVICE_STRING = r"""find /sys/devices/pci* -type d -name net -exec sh -c '{ grep -sH ^ \
384 $1/ifindex $1/address $1/operstate $1/device/vendor $1/device/device \
385 $1/device/subsystem_vendor $1/device/subsystem_device ; \
386 printf "%s/driver:" $1 ; basename $(readlink -s $1/device/driver); } \
387 ' sh  \{\}/* \;
388 """
389     BASE_ADAPTER_RE = re.compile(
390         '^/sys/devices/(.*)/net/([^/]*)/([^:]*):(.*)$', re.M)
391
392     @classmethod
393     def parse_netdev_info(cls, stdout):
394         network_devices = defaultdict(dict)
395         matches = cls.BASE_ADAPTER_RE.findall(stdout)
396         for bus_path, interface_name, name, value in matches:
397             dirname, bus_id = os.path.split(bus_path)
398             if 'virtio' in bus_id:
399                 # for some stupid reason VMs include virtio1/
400                 # in PCI device path
401                 bus_id = os.path.basename(dirname)
402             # remove extra 'device/' from 'device/vendor,
403             # device/subsystem_vendor', etc.
404             if 'device/' in name:
405                 name = name.split('/')[1]
406             network_devices[interface_name][name] = value
407             network_devices[interface_name][
408                 'interface_name'] = interface_name
409             network_devices[interface_name]['pci_bus_id'] = bus_id
410         # convert back to regular dict
411         return dict(network_devices)
412
413     @classmethod
414     def get_vnf_impl(cls, vnf_model_id):
415         """ Find the implementing class from vnf_model["vnf"]["name"] field
416
417         :param vnf_model_id: parsed vnfd model ID field
418         :return: subclass of GenericVNF
419         """
420         import_modules_from_package(
421             "yardstick.network_services.vnf_generic.vnf")
422         expected_name = vnf_model_id
423         classes_found = []
424
425         def impl():
426             for name, class_ in ((c.__name__, c) for c in itersubclasses(GenericVNF)):
427                 if name == expected_name:
428                     yield class_
429                 classes_found.append(name)
430
431         try:
432             return next(impl())
433         except StopIteration:
434             pass
435
436         raise IncorrectConfig("No implementation for %s found in %s" %
437                               (expected_name, classes_found))
438
439     @staticmethod
440     def update_interfaces_from_node(vnfd, node):
441         for intf in vnfd["vdu"][0]["external-interface"]:
442             node_intf = node['interfaces'][intf['name']]
443             intf['virtual-interface'].update(node_intf)
444
445     def load_vnf_models(self, scenario_cfg=None, context_cfg=None):
446         """ Create VNF objects based on YAML descriptors
447
448         :param scenario_cfg:
449         :type scenario_cfg:
450         :param context_cfg:
451         :return:
452         """
453         trex_lib_path = get_nsb_option('trex_client_lib')
454         sys.path[:] = list(chain([trex_lib_path], (x for x in sys.path if x != trex_lib_path)))
455
456         if scenario_cfg is None:
457             scenario_cfg = self.scenario_cfg
458
459         if context_cfg is None:
460             context_cfg = self.context_cfg
461
462         vnfs = []
463         # we assume OrderedDict for consistenct in instantiation
464         for node_name, node in context_cfg["nodes"].items():
465             LOG.debug(node)
466             file_name = node["VNF model"]
467             file_path = scenario_cfg['task_path']
468             with open_relative_file(file_name, file_path) as stream:
469                 vnf_model = stream.read()
470             vnfd = vnfdgen.generate_vnfd(vnf_model, node)
471             # TODO: here add extra context_cfg["nodes"] regardless of template
472             vnfd = vnfd["vnfd:vnfd-catalog"]["vnfd"][0]
473             self.update_interfaces_from_node(vnfd, node)
474             vnf_impl = self.get_vnf_impl(vnfd['id'])
475             vnf_instance = vnf_impl(node_name, vnfd)
476             vnfs.append(vnf_instance)
477
478         self.vnfs = vnfs
479         return vnfs
480
481     def setup(self):
482         """ Setup infrastructure, provission VNFs & start traffic
483
484         :return:
485         """
486         # 1. Verify if infrastructure mapping can meet topology
487         self.map_topology_to_infrastructure()
488         # 1a. Load VNF models
489         self.load_vnf_models()
490         # 1b. Fill traffic profile with information from topology
491         self._fill_traffic_profile()
492
493         # 2. Provision VNFs
494
495         # link events will cause VNF application to exit
496         # so we should start traffic runners before VNFs
497         traffic_runners = [vnf for vnf in self.vnfs if vnf.runs_traffic]
498         non_traffic_runners = [vnf for vnf in self.vnfs if not vnf.runs_traffic]
499         try:
500             for vnf in chain(traffic_runners, non_traffic_runners):
501                 LOG.info("Instantiating %s", vnf.name)
502                 vnf.instantiate(self.scenario_cfg, self.context_cfg)
503                 LOG.info("Waiting for %s to instantiate", vnf.name)
504                 vnf.wait_for_instantiate()
505         except RuntimeError:
506             for vnf in self.vnfs:
507                 vnf.terminate()
508             raise
509
510         # 3. Run experiment
511         # Start listeners first to avoid losing packets
512         for traffic_gen in traffic_runners:
513             traffic_gen.listen_traffic(self.traffic_profile)
514
515         # register collector with yardstick for KPI collection.
516         self.collector = Collector(self.vnfs, self.traffic_profile)
517         self.collector.start()
518
519         # Start the actual traffic
520         for traffic_gen in traffic_runners:
521             LOG.info("Starting traffic on %s", traffic_gen.name)
522             traffic_gen.run_traffic(self.traffic_profile)
523
524     def run(self, result):  # yardstick API
525         """ Yardstick calls run() at intervals defined in the yaml and
526             produces timestamped samples
527
528         :param result: dictionary with results to update
529         :return: None
530         """
531
532         for vnf in self.vnfs:
533             # Result example:
534             # {"VNF1: { "tput" : [1000, 999] }, "VNF2": { "latency": 100 }}
535             LOG.debug("collect KPI for %s", vnf.name)
536             result.update(self.collector.get_kpi(vnf))
537
538     def teardown(self):
539         """ Stop the collector and terminate VNF & TG instance
540
541         :return
542         """
543
544         self.collector.stop()
545         for vnf in self.vnfs:
546             LOG.info("Stopping %s", vnf.name)
547             vnf.terminate()