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