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