multiSSH3 5.85__py3-none-any.whl → 5.90__py3-none-any.whl
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.
- multiSSH3.py +519 -165
- {multissh3-5.85.dist-info → multissh3-5.90.dist-info}/METADATA +1 -1
- multissh3-5.90.dist-info/RECORD +6 -0
- multissh3-5.85.dist-info/RECORD +0 -6
- {multissh3-5.85.dist-info → multissh3-5.90.dist-info}/WHEEL +0 -0
- {multissh3-5.85.dist-info → multissh3-5.90.dist-info}/entry_points.txt +0 -0
- {multissh3-5.85.dist-info → multissh3-5.90.dist-info}/top_level.txt +0 -0
multiSSH3.py
CHANGED
|
@@ -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
|
-
|
|
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
|
+
version = '5.90'
|
|
85
87
|
VERSION = version
|
|
86
88
|
__version__ = version
|
|
87
|
-
COMMIT_DATE = '2025-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
@@ -1964,7 +2129,7 @@ def _get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None, indexO
|
|
|
1964
2129
|
rearrangedHosts.add(host)
|
|
1965
2130
|
return new_hosts_to_display , {'running':len(running_hosts), 'failed':len(failed_hosts), 'finished':len(finished_hosts), 'waiting':len(waiting_hosts)}, rearrangedHosts
|
|
1966
2131
|
|
|
1967
|
-
def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window=DEFAULT_SINGLE_WINDOW, config_reason = 'New Configuration'):
|
|
2132
|
+
def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window=DEFAULT_SINGLE_WINDOW,help_shown = False, config_reason = 'New Configuration'):
|
|
1968
2133
|
global _encoding
|
|
1969
2134
|
_ = config_reason
|
|
1970
2135
|
try:
|
|
@@ -1983,9 +2148,9 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
1983
2148
|
min_line_len_local = max_y-1
|
|
1984
2149
|
# return True if the terminal is too small
|
|
1985
2150
|
if max_x < 2 or max_y < 2:
|
|
1986
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal too small')
|
|
2151
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal too small')
|
|
1987
2152
|
if min_char_len_local < 1 or min_line_len_local < 1:
|
|
1988
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Minimum character or line length too small')
|
|
2153
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Minimum character or line length too small')
|
|
1989
2154
|
# We need to figure out how many hosts we can fit in the terminal
|
|
1990
2155
|
# We will need at least 2 lines per host, one for its name, one for its output
|
|
1991
2156
|
# Each line will be at least 61 characters long (60 for the output, 1 for the borders)
|
|
@@ -1993,10 +2158,10 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
1993
2158
|
max_num_hosts_y = max_y // (min_line_len_local + 1)
|
|
1994
2159
|
max_num_hosts = max_num_hosts_x * max_num_hosts_y
|
|
1995
2160
|
if max_num_hosts < 1:
|
|
1996
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal too small to display any hosts')
|
|
2161
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal too small to display any hosts')
|
|
1997
2162
|
hosts_to_display , host_stats, rearrangedHosts = _get_hosts_to_display(hosts, max_num_hosts)
|
|
1998
2163
|
if len(hosts_to_display) == 0:
|
|
1999
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'No hosts to display')
|
|
2164
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'No hosts to display')
|
|
2000
2165
|
# Now we calculate the actual number of hosts we will display for x and y
|
|
2001
2166
|
optimal_len_x = max(min_char_len_local, 80)
|
|
2002
2167
|
num_hosts_x = min(max(min(max_num_hosts_x, max_x // optimal_len_x),1),len(hosts_to_display))
|
|
@@ -2017,7 +2182,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2017
2182
|
host_window_height = max_y // num_hosts_y
|
|
2018
2183
|
host_window_width = max_x // num_hosts_x
|
|
2019
2184
|
if host_window_height < 1 or host_window_width < 1:
|
|
2020
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Host window too small')
|
|
2185
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Host window too small')
|
|
2021
2186
|
|
|
2022
2187
|
old_stat = ''
|
|
2023
2188
|
old_bottom_stat = ''
|
|
@@ -2078,7 +2243,6 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2078
2243
|
_curses_add_string_to_window(window=help_window,y=12,line='Esc : Clear line', color_pair_list=[-1,-1,1], lead_str='│', box_ansi_color=box_ansi_color)
|
|
2079
2244
|
help_panel = curses.panel.new_panel(help_window)
|
|
2080
2245
|
help_panel.hide()
|
|
2081
|
-
help_shown = False
|
|
2082
2246
|
curses.panel.update_panels()
|
|
2083
2247
|
indexOffset = 0
|
|
2084
2248
|
while host_stats['running'] > 0 or host_stats['waiting'] > 0:
|
|
@@ -2091,7 +2255,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2091
2255
|
# with open('keylog.txt','a') as f:
|
|
2092
2256
|
# f.write(str(key)+'\n')
|
|
2093
2257
|
if key == 410 or key == curses.KEY_RESIZE: # 410 is the key code for resize
|
|
2094
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize requested')
|
|
2258
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal resize requested')
|
|
2095
2259
|
# if the user pressed ctrl + d and the last line is empty, we will exit by adding 'exit\n' to the last line
|
|
2096
2260
|
elif key == 4 and not __keyPressesIn[-1]:
|
|
2097
2261
|
__keyPressesIn[-1].extend('exit\n')
|
|
@@ -2099,20 +2263,20 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2099
2263
|
elif key == 95 and not __keyPressesIn[-1]: # 95 is the key code for _
|
|
2100
2264
|
# if last line is empty, we will reconfigure the wh to be smaller
|
|
2101
2265
|
if min_line_len != 1:
|
|
2102
|
-
return (lineToDisplay,curserPosition , min_char_len , max(min_line_len -1,1), single_window, 'Decrease line length')
|
|
2266
|
+
return (lineToDisplay,curserPosition , min_char_len , max(min_line_len -1,1), single_window,help_shown, 'Decrease line length')
|
|
2103
2267
|
elif key == 43 and not __keyPressesIn[-1]: # 43 is the key code for +
|
|
2104
2268
|
# if last line is empty, we will reconfigure the wh to be larger
|
|
2105
|
-
return (lineToDisplay,curserPosition , min_char_len , min_line_len +1, single_window, 'Increase line length')
|
|
2269
|
+
return (lineToDisplay,curserPosition , min_char_len , min_line_len +1, single_window,help_shown, 'Increase line length')
|
|
2106
2270
|
elif key == 123 and not __keyPressesIn[-1]: # 123 is the key code for {
|
|
2107
2271
|
# if last line is empty, we will reconfigure the ww to be smaller
|
|
2108
2272
|
if min_char_len != 1:
|
|
2109
|
-
return (lineToDisplay,curserPosition , max(min_char_len -1,1), min_line_len, single_window, 'Decrease character length')
|
|
2273
|
+
return (lineToDisplay,curserPosition , max(min_char_len -1,1), min_line_len, single_window,help_shown, 'Decrease character length')
|
|
2110
2274
|
elif key == 124 and not __keyPressesIn[-1]: # 124 is the key code for |
|
|
2111
2275
|
# if last line is empty, we will toggle the single window mode
|
|
2112
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, not single_window, 'Toggle single window mode')
|
|
2276
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, not single_window,help_shown, 'Toggle single window mode')
|
|
2113
2277
|
elif key == 125 and not __keyPressesIn[-1]: # 125 is the key code for }
|
|
2114
2278
|
# if last line is empty, we will reconfigure the ww to be larger
|
|
2115
|
-
return (lineToDisplay,curserPosition , min_char_len +1, min_line_len, single_window, 'Increase character length')
|
|
2279
|
+
return (lineToDisplay,curserPosition , min_char_len +1, min_line_len, single_window,help_shown, 'Increase character length')
|
|
2116
2280
|
elif key == 60 and not __keyPressesIn[-1]: # 60 is the key code for <
|
|
2117
2281
|
indexOffset = (indexOffset - 1 ) % len(hosts)
|
|
2118
2282
|
elif key == 62 and not __keyPressesIn[-1]: # 62 is the key code for >
|
|
@@ -2147,11 +2311,11 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2147
2311
|
curserPosition = len(__keyPressesIn[lineToDisplay])
|
|
2148
2312
|
elif key == curses.KEY_REFRESH or key == curses.KEY_F5 or key == 18: # 18 is the key code for ctrl + R
|
|
2149
2313
|
# if the key is refresh, we will refresh the screen
|
|
2150
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Refresh requested')
|
|
2314
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Refresh requested')
|
|
2151
2315
|
elif key == curses.KEY_EXIT or key == 27: # 27 is the key code for ESC
|
|
2152
2316
|
# if the key is exit, we will exit the program
|
|
2153
2317
|
return
|
|
2154
|
-
elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1: # 63 is the key code for ?
|
|
2318
|
+
elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1 or key == 8: # 63 is the key code for ?
|
|
2155
2319
|
# if the key is help, we will display the help message
|
|
2156
2320
|
if not help_shown:
|
|
2157
2321
|
help_panel.show()
|
|
@@ -2194,7 +2358,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2194
2358
|
curserPosition += 1
|
|
2195
2359
|
# reconfigure when the terminal size changes
|
|
2196
2360
|
if org_dim != stdscr.getmaxyx():
|
|
2197
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize detected')
|
|
2361
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal resize detected')
|
|
2198
2362
|
# We generate the aggregated stats if user did not input anything
|
|
2199
2363
|
if not __keyPressesIn[lineToDisplay]:
|
|
2200
2364
|
#stats = '┍'+ f" Total: {len(hosts)} Running: {host_stats['running']} Failed: {host_stats['failed']} Finished: {host_stats['finished']} Waiting: {host_stats['waiting']} ww: {min_char_len} wh:{min_line_len} "[:max_x - 2].center(max_x - 2, "━")
|
|
@@ -2263,12 +2427,12 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2263
2427
|
# if the line is visible, we will reprint it
|
|
2264
2428
|
if visibleLowerBound <= lineNumToReprint <= len(host.output):
|
|
2265
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='')
|
|
2266
|
-
except Exception
|
|
2430
|
+
except Exception:
|
|
2267
2431
|
# import traceback
|
|
2268
2432
|
# print(str(e).strip())
|
|
2269
2433
|
# print(traceback.format_exc().strip())
|
|
2270
2434
|
if org_dim != stdscr.getmaxyx():
|
|
2271
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, 'Terminal resize detected')
|
|
2435
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, 'Terminal resize detected')
|
|
2272
2436
|
if host.lastPrintedUpdateTime != host.lastUpdateTime and host.output_buffer.tell() > 0:
|
|
2273
2437
|
# this means there is still output in the buffer, we will print it
|
|
2274
2438
|
# we will print the output in the window
|
|
@@ -2276,11 +2440,14 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2276
2440
|
host_window.noutrefresh()
|
|
2277
2441
|
host.lastPrintedUpdateTime = host.lastUpdateTime
|
|
2278
2442
|
hosts_to_display, host_stats,rearrangedHosts = _get_hosts_to_display(hosts, max_num_hosts,hosts_to_display, indexOffset)
|
|
2443
|
+
if help_shown:
|
|
2444
|
+
help_window.touchwin()
|
|
2445
|
+
help_window.noutrefresh()
|
|
2279
2446
|
curses.doupdate()
|
|
2280
2447
|
last_refresh_time = time.perf_counter()
|
|
2281
2448
|
except Exception as e:
|
|
2282
2449
|
import traceback
|
|
2283
|
-
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window, f'Error: {str(e)}',traceback.format_exc())
|
|
2450
|
+
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, f'Error: {str(e)}',traceback.format_exc())
|
|
2284
2451
|
return None
|
|
2285
2452
|
|
|
2286
2453
|
def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window = DEFAULT_SINGLE_WINDOW):
|
|
@@ -2328,9 +2495,9 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
|
|
|
2328
2495
|
# print if can change color
|
|
2329
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)
|
|
2330
2497
|
stdscr.refresh()
|
|
2331
|
-
except:
|
|
2498
|
+
except Exception:
|
|
2332
2499
|
pass
|
|
2333
|
-
params = (-1,0 , min_char_len, min_line_len, single_window,'new config')
|
|
2500
|
+
params = (-1,0 , min_char_len, min_line_len, single_window,False,'new config')
|
|
2334
2501
|
while params:
|
|
2335
2502
|
params = __generate_display(stdscr, hosts, *params)
|
|
2336
2503
|
if not params:
|
|
@@ -2341,21 +2508,166 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
|
|
|
2341
2508
|
# print the current configuration
|
|
2342
2509
|
stdscr.clear()
|
|
2343
2510
|
try:
|
|
2344
|
-
stdscr.addstr(0, 0, f"{params[
|
|
2345
|
-
if len(params) >
|
|
2511
|
+
stdscr.addstr(0, 0, f"{params[6]}, Reloading Configuration: min_char_len={params[2]}, min_line_len={params[3]}, single_window={params[4]} with window size {stdscr.getmaxyx()} and {len(hosts)} hosts...")
|
|
2512
|
+
if len(params) > 7:
|
|
2346
2513
|
# traceback is available, print it
|
|
2347
2514
|
i = 1
|
|
2348
|
-
for line in params[
|
|
2515
|
+
for line in params[7].split('\n'):
|
|
2349
2516
|
stdscr.addstr(i, 0, line)
|
|
2350
2517
|
i += 1
|
|
2351
2518
|
stdscr.refresh()
|
|
2352
|
-
except:
|
|
2519
|
+
except Exception:
|
|
2353
2520
|
pass
|
|
2354
|
-
params = params[:
|
|
2521
|
+
params = params[:6] + ('new config',)
|
|
2355
2522
|
time.sleep(0.01)
|
|
2356
2523
|
#time.sleep(0.25)
|
|
2357
2524
|
|
|
2358
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
|
+
|
|
2359
2671
|
def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
|
|
2360
2672
|
if quiet:
|
|
2361
2673
|
# remove hosts with returncode 0
|
|
@@ -2394,40 +2706,30 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2394
2706
|
rtnStr += 'User Inputs: '+ '\nUser Inputs: '.join(CMDsOut)
|
|
2395
2707
|
#rtnStr += '\n'
|
|
2396
2708
|
else:
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
for output, hostSet in outputs.items():
|
|
2413
|
-
compact_hosts = compact_hostnames(hostSet)
|
|
2414
|
-
rtnStr += '*'*80+'\n'
|
|
2415
|
-
if quiet:
|
|
2416
|
-
rtnStr += f'Abnormal returncode produced by {",".join(compact_hosts)}:\n'
|
|
2417
|
-
rtnStr += output+'\n'
|
|
2418
|
-
else:
|
|
2419
|
-
rtnStr += f'These hosts: "{",".join(compact_hosts)}" have a response of:\n'
|
|
2420
|
-
rtnStr += output+'\n'
|
|
2421
|
-
if not quiet or outputs:
|
|
2422
|
-
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)
|
|
2423
2724
|
if keyPressesIn[-1]:
|
|
2424
2725
|
CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
rtnStr += '\n'
|
|
2726
|
+
outputs.append("├─ User Inputs:".ljust(terminal_length-1,'─'))
|
|
2727
|
+
outputs.extend(CMDsOut)
|
|
2428
2728
|
keyPressesIn[-1].clear()
|
|
2429
2729
|
if quiet and not outputs:
|
|
2430
|
-
rtnStr
|
|
2730
|
+
rtnStr = 'Success'
|
|
2731
|
+
else:
|
|
2732
|
+
rtnStr = '\n'.join(outputs + [('╘'+'─'*(terminal_length-1))])
|
|
2431
2733
|
return rtnStr
|
|
2432
2734
|
|
|
2433
2735
|
def print_output(hosts,usejson = False,quiet = False,greppable = False):
|
|
@@ -2485,8 +2787,8 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
|
|
|
2485
2787
|
availableHosts = set()
|
|
2486
2788
|
for host in hosts:
|
|
2487
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)):
|
|
2488
|
-
unavailableHosts[host.name] = int(time.monotonic())
|
|
2489
|
-
__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)
|
|
2490
2792
|
else:
|
|
2491
2793
|
availableHosts.add(host.name)
|
|
2492
2794
|
if host.name in unavailableHosts:
|
|
@@ -2511,7 +2813,7 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
|
|
|
2511
2813
|
expireTime = int(line.split(',')[1])
|
|
2512
2814
|
if expireTime < time.monotonic() and hostname not in availableHosts:
|
|
2513
2815
|
oldDic[hostname] = expireTime
|
|
2514
|
-
except:
|
|
2816
|
+
except Exception:
|
|
2515
2817
|
pass
|
|
2516
2818
|
# add new entries
|
|
2517
2819
|
oldDic.update(unavailableHosts)
|
|
@@ -2565,33 +2867,60 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
|
|
|
2565
2867
|
repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
|
|
2566
2868
|
shortend = False) -> str:
|
|
2567
2869
|
argsList = []
|
|
2568
|
-
if oneonone:
|
|
2569
|
-
|
|
2570
|
-
if
|
|
2571
|
-
|
|
2572
|
-
if
|
|
2573
|
-
|
|
2574
|
-
if
|
|
2575
|
-
|
|
2576
|
-
if
|
|
2577
|
-
|
|
2578
|
-
if
|
|
2579
|
-
|
|
2580
|
-
if
|
|
2581
|
-
|
|
2582
|
-
if
|
|
2583
|
-
|
|
2584
|
-
if
|
|
2585
|
-
|
|
2586
|
-
if
|
|
2587
|
-
|
|
2588
|
-
if
|
|
2589
|
-
|
|
2590
|
-
if
|
|
2591
|
-
|
|
2592
|
-
if
|
|
2593
|
-
|
|
2594
|
-
if
|
|
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')
|
|
2595
2924
|
return ' '.join(argsList)
|
|
2596
2925
|
|
|
2597
2926
|
def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
|
|
@@ -2751,7 +3080,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
2751
3080
|
if line and ',' in line and len(line.split(',')) >= 2 and line.split(',')[0] and line.split(',')[1].isdigit():
|
|
2752
3081
|
hostname = line.split(',')[0]
|
|
2753
3082
|
expireTime = int(line.split(',')[1])
|
|
2754
|
-
if expireTime
|
|
3083
|
+
if expireTime > time.monotonic():
|
|
2755
3084
|
__globalUnavailableHosts[hostname] = expireTime
|
|
2756
3085
|
readed = True
|
|
2757
3086
|
if readed and not __global_suppress_printout:
|
|
@@ -2760,7 +3089,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
2760
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}")
|
|
2761
3090
|
eprint(str(e))
|
|
2762
3091
|
elif '__multiSSH3_UNAVAILABLE_HOSTS' in readEnvFromFile():
|
|
2763
|
-
__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})
|
|
2764
3093
|
if not max_connections:
|
|
2765
3094
|
max_connections = 4 * os.cpu_count()
|
|
2766
3095
|
elif max_connections == 0:
|
|
@@ -2832,7 +3161,8 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
2832
3161
|
# we will copy the id to the hosts
|
|
2833
3162
|
hosts = []
|
|
2834
3163
|
for host in targetHostDic:
|
|
2835
|
-
if host in skipHostSet or targetHostDic[host] in skipHostSet:
|
|
3164
|
+
if host in skipHostSet or targetHostDic[host] in skipHostSet:
|
|
3165
|
+
continue
|
|
2836
3166
|
command = f"{_binPaths['ssh-copy-id']} "
|
|
2837
3167
|
if identity_file:
|
|
2838
3168
|
command = f"{command}-i {identity_file} "
|
|
@@ -2868,7 +3198,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
2868
3198
|
for file in files:
|
|
2869
3199
|
try:
|
|
2870
3200
|
pathSet.update(glob.glob(file,include_hidden=True,recursive=True))
|
|
2871
|
-
except:
|
|
3201
|
+
except Exception:
|
|
2872
3202
|
pathSet.update(glob.glob(file,recursive=True))
|
|
2873
3203
|
if not pathSet:
|
|
2874
3204
|
_exit_with_code(66, f'No source files at {files!r} are found after resolving globs!')
|
|
@@ -2893,17 +3223,19 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
2893
3223
|
eprint('-'*80)
|
|
2894
3224
|
eprint("Running in one on one mode")
|
|
2895
3225
|
for host, command in zip(targetHostDic, commands):
|
|
2896
|
-
if not ipmi and skipUnreachable and host in unavailableHosts:
|
|
3226
|
+
if not ipmi and skipUnreachable and host in unavailableHosts and unavailableHosts[host] > time.monotonic():
|
|
2897
3227
|
eprint(f"Skipping unavailable host: {host}")
|
|
2898
3228
|
continue
|
|
2899
|
-
if host in skipHostSet or targetHostDic[host] in skipHostSet:
|
|
3229
|
+
if host in skipHostSet or targetHostDic[host] in skipHostSet:
|
|
3230
|
+
continue
|
|
2900
3231
|
if file_sync:
|
|
2901
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]))
|
|
2902
3233
|
else:
|
|
2903
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]))
|
|
2904
3235
|
if not __global_suppress_printout:
|
|
2905
3236
|
eprint(f"Running command: {command!r} on host: {host!r}")
|
|
2906
|
-
if not __global_suppress_printout:
|
|
3237
|
+
if not __global_suppress_printout:
|
|
3238
|
+
eprint('-'*80)
|
|
2907
3239
|
if not no_start:
|
|
2908
3240
|
processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
|
|
2909
3241
|
returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
|
|
@@ -2917,15 +3249,17 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
2917
3249
|
# run in interactive mode ssh mode
|
|
2918
3250
|
hosts = []
|
|
2919
3251
|
for host in targetHostDic:
|
|
2920
|
-
if not ipmi and skipUnreachable and host in unavailableHosts:
|
|
2921
|
-
if not __global_suppress_printout:
|
|
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:
|
|
2922
3257
|
continue
|
|
2923
|
-
if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
|
|
2924
3258
|
if file_sync:
|
|
2925
|
-
eprint(
|
|
3259
|
+
eprint("Error: file sync mode need to be specified with at least one path to sync.")
|
|
2926
3260
|
return []
|
|
2927
3261
|
elif files:
|
|
2928
|
-
eprint(
|
|
3262
|
+
eprint("Error: files need to be specified with at least one path to sync")
|
|
2929
3263
|
else:
|
|
2930
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]))
|
|
2931
3265
|
if not __global_suppress_printout:
|
|
@@ -2933,7 +3267,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
2933
3267
|
eprint(f"Running in interactive mode on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
|
|
2934
3268
|
eprint('-'*80)
|
|
2935
3269
|
if no_start:
|
|
2936
|
-
eprint(
|
|
3270
|
+
eprint("Warning: no_start is set, the command will not be started. As we are in interactive mode, no action will be done.")
|
|
2937
3271
|
else:
|
|
2938
3272
|
processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
|
|
2939
3273
|
returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
|
|
@@ -2944,10 +3278,12 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
2944
3278
|
for command in commands:
|
|
2945
3279
|
hosts = []
|
|
2946
3280
|
for host in targetHostDic:
|
|
2947
|
-
if not ipmi and skipUnreachable and host in unavailableHosts:
|
|
2948
|
-
if not __global_suppress_printout:
|
|
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:
|
|
2949
3286
|
continue
|
|
2950
|
-
if host in skipHostSet or targetHostDic[host] in skipHostSet: continue
|
|
2951
3287
|
if file_sync:
|
|
2952
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]))
|
|
2953
3289
|
else:
|
|
@@ -2997,6 +3333,8 @@ def generate_default_config(args):
|
|
|
2997
3333
|
'DEFAULT_IPMI': args.ipmi,
|
|
2998
3334
|
'DEFAULT_IPMI_INTERFACE_IP_PREFIX': args.ipmi_interface_ip_prefix,
|
|
2999
3335
|
'DEFAULT_INTERFACE_IP_PREFIX': args.interface_ip_prefix,
|
|
3336
|
+
'DEFAULT_IPMI_USERNAME': args.ipmi_username,
|
|
3337
|
+
'DEFAULT_IPMI_PASSWORD': args.ipmi_password,
|
|
3000
3338
|
'DEFAULT_NO_WATCH': args.no_watch,
|
|
3001
3339
|
'DEFAULT_CURSES_MINIMUM_CHAR_LEN': args.window_width,
|
|
3002
3340
|
'DEFAULT_CURSES_MINIMUM_LINE_LEN': args.window_height,
|
|
@@ -3015,6 +3353,7 @@ def generate_default_config(args):
|
|
|
3015
3353
|
'DEFAULT_SKIP_UNREACHABLE': args.skip_unreachable,
|
|
3016
3354
|
'DEFAULT_SKIP_HOSTS': args.skip_hosts,
|
|
3017
3355
|
'DEFAULT_ENCODING': args.encoding,
|
|
3356
|
+
'DEFAULT_DIFF_DISPLAY_THRESHOLD': args.diff_display_threshold,
|
|
3018
3357
|
'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
|
|
3019
3358
|
'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
|
|
3020
3359
|
}
|
|
@@ -3029,9 +3368,9 @@ def write_default_config(args,CONFIG_FILE = None):
|
|
|
3029
3368
|
backup = True
|
|
3030
3369
|
if os.path.exists(CONFIG_FILE):
|
|
3031
3370
|
eprint(f"Warning: {CONFIG_FILE!r} already exists, what to do? (o/b/n)")
|
|
3032
|
-
eprint(
|
|
3371
|
+
eprint("o: Overwrite the file")
|
|
3033
3372
|
eprint(f"b: Rename the current config file at {CONFIG_FILE!r}.bak forcefully and write the new config file (default)")
|
|
3034
|
-
eprint(
|
|
3373
|
+
eprint("n: Do nothing")
|
|
3035
3374
|
inStr = input_with_timeout_and_countdown(10)
|
|
3036
3375
|
if (not inStr) or inStr.lower().strip().startswith('b'):
|
|
3037
3376
|
backup = True
|
|
@@ -3054,7 +3393,7 @@ def write_default_config(args,CONFIG_FILE = None):
|
|
|
3054
3393
|
eprint(f"Config file written to {CONFIG_FILE!r}")
|
|
3055
3394
|
except Exception as e:
|
|
3056
3395
|
eprint(f"Error: Unable to write to the config file: {e!r}")
|
|
3057
|
-
eprint(
|
|
3396
|
+
eprint('Printing the config file to stdout:')
|
|
3058
3397
|
print(json.dumps(__configs_from_file, indent=4))
|
|
3059
3398
|
|
|
3060
3399
|
#%% ------------ Argument Processing -----------------
|
|
@@ -3073,7 +3412,7 @@ def get_parser():
|
|
|
3073
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")
|
|
3074
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)
|
|
3075
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)
|
|
3076
|
-
parser.add_argument('-G','-gm','--gather_mode', action='store_true', help=
|
|
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)
|
|
3077
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")
|
|
3078
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)
|
|
3079
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)
|
|
@@ -3082,6 +3421,8 @@ def get_parser():
|
|
|
3082
3421
|
parser.add_argument('-M',"--ipmi", action='store_true', help=f"Use ipmitool to run the command. (default: {DEFAULT_IPMI})", default=DEFAULT_IPMI)
|
|
3083
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)
|
|
3084
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)
|
|
3085
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)
|
|
3086
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)
|
|
3087
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)
|
|
@@ -3091,28 +3432,31 @@ def get_parser():
|
|
|
3091
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)
|
|
3092
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)
|
|
3093
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)
|
|
3094
|
-
parser.add_argument("-m","--max_connections", type=int, help=
|
|
3435
|
+
parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
|
|
3095
3436
|
parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
|
|
3096
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)
|
|
3097
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)
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3439
|
+
su_group = parser.add_mutually_exclusive_group()
|
|
3440
|
+
su_group.add_argument('-x',"-su","--skip_unreachable", action='store_true', help=f"Skip unreachable hosts. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {DEFAULT_SKIP_UNREACHABLE})", default=DEFAULT_SKIP_UNREACHABLE)
|
|
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)
|
|
3101
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)
|
|
3102
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)
|
|
3103
|
-
parser.add_argument('--generate_config_file', action='store_true', help=
|
|
3104
|
-
parser.add_argument('--config_file', type=str,nargs='?', help=
|
|
3105
|
-
parser.add_argument('--store_config_file',type = str,nargs='?',help=
|
|
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')
|
|
3106
3447
|
parser.add_argument('--debug', action='store_true', help='Print debug information')
|
|
3107
3448
|
parser.add_argument('-ci','--copy_id', action='store_true', help='Copy the ssh id to the hosts')
|
|
3108
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)
|
|
3109
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)
|
|
3110
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')
|
|
3111
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)
|
|
3112
3454
|
parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
|
|
3113
3455
|
return parser
|
|
3114
3456
|
|
|
3115
3457
|
def process_args(args = None):
|
|
3458
|
+
global DEFAULT_IPMI_USERNAME
|
|
3459
|
+
global DEFAULT_IPMI_PASSWORD
|
|
3116
3460
|
parser = get_parser()
|
|
3117
3461
|
# We handle the signal
|
|
3118
3462
|
signal.signal(signal.SIGINT, signal_handler)
|
|
@@ -3172,10 +3516,10 @@ def process_config_file(args):
|
|
|
3172
3516
|
|
|
3173
3517
|
def process_commands(args):
|
|
3174
3518
|
if not args.file and len(args.commands) > 1 and all([len(command.split()) == 1 for command in args.commands]):
|
|
3175
|
-
eprint(
|
|
3519
|
+
eprint("Multiple one word command detected, what to do? (1/m/n)")
|
|
3176
3520
|
eprint(f"1: Run 1 command [{' '.join(args.commands)}] on all hosts ( default )")
|
|
3177
3521
|
eprint(f"m: Run multiple commands [{', '.join(args.commands)}] on all hosts")
|
|
3178
|
-
eprint(
|
|
3522
|
+
eprint("n: Exit")
|
|
3179
3523
|
inStr = input_with_timeout_and_countdown(3)
|
|
3180
3524
|
if (not inStr) or inStr.lower().strip().startswith('1'):
|
|
3181
3525
|
args.commands = [" ".join(args.commands)]
|
|
@@ -3206,6 +3550,9 @@ def set_global_with_args(args):
|
|
|
3206
3550
|
global __configs_from_file
|
|
3207
3551
|
global _encoding
|
|
3208
3552
|
global __returnZero
|
|
3553
|
+
global DEFAULT_IPMI_USERNAME
|
|
3554
|
+
global DEFAULT_IPMI_PASSWORD
|
|
3555
|
+
global DEFAULT_DIFF_DISPLAY_THRESHOLD
|
|
3209
3556
|
_emo = False
|
|
3210
3557
|
__ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
|
|
3211
3558
|
_env_file = args.env_file
|
|
@@ -3213,6 +3560,11 @@ def set_global_with_args(args):
|
|
|
3213
3560
|
_encoding = args.encoding
|
|
3214
3561
|
if args.return_zero:
|
|
3215
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
|
|
3216
3568
|
|
|
3217
3569
|
#%% ------------ Wrapper Block ----------------
|
|
3218
3570
|
def main():
|
|
@@ -3252,7 +3604,8 @@ def main():
|
|
|
3252
3604
|
eprint(f"Sleeping for {args.interval} seconds")
|
|
3253
3605
|
time.sleep(args.interval)
|
|
3254
3606
|
|
|
3255
|
-
if not __global_suppress_printout:
|
|
3607
|
+
if not __global_suppress_printout:
|
|
3608
|
+
eprint(f"Running the {i+1}/{args.repeat} time") if args.repeat > 1 else None
|
|
3256
3609
|
hosts = run_command_on_hosts(args.hosts,args.commands,
|
|
3257
3610
|
oneonone=args.oneonone,timeout=args.timeout,password=args.password,
|
|
3258
3611
|
no_watch=args.no_watch,json=args.json,called=args.no_output,max_connections=args.max_connections,
|
|
@@ -3278,7 +3631,8 @@ def main():
|
|
|
3278
3631
|
eprint(f'Complete. Failed hosts (Return Code not 0) count: {__mainReturnCode}')
|
|
3279
3632
|
eprint(f'failed_hosts: {",".join(compact_hostnames(__failedHosts))}')
|
|
3280
3633
|
else:
|
|
3281
|
-
if not __global_suppress_printout:
|
|
3634
|
+
if not __global_suppress_printout:
|
|
3635
|
+
eprint('Complete. All hosts returned 0.')
|
|
3282
3636
|
|
|
3283
3637
|
if args.success_hosts and not __global_suppress_printout:
|
|
3284
3638
|
eprint(f'succeeded_hosts: {",".join(compact_hostnames(succeededHosts))}')
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
multiSSH3.py,sha256=xvYajOU_FiCfgYlGcO7AjJofnGgAj01DHarCIUeXiqU,166815
|
|
2
|
+
multissh3-5.90.dist-info/METADATA,sha256=0kE8yqDXIrcrWqRnhgqeiZ1X3U8-odE2r6QY8uIDZKk,18093
|
|
3
|
+
multissh3-5.90.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
4
|
+
multissh3-5.90.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
|
|
5
|
+
multissh3-5.90.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
|
|
6
|
+
multissh3-5.90.dist-info/RECORD,,
|
multissh3-5.85.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
multiSSH3.py,sha256=R7-ucXDmtJwhuaxT7uvJ3RXhdfKtQZZVW5BnsEjYyHA,154282
|
|
2
|
-
multissh3-5.85.dist-info/METADATA,sha256=xoQc9yqGuFvaS8Gdk8d16FzVmOQYTfM2ZyuBHaOJmx8,18093
|
|
3
|
-
multissh3-5.85.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
4
|
-
multissh3-5.85.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
|
|
5
|
-
multissh3-5.85.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
|
|
6
|
-
multissh3-5.85.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|