zz_failure_summary.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. """Ansible callback plugin to print a nicely formatted summary of failures.
  2. The file / module name is prefixed with `zz_` to make this plugin be loaded last
  3. by Ansible, thus making its output the last thing that users see.
  4. """
  5. from collections import defaultdict
  6. import traceback
  7. from ansible.plugins.callback import CallbackBase
  8. from ansible import constants as C
  9. from ansible.utils.color import stringc
  10. FAILED_NO_MSG = u'Failed without returning a message.'
  11. class CallbackModule(CallbackBase):
  12. """This callback plugin stores task results and summarizes failures."""
  13. CALLBACK_VERSION = 2.0
  14. CALLBACK_TYPE = 'aggregate'
  15. CALLBACK_NAME = 'failure_summary'
  16. CALLBACK_NEEDS_WHITELIST = False
  17. def __init__(self):
  18. super(CallbackModule, self).__init__()
  19. self.__failures = []
  20. self.__playbook_file = ''
  21. def v2_playbook_on_start(self, playbook):
  22. super(CallbackModule, self).v2_playbook_on_start(playbook)
  23. # pylint: disable=protected-access; Ansible gives us no public API to
  24. # get the file name of the current playbook from a callback plugin.
  25. self.__playbook_file = playbook._file_name
  26. def v2_runner_on_failed(self, result, ignore_errors=False):
  27. super(CallbackModule, self).v2_runner_on_failed(result, ignore_errors)
  28. if not ignore_errors:
  29. self.__failures.append(result)
  30. def v2_playbook_on_stats(self, stats):
  31. super(CallbackModule, self).v2_playbook_on_stats(stats)
  32. # pylint: disable=broad-except; capturing exceptions broadly is
  33. # intentional, to isolate arbitrary failures in this callback plugin.
  34. try:
  35. if self.__failures:
  36. self._display.display(failure_summary(self.__failures, self.__playbook_file))
  37. except Exception:
  38. msg = stringc(
  39. u'An error happened while generating a summary of failures:\n'
  40. u'{}'.format(traceback.format_exc()), C.COLOR_WARN)
  41. self._display.v(msg)
  42. def failure_summary(failures, playbook):
  43. """Return a summary of failed tasks, including details on health checks."""
  44. if not failures:
  45. return u''
  46. # NOTE: because we don't have access to task_vars from callback plugins, we
  47. # store the playbook context in the task result when the
  48. # openshift_health_check action plugin is used, and we use this context to
  49. # customize the error message.
  50. # pylint: disable=protected-access; Ansible gives us no sufficient public
  51. # API on TaskResult objects.
  52. context = next((
  53. context for context in
  54. (failure._result.get('playbook_context') for failure in failures)
  55. if context
  56. ), None)
  57. failures = [failure_to_dict(failure) for failure in failures]
  58. failures = deduplicate_failures(failures)
  59. summary = [u'', u'', u'Failure summary:', u'']
  60. width = len(str(len(failures)))
  61. initial_indent_format = u' {{:>{width}}}. '.format(width=width)
  62. initial_indent_len = len(initial_indent_format.format(0))
  63. subsequent_indent = u' ' * initial_indent_len
  64. subsequent_extra_indent = u' ' * (initial_indent_len + 10)
  65. for i, failure in enumerate(failures, 1):
  66. entries = format_failure(failure)
  67. summary.append(u'\n{}{}'.format(initial_indent_format.format(i), entries[0]))
  68. for entry in entries[1:]:
  69. entry = entry.replace(u'\n', u'\n' + subsequent_extra_indent)
  70. indented = u'{}{}'.format(subsequent_indent, entry)
  71. summary.append(indented)
  72. failed_checks = set()
  73. for failure in failures:
  74. failed_checks.update(name for name, message in failure['checks'])
  75. if failed_checks:
  76. summary.append(check_failure_footer(failed_checks, context, playbook))
  77. return u'\n'.join(summary)
  78. def failure_to_dict(failed_task_result):
  79. """Extract information out of a failed TaskResult into a dict.
  80. The intent is to transform a TaskResult object into something easier to
  81. manipulate. TaskResult is ansible.executor.task_result.TaskResult.
  82. """
  83. # pylint: disable=protected-access; Ansible gives us no sufficient public
  84. # API on TaskResult objects.
  85. _result = failed_task_result._result
  86. return {
  87. 'host': failed_task_result._host.get_name(),
  88. 'play': play_name(failed_task_result._task),
  89. 'task': failed_task_result.task_name,
  90. 'msg': _result.get('msg', FAILED_NO_MSG),
  91. 'checks': tuple(
  92. (name, result.get('msg', FAILED_NO_MSG))
  93. for name, result in sorted(_result.get('checks', {}).items())
  94. if result.get('failed')
  95. ),
  96. }
  97. def play_name(obj):
  98. """Given a task or block, return the name of its parent play.
  99. This is loosely inspired by ansible.playbook.base.Base.dump_me.
  100. """
  101. # pylint: disable=protected-access; Ansible gives us no sufficient public
  102. # API to implement this.
  103. if not obj:
  104. return ''
  105. if hasattr(obj, '_play'):
  106. return obj._play.get_name()
  107. return play_name(getattr(obj, '_parent'))
  108. def deduplicate_failures(failures):
  109. """Group together similar failures from different hosts.
  110. Returns a new list of failures such that identical failures from different
  111. hosts are grouped together in a single entry. The relative order of failures
  112. is preserved.
  113. """
  114. groups = defaultdict(list)
  115. for failure in failures:
  116. group_key = tuple(sorted((key, value) for key, value in failure.items() if key != 'host'))
  117. groups[group_key].append(failure)
  118. result = []
  119. for failure in failures:
  120. group_key = tuple(sorted((key, value) for key, value in failure.items() if key != 'host'))
  121. if group_key not in groups:
  122. continue
  123. failure['host'] = tuple(sorted(g_failure['host'] for g_failure in groups.pop(group_key)))
  124. result.append(failure)
  125. return result
  126. def format_failure(failure):
  127. """Return a list of pretty-formatted text entries describing a failure, including
  128. relevant information about it. Expect that the list of text entries will be joined
  129. by a newline separator when output to the user."""
  130. host = u', '.join(failure['host'])
  131. play = failure['play']
  132. task = failure['task']
  133. msg = failure['msg']
  134. checks = failure['checks']
  135. fields = (
  136. (u'Hosts', host),
  137. (u'Play', play),
  138. (u'Task', task),
  139. (u'Message', stringc(msg, C.COLOR_ERROR)),
  140. )
  141. if checks:
  142. fields += ((u'Details', format_failed_checks(checks)),)
  143. row_format = '{:10}{}'
  144. return [row_format.format(header + u':', body) for header, body in fields]
  145. def format_failed_checks(checks):
  146. """Return pretty-formatted text describing checks that failed."""
  147. messages = []
  148. for name, message in checks:
  149. messages.append(u'check "{}":\n{}'.format(name, message))
  150. return stringc(u'\n\n'.join(messages), C.COLOR_ERROR)
  151. def check_failure_footer(failed_checks, context, playbook):
  152. """Return a textual explanation about checks depending on context.
  153. The purpose of specifying context is to vary the output depending on what
  154. the user was expecting to happen (based on which playbook they ran). The
  155. only use currently is to vary the message depending on whether the user was
  156. deliberately running checks or was trying to install/upgrade and checks are
  157. just included. Other use cases may arise.
  158. """
  159. checks = ','.join(sorted(failed_checks))
  160. summary = [u'']
  161. if context in ['pre-install', 'health', 'adhoc']:
  162. # User was expecting to run checks, less explanation needed.
  163. summary.extend([
  164. u'You may configure or disable checks by setting Ansible '
  165. u'variables. To disable those above, set:',
  166. u' openshift_disable_check={checks}'.format(checks=checks),
  167. u'Consult check documentation for configurable variables.',
  168. ])
  169. else:
  170. # User may not be familiar with the checks, explain what checks are in
  171. # the first place.
  172. summary.extend([
  173. u'The execution of "{playbook}" includes checks designed to fail '
  174. u'early if the requirements of the playbook are not met. One or '
  175. u'more of these checks failed. To disregard these results,'
  176. u'explicitly disable checks by setting an Ansible variable:'.format(playbook=playbook),
  177. u' openshift_disable_check={checks}'.format(checks=checks),
  178. u'Failing check names are shown in the failure details above. '
  179. u'Some checks may be configurable by variables if your requirements '
  180. u'are different from the defaults; consult check documentation.',
  181. ])
  182. summary.append(
  183. u'Variables can be set in the inventory or passed on the command line '
  184. u'using the -e flag to ansible-playbook.'
  185. )
  186. return u'\n'.join(summary)