firewatcher 1.60__tar.gz → 1.61__tar.gz
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.
- {firewatcher-1.60/firewatcher.egg-info → firewatcher-1.61}/PKG-INFO +7 -1
- {firewatcher-1.60 → firewatcher-1.61}/README.md +6 -0
- {firewatcher-1.60 → firewatcher-1.61}/examples/firewatcher.conf +2 -0
- {firewatcher-1.60 → firewatcher-1.61/firewatcher.egg-info}/PKG-INFO +7 -1
- {firewatcher-1.60 → firewatcher-1.61}/firewatcher.py +217 -35
- {firewatcher-1.60 → firewatcher-1.61}/tests/conftest.py +7 -2
- {firewatcher-1.60 → firewatcher-1.61}/tests/test_capture.py +75 -4
- {firewatcher-1.60 → firewatcher-1.61}/tests/test_cli.py +4 -4
- {firewatcher-1.60 → firewatcher-1.61}/tests/test_config.py +86 -0
- {firewatcher-1.60 → firewatcher-1.61}/tests/test_matching.py +7 -0
- firewatcher-1.61/tests/test_notify.py +801 -0
- {firewatcher-1.60 → firewatcher-1.61}/tests/test_systemd.py +88 -2
- firewatcher-1.60/tests/test_notify.py +0 -400
- {firewatcher-1.60 → firewatcher-1.61}/LICENSE +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/MANIFEST.in +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/examples/nvme_failure.regex +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/examples/sys_msg.txt +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/firewatcher.egg-info/SOURCES.txt +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/firewatcher.egg-info/dependency_links.txt +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/firewatcher.egg-info/entry_points.txt +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/firewatcher.egg-info/top_level.txt +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/setup.cfg +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/setup.py +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/tests/test_compressor.py +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/tests/test_log_source.py +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/tests/test_patterns.py +0 -0
- {firewatcher-1.60 → firewatcher-1.61}/tests/test_slugify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: firewatcher
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.61
|
|
4
4
|
Summary: Watch logs for incident patterns and persist surrounding context off-box
|
|
5
5
|
Home-page: https://github.com/yufei-pan/firewatcher
|
|
6
6
|
Author: Yufei Pan
|
|
@@ -108,6 +108,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
|
|
|
108
108
|
| `--llm-model NAME` | Model name |
|
|
109
109
|
| `--llm-timeout SEC` | LLM HTTP timeout (default: 60) |
|
|
110
110
|
| `--llm-max-bytes N` | Capture bytes sent to the model (default: 48000) |
|
|
111
|
+
| `--llm-notes TEXT` | Short note sent to the model with each capture, such as what is normal on this host (default: none) |
|
|
111
112
|
| `--notify` / `--no-notify` | Enable or disable ntfy pushes |
|
|
112
113
|
| `--ntfy-url URL` | ntfy topic URL, such as `https://ntfy.sh/mytopic` |
|
|
113
114
|
| `--ntfy-priority N` | ntfy priority 1–5 (default: 4) |
|
|
@@ -136,6 +137,7 @@ base_url = http://127.0.0.1:11434/v1
|
|
|
136
137
|
model = llama3.1
|
|
137
138
|
timeout = 60
|
|
138
139
|
max_bytes = 48000
|
|
140
|
+
notes = loop/dm devices from pytest and OOMs in capped test containers are expected
|
|
139
141
|
|
|
140
142
|
[notify]
|
|
141
143
|
enabled = false
|
|
@@ -159,6 +161,10 @@ Both features default off.
|
|
|
159
161
|
- Both on: a push is sent only when the model sets `notify` to true.
|
|
160
162
|
- If the LLM request times out, returns an HTTP error, or is not the expected JSON, and notifications are on, firewatcher still pushes, using the generated title.
|
|
161
163
|
|
|
164
|
+
A capture larger than `max_bytes` is not sent whole. The model gets the first 2 KB of the file (the header and the oldest lines before the match; at most half of `max_bytes`), then a window around the matched line: a quarter of the remaining budget before it and the rest from it onward, with a `...` line wherever bytes were skipped. A window that reaches either end of the file gives its unused share to the other side. A match during a capture only extends that capture, so the matched lines after the window are sent too, in up to a quarter of the budget, newest first when they do not all fit, followed by the capture's last lines (often a retry that worked or a service back up) and its closing lines (line count, truncation), in up to an eighth of the budget.
|
|
165
|
+
|
|
166
|
+
`notes` under `[llm]` (also accepted as `note`) is optional free text, empty by default. When set, it is sent with each capture as `Operator notes about this host: ...`, and the model is told it describes what is normal there. Whitespace and newlines in a note collapse to single spaces.
|
|
167
|
+
|
|
162
168
|
The capture window closes even when no further log lines arrive. Analysis runs on a background worker so log following stays live. The queue holds four captures. Notify-only jobs run ahead of jobs that still need the model. When the queue is full, that incident skips the model and, if notifications are on, the capture thread sends the generated-title push itself.
|
|
163
169
|
|
|
164
170
|
Lines firewatcher writes back to the journal are a single physical line containing `firewatcher-self`, so a summary that quotes `panic` or `I/O error` does not start another capture. The capture files themselves stay in the log stream's original format.
|
|
@@ -81,6 +81,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
|
|
|
81
81
|
| `--llm-model NAME` | Model name |
|
|
82
82
|
| `--llm-timeout SEC` | LLM HTTP timeout (default: 60) |
|
|
83
83
|
| `--llm-max-bytes N` | Capture bytes sent to the model (default: 48000) |
|
|
84
|
+
| `--llm-notes TEXT` | Short note sent to the model with each capture, such as what is normal on this host (default: none) |
|
|
84
85
|
| `--notify` / `--no-notify` | Enable or disable ntfy pushes |
|
|
85
86
|
| `--ntfy-url URL` | ntfy topic URL, such as `https://ntfy.sh/mytopic` |
|
|
86
87
|
| `--ntfy-priority N` | ntfy priority 1–5 (default: 4) |
|
|
@@ -109,6 +110,7 @@ base_url = http://127.0.0.1:11434/v1
|
|
|
109
110
|
model = llama3.1
|
|
110
111
|
timeout = 60
|
|
111
112
|
max_bytes = 48000
|
|
113
|
+
notes = loop/dm devices from pytest and OOMs in capped test containers are expected
|
|
112
114
|
|
|
113
115
|
[notify]
|
|
114
116
|
enabled = false
|
|
@@ -132,6 +134,10 @@ Both features default off.
|
|
|
132
134
|
- Both on: a push is sent only when the model sets `notify` to true.
|
|
133
135
|
- If the LLM request times out, returns an HTTP error, or is not the expected JSON, and notifications are on, firewatcher still pushes, using the generated title.
|
|
134
136
|
|
|
137
|
+
A capture larger than `max_bytes` is not sent whole. The model gets the first 2 KB of the file (the header and the oldest lines before the match; at most half of `max_bytes`), then a window around the matched line: a quarter of the remaining budget before it and the rest from it onward, with a `...` line wherever bytes were skipped. A window that reaches either end of the file gives its unused share to the other side. A match during a capture only extends that capture, so the matched lines after the window are sent too, in up to a quarter of the budget, newest first when they do not all fit, followed by the capture's last lines (often a retry that worked or a service back up) and its closing lines (line count, truncation), in up to an eighth of the budget.
|
|
138
|
+
|
|
139
|
+
`notes` under `[llm]` (also accepted as `note`) is optional free text, empty by default. When set, it is sent with each capture as `Operator notes about this host: ...`, and the model is told it describes what is normal there. Whitespace and newlines in a note collapse to single spaces.
|
|
140
|
+
|
|
135
141
|
The capture window closes even when no further log lines arrive. Analysis runs on a background worker so log following stays live. The queue holds four captures. Notify-only jobs run ahead of jobs that still need the model. When the queue is full, that incident skips the model and, if notifications are on, the capture thread sends the generated-title push itself.
|
|
136
142
|
|
|
137
143
|
Lines firewatcher writes back to the journal are a single physical line containing `firewatcher-self`, so a summary that quotes `panic` or `I/O error` does not start another capture. The capture files themselves stay in the log stream's original format.
|
|
@@ -36,6 +36,8 @@ api_key =
|
|
|
36
36
|
timeout = 60
|
|
37
37
|
# Maximum capture bytes sent to the model.
|
|
38
38
|
max_bytes = 48000
|
|
39
|
+
# Optional note sent to the model with each capture, e.g. what is normal on this host.
|
|
40
|
+
notes =
|
|
39
41
|
|
|
40
42
|
[notify]
|
|
41
43
|
# Enable pushes; when LLM analysis is on, the model decides whether to notify.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: firewatcher
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.61
|
|
4
4
|
Summary: Watch logs for incident patterns and persist surrounding context off-box
|
|
5
5
|
Home-page: https://github.com/yufei-pan/firewatcher
|
|
6
6
|
Author: Yufei Pan
|
|
@@ -108,6 +108,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
|
|
|
108
108
|
| `--llm-model NAME` | Model name |
|
|
109
109
|
| `--llm-timeout SEC` | LLM HTTP timeout (default: 60) |
|
|
110
110
|
| `--llm-max-bytes N` | Capture bytes sent to the model (default: 48000) |
|
|
111
|
+
| `--llm-notes TEXT` | Short note sent to the model with each capture, such as what is normal on this host (default: none) |
|
|
111
112
|
| `--notify` / `--no-notify` | Enable or disable ntfy pushes |
|
|
112
113
|
| `--ntfy-url URL` | ntfy topic URL, such as `https://ntfy.sh/mytopic` |
|
|
113
114
|
| `--ntfy-priority N` | ntfy priority 1–5 (default: 4) |
|
|
@@ -136,6 +137,7 @@ base_url = http://127.0.0.1:11434/v1
|
|
|
136
137
|
model = llama3.1
|
|
137
138
|
timeout = 60
|
|
138
139
|
max_bytes = 48000
|
|
140
|
+
notes = loop/dm devices from pytest and OOMs in capped test containers are expected
|
|
139
141
|
|
|
140
142
|
[notify]
|
|
141
143
|
enabled = false
|
|
@@ -159,6 +161,10 @@ Both features default off.
|
|
|
159
161
|
- Both on: a push is sent only when the model sets `notify` to true.
|
|
160
162
|
- If the LLM request times out, returns an HTTP error, or is not the expected JSON, and notifications are on, firewatcher still pushes, using the generated title.
|
|
161
163
|
|
|
164
|
+
A capture larger than `max_bytes` is not sent whole. The model gets the first 2 KB of the file (the header and the oldest lines before the match; at most half of `max_bytes`), then a window around the matched line: a quarter of the remaining budget before it and the rest from it onward, with a `...` line wherever bytes were skipped. A window that reaches either end of the file gives its unused share to the other side. A match during a capture only extends that capture, so the matched lines after the window are sent too, in up to a quarter of the budget, newest first when they do not all fit, followed by the capture's last lines (often a retry that worked or a service back up) and its closing lines (line count, truncation), in up to an eighth of the budget.
|
|
165
|
+
|
|
166
|
+
`notes` under `[llm]` (also accepted as `note`) is optional free text, empty by default. When set, it is sent with each capture as `Operator notes about this host: ...`, and the model is told it describes what is normal there. Whitespace and newlines in a note collapse to single spaces.
|
|
167
|
+
|
|
162
168
|
The capture window closes even when no further log lines arrive. Analysis runs on a background worker so log following stays live. The queue holds four captures. Notify-only jobs run ahead of jobs that still need the model. When the queue is full, that incident skips the model and, if notifications are on, the capture thread sends the generated-title push itself.
|
|
163
169
|
|
|
164
170
|
Lines firewatcher writes back to the journal are a single physical line containing `firewatcher-self`, so a summary that quotes `panic` or `I/O error` does not start another capture. The capture files themselves stay in the log stream's original format.
|
|
@@ -21,7 +21,7 @@ import socket
|
|
|
21
21
|
import syslog
|
|
22
22
|
import threading
|
|
23
23
|
|
|
24
|
-
version = '1.
|
|
24
|
+
version = '1.61'
|
|
25
25
|
__version__ = version
|
|
26
26
|
|
|
27
27
|
DEFAULT_OUTPUT_FOLDER = '/var/log/captured_messages/'
|
|
@@ -53,6 +53,7 @@ LLM_SYSTEM_PROMPT = (
|
|
|
53
53
|
'Keys: "title" (short, one line), "summary" (plain text, a few sentences), '
|
|
54
54
|
'"notify" (boolean: true only if a person should look soon; '
|
|
55
55
|
'false for benign, expected, or duplicate-looking noise). '
|
|
56
|
+
'Operator notes about the host, when present, describe what is normal there. '
|
|
56
57
|
'Do not invent hosts, times, or causes that are not in the excerpt.'
|
|
57
58
|
)
|
|
58
59
|
|
|
@@ -99,9 +100,16 @@ class Log_Compressor:
|
|
|
99
100
|
self.lastProcessTime = time.time()
|
|
100
101
|
|
|
101
102
|
def print(*args, **kwargs):
|
|
102
|
-
'''Print with flush=True by default.'
|
|
103
|
+
'''Print with flush=True by default. Text the stream can't encode (Python 3.6 under a C locale) is
|
|
104
|
+
escaped instead of stopping the watcher.'''
|
|
103
105
|
kwargs.setdefault('flush', True)
|
|
104
|
-
|
|
106
|
+
sep = kwargs.pop('sep', None)
|
|
107
|
+
# One string, so a line that can't be encoded fails before any of it is written.
|
|
108
|
+
text = (' ' if sep is None else sep).join(str(arg) for arg in args)
|
|
109
|
+
try:
|
|
110
|
+
return builtins.print(text, **kwargs)
|
|
111
|
+
except UnicodeEncodeError:
|
|
112
|
+
return builtins.print(text.encode('ascii', 'backslashreplace').decode('ascii'), **kwargs)
|
|
105
113
|
|
|
106
114
|
class bcolors:
|
|
107
115
|
HEADER = '\033[95m'
|
|
@@ -143,7 +151,7 @@ def load_patterns(pattern_files):
|
|
|
143
151
|
print(f"Pattern file not found: {pattern_file}")
|
|
144
152
|
continue
|
|
145
153
|
try:
|
|
146
|
-
with open(pattern_file, 'r') as f:
|
|
154
|
+
with open(pattern_file, 'r', encoding='utf-8', errors='replace') as f:
|
|
147
155
|
patterns = [line.strip() for line in f if line.strip()]
|
|
148
156
|
except Exception as e:
|
|
149
157
|
print(f"Error reading pattern file: {pattern_file}")
|
|
@@ -251,6 +259,7 @@ class Settings(object):
|
|
|
251
259
|
self.llm_api_key = ''
|
|
252
260
|
self.llm_timeout = 60
|
|
253
261
|
self.llm_max_bytes = 48000
|
|
262
|
+
self.llm_notes = ''
|
|
254
263
|
self.notify_enabled = False
|
|
255
264
|
self.ntfy_url = ''
|
|
256
265
|
self.ntfy_token = ''
|
|
@@ -340,6 +349,9 @@ def _read_config_file(path):
|
|
|
340
349
|
take('llm', 'api_key', 'llm_api_key', 'str')
|
|
341
350
|
take('llm', 'timeout', 'llm_timeout', 'int')
|
|
342
351
|
take('llm', 'max_bytes', 'llm_max_bytes', 'int')
|
|
352
|
+
take('llm', 'notes', 'llm_notes', 'str')
|
|
353
|
+
if not values.get('llm_notes'):
|
|
354
|
+
take('llm', 'note', 'llm_notes', 'str')
|
|
343
355
|
take('notify', 'enabled', 'notify_enabled', 'bool')
|
|
344
356
|
take('notify', 'ntfy_url', 'ntfy_url', 'str')
|
|
345
357
|
take('notify', 'token', 'ntfy_token', 'str')
|
|
@@ -384,7 +396,7 @@ def resolve_settings(args, environ=None):
|
|
|
384
396
|
settings.output_folder = file_values['output_folder']
|
|
385
397
|
if file_values.get('tail_lines'):
|
|
386
398
|
settings.tail_lines = file_values['tail_lines']
|
|
387
|
-
for key in ('llm_base_url', 'llm_model', 'llm_api_key', 'ntfy_url', 'ntfy_token'):
|
|
399
|
+
for key in ('llm_base_url', 'llm_model', 'llm_api_key', 'llm_notes', 'ntfy_url', 'ntfy_token'):
|
|
388
400
|
if key in file_values:
|
|
389
401
|
setattr(settings, key, file_values[key])
|
|
390
402
|
if getattr(args, 'pattern_file', None):
|
|
@@ -415,6 +427,8 @@ def resolve_settings(args, environ=None):
|
|
|
415
427
|
settings.llm_timeout = args.llm_timeout
|
|
416
428
|
if getattr(args, 'llm_max_bytes', None) is not None:
|
|
417
429
|
settings.llm_max_bytes = args.llm_max_bytes
|
|
430
|
+
if getattr(args, 'llm_notes', None) is not None:
|
|
431
|
+
settings.llm_notes = args.llm_notes
|
|
418
432
|
if getattr(args, 'notify', None) is not None:
|
|
419
433
|
settings.notify_enabled = bool(args.notify)
|
|
420
434
|
if getattr(args, 'ntfy_url', None) is not None:
|
|
@@ -427,6 +441,7 @@ def resolve_settings(args, environ=None):
|
|
|
427
441
|
settings.llm_api_key = environ[LLM_API_KEY_ENV]
|
|
428
442
|
if NTFY_TOKEN_ENV in environ:
|
|
429
443
|
settings.ntfy_token = environ[NTFY_TOKEN_ENV]
|
|
444
|
+
settings.llm_notes = _note_text(settings.llm_notes)
|
|
430
445
|
settings.llm_base_url = (settings.llm_base_url or '').strip()
|
|
431
446
|
settings.llm_model = (settings.llm_model or '').strip()
|
|
432
447
|
settings.ntfy_url = (settings.ntfy_url or '').strip()
|
|
@@ -452,6 +467,33 @@ def _ini_patterns(paths):
|
|
|
452
467
|
return first + ''.join('\n\t' + item for item in paths[1:])
|
|
453
468
|
|
|
454
469
|
|
|
470
|
+
def _as_utf8(text):
|
|
471
|
+
"""Text that can be written or sent as UTF-8: bytes Python 3.6 decoded with surrogateescape (argv and
|
|
472
|
+
paths under a C locale) are decoded as UTF-8, and anything invalid is replaced."""
|
|
473
|
+
text = str(text)
|
|
474
|
+
try:
|
|
475
|
+
return text.encode('utf-8', 'surrogateescape').decode('utf-8', 'replace')
|
|
476
|
+
except UnicodeEncodeError: # a lone surrogate that is not an escaped byte, e.g. from a JSON reply
|
|
477
|
+
return text.encode('utf-8', 'replace').decode('utf-8')
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _strict_utf8(text):
|
|
481
|
+
"""Text for a config or unit file, with the bytes Python decoded from argv, paths or the environment
|
|
482
|
+
with surrogateescape restored (any byte the locale can't decode: non-UTF-8 bytes, or every non-ASCII
|
|
483
|
+
byte on Python 3.6 under a C locale). Bytes that are not UTF-8 are refused, not replaced: replacing
|
|
484
|
+
them would point the service at a different path. The message shows no value, since values include
|
|
485
|
+
the API key, the ntfy token and the private ntfy topic URL."""
|
|
486
|
+
try:
|
|
487
|
+
return text.encode('utf-8', 'surrogateescape').decode('utf-8')
|
|
488
|
+
except UnicodeError:
|
|
489
|
+
raise ConfigError('a path or value is not UTF-8, so it cannot go into a config or unit file')
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _note_text(value):
|
|
493
|
+
"""A note as one line: any run of whitespace, newlines included, becomes one space."""
|
|
494
|
+
return ' '.join(str('' if value is None else value).split())
|
|
495
|
+
|
|
496
|
+
|
|
455
497
|
def _ini_text(value):
|
|
456
498
|
return str('' if value is None else value).replace('\r', '').replace('\n', '').strip()
|
|
457
499
|
|
|
@@ -506,6 +548,8 @@ def render_config_text(settings):
|
|
|
506
548
|
'timeout = %s' % int(settings.llm_timeout),
|
|
507
549
|
'# Maximum capture bytes sent to the model.',
|
|
508
550
|
'max_bytes = %s' % int(settings.llm_max_bytes),
|
|
551
|
+
'# Optional note sent to the model with each capture, e.g. what is normal on this host.',
|
|
552
|
+
'notes = %s' % settings.llm_notes,
|
|
509
553
|
'',
|
|
510
554
|
'[notify]',
|
|
511
555
|
'# Enable pushes; when LLM analysis is on, the model decides whether to notify.',
|
|
@@ -525,10 +569,14 @@ def render_config_text(settings):
|
|
|
525
569
|
|
|
526
570
|
|
|
527
571
|
def write_config_file(path, settings):
|
|
572
|
+
_write_private_file(path, _strict_utf8(render_config_text(settings)))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _write_private_file(path, text):
|
|
576
|
+
payload = text.encode('utf-8')
|
|
528
577
|
directory = os.path.dirname(path)
|
|
529
578
|
if directory:
|
|
530
579
|
os.makedirs(directory, exist_ok=True)
|
|
531
|
-
payload = render_config_text(settings).encode('utf-8')
|
|
532
580
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
533
581
|
try:
|
|
534
582
|
os.write(fd, payload)
|
|
@@ -570,6 +618,8 @@ def _explicit_cli_args(raw):
|
|
|
570
618
|
parts.extend(['--llm-timeout', str(raw.llm_timeout)])
|
|
571
619
|
if getattr(raw, 'llm_max_bytes', None) is not None:
|
|
572
620
|
parts.extend(['--llm-max-bytes', str(raw.llm_max_bytes)])
|
|
621
|
+
if getattr(raw, 'llm_notes', None) is not None:
|
|
622
|
+
parts.append('--llm-notes=' + _note_text(raw.llm_notes))
|
|
573
623
|
if getattr(raw, 'notify', None) is True:
|
|
574
624
|
parts.append('--notify')
|
|
575
625
|
elif getattr(raw, 'notify', None) is False:
|
|
@@ -626,12 +676,12 @@ def _http_header(text, limit):
|
|
|
626
676
|
|
|
627
677
|
|
|
628
678
|
def _notify_body(text, path):
|
|
629
|
-
suffix = '\n' +
|
|
679
|
+
suffix = '\n' + _as_utf8(path)
|
|
630
680
|
suffix_b = suffix.encode('utf-8')
|
|
631
681
|
if len(suffix_b) >= NTFY_BODY_MAX:
|
|
632
682
|
return suffix_b[:NTFY_BODY_MAX].decode('utf-8', 'ignore')
|
|
633
683
|
budget = NTFY_BODY_MAX - len(suffix_b)
|
|
634
|
-
body = _flatten(text).encode('utf-8')[:budget]
|
|
684
|
+
body = _as_utf8(_flatten(text)).encode('utf-8')[:budget]
|
|
635
685
|
return body.decode('utf-8', 'ignore') + suffix
|
|
636
686
|
|
|
637
687
|
|
|
@@ -689,17 +739,92 @@ def _parse_llm_object(content):
|
|
|
689
739
|
return {'title': title, 'summary': str(summary), 'notify': obj['notify']}
|
|
690
740
|
|
|
691
741
|
|
|
692
|
-
def
|
|
742
|
+
def _excerpt_window(data, head_end, start, body, limit):
|
|
743
|
+
"""Bytes [lo, hi) around start: a quarter of body before it and the rest from it on, the unused share
|
|
744
|
+
of a side that reaches head_end or limit going to the other, cut at line ends. A bigger body never
|
|
745
|
+
gives a smaller window, which _capture_excerpt relies on."""
|
|
746
|
+
before = min(body // 4, start - head_end)
|
|
747
|
+
after = min(body - before, limit - start)
|
|
748
|
+
before = min(body - after, start - head_end)
|
|
749
|
+
lo = start - before
|
|
750
|
+
if lo > head_end:
|
|
751
|
+
# start - 1 is a newline here, so one is always found; a line too long for the share before the
|
|
752
|
+
# matched line is dropped, not cut. Its bytes stay unused: handing them to the after side would
|
|
753
|
+
# let a bigger body end the window earlier.
|
|
754
|
+
lo = data.find(b'\n', lo - 1, start) + 1
|
|
755
|
+
hi = start + after
|
|
756
|
+
if hi < limit:
|
|
757
|
+
nl = data.rfind(b'\n', start, hi)
|
|
758
|
+
hi = nl + 1 if nl != -1 else hi
|
|
759
|
+
return lo, hi
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _capture_excerpt(path, max_bytes, matched_offset=None, later_offsets=(), footer_offset=None):
|
|
763
|
+
"""At most max_bytes of a capture for the model.
|
|
764
|
+
|
|
765
|
+
A capture that fits goes whole. Otherwise the model gets the header (the start of the file), a window
|
|
766
|
+
around the matched line (a quarter of the budget before it, the rest from it on), the matched lines
|
|
767
|
+
after the window (a match during a capture only extends it; up to a quarter of the budget, newest
|
|
768
|
+
first, so the end of an escalation is kept) and the capture's last lines with its footer (an eighth). Offsets are bytes into the file,
|
|
769
|
+
as capture_messages recorded them. Every skipped stretch is marked with a "..." line. Without a
|
|
770
|
+
matched offset it is head + tail.
|
|
771
|
+
"""
|
|
693
772
|
max_bytes = int(max_bytes)
|
|
694
773
|
if max_bytes < 1:
|
|
695
774
|
return ''
|
|
696
775
|
with open(path, 'rb') as handle:
|
|
697
776
|
data = handle.read()
|
|
698
|
-
|
|
777
|
+
size = len(data)
|
|
778
|
+
head_n = min(EXCERPT_HEAD_BYTES, max(1, max_bytes // 2))
|
|
779
|
+
sep = b'\n...\n'
|
|
780
|
+
if size <= max_bytes:
|
|
699
781
|
text = data.decode('utf-8', 'replace')
|
|
782
|
+
elif matched_offset is not None and 0 <= matched_offset < size and max_bytes - head_n > 2 * len(sep):
|
|
783
|
+
head_end = data.rfind(b'\n', 0, head_n) + 1 or head_n
|
|
784
|
+
# The matched line starts at a line start; if the offset is stale, snap to the line it is in.
|
|
785
|
+
start = max(head_end, data.rfind(b'\n', 0, matched_offset) + 1)
|
|
786
|
+
# Each piece pays for the "..." before it; the window's two are in body.
|
|
787
|
+
body = max_bytes - head_end - 2 * len(sep)
|
|
788
|
+
# The capture's last lines (often a retry that worked or a service back up) and its footer, in up
|
|
789
|
+
# to an eighth of the budget; the window and the later matches stop where they start.
|
|
790
|
+
limit = size
|
|
791
|
+
end_share = body // 8 - len(sep)
|
|
792
|
+
if footer_offset is not None and start < footer_offset < size and size - footer_offset <= end_share:
|
|
793
|
+
# A last raw log line may lack its newline: then the end piece starts at the footer.
|
|
794
|
+
limit = data.find(b'\n', size - end_share - 1, footer_offset) + 1 or footer_offset
|
|
795
|
+
if limit <= start:
|
|
796
|
+
limit = footer_offset
|
|
797
|
+
body -= size - limit + len(sep)
|
|
798
|
+
later = []
|
|
799
|
+
for pos in sorted(p for p in later_offsets or () if start < p < limit):
|
|
800
|
+
nl = data.find(b'\n', pos, limit)
|
|
801
|
+
later.append((pos, data[pos:limit if nl == -1 else nl + 1]))
|
|
802
|
+
# Room for the later matches (up to a quarter) comes first, and the newest outside the window that
|
|
803
|
+
# fit are kept. The window then gets the rest of the budget; it only grows, so whatever it held
|
|
804
|
+
# stays, and a kept line it now reaches is dropped and its bytes go back to the window.
|
|
805
|
+
room = min(sum(len(line) + len(sep) for _pos, line in later), body // 4)
|
|
806
|
+
lo, hi = _excerpt_window(data, head_end, start, body - room, limit)
|
|
807
|
+
kept, used = [], 0
|
|
808
|
+
for pos, line in reversed(later):
|
|
809
|
+
if pos >= hi and used + len(line) + len(sep) <= room:
|
|
810
|
+
kept.append((pos, line))
|
|
811
|
+
used += len(line) + len(sep)
|
|
812
|
+
while True:
|
|
813
|
+
lo, hi = _excerpt_window(data, head_end, start, body - used, limit)
|
|
814
|
+
if all(pos >= hi for pos, _line in kept):
|
|
815
|
+
break
|
|
816
|
+
kept = [(pos, line) for pos, line in kept if pos >= hi]
|
|
817
|
+
used = sum(len(line) + len(sep) for _pos, line in kept)
|
|
818
|
+
kept.sort()
|
|
819
|
+
pieces = [(lo, data[lo:hi])] + kept + ([(limit, data[limit:])] if limit < size else [])
|
|
820
|
+
out, end = [data[:head_end]], head_end
|
|
821
|
+
for pos, piece in pieces:
|
|
822
|
+
out.extend([sep, piece] if pos > end else [piece])
|
|
823
|
+
end = pos + len(piece)
|
|
824
|
+
if end < size:
|
|
825
|
+
out.append(sep)
|
|
826
|
+
text = b''.join(out).decode('utf-8', 'replace')
|
|
700
827
|
else:
|
|
701
|
-
head_n = min(EXCERPT_HEAD_BYTES, max(1, max_bytes // 2))
|
|
702
|
-
sep = b'\n...\n'
|
|
703
828
|
tail_n = max_bytes - head_n - len(sep)
|
|
704
829
|
if tail_n < 1:
|
|
705
830
|
sep = b''
|
|
@@ -715,11 +840,17 @@ def _capture_excerpt(path, max_bytes):
|
|
|
715
840
|
|
|
716
841
|
def call_llm(settings, incident):
|
|
717
842
|
import json
|
|
718
|
-
excerpt = _capture_excerpt(
|
|
843
|
+
excerpt = _capture_excerpt(
|
|
844
|
+
incident['path'], settings.llm_max_bytes, incident.get('matched_offset'),
|
|
845
|
+
incident.get('later_offsets'), incident.get('footer_offset'))
|
|
846
|
+
notes = settings.llm_notes
|
|
719
847
|
user = (
|
|
720
|
-
'Host: %s\
|
|
721
|
-
% (incident.get('hostname', ''),
|
|
848
|
+
'Host: %s\n%sMatched pattern: %s\nMatched line: %s\nCapture path: %s\n\nExcerpt:\n%s'
|
|
849
|
+
% (incident.get('hostname', ''), 'Operator notes about this host: %s\n' % notes if notes else '',
|
|
850
|
+
incident.get('pattern', ''), incident.get('matched_line', ''), incident.get('path', ''), excerpt)
|
|
722
851
|
)
|
|
852
|
+
# Not lone surrogates, which a JSON parser may refuse.
|
|
853
|
+
user = _as_utf8(user)
|
|
723
854
|
url = settings.llm_base_url.rstrip('/') + '/chat/completions'
|
|
724
855
|
payload = {
|
|
725
856
|
'model': settings.llm_model,
|
|
@@ -754,7 +885,7 @@ def send_ntfy(url, title, body, priority, token, timeout):
|
|
|
754
885
|
|
|
755
886
|
def _append_note(path, text):
|
|
756
887
|
rows = str(text).splitlines() or ['']
|
|
757
|
-
with open(path, 'a', encoding='utf-8') as handle:
|
|
888
|
+
with open(path, 'a', encoding='utf-8', errors='replace') as handle:
|
|
758
889
|
handle.write('\n')
|
|
759
890
|
for row in rows:
|
|
760
891
|
handle.write('%s %s\n' % (SELF_MARKER, row))
|
|
@@ -893,13 +1024,18 @@ class CaptureFile(object):
|
|
|
893
1024
|
def __init__(self, path, sync_interval):
|
|
894
1025
|
self.path = path
|
|
895
1026
|
self.sync_interval = sync_interval
|
|
896
|
-
self.handle = open(path, 'a')
|
|
1027
|
+
self.handle = open(path, 'a', encoding='utf-8')
|
|
897
1028
|
self.last_sync = None
|
|
898
1029
|
self.dirty = False
|
|
899
1030
|
|
|
900
1031
|
def write(self, text):
|
|
901
1032
|
self.handle.write(text)
|
|
902
1033
|
|
|
1034
|
+
def offset(self):
|
|
1035
|
+
"""Bytes in the file so far, i.e. where the next write starts."""
|
|
1036
|
+
self.handle.flush()
|
|
1037
|
+
return self.handle.buffer.tell()
|
|
1038
|
+
|
|
903
1039
|
def commit(self):
|
|
904
1040
|
self.handle.flush()
|
|
905
1041
|
self.dirty = True
|
|
@@ -931,6 +1067,8 @@ def _end_capture(capture, captured_line_counter, capture_line_count_max, inciden
|
|
|
931
1067
|
if not capture:
|
|
932
1068
|
return
|
|
933
1069
|
try:
|
|
1070
|
+
if incident is not None:
|
|
1071
|
+
incident['footer_offset'] = capture.offset()
|
|
934
1072
|
if captured_line_counter > capture_line_count_max:
|
|
935
1073
|
capture.write(f"{bcolors.warning}Captured line count exceeded capture_line_count_max {capture_line_count_max}. Truncated to {capture_line_count_max} lines.{bcolors.ENDC}\n")
|
|
936
1074
|
capture.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
|
|
@@ -1030,7 +1168,7 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
|
|
|
1030
1168
|
log_file_name = os.path.abspath(f"{log_folder}/{slugify(matched_pattern)}_{datetime.datetime.fromtimestamp(read_time).strftime('%Y-%m-%dT%H_%M_%S%z')}.log")
|
|
1031
1169
|
# also record a journal of all logs captured in {output_folder}/journal.log
|
|
1032
1170
|
journal_file_name = os.path.abspath(f"{output_folder}/journal.log")
|
|
1033
|
-
with open(journal_file_name, "a") as journal_file:
|
|
1171
|
+
with open(journal_file_name, "a", encoding='utf-8', errors='surrogateescape') as journal_file:
|
|
1034
1172
|
journal_file.write(f"{datetime.datetime.fromtimestamp(read_time).isoformat()} Captured logs for {matched_pattern} to {log_file_name}\n")
|
|
1035
1173
|
print(f"Capturing logs to {log_file_name}")
|
|
1036
1174
|
incident = {
|
|
@@ -1051,13 +1189,18 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
|
|
|
1051
1189
|
capture.write(f"{bcolors.warning}Logs since {datetime.datetime.fromtimestamp(read_time - capture_time).isoformat()}:{bcolors.ENDC}\n")
|
|
1052
1190
|
if dropped_until is not None and read_time - dropped_until <= capture_time:
|
|
1053
1191
|
capture.write(f"{bcolors.warning}Captured line count exceeded capture_line_count_max {capture_line_count_max}. Older cached in-memory lines were dropped ...{bcolors.ENDC}\n")
|
|
1054
|
-
for
|
|
1192
|
+
# The newest buffered line is the trigger itself; remember where it starts for the LLM excerpt.
|
|
1193
|
+
trigger_index = len(log_buffer) - 1
|
|
1194
|
+
for index, (_buffered_time, buffered_line) in enumerate(log_buffer):
|
|
1195
|
+
if index == trigger_index:
|
|
1196
|
+
incident['matched_offset'] = capture.offset()
|
|
1055
1197
|
if matcher.find(buffered_line) is not None:
|
|
1056
1198
|
capture.write(f'{bcolors.critical}-> {buffered_line.strip()}{bcolors.ENDC}\n')
|
|
1057
1199
|
else:
|
|
1058
1200
|
capture.write(buffered_line)
|
|
1059
1201
|
else:
|
|
1060
|
-
# Append the line to the current log file
|
|
1202
|
+
# Append the line to the current log file; the LLM excerpt keeps these lines.
|
|
1203
|
+
incident.setdefault('later_offsets', []).append(capture.offset())
|
|
1061
1204
|
capture.write(f'{bcolors.critical}-> {line.strip()}{bcolors.ENDC}\n')
|
|
1062
1205
|
captured_line_counter += 1
|
|
1063
1206
|
capture.commit()
|
|
@@ -1159,7 +1302,7 @@ def expand_pattern_sources(paths):
|
|
|
1159
1302
|
|
|
1160
1303
|
|
|
1161
1304
|
def unit_filename(unit_name):
|
|
1162
|
-
name = (unit_name or
|
|
1305
|
+
name = (unit_name or '').strip() or DEFAULT_UNIT_NAME
|
|
1163
1306
|
if not name.endswith('.service'):
|
|
1164
1307
|
name = name + '.service'
|
|
1165
1308
|
base = os.path.basename(name)
|
|
@@ -1168,6 +1311,16 @@ def unit_filename(unit_name):
|
|
|
1168
1311
|
return base
|
|
1169
1312
|
|
|
1170
1313
|
|
|
1314
|
+
def _check_new_unit_name(base):
|
|
1315
|
+
"""ValueError unless systemd can load and enable a unit file named base: only its characters, at
|
|
1316
|
+
most 255 of them, a name before the first '@' and an instance after it. For install only: uninstall
|
|
1317
|
+
must still remove a unit an older firewatcher installed under another name."""
|
|
1318
|
+
prefix, at, instance = base[:-len('.service')].partition('@')
|
|
1319
|
+
if len(base) > 255 or not prefix or not re.match(r'[A-Za-z0-9:_.@\\-]+\Z', base) or (at and not instance):
|
|
1320
|
+
raise ValueError('unit name must be one systemd can load and enable: letters, digits and : _ . @ - \\, '
|
|
1321
|
+
'at most 255, and any @ with a name before it and an instance after it')
|
|
1322
|
+
|
|
1323
|
+
|
|
1171
1324
|
def resolve_executable():
|
|
1172
1325
|
found = shutil.which('firewatcher')
|
|
1173
1326
|
if found:
|
|
@@ -1175,9 +1328,13 @@ def resolve_executable():
|
|
|
1175
1328
|
return os.path.abspath(sys.argv[0])
|
|
1176
1329
|
|
|
1177
1330
|
|
|
1178
|
-
def _quote_unit_arg(value):
|
|
1179
|
-
|
|
1180
|
-
|
|
1331
|
+
def _quote_unit_arg(value, program=False):
|
|
1332
|
+
# systemd expands % specifiers in all of ExecStart and $ variables in its arguments (not the program
|
|
1333
|
+
# path); a free-text argument must survive both.
|
|
1334
|
+
value = str(value).replace('%', '%%')
|
|
1335
|
+
if not program:
|
|
1336
|
+
value = value.replace('$', '$$')
|
|
1337
|
+
if value and not any(ch.isspace() or ch in '"\\\'' for ch in value):
|
|
1181
1338
|
return value
|
|
1182
1339
|
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
|
1183
1340
|
|
|
@@ -1194,7 +1351,7 @@ def render_unit_file(args, executable=None, config_exists=None, raw=None):
|
|
|
1194
1351
|
parts.extend(_explicit_cli_args(raw))
|
|
1195
1352
|
if not resolved.config_has_patterns and not getattr(raw, 'pattern_file', None):
|
|
1196
1353
|
parts.extend(resolved.pattern_file or [DEFAULT_PATTERNS_DIR])
|
|
1197
|
-
exec_start = ' '.join([_quote_unit_arg(executable)] + [_quote_unit_arg(p) for p in parts])
|
|
1354
|
+
exec_start = ' '.join([_quote_unit_arg(executable, program=True)] + [_quote_unit_arg(p) for p in parts])
|
|
1198
1355
|
after = ['network-online.target']
|
|
1199
1356
|
wants = ['network-online.target']
|
|
1200
1357
|
if not resolved.log_file:
|
|
@@ -1211,7 +1368,8 @@ def render_unit_file(args, executable=None, config_exists=None, raw=None):
|
|
|
1211
1368
|
'Wants=' + ' '.join(wants),
|
|
1212
1369
|
]
|
|
1213
1370
|
for mount in requires:
|
|
1214
|
-
|
|
1371
|
+
# A path list: quoted and % escaped like an argument, but systemd substitutes no $ in it.
|
|
1372
|
+
lines.append('RequiresMountsFor=' + _quote_unit_arg(mount, program=True))
|
|
1215
1373
|
stop_sec = max(30, int(resolved.llm_timeout) + int(resolved.ntfy_timeout) + 15)
|
|
1216
1374
|
lines.extend([
|
|
1217
1375
|
'',
|
|
@@ -1295,18 +1453,32 @@ def install_service(args, systemd_available=None, unit_dir=None, systemctl=None,
|
|
|
1295
1453
|
resolved.pattern_file = [DEFAULT_PATTERNS_DIR]
|
|
1296
1454
|
if unit_dir is None:
|
|
1297
1455
|
unit_dir = SYSTEMD_UNIT_DIR
|
|
1456
|
+
config_existed = os.path.isfile(resolved.config_path)
|
|
1457
|
+
executable = executable or resolve_executable()
|
|
1458
|
+
# The unit name and both files are checked before anything is written: a refusal leaves nothing
|
|
1459
|
+
# behind (a left-over config would make the next install render a different unit).
|
|
1460
|
+
try:
|
|
1461
|
+
unit_name = unit_filename(resolved.unit_name)
|
|
1462
|
+
_check_new_unit_name(unit_name)
|
|
1463
|
+
except ValueError as exc:
|
|
1464
|
+
print(str(exc), file=sys.stderr)
|
|
1465
|
+
return 2
|
|
1466
|
+
try:
|
|
1467
|
+
text = _strict_utf8(render_unit_file(resolved, executable=executable, config_exists=config_existed, raw=raw))
|
|
1468
|
+
config_text = None if config_existed else _strict_utf8(render_config_text(resolved))
|
|
1469
|
+
except ConfigError as exc:
|
|
1470
|
+
print(str(exc), file=sys.stderr)
|
|
1471
|
+
return 2
|
|
1298
1472
|
os.makedirs(resolved.output_folder, exist_ok=True)
|
|
1299
1473
|
_seed_example_patterns(resolved.pattern_file)
|
|
1300
|
-
|
|
1301
|
-
if not config_existed:
|
|
1474
|
+
if config_text is not None:
|
|
1302
1475
|
try:
|
|
1303
|
-
|
|
1476
|
+
_write_private_file(resolved.config_path, config_text)
|
|
1304
1477
|
except OSError as exc:
|
|
1305
1478
|
print('cannot write config %s: %s' % (resolved.config_path, exc), file=sys.stderr)
|
|
1306
1479
|
return 1
|
|
1307
1480
|
os.makedirs(unit_dir, exist_ok=True)
|
|
1308
|
-
unit_path = os.path.join(unit_dir,
|
|
1309
|
-
text = render_unit_file(resolved, executable=executable, config_exists=config_existed, raw=raw)
|
|
1481
|
+
unit_path = os.path.join(unit_dir, unit_name)
|
|
1310
1482
|
with open(unit_path, 'w', encoding='utf-8') as fh:
|
|
1311
1483
|
fh.write(text)
|
|
1312
1484
|
print(f"Wrote {unit_path}")
|
|
@@ -1316,11 +1488,11 @@ def install_service(args, systemd_available=None, unit_dir=None, systemctl=None,
|
|
|
1316
1488
|
return reload.returncode
|
|
1317
1489
|
if getattr(resolved, 'no_enable', False):
|
|
1318
1490
|
return 0
|
|
1319
|
-
enable = _run_systemctl(systemctl, 'enable', '--now',
|
|
1491
|
+
enable = _run_systemctl(systemctl, 'enable', '--now', '--', unit_name)
|
|
1320
1492
|
if getattr(enable, 'returncode', 0):
|
|
1321
1493
|
print(f"systemctl enable --now failed (exit {enable.returncode})", file=sys.stderr)
|
|
1322
1494
|
return enable.returncode
|
|
1323
|
-
print(f"Enabled and started {
|
|
1495
|
+
print(f"Enabled and started {unit_name}")
|
|
1324
1496
|
return 0
|
|
1325
1497
|
|
|
1326
1498
|
|
|
@@ -1334,7 +1506,7 @@ def uninstall_service(args, systemd_available=None, unit_dir=None, systemctl=Non
|
|
|
1334
1506
|
unit_dir = SYSTEMD_UNIT_DIR
|
|
1335
1507
|
name = unit_filename(args.unit_name)
|
|
1336
1508
|
unit_path = os.path.join(unit_dir, name)
|
|
1337
|
-
disable = _run_systemctl(systemctl, 'disable', '--now', name)
|
|
1509
|
+
disable = _run_systemctl(systemctl, 'disable', '--now', '--', name)
|
|
1338
1510
|
if getattr(disable, 'returncode', 0) not in (0, None) and os.path.exists(unit_path):
|
|
1339
1511
|
print(f"systemctl disable --now {name} exited {disable.returncode}", file=sys.stderr)
|
|
1340
1512
|
if os.path.exists(unit_path):
|
|
@@ -1384,6 +1556,7 @@ def build_parser():
|
|
|
1384
1556
|
parser.add_argument('--llm-model', default=None, help='Model name sent to the LLM API.')
|
|
1385
1557
|
parser.add_argument('--llm-timeout', type=int, default=None, help='LLM HTTP timeout in seconds. Default: 60.')
|
|
1386
1558
|
parser.add_argument('--llm-max-bytes', type=int, default=None, help='Maximum capture bytes sent to the LLM. Default: 48000.')
|
|
1559
|
+
parser.add_argument('--llm-notes', default=None, metavar='TEXT', help='Short note sent to the LLM with each capture, for example what is normal on this host. Default: none.')
|
|
1387
1560
|
notify = parser.add_mutually_exclusive_group()
|
|
1388
1561
|
notify.add_argument('--notify', dest='notify', action='store_true', help='Push a notification for finished captures. With --llm, only when the model asks for one.')
|
|
1389
1562
|
notify.add_argument('--no-notify', dest='notify', action='store_false', help='Disable notifications, overriding the config file.')
|
|
@@ -1431,7 +1604,16 @@ def main(argv=None):
|
|
|
1431
1604
|
else:
|
|
1432
1605
|
parser.error('pattern file or directory required (for example /etc/firewatcher/patterns.d)')
|
|
1433
1606
|
if args.print_unit:
|
|
1434
|
-
|
|
1607
|
+
try:
|
|
1608
|
+
text = _strict_utf8(render_unit_file(resolved, raw=args))
|
|
1609
|
+
except ConfigError as exc:
|
|
1610
|
+
print(str(exc), file=sys.stderr)
|
|
1611
|
+
return 2
|
|
1612
|
+
# As UTF-8 whatever the locale: the text is pasted into a unit file, where an escape would be read
|
|
1613
|
+
# as a C escape.
|
|
1614
|
+
sys.stdout.flush()
|
|
1615
|
+
sys.stdout.buffer.write(text.encode('utf-8'))
|
|
1616
|
+
sys.stdout.buffer.flush()
|
|
1435
1617
|
return 0
|
|
1436
1618
|
if args.install_service:
|
|
1437
1619
|
return install_service(resolved, raw=args)
|
|
@@ -10,11 +10,16 @@ if str(ROOT) not in sys.path:
|
|
|
10
10
|
|
|
11
11
|
import firewatcher as fw # noqa: E402
|
|
12
12
|
|
|
13
|
+
# the real default, before the fixture below points it into each test's directory
|
|
14
|
+
REAL_OUTPUT_FOLDER = fw.DEFAULT_OUTPUT_FOLDER
|
|
15
|
+
|
|
13
16
|
|
|
14
17
|
@pytest.fixture(autouse=True)
|
|
15
18
|
def isolated_firewatcher_config(tmp_path, monkeypatch):
|
|
16
|
-
"""Keep tests off the host config and any secret environment variables."""
|
|
19
|
+
"""Keep tests off the host config, output and pattern folders, and any secret environment variables."""
|
|
17
20
|
monkeypatch.setattr(fw, 'DEFAULT_CONFIG_PATH', str(tmp_path / 'firewatcher.conf'))
|
|
21
|
+
monkeypatch.setattr(fw, 'DEFAULT_OUTPUT_FOLDER', str(tmp_path / 'captured_messages') + os.sep)
|
|
22
|
+
monkeypatch.setattr(fw, 'DEFAULT_PATTERNS_DIR', str(tmp_path / 'patterns.d'))
|
|
18
23
|
monkeypatch.delenv(fw.LLM_API_KEY_ENV, raising=False)
|
|
19
24
|
monkeypatch.delenv(fw.NTFY_TOKEN_ENV, raising=False)
|
|
20
25
|
|
|
@@ -44,7 +49,7 @@ def write_log(tmp_path, text, name='src.log'):
|
|
|
44
49
|
if isinstance(text, bytes):
|
|
45
50
|
src.write_bytes(text)
|
|
46
51
|
else:
|
|
47
|
-
src.write_text(text)
|
|
52
|
+
src.write_text(text, encoding='utf-8')
|
|
48
53
|
return src
|
|
49
54
|
|
|
50
55
|
|