multiSSH3 5.91__py3-none-any.whl → 6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of multiSSH3 might be problematic. Click here for more details.
- multiSSH3.py +802 -465
- multissh3-6.2.dist-info/METADATA +873 -0
- multissh3-6.2.dist-info/RECORD +6 -0
- multissh3-5.91.dist-info/METADATA +0 -379
- multissh3-5.91.dist-info/RECORD +0 -6
- {multissh3-5.91.dist-info → multissh3-6.2.dist-info}/WHEEL +0 -0
- {multissh3-5.91.dist-info → multissh3-6.2.dist-info}/entry_points.txt +0 -0
- {multissh3-5.91.dist-info → multissh3-6.2.dist-info}/top_level.txt +0 -0
multiSSH3.py
CHANGED
|
@@ -12,6 +12,7 @@ import getpass
|
|
|
12
12
|
import glob
|
|
13
13
|
import io
|
|
14
14
|
import ipaddress
|
|
15
|
+
import itertools
|
|
15
16
|
import json
|
|
16
17
|
import math
|
|
17
18
|
import os
|
|
@@ -29,7 +30,7 @@ import threading
|
|
|
29
30
|
import time
|
|
30
31
|
import typing
|
|
31
32
|
import uuid
|
|
32
|
-
from collections import Counter, deque
|
|
33
|
+
from collections import Counter, deque, defaultdict
|
|
33
34
|
from itertools import count, product
|
|
34
35
|
|
|
35
36
|
__curses_available = False
|
|
@@ -46,7 +47,6 @@ try:
|
|
|
46
47
|
except ImportError:
|
|
47
48
|
pass
|
|
48
49
|
|
|
49
|
-
|
|
50
50
|
try:
|
|
51
51
|
# Check if functiools.cache is available
|
|
52
52
|
# cache_decorator = functools.cache
|
|
@@ -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.02'
|
|
88
88
|
VERSION = version
|
|
89
89
|
__version__ = version
|
|
90
|
-
COMMIT_DATE = '2025-10
|
|
90
|
+
COMMIT_DATE = '2025-11-10'
|
|
91
91
|
|
|
92
92
|
CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
|
|
93
93
|
'~/multiSSH3.config.json',
|
|
@@ -153,33 +153,6 @@ def signal_handler(sig, frame):
|
|
|
153
153
|
os.system(f'pkill -ef {os.path.basename(__file__)}')
|
|
154
154
|
_exit_with_code(1, 'Exiting immediately due to Ctrl C')
|
|
155
155
|
|
|
156
|
-
# def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
|
|
157
|
-
# """
|
|
158
|
-
# Read an input from the user with a timeout and a countdown.
|
|
159
|
-
|
|
160
|
-
# Parameters:
|
|
161
|
-
# timeout (int): The timeout value in seconds.
|
|
162
|
-
# prompt (str): The prompt message to display to the user. Default is 'Please enter your selection'.
|
|
163
|
-
|
|
164
|
-
# Returns:
|
|
165
|
-
# str or None: The user input if received within the timeout, or None if no input is received.
|
|
166
|
-
# """
|
|
167
|
-
# import select
|
|
168
|
-
# # Print the initial prompt with the countdown
|
|
169
|
-
# eprint(f"{prompt} [{timeout}s]: ", end='', flush=True)
|
|
170
|
-
# # Loop until the timeout
|
|
171
|
-
# for remaining in range(timeout, 0, -1):
|
|
172
|
-
# # If there is an input, return it
|
|
173
|
-
# # this only works on linux
|
|
174
|
-
# if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
|
|
175
|
-
# return input().strip()
|
|
176
|
-
# # Print the remaining time
|
|
177
|
-
# eprint(f"\r{prompt} [{remaining}s]: ", end='', flush=True)
|
|
178
|
-
# # Wait a second
|
|
179
|
-
# time.sleep(1)
|
|
180
|
-
# # If there is no input, return None
|
|
181
|
-
# return None
|
|
182
|
-
|
|
183
156
|
def input_with_timeout_and_countdown(timeout, prompt='Please enter your selection'):
|
|
184
157
|
"""
|
|
185
158
|
Read input from the user with a timeout (cross-platform).
|
|
@@ -312,6 +285,13 @@ extraargs={self.extraargs}, resolvedName={self.resolvedName}, i={self.i}, uuid={
|
|
|
312
285
|
identity_file={self.identity_file}, ip={self.ip}, current_color_pair={self.current_color_pair}"
|
|
313
286
|
def __str__(self):
|
|
314
287
|
return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr})"
|
|
288
|
+
def get_output_hash(self):
|
|
289
|
+
return hash((
|
|
290
|
+
self.command,
|
|
291
|
+
tuple(self.stdout),
|
|
292
|
+
tuple(self.stderr),
|
|
293
|
+
self.returncode
|
|
294
|
+
))
|
|
315
295
|
|
|
316
296
|
#%% ------------ Load Defaults ( Config ) File ----------------
|
|
317
297
|
def load_config_file(config_file):
|
|
@@ -341,7 +321,7 @@ DEFAULT_HOSTS = 'all'
|
|
|
341
321
|
DEFAULT_USERNAME = None
|
|
342
322
|
DEFAULT_PASSWORD = ''
|
|
343
323
|
DEFAULT_IDENTITY_FILE = None
|
|
344
|
-
|
|
324
|
+
DEFAULT_SSH_KEY_SEARCH_PATH = '~/.ssh/'
|
|
345
325
|
DEFAULT_USE_KEY = False
|
|
346
326
|
DEFAULT_EXTRA_ARGS = None
|
|
347
327
|
DEFAULT_ONE_ON_ONE = False
|
|
@@ -358,14 +338,23 @@ DEFAULT_INTERFACE_IP_PREFIX = None
|
|
|
358
338
|
DEFAULT_IPMI_USERNAME = 'ADMIN'
|
|
359
339
|
DEFAULT_IPMI_PASSWORD = ''
|
|
360
340
|
DEFAULT_NO_WATCH = False
|
|
361
|
-
|
|
362
|
-
|
|
341
|
+
DEFAULT_WINDOW_WIDTH = 40
|
|
342
|
+
DEFAULT_WINDOW_HEIGHT = 1
|
|
363
343
|
DEFAULT_SINGLE_WINDOW = False
|
|
364
344
|
DEFAULT_ERROR_ONLY = False
|
|
365
345
|
DEFAULT_NO_OUTPUT = False
|
|
366
346
|
DEFAULT_RETURN_ZERO = False
|
|
367
347
|
DEFAULT_NO_ENV = False
|
|
368
|
-
DEFAULT_ENV_FILE = '
|
|
348
|
+
DEFAULT_ENV_FILE = ''
|
|
349
|
+
DEFAULT_ENV_FILES = ['/etc/profile.d/hosts.sh',
|
|
350
|
+
'~/.bashrc',
|
|
351
|
+
'~/.zshrc',
|
|
352
|
+
'~/host.env',
|
|
353
|
+
'~/hosts.env',
|
|
354
|
+
'.env',
|
|
355
|
+
'host.env',
|
|
356
|
+
'hosts.env',
|
|
357
|
+
]
|
|
369
358
|
DEFAULT_NO_HISTORY = False
|
|
370
359
|
DEFAULT_HISTORY_FILE = '~/.mssh_history'
|
|
371
360
|
DEFAULT_MAX_CONNECTIONS = 4 * os.cpu_count()
|
|
@@ -375,7 +364,7 @@ DEFAULT_GREPPABLE_MODE = False
|
|
|
375
364
|
DEFAULT_SKIP_UNREACHABLE = True
|
|
376
365
|
DEFAULT_SKIP_HOSTS = ''
|
|
377
366
|
DEFAULT_ENCODING = 'utf-8'
|
|
378
|
-
DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.
|
|
367
|
+
DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.6
|
|
379
368
|
SSH_STRICT_HOST_KEY_CHECKING = False
|
|
380
369
|
FORCE_TRUECOLOR = False
|
|
381
370
|
ERROR_MESSAGES_TO_IGNORE = [
|
|
@@ -387,6 +376,24 @@ ERROR_MESSAGES_TO_IGNORE = [
|
|
|
387
376
|
'Killed by signal',
|
|
388
377
|
'Connection reset by peer',
|
|
389
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()
|
|
390
397
|
_DEFAULT_CALLED = True
|
|
391
398
|
_DEFAULT_RETURN_UNFINISHED = False
|
|
392
399
|
_DEFAULT_UPDATE_UNREACHABLE_HOSTS = True
|
|
@@ -406,6 +413,9 @@ if __ERROR_MESSAGES_TO_IGNORE_REGEX:
|
|
|
406
413
|
__ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile(__ERROR_MESSAGES_TO_IGNORE_REGEX)
|
|
407
414
|
else:
|
|
408
415
|
__ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile('|'.join(ERROR_MESSAGES_TO_IGNORE))
|
|
416
|
+
if DEFAULT_ENV_FILE:
|
|
417
|
+
if DEFAULT_ENV_FILE not in DEFAULT_ENV_FILES:
|
|
418
|
+
DEFAULT_ENV_FILES.append(DEFAULT_ENV_FILE)
|
|
409
419
|
|
|
410
420
|
#%% Load mssh Functional Global Variables
|
|
411
421
|
__global_suppress_printout = False
|
|
@@ -413,7 +423,7 @@ __mainReturnCode = 0
|
|
|
413
423
|
__failedHosts = set()
|
|
414
424
|
__wildCharacters = ['*','?','x']
|
|
415
425
|
_no_env = DEFAULT_NO_ENV
|
|
416
|
-
|
|
426
|
+
_env_files = DEFAULT_ENV_FILES
|
|
417
427
|
__globalUnavailableHosts = dict()
|
|
418
428
|
__ipmiiInterfaceIPPrefix = DEFAULT_IPMI_INTERFACE_IP_PREFIX
|
|
419
429
|
__keyPressesIn = [[]]
|
|
@@ -427,6 +437,11 @@ __thread_start_delay = 0
|
|
|
427
437
|
_encoding = DEFAULT_ENCODING
|
|
428
438
|
__returnZero = DEFAULT_RETURN_ZERO
|
|
429
439
|
__running_threads = set()
|
|
440
|
+
__control_master_string = '''Host *
|
|
441
|
+
ControlMaster auto
|
|
442
|
+
ControlPath /run/user/%i/ssh_sockets_%C
|
|
443
|
+
ControlPersist 3600
|
|
444
|
+
'''
|
|
430
445
|
if __resource_lib_available:
|
|
431
446
|
# Get the current limits
|
|
432
447
|
_, __system_nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
@@ -474,12 +489,12 @@ def check_path(program_name):
|
|
|
474
489
|
|
|
475
490
|
[check_path(program) for program in _binCalled]
|
|
476
491
|
|
|
477
|
-
def find_ssh_key_file(searchPath =
|
|
492
|
+
def find_ssh_key_file(searchPath = DEFAULT_SSH_KEY_SEARCH_PATH):
|
|
478
493
|
'''
|
|
479
494
|
Find the ssh public key file
|
|
480
495
|
|
|
481
496
|
Args:
|
|
482
|
-
searchPath (str, optional): The path to search. Defaults to
|
|
497
|
+
searchPath (str, optional): The path to search. Defaults to DEFAULT_SSH_KEY_SEARCH_PATH.
|
|
483
498
|
|
|
484
499
|
Returns:
|
|
485
500
|
str: The path to the ssh key file
|
|
@@ -495,35 +510,65 @@ def find_ssh_key_file(searchPath = DEDAULT_SSH_KEY_SEARCH_PATH):
|
|
|
495
510
|
return None
|
|
496
511
|
|
|
497
512
|
@cache_decorator
|
|
498
|
-
def readEnvFromFile(
|
|
513
|
+
def readEnvFromFile():
|
|
499
514
|
'''
|
|
500
515
|
Read the environment variables from env_file
|
|
501
516
|
Returns:
|
|
502
517
|
dict: A dictionary of environment variables
|
|
503
518
|
'''
|
|
504
|
-
global
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
envf =
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
if line.startswith('#') or not line
|
|
519
|
+
global _env_files
|
|
520
|
+
global _no_env
|
|
521
|
+
envfs = _env_files if _env_files else DEFAULT_ENV_FILES
|
|
522
|
+
translator = str.maketrans('&|"', ';;\'')
|
|
523
|
+
replacement_re = re.compile(r'\$(?:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})')
|
|
524
|
+
environemnt = {}
|
|
525
|
+
scrubCounter = 0
|
|
526
|
+
for envf in envfs:
|
|
527
|
+
envf = os.path.expanduser(os.path.expandvars(envf))
|
|
528
|
+
if os.path.exists(envf):
|
|
529
|
+
with open(envf,'r') as f:
|
|
530
|
+
lines = f.readlines()
|
|
531
|
+
for line in lines:
|
|
532
|
+
line = line.strip()
|
|
533
|
+
if not line or line.startswith('#') or '=' not in line:
|
|
519
534
|
continue
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
535
|
+
line = line.translate(translator)
|
|
536
|
+
commands = re.split(r";(?=(?:[^']*'[^']*')*[^']*$)", line)
|
|
537
|
+
for command in commands:
|
|
538
|
+
if not command or command.startswith('#') or '=' not in command or command.startswith('alias '):
|
|
539
|
+
continue
|
|
540
|
+
fields = re.split(r" (?=(?:[^']*'[^']*')*[^']*$)", command)
|
|
541
|
+
for field in fields:
|
|
542
|
+
try:
|
|
543
|
+
if field.startswith('export '):
|
|
544
|
+
field = field.replace('export ', '', 1).strip()
|
|
545
|
+
if not field or field.startswith('#') or '=' not in field:
|
|
546
|
+
continue
|
|
547
|
+
key, _, values = field.partition('=')
|
|
548
|
+
key = key.strip().strip("'")
|
|
549
|
+
values = values.strip().strip("'")
|
|
550
|
+
if '$' in values:
|
|
551
|
+
scrubCounter += 16
|
|
552
|
+
if key and values and key != values:
|
|
553
|
+
environemnt[key] = values
|
|
554
|
+
except Exception:
|
|
555
|
+
continue
|
|
556
|
+
while scrubCounter:
|
|
557
|
+
scrubCounter -= 1
|
|
558
|
+
found = False
|
|
559
|
+
for key, value in environemnt.items():
|
|
560
|
+
if '$' in value:
|
|
561
|
+
for match in replacement_re.findall(value):
|
|
562
|
+
ref_key = match.strip('${}')
|
|
563
|
+
ref_value = environemnt.get(ref_key) if ref_key != key else None
|
|
564
|
+
if not ref_value and not _no_env:
|
|
565
|
+
ref_value = os.environ.get(ref_key)
|
|
566
|
+
if ref_value:
|
|
567
|
+
environemnt[key] = value.replace(match, ref_value)
|
|
568
|
+
found = True
|
|
569
|
+
if not found:
|
|
570
|
+
break
|
|
571
|
+
return environemnt
|
|
527
572
|
|
|
528
573
|
def replace_magic_strings(string,keys,value,case_sensitive=False):
|
|
529
574
|
'''
|
|
@@ -650,8 +695,6 @@ def format_commands(commands):
|
|
|
650
695
|
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}")
|
|
651
696
|
return commands
|
|
652
697
|
|
|
653
|
-
|
|
654
|
-
|
|
655
698
|
class OrderedMultiSet(deque):
|
|
656
699
|
"""
|
|
657
700
|
A deque extension with O(1) average lookup time.
|
|
@@ -663,60 +706,138 @@ class OrderedMultiSet(deque):
|
|
|
663
706
|
self._counter = Counter()
|
|
664
707
|
if iterable is not None:
|
|
665
708
|
self.extend(iterable)
|
|
666
|
-
def
|
|
667
|
-
"""Decrease count of item in counter."""
|
|
668
|
-
self._counter[item] -= 1
|
|
669
|
-
if self._counter[item] == 0:
|
|
670
|
-
del self._counter[item]
|
|
671
|
-
return self._counter.get(item, 0)
|
|
672
|
-
def append(self, item,left=False):
|
|
709
|
+
def append(self, item):
|
|
673
710
|
"""Add item to the right end. O(1)."""
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
self.
|
|
678
|
-
super().
|
|
711
|
+
if len(self) == self.maxlen:
|
|
712
|
+
self._counter -= Counter([self[0]])
|
|
713
|
+
# self._counter[self[0]] -= 1
|
|
714
|
+
# self._counter += Counter()
|
|
715
|
+
super().append(item)
|
|
679
716
|
self._counter[item] += 1
|
|
680
|
-
return removed
|
|
681
717
|
def appendleft(self, item):
|
|
682
718
|
"""Add item to the left end. O(1)."""
|
|
683
|
-
|
|
684
|
-
|
|
719
|
+
if len(self) == self.maxlen:
|
|
720
|
+
self._counter -= Counter([self[-1]])
|
|
721
|
+
super().appendleft(item)
|
|
722
|
+
self._counter[item] += 1
|
|
723
|
+
def pop(self):
|
|
685
724
|
"""Remove and return item from right end. O(1)."""
|
|
686
|
-
|
|
725
|
+
try:
|
|
726
|
+
item = super().pop()
|
|
727
|
+
self._counter -= Counter([item])
|
|
728
|
+
return item
|
|
729
|
+
except IndexError:
|
|
687
730
|
return None
|
|
688
|
-
item = super().popleft() if left else super().pop()
|
|
689
|
-
self.__decrease_count(item)
|
|
690
|
-
return item
|
|
691
731
|
def popleft(self):
|
|
692
732
|
"""Remove and return item from left end. O(1)."""
|
|
693
|
-
|
|
733
|
+
try:
|
|
734
|
+
item = super().popleft()
|
|
735
|
+
self._counter -= Counter([item])
|
|
736
|
+
return item
|
|
737
|
+
except IndexError:
|
|
738
|
+
return None
|
|
739
|
+
def put(self, item):
|
|
740
|
+
"""Alias for append, but return removed item - add to right end (FIFO put)."""
|
|
741
|
+
removed = None
|
|
742
|
+
if len(self) == self.maxlen:
|
|
743
|
+
removed = self[0] # Item that will be removed
|
|
744
|
+
self._counter -= Counter([removed])
|
|
745
|
+
super().append(item)
|
|
746
|
+
self._counter[item] += 1
|
|
747
|
+
return removed
|
|
748
|
+
def put_left(self, item):
|
|
749
|
+
"""Alias for appendleft, but return removed item - add to left end (LIFO put)."""
|
|
750
|
+
removed = None
|
|
751
|
+
if len(self) == self.maxlen:
|
|
752
|
+
removed = self[-1] # Item that will be removed
|
|
753
|
+
self._counter -= Counter([removed])
|
|
754
|
+
super().appendleft(item)
|
|
755
|
+
self._counter[item] += 1
|
|
756
|
+
return removed
|
|
757
|
+
def get(self):
|
|
758
|
+
"""Alias for popleft - remove from left end (FIFO get)."""
|
|
759
|
+
return self.popleft()
|
|
694
760
|
def remove(self, value):
|
|
695
761
|
"""Remove first occurrence of value. O(n)."""
|
|
696
762
|
if value not in self._counter:
|
|
697
763
|
return None
|
|
698
764
|
super().remove(value)
|
|
699
|
-
self.
|
|
765
|
+
self._counter -= Counter([value])
|
|
700
766
|
def clear(self):
|
|
701
767
|
"""Remove all items. O(1)."""
|
|
702
768
|
super().clear()
|
|
703
769
|
self._counter.clear()
|
|
704
770
|
def extend(self, iterable):
|
|
705
771
|
"""Extend deque by appending elements from iterable. O(k)."""
|
|
706
|
-
|
|
707
|
-
|
|
772
|
+
# if maxlen is set, and the new length exceeds maxlen, we clear then efficiently extend
|
|
773
|
+
try:
|
|
774
|
+
if not self.maxlen or len(self) + len(iterable) <= self.maxlen:
|
|
775
|
+
super().extend(iterable)
|
|
776
|
+
self._counter.update(iterable)
|
|
777
|
+
elif len(iterable) >= self.maxlen:
|
|
778
|
+
self.clear()
|
|
779
|
+
if isinstance(iterable, (list, tuple)):
|
|
780
|
+
iterable = iterable[-self.maxlen:]
|
|
781
|
+
else:
|
|
782
|
+
iterable = itertools.islice(iterable, len(iterable) - self.maxlen, None)
|
|
783
|
+
super().extend(iterable)
|
|
784
|
+
self._counter.update(iterable)
|
|
785
|
+
else:
|
|
786
|
+
num_to_keep = self.maxlen - len(iterable)
|
|
787
|
+
self.truncateright(num_to_keep)
|
|
788
|
+
super().extend(iterable)
|
|
789
|
+
self._counter.update(iterable)
|
|
790
|
+
except TypeError:
|
|
791
|
+
return self.extend(list(iterable))
|
|
708
792
|
def extendleft(self, iterable):
|
|
709
793
|
"""Extend left side by appending elements from iterable. O(k)."""
|
|
710
|
-
|
|
711
|
-
|
|
794
|
+
# if maxlen is set, and the new length exceeds maxlen, we clear then efficiently extendleft
|
|
795
|
+
try:
|
|
796
|
+
if not self.maxlen or len(self) + len(iterable) <= self.maxlen:
|
|
797
|
+
super().extendleft(iterable)
|
|
798
|
+
self._counter.update(iterable)
|
|
799
|
+
elif len(iterable) >= self.maxlen:
|
|
800
|
+
self.clear()
|
|
801
|
+
if isinstance(iterable, (list, tuple)):
|
|
802
|
+
iterable = iterable[:self.maxlen]
|
|
803
|
+
else:
|
|
804
|
+
iterable = itertools.islice(iterable, 0, self.maxlen)
|
|
805
|
+
super().extendleft(iterable)
|
|
806
|
+
self._counter.update(iterable)
|
|
807
|
+
else:
|
|
808
|
+
num_to_keep = self.maxlen - len(iterable)
|
|
809
|
+
self.truncate(num_to_keep)
|
|
810
|
+
super().extendleft(iterable)
|
|
811
|
+
self._counter.update(iterable)
|
|
812
|
+
except TypeError:
|
|
813
|
+
return self.extendleft(list(iterable))
|
|
814
|
+
def update(self, iterable):
|
|
815
|
+
"""Extend deque by appending elements from iterable. Alias for extend. O(k)."""
|
|
816
|
+
return self.extend(iterable)
|
|
817
|
+
def updateleft(self, iterable):
|
|
818
|
+
"""Extend left side by appending elements from iterable. Alias for extendleft. O(k)."""
|
|
819
|
+
return self.extendleft(iterable)
|
|
820
|
+
def truncate(self, n):
|
|
821
|
+
"""Truncate to keep left n items. O(n)."""
|
|
822
|
+
kept = list(itertools.islice(self, n))
|
|
823
|
+
dropped = Counter(itertools.islice(self, n, None))
|
|
824
|
+
super().clear()
|
|
825
|
+
super().extend(kept)
|
|
826
|
+
self._counter -= dropped
|
|
827
|
+
def truncateright(self, n):
|
|
828
|
+
"""Truncate to keep right n items. O(n)."""
|
|
829
|
+
kept = list(itertools.islice(self, len(self) - n, None))
|
|
830
|
+
dropped = Counter(itertools.islice(self, 0, len(self) - n))
|
|
831
|
+
super().clear()
|
|
832
|
+
super().extend(kept)
|
|
833
|
+
self._counter -= dropped
|
|
712
834
|
def rotate(self, n=1):
|
|
713
835
|
"""Rotate deque n steps to the right. O(k) where k = min(n, len)."""
|
|
714
|
-
if not self:
|
|
715
|
-
return
|
|
716
836
|
super().rotate(n)
|
|
717
837
|
def __contains__(self, item):
|
|
718
838
|
"""Check if item exists in deque. O(1) average."""
|
|
719
|
-
return item in self._counter
|
|
839
|
+
# return item in self._counter
|
|
840
|
+
return super().__contains__(item)
|
|
720
841
|
def count(self, item):
|
|
721
842
|
"""Return number of occurrences of item. O(1)."""
|
|
722
843
|
return self._counter[item]
|
|
@@ -724,14 +845,14 @@ class OrderedMultiSet(deque):
|
|
|
724
845
|
"""Set item at index. O(1) for access, O(1) for counter update."""
|
|
725
846
|
old_value = self[index]
|
|
726
847
|
super().__setitem__(index, value)
|
|
727
|
-
self.
|
|
848
|
+
self._counter -= Counter([old_value])
|
|
728
849
|
self._counter[value] += 1
|
|
729
850
|
return old_value
|
|
730
851
|
def __delitem__(self, index):
|
|
731
852
|
"""Delete item at index. O(n) for deletion, O(1) for counter update."""
|
|
732
853
|
value = self[index]
|
|
733
854
|
super().__delitem__(index)
|
|
734
|
-
self.
|
|
855
|
+
self._counter -= Counter([value])
|
|
735
856
|
return value
|
|
736
857
|
def insert(self, index, value):
|
|
737
858
|
"""Insert value at index. O(n) for insertion, O(1) for counter update."""
|
|
@@ -753,22 +874,40 @@ class OrderedMultiSet(deque):
|
|
|
753
874
|
if self.maxlen is not None:
|
|
754
875
|
return f"OrderedMultiSet({list(self)}, maxlen={self.maxlen})"
|
|
755
876
|
return f"OrderedMultiSet({list(self)})"
|
|
756
|
-
def put(self, item,left=False):
|
|
757
|
-
"""Alias for append - add to right end (FIFO put)."""
|
|
758
|
-
return self.append(item,left=left)
|
|
759
|
-
def get(self,left=True):
|
|
760
|
-
"""Alias for popleft - remove from left end (FIFO get)."""
|
|
761
|
-
return self.pop(left=left)
|
|
762
877
|
def peek(self):
|
|
763
878
|
"""Return leftmost item without removing it."""
|
|
764
|
-
|
|
879
|
+
try:
|
|
880
|
+
return self[0]
|
|
881
|
+
except IndexError:
|
|
765
882
|
return None
|
|
766
|
-
return self[0]
|
|
767
883
|
def peek_right(self):
|
|
768
884
|
"""Return rightmost item without removing it."""
|
|
769
|
-
|
|
885
|
+
try:
|
|
886
|
+
return self[-1]
|
|
887
|
+
except IndexError:
|
|
770
888
|
return None
|
|
771
|
-
|
|
889
|
+
def __iadd__(self, value):
|
|
890
|
+
return self.extend(value)
|
|
891
|
+
def __add__(self, value):
|
|
892
|
+
new_deque = self.copy()
|
|
893
|
+
new_deque.extend(value)
|
|
894
|
+
return new_deque
|
|
895
|
+
def __mul__(self, value):
|
|
896
|
+
new_deque = OrderedMultiSet(maxlen=self.maxlen)
|
|
897
|
+
for _ in range(value):
|
|
898
|
+
new_deque.extend(self)
|
|
899
|
+
return new_deque
|
|
900
|
+
def __imul__(self, value):
|
|
901
|
+
if value <= 0:
|
|
902
|
+
self.clear()
|
|
903
|
+
return self
|
|
904
|
+
for _ in range(value - 1):
|
|
905
|
+
self.extend(self)
|
|
906
|
+
return self
|
|
907
|
+
def __eq__(self, value):
|
|
908
|
+
if isinstance(value, OrderedMultiSet):
|
|
909
|
+
return self._counter == value._counter
|
|
910
|
+
return super().__eq__(value)
|
|
772
911
|
|
|
773
912
|
def get_terminal_size():
|
|
774
913
|
'''
|
|
@@ -814,6 +953,8 @@ def get_terminal_color_capability():
|
|
|
814
953
|
return '24bit'
|
|
815
954
|
elif "256" in term:
|
|
816
955
|
return '256'
|
|
956
|
+
elif "16" in term:
|
|
957
|
+
return '16'
|
|
817
958
|
try:
|
|
818
959
|
curses.setupterm()
|
|
819
960
|
colors = curses.tigetnum("colors")
|
|
@@ -832,96 +973,120 @@ def get_terminal_color_capability():
|
|
|
832
973
|
return 'None'
|
|
833
974
|
|
|
834
975
|
@cache_decorator
|
|
835
|
-
def
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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 ''
|
|
857
1000
|
|
|
858
|
-
|
|
859
|
-
def rgb_to_xterm_index(r, g, b):
|
|
1001
|
+
def _rgb_to_256_color(r, g, b):
|
|
860
1002
|
"""
|
|
861
|
-
Map
|
|
862
|
-
r, g, b should be in 0-255.
|
|
863
|
-
Returns an int in 0-255.
|
|
1003
|
+
Map (r,g,b) to the 256-color cube or grayscale ramp.
|
|
864
1004
|
"""
|
|
865
|
-
|
|
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
|
|
866
1039
|
best_dist = float('inf')
|
|
867
|
-
for i, (pr, pg, pb) in enumerate(
|
|
868
|
-
|
|
869
|
-
dg = pg - g
|
|
870
|
-
db = pb - b
|
|
871
|
-
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
|
|
872
1042
|
if dist < best_dist:
|
|
873
1043
|
best_dist = dist
|
|
874
|
-
|
|
875
|
-
return
|
|
1044
|
+
best_idx = i
|
|
1045
|
+
return best_idx
|
|
876
1046
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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):
|
|
880
1055
|
r = (hash_value >> 16) & 0xFF
|
|
881
1056
|
g = (hash_value >> 8) & 0xFF
|
|
882
1057
|
b = hash_value & 0xFF
|
|
883
|
-
|
|
884
|
-
|
|
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)
|
|
885
1063
|
return (r, g, b)
|
|
886
1064
|
|
|
887
|
-
|
|
1065
|
+
__previous_color_rgb = ()
|
|
888
1066
|
@cache_decorator
|
|
889
|
-
def
|
|
1067
|
+
def int_to_unique_ansi_color(number):
|
|
890
1068
|
'''
|
|
891
|
-
Convert a
|
|
1069
|
+
Convert a number to a unique ANSI color code
|
|
892
1070
|
|
|
893
1071
|
Args:
|
|
894
|
-
|
|
895
|
-
|
|
1072
|
+
number (int): The number to convert
|
|
896
1073
|
Returns:
|
|
897
1074
|
int: The ANSI color code
|
|
898
1075
|
'''
|
|
899
|
-
global
|
|
1076
|
+
global __previous_color_rgb
|
|
900
1077
|
# Use a hash function to generate a consistent integer from the string
|
|
901
1078
|
color_capability = get_terminal_color_capability()
|
|
902
|
-
index = None
|
|
903
1079
|
if color_capability == 'None':
|
|
904
1080
|
return ''
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
index = (hash(string) % 14) + 31
|
|
908
|
-
if index > 37:
|
|
909
|
-
index += 52 # Bright colors (90-97)
|
|
910
|
-
elif color_capability == '8':
|
|
911
|
-
index = (hash(string) % 6) + 31
|
|
912
|
-
r,g,b = hashable_to_color(string)
|
|
913
|
-
if color_capability == '256':
|
|
914
|
-
index = rgb_to_xterm_index(r,g,b)
|
|
915
|
-
if index:
|
|
916
|
-
if index == __previous_ansi_color_index:
|
|
917
|
-
return string_to_unique_ansi_color(hash(string))
|
|
918
|
-
__previous_ansi_color_index = index
|
|
919
|
-
if color_capability == '256':
|
|
920
|
-
return f'\033[38;5;{index}m'
|
|
921
|
-
else:
|
|
922
|
-
return f'\033[{index}m'
|
|
1081
|
+
if color_capability == '24bit':
|
|
1082
|
+
r, g, b = int_to_color(number)
|
|
923
1083
|
else:
|
|
924
|
-
|
|
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)
|
|
925
1090
|
|
|
926
1091
|
#%% ------------ Compacting Hostnames ----------------
|
|
927
1092
|
def __tokenize_hostname(hostname):
|
|
@@ -1267,13 +1432,18 @@ def compact_hostnames(Hostnames,verify = True):
|
|
|
1267
1432
|
['sub-s[1-2]']
|
|
1268
1433
|
"""
|
|
1269
1434
|
global __global_suppress_printout
|
|
1270
|
-
if not isinstance(Hostnames, frozenset):
|
|
1271
|
-
|
|
1272
|
-
else:
|
|
1273
|
-
|
|
1435
|
+
# if not isinstance(Hostnames, frozenset):
|
|
1436
|
+
# hostSet = frozenset(Hostnames)
|
|
1437
|
+
# else:
|
|
1438
|
+
# hostSet = Hostnames
|
|
1439
|
+
hostSet = frozenset(expand_hostnames(
|
|
1440
|
+
hostname.strip()
|
|
1441
|
+
for hostnames_str in Hostnames
|
|
1442
|
+
for hostname in hostnames_str.split(',')
|
|
1443
|
+
))
|
|
1274
1444
|
compact_hosts = __compact_hostnames(hostSet)
|
|
1275
1445
|
if verify:
|
|
1276
|
-
if
|
|
1446
|
+
if frozenset(expand_hostnames(compact_hosts)) != hostSet:
|
|
1277
1447
|
if not __global_suppress_printout:
|
|
1278
1448
|
eprint(f"Error compacting hostnames: {hostSet} -> {compact_hosts}")
|
|
1279
1449
|
compact_hosts = hostSet
|
|
@@ -1502,51 +1672,53 @@ def __handle_reading_stream(stream,target, host,buffer:io.BytesIO):
|
|
|
1502
1672
|
buffer.truncate(0)
|
|
1503
1673
|
host.output_buffer.seek(0)
|
|
1504
1674
|
host.output_buffer.truncate(0)
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
continue
|
|
1511
|
-
elif char == b'\r':
|
|
1512
|
-
buffer.seek(0)
|
|
1513
|
-
host.output_buffer.seek(0)
|
|
1514
|
-
elif char == b'\x08':
|
|
1515
|
-
# backspace
|
|
1516
|
-
if buffer.tell() > 0:
|
|
1517
|
-
buffer.seek(buffer.tell() - 1)
|
|
1518
|
-
buffer.truncate()
|
|
1519
|
-
if host.output_buffer.tell() > 0:
|
|
1520
|
-
host.output_buffer.seek(host.output_buffer.tell() - 1)
|
|
1521
|
-
host.output_buffer.truncate()
|
|
1522
|
-
else:
|
|
1523
|
-
# normal character
|
|
1524
|
-
buffer.write(char)
|
|
1525
|
-
host.output_buffer.write(char)
|
|
1526
|
-
# if the length of the buffer is greater than 100, we try to decode the buffer to find if there are any unicode line change chars
|
|
1527
|
-
if buffer.tell() % 100 == 0 and buffer.tell() > 0:
|
|
1528
|
-
try:
|
|
1529
|
-
# try to decode the buffer to find if there are any unicode line change chars
|
|
1530
|
-
decodedLine = buffer.getvalue().decode(_encoding,errors='backslashreplace')
|
|
1531
|
-
lines = decodedLine.splitlines()
|
|
1532
|
-
if len(lines) > 1:
|
|
1533
|
-
# if there are multiple lines, we add them to the target
|
|
1534
|
-
for line in lines[:-1]:
|
|
1535
|
-
# for all lines except the last one, we add them to the target
|
|
1536
|
-
target.append(line)
|
|
1537
|
-
host.output.append(line)
|
|
1538
|
-
host.lineNumToPrintSet.add(len(host.output)-1)
|
|
1539
|
-
# we keep the last line in the buffer
|
|
1540
|
-
buffer.seek(0)
|
|
1541
|
-
buffer.truncate(0)
|
|
1542
|
-
buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1543
|
-
host.output_buffer.seek(0)
|
|
1544
|
-
host.output_buffer.truncate(0)
|
|
1545
|
-
host.output_buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1546
|
-
|
|
1547
|
-
except UnicodeDecodeError:
|
|
1548
|
-
# if there is a unicode decode error, we just skip this character
|
|
1675
|
+
try:
|
|
1676
|
+
for char in iter(lambda:stream.read(1), b''):
|
|
1677
|
+
host.lastUpdateTime = time.monotonic()
|
|
1678
|
+
if char == b'\n':
|
|
1679
|
+
add_line(buffer,target, host)
|
|
1549
1680
|
continue
|
|
1681
|
+
elif char == b'\r':
|
|
1682
|
+
buffer.seek(0)
|
|
1683
|
+
host.output_buffer.seek(0)
|
|
1684
|
+
elif char == b'\x08':
|
|
1685
|
+
# backspace
|
|
1686
|
+
if buffer.tell() > 0:
|
|
1687
|
+
buffer.seek(buffer.tell() - 1)
|
|
1688
|
+
buffer.truncate()
|
|
1689
|
+
if host.output_buffer.tell() > 0:
|
|
1690
|
+
host.output_buffer.seek(host.output_buffer.tell() - 1)
|
|
1691
|
+
host.output_buffer.truncate()
|
|
1692
|
+
else:
|
|
1693
|
+
# normal character
|
|
1694
|
+
buffer.write(char)
|
|
1695
|
+
host.output_buffer.write(char)
|
|
1696
|
+
# if the length of the buffer is greater than 100, we try to decode the buffer to find if there are any unicode line change chars
|
|
1697
|
+
if buffer.tell() % 100 == 0 and buffer.tell() > 0:
|
|
1698
|
+
try:
|
|
1699
|
+
# try to decode the buffer to find if there are any unicode line change chars
|
|
1700
|
+
decodedLine = buffer.getvalue().decode(_encoding,errors='backslashreplace')
|
|
1701
|
+
lines = decodedLine.splitlines()
|
|
1702
|
+
if len(lines) > 1:
|
|
1703
|
+
# if there are multiple lines, we add them to the target
|
|
1704
|
+
for line in lines[:-1]:
|
|
1705
|
+
# for all lines except the last one, we add them to the target
|
|
1706
|
+
target.append(line)
|
|
1707
|
+
host.output.append(line)
|
|
1708
|
+
host.lineNumToPrintSet.add(len(host.output)-1)
|
|
1709
|
+
# we keep the last line in the buffer
|
|
1710
|
+
buffer.seek(0)
|
|
1711
|
+
buffer.truncate(0)
|
|
1712
|
+
buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1713
|
+
host.output_buffer.seek(0)
|
|
1714
|
+
host.output_buffer.truncate(0)
|
|
1715
|
+
host.output_buffer.write(lines[-1].encode(_encoding,errors='backslashreplace'))
|
|
1716
|
+
|
|
1717
|
+
except UnicodeDecodeError:
|
|
1718
|
+
# if there is a unicode decode error, we just skip this character
|
|
1719
|
+
continue
|
|
1720
|
+
except ValueError:
|
|
1721
|
+
pass
|
|
1550
1722
|
if buffer.tell() > 0:
|
|
1551
1723
|
# if there is still some data in the buffer, we add it to the target
|
|
1552
1724
|
add_line(buffer,target, host)
|
|
@@ -1590,7 +1762,7 @@ def __handle_writing_stream(stream,stop_event,host):
|
|
|
1590
1762
|
# host.output.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
|
|
1591
1763
|
# host.stdout.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
|
|
1592
1764
|
return sentInputPos
|
|
1593
|
-
|
|
1765
|
+
|
|
1594
1766
|
def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
1595
1767
|
'''
|
|
1596
1768
|
Run the command on the host. Will format the commands accordingly. Main execution function.
|
|
@@ -1641,6 +1813,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
|
1641
1813
|
host.command = replace_magic_strings(host.command,['#I#'],str(host.i),case_sensitive=False)
|
|
1642
1814
|
host.command = replace_magic_strings(host.command,['#PASSWD#','#PASSWORD#'],passwds,case_sensitive=False)
|
|
1643
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)
|
|
1644
1817
|
formatedCMD = []
|
|
1645
1818
|
if host.extraargs and isinstance(host.extraargs, str):
|
|
1646
1819
|
extraargs = host.extraargs.split()
|
|
@@ -1839,8 +2012,6 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
|
1839
2012
|
stdout_thread.join(timeout=1)
|
|
1840
2013
|
stderr_thread.join(timeout=1)
|
|
1841
2014
|
stdin_thread.join(timeout=1)
|
|
1842
|
-
# here we handle the rest of the stdout after the subprocess returns
|
|
1843
|
-
host.output.append('Pipe Closed. Trying to read the rest of the stdout...')
|
|
1844
2015
|
if not _emo:
|
|
1845
2016
|
stdout = None
|
|
1846
2017
|
stderr = None
|
|
@@ -1849,8 +2020,10 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
|
1849
2020
|
except subprocess.TimeoutExpired:
|
|
1850
2021
|
pass
|
|
1851
2022
|
if stdout:
|
|
2023
|
+
host.output.append('Trying to read the rest of the stdout...')
|
|
1852
2024
|
__handle_reading_stream(io.BytesIO(stdout),host.stdout, host,host.stdout_buffer)
|
|
1853
2025
|
if stderr:
|
|
2026
|
+
host.output.append('Trying to read the rest of the stderr...')
|
|
1854
2027
|
__handle_reading_stream(io.BytesIO(stderr),host.stderr, host,host.stderr_buffer)
|
|
1855
2028
|
# if the last line in host.stderr is Connection to * closed., we will remove it
|
|
1856
2029
|
host.returncode = proc.poll()
|
|
@@ -2260,7 +2433,7 @@ def _get_hosts_to_display (hosts, max_num_hosts, hosts_to_display = None, indexO
|
|
|
2260
2433
|
rearrangedHosts.add(host)
|
|
2261
2434
|
return new_hosts_to_display , {'running':len(running_hosts), 'failed':len(failed_hosts), 'finished':len(finished_hosts), 'waiting':len(waiting_hosts)}, rearrangedHosts
|
|
2262
2435
|
|
|
2263
|
-
def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min_char_len =
|
|
2436
|
+
def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min_char_len = DEFAULT_WINDOW_WIDTH, min_line_len = DEFAULT_WINDOW_HEIGHT,single_window=DEFAULT_SINGLE_WINDOW,help_shown = False, config_reason = 'New Configuration'):
|
|
2264
2437
|
global _encoding
|
|
2265
2438
|
_ = config_reason
|
|
2266
2439
|
try:
|
|
@@ -2446,7 +2619,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2446
2619
|
elif key == curses.KEY_EXIT or key == 27: # 27 is the key code for ESC
|
|
2447
2620
|
# if the key is exit, we will exit the program
|
|
2448
2621
|
return
|
|
2449
|
-
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
|
|
2450
2623
|
# if the key is help, we will display the help message
|
|
2451
2624
|
if not help_shown:
|
|
2452
2625
|
help_panel.show()
|
|
@@ -2581,7 +2754,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2581
2754
|
return (lineToDisplay,curserPosition , min_char_len, min_line_len, single_window,help_shown, f'Error: {str(e)}',traceback.format_exc())
|
|
2582
2755
|
return None
|
|
2583
2756
|
|
|
2584
|
-
def curses_print(stdscr, hosts, threads, min_char_len =
|
|
2757
|
+
def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_WINDOW_WIDTH, min_line_len = DEFAULT_WINDOW_HEIGHT,single_window = DEFAULT_SINGLE_WINDOW):
|
|
2585
2758
|
'''
|
|
2586
2759
|
Print the output of the hosts on the screen
|
|
2587
2760
|
|
|
@@ -2655,160 +2828,272 @@ def curses_print(stdscr, hosts, threads, min_char_len = DEFAULT_CURSES_MINIMUM_C
|
|
|
2655
2828
|
|
|
2656
2829
|
#%% ------------ Generate Output Block ----------------
|
|
2657
2830
|
def can_merge(line_bag1, line_bag2, threshold):
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
except StopIteration:
|
|
2664
|
-
break
|
|
2665
|
-
if item in line_bag2:
|
|
2666
|
-
found = True
|
|
2667
|
-
break
|
|
2668
|
-
if not found:
|
|
2669
|
-
return False
|
|
2670
|
-
return len(line_bag1.symmetric_difference(line_bag2)) < max((len(line_bag1) + len(line_bag2)) * (1 - threshold),1)
|
|
2831
|
+
if threshold > 0.5:
|
|
2832
|
+
samples = itertools.islice(line_bag1, max(int(len(line_bag1) * (1 - threshold)),1))
|
|
2833
|
+
if not line_bag2.intersection(samples):
|
|
2834
|
+
return False
|
|
2835
|
+
return len(line_bag1.intersection(line_bag2)) >= min(len(line_bag1),len(line_bag2)) * threshold
|
|
2671
2836
|
|
|
2672
2837
|
def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
|
|
2673
|
-
indexes = {hostname: 0 for hostname in merging_hostnames}
|
|
2674
|
-
|
|
2838
|
+
#indexes = {hostname: 0 for hostname in merging_hostnames}
|
|
2839
|
+
indexes = Counter({hostname: 0 for hostname in merging_hostnames})
|
|
2840
|
+
working_index_keys = set(merging_hostnames)
|
|
2675
2841
|
previousBuddies = set()
|
|
2676
|
-
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='- ')
|
|
2677
2843
|
hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
|
|
2844
|
+
diff_display_item_count = max(1,int(max(map(len, outputs_by_hostname.values())) * (1 - diff_display_threshold)))
|
|
2845
|
+
def get_multiset_index_for_hostname(hostname):
|
|
2846
|
+
index = indexes[hostname]
|
|
2847
|
+
tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
|
|
2848
|
+
tracking_iter = itertools.islice(outputs_by_hostname[hostname], tracking_index)
|
|
2849
|
+
return [deque(outputs_by_hostname[hostname][index:tracking_index],maxlen=diff_display_item_count),tracking_iter]
|
|
2850
|
+
# futuresChainMap = ChainMap()
|
|
2851
|
+
# class futureDict(UserDict):
|
|
2852
|
+
# def __missing__(self, key):
|
|
2853
|
+
# value = get_multiset_index_for_hostname(key)
|
|
2854
|
+
# self[key] = value
|
|
2855
|
+
# # futuresChainMap.maps.append(value[0]._counter)
|
|
2856
|
+
# return value
|
|
2857
|
+
# # def initializeHostnames(self, hostnames):
|
|
2858
|
+
# # entries = {hostname: get_multiset_index_for_hostname(hostname) for hostname in hostnames}
|
|
2859
|
+
# # self.update(entries)
|
|
2860
|
+
# # futuresChainMap.maps.extend(entry[0]._counter for entry in entries.values())
|
|
2861
|
+
def advance(dict,key):
|
|
2862
|
+
try:
|
|
2863
|
+
value = dict[key]
|
|
2864
|
+
value[0].append(next(value[1]))
|
|
2865
|
+
except StopIteration:
|
|
2866
|
+
try:
|
|
2867
|
+
value[0].popleft()
|
|
2868
|
+
except IndexError:
|
|
2869
|
+
pass
|
|
2870
|
+
except KeyError:
|
|
2871
|
+
pass
|
|
2872
|
+
# futures = futureDict()
|
|
2873
|
+
# for hostname in merging_hostnames:
|
|
2874
|
+
# futures[hostname] # ensure it's initialized
|
|
2875
|
+
futures = {hostname: get_multiset_index_for_hostname(hostname) for hostname in merging_hostnames}
|
|
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'
|
|
2884
|
+
for hostname in merging_hostnames:
|
|
2885
|
+
currentLines[outputs_by_hostname[hostname][0]].add(hostname)
|
|
2678
2886
|
while indexes:
|
|
2679
|
-
futures = {}
|
|
2680
2887
|
defer = False
|
|
2681
|
-
|
|
2682
|
-
golden_hostname,
|
|
2683
|
-
|
|
2888
|
+
# sorted_working_hostnames = sorted(working_index_keys, key=lambda hn: indexes[hn])
|
|
2889
|
+
golden_hostname = min(working_index_keys, key=indexes.get)
|
|
2890
|
+
golden_index = indexes[golden_hostname]
|
|
2684
2891
|
lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
|
|
2685
|
-
for hostname, index in sorted_working_indexes[1:]:
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2892
|
+
# for hostname, index in sorted_working_indexes[1:]:
|
|
2893
|
+
# if lineToAdd == outputs_by_hostname[hostname][index]:
|
|
2894
|
+
# buddy.add(hostname)
|
|
2895
|
+
# else:
|
|
2896
|
+
# futureLines,tracking_index = futures[hostname]
|
|
2897
|
+
# if lineToAdd in futureLines:
|
|
2898
|
+
# for hn in buddy:
|
|
2899
|
+
# working_indexes.pop(hn,None)
|
|
2900
|
+
# defer = True
|
|
2901
|
+
# break
|
|
2902
|
+
buddy = currentLines[lineToAdd].copy()
|
|
2903
|
+
if len(buddy) < len(working_index_keys):
|
|
2904
|
+
# we need to check the futures then
|
|
2905
|
+
# thisCounter = None
|
|
2906
|
+
# if golden_hostname in futures:
|
|
2907
|
+
# thisCounter = futures[golden_hostname][0]._counter
|
|
2908
|
+
# futuresChainMap.maps.remove(thisCounter)
|
|
2909
|
+
# for hostname in working_index_keys - buddy - set(futures.keys()):
|
|
2910
|
+
# futures[hostname] # ensure it's initialized
|
|
2911
|
+
# futures.initializeHostnames(working_index_keys - buddy - futures.keys())
|
|
2912
|
+
if any(lineToAdd in futures[hostname][0] for hostname in working_index_keys - buddy):
|
|
2913
|
+
defer = True
|
|
2914
|
+
working_index_keys -= buddy
|
|
2915
|
+
# if thisCounter is not None:
|
|
2916
|
+
# futuresChainMap.maps.append(thisCounter)
|
|
2698
2917
|
if not defer:
|
|
2699
2918
|
if buddy != previousBuddies:
|
|
2700
2919
|
hostnameStr = ','.join(compact_hostnames(buddy))
|
|
2701
2920
|
hostnameLines = hostnameWrapper.wrap(hostnameStr)
|
|
2702
|
-
hostnameLines = [line.ljust(line_length
|
|
2703
|
-
|
|
2704
|
-
|
|
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]}"
|
|
2705
2930
|
output.extend(hostnameLines)
|
|
2706
2931
|
previousBuddies = buddy
|
|
2707
|
-
output.append(lineToAdd
|
|
2932
|
+
output.append(lineToAdd)
|
|
2933
|
+
currentLines[lineToAdd].difference_update(buddy)
|
|
2934
|
+
if not currentLines[lineToAdd]:
|
|
2935
|
+
del currentLines[lineToAdd]
|
|
2936
|
+
indexes.update(buddy)
|
|
2708
2937
|
for hostname in buddy:
|
|
2709
|
-
|
|
2710
|
-
if
|
|
2938
|
+
# currentLines[lineToAdd].remove(hostname)
|
|
2939
|
+
# if not currentLines[lineToAdd]:
|
|
2940
|
+
# del currentLines[lineToAdd]
|
|
2941
|
+
# indexes[hostname] += 1
|
|
2942
|
+
try:
|
|
2943
|
+
currentLines[outputs_by_hostname[hostname][indexes[hostname]]].add(hostname)
|
|
2944
|
+
except IndexError:
|
|
2711
2945
|
indexes.pop(hostname, None)
|
|
2712
2946
|
futures.pop(hostname, None)
|
|
2947
|
+
# if future:
|
|
2948
|
+
# futuresChainMap.maps.remove(future[0]._counter)
|
|
2713
2949
|
continue
|
|
2714
2950
|
#advance futures
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
tracking_index += 1
|
|
2718
|
-
if tracking_index < len(outputs_by_hostname[hostname]):
|
|
2719
|
-
line = outputs_by_hostname[hostname][tracking_index]
|
|
2720
|
-
tracking_multiset.append(line)
|
|
2721
|
-
else:
|
|
2722
|
-
tracking_multiset.pop_left()
|
|
2723
|
-
futures[hostname] = (tracking_multiset, tracking_index)
|
|
2724
|
-
working_indexes = indexes.copy()
|
|
2951
|
+
advance(futures, hostname)
|
|
2952
|
+
working_index_keys = set(indexes.keys())
|
|
2725
2953
|
|
|
2726
2954
|
def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
|
|
2727
2955
|
output = []
|
|
2728
|
-
|
|
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'([,]+)')
|
|
2729
2968
|
for merging_hostnames in merge_groups:
|
|
2730
2969
|
mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
|
|
2731
|
-
output.append(
|
|
2970
|
+
output.append(color_line+'─'*(line_length)+color_reset)
|
|
2732
2971
|
for hostname in remaining_hostnames:
|
|
2733
|
-
hostnameLines =
|
|
2734
|
-
|
|
2735
|
-
output.extend(
|
|
2736
|
-
output.extend(
|
|
2737
|
-
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)
|
|
2738
2977
|
if output:
|
|
2739
2978
|
output.pop()
|
|
2740
2979
|
# if output and output[0] and output[0].startswith('├'):
|
|
2741
2980
|
# output[0] = '┌' + output[0][1:]
|
|
2742
2981
|
return output
|
|
2743
2982
|
|
|
2983
|
+
def pre_merge_hosts(hosts):
|
|
2984
|
+
'''Merge hosts with identical outputs.'''
|
|
2985
|
+
output_groups = defaultdict(list)
|
|
2986
|
+
# Group hosts by their output identity
|
|
2987
|
+
for host in hosts:
|
|
2988
|
+
identity = host.get_output_hash()
|
|
2989
|
+
output_groups[identity].append(host)
|
|
2990
|
+
# Create merged hosts
|
|
2991
|
+
merged_hosts = []
|
|
2992
|
+
for group in output_groups.values():
|
|
2993
|
+
group[0].name = ','.join(compact_hostnames(host.name for host in group))
|
|
2994
|
+
merged_hosts.append(group[0])
|
|
2995
|
+
return merged_hosts
|
|
2996
|
+
|
|
2744
2997
|
def get_host_raw_output(hosts, terminal_width):
|
|
2745
2998
|
outputs_by_hostname = {}
|
|
2746
2999
|
line_bag_by_hostname = {}
|
|
2747
3000
|
hostnames_by_line_bag_len = {}
|
|
2748
|
-
text_wrapper = textwrap.TextWrapper(width=terminal_width -
|
|
2749
|
-
initial_indent='
|
|
3001
|
+
text_wrapper = textwrap.TextWrapper(width=terminal_width - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
3002
|
+
initial_indent=' ', subsequent_indent='-')
|
|
2750
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']))
|
|
3017
|
+
hosts = pre_merge_hosts(hosts)
|
|
2751
3018
|
for host in hosts:
|
|
2752
|
-
|
|
2753
|
-
|
|
3019
|
+
max_length = max(max_length, len(max(host.name.split(','), key=len)) + 3)
|
|
3020
|
+
hostPrintOut = [f"{cyan_str}█{color_reset_str} EXECUTED COMMAND:"]
|
|
3021
|
+
for line in host.command.splitlines():
|
|
2754
3022
|
hostPrintOut.extend(text_wrapper.wrap(line))
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
3023
|
+
# hostPrintOut.extend(itertools.chain.from_iterable(text_wrapper.wrap(line) for line in host['command'].splitlines()))
|
|
3024
|
+
lineBag = {(0,host.command)}
|
|
3025
|
+
prevLine = host.command
|
|
3026
|
+
if host.stdout:
|
|
3027
|
+
hostPrintOut.append(f'{blue_str}▓{color_reset_str} STDOUT:')
|
|
3028
|
+
# for line in host.stdout:
|
|
3029
|
+
# if len(line) < terminal_width - 2:
|
|
3030
|
+
# hostPrintOut.append(f" {line}")
|
|
3031
|
+
# else:
|
|
3032
|
+
# hostPrintOut.extend(text_wrapper.wrap(line))
|
|
3033
|
+
hostPrintOut.extend(f" {line}" for line in host.stdout)
|
|
3034
|
+
max_length = max(max_length, max(map(len, host.stdout)))
|
|
3035
|
+
# hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
|
|
2761
3036
|
lineBag.add((prevLine,1))
|
|
2762
|
-
lineBag.add((1,host
|
|
2763
|
-
if len(host
|
|
2764
|
-
lineBag.update(zip(host
|
|
2765
|
-
lineBag.update(host
|
|
2766
|
-
prevLine = host
|
|
2767
|
-
if host
|
|
2768
|
-
if host
|
|
2769
|
-
host
|
|
2770
|
-
elif host
|
|
2771
|
-
host
|
|
2772
|
-
elif host
|
|
2773
|
-
host
|
|
2774
|
-
if host
|
|
2775
|
-
hostPrintOut.append('
|
|
2776
|
-
for line in host
|
|
2777
|
-
|
|
3037
|
+
lineBag.add((1,host.stdout[0]))
|
|
3038
|
+
if len(host.stdout) > 1:
|
|
3039
|
+
lineBag.update(zip(host.stdout, host.stdout[1:]))
|
|
3040
|
+
lineBag.update(host.stdout)
|
|
3041
|
+
prevLine = host.stdout[-1]
|
|
3042
|
+
if host.stderr:
|
|
3043
|
+
if host.stderr[0].strip().startswith('ssh: connect to host ') and host.stderr[0].strip().endswith('Connection refused'):
|
|
3044
|
+
host.stderr[0] = 'SSH not reachable!'
|
|
3045
|
+
elif host.stderr[-1].strip().endswith('Connection timed out'):
|
|
3046
|
+
host.stderr[-1] = 'SSH connection timed out!'
|
|
3047
|
+
elif host.stderr[-1].strip().endswith('No route to host'):
|
|
3048
|
+
host.stderr[-1] = 'Cannot find host!'
|
|
3049
|
+
if host.stderr:
|
|
3050
|
+
hostPrintOut.append(f'{red_str}▒{color_reset_str} STDERR:')
|
|
3051
|
+
# for line in host.stderr:
|
|
3052
|
+
# if len(line) < terminal_width - 2:
|
|
3053
|
+
# hostPrintOut.append(f" {line}")
|
|
3054
|
+
# else:
|
|
3055
|
+
# hostPrintOut.extend(text_wrapper.wrap(line))
|
|
3056
|
+
hostPrintOut.extend(f" {line}" for line in host.stderr)
|
|
3057
|
+
max_length = max(max_length, max(map(len, host.stderr)))
|
|
2778
3058
|
lineBag.add((prevLine,2))
|
|
2779
|
-
lineBag.add((2,host
|
|
2780
|
-
lineBag.update(host
|
|
2781
|
-
if len(host
|
|
2782
|
-
lineBag.update(zip(host
|
|
2783
|
-
prevLine = host
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
3059
|
+
lineBag.add((2,host.stderr[0]))
|
|
3060
|
+
lineBag.update(host.stderr)
|
|
3061
|
+
if len(host.stderr) > 1:
|
|
3062
|
+
lineBag.update(zip(host.stderr, host.stderr[1:]))
|
|
3063
|
+
prevLine = host.stderr[-1]
|
|
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}")
|
|
3069
|
+
lineBag.add((prevLine,f"{host.returncode}"))
|
|
3070
|
+
outputs_by_hostname[host.name] = hostPrintOut
|
|
3071
|
+
line_bag_by_hostname[host.name] = lineBag
|
|
3072
|
+
hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host.name)
|
|
2790
3073
|
return outputs_by_hostname, line_bag_by_hostname, hostnames_by_line_bag_len, sorted(hostnames_by_line_bag_len), min(max_length+2,terminal_width)
|
|
2791
3074
|
|
|
2792
3075
|
def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold):
|
|
2793
3076
|
merge_groups = []
|
|
2794
|
-
|
|
3077
|
+
remaining_hostnames = set()
|
|
3078
|
+
for lbl_i, line_bag_len in enumerate(sorted_hostnames_by_line_bag_len_keys):
|
|
2795
3079
|
for this_hostname in hostnames_by_line_bag_len.get(line_bag_len, set()).copy():
|
|
2796
|
-
if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
|
|
3080
|
+
# if this_hostname not in hostnames_by_line_bag_len.get(line_bag_len, set()):
|
|
3081
|
+
# continue
|
|
3082
|
+
try:
|
|
3083
|
+
this_line_bag = line_bag_by_hostname.pop(this_hostname)
|
|
3084
|
+
hostnames_by_line_bag_len.get(line_bag_len, set()).discard(this_hostname)
|
|
3085
|
+
except KeyError:
|
|
2797
3086
|
continue
|
|
2798
|
-
this_line_bag = line_bag_by_hostname[this_hostname]
|
|
2799
3087
|
target_threshold = line_bag_len * (2 - diff_display_threshold)
|
|
2800
3088
|
merge_group = []
|
|
2801
|
-
for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys:
|
|
3089
|
+
for other_line_bag_len in sorted_hostnames_by_line_bag_len_keys[lbl_i:]:
|
|
2802
3090
|
if other_line_bag_len > target_threshold:
|
|
2803
3091
|
break
|
|
2804
|
-
if other_line_bag_len < line_bag_len:
|
|
2805
|
-
|
|
3092
|
+
# if other_line_bag_len < line_bag_len:
|
|
3093
|
+
# continue
|
|
2806
3094
|
for other_hostname in hostnames_by_line_bag_len.get(other_line_bag_len, set()).copy():
|
|
2807
|
-
if this_hostname == other_hostname:
|
|
2808
|
-
continue
|
|
2809
3095
|
if can_merge(this_line_bag, line_bag_by_hostname[other_hostname], diff_display_threshold):
|
|
2810
3096
|
merge_group.append(other_hostname)
|
|
2811
|
-
hostnames_by_line_bag_len.get(line_bag_len, set()).discard(this_hostname)
|
|
2812
3097
|
hostnames_by_line_bag_len[other_line_bag_len].remove(other_hostname)
|
|
2813
3098
|
if not hostnames_by_line_bag_len[other_line_bag_len]:
|
|
2814
3099
|
del hostnames_by_line_bag_len[other_line_bag_len]
|
|
@@ -2816,23 +3101,28 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
|
|
|
2816
3101
|
if merge_group:
|
|
2817
3102
|
merge_group.append(this_hostname)
|
|
2818
3103
|
merge_groups.append(merge_group)
|
|
2819
|
-
|
|
3104
|
+
# del line_bag_by_hostname[this_hostname]
|
|
3105
|
+
else:
|
|
3106
|
+
remaining_hostnames.add(this_hostname)
|
|
3107
|
+
return merge_groups, remaining_hostnames
|
|
2820
3108
|
|
|
2821
3109
|
def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
|
|
3110
|
+
color_cap = get_terminal_color_capability()
|
|
2822
3111
|
if quiet:
|
|
2823
3112
|
# remove hosts with returncode 0
|
|
2824
|
-
hosts = [
|
|
3113
|
+
hosts = [host for host in hosts if host.returncode != 0]
|
|
2825
3114
|
if not hosts:
|
|
2826
3115
|
if usejson:
|
|
2827
3116
|
return '{"Success": true}'
|
|
2828
3117
|
else:
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
3118
|
+
if color_cap == 'None':
|
|
3119
|
+
return 'Success'
|
|
3120
|
+
else:
|
|
3121
|
+
return '\033[32mSuccess\033[0m'
|
|
2832
3122
|
if usejson:
|
|
2833
3123
|
# [print(dict(host)) for host in hosts]
|
|
2834
3124
|
#print(json.dumps([dict(host) for host in hosts],indent=4))
|
|
2835
|
-
rtnStr = json.dumps(hosts,indent=4)
|
|
3125
|
+
rtnStr = json.dumps([dict(host) for host in hosts],indent=4)
|
|
2836
3126
|
elif greppable:
|
|
2837
3127
|
# transform hosts to a 2d list
|
|
2838
3128
|
rtnStr = '*'*80+'\n'
|
|
@@ -2840,14 +3130,14 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2840
3130
|
for host in hosts:
|
|
2841
3131
|
#header = f"{host['name']} | rc: {host['returncode']} | "
|
|
2842
3132
|
hostAdded = False
|
|
2843
|
-
for line in host
|
|
2844
|
-
rtnList.append([host
|
|
3133
|
+
for line in host.stdout:
|
|
3134
|
+
rtnList.append([host.name,f"rc: {host.returncode}",'stdout',line])
|
|
2845
3135
|
hostAdded = True
|
|
2846
|
-
for line in host
|
|
2847
|
-
rtnList.append([host
|
|
3136
|
+
for line in host.stderr:
|
|
3137
|
+
rtnList.append([host.name,f"rc: {host.returncode}",'stderr',line])
|
|
2848
3138
|
hostAdded = True
|
|
2849
3139
|
if not hostAdded:
|
|
2850
|
-
rtnList.append([host
|
|
3140
|
+
rtnList.append([host.name,f"rc: {host.returncode}",'N/A','<EMPTY>'])
|
|
2851
3141
|
rtnList.append(['','','',''])
|
|
2852
3142
|
rtnStr += pretty_format_table(rtnList)
|
|
2853
3143
|
rtnStr += '*'*80+'\n'
|
|
@@ -2863,27 +3153,32 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2863
3153
|
except Exception:
|
|
2864
3154
|
eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
|
|
2865
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']))
|
|
2866
3159
|
terminal_length = get_terminal_size()[0]
|
|
2867
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)
|
|
2868
|
-
merge_groups = form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_len_keys, line_bag_by_hostname, diff_display_threshold)
|
|
2869
|
-
# get the remaining hostnames in the hostnames_by_line_bag_len
|
|
2870
|
-
remaining_hostnames = set()
|
|
2871
|
-
for hostnames in hostnames_by_line_bag_len.values():
|
|
2872
|
-
remaining_hostnames.update(hostnames)
|
|
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)
|
|
2873
3162
|
outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
|
|
2874
3163
|
if keyPressesIn[-1]:
|
|
2875
3164
|
CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
|
|
2876
|
-
outputs.append("
|
|
3165
|
+
outputs.append(color_reset_str + "░ User Inputs:".ljust(line_length,'─'))
|
|
2877
3166
|
cmdOut = []
|
|
2878
3167
|
for line in CMDsOut:
|
|
2879
3168
|
cmdOut.extend(textwrap.wrap(line, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
2880
|
-
initial_indent='
|
|
2881
|
-
outputs.extend(
|
|
3169
|
+
initial_indent=' ', subsequent_indent='-'))
|
|
3170
|
+
outputs.extend(cmdOut)
|
|
2882
3171
|
keyPressesIn[-1].clear()
|
|
2883
|
-
if
|
|
2884
|
-
|
|
3172
|
+
if not outputs:
|
|
3173
|
+
if quiet:
|
|
3174
|
+
if color_cap == 'None':
|
|
3175
|
+
return 'Success'
|
|
3176
|
+
else:
|
|
3177
|
+
return '\033[32mSuccess\033[0m'
|
|
3178
|
+
else:
|
|
3179
|
+
rtnStr = ''
|
|
2885
3180
|
else:
|
|
2886
|
-
rtnStr = '\n'.join(outputs + [
|
|
3181
|
+
rtnStr = '\n'.join(outputs + [white_str + '─' * (line_length) + color_reset_str])
|
|
2887
3182
|
return rtnStr
|
|
2888
3183
|
|
|
2889
3184
|
def print_output(hosts,usejson = False,quiet = False,greppable = False):
|
|
@@ -2901,6 +3196,8 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
|
|
|
2901
3196
|
global __global_suppress_printout
|
|
2902
3197
|
global _encoding
|
|
2903
3198
|
global __keyPressesIn
|
|
3199
|
+
for host in hosts:
|
|
3200
|
+
host.output.clear()
|
|
2904
3201
|
rtnStr = generate_output(hosts,usejson,greppable,quiet=__global_suppress_printout,encoding=_encoding,keyPressesIn=__keyPressesIn)
|
|
2905
3202
|
if not quiet:
|
|
2906
3203
|
print(rtnStr)
|
|
@@ -2908,8 +3205,8 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
|
|
|
2908
3205
|
|
|
2909
3206
|
#%% ------------ Run / Process Hosts Block ----------------
|
|
2910
3207
|
def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinished, no_watch, json, called, greppable,
|
|
2911
|
-
unavailableHosts:dict,willUpdateUnreachableHosts,
|
|
2912
|
-
|
|
3208
|
+
unavailableHosts:dict,willUpdateUnreachableHosts,window_width = DEFAULT_WINDOW_WIDTH,
|
|
3209
|
+
window_height = DEFAULT_WINDOW_HEIGHT,single_window = DEFAULT_SINGLE_WINDOW,
|
|
2913
3210
|
unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY):
|
|
2914
3211
|
global __globalUnavailableHosts
|
|
2915
3212
|
global _no_env
|
|
@@ -2927,7 +3224,16 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
|
|
|
2927
3224
|
if total_sleeped > 0.1:
|
|
2928
3225
|
break
|
|
2929
3226
|
if any([host.returncode is None for host in hosts]):
|
|
2930
|
-
|
|
3227
|
+
try:
|
|
3228
|
+
curses.wrapper(curses_print, hosts, threads, min_char_len = window_width, min_line_len = window_height, 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 = window_width, min_line_len = window_height, single_window = single_window)
|
|
3233
|
+
except Exception as e:
|
|
3234
|
+
eprint(f"Curses print error: {e}")
|
|
3235
|
+
import traceback
|
|
3236
|
+
print(traceback.format_exc())
|
|
2931
3237
|
if not returnUnfinished:
|
|
2932
3238
|
# wait until all hosts have a return code
|
|
2933
3239
|
while any([host.returncode is None for host in hosts]):
|
|
@@ -3017,7 +3323,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
|
|
|
3017
3323
|
no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
|
|
3018
3324
|
file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
|
|
3019
3325
|
copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY, no_history = DEFAULT_NO_HISTORY,
|
|
3020
|
-
history_file = DEFAULT_HISTORY_FILE, env_file =
|
|
3326
|
+
history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
|
|
3021
3327
|
repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
|
|
3022
3328
|
shortend = False) -> str:
|
|
3023
3329
|
argsList = []
|
|
@@ -3061,8 +3367,10 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
|
|
|
3061
3367
|
argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
|
|
3062
3368
|
if no_env:
|
|
3063
3369
|
argsList.append('--no_env')
|
|
3064
|
-
if env_file
|
|
3370
|
+
if env_file:
|
|
3065
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])
|
|
3066
3374
|
if no_history:
|
|
3067
3375
|
argsList.append('--no_history' if not shortend else '-nh')
|
|
3068
3376
|
if history_file and history_file != DEFAULT_HISTORY_FILE:
|
|
@@ -3082,18 +3390,18 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
|
|
|
3082
3390
|
files = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,returnUnfinished = _DEFAULT_RETURN_UNFINISHED,
|
|
3083
3391
|
scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
|
|
3084
3392
|
no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
|
|
3085
|
-
skip_hosts = DEFAULT_SKIP_HOSTS,
|
|
3393
|
+
skip_hosts = DEFAULT_SKIP_HOSTS, window_width = DEFAULT_WINDOW_WIDTH, window_height = DEFAULT_WINDOW_HEIGHT,
|
|
3086
3394
|
single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
|
|
3087
3395
|
copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = DEFAULT_NO_HISTORY,
|
|
3088
|
-
history_file = DEFAULT_HISTORY_FILE, env_file =
|
|
3396
|
+
history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = [],
|
|
3089
3397
|
repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
|
|
3090
|
-
shortend = False,tabSeperated = False):
|
|
3398
|
+
shortend = False,tabSeperated = False,**kwargs) -> str:
|
|
3091
3399
|
_ = called
|
|
3092
3400
|
_ = returnUnfinished
|
|
3093
3401
|
_ = willUpdateUnreachableHosts
|
|
3094
3402
|
_ = no_start
|
|
3095
|
-
_ =
|
|
3096
|
-
_ =
|
|
3403
|
+
_ = window_width
|
|
3404
|
+
_ = window_height
|
|
3097
3405
|
_ = single_window
|
|
3098
3406
|
hosts = hosts if isinstance(hosts,str) else frozenset(hosts)
|
|
3099
3407
|
hostStr = formHostStr(hosts)
|
|
@@ -3105,7 +3413,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
|
|
|
3105
3413
|
no_env=no_env, greppable=greppable,skip_hosts = skip_hosts,
|
|
3106
3414
|
file_sync = file_sync,error_only = error_only, identity_file = identity_file,
|
|
3107
3415
|
copy_id = copy_id, unavailable_host_expiry =unavailable_host_expiry,no_history = no_history,
|
|
3108
|
-
history_file = history_file, env_file = env_file,
|
|
3416
|
+
history_file = history_file, env_file = env_file, env_files = env_files,
|
|
3109
3417
|
repeat = repeat,interval = interval,
|
|
3110
3418
|
shortend = shortend)
|
|
3111
3419
|
commands = [command.replace('"', '\\"').replace('\n', '\\n').replace('\t', '\\t') for command in format_commands(commands)]
|
|
@@ -3156,56 +3464,56 @@ def record_command_history(kwargs):
|
|
|
3156
3464
|
#%% ------------ Main Block ----------------
|
|
3157
3465
|
def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
|
|
3158
3466
|
no_watch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,called = _DEFAULT_CALLED,max_connections=DEFAULT_MAX_CONNECTIONS,
|
|
3159
|
-
|
|
3467
|
+
file = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,returnUnfinished = _DEFAULT_RETURN_UNFINISHED,
|
|
3160
3468
|
scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
|
|
3161
3469
|
no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
|
|
3162
|
-
skip_hosts = DEFAULT_SKIP_HOSTS,
|
|
3470
|
+
skip_hosts = DEFAULT_SKIP_HOSTS, window_width = DEFAULT_WINDOW_WIDTH, window_height = DEFAULT_WINDOW_HEIGHT,
|
|
3163
3471
|
single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY,quiet = False,identity_file = DEFAULT_IDENTITY_FILE,
|
|
3164
3472
|
copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = True,
|
|
3165
|
-
history_file = DEFAULT_HISTORY_FILE
|
|
3473
|
+
history_file = DEFAULT_HISTORY_FILE,**kwargs
|
|
3166
3474
|
):
|
|
3167
|
-
|
|
3168
|
-
Run
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
hosts (str
|
|
3172
|
-
commands (list):
|
|
3173
|
-
oneonone (bool
|
|
3174
|
-
timeout (int
|
|
3175
|
-
password (str
|
|
3176
|
-
no_watch (bool
|
|
3177
|
-
json (bool
|
|
3178
|
-
called (bool
|
|
3179
|
-
max_connections (int
|
|
3180
|
-
|
|
3181
|
-
ipmi (bool
|
|
3182
|
-
interface_ip_prefix (str
|
|
3183
|
-
returnUnfinished (bool
|
|
3184
|
-
scp (bool
|
|
3185
|
-
gather_mode (bool
|
|
3186
|
-
username (str
|
|
3187
|
-
extraargs (str
|
|
3188
|
-
skipUnreachable (bool
|
|
3189
|
-
no_env (bool
|
|
3190
|
-
greppable (bool
|
|
3191
|
-
willUpdateUnreachableHosts (bool
|
|
3192
|
-
no_start (bool
|
|
3193
|
-
skip_hosts (str
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
single_window (bool
|
|
3197
|
-
file_sync (bool
|
|
3198
|
-
error_only (bool
|
|
3199
|
-
quiet (bool
|
|
3200
|
-
identity_file (str
|
|
3201
|
-
copy_id (bool
|
|
3202
|
-
unavailable_host_expiry (int
|
|
3203
|
-
no_history (bool
|
|
3204
|
-
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
|
+
file (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
|
+
window_width (int): Minimum width per curses window. Default: DEFAULT_WINDOW_WIDTH.
|
|
3503
|
+
window_height (int): Minimum height per curses window. Default: DEFAULT_WINDOW_HEIGHT.
|
|
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.
|
|
3205
3513
|
|
|
3206
3514
|
Returns:
|
|
3207
|
-
list:
|
|
3208
|
-
|
|
3515
|
+
list: List of Host objects representing each host/command run.
|
|
3516
|
+
"""
|
|
3209
3517
|
global __globalUnavailableHosts
|
|
3210
3518
|
global __global_suppress_printout
|
|
3211
3519
|
global _no_env
|
|
@@ -3333,39 +3641,39 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
3333
3641
|
processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
|
|
3334
3642
|
returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
|
|
3335
3643
|
unavailableHosts=unavailableHosts,willUpdateUnreachableHosts=willUpdateUnreachableHosts,
|
|
3336
|
-
|
|
3644
|
+
window_width = window_width, window_height = window_height,
|
|
3337
3645
|
single_window=single_window,unavailable_host_expiry=unavailable_host_expiry)
|
|
3338
3646
|
else:
|
|
3339
3647
|
eprint(f"Warning: ssh-copy-id not found in {_binPaths} , skipping copy id to the hosts")
|
|
3340
3648
|
if not commands:
|
|
3341
3649
|
_exit_with_code(0, "Copy id finished, no commands to run")
|
|
3342
|
-
if
|
|
3650
|
+
if file and not commands:
|
|
3343
3651
|
# if files are specified but not target dir, we default to file sync mode
|
|
3344
3652
|
file_sync = True
|
|
3345
3653
|
if file_sync:
|
|
3346
3654
|
# set the files to the union of files and commands
|
|
3347
|
-
|
|
3348
|
-
if
|
|
3655
|
+
file = set(file+commands) if file else set(commands)
|
|
3656
|
+
if file:
|
|
3349
3657
|
# try to resolve files first (like * etc)
|
|
3350
3658
|
if not gather_mode:
|
|
3351
3659
|
pathSet = set()
|
|
3352
|
-
for file in
|
|
3660
|
+
for file in file:
|
|
3353
3661
|
try:
|
|
3354
3662
|
pathSet.update(glob.glob(file,include_hidden=True,recursive=True))
|
|
3355
3663
|
except Exception:
|
|
3356
3664
|
pathSet.update(glob.glob(file,recursive=True))
|
|
3357
3665
|
if not pathSet:
|
|
3358
|
-
_exit_with_code(66, f'No source files at {
|
|
3666
|
+
_exit_with_code(66, f'No source files at {file!r} are found after resolving globs!')
|
|
3359
3667
|
else:
|
|
3360
|
-
pathSet = set(
|
|
3668
|
+
pathSet = set(file)
|
|
3361
3669
|
if file_sync:
|
|
3362
3670
|
# use abosolute path for file sync
|
|
3363
3671
|
commands = [os.path.abspath(file) for file in pathSet]
|
|
3364
|
-
|
|
3672
|
+
file = []
|
|
3365
3673
|
else:
|
|
3366
|
-
|
|
3674
|
+
file = list(pathSet)
|
|
3367
3675
|
if __DEBUG_MODE:
|
|
3368
|
-
eprint(f"Files: {
|
|
3676
|
+
eprint(f"Files: {file!r}")
|
|
3369
3677
|
if oneonone:
|
|
3370
3678
|
hosts = []
|
|
3371
3679
|
if len(commands) != len(set(targetHostDic) - set(skipHostSet)):
|
|
@@ -3385,7 +3693,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
3385
3693
|
if file_sync:
|
|
3386
3694
|
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]))
|
|
3387
3695
|
else:
|
|
3388
|
-
hosts.append(Host(host, command, files =
|
|
3696
|
+
hosts.append(Host(host, command, files = file,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
|
|
3389
3697
|
if not __global_suppress_printout:
|
|
3390
3698
|
eprint(f"Running command: {command!r} on host: {host!r}")
|
|
3391
3699
|
if not __global_suppress_printout:
|
|
@@ -3394,7 +3702,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
3394
3702
|
processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
|
|
3395
3703
|
returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
|
|
3396
3704
|
unavailableHosts=unavailableHosts,willUpdateUnreachableHosts=willUpdateUnreachableHosts,
|
|
3397
|
-
|
|
3705
|
+
window_width = window_width, window_height = window_height,
|
|
3398
3706
|
single_window=single_window,unavailable_host_expiry=unavailable_host_expiry)
|
|
3399
3707
|
return hosts
|
|
3400
3708
|
else:
|
|
@@ -3412,10 +3720,10 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
3412
3720
|
if file_sync:
|
|
3413
3721
|
eprint("Error: file sync mode need to be specified with at least one path to sync.")
|
|
3414
3722
|
return []
|
|
3415
|
-
elif
|
|
3723
|
+
elif file:
|
|
3416
3724
|
eprint("Error: files need to be specified with at least one path to sync")
|
|
3417
3725
|
else:
|
|
3418
|
-
hosts.append(Host(host, '', files =
|
|
3726
|
+
hosts.append(Host(host, '', files = file,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,identity_file=identity_file,ip=targetHostDic[host]))
|
|
3419
3727
|
if not __global_suppress_printout:
|
|
3420
3728
|
eprint('-'*80)
|
|
3421
3729
|
eprint(f"Running in interactive mode on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
|
|
@@ -3426,7 +3734,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
3426
3734
|
processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
|
|
3427
3735
|
returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
|
|
3428
3736
|
unavailableHosts=unavailableHosts,willUpdateUnreachableHosts=willUpdateUnreachableHosts,
|
|
3429
|
-
|
|
3737
|
+
window_width = window_width, window_height = window_height,
|
|
3430
3738
|
single_window=single_window,unavailable_host_expiry=unavailable_host_expiry)
|
|
3431
3739
|
return hosts
|
|
3432
3740
|
for command in commands:
|
|
@@ -3441,7 +3749,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
3441
3749
|
if file_sync:
|
|
3442
3750
|
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]))
|
|
3443
3751
|
else:
|
|
3444
|
-
hosts.append(Host(host, command, files =
|
|
3752
|
+
hosts.append(Host(host, command, files = file,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode,identity_file=identity_file,ip=targetHostDic[host]))
|
|
3445
3753
|
if not __global_suppress_printout and len(commands) > 1:
|
|
3446
3754
|
eprint('-'*80)
|
|
3447
3755
|
eprint(f"Running command: {command} on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
|
|
@@ -3450,7 +3758,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
3450
3758
|
processRunOnHosts(timeout=timeout, password=password, max_connections=max_connections, hosts=hosts,
|
|
3451
3759
|
returnUnfinished=returnUnfinished, no_watch=no_watch, json=json, called=called, greppable=greppable,
|
|
3452
3760
|
unavailableHosts=unavailableHosts,willUpdateUnreachableHosts=willUpdateUnreachableHosts,
|
|
3453
|
-
|
|
3761
|
+
window_width = window_width, window_height = window_height,
|
|
3454
3762
|
single_window=single_window,unavailable_host_expiry=unavailable_host_expiry)
|
|
3455
3763
|
allHosts += hosts
|
|
3456
3764
|
return allHosts
|
|
@@ -3472,8 +3780,8 @@ def generate_default_config(args):
|
|
|
3472
3780
|
'DEFAULT_HOSTS': args.hosts,
|
|
3473
3781
|
'DEFAULT_USERNAME': args.username,
|
|
3474
3782
|
'DEFAULT_PASSWORD': args.password,
|
|
3475
|
-
'DEFAULT_IDENTITY_FILE': args.
|
|
3476
|
-
'
|
|
3783
|
+
'DEFAULT_IDENTITY_FILE': args.identity_file if args.identity_file and not os.path.isdir(args.identity_file) else DEFAULT_IDENTITY_FILE,
|
|
3784
|
+
'DEFAULT_SSH_KEY_SEARCH_PATH': args.identity_file if args.identity_file and os.path.isdir(args.identity_file) else DEFAULT_SSH_KEY_SEARCH_PATH,
|
|
3477
3785
|
'DEFAULT_USE_KEY': args.use_key,
|
|
3478
3786
|
'DEFAULT_EXTRA_ARGS': args.extraargs,
|
|
3479
3787
|
'DEFAULT_ONE_ON_ONE': args.oneonone,
|
|
@@ -3490,14 +3798,14 @@ def generate_default_config(args):
|
|
|
3490
3798
|
'DEFAULT_IPMI_USERNAME': args.ipmi_username,
|
|
3491
3799
|
'DEFAULT_IPMI_PASSWORD': args.ipmi_password,
|
|
3492
3800
|
'DEFAULT_NO_WATCH': args.no_watch,
|
|
3493
|
-
'
|
|
3494
|
-
'
|
|
3801
|
+
'DEFAULT_WINDOW_WIDTH': args.window_width,
|
|
3802
|
+
'DEFAULT_WINDOW_HEIGHT': args.window_height,
|
|
3495
3803
|
'DEFAULT_SINGLE_WINDOW': args.single_window,
|
|
3496
3804
|
'DEFAULT_ERROR_ONLY': args.error_only,
|
|
3497
3805
|
'DEFAULT_NO_OUTPUT': args.no_output,
|
|
3498
3806
|
'DEFAULT_RETURN_ZERO': args.return_zero,
|
|
3499
3807
|
'DEFAULT_NO_ENV': args.no_env,
|
|
3500
|
-
'
|
|
3808
|
+
'DEFAULT_ENV_FILES': args.env_files,
|
|
3501
3809
|
'DEFAULT_NO_HISTORY': args.no_history,
|
|
3502
3810
|
'DEFAULT_HISTORY_FILE': args.history_file,
|
|
3503
3811
|
'DEFAULT_MAX_CONNECTIONS': args.max_connections if args.max_connections != 4 * os.cpu_count() else None,
|
|
@@ -3554,18 +3862,18 @@ def write_default_config(args,CONFIG_FILE = None):
|
|
|
3554
3862
|
#%% ------------ Argument Processing -----------------
|
|
3555
3863
|
def get_parser():
|
|
3556
3864
|
global _binPaths
|
|
3557
|
-
parser = argparse.ArgumentParser(description=
|
|
3558
|
-
epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}\n Terminal color capability: {get_terminal_color_capability()}',)
|
|
3865
|
+
parser = argparse.ArgumentParser(description='Run a command on multiple hosts, Use #HOST# or #HOSTNAME# to replace the host name in the command.',
|
|
3866
|
+
epilog=f'Found bins: {list(_binPaths.values())}\n Missing bins: {_binCalled - set(_binPaths.keys())}\n Terminal color capability: {get_terminal_color_capability()}\nConfig file chain: {CONFIG_FILE_CHAIN!r}',)
|
|
3559
3867
|
parser.add_argument('hosts', metavar='hosts', type=str, nargs='?', help=f'Hosts to run the command on, use "," to seperate hosts. (default: {DEFAULT_HOSTS})',default=DEFAULT_HOSTS)
|
|
3560
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.')
|
|
3561
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)
|
|
3562
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)
|
|
3563
|
-
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','--identity_file','--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)
|
|
3564
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)
|
|
3565
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)
|
|
3566
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)
|
|
3567
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")
|
|
3568
|
-
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])
|
|
3569
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)
|
|
3570
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)
|
|
3571
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")
|
|
@@ -3578,15 +3886,16 @@ def get_parser():
|
|
|
3578
3886
|
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)
|
|
3579
3887
|
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)
|
|
3580
3888
|
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)
|
|
3581
|
-
parser.add_argument('-S',"-q","-nw","--no_watch",
|
|
3582
|
-
parser.add_argument("-ww",'--window_width', type=int, help=f"The minimum character length of the curses window. (default: {
|
|
3583
|
-
parser.add_argument("-wh",'--window_height', type=int, help=f"The minimum line height of the curses window. (default: {
|
|
3889
|
+
parser.add_argument('-S',"-q","-nw","--no_watch", action='store_true', help=f"Quiet mode, no curses watch, only print the output. (default: {DEFAULT_NO_WATCH})", default=DEFAULT_NO_WATCH)
|
|
3890
|
+
parser.add_argument("-ww",'--window_width', type=int, help=f"The minimum character length of the curses window. (default: {DEFAULT_WINDOW_WIDTH})", default=DEFAULT_WINDOW_WIDTH)
|
|
3891
|
+
parser.add_argument("-wh",'--window_height', type=int, help=f"The minimum line height of the curses window. (default: {DEFAULT_WINDOW_HEIGHT})", default=DEFAULT_WINDOW_HEIGHT)
|
|
3584
3892
|
parser.add_argument('-B','-sw','--single_window', action='store_true', help=f'Use a single window for all hosts. (default: {DEFAULT_SINGLE_WINDOW})', default=DEFAULT_SINGLE_WINDOW)
|
|
3585
3893
|
parser.add_argument('-R','-eo','--error_only', action='store_true', help=f'Only print the error output. (default: {DEFAULT_ERROR_ONLY})', default=DEFAULT_ERROR_ONLY)
|
|
3586
|
-
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)
|
|
3894
|
+
parser.add_argument('-Q',"-no","--no_output","--quiet", action='store_true', help=f"Do not print the output. (default: {DEFAULT_NO_OUTPUT})", default=DEFAULT_NO_OUTPUT)
|
|
3587
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)
|
|
3588
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)
|
|
3589
|
-
parser.add_argument("--env_file", type=str, help=
|
|
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})")
|
|
3590
3899
|
parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
|
|
3591
3900
|
parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
|
|
3592
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)
|
|
@@ -3605,8 +3914,9 @@ def get_parser():
|
|
|
3605
3914
|
parser.add_argument('-hf','--history_file', type=str, help=f'The file to store the history. (default: {DEFAULT_HISTORY_FILE})', default=DEFAULT_HISTORY_FILE)
|
|
3606
3915
|
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')
|
|
3607
3916
|
parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
|
|
3608
|
-
parser.add_argument('-
|
|
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)
|
|
3609
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.')
|
|
3610
3920
|
parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
|
|
3611
3921
|
return parser
|
|
3612
3922
|
|
|
@@ -3636,7 +3946,18 @@ def process_args(args = None):
|
|
|
3636
3946
|
args.no_history = True
|
|
3637
3947
|
args.greppable = True
|
|
3638
3948
|
args.error_only = True
|
|
3639
|
-
|
|
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
|
|
3640
3961
|
if args.unavailable_host_expiry <= 0:
|
|
3641
3962
|
eprint(f"Warning: The unavailable host expiry time {args.unavailable_host_expiry} is less than 0, setting it to 10 seconds.")
|
|
3642
3963
|
args.unavailable_host_expiry = 10
|
|
@@ -3655,7 +3976,7 @@ def process_config_file(args):
|
|
|
3655
3976
|
else:
|
|
3656
3977
|
configFileToWriteTo = args.config_file
|
|
3657
3978
|
write_default_config(args,configFileToWriteTo)
|
|
3658
|
-
if not args.commands:
|
|
3979
|
+
if not args.commands and not args.file:
|
|
3659
3980
|
if configFileToWriteTo:
|
|
3660
3981
|
with open(configFileToWriteTo,'r') as f:
|
|
3661
3982
|
eprint(f"Config file content: \n{f.read()}")
|
|
@@ -3687,21 +4008,50 @@ def process_commands(args):
|
|
|
3687
4008
|
return args
|
|
3688
4009
|
|
|
3689
4010
|
def process_keys(args):
|
|
3690
|
-
if args.
|
|
3691
|
-
if not args.
|
|
3692
|
-
args.
|
|
4011
|
+
if args.identity_file or args.use_key:
|
|
4012
|
+
if not args.identity_file:
|
|
4013
|
+
args.identity_file = find_ssh_key_file()
|
|
3693
4014
|
else:
|
|
3694
|
-
if os.path.isdir(os.path.expanduser(args.
|
|
3695
|
-
args.
|
|
3696
|
-
elif not os.path.exists(args.
|
|
3697
|
-
eprint(f"Warning: Identity file {args.
|
|
4015
|
+
if os.path.isdir(os.path.expanduser(args.identity_file)):
|
|
4016
|
+
args.identity_file = find_ssh_key_file(args.identity_file)
|
|
4017
|
+
elif not os.path.exists(args.identity_file):
|
|
4018
|
+
eprint(f"Warning: Identity file {args.identity_file!r} not found. Passing to ssh anyway. Proceed with caution.")
|
|
3698
4019
|
return args
|
|
3699
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
|
|
3700
4050
|
|
|
3701
4051
|
def set_global_with_args(args):
|
|
3702
4052
|
global _emo
|
|
3703
4053
|
global __ipmiiInterfaceIPPrefix
|
|
3704
|
-
global
|
|
4054
|
+
global _env_files
|
|
3705
4055
|
global __DEBUG_MODE
|
|
3706
4056
|
global __configs_from_file
|
|
3707
4057
|
global _encoding
|
|
@@ -3712,7 +4062,10 @@ def set_global_with_args(args):
|
|
|
3712
4062
|
global FORCE_TRUECOLOR
|
|
3713
4063
|
_emo = False
|
|
3714
4064
|
__ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
|
|
3715
|
-
|
|
4065
|
+
if args.env_file:
|
|
4066
|
+
_env_files = [args.env_file]
|
|
4067
|
+
else:
|
|
4068
|
+
_env_files = DEFAULT_ENV_FILES + args.env_files if args.env_files else DEFAULT_ENV_FILES
|
|
3716
4069
|
__DEBUG_MODE = args.debug
|
|
3717
4070
|
_encoding = args.encoding
|
|
3718
4071
|
if args.return_zero:
|
|
@@ -3733,6 +4086,7 @@ def main():
|
|
|
3733
4086
|
args = process_config_file(args)
|
|
3734
4087
|
args = process_commands(args)
|
|
3735
4088
|
args = process_keys(args)
|
|
4089
|
+
args = process_control_master_config(args)
|
|
3736
4090
|
set_global_with_args(args)
|
|
3737
4091
|
|
|
3738
4092
|
if args.use_script_timeout:
|
|
@@ -3743,16 +4097,7 @@ def main():
|
|
|
3743
4097
|
if args.no_output:
|
|
3744
4098
|
__global_suppress_printout = True
|
|
3745
4099
|
if not __global_suppress_printout:
|
|
3746
|
-
cmdStr = getStrCommand(args
|
|
3747
|
-
oneonone=args.oneonone,timeout=args.timeout,password=args.password,
|
|
3748
|
-
no_watch=args.no_watch,json=args.json,called=args.no_output,max_connections=args.max_connections,
|
|
3749
|
-
files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
|
|
3750
|
-
extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
|
|
3751
|
-
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,
|
|
3752
|
-
copy_id=args.copy_id,unavailable_host_expiry=args.unavailable_host_expiry,no_history=args.no_history,
|
|
3753
|
-
history_file = args.history_file,
|
|
3754
|
-
env_file = args.env_file,
|
|
3755
|
-
repeat = args.repeat,interval = args.interval)
|
|
4100
|
+
cmdStr = getStrCommand(**vars(args))
|
|
3756
4101
|
eprint('> ' + cmdStr)
|
|
3757
4102
|
if args.error_only:
|
|
3758
4103
|
__global_suppress_printout = True
|
|
@@ -3764,15 +4109,7 @@ def main():
|
|
|
3764
4109
|
|
|
3765
4110
|
if not __global_suppress_printout:
|
|
3766
4111
|
eprint(f"Running the {i+1}/{args.repeat} time") if args.repeat > 1 else None
|
|
3767
|
-
hosts = run_command_on_hosts(args
|
|
3768
|
-
oneonone=args.oneonone,timeout=args.timeout,password=args.password,
|
|
3769
|
-
no_watch=args.no_watch,json=args.json,called=args.no_output,max_connections=args.max_connections,
|
|
3770
|
-
files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
|
|
3771
|
-
extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
|
|
3772
|
-
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,
|
|
3773
|
-
copy_id=args.copy_id,unavailable_host_expiry=args.unavailable_host_expiry,no_history=args.no_history,
|
|
3774
|
-
history_file = args.history_file,
|
|
3775
|
-
)
|
|
4112
|
+
hosts = run_command_on_hosts(**vars(args),called=False)
|
|
3776
4113
|
#print('*'*80)
|
|
3777
4114
|
#if not __global_suppress_printout: eprint('-'*80)
|
|
3778
4115
|
succeededHosts = set()
|