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 +3 -0
- rsh/build_inventory.py +99 -0
- rsh/compgen.py +40 -0
- rsh/completion.bash +10 -0
- rsh/config.py +174 -0
- rsh/exec_functions.py +161 -0
- rsh/execute.py +171 -0
- rsh/inventory.py +66 -0
- rsh/plugins/__init__.py +13 -0
- rsh/plugins/aws.py +46 -0
- rsh/plugins/base.py +93 -0
- rsh/plugins/gce.py +43 -0
- rsh/plugins/ovh.py +41 -0
- rsh/sr.py +239 -0
- rsh_tools-3.0.1.dist-info/METADATA +113 -0
- rsh_tools-3.0.1.dist-info/RECORD +20 -0
- rsh_tools-3.0.1.dist-info/WHEEL +5 -0
- rsh_tools-3.0.1.dist-info/entry_points.txt +6 -0
- rsh_tools-3.0.1.dist-info/licenses/LICENSE +21 -0
- rsh_tools-3.0.1.dist-info/top_level.txt +1 -0
rsh/__init__.py
ADDED
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)
|