multiSSH3 6.33__tar.gz → 6.34__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.
- {multissh3-6.33/multiSSH3.egg-info → multissh3-6.34}/PKG-INFO +4 -2
- {multissh3-6.33 → multissh3-6.34}/README.md +3 -1
- {multissh3-6.33 → multissh3-6.34}/mssh_complete.py +3 -6
- {multissh3-6.33 → multissh3-6.34/multiSSH3.egg-info}/PKG-INFO +4 -2
- {multissh3-6.33 → multissh3-6.34}/multiSSH3.py +91 -17
- {multissh3-6.33 → multissh3-6.34}/tests/complete_shell_harness.py +18 -2
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_diagnose.py +4 -3
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_keys.py +2 -2
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_shells.py +2 -2
- {multissh3-6.33 → multissh3-6.34}/tests/test_curses_event_loop.py +28 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_expand_hostnames.py +15 -0
- multissh3-6.34/tests/test_stopped_commands.py +287 -0
- multissh3-6.33/tests/test_stopped_commands.py +0 -123
- {multissh3-6.33 → multissh3-6.34}/LICENSE +0 -0
- {multissh3-6.33 → multissh3-6.34}/MANIFEST.in +0 -0
- {multissh3-6.33 → multissh3-6.34}/multiSSH3.egg-info/SOURCES.txt +0 -0
- {multissh3-6.33 → multissh3-6.34}/multiSSH3.egg-info/dependency_links.txt +0 -0
- {multissh3-6.33 → multissh3-6.34}/multiSSH3.egg-info/entry_points.txt +0 -0
- {multissh3-6.33 → multissh3-6.34}/multiSSH3.egg-info/top_level.txt +0 -0
- {multissh3-6.33 → multissh3-6.34}/pytest.ini +0 -0
- {multissh3-6.33 → multissh3-6.34}/setup.cfg +0 -0
- {multissh3-6.33 → multissh3-6.34}/setup.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/bench_complete.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/conftest.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/data/run_command_golden.json +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.bash5.1 +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.bash5.3 +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.centos7 +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.current +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.legacy +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/shells/Dockerfile.modern +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/shells/run_matrix.sh +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_cli_branches.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_cli_option_words.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_cli_processing.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_closed_output.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_color_helpers.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_command_terminal.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_compact_hostnames.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_blesh.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_cli.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_context.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_editor.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_engine.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_eval.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_hosts.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_hoststate.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_index.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_lex.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_mssh_helpers.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_password_spellings.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_picker.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_probe.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_start.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_store.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_suggest.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_terminal.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_untrusted.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_complete_zsh_options.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_config_cli.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_config_validate.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_config_values.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_control_master_config.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_copy_id.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_curses_branches.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_curses_tui.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_curses_wide_chars.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_file_trust.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_harness_smoke.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_helpers_extra.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_history_replay.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_host.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_host_validity.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_hosts_file.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_identities_only.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_ipmi_lockout_wait.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_ipmi_logins.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_key_path_home.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_login_drop_retry.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_main_lifecycle.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_narrow_terminal.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_no_hosts.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_option_no_effect.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_options_after_command.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_output_merge.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_output_robustness.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_packaging.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_password_handling.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_passwords_out_of_ps.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_run_command.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_run_command_branches.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_run_command_characterization.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_run_command_exit.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_run_on_hosts.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_run_on_hosts_modes.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_runtime_branch_matrix.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_runtime_dir.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_smoke_optional.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_ssh_error_text.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_ssh_timeouts.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_sshpass_exit.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_start_run_on_hosts.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_stream_reader.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_tui_ssh_notices.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_unavailable_hosts.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_underscore_settings.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/test_wide_wrap.py +0 -0
- {multissh3-6.33 → multissh3-6.34}/tests/vt_grid.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multiSSH3
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.34
|
|
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
|
|
@@ -336,6 +336,8 @@ When hostname need to be resolved, mssh will check in the following order:
|
|
|
336
336
|
|
|
337
337
|
An alias's value is split into hosts as the hosts argument is (`,`, spaces, `;`, ...). An alias with an empty value (`eu=''`) stands for no hosts.
|
|
338
338
|
|
|
339
|
+
A member can name its user: with `dbs="postgres@db1,db2"`, mssh logs in to db1 as postgres, and `-u` changes only db2's user. A user typed with the alias replaces it: `mssh root@dbs` logs in to both as root, as a user on the command line wins over ssh config's `User`.
|
|
340
|
+
|
|
339
341
|
Inside its own expansion, directly or through other aliases, an alias's name is not expanded again but taken as a host name, as bash does with aliases: `web="web,web-b"` runs on the host `web` and on `web-b`. When no host has that name, mssh reports the loop and runs on the rest: `Error: alias loop through 'web' (web -> app -> web): 'web' is not a valid hostname or IP address!`
|
|
340
342
|
|
|
341
343
|
|
|
@@ -970,7 +972,7 @@ Option names count in full only: `--user` is not `--username` and `-c` is not `-
|
|
|
970
972
|
|
|
971
973
|
Putting the command after `--` is no substitute for quotes: every word after the hosts is a command of its own, so `mssh web1 -- bash -c 'echo hi'` runs `bash`, `-c` and `echo hi` one after another (when each word is a single word, as in `mssh web1 -- ls -la`, mssh asks whether to join them).
|
|
972
974
|
|
|
973
|
-
Below is a sample help message output from multiSSH3 6.
|
|
975
|
+
Below is a sample help message output from multiSSH3 6.34 @ 2026-10-16 (no config file, 120 columns; the lines after the options list the programs found on that machine)
|
|
974
976
|
|
|
975
977
|
```bash
|
|
976
978
|
$ mssh -h
|
|
@@ -310,6 +310,8 @@ When hostname need to be resolved, mssh will check in the following order:
|
|
|
310
310
|
|
|
311
311
|
An alias's value is split into hosts as the hosts argument is (`,`, spaces, `;`, ...). An alias with an empty value (`eu=''`) stands for no hosts.
|
|
312
312
|
|
|
313
|
+
A member can name its user: with `dbs="postgres@db1,db2"`, mssh logs in to db1 as postgres, and `-u` changes only db2's user. A user typed with the alias replaces it: `mssh root@dbs` logs in to both as root, as a user on the command line wins over ssh config's `User`.
|
|
314
|
+
|
|
313
315
|
Inside its own expansion, directly or through other aliases, an alias's name is not expanded again but taken as a host name, as bash does with aliases: `web="web,web-b"` runs on the host `web` and on `web-b`. When no host has that name, mssh reports the loop and runs on the rest: `Error: alias loop through 'web' (web -> app -> web): 'web' is not a valid hostname or IP address!`
|
|
314
316
|
|
|
315
317
|
|
|
@@ -944,7 +946,7 @@ Option names count in full only: `--user` is not `--username` and `-c` is not `-
|
|
|
944
946
|
|
|
945
947
|
Putting the command after `--` is no substitute for quotes: every word after the hosts is a command of its own, so `mssh web1 -- bash -c 'echo hi'` runs `bash`, `-c` and `echo hi` one after another (when each word is a single word, as in `mssh web1 -- ls -la`, mssh asks whether to join them).
|
|
946
948
|
|
|
947
|
-
Below is a sample help message output from multiSSH3 6.
|
|
949
|
+
Below is a sample help message output from multiSSH3 6.34 @ 2026-10-16 (no config file, 120 columns; the lines after the options list the programs found on that machine)
|
|
948
950
|
|
|
949
951
|
```bash
|
|
950
952
|
$ mssh -h
|
|
@@ -16,7 +16,7 @@ import sys
|
|
|
16
16
|
import time
|
|
17
17
|
from bisect import bisect_left
|
|
18
18
|
|
|
19
|
-
version = '0.
|
|
19
|
+
version = '0.7'
|
|
20
20
|
__version__ = version
|
|
21
21
|
PROTOCOL = 'MSSHC1'
|
|
22
22
|
MSSH_NAMES = ('mssh', 'mssh3', 'multissh', 'multissh3', 'multiSSH3')
|
|
@@ -2577,12 +2577,9 @@ def _run_name(host, typed_user, line_user, own_user=''):
|
|
|
2577
2577
|
The name mssh gives a host in this run: what it records a failure under in unavailable_hosts.csv
|
|
2578
2578
|
and looks up to skip the host. The user typed with the host, else -u (line_user), else none.
|
|
2579
2579
|
own_user: the user an alias member is written with. -u does not replace it, a user typed with the
|
|
2580
|
-
alias
|
|
2580
|
+
alias does (for root@dbs with dbs=postgres@db1 mssh runs root@db1).
|
|
2581
2581
|
'''
|
|
2582
|
-
|
|
2583
|
-
host = own_user + '@' + host
|
|
2584
|
-
line_user = ''
|
|
2585
|
-
user = typed_user or line_user
|
|
2582
|
+
user = typed_user or own_user or line_user
|
|
2586
2583
|
return user + '@' + host if user else host
|
|
2587
2584
|
|
|
2588
2585
|
# characters that can start an expansion the shell makes before mssh sees the word
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multiSSH3
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.34
|
|
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
|
|
@@ -336,6 +336,8 @@ When hostname need to be resolved, mssh will check in the following order:
|
|
|
336
336
|
|
|
337
337
|
An alias's value is split into hosts as the hosts argument is (`,`, spaces, `;`, ...). An alias with an empty value (`eu=''`) stands for no hosts.
|
|
338
338
|
|
|
339
|
+
A member can name its user: with `dbs="postgres@db1,db2"`, mssh logs in to db1 as postgres, and `-u` changes only db2's user. A user typed with the alias replaces it: `mssh root@dbs` logs in to both as root, as a user on the command line wins over ssh config's `User`.
|
|
340
|
+
|
|
339
341
|
Inside its own expansion, directly or through other aliases, an alias's name is not expanded again but taken as a host name, as bash does with aliases: `web="web,web-b"` runs on the host `web` and on `web-b`. When no host has that name, mssh reports the loop and runs on the rest: `Error: alias loop through 'web' (web -> app -> web): 'web' is not a valid hostname or IP address!`
|
|
340
342
|
|
|
341
343
|
|
|
@@ -970,7 +972,7 @@ Option names count in full only: `--user` is not `--username` and `-c` is not `-
|
|
|
970
972
|
|
|
971
973
|
Putting the command after `--` is no substitute for quotes: every word after the hosts is a command of its own, so `mssh web1 -- bash -c 'echo hi'` runs `bash`, `-c` and `echo hi` one after another (when each word is a single word, as in `mssh web1 -- ls -la`, mssh asks whether to join them).
|
|
972
974
|
|
|
973
|
-
Below is a sample help message output from multiSSH3 6.
|
|
975
|
+
Below is a sample help message output from multiSSH3 6.34 @ 2026-10-16 (no config file, 120 columns; the lines after the options list the programs found on that machine)
|
|
974
976
|
|
|
975
977
|
```bash
|
|
976
978
|
$ mssh -h
|
|
@@ -40,10 +40,10 @@ from itertools import count, product
|
|
|
40
40
|
from pprint import pformat
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
version = '6.
|
|
43
|
+
version = '6.34'
|
|
44
44
|
VERSION = version
|
|
45
45
|
__version__ = version
|
|
46
|
-
COMMIT_DATE = '2026-10-
|
|
46
|
+
COMMIT_DATE = '2026-10-16'
|
|
47
47
|
# TODO: Add terminal TUI
|
|
48
48
|
|
|
49
49
|
# no current-directory entry: a cloned repo or unpacked archive could set any global (e.g. _sshPath),
|
|
@@ -2642,9 +2642,10 @@ def __expand_hostnames(hosts, hosts_only=False) -> dict:
|
|
|
2642
2642
|
if username:
|
|
2643
2643
|
# we expand the username
|
|
2644
2644
|
username = sorted(__expand_hostname(username,validate=False))
|
|
2645
|
-
# we combine the username and hostname
|
|
2645
|
+
# we combine the username and hostname: the typed user replaces the one an alias member
|
|
2646
|
+
# is written with, as the command line wins over ssh config's User
|
|
2646
2647
|
for user in username:
|
|
2647
|
-
[expandedhosts.update({f'{user}@{host}':ip}) for host,ip in zip(hostSetToAdd,iplist)]
|
|
2648
|
+
[expandedhosts.update({f'{user}@{host.rpartition("@")[2]}':ip}) for host,ip in zip(hostSetToAdd,iplist)]
|
|
2648
2649
|
else:
|
|
2649
2650
|
[expandedhosts.update({host:ip}) for host,ip in zip(hostSetToAdd,iplist)]
|
|
2650
2651
|
return expandedhosts
|
|
@@ -2697,6 +2698,8 @@ def expand_hostnames_local(hosts):
|
|
|
2697
2698
|
else:
|
|
2698
2699
|
members = [(user, name, source, None)]
|
|
2699
2700
|
for member_user, member, member_source, _ in members:
|
|
2701
|
+
# a typed user replaces a member's own, as in expand_hostnames
|
|
2702
|
+
member_user = user or member_user
|
|
2700
2703
|
full = '{}@{}'.format(member_user, member) if member_user else member
|
|
2701
2704
|
expanded.add(full)
|
|
2702
2705
|
if member_source is None:
|
|
@@ -3325,15 +3328,19 @@ def _stopped_command_note(formatedCMD):
|
|
|
3325
3328
|
Only the local program ends: a command ssh runs on a host gets no signal (it has no terminal), it
|
|
3326
3329
|
runs on until it ends, or until it writes to the closed connection.
|
|
3327
3330
|
'''
|
|
3328
|
-
program
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
program = formatedCMD[
|
|
3332
|
-
|
|
3331
|
+
# the program under sshpass, also in --copy_id's sh -c line
|
|
3332
|
+
name = _sshpass_program(formatedCMD)
|
|
3333
|
+
if not name:
|
|
3334
|
+
program = str(formatedCMD[0]) if formatedCMD else ''
|
|
3335
|
+
# known by the path mssh found it at (ssh.EXE on Windows)
|
|
3336
|
+
name = next((known for known in ('ssh', 'rsync', 'scp') if program == _binPaths.get(known)), os.path.basename(program))
|
|
3337
|
+
if name == 'ssh':
|
|
3333
3338
|
return 'ssh was stopped: the command may still run on the host'
|
|
3334
|
-
name
|
|
3335
|
-
if program in (_binPaths.get('rsync'), _binPaths.get('scp')):
|
|
3339
|
+
if name in ('rsync', 'scp'):
|
|
3336
3340
|
return f'{name} was stopped: the copy may be incomplete'
|
|
3341
|
+
if name == 'ssh-copy-id':
|
|
3342
|
+
# after its clean-up it may say all keys were skipped as already there
|
|
3343
|
+
return 'ssh-copy-id was stopped: the key may not be installed'
|
|
3337
3344
|
return f'{name} was stopped'
|
|
3338
3345
|
|
|
3339
3346
|
def _ipmi_failure_should_wait(host, line_starts=(0, 0, 0)):
|
|
@@ -4225,9 +4232,7 @@ def _execute_command(host, formatedCMD, sem, timeout, retry_limit, passwords=(),
|
|
|
4225
4232
|
stopped = f'No output for {timeout} s, so {_stopped_command_note(formatedCMD)}.'
|
|
4226
4233
|
host.stderr.append(stopped)
|
|
4227
4234
|
host.output.append(stopped)
|
|
4228
|
-
proc
|
|
4229
|
-
time.sleep(0.1)
|
|
4230
|
-
proc.terminate()
|
|
4235
|
+
_stop_command(proc, formatedCMD)
|
|
4231
4236
|
break
|
|
4232
4237
|
elif time.monotonic() - host.lastUpdateTime > max(1, timeout // 2):
|
|
4233
4238
|
timeoutLine = f'Timeout in [{timeout - int(time.monotonic() - host.lastUpdateTime)}] seconds!'
|
|
@@ -4247,9 +4252,7 @@ def _execute_command(host, formatedCMD, sem, timeout, retry_limit, passwords=(),
|
|
|
4247
4252
|
host.stderr.append('Ctrl C detected, Emergency Stop!')
|
|
4248
4253
|
host.output.append('Ctrl C detected, Emergency Stop!')
|
|
4249
4254
|
stopped_by_ctrl_c = True
|
|
4250
|
-
proc
|
|
4251
|
-
time.sleep(0.1)
|
|
4252
|
-
proc.terminate()
|
|
4255
|
+
_stop_command(proc, formatedCMD)
|
|
4253
4256
|
break
|
|
4254
4257
|
time.sleep(sleep_interval) # avoid busy-waiting
|
|
4255
4258
|
if sleep_interval < 0.001:
|
|
@@ -4378,6 +4381,71 @@ def _execute_command(host, formatedCMD, sem, timeout, retry_limit, passwords=(),
|
|
|
4378
4381
|
retry_limit -= 1
|
|
4379
4382
|
return False, retry_limit, attempt_line_starts
|
|
4380
4383
|
|
|
4384
|
+
def _child_pids(pid):
|
|
4385
|
+
'''The pids of pid's child processes; [] when neither /proc nor pgrep can list them.'''
|
|
4386
|
+
try:
|
|
4387
|
+
with open('/proc/{0}/task/{0}/children'.format(pid)) as children:
|
|
4388
|
+
return [int(child) for child in children.read().split()]
|
|
4389
|
+
except (OSError, ValueError):
|
|
4390
|
+
pass
|
|
4391
|
+
try:
|
|
4392
|
+
listed = subprocess.run(['pgrep', '-P', str(pid)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=1).stdout
|
|
4393
|
+
return [int(child) for child in listed.split()]
|
|
4394
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
4395
|
+
return []
|
|
4396
|
+
|
|
4397
|
+
def _sessions_below(pid):
|
|
4398
|
+
'''
|
|
4399
|
+
The processes below pid that lead a session of their own (sshpass makes its command one); each is
|
|
4400
|
+
its process group. Never init's or mssh's own group: pgrep -P 0 lists init.
|
|
4401
|
+
'''
|
|
4402
|
+
if not isinstance(pid, int) or pid <= 1:
|
|
4403
|
+
return []
|
|
4404
|
+
leaders = []
|
|
4405
|
+
seen = {pid}
|
|
4406
|
+
pids = _child_pids(pid)
|
|
4407
|
+
while pids:
|
|
4408
|
+
child = pids.pop()
|
|
4409
|
+
if child in seen or child <= 1:
|
|
4410
|
+
continue
|
|
4411
|
+
seen.add(child)
|
|
4412
|
+
try:
|
|
4413
|
+
if os.getsid(child) == child:
|
|
4414
|
+
if child != os.getpgrp():
|
|
4415
|
+
leaders.append(child)
|
|
4416
|
+
continue
|
|
4417
|
+
except OSError:
|
|
4418
|
+
continue
|
|
4419
|
+
pids.extend(_child_pids(child))
|
|
4420
|
+
return leaders
|
|
4421
|
+
|
|
4422
|
+
def _stop_command(proc, formatedCMD):
|
|
4423
|
+
'''
|
|
4424
|
+
Stop a command mssh gives up on (-t, Ctrl C) as Ctrl C at a terminal would, then as mssh always did.
|
|
4425
|
+
|
|
4426
|
+
sshpass runs its command as a session of its own on a terminal of its own, and passes SIGINT on as
|
|
4427
|
+
a typed Ctrl C only once ssh has written there. While ssh is still connecting, any signal ends
|
|
4428
|
+
sshpass at once and hangs up that terminal: ssh-copy-id, whose trap does not cover SIGHUP, then left
|
|
4429
|
+
an empty ~/.ssh/ssh-copy-id.XXXXXXXXXX. So the command under sshpass gets the SIGINT first, as a
|
|
4430
|
+
typed Ctrl C would give it, and a moment to end.
|
|
4431
|
+
'''
|
|
4432
|
+
if os.name == 'posix' and _sshpass_program(formatedCMD):
|
|
4433
|
+
leaders = _sessions_below(proc.pid)
|
|
4434
|
+
for leader in leaders:
|
|
4435
|
+
try:
|
|
4436
|
+
os.killpg(leader, signal.SIGINT)
|
|
4437
|
+
except OSError:
|
|
4438
|
+
pass
|
|
4439
|
+
if leaders:
|
|
4440
|
+
try:
|
|
4441
|
+
proc.wait(timeout=1)
|
|
4442
|
+
except subprocess.TimeoutExpired:
|
|
4443
|
+
pass
|
|
4444
|
+
if proc.poll() is None:
|
|
4445
|
+
proc.send_signal(signal.SIGINT)
|
|
4446
|
+
time.sleep(0.1)
|
|
4447
|
+
proc.terminate()
|
|
4448
|
+
|
|
4381
4449
|
# sshpass's own exit codes: 5 ssh asked for the password again, 6 ssh asked whether to trust a host key it
|
|
4382
4450
|
# does not know, 7 ssh warned that the key known for the host's IP address differs and asked whether to go on
|
|
4383
4451
|
_SSHPASS_EXIT_CODES = (5, 6, 7)
|
|
@@ -5650,6 +5718,12 @@ def __generate_display(stdscr, hosts, lineToDisplay = -1,curserPosition = 0, min
|
|
|
5650
5718
|
host_window.noutrefresh()
|
|
5651
5719
|
host.lastPrintedUpdateTime = host.lastUpdateTime
|
|
5652
5720
|
hosts_to_display, host_stats,rearrangedHosts = _get_hosts_to_display(hosts, max_num_hosts,hosts_to_display, indexOffset)
|
|
5721
|
+
# a host coming into a window is drawn whole: the lines noted for hosts without one would only
|
|
5722
|
+
# pile up, one entry per line of their output
|
|
5723
|
+
shown = set(hosts_to_display)
|
|
5724
|
+
for host in hosts:
|
|
5725
|
+
if host.lineNumToPrintSet and host not in shown:
|
|
5726
|
+
host.lineNumToPrintSet = set()
|
|
5653
5727
|
if help_shown:
|
|
5654
5728
|
help_window.touchwin()
|
|
5655
5729
|
help_window.noutrefresh()
|
|
@@ -17,6 +17,22 @@ import pytest
|
|
|
17
17
|
|
|
18
18
|
REPO = Path(__file__).resolve().parents[1]
|
|
19
19
|
ENGINE = [sys.executable, '-I', str(REPO / 'mssh_complete.py')]
|
|
20
|
+
# 2026-10-13 00:00 UTC, when the histories these tests write were given their fixed times (1790000000 on)
|
|
21
|
+
WRITTEN = 1791849600
|
|
22
|
+
|
|
23
|
+
def aged(stamp):
|
|
24
|
+
'''
|
|
25
|
+
A fixed time from before WRITTEN, moved to be as old now as it was then. The engine weighs history
|
|
26
|
+
by its age on the real clock (a use halves its weight every week), so fixed times would rank
|
|
27
|
+
differently as the days pass: a suggestion the tests do not expect appeared 24 days on.
|
|
28
|
+
'''
|
|
29
|
+
stamp = int(stamp)
|
|
30
|
+
return stamp + max(0, int(time.time()) - WRITTEN) if stamp < WRITTEN else stamp
|
|
31
|
+
|
|
32
|
+
def _aged_line(line):
|
|
33
|
+
'''An mssh history line with its time aged.'''
|
|
34
|
+
stamp, tab, rest = line.partition('\t')
|
|
35
|
+
return '{}\t{}'.format(aged(stamp), rest) if tab and stamp.isdigit() else line
|
|
20
36
|
|
|
21
37
|
def require_shell(name):
|
|
22
38
|
'''The path of shell name; skip the test if it is missing, unless MSSH_REQUIRE_SHELLS lists it.'''
|
|
@@ -30,7 +46,7 @@ def require_shell(name):
|
|
|
30
46
|
|
|
31
47
|
def make_home(tmp_path, history_lines=()):
|
|
32
48
|
'''
|
|
33
|
-
A private HOME and XDG_RUNTIME_DIR holding the given mssh history. Returns (env, home).
|
|
49
|
+
A private HOME and XDG_RUNTIME_DIR holding the given mssh history, its fixed times aged. Returns (env, home).
|
|
34
50
|
|
|
35
51
|
The prober's lock is taken, so no prober runs (no DNS, no ping): tests that want answers about
|
|
36
52
|
hosts write host-state.tsv themselves. An mssh config in HOME keeps this machine's /etc/hosts and
|
|
@@ -43,7 +59,7 @@ def make_home(tmp_path, history_lines=()):
|
|
|
43
59
|
runtime.mkdir(mode=0o700)
|
|
44
60
|
(runtime / 'multiSSH3').mkdir(mode=0o700)
|
|
45
61
|
(runtime / 'multiSSH3' / 'probe.lock').write_text('')
|
|
46
|
-
(home / '.mssh_history').write_text(''.join(line + '\n' for line in history_lines))
|
|
62
|
+
(home / '.mssh_history').write_text(''.join(_aged_line(line) + '\n' for line in history_lines))
|
|
47
63
|
(home / '.multiSSH3.config.json').write_text(json.dumps({'DEFAULT_HOST_FILE': str(home / 'hosts'),
|
|
48
64
|
'DEFAULT_ENV_FILES': [path for path in multiSSH3.DEFAULT_ENV_FILES if path.startswith('~')]}))
|
|
49
65
|
env = {
|
|
@@ -895,9 +895,10 @@ def test_unreachable_members_of_an_alias_with_a_user(skipping, line, word):
|
|
|
895
895
|
# a member written with its user keeps it, -u is for the others
|
|
896
896
|
('mssh -u root dbs uptime', ['postgres@db1', 'root@node02'], 'db1, node02'),
|
|
897
897
|
('mssh -u root dbs uptime', ['root@db1'], None),
|
|
898
|
-
# a user typed with the alias
|
|
899
|
-
('mssh root@dbs uptime', ['root@
|
|
900
|
-
('mssh root@dbs uptime', ['
|
|
898
|
+
# a user typed with the alias replaces the member's: mssh runs root@db1
|
|
899
|
+
('mssh root@dbs uptime', ['root@db1'], 'db1'),
|
|
900
|
+
('mssh root@dbs uptime', ['postgres@db1'], None),
|
|
901
|
+
('mssh root@dbs uptime', ['root@postgres@db1'], None),
|
|
901
902
|
])
|
|
902
903
|
def test_unreachable_members_of_an_alias_written_with_users(skipping, tmp_path, line, failed, down):
|
|
903
904
|
# dbs=postgres@db1,node02 in an env file
|
|
@@ -13,7 +13,7 @@ import time
|
|
|
13
13
|
import pytest
|
|
14
14
|
|
|
15
15
|
import mssh_complete as mc
|
|
16
|
-
from complete_shell_harness import (ENGINE, bash_session, blesh_session, build_index, fish_session, make_home,
|
|
16
|
+
from complete_shell_harness import (ENGINE, aged, bash_session, blesh_session, build_index, fish_session, make_home,
|
|
17
17
|
require_shell, without_command, zsh_session)
|
|
18
18
|
|
|
19
19
|
HISTORY = ['17900000{:02d}\t/x/multiSSH3.py\t-su\tgpu[01-16]\t"nvidia-smi"'.format(number) for number in range(6)] + [
|
|
@@ -1845,7 +1845,7 @@ def test_fish_ignores_the_operators_functions(home, where):
|
|
|
1845
1845
|
# lines only fish's own history has: a local command, and an mssh line
|
|
1846
1846
|
data = home / '.local' / 'share' / 'fish'
|
|
1847
1847
|
data.mkdir(parents=True)
|
|
1848
|
-
(data / 'fish_history').write_text('- cmd: echo three\n when:
|
|
1848
|
+
(data / 'fish_history').write_text('- cmd: echo three\n when: {}\n- cmd: mssh zzq9 fishonly\n when: {}\n'.format(aged(1790000000), aged(1790000001)))
|
|
1849
1849
|
config = home / '.config' / 'fish'
|
|
1850
1850
|
(config / 'completions').mkdir(parents=True)
|
|
1851
1851
|
(config / 'completions' / 'mssh.fish').write_text(mc.render_glue('fish', ENGINE))
|
|
@@ -10,7 +10,7 @@ import time
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
12
|
import mssh_complete as mc
|
|
13
|
-
from complete_shell_harness import (ENGINE, REPO, bash_session, build_index, fish_complete, fish_session, make_home,
|
|
13
|
+
from complete_shell_harness import (ENGINE, REPO, aged, bash_session, build_index, fish_complete, fish_session, make_home,
|
|
14
14
|
require_shell, without_command, zsh_session)
|
|
15
15
|
|
|
16
16
|
HISTORY = [
|
|
@@ -315,7 +315,7 @@ def test_fish_without_timeout_shows_nothing_but_the_completion(setup, tmp_path,
|
|
|
315
315
|
# more shell history than a pipe holds: the slow engine never reads it, and fish must not wait to write it
|
|
316
316
|
history = home / '.local' / 'share' / 'fish'
|
|
317
317
|
history.mkdir(parents=True)
|
|
318
|
-
(history / 'fish_history').write_text(''.join('- cmd: echo {} {}\n when: {}\n'.format(number, 'x' * 300, 1790000000 + number) for number in range(1000)))
|
|
318
|
+
(history / 'fish_history').write_text(''.join('- cmd: echo {} {}\n when: {}\n'.format(number, 'x' * 300, aged(1790000000 + number)) for number in range(1000)))
|
|
319
319
|
if engine == 'answering':
|
|
320
320
|
command, typed, expected = ENGINE, 'mssh gp', 'mssh gpu\\[01-16\\] '
|
|
321
321
|
else:
|
|
@@ -727,3 +727,31 @@ def test_curses_print_too_small_keeps_the_keys_typed_for_the_display(stub_curses
|
|
|
727
727
|
# the resize redraws; the keys typed meanwhile, the Esc after text among them, come back in their order
|
|
728
728
|
assert len(displays) == 2 and displays[1][2:] == TOO_SMALL[:6] + ("new config",)
|
|
729
729
|
assert ungot == [27, ord("y")]
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def test_hosts_without_a_window_keep_no_lines_to_draw(stub_curses_harness, fake_host, monkeypatch):
|
|
733
|
+
# a host coming into a window is drawn whole: the lines noted while it had none were never drawn,
|
|
734
|
+
# and held one entry per line of its output until it came in
|
|
735
|
+
shown, hidden = _running_host(fake_host), _running_host(fake_host)
|
|
736
|
+
shown.name, hidden.name = "mock-a", "mock-b"
|
|
737
|
+
hidden.output = ["line {}".format(number) for number in range(1000)]
|
|
738
|
+
hidden.lineNumToPrintSet = set(range(1000))
|
|
739
|
+
polls = []
|
|
740
|
+
|
|
741
|
+
def finish_on_second_poll():
|
|
742
|
+
polls.append(None)
|
|
743
|
+
if len(polls) == 2:
|
|
744
|
+
shown.returncode = hidden.returncode = 0
|
|
745
|
+
return -1
|
|
746
|
+
|
|
747
|
+
monkeypatch.setattr(stub_curses_harness.window, "getch", finish_on_second_poll)
|
|
748
|
+
monkeypatch.setattr(multiSSH3.time, "perf_counter", lambda: 1.0)
|
|
749
|
+
monkeypatch.setattr(multiSSH3.time, "sleep", lambda seconds: None)
|
|
750
|
+
|
|
751
|
+
# room for one host window
|
|
752
|
+
multiSSH3.__generate_display(stub_curses_harness.window, [shown, hidden], min_char_len=80, min_line_len=20)
|
|
753
|
+
|
|
754
|
+
drawn = [arg for window in stub_curses_harness.windows for method, args, kwargs in window.calls
|
|
755
|
+
for arg in args if method == "addnstr" and isinstance(arg, str)]
|
|
756
|
+
assert any("mock-a" in text for text in drawn) and not any("mock-b" in text for text in drawn)
|
|
757
|
+
assert hidden.lineNumToPrintSet == set()
|
|
@@ -466,6 +466,21 @@ def test_an_alias_inside_itself_is_the_host_of_that_name(env_aliases, capsys):
|
|
|
466
466
|
assert capsys.readouterr().err == ""
|
|
467
467
|
|
|
468
468
|
|
|
469
|
+
def test_a_user_typed_with_an_alias_replaces_the_user_a_member_is_written_with(env_aliases, capsys):
|
|
470
|
+
# root@postgres@db1 made ssh log in to db1 as the user "root@postgres": a failed login on every
|
|
471
|
+
# such host. Typed on the command line, root wins, as it does over ssh config's User.
|
|
472
|
+
known = env_aliases('dbs="postgres@db1,db2"\n', dns=["db1", "db2"])
|
|
473
|
+
|
|
474
|
+
assert multiSSH3.expand_hostnames(["root@dbs"]) == {"root@db1": known["db1"], "root@db2": known["db2"]}
|
|
475
|
+
assert multiSSH3.expand_hostnames(["u[1-2]@dbs"]) == {
|
|
476
|
+
"u1@db1": known["db1"], "u2@db1": known["db1"], "u1@db2": known["db2"], "u2@db2": known["db2"]}
|
|
477
|
+
assert multiSSH3.expand_hostnames(["dbs"]) == {"postgres@db1": known["db1"], "db2": known["db2"]}
|
|
478
|
+
# without DNS the same names
|
|
479
|
+
assert multiSSH3.expand_hostnames_local(["root@dbs"])[0] == ["root@db1", "root@db2"]
|
|
480
|
+
assert multiSSH3.expand_hostnames_local(["dbs"])[0] == ["db2", "postgres@db1"]
|
|
481
|
+
assert capsys.readouterr().err == ""
|
|
482
|
+
|
|
483
|
+
|
|
469
484
|
def test_alias_values_are_split_like_the_hosts_argument(env_aliases, monkeypatch, capsys):
|
|
470
485
|
monkeypatch.setattr(multiSSH3, "_no_env", False)
|
|
471
486
|
monkeypatch.setenv("MSSH_TEST_SPACED", "127.0.0.7 127.0.0.8")
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
-t and Ctrl C stop only mssh's local ssh (or rsync, scp, ...): the remote command gets no signal and
|
|
3
|
+
may run on. -t fires after that many seconds without output. A host mssh stopped says so, and what
|
|
4
|
+
stopping it left; one stopped by Ctrl C is never a success (sshpass exits 0 when interrupted). The
|
|
5
|
+
command sshpass runs gets the SIGINT first, as Ctrl C at a terminal gives it: ssh-copy-id cleans up.
|
|
6
|
+
"""
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
import multiSSH3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RunningProc:
|
|
22
|
+
"""A command that runs until it is terminated."""
|
|
23
|
+
def __init__(self):
|
|
24
|
+
# no process: none to look for below it
|
|
25
|
+
self.pid = None
|
|
26
|
+
self.stdout = io.BytesIO()
|
|
27
|
+
self.stderr = io.BytesIO()
|
|
28
|
+
self.stdin = io.BytesIO()
|
|
29
|
+
self.returncode = None
|
|
30
|
+
self.signals = []
|
|
31
|
+
|
|
32
|
+
def poll(self):
|
|
33
|
+
return self.returncode
|
|
34
|
+
|
|
35
|
+
def communicate(self, *args, **kwargs):
|
|
36
|
+
return b"", b""
|
|
37
|
+
|
|
38
|
+
def send_signal(self, sig):
|
|
39
|
+
self.signals.append(sig)
|
|
40
|
+
|
|
41
|
+
def terminate(self):
|
|
42
|
+
self.returncode = -15
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EndedProc(RunningProc):
|
|
46
|
+
"""A command that has already ended when mssh looks."""
|
|
47
|
+
def __init__(self, returncode, stderr=b""):
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.returncode = returncode
|
|
50
|
+
self.stderr = io.BytesIO(stderr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run(fake_host, monkeypatch, proc, timeout=0, passwds=None, ctrl_c=False, **host_kwargs):
|
|
54
|
+
monkeypatch.setattr(multiSSH3, "_binPaths", {"ssh": "/usr/bin/ssh", "sshpass": "/usr/bin/sshpass", "rsync": "/usr/bin/rsync", "scp": "/usr/bin/scp"})
|
|
55
|
+
monkeypatch.setattr(multiSSH3, "_emo", False)
|
|
56
|
+
|
|
57
|
+
def start(*args, **kwargs):
|
|
58
|
+
# with ctrl_c, Ctrl C comes once the command runs (one pressed before would keep it from starting)
|
|
59
|
+
if ctrl_c:
|
|
60
|
+
multiSSH3._emo = True
|
|
61
|
+
return proc
|
|
62
|
+
monkeypatch.setattr(multiSSH3.subprocess, "Popen", start)
|
|
63
|
+
host = fake_host("mock-a", **host_kwargs)
|
|
64
|
+
# the command starts at 0 and is silent from then on
|
|
65
|
+
ticks = iter([0.0, 2.0])
|
|
66
|
+
monkeypatch.setattr(multiSSH3.time, "monotonic", lambda: next(ticks, 2.0))
|
|
67
|
+
monkeypatch.setattr(multiSSH3.time, "sleep", lambda seconds: None)
|
|
68
|
+
multiSSH3.run_command(host, threading.Semaphore(1), timeout, passwds)
|
|
69
|
+
return host
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_a_timeout_says_the_command_may_still_run_on_the_host(fake_host, monkeypatch):
|
|
73
|
+
host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, command="sleep 100")
|
|
74
|
+
|
|
75
|
+
assert host.returncode == 124
|
|
76
|
+
assert host.stderr == ["Timeout!", "No output for 1 s, so ssh was stopped: the command may still run on the host."]
|
|
77
|
+
assert "No output for 1 s, so ssh was stopped: the command may still run on the host." in host.output
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_a_timeout_with_a_password_names_ssh_not_sshpass(fake_host, monkeypatch):
|
|
81
|
+
host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, passwds="pw", command="sleep 100")
|
|
82
|
+
|
|
83
|
+
assert host.stderr[-1] == "No output for 1 s, so ssh was stopped: the command may still run on the host."
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_copy_that_timed_out_may_be_incomplete(fake_host, monkeypatch):
|
|
87
|
+
# scp: a failed rsync may be tried again with scp
|
|
88
|
+
host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, command="/tmp/dest", files=["/tmp/a"], scp=True)
|
|
89
|
+
|
|
90
|
+
assert host.returncode == 124
|
|
91
|
+
assert host.stderr == ["Timeout!", "No output for 1 s, so scp was stopped: the copy may be incomplete."]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_ctrl_c_while_the_command_runs(fake_host, monkeypatch):
|
|
95
|
+
proc = RunningProc()
|
|
96
|
+
|
|
97
|
+
host = _run(fake_host, monkeypatch, proc, command="sleep 100", ctrl_c=True)
|
|
98
|
+
|
|
99
|
+
assert proc.signals == [multiSSH3.signal.SIGINT]
|
|
100
|
+
assert host.returncode == 130
|
|
101
|
+
assert host.stderr == ["Ctrl C detected, Emergency Stop!", "ssh was stopped: the command may still run on the host."]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_ctrl_c_that_reached_ssh_first(fake_host, monkeypatch):
|
|
105
|
+
# the terminal's Ctrl C reaches ssh as well as mssh: ssh ends before mssh stops it, saying only
|
|
106
|
+
# "Killed by signal 2." (hidden)
|
|
107
|
+
|
|
108
|
+
host = _run(fake_host, monkeypatch, EndedProc(255, b"Killed by signal 2.\n"), command="sleep 100", ctrl_c=True)
|
|
109
|
+
|
|
110
|
+
assert host.returncode == 130
|
|
111
|
+
assert host.stderr == ["Ctrl C detected, Emergency Stop!", "ssh was stopped: the command may still run on the host."]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_ctrl_c_with_sshpass_is_not_a_success(fake_host, monkeypatch):
|
|
115
|
+
# sshpass exits 0 when Ctrl C stops it
|
|
116
|
+
|
|
117
|
+
host = _run(fake_host, monkeypatch, EndedProc(0), passwds="pw", command="sleep 100", ctrl_c=True)
|
|
118
|
+
|
|
119
|
+
assert host.returncode == 130
|
|
120
|
+
assert host.stderr[-1] == "ssh was stopped: the command may still run on the host."
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_a_command_that_ends_on_its_own_keeps_its_result(fake_host, monkeypatch):
|
|
124
|
+
host = _run(fake_host, monkeypatch, EndedProc(3, b"real error\n"), timeout=1, command="false")
|
|
125
|
+
|
|
126
|
+
assert host.returncode == 3
|
|
127
|
+
assert host.stderr == ["real error"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_the_timeout_help_says_it_counts_time_without_output():
|
|
131
|
+
help_text = next(action.help for action in multiSSH3.get_parser()._actions if "--timeout" in action.option_strings)
|
|
132
|
+
|
|
133
|
+
assert "without output" in help_text
|
|
134
|
+
assert "Timeout for each command" not in help_text
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
posix_only = pytest.mark.skipif(os.name != "posix", reason="sessions and terminals are POSIX")
|
|
138
|
+
|
|
139
|
+
# sshpass 1.10 while ssh has written nothing to sshpass's terminal yet: the command runs as a session
|
|
140
|
+
# leader on a new terminal, and SIGINT or SIGTERM ends sshpass at once, which hangs up that terminal
|
|
141
|
+
FAKE_SSHPASS = """#!{python}
|
|
142
|
+
import os, pty, signal, sys
|
|
143
|
+
pid, master = pty.fork()
|
|
144
|
+
if pid == 0:
|
|
145
|
+
os.execv(sys.argv[2], sys.argv[2:])
|
|
146
|
+
signal.signal(signal.SIGINT, lambda *_: os._exit(0))
|
|
147
|
+
signal.signal(signal.SIGTERM, lambda *_: os._exit(0))
|
|
148
|
+
while True:
|
|
149
|
+
try:
|
|
150
|
+
if not os.read(master, 1024):
|
|
151
|
+
break
|
|
152
|
+
except OSError:
|
|
153
|
+
break
|
|
154
|
+
_, status = os.waitpid(pid, 0)
|
|
155
|
+
sys.exit(status >> 8)
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
# ssh-copy-id's scratch directory and the trap that removes it (not on SIGHUP); sleep is ssh connecting
|
|
159
|
+
FAKE_SSH_COPY_ID = """#!/bin/sh
|
|
160
|
+
SCRATCH_DIR=$(mktemp -d ~/.ssh/ssh-copy-id.XXXXXXXXXX)
|
|
161
|
+
trap 'rm -rf "$SCRATCH_DIR"; echo cleaned >> ~/cleaned' EXIT TERM INT QUIT
|
|
162
|
+
sleep 30
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _tool(path, text):
|
|
167
|
+
path.write_text(text)
|
|
168
|
+
path.chmod(0o755)
|
|
169
|
+
return str(path)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.fixture
|
|
173
|
+
def copy_id_that_hangs(monkeypatch, tmp_path):
|
|
174
|
+
home = tmp_path / "home"
|
|
175
|
+
(home / ".ssh").mkdir(parents=True)
|
|
176
|
+
monkeypatch.setenv("HOME", str(home))
|
|
177
|
+
bins = tmp_path / "bin"
|
|
178
|
+
bins.mkdir()
|
|
179
|
+
monkeypatch.setattr(multiSSH3, "_binPaths", {
|
|
180
|
+
"sh": "/bin/sh",
|
|
181
|
+
"ssh": str(bins / "ssh"),
|
|
182
|
+
"sshpass": _tool(bins / "sshpass", FAKE_SSHPASS.format(python=sys.executable)),
|
|
183
|
+
"ssh-copy-id": _tool(bins / "ssh-copy-id", FAKE_SSH_COPY_ID),
|
|
184
|
+
})
|
|
185
|
+
monkeypatch.setattr(multiSSH3, "getIP", lambda hostname, local=False: "127.0.0.1")
|
|
186
|
+
monkeypatch.setattr(multiSSH3, "_ssh_config_values", lambda hostname: {"proxyjump": "none", "proxycommand": "none"})
|
|
187
|
+
monkeypatch.setattr(multiSSH3, "_unavailable_hosts_file_path", lambda create=True: str(tmp_path / "unavailable.csv"))
|
|
188
|
+
monkeypatch.setattr(multiSSH3, "__mainReturnCode", 0)
|
|
189
|
+
monkeypatch.setattr(multiSSH3, "__failedHosts", set())
|
|
190
|
+
return home
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@posix_only
|
|
194
|
+
def test_a_timed_out_ssh_copy_id_under_sshpass_cleans_up_after_itself(copy_id_that_hangs):
|
|
195
|
+
# sshpass ended at once and hung up ssh-copy-id, whose trap does not cover SIGHUP: every host that
|
|
196
|
+
# timed out left an empty ~/.ssh/ssh-copy-id.XXXXXXXXXX
|
|
197
|
+
home = copy_id_that_hangs
|
|
198
|
+
start = time.monotonic()
|
|
199
|
+
|
|
200
|
+
hosts = multiSSH3.run_command_on_hosts(hosts="mock-a", copy_id=True, password="pw", timeout=1,
|
|
201
|
+
no_watch=True, quiet=True, no_history=True, called=True, will_update_unreachable_hosts=False)
|
|
202
|
+
|
|
203
|
+
assert [host.returncode for host in hosts] == [124]
|
|
204
|
+
deadline = time.monotonic() + 5
|
|
205
|
+
while not (home / "cleaned").exists() and time.monotonic() < deadline:
|
|
206
|
+
time.sleep(0.05)
|
|
207
|
+
assert list((home / ".ssh").glob("ssh-copy-id.*")) == []
|
|
208
|
+
assert (home / "cleaned").exists()
|
|
209
|
+
# stopped, not left to sleep out its 30 s
|
|
210
|
+
assert time.monotonic() - start < 10
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@posix_only
|
|
214
|
+
def test_ssh_copy_id_under_sshpass_stopped_by_ctrl_c_cleans_up_after_itself(copy_id_that_hangs, monkeypatch):
|
|
215
|
+
home = copy_id_that_hangs
|
|
216
|
+
monkeypatch.setattr(multiSSH3, "_emo", False)
|
|
217
|
+
monkeypatch.setattr(multiSSH3, "_stopping", False)
|
|
218
|
+
# the first Ctrl C, once ssh-copy-id is waiting for ssh
|
|
219
|
+
threading.Timer(0.5, lambda: setattr(multiSSH3, "_emo", True)).start()
|
|
220
|
+
|
|
221
|
+
hosts = multiSSH3.run_command_on_hosts(hosts="mock-a", copy_id=True, password="pw",
|
|
222
|
+
no_watch=True, quiet=True, no_history=True, called=True, will_update_unreachable_hosts=False)
|
|
223
|
+
|
|
224
|
+
assert [host.returncode for host in hosts] == [130]
|
|
225
|
+
deadline = time.monotonic() + 5
|
|
226
|
+
while not (home / "cleaned").exists() and time.monotonic() < deadline:
|
|
227
|
+
time.sleep(0.05)
|
|
228
|
+
assert list((home / ".ssh").glob("ssh-copy-id.*")) == []
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@posix_only
|
|
232
|
+
@pytest.mark.skipif(not shutil.which("pgrep"), reason="pgrep is the fallback")
|
|
233
|
+
def test_child_processes_are_found_without_proc_children_files(monkeypatch):
|
|
234
|
+
child = subprocess.Popen(["sleep", "30"])
|
|
235
|
+
try:
|
|
236
|
+
def no_children_file(path, *args, **kwargs):
|
|
237
|
+
raise FileNotFoundError(path)
|
|
238
|
+
monkeypatch.setattr(multiSSH3, "open", no_children_file, raising=False)
|
|
239
|
+
assert child.pid in multiSSH3._child_pids(os.getpid())
|
|
240
|
+
finally:
|
|
241
|
+
child.kill()
|
|
242
|
+
child.wait()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@posix_only
|
|
246
|
+
def test_sessions_below_a_process_are_found(tmp_path):
|
|
247
|
+
# sh -> python (setsid) -> sleep: the python process leads a session, as sshpass's command does
|
|
248
|
+
ready = tmp_path / "ready"
|
|
249
|
+
script = 'import os, time; os.setsid(); open({!r}, "w").close(); time.sleep(30)'.format(str(ready))
|
|
250
|
+
proc = subprocess.Popen(["/bin/sh", "-c", "{} -c {}; true".format(shlex.quote(sys.executable), shlex.quote(script))])
|
|
251
|
+
try:
|
|
252
|
+
deadline = time.monotonic() + 5
|
|
253
|
+
while not ready.exists() and time.monotonic() < deadline:
|
|
254
|
+
time.sleep(0.02)
|
|
255
|
+
leaders = multiSSH3._sessions_below(proc.pid)
|
|
256
|
+
assert len(leaders) == 1 and leaders[0] != proc.pid
|
|
257
|
+
assert os.getsid(leaders[0]) == leaders[0]
|
|
258
|
+
finally:
|
|
259
|
+
for leader in multiSSH3._sessions_below(proc.pid):
|
|
260
|
+
os.killpg(leader, 9)
|
|
261
|
+
proc.kill()
|
|
262
|
+
proc.wait()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_a_copy_id_that_timed_out_names_ssh_copy_id(monkeypatch):
|
|
266
|
+
# --copy_id runs sshpass in an sh -c line: the note named sh. Stopped, ssh-copy-id cleans up and goes
|
|
267
|
+
# on, saying "All keys were skipped because they already exist on the remote system."
|
|
268
|
+
monkeypatch.setattr(multiSSH3, "_binPaths", {"sh": "/bin/sh", "sshpass": "/usr/bin/sshpass", "ssh-copy-id": "/usr/bin/ssh-copy-id"})
|
|
269
|
+
line = ["/bin/sh", "-c", "/usr/bin/sshpass -e /usr/bin/ssh-copy-id -o ConnectTimeout=30 bob@mock-a"]
|
|
270
|
+
|
|
271
|
+
assert multiSSH3._stopped_command_note(line) == "ssh-copy-id was stopped: the key may not be installed"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@posix_only
|
|
275
|
+
def test_no_session_is_looked_for_below_init_or_no_process():
|
|
276
|
+
# pgrep -P 0 lists init: its group must never get mssh's SIGINT
|
|
277
|
+
assert multiSSH3._sessions_below(0) == []
|
|
278
|
+
assert multiSSH3._sessions_below(1) == []
|
|
279
|
+
assert multiSSH3._sessions_below(None) == []
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_a_timed_out_ssh_found_under_another_name_is_still_ssh(monkeypatch):
|
|
283
|
+
# Windows finds ssh as ...\ssh.EXE: mssh knows it by the path it found
|
|
284
|
+
monkeypatch.setattr(multiSSH3, "_binPaths", {"ssh": "/opt/openssh/ssh.EXE", "scp": "/opt/openssh/scp.EXE"})
|
|
285
|
+
|
|
286
|
+
assert multiSSH3._stopped_command_note(["/opt/openssh/ssh.EXE", "mock-a", "uptime"]) == "ssh was stopped: the command may still run on the host"
|
|
287
|
+
assert multiSSH3._stopped_command_note(["/opt/openssh/scp.EXE", "a", "mock-a:b"]) == "scp was stopped: the copy may be incomplete"
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
-t and Ctrl C stop only mssh's local ssh (or rsync, scp, ...): the remote command gets no signal and
|
|
3
|
-
may run on. -t fires after that many seconds without output. A host mssh stopped says so, and what
|
|
4
|
-
stopping it left; one stopped by Ctrl C is never a success (sshpass exits 0 when interrupted).
|
|
5
|
-
"""
|
|
6
|
-
import io
|
|
7
|
-
import threading
|
|
8
|
-
|
|
9
|
-
import multiSSH3
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class RunningProc:
|
|
13
|
-
"""A command that runs until it is terminated."""
|
|
14
|
-
def __init__(self):
|
|
15
|
-
self.stdout = io.BytesIO()
|
|
16
|
-
self.stderr = io.BytesIO()
|
|
17
|
-
self.stdin = io.BytesIO()
|
|
18
|
-
self.returncode = None
|
|
19
|
-
self.signals = []
|
|
20
|
-
|
|
21
|
-
def poll(self):
|
|
22
|
-
return self.returncode
|
|
23
|
-
|
|
24
|
-
def communicate(self, *args, **kwargs):
|
|
25
|
-
return b"", b""
|
|
26
|
-
|
|
27
|
-
def send_signal(self, sig):
|
|
28
|
-
self.signals.append(sig)
|
|
29
|
-
|
|
30
|
-
def terminate(self):
|
|
31
|
-
self.returncode = -15
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class EndedProc(RunningProc):
|
|
35
|
-
"""A command that has already ended when mssh looks."""
|
|
36
|
-
def __init__(self, returncode, stderr=b""):
|
|
37
|
-
super().__init__()
|
|
38
|
-
self.returncode = returncode
|
|
39
|
-
self.stderr = io.BytesIO(stderr)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _run(fake_host, monkeypatch, proc, timeout=0, passwds=None, ctrl_c=False, **host_kwargs):
|
|
43
|
-
monkeypatch.setattr(multiSSH3, "_binPaths", {"ssh": "/usr/bin/ssh", "sshpass": "/usr/bin/sshpass", "rsync": "/usr/bin/rsync", "scp": "/usr/bin/scp"})
|
|
44
|
-
monkeypatch.setattr(multiSSH3, "_emo", False)
|
|
45
|
-
|
|
46
|
-
def start(*args, **kwargs):
|
|
47
|
-
# with ctrl_c, Ctrl C comes once the command runs (one pressed before would keep it from starting)
|
|
48
|
-
if ctrl_c:
|
|
49
|
-
multiSSH3._emo = True
|
|
50
|
-
return proc
|
|
51
|
-
monkeypatch.setattr(multiSSH3.subprocess, "Popen", start)
|
|
52
|
-
host = fake_host("mock-a", **host_kwargs)
|
|
53
|
-
# the command starts at 0 and is silent from then on
|
|
54
|
-
ticks = iter([0.0, 2.0])
|
|
55
|
-
monkeypatch.setattr(multiSSH3.time, "monotonic", lambda: next(ticks, 2.0))
|
|
56
|
-
monkeypatch.setattr(multiSSH3.time, "sleep", lambda seconds: None)
|
|
57
|
-
multiSSH3.run_command(host, threading.Semaphore(1), timeout, passwds)
|
|
58
|
-
return host
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_a_timeout_says_the_command_may_still_run_on_the_host(fake_host, monkeypatch):
|
|
62
|
-
host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, command="sleep 100")
|
|
63
|
-
|
|
64
|
-
assert host.returncode == 124
|
|
65
|
-
assert host.stderr == ["Timeout!", "No output for 1 s, so ssh was stopped: the command may still run on the host."]
|
|
66
|
-
assert "No output for 1 s, so ssh was stopped: the command may still run on the host." in host.output
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def test_a_timeout_with_a_password_names_ssh_not_sshpass(fake_host, monkeypatch):
|
|
70
|
-
host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, passwds="pw", command="sleep 100")
|
|
71
|
-
|
|
72
|
-
assert host.stderr[-1] == "No output for 1 s, so ssh was stopped: the command may still run on the host."
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_a_copy_that_timed_out_may_be_incomplete(fake_host, monkeypatch):
|
|
76
|
-
# scp: a failed rsync may be tried again with scp
|
|
77
|
-
host = _run(fake_host, monkeypatch, RunningProc(), timeout=1, command="/tmp/dest", files=["/tmp/a"], scp=True)
|
|
78
|
-
|
|
79
|
-
assert host.returncode == 124
|
|
80
|
-
assert host.stderr == ["Timeout!", "No output for 1 s, so scp was stopped: the copy may be incomplete."]
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_ctrl_c_while_the_command_runs(fake_host, monkeypatch):
|
|
84
|
-
proc = RunningProc()
|
|
85
|
-
|
|
86
|
-
host = _run(fake_host, monkeypatch, proc, command="sleep 100", ctrl_c=True)
|
|
87
|
-
|
|
88
|
-
assert proc.signals == [multiSSH3.signal.SIGINT]
|
|
89
|
-
assert host.returncode == 130
|
|
90
|
-
assert host.stderr == ["Ctrl C detected, Emergency Stop!", "ssh was stopped: the command may still run on the host."]
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def test_ctrl_c_that_reached_ssh_first(fake_host, monkeypatch):
|
|
94
|
-
# the terminal's Ctrl C reaches ssh as well as mssh: ssh ends before mssh stops it, saying only
|
|
95
|
-
# "Killed by signal 2." (hidden)
|
|
96
|
-
|
|
97
|
-
host = _run(fake_host, monkeypatch, EndedProc(255, b"Killed by signal 2.\n"), command="sleep 100", ctrl_c=True)
|
|
98
|
-
|
|
99
|
-
assert host.returncode == 130
|
|
100
|
-
assert host.stderr == ["Ctrl C detected, Emergency Stop!", "ssh was stopped: the command may still run on the host."]
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def test_ctrl_c_with_sshpass_is_not_a_success(fake_host, monkeypatch):
|
|
104
|
-
# sshpass exits 0 when Ctrl C stops it
|
|
105
|
-
|
|
106
|
-
host = _run(fake_host, monkeypatch, EndedProc(0), passwds="pw", command="sleep 100", ctrl_c=True)
|
|
107
|
-
|
|
108
|
-
assert host.returncode == 130
|
|
109
|
-
assert host.stderr[-1] == "ssh was stopped: the command may still run on the host."
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def test_a_command_that_ends_on_its_own_keeps_its_result(fake_host, monkeypatch):
|
|
113
|
-
host = _run(fake_host, monkeypatch, EndedProc(3, b"real error\n"), timeout=1, command="false")
|
|
114
|
-
|
|
115
|
-
assert host.returncode == 3
|
|
116
|
-
assert host.stderr == ["real error"]
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def test_the_timeout_help_says_it_counts_time_without_output():
|
|
120
|
-
help_text = next(action.help for action in multiSSH3.get_parser()._actions if "--timeout" in action.option_strings)
|
|
121
|
-
|
|
122
|
-
assert "without output" in help_text
|
|
123
|
-
assert "Timeout for each command" not in help_text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|