├── test-files ├── non-variable.txt ├── non-variable.txt.j2 ├── variable.txt ├── variable.txt.j2 └── included-tasks.yml ├── .pylintrc ├── img ├── with_anstomlog.png └── without_anstomlog.png ├── test-2.yml ├── test-multiple-hosts.yml ├── test-31.yml ├── test-5.yml ├── ansible.cfg ├── hosts ├── roles ├── imported │ └── tasks │ │ └── main.yml └── test-role │ └── tasks │ ├── imported_task.yml │ └── main.yml ├── test-handler.yml ├── test-18.yml ├── test-4.yml ├── test-12.yml ├── test-issues-1.yml ├── test-all.yml ├── test-1.yml ├── test-long.yml ├── LICENSE ├── .gitignore ├── README.md └── callbacks └── anstomlog.py /test-files/non-variable.txt: -------------------------------------------------------------------------------- 1 | nope nope nope -------------------------------------------------------------------------------- /test-files/non-variable.txt.j2: -------------------------------------------------------------------------------- 1 | nope nope nope -------------------------------------------------------------------------------- /test-files/variable.txt: -------------------------------------------------------------------------------- 1 | Sun Mar 5 22:19:47 CET 2023 -------------------------------------------------------------------------------- /test-files/variable.txt.j2: -------------------------------------------------------------------------------- 1 | {{ lookup('pipe','date') }} -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=E1101, C0103, C0114, C0115, C0116, W0212 3 | -------------------------------------------------------------------------------- /img/with_anstomlog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octplane/ansible_stdout_compact_logger/HEAD/img/with_anstomlog.png -------------------------------------------------------------------------------- /test-2.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test single execution" 3 | hosts: first,second 4 | tasks: 5 | - command: "ls ." 6 | -------------------------------------------------------------------------------- /img/without_anstomlog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octplane/ansible_stdout_compact_logger/HEAD/img/without_anstomlog.png -------------------------------------------------------------------------------- /test-multiple-hosts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test case for issue unicode" 3 | hosts: first,second 4 | tasks: 5 | - command: "hostname" 6 | -------------------------------------------------------------------------------- /test-31.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test single execution" 3 | hosts: unreach 4 | ignore_unreachable: true 5 | tasks: 6 | - command: "ls ." 7 | -------------------------------------------------------------------------------- /test-5.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test case for issue unicode" 3 | hosts: first 4 | tasks: 5 | - include_tasks: "./test-files/included-tasks.yml" 6 | -------------------------------------------------------------------------------- /test-files/included-tasks.yml: -------------------------------------------------------------------------------- 1 | - block: 2 | - debug: 3 | msg: "Je suis une tâche incluse!" 4 | - name: "Error with élégant name" 5 | debug: msg="Hello world!" 6 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | callback_plugins= ./callbacks 3 | inventory = hosts 4 | interpreter_python = /usr/bin/python3 5 | stdout_callback = anstomlog 6 | 7 | # Silence 8 | retry_files_enabled = False 9 | -------------------------------------------------------------------------------- /hosts: -------------------------------------------------------------------------------- 1 | [first] 2 | localhost ansible_connection=local 3 | 4 | [second] 5 | 127.0.0.1 ansible_connection=local 6 | 7 | [third] 8 | 127.0.0.1 ansible_connection=local 9 | 10 | [unreach] 11 | 1.2.3.4 12 | -------------------------------------------------------------------------------- /roles/imported/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: First task of imported role 4 | file: 5 | path: /tmp/test 6 | state: touch 7 | 8 | - name: Second task of imported role 9 | file: 10 | path: /tmp/test 11 | -------------------------------------------------------------------------------- /test-handler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test single execution" 3 | hosts: localhost 4 | tasks: 5 | - shell: "true" 6 | notify: handler 7 | handlers: 8 | - name: handler 9 | debug: 10 | msg: this is a handler 11 | -------------------------------------------------------------------------------- /roles/test-role/tasks/imported_task.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: First imported task of test-role 4 | file: 5 | path: /tmp/test 6 | state: touch 7 | 8 | - name: Second imported task of test-role 9 | file: 10 | path: /tmp/test 11 | state: absent 12 | -------------------------------------------------------------------------------- /roles/test-role/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Import roles 4 | include_role: 5 | name: imported 6 | 7 | - name: Import tasks 8 | include_tasks: 9 | file: imported_task.yml 10 | 11 | - name: Clean test 12 | file: 13 | path: /tmp/test 14 | state: absent 15 | -------------------------------------------------------------------------------- /test-18.yml: -------------------------------------------------------------------------------- 1 | - hosts: all 2 | gather_facts: False 3 | tasks: 4 | - name: "Test case for #18, run with -vv or add dump_loop_items = True in ansible.cfg to show items" 5 | debug: 6 | msg: "Hello {{item}}" 7 | with_items: 8 | - alice 9 | - bob 10 | -------------------------------------------------------------------------------- /test-4.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test case for issue unicode" 3 | hosts: first, 4 | tasks: 5 | - debug: 6 | msg: "Ansible est ÉLÉGANT!" 7 | - name: "Error when using -v" 8 | command: echo -e "\xe2\x98\xba\x0a" 9 | - name: "Error with élégant name" 10 | debug: msg="Hello world!" 11 | -------------------------------------------------------------------------------- /test-12.yml: -------------------------------------------------------------------------------- 1 | - hosts: all 2 | gather_facts: no 3 | tasks: 4 | - local_action: 5 | module: debug 6 | msg: "test debug not printed (test case for #12)" 7 | - local_action: 8 | module: debug 9 | msg: "test debug printed (test case for #12)" 10 | changed_when: true 11 | -------------------------------------------------------------------------------- /test-issues-1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test case for issue 1" 3 | hosts: first 4 | tasks: 5 | - template: 6 | src: "test-files/{{ item.src }}" 7 | dest: "test-files/{{ item.dest }}" 8 | with_items: 9 | - { src: 'variable.txt.j2', dest: 'variable.txt'} 10 | - { src: 'non-variable.txt.j2', dest: 'non-variable.txt'} -------------------------------------------------------------------------------- /test-all.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test single execution" 3 | hosts: all 4 | vars: 5 | version: coucou 6 | tasks: 7 | - command: "sleep 1" 8 | - name: "Secret action" 9 | command: "sleep 1" 10 | no_log: True 11 | - name: "Crash on half of the servers" 12 | assert: 13 | that: 14 | - "{{ 'second' in group_names }}" 15 | -------------------------------------------------------------------------------- /test-1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test single execution" 3 | hosts: localhost 4 | vars: 5 | version: coucou 6 | tasks: 7 | - command: "sleep 1" 8 | - name: "Secret action" 9 | command: "sleep 1" 10 | no_log: True 11 | - name: "Validate version is a number, > 0" 12 | assert: 13 | that: 14 | - "{{ version | int }} != 0" 15 | msg: "'version' must be a number and > 0, is \"{{version}}\"" 16 | 17 | roles: 18 | - test-role -------------------------------------------------------------------------------- /test-long.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Test single execution" 3 | hosts: localhost 4 | vars: 5 | version: coucou 6 | tasks: 7 | - name: Long running task (1m2s) 8 | command: "sleep 62" 9 | - name: "Secret action" 10 | command: "sleep 1" 11 | no_log: True 12 | - name: "Validate version is a number, > 0" 13 | assert: 14 | that: 15 | - "{{ version | int }} != 0" 16 | msg: "'version' must be a number and > 0, is \"{{version}}\"" 17 | 18 | roles: 19 | - test-role -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Pierre Baillet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.pyc 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | .venv/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ansible Stdout Compact Logger 2 | 3 | ## Installation 4 | 5 | - put somewhere on your disk 6 | - add `callback_plugins` settings in your `[defaults]` settings in your ansible configuration 7 | - change stdout_callback to `anstomlog` 8 | 9 | cf `ansible.cfg`. 10 | 11 | ## Features 12 | - [x] one-line display 13 | - [x] pylint compatible (almost...) 14 | - [x] displays tasks content in a nice way 15 | - [x] including UTF-8 strings 16 | - [x] indents structs, displays empty arrays, strings 17 | - [x] puts fields on top when available `['stdout', 'rc', 'stderr', 'start', 'end', 'msg']` 18 | - [x] removes some fields when present `[, 'stdout_lines', '_ansible_verbose_always', '_ansible_verbose_override']` to avoid too much clutter 19 | - [x] reverts to standard logger when more than `vv` verbosity 20 | - [x] supports `no_log` attribute in Task 21 | - [x] supports `_ansible_verbose_always` and `_ansible_verbose_override` 22 | - [x] supports multiple items in task (#1) 23 | - [x] multi host support 24 | - [x] correct duration computation 25 | - [x] Display duration in minutes when tasks last for more than 1 minute 26 | - [x] diff display support 27 | - [x] displays `stdout` and `stderr` nicely even when they contain `\n` 28 | - [x] displays handlers calls 29 | - [x] Python 2/3 compatible 30 | - [ ] better line colouring 31 | - [ ] more test around curious errors 32 | 33 | ## Without anstomlog 34 | 35 | ![Stdout Display without anstomlog](img/without_anstomlog.png) 36 | 37 | ## With anstomlog 38 | 39 | ![Stdout Display with multiline outputs](img/with_anstomlog.png) 40 | 41 | ## Test the logger 42 | 43 | - clone this repository 44 | ``` 45 | ansible-playbook test-1.yml 46 | ``` 47 | - to run the tests, call `python callbacks/anstomlog.py` 48 | 49 | ## License 50 | 51 | MIT, see LICENSE file. 52 | 53 | ## Tips and tricks 54 | 55 | - Issue with non-ascii or utf-8 chars? Have a look at #4 56 | 57 | ## Contributors 58 | 59 | - Pierre BAILLET @octplane 60 | - Frédéric COMBES @marthym 61 | - @tterranigma 62 | - @OurFriendIrony 63 | - Farzad FARID @farzy 64 | - Jérôme BAROTIN @jbarotin 65 | - Dale Henries @dalehenries 66 | - Thomas Decaux @ebuildy 67 | 68 | -------------------------------------------------------------------------------- /callbacks/anstomlog.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | from __future__ import (absolute_import, division, print_function) 4 | __metaclass__ = type 5 | 6 | import sys 7 | import os 8 | from datetime import datetime 9 | 10 | from ansible.utils.color import colorize, hostcolor, ANSIBLE_COLOR 11 | from ansible.plugins.callback import CallbackBase 12 | from ansible import constants as C 13 | from ansible.vars.clean import strip_internal_keys, module_response_deepcopy 14 | from ansible.parsing.ajson import AnsibleJSONEncoder 15 | 16 | import unittest 17 | 18 | DOCUMENTATION = r''' 19 | options: 20 | display_skipped_hosts: 21 | name: Show skipped hosts 22 | description: "Toggle to control displaying skipped task/host results in a task" 23 | type: bool 24 | default: yes 25 | env: 26 | - name: ANSIBLE_DISPLAY_SKIPPED_HOSTS 27 | ini: 28 | - key: display_skipped_hosts 29 | section: defaults 30 | display_ok_hosts: 31 | name: Show 'ok' hosts 32 | description: "Toggle to control displaying 'ok' task/host results in a task" 33 | type: bool 34 | default: yes 35 | env: 36 | - name: ANSIBLE_DISPLAY_OK_HOSTS 37 | ini: 38 | - key: display_ok_hosts 39 | section: defaults 40 | dump_loop_items: 41 | name: Dump loop items 42 | description: "Show the details of loop executions" 43 | type: bool 44 | default: no 45 | env: 46 | - name: ANSIBLE_DUMP_LOOP_ITEMS 47 | ini: 48 | - key: dump_loop_items 49 | section: defaults 50 | ''' 51 | 52 | 53 | # Fields we would like to see before the others, in this order, please... 54 | PREFERED_FIELDS = ['stdout', 'rc', 'stderr', 'start', 'end', 'msg'] 55 | # Fields we will delete from the result 56 | DELETABLE_FIELDS = [ 57 | 'stdout', 'stdout_lines', 'rc', 'stderr', 'start', 'end', 'msg', 58 | '_ansible_verbose_always', '_ansible_no_log', 'invocation', 59 | '_ansible_parsed', '_ansible_item_result', '_ansible_ignore_errors', 60 | '_ansible_item_label'] 61 | 62 | 63 | def deep_serialize(data, indent=0): 64 | # pylint: disable=I0011,E0602,R0912,W0631 65 | 66 | padding = " " * indent * 2 67 | if isinstance(data, list): 68 | if data == []: 69 | return "[]" 70 | output = "[ " 71 | if len(data) == 1: 72 | output = output + \ 73 | ("\n" + 74 | padding).join(deep_serialize(data[0], 0).splitlines()) + " ]" 75 | else: 76 | list_padding = " " * (indent + 1) * 2 77 | 78 | for item in data: 79 | output = output + "\n" + list_padding + "- " + \ 80 | deep_serialize(item, indent) 81 | output = output + "\n" + padding + " ]" 82 | elif isinstance(data, dict): 83 | if "_ansible_no_log" in data and data["_ansible_no_log"]: 84 | data = {"censored": 85 | "the output has been hidden due to the fact that" 86 | " 'no_log: true' was specified for this result"} 87 | list_padding = " " * (indent + 1) * 2 88 | output = "{\n" 89 | 90 | for key in PREFERED_FIELDS: 91 | if key in data.keys(): 92 | value = data[key] 93 | prefix = list_padding + "- %s: " % key 94 | output = output + prefix + "%s\n" % \ 95 | "\n".join([" " * len(prefix) + line 96 | for line in deep_serialize(value, indent) 97 | .splitlines()]).strip() 98 | 99 | for key in DELETABLE_FIELDS: 100 | if key in data.keys(): 101 | del data[key] 102 | 103 | for key, value in data.items(): 104 | output = output + list_padding + \ 105 | "- %s: %s\n" % (key, deep_serialize(value, indent + 1)) 106 | 107 | output = output + padding + "}" 108 | else: 109 | string_form = str(data) 110 | if len(string_form) == 0: 111 | return "\"\"" 112 | 113 | return string_form 114 | return output 115 | 116 | 117 | class TestStringMethods(unittest.TestCase): 118 | 119 | test_structure = { 120 | u'cmd': [u'false'], u'end': u'2016-12-29 16:46:04.151591', 121 | '_ansible_no_log': False, u'stdout': u'', u'changed': True, 'failed': True, 122 | u'delta': u'0:00:00.005046', u'stderr': u'', u'rc': 1, 'invocation': 123 | {'module_name': u'command', 124 | u'module_args': { 125 | u'creates': None, u'executable': None, u'chdir': None, 126 | u'_raw_params': u'false', u'removes': None, 127 | u'warn': True, u'_uses_shell': False}}, 128 | 'stdout_lines': [], u'start': u'2016-12-29 16:46:04.146545', u'warnings': []} 129 | 130 | def test_single_item_array(self): 131 | self.assertEqual( 132 | deep_serialize(self.test_structure['cmd']), 133 | "[ false ]") 134 | 135 | def test_single_empty_item_array(self): 136 | self.assertEqual( 137 | deep_serialize([""]), 138 | "[ \"\" ]") 139 | 140 | def test_issue_4(self): 141 | self.assertEqual( 142 | deep_serialize(["ÉLÉGANT"]), 143 | "[ ÉLÉGANT ]") 144 | 145 | def test_empty_array(self): 146 | self.assertEqual( 147 | deep_serialize(self.test_structure['stdout_lines']), 148 | "[]") 149 | 150 | def test_simple_hash(self): 151 | hs = {"cmd": "toto", "ret": 12} 152 | expected_result = "{\n - cmd: toto\n - ret: 12\n}" 153 | self.assertEqual(deep_serialize(hs), expected_result) 154 | 155 | def test_hash_array(self): 156 | hs = {u'cmd': [u'false']} 157 | expected_result = "{\n - cmd: [ false ]\n}" 158 | self.assertEqual(deep_serialize(hs), expected_result) 159 | 160 | def test_hash_array2(self): 161 | hs = {u'cmd': ['one', 'two']} 162 | expected_result = """{ 163 | - cmd: [ 164 | - one 165 | - two 166 | ] 167 | }""" 168 | self.assertEqual(deep_serialize(hs), expected_result) 169 | 170 | def test_favorite_hash(self): 171 | hs = {"cmd": "toto", "rc": 12} 172 | expected_result = "{\n - rc: 12\n - cmd: toto\n}" 173 | self.assertEqual(deep_serialize(hs), expected_result) 174 | 175 | def test_nested(self): 176 | hs = {u'cmd': {'bar': ['one', 'two']}} 177 | expected_result = """{ 178 | - cmd: { 179 | - bar: [ 180 | - one 181 | - two 182 | ] 183 | } 184 | }""" 185 | self.assertEqual(deep_serialize(hs), expected_result) 186 | 187 | def test_multiline_single(self): 188 | # pylint: disable=I0011,C0303 189 | hs = [["foo", "bar"]] 190 | expected_result = """[ [ 191 | - foo 192 | - bar 193 | ] ]""" 194 | # print(deep_serialize(hs)) 195 | # print(expected_result) 196 | self.assertEqual(deep_serialize(hs), expected_result) 197 | 198 | def test_empty_array_no_padding(self): 199 | hs = [[{"foo": []}]] 200 | expected_result = """[ [ { 201 | - foo: [] 202 | } ] ]""" 203 | # print(deep_serialize(hs)) 204 | # print(expected_result) 205 | self.assertEqual(deep_serialize(hs), expected_result) 206 | 207 | def test_hidden_fields(self): 208 | hs = {"_ansible_verbose_always": True} 209 | expected_result = """{ 210 | }""" 211 | # print(deep_serialize(hs)) 212 | # print(expected_result) 213 | self.assertEqual(deep_serialize(hs), expected_result) 214 | 215 | 216 | class CallbackModule(CallbackBase): 217 | 218 | ''' 219 | This is the default callback interface, which simply prints messages 220 | to stdout when new callback events are received. 221 | ''' 222 | 223 | CALLBACK_VERSION = 2.0 224 | CALLBACK_TYPE = 'stdout' 225 | CALLBACK_NAME = 'anstomlog' 226 | 227 | def _get_duration(self): 228 | end = datetime.now() 229 | total_duration = (end - self.task_started) 230 | seconds = total_duration.total_seconds() 231 | if seconds >= 60: 232 | seconds_remaining = seconds % 60 233 | minutes = (seconds - seconds_remaining) / 60 234 | duration = "{0:.0f}m{1:.0f}s".format(minutes, seconds_remaining) 235 | elif seconds >= 1: 236 | duration = "{0:.2f}s".format(seconds) 237 | else: 238 | duration = "{0:.0f}ms".format(seconds * 1000) 239 | return duration 240 | 241 | def _command_generic_msg(self, hostname, result, caption): 242 | duration = self._get_duration() 243 | 244 | stdout = result.get('stdout', '') 245 | if self._display.verbosity > 0: 246 | if 'stderr' in result and result['stderr']: 247 | stderr = result.get('stderr', '') 248 | return "%s | %s | %s | rc=%s | stdout: \n%s\n\n\t\t\t\tstderr: %s" % \ 249 | (hostname, caption, duration, 250 | result.get('rc', 0), stdout, stderr) 251 | 252 | if len(stdout) > 0: 253 | return "%s | %s | %s | rc=%s | stdout: \n%s\n" % \ 254 | (hostname, caption, duration, result.get('rc', 0), stdout) 255 | 256 | return "%s | %s | %s | rc=%s | no stdout" % \ 257 | (hostname, caption, duration, result.get('rc', 0)) 258 | 259 | return "%s | %s | %s | rc=%s" % (hostname, caption, duration, result.get('rc', 0)) 260 | 261 | def v2_playbook_on_task_start(self, task, is_conditional): 262 | parentTask = task.get_first_parent_include() 263 | if parentTask is not None: 264 | if parentTask.action.endswith('tasks'): 265 | parentTaskName = os.path.splitext(os.path.basename(task.get_path()))[0] 266 | self._open_section(" ↳ {}: {}".format(parentTaskName, task.name)) 267 | else: 268 | sectionName = task._role.get_name() 269 | self._open_section(" ↳ {}: {}".format(sectionName, task.name)) 270 | else: 271 | self._open_section(task.get_name(), task.get_path()) 272 | 273 | def _open_section(self, section_name, path=None): 274 | self.task_started = datetime.now() 275 | 276 | prefix = '' 277 | ts = self.task_started.strftime("%H:%M:%S") 278 | 279 | if self._display.verbosity > 1: 280 | if path: 281 | self._emit_line("[{}]: {}".format(ts, path)) 282 | self.task_start_preamble = "[{}]{} {}\n".format(ts, prefix, section_name) 283 | sys.stdout.write(self.task_start_preamble) 284 | 285 | def v2_playbook_on_handler_task_start(self, task): 286 | self._emit_line("triggering handler | %s " % task.get_name().strip()) 287 | 288 | def v2_runner_on_failed(self, result, ignore_errors=False): 289 | duration = self._get_duration() 290 | host_string = self._host_string(result) 291 | 292 | if 'exception' in result._result: 293 | exception_message = "An exception occurred during task execution." 294 | if self._display.verbosity < 3: 295 | # extract just the actual error message from the exception text 296 | error = result._result['exception'].strip().split('\n')[-1] 297 | msg = exception_message + \ 298 | "To see the full traceback, use -vvv. The error was: %s" % error 299 | else: 300 | msg = exception_message + \ 301 | "The full traceback is:\n" + \ 302 | result._result['exception'].replace('\n', '') 303 | 304 | self._emit_line(msg, color=C.COLOR_ERROR) 305 | 306 | self._emit_line("%s | FAILED | %s" % 307 | (host_string, 308 | duration), color=C.COLOR_ERROR) 309 | self._emit_line(deep_serialize(result._result), color=C.COLOR_ERROR) 310 | 311 | def v2_on_file_diff(self, result): 312 | 313 | if result._task.loop and 'results' in result._result: 314 | for res in result._result['results']: 315 | if 'diff' in res and res['diff'] and res.get('changed', False): 316 | diff = self._get_diff(res['diff']) 317 | if diff: 318 | self._emit_line(diff) 319 | 320 | elif 'diff' in result._result and \ 321 | result._result['diff'] and \ 322 | result._result.get('changed', False): 323 | diff = self._get_diff(result._result['diff']) 324 | if diff: 325 | self._emit_line(diff) 326 | 327 | @staticmethod 328 | def _host_string(result): 329 | delegated_vars = result._result.get('_ansible_delegated_vars', None) 330 | 331 | if delegated_vars: 332 | host_string = "%s -> %s" % ( 333 | result._host.get_name(), delegated_vars['ansible_host']) 334 | else: 335 | host_string = result._host.get_name() 336 | 337 | return host_string 338 | 339 | def v2_runner_on_ok(self, result): 340 | duration = self._get_duration() 341 | host_string = self._host_string(result) 342 | display_ok = self.get_option("display_ok_hosts") 343 | msg, color = self._changed_or_not(result._result, host_string) 344 | 345 | if not display_ok: 346 | return 347 | 348 | verbose = '_ansible_verbose_always' in result._result 349 | no_verbose_override = '_ansible_verbose_override' not in result._result 350 | 351 | abridged_result = strip_internal_keys(module_response_deepcopy(result._result)) 352 | if self._display.verbosity < 3 and 'invocation' in abridged_result: 353 | del abridged_result['invocation'] 354 | 355 | # remove diff information from screen output 356 | if self._display.verbosity < 3 and 'diff' in abridged_result: 357 | del abridged_result['diff'] 358 | 359 | # remove exception from screen output 360 | if 'exception' in abridged_result: 361 | del abridged_result['exception'] 362 | 363 | if (self.get_option("dump_loop_items") or \ 364 | self._display.verbosity > 0) \ 365 | and result._task.loop \ 366 | and 'results' in result._result: 367 | # remove invocation unless specifically wanting it 368 | for item in abridged_result['results']: 369 | msg, color = self._changed_or_not(item, host_string) 370 | del item['ansible_loop_var'] 371 | del item['failed'] 372 | del item['changed'] 373 | item_msg = "%s - item=%s" % (msg, item) 374 | self._emit_line("%s | %s" % 375 | (item_msg, duration), color=color) 376 | else: 377 | for key in ['failed', 'changed']: 378 | if key in abridged_result: 379 | del abridged_result[key] 380 | 381 | self._emit_line("↳ %s | %s" % 382 | (msg, duration), color=color) 383 | if ((self._display.verbosity > 0 384 | or verbose) 385 | and no_verbose_override): 386 | self._emit_line(deep_serialize(abridged_result), color=color) 387 | 388 | self._clean_results(result._result, result._task.action) 389 | self._handle_warnings(result._result) 390 | 391 | result._preamble = self.task_start_preamble 392 | 393 | def eat(self, count=4): 394 | if ANSIBLE_COLOR: 395 | sys.stdout.write(count*"\b") 396 | 397 | @staticmethod 398 | def _changed_or_not(result, host_string): 399 | if result.get('changed', False): 400 | msg = "%s | CHANGED" % host_string 401 | color = C.COLOR_CHANGED 402 | else: 403 | msg = "%s | SUCCESS" % host_string 404 | color = C.COLOR_OK 405 | 406 | return [msg, color] 407 | 408 | def _emit_line(self, lines, color=C.COLOR_OK): 409 | 410 | if self.task_start_preamble is None: 411 | self._open_section("system") 412 | 413 | if self.task_start_preamble.endswith(" ..."): 414 | self.eat() 415 | self.stdout.write(" | ") 416 | self.task_start_preamble = " " 417 | 418 | for line in lines.splitlines(): 419 | self._display.display(line, color=color) 420 | 421 | def v2_runner_on_unreachable(self, result): 422 | line = '{} | UNREACHABLE!: {}'.format( 423 | self._host_string(result), result._result.get('msg', '')) 424 | 425 | 426 | if result._task.ignore_unreachable: 427 | line = line + " | IGNORED" 428 | 429 | self._emit_line(line, C.COLOR_SKIP) 430 | 431 | def v2_runner_on_skipped(self, result): 432 | display_skipped = self.get_option('display_skipped_hosts') 433 | if not display_skipped: 434 | return 435 | 436 | duration = self._get_duration() 437 | 438 | self._emit_line("%s | SKIPPED | %s" % 439 | (self._host_string(result), duration), color=C.COLOR_SKIP) 440 | 441 | def v2_playbook_on_include(self, included_file): 442 | if self.task_start_preamble.endswith(" ..."): 443 | self.task_start_preamble = " " 444 | msg = '| {} | {} | {}'.format( 445 | ", ".join([h.name for h in included_file._hosts]), 446 | 'INCLUDED', 447 | os.path.basename(included_file._filename)) 448 | self._display.display(msg, color=C.COLOR_SKIP) 449 | 450 | def v2_playbook_on_stats(self, stats): 451 | self._open_section("system") 452 | self._emit_line("-- Play recap --") 453 | 454 | hosts = sorted(stats.processed.keys()) 455 | for h in hosts: 456 | t = stats.summarize(h) 457 | 458 | self._emit_line(u"%s : %s %s %s %s %s %s %s" % ( 459 | hostcolor(h, t), 460 | colorize(u'ok', t['ok'], C.COLOR_OK), 461 | colorize(u'changed', t['changed'], C.COLOR_CHANGED), 462 | colorize(u'unreachable', t['unreachable'], C.COLOR_UNREACHABLE), 463 | colorize(u'failed', t['failures'], C.COLOR_ERROR), 464 | colorize(u'skipped', t['skipped'], C.COLOR_SKIP), 465 | colorize(u'rescued', t['rescued'], C.COLOR_OK), 466 | colorize(u'ignored', t['ignored'], C.COLOR_WARN))) 467 | 468 | def __init__(self, *args, **kwargs): 469 | super(CallbackModule, self).__init__(*args, **kwargs) 470 | self.task_started = datetime.now() 471 | self.task_start_preamble = None 472 | 473 | 474 | if __name__ == '__main__': 475 | unittest.main() 476 | --------------------------------------------------------------------------------