multiSSH3 5.92__py3-none-any.whl → 6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multiSSH3.py +516 -245
- multissh3-6.1.dist-info/METADATA +698 -0
- multissh3-6.1.dist-info/RECORD +6 -0
- multissh3-5.92.dist-info/METADATA +0 -379
- multissh3-5.92.dist-info/RECORD +0 -6
- {multissh3-5.92.dist-info → multissh3-6.1.dist-info}/WHEEL +0 -0
- {multissh3-5.92.dist-info → multissh3-6.1.dist-info}/entry_points.txt +0 -0
- {multissh3-5.92.dist-info → multissh3-6.1.dist-info}/top_level.txt +0 -0
multiSSH3.py
CHANGED
|
@@ -30,7 +30,7 @@ import threading
|
|
|
30
30
|
import time
|
|
31
31
|
import typing
|
|
32
32
|
import uuid
|
|
33
|
-
from collections import Counter, deque, defaultdict
|
|
33
|
+
from collections import Counter, deque, defaultdict
|
|
34
34
|
from itertools import count, product
|
|
35
35
|
|
|
36
36
|
__curses_available = False
|
|
@@ -47,7 +47,6 @@ try:
|
|
|
47
47
|
except ImportError:
|
|
48
48
|
pass
|
|
49
49
|
|
|
50
|
-
|
|
51
50
|
try:
|
|
52
51
|
# Check if functiools.cache is available
|
|
53
52
|
# cache_decorator = functools.cache
|
|
@@ -85,10 +84,10 @@ except Exception:
|
|
|
85
84
|
print('Warning: functools.lru_cache is not available, multiSSH3 will run slower without cache.',file=sys.stderr)
|
|
86
85
|
def cache_decorator(func):
|
|
87
86
|
return func
|
|
88
|
-
version = '
|
|
87
|
+
version = '6.01'
|
|
89
88
|
VERSION = version
|
|
90
89
|
__version__ = version
|
|
91
|
-
COMMIT_DATE = '2025-
|
|
90
|
+
COMMIT_DATE = '2025-11-06'
|
|
92
91
|
|
|
93
92
|
CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
|
|
94
93
|
'~/multiSSH3.config.json',
|
|
@@ -322,7 +321,7 @@ DEFAULT_HOSTS = 'all'
|
|
|
322
321
|
DEFAULT_USERNAME = None
|
|
323
322
|
DEFAULT_PASSWORD = ''
|
|
324
323
|
DEFAULT_IDENTITY_FILE = None
|
|
325
|
-
|
|
324
|
+
DEFAULT_SSH_KEY_SEARCH_PATH = '~/.ssh/'
|
|
326
325
|
DEFAULT_USE_KEY = False
|
|
327
326
|
DEFAULT_EXTRA_ARGS = None
|
|
328
327
|
DEFAULT_ONE_ON_ONE = False
|
|
@@ -346,7 +345,16 @@ DEFAULT_ERROR_ONLY = False
|
|
|
346
345
|
DEFAULT_NO_OUTPUT = False
|
|
347
346
|
DEFAULT_RETURN_ZERO = False
|
|
348
347
|
DEFAULT_NO_ENV = False
|
|
349
|
-
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
|
+
]
|
|
350
358
|
DEFAULT_NO_HISTORY = False
|
|
351
359
|
DEFAULT_HISTORY_FILE = '~/.mssh_history'
|
|
352
360
|
DEFAULT_MAX_CONNECTIONS = 4 * os.cpu_count()
|
|
@@ -356,7 +364,7 @@ DEFAULT_GREPPABLE_MODE = False
|
|
|
356
364
|
DEFAULT_SKIP_UNREACHABLE = True
|
|
357
365
|
DEFAULT_SKIP_HOSTS = ''
|
|
358
366
|
DEFAULT_ENCODING = 'utf-8'
|
|
359
|
-
DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.
|
|
367
|
+
DEFAULT_DIFF_DISPLAY_THRESHOLD = 0.6
|
|
360
368
|
SSH_STRICT_HOST_KEY_CHECKING = False
|
|
361
369
|
FORCE_TRUECOLOR = False
|
|
362
370
|
ERROR_MESSAGES_TO_IGNORE = [
|
|
@@ -368,6 +376,24 @@ ERROR_MESSAGES_TO_IGNORE = [
|
|
|
368
376
|
'Killed by signal',
|
|
369
377
|
'Connection reset by peer',
|
|
370
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()
|
|
371
397
|
_DEFAULT_CALLED = True
|
|
372
398
|
_DEFAULT_RETURN_UNFINISHED = False
|
|
373
399
|
_DEFAULT_UPDATE_UNREACHABLE_HOSTS = True
|
|
@@ -387,6 +413,9 @@ if __ERROR_MESSAGES_TO_IGNORE_REGEX:
|
|
|
387
413
|
__ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile(__ERROR_MESSAGES_TO_IGNORE_REGEX)
|
|
388
414
|
else:
|
|
389
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)
|
|
390
419
|
|
|
391
420
|
#%% Load mssh Functional Global Variables
|
|
392
421
|
__global_suppress_printout = False
|
|
@@ -394,7 +423,7 @@ __mainReturnCode = 0
|
|
|
394
423
|
__failedHosts = set()
|
|
395
424
|
__wildCharacters = ['*','?','x']
|
|
396
425
|
_no_env = DEFAULT_NO_ENV
|
|
397
|
-
|
|
426
|
+
_env_files = DEFAULT_ENV_FILES
|
|
398
427
|
__globalUnavailableHosts = dict()
|
|
399
428
|
__ipmiiInterfaceIPPrefix = DEFAULT_IPMI_INTERFACE_IP_PREFIX
|
|
400
429
|
__keyPressesIn = [[]]
|
|
@@ -408,6 +437,11 @@ __thread_start_delay = 0
|
|
|
408
437
|
_encoding = DEFAULT_ENCODING
|
|
409
438
|
__returnZero = DEFAULT_RETURN_ZERO
|
|
410
439
|
__running_threads = set()
|
|
440
|
+
__control_master_string = '''Host *
|
|
441
|
+
ControlMaster auto
|
|
442
|
+
ControlPath /run/user/%i/ssh_sockets_%C
|
|
443
|
+
ControlPersist 3600
|
|
444
|
+
'''
|
|
411
445
|
if __resource_lib_available:
|
|
412
446
|
# Get the current limits
|
|
413
447
|
_, __system_nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
@@ -455,12 +489,12 @@ def check_path(program_name):
|
|
|
455
489
|
|
|
456
490
|
[check_path(program) for program in _binCalled]
|
|
457
491
|
|
|
458
|
-
def find_ssh_key_file(searchPath =
|
|
492
|
+
def find_ssh_key_file(searchPath = DEFAULT_SSH_KEY_SEARCH_PATH):
|
|
459
493
|
'''
|
|
460
494
|
Find the ssh public key file
|
|
461
495
|
|
|
462
496
|
Args:
|
|
463
|
-
searchPath (str, optional): The path to search. Defaults to
|
|
497
|
+
searchPath (str, optional): The path to search. Defaults to DEFAULT_SSH_KEY_SEARCH_PATH.
|
|
464
498
|
|
|
465
499
|
Returns:
|
|
466
500
|
str: The path to the ssh key file
|
|
@@ -476,35 +510,65 @@ def find_ssh_key_file(searchPath = DEDAULT_SSH_KEY_SEARCH_PATH):
|
|
|
476
510
|
return None
|
|
477
511
|
|
|
478
512
|
@cache_decorator
|
|
479
|
-
def readEnvFromFile(
|
|
513
|
+
def readEnvFromFile():
|
|
480
514
|
'''
|
|
481
515
|
Read the environment variables from env_file
|
|
482
516
|
Returns:
|
|
483
517
|
dict: A dictionary of environment variables
|
|
484
518
|
'''
|
|
485
|
-
global
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
envf =
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
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:
|
|
500
534
|
continue
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
508
572
|
|
|
509
573
|
def replace_magic_strings(string,keys,value,case_sensitive=False):
|
|
510
574
|
'''
|
|
@@ -642,28 +706,25 @@ class OrderedMultiSet(deque):
|
|
|
642
706
|
self._counter = Counter()
|
|
643
707
|
if iterable is not None:
|
|
644
708
|
self.extend(iterable)
|
|
645
|
-
def __decrease_count(self, item):
|
|
646
|
-
"""Decrease count of item in counter."""
|
|
647
|
-
self._counter[item] -= 1
|
|
648
|
-
if self._counter[item] == 0:
|
|
649
|
-
del self._counter[item]
|
|
650
709
|
def append(self, item):
|
|
651
710
|
"""Add item to the right end. O(1)."""
|
|
652
711
|
if len(self) == self.maxlen:
|
|
653
|
-
self.
|
|
712
|
+
self._counter -= Counter([self[0]])
|
|
713
|
+
# self._counter[self[0]] -= 1
|
|
714
|
+
# self._counter += Counter()
|
|
654
715
|
super().append(item)
|
|
655
716
|
self._counter[item] += 1
|
|
656
717
|
def appendleft(self, item):
|
|
657
718
|
"""Add item to the left end. O(1)."""
|
|
658
719
|
if len(self) == self.maxlen:
|
|
659
|
-
self.
|
|
720
|
+
self._counter -= Counter([self[-1]])
|
|
660
721
|
super().appendleft(item)
|
|
661
722
|
self._counter[item] += 1
|
|
662
723
|
def pop(self):
|
|
663
724
|
"""Remove and return item from right end. O(1)."""
|
|
664
725
|
try:
|
|
665
726
|
item = super().pop()
|
|
666
|
-
self.
|
|
727
|
+
self._counter -= Counter([item])
|
|
667
728
|
return item
|
|
668
729
|
except IndexError:
|
|
669
730
|
return None
|
|
@@ -671,7 +732,7 @@ class OrderedMultiSet(deque):
|
|
|
671
732
|
"""Remove and return item from left end. O(1)."""
|
|
672
733
|
try:
|
|
673
734
|
item = super().popleft()
|
|
674
|
-
self.
|
|
735
|
+
self._counter -= Counter([item])
|
|
675
736
|
return item
|
|
676
737
|
except IndexError:
|
|
677
738
|
return None
|
|
@@ -680,7 +741,7 @@ class OrderedMultiSet(deque):
|
|
|
680
741
|
removed = None
|
|
681
742
|
if len(self) == self.maxlen:
|
|
682
743
|
removed = self[0] # Item that will be removed
|
|
683
|
-
self.
|
|
744
|
+
self._counter -= Counter([removed])
|
|
684
745
|
super().append(item)
|
|
685
746
|
self._counter[item] += 1
|
|
686
747
|
return removed
|
|
@@ -689,7 +750,7 @@ class OrderedMultiSet(deque):
|
|
|
689
750
|
removed = None
|
|
690
751
|
if len(self) == self.maxlen:
|
|
691
752
|
removed = self[-1] # Item that will be removed
|
|
692
|
-
self.
|
|
753
|
+
self._counter -= Counter([removed])
|
|
693
754
|
super().appendleft(item)
|
|
694
755
|
self._counter[item] += 1
|
|
695
756
|
return removed
|
|
@@ -701,7 +762,7 @@ class OrderedMultiSet(deque):
|
|
|
701
762
|
if value not in self._counter:
|
|
702
763
|
return None
|
|
703
764
|
super().remove(value)
|
|
704
|
-
self.
|
|
765
|
+
self._counter -= Counter([value])
|
|
705
766
|
def clear(self):
|
|
706
767
|
"""Remove all items. O(1)."""
|
|
707
768
|
super().clear()
|
|
@@ -722,24 +783,61 @@ class OrderedMultiSet(deque):
|
|
|
722
783
|
super().extend(iterable)
|
|
723
784
|
self._counter.update(iterable)
|
|
724
785
|
else:
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
for _ in range(num_to_remove):
|
|
728
|
-
self.__decrease_count(super().popleft())
|
|
786
|
+
num_to_keep = self.maxlen - len(iterable)
|
|
787
|
+
self.truncateright(num_to_keep)
|
|
729
788
|
super().extend(iterable)
|
|
730
789
|
self._counter.update(iterable)
|
|
731
790
|
except TypeError:
|
|
732
791
|
return self.extend(list(iterable))
|
|
733
792
|
def extendleft(self, iterable):
|
|
734
793
|
"""Extend left side by appending elements from iterable. O(k)."""
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
737
834
|
def rotate(self, n=1):
|
|
738
835
|
"""Rotate deque n steps to the right. O(k) where k = min(n, len)."""
|
|
739
836
|
super().rotate(n)
|
|
740
837
|
def __contains__(self, item):
|
|
741
838
|
"""Check if item exists in deque. O(1) average."""
|
|
742
|
-
return item in self._counter
|
|
839
|
+
# return item in self._counter
|
|
840
|
+
return super().__contains__(item)
|
|
743
841
|
def count(self, item):
|
|
744
842
|
"""Return number of occurrences of item. O(1)."""
|
|
745
843
|
return self._counter[item]
|
|
@@ -747,14 +845,14 @@ class OrderedMultiSet(deque):
|
|
|
747
845
|
"""Set item at index. O(1) for access, O(1) for counter update."""
|
|
748
846
|
old_value = self[index]
|
|
749
847
|
super().__setitem__(index, value)
|
|
750
|
-
self.
|
|
848
|
+
self._counter -= Counter([old_value])
|
|
751
849
|
self._counter[value] += 1
|
|
752
850
|
return old_value
|
|
753
851
|
def __delitem__(self, index):
|
|
754
852
|
"""Delete item at index. O(n) for deletion, O(1) for counter update."""
|
|
755
853
|
value = self[index]
|
|
756
854
|
super().__delitem__(index)
|
|
757
|
-
self.
|
|
855
|
+
self._counter -= Counter([value])
|
|
758
856
|
return value
|
|
759
857
|
def insert(self, index, value):
|
|
760
858
|
"""Insert value at index. O(n) for insertion, O(1) for counter update."""
|
|
@@ -788,6 +886,28 @@ class OrderedMultiSet(deque):
|
|
|
788
886
|
return self[-1]
|
|
789
887
|
except IndexError:
|
|
790
888
|
return None
|
|
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)
|
|
791
911
|
|
|
792
912
|
def get_terminal_size():
|
|
793
913
|
'''
|
|
@@ -833,6 +953,8 @@ def get_terminal_color_capability():
|
|
|
833
953
|
return '24bit'
|
|
834
954
|
elif "256" in term:
|
|
835
955
|
return '256'
|
|
956
|
+
elif "16" in term:
|
|
957
|
+
return '16'
|
|
836
958
|
try:
|
|
837
959
|
curses.setupterm()
|
|
838
960
|
colors = curses.tigetnum("colors")
|
|
@@ -851,96 +973,120 @@ def get_terminal_color_capability():
|
|
|
851
973
|
return 'None'
|
|
852
974
|
|
|
853
975
|
@cache_decorator
|
|
854
|
-
def
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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 ''
|
|
876
1000
|
|
|
877
|
-
|
|
878
|
-
|
|
1001
|
+
def _rgb_to_256_color(r, g, b):
|
|
1002
|
+
"""
|
|
1003
|
+
Map (r,g,b) to the 256-color cube or grayscale ramp.
|
|
879
1004
|
"""
|
|
880
|
-
|
|
881
|
-
r
|
|
882
|
-
|
|
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):
|
|
883
1016
|
"""
|
|
884
|
-
|
|
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
|
|
885
1039
|
best_dist = float('inf')
|
|
886
|
-
for i, (pr, pg, pb) in enumerate(
|
|
887
|
-
|
|
888
|
-
dg = pg - g
|
|
889
|
-
db = pb - b
|
|
890
|
-
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
|
|
891
1042
|
if dist < best_dist:
|
|
892
1043
|
best_dist = dist
|
|
893
|
-
|
|
894
|
-
return
|
|
1044
|
+
best_idx = i
|
|
1045
|
+
return best_idx
|
|
895
1046
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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):
|
|
899
1055
|
r = (hash_value >> 16) & 0xFF
|
|
900
1056
|
g = (hash_value >> 8) & 0xFF
|
|
901
1057
|
b = hash_value & 0xFF
|
|
902
|
-
|
|
903
|
-
|
|
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)
|
|
904
1063
|
return (r, g, b)
|
|
905
1064
|
|
|
906
|
-
|
|
1065
|
+
__previous_color_rgb = ()
|
|
907
1066
|
@cache_decorator
|
|
908
|
-
def
|
|
1067
|
+
def int_to_unique_ansi_color(number):
|
|
909
1068
|
'''
|
|
910
|
-
Convert a
|
|
1069
|
+
Convert a number to a unique ANSI color code
|
|
911
1070
|
|
|
912
1071
|
Args:
|
|
913
|
-
|
|
914
|
-
|
|
1072
|
+
number (int): The number to convert
|
|
915
1073
|
Returns:
|
|
916
1074
|
int: The ANSI color code
|
|
917
1075
|
'''
|
|
918
|
-
global
|
|
1076
|
+
global __previous_color_rgb
|
|
919
1077
|
# Use a hash function to generate a consistent integer from the string
|
|
920
1078
|
color_capability = get_terminal_color_capability()
|
|
921
|
-
index = None
|
|
922
1079
|
if color_capability == 'None':
|
|
923
1080
|
return ''
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
index = (hash(string) % 14) + 31
|
|
927
|
-
if index > 37:
|
|
928
|
-
index += 52 # Bright colors (90-97)
|
|
929
|
-
elif color_capability == '8':
|
|
930
|
-
index = (hash(string) % 6) + 31
|
|
931
|
-
r,g,b = hashable_to_color(string)
|
|
932
|
-
if color_capability == '256':
|
|
933
|
-
index = rgb_to_xterm_index(r,g,b)
|
|
934
|
-
if index:
|
|
935
|
-
if index == __previous_ansi_color_index:
|
|
936
|
-
return string_to_unique_ansi_color(hash(string))
|
|
937
|
-
__previous_ansi_color_index = index
|
|
938
|
-
if color_capability == '256':
|
|
939
|
-
return f'\033[38;5;{index}m'
|
|
940
|
-
else:
|
|
941
|
-
return f'\033[{index}m'
|
|
1081
|
+
if color_capability == '24bit':
|
|
1082
|
+
r, g, b = int_to_color(number)
|
|
942
1083
|
else:
|
|
943
|
-
|
|
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)
|
|
944
1090
|
|
|
945
1091
|
#%% ------------ Compacting Hostnames ----------------
|
|
946
1092
|
def __tokenize_hostname(hostname):
|
|
@@ -1290,14 +1436,14 @@ def compact_hostnames(Hostnames,verify = True):
|
|
|
1290
1436
|
# hostSet = frozenset(Hostnames)
|
|
1291
1437
|
# else:
|
|
1292
1438
|
# hostSet = Hostnames
|
|
1293
|
-
hostSet = frozenset(
|
|
1439
|
+
hostSet = frozenset(expand_hostnames(
|
|
1294
1440
|
hostname.strip()
|
|
1295
1441
|
for hostnames_str in Hostnames
|
|
1296
1442
|
for hostname in hostnames_str.split(',')
|
|
1297
|
-
)
|
|
1443
|
+
))
|
|
1298
1444
|
compact_hosts = __compact_hostnames(hostSet)
|
|
1299
1445
|
if verify:
|
|
1300
|
-
if
|
|
1446
|
+
if frozenset(expand_hostnames(compact_hosts)) != hostSet:
|
|
1301
1447
|
if not __global_suppress_printout:
|
|
1302
1448
|
eprint(f"Error compacting hostnames: {hostSet} -> {compact_hosts}")
|
|
1303
1449
|
compact_hosts = hostSet
|
|
@@ -1667,6 +1813,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
|
1667
1813
|
host.command = replace_magic_strings(host.command,['#I#'],str(host.i),case_sensitive=False)
|
|
1668
1814
|
host.command = replace_magic_strings(host.command,['#PASSWD#','#PASSWORD#'],passwds,case_sensitive=False)
|
|
1669
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)
|
|
1670
1817
|
formatedCMD = []
|
|
1671
1818
|
if host.extraargs and isinstance(host.extraargs, str):
|
|
1672
1819
|
extraargs = host.extraargs.split()
|
|
@@ -1865,8 +2012,6 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
|
1865
2012
|
stdout_thread.join(timeout=1)
|
|
1866
2013
|
stderr_thread.join(timeout=1)
|
|
1867
2014
|
stdin_thread.join(timeout=1)
|
|
1868
|
-
# here we handle the rest of the stdout after the subprocess returns
|
|
1869
|
-
host.output.append('Pipe Closed. Trying to read the rest of the stdout...')
|
|
1870
2015
|
if not _emo:
|
|
1871
2016
|
stdout = None
|
|
1872
2017
|
stderr = None
|
|
@@ -1875,8 +2020,10 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 5):
|
|
|
1875
2020
|
except subprocess.TimeoutExpired:
|
|
1876
2021
|
pass
|
|
1877
2022
|
if stdout:
|
|
2023
|
+
host.output.append('Trying to read the rest of the stdout...')
|
|
1878
2024
|
__handle_reading_stream(io.BytesIO(stdout),host.stdout, host,host.stdout_buffer)
|
|
1879
2025
|
if stderr:
|
|
2026
|
+
host.output.append('Trying to read the rest of the stderr...')
|
|
1880
2027
|
__handle_reading_stream(io.BytesIO(stderr),host.stderr, host,host.stderr_buffer)
|
|
1881
2028
|
# if the last line in host.stderr is Connection to * closed., we will remove it
|
|
1882
2029
|
host.returncode = proc.poll()
|
|
@@ -2472,7 +2619,7 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
2472
2619
|
elif key == curses.KEY_EXIT or key == 27: # 27 is the key code for ESC
|
|
2473
2620
|
# if the key is exit, we will exit the program
|
|
2474
2621
|
return
|
|
2475
|
-
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
|
|
2476
2623
|
# if the key is help, we will display the help message
|
|
2477
2624
|
if not help_shown:
|
|
2478
2625
|
help_panel.show()
|
|
@@ -2688,35 +2835,58 @@ def can_merge(line_bag1, line_bag2, threshold):
|
|
|
2688
2835
|
return len(line_bag1.intersection(line_bag2)) >= min(len(line_bag1),len(line_bag2)) * threshold
|
|
2689
2836
|
|
|
2690
2837
|
def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_threshold,line_length):
|
|
2691
|
-
indexes = {hostname: 0 for hostname in merging_hostnames}
|
|
2692
|
-
|
|
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)
|
|
2693
2841
|
previousBuddies = set()
|
|
2694
|
-
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='- ')
|
|
2695
2843
|
hostnameWrapper.wordsep_simple_re = re.compile(r'([,]+)')
|
|
2696
2844
|
diff_display_item_count = max(1,int(max(map(len, outputs_by_hostname.values())) * (1 - diff_display_threshold)))
|
|
2697
2845
|
def get_multiset_index_for_hostname(hostname):
|
|
2698
2846
|
index = indexes[hostname]
|
|
2699
2847
|
tracking_index = min(index + diff_display_item_count,len(outputs_by_hostname[hostname]))
|
|
2700
|
-
|
|
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]
|
|
2701
2850
|
# futuresChainMap = ChainMap()
|
|
2702
|
-
class futureDict(UserDict):
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
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}
|
|
2713
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'
|
|
2714
2884
|
for hostname in merging_hostnames:
|
|
2715
2885
|
currentLines[outputs_by_hostname[hostname][0]].add(hostname)
|
|
2716
2886
|
while indexes:
|
|
2717
2887
|
defer = False
|
|
2718
2888
|
# sorted_working_hostnames = sorted(working_index_keys, key=lambda hn: indexes[hn])
|
|
2719
|
-
golden_hostname = min(working_index_keys, key=
|
|
2889
|
+
golden_hostname = min(working_index_keys, key=indexes.get)
|
|
2720
2890
|
golden_index = indexes[golden_hostname]
|
|
2721
2891
|
lineToAdd = outputs_by_hostname[golden_hostname][golden_index]
|
|
2722
2892
|
# for hostname, index in sorted_working_indexes[1:]:
|
|
@@ -2736,8 +2906,8 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
|
|
|
2736
2906
|
# if golden_hostname in futures:
|
|
2737
2907
|
# thisCounter = futures[golden_hostname][0]._counter
|
|
2738
2908
|
# futuresChainMap.maps.remove(thisCounter)
|
|
2739
|
-
for hostname in working_index_keys - buddy - set(futures.keys()):
|
|
2740
|
-
|
|
2909
|
+
# for hostname in working_index_keys - buddy - set(futures.keys()):
|
|
2910
|
+
# futures[hostname] # ensure it's initialized
|
|
2741
2911
|
# futures.initializeHostnames(working_index_keys - buddy - futures.keys())
|
|
2742
2912
|
if any(lineToAdd in futures[hostname][0] for hostname in working_index_keys - buddy):
|
|
2743
2913
|
defer = True
|
|
@@ -2748,20 +2918,27 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
|
|
|
2748
2918
|
if buddy != previousBuddies:
|
|
2749
2919
|
hostnameStr = ','.join(compact_hostnames(buddy))
|
|
2750
2920
|
hostnameLines = hostnameWrapper.wrap(hostnameStr)
|
|
2751
|
-
hostnameLines = [line.ljust(line_length
|
|
2752
|
-
|
|
2753
|
-
|
|
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]}"
|
|
2754
2930
|
output.extend(hostnameLines)
|
|
2755
2931
|
previousBuddies = buddy
|
|
2756
|
-
output.append(lineToAdd
|
|
2932
|
+
output.append(lineToAdd)
|
|
2757
2933
|
currentLines[lineToAdd].difference_update(buddy)
|
|
2758
2934
|
if not currentLines[lineToAdd]:
|
|
2759
2935
|
del currentLines[lineToAdd]
|
|
2936
|
+
indexes.update(buddy)
|
|
2760
2937
|
for hostname in buddy:
|
|
2761
2938
|
# currentLines[lineToAdd].remove(hostname)
|
|
2762
2939
|
# if not currentLines[lineToAdd]:
|
|
2763
2940
|
# del currentLines[lineToAdd]
|
|
2764
|
-
indexes[hostname] += 1
|
|
2941
|
+
# indexes[hostname] += 1
|
|
2765
2942
|
try:
|
|
2766
2943
|
currentLines[outputs_by_hostname[hostname][indexes[hostname]]].add(hostname)
|
|
2767
2944
|
except IndexError:
|
|
@@ -2771,29 +2948,32 @@ def mergeOutput(merging_hostnames,outputs_by_hostname,output,diff_display_thresh
|
|
|
2771
2948
|
# futuresChainMap.maps.remove(future[0]._counter)
|
|
2772
2949
|
continue
|
|
2773
2950
|
#advance futures
|
|
2774
|
-
|
|
2775
|
-
futures[hostname][1] += 1
|
|
2776
|
-
tracking_multiset, tracking_index = futures[hostname]
|
|
2777
|
-
if tracking_index < len(outputs_by_hostname[hostname]):
|
|
2778
|
-
line = outputs_by_hostname[hostname][tracking_index]
|
|
2779
|
-
tracking_multiset.append(line)
|
|
2780
|
-
else:
|
|
2781
|
-
tracking_multiset.popleft()
|
|
2782
|
-
#futures[hostname] = (tracking_multiset, tracking_index)
|
|
2951
|
+
advance(futures, hostname)
|
|
2783
2952
|
working_index_keys = set(indexes.keys())
|
|
2784
2953
|
|
|
2785
2954
|
def mergeOutputs(outputs_by_hostname, merge_groups, remaining_hostnames, diff_display_threshold, line_length):
|
|
2786
2955
|
output = []
|
|
2787
|
-
|
|
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'([,]+)')
|
|
2788
2968
|
for merging_hostnames in merge_groups:
|
|
2789
2969
|
mergeOutput(merging_hostnames, outputs_by_hostname, output, diff_display_threshold,line_length)
|
|
2790
|
-
output.append(
|
|
2970
|
+
output.append(color_line+'─'*(line_length)+color_reset)
|
|
2791
2971
|
for hostname in remaining_hostnames:
|
|
2792
|
-
hostnameLines =
|
|
2793
|
-
|
|
2794
|
-
output.extend(
|
|
2795
|
-
output.extend(
|
|
2796
|
-
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)
|
|
2797
2977
|
if output:
|
|
2798
2978
|
output.pop()
|
|
2799
2979
|
# if output and output[0] and output[0].startswith('├'):
|
|
@@ -2810,7 +2990,7 @@ def pre_merge_hosts(hosts):
|
|
|
2810
2990
|
# Create merged hosts
|
|
2811
2991
|
merged_hosts = []
|
|
2812
2992
|
for group in output_groups.values():
|
|
2813
|
-
group[0].name = ','.join(host.name for host in group)
|
|
2993
|
+
group[0].name = ','.join(compact_hostnames(host.name for host in group))
|
|
2814
2994
|
merged_hosts.append(group[0])
|
|
2815
2995
|
return merged_hosts
|
|
2816
2996
|
|
|
@@ -2818,24 +2998,40 @@ def get_host_raw_output(hosts, terminal_width):
|
|
|
2818
2998
|
outputs_by_hostname = {}
|
|
2819
2999
|
line_bag_by_hostname = {}
|
|
2820
3000
|
hostnames_by_line_bag_len = {}
|
|
2821
|
-
text_wrapper = textwrap.TextWrapper(width=terminal_width -
|
|
2822
|
-
initial_indent='
|
|
3001
|
+
text_wrapper = textwrap.TextWrapper(width=terminal_width - 1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
3002
|
+
initial_indent=' ', subsequent_indent='-')
|
|
2823
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']))
|
|
2824
3017
|
hosts = pre_merge_hosts(hosts)
|
|
2825
3018
|
for host in hosts:
|
|
2826
|
-
|
|
3019
|
+
max_length = max(max_length, len(max(host.name.split(','), key=len)) + 3)
|
|
3020
|
+
hostPrintOut = [f"{cyan_str}█{color_reset_str} EXECUTED COMMAND:"]
|
|
2827
3021
|
for line in host.command.splitlines():
|
|
2828
3022
|
hostPrintOut.extend(text_wrapper.wrap(line))
|
|
2829
3023
|
# hostPrintOut.extend(itertools.chain.from_iterable(text_wrapper.wrap(line) for line in host['command'].splitlines()))
|
|
2830
3024
|
lineBag = {(0,host.command)}
|
|
2831
3025
|
prevLine = host.command
|
|
2832
3026
|
if host.stdout:
|
|
2833
|
-
hostPrintOut.append('
|
|
2834
|
-
for line in host.stdout:
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
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)))
|
|
2839
3035
|
# hostPrintOut.extend(text_wrapper.wrap(line) for line in host.stdout)
|
|
2840
3036
|
lineBag.add((prevLine,1))
|
|
2841
3037
|
lineBag.add((1,host.stdout[0]))
|
|
@@ -2851,21 +3047,26 @@ def get_host_raw_output(hosts, terminal_width):
|
|
|
2851
3047
|
elif host.stderr[-1].strip().endswith('No route to host'):
|
|
2852
3048
|
host.stderr[-1] = 'Cannot find host!'
|
|
2853
3049
|
if host.stderr:
|
|
2854
|
-
hostPrintOut.append('
|
|
2855
|
-
for line in host.stderr:
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
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)))
|
|
2860
3058
|
lineBag.add((prevLine,2))
|
|
2861
3059
|
lineBag.add((2,host.stderr[0]))
|
|
2862
3060
|
lineBag.update(host.stderr)
|
|
2863
3061
|
if len(host.stderr) > 1:
|
|
2864
3062
|
lineBag.update(zip(host.stderr, host.stderr[1:]))
|
|
2865
3063
|
prevLine = host.stderr[-1]
|
|
2866
|
-
|
|
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}")
|
|
2867
3069
|
lineBag.add((prevLine,f"{host.returncode}"))
|
|
2868
|
-
max_length = max(max_length, max(map(len, hostPrintOut)))
|
|
2869
3070
|
outputs_by_hostname[host.name] = hostPrintOut
|
|
2870
3071
|
line_bag_by_hostname[host.name] = lineBag
|
|
2871
3072
|
hostnames_by_line_bag_len.setdefault(len(lineBag), set()).add(host.name)
|
|
@@ -2906,6 +3107,7 @@ def form_merge_groups(hostnames_by_line_bag_len, sorted_hostnames_by_line_bag_le
|
|
|
2906
3107
|
return merge_groups, remaining_hostnames
|
|
2907
3108
|
|
|
2908
3109
|
def generate_output(hosts, usejson = False, greppable = False,quiet = False,encoding = _encoding,keyPressesIn = [[]]):
|
|
3110
|
+
color_cap = get_terminal_color_capability()
|
|
2909
3111
|
if quiet:
|
|
2910
3112
|
# remove hosts with returncode 0
|
|
2911
3113
|
hosts = [host for host in hosts if host.returncode != 0]
|
|
@@ -2913,7 +3115,10 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2913
3115
|
if usejson:
|
|
2914
3116
|
return '{"Success": true}'
|
|
2915
3117
|
else:
|
|
2916
|
-
|
|
3118
|
+
if color_cap == 'None':
|
|
3119
|
+
return 'Success'
|
|
3120
|
+
else:
|
|
3121
|
+
return '\033[32mSuccess\033[0m'
|
|
2917
3122
|
if usejson:
|
|
2918
3123
|
# [print(dict(host)) for host in hosts]
|
|
2919
3124
|
#print(json.dumps([dict(host) for host in hosts],indent=4))
|
|
@@ -2948,23 +3153,32 @@ def generate_output(hosts, usejson = False, greppable = False,quiet = False,enco
|
|
|
2948
3153
|
except Exception:
|
|
2949
3154
|
eprint("Warning: diff_display_threshold should be a float between 0 and 1. Setting to default value of 0.9")
|
|
2950
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']))
|
|
2951
3159
|
terminal_length = get_terminal_size()[0]
|
|
2952
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)
|
|
2953
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)
|
|
2954
3162
|
outputs = mergeOutputs(outputs_by_hostname, merge_groups,remaining_hostnames, diff_display_threshold,line_length)
|
|
2955
3163
|
if keyPressesIn[-1]:
|
|
2956
3164
|
CMDsOut = [''.join(cmd).encode(encoding=encoding,errors='backslashreplace').decode(encoding=encoding,errors='backslashreplace').replace('\\n', '↵') for cmd in keyPressesIn if cmd]
|
|
2957
|
-
outputs.append("
|
|
3165
|
+
outputs.append(color_reset_str + "░ User Inputs:".ljust(line_length,'─'))
|
|
2958
3166
|
cmdOut = []
|
|
2959
3167
|
for line in CMDsOut:
|
|
2960
3168
|
cmdOut.extend(textwrap.wrap(line, width=line_length-1, tabsize=4, replace_whitespace=False, drop_whitespace=False,
|
|
2961
|
-
initial_indent='
|
|
2962
|
-
outputs.extend(
|
|
3169
|
+
initial_indent=' ', subsequent_indent='-'))
|
|
3170
|
+
outputs.extend(cmdOut)
|
|
2963
3171
|
keyPressesIn[-1].clear()
|
|
2964
3172
|
if not outputs:
|
|
2965
|
-
|
|
3173
|
+
if quiet:
|
|
3174
|
+
if color_cap == 'None':
|
|
3175
|
+
return 'Success'
|
|
3176
|
+
else:
|
|
3177
|
+
return '\033[32mSuccess\033[0m'
|
|
3178
|
+
else:
|
|
3179
|
+
rtnStr = ''
|
|
2966
3180
|
else:
|
|
2967
|
-
rtnStr = '\n'.join(outputs + [
|
|
3181
|
+
rtnStr = '\n'.join(outputs + [white_str + '─' * (line_length) + color_reset_str])
|
|
2968
3182
|
return rtnStr
|
|
2969
3183
|
|
|
2970
3184
|
def print_output(hosts,usejson = False,quiet = False,greppable = False):
|
|
@@ -3010,7 +3224,16 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
|
|
|
3010
3224
|
if total_sleeped > 0.1:
|
|
3011
3225
|
break
|
|
3012
3226
|
if any([host.returncode is None for host in hosts]):
|
|
3013
|
-
|
|
3227
|
+
try:
|
|
3228
|
+
curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
|
|
3229
|
+
except Exception:
|
|
3230
|
+
try:
|
|
3231
|
+
os.environ['TERM'] = 'xterm-256color'
|
|
3232
|
+
curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
|
|
3233
|
+
except Exception as e:
|
|
3234
|
+
eprint(f"Curses print error: {e}")
|
|
3235
|
+
import traceback
|
|
3236
|
+
print(traceback.format_exc())
|
|
3014
3237
|
if not returnUnfinished:
|
|
3015
3238
|
# wait until all hosts have a return code
|
|
3016
3239
|
while any([host.returncode is None for host in hosts]):
|
|
@@ -3100,7 +3323,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
|
|
|
3100
3323
|
no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
|
|
3101
3324
|
file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
|
|
3102
3325
|
copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY, no_history = DEFAULT_NO_HISTORY,
|
|
3103
|
-
history_file = DEFAULT_HISTORY_FILE, env_file =
|
|
3326
|
+
history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
|
|
3104
3327
|
repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
|
|
3105
3328
|
shortend = False) -> str:
|
|
3106
3329
|
argsList = []
|
|
@@ -3144,8 +3367,10 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
|
|
|
3144
3367
|
argsList.append(f'--unavailable_host_expiry={unavailable_host_expiry}' if not shortend else f'-uhe={unavailable_host_expiry}')
|
|
3145
3368
|
if no_env:
|
|
3146
3369
|
argsList.append('--no_env')
|
|
3147
|
-
if env_file
|
|
3370
|
+
if env_file:
|
|
3148
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])
|
|
3149
3374
|
if no_history:
|
|
3150
3375
|
argsList.append('--no_history' if not shortend else '-nh')
|
|
3151
3376
|
if history_file and history_file != DEFAULT_HISTORY_FILE:
|
|
@@ -3168,7 +3393,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
|
|
|
3168
3393
|
skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
|
|
3169
3394
|
single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
|
|
3170
3395
|
copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = DEFAULT_NO_HISTORY,
|
|
3171
|
-
history_file = DEFAULT_HISTORY_FILE, env_file =
|
|
3396
|
+
history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
|
|
3172
3397
|
repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
|
|
3173
3398
|
shortend = False,tabSeperated = False):
|
|
3174
3399
|
_ = called
|
|
@@ -3188,7 +3413,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
|
|
|
3188
3413
|
no_env=no_env, greppable=greppable,skip_hosts = skip_hosts,
|
|
3189
3414
|
file_sync = file_sync,error_only = error_only, identity_file = identity_file,
|
|
3190
3415
|
copy_id = copy_id, unavailable_host_expiry =unavailable_host_expiry,no_history = no_history,
|
|
3191
|
-
history_file = history_file, env_file = env_file,
|
|
3416
|
+
history_file = history_file, env_file = env_file, env_files = env_files,
|
|
3192
3417
|
repeat = repeat,interval = interval,
|
|
3193
3418
|
shortend = shortend)
|
|
3194
3419
|
commands = [command.replace('"', '\\"').replace('\n', '\\n').replace('\t', '\\t') for command in format_commands(commands)]
|
|
@@ -3247,48 +3472,48 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
|
|
|
3247
3472
|
copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = True,
|
|
3248
3473
|
history_file = DEFAULT_HISTORY_FILE,
|
|
3249
3474
|
):
|
|
3250
|
-
|
|
3251
|
-
Run
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
hosts (str
|
|
3255
|
-
commands (list):
|
|
3256
|
-
oneonone (bool
|
|
3257
|
-
timeout (int
|
|
3258
|
-
password (str
|
|
3259
|
-
no_watch (bool
|
|
3260
|
-
json (bool
|
|
3261
|
-
called (bool
|
|
3262
|
-
max_connections (int
|
|
3263
|
-
files (list
|
|
3264
|
-
ipmi (bool
|
|
3265
|
-
interface_ip_prefix (str
|
|
3266
|
-
returnUnfinished (bool
|
|
3267
|
-
scp (bool
|
|
3268
|
-
gather_mode (bool
|
|
3269
|
-
username (str
|
|
3270
|
-
extraargs (str
|
|
3271
|
-
skipUnreachable (bool
|
|
3272
|
-
no_env (bool
|
|
3273
|
-
greppable (bool
|
|
3274
|
-
willUpdateUnreachableHosts (bool
|
|
3275
|
-
no_start (bool
|
|
3276
|
-
skip_hosts (str
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
single_window (bool
|
|
3280
|
-
file_sync (bool
|
|
3281
|
-
error_only (bool
|
|
3282
|
-
quiet (bool
|
|
3283
|
-
identity_file (str
|
|
3284
|
-
copy_id (bool
|
|
3285
|
-
unavailable_host_expiry (int
|
|
3286
|
-
no_history (bool
|
|
3287
|
-
history_file (str
|
|
3475
|
+
"""
|
|
3476
|
+
Run commands on multiple hosts via SSH or IPMI.
|
|
3477
|
+
|
|
3478
|
+
Parameters:
|
|
3479
|
+
hosts (str or iterable): Hosts to run the command on. Can be a string (comma/space-separated) or iterable. Default: DEFAULT_HOSTS.
|
|
3480
|
+
commands (list or None): List of commands to run on the hosts. If files are used, defines the destination. Default: None.
|
|
3481
|
+
oneonone (bool): If True, run each command on the corresponding host (1:1 mapping). Default: DEFAULT_ONE_ON_ONE.
|
|
3482
|
+
timeout (int): Timeout for each command in seconds. Default: DEFAULT_TIMEOUT.
|
|
3483
|
+
password (str): Password for SSH/IPMI authentication. Default: DEFAULT_PASSWORD.
|
|
3484
|
+
no_watch (bool): If True, do not use curses TUI; just print output. Default: DEFAULT_NO_WATCH.
|
|
3485
|
+
json (bool): If True, output results in JSON format. Default: DEFAULT_JSON_MODE.
|
|
3486
|
+
called (bool): If True, function is called programmatically (not CLI). Default: _DEFAULT_CALLED.
|
|
3487
|
+
max_connections (int): Maximum concurrent SSH sessions. Default: 4 * os.cpu_count().
|
|
3488
|
+
files (list or None): Files to copy to hosts. Default: None.
|
|
3489
|
+
ipmi (bool): Use IPMI instead of SSH. Default: DEFAULT_IPMI.
|
|
3490
|
+
interface_ip_prefix (str or None): Override IP prefix for host connection. Default: DEFAULT_INTERFACE_IP_PREFIX.
|
|
3491
|
+
returnUnfinished (bool): If True, return hosts even if not finished. Default: _DEFAULT_RETURN_UNFINISHED.
|
|
3492
|
+
scp (bool): Use scp for file transfer (instead of rsync). Default: DEFAULT_SCP.
|
|
3493
|
+
gather_mode (bool): Gather files from hosts (pull mode). Default: False.
|
|
3494
|
+
username (str or None): Username for SSH/IPMI. Default: DEFAULT_USERNAME.
|
|
3495
|
+
extraargs (str or list or None): Extra args for SSH/SCP/rsync. Default: DEFAULT_EXTRA_ARGS.
|
|
3496
|
+
skipUnreachable (bool): Skip hosts marked as unreachable. Default: DEFAULT_SKIP_UNREACHABLE.
|
|
3497
|
+
no_env (bool): Do not load environment variables from shell. Default: DEFAULT_NO_ENV.
|
|
3498
|
+
greppable (bool): Output in greppable table format. Default: DEFAULT_GREPPABLE_MODE.
|
|
3499
|
+
willUpdateUnreachableHosts (bool): Update global unreachable hosts file. Default: _DEFAULT_UPDATE_UNREACHABLE_HOSTS.
|
|
3500
|
+
no_start (bool): If True, return Host objects without running commands. Default: _DEFAULT_NO_START.
|
|
3501
|
+
skip_hosts (str or None): Hosts to skip. Default: DEFAULT_SKIP_HOSTS.
|
|
3502
|
+
curses_min_char_len (int): Minimum width per curses window. Default: DEFAULT_CURSES_MINIMUM_CHAR_LEN.
|
|
3503
|
+
curses_min_line_len (int): Minimum height per curses window. Default: DEFAULT_CURSES_MINIMUM_LINE_LEN.
|
|
3504
|
+
single_window (bool): Use a single curses window for all hosts. Default: DEFAULT_SINGLE_WINDOW.
|
|
3505
|
+
file_sync (bool): Enable file sync mode (sync directories). Default: DEFAULT_FILE_SYNC.
|
|
3506
|
+
error_only (bool): Only print error output. Default: DEFAULT_ERROR_ONLY.
|
|
3507
|
+
quiet (bool): Suppress all output (overrides other output options). Default: False.
|
|
3508
|
+
identity_file (str or None): SSH identity file. Default: DEFAULT_IDENTITY_FILE.
|
|
3509
|
+
copy_id (bool): Use ssh-copy-id to copy public key to hosts. Default: False.
|
|
3510
|
+
unavailable_host_expiry (int): Seconds to keep hosts marked as unavailable. Default: DEFAULT_UNAVAILABLE_HOST_EXPIRY.
|
|
3511
|
+
no_history (bool): Do not record command history. Default: True.
|
|
3512
|
+
history_file (str): File to store command history. Default: DEFAULT_HISTORY_FILE.
|
|
3288
3513
|
|
|
3289
3514
|
Returns:
|
|
3290
|
-
list:
|
|
3291
|
-
|
|
3515
|
+
list: List of Host objects representing each host/command run.
|
|
3516
|
+
"""
|
|
3292
3517
|
global __globalUnavailableHosts
|
|
3293
3518
|
global __global_suppress_printout
|
|
3294
3519
|
global _no_env
|
|
@@ -3556,7 +3781,7 @@ def generate_default_config(args):
|
|
|
3556
3781
|
'DEFAULT_USERNAME': args.username,
|
|
3557
3782
|
'DEFAULT_PASSWORD': args.password,
|
|
3558
3783
|
'DEFAULT_IDENTITY_FILE': args.key if args.key and not os.path.isdir(args.key) else DEFAULT_IDENTITY_FILE,
|
|
3559
|
-
'
|
|
3784
|
+
'DEFAULT_SSH_KEY_SEARCH_PATH': args.key if args.key and os.path.isdir(args.key) else DEFAULT_SSH_KEY_SEARCH_PATH,
|
|
3560
3785
|
'DEFAULT_USE_KEY': args.use_key,
|
|
3561
3786
|
'DEFAULT_EXTRA_ARGS': args.extraargs,
|
|
3562
3787
|
'DEFAULT_ONE_ON_ONE': args.oneonone,
|
|
@@ -3580,7 +3805,7 @@ def generate_default_config(args):
|
|
|
3580
3805
|
'DEFAULT_NO_OUTPUT': args.no_output,
|
|
3581
3806
|
'DEFAULT_RETURN_ZERO': args.return_zero,
|
|
3582
3807
|
'DEFAULT_NO_ENV': args.no_env,
|
|
3583
|
-
'
|
|
3808
|
+
'DEFAULT_ENV_FILES': args.env_files,
|
|
3584
3809
|
'DEFAULT_NO_HISTORY': args.no_history,
|
|
3585
3810
|
'DEFAULT_HISTORY_FILE': args.history_file,
|
|
3586
3811
|
'DEFAULT_MAX_CONNECTIONS': args.max_connections if args.max_connections != 4 * os.cpu_count() else None,
|
|
@@ -3643,12 +3868,12 @@ def get_parser():
|
|
|
3643
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.')
|
|
3644
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)
|
|
3645
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)
|
|
3646
|
-
parser.add_argument('-k','--key','--identity',nargs='?', type=str,help=f'The identity file to use to connect to the hosts. Implies --use_key. Specify a folder for program to search for a key. Use option without value to use {
|
|
3871
|
+
parser.add_argument('-k','--key','--identity',nargs='?', type=str,help=f'The identity file to use to connect to the hosts. Implies --use_key. Specify a folder for program to search for a key. Use option without value to use {DEFAULT_SSH_KEY_SEARCH_PATH} (default: {DEFAULT_IDENTITY_FILE})',const=DEFAULT_SSH_KEY_SEARCH_PATH,default=DEFAULT_IDENTITY_FILE)
|
|
3647
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)
|
|
3648
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)
|
|
3649
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)
|
|
3650
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")
|
|
3651
|
-
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])
|
|
3652
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)
|
|
3653
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)
|
|
3654
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")
|
|
@@ -3669,7 +3894,8 @@ def get_parser():
|
|
|
3669
3894
|
parser.add_argument('-Q',"-no","--no_output", action='store_true', help=f"Do not print the output. (default: {DEFAULT_NO_OUTPUT})", default=DEFAULT_NO_OUTPUT)
|
|
3670
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)
|
|
3671
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)
|
|
3672
|
-
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})")
|
|
3673
3899
|
parser.add_argument("-m","--max_connections", type=int, help="Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
|
|
3674
3900
|
parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
|
|
3675
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)
|
|
@@ -3688,8 +3914,9 @@ def get_parser():
|
|
|
3688
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)
|
|
3689
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')
|
|
3690
3916
|
parser.add_argument('-e','--encoding', type=str, help=f'The encoding to use for the output. (default: {DEFAULT_ENCODING})', default=DEFAULT_ENCODING)
|
|
3691
|
-
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)
|
|
3692
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.')
|
|
3693
3920
|
parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} @ {COMMIT_DATE} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
|
|
3694
3921
|
return parser
|
|
3695
3922
|
|
|
@@ -3719,7 +3946,18 @@ def process_args(args = None):
|
|
|
3719
3946
|
args.no_history = True
|
|
3720
3947
|
args.greppable = True
|
|
3721
3948
|
args.error_only = True
|
|
3722
|
-
|
|
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
|
|
3723
3961
|
if args.unavailable_host_expiry <= 0:
|
|
3724
3962
|
eprint(f"Warning: The unavailable host expiry time {args.unavailable_host_expiry} is less than 0, setting it to 10 seconds.")
|
|
3725
3963
|
args.unavailable_host_expiry = 10
|
|
@@ -3738,7 +3976,7 @@ def process_config_file(args):
|
|
|
3738
3976
|
else:
|
|
3739
3977
|
configFileToWriteTo = args.config_file
|
|
3740
3978
|
write_default_config(args,configFileToWriteTo)
|
|
3741
|
-
if not args.commands:
|
|
3979
|
+
if not args.commands and not args.file:
|
|
3742
3980
|
if configFileToWriteTo:
|
|
3743
3981
|
with open(configFileToWriteTo,'r') as f:
|
|
3744
3982
|
eprint(f"Config file content: \n{f.read()}")
|
|
@@ -3780,11 +4018,40 @@ def process_keys(args):
|
|
|
3780
4018
|
eprint(f"Warning: Identity file {args.key!r} not found. Passing to ssh anyway. Proceed with caution.")
|
|
3781
4019
|
return args
|
|
3782
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
|
|
3783
4050
|
|
|
3784
4051
|
def set_global_with_args(args):
|
|
3785
4052
|
global _emo
|
|
3786
4053
|
global __ipmiiInterfaceIPPrefix
|
|
3787
|
-
global
|
|
4054
|
+
global _env_files
|
|
3788
4055
|
global __DEBUG_MODE
|
|
3789
4056
|
global __configs_from_file
|
|
3790
4057
|
global _encoding
|
|
@@ -3795,7 +4062,10 @@ def set_global_with_args(args):
|
|
|
3795
4062
|
global FORCE_TRUECOLOR
|
|
3796
4063
|
_emo = False
|
|
3797
4064
|
__ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
|
|
3798
|
-
|
|
4065
|
+
if args.env_file:
|
|
4066
|
+
_env_files = [args.env_file]
|
|
4067
|
+
else:
|
|
4068
|
+
_env_files = DEFAULT_ENV_FILES.extend(args.env_files) if args.env_files else DEFAULT_ENV_FILES
|
|
3799
4069
|
__DEBUG_MODE = args.debug
|
|
3800
4070
|
_encoding = args.encoding
|
|
3801
4071
|
if args.return_zero:
|
|
@@ -3816,6 +4086,7 @@ def main():
|
|
|
3816
4086
|
args = process_config_file(args)
|
|
3817
4087
|
args = process_commands(args)
|
|
3818
4088
|
args = process_keys(args)
|
|
4089
|
+
args = process_control_master_config(args)
|
|
3819
4090
|
set_global_with_args(args)
|
|
3820
4091
|
|
|
3821
4092
|
if args.use_script_timeout:
|
|
@@ -3834,7 +4105,7 @@ def main():
|
|
|
3834
4105
|
curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only,identity_file=args.key,
|
|
3835
4106
|
copy_id=args.copy_id,unavailable_host_expiry=args.unavailable_host_expiry,no_history=args.no_history,
|
|
3836
4107
|
history_file = args.history_file,
|
|
3837
|
-
env_file = args.env_file,
|
|
4108
|
+
env_file = args.env_file,env_files = args.env_files,
|
|
3838
4109
|
repeat = args.repeat,interval = args.interval)
|
|
3839
4110
|
eprint('> ' + cmdStr)
|
|
3840
4111
|
if args.error_only:
|