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