atex 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.
- atex/__init__.py +35 -0
- atex/cli/__init__.py +83 -0
- atex/cli/testingfarm.py +171 -0
- atex/fmf.py +168 -0
- atex/minitmt/__init__.py +109 -0
- atex/minitmt/report.py +174 -0
- atex/minitmt/scripts.py +51 -0
- atex/minitmt/testme.py +3 -0
- atex/orchestrator.py +38 -0
- atex/provision/__init__.py +113 -0
- atex/provision/libvirt/VM_PROVISION +51 -0
- atex/provision/libvirt/__init__.py +23 -0
- atex/provision/libvirt/setup-libvirt.sh +72 -0
- atex/ssh.py +320 -0
- atex/testingfarm.py +523 -0
- atex/util/__init__.py +49 -0
- atex/util/dedent.py +25 -0
- atex/util/lockable_class.py +38 -0
- atex/util/log.py +53 -0
- atex/util/subprocess.py +51 -0
- atex-0.1.dist-info/METADATA +11 -0
- atex-0.1.dist-info/RECORD +25 -0
- atex-0.1.dist-info/WHEEL +4 -0
- atex-0.1.dist-info/entry_points.txt +2 -0
- atex-0.1.dist-info/licenses/COPYING.txt +14 -0
atex/ssh.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import tempfile
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import util
|
|
8
|
+
|
|
9
|
+
DEFAULT_OPTIONS = {
|
|
10
|
+
'LogLevel': 'ERROR',
|
|
11
|
+
'StrictHostKeyChecking': 'no',
|
|
12
|
+
'UserKnownHostsFile': '/dev/null',
|
|
13
|
+
'ConnectionAttempts': '3',
|
|
14
|
+
'ServerAliveCountMax': '4',
|
|
15
|
+
'ServerAliveInterval': '5',
|
|
16
|
+
'TCPKeepAlive': 'no',
|
|
17
|
+
'EscapeChar': 'none',
|
|
18
|
+
'ExitOnForwardFailure': 'yes',
|
|
19
|
+
# 'RequestTTY': 'force',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _shell_cmd(args, sudo=None):
|
|
24
|
+
"""
|
|
25
|
+
Make a command line for running 'args' on the target system.
|
|
26
|
+
"""
|
|
27
|
+
if sudo:
|
|
28
|
+
cmd = ('exec', 'sudo', '--no-update', '--non-interactive', '--user', sudo, '--', *args)
|
|
29
|
+
else:
|
|
30
|
+
cmd = ('exec', '--', *args)
|
|
31
|
+
return ' '.join(cmd)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _options_to_cli(options):
|
|
35
|
+
"""
|
|
36
|
+
Assemble an ssh(1) or sshpass(1) command line with -o options.
|
|
37
|
+
"""
|
|
38
|
+
list_opts = []
|
|
39
|
+
for key, value in options.items():
|
|
40
|
+
if isinstance(value, str):
|
|
41
|
+
list_opts.append(f'-o{key}={value}')
|
|
42
|
+
else:
|
|
43
|
+
if len(value) == 0:
|
|
44
|
+
raise ValueError(f"key {key} has an empty iterable value")
|
|
45
|
+
list_opts += (f'-o{key}={v}' for v in value)
|
|
46
|
+
return list_opts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _options_to_ssh(options, password=None, extra_cli_flags=()):
|
|
50
|
+
"""
|
|
51
|
+
Assemble an ssh(1) or sshpass(1) command line with -o options.
|
|
52
|
+
"""
|
|
53
|
+
cli_opts = _options_to_cli(options)
|
|
54
|
+
if password:
|
|
55
|
+
return (
|
|
56
|
+
'sshpass', '-p', password,
|
|
57
|
+
'ssh', *extra_cli_flags, '-oBatchMode=no', *cli_opts,
|
|
58
|
+
'ignored_arg',
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
# let cli_opts override BatchMode if specified
|
|
62
|
+
return ('ssh', *extra_cli_flags, *cli_opts, '-oBatchMode=yes', 'ignored_arg')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# return a string usable for rsync -e
|
|
66
|
+
def _options_to_rsync_e(options, password=None):
|
|
67
|
+
"""
|
|
68
|
+
Return a string usable for the rsync -e argument.
|
|
69
|
+
"""
|
|
70
|
+
cli_opts = _options_to_cli(options)
|
|
71
|
+
batch_mode = '-oBatchMode=no' if password else '-oBatchMode=yes'
|
|
72
|
+
return ' '.join(('ssh', *cli_opts, batch_mode)) # no ignored_arg inside -e
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _rsync_host_cmd(*args, options, password=None, sudo=None):
|
|
76
|
+
"""
|
|
77
|
+
Assemble a rsync command line, noting that
|
|
78
|
+
- 'sshpass' must be before 'rsync', not inside the '-e' argument
|
|
79
|
+
- 'ignored_arg' must be passed by user as destination, not inside '-e'
|
|
80
|
+
- 'sudo' is part of '--rsync-path', yet another argument
|
|
81
|
+
"""
|
|
82
|
+
return (
|
|
83
|
+
*(('sshpass', '-p', password) if password else ()),
|
|
84
|
+
'rsync',
|
|
85
|
+
'-e', _options_to_rsync_e(options, password=password),
|
|
86
|
+
'--rsync-path', _shell_cmd(('rsync',), sudo=sudo),
|
|
87
|
+
*args,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SSHConn:
|
|
92
|
+
r"""
|
|
93
|
+
Represents a persistent SSH connection to a host (ControlMaster).
|
|
94
|
+
|
|
95
|
+
When instantiated, it attempts to connect to the specified host, with any
|
|
96
|
+
subsequent instance method calls using that connection, or raising
|
|
97
|
+
ConnectionResetError when it is lost.
|
|
98
|
+
|
|
99
|
+
The ssh(1) command is parametrized purely and solely via ssh_config(5)
|
|
100
|
+
options, including 'Hostname', 'User', 'Port', etc.
|
|
101
|
+
Pass any overrides or missing options as 'options' (dict).
|
|
102
|
+
|
|
103
|
+
options = {
|
|
104
|
+
'Hostname': '1.2.3.4',
|
|
105
|
+
'User': 'testuser',
|
|
106
|
+
'IdentityFile': '/home/testuser/.ssh/id_rsa',
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# with a persistent ControlMaster
|
|
110
|
+
conn = SSHConn(options)
|
|
111
|
+
conn.connect()
|
|
112
|
+
output = conn.run('ls /')
|
|
113
|
+
#proc = conn.Popen('ls /') # non-blocking
|
|
114
|
+
conn.disconnect()
|
|
115
|
+
|
|
116
|
+
# or as try/except/finally
|
|
117
|
+
conn = SSHConn(options)
|
|
118
|
+
try:
|
|
119
|
+
conn.connect()
|
|
120
|
+
...
|
|
121
|
+
finally:
|
|
122
|
+
conn.disconnect()
|
|
123
|
+
|
|
124
|
+
# or via Context Manager
|
|
125
|
+
with SSHConn(options) as conn:
|
|
126
|
+
...
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, options, *, password=None):
|
|
130
|
+
"""
|
|
131
|
+
Connect to an SSH server specified in 'options'.
|
|
132
|
+
|
|
133
|
+
If 'password' is given, spawn the ssh(1) command via 'sshpass' and
|
|
134
|
+
pass the password to it.
|
|
135
|
+
|
|
136
|
+
If 'sudo' specifies a username, call sudo(8) on the remote shell
|
|
137
|
+
to run under a different user on the remote host.
|
|
138
|
+
"""
|
|
139
|
+
self.options = DEFAULT_OPTIONS.copy()
|
|
140
|
+
self.options.update(options)
|
|
141
|
+
self.password = password
|
|
142
|
+
self.tmpdir = None
|
|
143
|
+
self.proc_master = None
|
|
144
|
+
|
|
145
|
+
def _assert_master(self):
|
|
146
|
+
proc = self.master_proc
|
|
147
|
+
if not proc:
|
|
148
|
+
raise RuntimeError("SSH ControlMaster is not running")
|
|
149
|
+
# we need to consume any potential proc output for the process to
|
|
150
|
+
# actually terminate (stop being a zombie) if it crashes
|
|
151
|
+
proc = self.master_proc
|
|
152
|
+
out = proc.stdout.read()
|
|
153
|
+
code = proc.poll()
|
|
154
|
+
if code is not None:
|
|
155
|
+
self.master_proc = None
|
|
156
|
+
raise RuntimeError(f"SSH ControlMaster on {self.tmpdir} exited with {code}:\n{out}")
|
|
157
|
+
|
|
158
|
+
def disconnect(self):
|
|
159
|
+
proc = self.master_proc
|
|
160
|
+
if not proc:
|
|
161
|
+
return
|
|
162
|
+
proc.terminate()
|
|
163
|
+
# don't zombie forever, return EPIPE on any attempts to write to us
|
|
164
|
+
proc.stdout.close()
|
|
165
|
+
proc.wait()
|
|
166
|
+
self.master_proc = None
|
|
167
|
+
|
|
168
|
+
def connect(self):
|
|
169
|
+
if self.proc_master:
|
|
170
|
+
raise FileExistsError(f"SSH ControlMaster process already running on {self.tmpdir}")
|
|
171
|
+
|
|
172
|
+
if not self.tmpdir:
|
|
173
|
+
self.tmpdir_handle = tempfile.TemporaryDirectory(prefix='atex-ssh-')
|
|
174
|
+
self.tmpdir = Path(self.tmpdir_handle.name)
|
|
175
|
+
|
|
176
|
+
options = self.options.copy()
|
|
177
|
+
options['SessionType'] = 'none'
|
|
178
|
+
options['ControlMaster'] = 'yes'
|
|
179
|
+
sock = self.tmpdir / 'control.sock'
|
|
180
|
+
options['ControlPath'] = sock
|
|
181
|
+
|
|
182
|
+
# TODO: shouldn't password be handled here and passed to _options_to_ssh
|
|
183
|
+
# rather than for member .ssh() ?
|
|
184
|
+
|
|
185
|
+
proc = util.subprocess_Popen(
|
|
186
|
+
_options_to_ssh(options),
|
|
187
|
+
stdin=subprocess.DEVNULL,
|
|
188
|
+
stdout=subprocess.PIPE,
|
|
189
|
+
stderr=subprocess.STDOUT,
|
|
190
|
+
cwd=str(self.tmpdir),
|
|
191
|
+
)
|
|
192
|
+
os.set_blocking(proc.stdout.fileno(), False)
|
|
193
|
+
|
|
194
|
+
# wait for the master to either create the socket (indicating valid
|
|
195
|
+
# connection) or give up and exit
|
|
196
|
+
while proc.poll() is None:
|
|
197
|
+
if sock.exists():
|
|
198
|
+
break
|
|
199
|
+
time.sleep(0.1)
|
|
200
|
+
else:
|
|
201
|
+
code = proc.poll()
|
|
202
|
+
out = proc.stdout.read()
|
|
203
|
+
raise FileNotFoundError(
|
|
204
|
+
f"SSH ControlMaster failed to start on {self.tmpdir} with {code}:\n{out}",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
self.master_proc = proc
|
|
208
|
+
|
|
209
|
+
def add_local_forward(self, *spec):
|
|
210
|
+
"""
|
|
211
|
+
Add (one or more) ssh forwarding specifications as 'spec' to an
|
|
212
|
+
already-connected instance. Each specification has to follow the
|
|
213
|
+
format of ssh client's LocalForward option (see ssh_config(5)).
|
|
214
|
+
"""
|
|
215
|
+
self._assert_master()
|
|
216
|
+
options = self.options.copy()
|
|
217
|
+
options['LocalForward'] = spec
|
|
218
|
+
options['ControlPath'] = self.tmpdir / 'control.sock'
|
|
219
|
+
util.subprocess_run(
|
|
220
|
+
_options_to_ssh(options, extra_cli_flags=('-O', 'forward')),
|
|
221
|
+
skip_frames=1,
|
|
222
|
+
check=True,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def add_remote_forward(self, *spec):
|
|
226
|
+
"""
|
|
227
|
+
Add (one or more) ssh forwarding specifications as 'spec' to an
|
|
228
|
+
already-connected instance. Each specification has to follow the
|
|
229
|
+
format of ssh client's RemoteForward option (see ssh_config(5)).
|
|
230
|
+
"""
|
|
231
|
+
self._assert_master()
|
|
232
|
+
options = self.options.copy()
|
|
233
|
+
options['RemoteForward'] = spec
|
|
234
|
+
options['ControlPath'] = self.tmpdir / 'control.sock'
|
|
235
|
+
util.subprocess_run(
|
|
236
|
+
_options_to_ssh(options, extra_cli_flags=('-O', 'forward')),
|
|
237
|
+
skip_frames=1,
|
|
238
|
+
check=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def __enter__(self):
|
|
242
|
+
self.connect()
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
246
|
+
self.disconnect()
|
|
247
|
+
|
|
248
|
+
def ssh(
|
|
249
|
+
self, cmd, *args, options=None, sudo=None, text=True,
|
|
250
|
+
func=util.subprocess_run, **run_kwargs,
|
|
251
|
+
):
|
|
252
|
+
self._assert_master()
|
|
253
|
+
unified_options = self.options.copy()
|
|
254
|
+
if options:
|
|
255
|
+
unified_options.update(options)
|
|
256
|
+
unified_options['RemoteCommand'] = _shell_cmd((cmd, *args), sudo=sudo)
|
|
257
|
+
unified_options['ControlPath'] = self.tmpdir / 'control.sock'
|
|
258
|
+
return func(
|
|
259
|
+
_options_to_cli(unified_options, password=self.password),
|
|
260
|
+
skip_frames=1,
|
|
261
|
+
text=text,
|
|
262
|
+
**run_kwargs,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def rsync(
|
|
266
|
+
self, *args, options=None, sudo=None,
|
|
267
|
+
func=util.subprocess_run, **run_kwargs,
|
|
268
|
+
):
|
|
269
|
+
self._assert_master()
|
|
270
|
+
unified_options = self.options.copy()
|
|
271
|
+
if options:
|
|
272
|
+
unified_options.update(options)
|
|
273
|
+
unified_options['ControlPath'] = self.tmpdir / 'control.sock'
|
|
274
|
+
return func(
|
|
275
|
+
_rsync_host_cmd(*args, options=unified_options, password=self.password, sudo=sudo),
|
|
276
|
+
skip_frames=1,
|
|
277
|
+
text=True,
|
|
278
|
+
stdin=subprocess.DEVNULL,
|
|
279
|
+
**run_kwargs,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# have options as kwarg to be compatible with other functions here
|
|
284
|
+
def ssh(
|
|
285
|
+
cmd, *args, options, password=None, sudo=None, text=True,
|
|
286
|
+
func=util.subprocess_run, **run_kwargs,
|
|
287
|
+
):
|
|
288
|
+
"""
|
|
289
|
+
Execute ssh(1) with the given options.
|
|
290
|
+
|
|
291
|
+
On the remote system, run 'cmd' in a shell.
|
|
292
|
+
|
|
293
|
+
If 'password' is given, spawn the ssh(1) command via 'sshpass' and
|
|
294
|
+
pass the password to it.
|
|
295
|
+
|
|
296
|
+
If 'sudo' specifies a username, call sudo(8) on the remote shell
|
|
297
|
+
to run under a different user on the remote host.
|
|
298
|
+
"""
|
|
299
|
+
unified_options = DEFAULT_OPTIONS.copy()
|
|
300
|
+
unified_options['RemoteCommand'] = _shell_cmd((cmd, *args), sudo=sudo)
|
|
301
|
+
unified_options.update(options)
|
|
302
|
+
return func(
|
|
303
|
+
_options_to_cli(unified_options, password=password),
|
|
304
|
+
skip_frames=1,
|
|
305
|
+
text=text,
|
|
306
|
+
**run_kwargs,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def rsync(
|
|
311
|
+
*args, options, password=None, sudo=None,
|
|
312
|
+
func=util.subprocess_run, **run_kwargs,
|
|
313
|
+
):
|
|
314
|
+
return func(
|
|
315
|
+
_rsync_host_cmd(*args, options=options, password=password, sudo=sudo),
|
|
316
|
+
skip_frames=1,
|
|
317
|
+
text=True,
|
|
318
|
+
stdin=subprocess.DEVNULL,
|
|
319
|
+
**run_kwargs,
|
|
320
|
+
)
|