multiSSH3 5.86__tar.gz → 5.90__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of multiSSH3 might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.86
3
+ Version: 5.90
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 5.86
3
+ Version: 5.90
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -6,6 +6,31 @@
6
6
  # "ipaddress",
7
7
  # ]
8
8
  # ///
9
+ import argparse
10
+ import functools
11
+ import getpass
12
+ import glob
13
+ import io
14
+ import ipaddress
15
+ import json
16
+ import math
17
+ import os
18
+ import queue
19
+ import re
20
+ import shutil
21
+ import signal
22
+ import socket
23
+ import string
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import threading
28
+ import time
29
+ import typing
30
+ import uuid
31
+ from collections import Counter, deque
32
+ from itertools import count, product
33
+
9
34
  __curses_available = False
10
35
  __resource_lib_available = False
11
36
  try:
@@ -20,30 +45,7 @@ try:
20
45
  except ImportError:
21
46
  pass
22
47
 
23
- import subprocess
24
- import threading
25
- import time
26
- import os
27
- import argparse
28
- from itertools import product
29
- import re
30
- import string
31
- import ipaddress
32
- import sys
33
- import json
34
- import socket
35
- import io
36
- import signal
37
- import functools
38
- import glob
39
- import shutil
40
- import getpass
41
- import uuid
42
- import tempfile
43
- import math
44
- from itertools import count
45
- import queue
46
- import typing
48
+
47
49
  try:
48
50
  # Check if functiools.cache is available
49
51
  # cache_decorator = functools.cache
@@ -76,15 +78,15 @@ try:
76
78
  wrapper.cache_clear = cached_func.cache_clear
77
79
  return wrapper
78
80
  return decorating_function(user_function)
79
- except :
81
+ except Exception:
80
82
  # If lrucache is not available, use a dummy decorator
81
83
  print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
82
84
  def cache_decorator(func):
83
85
  return func
84
- version = '5.86'
86
+ version = '5.90'
85
87
  VERSION = version
86
88
  __version__ = version
87
- COMMIT_DATE = '2025-10-07'
89
+ COMMIT_DATE = '2025-10-17'
88
90
 
89
91
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
90
92
  '~/multiSSH3.config.json',
@@ -93,16 +95,22 @@ CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
93
95
  '/etc/multiSSH3.d/multiSSH3.config.json',
94
96
  '/etc/multiSSH3.config.json'] # The first one has the highest priority
95
97
 
98
+ ERRORS = []
96
99
 
97
100
  # TODO: Add terminal TUI
98
101
 
99
102
  #%% ------------ Pre Helper Functions ----------------
100
103
  def eprint(*args, **kwargs):
104
+ global ERRORS
101
105
  try:
102
- print(*args, file=sys.stderr, **kwargs)
106
+ if 'file' in kwargs:
107
+ print(*args, **kwargs)
108
+ else:
109
+ print(*args, file=sys.stderr, **kwargs)
103
110
  except Exception as e:
104
111
  print(f"Error: Cannot print to stderr: {e}")
105
112
  print(*args, **kwargs)
113
+ ERRORS.append(' '.join(map(str,args)))
106
114
 
107
115
  def _exit_with_code(code, message=None):
108
116
  '''
@@ -247,7 +255,7 @@ def getIP(hostname: str,local=False):
247
255
  # Then we check the DNS
248
256
  try:
249
257
  return socket.gethostbyname(hostname)
250
- except:
258
+ except Exception:
251
259
  return None
252
260
 
253
261
 
@@ -320,8 +328,8 @@ def load_config_file(config_file):
320
328
  try:
321
329
  with open(config_file,'r') as f:
322
330
  config = json.load(f)
323
- except:
324
- eprint(f"Error: Cannot load config file {config_file!r}")
331
+ except Exception as e:
332
+ eprint(f"Error: Cannot load config file {config_file!r}: {e}")
325
333
  return {}
326
334
  return config
327
335
 
@@ -346,6 +354,8 @@ DEFAULT_INTERVAL = 0
346
354
  DEFAULT_IPMI = False
347
355
  DEFAULT_IPMI_INTERFACE_IP_PREFIX = ''
348
356
  DEFAULT_INTERFACE_IP_PREFIX = None
357
+ DEFAULT_IPMI_USERNAME = 'ADMIN'
358
+ DEFAULT_IPMI_PASSWORD = ''
349
359
  DEFAULT_NO_WATCH = False
350
360
  DEFAULT_CURSES_MINIMUM_CHAR_LEN = 40
351
361
  DEFAULT_CURSES_MINIMUM_LINE_LEN = 1
@@ -364,6 +374,7 @@ DEFAULT_GREPPABLE_MODE = False
364
374
  DEFAULT_SKIP_UNREACHABLE = True
365
375
  DEFAULT_SKIP_HOSTS = ''
366
376
  DEFAULT_ENCODING = 'utf-8'
377
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.75
367
378
  SSH_STRICT_HOST_KEY_CHECKING = False
368
379
  ERROR_MESSAGES_TO_IGNORE = [
369
380
  'Pseudo-terminal will not be allocated because stdin is not a terminal',
@@ -492,7 +503,7 @@ def readEnvFromFile(environemnt_file = ''):
492
503
  try:
493
504
  if env:
494
505
  return env
495
- except:
506
+ except Exception:
496
507
  env = {}
497
508
  global _env_file
498
509
  if environemnt_file:
@@ -633,10 +644,154 @@ def format_commands(commands):
633
644
  # reformat commands into a list of strings, join the iterables if they are not strings
634
645
  try:
635
646
  commands = [' '.join(command) if not isinstance(command,str) else command for command in commands]
636
- except:
637
- eprint(f"Warning: commands should ideally be a list of strings. Now mssh had failed to convert {commands!r} to a list of strings. Continuing anyway but expect failures.")
647
+ except Exception as e:
648
+ eprint(f"Warning: commands should ideally be a list of strings. Now mssh had failed to convert {commands!r} to a list of strings. Continuing anyway but expect failures. Error: {e}")
638
649
  return commands
639
650
 
651
+
652
+
653
+ class OrderedMultiSet(deque):
654
+ """
655
+ A deque extension with O(1) average lookup time.
656
+ Maintains all deque functionality while tracking item counts.
657
+ """
658
+ def __init__(self, iterable=None, maxlen=None):
659
+ """Initialize with optional iterable and maxlen."""
660
+ super().__init__(maxlen=maxlen)
661
+ self._counter = Counter()
662
+ if iterable is not None:
663
+ self.extend(iterable)
664
+ def __decrease_count(self, item):
665
+ """Decrease count of item in counter."""
666
+ self._counter[item] -= 1
667
+ if self._counter[item] == 0:
668
+ del self._counter[item]
669
+ return self._counter.get(item, 0)
670
+ def append(self, item,left=False):
671
+ """Add item to the right end. O(1)."""
672
+ removed = None
673
+ if self.maxlen is not None and len(self) == self.maxlen:
674
+ removed = self[-1] if left else self[0] # Item that will be removed
675
+ self.__decrease_count(removed)
676
+ super().appendleft(item) if left else super().append(item)
677
+ self._counter[item] += 1
678
+ return removed
679
+ def appendleft(self, item):
680
+ """Add item to the left end. O(1)."""
681
+ return self.append(item,left=True)
682
+ def pop(self,left=False):
683
+ """Remove and return item from right end. O(1)."""
684
+ if not self:
685
+ return None
686
+ item = super().popleft() if left else super().pop()
687
+ self.__decrease_count(item)
688
+ return item
689
+ def popleft(self):
690
+ """Remove and return item from left end. O(1)."""
691
+ return self.pop(left=True)
692
+ def remove(self, value):
693
+ """Remove first occurrence of value. O(n)."""
694
+ if value not in self._counter:
695
+ return None
696
+ super().remove(value)
697
+ self.__decrease_count(value)
698
+ def clear(self):
699
+ """Remove all items. O(1)."""
700
+ super().clear()
701
+ self._counter.clear()
702
+ def extend(self, iterable):
703
+ """Extend deque by appending elements from iterable. O(k)."""
704
+ for item in iterable:
705
+ self.append(item)
706
+ def extendleft(self, iterable):
707
+ """Extend left side by appending elements from iterable. O(k)."""
708
+ for item in iterable:
709
+ self.appendleft(item)
710
+ def rotate(self, n=1):
711
+ """Rotate deque n steps to the right. O(k) where k = min(n, len)."""
712
+ if not self:
713
+ return
714
+ super().rotate(n)
715
+ def __contains__(self, item):
716
+ """Check if item exists in deque. O(1) average."""
717
+ return item in self._counter
718
+ def count(self, item):
719
+ """Return number of occurrences of item. O(1)."""
720
+ return self._counter[item]
721
+ def __setitem__(self, index, value):
722
+ """Set item at index. O(1) for access, O(1) for counter update."""
723
+ old_value = self[index]
724
+ super().__setitem__(index, value)
725
+ self.__decrease_count(old_value)
726
+ self._counter[value] += 1
727
+ return old_value
728
+ def __delitem__(self, index):
729
+ """Delete item at index. O(n) for deletion, O(1) for counter update."""
730
+ value = self[index]
731
+ super().__delitem__(index)
732
+ self.__decrease_count(value)
733
+ return value
734
+ def insert(self, index, value):
735
+ """Insert value at index. O(n) for insertion, O(1) for counter update."""
736
+ super().insert(index, value)
737
+ self._counter[value] += 1
738
+ def reverse(self):
739
+ """Reverse deque in place. O(n)."""
740
+ super().reverse()
741
+ def copy(self):
742
+ """Create a shallow copy. O(n)."""
743
+ new_deque = OrderedMultiSet(maxlen=self.maxlen)
744
+ new_deque.extend(self)
745
+ return new_deque
746
+ def __copy__(self):
747
+ """Support for copy.copy()."""
748
+ return self.copy()
749
+ def __repr__(self):
750
+ """String representation."""
751
+ if self.maxlen is not None:
752
+ return f"OrderedMultiSet({list(self)}, maxlen={self.maxlen})"
753
+ return f"OrderedMultiSet({list(self)})"
754
+ def put(self, item,left=False):
755
+ """Alias for append - add to right end (FIFO put)."""
756
+ return self.append(item,left=left)
757
+ def get(self,left=True):
758
+ """Alias for popleft - remove from left end (FIFO get)."""
759
+ return self.pop(left=left)
760
+ def peek(self):
761
+ """Return leftmost item without removing it."""
762
+ if not self:
763
+ return None
764
+ return self[0]
765
+ def peek_right(self):
766
+ """Return rightmost item without removing it."""
767
+ if not self:
768
+ return None
769
+ return self[-1]
770
+
771
+ def get_terminal_size():
772
+ '''
773
+ Get the terminal size
774
+
775
+ @params:
776
+ None
777
+
778
+ @returns:
779
+ (int,int): the number of columns and rows of the terminal
780
+ '''
781
+ try:
782
+ import os
783
+ _tsize = os.get_terminal_size()
784
+ except Exception:
785
+ try:
786
+ import fcntl
787
+ import struct
788
+ import termios
789
+ packed = fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))
790
+ _tsize = struct.unpack('HHHH', packed)[:2]
791
+ except Exception:
792
+ import shutil
793
+ _tsize = shutil.get_terminal_size(fallback=(120, 30))
794
+ return _tsize
640
795
  #%% ------------ Compacting Hostnames ----------------
641
796
  def __tokenize_hostname(hostname):
642
797
  """
@@ -1323,6 +1478,11 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1323
1478
  global __ipmiiInterfaceIPPrefix
1324
1479
  global _binPaths
1325
1480
  global __DEBUG_MODE
1481
+ global DEFAULT_IPMI_USERNAME
1482
+ global DEFAULT_IPMI_PASSWORD
1483
+ global DEFAULT_USERNAME
1484
+ global DEFAULT_PASSWORD
1485
+ global SSH_STRICT_HOST_KEY_CHECKING
1326
1486
  if retry_limit < 0:
1327
1487
  host.output.append('Error: Retry limit reached!')
1328
1488
  host.stderr.append('Error: Retry limit reached!')
@@ -1366,7 +1526,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1366
1526
  host.address = '.'.join(prefixOctets[:3]+hostOctets[min(3,len(prefixOctets)):])
1367
1527
  host.resolvedName = host.username + '@' if host.username else ''
1368
1528
  host.resolvedName += host.address
1369
- except:
1529
+ except Exception:
1370
1530
  host.resolvedName = host.name
1371
1531
  else:
1372
1532
  host.resolvedName = host.name
@@ -1378,22 +1538,27 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1378
1538
  host.command = host.command.replace('ipmitool ','')
1379
1539
  elif host.command.startswith(_binPaths['ipmitool']):
1380
1540
  host.command = host.command.replace(_binPaths['ipmitool'],'')
1381
- if not host.username:
1382
- host.username = 'admin'
1541
+ if not host.username or host.username == DEFAULT_USERNAME:
1542
+ if DEFAULT_IPMI_USERNAME:
1543
+ host.username = DEFAULT_IPMI_USERNAME
1544
+ elif DEFAULT_USERNAME:
1545
+ host.username = DEFAULT_USERNAME
1546
+ else:
1547
+ host.username = 'ADMIN'
1548
+ if not passwds or passwds == DEFAULT_PASSWORD:
1549
+ if DEFAULT_IPMI_PASSWORD:
1550
+ passwds = DEFAULT_IPMI_PASSWORD
1551
+ elif DEFAULT_PASSWORD:
1552
+ passwds = DEFAULT_PASSWORD
1553
+ else:
1554
+ host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1555
+ passwds = 'admin'
1383
1556
  if not host.command:
1384
1557
  host.command = 'power status'
1385
1558
  if 'sh' in _binPaths:
1386
- if passwds:
1387
- formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
1388
- else:
1389
- host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1390
- formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P admin {" ".join(extraargs)} {host.command}']
1559
+ formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
1391
1560
  else:
1392
- if passwds:
1393
- formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + extraargs + [host.command]
1394
- else:
1395
- host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1396
- formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P admin'] + extraargs + [host.command]
1561
+ formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + extraargs + [host.command]
1397
1562
  elif 'ssh' in _binPaths:
1398
1563
  host.output.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
1399
1564
  if __DEBUG_MODE:
@@ -1544,7 +1709,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
1544
1709
  stderr_thread.join(timeout=1)
1545
1710
  stdin_thread.join(timeout=1)
1546
1711
  # here we handle the rest of the stdout after the subprocess returns
1547
- host.output.append(f'Pipe Closed. Trying to read the rest of the stdout...')
1712
+ host.output.append('Pipe Closed. Trying to read the rest of the stdout...')
1548
1713
  if not _emo:
1549
1714
  stdout = None
1550
1715
  stderr = None
@@ -2262,7 +2427,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
2262
2427
  # if the line is visible, we will reprint it
2263
2428
  if visibleLowerBound <= lineNumToReprint <= len(host.output):
2264
2429
  _curses_add_string_to_window(window=host_window, y=lineNumToReprint + 1, line=host.output[lineNumToReprint], color_pair_list=host.current_color_pair,lead_str='│',keep_top_n_lines=1,box_ansi_color=box_ansi_color,fill_char='')
2265
- except Exception as e:
2430
+ except Exception:
2266
2431
  # import traceback
2267
2432
  # print(str(e).strip())
2268
2433
  # print(traceback.format_exc().strip())
@@ -2330,7 +2495,7 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
2330
2495
  # print if can change color
2331
2496
  _curses_add_string_to_window(window=stdscr, y=8, line=f"Real color capability: {curses.can_change_color()}", number_of_char_to_write=stdscr.getmaxyx()[1] - 1)
2332
2497
  stdscr.refresh()
2333
- except:
2498
+ except Exception:
2334
2499
  pass
2335
2500
  params = (-1,0 , min_char_len, min_line_len, single_window,False,'new config')
2336
2501
  while params:
@@ -2351,13 +2516,158 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
2351
2516
  stdscr.addstr(i, 0, line)
2352
2517
  i += 1
2353
2518
  stdscr.refresh()
2354
- except:
2519
+ except Exception:
2355
2520
  pass
2356
2521
  params = params[:6] + ('new config',)
2357
2522
  time.sleep(0.01)
2358
2523
  #time.sleep(0.25)
2359
2524
 
2360
2525
  #%% ------------ Generate Output Block ----------------
2526
+ def can_merge(line_bag1, line_bag2, threshold):
2527
+ bag1_iter = iter(line_bag1)
2528
+ found = False
2529
+ for _ in range(max(int(len(line_bag1) * (1-threshold)),1)):
2530
+ try:
2531
+ item = next(bag1_iter)
2532
+ except StopIteration:
2533
+ break
2534
+ if item in line_bag2:
2535
+ found = True
2536
+ break
2537
+ if not found:
2538
+ return False
2539
+ return len(line_bag1.symmetric_difference(line_bag2)) < max((len(line_bag1) + len(line_bag2)) * (1 - threshold),1)
2540
+
2541
+ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold):
2542
+ terminal_length = get_terminal_size()[0]
2543
+ output.append(('├'+'─'*(terminal_length-1)))
2544
+ indexes = {hostname: 0 for hostname in merging_hostnames}
2545
+ working_indexes = indexes.copy()
2546
+ previousBuddies = set()
2547
+ while indexes:
2548
+ futures = {}
2549
+ defer = False
2550
+ sorted_working_indexes = sorted(working_indexes.items(), key=lambda x: x[1])
2551
+ golden_hostname, golden_index = sorted_working_indexes[0]
2552
+ buddy = {golden_hostname}
2553
+ lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
2554
+ for hostname, index in sorted_working_indexes[1:]:
2555
+ if lineToAdd == outputs_by_hostname[hostname][index]:
2556
+ buddy.add(hostname)
2557
+ else:
2558
+ if hostname not in futures:
2559
+ diff_display_item_count = max(int(len(outputs_by_hostname[hostname]) * (1 - diff_display_threshold)),1)
2560
+ tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
2561
+ futures[hostname] = (OrderedMultiSet(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_index)
2562
+ if lineToAdd in futures[hostname]:
2563
+ for hn in buddy:
2564
+ del working_indexes[hn]
2565
+ defer = True
2566
+ break
2567
+ if not defer:
2568
+ if buddy != previousBuddies:
2569
+ output.append(f"├─ {','.join(compact_hostnames(buddy))}")
2570
+ previousBuddies = buddy
2571
+ output.append(lineToAdd)
2572
+ for hostname in buddy:
2573
+ indexes[hostname] += 1
2574
+ if indexes[hostname] >= len(outputs_by_hostname[hostname]):
2575
+ indexes.pop(hostname, None)
2576
+ futures.pop(hostname, None)
2577
+ continue
2578
+ #advance futures
2579
+ if hostname in futures:
2580
+ tracking_multiset, tracking_index = futures[hostname]
2581
+ tracking_index += 1
2582
+ if tracking_index < len(outputs_by_hostname[hostname]):
2583
+ line = outputs_by_hostname[hostname][tracking_index]
2584
+ tracking_multiset.append(line)
2585
+ else:
2586
+ tracking_multiset.pop_left()
2587
+ futures[hostname] = (tracking_multiset, tracking_index)
2588
+ working_indexes = indexes.copy()
2589
+
2590
+ def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold):
2591
+ terminal_length = get_terminal_size()[0]
2592
+ output = []
2593
+ for merging_hostnames in merge_groups:
2594
+ mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold)
2595
+ for hostname in remaining_hostnames:
2596
+ output.append('├'+'─'*(terminal_length-1))
2597
+ output.append(f"├─ {hostname}")
2598
+ output.extend(outputs_by_hostname[hostname])
2599
+ return output
2600
+
2601
+ def get_host_raw_output(hosts):
2602
+ outputs_by_hostname = {}
2603
+ line_bag_by_hostname = {}
2604
+ hostnames_by_line_bag_len = {}
2605
+ for host in hosts:
2606
+ hostPrintOut = ["│█ EXECUTED COMMAND"]
2607
+ hostPrintOut.extend(['│ ' + line for line in host['command'].splitlines()])
2608
+ lineBag = {(0,host['command'])}
2609
+ prevLine = host['command']
2610
+ if host['stdout']:
2611
+ hostPrintOut.append('│▓ STDOUT:')
2612
+ hostPrintOut.extend(['│ ' + line for line in host['stdout']])
2613
+ lineBag.add((prevLine,1))
2614
+ lineBag.add((1,host['stdout'][0]))
2615
+ if len(host['stdout']) > 1:
2616
+ lineBag.update(zip(host['stdout'], host['stdout'][1:]))
2617
+ lineBag.update(host['stdout'])
2618
+ prevLine = host['stdout'][-1]
2619
+ if host['stderr']:
2620
+ if host['stderr'][0].strip().startswith('ssh: connect to host ') and host['stderr'][0].strip().endswith('Connection refused'):
2621
+ host['stderr'][0] = 'SSH not reachable!'
2622
+ elif host['stderr'][-1].strip().endswith('Connection timed out'):
2623
+ host['stderr'][-1] = 'SSH connection timed out!'
2624
+ elif host['stderr'][-1].strip().endswith('No route to host'):
2625
+ host['stderr'][-1] = 'Cannot find host!'
2626
+ if host['stderr']:
2627
+ hostPrintOut.append('│▒ STDERR:')
2628
+ hostPrintOut.extend(['│ ' + line for line in host['stderr']])
2629
+ lineBag.add((prevLine,2))
2630
+ lineBag.add((2,host['stderr'][0]))
2631
+ lineBag.update(host['stderr'])
2632
+ if len(host['stderr']) > 1:
2633
+ lineBag.update(zip(host['stderr'], host['stderr'][1:]))
2634
+ prevLine = host['stderr'][-1]
2635
+ hostPrintOut.append(f"│░ RETURN CODE: {host['returncode']}")
2636
+ lineBag.add((prevLine,f"{host['returncode']}"))
2637
+ outputs_by_hostname[host['name']] = hostPrintOut
2638
+ line_bag_by_hostname[host['name']] = lineBag
2639
+ hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host['name'])
2640
+ return outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted(hostnames_by_line_bag_len)
2641
+
2642
+ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold):
2643
+ merge_groups = []
2644
+ for line_bag_len in hostnames_by_line_bag_len.copy():
2645
+ for this_hostname in hostnames_by_line_bag_len.get(line_bag_len, set()).copy():
2646
+ if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
2647
+ continue
2648
+ this_line_bag = line_bag_by_hostname[this_hostname]
2649
+ target_threshold = line_bag_len * (2 - diff_display_threshold)
2650
+ merge_group = []
2651
+ for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys:
2652
+ if other_line_bag_len > target_threshold:
2653
+ break
2654
+ if other_line_bag_len < line_bag_len:
2655
+ continue
2656
+ for other_hostname in hostnames_by_line_bag_len.get(other_line_bag_len, set()).copy():
2657
+ if this_hostname == other_hostname:
2658
+ continue
2659
+ if can_merge(this_line_bag, line_bag_by_hostname[other_hostname], diff_display_threshold):
2660
+ merge_group.append(other_hostname)
2661
+ hostnames_by_line_bag_len[line_bag_len].discard(this_hostname)
2662
+ hostnames_by_line_bag_len[other_line_bag_len].remove(other_hostname)
2663
+ if not hostnames_by_line_bag_len[other_line_bag_len]:
2664
+ del hostnames_by_line_bag_len[other_line_bag_len]
2665
+ del line_bag_by_hostname[other_hostname]
2666
+ if merge_group:
2667
+ merge_group.append(this_hostname)
2668
+ merge_groups.append(merge_group)
2669
+ return merge_groups
2670
+
2361
2671
  def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
2362
2672
  if quiet:
2363
2673
  # remove hosts with returncode 0
@@ -2396,40 +2706,30 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
2396
2706
  rtnStr += 'User Inputs: '+ '\nUser Inputs: '.join(CMDsOut)
2397
2707
  #rtnStr += '\n'
2398
2708
  else:
2399
- outputs = {}
2400
- for host in hosts:
2401
- hostPrintOut = f" Command:\n {host['command']}\n"
2402
- hostPrintOut += " stdout:\n "+'\n '.join(host['stdout'])
2403
- if host['stderr']:
2404
- if host['stderr'][0].strip().startswith('ssh: connect to host ') and host['stderr'][0].strip().endswith('Connection refused'):
2405
- host['stderr'][0] = 'SSH not reachable!'
2406
- elif host['stderr'][-1].strip().endswith('Connection timed out'):
2407
- host['stderr'][-1] = 'SSH connection timed out!'
2408
- elif host['stderr'][-1].strip().endswith('No route to host'):
2409
- host['stderr'][-1] = 'Cannot find host!'
2410
- hostPrintOut += "\n stderr:\n "+'\n '.join(host['stderr'])
2411
- hostPrintOut += f"\n return_code: {host['returncode']}"
2412
- outputs.setdefault(hostPrintOut, set()).add(host['name'])
2413
- rtnStr = ''
2414
- for output, hostSet in outputs.items():
2415
- compact_hosts = compact_hostnames(hostSet)
2416
- rtnStr += '*'*80+'\n'
2417
- if quiet:
2418
- rtnStr += f'Abnormal returncode produced by {",".join(compact_hosts)}:\n'
2419
- rtnStr += output+'\n'
2420
- else:
2421
- rtnStr += f'These hosts: "{",".join(compact_hosts)}" have a response of:\n'
2422
- rtnStr += output+'\n'
2423
- if not quiet or outputs:
2424
- rtnStr += '*'*80+'\n'
2709
+ try:
2710
+ diff_display_threshold = float(DEFAULT_DIFF_DISPLAY_THRESHOLD)
2711
+ if diff_display_threshold < 0 or diff_display_threshold > 1:
2712
+ raise ValueError
2713
+ except Exception:
2714
+ eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
2715
+ diff_display_threshold = 0.9
2716
+ terminal_length = get_terminal_size()[0]
2717
+ outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys = get_host_raw_output(hosts)
2718
+ merge_groups = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
2719
+ # get the remaining hostnames in the hostnames_by_line_bag_len
2720
+ remaining_hostnames = set()
2721
+ for hostnames in hostnames_by_line_bag_len.values():
2722
+ remaining_hostnames.update(hostnames)
2723
+ outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold)
2425
2724
  if keyPressesIn[-1]:
2426
2725
  CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
2427
- rtnStr += "User Inputs: \n "
2428
- rtnStr += '\n '.join(CMDsOut)
2429
- rtnStr += '\n'
2726
+ outputs.append("├─ User Inputs:".ljust(terminal_length-1,'─'))
2727
+ outputs.extend(CMDsOut)
2430
2728
  keyPressesIn[-1].clear()
2431
2729
  if quiet and not outputs:
2432
- rtnStr += 'Success'
2730
+ rtnStr = 'Success'
2731
+ else:
2732
+ rtnStr = '\n'.join(outputs + [('╘'+'─'*(terminal_length-1))])
2433
2733
  return rtnStr
2434
2734
 
2435
2735
  def print_output(hosts,usejson = False,quiet = False,greppable = False):
@@ -2487,8 +2787,8 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
2487
2787
  availableHosts = set()
2488
2788
  for host in hosts:
2489
2789
  if host.stderr and ('No route to host' in host.stderr[0].strip() or 'Connection timed out' in host.stderr[0].strip() or (host.stderr[-1].strip().startswith('Timeout!') and host.returncode == 124)):
2490
- unavailableHosts[host.name] = int(time.monotonic())
2491
- __globalUnavailableHosts[host.name] = int(time.monotonic())
2790
+ unavailableHosts[host.name] = int(time.monotonic() + unavailable_host_expiry)
2791
+ __globalUnavailableHosts[host.name] = int(time.monotonic() + unavailable_host_expiry)
2492
2792
  else:
2493
2793
  availableHosts.add(host.name)
2494
2794
  if host.name in unavailableHosts:
@@ -2513,7 +2813,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
2513
2813
  expireTime = int(line.split(',')[1])
2514
2814
  if expireTime < time.monotonic() and hostname not in availableHosts:
2515
2815
  oldDic[hostname] = expireTime
2516
- except:
2816
+ except Exception:
2517
2817
  pass
2518
2818
  # add new entries
2519
2819
  oldDic.update(unavailableHosts)
@@ -2567,33 +2867,60 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
2567
2867
  repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
2568
2868
  shortend = False) -> str:
2569
2869
  argsList = []
2570
- if oneonone: argsList.append('--oneonone' if not shortend else '-11')
2571
- if timeout and timeout != DEFAULT_TIMEOUT: argsList.append(f'--timeout={timeout}' if not shortend else f'-t={timeout}')
2572
- if repeat and repeat != DEFAULT_REPEAT: argsList.append(f'--repeat={repeat}' if not shortend else f'-r={repeat}')
2573
- if interval and interval != DEFAULT_INTERVAL: argsList.append(f'--interval={interval}' if not shortend else f'-i={interval}')
2574
- if password and password != DEFAULT_PASSWORD: argsList.append(f'--password="{password}"' if not shortend else f'-p="{password}"')
2575
- if identity_file and identity_file != DEFAULT_IDENTITY_FILE: argsList.append(f'--key="{identity_file}"' if not shortend else f'-k="{identity_file}"')
2576
- if copy_id: argsList.append('--copy_id' if not shortend else '-ci')
2577
- if no_watch: argsList.append('--no_watch' if not shortend else '-q')
2578
- if json: argsList.append('--json' if not shortend else '-j')
2579
- if max_connections and max_connections != DEFAULT_MAX_CONNECTIONS: argsList.append(f'--max_connections={max_connections}' if not shortend else f'-m={max_connections}')
2580
- if files: argsList.extend([f'--file="{file}"' for file in files] if not shortend else [f'-f="{file}"' for file in files])
2581
- if ipmi: argsList.append('--ipmi')
2582
- if interface_ip_prefix and interface_ip_prefix != DEFAULT_INTERFACE_IP_PREFIX: argsList.append(f'--interface_ip_prefix="{interface_ip_prefix}"' if not shortend else f'-pre="{interface_ip_prefix}"')
2583
- if scp: argsList.append('--scp')
2584
- if gather_mode: argsList.append('--gather_mode' if not shortend else '-gm')
2585
- if username and username != DEFAULT_USERNAME: argsList.append(f'--username="{username}"' if not shortend else f'-u="{username}"')
2586
- if extraargs and extraargs != DEFAULT_EXTRA_ARGS: argsList.append(f'--extraargs="{extraargs}"' if not shortend else f'-ea="{extraargs}"')
2587
- if skipUnreachable: argsList.append('--skip_unreachable' if not shortend else '-su')
2588
- if unavailable_host_expiry and unavailable_host_expiry != DEFAULT_UNAVAILABLE_HOST_EXPIRY: argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
2589
- if no_env: argsList.append('--no_env')
2590
- if env_file and env_file != DEFAULT_ENV_FILE: argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
2591
- if no_history: argsList.append('--no_history' if not shortend else '-nh')
2592
- if history_file and history_file != DEFAULT_HISTORY_FILE: argsList.append(f'--history_file="{history_file}"' if not shortend else f'-hf="{history_file}"')
2593
- if greppable: argsList.append('--greppable' if not shortend else '-g')
2594
- if error_only: argsList.append('--error_only' if not shortend else '-eo')
2595
- if skip_hosts and skip_hosts != DEFAULT_SKIP_HOSTS: argsList.append(f'--skip_hosts="{skip_hosts}"' if not shortend else f'-sh="{skip_hosts}"')
2596
- if file_sync: argsList.append('--file_sync' if not shortend else '-fs')
2870
+ if oneonone:
2871
+ argsList.append('--oneonone' if not shortend else '-11')
2872
+ if timeout and timeout != DEFAULT_TIMEOUT:
2873
+ argsList.append(f'--timeout={timeout}' if not shortend else f'-t={timeout}')
2874
+ if repeat and repeat != DEFAULT_REPEAT:
2875
+ argsList.append(f'--repeat={repeat}' if not shortend else f'-r={repeat}')
2876
+ if interval and interval != DEFAULT_INTERVAL:
2877
+ argsList.append(f'--interval={interval}' if not shortend else f'-i={interval}')
2878
+ if password and password != DEFAULT_PASSWORD:
2879
+ argsList.append(f'--password="{password}"' if not shortend else f'-p="{password}"')
2880
+ if identity_file and identity_file != DEFAULT_IDENTITY_FILE:
2881
+ argsList.append(f'--key="{identity_file}"' if not shortend else f'-k="{identity_file}"')
2882
+ if copy_id:
2883
+ argsList.append('--copy_id' if not shortend else '-ci')
2884
+ if no_watch:
2885
+ argsList.append('--no_watch' if not shortend else '-q')
2886
+ if json:
2887
+ argsList.append('--json' if not shortend else '-j')
2888
+ if max_connections and max_connections != DEFAULT_MAX_CONNECTIONS:
2889
+ argsList.append(f'--max_connections={max_connections}' if not shortend else f'-m={max_connections}')
2890
+ if files:
2891
+ argsList.extend([f'--file="{file}"' for file in files] if not shortend else [f'-f="{file}"' for file in files])
2892
+ if ipmi:
2893
+ argsList.append('--ipmi')
2894
+ if interface_ip_prefix and interface_ip_prefix != DEFAULT_INTERFACE_IP_PREFIX:
2895
+ argsList.append(f'--interface_ip_prefix="{interface_ip_prefix}"' if not shortend else f'-pre="{interface_ip_prefix}"')
2896
+ if scp:
2897
+ argsList.append('--scp')
2898
+ if gather_mode:
2899
+ argsList.append('--gather_mode' if not shortend else '-gm')
2900
+ if username and username != DEFAULT_USERNAME:
2901
+ argsList.append(f'--username="{username}"' if not shortend else f'-u="{username}"')
2902
+ if extraargs and extraargs != DEFAULT_EXTRA_ARGS:
2903
+ argsList.append(f'--extraargs="{extraargs}"' if not shortend else f'-ea="{extraargs}"')
2904
+ if skipUnreachable:
2905
+ argsList.append('--skip_unreachable' if not shortend else '-su')
2906
+ if unavailable_host_expiry and unavailable_host_expiry != DEFAULT_UNAVAILABLE_HOST_EXPIRY:
2907
+ argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
2908
+ if no_env:
2909
+ argsList.append('--no_env')
2910
+ if env_file and env_file != DEFAULT_ENV_FILE:
2911
+ argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
2912
+ if no_history:
2913
+ argsList.append('--no_history' if not shortend else '-nh')
2914
+ if history_file and history_file != DEFAULT_HISTORY_FILE:
2915
+ argsList.append(f'--history_file="{history_file}"' if not shortend else f'-hf="{history_file}"')
2916
+ if greppable:
2917
+ argsList.append('--greppable' if not shortend else '-g')
2918
+ if error_only:
2919
+ argsList.append('--error_only' if not shortend else '-eo')
2920
+ if skip_hosts and skip_hosts != DEFAULT_SKIP_HOSTS:
2921
+ argsList.append(f'--skip_hosts="{skip_hosts}"' if not shortend else f'-sh="{skip_hosts}"')
2922
+ if file_sync:
2923
+ argsList.append('--file_sync' if not shortend else '-fs')
2597
2924
  return ' '.join(argsList)
2598
2925
 
2599
2926
  def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
@@ -2753,7 +3080,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2753
3080
  if line and ',' in line and len(line.split(',')) >= 2 and line.split(',')[0] and line.split(',')[1].isdigit():
2754
3081
  hostname = line.split(',')[0]
2755
3082
  expireTime = int(line.split(',')[1])
2756
- if expireTime < time.monotonic() and expireTime + unavailable_host_expiry > time.monotonic():
3083
+ if expireTime > time.monotonic():
2757
3084
  __globalUnavailableHosts[hostname] = expireTime
2758
3085
  readed = True
2759
3086
  if readed and not __global_suppress_printout:
@@ -2762,7 +3089,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2762
3089
  eprint(f"Warning: Unable to read the unavailable hosts from the file {os.path.join(tempfile.gettempdir(),f'__{getpass.getuser()}_multiSSH3_UNAVAILABLE_HOSTS.csv')!r}")
2763
3090
  eprint(str(e))
2764
3091
  elif '__multiSSH3_UNAVAILABLE_HOSTS' in readEnvFromFile():
2765
- __globalUnavailableHosts.update({host: int(time.monotonic()) for host in readEnvFromFile()['__multiSSH3_UNAVAILABLE_HOSTS'].split(',') if host})
3092
+ __globalUnavailableHosts.update({host: int(time.monotonic()+ unavailable_host_expiry) for host in readEnvFromFile()['__multiSSH3_UNAVAILABLE_HOSTS'].split(',') if host})
2766
3093
  if not max_connections:
2767
3094
  max_connections = 4 * os.cpu_count()
2768
3095
  elif max_connections == 0:
@@ -2834,7 +3161,8 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2834
3161
  # we will copy the id to the hosts
2835
3162
  hosts = []
2836
3163
  for host in targetHostDic:
2837
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
3164
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
3165
+ continue
2838
3166
  command = f"{_binPaths['ssh-copy-id']} "
2839
3167
  if identity_file:
2840
3168
  command = f"{command}-i {identity_file} "
@@ -2870,7 +3198,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2870
3198
  for file in files:
2871
3199
  try:
2872
3200
  pathSet.update(glob.glob(file,include_hidden=True,recursive=True))
2873
- except:
3201
+ except Exception:
2874
3202
  pathSet.update(glob.glob(file,recursive=True))
2875
3203
  if not pathSet:
2876
3204
  _exit_with_code(66, f'No source files at {files!r} are found after resolving globs!')
@@ -2895,17 +3223,19 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2895
3223
  eprint('-'*80)
2896
3224
  eprint("Running in one on one mode")
2897
3225
  for host, command in zip(targetHostDic, commands):
2898
- if not ipmi and skipUnreachable and host in unavailableHosts:
3226
+ if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
2899
3227
  eprint(f"Skipping unavailable host: {host}")
2900
3228
  continue
2901
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
3229
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
3230
+ continue
2902
3231
  if file_sync:
2903
3232
  hosts.append(Host(host, os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip = targetHostDic[host]))
2904
3233
  else:
2905
3234
  hosts.append(Host(host, command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
2906
3235
  if not __global_suppress_printout:
2907
3236
  eprint(f"Running command: {command!r} on host: {host!r}")
2908
- if not __global_suppress_printout: eprint('-'*80)
3237
+ if not __global_suppress_printout:
3238
+ eprint('-'*80)
2909
3239
  if not no_start:
2910
3240
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
2911
3241
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
@@ -2919,15 +3249,17 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2919
3249
  # run in interactive mode ssh mode
2920
3250
  hosts = []
2921
3251
  for host in targetHostDic:
2922
- if not ipmi and skipUnreachable and host in unavailableHosts:
2923
- if not __global_suppress_printout: print(f"Skipping unavailable host: {host}")
3252
+ if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
3253
+ if not __global_suppress_printout:
3254
+ print(f"Skipping unavailable host: {host}")
3255
+ continue
3256
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
2924
3257
  continue
2925
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
2926
3258
  if file_sync:
2927
- eprint(f"Error: file sync mode need to be specified with at least one path to sync.")
3259
+ eprint("Error: file sync mode need to be specified with at least one path to sync.")
2928
3260
  return []
2929
3261
  elif files:
2930
- eprint(f"Error: files need to be specified with at least one path to sync")
3262
+ eprint("Error: files need to be specified with at least one path to sync")
2931
3263
  else:
2932
3264
  hosts.append(Host(host, '', files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,identity_file=identity_file,ip=targetHostDic[host]))
2933
3265
  if not __global_suppress_printout:
@@ -2935,7 +3267,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2935
3267
  eprint(f"Running in interactive mode on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
2936
3268
  eprint('-'*80)
2937
3269
  if no_start:
2938
- eprint(f"Warning: no_start is set, the command will not be started. As we are in interactive mode, no action will be done.")
3270
+ eprint("Warning: no_start is set, the command will not be started. As we are in interactive mode, no action will be done.")
2939
3271
  else:
2940
3272
  processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
2941
3273
  returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
@@ -2946,10 +3278,12 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
2946
3278
  for command in commands:
2947
3279
  hosts = []
2948
3280
  for host in targetHostDic:
2949
- if not ipmi and skipUnreachable and host in unavailableHosts:
2950
- if not __global_suppress_printout: print(f"Skipping unavailable host: {host}")
3281
+ if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
3282
+ if not __global_suppress_printout:
3283
+ print(f"Skipping unavailable host: {host}")
3284
+ continue
3285
+ if host in skipHostSet or targetHostDic[host] in skipHostSet:
2951
3286
  continue
2952
- if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
2953
3287
  if file_sync:
2954
3288
  hosts.append(Host(host, os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
2955
3289
  else:
@@ -2999,6 +3333,8 @@ def generate_default_config(args):
2999
3333
  'DEFAULT_IPMI': args.ipmi,
3000
3334
  'DEFAULT_IPMI_INTERFACE_IP_PREFIX': args.ipmi_interface_ip_prefix,
3001
3335
  'DEFAULT_INTERFACE_IP_PREFIX': args.interface_ip_prefix,
3336
+ 'DEFAULT_IPMI_USERNAME': args.ipmi_username,
3337
+ 'DEFAULT_IPMI_PASSWORD': args.ipmi_password,
3002
3338
  'DEFAULT_NO_WATCH': args.no_watch,
3003
3339
  'DEFAULT_CURSES_MINIMUM_CHAR_LEN': args.window_width,
3004
3340
  'DEFAULT_CURSES_MINIMUM_LINE_LEN': args.window_height,
@@ -3017,6 +3353,7 @@ def generate_default_config(args):
3017
3353
  'DEFAULT_SKIP_UNREACHABLE': args.skip_unreachable,
3018
3354
  'DEFAULT_SKIP_HOSTS': args.skip_hosts,
3019
3355
  'DEFAULT_ENCODING': args.encoding,
3356
+ 'DEFAULT_DIFF_DISPLAY_THRESHOLD': args.diff_display_threshold,
3020
3357
  'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
3021
3358
  'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
3022
3359
  }
@@ -3031,9 +3368,9 @@ def write_default_config(args,CONFIG_FILE = None):
3031
3368
  backup = True
3032
3369
  if os.path.exists(CONFIG_FILE):
3033
3370
  eprint(f"Warning: {CONFIG_FILE!r} already exists, what to do? (o/b/n)")
3034
- eprint(f"o: Overwrite the file")
3371
+ eprint("o: Overwrite the file")
3035
3372
  eprint(f"b: Rename the current config file at {CONFIG_FILE!r}.bak forcefully and write the new config file (default)")
3036
- eprint(f"n: Do nothing")
3373
+ eprint("n: Do nothing")
3037
3374
  inStr = input_with_timeout_and_countdown(10)
3038
3375
  if (not inStr) or inStr.lower().strip().startswith('b'):
3039
3376
  backup = True
@@ -3056,7 +3393,7 @@ def write_default_config(args,CONFIG_FILE = None):
3056
3393
  eprint(f"Config file written to {CONFIG_FILE!r}")
3057
3394
  except Exception as e:
3058
3395
  eprint(f"Error: Unable to write to the config file: {e!r}")
3059
- eprint(f'Printing the config file to stdout:')
3396
+ eprint('Printing the config file to stdout:')
3060
3397
  print(json.dumps(__configs_from_file, indent=4))
3061
3398
 
3062
3399
  #%% ------------ Argument Processing -----------------
@@ -3075,7 +3412,7 @@ def get_parser():
3075
3412
  parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
3076
3413
  parser.add_argument('-s','-fs','--file_sync', action='store_true', help=f'Operate in file sync mode, sync path in <COMMANDS> from this machine to <HOSTS>. Treat --file <FILE> and <COMMANDS> both as source and source and destination will be the same in this mode. Infer destination from source path. (default: {DEFAULT_FILE_SYNC})', default=DEFAULT_FILE_SYNC)
3077
3414
  parser.add_argument('-W','--scp', action='store_true', help=f'Use scp for copying files instead of rsync. Need to use this on windows. (default: {DEFAULT_SCP})', default=DEFAULT_SCP)
3078
- parser.add_argument('-G','-gm','--gather_mode', action='store_true', help=f'Gather files from the hosts instead of sending files to the hosts. Will send remote files specified in <FILE> to local path specified in <COMMANDS> (default: False)', default=False)
3415
+ parser.add_argument('-G','-gm','--gather_mode', action='store_true', help='Gather files from the hosts instead of sending files to the hosts. Will send remote files specified in <FILE> to local path specified in <COMMANDS> (default: False)', default=False)
3079
3416
  #parser.add_argument("-d",'-c',"--destination", type=str, help="The destination of the files. Same as specify with commands. Added for compatibility. Use #HOST# or #HOSTNAME# to replace the host name in the destination")
3080
3417
  parser.add_argument("-t","--timeout", type=int, help=f"Timeout for each command in seconds. Set default value via DEFAULT_CLI_TIMEOUT in config file. Use 0 for disabling timeout. (default: {DEFAULT_CLI_TIMEOUT})", default=DEFAULT_CLI_TIMEOUT)
3081
3418
  parser.add_argument('-T','--use_script_timeout',action='store_true', help=f'Use shortened timeout suitable to use in a script. Set value via DEFAULT_TIMEOUT field in config file. (current: {DEFAULT_TIMEOUT})', default=False)
@@ -3084,6 +3421,8 @@ def get_parser():
3084
3421
  parser.add_argument('-M',"--ipmi", action='store_true', help=f"Use ipmitool to run the command. (default: {DEFAULT_IPMI})", default=DEFAULT_IPMI)
3085
3422
  parser.add_argument("-mpre","--ipmi_interface_ip_prefix", type=str, help=f"The prefix of the IPMI interfaces (default: {DEFAULT_IPMI_INTERFACE_IP_PREFIX})", default=DEFAULT_IPMI_INTERFACE_IP_PREFIX)
3086
3423
  parser.add_argument("-pre","--interface_ip_prefix", type=str, help=f"The prefix of the for the interfaces (default: {DEFAULT_INTERFACE_IP_PREFIX})", default=DEFAULT_INTERFACE_IP_PREFIX)
3424
+ parser.add_argument('-iu','--ipmi_username', type=str,help=f'The username to use to connect to the hosts via ipmi. (default: {DEFAULT_IPMI_USERNAME})',default=DEFAULT_IPMI_USERNAME)
3425
+ parser.add_argument('-ip','--ipmi_password', type=str,help=f'The password to use to connect to the hosts via ipmi. (default: {DEFAULT_IPMI_PASSWORD})',default=DEFAULT_IPMI_PASSWORD)
3087
3426
  parser.add_argument('-S',"-q","-nw","--no_watch","--quiet", action='store_true', help=f"Quiet mode, no curses watch, only print the output. (default: {DEFAULT_NO_WATCH})", default=DEFAULT_NO_WATCH)
3088
3427
  parser.add_argument("-ww",'--window_width', type=int, help=f"The minimum character length of the curses window. (default: {DEFAULT_CURSES_MINIMUM_CHAR_LEN})", default=DEFAULT_CURSES_MINIMUM_CHAR_LEN)
3089
3428
  parser.add_argument("-wh",'--window_height', type=int, help=f"The minimum line height of the curses window. (default: {DEFAULT_CURSES_MINIMUM_LINE_LEN})", default=DEFAULT_CURSES_MINIMUM_LINE_LEN)
@@ -3093,7 +3432,7 @@ def get_parser():
3093
3432
  parser.add_argument('-Z','-rz','--return_zero', action='store_true', help=f"Return 0 even if there are errors. (default: {DEFAULT_RETURN_ZERO})", default=DEFAULT_RETURN_ZERO)
3094
3433
  parser.add_argument('-C','--no_env', action='store_true', help=f'Do not load the command line environment variables. (default: {DEFAULT_NO_ENV})', default=DEFAULT_NO_ENV)
3095
3434
  parser.add_argument("--env_file", type=str, help=f"The file to load the mssh file based environment variables from. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILE})", default=DEFAULT_ENV_FILE)
3096
- parser.add_argument("-m","--max_connections", type=int, help=f"Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3435
+ parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
3097
3436
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
3098
3437
  parser.add_argument('-w',"--success_hosts", action='store_true', help=f"Output the hosts that succeeded in summary as well. (default: {DEFAULT_PRINT_SUCCESS_HOSTS})", default=DEFAULT_PRINT_SUCCESS_HOSTS)
3099
3438
  parser.add_argument('-P',"-g","--greppable",'--table', action='store_true', help=f"Output in greppable table. (default: {DEFAULT_GREPPABLE_MODE})", default=DEFAULT_GREPPABLE_MODE)
@@ -3102,19 +3441,22 @@ def get_parser():
3102
3441
  su_group.add_argument('-a',"-nsu","--no_skip_unreachable",dest = 'skip_unreachable', action='store_false', help=f"Do not skip unreachable hosts. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {not DEFAULT_SKIP_UNREACHABLE})", default=not DEFAULT_SKIP_UNREACHABLE)
3103
3442
  parser.add_argument('-uhe','--unavailable_host_expiry', type=int, help=f"Time in seconds to expire the unavailable hosts (default: {DEFAULT_UNAVAILABLE_HOST_EXPIRY})", default=DEFAULT_UNAVAILABLE_HOST_EXPIRY)
3104
3443
  parser.add_argument('-X',"-sh","--skip_hosts", type=str, help=f"Skip the hosts in the list. (default: {DEFAULT_SKIP_HOSTS if DEFAULT_SKIP_HOSTS else 'None'})", default=DEFAULT_SKIP_HOSTS)
3105
- parser.add_argument('--generate_config_file', action='store_true', help=f'Store / generate the default config file from command line argument and current config at --config_file / stdout')
3106
- parser.add_argument('--config_file', type=str,nargs='?', help=f'Additional config file to use, will pioritize over config chains. When using with store_config_file, will store the resulting config file at this location. Use without a path will use multiSSH3.config.json',const='multiSSH3.config.json',default=None)
3107
- parser.add_argument('--store_config_file',type = str,nargs='?',help=f'Store the default config file from command line argument and current config. Same as --store_config_file --config_file=<path>',const='multiSSH3.config.json')
3444
+ parser.add_argument('--generate_config_file', action='store_true', help='Store / generate the default config file from command line argument and current config at --config_file / stdout')
3445
+ parser.add_argument('--config_file', type=str,nargs='?', help='Additional config file to use, will pioritize over config chains. When using with store_config_file, will store the resulting config file at this location. Use without a path will use multiSSH3.config.json',const='multiSSH3.config.json',default=None)
3446
+ parser.add_argument('--store_config_file',type = str,nargs='?',help='Store the default config file from command line argument and current config. Same as --store_config_file --config_file=<path>',const='multiSSH3.config.json')
3108
3447
  parser.add_argument('--debug', action='store_true', help='Print debug information')
3109
3448
  parser.add_argument('-ci','--copy_id', action='store_true', help='Copy the ssh id to the hosts')
3110
3449
  parser.add_argument('-I','-nh','--no_history', action='store_true', help=f'Do not record the command to history. Default: {DEFAULT_NO_HISTORY}', default=DEFAULT_NO_HISTORY)
3111
3450
  parser.add_argument('-hf','--history_file', type=str, help=f'The file to store the history. (default: {DEFAULT_HISTORY_FILE})', default=DEFAULT_HISTORY_FILE)
3112
3451
  parser.add_argument('--script', action='store_true', help='Run the command in script mode, short for -SCRIPT or --no_watch --skip_unreachable --no_env --no_history --greppable --error_only')
3113
3452
  parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
3453
+ parser.add_argument('-ddt','--diff_display_threshold', type=float, help=f'The threshold of lines to display the diff when files differ. Set to 0 to always display the diff. (default: {DEFAULT_DIFF_DISPLAY_THRESHOLD})', default=DEFAULT_DIFF_DISPLAY_THRESHOLD)
3114
3454
  parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
3115
3455
  return parser
3116
3456
 
3117
3457
  def process_args(args = None):
3458
+ global DEFAULT_IPMI_USERNAME
3459
+ global DEFAULT_IPMI_PASSWORD
3118
3460
  parser = get_parser()
3119
3461
  # We handle the signal
3120
3462
  signal.signal(signal.SIGINT, signal_handler)
@@ -3174,10 +3516,10 @@ def process_config_file(args):
3174
3516
 
3175
3517
  def process_commands(args):
3176
3518
  if not args.file and len(args.commands) > 1 and all([len(command.split()) == 1 for command in args.commands]):
3177
- eprint(f"Multiple one word command detected, what to do? (1/m/n)")
3519
+ eprint("Multiple one word command detected, what to do? (1/m/n)")
3178
3520
  eprint(f"1: Run 1 command [{' '.join(args.commands)}] on all hosts ( default )")
3179
3521
  eprint(f"m: Run multiple commands [{', '.join(args.commands)}] on all hosts")
3180
- eprint(f"n: Exit")
3522
+ eprint("n: Exit")
3181
3523
  inStr = input_with_timeout_and_countdown(3)
3182
3524
  if (not inStr) or inStr.lower().strip().startswith('1'):
3183
3525
  args.commands = [" ".join(args.commands)]
@@ -3208,6 +3550,9 @@ def set_global_with_args(args):
3208
3550
  global __configs_from_file
3209
3551
  global _encoding
3210
3552
  global __returnZero
3553
+ global DEFAULT_IPMI_USERNAME
3554
+ global DEFAULT_IPMI_PASSWORD
3555
+ global DEFAULT_DIFF_DISPLAY_THRESHOLD
3211
3556
  _emo = False
3212
3557
  __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
3213
3558
  _env_file = args.env_file
@@ -3215,6 +3560,11 @@ def set_global_with_args(args):
3215
3560
  _encoding = args.encoding
3216
3561
  if args.return_zero:
3217
3562
  __returnZero = True
3563
+ if args.ipmi_username:
3564
+ DEFAULT_IPMI_USERNAME = args.ipmi_username
3565
+ if args.ipmi_password:
3566
+ DEFAULT_IPMI_PASSWORD = args.ipmi_password
3567
+ DEFAULT_DIFF_DISPLAY_THRESHOLD = args.diff_display_threshold
3218
3568
 
3219
3569
  #%% ------------ Wrapper Block ----------------
3220
3570
  def main():
@@ -3254,7 +3604,8 @@ def main():
3254
3604
  eprint(f"Sleeping for {args.interval} seconds")
3255
3605
  time.sleep(args.interval)
3256
3606
 
3257
- if not __global_suppress_printout: eprint(f"Running the {i+1}/{args.repeat} time") if args.repeat > 1 else None
3607
+ if not __global_suppress_printout:
3608
+ eprint(f"Running the {i+1}/{args.repeat} time") if args.repeat > 1 else None
3258
3609
  hosts = run_command_on_hosts(args.hosts,args.commands,
3259
3610
  oneonone=args.oneonone,timeout=args.timeout,password=args.password,
3260
3611
  no_watch=args.no_watch,json=args.json,called=args.no_output,max_connections=args.max_connections,
@@ -3280,7 +3631,8 @@ def main():
3280
3631
  eprint(f'Complete. Failed hosts (Return Code not 0) count: {__mainReturnCode}')
3281
3632
  eprint(f'failed_hosts: {",".join(compact_hostnames(__failedHosts))}')
3282
3633
  else:
3283
- if not __global_suppress_printout: eprint('Complete. All hosts returned 0.')
3634
+ if not __global_suppress_printout:
3635
+ eprint('Complete. All hosts returned 0.')
3284
3636
 
3285
3637
  if args.success_hosts and not __global_suppress_printout:
3286
3638
  eprint(f'succeeded_hosts: {",".join(compact_hostnames(succeededHosts))}')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes