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