meerschaum 2.1.7__py3-none-any.whl → 2.2.0__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.
- meerschaum/__main__.py +1 -1
- meerschaum/_internal/arguments/_parser.py +3 -0
- meerschaum/_internal/entry.py +3 -2
- meerschaum/actions/install.py +7 -3
- meerschaum/actions/show.py +128 -42
- meerschaum/actions/sync.py +7 -3
- meerschaum/api/__init__.py +24 -14
- meerschaum/api/_oauth2.py +4 -4
- meerschaum/api/dash/callbacks/dashboard.py +93 -23
- meerschaum/api/dash/callbacks/jobs.py +55 -3
- meerschaum/api/dash/jobs.py +34 -8
- meerschaum/api/dash/keys.py +1 -1
- meerschaum/api/dash/pages/dashboard.py +14 -4
- meerschaum/api/dash/pipes.py +137 -26
- meerschaum/api/dash/plugins.py +25 -9
- meerschaum/api/resources/static/js/xterm.js +1 -1
- meerschaum/api/resources/templates/termpage.html +3 -0
- meerschaum/api/routes/_login.py +5 -4
- meerschaum/api/routes/_plugins.py +6 -3
- meerschaum/config/_dash.py +11 -0
- meerschaum/config/_default.py +3 -1
- meerschaum/config/_jobs.py +13 -4
- meerschaum/config/_paths.py +2 -0
- meerschaum/config/_sync.py +2 -3
- meerschaum/config/_version.py +1 -1
- meerschaum/config/stack/__init__.py +6 -7
- meerschaum/config/stack/grafana/__init__.py +1 -1
- meerschaum/config/static/__init__.py +4 -1
- meerschaum/connectors/__init__.py +2 -0
- meerschaum/connectors/api/_plugins.py +2 -1
- meerschaum/connectors/sql/SQLConnector.py +4 -2
- meerschaum/connectors/sql/_create_engine.py +9 -9
- meerschaum/connectors/sql/_instance.py +3 -1
- meerschaum/connectors/sql/_pipes.py +54 -38
- meerschaum/connectors/sql/_plugins.py +0 -2
- meerschaum/connectors/sql/_sql.py +7 -9
- meerschaum/core/User/_User.py +158 -16
- meerschaum/core/User/__init__.py +1 -1
- meerschaum/plugins/_Plugin.py +12 -3
- meerschaum/plugins/__init__.py +23 -1
- meerschaum/utils/daemon/Daemon.py +89 -36
- meerschaum/utils/daemon/FileDescriptorInterceptor.py +140 -0
- meerschaum/utils/daemon/RotatingFile.py +130 -14
- meerschaum/utils/daemon/__init__.py +3 -0
- meerschaum/utils/dtypes/__init__.py +9 -5
- meerschaum/utils/packages/__init__.py +21 -5
- meerschaum/utils/packages/_packages.py +18 -20
- meerschaum/utils/process.py +13 -10
- meerschaum/utils/schedule.py +276 -30
- meerschaum/utils/threading.py +1 -0
- meerschaum/utils/typing.py +1 -1
- {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/METADATA +59 -62
- {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/RECORD +59 -57
- {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/WHEEL +1 -1
- {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/LICENSE +0 -0
- {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/NOTICE +0 -0
- {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/entry_points.txt +0 -0
- {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/top_level.txt +0 -0
- {meerschaum-2.1.7.dist-info → meerschaum-2.2.0.dist-info}/zip-safe +0 -0
meerschaum/core/User/_User.py
CHANGED
@@ -7,22 +7,157 @@ User class definition
|
|
7
7
|
"""
|
8
8
|
|
9
9
|
from __future__ import annotations
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
10
|
+
import os
|
11
|
+
import hashlib
|
12
|
+
import hmac
|
13
|
+
from binascii import b2a_base64, a2b_base64, Error as _BinAsciiError
|
14
|
+
from meerschaum.utils.typing import Optional, Dict, Any, Tuple
|
15
|
+
from meerschaum.config.static import STATIC_CONFIG
|
16
|
+
from meerschaum.utils.warnings import warn
|
17
|
+
|
18
|
+
|
19
|
+
__all__ = ('hash_password', 'verify_password', 'User')
|
20
|
+
|
21
|
+
def hash_password(
|
22
|
+
password: str,
|
23
|
+
salt: Optional[bytes] = None,
|
24
|
+
rounds: Optional[int] = None,
|
25
|
+
) -> str:
|
26
|
+
"""
|
27
|
+
Return an encoded hash string from the given password.
|
28
|
+
|
29
|
+
Parameters
|
30
|
+
----------
|
31
|
+
password: str
|
32
|
+
The password to be hashed.
|
33
|
+
|
34
|
+
salt: Optional[str], default None
|
35
|
+
If provided, use these bytes for the salt in the hash.
|
36
|
+
Otherwise defaults to 16 random bytes.
|
37
|
+
|
38
|
+
rounds: Optional[int], default None
|
39
|
+
If provided, use this number of rounds to generate the hash.
|
40
|
+
Defaults to 3,000,000.
|
41
|
+
|
42
|
+
Returns
|
43
|
+
-------
|
44
|
+
An encoded hash string to be stored in a database.
|
45
|
+
See the `passlib` documentation on the string format:
|
46
|
+
https://passlib.readthedocs.io/en/stable/lib/passlib.hash.pbkdf2_digest.html#format-algorithm
|
47
|
+
"""
|
48
|
+
hash_config = STATIC_CONFIG['users']['password_hash']
|
49
|
+
if password is None:
|
50
|
+
password = ''
|
51
|
+
if salt is None:
|
52
|
+
salt = os.urandom(hash_config['salt_bytes'])
|
53
|
+
if rounds is None:
|
54
|
+
rounds = hash_config['pbkdf2_sha256__default_rounds']
|
55
|
+
|
56
|
+
pw_hash = hashlib.pbkdf2_hmac(
|
57
|
+
hash_config['algorithm_name'],
|
58
|
+
password.encode('utf-8'),
|
59
|
+
salt,
|
60
|
+
rounds,
|
61
|
+
)
|
62
|
+
return (
|
63
|
+
f"$pbkdf2-{hash_config['algorithm_name']}"
|
64
|
+
+ f"${hash_config['pbkdf2_sha256__default_rounds']}"
|
65
|
+
+ '$' + ab64_encode(salt).decode('utf-8')
|
66
|
+
+ '$' + ab64_encode(pw_hash).decode('utf-8')
|
67
|
+
)
|
68
|
+
|
69
|
+
|
70
|
+
def verify_password(
|
71
|
+
password: str,
|
72
|
+
password_hash: str,
|
73
|
+
) -> bool:
|
74
|
+
"""
|
75
|
+
Return `True` if the password matches the provided hash.
|
76
|
+
|
77
|
+
Parameters
|
78
|
+
----------
|
79
|
+
password: str
|
80
|
+
The password to be checked.
|
81
|
+
|
82
|
+
password_hash: str
|
83
|
+
The encoded hash string as generated from `hash_password()`.
|
84
|
+
|
85
|
+
Returns
|
86
|
+
-------
|
87
|
+
A `bool` indicating whether `password` matches `password_hash`.
|
88
|
+
"""
|
89
|
+
if password is None or password_hash is None:
|
90
|
+
return False
|
91
|
+
hash_config = STATIC_CONFIG['users']['password_hash']
|
92
|
+
try:
|
93
|
+
digest, rounds_str, encoded_salt, encoded_checksum = password_hash.split('$')[1:]
|
94
|
+
algorithm_name = digest.split('-')[-1]
|
95
|
+
salt = ab64_decode(encoded_salt)
|
96
|
+
checksum = ab64_decode(encoded_checksum)
|
97
|
+
rounds = int(rounds_str)
|
98
|
+
except Exception as e:
|
99
|
+
warn(f"Failed to extract context from password hash '{password_hash}'. Is it corrupted?")
|
100
|
+
return False
|
101
|
+
|
102
|
+
return hmac.compare_digest(
|
103
|
+
checksum,
|
104
|
+
hashlib.pbkdf2_hmac(
|
105
|
+
algorithm_name,
|
106
|
+
password.encode('utf-8'),
|
107
|
+
salt,
|
108
|
+
rounds,
|
24
109
|
)
|
25
|
-
|
110
|
+
)
|
111
|
+
|
112
|
+
_BASE64_STRIP = b"=\n"
|
113
|
+
_BASE64_PAD1 = b"="
|
114
|
+
_BASE64_PAD2 = b"=="
|
115
|
+
|
116
|
+
def ab64_encode(data):
|
117
|
+
return b64s_encode(data).replace(b"+", b".")
|
118
|
+
|
119
|
+
def ab64_decode(data):
|
120
|
+
"""
|
121
|
+
decode from shortened base64 format which omits padding & whitespace.
|
122
|
+
uses custom ``./`` altchars, but supports decoding normal ``+/`` altchars as well.
|
123
|
+
"""
|
124
|
+
if isinstance(data, str):
|
125
|
+
# needs bytes for replace() call, but want to accept ascii-unicode ala a2b_base64()
|
126
|
+
try:
|
127
|
+
data = data.encode("ascii")
|
128
|
+
except UnicodeEncodeError:
|
129
|
+
raise ValueError("string argument should contain only ASCII characters")
|
130
|
+
return b64s_decode(data.replace(b".", b"+"))
|
131
|
+
|
132
|
+
|
133
|
+
def b64s_encode(data):
|
134
|
+
return b2a_base64(data).rstrip(_BASE64_STRIP)
|
135
|
+
|
136
|
+
def b64s_decode(data):
|
137
|
+
"""
|
138
|
+
decode from shortened base64 format which omits padding & whitespace.
|
139
|
+
uses default ``+/`` altchars.
|
140
|
+
"""
|
141
|
+
if isinstance(data, str):
|
142
|
+
# needs bytes for replace() call, but want to accept ascii-unicode ala a2b_base64()
|
143
|
+
try:
|
144
|
+
data = data.encode("ascii")
|
145
|
+
except UnicodeEncodeError as ue:
|
146
|
+
raise ValueError("string argument should contain only ASCII characters") from ue
|
147
|
+
off = len(data) & 3
|
148
|
+
if off == 0:
|
149
|
+
pass
|
150
|
+
elif off == 2:
|
151
|
+
data += _BASE64_PAD2
|
152
|
+
elif off == 3:
|
153
|
+
data += _BASE64_PAD1
|
154
|
+
else: # off == 1
|
155
|
+
raise ValueError("Invalid base64 input")
|
156
|
+
try:
|
157
|
+
return a2b_base64(data)
|
158
|
+
except _BinAsciiError as err:
|
159
|
+
raise TypeError(err) from err
|
160
|
+
|
26
161
|
|
27
162
|
class User:
|
28
163
|
"""
|
@@ -42,7 +177,6 @@ class User:
|
|
42
177
|
if password is None:
|
43
178
|
password = ''
|
44
179
|
self.password = password
|
45
|
-
self.password_hash = get_pwd_context().hash(password)
|
46
180
|
self.username = username
|
47
181
|
self.email = email
|
48
182
|
self.type = type
|
@@ -80,3 +214,11 @@ class User:
|
|
80
214
|
@user_id.setter
|
81
215
|
def user_id(self, user_id):
|
82
216
|
self._user_id = user_id
|
217
|
+
|
218
|
+
@property
|
219
|
+
def password_hash(self):
|
220
|
+
_password_hash = self.__dict__.get('_password_hash', None)
|
221
|
+
if _password_hash is not None:
|
222
|
+
return _password_hash
|
223
|
+
self._password_hash = hash_password(self.password)
|
224
|
+
return self._password_hash
|
meerschaum/core/User/__init__.py
CHANGED
meerschaum/plugins/_Plugin.py
CHANGED
@@ -209,7 +209,7 @@ class Plugin:
|
|
209
209
|
def parse_gitignore() -> 'Set[str]':
|
210
210
|
gitignore_path = pathlib.Path(path) / '.gitignore'
|
211
211
|
if not gitignore_path.exists():
|
212
|
-
return set()
|
212
|
+
return set(default_patterns_to_ignore)
|
213
213
|
with open(gitignore_path, 'r', encoding='utf-8') as f:
|
214
214
|
gitignore_text = f.read()
|
215
215
|
return set(pathspec.PathSpec.from_lines(
|
@@ -252,6 +252,7 @@ class Plugin:
|
|
252
252
|
|
253
253
|
def install(
|
254
254
|
self,
|
255
|
+
skip_deps: bool = False,
|
255
256
|
force: bool = False,
|
256
257
|
debug: bool = False,
|
257
258
|
) -> SuccessTuple:
|
@@ -263,6 +264,9 @@ class Plugin:
|
|
263
264
|
|
264
265
|
Parameters
|
265
266
|
----------
|
267
|
+
skip_deps: bool, default False
|
268
|
+
If `True`, do not install dependencies.
|
269
|
+
|
266
270
|
force: bool, default False
|
267
271
|
If `True`, continue with installation, even if required packages fail to install.
|
268
272
|
|
@@ -366,7 +370,11 @@ class Plugin:
|
|
366
370
|
plugin_installation_dir_path = path
|
367
371
|
break
|
368
372
|
|
369
|
-
success_msg =
|
373
|
+
success_msg = (
|
374
|
+
f"Successfully installed plugin '{self}'"
|
375
|
+
+ ("\n (skipped dependencies)" if skip_deps else "")
|
376
|
+
+ "."
|
377
|
+
)
|
370
378
|
success, abort = None, None
|
371
379
|
|
372
380
|
if is_same_version and not force:
|
@@ -423,7 +431,8 @@ class Plugin:
|
|
423
431
|
return success, msg
|
424
432
|
|
425
433
|
### attempt to install dependencies
|
426
|
-
|
434
|
+
dependencies_installed = skip_deps or self.install_dependencies(force=force, debug=debug)
|
435
|
+
if not dependencies_installed:
|
427
436
|
_ongoing_installations.remove(self.full_name)
|
428
437
|
return False, f"Failed to install dependencies for plugin '{self}'."
|
429
438
|
|
meerschaum/plugins/__init__.py
CHANGED
@@ -247,6 +247,26 @@ def sync_plugins_symlinks(debug: bool = False, warn: bool = True) -> None:
|
|
247
247
|
_warn(f"Unable to create lockfile {PLUGINS_INTERNAL_LOCK_PATH}:\n{e}")
|
248
248
|
|
249
249
|
with _locks['internal_plugins']:
|
250
|
+
|
251
|
+
try:
|
252
|
+
from importlib.metadata import entry_points
|
253
|
+
except ImportError:
|
254
|
+
importlib_metadata = attempt_import('importlib_metadata', lazy=False)
|
255
|
+
entry_points = importlib_metadata.entry_points
|
256
|
+
|
257
|
+
### NOTE: Allow plugins to be installed via `pip`.
|
258
|
+
packaged_plugin_paths = []
|
259
|
+
discovered_packaged_plugins_eps = entry_points(group='meerschaum.plugins')
|
260
|
+
for ep in discovered_packaged_plugins_eps:
|
261
|
+
module_name = ep.name
|
262
|
+
for package_file_path in ep.dist.files:
|
263
|
+
if package_file_path.suffix != '.py':
|
264
|
+
continue
|
265
|
+
if str(package_file_path) == f'{module_name}.py':
|
266
|
+
packaged_plugin_paths.append(package_file_path.locate())
|
267
|
+
elif str(package_file_path) == f'{module_name}/__init__.py':
|
268
|
+
packaged_plugin_paths.append(package_file_path.locate().parent)
|
269
|
+
|
250
270
|
if is_symlink(PLUGINS_RESOURCES_PATH) or not PLUGINS_RESOURCES_PATH.exists():
|
251
271
|
try:
|
252
272
|
PLUGINS_RESOURCES_PATH.unlink()
|
@@ -255,7 +275,6 @@ def sync_plugins_symlinks(debug: bool = False, warn: bool = True) -> None:
|
|
255
275
|
|
256
276
|
PLUGINS_RESOURCES_PATH.mkdir(exist_ok=True)
|
257
277
|
|
258
|
-
|
259
278
|
existing_symlinked_paths = [
|
260
279
|
(PLUGINS_RESOURCES_PATH / item)
|
261
280
|
for item in os.listdir(PLUGINS_RESOURCES_PATH)
|
@@ -275,6 +294,7 @@ def sync_plugins_symlinks(debug: bool = False, warn: bool = True) -> None:
|
|
275
294
|
for plugins_path in PLUGINS_DIR_PATHS
|
276
295
|
]
|
277
296
|
))
|
297
|
+
plugins_to_be_symlinked.extend(packaged_plugin_paths)
|
278
298
|
|
279
299
|
### Check for duplicates.
|
280
300
|
seen_plugins = defaultdict(lambda: 0)
|
@@ -538,6 +558,8 @@ def get_plugins(*to_load, try_import: bool = True) -> Union[Tuple[Plugin], Plugi
|
|
538
558
|
]
|
539
559
|
plugins = tuple(plugin for plugin in _plugins if plugin.is_installed(try_import=try_import))
|
540
560
|
if len(to_load) == 1:
|
561
|
+
if len(plugins) == 0:
|
562
|
+
raise ValueError(f"Plugin '{to_load[0]}' is not installed.")
|
541
563
|
return plugins[0]
|
542
564
|
return plugins
|
543
565
|
|
@@ -15,9 +15,11 @@ import signal
|
|
15
15
|
import sys
|
16
16
|
import time
|
17
17
|
import traceback
|
18
|
+
from functools import partial
|
18
19
|
from datetime import datetime, timezone
|
19
20
|
from meerschaum.utils.typing import Optional, Dict, Any, SuccessTuple, Callable, List, Union
|
20
21
|
from meerschaum.config import get_config
|
22
|
+
from meerschaum.config.static import STATIC_CONFIG
|
21
23
|
from meerschaum.config._paths import DAEMON_RESOURCES_PATH, LOGS_RESOURCES_PATH
|
22
24
|
from meerschaum.config._patch import apply_patch_to_config
|
23
25
|
from meerschaum.utils.warnings import warn, error
|
@@ -36,7 +38,7 @@ class Daemon:
|
|
36
38
|
def __new__(
|
37
39
|
cls,
|
38
40
|
*args,
|
39
|
-
daemon_id
|
41
|
+
daemon_id: Optional[str] = None,
|
40
42
|
**kw
|
41
43
|
):
|
42
44
|
"""
|
@@ -129,7 +131,7 @@ class Daemon:
|
|
129
131
|
keep_daemon_output: bool, default True
|
130
132
|
If `False`, delete the daemon's output directory upon exiting.
|
131
133
|
|
132
|
-
allow_dirty_run :
|
134
|
+
allow_dirty_run, bool, default False:
|
133
135
|
If `True`, run the daemon, even if the `daemon_id` directory exists.
|
134
136
|
This option is dangerous because if the same `daemon_id` runs twice,
|
135
137
|
the last to finish will overwrite the output of the first.
|
@@ -138,8 +140,13 @@ class Daemon:
|
|
138
140
|
-------
|
139
141
|
Nothing — this will exit the parent process.
|
140
142
|
"""
|
141
|
-
import platform, sys, os
|
143
|
+
import platform, sys, os, traceback
|
144
|
+
from meerschaum.config._paths import DAEMON_ERROR_LOG_PATH
|
145
|
+
from meerschaum.utils.warnings import warn
|
146
|
+
from meerschaum.config import get_config
|
142
147
|
daemon = attempt_import('daemon')
|
148
|
+
lines = get_config('jobs', 'terminal', 'lines')
|
149
|
+
columns = get_config('jobs','terminal', 'columns')
|
143
150
|
|
144
151
|
if platform.system() == 'Windows':
|
145
152
|
return False, "Windows is no longer supported."
|
@@ -160,31 +167,45 @@ class Daemon:
|
|
160
167
|
)
|
161
168
|
|
162
169
|
log_refresh_seconds = get_config('jobs', 'logs', 'refresh_files_seconds')
|
163
|
-
self._log_refresh_timer = RepeatTimer(
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
with open(self.pid_path, 'w+') as f:
|
168
|
-
f.write(str(os.getpid()))
|
170
|
+
self._log_refresh_timer = RepeatTimer(
|
171
|
+
log_refresh_seconds,
|
172
|
+
partial(self.rotating_log.refresh_files, start_interception=True),
|
173
|
+
)
|
169
174
|
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
self.rotating_log.close()
|
179
|
-
if self.pid is None and self.pid_path.exists():
|
180
|
-
self.pid_path.unlink()
|
175
|
+
try:
|
176
|
+
os.environ['LINES'], os.environ['COLUMNS'] = str(int(lines)), str(int(columns))
|
177
|
+
with self._daemon_context:
|
178
|
+
os.environ[STATIC_CONFIG['environment']['daemon_id']] = self.daemon_id
|
179
|
+
self.rotating_log.refresh_files(start_interception=True)
|
180
|
+
try:
|
181
|
+
with open(self.pid_path, 'w+', encoding='utf-8') as f:
|
182
|
+
f.write(str(os.getpid()))
|
181
183
|
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
184
|
+
self._log_refresh_timer.start()
|
185
|
+
result = self.target(*self.target_args, **self.target_kw)
|
186
|
+
self.properties['result'] = result
|
187
|
+
except Exception as e:
|
188
|
+
warn(e, stacklevel=3)
|
189
|
+
result = e
|
190
|
+
finally:
|
191
|
+
self._log_refresh_timer.cancel()
|
192
|
+
self.rotating_log.close()
|
193
|
+
if self.pid is None and self.pid_path.exists():
|
194
|
+
self.pid_path.unlink()
|
195
|
+
|
196
|
+
if keep_daemon_output:
|
197
|
+
self._capture_process_timestamp('ended')
|
198
|
+
else:
|
199
|
+
self.cleanup()
|
200
|
+
|
201
|
+
return result
|
202
|
+
except Exception as e:
|
203
|
+
daemon_error = traceback.format_exc()
|
204
|
+
with open(DAEMON_ERROR_LOG_PATH, 'a+', encoding='utf-8') as f:
|
205
|
+
f.write(daemon_error)
|
186
206
|
|
187
|
-
|
207
|
+
if daemon_error:
|
208
|
+
warn(f"Encountered an error while starting the daemon '{self}':\n{daemon_error}")
|
188
209
|
|
189
210
|
|
190
211
|
def _capture_process_timestamp(
|
@@ -444,6 +465,9 @@ class Daemon:
|
|
444
465
|
Handle `SIGINT` within the Daemon context.
|
445
466
|
This method is injected into the `DaemonContext`.
|
446
467
|
"""
|
468
|
+
# from meerschaum.utils.daemon.FileDescriptorInterceptor import STOP_READING_FD_EVENT
|
469
|
+
# STOP_READING_FD_EVENT.set()
|
470
|
+
self.rotating_log.stop_log_fd_interception(unused_only=False)
|
447
471
|
timer = self.__dict__.get('_log_refresh_timer', None)
|
448
472
|
if timer is not None:
|
449
473
|
timer.cancel()
|
@@ -453,9 +477,17 @@ class Daemon:
|
|
453
477
|
daemon_context.close()
|
454
478
|
|
455
479
|
_close_pools()
|
456
|
-
|
457
|
-
|
458
|
-
|
480
|
+
import threading
|
481
|
+
for thread in threading.enumerate():
|
482
|
+
if thread.name == 'MainThread':
|
483
|
+
continue
|
484
|
+
try:
|
485
|
+
if thread.is_alive():
|
486
|
+
stack = traceback.format_stack(sys._current_frames()[thread.ident])
|
487
|
+
thread.join()
|
488
|
+
except Exception as e:
|
489
|
+
warn(traceback.format_exc())
|
490
|
+
raise KeyboardInterrupt()
|
459
491
|
|
460
492
|
|
461
493
|
def _handle_sigterm(self, signal_number: int, stack_frame: 'frame') -> None:
|
@@ -472,8 +504,7 @@ class Daemon:
|
|
472
504
|
daemon_context.close()
|
473
505
|
|
474
506
|
_close_pools()
|
475
|
-
|
476
|
-
raise SystemExit()
|
507
|
+
raise SystemExit(0)
|
477
508
|
|
478
509
|
|
479
510
|
def _send_signal(
|
@@ -650,7 +681,12 @@ class Daemon:
|
|
650
681
|
if '_rotating_log' in self.__dict__:
|
651
682
|
return self._rotating_log
|
652
683
|
|
653
|
-
self._rotating_log = RotatingFile(
|
684
|
+
self._rotating_log = RotatingFile(
|
685
|
+
self.log_path,
|
686
|
+
redirect_streams = True,
|
687
|
+
write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled'),
|
688
|
+
timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format'),
|
689
|
+
)
|
654
690
|
return self._rotating_log
|
655
691
|
|
656
692
|
|
@@ -663,6 +699,8 @@ class Daemon:
|
|
663
699
|
self.rotating_log.file_path,
|
664
700
|
num_files_to_keep = self.rotating_log.num_files_to_keep,
|
665
701
|
max_file_size = self.rotating_log.max_file_size,
|
702
|
+
write_timestamps = get_config('jobs', 'logs', 'timestamps', 'enabled'),
|
703
|
+
timestamp_format = get_config('jobs', 'logs', 'timestamps', 'format'),
|
666
704
|
)
|
667
705
|
return new_rotating_log.read()
|
668
706
|
|
@@ -714,7 +752,7 @@ class Daemon:
|
|
714
752
|
if not self.pid_path.exists():
|
715
753
|
return None
|
716
754
|
try:
|
717
|
-
with open(self.pid_path, 'r') as f:
|
755
|
+
with open(self.pid_path, 'r', encoding='utf-8') as f:
|
718
756
|
text = f.read()
|
719
757
|
pid = int(text.rstrip())
|
720
758
|
except Exception as e:
|
@@ -815,7 +853,7 @@ class Daemon:
|
|
815
853
|
if self.properties is not None:
|
816
854
|
try:
|
817
855
|
self.path.mkdir(parents=True, exist_ok=True)
|
818
|
-
with open(self.properties_path, 'w+') as properties_file:
|
856
|
+
with open(self.properties_path, 'w+', encoding='utf-8') as properties_file:
|
819
857
|
json.dump(self.properties, properties_file)
|
820
858
|
success, msg = True, 'Success'
|
821
859
|
except Exception as e:
|
@@ -865,21 +903,36 @@ class Daemon:
|
|
865
903
|
error(_write_pickle_success_tuple[1])
|
866
904
|
|
867
905
|
|
868
|
-
def cleanup(self, keep_logs: bool = False) ->
|
869
|
-
"""
|
906
|
+
def cleanup(self, keep_logs: bool = False) -> SuccessTuple:
|
907
|
+
"""
|
908
|
+
Remove a daemon's directory after execution.
|
870
909
|
|
871
910
|
Parameters
|
872
911
|
----------
|
873
912
|
keep_logs: bool, default False
|
874
913
|
If `True`, skip deleting the daemon's log files.
|
914
|
+
|
915
|
+
Returns
|
916
|
+
-------
|
917
|
+
A `SuccessTuple` indicating success.
|
875
918
|
"""
|
876
919
|
if self.path.exists():
|
877
920
|
try:
|
878
921
|
shutil.rmtree(self.path)
|
879
922
|
except Exception as e:
|
880
|
-
|
923
|
+
msg = f"Failed to clean up '{self.daemon_id}':\n{e}"
|
924
|
+
warn(msg)
|
925
|
+
return False, msg
|
881
926
|
if not keep_logs:
|
882
927
|
self.rotating_log.delete()
|
928
|
+
try:
|
929
|
+
if self.log_offset_path.exists():
|
930
|
+
self.log_offset_path.unlink()
|
931
|
+
except Exception as e:
|
932
|
+
msg = f"Failed to remove offset file for '{self.daemon_id}':\n{e}"
|
933
|
+
warn(msg)
|
934
|
+
return False, msg
|
935
|
+
return True, "Success"
|
883
936
|
|
884
937
|
|
885
938
|
def get_timeout_seconds(self, timeout: Union[int, float, None] = None) -> Union[int, float]:
|
@@ -0,0 +1,140 @@
|
|
1
|
+
#! /usr/bin/env python3
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
# vim:fenc=utf-8
|
4
|
+
|
5
|
+
"""
|
6
|
+
Intercept OS-level file descriptors.
|
7
|
+
"""
|
8
|
+
|
9
|
+
import os
|
10
|
+
import select
|
11
|
+
import traceback
|
12
|
+
from threading import Event
|
13
|
+
from datetime import datetime
|
14
|
+
from meerschaum.utils.typing import Callable
|
15
|
+
from meerschaum.utils.warnings import warn
|
16
|
+
|
17
|
+
FD_CLOSED: int = 9
|
18
|
+
STOP_READING_FD_EVENT: Event = Event()
|
19
|
+
|
20
|
+
class FileDescriptorInterceptor:
|
21
|
+
"""
|
22
|
+
A management class to intercept data written to a file descriptor.
|
23
|
+
"""
|
24
|
+
def __init__(
|
25
|
+
self,
|
26
|
+
file_descriptor: int,
|
27
|
+
injection_hook: Callable[[], str],
|
28
|
+
):
|
29
|
+
"""
|
30
|
+
Parameters
|
31
|
+
----------
|
32
|
+
file_descriptor: int
|
33
|
+
The OS file descriptor from which to read.
|
34
|
+
|
35
|
+
injection_hook: Callable[[], str]
|
36
|
+
A callable which returns a string to be injected into the written data.
|
37
|
+
"""
|
38
|
+
self.stop_event = Event()
|
39
|
+
self.injection_hook = injection_hook
|
40
|
+
self.original_file_descriptor = file_descriptor
|
41
|
+
self.new_file_descriptor = os.dup(file_descriptor)
|
42
|
+
self.read_pipe, self.write_pipe = os.pipe()
|
43
|
+
self.signal_read_pipe, self.signal_write_pipe = os.pipe()
|
44
|
+
os.dup2(self.write_pipe, file_descriptor)
|
45
|
+
|
46
|
+
def start_interception(self):
|
47
|
+
"""
|
48
|
+
Read from the file descriptor and write the modified data after injection.
|
49
|
+
|
50
|
+
NOTE: This is blocking and is meant to be run in a thread.
|
51
|
+
"""
|
52
|
+
os.set_blocking(self.read_pipe, False)
|
53
|
+
os.set_blocking(self.signal_read_pipe, False)
|
54
|
+
is_first_read = True
|
55
|
+
while not self.stop_event.is_set():
|
56
|
+
try:
|
57
|
+
rlist, _, _ = select.select([self.read_pipe, self.signal_read_pipe], [], [], 0.1)
|
58
|
+
if self.signal_read_pipe in rlist:
|
59
|
+
break
|
60
|
+
if not rlist:
|
61
|
+
continue
|
62
|
+
data = os.read(self.read_pipe, 1024)
|
63
|
+
if not data:
|
64
|
+
break
|
65
|
+
except BlockingIOError:
|
66
|
+
continue
|
67
|
+
except OSError as e:
|
68
|
+
continue
|
69
|
+
|
70
|
+
first_char_is_newline = data[0] == b'\n'
|
71
|
+
last_char_is_newline = data[-1] == b'\n'
|
72
|
+
|
73
|
+
injected_str = self.injection_hook()
|
74
|
+
injected_bytes = injected_str.encode('utf-8')
|
75
|
+
|
76
|
+
if is_first_read:
|
77
|
+
data = b'\n' + data
|
78
|
+
is_first_read = False
|
79
|
+
|
80
|
+
modified_data = (
|
81
|
+
(data[:-1].replace(b'\n', b'\n' + injected_bytes) + b'\n')
|
82
|
+
if last_char_is_newline
|
83
|
+
else data.replace(b'\n', b'\n' + injected_bytes)
|
84
|
+
)
|
85
|
+
os.write(self.new_file_descriptor, modified_data)
|
86
|
+
|
87
|
+
|
88
|
+
def stop_interception(self):
|
89
|
+
"""
|
90
|
+
Close the new file descriptors.
|
91
|
+
"""
|
92
|
+
self.stop_event.set()
|
93
|
+
os.write(self.signal_write_pipe, b'\0')
|
94
|
+
try:
|
95
|
+
os.close(self.new_file_descriptor)
|
96
|
+
except OSError as e:
|
97
|
+
if e.errno != FD_CLOSED:
|
98
|
+
warn(
|
99
|
+
f"Error while trying to close the duplicated file descriptor:\n"
|
100
|
+
+ f"{traceback.format_exc()}"
|
101
|
+
)
|
102
|
+
|
103
|
+
try:
|
104
|
+
os.close(self.write_pipe)
|
105
|
+
except OSError as e:
|
106
|
+
if e.errno != FD_CLOSED:
|
107
|
+
warn(
|
108
|
+
f"Error while trying to close the write-pipe "
|
109
|
+
+ "to the intercepted file descriptor:\n"
|
110
|
+
+ f"{traceback.format_exc()}"
|
111
|
+
)
|
112
|
+
try:
|
113
|
+
os.close(self.read_pipe)
|
114
|
+
except OSError as e:
|
115
|
+
if e.errno != FD_CLOSED:
|
116
|
+
warn(
|
117
|
+
f"Error while trying to close the read-pipe "
|
118
|
+
+ "to the intercepted file descriptor:\n"
|
119
|
+
+ f"{traceback.format_exc()}"
|
120
|
+
)
|
121
|
+
|
122
|
+
try:
|
123
|
+
os.close(self.signal_read_pipe)
|
124
|
+
except OSError as e:
|
125
|
+
if e.errno != FD_CLOSED:
|
126
|
+
warn(
|
127
|
+
f"Error while trying to close the signal-read-pipe "
|
128
|
+
+ "to the intercepted file descriptor:\n"
|
129
|
+
+ f"{traceback.format_exc()}"
|
130
|
+
)
|
131
|
+
|
132
|
+
try:
|
133
|
+
os.close(self.signal_write_pipe)
|
134
|
+
except OSError as e:
|
135
|
+
if e.errno != FD_CLOSED:
|
136
|
+
warn(
|
137
|
+
f"Error while trying to close the signal-write-pipe "
|
138
|
+
+ "to the intercepted file descriptor:\n"
|
139
|
+
+ f"{traceback.format_exc()}"
|
140
|
+
)
|