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