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.
@@ -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
+ )
@@ -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()