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.
- {multissh3-5.86 → multissh3-5.90}/PKG-INFO +1 -1
- {multissh3-5.86 → multissh3-5.90}/multiSSH3.egg-info/PKG-INFO +1 -1
- {multissh3-5.86 → multissh3-5.90}/multiSSH3.py +491 -139
- {multissh3-5.86 → multissh3-5.90}/README.md +0 -0
- {multissh3-5.86 → multissh3-5.90}/multiSSH3.egg-info/SOURCES.txt +0 -0
- {multissh3-5.86 → multissh3-5.90}/multiSSH3.egg-info/dependency_links.txt +0 -0
- {multissh3-5.86 → multissh3-5.90}/multiSSH3.egg-info/entry_points.txt +0 -0
- {multissh3-5.86 → multissh3-5.90}/multiSSH3.egg-info/requires.txt +0 -0
- {multissh3-5.86 → multissh3-5.90}/multiSSH3.egg-info/top_level.txt +0 -0
- {multissh3-5.86 → multissh3-5.90}/setup.cfg +0 -0
- {multissh3-5.86 → multissh3-5.90}/setup.py +0 -0
- {multissh3-5.86 → multissh3-5.90}/test/test.py +0 -0
- {multissh3-5.86 → multissh3-5.90}/test/testCurses.py +0 -0
- {multissh3-5.86 → multissh3-5.90}/test/testCursesOld.py +0 -0
- {multissh3-5.86 → multissh3-5.90}/test/testPerfCompact.py +0 -0
- {multissh3-5.86 → multissh3-5.90}/test/testPerfExpand.py +0 -0
|
@@ -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-10-
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
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
|
-
|
|
2428
|
-
|
|
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
|
|
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:
|
|
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
|
|
2595
|
-
|
|
2596
|
-
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')
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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=
|
|
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=
|
|
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=
|
|
3106
|
-
parser.add_argument('--config_file', type=str,nargs='?', help=
|
|
3107
|
-
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')
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|