[VVP] Adding preload generation functionality
[vvp/validation-scripts.git] / ice_validator / preload.py
1 # -*- coding: utf8 -*-
2 # ============LICENSE_START====================================================
3 # org.onap.vvp/validation-scripts
4 # ===================================================================
5 # Copyright © 2019 AT&T Intellectual Property. All rights reserved.
6 # ===================================================================
7 #
8 # Unless otherwise specified, all software contained herein is licensed
9 # under the Apache License, Version 2.0 (the "License");
10 # you may not use this software except in compliance with the License.
11 # You may obtain a copy of the License at
12 #
13 #             http://www.apache.org/licenses/LICENSE-2.0
14 #
15 # Unless required by applicable law or agreed to in writing, software
16 # distributed under the License is distributed on an "AS IS" BASIS,
17 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18 # See the License for the specific language governing permissions and
19 # limitations under the License.
20 #
21 #
22 #
23 # Unless otherwise specified, all documentation contained herein is licensed
24 # under the Creative Commons License, Attribution 4.0 Intl. (the "License");
25 # you may not use this documentation except in compliance with the License.
26 # You may obtain a copy of the License at
27 #
28 #             https://creativecommons.org/licenses/by/4.0/
29 #
30 # Unless required by applicable law or agreed to in writing, documentation
31 # distributed under the License is distributed on an "AS IS" BASIS,
32 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
33 # See the License for the specific language governing permissions and
34 # limitations under the License.
35 #
36 # ============LICENSE_END============================================
37 import importlib
38 import inspect
39 import json
40 import os
41 import pkgutil
42 import shutil
43 from abc import ABC, abstractmethod
44 from itertools import chain
45 from typing import Set
46
47 from tests.helpers import (
48     get_param,
49     get_environment_pair,
50     prop_iterator,
51     get_output_dir,
52     is_base_module,
53 )
54 from tests.parametrizers import parametrize_heat_templates
55 from tests.structures import NeutronPortProcessor, Heat
56 from tests.test_environment_file_parameters import get_preload_excluded_parameters
57 from tests.utils import nested_dict
58 from tests.utils.vm_types import get_vm_type_for_nova_server
59
60
61 # This is only used to fake out parametrizers
62 class DummyMetafunc:
63     def __init__(self, config):
64         self.inputs = {}
65         self.config = config
66
67     def parametrize(self, name, file_list):
68         self.inputs[name] = file_list
69
70
71 def get_heat_templates(config):
72     """
73     Returns the Heat template paths discovered by the pytest parameterizers
74     :param config: pytest config
75     :return: list of heat template paths
76     """
77     meta = DummyMetafunc(config)
78     parametrize_heat_templates(meta)
79     heat_templates = meta.inputs.get("heat_templates", [])
80     if isinstance(heat_templates, list) and len(heat_templates) > 0:
81         heat_templates = heat_templates[0]
82     else:
83         return
84     return heat_templates
85
86
87 def get_json_template(template_dir, template_name):
88     template_name = template_name + ".json"
89     with open(os.path.join(template_dir, template_name)) as f:
90         return json.loads(f.read())
91
92
93 def remove(sequence, exclude, key=None):
94     """
95     Remove a copy of sequence that items occur in exclude.
96
97     :param sequence: sequence of objects
98     :param exclude:  objects to excluded (must support ``in`` check)
99     :param key:      optional function to extract key from item in sequence
100     :return:         list of items not in the excluded
101     """
102     key_func = key if key else lambda x: x
103     result = (s for s in sequence if key_func(s) not in exclude)
104     return set(result) if isinstance(sequence, Set) else list(result)
105
106
107 def get_or_create_template(template_dir, key, value, sequence, template_name):
108     """
109     Search a sequence of dicts where a given key matches value.  If
110     found, then it returns that item.  If not, then it loads the
111     template identified by template_name, adds it ot the sequence, and
112     returns the template
113     """
114     for item in sequence:
115         if item[key] == value:
116             return item
117     new_template = get_json_template(template_dir, template_name)
118     sequence.append(new_template)
119     return new_template
120
121
122 def replace(param):
123     """
124     Optionally used by the preload generator to wrap items in the preload
125     that need to be replaced by end users
126     :param param: p
127     """
128     return "VALUE FOR: {}".format(param) if param else ""
129
130
131 class AbstractPreloadGenerator(ABC):
132     """
133     All preload generators must inherit from this class and implement the
134     abstract methods.
135
136     Preload generators are automatically discovered at runtime via a plugin
137     architecture.  The system path is scanned looking for modules with the name
138     preload_*, then all non-abstract classes that inherit from AbstractPreloadGenerator
139     are registered as preload plugins
140
141     Attributes:
142         :param vnf:             Instance of Vnf that contains the preload data
143         :param base_output_dir: Base directory to house the preloads.  All preloads
144                                 must be written to a subdirectory under this directory
145     """
146
147     def __init__(self, vnf, base_output_dir):
148         self.vnf = vnf
149         self.base_output_dir = base_output_dir
150         os.makedirs(self.output_dir, exist_ok=True)
151
152     @classmethod
153     @abstractmethod
154     def format_name(cls):
155         """
156         String name to identify the format (ex: VN-API, GR-API)
157         """
158         raise NotImplementedError()
159
160     @classmethod
161     @abstractmethod
162     def output_sub_dir(cls):
163         """
164         String sub-directory name that will appear under ``base_output_dir``
165         """
166         raise NotImplementedError()
167
168     @classmethod
169     @abstractmethod
170     def supports_output_passing(cls):
171         """
172         Some preload methods allow automatically mapping output parameters in the
173         base module to the input parameter of other modules.  This means these
174         that the incremental modules do not need these base module outputs in their
175         preloads.
176
177         At this time, VNF-API does not support output parameter passing, but
178         GR-API does.
179
180         If this is true, then the generator will call Vnf#filter_output_params
181         after the preload module for the base module has been created
182         """
183         raise NotImplementedError()
184
185     @abstractmethod
186     def generate_module(self, module):
187         """
188         Create the preloads and write them to ``self.output_dir``.  This
189         method is responsible for generating the content of the preload and
190         writing the file to disk.
191         """
192         raise NotImplementedError()
193
194     @property
195     def output_dir(self):
196         return os.path.join(self.base_output_dir, self.output_sub_dir())
197
198     def generate(self):
199         # handle the base module first
200         print("\nGenerating {} preloads".format(self.format_name()))
201         self.generate_module(self.vnf.base_module)
202         print("... generated template for {}".format(self.vnf.base_module))
203         if self.supports_output_passing():
204             self.vnf.filter_base_outputs()
205         for mod in self.vnf.incremental_modules:
206             self.generate_module(mod)
207             print("... generated for {}".format(mod))
208
209
210 class FilterBaseOutputs(ABC):
211     """
212     Invoked to remove parameters in an object that appear in the base module.
213     Base output parameters can be passed to incremental modules
214     so they do not need to be defined in a preload.  This method can be
215     invoked on a module to pre-filter the parameters before a preload is
216     created.
217
218     The method should remove the parameters that exist in the base module from
219     both itself and any sub-objects.
220     """
221
222     @abstractmethod
223     def filter_output_params(self, base_outputs):
224         raise NotImplementedError()
225
226
227 class IpParam:
228     def __init__(self, ip_addr_param, port):
229         self.param = ip_addr_param or ""
230         self.port = port
231
232     @property
233     def ip_version(self):
234         return 6 if "_v6_" in self.param else 4
235
236     def __hash__(self):
237         return hash(self.param)
238
239     def __eq__(self, other):
240         return hash(self) == hash(other)
241
242     def __str__(self):
243         return "{}(v{})".format(self.param, self.ip_version)
244
245     def __repr(self):
246         return str(self)
247
248
249 class Network(FilterBaseOutputs):
250     def __init__(self, role, name_param):
251         self.network_role = role
252         self.name_param = name_param
253         self.subnet_params = set()
254
255     def filter_output_params(self, base_outputs):
256         self.subnet_params = remove(self.subnet_params, base_outputs)
257
258     def __hash__(self):
259         return hash(self.network_role)
260
261     def __eq__(self, other):
262         return hash(self) == hash(other)
263
264
265 class Port(FilterBaseOutputs):
266     def __init__(self, vm, network):
267         self.vm = vm
268         self.network = network
269         self.fixed_ips = []
270         self.floating_ips = []
271         self.uses_dhcp = True
272
273     def add_ips(self, props):
274         props = props.get("properties") or props
275         for fixed_ip in props.get("fixed_ips") or []:
276             if not isinstance(fixed_ip, dict):
277                 continue
278             ip_address = get_param(fixed_ip.get("ip_address"))
279             subnet = get_param(fixed_ip.get("subnet") or fixed_ip.get("subnet_id"))
280             if ip_address:
281                 self.uses_dhcp = False
282                 self.fixed_ips.append(IpParam(ip_address, self))
283             if subnet:
284                 self.network.subnet_params.add(subnet)
285         for ip in prop_iterator(props, "allowed_address_pairs", "ip_address"):
286             self.uses_dhcp = False
287             param = get_param(ip) if ip else ""
288             if param:
289                 self.floating_ips.append(IpParam(param, self))
290
291     def filter_output_params(self, base_outputs):
292         self.fixed_ips = remove(self.fixed_ips, base_outputs, key=lambda ip: ip.param)
293         self.floating_ips = remove(
294             self.floating_ips, base_outputs, key=lambda ip: ip.param
295         )
296
297
298 class VirtualMachineType(FilterBaseOutputs):
299     def __init__(self, vm_type, vnf_module):
300         self.vm_type = vm_type
301         self.names = []
302         self.ports = []
303         self.vm_count = 0
304         self.vnf_module = vnf_module
305
306     def filter_output_params(self, base_outputs):
307         self.names = remove(self.names, base_outputs)
308         for port in self.ports:
309             port.filter_output_params(base_outputs)
310
311     @property
312     def networks(self):
313         return {port.network for port in self.ports}
314
315     @property
316     def floating_ips(self):
317         for port in self.ports:
318             for ip in port.floating_ips:
319                 yield ip
320
321     @property
322     def fixed_ips(self):
323         for port in self.ports:
324             for ip in port.fixed_ips:
325                 yield ip
326
327     def update_ports(self, network, props):
328         port = self.get_or_create_port(network)
329         port.add_ips(props)
330
331     def get_or_create_port(self, network):
332         for port in self.ports:
333             if port.network == network:
334                 return port
335         port = Port(self, network)
336         self.ports.append(port)
337         return port
338
339
340 class Vnf:
341     def __init__(self, templates):
342         self.modules = [VnfModule(t, self) for t in templates]
343         self.uses_contrail = self._uses_contrail()
344         self.base_module = next(
345             (mod for mod in self.modules if mod.is_base_module), None
346         )
347         self.incremental_modules = [m for m in self.modules if not m.is_base_module]
348
349     def _uses_contrail(self):
350         for mod in self.modules:
351             resources = mod.heat.get_all_resources()
352             types = (r.get("type", "") for r in resources.values())
353             if any(t.startswith("OS::ContrailV2") for t in types):
354                 return True
355         return False
356
357     @property
358     def base_output_params(self):
359         return self.base_module.heat.outputs
360
361     def filter_base_outputs(self):
362         non_base_modules = (m for m in self.modules if not m.is_base_module)
363         for mod in non_base_modules:
364             mod.filter_output_params(self.base_output_params)
365
366
367 def yield_by_count(sequence):
368     """
369     Iterates through sequence and yields each item according to its __count__
370     attribute.  If an item has a __count__ of it will be returned 3 times
371     before advancing to the next item in the sequence.
372
373     :param sequence: sequence of dicts (must contain __count__)
374     :returns:        generator of tuple key, value pairs
375     """
376     for key, value in sequence.items():
377         for i in range(value["__count__"]):
378             yield (key, value)
379
380
381 def env_path(heat_path):
382     """
383     Create the path to the env file for the give heat path.
384     :param heat_path: path to heat file
385     :return: path to env file (assumes it is present and named correctly)
386     """
387     base_path = os.path.splitext(heat_path)[0]
388     return "{}.env".format(base_path)
389
390
391 class VnfModule(FilterBaseOutputs):
392     def __init__(self, template_file, vnf):
393         self.vnf = vnf
394         self.vnf_name = os.path.splitext(os.path.basename(template_file))[0]
395         self.template_file = template_file
396         self.heat = Heat(filepath=template_file, envpath=env_path(template_file))
397         env_pair = get_environment_pair(self.template_file)
398         env_yaml = env_pair.get("eyml") if env_pair else {}
399         self.parameters = env_yaml.get("parameters") or {}
400         self.networks = []
401         self.virtual_machine_types = self._create_vm_types()
402         self._add_networks()
403         self.outputs_filtered = False
404
405     def filter_output_params(self, base_outputs):
406         for vm in self.virtual_machine_types:
407             vm.filter_output_params(base_outputs)
408         for network in self.networks:
409             network.filter_output_params(base_outputs)
410         self.parameters = {
411             k: v for k, v in self.parameters.items() if k not in base_outputs
412         }
413         self.networks = [
414             network
415             for network in self.networks
416             if network.name_param not in base_outputs or network.subnet_params
417         ]
418         self.outputs_filtered = True
419
420     def _create_vm_types(self):
421         servers = self.heat.get_resource_by_type("OS::Nova::Server", all_resources=True)
422         vm_types = {}
423         for _, props in yield_by_count(servers):
424             vm_type = get_vm_type_for_nova_server(props)
425             vm = vm_types.setdefault(vm_type, VirtualMachineType(vm_type, self))
426             vm.vm_count += 1
427             name = nested_dict.get(props, "properties", "name", default={})
428             vm_name = get_param(name) if name else ""
429             vm.names.append(vm_name)
430         return list(vm_types.values())
431
432     def _add_networks(self):
433         ports = self.heat.get_resource_by_type("OS::Neutron::Port", all_resources=True)
434         for rid, props in yield_by_count(ports):
435             resource_type, port_match = NeutronPortProcessor.get_rid_match_tuple(rid)
436             if resource_type != "external":
437                 continue
438             network_role = port_match.group("network_role")
439             vm = self._get_vm_type(port_match.group("vm_type"))
440             network = self._get_network(network_role, props)
441             vm.update_ports(network, props)
442
443     @property
444     def is_base_module(self):
445         return is_base_module(self.template_file)
446
447     @property
448     def availability_zones(self):
449         """Returns a list of all availability zone parameters found in the template"""
450         return sorted(
451             p for p in self.heat.parameters if p.startswith("availability_zone")
452         )
453
454     @property
455     def preload_parameters(self):
456         """
457         Subset of parameters from the env file that can be overridden in
458         tag values. Per VNF Heat Guidelines, specific parameters such as
459         flavor, image, etc. must not be overridden so they are excluded.
460
461         :return: dict of parameters suitable for the preload
462         """
463         excluded = get_preload_excluded_parameters(self.template_file)
464         return {k: v for k, v in self.parameters.items() if k not in excluded}
465
466     def _get_vm_type(self, vm_type):
467         for vm in self.virtual_machine_types:
468             if vm_type.lower() == vm.vm_type.lower():
469                 return vm
470         raise RuntimeError("Encountered unknown VM type: {}".format(vm_type))
471
472     def _get_network(self, network_role, props):
473         network_prop = nested_dict.get(props, "properties", "network") or {}
474         name_param = get_param(network_prop) if network_prop else ""
475         for network in self.networks:
476             if network.network_role.lower() == network_role.lower():
477                 return network
478         new_network = Network(network_role, name_param)
479         self.networks.append(new_network)
480         return new_network
481
482     def __str__(self):
483         return "VNF Module ({})".format(os.path.basename(self.template_file))
484
485     def __repr__(self):
486         return str(self)
487
488     def __hash__(self):
489         return hash(self.vnf_name)
490
491     def __eq__(self, other):
492         return hash(self) == hash(other)
493
494
495 def create_preloads(config, exitstatus):
496     """
497     Create preloads in every format that can be discovered by get_generator_plugins
498     """
499     if config.getoption("self_test"):
500         return
501     print("+===================================================================+")
502     print("|                      Preload Template Generation                  |")
503     print("+===================================================================+")
504
505     preload_dir = os.path.join(get_output_dir(config), "preloads")
506     if os.path.exists(preload_dir):
507         shutil.rmtree(preload_dir)
508     heat_templates = get_heat_templates(config)
509     vnf = None
510     for gen_class in get_generator_plugins():
511         vnf = Vnf(heat_templates)
512         generator = gen_class(vnf, preload_dir)
513         generator.generate()
514     if vnf and vnf.uses_contrail:
515         print(
516             "\nWARNING: Preload template generation does not support Contrail\n"
517             "at this time, but Contrail resources were detected. The preload \n"
518             "template may be incomplete."
519         )
520     if exitstatus != 0:
521         print(
522             "\nWARNING: Heat violations detected. Preload templates may be\n"
523             "incomplete."
524         )
525
526
527 def is_preload_generator(class_):
528     """
529     Returns True if the class is an implementation of AbstractPreloadGenerator
530     """
531     return (
532         inspect.isclass(class_)
533         and not inspect.isabstract(class_)
534         and issubclass(class_, AbstractPreloadGenerator)
535     )
536
537
538 def get_generator_plugins():
539     """
540     Scan the system path for modules that are preload plugins and discover
541     and return the classes that implement AbstractPreloadGenerator in those
542     modules
543     """
544     preload_plugins = (
545         importlib.import_module(name)
546         for finder, name, ispkg in pkgutil.iter_modules()
547         if name.startswith("preload_")
548     )
549     members = chain.from_iterable(
550         inspect.getmembers(mod, is_preload_generator) for mod in preload_plugins
551     )
552     return [m[1] for m in members]