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