vortex-nwp 2.0.0b1__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.
Files changed (146) hide show
  1. vortex/__init__.py +135 -0
  2. vortex/algo/__init__.py +12 -0
  3. vortex/algo/components.py +2136 -0
  4. vortex/algo/mpitools.py +1648 -0
  5. vortex/algo/mpitools_templates/envelope_wrapper_default.tpl +27 -0
  6. vortex/algo/mpitools_templates/envelope_wrapper_mpiauto.tpl +29 -0
  7. vortex/algo/mpitools_templates/wrapstd_wrapper_default.tpl +18 -0
  8. vortex/algo/serversynctools.py +170 -0
  9. vortex/config.py +115 -0
  10. vortex/data/__init__.py +13 -0
  11. vortex/data/abstractstores.py +1572 -0
  12. vortex/data/containers.py +780 -0
  13. vortex/data/contents.py +596 -0
  14. vortex/data/executables.py +284 -0
  15. vortex/data/flow.py +113 -0
  16. vortex/data/geometries.ini +2689 -0
  17. vortex/data/geometries.py +703 -0
  18. vortex/data/handlers.py +1021 -0
  19. vortex/data/outflow.py +67 -0
  20. vortex/data/providers.py +465 -0
  21. vortex/data/resources.py +201 -0
  22. vortex/data/stores.py +1271 -0
  23. vortex/gloves.py +282 -0
  24. vortex/layout/__init__.py +27 -0
  25. vortex/layout/appconf.py +109 -0
  26. vortex/layout/contexts.py +511 -0
  27. vortex/layout/dataflow.py +1069 -0
  28. vortex/layout/jobs.py +1276 -0
  29. vortex/layout/monitor.py +833 -0
  30. vortex/layout/nodes.py +1424 -0
  31. vortex/layout/subjobs.py +464 -0
  32. vortex/nwp/__init__.py +11 -0
  33. vortex/nwp/algo/__init__.py +12 -0
  34. vortex/nwp/algo/assim.py +483 -0
  35. vortex/nwp/algo/clim.py +920 -0
  36. vortex/nwp/algo/coupling.py +609 -0
  37. vortex/nwp/algo/eda.py +632 -0
  38. vortex/nwp/algo/eps.py +613 -0
  39. vortex/nwp/algo/forecasts.py +745 -0
  40. vortex/nwp/algo/fpserver.py +927 -0
  41. vortex/nwp/algo/ifsnaming.py +403 -0
  42. vortex/nwp/algo/ifsroot.py +311 -0
  43. vortex/nwp/algo/monitoring.py +202 -0
  44. vortex/nwp/algo/mpitools.py +554 -0
  45. vortex/nwp/algo/odbtools.py +974 -0
  46. vortex/nwp/algo/oopsroot.py +735 -0
  47. vortex/nwp/algo/oopstests.py +186 -0
  48. vortex/nwp/algo/request.py +579 -0
  49. vortex/nwp/algo/stdpost.py +1285 -0
  50. vortex/nwp/data/__init__.py +12 -0
  51. vortex/nwp/data/assim.py +392 -0
  52. vortex/nwp/data/boundaries.py +261 -0
  53. vortex/nwp/data/climfiles.py +539 -0
  54. vortex/nwp/data/configfiles.py +149 -0
  55. vortex/nwp/data/consts.py +929 -0
  56. vortex/nwp/data/ctpini.py +133 -0
  57. vortex/nwp/data/diagnostics.py +181 -0
  58. vortex/nwp/data/eda.py +148 -0
  59. vortex/nwp/data/eps.py +383 -0
  60. vortex/nwp/data/executables.py +1039 -0
  61. vortex/nwp/data/fields.py +96 -0
  62. vortex/nwp/data/gridfiles.py +308 -0
  63. vortex/nwp/data/logs.py +551 -0
  64. vortex/nwp/data/modelstates.py +334 -0
  65. vortex/nwp/data/monitoring.py +220 -0
  66. vortex/nwp/data/namelists.py +644 -0
  67. vortex/nwp/data/obs.py +748 -0
  68. vortex/nwp/data/oopsexec.py +72 -0
  69. vortex/nwp/data/providers.py +182 -0
  70. vortex/nwp/data/query.py +217 -0
  71. vortex/nwp/data/stores.py +147 -0
  72. vortex/nwp/data/surfex.py +338 -0
  73. vortex/nwp/syntax/__init__.py +9 -0
  74. vortex/nwp/syntax/stdattrs.py +375 -0
  75. vortex/nwp/tools/__init__.py +10 -0
  76. vortex/nwp/tools/addons.py +35 -0
  77. vortex/nwp/tools/agt.py +55 -0
  78. vortex/nwp/tools/bdap.py +48 -0
  79. vortex/nwp/tools/bdcp.py +38 -0
  80. vortex/nwp/tools/bdm.py +21 -0
  81. vortex/nwp/tools/bdmp.py +49 -0
  82. vortex/nwp/tools/conftools.py +1311 -0
  83. vortex/nwp/tools/drhook.py +62 -0
  84. vortex/nwp/tools/grib.py +268 -0
  85. vortex/nwp/tools/gribdiff.py +99 -0
  86. vortex/nwp/tools/ifstools.py +163 -0
  87. vortex/nwp/tools/igastuff.py +249 -0
  88. vortex/nwp/tools/mars.py +56 -0
  89. vortex/nwp/tools/odb.py +548 -0
  90. vortex/nwp/tools/partitioning.py +234 -0
  91. vortex/nwp/tools/satrad.py +56 -0
  92. vortex/nwp/util/__init__.py +6 -0
  93. vortex/nwp/util/async.py +184 -0
  94. vortex/nwp/util/beacon.py +40 -0
  95. vortex/nwp/util/diffpygram.py +359 -0
  96. vortex/nwp/util/ens.py +198 -0
  97. vortex/nwp/util/hooks.py +128 -0
  98. vortex/nwp/util/taskdeco.py +81 -0
  99. vortex/nwp/util/usepygram.py +591 -0
  100. vortex/nwp/util/usetnt.py +87 -0
  101. vortex/proxy.py +6 -0
  102. vortex/sessions.py +341 -0
  103. vortex/syntax/__init__.py +9 -0
  104. vortex/syntax/stdattrs.py +628 -0
  105. vortex/syntax/stddeco.py +176 -0
  106. vortex/toolbox.py +982 -0
  107. vortex/tools/__init__.py +11 -0
  108. vortex/tools/actions.py +457 -0
  109. vortex/tools/addons.py +297 -0
  110. vortex/tools/arm.py +76 -0
  111. vortex/tools/compression.py +322 -0
  112. vortex/tools/date.py +20 -0
  113. vortex/tools/ddhpack.py +10 -0
  114. vortex/tools/delayedactions.py +672 -0
  115. vortex/tools/env.py +513 -0
  116. vortex/tools/folder.py +663 -0
  117. vortex/tools/grib.py +559 -0
  118. vortex/tools/lfi.py +746 -0
  119. vortex/tools/listings.py +354 -0
  120. vortex/tools/names.py +575 -0
  121. vortex/tools/net.py +1790 -0
  122. vortex/tools/odb.py +10 -0
  123. vortex/tools/parallelism.py +336 -0
  124. vortex/tools/prestaging.py +186 -0
  125. vortex/tools/rawfiles.py +10 -0
  126. vortex/tools/schedulers.py +413 -0
  127. vortex/tools/services.py +871 -0
  128. vortex/tools/storage.py +1061 -0
  129. vortex/tools/surfex.py +61 -0
  130. vortex/tools/systems.py +3396 -0
  131. vortex/tools/targets.py +384 -0
  132. vortex/util/__init__.py +9 -0
  133. vortex/util/config.py +1071 -0
  134. vortex/util/empty.py +24 -0
  135. vortex/util/helpers.py +184 -0
  136. vortex/util/introspection.py +63 -0
  137. vortex/util/iosponge.py +76 -0
  138. vortex/util/roles.py +51 -0
  139. vortex/util/storefunctions.py +103 -0
  140. vortex/util/structs.py +26 -0
  141. vortex/util/worker.py +150 -0
  142. vortex_nwp-2.0.0b1.dist-info/LICENSE +517 -0
  143. vortex_nwp-2.0.0b1.dist-info/METADATA +50 -0
  144. vortex_nwp-2.0.0b1.dist-info/RECORD +146 -0
  145. vortex_nwp-2.0.0b1.dist-info/WHEEL +5 -0
  146. vortex_nwp-2.0.0b1.dist-info/top_level.txt +1 -0
vortex/tools/net.py ADDED
@@ -0,0 +1,1790 @@
1
+ """
2
+ Net tools.
3
+ """
4
+
5
+ import abc
6
+ import binascii
7
+ import collections
8
+ from collections import namedtuple
9
+ from datetime import datetime
10
+ import ftplib
11
+ import functools
12
+ import io
13
+ import itertools
14
+ import operator
15
+ import random
16
+ import re
17
+ import shlex
18
+ import socket
19
+ import stat
20
+ import struct
21
+ import time
22
+ from urllib import request as urlrequest
23
+ from urllib import parse as urlparse
24
+
25
+ from bronx.fancies import loggers
26
+ from bronx.net.netrc import netrc
27
+ from bronx.syntax.decorators import nicedeco, secure_getattr
28
+
29
+ #: No automatic export
30
+ __all__ = []
31
+
32
+ logger = loggers.getLogger(__name__)
33
+
34
+ DEFAULT_FTP_PORT = ftplib.FTP_PORT
35
+
36
+
37
+ def uriparse(uristring):
38
+ """Parse the specified ``uristring`` as a dictionary including keys:
39
+
40
+ * scheme
41
+ * netloc
42
+ * port
43
+ * query
44
+ * username
45
+ * password
46
+ """
47
+ (realscheme, other) = uristring.split(':', 1)
48
+ rp = urlparse.urlparse('http:' + other)
49
+ uridict = rp._asdict()
50
+ netloc = uridict['netloc'].split('@', 1)
51
+ hostport = netloc.pop().split(':')
52
+ uridict['netloc'] = hostport.pop(0)
53
+ if hostport:
54
+ uridict['port'] = hostport.pop()
55
+ else:
56
+ uridict['port'] = None
57
+ if netloc:
58
+ userpass = netloc.pop().split(':')
59
+ uridict['username'] = userpass.pop(0)
60
+ if userpass:
61
+ uridict['password'] = userpass.pop()
62
+ else:
63
+ uridict['password'] = None
64
+ else:
65
+ uridict['username'] = None
66
+ uridict['password'] = None
67
+ uridict['scheme'] = realscheme
68
+ uridict['query'] = urlparse.parse_qs(uridict['query'])
69
+ return uridict
70
+
71
+
72
+ def uriunparse(uridesc):
73
+ """Delegates to :mod:`urlparse` the job to unparse the given description (as a dictionary)."""
74
+ return urlparse.urlunparse(uridesc)
75
+
76
+
77
+ def http_post_data(url, data, ok_statuses=(), proxies=None, headers=None, verify=None):
78
+ """Make a http/https POST request, encoding **data**."""
79
+ if isinstance(proxies, (list, tuple)):
80
+ proxies = {scheme: proxies for scheme in ('http', 'https')}
81
+ # Try to use the requests package
82
+ try:
83
+ import requests
84
+ use_requests = True
85
+ except ImportError:
86
+ use_requests = False
87
+ # The modern way
88
+ if use_requests:
89
+ resp = requests.post(url=url, data=data, headers=headers,
90
+ proxies=proxies, verify=verify)
91
+ if ok_statuses:
92
+ is_ok = resp.status_code in ok_statuses
93
+ else:
94
+ is_ok = resp.ok
95
+ return is_ok, resp.status_code, resp.headers, resp.text
96
+ else:
97
+ if not isinstance(data, bytes):
98
+ data = urlparse.urlencode(data).encode('utf-8')
99
+ if uriparse(url)['scheme'] == 'https':
100
+ raise RuntimeError('HTTPS is not properly supported by urllib.request ({}).'
101
+ .format(url))
102
+ handlers = []
103
+ if isinstance(proxies, dict):
104
+ handlers.append(urlrequest.ProxyHandler(proxies))
105
+ opener = urlrequest.build_opener(* handlers)
106
+ req = urlrequest.Request(url=url, data=data,
107
+ headers={} if headers is None else headers)
108
+ try:
109
+ req_f = opener.open(req)
110
+ except Exception as e:
111
+ try: # ignore UnboundLocalError if req_f has not been created yet
112
+ req_f.close()
113
+ finally:
114
+ raise e
115
+ else:
116
+ try:
117
+ req_rc = req_f.getcode()
118
+ req_info = req_f.info()
119
+ req_data = req_f.read().decode('utf-8')
120
+ if ok_statuses:
121
+ return req_rc in ok_statuses, req_rc, req_info, req_data
122
+ else:
123
+ return 200 <= req_rc < 400, req_rc, req_info, req_data
124
+ finally:
125
+ req_f.close()
126
+
127
+
128
+ def netrc_lookup(logname, hostname, nrcfile=None):
129
+ """Looks into the .netrc file to find FTP authentication credentials.
130
+
131
+ :param str logname: The login to look for
132
+ :param str hostname: The hostname to look for
133
+
134
+ For backward compatibility reasons:
135
+
136
+ * If *hostname* is a FQDN, an attempt will be made using the hostname
137
+ alone.
138
+ * If credentials are not found for the *logname*/*hostname* pair, an attempt
139
+ is made ignoring the provided *logname*.
140
+
141
+ """
142
+ actual_logname = None
143
+ actual_pwd = None
144
+ nrc = netrc(file=nrcfile)
145
+ if nrc:
146
+ auth = nrc.authenticators(hostname, login=logname)
147
+ if not auth:
148
+ # self.host may be a FQDN, try to guess only the hostname
149
+ auth = nrc.authenticators(hostname.split('.')[0], login=logname)
150
+ # for backward compatibility: This might be removed one day
151
+ if not auth:
152
+ auth = nrc.authenticators(hostname)
153
+ if not auth:
154
+ # self.host may be a FQDN, try to guess only the hostname
155
+ auth = nrc.authenticators(hostname.split('.')[0])
156
+ # End of backward compatibility section
157
+ if auth:
158
+ actual_logname = auth[0]
159
+ actual_pwd = auth[2]
160
+ else:
161
+ logger.warning('netrc lookup failed (%s)', str(auth))
162
+ else:
163
+ logger.warning('unable to fetch .netrc file')
164
+ return actual_logname, actual_pwd
165
+
166
+
167
+ class ExtendedFtplib:
168
+ """Simple Vortex's extension to the bare ftplib object.
169
+
170
+ It wraps the standard ftplib object to add or overwrite methods.
171
+ """
172
+
173
+ def __init__(self, system, ftpobj, hostname='', port=DEFAULT_FTP_PORT):
174
+ """
175
+ :param ~vortex.tools.systems.OSExtended system: The system object to work with
176
+ :param ftplib.FTP ftpobj: The FTP object to work with / to extend
177
+ """
178
+ self._system = system
179
+ self._ftplib = ftpobj
180
+ self._closed = True
181
+ self._logname = 'not_logged_in'
182
+ self._created = datetime.now()
183
+ self._opened = None
184
+ self._deleted = None
185
+ if hostname:
186
+ self._ftplib.connect(hostname, port)
187
+
188
+ @property
189
+ def host(self):
190
+ """Return the hostname."""
191
+ return self._ftplib.host
192
+
193
+ @property
194
+ def port(self):
195
+ """Return the port number."""
196
+ return self._ftplib.port
197
+
198
+ def __str__(self):
199
+ """
200
+ Nicely formatted print, built as the concatenation
201
+ of the class full name and `logname` and `length` attributes.
202
+ """
203
+ return '{:s} | host={:s} logname={:s} since={!s}>'.format(
204
+ repr(self).rstrip('>'),
205
+ self.host,
206
+ self.logname,
207
+ self.length,
208
+ )
209
+
210
+ @secure_getattr
211
+ def __getattr__(self, key):
212
+ """Gateway to undefined method or attributes if present in ``_ftplib``."""
213
+ actualattr = getattr(self._ftplib, key)
214
+ if callable(actualattr):
215
+ def osproxy(*args, **kw):
216
+ cmd = [key]
217
+ cmd.extend(args)
218
+ cmd.extend(['{:s}={!s}'.format(x, kw[x]) for x in kw.keys()])
219
+ self.stderr(*cmd)
220
+ return actualattr(*args, **kw)
221
+
222
+ osproxy.func_name = str(key)
223
+ osproxy.__name__ = str(key)
224
+ osproxy.func_doc = actualattr.__doc__
225
+ setattr(self, key, osproxy)
226
+ return osproxy
227
+ else:
228
+ return actualattr
229
+
230
+ @property
231
+ def system(self):
232
+ """Current local system interface."""
233
+ return self._system
234
+
235
+ def stderr(self, cmd, *args):
236
+ """Proxy to local system's standard error."""
237
+ self.system.stderr('ftp:' + cmd, *args)
238
+
239
+ @property
240
+ def closed(self):
241
+ """Current status of the ftp connection."""
242
+ return self._closed
243
+
244
+ @property
245
+ def logname(self):
246
+ """Current logname of the ftp connection."""
247
+ return self._logname
248
+
249
+ @property
250
+ def length(self):
251
+ """Length in seconds of the current opened connection."""
252
+ timelength = 0
253
+ try:
254
+ topnow = datetime.now() if self._deleted is None else self._deleted
255
+ timelength = (topnow - self._opened).total_seconds()
256
+ except TypeError:
257
+ logger.warning('Could not evaluate connexion length %s', repr(self))
258
+ return timelength
259
+
260
+ def close(self):
261
+ """Proxy to ftplib :meth:`ftplib.FTP.close`."""
262
+ self.stderr('close')
263
+ rc = True
264
+ if not self.closed:
265
+ rc = self._ftplib.close() or True
266
+ self._closed = True
267
+ self._deleted = datetime.now()
268
+ return rc
269
+
270
+ def login(self, *args):
271
+ """Proxy to ftplib :meth:`ftplib.FTP.login`."""
272
+ self.stderr('login', args[0])
273
+ self._logname = args[0]
274
+ # kept for debugging, but this exposes the user's password!
275
+ # logger.debug('FTP login <args:%s>', str(args))
276
+ rc = self._ftplib.login(*args)
277
+ if rc:
278
+ self._closed = False
279
+ self._deleted = None
280
+ self._opened = datetime.now()
281
+ else:
282
+ logger.warning('FTP could not login <args:%s>', str(args))
283
+ return rc
284
+
285
+ def list(self, *args):
286
+ """Returns standard directory listing from ftp protocol."""
287
+ self.stderr('list', *args)
288
+ contents = []
289
+ self.retrlines('LIST', callback=contents.append)
290
+ return contents
291
+
292
+ def dir(self, *args):
293
+ """Proxy to ftplib :meth:`ftplib.FTP.dir`."""
294
+ self.stderr('dir', *args)
295
+ return self._ftplib.dir(*args)
296
+
297
+ def ls(self, *args):
298
+ """Returns directory listing."""
299
+ self.stderr('ls', *args)
300
+ return self.dir(*args)
301
+
302
+ ll = ls
303
+
304
+ def get(self, source, destination):
305
+ """Retrieve a remote `destination` file to a local `source` file object."""
306
+ self.stderr('get', source, destination)
307
+ if isinstance(destination, str):
308
+ self.system.filecocoon(destination)
309
+ target = open(destination, 'wb')
310
+ xdestination = True
311
+ else:
312
+ target = destination
313
+ xdestination = False
314
+ logger.info('FTP <get:{:s}>'.format(source))
315
+ rc = False
316
+ try:
317
+ self.retrbinary('RETR ' + source, target.write)
318
+ if xdestination:
319
+ target.seek(0, io.SEEK_END)
320
+ if self.size(source) == target.tell():
321
+ rc = True
322
+ else:
323
+ logger.error('FTP incomplete get %s', repr(source))
324
+ else:
325
+ rc = True
326
+ finally:
327
+ if xdestination:
328
+ target.close()
329
+ # If the ftp GET fails, a zero size file is here: remove it
330
+ if not rc:
331
+ self.system.remove(destination)
332
+ return rc
333
+
334
+ def put(self, source, destination, size=None, exact=False):
335
+ """Store a local `source` file object to a remote `destination`.
336
+
337
+ When `size` is known, it is sent to the ftp server with the ALLO
338
+ command. It is mesured in this method for real files, but should
339
+ be given for other (non-seekeable) sources such as pipes.
340
+
341
+ When `exact` is True, the size is checked against the size of the
342
+ destination, and a mismatch is considered a failure.
343
+ """
344
+ self.stderr('put', source, destination)
345
+ if isinstance(source, str):
346
+ inputsrc = open(source, 'rb')
347
+ xsource = True
348
+ else:
349
+ inputsrc = source
350
+ xsource = False
351
+ try:
352
+ inputsrc.seek(0, io.SEEK_END)
353
+ size = inputsrc.tell()
354
+ exact = True
355
+ inputsrc.seek(0)
356
+ except AttributeError:
357
+ logger.warning('Could not rewind <source:%s>', str(source))
358
+ except OSError:
359
+ logger.debug('Seek trouble <source:%s>', str(source))
360
+
361
+ self.rmkdir(destination)
362
+ try:
363
+ self.delete(destination)
364
+ logger.info('Replacing <file:%s>', str(destination))
365
+ except ftplib.error_perm:
366
+ logger.info('Creating <file:%s>', str(destination))
367
+ except (ValueError, TypeError, OSError,
368
+ ftplib.error_proto, ftplib.error_reply, ftplib.error_temp) as e:
369
+ logger.error('Serious delete trouble <file:%s> <error:%s>',
370
+ str(destination), str(e))
371
+
372
+ logger.info('FTP <put:%s>', str(destination))
373
+ rc = False
374
+
375
+ if size is not None:
376
+ try:
377
+ self.voidcmd('ALLO {:d}'.format(size))
378
+ except ftplib.error_perm:
379
+ pass
380
+
381
+ try:
382
+ self.storbinary('STOR ' + destination, inputsrc)
383
+ if exact:
384
+ if self.size(destination) == size:
385
+ rc = True
386
+ else:
387
+ logger.error('FTP incomplete put %s (%d / %d bytes)', repr(source),
388
+ self.size(destination), size)
389
+ else:
390
+ rc = True
391
+ if self.size(destination) != size:
392
+ logger.info('FTP put %s: estimated %s bytes, real %s bytes',
393
+ repr(source), str(size), self.size(destination))
394
+ finally:
395
+ if xsource:
396
+ inputsrc.close()
397
+ return rc
398
+
399
+ def rmkdir(self, destination):
400
+ """Recursive directory creation (mimics `mkdir -p`)."""
401
+ self.stderr('rmkdir', destination)
402
+ origin = self.pwd()
403
+ if destination.startswith('/'):
404
+ path_pre = '/'
405
+ elif destination.startswith('~'):
406
+ path_pre = ''
407
+ else:
408
+ path_pre = origin + '/'
409
+
410
+ for subdir in self.system.path.dirname(destination).split('/'):
411
+ current = path_pre + subdir
412
+ try:
413
+ self.cwd(current)
414
+ path_pre = current + '/'
415
+ except ftplib.error_perm:
416
+ self.stderr('mkdir', current)
417
+ try:
418
+ self.mkd(current)
419
+ except ftplib.error_perm as errmkd:
420
+ if 'File exists' not in str(errmkd):
421
+ raise
422
+ self.cwd(current)
423
+ path_pre = current + '/'
424
+ self.cwd(origin)
425
+
426
+ def cd(self, destination):
427
+ """Change to a directory."""
428
+ return self.cwd(destination)
429
+
430
+ def rm(self, source):
431
+ """Proxy to ftp delete command."""
432
+ return self.delete(source)
433
+
434
+ def mtime(self, filename):
435
+ """Retrieve the modification time of a file."""
436
+ resp = self.sendcmd('MDTM ' + filename)
437
+ if resp[:3] == '213':
438
+ s = resp[3:].strip().split()[-1]
439
+ return int(s)
440
+
441
+ def size(self, filename):
442
+ """Retrieve the size of a file."""
443
+ # The SIZE command is defined in RFC-3659
444
+ resp = self.sendcmd('SIZE ' + filename)
445
+ if resp[:3] == '213':
446
+ s = resp[3:].strip().split()[-1]
447
+ return int(s)
448
+
449
+
450
+ class StdFtp:
451
+ """Standard wrapper for the crude FTP object (of class :class:`ExtendedFtplib`).
452
+
453
+ It relies heavily on the :class:`ExtendedFtplib` class for FTP commands but
454
+ adds some interesting features such as:
455
+
456
+ * a fast login using the .netrc file;
457
+ * the ability to delay the :class:`ftplib.FTP` object creation as much as possible;
458
+ * the VORTEX_FTP_PROXY environment variable is looked for (if not available
459
+ FTP_PROXY is also scrutated). If defined, a FTP proxy will be used.
460
+
461
+ Methods that are not explicitly defined in the present class will be looked
462
+ for in the associated :class:`ExtendedFtplib` object (and eventually in the
463
+ wrapped :class:`ftplib.FTP` object). For example, it's possible
464
+ to call `self.get(...)` (exactly as one would do with the native
465
+ :class:`ExtendedFtplib` and :class:`ftplib.FTP` class).
466
+ """
467
+
468
+ _PROXY_TYPES = ('no-auth-logname-based', )
469
+
470
+ _NO_AUTOLOGIN = ('set_debuglevel', 'connect', 'login', 'stderr',)
471
+
472
+ def __init__(self, system, hostname, port=DEFAULT_FTP_PORT, nrcfile=None, ignoreproxy=False):
473
+ """
474
+ :param ~vortex.tools.systems.OSExtended system: The system object to work with
475
+ :param str hostname: The remote host's network name
476
+ :param int port: The remote host's FTP port.
477
+ :param str nrcfile: The path to the .netrc file (if `None` the ~/.netrc default is used)
478
+ :param bool ignoreproxy: Forcibly ignore any proxy related environment variables
479
+ """
480
+ logger.debug('FTP init <host:%s>', hostname)
481
+ self._system = system
482
+ if ignoreproxy:
483
+ self._proxy_host, self._proxy_port, self._proxy_type = (None, None, None)
484
+ else:
485
+ self._proxy_host, self._proxy_port, self._proxy_type = self._proxy_init()
486
+ self._hostname = hostname
487
+ self._port = port
488
+ self._nrcfile = nrcfile
489
+ self._internal_ftp = None
490
+ self._logname = None
491
+ self._cached_pwd = None
492
+ self._barelogname = None
493
+
494
+ def _proxy_init(self):
495
+ """Return the proxy type, address and port."""
496
+ p_netloc = (None, None)
497
+ p_url = self.system.env.get(
498
+ 'VORTEX_FTP_PROXY', self.system.env.get('FTP_PROXY', None)
499
+ )
500
+ if p_url:
501
+ p_netloc = p_url.split(':', 1)
502
+ if len(p_netloc) == 1:
503
+ p_netloc.append(DEFAULT_FTP_PORT)
504
+ else:
505
+ p_netloc[1] = int(p_netloc[1])
506
+ p_type = self.system.env.get('VORTEX_FTP_PROXY_TYPE', self._PROXY_TYPES[0])
507
+ if p_type not in self._PROXY_TYPES:
508
+ raise ValueError('Incorrect value for the VORTEX_FTP_PROXY_TYPE ' +
509
+ 'environment variable (got: {:s})'.format(p_type))
510
+ return p_netloc[0], p_netloc[1], p_type
511
+
512
+ def _extended_ftp_host_and_port(self):
513
+ if self._proxy_host:
514
+ if self._proxy_type == self._PROXY_TYPES[0]:
515
+ return self._proxy_host, self._proxy_port
516
+ else:
517
+ return self._hostname, self._port
518
+
519
+ @property
520
+ def _extended_ftp(self):
521
+ """This property provides the :class:`ExtendedFtpLib` to work with.
522
+
523
+ It is created on-demand.
524
+ """
525
+ if self._internal_ftp is None:
526
+ self._internal_ftp = ExtendedFtplib(self._system,
527
+ ftplib.FTP(),
528
+ * self._extended_ftp_host_and_port())
529
+ return self._internal_ftp
530
+
531
+ _loginlike_extended_ftp = _extended_ftp
532
+
533
+ @property
534
+ def system(self):
535
+ """The current local system interface."""
536
+ return self._system
537
+
538
+ @property
539
+ def host(self):
540
+ """The FTP server hostname."""
541
+ if self._internal_ftp is None or self._proxy_host:
542
+ return self._hostname
543
+ else:
544
+ return self._extended_ftp.host
545
+
546
+ @property
547
+ def port(self):
548
+ """The FTP server port number."""
549
+ if self._internal_ftp is None or self._proxy_host:
550
+ return self._port
551
+ else:
552
+ return self._extended_ftp.port
553
+
554
+ @property
555
+ def logname(self):
556
+ """The current logname."""
557
+ return self._barelogname
558
+
559
+ @property
560
+ def proxy(self):
561
+ if self._proxy_host:
562
+ return '{0._proxy_host}:{0._proxy_port}'.format(self)
563
+ else:
564
+ return None
565
+
566
+ @property
567
+ def cached_pwd(self):
568
+ """The current cached password."""
569
+ return self._cached_pwd
570
+
571
+ def netpath(self, remote):
572
+ """The complete qualified net path of the remote resource."""
573
+ return '{:s}@{:s}:{:s}'.format(self.logname if self.logname is not None else 'unknown',
574
+ self.host, remote)
575
+
576
+ def delayedlogin(self):
577
+ """Login to the FTP server (if it was not already done)."""
578
+ if self._loginlike_extended_ftp.closed:
579
+ if self._logname is None or self.cached_pwd is None:
580
+ logger.warning('FTP logname/password must be set first. Use the fastlogin method.')
581
+ raise RuntimeError('logname/password were not provided')
582
+ return self.login(self._logname, self.cached_pwd)
583
+ else:
584
+ return True
585
+
586
+ def _process_logname_password(self, logname, password=None):
587
+ """Find the actual *logname* and *password*."""
588
+ if logname and password:
589
+ bare_logname = logname
590
+ else:
591
+ bare_logname, password = netrc_lookup(logname, self.host, nrcfile=self._nrcfile)
592
+ logname = bare_logname
593
+ if logname and self._proxy_host:
594
+ if self._proxy_type == self._PROXY_TYPES[0]:
595
+ logname = '{0:s}@{1.host:s}:{1.port:d}'.format(bare_logname, self)
596
+ if logname:
597
+ return logname, password, bare_logname
598
+ else:
599
+ return None, None, None
600
+
601
+ def close(self):
602
+ """Terminates the FTP session."""
603
+ rc = True
604
+ if self._internal_ftp is not None:
605
+ rc = self._internal_ftp.close()
606
+ return rc
607
+
608
+ def fastlogin(self, logname, password=None, delayed=True):
609
+ """
610
+ Simple heuristic using actual attributes and/or netrc information to find
611
+ login informations.
612
+
613
+ If *delayed=True*, the actual login will be performed later (whenever
614
+ necessary).
615
+ """
616
+ rc = False
617
+ p_logname, p_password, p_barelogname = self._process_logname_password(logname, password)
618
+ if p_logname and p_password:
619
+ self._logname = p_logname
620
+ self._cached_pwd = p_password
621
+ self._barelogname = p_barelogname
622
+ rc = True
623
+ if not delayed and rc:
624
+ # If one really wants to login...
625
+ rc = self.login(self._logname, self._cached_pwd)
626
+ return bool(rc)
627
+
628
+ def _extended_ftp_lookup_check(self, key):
629
+ """Are we allowed to look for *key* in the `self._extended_ftp` object ?"""
630
+ return not key.startswith('_')
631
+
632
+ def _extended_ftp_lookup(self, key):
633
+ """Look if the `self._extended_ftp` object can provide a given method.
634
+
635
+ If so, a possibly wrapped method is returned (in order to perform the
636
+ delayed login).
637
+ """
638
+ actualattr = getattr(self._extended_ftp, key)
639
+ if callable(actualattr):
640
+ def osproxy(*args, **kw):
641
+ # For most of the native commands, we want autologin to be performed
642
+ if key not in self._NO_AUTOLOGIN:
643
+ self.delayedlogin()
644
+ # This is important because wrapper functions are cached (see __getattr__)
645
+ actualattr = getattr(self._extended_ftp, key)
646
+ return actualattr(*args, **kw)
647
+
648
+ osproxy.func_name = str(key)
649
+ osproxy.__name__ = str(key)
650
+ osproxy.__doc__ = actualattr.__doc__
651
+ return osproxy
652
+ else:
653
+ return actualattr
654
+
655
+ @secure_getattr
656
+ def __getattr__(self, key):
657
+ """Gateway to undefined method or attributes if present in ``_extended_ftp``."""
658
+ if self._extended_ftp_lookup_check(key):
659
+ attr = self._extended_ftp_lookup(key)
660
+ if callable(attr):
661
+ setattr(self, key, attr)
662
+ return attr
663
+ raise AttributeError(key)
664
+
665
+ def __enter__(self):
666
+ return self
667
+
668
+ def __exit__(self, exc_type, exc_value, traceback): # @UnusedVariable
669
+ self.close()
670
+
671
+
672
+ class AutoRetriesFtp(StdFtp):
673
+ """An advanced FTP client with retry-on-failure capabilities.
674
+
675
+ It inherits from :class:`StdFtp` class thus providing the same interface (no
676
+ new public methods are added).
677
+
678
+ However, most of the :class:`StdFtp` methods are wrapped in order to implement
679
+ the retry-on-failure capability.
680
+ """
681
+
682
+ def __init__(self, system, hostname, port=DEFAULT_FTP_PORT, nrcfile=None, ignoreproxy=False,
683
+ retrycount_default=6, retrycount_connect=8, retrycount_login=3,
684
+ retrydelay_default=15, retrydelay_connect=15, retrydelay_login=10):
685
+ """
686
+ :param ~vortex.tools.systems.OSExtended system: The system object to work with.
687
+ :param str hostname: The remote host's network name.
688
+ :param int port: The remote host's FTP port.
689
+ :param str nrcfile: The path to the .netrc file (if `None` the ~/.netrc default is used)
690
+ :param bool ignoreproxy: Forcibly ignore any proxy related environment variables
691
+ :param int retrycount_default: The maximum number of retries for most of the FTP functions.
692
+ :param int retrydelay_default: The delay (in seconds) between two retries for most of the FTP functions.
693
+ :param int retrycount_connect: The maximum number of retries when connecting to the FTP server.
694
+ :param int retrydelay_connect: The delay (in seconds) between two retries when connecting to the FTP server.
695
+ :param int retrycount_login: The maximum number of retries when login in to the FTP server.
696
+ :param int retrydelay_login: The delay (in seconds) between two retries when login in to the FTP server.
697
+ """
698
+ logger.debug('AutoRetries FTP init <host:%s>', hostname)
699
+ # Retry stuff
700
+ self.retrycount_default = retrycount_default
701
+ self.retrycount_connect = retrycount_connect
702
+ self.retrycount_login = retrycount_login
703
+ self.retrydelay_default = retrydelay_default
704
+ self.retrydelay_connect = retrydelay_connect
705
+ self.retrydelay_login = retrydelay_login
706
+ # Reset everything
707
+ self._initialise()
708
+ # Finalise
709
+ super().__init__(system, hostname, port=port, nrcfile=nrcfile, ignoreproxy=ignoreproxy)
710
+
711
+ def _initialise(self):
712
+ self._internal_retries_max = None
713
+ self._cwd = ''
714
+ self._autodestroy()
715
+
716
+ def _autodestroy(self):
717
+ """Reset the proxied :class:`ExtendedFtpLib` object."""
718
+ self._internal_ftp = None
719
+
720
+ def _get_extended_ftp(self, retrycount, retrydelay, exceptions_extras):
721
+ """Delay the call to 'connect' as much as possible."""
722
+ if self._internal_ftp is None:
723
+ eftplib = self._retry_wrapped_callable(ExtendedFtplib,
724
+ retrycount=retrycount,
725
+ retrydelay=retrydelay,
726
+ exceptions_extras=exceptions_extras)
727
+ self._internal_ftp = eftplib(self._system, ftplib.FTP(),
728
+ * self._extended_ftp_host_and_port())
729
+ return self._internal_ftp
730
+
731
+ @property
732
+ def _extended_ftp(self):
733
+ """Delay the call to 'connect' as much as possible."""
734
+ return self._get_extended_ftp(self.retrycount_connect, self.retrydelay_connect,
735
+ [socket.timeout, ])
736
+
737
+ @property
738
+ def _loginlike_extended_ftp(self):
739
+ """Delay the call to 'connect' as much as possible."""
740
+ return self._get_extended_ftp(self.retrycount_login, self.retrydelay_login,
741
+ [ftplib.error_perm, socket.error, ])
742
+
743
+ def _actual_login(self, *args):
744
+ """Actually log in + save logname/password + correct the cwd if needed."""
745
+ rc = self._extended_ftp.login(*args)
746
+ if rc:
747
+ if self._logname is None or self._logname != args[0]:
748
+ self._logname = args[0]
749
+ self._barelogname = args[0]
750
+ self._cached_pwd = args[1]
751
+ if rc and self._cwd:
752
+ cocoondir = self._cwd
753
+ self._cwd = ''
754
+ rc = rc and self.cwd(cocoondir)
755
+ return rc
756
+
757
+ def login(self, *args):
758
+ """Proxy to ftplib :meth:`ftplib.FTP.login`."""
759
+ wftplogin = self._retry_wrapped_callable(self._actual_login,
760
+ retrycount=self.retrycount_login,
761
+ retrydelay=self.retrydelay_login,
762
+ exceptions_extras=[ftplib.error_perm,
763
+ socket.error,
764
+ EOFError])
765
+ return wftplogin(*args)
766
+
767
+ def _retry_wrapped_callable(self, func, retrycount=None, retrydelay=None,
768
+ exceptions_extras=None):
769
+ """
770
+ Wraps the *func* function in order to implement a retry on failure
771
+ mechanism.
772
+
773
+ :param callable func: Any callable that should be wrapped (usually a function)
774
+ :param int retrycount: The wanted retry count (`self.retrycount_default` if omitted)
775
+ :param int retrydelay: The delay between retries (`self.retrydelay_default` if omitted)
776
+ :param list exceptions_extras: Extra exceptions to be catch during the retry
777
+ phase (in addtion of `ftplib.error_temp`, `ftplib.error_proto`,
778
+ `ftplib.error_reply`).
779
+
780
+ Upon failure, :meth:`_autodestroy` is called in order to reset this object
781
+ and start with a clean slate.
782
+ """
783
+ actual_rcount = retrycount or self.retrycount_default
784
+ actual_rdelay = retrydelay or self.retrydelay_default
785
+ actual_exc = [ftplib.error_temp, ftplib.error_proto, ftplib.error_reply, ]
786
+ if exceptions_extras:
787
+ actual_exc.extend(exceptions_extras)
788
+ actual_exc = tuple(actual_exc)
789
+
790
+ def retries_wrapper(*args, **kw):
791
+ globalcounter_driver = self._internal_retries_max is None
792
+ if globalcounter_driver:
793
+ self._internal_retries_max = actual_rcount
794
+ retriesleft = max(min(self._internal_retries_max, actual_rcount), 1)
795
+ try:
796
+ while retriesleft:
797
+ try:
798
+ return func(*args, **kw)
799
+ except actual_exc as e:
800
+ logger.warning('An error occurred (in "%s"): %s',
801
+ func.__name__, e)
802
+ retriesleft -= 1
803
+ self._internal_retries_max -= 1
804
+ if not retriesleft:
805
+ logger.warning('The maximum number of retries (%d) was reached.',
806
+ actual_rcount)
807
+ raise
808
+ logger.warning('Sleeping %d sec. before the next attempt.',
809
+ actual_rdelay)
810
+ self._autodestroy()
811
+ self.system.sleep(actual_rdelay)
812
+ finally:
813
+ if globalcounter_driver:
814
+ self._internal_retries_max = None
815
+
816
+ retries_wrapper.func_name = func.__name__
817
+ retries_wrapper.__name__ = func.__name__
818
+ retries_wrapper.__doc__ = func.__doc__
819
+ return retries_wrapper
820
+
821
+ @secure_getattr
822
+ def __getattr__(self, key):
823
+ """Gateway to undefined method or attributes if present in ``_extended_ftp``."""
824
+ if self._extended_ftp_lookup_check(key):
825
+ attr = self._extended_ftp_lookup(key)
826
+ if callable(attr):
827
+ if key not in self._NO_AUTOLOGIN:
828
+ attr = self._retry_wrapped_callable(attr,
829
+ exceptions_extras=[socket.error, ])
830
+ setattr(self, key, attr)
831
+ return attr
832
+ raise AttributeError(key)
833
+
834
+ def cwd(self, pathname):
835
+ """Change the current directory to the *pathname* directory."""
836
+ todo = self._retry_wrapped_callable(self._extended_ftp_lookup('cwd'))
837
+ rc = todo(pathname)
838
+ if rc:
839
+ if self.system.path.isabs(pathname):
840
+ self._cwd = pathname
841
+ else:
842
+ self._cwd = self.system.path.join(self._cwd, pathname)
843
+ self._cwd = self.system.path.normpath(self._cwd)
844
+ return rc
845
+
846
+ def cd(self, destination):
847
+ """Change the current directory to the *pathname* directory."""
848
+ return self.cwd(destination)
849
+
850
+ def quit(self):
851
+ """Quit the current ftp session politely."""
852
+ try:
853
+ rc = self._retry_wrapped_callable(self._extended_ftp_lookup('quit'))()
854
+ finally:
855
+ self._initialise()
856
+ return rc
857
+
858
+ def close(self):
859
+ """Quit the current ftp session abruptly."""
860
+ rc = super().close()
861
+ self._initialise()
862
+ return rc
863
+
864
+
865
+ class ResetableAutoRetriesFtp(AutoRetriesFtp):
866
+ """
867
+ An advanced FTP client with retry-on-failure capabilities and an additional
868
+ method :meth:`reset` to reset the current working directory to its initial
869
+ value (i.e. The working directory just after login).
870
+ """
871
+
872
+ def _initialise(self):
873
+ super()._initialise()
874
+ self._initialpath = None
875
+
876
+ def _actual_login(self, *args):
877
+ if self._initialpath is not None and self._cwd:
878
+ rc = super()._actual_login(*args)
879
+ else:
880
+ rc = super()._actual_login(*args)
881
+ if rc:
882
+ self._initialpath = self.pwd()
883
+ return rc
884
+
885
+ def reset(self):
886
+ """Reset the current working directory to its initial value."""
887
+ if self._initialpath is not None and self._cwd:
888
+ self._cwd = ''
889
+ return self.cwd(self._initialpath)
890
+
891
+
892
+ class PooledResetableAutoRetriesFtp(ResetableAutoRetriesFtp):
893
+ """
894
+ An advanced FTP client derived from :class:`ResetableAutoRetriesFtp` that can
895
+ be used in conjunction with an :class:`FtpConnectionPool` object.
896
+ """
897
+
898
+ def __init__(self, pool, *kargs, **kwargs):
899
+ """
900
+ :param FtpConnectionPool pool: The FTP connection pool to work with.
901
+
902
+ *kargs* and *kwargs* are passed directly to the :class:`ResetableAutoRetriesFtp`
903
+ class constructor (refers to its documentation).
904
+ """
905
+ self._pool = pool
906
+ super().__init__(*kargs, **kwargs)
907
+ logger.debug('Pooled FTP init <host:%s> <pool:%s>', self.host, repr(pool))
908
+
909
+ def forceclose(self):
910
+ """Really quit the ftp session."""
911
+ if self._internal_ftp is not None:
912
+ return super().close()
913
+ else:
914
+ return True
915
+
916
+ def close(self):
917
+ """
918
+ The ftp session is not really closed... instead, the current object is
919
+ given back to the FTP connection pool that will be able to reuse it.
920
+ """
921
+ # If no underlying library is available, do not bother...
922
+ if self._internal_ftp is not None:
923
+ self._pool.relinquishing(self)
924
+ return True
925
+
926
+
927
+ class FtpConnectionPool:
928
+ """A class that dispense FTP client objects for a given *hostname*/*logname* pair.
929
+
930
+ Dispensed objects can either be new object or re-used pre-existing ones: this
931
+ makes no differences for the caller since re-used object are properly "reseted"
932
+ before being dispensed.
933
+
934
+ The great advantage of this class is to keep FTP connections open for a given
935
+ number of clients which avoids multiple connect/login sequences (that are
936
+ time consuming). On the other hand, the user must be cautious when using this
937
+ class since having numerous long standing opened connections can harm the
938
+ remote FTP hosts.
939
+ """
940
+
941
+ #: The FTP client class that will be used
942
+ _FTPCLIENT_CLASS = PooledResetableAutoRetriesFtp
943
+ #: The maximum number of spare FTP client (when this threshold is hit,
944
+ #: warning are issued)
945
+ _REUSABLE_THRESHOLD = 10
946
+
947
+ def __init__(self, system, nrcfile=None, ignoreproxy=False):
948
+ """
949
+ :param ~vortex.tools.systems.OSExtended system: The system object to work with.
950
+ :param str nrcfile: The path to the .netrc file (if `None` the ~/.netrc default is used)
951
+ :param bool ignoreproxy: Forcibly ignore any proxy related environment variables
952
+ """
953
+ self._system = system
954
+ self._nrcfile = nrcfile
955
+ self._ignoreproxy = ignoreproxy
956
+ self._reusable = collections.defaultdict(collections.deque)
957
+ self._created = 0
958
+ self._reused = 0
959
+ self._givenback = 0
960
+
961
+ @property
962
+ def poolsize(self):
963
+ """The number of spare FTP clients."""
964
+ return sum([len(hpool) for hpool in self._reusable.values()])
965
+
966
+ def __str__(self):
967
+ """Print a summary of the connection pool activity."""
968
+ out = 'Current connection pool size: {:d}\n'.format(self.poolsize)
969
+ out += ' # of created objects: {:d}\n'.format(self._created)
970
+ out += ' # of re-used objects: {:d}\n'.format(self._reused)
971
+ out += ' # of given back objects: {:d}\n'.format(self._givenback)
972
+ if self.poolsize:
973
+ out += '\nDetailed list of current spare clients:\n'
974
+ for ident, hpool in self._reusable.items():
975
+ for client in hpool:
976
+ out += ' - {id[1]:s}@{id[0]:s}: {cl!r}\n'.format(id=ident, cl=client)
977
+ return out
978
+
979
+ def deal(self, hostname, logname, port=DEFAULT_FTP_PORT, delayed=True, ignoreproxy=False):
980
+ """Retrieve an FTP client for the *hostname*/*logname* pair."""
981
+ p_logname, _ = netrc_lookup(logname, hostname, nrcfile=self._nrcfile)
982
+ if self._reusable[(hostname, port, p_logname)]:
983
+ ftpc = self._reusable[(hostname, port, p_logname)].pop()
984
+ ftpc.reset()
985
+ logger.debug('Re-using a client: %s', repr(ftpc))
986
+ if not delayed:
987
+ # If requested, ensure that we are logged in
988
+ ftpc.delayedlogin()
989
+ self._reused += 1
990
+ return ftpc
991
+ else:
992
+ ftpc = self._FTPCLIENT_CLASS(self, self._system, hostname,
993
+ port=port, nrcfile=self._nrcfile,
994
+ ignoreproxy=self._ignoreproxy)
995
+ rc = ftpc.fastlogin(p_logname, delayed=delayed)
996
+ if rc:
997
+ logger.debug('Creating a new client: %s', repr(ftpc))
998
+ self._created += 1
999
+ return ftpc
1000
+ else:
1001
+ logger.warning('Could not login on %s:%d as %s [%s]', hostname, port, p_logname, str(rc))
1002
+ return None
1003
+
1004
+ def relinquishing(self, client):
1005
+ """
1006
+ When the user is done with a reusable *client*, this method should be
1007
+ called in order for the FTP connection pool to reuse it.
1008
+
1009
+ It is usually dealt with properly by the FTP client object itself when
1010
+ its `close` method is called.
1011
+ """
1012
+ assert isinstance(client, self._FTPCLIENT_CLASS)
1013
+ self._reusable[(client.host, client.port, client.logname)].append(client)
1014
+ self._givenback += 1
1015
+ logger.debug("Spare client for %s@%s:%d has been stored (poolsize=%d).",
1016
+ client.logname, client.host, client.port, self.poolsize)
1017
+ if self.poolsize >= self._REUSABLE_THRESHOLD:
1018
+ logger.warning('The FTP pool is too big ! (%d >= %d). Here are the details:\n%s',
1019
+ self.poolsize, self._REUSABLE_THRESHOLD, str(self))
1020
+
1021
+ def clear(self):
1022
+ """Destroy all the spare FTP clients."""
1023
+ for hpool in self._reusable.values():
1024
+ for client in hpool:
1025
+ logger.debug("Destroying client for %s@%s", client.logname, client.host)
1026
+ client.forceclose()
1027
+ hpool.clear()
1028
+
1029
+
1030
+ class Ssh:
1031
+ """Remote command execution via ssh.
1032
+
1033
+ Also handles remote copy via scp or ssh, which is intimately linked
1034
+ """
1035
+
1036
+ def __init__(self, sh, hostname, logname=None, sshopts=None, scpopts=None):
1037
+ """
1038
+ :param System sh: The :class:`System` object that is to be used.
1039
+ :param str hostname: The target hostname(s).
1040
+ :param logname: The logname for the Ssh commands.
1041
+ :param str sshopts: Extra SSH options (in addition to the configuration file ones).
1042
+ :param str scpopts: Extra SCP options (in addition to the configuration file ones).
1043
+ """
1044
+ self._sh = sh
1045
+
1046
+ self._logname = logname
1047
+ self._remote = hostname
1048
+
1049
+ target = sh.default_target
1050
+ self._sshcmd = target.get(key='services:sshcmd', default='ssh')
1051
+ self._scpcmd = target.get(key='services:scpcmd', default='scp')
1052
+ self._sshopts = (
1053
+ target.get(key='services:sshopts', default='-x').split() +
1054
+ (sshopts or '').split())
1055
+ self._scpopts = (
1056
+ target.get(key='services:scpopts', default='-Bp').split() +
1057
+ (scpopts or '').split())
1058
+
1059
+ @property
1060
+ def sh(self):
1061
+ return self._sh
1062
+
1063
+ @property
1064
+ def remote(self):
1065
+ return ('' if self._logname is None else self._logname + '@') + self._remote
1066
+
1067
+ def check_ok(self):
1068
+ """Is the connexion ok ?"""
1069
+ return self.execute('true') is not False
1070
+
1071
+ def execute(self, remote_command, sshopts=''):
1072
+ """Execute the command remotely.
1073
+
1074
+ Return the output of the command (list of lines), or False on error.
1075
+
1076
+ Only the output sent to the log (when silent=False) shows the difference
1077
+ between:
1078
+
1079
+ - a bad connection (e.g. wrong user)
1080
+ - a remote command retcode != 0 (e.g. cmd='/bin/false')
1081
+
1082
+ """
1083
+ myremote = self.remote
1084
+ if myremote is None:
1085
+ return False
1086
+ cmd = ([self._sshcmd, ] +
1087
+ self._sshopts + sshopts.split() +
1088
+ [myremote, ] + [remote_command, ])
1089
+ return self.sh.spawn(cmd, output=True, fatal=False)
1090
+
1091
+ def background_execute(self, remote_command, sshopts='', stdout=None, stderr=None):
1092
+ """Execute the command remotely and return the object representing the ssh process.
1093
+
1094
+ Return a Popen object representing the ssh process. The user is reponsible
1095
+ for calling pclose on this object and check the return code.
1096
+ """
1097
+ myremote = self.remote
1098
+ if myremote is None:
1099
+ return False
1100
+ cmd = ([self._sshcmd, ] +
1101
+ self._sshopts + sshopts.split() +
1102
+ [myremote, ] + [remote_command, ])
1103
+ return self.sh.popen(cmd, stdout=stdout, stderr=stderr)
1104
+
1105
+ def cocoon(self, destination):
1106
+ """Create the remote directory to contain ``destination``.
1107
+
1108
+ Return ``False`` on failure.
1109
+ """
1110
+ remote_dir = self.sh.path.dirname(destination)
1111
+ if remote_dir == '':
1112
+ return True
1113
+ logger.debug('Cocooning remote directory "%s"', remote_dir)
1114
+ cmd = 'mkdir -p "{}"'.format(remote_dir)
1115
+ rc = self.execute(cmd)
1116
+ if not rc:
1117
+ logger.error('Cannot cocoon on %s (user: %s) for %s',
1118
+ str(self._remote), str(self._logname), destination)
1119
+ return rc
1120
+
1121
+ def remove(self, target):
1122
+ """Remove the remote target, if present. Return False on failure.
1123
+
1124
+ Does not fail when the target is missing, but does when it exists
1125
+ and cannot be removed, which would make a final move also fail.
1126
+ """
1127
+ logger.debug('Removing remote target "%s"', target)
1128
+ cmd = 'rm -fr "{}"'.format(target)
1129
+ rc = self.execute(cmd)
1130
+ if not rc:
1131
+ logger.error('Cannot remove from %s (user: %s) item "%s"',
1132
+ str(self._remote), str(self._logname), target)
1133
+ return rc
1134
+
1135
+ def _scp_putget_commons(self, source, destination):
1136
+ """Common checks on source and destination."""
1137
+ if not isinstance(source, str):
1138
+ msg = 'Source is not a plain file path: {!r}'.format(source)
1139
+ raise TypeError(msg)
1140
+ if not isinstance(destination, str):
1141
+ msg = 'Destination is not a plain file path: {!r}'.format(destination)
1142
+ raise TypeError(msg)
1143
+
1144
+ # avoid special cases
1145
+ if destination == '' or destination == '.':
1146
+ destination = './'
1147
+ else:
1148
+ if destination.endswith('..'):
1149
+ destination += '/'
1150
+ if '../' in destination:
1151
+ raise ValueError('"../" is not allowed in the destination path')
1152
+ if destination.endswith('/'):
1153
+ destination = self.sh.path.join(destination, self.sh.path.basename(source))
1154
+
1155
+ return source, destination
1156
+
1157
+ def scpput(self, source, destination, scpopts=''):
1158
+ r"""Send ``source`` to ``destination``.
1159
+
1160
+ - ``source`` is a single file or a directory, not a pattern (no '\*.grib').
1161
+ - ``destination`` is the remote name, unless it ends with '/', in
1162
+ which case it is the containing directory, and the remote name is
1163
+ the basename of ``source`` (like a real cp or scp):
1164
+
1165
+ - ``scp a/b.gif c/d.gif --> c/d.gif``
1166
+ - ``scp a/b.gif c/d/ --> c/d/b.gif``
1167
+
1168
+ Return True for ok, False on error.
1169
+ """
1170
+ source, destination = self._scp_putget_commons(source, destination)
1171
+
1172
+ if not self.sh.path.exists(source):
1173
+ logger.error('No such file or directory: %s', source)
1174
+ return False
1175
+
1176
+ source = self.sh.path.realpath(source)
1177
+
1178
+ myremote = self.remote
1179
+ if myremote is None:
1180
+ return False
1181
+
1182
+ if not self.cocoon(destination):
1183
+ return False
1184
+
1185
+ if not self.remove(destination):
1186
+ return False
1187
+
1188
+ if self.sh.path.isdir(source):
1189
+ scpopts += ' -r'
1190
+
1191
+ if not self.remove(destination + '.tmp'):
1192
+ return False
1193
+
1194
+ # transfer to a temporary place.
1195
+ # when ``destination`` contains spaces, 1 round of quoting
1196
+ # is necessary, to avoid an 'scp: ambiguous target' error.
1197
+ cmd = ([self._scpcmd, ] +
1198
+ self._scpopts + scpopts.split() +
1199
+ [source,
1200
+ myremote + ':' + shlex.quote(destination + '.tmp')])
1201
+ rc = self.sh.spawn(cmd, output=False, fatal=False)
1202
+ if rc:
1203
+ # success, rename the tmp
1204
+ rc = self.execute('mv "{0}.tmp" "{0}"'.format(destination))
1205
+ return rc
1206
+
1207
+ def scpget(self, source, destination, scpopts='', isadir=False):
1208
+ r"""Send ``source`` to ``destination``.
1209
+
1210
+ - ``source`` is the remote name, not a pattern (no '\*.grib').
1211
+ - ``destination`` is a single file or a directory, unless it ends with
1212
+ '/', in which case it is the containing directory, and the remote name
1213
+ is the basename of ``source`` (like a real cp or scp):
1214
+
1215
+ - ``scp a/b.gif c/d.gif --> c/d.gif``
1216
+ - ``scp a/b.gif c/d/ --> c/d/b.gif``
1217
+
1218
+ Return True for ok, False on error.
1219
+ """
1220
+ source, destination = self._scp_putget_commons(source, destination)
1221
+
1222
+ myremote = self.remote
1223
+ if myremote is None:
1224
+ return False
1225
+
1226
+ if not self.sh.filecocoon(destination):
1227
+ return False
1228
+
1229
+ if isadir:
1230
+ if not self.sh.remove(destination):
1231
+ return False
1232
+ scpopts += ' -r'
1233
+
1234
+ # transfer to a temporary place.
1235
+ # when ``source`` contains spaces, 1 round of quoting
1236
+ # is necessary, to avoid an 'scp: ambiguous target' error.
1237
+ cmd = ([self._scpcmd, ] +
1238
+ self._scpopts + scpopts.split() +
1239
+ [myremote + ':' + shlex.quote(source),
1240
+ destination + '.tmp'])
1241
+ rc = self.sh.spawn(cmd, output=False, fatal=False)
1242
+ if rc:
1243
+ # success, rename the tmp
1244
+ rc = self.sh.move(destination + '.tmp', destination)
1245
+ return rc
1246
+
1247
+ def get_permissions(self, source):
1248
+ """
1249
+ Convenience method to retrieve the permissions of a file/dir (in a form
1250
+ suitable for chmod).
1251
+ """
1252
+ mode = self.sh.stat(source).st_mode
1253
+ return stat.S_IMODE(mode)
1254
+
1255
+ def scpput_stream(self, stream, destination, permissions=None, sshopts=''):
1256
+ """Send the ``stream`` to the ``destination``.
1257
+
1258
+ - ``stream`` is a ``file`` (typically returned by open(),
1259
+ or the piped output of a spawned process).
1260
+ - ``destination`` is the remote file name.
1261
+
1262
+ Return True for ok, False on error.
1263
+ """
1264
+ if not isinstance(stream, io.IOBase):
1265
+ msg = "stream is a {}, should be a <type 'file'>".format(type(stream))
1266
+ raise TypeError(msg)
1267
+
1268
+ if not isinstance(destination, str):
1269
+ msg = 'Destination is not a plain file path: {!r}'.format(destination)
1270
+ raise TypeError(msg)
1271
+
1272
+ myremote = self.remote
1273
+ if myremote is None:
1274
+ return False
1275
+
1276
+ if not self.cocoon(destination):
1277
+ return False
1278
+
1279
+ # transfer to a tmp, rename and set permissions in one go
1280
+ remote_cmd = 'cat > {0}.tmp && mv {0}.tmp {0}'.format(shlex.quote(destination))
1281
+ if permissions:
1282
+ remote_cmd += ' && chmod -v {:o} {}'.format(permissions, shlex.quote(destination))
1283
+
1284
+ cmd = ([self._sshcmd, ] +
1285
+ self._sshopts + sshopts.split() +
1286
+ [myremote, remote_cmd])
1287
+ return self.sh.spawn(cmd, stdin=stream, output=False, fatal=False)
1288
+
1289
+ def scpget_stream(self, source, stream, sshopts=''):
1290
+ """Send the ``source`` to the ``stream``.
1291
+
1292
+ - ``source`` is the remote file name.
1293
+ - ``stream`` is a ``file`` (typically returned by open(),
1294
+ or the piped output of a spawned process).
1295
+
1296
+ Return True for ok, False on error.
1297
+ """
1298
+ if not isinstance(stream, io.IOBase):
1299
+ msg = "stream is a {}, should be a <type 'file'>".format(type(stream))
1300
+ raise TypeError(msg)
1301
+
1302
+ if not isinstance(source, str):
1303
+ msg = 'Source is not a plain file path: {!r}'.format(source)
1304
+ raise TypeError(msg)
1305
+
1306
+ myremote = self.remote
1307
+ if myremote is None:
1308
+ return False
1309
+
1310
+ # transfer to a tmp, rename and set permissions in one go
1311
+ remote_cmd = 'cat {}'.format(shlex.quote(source))
1312
+ cmd = ([self._sshcmd, ] +
1313
+ self._sshopts + sshopts.split() +
1314
+ [myremote, remote_cmd])
1315
+ return self.sh.spawn(cmd, output=stream, fatal=False)
1316
+
1317
+ def tunnel(self, finaldestination, finalport=0, entranceport=None,
1318
+ maxwait=3., checkdelay=0.25):
1319
+ """Create an SSH tunnel and check that it actually starts.
1320
+
1321
+ :param str finaldestination: The destination hostname (i.e the machine
1322
+ at the far end of the tunnel). If the
1323
+ "socks" special value is provided, the SSH
1324
+ tunnel will behave as a SOCKS4/SOCKS5 proxy.
1325
+ :param int finalport: The destination port
1326
+ :param int entranceport: The port number of the tunnel entrance (if None,
1327
+ which is the default, it is automatically
1328
+ assigned)
1329
+ :param float maxwait: The maximum time to wait for the entrance port to
1330
+ be opened by the SSH client (if the entrance port
1331
+ is not ready by that time, the SSH command is
1332
+ considered to have failed).
1333
+ :return: False if the tunnel command failed, otherwise an object that
1334
+ contains all kind of details on the SSH tunnel.
1335
+ :rtype: ActiveSshTunnel
1336
+ """
1337
+
1338
+ myremote = self.remote
1339
+ if myremote is None:
1340
+ return False
1341
+
1342
+ if entranceport is None:
1343
+ entranceport = self.sh.available_localport()
1344
+ else:
1345
+ if self.sh.check_localport(entranceport):
1346
+ logger.error('The SSH tunnel creation failed ' +
1347
+ '(entrance: %d, dest: %s:%d, via %s).',
1348
+ entranceport, finaldestination, finalport, myremote)
1349
+ logger.error('The entrance port is already in use.')
1350
+ return False
1351
+ if finaldestination == 'socks':
1352
+ p = self.sh.popen([self._sshcmd, ] + self._sshopts +
1353
+ ['-N', '-D', '{:d}'.format(entranceport), myremote],
1354
+ stdin=False, output=False)
1355
+ else:
1356
+ if finalport <= 0:
1357
+ raise ValueError('Erroneous finalport value: {!s}'.format(finalport))
1358
+ p = self.sh.popen([self._sshcmd, ] + self._sshopts +
1359
+ ['-N', '-L',
1360
+ '{:d}:{:s}:{:d}'.format(entranceport,
1361
+ finaldestination, finalport),
1362
+ myremote],
1363
+ stdin=False, output=False)
1364
+ tunnel = ActiveSshTunnel(self.sh, p, entranceport, finaldestination, finalport)
1365
+ elapsed = 0.
1366
+ while (not self.sh.check_localport(entranceport)) and elapsed < maxwait:
1367
+ self.sh.sleep(checkdelay)
1368
+ elapsed += checkdelay
1369
+ if not self.sh.check_localport(entranceport):
1370
+ logger.error('The SSH tunnel creation failed ' +
1371
+ '(entrance: %d, dest: %s:%d, via %s).',
1372
+ entranceport, finaldestination, finalport, myremote)
1373
+ tunnel.close()
1374
+ tunnel = False
1375
+ logger.info('SSH tunnel opened, enjoy the ride ! ' +
1376
+ '(entrance: %d, dest: %s:%d, via %s).',
1377
+ entranceport, finaldestination, finalport, myremote)
1378
+ return tunnel
1379
+
1380
+
1381
+ class ActiveSshTunnel:
1382
+ """Hold an opened SSH tunnel."""
1383
+
1384
+ def __init__(self, sh, activeprocess, entranceport, finaldestination, finalport):
1385
+ """
1386
+ :param Popen activeprocess: The active tunnel process.
1387
+ :param int entranceport: Tunnel's entrance port.
1388
+ :param str finaldestination: Tunnel's final destination.
1389
+ :param int finalport: Tunnel's destination port.
1390
+
1391
+ Objects of this class can be used as context managers (the tunnel will
1392
+ be closed when the context is exited).
1393
+ """
1394
+ self._sh = sh
1395
+ self.activeprocess = activeprocess
1396
+ self.entranceport = entranceport
1397
+ self.finaldestination = finaldestination
1398
+ self.finalport = finalport
1399
+
1400
+ def __del__(self):
1401
+ self.close()
1402
+
1403
+ def close(self):
1404
+ """Close the tunnel (i.e. kill the SSH process)."""
1405
+ if self.opened:
1406
+ self.activeprocess.terminate()
1407
+ t0 = time.time()
1408
+ while self.opened and time.time() - t0 < 5:
1409
+ self._sh.sleep(0.1)
1410
+ logger.debug("Tunnel termination took: %f seconds", time.time() - t0)
1411
+ if self.opened:
1412
+ logger.debug("Tunnel termination failed: issuing SIGKILL")
1413
+ self.activeprocess.kill()
1414
+ logger.info('SSH tunnel closed (entrance: %d, dest: %s:%d).',
1415
+ self.entranceport, self.finaldestination, self.finalport)
1416
+
1417
+ @property
1418
+ def opened(self):
1419
+ """Is the tunnel opened ?"""
1420
+ return self.activeprocess.poll() is None
1421
+
1422
+ def __enter__(self):
1423
+ return self
1424
+
1425
+ def __exit__(self, exc_type, exc_value, traceback): # @UnusedVariable
1426
+ self.close()
1427
+
1428
+
1429
+ @nicedeco
1430
+ def _check_fatal(func):
1431
+ """decorator: an exception is raised, if fatal=True and the returncode != True.
1432
+
1433
+ This decorator is very specialised and should be used solely with the AssistedSsh
1434
+ class since it relies on several attributes (_fatal, _maxtries).
1435
+ """
1436
+
1437
+ def wrapped(*args, **kwargs):
1438
+ self = args[0]
1439
+ if self._fatal_in_progress:
1440
+ return func(self, *args[1:], **kwargs)
1441
+ else:
1442
+ # This trick ensure that only one fatal check is attempted
1443
+ self._fatal_in_progress = True
1444
+ try:
1445
+ rc = func(self, *args[1:], **kwargs)
1446
+ if not rc:
1447
+ logger.error("The maximum number of retries (%s) was reached...", self._maxtries)
1448
+ if self._fatal:
1449
+ raise RuntimeError("Could not execute the SSH command.")
1450
+ finally:
1451
+ self._fatal_in_progress = False
1452
+ return rc
1453
+
1454
+ return wrapped
1455
+
1456
+
1457
+ @nicedeco
1458
+ def _tryagain(func):
1459
+ """decorator: whenever the return code != True, several attempts are made according to self._maxtries.
1460
+
1461
+ This decorator is very specialised and should be used solely with the AssistedSsh
1462
+ class since it relies on several attributes (_retry_in_progress, _retries, _maxtries).
1463
+ """
1464
+
1465
+ def wrapped(*args, **kwargs):
1466
+ self = args[0]
1467
+ if self._retry_in_progress:
1468
+ return func(self, *args[1:], **kwargs)
1469
+ else:
1470
+ # This trick ensures that only one retry loop is attempted
1471
+ self._retry_in_progress = True
1472
+ trycount = 1
1473
+ try:
1474
+ rc = func(self, *args[1:], **kwargs)
1475
+ while not rc and trycount < self._maxtries:
1476
+ trycount += 1
1477
+ logger.info("Trying again (retries=%d/%d)...", trycount, self._maxtries)
1478
+ self.sh.sleep(self._triesdelay)
1479
+ rc = func(self, *args[1:], **kwargs)
1480
+ finally:
1481
+ self._retries = trycount
1482
+ self._retry_in_progress = False
1483
+ return rc
1484
+
1485
+ return wrapped
1486
+
1487
+
1488
+ class _AssistedSshMeta(type):
1489
+ """Specialized metaclass for AssitedSsh."""
1490
+
1491
+ def __new__(cls, n, b, d):
1492
+ """Adds _tryagain and _check_fatal decorators on a list of inherited methods.
1493
+
1494
+ This is controled by two class variables:
1495
+
1496
+ - _auto_retries: list of inherited methods that should be decorated
1497
+ with _tryagin
1498
+ - _auto_checkfatal: list of inherited methods that should be
1499
+ decorated with _check_fatal
1500
+
1501
+ Note: it only acts on inherited methods. For overridden methods,
1502
+ decorators have to be added manually.
1503
+ """
1504
+ bare_methods = list(d.keys())
1505
+ # Add the tryagain decorator...
1506
+ for tagain in [x for x in d['_auto_retries'] if x not in bare_methods]:
1507
+ inherited = [base for base in b if hasattr(base, tagain)]
1508
+ d[tagain] = _tryagain(getattr(inherited[0], tagain))
1509
+ # Add the check_fatal decorator...
1510
+ for cfatal in [x for x in d['_auto_checkfatal'] if x not in bare_methods]:
1511
+ inherited = [base for base in b if hasattr(base, cfatal)]
1512
+ d[cfatal] = _check_fatal(d.get(cfatal, getattr(inherited[0], cfatal)))
1513
+ return super().__new__(cls, n, b, d)
1514
+
1515
+
1516
+ class AssistedSsh(Ssh, metaclass=_AssistedSshMeta):
1517
+ """Remote command execution via ssh.
1518
+
1519
+ Also handles remote copy via scp or ssh, which is intimately linked.
1520
+ Compared to the :class:`Ssh` class it adds:
1521
+
1522
+ - retries capabilities
1523
+ - support for multiple hostnames (a hostname is picked up in the hostnames
1524
+ list, it is tested and if the test succeeds it is chosen. If not, the next
1525
+ hostname is tested, ... and so on).
1526
+ - virtual nodes support (i.e. the real hostnames associated with a virtual
1527
+ node name are read in the configuration file).
1528
+
1529
+ Examples (`sh` being an :class:`~vortex.tools.systems.OSExtended` object):
1530
+
1531
+ - Basic use::
1532
+
1533
+ >>> ssh1 = AssistedSsh(sh, 'localhost')
1534
+ >>> print(ssh1, ssh1.remote)
1535
+ <vortex.tools.net.AssistedSsh object at 0x7fac3bb19810> localhost
1536
+ >> ssh1.execute("echo -n 'My name is: '; hostname")
1537
+ ['My name is: belenoslogin3']
1538
+
1539
+ - Using virtual nodes names (let's consider here that "network" nodes are
1540
+ defined in the current target-?.ini configuration file)::
1541
+
1542
+ >>> ssh2 = AssistedSsh(sh, 'network', virtualnode=True)
1543
+ >>> print(ssh2, ssh2.targets) # The list of possible network nodes
1544
+ ['belenoslogin0', 'belenoslogin1', 'belenoslogin2', 'belenoslogin3', ]
1545
+ >>> print(ssh2, ssh2.remote) # Pick one randomly
1546
+ 'belenoslogin2'
1547
+
1548
+ - The multiple retries concept::
1549
+
1550
+ >>> ssh3 = AssistedSsh(sh, 'network', virtualnode=True, maxtries=3)
1551
+ >>> print(ssh3, ssh3.remote) # Pick one randomly
1552
+ 'belenoslogin0'
1553
+ >>> ssh3.execute("false")
1554
+ # [2018/02/19-11:29:00][vortex.tools.systems][spawn:0878][WARNING]:
1555
+ Bad return code [1] for ['ssh', '-x', 'belenoslogin0', 'false']
1556
+ # [2018/02/19-11:29:00][vortex.tools.systems][spawn:0885][WARNING]: Carry on because fatal is off
1557
+ # [2018/02/19-11:29:00][vortex.tools.net][wrapped:1296][INFO]: Trying again (retries=2/3)...
1558
+ # [2018/02/19-11:29:01][vortex.tools.systems][spawn:0878][WARNING]:
1559
+ Bad return code [1] for ['ssh', '-x', 'belenoslogin0', 'false']
1560
+ # [2018/02/19-11:29:01][vortex.tools.systems][spawn:0885][WARNING]: Carry on because fatal is off
1561
+ # [2018/02/19-11:29:01][vortex.tools.net][wrapped:1296][INFO]: Trying again (retries=3/3)...
1562
+ # [2018/02/19-11:29:02][vortex.tools.systems][spawn:0878][WARNING]:
1563
+ Bad return code [1] for ['ssh', '-x', 'belenoslogin0', 'false']
1564
+ # [2018/02/19-11:29:02][vortex.tools.systems][spawn:0885][WARNING]: Carry on because fatal is off
1565
+ # [2018/02/19-11:29:02][vortex.tools.net][wrapped:1268][ERROR]: The maximum number of retries (3) was reached...
1566
+ False
1567
+
1568
+ - Raise an exception on failure::
1569
+
1570
+ >>> ssh4 = AssistedSsh(sh, 'network', virtualnode=True, fatal=True)
1571
+ >>> ssh4.execute("false")
1572
+ # [2018/02/19-11:29:00][vortex.tools.systems][spawn:0878][WARNING]:
1573
+ Bad return code [1] for ['ssh', '-x', 'belenoslogin0', 'false']
1574
+ # [2018/02/19-11:29:00][vortex.tools.systems][spawn:0885][WARNING]: Carry on because fatal is off
1575
+ # [2018/02/19-11:29:02][vortex.tools.net][wrapped:1268][ERROR]: The maximum number of retries (1) was reached...
1576
+ RuntimeError: Could not execute the SSH command.
1577
+
1578
+ """
1579
+
1580
+ _auto_checkfatal = ['check_ok', 'execute', 'cocoon', 'remove',
1581
+ 'scpput', 'scpget', 'scpput_stream', 'scpget_stream',
1582
+ 'tunnel']
1583
+ # No retries on scpput_stream since it's not guaranteed that the stream is seekable.
1584
+ _auto_retries = ['check_ok', 'execute', 'cocoon', 'remove',
1585
+ 'scpput', 'scpget', 'tunnel']
1586
+
1587
+ def __init__(self, sh, hostname, logname=None, sshopts=None, scpopts=None,
1588
+ maxtries=1, triesdelay=1, virtualnode=False, permut=True,
1589
+ fatal=False, mandatory_hostcheck=True):
1590
+ """
1591
+ :param System sh: The :class:`System` object that is to be used.
1592
+ :param hostname: The target hostname(s).
1593
+ :type hostname: str or list
1594
+ :param logname: The logname for the Ssh commands.
1595
+ :param str sshopts: Extra SSH options (in addition to the configuration file ones).
1596
+ :param str scpopts: Extra SCP options (in addition to the configuration file ones).
1597
+ :param int maxtries: The maximum number of retries.
1598
+ :param int triesdelay: The delay in seconds between retries.
1599
+ :param bool virtualnode: If True, the *hostname* is considered to be a
1600
+ virtual node name. It is therefore looked up in
1601
+ the configuration file.
1602
+ :param bool permut: If True, the hostnames list is shuffled prior to
1603
+ being used.
1604
+ :param bool fatal: If True, a RuntimeError exception is raised whenever
1605
+ something fails.
1606
+ :param mandatory_hostcheck: If True and several host names are provided,
1607
+ the hostname is always checked prior to being
1608
+ used for the real Ssh command. When a single
1609
+ host name is provided, such a check is never
1610
+ performed.
1611
+ """
1612
+ super().__init__(sh, hostname, logname, sshopts, scpopts)
1613
+ self._triesdelay = triesdelay
1614
+ self._virtualnode = virtualnode
1615
+ self._permut = permut
1616
+ self._fatal = fatal
1617
+ self._mandatory_hostcheck = mandatory_hostcheck
1618
+ if self._virtualnode and isinstance(self._remote, (list, tuple)):
1619
+ raise ValueError('When virtual nodes are used, the hostname must be a string')
1620
+
1621
+ self._retry_in_progress = False
1622
+ self._fatal_in_progress = False
1623
+ self._retries = 0
1624
+ self._targets = self._setup_targets()
1625
+ self._targets_iter = itertools.cycle(self._targets)
1626
+ if not self._mandatory_hostcheck and len(self._targets) > 1:
1627
+ # Try at least one time with each of the possible targets
1628
+ self._maxtries = maxtries + len(self._targets) - 1
1629
+ else:
1630
+ self._maxtries = maxtries
1631
+ self._chosen_target = None
1632
+
1633
+ def _setup_targets(self):
1634
+ """Build the actual hostnames list."""
1635
+ if self._virtualnode:
1636
+ targets = self.sh.default_target.specialproxies[self._remote]
1637
+ else:
1638
+ if isinstance(self._remote, (list, tuple)):
1639
+ targets = self._remote
1640
+ else:
1641
+ targets = [self._remote]
1642
+ if self._logname is not None:
1643
+ targets = [self._logname + '@' + x for x in targets]
1644
+ if self._permut:
1645
+ random.shuffle(targets)
1646
+ return targets
1647
+
1648
+ @property
1649
+ def targets(self):
1650
+ """The actual hostnames list."""
1651
+ return self._targets
1652
+
1653
+ @property
1654
+ def retries(self):
1655
+ """The number of tries made for the last Ssh command."""
1656
+ return self._retries
1657
+
1658
+ @property
1659
+ @_check_fatal
1660
+ @_tryagain
1661
+ def remote(self):
1662
+ """Hostname to use for this kind of remote execution."""
1663
+ if len(self.targets) == 1:
1664
+ # This is simple enough, do not bother testing...
1665
+ self._chosen_target = self.targets[0]
1666
+ # Ok, let's take self._mandatory_hostcheck into account
1667
+ if self._mandatory_hostcheck:
1668
+ if self._chosen_target is None:
1669
+ for guess in self.targets:
1670
+ cmd = [self._sshcmd, ] + self._sshopts + [guess, 'true', ]
1671
+ try:
1672
+ self.sh.spawn(cmd, output=False, silent=True)
1673
+ except Exception:
1674
+ pass
1675
+ else:
1676
+ self._chosen_target = guess
1677
+ break
1678
+ return self._chosen_target
1679
+ else:
1680
+ return next(self._targets_iter)
1681
+
1682
+
1683
+ _ConnectionStatusAttrs = ('Family', 'LocalAddr', 'LocalPort', 'DestAddr', 'DestPort', 'Status')
1684
+ TcpConnectionStatus = namedtuple('TcpConnectionStatus', _ConnectionStatusAttrs)
1685
+ UdpConnectionStatus = namedtuple('UdpConnectionStatus', _ConnectionStatusAttrs)
1686
+
1687
+
1688
+ class AbstractNetstats(metaclass=abc.ABCMeta):
1689
+ """AbstractNetstats classes provide all kind of informations on network connections."""
1690
+
1691
+ @property
1692
+ @abc.abstractmethod
1693
+ def unprivileged_ports(self):
1694
+ """The list of unprivileged port that may be opened by any user."""
1695
+ pass
1696
+
1697
+ @abc.abstractmethod
1698
+ def tcp_netstats(self):
1699
+ """Informations on active TCP connections.
1700
+
1701
+ Returns a list of :class:`TcpConnectionStatus` objects.
1702
+ """
1703
+ pass
1704
+
1705
+ @abc.abstractmethod
1706
+ def udp_netstats(self):
1707
+ """Informations on active UDP connections.
1708
+
1709
+ Returns a list of :class:`UdpConnectionStatus` objects.
1710
+ """
1711
+ pass
1712
+
1713
+ def available_localport(self):
1714
+ """Returns the number of an unused unprivileged port."""
1715
+ netstats = self.tcp_netstats() + self.udp_netstats()
1716
+ busyports = {x.LocalPort for x in netstats}
1717
+ busy = True
1718
+ while busy:
1719
+ guess_port = random.choice(self.unprivileged_ports)
1720
+ busy = guess_port in busyports
1721
+ return guess_port
1722
+
1723
+ def check_localport(self, port):
1724
+ """Check if ``port`` is currently in use."""
1725
+ netstats = self.tcp_netstats() + self.udp_netstats()
1726
+ busyports = {x.LocalPort for x in netstats}
1727
+ return port in busyports
1728
+
1729
+
1730
+ class LinuxNetstats(AbstractNetstats):
1731
+ """A Netstats implementation for Linux (based on the /proc/net data)."""
1732
+
1733
+ _LINUX_LPORT = '/proc/sys/net/ipv4/ip_local_port_range'
1734
+ _LINUX_PORTS_V4 = {'tcp': '/proc/net/tcp',
1735
+ 'udp': '/proc/net/udp'}
1736
+ _LINUX_PORTS_V6 = {'tcp': '/proc/net/tcp6',
1737
+ 'udp': '/proc/net/udp6'}
1738
+ _LINUX_AF_INET4 = socket.AF_INET
1739
+ _LINUX_AF_INET6 = socket.AF_INET6
1740
+
1741
+ def __init__(self):
1742
+ self.__unprivileged_ports = None
1743
+
1744
+ @property
1745
+ def unprivileged_ports(self):
1746
+ if self.__unprivileged_ports is None:
1747
+ with open(self._LINUX_LPORT) as tmprange:
1748
+ tmpports = [int(x) for x in tmprange.readline().split()]
1749
+ unports = set(range(5001, 65536))
1750
+ self.__unprivileged_ports = sorted(unports - set(range(tmpports[0], tmpports[1] + 1)))
1751
+ return self.__unprivileged_ports
1752
+
1753
+ @classmethod
1754
+ def _ip_from_hex(cls, hexip, family=_LINUX_AF_INET4):
1755
+ if family == cls._LINUX_AF_INET4:
1756
+ packed = struct.pack(b"<I", int(hexip, 16))
1757
+ elif family == cls._LINUX_AF_INET6:
1758
+ packed = struct.unpack(b">IIII",
1759
+ binascii.a2b_hex(hexip))
1760
+ packed = struct.pack(b"@IIII", *packed)
1761
+ else:
1762
+ raise ValueError("Unknown address family.")
1763
+ return socket.inet_ntop(family, packed)
1764
+
1765
+ def _generic_netstats(self, proto, rclass):
1766
+ tmpports = dict()
1767
+ with open(self._LINUX_PORTS_V4[proto]) as netstats:
1768
+ netstats.readline() # Skip the header line
1769
+ tmpports[self._LINUX_AF_INET4] = [re.split(r':\b|\s+', x.strip())[1:6]
1770
+ for x in netstats.readlines()]
1771
+ try:
1772
+ with open(self._LINUX_PORTS_V6[proto]) as netstats:
1773
+ netstats.readline() # Skip the header line
1774
+ tmpports[self._LINUX_AF_INET6] = [re.split(r':\b|\s+', x.strip())[1:6]
1775
+ for x in netstats.readlines()]
1776
+ except OSError:
1777
+ # Apparently, no IPv6 support on this machine
1778
+ tmpports[self._LINUX_AF_INET6] = []
1779
+ tmpports = [[rclass(family,
1780
+ self._ip_from_hex(l[0], family), int(l[1], 16),
1781
+ self._ip_from_hex(l[2], family), int(l[3], 16),
1782
+ int(l[4], 16)) for l in tmpports[family]]
1783
+ for family in (self._LINUX_AF_INET4, self._LINUX_AF_INET6)]
1784
+ return functools.reduce(operator.add, tmpports)
1785
+
1786
+ def tcp_netstats(self):
1787
+ return self._generic_netstats('tcp', TcpConnectionStatus)
1788
+
1789
+ def udp_netstats(self):
1790
+ return self._generic_netstats('udp', UdpConnectionStatus)