nsb_installation: updates
[yardstick.git] / yardstick / common / ansible_common.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
15 from __future__ import absolute_import
16
17 import cgitb
18 import collections
19 import contextlib as cl
20 import json
21 import logging
22 import os
23 from collections import Mapping, MutableMapping, Iterable, Callable, deque
24 from functools import partial
25 from itertools import chain
26 from subprocess import CalledProcessError, Popen, PIPE
27 from tempfile import NamedTemporaryFile
28
29 import six
30 import six.moves.configparser as ConfigParser
31 import yaml
32 from six import StringIO
33 from chainmap import ChainMap
34
35 from yardstick.common.utils import Timer
36
37
38 cgitb.enable(format="text")
39
40 _LOCAL_DEFAULT = object()
41
42 LOG = logging.getLogger(__name__)
43
44
45 def overwrite_dict_to_cfg(cfg, cfg_dict):
46     for section in cfg_dict:
47         # delete then add
48         cfg.remove_section(section)
49         cfg.add_section(section)
50     for section, val in cfg_dict.items():
51         if isinstance(val, six.string_types):
52             cfg.set(section, val)
53         elif isinstance(val, collections.Mapping):
54             for k, v in val.items():
55                 cfg.set(section, k, v)
56         else:
57             for v in val:
58                 cfg.set(section, v)
59
60
61 class TempfileContext(object):
62     @staticmethod
63     def _try_get_filename_from_file(param):
64         try:
65             if isinstance(param.read, Callable):
66                 return param.name
67         except AttributeError:
68             pass
69         # return what was given
70         return param
71
72     def __init__(self, data, write_func, descriptor, data_types, directory,
73                  prefix, suffix, creator):
74         super(TempfileContext, self).__init__()
75         self.data = data
76         self.write_func = write_func
77         self.descriptor = descriptor
78         self.data_types = data_types
79         self.directory = directory
80         self.suffix = suffix
81         self.creator = creator
82         self.data_file = None
83         self.prefix = prefix
84
85     def __enter__(self):
86         self.data = self._try_get_filename_from_file(self.data)
87         if isinstance(self.data, six.string_types):
88             # string -> playbook filename directly
89             data_filename = self.data
90         elif isinstance(self.data, self.data_types):
91             # list of playbooks -> put into a temporary playbook file
92             if self.prefix:
93                 self.prefix = self.prefix.rstrip('_')
94             data_filename = ''.join([self.prefix, self.suffix])
95             if self.directory:
96                 data_filename = os.path.join(self.directory, data_filename)
97             if not os.path.exists(data_filename):
98                 self.data_file = open(data_filename, 'w+')
99             else:
100                 self.data_file = self.creator()
101             self.write_func(self.data_file)
102             self.data_file.flush()
103             self.data_file.seek(0)
104         else:
105             # data not passed properly -> error
106             LOG.error("%s type not recognized: %s", self.descriptor, self.data)
107             raise ValueError("{} type not recognized".format(self.descriptor))
108
109         LOG.debug("%s file : %s", self.descriptor, data_filename)
110
111         return data_filename
112
113     def __exit__(self, exc_type, exc_val, exc_tb):
114         if self.data_file:
115             self.data_file.close()
116
117
118 class CustomTemporaryFile(object):
119     DEFAULT_SUFFIX = None
120     DEFAULT_DATA_TYPES = None
121
122     def __init__(self, directory, prefix, suffix=_LOCAL_DEFAULT,
123                  data_types=_LOCAL_DEFAULT):
124         super(CustomTemporaryFile, self).__init__()
125         self.directory = directory
126         self.prefix = prefix
127         if suffix is not _LOCAL_DEFAULT:
128             self.suffix = suffix
129         else:
130             self.suffix = self.DEFAULT_SUFFIX
131         if data_types is not _LOCAL_DEFAULT:
132             self.data_types = data_types
133         else:
134             self.data_types = self.DEFAULT_DATA_TYPES
135         # must open "w+" so unicode is encoded correctly
136         self.creator = partial(NamedTemporaryFile, mode="w+", delete=False,
137                                dir=directory,
138                                prefix=prefix,
139                                suffix=self.suffix)
140
141     def make_context(self, data, write_func, descriptor='data'):
142         return TempfileContext(data, write_func, descriptor, self.data_types,
143                                self.directory, self.prefix, self.suffix,
144                                self.creator)
145
146
147 class ListTemporaryFile(CustomTemporaryFile):
148     DEFAULT_DATA_TYPES = (list, tuple)
149
150
151 class MapTemporaryFile(CustomTemporaryFile):
152     DEFAULT_DATA_TYPES = dict
153
154
155 class YmlTemporaryFile(ListTemporaryFile):
156     DEFAULT_SUFFIX = '.yml'
157
158
159 class IniListTemporaryFile(ListTemporaryFile):
160     DEFAULT_SUFFIX = '.ini'
161
162
163 class IniMapTemporaryFile(MapTemporaryFile):
164     DEFAULT_SUFFIX = '.ini'
165
166
167 class JsonTemporaryFile(MapTemporaryFile):
168     DEFAULT_SUFFIX = '.json'
169
170
171 class FileNameGenerator(object):
172     @staticmethod
173     def get_generator_from_filename(filename, directory, prefix, middle):
174         basename = os.path.splitext(os.path.basename(filename))[0]
175         if not basename.startswith(prefix):
176             part_list = [prefix, middle, basename]
177         elif not middle or middle in basename:
178             part_list = [basename]
179         else:
180             part_list = [middle, basename]
181         return FileNameGenerator(directory=directory, part_list=part_list)
182
183     @staticmethod
184     def _handle_existing_file(filename):
185         if not os.path.exists(filename):
186             return filename
187
188         prefix, suffix = os.path.splitext(os.path.basename(filename))
189         directory = os.path.dirname(filename)
190         if not prefix.endswith('_'):
191             prefix += '_'
192
193         temp_file = NamedTemporaryFile(delete=False, dir=directory,
194                                        prefix=prefix, suffix=suffix)
195         with cl.closing(temp_file):
196             return temp_file.name
197
198     def __init__(self, directory, part_list):
199         super(FileNameGenerator, self).__init__()
200         self.directory = directory
201         self.part_list = part_list
202
203     def make(self, extra):
204         if not isinstance(extra, Iterable) or isinstance(extra,
205                                                          six.string_types):
206             extra = (extra,)  # wrap the singleton in an iterable
207         return self._handle_existing_file(
208             os.path.join(
209                 self.directory,
210                 '_'.join(chain(self.part_list, extra))
211             )
212         )
213
214
215 class AnsibleNodeDict(Mapping):
216     def __init__(self, node_class, nodes):
217         super(AnsibleNodeDict, self).__init__()
218         # create a dict of name, Node instance
219         self.node_dict = {k: v for k, v in
220                           (node_class(node).get_tuple() for node in
221                            nodes)}
222         # collect all the node roles
223         self.node_roles = set(
224             n['role'] for n in six.itervalues(self.node_dict))
225
226     def __repr__(self):
227         return repr(self.node_dict)
228
229     def __len__(self):
230         return len(self.node_dict)
231
232     def __getitem__(self, item):
233         return self.node_dict[item]
234
235     def __iter__(self):
236         return iter(self.node_dict)
237
238     def iter_all_of_type(self, node_type, default=_LOCAL_DEFAULT):
239         return (node for node in six.itervalues(self) if
240                 node.is_role(node_type, default))
241
242     def gen_inventory_lines_for_all_of_type(self, node_type,
243                                             default=_LOCAL_DEFAULT):
244         return [node.gen_inventory_line() for node in
245                 self.iter_all_of_type(node_type, default)]
246
247     def gen_all_inventory_lines(self):
248         return [node.gen_inventory_line() for node in
249                 six.itervalues(self.node_dict)]
250
251     def gen_inventory_groups(self):
252         # lowercase group names
253         return {role.lower(): [node['name'] for
254                                node in self.iter_all_of_type(role)]
255                 for role in self.node_roles}
256
257
258 class AnsibleNode(MutableMapping):
259     ANSIBLE_NODE_KEY_MAP = {
260         u'ansible_host': 'ip',
261         u'ansible_user': 'user',
262         u'ansible_port': 'ssh_port',
263         u'ansible_ssh_pass': 'password',
264         u'ansible_ssh_private_key_file': 'key_filename',
265     }
266
267     def __init__(self, data=None, **kwargs):
268         super(AnsibleNode, self).__init__()
269         if isinstance(data, MutableMapping):
270             self.data = data
271         else:
272             self.data = kwargs
273
274     def __repr__(self):
275         return 'AnsibleNode<{}>'.format(self.data)
276
277     def __len__(self):
278         return len(self.data)
279
280     def __iter__(self):
281         return iter(self.data)
282
283     @property
284     def node_key_map(self):
285         return self.ANSIBLE_NODE_KEY_MAP
286
287     def get_inventory_params(self):
288         node_key_map = self.node_key_map
289         # password or key_filename may not be present
290         return {inventory_key: self[self_key] for inventory_key, self_key in
291                 node_key_map.items() if self_key in self}
292
293     def is_role(self, node_type, default=_LOCAL_DEFAULT):
294         if default is not _LOCAL_DEFAULT:
295             return self.setdefault('role', default) in node_type
296         return node_type in self.get('role', set())
297
298     def gen_inventory_line(self):
299         inventory_params = self.get_inventory_params()
300         # use format to convert ints
301         formatted_args = (u"{}={}".format(*entry) for entry in
302                           inventory_params.items())
303         line = u" ".join(chain([self['name']], formatted_args))
304         return line
305
306     def get_tuple(self):
307         return self['name'], self
308
309     def __contains__(self, key):
310         return self.data.__contains__(key)
311
312     def __getitem__(self, item):
313         return self.data[item]
314
315     def __setitem__(self, key, value):
316         self.data[key] = value
317
318     def __delitem__(self, key):
319         del self.data[key]
320
321     def __getattr__(self, item):
322         return getattr(self.data, item)
323
324
325 class AnsibleCommon(object):
326     NODE_CLASS = AnsibleNode
327     OUTFILE_PREFIX_TEMPLATE = 'ansible_{:02}'
328
329     __DEFAULT_VALUES_MAP = {
330         'default_timeout': 1200,
331         'counter': 0,
332         'prefix': '',
333         # default 10 min ansible timeout for non-main calls
334         'ansible_timeout': 600,
335         'scripts_dest': None,
336         '_deploy_dir': _LOCAL_DEFAULT,
337     }
338
339     __DEFAULT_CALLABLES_MAP = {
340         'test_vars': dict,
341         'inventory_dict': dict,
342         '_node_dict': dict,
343         '_node_info_dict': dict,
344     }
345
346     @classmethod
347     def _get_defaults(cls):
348         # subclasses will override to change defaults using the ChainMap
349         # layering
350         values_map_deque, defaults_map_deque = cls._get_defaults_map_deques()
351         return ChainMap(*values_map_deque), ChainMap(*defaults_map_deque)
352
353     @classmethod
354     def _get_defaults_map_deques(cls):
355         # deque so we can insert or append easily
356         return (deque([cls.__DEFAULT_VALUES_MAP]),
357                 deque([cls.__DEFAULT_CALLABLES_MAP]))
358
359     def __init__(self, nodes, **kwargs):
360         # TODO: add default Heat vars
361         super(AnsibleCommon, self).__init__()
362         self.nodes = nodes
363         self.counter = 0
364         self.prefix = ''
365         # default 10 min ansible timeout for non-main calls
366         self.ansible_timeout = 600
367         self.inventory_dict = None
368         self.scripts_dest = None
369         self._deploy_dir = _LOCAL_DEFAULT
370         self._node_dict = None
371         self._node_info_dict = None
372         self.callable_task = None
373         self.test_vars = None
374         self.default_timeout = None
375         self.reset(**kwargs)
376
377     def reset(self, **kwargs):
378         """
379         reset all attributes based on various layers of default dicts
380         including new default added in subclasses
381         """
382
383         default_values_map, default_callables_map = self._get_defaults()
384         for name, default_value in list(default_values_map.items()):
385             setattr(self, name, kwargs.pop(name, default_value))
386
387         for name, func in list(default_callables_map.items()):
388             try:
389                 value = kwargs.pop(name)
390             except KeyError:
391                 # usually dict()
392                 value = func()
393             setattr(self, name, value)
394
395     def do_install(self, playbook, directory):
396         # TODO: how to get openstack nodes from Heat
397         self.gen_inventory_ini_dict()
398         self.execute_ansible(playbook, directory)
399
400     @property
401     def deploy_dir(self):
402         if self._deploy_dir is _LOCAL_DEFAULT:
403             raise ValueError('Deploy dir must be set before using it')
404         return self._deploy_dir
405
406     @deploy_dir.setter
407     def deploy_dir(self, value):
408         self._deploy_dir = value
409
410     @property
411     def node_dict(self):
412         if not self._node_dict:
413             self._node_dict = AnsibleNodeDict(self.NODE_CLASS, self.nodes)
414             LOG.debug("node_dict = \n%s", self._node_dict)
415         return self._node_dict
416
417     def gen_inventory_ini_dict(self):
418         if self.inventory_dict and isinstance(self.inventory_dict,
419                                               MutableMapping):
420             return
421
422         node_dict = self.node_dict
423         # add all nodes to 'node' group and specify full parameter there
424         self.inventory_dict = {
425             "nodes": node_dict.gen_all_inventory_lines()
426         }
427         # place nodes into ansible groups according to their role
428         # using just node name
429         self.inventory_dict.update(node_dict.gen_inventory_groups())
430
431     @staticmethod
432     def ansible_env(directory, log_file):
433         # have to overload here in the env because we can't modify local.conf
434         ansible_dict = dict(os.environ, **{
435             "ANSIBLE_LOG_PATH": os.path.join(directory, log_file),
436             "ANSIBLE_LOG_BASE": directory,
437             # # required for SSH to work
438             # "ANSIBLE_SSH_ARGS": "-o UserKnownHostsFile=/dev/null "
439             #                     "-o GSSAPIAuthentication=no "
440             #                     "-o PreferredAuthentications=password "
441             #                     "-o ControlMaster=auto "
442             #                     "-o ControlPersist=60s",
443             # "ANSIBLE_HOST_KEY_CHECKING": "False",
444             # "ANSIBLE_SSH_PIPELINING": "True",
445         })
446         return ansible_dict
447
448     def _gen_ansible_playbook_file(self, playbooks, directory, prefix='tmp'):
449         # check what is passed in playbooks
450         if isinstance(playbooks, (list, tuple)):
451             if len(playbooks) == 1:
452                 # list or tuple with one member -> take it
453                 playbooks = playbooks[0]
454             else:
455                 playbooks = [{'include': playbook} for playbook in playbooks]
456         prefix = '_'.join([self.prefix, prefix, 'playbook'])
457         yml_temp_file = YmlTemporaryFile(directory=directory, prefix=prefix)
458         write_func = partial(yaml.safe_dump, playbooks,
459                              default_flow_style=False,
460                              explicit_start=True)
461         return yml_temp_file.make_context(playbooks, write_func,
462                                           descriptor='playbooks')
463
464     def _gen_ansible_inventory_file(self, directory, prefix='tmp'):
465         def write_func(data_file):
466             overwrite_dict_to_cfg(inventory_config, self.inventory_dict)
467             debug_inventory = StringIO()
468             inventory_config.write(debug_inventory)
469             LOG.debug("inventory = \n%s", debug_inventory.getvalue())
470             inventory_config.write(data_file)
471
472         prefix = '_'.join([self.prefix, prefix, 'inventory'])
473         ini_temp_file = IniMapTemporaryFile(directory=directory, prefix=prefix)
474         inventory_config = ConfigParser.ConfigParser(allow_no_value=True)
475         return ini_temp_file.make_context(self.inventory_dict, write_func,
476                                           descriptor='inventory')
477
478     def _gen_ansible_extra_vars(self, extra_vars, directory, prefix='tmp'):
479         if not isinstance(extra_vars, MutableMapping):
480             extra_vars = self.test_vars
481         prefix = '_'.join([self.prefix, prefix, 'extra_vars'])
482         # use JSON because Python YAML serializes unicode wierdly
483         json_temp_file = JsonTemporaryFile(directory=directory, prefix=prefix)
484         write_func = partial(json.dump, extra_vars, indent=4)
485         return json_temp_file.make_context(extra_vars, write_func,
486                                            descriptor='extra_vars')
487
488     def _gen_log_names(self, directory, prefix, playbook_filename):
489         generator = FileNameGenerator.get_generator_from_filename(
490             playbook_filename, directory, self.prefix, prefix)
491         return generator.make('execute.log'), generator.make(
492             'syntax_check.log')
493
494     @staticmethod
495     def get_timeout(*timeouts):
496         for timeout in timeouts:
497             try:
498                 timeout = float(timeout)
499                 if timeout > 0:
500                     break
501             except (TypeError, ValueError):
502                 pass
503         else:
504             timeout = 1200.0
505         return timeout
506
507     def execute_ansible(self, playbooks, directory, timeout=None,
508                         extra_vars=None, ansible_check=False, prefix='tmp',
509                         verbose=False):
510         # there can be three types of dirs:
511         #  log dir: can be anywhere
512         #  inventory dir: can be anywhere
513         #  playbook dir: use include to point to files in  consts.ANSIBLE_DIR
514
515         if not os.path.isdir(directory):
516             raise OSError("Not a directory, %s", directory)
517         timeout = self.get_timeout(timeout, self.default_timeout)
518
519         self.counter += 1
520         self.prefix = self.OUTFILE_PREFIX_TEMPLATE.format(self.counter)
521
522         playbook_ctx = self._gen_ansible_playbook_file(playbooks, directory,
523                                                        prefix)
524         inventory_ctx = self._gen_ansible_inventory_file(directory,
525                                                          prefix=prefix)
526         extra_vars_ctx = self._gen_ansible_extra_vars(extra_vars, directory,
527                                                       prefix=prefix)
528
529         with playbook_ctx as playbook_filename, \
530                 inventory_ctx as inventory_filename, \
531                 extra_vars_ctx as extra_vars_filename:
532             cmd = [
533                 "ansible-playbook",
534                 "--syntax-check",
535                 "-i",
536                 inventory_filename,
537             ]
538             if verbose:
539                 cmd.append('-vvv')
540             if extra_vars_filename is not None:
541                 cmd.extend([
542                     "-e",
543                     "@{}".format(extra_vars_filename),
544                 ])
545             cmd.append(playbook_filename)
546
547             log_file_main, log_file_checks = self._gen_log_names(
548                 directory, prefix, playbook_filename)
549
550             exec_args = {
551                 'cwd': directory,
552                 'shell': False,
553             }
554
555             if ansible_check:
556                 LOG.debug('log file checks: %s', log_file_checks)
557                 exec_args.update({
558                     'env': self.ansible_env(directory, log_file_checks),
559                     # TODO: add timeout support of use subprocess32 backport
560                     # 'timeout': timeout / 2,
561                 })
562                 with Timer() as timer:
563                     proc = Popen(cmd, stdout=PIPE, **exec_args)
564                     output, _ = proc.communicate()
565                     retcode = proc.wait()
566                 LOG.debug("exit status = %s", retcode)
567                 if retcode != 0:
568                     raise CalledProcessError(retcode, cmd, output)
569                 timeout -= timer.total_seconds()
570
571             cmd.remove("--syntax-check")
572             LOG.debug('log file main: %s', log_file_main)
573             exec_args.update({
574                 'env': self.ansible_env(directory, log_file_main),
575                 # TODO: add timeout support of use subprocess32 backport
576                 # 'timeout': timeout,
577             })
578             proc = Popen(cmd, stdout=PIPE, **exec_args)
579             output, _ = proc.communicate()
580             retcode = proc.wait()
581             LOG.debug("exit status = %s", retcode)
582             if retcode != 0:
583                 raise CalledProcessError(retcode, cmd, output)
584             return output