azul-runner 9.0.38__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.
- azul_runner/__init__.py +58 -0
- azul_runner/binary_plugin.py +22 -0
- azul_runner/coordinator.py +387 -0
- azul_runner/local.py +102 -0
- azul_runner/log_setup.py +61 -0
- azul_runner/main.py +442 -0
- azul_runner/models.py +574 -0
- azul_runner/monitor.py +635 -0
- azul_runner/network.py +191 -0
- azul_runner/network_transform.py +294 -0
- azul_runner/plugin.py +210 -0
- azul_runner/plugin_executor.py +223 -0
- azul_runner/pusher.py +268 -0
- azul_runner/settings.py +257 -0
- azul_runner/storage.py +527 -0
- azul_runner/storage_spooled.py +24 -0
- azul_runner/test_utils/__init__.py +8 -0
- azul_runner/test_utils/test_template.py +633 -0
- azul_runner-9.0.38.dist-info/METADATA +178 -0
- azul_runner-9.0.38.dist-info/RECORD +22 -0
- azul_runner-9.0.38.dist-info/WHEEL +5 -0
- azul_runner-9.0.38.dist-info/top_level.txt +1 -0
azul_runner/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# flake8: noqa - Flake8 doesn't detect these exported functions and classes properly.
|
|
2
|
+
import contextlib
|
|
3
|
+
|
|
4
|
+
from azul_bedrock.models_network import DataLabel, FeatureType
|
|
5
|
+
from azul_bedrock.models_network import FeatureValue as APIFeatureValue
|
|
6
|
+
|
|
7
|
+
from .binary_plugin import BinaryPlugin
|
|
8
|
+
from .main import cmdline_run
|
|
9
|
+
from .models import (
|
|
10
|
+
FV,
|
|
11
|
+
Event,
|
|
12
|
+
EventData,
|
|
13
|
+
EventParent,
|
|
14
|
+
Feature,
|
|
15
|
+
FeatureValue,
|
|
16
|
+
Filepath,
|
|
17
|
+
Job,
|
|
18
|
+
JobResult,
|
|
19
|
+
State,
|
|
20
|
+
Uri,
|
|
21
|
+
)
|
|
22
|
+
from .plugin import Plugin
|
|
23
|
+
from .settings import add_settings
|
|
24
|
+
from .storage import DATA_HASH, DATA_HASH_NAME, StorageProxyFile
|
|
25
|
+
|
|
26
|
+
append_all: list[str] = []
|
|
27
|
+
with contextlib.suppress(ImportError):
|
|
28
|
+
from .test_utils import test_template
|
|
29
|
+
from .test_utils.test_template import TestPlugin
|
|
30
|
+
|
|
31
|
+
print("You have the azul-runner[test_utils] installed, this should only be used for development or testing.")
|
|
32
|
+
append_all = ["TestPlugin", "test_template"]
|
|
33
|
+
|
|
34
|
+
EXPORTS = append_all + [
|
|
35
|
+
"add_settings",
|
|
36
|
+
"APIFeatureValue",
|
|
37
|
+
"BinaryPlugin",
|
|
38
|
+
"cmdline_run",
|
|
39
|
+
"DATA_HASH_NAME",
|
|
40
|
+
"DATA_HASH",
|
|
41
|
+
"DataLabel",
|
|
42
|
+
"Event",
|
|
43
|
+
"EventData",
|
|
44
|
+
"EventParent",
|
|
45
|
+
"Feature",
|
|
46
|
+
"FeatureType",
|
|
47
|
+
"FeatureValue",
|
|
48
|
+
"Filepath",
|
|
49
|
+
"FV",
|
|
50
|
+
"Job",
|
|
51
|
+
"JobResult",
|
|
52
|
+
"Plugin",
|
|
53
|
+
"State",
|
|
54
|
+
"StorageProxyFile",
|
|
55
|
+
"Uri",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
__all__ = list(EXPORTS)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Subclass of Plugin designed for processing binary files."""
|
|
2
|
+
|
|
3
|
+
from azul_bedrock import models_network
|
|
4
|
+
|
|
5
|
+
from . import plugin, settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BinaryPlugin(plugin.Plugin):
|
|
9
|
+
"""Base class for plugins that want to run on new sightings of a binary.
|
|
10
|
+
|
|
11
|
+
See readme.md for usage information.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
SETTINGS = settings.add_settings(
|
|
15
|
+
# plugin code generally assumes it can access data
|
|
16
|
+
assume_streams_available=True,
|
|
17
|
+
# event must be a new sighting of a label='content' binary
|
|
18
|
+
filter_allow_event_types=[
|
|
19
|
+
models_network.BinaryAction.Sourced,
|
|
20
|
+
models_network.BinaryAction.Extracted,
|
|
21
|
+
],
|
|
22
|
+
)
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Handle full Plugin execution loop.
|
|
2
|
+
|
|
3
|
+
* Receive jobs from external source or local
|
|
4
|
+
* Send job results to external source or local
|
|
5
|
+
* Run required multiplugins
|
|
6
|
+
* Handle timeouts and critical errors
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import datetime
|
|
11
|
+
import logging
|
|
12
|
+
import multiprocessing
|
|
13
|
+
import multiprocessing.sharedctypes
|
|
14
|
+
import multiprocessing.util
|
|
15
|
+
import queue as queueLib
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess # noqa: S404 # nosec B404
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import traceback
|
|
21
|
+
from typing import Any, Generator, Optional, Type
|
|
22
|
+
|
|
23
|
+
from azul_bedrock import models_network as azm
|
|
24
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
25
|
+
from watchdog.observers import Observer
|
|
26
|
+
|
|
27
|
+
from . import models, network
|
|
28
|
+
from . import plugin as mplugin
|
|
29
|
+
from . import plugin_executor, settings
|
|
30
|
+
from .models import JobResult, State, TaskModel
|
|
31
|
+
from .storage import StorageError, StorageProxyFile
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
QUEUE_PUT_TIMEOUT = 0.5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CriticalError(Exception):
|
|
40
|
+
"""Something has gone wrong that indicates the plugin needs to exit."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SigTermExitError(Exception):
|
|
46
|
+
"""Sig term has been provided to the process and it is not exiting."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RecreateException(Exception):
|
|
52
|
+
"""Plugin needs to be re-created by it's parent process.
|
|
53
|
+
|
|
54
|
+
This is raised when the source plugin has detected a file change.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_git_version_suffix(config: settings.Settings) -> str | None:
|
|
61
|
+
"""Get the git version suffix for the watched repo if there is one."""
|
|
62
|
+
if config.watch_path and config.watch_type == settings.WatchTypeEnum.GIT:
|
|
63
|
+
# wait for valid git repo on disk
|
|
64
|
+
for _ in range(3):
|
|
65
|
+
retcode = subprocess.call( # noqa: S603, S607 # nosec B603 B607
|
|
66
|
+
["git", "status"],
|
|
67
|
+
cwd=config.watch_path,
|
|
68
|
+
shell=False,
|
|
69
|
+
)
|
|
70
|
+
if retcode == 0:
|
|
71
|
+
break
|
|
72
|
+
logger.warning(f"'git status' failed with {retcode} on {config.watch_path}, sleeping")
|
|
73
|
+
time.sleep(config.watch_wait)
|
|
74
|
+
|
|
75
|
+
# recalculate commit hash as version_suffix
|
|
76
|
+
# requires git to be installed
|
|
77
|
+
try:
|
|
78
|
+
output = subprocess.check_output( # noqa: S603, S607 # nosec B603 B607
|
|
79
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
80
|
+
cwd=config.watch_path,
|
|
81
|
+
shell=False,
|
|
82
|
+
)
|
|
83
|
+
except subprocess.CalledProcessError as e:
|
|
84
|
+
raise CriticalError(f"is git not installed or {config.watch_path} not a valid git checkout?") from e
|
|
85
|
+
return output.decode().strip()
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class WatchPath(FileSystemEventHandler):
|
|
90
|
+
"""Event handler for watchdog."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, watch_wait: int) -> None:
|
|
93
|
+
self._restart_after = None
|
|
94
|
+
self._watch_wait = watch_wait
|
|
95
|
+
super().__init__()
|
|
96
|
+
|
|
97
|
+
def on_created(self, event):
|
|
98
|
+
"""Event trigger."""
|
|
99
|
+
self._update(event)
|
|
100
|
+
|
|
101
|
+
def on_deleted(self, event):
|
|
102
|
+
"""Event trigger."""
|
|
103
|
+
self._update(event)
|
|
104
|
+
|
|
105
|
+
def on_moved(self, event):
|
|
106
|
+
"""Event trigger."""
|
|
107
|
+
self._update(event)
|
|
108
|
+
|
|
109
|
+
def on_modified(self, event):
|
|
110
|
+
"""Event trigger."""
|
|
111
|
+
self._update(event)
|
|
112
|
+
|
|
113
|
+
def _update(self, event: FileSystemEvent):
|
|
114
|
+
"""Trigger restart if necessary."""
|
|
115
|
+
logger.info(f"Watched file {event.event_type}: {event.src_path}")
|
|
116
|
+
if not self._restart_after:
|
|
117
|
+
logger.info(f"Plugin will be restarted after at least {self._watch_wait} seconds.")
|
|
118
|
+
# delay in case multiple files are being rewritten
|
|
119
|
+
self._restart_after = time.time() + self._watch_wait
|
|
120
|
+
|
|
121
|
+
def check_updated(self) -> bool:
|
|
122
|
+
"""Return true if path was updated."""
|
|
123
|
+
if self._restart_after and self._restart_after < time.time():
|
|
124
|
+
self._restart_after = None
|
|
125
|
+
return True
|
|
126
|
+
else:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Coordinator:
|
|
131
|
+
"""Manage continuous plugin execution and prevent bad jobs from stalling plugin."""
|
|
132
|
+
|
|
133
|
+
_plugin: mplugin.Plugin
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
plugin_class: Type[mplugin.Plugin],
|
|
138
|
+
config: settings.Settings,
|
|
139
|
+
):
|
|
140
|
+
self._plugin_class = plugin_class
|
|
141
|
+
if isinstance(config, dict):
|
|
142
|
+
raise ValueError("Config should be a Settings object not a dictionary!")
|
|
143
|
+
self._cfg = config
|
|
144
|
+
|
|
145
|
+
self.is_signalled_to_exit = False
|
|
146
|
+
signal.signal(signal.SIGINT, self.set_signal_exit)
|
|
147
|
+
signal.signal(signal.SIGTERM, self.set_signal_exit)
|
|
148
|
+
|
|
149
|
+
self._watchdog: Any | None = None
|
|
150
|
+
self._watched: WatchPath | None = None
|
|
151
|
+
|
|
152
|
+
# start watchdog
|
|
153
|
+
if self._cfg.watch_path:
|
|
154
|
+
self._watchdog = Observer()
|
|
155
|
+
self._watched = WatchPath(self._cfg.watch_wait)
|
|
156
|
+
# sleep before starting plugin
|
|
157
|
+
# In Kubernetes, the sidecar is unlikely to have completed sync yet
|
|
158
|
+
# so this sleep avoids kubernetes pod restarts.
|
|
159
|
+
time.sleep(self._cfg.watch_wait)
|
|
160
|
+
self._watchdog.schedule(self._watched, self._cfg.watch_path, recursive=True)
|
|
161
|
+
self._watchdog.start()
|
|
162
|
+
self._recreate_plugin()
|
|
163
|
+
|
|
164
|
+
def set_signal_exit(self, *args):
|
|
165
|
+
"""Set the option to exit the plugin based on a signal (SIGINT, SIGTERM...)."""
|
|
166
|
+
self.is_signalled_to_exit = True
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def plugin(self):
|
|
170
|
+
"""Return plugin instance."""
|
|
171
|
+
return self._plugin
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def cfg(self):
|
|
175
|
+
"""Return config instance."""
|
|
176
|
+
return self._cfg
|
|
177
|
+
|
|
178
|
+
def __del__(self):
|
|
179
|
+
"""Cleanup on deletion."""
|
|
180
|
+
if hasattr(self, "_watchdog") and self._watchdog:
|
|
181
|
+
self._watchdog.stop()
|
|
182
|
+
|
|
183
|
+
def _recreate_plugin(self):
|
|
184
|
+
"""Recreate plugin including git version suffix."""
|
|
185
|
+
version_suffix = get_git_version_suffix(self._cfg)
|
|
186
|
+
if version_suffix:
|
|
187
|
+
self._cfg.version_suffix = version_suffix
|
|
188
|
+
self._plugin = self._plugin_class(config=self._cfg)
|
|
189
|
+
self._network = network.Network(self._plugin)
|
|
190
|
+
|
|
191
|
+
def run_once(
|
|
192
|
+
self,
|
|
193
|
+
event: azm.BinaryEvent,
|
|
194
|
+
local_streams: list[StorageProxyFile] = None,
|
|
195
|
+
) -> dict[str, JobResult]:
|
|
196
|
+
"""Perform a local run of plugin, with timeout and error capture."""
|
|
197
|
+
if self._watched and self._watched.check_updated():
|
|
198
|
+
self._recreate_plugin()
|
|
199
|
+
logger.info("Plugin has been restarted due to a file change")
|
|
200
|
+
# keep newest result for each multiplugin
|
|
201
|
+
results = {}
|
|
202
|
+
for result, multiplugin in self._run_job(event, local=local_streams):
|
|
203
|
+
results[multiplugin] = result
|
|
204
|
+
return results
|
|
205
|
+
|
|
206
|
+
def run_loop(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
queue: multiprocessing.Queue,
|
|
210
|
+
job_limit: int = None,
|
|
211
|
+
):
|
|
212
|
+
"""Run continually and fetch jobs from the queue for processing while posting results to dispatcher.
|
|
213
|
+
|
|
214
|
+
If `job_limit` is given, the loop will terminate after completing that many jobs (whether successful or not).
|
|
215
|
+
Relies on network interactions to handle their own errors. If they fail then the runner will die.
|
|
216
|
+
"""
|
|
217
|
+
if not self._cfg.events_url:
|
|
218
|
+
raise ValueError("Cannot run fetch/execute loop when events_url is None")
|
|
219
|
+
|
|
220
|
+
self._network.post_registrations()
|
|
221
|
+
job_count = 0
|
|
222
|
+
while job_limit is None or (job_limit and job_limit > job_count):
|
|
223
|
+
if self._watched and self._watched.check_updated():
|
|
224
|
+
logger.info("Plugin being restarted due to a file change")
|
|
225
|
+
raise RecreateException()
|
|
226
|
+
# ensure plugin is (still) ready to receive jobs
|
|
227
|
+
if not self._plugin.is_ready():
|
|
228
|
+
backoff = self._cfg.not_ready_backoff
|
|
229
|
+
logger.info("Plugin is not ready to receive jobs; retrying in %ds" % backoff)
|
|
230
|
+
time.sleep(backoff)
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# Set current job to None to indicate fetching a job.
|
|
234
|
+
with contextlib.suppress(queueLib.Full):
|
|
235
|
+
queue.put(None, block=False)
|
|
236
|
+
|
|
237
|
+
# Exit gracefully when a SIGTERM Or SIGINT is provided. (placed after queue.put for testing reasons)
|
|
238
|
+
if self.is_signalled_to_exit:
|
|
239
|
+
raise SigTermExitError()
|
|
240
|
+
|
|
241
|
+
event = self._network.fetch_job()
|
|
242
|
+
# Put task immediately to account for time spend fetching a file.
|
|
243
|
+
try:
|
|
244
|
+
queue.put(TaskModel(in_event=event, start_time_epoch=time.time()), timeout=QUEUE_PUT_TIMEOUT)
|
|
245
|
+
except queueLib.Full:
|
|
246
|
+
logger.warning("monitoring queue is full bypassing, which may result in odd heartbeats.")
|
|
247
|
+
|
|
248
|
+
job_count += 1
|
|
249
|
+
try:
|
|
250
|
+
for result, multiplugin in self._run_job(event, queue=queue):
|
|
251
|
+
self._network.ack_job(event, result, multiplugin)
|
|
252
|
+
# close any open handles in JobResult
|
|
253
|
+
result.close()
|
|
254
|
+
except CriticalError: # No extra logging required.
|
|
255
|
+
logger.info("Exited on a critical error.")
|
|
256
|
+
raise
|
|
257
|
+
except Exception: # Unknown error so extra logging required.
|
|
258
|
+
traceback.print_exc()
|
|
259
|
+
logger.error(
|
|
260
|
+
f"Error during job with kafka_key: '{event.kafka_key}', action: '{event.action}',"
|
|
261
|
+
+ f" author info: {event.author.model_dump()}"
|
|
262
|
+
+ f"\n source info: {event.source.model_dump()}."
|
|
263
|
+
)
|
|
264
|
+
raise
|
|
265
|
+
sys.exit(0)
|
|
266
|
+
|
|
267
|
+
def _run_job(
|
|
268
|
+
self,
|
|
269
|
+
event: azm.BinaryEvent,
|
|
270
|
+
local: list[StorageProxyFile] | None = None,
|
|
271
|
+
queue: multiprocessing.Queue = None,
|
|
272
|
+
) -> Generator[tuple[JobResult, str], None, None]:
|
|
273
|
+
"""Run plugin with supplied event and yield response.
|
|
274
|
+
|
|
275
|
+
This is a generator so that yielded results may be published while plugin continues to run.
|
|
276
|
+
Useful if a certain multiplugin exceeds max memory and crashes the process.
|
|
277
|
+
"""
|
|
278
|
+
logger.info(
|
|
279
|
+
"received plugin=%s file_format_legacy=%s size=%s sha256=%s"
|
|
280
|
+
% (
|
|
281
|
+
self._plugin.NAME,
|
|
282
|
+
event.entity.file_format_legacy,
|
|
283
|
+
event.entity.size,
|
|
284
|
+
event.entity.sha256,
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
run_start = datetime.datetime.now(datetime.timezone.utc)
|
|
288
|
+
|
|
289
|
+
# check whether the maximum depth limit for the event has been reached, and opt-out if it has been.
|
|
290
|
+
max_depth = self._cfg.plugin_depth_limit
|
|
291
|
+
depth_reached = False
|
|
292
|
+
if max_depth >= 0:
|
|
293
|
+
if len(event.source.path) >= max_depth:
|
|
294
|
+
depth_reached = True
|
|
295
|
+
|
|
296
|
+
if depth_reached:
|
|
297
|
+
result = JobResult(
|
|
298
|
+
state=State(
|
|
299
|
+
State.Label.OPT_OUT,
|
|
300
|
+
message=f"{self._plugin.NAME} reached configured plugin_depth_limit",
|
|
301
|
+
),
|
|
302
|
+
date_start=run_start,
|
|
303
|
+
date_end=run_start,
|
|
304
|
+
runtime=0,
|
|
305
|
+
)
|
|
306
|
+
logger.warning(f"{self._plugin.NAME}: hit max depth OPT_OUT for '{event.entity.sha256}'")
|
|
307
|
+
yield (result, None)
|
|
308
|
+
return # Fetch next job
|
|
309
|
+
|
|
310
|
+
job = models.Job(event=event)
|
|
311
|
+
try:
|
|
312
|
+
job.load_streams(dp=self._network.api, local=local)
|
|
313
|
+
except (StorageError, FileNotFoundError):
|
|
314
|
+
result = JobResult(
|
|
315
|
+
state=State(
|
|
316
|
+
State.Label.ERROR_INPUT,
|
|
317
|
+
failure_name="Error opening data stream",
|
|
318
|
+
message=traceback.format_exc(),
|
|
319
|
+
),
|
|
320
|
+
date_start=run_start,
|
|
321
|
+
date_end=run_start,
|
|
322
|
+
runtime=0,
|
|
323
|
+
)
|
|
324
|
+
logger.warning("Error opening data stream for %s" % event.entity.sha256)
|
|
325
|
+
yield (result, None)
|
|
326
|
+
return # Fetch next job
|
|
327
|
+
|
|
328
|
+
# run each multiplugin
|
|
329
|
+
run_start = datetime.datetime.now(datetime.timezone.utc)
|
|
330
|
+
|
|
331
|
+
# run first entrypoint to see if the whole plugin is opting out
|
|
332
|
+
# first entrypoint == execute() function
|
|
333
|
+
result = self._run_job_with_multiplugin(job, multiplugin=None, queue=queue)
|
|
334
|
+
# start time should include multiprocessing code
|
|
335
|
+
result.date_start = run_start
|
|
336
|
+
if result.state.label not in azm.StatusEnumSuccess:
|
|
337
|
+
# ending time should include multiprocessing code
|
|
338
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
339
|
+
result.date_end = now
|
|
340
|
+
result.runtime = int((now - run_start).total_seconds())
|
|
341
|
+
# yield heartbeats and other non-ok events
|
|
342
|
+
yield result, None
|
|
343
|
+
return # fetch next job
|
|
344
|
+
|
|
345
|
+
# Execute any multiplugins and always send the main result
|
|
346
|
+
# NOTE - multiprocessing is not included in the time cost of these events
|
|
347
|
+
try:
|
|
348
|
+
for multiplugin in self._plugin.get_registered_multiplugins():
|
|
349
|
+
if multiplugin is None:
|
|
350
|
+
continue
|
|
351
|
+
yield self._run_job_with_multiplugin(job, multiplugin, queue), multiplugin
|
|
352
|
+
finally:
|
|
353
|
+
# the overall plugin finished without error, now need to raise event for it
|
|
354
|
+
# send main plugin message with runtime covering all multiplugins and multiprocessing code
|
|
355
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
356
|
+
result.date_end = now
|
|
357
|
+
result.runtime = int((now - run_start).total_seconds())
|
|
358
|
+
yield result, None
|
|
359
|
+
|
|
360
|
+
def _run_job_with_multiplugin(
|
|
361
|
+
self, job: models.Job, multiplugin: Optional[str], queue: multiprocessing.Queue = None
|
|
362
|
+
) -> JobResult:
|
|
363
|
+
"""Run a multiplugin with the given event and streams."""
|
|
364
|
+
try:
|
|
365
|
+
# Don't enqueue job if multiplugin is None as it will have already been enqueued.
|
|
366
|
+
if queue and multiplugin is not None:
|
|
367
|
+
queue.put(
|
|
368
|
+
TaskModel(in_event=job.event, start_time_epoch=time.time(), multi_plugin_name=multiplugin),
|
|
369
|
+
timeout=QUEUE_PUT_TIMEOUT,
|
|
370
|
+
)
|
|
371
|
+
result = plugin_executor.run_plugin_with_job(self._plugin, job, multiplugin)
|
|
372
|
+
except BaseException:
|
|
373
|
+
logger.fatal("run_once terminated with uncaught exception")
|
|
374
|
+
raise
|
|
375
|
+
|
|
376
|
+
logger.info(
|
|
377
|
+
"finish plugin=%s mp=%s state=%s file_format_legacy=%s size=%s sha256=%s"
|
|
378
|
+
% (
|
|
379
|
+
self._plugin.NAME,
|
|
380
|
+
multiplugin,
|
|
381
|
+
result.state.label.name,
|
|
382
|
+
job.event.entity.file_format_legacy,
|
|
383
|
+
job.event.entity.size,
|
|
384
|
+
job.event.entity.sha256,
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
return result
|
azul_runner/local.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Local event generation."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import hashlib
|
|
5
|
+
import typing
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from azul_bedrock import identify
|
|
9
|
+
from azul_bedrock import models_network as azm
|
|
10
|
+
|
|
11
|
+
from . import storage
|
|
12
|
+
from .settings import Settings
|
|
13
|
+
from .storage import StorageProxyFile
|
|
14
|
+
|
|
15
|
+
unittest.util._MAX_LENGTH = 2000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_streams(datastreams: list[StorageProxyFile], plugin_settings: Settings):
|
|
19
|
+
"""Verify that a set of data streams matches input reqs specified by plugin, or raise AssertionError."""
|
|
20
|
+
# calculate what data the job provided in same format as INPUT_DATA
|
|
21
|
+
available: dict[str, set[str]] = {}
|
|
22
|
+
magics = set()
|
|
23
|
+
mimes = set()
|
|
24
|
+
|
|
25
|
+
for ds in datastreams:
|
|
26
|
+
types = ds.file_info.file_format.split(";")[0]
|
|
27
|
+
available.setdefault(ds.file_info.label, set()).add(types)
|
|
28
|
+
available.setdefault("*", set()).add(types)
|
|
29
|
+
magics.add(ds.file_info.magic)
|
|
30
|
+
mimes.add(ds.file_info.mime)
|
|
31
|
+
|
|
32
|
+
# check INPUT_DATA against job provided
|
|
33
|
+
is_matching_type = False
|
|
34
|
+
for content_label, plugin_allowed_formats in plugin_settings.filter_data_types.items():
|
|
35
|
+
if content_label not in available:
|
|
36
|
+
break
|
|
37
|
+
# if no required type is available
|
|
38
|
+
if plugin_allowed_formats:
|
|
39
|
+
for allowed_type in available[content_label]:
|
|
40
|
+
for allowed_format in plugin_allowed_formats:
|
|
41
|
+
if allowed_type.startswith(allowed_format):
|
|
42
|
+
is_matching_type = True
|
|
43
|
+
break
|
|
44
|
+
# Type found
|
|
45
|
+
if is_matching_type:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if any(len(list_of_values) > 0 for list_of_values in plugin_settings.filter_data_types.values()):
|
|
49
|
+
raise AssertionError(
|
|
50
|
+
f"Provided file format {available} does not meet plugin requirements {plugin_settings.filter_data_types}\n"
|
|
51
|
+
f"magic: {sorted(magics)}\n"
|
|
52
|
+
f"mime types: {sorted(mimes)}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def gen_api_content(binaryio: typing.BinaryIO, label: azm.DataLabel = azm.DataLabel.CONTENT):
|
|
57
|
+
"""Generate api content from bytes."""
|
|
58
|
+
# get last offset and assume that is correct size
|
|
59
|
+
binaryio.seek(0, 2)
|
|
60
|
+
size = binaryio.tell()
|
|
61
|
+
binaryio.seek(0)
|
|
62
|
+
|
|
63
|
+
magic, mime, file_format, file_format_legacy, file_extension = identify.from_buffer(binaryio.read())
|
|
64
|
+
binaryio.seek(0)
|
|
65
|
+
return azm.Datastream(
|
|
66
|
+
label=label,
|
|
67
|
+
size=size,
|
|
68
|
+
md5=storage.calc_stream_hash(binaryio, hashlib.md5),
|
|
69
|
+
sha1=storage.calc_stream_hash(binaryio, hashlib.sha1),
|
|
70
|
+
sha256=storage.calc_stream_hash(binaryio, hashlib.sha256),
|
|
71
|
+
sha512=storage.calc_stream_hash(binaryio, hashlib.sha512),
|
|
72
|
+
mime=mime,
|
|
73
|
+
magic=magic,
|
|
74
|
+
file_format_legacy=file_format_legacy,
|
|
75
|
+
file_format=file_format,
|
|
76
|
+
file_extension=file_extension,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def gen_event(entity: azm.BinaryEvent.Entity):
|
|
81
|
+
"""Generate a simple network event."""
|
|
82
|
+
return azm.BinaryEvent(
|
|
83
|
+
model_version=azm.CURRENT_MODEL_VERSION,
|
|
84
|
+
kafka_key="test",
|
|
85
|
+
dequeued="test-response",
|
|
86
|
+
action=azm.BinaryAction.Extracted,
|
|
87
|
+
timestamp=datetime.datetime(year=1900, month=1, day=1, tzinfo=datetime.timezone.utc),
|
|
88
|
+
source=azm.Source(
|
|
89
|
+
name="source",
|
|
90
|
+
path=[
|
|
91
|
+
azm.PathNode(
|
|
92
|
+
author=azm.Author(name="TestServer", category="plugin"),
|
|
93
|
+
action=azm.BinaryAction.Extracted,
|
|
94
|
+
timestamp=datetime.datetime(year=1900, month=1, day=1, tzinfo=datetime.timezone.utc),
|
|
95
|
+
sha256=entity.sha256,
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
timestamp=datetime.datetime(year=1900, month=1, day=1, tzinfo=datetime.timezone.utc),
|
|
99
|
+
),
|
|
100
|
+
author=azm.Author(name="TestServer", category="plugin"),
|
|
101
|
+
entity=entity,
|
|
102
|
+
)
|
azul_runner/log_setup.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Common logging utilities for azul-runner."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import multiprocessing
|
|
5
|
+
import sys
|
|
6
|
+
from logging import handlers
|
|
7
|
+
from typing import Optional, TypeAlias
|
|
8
|
+
|
|
9
|
+
AZUL_RUNNER_LOGGER_NAME = "azul_runner"
|
|
10
|
+
LogLevel: TypeAlias = int | str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def setup_logger(log_level: LogLevel, queue: Optional[multiprocessing.Queue] = None):
|
|
14
|
+
"""Setup the loggers for azul runner."""
|
|
15
|
+
# use log level settings
|
|
16
|
+
rootlogger = logging.getLogger()
|
|
17
|
+
# Set all loggers to error/warning
|
|
18
|
+
rootlogger.setLevel(log_level)
|
|
19
|
+
|
|
20
|
+
# Clear existing handlers
|
|
21
|
+
for old_handle in rootlogger.handlers:
|
|
22
|
+
rootlogger.removeHandler(old_handle)
|
|
23
|
+
|
|
24
|
+
# Set the handler and formatter for azul_runner logggers.
|
|
25
|
+
if queue:
|
|
26
|
+
# Logging Queue is in use, send all logs to the parent process via the queue and don't log to stderr.
|
|
27
|
+
h3 = handlers.QueueHandler(queue)
|
|
28
|
+
h3.setLevel(log_level)
|
|
29
|
+
rootlogger.addHandler(h3)
|
|
30
|
+
else:
|
|
31
|
+
# Root process is configured to log to stderr.
|
|
32
|
+
h = logging.StreamHandler(sys.stderr)
|
|
33
|
+
h.setLevel(log_level)
|
|
34
|
+
h.setFormatter(
|
|
35
|
+
logging.Formatter(
|
|
36
|
+
fmt="%(asctime)s %(levelname)-8s %(name)-30s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S%z"
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
rootlogger.addHandler(h)
|
|
40
|
+
|
|
41
|
+
# Set HTTPX logging to debug to avoid logging spam in production
|
|
42
|
+
logging.getLogger("httpx").setLevel("DEBUG")
|
|
43
|
+
logging.getLogger("httpcore").setLevel("DEBUG")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AddLoggingQueueListener:
|
|
47
|
+
"""Handle adding and removing a logging queue handler."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, queue: multiprocessing.Queue, logger: logging.Logger):
|
|
50
|
+
"""Handle adding and removing a logging queue handler."""
|
|
51
|
+
self.queue = queue
|
|
52
|
+
self.logger = logger
|
|
53
|
+
|
|
54
|
+
def __enter__(self):
|
|
55
|
+
"""Open the queue listener."""
|
|
56
|
+
self.q_listen = handlers.QueueListener(self.queue, *self.logger.handlers)
|
|
57
|
+
self.q_listen.start()
|
|
58
|
+
|
|
59
|
+
def __exit__(self, *args, **kwargs):
|
|
60
|
+
"""Close the Queue listener to stop log capture."""
|
|
61
|
+
self.q_listen.stop()
|