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