multiSSH3 5.95__py3-none-any.whl → 6.1__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.
- multiSSH3.py +327 -165
- multissh3-6.1.dist-info/METADATA +698 -0
- multissh3-6.1.dist-info/RECORD +6 -0
- multissh3-5.95.dist-info/METADATA +0 -379
- multissh3-5.95.dist-info/RECORD +0 -6
- {multissh3-5.95.dist-info → multissh3-6.1.dist-info}/WHEEL +0 -0
- {multissh3-5.95.dist-info → multissh3-6.1.dist-info}/entry_points.txt +0 -0
- {multissh3-5.95.dist-info → multissh3-6.1.dist-info}/top_level.txt +0 -0
multiSSH3.py
CHANGED
|
@@ -84,10 +84,10 @@ except Exception:
|
|
|
84
84
|
print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
|
|
85
85
|
def cache_decorator(func):
|
|
86
86
|
return func
|
|
87
|
-
version = '
|
|
87
|
+
version = '6.01'
|
|
88
88
|
VERSION = version
|
|
89
89
|
__version__ = version
|
|
90
|
-
COMMIT_DATE = '2025-
|
|
90
|
+
COMMIT_DATE = '2025-11-06'
|
|
91
91
|
|
|
92
92
|
CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
|
|
93
93
|
'~/multiSSH3.config.json',
|
|
@@ -99,7 +99,6 @@ CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
|
|
|
99
99
|
ERRORS = []
|
|
100
100
|
|
|
101
101
|
# TODO: Add terminal TUI
|
|
102
|
-
# TODO: Change -fs behavior
|
|
103
102
|
|
|
104
103
|
#%% ------------ Pre Helper Functions ----------------
|
|
105
104
|
def eprint(*args, **kwargs):
|
|
@@ -322,7 +321,7 @@ DEFAULT_HOSTS = 'all'
|
|
|
322
321
|
DEFAULT_USERNAME = None
|
|
323
322
|
DEFAULT_PASSWORD = ''
|
|
324
323
|
DEFAULT_IDENTITY_FILE = None
|
|
325
|
-
|
|
324
|
+
DEFAULT_SSH_KEY_SEARCH_PATH = '~/.ssh/'
|
|
326
325
|
DEFAULT_USE_KEY = False
|
|
327
326
|
DEFAULT_EXTRA_ARGS = None
|
|
328
327
|
DEFAULT_ONE_ON_ONE = False
|
|
@@ -377,6 +376,24 @@ ERROR_MESSAGES_TO_IGNORE = [
|
|
|
377
376
|
'Killed by signal',
|
|
378
377
|
'Connection reset by peer',
|
|
379
378
|
]
|
|
379
|
+
__DEFAULT_COLOR_PALETTE = {
|
|
380
|
+
'cyan': (86, 173, 188),
|
|
381
|
+
'green': (114, 180, 43),
|
|
382
|
+
'magenta': (140, 107, 200),
|
|
383
|
+
'red': (196, 38, 94),
|
|
384
|
+
'white': (227, 227, 221),
|
|
385
|
+
'yellow': (179, 180, 43),
|
|
386
|
+
'blue': (106, 126, 200),
|
|
387
|
+
'bright_black': (102, 102, 102),
|
|
388
|
+
'bright_blue': (129, 154, 255),
|
|
389
|
+
'bright_cyan': (102, 217, 239),
|
|
390
|
+
'bright_green': (126, 226, 46),
|
|
391
|
+
'bright_magenta': (174, 129, 255),
|
|
392
|
+
'bright_red': (249, 38, 114),
|
|
393
|
+
'bright_white': (248, 248, 242),
|
|
394
|
+
'bright_yellow': (226, 226, 46),
|
|
395
|
+
}
|
|
396
|
+
COLOR_PALETTE = __DEFAULT_COLOR_PALETTE.copy()
|
|
380
397
|
_DEFAULT_CALLED = True
|
|
381
398
|
_DEFAULT_RETURN_UNFINISHED = False
|
|
382
399
|
_DEFAULT_UPDATE_UNREACHABLE_HOSTS = True
|
|
@@ -420,6 +437,11 @@ __thread_start_delay = 0
|
|
|
420
437
|
_encoding = DEFAULT_ENCODING
|
|
421
438
|
__returnZero = DEFAULT_RETURN_ZERO
|
|
422
439
|
__running_threads = set()
|
|
440
|
+
__control_master_string = '''Host *
|
|
441
|
+
ControlMaster auto
|
|
442
|
+
ControlPath /run/user/%i/ssh_sockets_%C
|
|
443
|
+
ControlPersist 3600
|
|
444
|
+
'''
|
|
423
445
|
if __resource_lib_available:
|
|
424
446
|
# Get the current limits
|
|
425
447
|
_, __system_nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
@@ -467,12 +489,12 @@ def check_path(program_name):
|
|
|
467
489
|
|
|
468
490
|
[check_path(program) for program in _binCalled]
|
|
469
491
|
|
|
470
|
-
def find_ssh_key_file(searchPath =
|
|
492
|
+
def find_ssh_key_file(searchPath = DEFAULT_SSH_KEY_SEARCH_PATH):
|
|
471
493
|
'''
|
|
472
494
|
Find the ssh public key file
|
|
473
495
|
|
|
474
496
|
Args:
|
|
475
|
-
searchPath (str, optional): The path to search. Defaults to
|
|
497
|
+
searchPath (str, optional): The path to search. Defaults to DEFAULT_SSH_KEY_SEARCH_PATH.
|
|
476
498
|
|
|
477
499
|
Returns:
|
|
478
500
|
str: The path to the ssh key file
|
|
@@ -931,6 +953,8 @@ def get_terminal_color_capability():
|
|
|
931
953
|
return '24bit'
|
|
932
954
|
elif "256" in term:
|
|
933
955
|
return '256'
|
|
956
|
+
elif "16" in term:
|
|
957
|
+
return '16'
|
|
934
958
|
try:
|
|
935
959
|
curses.setupterm()
|
|
936
960
|
colors = curses.tigetnum("colors")
|
|
@@ -949,96 +973,120 @@ def get_terminal_color_capability():
|
|
|
949
973
|
return 'None'
|
|
950
974
|
|
|
951
975
|
@cache_decorator
|
|
952
|
-
def
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
976
|
+
def rgb_to_ansi_color_string(r, g, b):
|
|
977
|
+
"""
|
|
978
|
+
Return an ANSI escape sequence setting the foreground to (r,g,b)
|
|
979
|
+
approximated to the terminal's capability, or '' if none.
|
|
980
|
+
"""
|
|
981
|
+
cap = get_terminal_color_capability()
|
|
982
|
+
if cap == 'None':
|
|
983
|
+
return ''
|
|
984
|
+
if cap == '24bit':
|
|
985
|
+
return f'\x1b[38;2;{r};{g};{b}m'
|
|
986
|
+
if cap == '256':
|
|
987
|
+
idx = _rgb_to_256_color(r, g, b)
|
|
988
|
+
return f'\x1b[38;5;{idx}m'
|
|
989
|
+
if cap == '16':
|
|
990
|
+
idx = _rgb_to_16_color(r, g, b)
|
|
991
|
+
# 0–7 = 30–37, 8–15 = 90–97
|
|
992
|
+
if idx < 8:
|
|
993
|
+
return f'\x1b[{30 + idx}m'
|
|
994
|
+
else:
|
|
995
|
+
return f'\x1b[{90 + (idx - 8)}m'
|
|
996
|
+
if cap == '8':
|
|
997
|
+
idx = _rgb_to_8_color(r, g, b)
|
|
998
|
+
return f'\x1b[{30 + idx}m'
|
|
999
|
+
return ''
|
|
974
1000
|
|
|
975
|
-
|
|
976
|
-
def rgb_to_xterm_index(r, g, b):
|
|
1001
|
+
def _rgb_to_256_color(r, g, b):
|
|
977
1002
|
"""
|
|
978
|
-
Map
|
|
979
|
-
r, g, b should be in 0-255.
|
|
980
|
-
Returns an int in 0-255.
|
|
1003
|
+
Map (r,g,b) to the 256-color cube or grayscale ramp.
|
|
981
1004
|
"""
|
|
982
|
-
|
|
1005
|
+
# if it’s already gray, use the 232–255 grayscale ramp
|
|
1006
|
+
if r == g == b:
|
|
1007
|
+
# 24 shades from 232 to 255
|
|
1008
|
+
return 232 + int(round(r / 255 * 23))
|
|
1009
|
+
# else map each channel to 0–5
|
|
1010
|
+
def to6(v):
|
|
1011
|
+
return int(round(v / 255 * 5))
|
|
1012
|
+
r6, g6, b6 = to6(r), to6(g), to6(b)
|
|
1013
|
+
return 16 + 36 * r6 + 6 * g6 + b6
|
|
1014
|
+
|
|
1015
|
+
def _rgb_to_16_color(r, g, b):
|
|
1016
|
+
"""
|
|
1017
|
+
Pick the nearest of the 16 ANSI standard colors.
|
|
1018
|
+
Returns an index 0-15.
|
|
1019
|
+
"""
|
|
1020
|
+
palette = [
|
|
1021
|
+
(0, 0, 0), # 0 black
|
|
1022
|
+
(128, 0, 0), # 1 red
|
|
1023
|
+
(0, 128, 0), # 2 green
|
|
1024
|
+
(128, 128, 0), # 3 yellow
|
|
1025
|
+
(0, 0, 128), # 4 blue
|
|
1026
|
+
(128, 0, 128), # 5 magenta
|
|
1027
|
+
(0, 128, 128), # 6 cyan
|
|
1028
|
+
(192, 192, 192), # 7 white (light gray)
|
|
1029
|
+
(128, 128, 128), # 8 bright black (dark gray)
|
|
1030
|
+
(255, 0, 0), # 9 bright red
|
|
1031
|
+
(0, 255, 0), # 10 bright green
|
|
1032
|
+
(255, 255, 0), # 11 bright yellow
|
|
1033
|
+
(0, 0, 255), # 12 bright blue
|
|
1034
|
+
(255, 0, 255), # 13 bright magenta
|
|
1035
|
+
(0, 255, 255), # 14 bright cyan
|
|
1036
|
+
(255, 255, 255), # 15 bright white
|
|
1037
|
+
]
|
|
1038
|
+
best_idx = 0
|
|
983
1039
|
best_dist = float('inf')
|
|
984
|
-
for i, (pr, pg, pb) in enumerate(
|
|
985
|
-
|
|
986
|
-
dg = pg - g
|
|
987
|
-
db = pb - b
|
|
988
|
-
dist = dr*dr + dg*dg + db*db
|
|
1040
|
+
for i, (pr, pg, pb) in enumerate(palette):
|
|
1041
|
+
dist = (r - pr)**2 + (g - pg)**2 + (b - pb)**2
|
|
989
1042
|
if dist < best_dist:
|
|
990
1043
|
best_dist = dist
|
|
991
|
-
|
|
992
|
-
return
|
|
1044
|
+
best_idx = i
|
|
1045
|
+
return best_idx
|
|
993
1046
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1047
|
+
def _rgb_to_8_color(r, g, b):
|
|
1048
|
+
"""
|
|
1049
|
+
Reduce to 8 colors by mapping to the 16-color index then clamping 0-7.
|
|
1050
|
+
"""
|
|
1051
|
+
return _rgb_to_16_color(r//2, g//2, b//2)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def int_to_color(hash_value, min_brightness=100,max_brightness=220):
|
|
997
1055
|
r = (hash_value >> 16) & 0xFF
|
|
998
1056
|
g = (hash_value >> 8) & 0xFF
|
|
999
1057
|
b = hash_value & 0xFF
|
|
1000
|
-
|
|
1001
|
-
|
|
1058
|
+
brightness = math.sqrt(0.299 * r**2 + 0.587 * g**2 + 0.114 * b**2)
|
|
1059
|
+
if brightness < min_brightness:
|
|
1060
|
+
return int_to_color(hash(str(hash_value)), min_brightness, max_brightness)
|
|
1061
|
+
if brightness > max_brightness:
|
|
1062
|
+
return int_to_color(hash(str(hash_value)), min_brightness, max_brightness)
|
|
1002
1063
|
return (r, g, b)
|
|
1003
1064
|
|
|
1004
|
-
|
|
1065
|
+
__previous_color_rgb = ()
|
|
1005
1066
|
@cache_decorator
|
|
1006
|
-
def
|
|
1067
|
+
def int_to_unique_ansi_color(number):
|
|
1007
1068
|
'''
|
|
1008
|
-
Convert a
|
|
1069
|
+
Convert a number to a unique ANSI color code
|
|
1009
1070
|
|
|
1010
1071
|
Args:
|
|
1011
|
-
|
|
1012
|
-
|
|
1072
|
+
number (int): The number to convert
|
|
1013
1073
|
Returns:
|
|
1014
1074
|
int: The ANSI color code
|
|
1015
1075
|
'''
|
|
1016
|
-
global
|
|
1076
|
+
global __previous_color_rgb
|
|
1017
1077
|
# Use a hash function to generate a consistent integer from the string
|
|
1018
1078
|
color_capability = get_terminal_color_capability()
|
|
1019
|
-
index = None
|
|
1020
1079
|
if color_capability == 'None':
|
|
1021
1080
|
return ''
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
index = (hash(string) % 14) + 31
|
|
1025
|
-
if index > 37:
|
|
1026
|
-
index += 52 # Bright colors (90-97)
|
|
1027
|
-
elif color_capability == '8':
|
|
1028
|
-
index = (hash(string) % 6) + 31
|
|
1029
|
-
r,g,b = hashable_to_color(string)
|
|
1030
|
-
if color_capability == '256':
|
|
1031
|
-
index = rgb_to_xterm_index(r,g,b)
|
|
1032
|
-
if index:
|
|
1033
|
-
if index == __previous_ansi_color_index:
|
|
1034
|
-
return string_to_unique_ansi_color(hash(string))
|
|
1035
|
-
__previous_ansi_color_index = index
|
|
1036
|
-
if color_capability == '256':
|
|
1037
|
-
return f'\033[38;5;{index}m'
|
|
1038
|
-
else:
|
|
1039
|
-
return f'\033[{index}m'
|
|
1081
|
+
if color_capability == '24bit':
|
|
1082
|
+
r, g, b = int_to_color(number)
|
|
1040
1083
|
else:
|
|
1041
|
-
|
|
1084
|
+
# for 256 colors and below, reduce brightness threshold as we do not have many color to work with
|
|
1085
|
+
r, g, b = int_to_color(number, min_brightness=70, max_brightness=190)
|
|
1086
|
+
if sum(abs(a - b) for a, b in zip((r, g, b), __previous_color_rgb)) <= 256:
|
|
1087
|
+
r, g, b = int_to_color(hash(str(number)))
|
|
1088
|
+
__previous_color_rgb = (r, g, b)
|
|
1089
|
+
return rgb_to_ansi_color_string(r, g, b)
|
|
1042
1090
|
|
|
1043
1091
|
#%% ------------ Compacting Hostnames ----------------
|
|
1044
1092
|
def __tokenize_hostname(hostname):
|
|
@@ -1388,14 +1436,14 @@ def compact_hostnames(Hostnames,verify = True):
|
|
|
1388
1436
|
# hostSet = frozenset(Hostnames)
|
|
1389
1437
|
# else:
|
|
1390
1438
|
# hostSet = Hostnames
|
|
1391
|
-
hostSet = frozenset(
|
|
1439
|
+
hostSet = frozenset(expand_hostnames(
|
|
1392
1440
|
hostname.strip()
|
|
1393
1441
|
for hostnames_str in Hostnames
|
|
1394
1442
|
for hostname in hostnames_str.split(',')
|
|
1395
|
-
)
|
|
1443
|
+
))
|
|
1396
1444
|
compact_hosts = __compact_hostnames(hostSet)
|
|
1397
1445
|
if verify:
|
|
1398
|
-
if
|
|
1446
|
+
if frozenset(expand_hostnames(compact_hosts)) != hostSet:
|
|
1399
1447
|
if not __global_suppress_printout:
|
|
1400
1448
|
eprint(f"Error compacting hostnames: {hostSet} -> {compact_hosts}")
|
|
1401
1449
|
compact_hosts = hostSet
|
|
@@ -1765,6 +1813,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
|
1765
1813
|
host.command = replace_magic_strings(host.command,['#I#'],str(host.i),case_sensitive=False)
|
|
1766
1814
|
host.command = replace_magic_strings(host.command,['#PASSWD#','#PASSWORD#'],passwds,case_sensitive=False)
|
|
1767
1815
|
host.command = replace_magic_strings(host.command,['#UUID#'],str(host.uuid),case_sensitive=False)
|
|
1816
|
+
host.command = replace_magic_strings(host.command,['#IP#'],str(host.ip),case_sensitive=False)
|
|
1768
1817
|
formatedCMD = []
|
|
1769
1818
|
if host.extraargs and isinstance(host.extraargs, str):
|
|
1770
1819
|
extraargs = host.extraargs.split()
|
|
@@ -1963,8 +2012,6 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
|
1963
2012
|
stdout_thread.join(timeout=1)
|
|
1964
2013
|
stderr_thread.join(timeout=1)
|
|
1965
2014
|
stdin_thread.join(timeout=1)
|
|
1966
|
-
# here we handle the rest of the stdout after the subprocess returns
|
|
1967
|
-
host.output.append('Pipe Closed. Trying to read the rest of the stdout...')
|
|
1968
2015
|
if not _emo:
|
|
1969
2016
|
stdout = None
|
|
1970
2017
|
stderr = None
|
|
@@ -1973,8 +2020,10 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
|
1973
2020
|
except subprocess.TimeoutExpired:
|
|
1974
2021
|
pass
|
|
1975
2022
|
if stdout:
|
|
2023
|
+
host.output.append('Trying to read the rest of the stdout...')
|
|
1976
2024
|
__handle_reading_stream(io.BytesIO(stdout),host.stdout, host,host.stdout_buffer)
|
|
1977
2025
|
if stderr:
|
|
2026
|
+
host.output.append('Trying to read the rest of the stderr...')
|
|
1978
2027
|
__handle_reading_stream(io.BytesIO(stderr),host.stderr, host,host.stderr_buffer)
|
|
1979
2028
|
# if the last line in host.stderr is Connection to * closed., we will remove it
|
|
1980
2029
|
host.returncode = proc.poll()
|
|
@@ -2570,7 +2619,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2570
2619
|
elif key == curses.KEY_EXIT or key == 27: # 27 is the key code for ESC
|
|
2571
2620
|
# if the key is exit, we will exit the program
|
|
2572
2621
|
return
|
|
2573
|
-
elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1 or key == 8: # 63 is the key code for
|
|
2622
|
+
elif key == curses.KEY_HELP or key == 63 or key == curses.KEY_F1 or key == 8: # 63 is the key code for ?, 8 is the key code for backspace
|
|
2574
2623
|
# if the key is help, we will display the help message
|
|
2575
2624
|
if not help_shown:
|
|
2576
2625
|
help_panel.show()
|
|
@@ -2790,7 +2839,7 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
|
|
|
2790
2839
|
indexes = Counter({hostname: 0 for hostname in merging_hostnames})
|
|
2791
2840
|
working_index_keys = set(merging_hostnames)
|
|
2792
2841
|
previousBuddies = set()
|
|
2793
|
-
hostnameWrapper = textwrap.TextWrapper(width=line_length -
|
|
2842
|
+
hostnameWrapper = textwrap.TextWrapper(width=line_length -1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent=' ', subsequent_indent='- ')
|
|
2794
2843
|
hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
|
|
2795
2844
|
diff_display_item_count = max(1,int(max(map(len, outputs_by_hostname.values())) * (1 - diff_display_threshold)))
|
|
2796
2845
|
def get_multiset_index_for_hostname(hostname):
|
|
@@ -2825,6 +2874,13 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
|
|
|
2825
2874
|
# futures[hostname] # ensure it's initialized
|
|
2826
2875
|
futures = {hostname: get_multiset_index_for_hostname(hostname) for hostname in merging_hostnames}
|
|
2827
2876
|
currentLines = defaultdict(set)
|
|
2877
|
+
color_cap = get_terminal_color_capability()
|
|
2878
|
+
if color_cap == 'None':
|
|
2879
|
+
green_str = ''
|
|
2880
|
+
reset_str = ''
|
|
2881
|
+
else:
|
|
2882
|
+
green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('green', __DEFAULT_COLOR_PALETTE['green']))
|
|
2883
|
+
reset_str = '\033[0m'
|
|
2828
2884
|
for hostname in merging_hostnames:
|
|
2829
2885
|
currentLines[outputs_by_hostname[hostname][0]].add(hostname)
|
|
2830
2886
|
while indexes:
|
|
@@ -2862,12 +2918,18 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
|
|
|
2862
2918
|
if buddy != previousBuddies:
|
|
2863
2919
|
hostnameStr = ','.join(compact_hostnames(buddy))
|
|
2864
2920
|
hostnameLines = hostnameWrapper.wrap(hostnameStr)
|
|
2865
|
-
hostnameLines = [line.ljust(line_length
|
|
2866
|
-
|
|
2867
|
-
|
|
2921
|
+
# hostnameLines = [line.ljust(line_length) for line in hostnameLines]
|
|
2922
|
+
if color_cap == 'None':
|
|
2923
|
+
hostnameLines[0] = f"■{hostnameLines[0]}"
|
|
2924
|
+
elif len(buddy) < len(merging_hostnames):
|
|
2925
|
+
color = int_to_unique_ansi_color(hash(hostnameStr))
|
|
2926
|
+
hostnameLines[0] = f"{color}■{hostnameLines[0]}"
|
|
2927
|
+
hostnameLines[-1] += reset_str
|
|
2928
|
+
else:
|
|
2929
|
+
hostnameLines[0] = f"{green_str}■{reset_str}{hostnameLines[0]}"
|
|
2868
2930
|
output.extend(hostnameLines)
|
|
2869
2931
|
previousBuddies = buddy
|
|
2870
|
-
output.append(lineToAdd
|
|
2932
|
+
output.append(lineToAdd)
|
|
2871
2933
|
currentLines[lineToAdd].difference_update(buddy)
|
|
2872
2934
|
if not currentLines[lineToAdd]:
|
|
2873
2935
|
del currentLines[lineToAdd]
|
|
@@ -2891,16 +2953,27 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
|
|
|
2891
2953
|
|
|
2892
2954
|
def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
|
|
2893
2955
|
output = []
|
|
2894
|
-
|
|
2956
|
+
color_cap = get_terminal_color_capability()
|
|
2957
|
+
if color_cap == 'None':
|
|
2958
|
+
color_line = ''
|
|
2959
|
+
color_reset = ''
|
|
2960
|
+
green_str = ''
|
|
2961
|
+
else:
|
|
2962
|
+
color_line = rgb_to_ansi_color_string(*COLOR_PALETTE.get('white', __DEFAULT_COLOR_PALETTE['white']))
|
|
2963
|
+
color_reset = '\033[0m'
|
|
2964
|
+
green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('green', __DEFAULT_COLOR_PALETTE['green']))
|
|
2965
|
+
output.append(color_line+'─'*(line_length)+color_reset)
|
|
2966
|
+
hostnameWrapper = textwrap.TextWrapper(width=line_length - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False, break_on_hyphens=False,initial_indent=' ', subsequent_indent='- ')
|
|
2967
|
+
hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
|
|
2895
2968
|
for merging_hostnames in merge_groups:
|
|
2896
2969
|
mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
|
|
2897
|
-
output.append(
|
|
2970
|
+
output.append(color_line+'─'*(line_length)+color_reset)
|
|
2898
2971
|
for hostname in remaining_hostnames:
|
|
2899
|
-
hostnameLines =
|
|
2900
|
-
|
|
2901
|
-
output.extend(
|
|
2902
|
-
output.extend(
|
|
2903
|
-
output.append(
|
|
2972
|
+
hostnameLines = hostnameWrapper.wrap(','.join(compact_hostnames([hostname])))
|
|
2973
|
+
hostnameLines[0] = f"{green_str}■{color_reset}{hostnameLines[0]}"
|
|
2974
|
+
output.extend(hostnameLines)
|
|
2975
|
+
output.extend(outputs_by_hostname[hostname])
|
|
2976
|
+
output.append(color_line+'─'*(line_length)+color_reset)
|
|
2904
2977
|
if output:
|
|
2905
2978
|
output.pop()
|
|
2906
2979
|
# if output and output[0] and output[0].startswith('├'):
|
|
@@ -2917,7 +2990,7 @@ def pre_merge_hosts(hosts):
|
|
|
2917
2990
|
# Create merged hosts
|
|
2918
2991
|
merged_hosts = []
|
|
2919
2992
|
for group in output_groups.values():
|
|
2920
|
-
group[0].name = ','.join(host.name for host in group)
|
|
2993
|
+
group[0].name = ','.join(compact_hostnames(host.name for host in group))
|
|
2921
2994
|
merged_hosts.append(group[0])
|
|
2922
2995
|
return merged_hosts
|
|
2923
2996
|
|
|
@@ -2925,30 +2998,45 @@ def get_host_raw_output(hosts, terminal_width):
|
|
|
2925
2998
|
outputs_by_hostname = {}
|
|
2926
2999
|
line_bag_by_hostname = {}
|
|
2927
3000
|
hostnames_by_line_bag_len = {}
|
|
2928
|
-
text_wrapper = textwrap.TextWrapper(width=terminal_width -
|
|
2929
|
-
initial_indent='
|
|
3001
|
+
text_wrapper = textwrap.TextWrapper(width=terminal_width - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
3002
|
+
initial_indent=' ', subsequent_indent='-')
|
|
2930
3003
|
max_length = 20
|
|
3004
|
+
color_cap = get_terminal_color_capability()
|
|
3005
|
+
if color_cap == 'None':
|
|
3006
|
+
color_reset_str = ''
|
|
3007
|
+
blue_str = ''
|
|
3008
|
+
cyan_str = ''
|
|
3009
|
+
green_str = ''
|
|
3010
|
+
red_str = ''
|
|
3011
|
+
else:
|
|
3012
|
+
color_reset_str = '\033[0m'
|
|
3013
|
+
blue_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('blue', __DEFAULT_COLOR_PALETTE['blue']))
|
|
3014
|
+
cyan_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_cyan', __DEFAULT_COLOR_PALETTE['bright_cyan']))
|
|
3015
|
+
green_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_green', __DEFAULT_COLOR_PALETTE['bright_green']))
|
|
3016
|
+
red_str = rgb_to_ansi_color_string(*COLOR_PALETTE.get('bright_red', __DEFAULT_COLOR_PALETTE['bright_red']))
|
|
2931
3017
|
hosts = pre_merge_hosts(hosts)
|
|
2932
3018
|
for host in hosts:
|
|
2933
|
-
|
|
3019
|
+
max_length = max(max_length, len(max(host.name.split(','), key=len)) + 3)
|
|
3020
|
+
hostPrintOut = [f"{cyan_str}█{color_reset_str} EXECUTED COMMAND:"]
|
|
2934
3021
|
for line in host.command.splitlines():
|
|
2935
3022
|
hostPrintOut.extend(text_wrapper.wrap(line))
|
|
2936
3023
|
# hostPrintOut.extend(itertools.chain.from_iterable(text_wrapper.wrap(line) for line in host['command'].splitlines()))
|
|
2937
3024
|
lineBag = {(0,host.command)}
|
|
2938
3025
|
prevLine = host.command
|
|
2939
3026
|
if host.stdout:
|
|
2940
|
-
hostPrintOut.append('
|
|
3027
|
+
hostPrintOut.append(f'{blue_str}▓{color_reset_str} STDOUT:')
|
|
2941
3028
|
# for line in host.stdout:
|
|
2942
3029
|
# if len(line) < terminal_width - 2:
|
|
2943
|
-
# hostPrintOut.append(f"
|
|
3030
|
+
# hostPrintOut.append(f" {line}")
|
|
2944
3031
|
# else:
|
|
2945
3032
|
# hostPrintOut.extend(text_wrapper.wrap(line))
|
|
2946
|
-
hostPrintOut.extend(f"
|
|
3033
|
+
hostPrintOut.extend(f" {line}" for line in host.stdout)
|
|
3034
|
+
max_length = max(max_length, max(map(len, host.stdout)))
|
|
2947
3035
|
# hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
|
|
2948
3036
|
lineBag.add((prevLine,1))
|
|
2949
3037
|
lineBag.add((1,host.stdout[0]))
|
|
2950
3038
|
if len(host.stdout) > 1:
|
|
2951
|
-
lineBag.update(
|
|
3039
|
+
lineBag.update(zip(host.stdout, host.stdout[1:]))
|
|
2952
3040
|
lineBag.update(host.stdout)
|
|
2953
3041
|
prevLine = host.stdout[-1]
|
|
2954
3042
|
if host.stderr:
|
|
@@ -2959,22 +3047,26 @@ def get_host_raw_output(hosts, terminal_width):
|
|
|
2959
3047
|
elif host.stderr[-1].strip().endswith('No route to host'):
|
|
2960
3048
|
host.stderr[-1] = 'Cannot find host!'
|
|
2961
3049
|
if host.stderr:
|
|
2962
|
-
hostPrintOut.append('
|
|
3050
|
+
hostPrintOut.append(f'{red_str}▒{color_reset_str} STDERR:')
|
|
2963
3051
|
# for line in host.stderr:
|
|
2964
3052
|
# if len(line) < terminal_width - 2:
|
|
2965
|
-
# hostPrintOut.append(f"
|
|
3053
|
+
# hostPrintOut.append(f" {line}")
|
|
2966
3054
|
# else:
|
|
2967
3055
|
# hostPrintOut.extend(text_wrapper.wrap(line))
|
|
2968
|
-
hostPrintOut.extend(f"
|
|
3056
|
+
hostPrintOut.extend(f" {line}" for line in host.stderr)
|
|
3057
|
+
max_length = max(max_length, max(map(len, host.stderr)))
|
|
2969
3058
|
lineBag.add((prevLine,2))
|
|
2970
3059
|
lineBag.add((2,host.stderr[0]))
|
|
2971
3060
|
lineBag.update(host.stderr)
|
|
2972
3061
|
if len(host.stderr) > 1:
|
|
2973
|
-
lineBag.update(
|
|
3062
|
+
lineBag.update(zip(host.stderr, host.stderr[1:]))
|
|
2974
3063
|
prevLine = host.stderr[-1]
|
|
2975
|
-
|
|
3064
|
+
if host.returncode != 0:
|
|
3065
|
+
codeColor = red_str
|
|
3066
|
+
else:
|
|
3067
|
+
codeColor = green_str
|
|
3068
|
+
hostPrintOut.append(f"{codeColor}░{color_reset_str} RETURN CODE: {host.returncode}")
|
|
2976
3069
|
lineBag.add((prevLine,f"{host.returncode}"))
|
|
2977
|
-
max_length = max(max_length, max(map(len, hostPrintOut)))
|
|
2978
3070
|
outputs_by_hostname[host.name] = hostPrintOut
|
|
2979
3071
|
line_bag_by_hostname[host.name] = lineBag
|
|
2980
3072
|
hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host.name)
|
|
@@ -3015,6 +3107,7 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
|
|
|
3015
3107
|
return merge_groups, remaining_hostnames
|
|
3016
3108
|
|
|
3017
3109
|
def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
|
|
3110
|
+
color_cap = get_terminal_color_capability()
|
|
3018
3111
|
if quiet:
|
|
3019
3112
|
# remove hosts with returncode 0
|
|
3020
3113
|
hosts = [host for host in hosts if host.returncode != 0]
|
|
@@ -3022,7 +3115,10 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
3022
3115
|
if usejson:
|
|
3023
3116
|
return '{"Success": true}'
|
|
3024
3117
|
else:
|
|
3025
|
-
|
|
3118
|
+
if color_cap == 'None':
|
|
3119
|
+
return 'Success'
|
|
3120
|
+
else:
|
|
3121
|
+
return '\033[32mSuccess\033[0m'
|
|
3026
3122
|
if usejson:
|
|
3027
3123
|
# [print(dict(host)) for host in hosts]
|
|
3028
3124
|
#print(json.dumps([dict(host) for host in hosts],indent=4))
|
|
@@ -3057,23 +3153,32 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
3057
3153
|
except Exception:
|
|
3058
3154
|
eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
|
|
3059
3155
|
diff_display_threshold = 0.9
|
|
3156
|
+
|
|
3157
|
+
color_reset_str = '' if color_cap == 'None' else '\033[0m'
|
|
3158
|
+
white_str = '' if color_cap == 'None' else rgb_to_ansi_color_string(*COLOR_PALETTE.get('white', __DEFAULT_COLOR_PALETTE['white']))
|
|
3060
3159
|
terminal_length = get_terminal_size()[0]
|
|
3061
3160
|
outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_length = get_host_raw_output(hosts,terminal_length)
|
|
3062
3161
|
merge_groups ,remaining_hostnames = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
|
|
3063
3162
|
outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
|
|
3064
3163
|
if keyPressesIn[-1]:
|
|
3065
3164
|
CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
|
|
3066
|
-
outputs.append("
|
|
3165
|
+
outputs.append(color_reset_str + "░ User Inputs:".ljust(line_length,'─'))
|
|
3067
3166
|
cmdOut = []
|
|
3068
3167
|
for line in CMDsOut:
|
|
3069
3168
|
cmdOut.extend(textwrap.wrap(line, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
3070
|
-
initial_indent='
|
|
3071
|
-
outputs.extend(
|
|
3169
|
+
initial_indent=' ', subsequent_indent='-'))
|
|
3170
|
+
outputs.extend(cmdOut)
|
|
3072
3171
|
keyPressesIn[-1].clear()
|
|
3073
3172
|
if not outputs:
|
|
3074
|
-
|
|
3173
|
+
if quiet:
|
|
3174
|
+
if color_cap == 'None':
|
|
3175
|
+
return 'Success'
|
|
3176
|
+
else:
|
|
3177
|
+
return '\033[32mSuccess\033[0m'
|
|
3178
|
+
else:
|
|
3179
|
+
rtnStr = ''
|
|
3075
3180
|
else:
|
|
3076
|
-
rtnStr = '\n'.join(outputs + [
|
|
3181
|
+
rtnStr = '\n'.join(outputs + [white_str + '─' * (line_length) + color_reset_str])
|
|
3077
3182
|
return rtnStr
|
|
3078
3183
|
|
|
3079
3184
|
def print_output(hosts,usejson = False,quiet = False,greppable = False):
|
|
@@ -3119,7 +3224,16 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
|
|
|
3119
3224
|
if total_sleeped > 0.1:
|
|
3120
3225
|
break
|
|
3121
3226
|
if any([host.returncode is None for host in hosts]):
|
|
3122
|
-
|
|
3227
|
+
try:
|
|
3228
|
+
curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
|
|
3229
|
+
except Exception:
|
|
3230
|
+
try:
|
|
3231
|
+
os.environ['TERM'] = 'xterm-256color'
|
|
3232
|
+
curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
|
|
3233
|
+
except Exception as e:
|
|
3234
|
+
eprint(f"Curses print error: {e}")
|
|
3235
|
+
import traceback
|
|
3236
|
+
print(traceback.format_exc())
|
|
3123
3237
|
if not returnUnfinished:
|
|
3124
3238
|
# wait until all hosts have a return code
|
|
3125
3239
|
while any([host.returncode is None for host in hosts]):
|
|
@@ -3209,7 +3323,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
|
|
|
3209
3323
|
no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
|
|
3210
3324
|
file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
|
|
3211
3325
|
copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY, no_history = DEFAULT_NO_HISTORY,
|
|
3212
|
-
history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILES,
|
|
3326
|
+
history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
|
|
3213
3327
|
repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
|
|
3214
3328
|
shortend = False) -> str:
|
|
3215
3329
|
argsList = []
|
|
@@ -3253,8 +3367,10 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
|
|
|
3253
3367
|
argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
|
|
3254
3368
|
if no_env:
|
|
3255
3369
|
argsList.append('--no_env')
|
|
3256
|
-
if env_file
|
|
3257
|
-
argsList.
|
|
3370
|
+
if env_file:
|
|
3371
|
+
argsList.append(f'--env_file="{env_file}"' if not shortend else f'-ef="{env_file}"')
|
|
3372
|
+
if env_files:
|
|
3373
|
+
argsList.extend([f'--env_files="{ef}"' for ef in env_files] if not shortend else [f'-efs="{ef}"' for ef in env_files])
|
|
3258
3374
|
if no_history:
|
|
3259
3375
|
argsList.append('--no_history' if not shortend else '-nh')
|
|
3260
3376
|
if history_file and history_file != DEFAULT_HISTORY_FILE:
|
|
@@ -3277,7 +3393,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
|
|
|
3277
3393
|
skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
|
|
3278
3394
|
single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
|
|
3279
3395
|
copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = DEFAULT_NO_HISTORY,
|
|
3280
|
-
history_file = DEFAULT_HISTORY_FILE, env_file = DEFAULT_ENV_FILES,
|
|
3396
|
+
history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
|
|
3281
3397
|
repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
|
|
3282
3398
|
shortend = False,tabSeperated = False):
|
|
3283
3399
|
_ = called
|
|
@@ -3297,7 +3413,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
|
|
|
3297
3413
|
no_env=no_env, greppable=greppable,skip_hosts = skip_hosts,
|
|
3298
3414
|
file_sync = file_sync,error_only = error_only, identity_file = identity_file,
|
|
3299
3415
|
copy_id = copy_id, unavailable_host_expiry =unavailable_host_expiry,no_history = no_history,
|
|
3300
|
-
history_file = history_file, env_file = env_file,
|
|
3416
|
+
history_file = history_file, env_file = env_file, env_files = env_files,
|
|
3301
3417
|
repeat = repeat,interval = interval,
|
|
3302
3418
|
shortend = shortend)
|
|
3303
3419
|
commands = [command.replace('"', '\\"').replace('\n', '\\n').replace('\t', '\\t') for command in format_commands(commands)]
|
|
@@ -3356,48 +3472,48 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
3356
3472
|
copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = True,
|
|
3357
3473
|
history_file = DEFAULT_HISTORY_FILE,
|
|
3358
3474
|
):
|
|
3359
|
-
|
|
3360
|
-
Run
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
hosts (str
|
|
3364
|
-
commands (list):
|
|
3365
|
-
oneonone (bool
|
|
3366
|
-
timeout (int
|
|
3367
|
-
password (str
|
|
3368
|
-
no_watch (bool
|
|
3369
|
-
json (bool
|
|
3370
|
-
called (bool
|
|
3371
|
-
max_connections (int
|
|
3372
|
-
files (list
|
|
3373
|
-
ipmi (bool
|
|
3374
|
-
interface_ip_prefix (str
|
|
3375
|
-
returnUnfinished (bool
|
|
3376
|
-
scp (bool
|
|
3377
|
-
gather_mode (bool
|
|
3378
|
-
username (str
|
|
3379
|
-
extraargs (str
|
|
3380
|
-
skipUnreachable (bool
|
|
3381
|
-
no_env (bool
|
|
3382
|
-
greppable (bool
|
|
3383
|
-
willUpdateUnreachableHosts (bool
|
|
3384
|
-
no_start (bool
|
|
3385
|
-
skip_hosts (str
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
single_window (bool
|
|
3389
|
-
file_sync (bool
|
|
3390
|
-
error_only (bool
|
|
3391
|
-
quiet (bool
|
|
3392
|
-
identity_file (str
|
|
3393
|
-
copy_id (bool
|
|
3394
|
-
unavailable_host_expiry (int
|
|
3395
|
-
no_history (bool
|
|
3396
|
-
history_file (str
|
|
3475
|
+
"""
|
|
3476
|
+
Run commands on multiple hosts via SSH or IPMI.
|
|
3477
|
+
|
|
3478
|
+
Parameters:
|
|
3479
|
+
hosts (str or iterable): Hosts to run the command on. Can be a string (comma/space-separated) or iterable. Default: DEFAULT_HOSTS.
|
|
3480
|
+
commands (list or None): List of commands to run on the hosts. If files are used, defines the destination. Default: None.
|
|
3481
|
+
oneonone (bool): If True, run each command on the corresponding host (1:1 mapping). Default: DEFAULT_ONE_ON_ONE.
|
|
3482
|
+
timeout (int): Timeout for each command in seconds. Default: DEFAULT_TIMEOUT.
|
|
3483
|
+
password (str): Password for SSH/IPMI authentication. Default: DEFAULT_PASSWORD.
|
|
3484
|
+
no_watch (bool): If True, do not use curses TUI; just print output. Default: DEFAULT_NO_WATCH.
|
|
3485
|
+
json (bool): If True, output results in JSON format. Default: DEFAULT_JSON_MODE.
|
|
3486
|
+
called (bool): If True, function is called programmatically (not CLI). Default: _DEFAULT_CALLED.
|
|
3487
|
+
max_connections (int): Maximum concurrent SSH sessions. Default: 4 * os.cpu_count().
|
|
3488
|
+
files (list or None): Files to copy to hosts. Default: None.
|
|
3489
|
+
ipmi (bool): Use IPMI instead of SSH. Default: DEFAULT_IPMI.
|
|
3490
|
+
interface_ip_prefix (str or None): Override IP prefix for host connection. Default: DEFAULT_INTERFACE_IP_PREFIX.
|
|
3491
|
+
returnUnfinished (bool): If True, return hosts even if not finished. Default: _DEFAULT_RETURN_UNFINISHED.
|
|
3492
|
+
scp (bool): Use scp for file transfer (instead of rsync). Default: DEFAULT_SCP.
|
|
3493
|
+
gather_mode (bool): Gather files from hosts (pull mode). Default: False.
|
|
3494
|
+
username (str or None): Username for SSH/IPMI. Default: DEFAULT_USERNAME.
|
|
3495
|
+
extraargs (str or list or None): Extra args for SSH/SCP/rsync. Default: DEFAULT_EXTRA_ARGS.
|
|
3496
|
+
skipUnreachable (bool): Skip hosts marked as unreachable. Default: DEFAULT_SKIP_UNREACHABLE.
|
|
3497
|
+
no_env (bool): Do not load environment variables from shell. Default: DEFAULT_NO_ENV.
|
|
3498
|
+
greppable (bool): Output in greppable table format. Default: DEFAULT_GREPPABLE_MODE.
|
|
3499
|
+
willUpdateUnreachableHosts (bool): Update global unreachable hosts file. Default: _DEFAULT_UPDATE_UNREACHABLE_HOSTS.
|
|
3500
|
+
no_start (bool): If True, return Host objects without running commands. Default: _DEFAULT_NO_START.
|
|
3501
|
+
skip_hosts (str or None): Hosts to skip. Default: DEFAULT_SKIP_HOSTS.
|
|
3502
|
+
curses_min_char_len (int): Minimum width per curses window. Default: DEFAULT_CURSES_MINIMUM_CHAR_LEN.
|
|
3503
|
+
curses_min_line_len (int): Minimum height per curses window. Default: DEFAULT_CURSES_MINIMUM_LINE_LEN.
|
|
3504
|
+
single_window (bool): Use a single curses window for all hosts. Default: DEFAULT_SINGLE_WINDOW.
|
|
3505
|
+
file_sync (bool): Enable file sync mode (sync directories). Default: DEFAULT_FILE_SYNC.
|
|
3506
|
+
error_only (bool): Only print error output. Default: DEFAULT_ERROR_ONLY.
|
|
3507
|
+
quiet (bool): Suppress all output (overrides other output options). Default: False.
|
|
3508
|
+
identity_file (str or None): SSH identity file. Default: DEFAULT_IDENTITY_FILE.
|
|
3509
|
+
copy_id (bool): Use ssh-copy-id to copy public key to hosts. Default: False.
|
|
3510
|
+
unavailable_host_expiry (int): Seconds to keep hosts marked as unavailable. Default: DEFAULT_UNAVAILABLE_HOST_EXPIRY.
|
|
3511
|
+
no_history (bool): Do not record command history. Default: True.
|
|
3512
|
+
history_file (str): File to store command history. Default: DEFAULT_HISTORY_FILE.
|
|
3397
3513
|
|
|
3398
3514
|
Returns:
|
|
3399
|
-
list:
|
|
3400
|
-
|
|
3515
|
+
list: List of Host objects representing each host/command run.
|
|
3516
|
+
"""
|
|
3401
3517
|
global __globalUnavailableHosts
|
|
3402
3518
|
global __global_suppress_printout
|
|
3403
3519
|
global _no_env
|
|
@@ -3665,7 +3781,7 @@ def generate_default_config(args):
|
|
|
3665
3781
|
'DEFAULT_USERNAME': args.username,
|
|
3666
3782
|
'DEFAULT_PASSWORD': args.password,
|
|
3667
3783
|
'DEFAULT_IDENTITY_FILE': args.key if args.key and not os.path.isdir(args.key) else DEFAULT_IDENTITY_FILE,
|
|
3668
|
-
'
|
|
3784
|
+
'DEFAULT_SSH_KEY_SEARCH_PATH': args.key if args.key and os.path.isdir(args.key) else DEFAULT_SSH_KEY_SEARCH_PATH,
|
|
3669
3785
|
'DEFAULT_USE_KEY': args.use_key,
|
|
3670
3786
|
'DEFAULT_EXTRA_ARGS': args.extraargs,
|
|
3671
3787
|
'DEFAULT_ONE_ON_ONE': args.oneonone,
|
|
@@ -3689,7 +3805,7 @@ def generate_default_config(args):
|
|
|
3689
3805
|
'DEFAULT_NO_OUTPUT': args.no_output,
|
|
3690
3806
|
'DEFAULT_RETURN_ZERO': args.return_zero,
|
|
3691
3807
|
'DEFAULT_NO_ENV': args.no_env,
|
|
3692
|
-
'DEFAULT_ENV_FILES': args.
|
|
3808
|
+
'DEFAULT_ENV_FILES': args.env_files,
|
|
3693
3809
|
'DEFAULT_NO_HISTORY': args.no_history,
|
|
3694
3810
|
'DEFAULT_HISTORY_FILE': args.history_file,
|
|
3695
3811
|
'DEFAULT_MAX_CONNECTIONS': args.max_connections if args.max_connections != 4 * os.cpu_count() else None,
|
|
@@ -3752,12 +3868,12 @@ def get_parser():
|
|
|
3752
3868
|
parser.add_argument('commands', metavar='commands', type=str, nargs='*',default=None,help='the command to run on the hosts / the destination of the files #HOST# or #HOSTNAME# will be replaced with the host name.')
|
|
3753
3869
|
parser.add_argument('-u','--username', type=str,help=f'The general username to use to connect to the hosts. Will get overwrote by individual username@host if specified. (default: {DEFAULT_USERNAME})',default=DEFAULT_USERNAME)
|
|
3754
3870
|
parser.add_argument('-p', '--password', type=str,help=f'The password to use to connect to the hosts, (default: {DEFAULT_PASSWORD})',default=DEFAULT_PASSWORD)
|
|
3755
|
-
parser.add_argument('-k','--key','--identity',nargs='?', type=str,help=f'The identity file to use to connect to the hosts. Implies --use_key. Specify a folder for program to search for a key. Use option without value to use {
|
|
3871
|
+
parser.add_argument('-k','--key','--identity',nargs='?', type=str,help=f'The identity file to use to connect to the hosts. Implies --use_key. Specify a folder for program to search for a key. Use option without value to use {DEFAULT_SSH_KEY_SEARCH_PATH} (default: {DEFAULT_IDENTITY_FILE})',const=DEFAULT_SSH_KEY_SEARCH_PATH,default=DEFAULT_IDENTITY_FILE)
|
|
3756
3872
|
parser.add_argument('-uk','--use_key', action='store_true', help=f'Attempt to use public key file to connect to the hosts. (default: {DEFAULT_USE_KEY})', default=DEFAULT_USE_KEY)
|
|
3757
3873
|
parser.add_argument('-ea','--extraargs',type=str,help=f'Extra arguments to pass to the ssh / rsync / scp command. Put in one string for multiple arguments.Use "=" ! Ex. -ea="--delete" (default: {DEFAULT_EXTRA_ARGS})',default=DEFAULT_EXTRA_ARGS)
|
|
3758
3874
|
parser.add_argument("-11",'--oneonone', action='store_true', help=f"Run one corresponding command on each host. (default: {DEFAULT_ONE_ON_ONE})", default=DEFAULT_ONE_ON_ONE)
|
|
3759
3875
|
parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
|
|
3760
|
-
parser.add_argument('-s','-fs','--file_sync', action='
|
|
3876
|
+
parser.add_argument('-s','-fs','--file_sync',nargs='?', action='append', 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})',const=True, default=[DEFAULT_FILE_SYNC])
|
|
3761
3877
|
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)
|
|
3762
3878
|
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)
|
|
3763
3879
|
#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")
|
|
@@ -3778,7 +3894,8 @@ def get_parser():
|
|
|
3778
3894
|
parser.add_argument('-Q',"-no","--no_output", action='store_true', help=f"Do not print the output. (default: {DEFAULT_NO_OUTPUT})", default=DEFAULT_NO_OUTPUT)
|
|
3779
3895
|
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)
|
|
3780
3896
|
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)
|
|
3781
|
-
parser.add_argument("--env_file",
|
|
3897
|
+
parser.add_argument('-ef',"--env_file", type=str, help="Replace the env file look up chain with this env_file. ( Still work with --no_env ) (default: None)", default='')
|
|
3898
|
+
parser.add_argument('-efs',"--env_files", action='append', help=f"The files to load the mssh file based environment variables from. Can specify multiple. Load first to last. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILES})")
|
|
3782
3899
|
parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
|
|
3783
3900
|
parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
|
|
3784
3901
|
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)
|
|
@@ -3799,6 +3916,7 @@ def get_parser():
|
|
|
3799
3916
|
parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
|
|
3800
3917
|
parser.add_argument('-dt','--diff_display_threshold', type=float, help=f'The threshold of lines to display the diff when files differ. {{0-1}} Set to 0 to always display the diff. Set to 1 to disable diff. (Only merge same) (default: {DEFAULT_DIFF_DISPLAY_THRESHOLD})', default=DEFAULT_DIFF_DISPLAY_THRESHOLD)
|
|
3801
3918
|
parser.add_argument('--force_truecolor', action='store_true', help=f'Force truecolor output even when not in a truecolor terminal. (default: {FORCE_TRUECOLOR})', default=FORCE_TRUECOLOR)
|
|
3919
|
+
parser.add_argument('--add_control_master_config', action='store_true', help='Add ControlMaster configuration to ~/.ssh/config to speed up multiple connections to the same host.')
|
|
3802
3920
|
parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
|
|
3803
3921
|
return parser
|
|
3804
3922
|
|
|
@@ -3828,7 +3946,18 @@ def process_args(args = None):
|
|
|
3828
3946
|
args.no_history = True
|
|
3829
3947
|
args.greppable = True
|
|
3830
3948
|
args.error_only = True
|
|
3831
|
-
|
|
3949
|
+
|
|
3950
|
+
if args.file_sync:
|
|
3951
|
+
for path in args.file_sync:
|
|
3952
|
+
if path and isinstance(path, str):
|
|
3953
|
+
if args.file:
|
|
3954
|
+
if path not in args.file:
|
|
3955
|
+
args.file.append(path)
|
|
3956
|
+
else:
|
|
3957
|
+
args.file = [path]
|
|
3958
|
+
args.file_sync = any(args.file_sync)
|
|
3959
|
+
else:
|
|
3960
|
+
args.file_sync = False
|
|
3832
3961
|
if args.unavailable_host_expiry <= 0:
|
|
3833
3962
|
eprint(f"Warning: The unavailable host expiry time {args.unavailable_host_expiry} is less than 0, setting it to 10 seconds.")
|
|
3834
3963
|
args.unavailable_host_expiry = 10
|
|
@@ -3847,7 +3976,7 @@ def process_config_file(args):
|
|
|
3847
3976
|
else:
|
|
3848
3977
|
configFileToWriteTo = args.config_file
|
|
3849
3978
|
write_default_config(args,configFileToWriteTo)
|
|
3850
|
-
if not args.commands:
|
|
3979
|
+
if not args.commands and not args.file:
|
|
3851
3980
|
if configFileToWriteTo:
|
|
3852
3981
|
with open(configFileToWriteTo,'r') as f:
|
|
3853
3982
|
eprint(f"Config file content: \n{f.read()}")
|
|
@@ -3889,6 +4018,35 @@ def process_keys(args):
|
|
|
3889
4018
|
eprint(f"Warning: Identity file {args.key!r} not found. Passing to ssh anyway. Proceed with caution.")
|
|
3890
4019
|
return args
|
|
3891
4020
|
|
|
4021
|
+
def process_control_master_config(args):
|
|
4022
|
+
global __control_master_string
|
|
4023
|
+
if args.add_control_master_config:
|
|
4024
|
+
try:
|
|
4025
|
+
if not os.path.exists(os.path.expanduser('~/.ssh')):
|
|
4026
|
+
os.makedirs(os.path.expanduser('~/.ssh'),mode=0o700)
|
|
4027
|
+
ssh_config_file = os.path.expanduser('~/.ssh/config')
|
|
4028
|
+
if not os.path.exists(ssh_config_file):
|
|
4029
|
+
with open(ssh_config_file,'w') as f:
|
|
4030
|
+
f.write(__control_master_string)
|
|
4031
|
+
os.chmod(ssh_config_file,0o644)
|
|
4032
|
+
else:
|
|
4033
|
+
with open(ssh_config_file,'r') as f:
|
|
4034
|
+
ssh_config_content = f.readlines()
|
|
4035
|
+
if set(map(str.strip,ssh_config_content)).issuperset(set(map(str.strip,__control_master_string.splitlines()))):
|
|
4036
|
+
eprint("ControlMaster configuration already exists in ~/.ssh/config, skipping adding:")
|
|
4037
|
+
eprint(__control_master_string)
|
|
4038
|
+
else:
|
|
4039
|
+
with open(ssh_config_file,'a') as f:
|
|
4040
|
+
f.write('\n# Added by multiSSH3.py to speed up subsequent connections\n')
|
|
4041
|
+
f.write(__control_master_string)
|
|
4042
|
+
eprint("ControlMaster configuration added to ~/.ssh/config.")
|
|
4043
|
+
except Exception as e:
|
|
4044
|
+
eprint(f"Error adding ControlMaster configuration: {e}")
|
|
4045
|
+
import traceback
|
|
4046
|
+
traceback.print_exc()
|
|
4047
|
+
if not args.commands and not args.file:
|
|
4048
|
+
_exit_with_code(0, "Done configuring ControlMaster.")
|
|
4049
|
+
return args
|
|
3892
4050
|
|
|
3893
4051
|
def set_global_with_args(args):
|
|
3894
4052
|
global _emo
|
|
@@ -3904,7 +4062,10 @@ def set_global_with_args(args):
|
|
|
3904
4062
|
global FORCE_TRUECOLOR
|
|
3905
4063
|
_emo = False
|
|
3906
4064
|
__ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
|
|
3907
|
-
|
|
4065
|
+
if args.env_file:
|
|
4066
|
+
_env_files = [args.env_file]
|
|
4067
|
+
else:
|
|
4068
|
+
_env_files = DEFAULT_ENV_FILES.extend(args.env_files) if args.env_files else DEFAULT_ENV_FILES
|
|
3908
4069
|
__DEBUG_MODE = args.debug
|
|
3909
4070
|
_encoding = args.encoding
|
|
3910
4071
|
if args.return_zero:
|
|
@@ -3925,6 +4086,7 @@ def main():
|
|
|
3925
4086
|
args = process_config_file(args)
|
|
3926
4087
|
args = process_commands(args)
|
|
3927
4088
|
args = process_keys(args)
|
|
4089
|
+
args = process_control_master_config(args)
|
|
3928
4090
|
set_global_with_args(args)
|
|
3929
4091
|
|
|
3930
4092
|
if args.use_script_timeout:
|
|
@@ -3943,7 +4105,7 @@ def main():
|
|
|
3943
4105
|
curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only,identity_file=args.key,
|
|
3944
4106
|
copy_id=args.copy_id,unavailable_host_expiry=args.unavailable_host_expiry,no_history=args.no_history,
|
|
3945
4107
|
history_file = args.history_file,
|
|
3946
|
-
env_file = args.env_file,
|
|
4108
|
+
env_file = args.env_file,env_files = args.env_files,
|
|
3947
4109
|
repeat = args.repeat,interval = args.interval)
|
|
3948
4110
|
eprint('> ' + cmdStr)
|
|
3949
4111
|
if args.error_only:
|