1""" 2Outputter for displaying results of state runs 3============================================== 4 5The return data from the Highstate command is a standard data structure 6which is parsed by the highstate outputter to deliver a clean and readable 7set of information about the HighState run on minions. 8 9Two configurations can be set to modify the highstate outputter. These values 10can be set in the master config to change the output of the ``salt`` command or 11set in the minion config to change the output of the ``salt-call`` command. 12 13state_verbose 14 By default `state_verbose` is set to `True`, setting this to `False` will 15 instruct the highstate outputter to omit displaying anything in green, this 16 means that nothing with a result of True and no changes will not be printed 17state_output: 18 The highstate outputter has six output modes, 19 ``full``, ``terse``, ``mixed``, ``changes`` and ``filter`` 20 21 * The default is set to ``full``, which will display many lines of detailed 22 information for each executed chunk. 23 24 * If ``terse`` is used, then the output is greatly simplified and shown in 25 only one line. 26 27 * If ``mixed`` is used, then terse output will be used unless a state 28 failed, in which case full output will be used. 29 30 * If ``changes`` is used, then terse output will be used if there was no 31 error and no changes, otherwise full output will be used. 32 33 * If ``filter`` is used, then either or both of two different filters can be 34 used: ``exclude`` or ``terse``. 35 36 * for ``exclude``, state.highstate expects a list of states to be excluded (or ``None``) 37 followed by ``True`` for terse output or ``False`` for regular output. 38 Because of parsing nuances, if only one of these is used, it must still 39 contain a comma. For instance: `exclude=True,`. 40 41 * for ``terse``, state.highstate expects simply ``True`` or ``False``. 42 43 These can be set as such from the command line, or in the Salt config as 44 `state_output_exclude` or `state_output_terse`, respectively. 45 46 The output modes have one modifier: 47 48 ``full_id``, ``terse_id``, ``mixed_id``, ``changes_id`` and ``filter_id`` 49 If ``_id`` is used, then the corresponding form will be used, but the value for ``name`` 50 will be drawn from the state ID. This is useful for cases where the name 51 value might be very long and hard to read. 52 53state_tabular: 54 If `state_output` uses the terse output, set this to `True` for an aligned 55 output format. If you wish to use a custom format, this can be set to a 56 string. 57 58Example usage: 59 60If ``state_output: filter`` is set in the configuration file: 61 62.. code-block:: bash 63 64 salt '*' state.highstate exclude=None,True 65 66 67means to exclude no states from the highstate and turn on terse output. 68 69.. code-block:: bash 70 71 salt twd state.highstate exclude=problemstate1,problemstate2,False 72 73 74means to exclude states ``problemstate1`` and ``problemstate2`` 75from the highstate, and use regular output. 76 77Example output for the above highstate call when ``top.sls`` defines only 78one other state to apply to minion ``twd``: 79 80.. code-block:: text 81 82 twd: 83 84 Summary for twd 85 ------------ 86 Succeeded: 1 (changed=1) 87 Failed: 0 88 ------------ 89 Total states run: 1 90 91 92Example output with no special settings in configuration files: 93 94.. code-block:: text 95 96 myminion: 97 ---------- 98 ID: test.ping 99 Function: module.run 100 Result: True 101 Comment: Module function test.ping executed 102 Changes: 103 ---------- 104 ret: 105 True 106 107 Summary for myminion 108 ------------ 109 Succeeded: 1 110 Failed: 0 111 ------------ 112 Total: 0 113""" 114 115 116import logging 117import pprint 118import re 119import textwrap 120 121import salt.output 122import salt.utils.color 123import salt.utils.data 124import salt.utils.stringutils 125 126log = logging.getLogger(__name__) 127 128 129def output(data, **kwargs): # pylint: disable=unused-argument 130 """ 131 The HighState Outputter is only meant to be used with the state.highstate 132 function, or a function that returns highstate return data. 133 """ 134 # If additional information is passed through via the "data" dictionary to 135 # the highstate outputter, such as "outputter" or "retcode", discard it. 136 # We only want the state data that was passed through, if it is wrapped up 137 # in the "data" key, as the orchestrate runner does. See Issue #31330, 138 # pull request #27838, and pull request #27175 for more information. 139 # account for envelope data if being passed lookup_jid ret 140 if isinstance(data, dict) and "return" in data: 141 data = data["return"] 142 143 if isinstance(data, dict) and "data" in data: 144 data = data["data"] 145 146 # account for envelope data if being passed lookup_jid ret 147 if isinstance(data, dict) and len(data.keys()) == 1: 148 _data = next(iter(data.values())) 149 150 if isinstance(_data, dict): 151 if "jid" in _data and "fun" in _data: 152 data = _data.get("return", {}).get("data", data) 153 154 # output() is recursive, if we aren't passed a dict just return it 155 if isinstance(data, int) or isinstance(data, str): 156 return data 157 158 if data is None: 159 return "None" 160 161 # Discard retcode in dictionary as present in orchestrate data 162 local_masters = [key for key in data.keys() if key.endswith("_master")] 163 orchestrator_output = "retcode" in data.keys() and len(local_masters) == 1 164 165 if orchestrator_output: 166 del data["retcode"] 167 168 indent_level = kwargs.get("indent_level", 1) 169 ret = [ 170 _format_host(host, hostdata, indent_level=indent_level)[0] 171 for host, hostdata in data.items() 172 ] 173 if ret: 174 return "\n".join(ret) 175 log.error( 176 "Data passed to highstate outputter is not a valid highstate return: %s", data 177 ) 178 # We should not reach here, but if we do return empty string 179 return "" 180 181 182def _format_host(host, data, indent_level=1): 183 """ 184 Main highstate formatter. can be called recursively if a nested highstate 185 contains other highstates (ie in an orchestration) 186 """ 187 host = salt.utils.data.decode(host) 188 189 colors = salt.utils.color.get_colors( 190 __opts__.get("color"), __opts__.get("color_theme") 191 ) 192 tabular = __opts__.get("state_tabular", False) 193 rcounts = {} 194 rdurations = [] 195 hcolor = colors["GREEN"] 196 hstrs = [] 197 nchanges = 0 198 strip_colors = __opts__.get("strip_colors", True) 199 200 if isinstance(data, int): 201 nchanges = 1 202 hstrs.append("{0} {1}{2[ENDC]}".format(hcolor, data, colors)) 203 hcolor = colors["CYAN"] # Print the minion name in cyan 204 elif isinstance(data, str): 205 # Data in this format is from saltmod.function, 206 # so it is always a 'change' 207 nchanges = 1 208 for data in data.splitlines(): 209 hstrs.append("{0} {1}{2[ENDC]}".format(hcolor, data, colors)) 210 hcolor = colors["CYAN"] # Print the minion name in cyan 211 elif isinstance(data, list): 212 # Errors have been detected, list them in RED! 213 hcolor = colors["LIGHT_RED"] 214 hstrs.append(" {0}Data failed to compile:{1[ENDC]}".format(hcolor, colors)) 215 for err in data: 216 if strip_colors: 217 err = salt.output.strip_esc_sequence(salt.utils.data.decode(err)) 218 hstrs.append("{0}----------\n {1}{2[ENDC]}".format(hcolor, err, colors)) 219 elif isinstance(data, dict): 220 # Verify that the needed data is present 221 data_tmp = {} 222 for tname, info in data.items(): 223 if ( 224 isinstance(info, dict) 225 and tname != "changes" 226 and info 227 and "__run_num__" not in info 228 ): 229 err = ( 230 "The State execution failed to record the order " 231 "in which all states were executed. The state " 232 "return missing data is:" 233 ) 234 hstrs.insert(0, pprint.pformat(info)) 235 hstrs.insert(0, err) 236 if isinstance(info, dict) and "result" in info: 237 data_tmp[tname] = info 238 data = data_tmp 239 # Everything rendered as it should display the output 240 for tname in sorted(data, key=lambda k: data[k].get("__run_num__", 0)): 241 ret = data[tname] 242 # Increment result counts 243 rcounts.setdefault(ret["result"], 0) 244 rcounts[ret["result"]] += 1 245 rduration = ret.get("duration", 0) 246 try: 247 rdurations.append(float(rduration)) 248 except ValueError: 249 rduration, _, _ = rduration.partition(" ms") 250 try: 251 rdurations.append(float(rduration)) 252 except ValueError: 253 log.error( 254 "Cannot parse a float from duration %s", ret.get("duration", 0) 255 ) 256 257 tcolor = colors["GREEN"] 258 if ret.get("name") in ["state.orch", "state.orchestrate", "state.sls"]: 259 nested = output(ret["changes"], indent_level=indent_level + 1) 260 ctext = re.sub( 261 "^", " " * 14 * indent_level, "\n" + nested, flags=re.MULTILINE 262 ) 263 schanged = True 264 nchanges += 1 265 else: 266 schanged, ctext = _format_changes(ret["changes"]) 267 nchanges += 1 if schanged else 0 268 269 # Skip this state if it was successful & diff output was requested 270 if ( 271 __opts__.get("state_output_diff", False) 272 and ret["result"] 273 and not schanged 274 ): 275 continue 276 277 # Skip this state if state_verbose is False, the result is True and 278 # there were no changes made 279 if ( 280 not __opts__.get("state_verbose", False) 281 and ret["result"] 282 and not schanged 283 ): 284 continue 285 286 if schanged: 287 tcolor = colors["CYAN"] 288 if ret["result"] is False: 289 hcolor = colors["RED"] 290 tcolor = colors["RED"] 291 if ret["result"] is None: 292 hcolor = colors["LIGHT_YELLOW"] 293 tcolor = colors["LIGHT_YELLOW"] 294 295 state_output = __opts__.get("state_output", "full").lower() 296 comps = tname.split("_|-") 297 298 if state_output.endswith("_id"): 299 # Swap in the ID for the name. Refs #35137 300 comps[2] = comps[1] 301 302 if state_output.startswith("filter"): 303 # By default, full data is shown for all types. However, return 304 # data may be excluded by setting state_output_exclude to a 305 # comma-separated list of True, False or None, or including the 306 # same list with the exclude option on the command line. For 307 # now, this option must include a comma. For example: 308 # exclude=True, 309 # The same functionality is also available for making return 310 # data terse, instead of excluding it. 311 cliargs = __opts__.get("arg", []) 312 clikwargs = {} 313 for item in cliargs: 314 if isinstance(item, dict) and "__kwarg__" in item: 315 clikwargs = item.copy() 316 317 exclude = clikwargs.get( 318 "exclude", __opts__.get("state_output_exclude", []) 319 ) 320 if isinstance(exclude, str): 321 exclude = str(exclude).split(",") 322 323 terse = clikwargs.get("terse", __opts__.get("state_output_terse", [])) 324 if isinstance(terse, str): 325 terse = str(terse).split(",") 326 327 if str(ret["result"]) in terse: 328 msg = _format_terse(tcolor, comps, ret, colors, tabular) 329 hstrs.append(msg) 330 continue 331 if str(ret["result"]) in exclude: 332 continue 333 334 elif any( 335 ( 336 state_output.startswith("terse"), 337 state_output.startswith("mixed") 338 and ret["result"] is not False, # only non-error'd 339 state_output.startswith("changes") 340 and ret["result"] 341 and not schanged, # non-error'd non-changed 342 ) 343 ): 344 # Print this chunk in a terse way and continue in the loop 345 msg = _format_terse(tcolor, comps, ret, colors, tabular) 346 hstrs.append(msg) 347 continue 348 349 state_lines = [ 350 "{tcolor}----------{colors[ENDC]}", 351 " {tcolor} ID: {comps[1]}{colors[ENDC]}", 352 " {tcolor}Function: {comps[0]}.{comps[3]}{colors[ENDC]}", 353 " {tcolor} Result: {ret[result]!s}{colors[ENDC]}", 354 " {tcolor} Comment: {comment}{colors[ENDC]}", 355 ] 356 if __opts__.get("state_output_profile") and "start_time" in ret: 357 state_lines.extend( 358 [ 359 " {tcolor} Started: {ret[start_time]!s}{colors[ENDC]}", 360 " {tcolor}Duration: {ret[duration]!s}{colors[ENDC]}", 361 ] 362 ) 363 # This isn't the prettiest way of doing this, but it's readable. 364 if comps[1] != comps[2]: 365 state_lines.insert(3, " {tcolor} Name: {comps[2]}{colors[ENDC]}") 366 # be sure that ret['comment'] is utf-8 friendly 367 try: 368 if not isinstance(ret["comment"], str): 369 ret["comment"] = str(ret["comment"]) 370 except UnicodeDecodeError: 371 # If we got here, we're on Python 2 and ret['comment'] somehow 372 # contained a str type with unicode content. 373 ret["comment"] = salt.utils.stringutils.to_unicode(ret["comment"]) 374 try: 375 comment = salt.utils.data.decode(ret["comment"]) 376 comment = comment.strip().replace("\n", "\n" + " " * 14) 377 except AttributeError: # Assume comment is a list 378 try: 379 comment = ret["comment"].join(" ").replace("\n", "\n" + " " * 13) 380 except AttributeError: 381 # Comment isn't a list either, just convert to string 382 comment = str(ret["comment"]) 383 comment = comment.strip().replace("\n", "\n" + " " * 14) 384 # If there is a data attribute, append it to the comment 385 if "data" in ret: 386 if isinstance(ret["data"], list): 387 for item in ret["data"]: 388 comment = "{} {}".format(comment, item) 389 elif isinstance(ret["data"], dict): 390 for key, value in ret["data"].items(): 391 comment = "{}\n\t\t{}: {}".format(comment, key, value) 392 else: 393 comment = "{} {}".format(comment, ret["data"]) 394 for detail in ["start_time", "duration"]: 395 ret.setdefault(detail, "") 396 if ret["duration"] != "": 397 ret["duration"] = "{} ms".format(ret["duration"]) 398 svars = { 399 "tcolor": tcolor, 400 "comps": comps, 401 "ret": ret, 402 "comment": salt.utils.data.decode(comment), 403 # This nukes any trailing \n and indents the others. 404 "colors": colors, 405 } 406 hstrs.extend([sline.format(**svars) for sline in state_lines]) 407 changes = " Changes: " + ctext 408 hstrs.append("{0}{1}{2[ENDC]}".format(tcolor, changes, colors)) 409 410 if "warnings" in ret: 411 rcounts.setdefault("warnings", 0) 412 rcounts["warnings"] += 1 413 wrapper = textwrap.TextWrapper( 414 width=80, initial_indent=" " * 14, subsequent_indent=" " * 14 415 ) 416 hstrs.append( 417 " {colors[LIGHT_RED]} Warnings: {0}{colors[ENDC]}".format( 418 wrapper.fill("\n".join(ret["warnings"])).lstrip(), colors=colors 419 ) 420 ) 421 422 # Append result counts to end of output 423 colorfmt = "{0}{1}{2[ENDC]}" 424 rlabel = { 425 True: "Succeeded", 426 False: "Failed", 427 None: "Not Run", 428 "warnings": "Warnings", 429 } 430 count_max_len = max([len(str(x)) for x in rcounts.values()] or [0]) 431 label_max_len = max([len(x) for x in rlabel.values()] or [0]) 432 line_max_len = label_max_len + count_max_len + 2 # +2 for ': ' 433 hstrs.append( 434 colorfmt.format( 435 colors["CYAN"], 436 "\nSummary for {}\n{}".format(host, "-" * line_max_len), 437 colors, 438 ) 439 ) 440 441 def _counts(label, count): 442 return "{0}: {1:>{2}}".format(label, count, line_max_len - (len(label) + 2)) 443 444 # Successful states 445 changestats = [] 446 if None in rcounts and rcounts.get(None, 0) > 0: 447 # test=True states 448 changestats.append( 449 colorfmt.format( 450 colors["LIGHT_YELLOW"], 451 "unchanged={}".format(rcounts.get(None, 0)), 452 colors, 453 ) 454 ) 455 if nchanges > 0: 456 changestats.append( 457 colorfmt.format(colors["GREEN"], "changed={}".format(nchanges), colors) 458 ) 459 if changestats: 460 changestats = " ({})".format(", ".join(changestats)) 461 else: 462 changestats = "" 463 hstrs.append( 464 colorfmt.format( 465 colors["GREEN"], 466 _counts(rlabel[True], rcounts.get(True, 0) + rcounts.get(None, 0)), 467 colors, 468 ) 469 + changestats 470 ) 471 472 # Failed states 473 num_failed = rcounts.get(False, 0) 474 hstrs.append( 475 colorfmt.format( 476 colors["RED"] if num_failed else colors["CYAN"], 477 _counts(rlabel[False], num_failed), 478 colors, 479 ) 480 ) 481 482 num_warnings = rcounts.get("warnings", 0) 483 if num_warnings: 484 hstrs.append( 485 colorfmt.format( 486 colors["LIGHT_RED"], 487 _counts(rlabel["warnings"], num_warnings), 488 colors, 489 ) 490 ) 491 totals = "{0}\nTotal states run: {1:>{2}}".format( 492 "-" * line_max_len, 493 sum(rcounts.values()) - rcounts.get("warnings", 0), 494 line_max_len - 7, 495 ) 496 hstrs.append(colorfmt.format(colors["CYAN"], totals, colors)) 497 498 if __opts__.get("state_output_profile"): 499 sum_duration = sum(rdurations) 500 duration_unit = "ms" 501 # convert to seconds if duration is 1000ms or more 502 if sum_duration > 999: 503 sum_duration /= 1000 504 duration_unit = "s" 505 total_duration = "Total run time: {} {}".format( 506 "{:.3f}".format(sum_duration).rjust(line_max_len - 5), duration_unit 507 ) 508 hstrs.append(colorfmt.format(colors["CYAN"], total_duration, colors)) 509 510 if strip_colors: 511 host = salt.output.strip_esc_sequence(host) 512 hstrs.insert(0, "{0}{1}:{2[ENDC]}".format(hcolor, host, colors)) 513 return "\n".join(hstrs), nchanges > 0 514 515 516def _nested_changes(changes): 517 """ 518 Print the changes data using the nested outputter 519 """ 520 ret = "\n" 521 ret += salt.output.out_format(changes, "nested", __opts__, nested_indent=14) 522 return ret 523 524 525def _format_changes(changes, orchestration=False): 526 """ 527 Format the changes dict based on what the data is 528 """ 529 if not changes: 530 return False, "" 531 532 if orchestration: 533 return True, _nested_changes(changes) 534 535 if not isinstance(changes, dict): 536 return True, "Invalid Changes data: {}".format(changes) 537 538 ret = changes.get("ret") 539 if ret is not None and changes.get("out") == "highstate": 540 ctext = "" 541 changed = False 542 for host, hostdata in ret.items(): 543 s, c = _format_host(host, hostdata) 544 ctext += "\n" + "\n".join((" " * 14 + l) for l in s.splitlines()) 545 changed = changed or c 546 else: 547 changed = True 548 ctext = _nested_changes(changes) 549 return changed, ctext 550 551 552def _format_terse(tcolor, comps, ret, colors, tabular): 553 """ 554 Terse formatting of a message. 555 """ 556 result = "Clean" 557 if ret["changes"]: 558 result = "Changed" 559 if ret["result"] is False: 560 result = "Failed" 561 elif ret["result"] is None: 562 result = "Differs" 563 if tabular is True: 564 fmt_string = "" 565 if "warnings" in ret: 566 fmt_string += "{c[LIGHT_RED]}Warnings:\n{w}{c[ENDC]}\n".format( 567 c=colors, w="\n".join(ret["warnings"]) 568 ) 569 fmt_string += "{0}" 570 if __opts__.get("state_output_profile") and "start_time" in ret: 571 fmt_string += "{6[start_time]!s} [{6[duration]!s:>7} ms] " 572 fmt_string += "{2:>10}.{3:<10} {4:7} Name: {1}{5}" 573 elif isinstance(tabular, str): 574 fmt_string = tabular 575 else: 576 fmt_string = "" 577 if "warnings" in ret: 578 fmt_string += "{c[LIGHT_RED]}Warnings:\n{w}{c[ENDC]}".format( 579 c=colors, w="\n".join(ret["warnings"]) 580 ) 581 fmt_string += " {0} Name: {1} - Function: {2}.{3} - Result: {4}" 582 if __opts__.get("state_output_profile") and "start_time" in ret: 583 fmt_string += " Started: - {6[start_time]!s} Duration: {6[duration]!s} ms" 584 fmt_string += "{5}" 585 586 msg = fmt_string.format( 587 tcolor, comps[2], comps[0], comps[-1], result, colors["ENDC"], ret 588 ) 589 return msg 590