multiSSH3 6.18__tar.gz → 6.20__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 6.18
3
+ Version: 6.20
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multiSSH3
3
- Version: 6.18
3
+ Version: 6.20
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -30,15 +30,15 @@ import threading
30
30
  import time
31
31
  import typing
32
32
  import uuid
33
- from collections import Counter, defaultdict, deque
33
+ from collections import Counter, defaultdict, deque, ChainMap
34
34
  from itertools import count, product
35
35
  from pprint import pformat
36
36
 
37
37
 
38
- version = '6.18'
38
+ version = '6.20'
39
39
  VERSION = version
40
40
  __version__ = version
41
- COMMIT_DATE = '2026-04-09'
41
+ COMMIT_DATE = '2026-04-16'
42
42
  # TODO: Add terminal TUI
43
43
 
44
44
  CONFIG_FILE_CHAIN = ['./multiSSH3.config.json',
@@ -70,10 +70,22 @@ DEFAULT_REPEAT = 1
70
70
  DEFAULT_INTERVAL = 0
71
71
  DEFAULT_IPMI = False
72
72
  DEFAULT_IPMI_INTERFACE_IP_PREFIX = ''
73
+ DEFAULT_IPMI_USERNAME = 'admin'
74
+ DEFAULT_IPMI_PASSWORD = 'admin'
75
+ DEFAULT_IPMI_ARGS=''
76
+ DEFAULT_IPMI_METHOD = 'ipmitool'
77
+ DEFAULT_IPMI_RETRY_LOCKOUT_TIME = -1
78
+ def get_default_ipmi_definition():
79
+ return {'interface_ip_prefix': DEFAULT_IPMI_INTERFACE_IP_PREFIX,
80
+ 'username': DEFAULT_IPMI_USERNAME,
81
+ 'password': DEFAULT_IPMI_PASSWORD,
82
+ 'args': DEFAULT_IPMI_ARGS,
83
+ 'ipmi_method': DEFAULT_IPMI_METHOD,
84
+ 'hostname_keyword': '',
85
+ 'retry_lockout_time': DEFAULT_IPMI_RETRY_LOCKOUT_TIME,
86
+ }
87
+ DEFAULT_IPMI_DEFINITIONS = [get_default_ipmi_definition()]
73
88
  DEFAULT_INTERFACE_IP_PREFIX = None
74
- DEFAULT_IPMI_USERNAME = 'ADMIN'
75
- DEFAULT_IPMI_PASSWORD = ''
76
- DEAFULT_IPMI_ARGS=''
77
89
  DEFAULT_NO_WATCH = False
78
90
  DEFAULT_WINDOW_WIDTH = 40
79
91
  DEFAULT_WINDOW_HEIGHT = 1
@@ -367,8 +379,6 @@ __wildCharacters = ['*','?','x']
367
379
  _no_env = DEFAULT_NO_ENV
368
380
  _env_files = DEFAULT_ENV_FILES
369
381
  __globalUnavailableHosts = dict()
370
- __ipmiiInterfaceIPPrefix = DEFAULT_IPMI_INTERFACE_IP_PREFIX
371
- __ipmi_args = DEAFULT_IPMI_ARGS
372
382
  __keyPressesIn = [[]]
373
383
  _emo = False
374
384
  __curses_global_color_pairs = {(-1,-1):1}
@@ -405,7 +415,7 @@ if __resource_lib_available:
405
415
  resource.setrlimit(resource.RLIMIT_NOFILE, (__system_nofile_limit, __system_nofile_limit))
406
416
  __max_connections_nofile_limit_supported = int((__system_nofile_limit - 10) / 3)
407
417
  _binPaths = {}
408
- _binCalled = set(['sshpass', 'ssh', 'scp', 'ipmitool','rsync','sh','ssh-copy-id'])
418
+ _binCalled = set(['sshpass', 'ssh', 'scp', 'ipmitool','rsync','sh','ssh-copy-id','redfishtool'])
409
419
  def check_path(program_name):
410
420
  global __configs_from_file
411
421
  global _binPaths
@@ -1789,7 +1799,7 @@ def __handle_writing_stream(stream,stop_event,host):
1789
1799
  # host.stdout.append(' $ ' + ''.join(__keyPressesIn[-1]).encode().decode().replace('\n', '↵'))
1790
1800
  return sentInputPos
1791
1801
 
1792
- def run_command(host, sem, timeout=60,passwds=None, retry_limit = 7,ipmi_args = ...):
1802
+ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 7 + len(DEFAULT_IPMI_DEFINITIONS),ipmi_definitions_list = ...):
1793
1803
  '''
1794
1804
  Run the command on the host. Will format the commands accordingly. Main execution function.
1795
1805
 
@@ -1804,45 +1814,67 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 7,ipmi_args =
1804
1814
  '''
1805
1815
  global _emo
1806
1816
  global __ERROR_MESSAGES_TO_IGNORE_REGEX
1807
- global __ipmiiInterfaceIPPrefix
1808
1817
  global _binPaths
1809
1818
  global __DEBUG_MODE
1810
- global DEFAULT_IPMI_USERNAME
1811
- global DEFAULT_IPMI_PASSWORD
1812
1819
  global DEFAULT_USERNAME
1813
1820
  global DEFAULT_PASSWORD
1814
1821
  global SSH_STRICT_HOST_KEY_CHECKING
1815
- global __ipmi_args
1816
- global DEAFULT_IPMI_ARGS
1817
1822
  if retry_limit < 0:
1818
1823
  host.output.append('Error: Retry limit reached!')
1819
1824
  host.stderr.append('Error: Retry limit reached!')
1820
1825
  host.returncode = 1
1821
1826
  return
1822
- if ipmi_args is ...:
1823
- ipmi_args = __ipmi_args
1824
- prePasword = passwds
1827
+ ipmi_definition = {}
1828
+ username = None
1829
+ sshLocalExtraArgs = []
1830
+ ip_prefix = None
1825
1831
  try:
1826
- localExtraArgs = []
1827
-
1828
1832
  if not SSH_STRICT_HOST_KEY_CHECKING:
1829
- localExtraArgs = ['-o StrictHostKeyChecking=no','-o UserKnownHostsFile=/dev/null']
1833
+ sshLocalExtraArgs = ['-o StrictHostKeyChecking=no','-o UserKnownHostsFile=/dev/null']
1830
1834
  if host.identity_file:
1831
- localExtraArgs += ['-i',host.identity_file]
1832
- rsyncLocalExtraArgs = ['--rsh','ssh ' + ' '.join(localExtraArgs)]
1833
- host.username = None
1835
+ sshLocalExtraArgs += ['-i',host.identity_file]
1836
+ rsyncLocalExtraArgs = ['--rsh','ssh ' + ' '.join(sshLocalExtraArgs)]
1834
1837
  host.address = host.name
1835
1838
  if '@' in host.name:
1836
- host.username, host.address = host.name.rsplit('@',1)
1839
+ username, host.address = host.name.rsplit('@',1)
1840
+ if host.ipmi:
1841
+ if ipmi_definitions_list is ...:
1842
+ ipmi_definitions_list = [ipmi_definition for ipmi_definition in DEFAULT_IPMI_DEFINITIONS if not ipmi_definition.get('hostname_keyword') or str(ipmi_definition.get('hostname_keyword')).strip() in host.name]
1843
+ if not ipmi_definitions_list:
1844
+ host.output.append('Error: Exhausted all matching ipmi definitions!')
1845
+ host.stderr.append('Error: Exhausted all matching ipmi definitions!')
1846
+ host.returncode = 1
1847
+ return
1848
+ ipmi_definition = dict(ChainMap(ipmi_definitions_list.pop(0),get_default_ipmi_definition()))
1849
+ if ipmi_definition.get('ipmi_method') == 'ssh_ipmitool':
1850
+ if not host.command:
1851
+ host.command = 'ipmitool power status'
1852
+ else:
1853
+ host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
1854
+ if username:
1855
+ host.output.append(f'Warning:-u SSH_USERNAME {username} is provided in the host name! Using the username from the ipmi definition if provided: {ipmi_definition.get("username")}')
1856
+ if __DEBUG_MODE:
1857
+ host.stderr.append(f'Warning:-u SSH_USERNAME {username} is provided in the host name! Using the username from the ipmi definition if provided: {ipmi_definition.get("username")}')
1858
+ username = ipmi_definition.get('username',username)
1859
+ if passwds:
1860
+ host.output.append(f'Warning: -p SSH_PASSWORD {passwds} is provided! Using the password from the ipmi definition if provided: {ipmi_definition.get("password")}')
1861
+ if __DEBUG_MODE:
1862
+ host.stderr.append(f'Warning: -p SSH_PASSWORD {passwds} is provided! Using the password from the ipmi definition if provided: {ipmi_definition.get("password")}')
1863
+ passwds = ipmi_definition.get('password',passwds)
1864
+ if ipmi_definition.get('interface_ip_prefix') and ipmi_definition.get('interface_ip_prefix') == DEFAULT_IPMI_INTERFACE_IP_PREFIX:
1865
+ host.output.append('Warning: ipmi_interface_ip_prefix that is the same as the DEFAULT_IPMI_INTERFACE_IP_PREFIX is ignored in ssh_ipmitool mode.')
1866
+ if __DEBUG_MODE:
1867
+ host.stderr.append('Warning: ipmi_interface_ip_prefix that is the same as the DEFAULT_IPMI_INTERFACE_IP_PREFIX is ignored in ssh_ipmitool mode.')
1868
+ else:
1869
+ ip_prefix = ipmi_definition.get('interface_ip_prefix')
1837
1870
  host.command = replace_magic_strings(host.command,['#HOST#','#HOSTNAME#'],host.address,case_sensitive=False)
1838
- if host.username:
1839
- host.command = replace_magic_strings(host.command,['#USER#','#USERNAME#'],host.username,case_sensitive=False)
1871
+ if username:
1872
+ host.command = replace_magic_strings(host.command,['#USER#','#USERNAME#'],username,case_sensitive=False)
1840
1873
  else:
1841
1874
  current_user = getpass.getuser()
1842
1875
  host.command = replace_magic_strings(host.command,['#USER#','#USERNAME#'],current_user,case_sensitive=False)
1843
1876
  host.command = replace_magic_strings(host.command,['#ID#'],str(id(host)),case_sensitive=False)
1844
1877
  host.command = replace_magic_strings(host.command,['#I#'],str(host.i),case_sensitive=False)
1845
- host.command = replace_magic_strings(host.command,['#PASSWD#','#PASSWORD#'],passwds,case_sensitive=False)
1846
1878
  host.command = replace_magic_strings(host.command,['#UUID#'],str(host.uuid),case_sensitive=False)
1847
1879
  host.command = replace_magic_strings(host.command,['#IP#'],str(host.ip),case_sensitive=False)
1848
1880
  formatedCMD = []
@@ -1852,63 +1884,65 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 7,ipmi_args =
1852
1884
  extraargs = [str(arg) for arg in host.extraargs]
1853
1885
  else:
1854
1886
  extraargs = []
1855
- if __ipmiiInterfaceIPPrefix:
1856
- host.interface_ip_prefix = __ipmiiInterfaceIPPrefix if host.ipmi and not host.interface_ip_prefix else host.interface_ip_prefix
1857
- if host.interface_ip_prefix:
1887
+ if not ip_prefix:
1888
+ ip_prefix = host.interface_ip_prefix
1889
+ if ip_prefix:
1858
1890
  try:
1859
1891
  hostOctets = host.ip.split('.')
1860
- prefixOctets = host.interface_ip_prefix.split('.')
1892
+ prefixOctets = ip_prefix.split('.')
1861
1893
  host.address = '.'.join(prefixOctets[:3]+hostOctets[min(3,len(prefixOctets)):])
1862
- host.resolvedName = host.username + '@' if host.username else ''
1863
- host.resolvedName += host.address
1864
1894
  except Exception:
1865
- host.resolvedName = host.name
1895
+ host.output.append(f'Warning: Failed to resolve the IP address for the host {host.name}. Unable to resolve ip_prefix {ip_prefix}!')
1896
+ if __DEBUG_MODE:
1897
+ host.stderr.append(f'Warning: Failed to resolve the IP address for the host {host.name}. Unable to resolve ip_prefix {ip_prefix}!')
1898
+ if username:
1899
+ host.resolvedName = username + '@' + host.address
1866
1900
  else:
1867
- host.resolvedName = host.name
1901
+ host.resolvedName = host.address
1868
1902
  if host.resolvedName:
1869
1903
  host.command = replace_magic_strings(host.command,['#RESOLVEDNAME#','#RESOLVED#'],host.resolvedName,case_sensitive=False)
1870
- if host.ipmi:
1871
- if 'ipmitool' in _binPaths:
1904
+ if __DEBUG_MODE:
1905
+ host.stderr.append(f'Resolved name: {host.resolvedName}')
1906
+ if host.ipmi and ipmi_definition.get('ipmi_method') != 'ssh_ipmitool':
1907
+ #{interface_ip_prefix,username,password,args,ipmi_method,hostname_keyword,retry_lockout_time}
1908
+ if ipmi_definition.get('ipmi_method') == 'ipmitool':
1909
+ if 'ipmitool' not in _binPaths:
1910
+ host.output.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
1911
+ if __DEBUG_MODE:
1912
+ host.stderr.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
1913
+ if ipmi_definition.get('interface_ip_prefix') == DEFAULT_IPMI_INTERFACE_IP_PREFIX:
1914
+ ipmi_definition['interface_ip_prefix'] = DEFAULT_INTERFACE_IP_PREFIX
1915
+ ipmi_definition['username'] = username
1916
+ ipmi_definition['password'] = passwds
1917
+ ipmi_definition['ipmi_method'] = 'ssh_ipmitool'
1918
+ ipmi_definitions_list.insert(0,ipmi_definition)
1919
+ return run_command(host,sem,timeout,passwds,retry_limit=retry_limit - 1,ipmi_definitions_list=ipmi_definitions_list)
1872
1920
  if host.command.startswith('ipmitool '):
1873
1921
  host.command = host.command.replace('ipmitool ','')
1874
1922
  elif host.command.startswith(_binPaths['ipmitool']):
1875
1923
  host.command = host.command.replace(_binPaths['ipmitool'],'')
1876
- if not host.username or host.username == DEFAULT_USERNAME:
1877
- if DEFAULT_IPMI_USERNAME:
1878
- host.username = DEFAULT_IPMI_USERNAME
1879
- elif DEFAULT_USERNAME:
1880
- host.username = DEFAULT_USERNAME
1881
- else:
1882
- host.username = 'ADMIN'
1883
- if not passwds or passwds == DEFAULT_PASSWORD:
1884
- if DEFAULT_IPMI_PASSWORD:
1885
- passwds = DEFAULT_IPMI_PASSWORD
1886
- elif DEFAULT_PASSWORD:
1887
- passwds = DEFAULT_PASSWORD
1888
- else:
1889
- host.output.append('Warning: Password not provided for ipmi! Using a default password `admin`.')
1890
- passwds = 'admin'
1891
1924
  if not host.command:
1892
1925
  host.command = 'power status'
1926
+ ipmi_args = ipmi_definition.get('args','')
1893
1927
  if 'sh' in _binPaths:
1894
- formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {ipmi_args} {" ".join(extraargs)} {host.command}']
1928
+ formatedCMD = [_binPaths['sh'],'-c',f'ipmitool -H {host.address} -U {ipmi_definition.get("username",username)} -P {ipmi_definition.get("password",passwds)} {ipmi_args} {host.command}']
1895
1929
  else:
1896
- formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + [f'-{s}' for s in ipmi_args.split('-') if s] + extraargs + [host.command]
1897
- elif 'ssh' in _binPaths:
1898
- host.output.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
1899
- if __DEBUG_MODE:
1900
- host.stderr.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
1901
- host.ipmi = False
1902
- host.interface_ip_prefix = None
1930
+ formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {ipmi_definition.get("username",username)}',f'-P {ipmi_definition.get("password",passwds)}'] + [f'-{s}' for s in ipmi_args.split('-') if s] + [host.command]
1931
+ elif ipmi_definition.get('ipmi_method') == 'redfish':
1932
+ if host.command.startswith('redfishtool '):
1933
+ host.command = host.command.replace('redfishtool ','')
1934
+ elif host.command.startswith(_binPaths['redfishtool']):
1935
+ host.command = host.command.replace(_binPaths['redfishtool'],'')
1903
1936
  if not host.command:
1904
- host.command = 'ipmitool power status'
1937
+ host.command = 'Systems'
1938
+ ipmi_args = ipmi_definition.get('args','')
1939
+ if 'sh' in _binPaths:
1940
+ formatedCMD = [_binPaths['sh'],'-c',f'redfishtool -S Always -a -r {host.address} -u {ipmi_definition.get("username",username)} -p {ipmi_definition.get("password",passwds)} {ipmi_args} {host.command}']
1905
1941
  else:
1906
- host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
1907
- run_command(host,sem,timeout,prePasword,retry_limit=retry_limit - 1,ipmi_args=ipmi_args)
1908
- return
1942
+ formatedCMD = [_binPaths['redfishtool'],'-S', 'Always','-a','-r',host.address,'-u',ipmi_definition.get("username",username),'-p',ipmi_definition.get("password",passwds)] + [f'-{s}' for s in ipmi_args.split('-') if s] + [host.command]
1909
1943
  else:
1910
- host.output.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
1911
- host.stderr.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
1944
+ host.output.append(f'IPMI method {ipmi_definition.get("ipmi_method","None")} not supported!')
1945
+ host.stderr.append(f'IPMI method {ipmi_definition.get("ipmi_method","None")} not supported!')
1912
1946
  host.returncode = 1
1913
1947
  return
1914
1948
  elif host.shell:
@@ -1923,7 +1957,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 7,ipmi_args =
1923
1957
  host.stderr.append('shell not found on the local machine! Using ssh localhost instead...')
1924
1958
  host.shell = False
1925
1959
  host.name = 'localhost'
1926
- run_command(host,sem,timeout,prePasword,retry_limit=retry_limit - 1,ipmi_args=ipmi_args)
1960
+ run_command(host,sem,timeout,passwds,retry_limit=retry_limit - 1,ipmi_definitions_list=ipmi_definitions_list)
1927
1961
  else:
1928
1962
  if host.files:
1929
1963
  if host.scp:
@@ -1956,11 +1990,11 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 7,ipmi_args =
1956
1990
  else:
1957
1991
  fileArgs = host.files + [f'{host.resolvedName}:{host.command}']
1958
1992
  if useScp:
1959
- formatedCMD = [_binPaths['scp'],'-rp'] + localExtraArgs + extraargs +['--']+fileArgs
1993
+ formatedCMD = [_binPaths['scp'],'-rp'] + sshLocalExtraArgs + extraargs +['--']+fileArgs
1960
1994
  else:
1961
1995
  formatedCMD = [_binPaths['rsync'],'-ahlX','--partial','--inplace', '--info=name'] + rsyncLocalExtraArgs + extraargs +['--']+fileArgs
1962
1996
  else:
1963
- formatedCMD = [_binPaths['ssh']] + localExtraArgs + extraargs +['--']+ [host.resolvedName, host.command]
1997
+ formatedCMD = [_binPaths['ssh']] + sshLocalExtraArgs + extraargs +['--']+ [host.resolvedName, host.command]
1964
1998
  if passwds and 'sshpass' in _binPaths:
1965
1999
  formatedCMD = [_binPaths['sshpass'], '-p', passwds] + formatedCMD
1966
2000
  elif passwds:
@@ -2077,7 +2111,7 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 7,ipmi_args =
2077
2111
  host.output.append("Warning: Too many open files. retrying...")
2078
2112
  # Handle the error, e.g., clean up, retry logic, or exit
2079
2113
  time.sleep(0.1)
2080
- run_command(host,sem,timeout,prePasword,retry_limit=retry_limit - 1,ipmi_args=ipmi_args)
2114
+ run_command(host,sem,timeout,passwds,retry_limit=retry_limit - 1,ipmi_definitions_list=ipmi_definitions_list)
2081
2115
  else:
2082
2116
  # Re-raise the exception if it's not the specific one
2083
2117
  raise
@@ -2088,39 +2122,47 @@ def run_command(host, sem, timeout=60,passwds=None, retry_limit = 7,ipmi_args =
2088
2122
  host.stderr.extend(traceback.format_exc().split('\n'))
2089
2123
  host.output.extend(traceback.format_exc().split('\n'))
2090
2124
  host.returncode = -1
2091
- # If using ipmi, we will try again using ssh if ipmi connection is not successful
2092
- if host.ipmi and host.returncode != 0 and any(['Unable to establish IPMI' in line for line in host.stderr]):
2125
+ # If using ipmi, we will try again using next ipmi definition if ipmi connection is not successful
2126
+ if host.ipmi and host.returncode != 0:
2093
2127
  host.returncode = None
2094
- host.stderr = []
2095
- if ipmi_args:
2096
- if DEAFULT_IPMI_ARGS and ipmi_args != DEAFULT_IPMI_ARGS:
2097
- host.output.append(f'IPMI connection failed with arg {ipmi_args}! Trying DEFAULT_IPMI_ARGS:{DEAFULT_IPMI_ARGS}')
2098
- if __DEBUG_MODE:
2099
- host.stderr.append(f'IPMI connection failed with arg {ipmi_args}! Trying DEFAULT_IPMI_ARGS:{DEAFULT_IPMI_ARGS}')
2100
- ipmi_args = DEAFULT_IPMI_ARGS
2101
- else:
2102
- host.output.append(f'IPMI connection failed with arg {ipmi_args}! Trying without args...')
2103
- if __DEBUG_MODE:
2104
- host.stderr.append(f'IPMI connection failed with arg {ipmi_args}! Trying without args...')
2105
- ipmi_args = ''
2128
+ host.output.append('IPMI connection failed! Trying next ipmi definition...')
2129
+ if __DEBUG_MODE:
2130
+ host.stderr.append('IPMI connection failed! Trying next ipmi definition...')
2106
2131
  else:
2107
- host.output.append('IPMI connection failed! Trying SSH connection...')
2108
- if __DEBUG_MODE:
2109
- host.stderr.append('IPMI connection failed! Trying SSH connection...')
2110
- host.ipmi = False
2111
- host.interface_ip_prefix = None
2112
- host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
2113
- run_command(host,sem,timeout,prePasword,retry_limit=retry_limit - 1,ipmi_args=ipmi_args)
2132
+ host.stdout = []
2133
+ host.stderr = []
2134
+ if ipmi_definition.get('retry_lockout_time'):
2135
+ try:
2136
+ sleep_seconds = int(ipmi_definition.get('retry_lockout_time'))
2137
+ if sleep_seconds >= 0:
2138
+ host.output.append(f'Sleeping for {sleep_seconds} seconds...')
2139
+ if __DEBUG_MODE:
2140
+ host.stderr.append(f'Sleeping for {sleep_seconds} seconds...')
2141
+ time.sleep(sleep_seconds)
2142
+ except Exception as e:
2143
+ host.output.append(f'Error: Failed to sleep for {ipmi_definition.get("retry_lockout_time")} seconds! {e}')
2144
+ if __DEBUG_MODE:
2145
+ host.stderr.append(f'Error: Failed to sleep for {ipmi_definition.get("retry_lockout_time")} seconds! {e}')
2146
+ time.sleep(DEFAULT_IPMI_RETRY_LOCKOUT_TIME)
2147
+ if ipmi_definition.get('ipmi_method') == 'ipmitool':
2148
+ if ipmi_definition.get('interface_ip_prefix') == DEFAULT_IPMI_INTERFACE_IP_PREFIX:
2149
+ ipmi_definition['interface_ip_prefix'] = DEFAULT_INTERFACE_IP_PREFIX
2150
+ ipmi_definition['username'] = username
2151
+ ipmi_definition['password'] = passwds
2152
+ ipmi_definition['ipmi_method'] = 'ssh_ipmitool'
2153
+ ipmi_definitions_list.append(ipmi_definition)
2154
+ run_command(host,sem,timeout,passwds,retry_limit=retry_limit - 1,ipmi_definitions_list=ipmi_definitions_list)
2114
2155
  # If transfering files, we will try again using scp if rsync connection is not successful
2115
2156
  elif host.files and not host.scp and not useScp and host.returncode != 0 and host.stderr:
2116
2157
  host.returncode = None
2117
- host.stderr = []
2118
- host.stdout = []
2119
2158
  host.output.append('Rsync connection failed! Trying SCP connection...')
2120
2159
  if __DEBUG_MODE:
2121
2160
  host.stderr.append('Rsync connection failed! Trying SCP connection...')
2161
+ else:
2162
+ host.stdout = []
2163
+ host.stderr = []
2122
2164
  host.scp = True
2123
- run_command(host,sem,timeout,prePasword,retry_limit=retry_limit - 1,ipmi_args=ipmi_args)
2165
+ run_command(host,sem,timeout,passwds,retry_limit=retry_limit - 1,ipmi_definitions_list=ipmi_definitions_list)
2124
2166
 
2125
2167
  #%% ------------ Start Threading Block ----------------
2126
2168
  def start_run_on_hosts(hosts, timeout=60,password=None,max_connections=4 * os.cpu_count()):
@@ -3378,16 +3420,35 @@ def formHostStr(host) -> str:
3378
3420
  return ','.join(compact_hostnames(host))
3379
3421
 
3380
3422
  @cache_decorator
3381
- def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
3382
- no_watch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,max_connections=DEFAULT_MAX_CONNECTIONS,
3383
- file = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,
3384
- scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skip_unreachable=DEFAULT_SKIP_UNREACHABLE,
3385
- no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
3386
- file_sync = False, error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3387
- copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY, no_history = DEFAULT_NO_HISTORY,
3388
- history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = DEFAULT_ENV_FILES,
3389
- repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3390
- shortend = False) -> str:
3423
+ def __formCommandArgStr(shortend = False, **kwargs) -> str:
3424
+ oneonone = kwargs.get('oneonone', DEFAULT_ONE_ON_ONE)
3425
+ timeout = kwargs.get('timeout', DEFAULT_TIMEOUT)
3426
+ password = kwargs.get('password', DEFAULT_PASSWORD)
3427
+ no_watch = kwargs.get('no_watch', DEFAULT_NO_WATCH)
3428
+ json = kwargs.get('json', DEFAULT_JSON_MODE)
3429
+ max_connections = kwargs.get('max_connections', DEFAULT_MAX_CONNECTIONS)
3430
+ file = kwargs.get('file', None)
3431
+ ipmi = kwargs.get('ipmi', DEFAULT_IPMI)
3432
+ interface_ip_prefix = kwargs.get('interface_ip_prefix', DEFAULT_INTERFACE_IP_PREFIX)
3433
+ scp = kwargs.get('scp', DEFAULT_SCP)
3434
+ gather_mode = kwargs.get('gather_mode', False)
3435
+ username = kwargs.get('username', DEFAULT_USERNAME)
3436
+ extraargs = kwargs.get('extraargs', DEFAULT_EXTRA_ARGS)
3437
+ skip_unreachable = kwargs.get('skip_unreachable', DEFAULT_SKIP_UNREACHABLE)
3438
+ no_env = kwargs.get('no_env', DEFAULT_NO_ENV)
3439
+ greppable = kwargs.get('greppable', DEFAULT_GREPPABLE_MODE)
3440
+ skip_hosts = kwargs.get('skip_hosts', DEFAULT_SKIP_HOSTS)
3441
+ file_sync = kwargs.get('file_sync', False)
3442
+ error_only = kwargs.get('error_only', DEFAULT_ERROR_ONLY)
3443
+ identity_file = kwargs.get('identity_file', DEFAULT_IDENTITY_FILE)
3444
+ copy_id = kwargs.get('copy_id', False)
3445
+ unavailable_host_expiry = kwargs.get('unavailable_host_expiry', DEFAULT_UNAVAILABLE_HOST_EXPIRY)
3446
+ no_history = kwargs.get('no_history', DEFAULT_NO_HISTORY)
3447
+ history_file = kwargs.get('history_file', DEFAULT_HISTORY_FILE)
3448
+ env_file = kwargs.get('env_file', '')
3449
+ env_files = kwargs.get('env_files', DEFAULT_ENV_FILES)
3450
+ repeat = kwargs.get('repeat', DEFAULT_REPEAT)
3451
+ interval = kwargs.get('interval', DEFAULT_INTERVAL)
3391
3452
  argsList = []
3392
3453
  if oneonone:
3393
3454
  argsList.append('--oneonone' if not shortend else '-11')
@@ -3449,37 +3510,22 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
3449
3510
  argsList.append('--file_sync' if not shortend else '-fs')
3450
3511
  return ' '.join(argsList)
3451
3512
 
3452
- def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
3453
- no_watch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,called = _DEFAULT_CALLED,max_connections=DEFAULT_MAX_CONNECTIONS,
3454
- file = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,return_unfinished = _DEFAULT_RETURN_UNFINISHED,
3455
- scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skip_unreachable=DEFAULT_SKIP_UNREACHABLE,
3456
- no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,will_update_unreachable_hosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
3457
- skip_hosts = DEFAULT_SKIP_HOSTS, window_width = DEFAULT_WINDOW_WIDTH, window_height = DEFAULT_WINDOW_HEIGHT,
3458
- single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, identity_file = DEFAULT_IDENTITY_FILE,
3459
- copy_id = False, unavailable_host_expiry = DEFAULT_UNAVAILABLE_HOST_EXPIRY,no_history = DEFAULT_NO_HISTORY,
3460
- history_file = DEFAULT_HISTORY_FILE, env_file = '', env_files = [],
3461
- repeat = DEFAULT_REPEAT,interval = DEFAULT_INTERVAL,
3462
- shortend = False,tabSeperated = False,**kwargs) -> str:
3463
- _ = called
3464
- _ = return_unfinished
3465
- _ = will_update_unreachable_hosts
3466
- _ = no_start
3467
- _ = window_width
3468
- _ = window_height
3469
- _ = single_window
3513
+ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,shortend = False,tabSeperated = False,**kwargs) -> str:
3514
+ # Allow explicit kwargs to override base parameters when forwarding dicts.
3515
+ hosts = kwargs.get('hosts', hosts)
3516
+ commands = kwargs.get('commands', commands)
3517
+ shortend = kwargs.get('shortend', shortend)
3518
+ tabSeperated = kwargs.get('tabSeperated', tabSeperated)
3470
3519
  hosts = hosts if isinstance(hosts,str) else frozenset(hosts)
3471
3520
  hostStr = formHostStr(hosts)
3472
- file = frozenset(file) if file else None
3473
- argsStr = __formCommandArgStr(oneonone = oneonone, timeout = timeout,password = password,
3474
- no_watch = no_watch,json = json,max_connections=max_connections,
3475
- file = file,ipmi = ipmi,interface_ip_prefix = interface_ip_prefix,
3476
- scp=scp,gather_mode = gather_mode,username=username,extraargs=extraargs,skip_unreachable=skip_unreachable,
3477
- no_env=no_env, greppable=greppable,skip_hosts = skip_hosts,
3478
- file_sync = file_sync,error_only = error_only, identity_file = identity_file,
3479
- copy_id = copy_id, unavailable_host_expiry =unavailable_host_expiry,no_history = no_history,
3480
- history_file = history_file, env_file = env_file, env_files = env_files,
3481
- repeat = repeat,interval = interval,
3482
- shortend = shortend)
3521
+ commandArgKwargs = dict(kwargs)
3522
+ commandArgKwargs.pop('hosts', None)
3523
+ commandArgKwargs.pop('commands', None)
3524
+ commandArgKwargs.pop('shortend', None)
3525
+ commandArgKwargs.pop('tabSeperated', None)
3526
+ if commandArgKwargs.get('file'):
3527
+ commandArgKwargs['file'] = frozenset(commandArgKwargs['file'])
3528
+ argsStr = __formCommandArgStr(shortend = shortend, **commandArgKwargs)
3483
3529
  commands = [command.replace('"', '\\"').replace('\n', '\\n').replace('\t', '\\t') for command in format_commands(commands)]
3484
3530
  commandStr = '"' + '" "'.join(commands) + '"' if commands else ''
3485
3531
  filePath = os.path.abspath(__file__)
@@ -3503,15 +3549,11 @@ def record_command_history(kwargs):
3503
3549
  global __global_suppress_printout
3504
3550
  global __DEBUG_MODE
3505
3551
  try:
3506
- history_file = os.path.expanduser(kwargs.get('history_file', DEFAULT_HISTORY_FILE))
3507
- import inspect
3508
- sig = inspect.signature(getStrCommand)
3509
- wanted = {
3510
- name: kwargs[name]
3511
- for name in sig.parameters
3512
- if name in kwargs
3513
- }
3514
- strCommand = getStrCommand(**wanted,shortend=True,tabSeperated=True)
3552
+ historyKwargs = dict(kwargs)
3553
+ historyKwargs['shortend'] = True
3554
+ historyKwargs['tabSeperated'] = True
3555
+ history_file = os.path.expanduser(historyKwargs.get('history_file', DEFAULT_HISTORY_FILE))
3556
+ strCommand = getStrCommand(**historyKwargs)
3515
3557
  with open(history_file, 'a') as f:
3516
3558
  # it follows <timestamp>\t<strCommand>\n
3517
3559
  f.write(f'{int(time.time())}\t{strCommand}\n')
@@ -3867,10 +3909,13 @@ def generate_default_config(args):
3867
3909
  'DEFAULT_INTERVAL': args.interval,
3868
3910
  'DEFAULT_IPMI': args.ipmi,
3869
3911
  'DEFAULT_IPMI_INTERFACE_IP_PREFIX': args.ipmi_interface_ip_prefix,
3870
- 'DEFAULT_INTERFACE_IP_PREFIX': args.interface_ip_prefix,
3871
3912
  'DEFAULT_IPMI_USERNAME': args.ipmi_username,
3872
3913
  'DEFAULT_IPMI_PASSWORD': args.ipmi_password,
3873
3914
  'DEAFULT_IPMI_ARGS': args.ipmi_args,
3915
+ 'DEFAULT_IPMI_METHOD': args.ipmi_method,
3916
+ 'DEFAULT_IPMI_RETRY_LOCKOUT_TIME': args.ipmi_retry_lockout_time,
3917
+ 'DEFAULT_INTERFACE_IP_PREFIX': args.interface_ip_prefix,
3918
+ 'DEFAULT_IPMI_DEFINITIONS': DEFAULT_IPMI_DEFINITIONS,
3874
3919
  'DEFAULT_NO_WATCH': args.no_watch,
3875
3920
  'DEFAULT_WINDOW_WIDTH': args.window_width,
3876
3921
  'DEFAULT_WINDOW_HEIGHT': args.window_height,
@@ -3891,10 +3936,7 @@ def generate_default_config(args):
3891
3936
  'DEFAULT_HOST_FILE': args.host_file,
3892
3937
  'DEFAULT_ENCODING': args.encoding,
3893
3938
  'DEFAULT_DIFF_DISPLAY_THRESHOLD': args.diff_display_threshold,
3894
- 'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
3895
- 'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
3896
3939
  'FORCE_TRUECOLOR': args.force_truecolor,
3897
- 'POSSIBLE_SSH_KEY_FILES': POSSIBLE_SSH_KEY_FILES,
3898
3940
  }
3899
3941
 
3900
3942
  def write_default_config(args,CONFIG_FILE = None, force = False):
@@ -3951,11 +3993,11 @@ def get_parser():
3951
3993
  )
3952
3994
  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)
3953
3995
  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.')
3954
- 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)
3955
- parser.add_argument('-p', '--password', type=str,help=f'The password to use to connect to the hosts, (default: {DEFAULT_PASSWORD})',default=DEFAULT_PASSWORD)
3996
+ parser.add_argument('-u','--username', type=str,help=f'The general username to use to connect to the hosts via ssh. Will get overwrote by individual username@host if specified. (default: {DEFAULT_USERNAME})',default=DEFAULT_USERNAME)
3997
+ parser.add_argument('-p', '--password', type=str,help=f'The password to use to connect to the hosts via ssh, (default: {DEFAULT_PASSWORD})',default=DEFAULT_PASSWORD)
3956
3998
  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)
3957
3999
  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)
3958
- 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)
4000
+ parser.add_argument('-ea','--extraargs',type=str,help=f'Extra arguments to pass to the ssh / rsync / scp command. (Use -ia for ipmi arguments) Put in one string for multiple arguments.Use "=" ! Ex. -ea="--delete" (default: {DEFAULT_EXTRA_ARGS})',default=DEFAULT_EXTRA_ARGS)
3959
4001
  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)
3960
4002
  parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
3961
4003
  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])
@@ -3971,7 +4013,9 @@ def get_parser():
3971
4013
  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)
3972
4014
  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)
3973
4015
  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)
3974
- parser.add_argument('-ia','--ipmi_args',type=str,help=f'The arguments passed to ipmitool to use to connect to the hosts via ipmi. (default: {DEAFULT_IPMI_ARGS})',default=DEAFULT_IPMI_ARGS)
4016
+ parser.add_argument('-ia','--ipmi_args',type=str,help=f'The arguments passed to ipmitool to use to connect to the hosts via ipmi. (default: {DEFAULT_IPMI_ARGS})',default=DEFAULT_IPMI_ARGS)
4017
+ parser.add_argument('-im','--ipmi_method', type=str,help=f'The method to use to connect to the hosts via ipmi. (default: {DEFAULT_IPMI_METHOD})',default=DEFAULT_IPMI_METHOD)
4018
+ parser.add_argument('-irlt','--ipmi_retry_lockout_time', type=int,help=f'The time in seconds to retry the lockout of the hosts via ipmi. (default: {DEFAULT_IPMI_RETRY_LOCKOUT_TIME})',default=DEFAULT_IPMI_RETRY_LOCKOUT_TIME)
3975
4019
  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)
3976
4020
  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)
3977
4021
  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)
@@ -4008,8 +4052,6 @@ def get_parser():
4008
4052
  return parser
4009
4053
 
4010
4054
  def process_args(args = None):
4011
- global DEFAULT_IPMI_USERNAME
4012
- global DEFAULT_IPMI_PASSWORD
4013
4055
  global __configs_from_file
4014
4056
  cfp = parser = argparse.ArgumentParser(add_help=False)
4015
4057
  cfp.add_argument('--config_file', type=str,nargs='?',const='multiSSH3.config.json',default=None)
@@ -4067,7 +4109,27 @@ def process_args(args = None):
4067
4109
  if args.unavailable_host_expiry <= 0:
4068
4110
  eprint(f"Warning: The unavailable host expiry time {args.unavailable_host_expiry} is less than 0, setting it to 10 seconds.")
4069
4111
  args.unavailable_host_expiry = 10
4070
-
4112
+ default_ipmi_definition = get_default_ipmi_definition()
4113
+ while DEFAULT_IPMI_DEFINITIONS:
4114
+ last_definition = DEFAULT_IPMI_DEFINITIONS.pop()
4115
+ if last_definition != default_ipmi_definition:
4116
+ DEFAULT_IPMI_DEFINITIONS.append(last_definition)
4117
+ break
4118
+ fields = [
4119
+ ('interface_ip_prefix', args.ipmi_interface_ip_prefix),
4120
+ ('username', args.ipmi_username),
4121
+ ('password', args.ipmi_password),
4122
+ ('args', args.ipmi_args),
4123
+ ('ipmi_method', args.ipmi_method),
4124
+ ('retry_lockout_time', args.ipmi_retry_lockout_time),
4125
+ ]
4126
+ modified = any(val != default_ipmi_definition[key] for key, val in fields)
4127
+ for key, val in fields:
4128
+ default_ipmi_definition[key] = val
4129
+ if modified:
4130
+ DEFAULT_IPMI_DEFINITIONS.insert(0, default_ipmi_definition)
4131
+ else:
4132
+ DEFAULT_IPMI_DEFINITIONS.append(default_ipmi_definition)
4071
4133
  return args
4072
4134
 
4073
4135
  def process_config_file(args):
@@ -4150,19 +4212,14 @@ def process_control_master_config(args):
4150
4212
 
4151
4213
  def set_global_with_args(args):
4152
4214
  global _emo
4153
- global __ipmiiInterfaceIPPrefix
4154
4215
  global _env_files
4155
4216
  global __DEBUG_MODE
4156
4217
  global _encoding
4157
4218
  global __returnZero
4158
- global DEFAULT_IPMI_USERNAME
4159
- global DEFAULT_IPMI_PASSWORD
4160
4219
  global DEFAULT_DIFF_DISPLAY_THRESHOLD
4161
4220
  global FORCE_TRUECOLOR
4162
4221
  global DEFAULT_HOST_FILE
4163
- global __ipmi_args
4164
4222
  _emo = False
4165
- __ipmiiInterfaceIPPrefix = args.ipmi_interface_ip_prefix
4166
4223
  if args.env_file:
4167
4224
  _env_files = [args.env_file]
4168
4225
  else:
@@ -4171,15 +4228,10 @@ def set_global_with_args(args):
4171
4228
  _encoding = args.encoding
4172
4229
  if args.return_zero:
4173
4230
  __returnZero = True
4174
- if args.ipmi_username:
4175
- DEFAULT_IPMI_USERNAME = args.ipmi_username
4176
- if args.ipmi_password:
4177
- DEFAULT_IPMI_PASSWORD = args.ipmi_password
4178
4231
  DEFAULT_DIFF_DISPLAY_THRESHOLD = args.diff_display_threshold
4179
4232
  FORCE_TRUECOLOR = args.force_truecolor
4180
4233
  if args.host_file:
4181
4234
  DEFAULT_HOST_FILE = args.host_file
4182
- __ipmi_args = args.ipmi_args
4183
4235
 
4184
4236
  #%% ------------ Wrapper Block ----------------
4185
4237
  def main(args = None):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes