rsh-tools 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rsh/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """rsh - ssh helpers: sr (root shell), sExec/pExec (run a command on hosts), inventory plugins."""
2
+
3
+ __version__ = '3.0.1'
rsh/build_inventory.py ADDED
@@ -0,0 +1,99 @@
1
+ # -*- coding: utf-8 -*-
2
+ """rshInventory - collect hosts from the enabled inventory plugins and write inventory.yaml."""
3
+
4
+ import os
5
+ import sys
6
+ import json
7
+ import logging
8
+ import re
9
+
10
+ import yaml
11
+
12
+ import rsh.config
13
+ from rsh.plugins import PLUGINS
14
+
15
+ logger = logging.getLogger('rshInventory')
16
+
17
+
18
+ def natural_sort_key(name):
19
+ """Token-aware natural order: 'web2' < 'web10'; the domain part (e.g. '.aws') is ignored."""
20
+ left = str(name).split('.', 1)[0]
21
+ tokens = []
22
+ for rough in re.split(r'[^A-Za-z0-9]+', left):
23
+ for part in re.findall(r'[A-Za-z]+|\d+', rough):
24
+ tokens.append((1, int(part)) if part.isdigit() else (0, part.lower()))
25
+ return tokens
26
+
27
+
28
+ def group_name(*parts):
29
+ name = '_'.join(part.replace('-', '_') for part in parts)
30
+ return re.sub(r'\s+', '_', name)
31
+
32
+
33
+ def collect_records(cfg):
34
+ """Run every enabled plugin, return the merged list of normalized records."""
35
+ records = []
36
+ for key, plugin_class in PLUGINS.items():
37
+ if not (cfg.get(key) or {}).get('enable'):
38
+ logger.debug("plugin '%s' disabled", key)
39
+ continue
40
+ logger.debug("running plugin '%s'", key)
41
+ records.extend(plugin_class(cfg).instances_info())
42
+ for key in cfg:
43
+ if key.endswith('Inventory') and key not in PLUGINS:
44
+ logger.warning("config section '%s' has no matching inventory plugin, known: %s", key, sorted(PLUGINS))
45
+ return records
46
+
47
+
48
+ def build_inventory(records, cfg):
49
+ """Turn plugin records into the {'hosts': ..., 'groups': ...} structure."""
50
+ hosts = {}
51
+ groups = {}
52
+
53
+ def add_to_group(name, host):
54
+ groups.setdefault(name, [])
55
+ if host not in groups[name]:
56
+ groups[name].append(host)
57
+
58
+ for record in records:
59
+ host = record.get('host')
60
+ if not host:
61
+ logger.error("record without 'host': %s", record)
62
+ sys.exit(1)
63
+ module = record.get('rshInventoryModule')
64
+ settings = cfg.get(module) or {}
65
+ if host not in hosts:
66
+ hosts[host] = {'sshHost': record['sshHost']}
67
+ for field in settings.get('hostInfoFields', []):
68
+ hosts[host][field] = record.get(field, 'unknown')
69
+ skip_tags = settings.get('skipTagsForMakeGroups', [])
70
+ for tag in record.get('tags', []):
71
+ if tag['Key'] in skip_tags:
72
+ continue
73
+ add_to_group(group_name('tag', tag['Key'], tag['Value']), host)
74
+ add_to_group(group_name('dc', record.get('hosting', 'unknown'), record['dc']), host)
75
+
76
+ hosts = {name: hosts[name] for name in sorted(hosts, key=natural_sort_key)}
77
+ groups = {name: sorted(groups[name], key=natural_sort_key) for name in sorted(groups, key=natural_sort_key)}
78
+ return {'hosts': hosts, 'groups': groups}
79
+
80
+
81
+ def write_inventory(inventory, path):
82
+ path = os.path.expanduser(path)
83
+ with open(path, 'w') as f:
84
+ yaml.dump(inventory, f, default_flow_style=False, sort_keys=False)
85
+ logger.info("inventory written to '%s': %d hosts, %d groups", path, len(inventory['hosts']), len(inventory['groups']))
86
+
87
+
88
+ def main():
89
+ cfg = rsh.config.cfg
90
+ logger.debug("cfg=%s", json.dumps(cfg, indent=4))
91
+ records = collect_records(cfg)
92
+ logger.debug("records=%s", json.dumps(records, indent=4))
93
+ inventory = build_inventory(records, cfg)
94
+ logger.debug("inventory=%s", inventory)
95
+ write_inventory(inventory, cfg['inventoryFilePath'])
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
rsh/compgen.py ADDED
@@ -0,0 +1,40 @@
1
+ # -*- coding: utf-8 -*-
2
+ """rshCompgen - word lists for bash completion, and the completion script itself."""
3
+
4
+ import argparse
5
+ import logging
6
+ import sys
7
+ from importlib import resources
8
+
9
+ import rsh.config
10
+ from rsh.inventory import load_inventory
11
+
12
+ logger = logging.getLogger('rshCompgen')
13
+
14
+
15
+ def completion_script():
16
+ """Bash completion for sr/sExec/pExec; install with: eval "$(rshCompgen completion)"."""
17
+ return resources.files('rsh').joinpath('completion.bash').read_text()
18
+
19
+
20
+ def parse_args(argv=None):
21
+ parser = argparse.ArgumentParser(description='bash completion helper for sr, sExec and pExec')
22
+ parser.add_argument(
23
+ 'what',
24
+ choices=['hosts', 'groups', 'completion'],
25
+ help="'hosts' or 'groups' print inventory names, 'completion' prints the bash completion script",
26
+ )
27
+ return parser.parse_args(argv)
28
+
29
+
30
+ def main(argv=None):
31
+ args = parse_args(argv)
32
+ if args.what == 'completion':
33
+ sys.stdout.write(completion_script())
34
+ return
35
+ inventory = load_inventory(rsh.config.cfg)
36
+ print(' '.join(inventory[args.what].keys()))
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
rsh/completion.bash ADDED
@@ -0,0 +1,10 @@
1
+ # bash completion for rsh tools; install with: eval "$(rshCompgen completion)"
2
+ _rsh_completion_hosts(){
3
+ COMPREPLY=( $(compgen -W "$(rshCompgen hosts)" -- "$2") )
4
+ }
5
+ _rsh_completion_groups(){
6
+ COMPREPLY=( $(compgen -W "$(rshCompgen groups)" -- "$2") )
7
+ }
8
+ complete -F _rsh_completion_hosts sr
9
+ complete -F _rsh_completion_groups sExec
10
+ complete -F _rsh_completion_groups pExec
rsh/config.py ADDED
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ from os.path import expanduser
6
+ import sys
7
+ import yaml
8
+ import re
9
+ import logging
10
+ from deepmerge import always_merger
11
+
12
+ PROGRAM_NAME = 'rsh'
13
+ HOME_DIR = expanduser("~")
14
+ DEFAULT_CONFIG_FILES = [
15
+ '/etc/' + PROGRAM_NAME + '/config.yaml',
16
+ HOME_DIR + '/.' + PROGRAM_NAME + '.yaml',
17
+ './.config.yaml',
18
+ ]
19
+ cfg = {
20
+ #'logFile': HOME_DIR + '/log/' + PROGRAM_NAME + '/' + PROGRAM_NAME + '.log',
21
+ 'logFile': 'stdout',
22
+ 'logLevel': 'info',
23
+ 'gpgFile': HOME_DIR + '/.pass.gpg',
24
+ 'inventoryFilePath': HOME_DIR + '/inventory.yaml',
25
+ 'sensitiveKeys': [
26
+ 'application_key',
27
+ 'application_secret',
28
+ 'consumer_key',
29
+ ],
30
+ 'awsInventory': {
31
+ 'enable': True,
32
+ 'regions': [],
33
+ 'sshHostField': 'host', # field for 'sshHost', variants: host, PublicDnsName, PublicIpAddress, PrivateDnsName, PrivateIpAddress
34
+ 'skipTagsForMakeGroups': [ # these tag 'key' will skip when creating groups
35
+ 'Name',
36
+ 'Description',
37
+ 'description',
38
+ ],
39
+ 'skipNotRunningInstance': True, # skipping not running instance
40
+ 'hostInfoFields': [
41
+ 'PrivateIpAddress',
42
+ 'PublicIpAddress',
43
+ ],
44
+ 'nameSuffix': '.aws', # suffix for instance names
45
+ },
46
+ 'ovhInventory': {
47
+ 'enable': True,
48
+ 'api': {
49
+ 'endpoint': 'ovh-ca', # Endpoint of API OVH
50
+ 'application_key': 'xxx', # Application Key
51
+ 'application_secret': 'yyy', # Application Secret
52
+ 'consumer_key': 'zzz', # Consumer Key
53
+ },
54
+ 'sshHostField': 'host', # field for 'sshHost', variants: host, customName, publicIp, internalName
55
+ 'skipTagsForMakeGroups': [], # these tag 'key' will skip when creating groups
56
+ 'hostInfoFields': [
57
+ 'publicIp',
58
+ 'internalName', # internal name
59
+ 'dc',
60
+ ],
61
+ },
62
+ 'gceInventory': {
63
+ 'enable': True,
64
+ 'skipNotRunningInstance': True, # skipping not running instance
65
+ 'sshHostField': 'host', # field for 'sshHost', variants: host, PrivateIpAddress, PublicIpAddress
66
+ 'hostInfoFields': [
67
+ 'PrivateIpAddress',
68
+ 'PublicIpAddress',
69
+ 'dc',
70
+ ],
71
+ 'skipTagsForMakeGroups': [ # these tag 'key' will skip when creating groups
72
+ 'name',
73
+ ],
74
+ 'nameSuffix': '.gc', # suffix for instance names
75
+ },
76
+ 'sr': {
77
+ 'ssh': {
78
+ 'command': 'ssh -Y -o ConnectTimeout=5 -o StrictHostKeyChecking=no',
79
+ },
80
+ 'pexpect': {
81
+ 'timeout': 120,
82
+ },
83
+ 'useInventorySshHost': True, # use sshHost from inventory for connect to host
84
+ 'escalatePrivilegesCommands': [], # commands are tried on the host in the listed order until one gives a root shell,
85
+ # e.g. ['su -m', 'sudo -s -E'];
86
+ # must stay empty here: deepmerge appends lists, so a default value would be added to the user's list
87
+ 'escalatePrivilegesCommand': 'sudo -s -E', # deprecated, used only when 'escalatePrivilegesCommands' is empty; variants: 'su -m', 'sudo -s', 'sudo -s -E'
88
+ 'postLoginCommand': None # command will run after login, used to set up the environment
89
+ },
90
+ }
91
+
92
+ # load config files {{
93
+ def load_config_files(cfg, paths):
94
+ """Deep-merge every existing YAML mapping from paths into cfg, in order."""
95
+ for path in paths:
96
+ if not os.path.isfile(path):
97
+ continue
98
+ try:
99
+ with open(path, 'r') as f:
100
+ loaded = yaml.load(f, Loader=yaml.Loader)
101
+ except Exception as e:
102
+ logging.warning("skipping config file '%s': %s", path, e)
103
+ continue
104
+ if not isinstance(loaded, dict):
105
+ logging.warning("skipping config file '%s': top level is not a mapping", path)
106
+ continue
107
+ cfg = always_merger.merge(cfg, loaded)
108
+ return cfg
109
+
110
+ cfg = load_config_files(cfg, DEFAULT_CONFIG_FILES)
111
+ cfg['logDir'] = os.path.dirname(cfg['logFile']) or '.'
112
+ # }}
113
+
114
+ # logging {{
115
+ def parse_log_level(name):
116
+ if re.match(r"^(warn|warning)$", str(name), re.IGNORECASE):
117
+ return logging.WARNING
118
+ if re.match(r"^debug$", str(name), re.IGNORECASE):
119
+ return logging.DEBUG
120
+ return logging.INFO
121
+
122
+
123
+ def find_values(data, keys):
124
+ """Collect values of the given keys anywhere in a nested dict/list structure."""
125
+ if isinstance(keys, str):
126
+ keys = [keys]
127
+ found = []
128
+ if isinstance(data, dict):
129
+ for key, value in data.items():
130
+ if key in keys:
131
+ found.append(value)
132
+ else:
133
+ found.extend(find_values(value, keys))
134
+ elif isinstance(data, list):
135
+ for item in data:
136
+ found.extend(find_values(item, keys))
137
+ return found
138
+
139
+
140
+ class SensitiveFormatter(logging.Formatter):
141
+ """Masks sensitive config values (api keys etc.) in log output."""
142
+
143
+ def __init__(self, fmt, datefmt, sensitive_values):
144
+ super().__init__(fmt, datefmt=datefmt)
145
+ values = [str(value) for value in sensitive_values if value]
146
+ self.pattern = re.compile('|'.join(re.escape(value) for value in values)) if values else None
147
+
148
+ def format(self, record):
149
+ message = super().format(record)
150
+ if self.pattern is None:
151
+ return message
152
+ return self.pattern.sub('[CLASSIFIED]', message)
153
+
154
+
155
+ LOG_FORMAT = '%(asctime)s\t%(name)s\t%(funcName)s\t%(levelname)s\t%(message)s'
156
+ LOG_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S'
157
+
158
+
159
+ def setup_logging(cfg):
160
+ level = parse_log_level(cfg['logLevel'])
161
+ if level > logging.DEBUG:
162
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
163
+ logging.getLogger("requests").setLevel(logging.WARNING)
164
+ if cfg['logFile'] == 'stdout':
165
+ logging.basicConfig(level=level, format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT)
166
+ else:
167
+ os.makedirs(cfg['logDir'], exist_ok=True)
168
+ logging.basicConfig(filename=cfg['logFile'], level=level, format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT)
169
+ formatter = SensitiveFormatter(LOG_FORMAT, LOG_DATE_FORMAT, find_values(cfg, cfg['sensitiveKeys']))
170
+ for handler in logging.root.handlers:
171
+ handler.setFormatter(formatter)
172
+
173
+ setup_logging(cfg)
174
+ # }}
rsh/exec_functions.py ADDED
@@ -0,0 +1,161 @@
1
+ # exec_functions.py
2
+
3
+ import sys
4
+ import os
5
+ import json
6
+ import argparse
7
+ import time
8
+ import logging
9
+ from braceexpand import braceexpand
10
+ from fabric import Connection
11
+ import rsh.config
12
+ from rsh.inventory import load_inventory as _load_inventory, resolve_hosts as _resolve_hosts
13
+
14
+ logger = logging.getLogger(__name__)
15
+ logging.getLogger("paramiko").setLevel(logging.WARNING)
16
+
17
+
18
+ def parse_arguments():
19
+ """
20
+ Parse CLI arguments.
21
+ Returns a tuple: (hosts_or_groups: list[str], command: str, delay: float)
22
+ """
23
+ prog_name = os.path.basename(sys.argv[0])
24
+ parser = argparse.ArgumentParser(
25
+ description='Execute commands via SSH on remote servers.',
26
+ usage=(
27
+ f"[{prog_name}] -g GROUP -c COMMAND "
28
+ f"or [{prog_name}] GROUP COMMAND... "
29
+ f"with optional --delay SECONDS"
30
+ ),
31
+ )
32
+
33
+ parser.add_argument(
34
+ '-g', '--groups', dest='hosts_or_groups', nargs='+',
35
+ help='Hosts or groups (supports brace expansions)'
36
+ )
37
+ parser.add_argument(
38
+ '-c', '--command', nargs=argparse.REMAINDER,
39
+ help='Command to execute'
40
+ )
41
+ parser.add_argument(
42
+ 'fallback', nargs=argparse.REMAINDER,
43
+ help='Fallback: GROUP COMMAND ...'
44
+ )
45
+ parser.add_argument(
46
+ '-d', '--delay', type=float, default=0.0,
47
+ help='Delay (in seconds) between hosts when running sequentially'
48
+ )
49
+
50
+ args = parser.parse_args()
51
+
52
+ if args.hosts_or_groups and args.command:
53
+ hosts_or_groups_raw, command_parts = args.hosts_or_groups, args.command
54
+ elif args.fallback:
55
+ if len(args.fallback) < 2:
56
+ parser.print_usage()
57
+ sys.exit(1)
58
+ hosts_or_groups_raw, command_parts = args.fallback[0], args.fallback[1:]
59
+ else:
60
+ parser.print_usage()
61
+ sys.exit(1)
62
+
63
+ hosts_or_groups = (
64
+ list(braceexpand(hosts_or_groups_raw))
65
+ if isinstance(hosts_or_groups_raw, str)
66
+ else sum([list(braceexpand(x)) for x in hosts_or_groups_raw], [])
67
+ )
68
+
69
+ return hosts_or_groups, ' '.join(command_parts), float(args.delay)
70
+
71
+
72
+ def load_inventory():
73
+ """Load inventory file defined in rsh.config.cfg (see rsh.inventory)."""
74
+ return _load_inventory(rsh.config.cfg)
75
+
76
+
77
+ def resolve_hosts(hosts_or_groups, inventory):
78
+ """Resolve group names to ssh addresses (see rsh.inventory)."""
79
+ return _resolve_hosts(hosts_or_groups, inventory)
80
+
81
+
82
+ def run_command_sequential(hosts, command, delay=0.0):
83
+ """
84
+ Run a command sequentially on each host, streaming stdout/stderr immediately.
85
+ Parameter `delay` adds a pause (in seconds) between hosts.
86
+ """
87
+ logger.debug(f"hosts={json.dumps(hosts)}")
88
+ logger.debug(f"command='{command}'")
89
+ logger.debug(f"delay={delay}")
90
+
91
+ for idx, host in enumerate(hosts, 1):
92
+ header = f"[{host}] ({idx}/{len(hosts)})"
93
+ print(header)
94
+ print("-" * len(header))
95
+ try:
96
+ conn = Connection(host)
97
+ result = conn.sudo(command, pty=True, hide=False, warn=True)
98
+ print(f"\n[{host}] exit status: {result.return_code}")
99
+ except Exception as e:
100
+ logger.error(f"[{host}] Command execution failed: {e}")
101
+ print(f"[{host}] ERROR: {e}")
102
+ finally:
103
+ if len(hosts) > 1:
104
+ print("===\n")
105
+
106
+ # Optional delay between hosts
107
+ if idx < len(hosts) and delay > 0:
108
+ print(f"Sleeping {delay} seconds before next host...\n")
109
+ try:
110
+ time.sleep(delay)
111
+ except KeyboardInterrupt:
112
+ print("Sleep interrupted by user, continuing...\n")
113
+
114
+
115
+ def run_command_parallel_dedup(GroupClass, hosts, command):
116
+ """
117
+ Run a command in parallel across hosts and deduplicate identical outputs.
118
+ Prints each unique output once alongside the list of hosts that produced it.
119
+ """
120
+ logger.debug(f"hosts={json.dumps(hosts)}")
121
+ logger.debug(f"command='{command}'")
122
+
123
+ try:
124
+ group = GroupClass(*hosts)
125
+ results = group.sudo(command, warn=True, hide=True, pty=True)
126
+
127
+ buckets = {} # output -> list of hosts
128
+ exit_codes = {} # output -> representative exit code
129
+
130
+ for conn, res in results.items():
131
+ out = (res.stdout or "").strip()
132
+ err = (res.stderr or "").strip()
133
+ combined = out if out else err
134
+ key = combined
135
+
136
+ if key not in buckets:
137
+ buckets[key] = []
138
+ exit_codes[key] = res.return_code
139
+ buckets[key].append(conn.host)
140
+
141
+ def sort_key(item):
142
+ _, host_list = item
143
+ return (-len(host_list), ", ".join(sorted(host_list)))
144
+
145
+ for output, host_list in sorted(buckets.items(), key=sort_key):
146
+ host_line = ", ".join(sorted(host_list))
147
+ header = f"[{host_line}]"
148
+ print(header)
149
+ print("-" * len(header))
150
+
151
+ print(output if output else "(no output)")
152
+ print(f"\nexit status (representative): {exit_codes[output]}")
153
+ print("===\n")
154
+
155
+ except Exception as e:
156
+ try:
157
+ for i in e:
158
+ logger.error(f"Command execution failed: {i}")
159
+ except Exception:
160
+ logger.error(f"Command execution failed: {e}")
161
+ sys.exit(1)
rsh/execute.py ADDED
@@ -0,0 +1,171 @@
1
+ # -*- coding: utf-8 -*-
2
+ """sExec / pExec - run a command over ssh on hosts or inventory groups, sequentially or in parallel."""
3
+
4
+ import sys
5
+ import os
6
+ import json
7
+ import argparse
8
+ import time
9
+ import logging
10
+ from braceexpand import braceexpand
11
+ from fabric import Connection, ThreadingGroup
12
+ import rsh.config
13
+ from rsh.inventory import load_inventory, resolve_hosts
14
+
15
+ logger = logging.getLogger(__name__)
16
+ logging.getLogger("paramiko").setLevel(logging.WARNING)
17
+
18
+
19
+ def parse_arguments():
20
+ """
21
+ Parse CLI arguments.
22
+ Returns a tuple: (hosts_or_groups: list[str], command: str, delay: float)
23
+ """
24
+ prog_name = os.path.basename(sys.argv[0])
25
+ parser = argparse.ArgumentParser(
26
+ description='Execute commands via SSH on remote servers.',
27
+ usage=(
28
+ f"[{prog_name}] -g GROUP -c COMMAND "
29
+ f"or [{prog_name}] GROUP COMMAND... "
30
+ f"with optional --delay SECONDS"
31
+ ),
32
+ )
33
+
34
+ parser.add_argument(
35
+ '-g', '--groups', dest='hosts_or_groups', nargs='+',
36
+ help='Hosts or groups (supports brace expansions)'
37
+ )
38
+ parser.add_argument(
39
+ '-c', '--command', nargs=argparse.REMAINDER,
40
+ help='Command to execute'
41
+ )
42
+ parser.add_argument(
43
+ 'fallback', nargs=argparse.REMAINDER,
44
+ help='Fallback: GROUP COMMAND ...'
45
+ )
46
+ parser.add_argument(
47
+ '-d', '--delay', type=float, default=0.0,
48
+ help='Delay (in seconds) between hosts when running sequentially'
49
+ )
50
+
51
+ args = parser.parse_args()
52
+
53
+ if args.hosts_or_groups and args.command:
54
+ hosts_or_groups_raw, command_parts = args.hosts_or_groups, args.command
55
+ elif args.fallback:
56
+ if len(args.fallback) < 2:
57
+ parser.print_usage()
58
+ sys.exit(1)
59
+ hosts_or_groups_raw, command_parts = args.fallback[0], args.fallback[1:]
60
+ else:
61
+ parser.print_usage()
62
+ sys.exit(1)
63
+
64
+ hosts_or_groups = (
65
+ list(braceexpand(hosts_or_groups_raw))
66
+ if isinstance(hosts_or_groups_raw, str)
67
+ else sum([list(braceexpand(x)) for x in hosts_or_groups_raw], [])
68
+ )
69
+
70
+ return hosts_or_groups, ' '.join(command_parts), float(args.delay)
71
+
72
+
73
+ def resolve_targets():
74
+ """Parse CLI args and resolve them against the inventory: (hosts, command, delay)."""
75
+ hosts_or_groups, command, delay = parse_arguments()
76
+ inventory = load_inventory(rsh.config.cfg)
77
+ return resolve_hosts(hosts_or_groups, inventory), command, delay
78
+
79
+
80
+ def run_command_sequential(hosts, command, delay=0.0):
81
+ """
82
+ Run a command sequentially on each host, streaming stdout/stderr immediately.
83
+ Parameter `delay` adds a pause (in seconds) between hosts.
84
+ """
85
+ logger.debug(f"hosts={json.dumps(hosts)}")
86
+ logger.debug(f"command='{command}'")
87
+ logger.debug(f"delay={delay}")
88
+
89
+ for idx, host in enumerate(hosts, 1):
90
+ header = f"[{host}] ({idx}/{len(hosts)})"
91
+ print(header)
92
+ print("-" * len(header))
93
+ try:
94
+ conn = Connection(host)
95
+ result = conn.sudo(command, pty=True, hide=False, warn=True)
96
+ print(f"\n[{host}] exit status: {result.return_code}")
97
+ except Exception as e:
98
+ logger.error(f"[{host}] Command execution failed: {e}")
99
+ print(f"[{host}] ERROR: {e}")
100
+ finally:
101
+ if len(hosts) > 1:
102
+ print("===\n")
103
+
104
+ # Optional delay between hosts
105
+ if idx < len(hosts) and delay > 0:
106
+ print(f"Sleeping {delay} seconds before next host...\n")
107
+ try:
108
+ time.sleep(delay)
109
+ except KeyboardInterrupt:
110
+ print("Sleep interrupted by user, continuing...\n")
111
+
112
+
113
+ def run_command_parallel_dedup(GroupClass, hosts, command):
114
+ """
115
+ Run a command in parallel across hosts and deduplicate identical outputs.
116
+ Prints each unique output once alongside the list of hosts that produced it.
117
+ """
118
+ logger.debug(f"hosts={json.dumps(hosts)}")
119
+ logger.debug(f"command='{command}'")
120
+
121
+ try:
122
+ group = GroupClass(*hosts)
123
+ results = group.sudo(command, warn=True, hide=True, pty=True)
124
+
125
+ buckets = {} # output -> list of hosts
126
+ exit_codes = {} # output -> representative exit code
127
+
128
+ for conn, res in results.items():
129
+ out = (res.stdout or "").strip()
130
+ err = (res.stderr or "").strip()
131
+ combined = out if out else err
132
+ key = combined
133
+
134
+ if key not in buckets:
135
+ buckets[key] = []
136
+ exit_codes[key] = res.return_code
137
+ buckets[key].append(conn.host)
138
+
139
+ def sort_key(item):
140
+ _, host_list = item
141
+ return (-len(host_list), ", ".join(sorted(host_list)))
142
+
143
+ for output, host_list in sorted(buckets.items(), key=sort_key):
144
+ host_line = ", ".join(sorted(host_list))
145
+ header = f"[{host_line}]"
146
+ print(header)
147
+ print("-" * len(header))
148
+
149
+ print(output if output else "(no output)")
150
+ print(f"\nexit status (representative): {exit_codes[output]}")
151
+ print("===\n")
152
+
153
+ except Exception as e:
154
+ try:
155
+ for i in e:
156
+ logger.error(f"Command execution failed: {i}")
157
+ except Exception:
158
+ logger.error(f"Command execution failed: {e}")
159
+ sys.exit(1)
160
+
161
+
162
+ def main_sequential():
163
+ """Entry point for sExec."""
164
+ hosts, command, delay = resolve_targets()
165
+ run_command_sequential(hosts, command, delay=delay)
166
+
167
+
168
+ def main_parallel():
169
+ """Entry point for pExec."""
170
+ hosts, command, _ = resolve_targets()
171
+ run_command_parallel_dedup(ThreadingGroup, hosts, command)