firewatcher 1.58__tar.gz → 1.59__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.58/firewatcher.egg-info → firewatcher-1.59}/PKG-INFO +3 -3
- {firewatcher-1.58 → firewatcher-1.59}/README.md +2 -2
- {firewatcher-1.58 → firewatcher-1.59/firewatcher.egg-info}/PKG-INFO +3 -3
- {firewatcher-1.58 → firewatcher-1.59}/firewatcher.py +180 -94
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_capture.py +122 -1
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_cli.py +11 -1
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_matching.py +33 -0
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_notify.py +13 -12
- {firewatcher-1.58 → firewatcher-1.59}/MANIFEST.in +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/examples/firewatcher.conf +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/examples/nvme_failure.regex +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/examples/sys_msg.txt +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/firewatcher.egg-info/SOURCES.txt +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/firewatcher.egg-info/dependency_links.txt +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/firewatcher.egg-info/entry_points.txt +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/firewatcher.egg-info/top_level.txt +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/setup.cfg +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/setup.py +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/tests/conftest.py +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_compressor.py +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_config.py +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_log_source.py +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_patterns.py +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_slugify.py +0 -0
- {firewatcher-1.58 → firewatcher-1.59}/tests/test_systemd.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: firewatcher
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.59
|
|
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
|
|
@@ -59,7 +59,7 @@ On a match, `firewatcher` writes:
|
|
|
59
59
|
- a per-incident capture under `{output-folder}/{YYYY-MM}/…`
|
|
60
60
|
- a line in `{output-folder}/journal.log`
|
|
61
61
|
|
|
62
|
-
Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears.
|
|
62
|
+
Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears. Each captured line is flushed as it is written, and the capture file is fsynced at most once a second and when the capture closes, so a host crash loses at most about the last second of it.
|
|
63
63
|
|
|
64
64
|
## systemd service
|
|
65
65
|
|
|
@@ -99,7 +99,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
|
|
|
99
99
|
| `--filter_only` | Filter existing logs only (`cat` instead of `tail -F`, or `journalctl` without `--follow`) |
|
|
100
100
|
| `--compress-after-months` | Tar.xz monthly dirs after N months (default: 3) |
|
|
101
101
|
| `--delete-after-months` | Delete `YYYY-MM` dirs after N months (default: 0 = never) |
|
|
102
|
-
| `--capture_line_count_max` | Max lines before/after a match (default: 10000) |
|
|
102
|
+
| `--capture_line_count_max` | Max lines before/after a match (default: 10000). Also caps how many recent lines are held in memory |
|
|
103
103
|
| `--config PATH` | INI config file. Default: `/etc/firewatcher/firewatcher.conf` when that file exists. An explicit path must exist |
|
|
104
104
|
| `--llm` / `--no-llm` | Enable or disable LLM analysis of each finished capture |
|
|
105
105
|
| `--llm-base-url URL` | OpenAI-compatible API root, such as `http://127.0.0.1:11434/v1` |
|
|
@@ -34,7 +34,7 @@ On a match, `firewatcher` writes:
|
|
|
34
34
|
- a per-incident capture under `{output-folder}/{YYYY-MM}/…`
|
|
35
35
|
- a line in `{output-folder}/journal.log`
|
|
36
36
|
|
|
37
|
-
Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears.
|
|
37
|
+
Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears. Each captured line is flushed as it is written, and the capture file is fsynced at most once a second and when the capture closes, so a host crash loses at most about the last second of it.
|
|
38
38
|
|
|
39
39
|
## systemd service
|
|
40
40
|
|
|
@@ -74,7 +74,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
|
|
|
74
74
|
| `--filter_only` | Filter existing logs only (`cat` instead of `tail -F`, or `journalctl` without `--follow`) |
|
|
75
75
|
| `--compress-after-months` | Tar.xz monthly dirs after N months (default: 3) |
|
|
76
76
|
| `--delete-after-months` | Delete `YYYY-MM` dirs after N months (default: 0 = never) |
|
|
77
|
-
| `--capture_line_count_max` | Max lines before/after a match (default: 10000) |
|
|
77
|
+
| `--capture_line_count_max` | Max lines before/after a match (default: 10000). Also caps how many recent lines are held in memory |
|
|
78
78
|
| `--config PATH` | INI config file. Default: `/etc/firewatcher/firewatcher.conf` when that file exists. An explicit path must exist |
|
|
79
79
|
| `--llm` / `--no-llm` | Enable or disable LLM analysis of each finished capture |
|
|
80
80
|
| `--llm-base-url URL` | OpenAI-compatible API root, such as `http://127.0.0.1:11434/v1` |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: firewatcher
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.59
|
|
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
|
|
@@ -59,7 +59,7 @@ On a match, `firewatcher` writes:
|
|
|
59
59
|
- a per-incident capture under `{output-folder}/{YYYY-MM}/…`
|
|
60
60
|
- a line in `{output-folder}/journal.log`
|
|
61
61
|
|
|
62
|
-
Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears.
|
|
62
|
+
Live capture is written as lines arrive, so a copy can already be on another filesystem if the host then disappears. Each captured line is flushed as it is written, and the capture file is fsynced at most once a second and when the capture closes, so a host crash loses at most about the last second of it.
|
|
63
63
|
|
|
64
64
|
## systemd service
|
|
65
65
|
|
|
@@ -99,7 +99,7 @@ sudo firewatcher --install-service -o /mnt/logs/captured_messages \
|
|
|
99
99
|
| `--filter_only` | Filter existing logs only (`cat` instead of `tail -F`, or `journalctl` without `--follow`) |
|
|
100
100
|
| `--compress-after-months` | Tar.xz monthly dirs after N months (default: 3) |
|
|
101
101
|
| `--delete-after-months` | Delete `YYYY-MM` dirs after N months (default: 0 = never) |
|
|
102
|
-
| `--capture_line_count_max` | Max lines before/after a match (default: 10000) |
|
|
102
|
+
| `--capture_line_count_max` | Max lines before/after a match (default: 10000). Also caps how many recent lines are held in memory |
|
|
103
103
|
| `--config PATH` | INI config file. Default: `/etc/firewatcher/firewatcher.conf` when that file exists. An explicit path must exist |
|
|
104
104
|
| `--llm` / `--no-llm` | Enable or disable LLM analysis of each finished capture |
|
|
105
105
|
| `--llm-base-url URL` | OpenAI-compatible API root, such as `http://127.0.0.1:11434/v1` |
|
|
@@ -16,16 +16,12 @@ import datetime
|
|
|
16
16
|
import os
|
|
17
17
|
import unicodedata
|
|
18
18
|
import configparser
|
|
19
|
-
import json
|
|
20
19
|
import queue
|
|
21
|
-
import secrets
|
|
22
20
|
import socket
|
|
23
21
|
import syslog
|
|
24
22
|
import threading
|
|
25
|
-
import urllib.error
|
|
26
|
-
import urllib.request
|
|
27
23
|
|
|
28
|
-
version = '1.
|
|
24
|
+
version = '1.59'
|
|
29
25
|
__version__ = version
|
|
30
26
|
|
|
31
27
|
DEFAULT_OUTPUT_FOLDER = '/var/log/captured_messages/'
|
|
@@ -35,10 +31,13 @@ SYSTEMD_RUNTIME_DIR = '/run/systemd/system'
|
|
|
35
31
|
SYSTEMD_UNIT_DIR = '/etc/systemd/system'
|
|
36
32
|
SLUG_MAX_LEN = 80
|
|
37
33
|
_OWN_SYSLOG_IDENT_RE = re.compile(r'(?:^|\s)firewatcher\[\d+\]:')
|
|
34
|
+
_OWN_FILE_NEEDLE = '/' + os.path.basename(__file__)
|
|
35
|
+
_GLOB_CHARS = frozenset('*?[')
|
|
38
36
|
|
|
39
37
|
DEFAULT_CONFIG_PATH = '/etc/firewatcher/firewatcher.conf'
|
|
40
38
|
SELF_MARKER = 'firewatcher-self'
|
|
41
39
|
ANALYSIS_QUEUE_MAX = 4
|
|
40
|
+
CAPTURE_SYNC_INTERVAL = 1.0
|
|
42
41
|
OVERFLOW_NOTIFY_TIMEOUT = 5
|
|
43
42
|
PROGRAMMATIC_TITLE_MAX = 120
|
|
44
43
|
NTFY_TITLE_MAX = 200
|
|
@@ -168,41 +167,60 @@ def load_patterns(pattern_files):
|
|
|
168
167
|
|
|
169
168
|
def _is_own_log_line(line):
|
|
170
169
|
"""True for this process's own syslog/journal lines, not incidental matching text."""
|
|
171
|
-
|
|
172
|
-
return True
|
|
173
|
-
if _OWN_SYSLOG_IDENT_RE.search(line):
|
|
174
|
-
return True
|
|
175
|
-
base = os.path.basename(__file__)
|
|
176
|
-
if base and ('/' + base) in line:
|
|
177
|
-
return True
|
|
178
|
-
return False
|
|
170
|
+
return SELF_MARKER in line or _OWN_FILE_NEEDLE in line or _OWN_SYSLOG_IDENT_RE.search(line) is not None
|
|
179
171
|
|
|
180
|
-
|
|
181
|
-
"""
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
172
|
+
class PatternMatcher(object):
|
|
173
|
+
"""load_patterns() output compiled once for the per-line hot path.
|
|
174
|
+
|
|
175
|
+
A `*text*` fnmatch pattern without wildcards inside is a plain substring test,
|
|
176
|
+
which is far cheaper than fnmatch's backtracking regex. Patterns are checked in
|
|
177
|
+
sorted order (regex, then substrings, then wildcard globs) so the label does not
|
|
178
|
+
depend on set order.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self, patterns):
|
|
182
|
+
self.regexes = sorted(patterns.get('regex', ()), key=lambda regex: (regex.pattern, regex.flags))
|
|
183
|
+
self.substrings = []
|
|
184
|
+
self.globs = []
|
|
185
|
+
for pattern in sorted(patterns.get('fnmatch', ())):
|
|
186
|
+
inner = pattern[1:-1]
|
|
187
|
+
if len(pattern) > 2 and pattern[0] == '*' and pattern[-1] == '*' and not _GLOB_CHARS.intersection(inner):
|
|
188
|
+
self.substrings.append((inner, pattern))
|
|
189
|
+
else:
|
|
190
|
+
# fnmatch.fnmatch is fnmatchcase on POSIX; this is its compiled form.
|
|
191
|
+
self.globs.append((re.compile(fnmatch.translate(pattern)).match, pattern))
|
|
192
|
+
|
|
193
|
+
def first_match(self, line):
|
|
194
|
+
"""Label of the first matching pattern: the regex match text or the fnmatch pattern."""
|
|
195
|
+
for regex in self.regexes:
|
|
198
196
|
matched = regex.search(line)
|
|
199
197
|
if matched:
|
|
200
198
|
return matched[0]
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if fnmatch.fnmatch(line, pattern):
|
|
199
|
+
for needle, pattern in self.substrings:
|
|
200
|
+
if needle in line:
|
|
204
201
|
return pattern
|
|
205
|
-
|
|
202
|
+
for match, pattern in self.globs:
|
|
203
|
+
if match(line):
|
|
204
|
+
return pattern
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def find(self, line):
|
|
208
|
+
"""Like first_match(), but this process's own log lines never match."""
|
|
209
|
+
label = self.first_match(line)
|
|
210
|
+
if label is None or _is_own_log_line(line):
|
|
211
|
+
return None
|
|
212
|
+
return label
|
|
213
|
+
|
|
214
|
+
def _as_matcher(patterns):
|
|
215
|
+
return patterns if isinstance(patterns, PatternMatcher) else PatternMatcher(patterns)
|
|
216
|
+
|
|
217
|
+
def match_patterns(line, patterns):
|
|
218
|
+
"""Check if the line matches any of the patterns based on the matching mode."""
|
|
219
|
+
return _as_matcher(patterns).find(line) is not None
|
|
220
|
+
|
|
221
|
+
def get_matched_pattern(line, patterns):
|
|
222
|
+
'''Get the first matched pattern for the line'''
|
|
223
|
+
return _as_matcher(patterns).first_match(line)
|
|
206
224
|
|
|
207
225
|
class ConfigError(Exception):
|
|
208
226
|
"""Raised when the config file or merged settings are unusable."""
|
|
@@ -440,6 +458,7 @@ def _ini_text(value):
|
|
|
440
458
|
|
|
441
459
|
def _generate_ntfy_url():
|
|
442
460
|
"""A hostname plus 72 random bits in ntfy's allowed URL-safe alphabet."""
|
|
461
|
+
import secrets
|
|
443
462
|
prefix = 'firewatcher-'
|
|
444
463
|
suffix = secrets.token_urlsafe(9)
|
|
445
464
|
hostname = re.sub(r'[^A-Za-z0-9_-]+', '-', _hostname()).strip('-_') or 'localhost'
|
|
@@ -617,6 +636,10 @@ def _notify_body(text, path):
|
|
|
617
636
|
|
|
618
637
|
|
|
619
638
|
def _urlopen_bytes(url, data, headers, timeout):
|
|
639
|
+
# Imported on first use: urllib.request pulls in ssl and http.client (about 9 MB RSS),
|
|
640
|
+
# and only LLM analysis and ntfy pushes need them.
|
|
641
|
+
import urllib.error
|
|
642
|
+
import urllib.request
|
|
620
643
|
request = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
|
621
644
|
try:
|
|
622
645
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
@@ -631,6 +654,7 @@ def _urlopen_bytes(url, data, headers, timeout):
|
|
|
631
654
|
|
|
632
655
|
|
|
633
656
|
def _parse_llm_object(content):
|
|
657
|
+
import json
|
|
634
658
|
text = str(content).strip()
|
|
635
659
|
if text.startswith('```'):
|
|
636
660
|
rows = text.splitlines()
|
|
@@ -690,6 +714,7 @@ def _capture_excerpt(path, max_bytes):
|
|
|
690
714
|
|
|
691
715
|
|
|
692
716
|
def call_llm(settings, incident):
|
|
717
|
+
import json
|
|
693
718
|
excerpt = _capture_excerpt(incident['path'], settings.llm_max_bytes)
|
|
694
719
|
user = (
|
|
695
720
|
'Host: %s\nMatched pattern: %s\nMatched line: %s\nCapture path: %s\n\nExcerpt:\n%s'
|
|
@@ -857,30 +882,81 @@ def _start_dispatcher(settings):
|
|
|
857
882
|
return None
|
|
858
883
|
return IncidentDispatcher(settings)
|
|
859
884
|
|
|
860
|
-
|
|
861
|
-
|
|
885
|
+
class CaptureFile(object):
|
|
886
|
+
"""Append handle kept open for one capture.
|
|
887
|
+
|
|
888
|
+
commit() flushes to the OS after each captured line, so losing this process
|
|
889
|
+
loses nothing. fsync runs at most once per sync_interval and on close, which
|
|
890
|
+
bounds what a host crash (or an NFS client cache) can lose to about that long.
|
|
891
|
+
"""
|
|
892
|
+
|
|
893
|
+
def __init__(self, path, sync_interval):
|
|
894
|
+
self.path = path
|
|
895
|
+
self.sync_interval = sync_interval
|
|
896
|
+
self.handle = open(path, 'a')
|
|
897
|
+
self.last_sync = None
|
|
898
|
+
self.dirty = False
|
|
899
|
+
|
|
900
|
+
def write(self, text):
|
|
901
|
+
self.handle.write(text)
|
|
902
|
+
|
|
903
|
+
def commit(self):
|
|
904
|
+
self.handle.flush()
|
|
905
|
+
self.dirty = True
|
|
906
|
+
now = time.time()
|
|
907
|
+
if self.last_sync is None or now - self.last_sync >= self.sync_interval:
|
|
908
|
+
self.sync(now)
|
|
909
|
+
|
|
910
|
+
def sync_due(self):
|
|
911
|
+
"""When committed data must be fsynced, or None when nothing is pending."""
|
|
912
|
+
return self.last_sync + self.sync_interval if self.dirty else None
|
|
913
|
+
|
|
914
|
+
def sync(self, now=None):
|
|
915
|
+
self.handle.flush()
|
|
916
|
+
try:
|
|
917
|
+
os.fsync(self.handle.fileno())
|
|
918
|
+
except OSError:
|
|
919
|
+
# Some filesystems cannot fsync; the data has still been flushed.
|
|
920
|
+
pass
|
|
921
|
+
self.dirty = False
|
|
922
|
+
self.last_sync = time.time() if now is None else now
|
|
923
|
+
|
|
924
|
+
def close(self):
|
|
925
|
+
try:
|
|
926
|
+
self.sync()
|
|
927
|
+
finally:
|
|
928
|
+
self.handle.close()
|
|
929
|
+
|
|
930
|
+
def _end_capture(capture, captured_line_counter, capture_line_count_max, incident=None, dispatcher=None):
|
|
931
|
+
if not capture:
|
|
862
932
|
return
|
|
863
|
-
|
|
933
|
+
try:
|
|
864
934
|
if captured_line_counter > capture_line_count_max:
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
935
|
+
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
|
+
capture.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
|
|
937
|
+
capture.write(f"{bcolors.warning}End of capture at {datetime.datetime.now().isoformat()}{bcolors.ENDC}\n")
|
|
938
|
+
capture.write(f"{bcolors.warning}Captured {captured_line_counter} lines after first match.{bcolors.ENDC}\n")
|
|
939
|
+
capture.write(bcolors.warning+'-'*80 + bcolors.ENDC +'\n')
|
|
870
940
|
print(f" End of capture at {datetime.datetime.now().isoformat()}")
|
|
871
|
-
|
|
941
|
+
capture.write('-'*80 +'\n')
|
|
942
|
+
finally:
|
|
943
|
+
capture.close()
|
|
872
944
|
if dispatcher is not None and incident is not None:
|
|
873
945
|
job = dict(incident)
|
|
874
|
-
job['path'] =
|
|
946
|
+
job['path'] = capture.path
|
|
875
947
|
dispatcher.submit(job)
|
|
876
948
|
|
|
877
949
|
|
|
878
950
|
def capture_messages(command_to_run, patterns, capture_time=300, output_folder=DEFAULT_OUTPUT_FOLDER, compress_after_months=3, delete_after_months=0,capture_line_count_max=10000, notify_settings=None):
|
|
879
951
|
"""Tail the log file and process lines with buffer management."""
|
|
880
|
-
|
|
952
|
+
matcher = _as_matcher(patterns)
|
|
953
|
+
# Only the newest capture_line_count_max buffered lines are ever written, so hold no more.
|
|
954
|
+
buffer_cap = max(0, capture_line_count_max)
|
|
955
|
+
log_buffer = deque(maxlen=buffer_cap) # Stores tuples of (read_time, line)
|
|
956
|
+
dropped_until = None # read_time of the newest line the cap pushed out of log_buffer
|
|
881
957
|
log_compressor = Log_Compressor(output_folder, compress_after_months, delete_after_months)
|
|
882
958
|
capture_until = None
|
|
883
|
-
|
|
959
|
+
capture = None
|
|
884
960
|
captured_line_counter = 0
|
|
885
961
|
processed_line_counter = 0
|
|
886
962
|
incident = None
|
|
@@ -896,17 +972,28 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
|
|
|
896
972
|
pending_read = None
|
|
897
973
|
while True:
|
|
898
974
|
try:
|
|
899
|
-
if pending_read is None:
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
975
|
+
if pending_read is None and capture_until is None:
|
|
976
|
+
# Nothing can time out, so read inline instead of waking the reader thread per line.
|
|
977
|
+
line = proc.stdout.readline()
|
|
978
|
+
else:
|
|
979
|
+
if pending_read is None:
|
|
980
|
+
pending_read = reader.submit(proc.stdout.readline)
|
|
981
|
+
deadline = capture_until
|
|
982
|
+
sync_due = capture.sync_due() if capture else None
|
|
983
|
+
if sync_due is not None and (deadline is None or sync_due < deadline):
|
|
984
|
+
deadline = sync_due
|
|
985
|
+
remaining = None if deadline is None else max(0, deadline - time.time())
|
|
986
|
+
line = pending_read.result(timeout=remaining)
|
|
987
|
+
pending_read = None
|
|
904
988
|
except ReadTimeout:
|
|
905
989
|
# Keep the same pending read while the source is quiet.
|
|
906
|
-
|
|
990
|
+
if capture and capture_until is not None and time.time() < capture_until:
|
|
991
|
+
capture.sync()
|
|
992
|
+
continue
|
|
993
|
+
_end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
|
|
907
994
|
capture_until = None
|
|
908
995
|
captured_line_counter = 0
|
|
909
|
-
|
|
996
|
+
capture = None
|
|
910
997
|
continue
|
|
911
998
|
except KeyboardInterrupt:
|
|
912
999
|
print("Exiting.")
|
|
@@ -926,15 +1013,17 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
|
|
|
926
1013
|
read_time = time.time()
|
|
927
1014
|
processed_line_counter += 1
|
|
928
1015
|
|
|
929
|
-
#
|
|
1016
|
+
# Keep capture_time seconds of context, at most buffer_cap lines
|
|
930
1017
|
while log_buffer and read_time - log_buffer[0][0] > capture_time:
|
|
931
1018
|
log_buffer.popleft()
|
|
1019
|
+
if len(log_buffer) == buffer_cap:
|
|
1020
|
+
dropped_until = log_buffer[0][0] if log_buffer else read_time
|
|
932
1021
|
log_buffer.append((read_time, line))
|
|
933
1022
|
|
|
934
1023
|
# if the current line matches the pattern, capture the logs for the next capture_time seconds
|
|
935
|
-
|
|
1024
|
+
matched_pattern = matcher.find(line)
|
|
1025
|
+
if matched_pattern is not None:
|
|
936
1026
|
if not capture_until:
|
|
937
|
-
matched_pattern = get_matched_pattern(line, patterns)
|
|
938
1027
|
# Dump the buffer to file at output_folder/{Year-Month}/{Day-Hour-Minute-Second}.log
|
|
939
1028
|
log_folder = f"{output_folder}/{datetime.datetime.fromtimestamp(read_time).strftime('%Y-%m')}"
|
|
940
1029
|
os.makedirs(log_folder, exist_ok=True)
|
|
@@ -945,59 +1034,56 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
|
|
|
945
1034
|
journal_file.write(f"{datetime.datetime.fromtimestamp(read_time).isoformat()} Captured logs for {matched_pattern} to {log_file_name}\n")
|
|
946
1035
|
print(f"Capturing logs to {log_file_name}")
|
|
947
1036
|
incident = {
|
|
948
|
-
'pattern': matched_pattern
|
|
1037
|
+
'pattern': matched_pattern,
|
|
949
1038
|
'matched_line': line.strip(),
|
|
950
1039
|
'hostname': _hostname(),
|
|
951
1040
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
log_file.write(f'{bcolors.critical}-> {buffered_line.strip()}{bcolors.ENDC}\n')
|
|
971
|
-
else:
|
|
972
|
-
log_file.write(buffered_line)
|
|
1041
|
+
capture = CaptureFile(log_file_name, CAPTURE_SYNC_INTERVAL)
|
|
1042
|
+
capture.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
|
|
1043
|
+
# Write the matched pattern and the line that matched
|
|
1044
|
+
capture.write(f"{bcolors.warning}Matched pattern: {bcolors.critical}{matched_pattern}{bcolors.ENDC}\n")
|
|
1045
|
+
print(f" Matched pattern: {matched_pattern}")
|
|
1046
|
+
capture.write(f"{bcolors.warning}Matched line: {line}{bcolors.ENDC}")
|
|
1047
|
+
print(f" Matched line: {line.strip()}")
|
|
1048
|
+
capture.write(f"{bcolors.warning}Captured at: {datetime.datetime.now().isoformat()}{bcolors.ENDC}\n")
|
|
1049
|
+
print(f" Captured at: {datetime.datetime.now().isoformat()}")
|
|
1050
|
+
capture.write(bcolors.warning + '-'*80 +bcolors.ENDC + '\n')
|
|
1051
|
+
capture.write(f"{bcolors.warning}Logs since {datetime.datetime.fromtimestamp(read_time - capture_time).isoformat()}:{bcolors.ENDC}\n")
|
|
1052
|
+
if dropped_until is not None and read_time - dropped_until <= capture_time:
|
|
1053
|
+
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 _buffered_time, buffered_line in log_buffer:
|
|
1055
|
+
if matcher.find(buffered_line) is not None:
|
|
1056
|
+
capture.write(f'{bcolors.critical}-> {buffered_line.strip()}{bcolors.ENDC}\n')
|
|
1057
|
+
else:
|
|
1058
|
+
capture.write(buffered_line)
|
|
973
1059
|
else:
|
|
974
1060
|
# Append the line to the current log file
|
|
975
|
-
|
|
976
|
-
log_file.write(f'{bcolors.critical}-> {line.strip()}{bcolors.ENDC}\n')
|
|
1061
|
+
capture.write(f'{bcolors.critical}-> {line.strip()}{bcolors.ENDC}\n')
|
|
977
1062
|
captured_line_counter += 1
|
|
1063
|
+
capture.commit()
|
|
978
1064
|
# If a match is found, set to capture the next capture_time seconds
|
|
979
1065
|
capture_until = read_time + capture_time
|
|
980
|
-
if
|
|
981
|
-
_end_capture(
|
|
1066
|
+
if captured_line_counter > capture_line_count_max:
|
|
1067
|
+
_end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
|
|
982
1068
|
capture_until = None
|
|
983
1069
|
captured_line_counter = 0
|
|
984
|
-
|
|
1070
|
+
capture = None
|
|
985
1071
|
# if the current line doesn't match the pattern, and capture_until is set and expired, reset capture_until and close the current log file
|
|
986
|
-
elif
|
|
987
|
-
_end_capture(
|
|
1072
|
+
elif capture and ((capture_until and read_time > capture_until) or captured_line_counter > capture_line_count_max):
|
|
1073
|
+
_end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
|
|
988
1074
|
capture_until = None
|
|
989
1075
|
captured_line_counter = 0
|
|
990
|
-
|
|
1076
|
+
capture = None
|
|
991
1077
|
# if the current line doesn't match the pattern, continue to write the logs to the current log file if capture_until is set and not expired
|
|
992
1078
|
elif capture_until and read_time <= capture_until:
|
|
993
|
-
|
|
994
|
-
|
|
1079
|
+
capture.write(line)
|
|
1080
|
+
capture.commit()
|
|
995
1081
|
captured_line_counter += 1
|
|
996
1082
|
if captured_line_counter > capture_line_count_max:
|
|
997
|
-
_end_capture(
|
|
1083
|
+
_end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
|
|
998
1084
|
capture_until = None
|
|
999
1085
|
captured_line_counter = 0
|
|
1000
|
-
|
|
1086
|
+
capture = None
|
|
1001
1087
|
# if the current line doesn't match the pattern, and capture_until is not set, check for log compression
|
|
1002
1088
|
else:
|
|
1003
1089
|
log_compressor.compressLogs()
|
|
@@ -1005,8 +1091,8 @@ def capture_messages(command_to_run, patterns, capture_time=300, output_folder=D
|
|
|
1005
1091
|
print(f"Processed {processed_line_counter} lines.",flush=True)
|
|
1006
1092
|
finally:
|
|
1007
1093
|
try:
|
|
1008
|
-
if
|
|
1009
|
-
_end_capture(
|
|
1094
|
+
if capture:
|
|
1095
|
+
_end_capture(capture, captured_line_counter, capture_line_count_max, incident=incident, dispatcher=dispatcher)
|
|
1010
1096
|
finally:
|
|
1011
1097
|
if dispatcher is not None:
|
|
1012
1098
|
dispatcher.close()
|
|
@@ -1280,7 +1366,7 @@ def build_parser():
|
|
|
1280
1366
|
filt.add_argument('--filter_only', dest='filter_only', action='store_true', help='Only filter logs and do not wait for new lines. Uses cat instead of tail -F, or journalctl without --follow.')
|
|
1281
1367
|
filt.add_argument('--no-filter_only', dest='filter_only', action='store_false', help='Follow new log lines. Overrides filter_only from the config file.')
|
|
1282
1368
|
parser.add_argument('--compress-after-months', type=int, default=None, help='Compress logs after this many months. Default: 3 months.')
|
|
1283
|
-
parser.add_argument('--capture_line_count_max', type=int, default=None, help='Maximum number of lines to capture before and after in a single log file. Default: 10000 lines. Note: The final file maybe 2 * this number.')
|
|
1369
|
+
parser.add_argument('--capture_line_count_max', type=int, default=None, help='Maximum number of lines to capture before and after in a single log file. Default: 10000 lines. Note: The final file maybe 2 * this number. Also caps how many recent lines are held in memory.')
|
|
1284
1370
|
parser.add_argument('--delete-after-months', type=int, default=None, help='Delete logs after this many months. (will only delete files in YYYY-MM format) Default: 0 (never).')
|
|
1285
1371
|
svc = parser.add_mutually_exclusive_group()
|
|
1286
1372
|
svc.add_argument('--install-service', action='store_true', help='Install and enable a systemd unit for this command line. Warns and exits if systemd is not available.')
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import collections
|
|
1
2
|
import datetime
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
2
5
|
|
|
3
6
|
import firewatcher as fw
|
|
4
7
|
from conftest import Clock, capture, incident_logs, ScriptedPopen, patch_popen
|
|
@@ -157,7 +160,7 @@ def test_capture_skips_excess_pre_match_buffer(tmp_path):
|
|
|
157
160
|
body = ''.join(f'pre-{i}\n' for i in range(10)) + 'HIT\n'
|
|
158
161
|
out = capture(tmp_path, body, capture_line_count_max=3)
|
|
159
162
|
text = incident_logs(out)[0].read_text()
|
|
160
|
-
assert '
|
|
163
|
+
assert 'Older cached in-memory lines were dropped' in text
|
|
161
164
|
assert 'pre-0' not in text
|
|
162
165
|
assert 'pre-8' in text
|
|
163
166
|
assert 'pre-9' in text
|
|
@@ -224,3 +227,121 @@ def test_capture_rematch_extends_window(tmp_path, monkeypatch):
|
|
|
224
227
|
text = incident_logs(out)[0].read_text()
|
|
225
228
|
assert '-> HIT 2' in text
|
|
226
229
|
assert 'late' in text
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def run_scripted(tmp_path, monkeypatch, popen, **kw):
|
|
233
|
+
pat = tmp_path / 'p.txt'
|
|
234
|
+
pat.write_text('HIT\n')
|
|
235
|
+
opts = dict(capture_time=60, output_folder=str(tmp_path / 'out'), compress_after_months=0, delete_after_months=0)
|
|
236
|
+
opts.update(kw)
|
|
237
|
+
patch_popen(monkeypatch, popen)
|
|
238
|
+
fw.capture_messages(['unused'], fw.load_patterns([str(pat)]), **opts)
|
|
239
|
+
return tmp_path / 'out'
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_pre_match_buffer_is_capped_at_capture_line_count_max(tmp_path, monkeypatch):
|
|
243
|
+
class RecordingDeque(collections.deque):
|
|
244
|
+
peak = 0
|
|
245
|
+
|
|
246
|
+
def append(self, item):
|
|
247
|
+
super().append(item)
|
|
248
|
+
RecordingDeque.peak = max(RecordingDeque.peak, len(self))
|
|
249
|
+
|
|
250
|
+
monkeypatch.setattr(fw, 'deque', RecordingDeque)
|
|
251
|
+
capture(tmp_path, ''.join(f'noise-{i}\n' for i in range(50)), capture_line_count_max=5)
|
|
252
|
+
assert 0 < RecordingDeque.peak <= 5
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_capture_does_not_report_dropped_lines_that_aged_out(tmp_path, monkeypatch):
|
|
256
|
+
clock = Clock(1_000_000.0)
|
|
257
|
+
monkeypatch.setattr(fw.time, 'time', clock.time)
|
|
258
|
+
popen = ScriptedPopen([
|
|
259
|
+
('old-1\n', 0), ('old-2\n', 0), ('old-3\n', 0),
|
|
260
|
+
('mid\n', 70), ('HIT now\n', 0),
|
|
261
|
+
], clock=clock)
|
|
262
|
+
out = run_scripted(tmp_path, monkeypatch, popen, capture_line_count_max=2)
|
|
263
|
+
text = incident_logs(out)[0].read_text()
|
|
264
|
+
assert 'mid' in text
|
|
265
|
+
assert 'old-3' not in text
|
|
266
|
+
assert 'Older cached in-memory lines were dropped' not in text
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_idle_reads_stay_on_the_calling_thread(tmp_path, monkeypatch):
|
|
270
|
+
threads = []
|
|
271
|
+
|
|
272
|
+
class RecordingPopen(ScriptedPopen):
|
|
273
|
+
def __init__(self, events):
|
|
274
|
+
super().__init__(events)
|
|
275
|
+
readline = self.stdout.readline
|
|
276
|
+
|
|
277
|
+
def recording_readline():
|
|
278
|
+
threads.append(threading.current_thread())
|
|
279
|
+
return readline()
|
|
280
|
+
|
|
281
|
+
self.stdout.readline = recording_readline
|
|
282
|
+
|
|
283
|
+
run_scripted(tmp_path, monkeypatch, RecordingPopen(['noise\n', 'HIT now\n', 'after\n']))
|
|
284
|
+
main = threading.current_thread()
|
|
285
|
+
assert threads[0] is main and threads[1] is main
|
|
286
|
+
assert threads[2] is not main
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_capture_opens_the_capture_file_once(tmp_path, monkeypatch):
|
|
290
|
+
opened = []
|
|
291
|
+
real_open = open
|
|
292
|
+
|
|
293
|
+
def counting_open(path, *args, **kwargs):
|
|
294
|
+
opened.append(str(path))
|
|
295
|
+
return real_open(path, *args, **kwargs)
|
|
296
|
+
|
|
297
|
+
monkeypatch.setattr(fw, 'open', counting_open, raising=False)
|
|
298
|
+
out = capture(tmp_path, 'HIT now\n' + ''.join(f'after-{i}\n' for i in range(20)))
|
|
299
|
+
cap = str(incident_logs(out)[0])
|
|
300
|
+
assert opened.count(cap) == 1
|
|
301
|
+
assert 'after-19' in incident_logs(out)[0].read_text()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_capture_fsyncs_at_most_once_per_interval(tmp_path, monkeypatch):
|
|
305
|
+
clock = Clock(1_000_000.0)
|
|
306
|
+
monkeypatch.setattr(fw.time, 'time', clock.time)
|
|
307
|
+
synced_at = []
|
|
308
|
+
monkeypatch.setattr(fw.os, 'fsync', lambda fd: synced_at.append(clock.t))
|
|
309
|
+
popen = ScriptedPopen([
|
|
310
|
+
('HIT now\n', 0), ('a\n', 0.125), ('b\n', 0.25), ('c\n', 0.875),
|
|
311
|
+
], clock=clock)
|
|
312
|
+
run_scripted(tmp_path, monkeypatch, popen)
|
|
313
|
+
start = 1_000_000.0
|
|
314
|
+
assert synced_at == [start, start + 1.25, start + 1.25]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_capture_fsyncs_pending_lines_when_source_goes_quiet(tmp_path, monkeypatch):
|
|
318
|
+
monkeypatch.setattr(fw, 'CAPTURE_SYNC_INTERVAL', 0.05)
|
|
319
|
+
events = []
|
|
320
|
+
monkeypatch.setattr(fw.os, 'fsync', lambda fd: events.append('fsync'))
|
|
321
|
+
|
|
322
|
+
class QuietStdout:
|
|
323
|
+
def __init__(self):
|
|
324
|
+
self.lines = ['HIT now\n', 'more\n']
|
|
325
|
+
|
|
326
|
+
def readline(self):
|
|
327
|
+
if self.lines:
|
|
328
|
+
return self.lines.pop(0)
|
|
329
|
+
time.sleep(0.5)
|
|
330
|
+
events.append('eof')
|
|
331
|
+
return ''
|
|
332
|
+
|
|
333
|
+
popen = ScriptedPopen([])
|
|
334
|
+
popen.stdout = QuietStdout()
|
|
335
|
+
run_scripted(tmp_path, monkeypatch, popen)
|
|
336
|
+
assert events == ['fsync', 'fsync', 'eof', 'fsync']
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_capture_survives_filesystems_without_fsync(tmp_path, monkeypatch):
|
|
340
|
+
def no_fsync(fd):
|
|
341
|
+
raise OSError(22, 'Invalid argument')
|
|
342
|
+
|
|
343
|
+
monkeypatch.setattr(fw.os, 'fsync', no_fsync)
|
|
344
|
+
out = capture(tmp_path, 'HIT now\nafter\n')
|
|
345
|
+
text = incident_logs(out)[0].read_text()
|
|
346
|
+
assert 'after' in text
|
|
347
|
+
assert 'End of capture' in text
|
|
@@ -102,7 +102,7 @@ def test_parser_rejects_conflicting_service_flags():
|
|
|
102
102
|
def test_module_has_no_nebula_product_name():
|
|
103
103
|
src = open(fw.__file__, encoding='utf-8').read().lower()
|
|
104
104
|
assert 'nebula' not in src
|
|
105
|
-
assert fw.version == '1.
|
|
105
|
+
assert fw.version == '1.59'
|
|
106
106
|
assert fw.__version__ == fw.version
|
|
107
107
|
|
|
108
108
|
|
|
@@ -142,3 +142,13 @@ def test_main_install_defaults_pattern_dir(monkeypatch):
|
|
|
142
142
|
def test_print_honors_explicit_flush_false(capsys):
|
|
143
143
|
fw.print('no-force', flush=False)
|
|
144
144
|
assert 'no-force' in capsys.readouterr().out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_import_leaves_http_and_secret_modules_unloaded():
|
|
148
|
+
heavy = ('urllib.request', 'http.client', 'ssl', 'json', 'secrets')
|
|
149
|
+
code = (
|
|
150
|
+
'import sys; sys.path.insert(0, %r); import firewatcher; '
|
|
151
|
+
'print(" ".join(m for m in %r if m in sys.modules))'
|
|
152
|
+
) % (os.path.dirname(os.path.abspath(fw.__file__)), heavy)
|
|
153
|
+
loaded = subprocess.run([sys.executable, '-I', '-S', '-c', code], capture_output=True, text=True, check=True).stdout.split()
|
|
154
|
+
assert loaded == []
|
|
@@ -94,3 +94,36 @@ def test_match_regex_miss_falls_through_to_fnmatch():
|
|
|
94
94
|
assert fw.get_matched_pattern('xx HIT yy', patterns) == '*HIT*'
|
|
95
95
|
assert fw.match_patterns('nope', patterns) is False
|
|
96
96
|
assert fw.get_matched_pattern('nope', patterns) is None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_pattern_matcher_agrees_with_fnmatch_for_wildcards_and_literals():
|
|
100
|
+
import fnmatch
|
|
101
|
+
pats = ['*I/O error*', '*a?c*', '*[0-9]x*', '*back\\slash*', '*100%*', 'exact', '*[unclosed*', '***']
|
|
102
|
+
lines = ['sd I/O error', 'zzabczz', 'a?c', 'id 7x', 'back\\slash', 'at 100%', 'exact', 'exactly',
|
|
103
|
+
'[unclosed', 'nothing', '', 'multi\nline a1c\n']
|
|
104
|
+
for pat in pats:
|
|
105
|
+
matcher = fw.PatternMatcher({'fnmatch': {pat}})
|
|
106
|
+
for line in lines:
|
|
107
|
+
expected = pat if fnmatch.fnmatch(line, pat) else None
|
|
108
|
+
assert matcher.first_match(line) == expected, (pat, line)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_matched_pattern_label_does_not_depend_on_pattern_order():
|
|
112
|
+
for order in (['*error*', '*disk*'], ['*disk*', '*error*']):
|
|
113
|
+
assert fw.get_matched_pattern('disk error', {'fnmatch': order}) == '*disk*'
|
|
114
|
+
regexes = [re.compile(r'sd[a-z]'), re.compile(r'ata\d')]
|
|
115
|
+
for order in (regexes, regexes[::-1]):
|
|
116
|
+
assert fw.get_matched_pattern('ata3 sdb fail', {'regex': order}) == 'ata3'
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_own_line_check_runs_only_for_candidate_matches(monkeypatch):
|
|
120
|
+
calls = []
|
|
121
|
+
real = fw._is_own_log_line
|
|
122
|
+
monkeypatch.setattr(fw, '_is_own_log_line', lambda line: calls.append(line) or real(line))
|
|
123
|
+
matcher = fw.PatternMatcher({'fnmatch': {'*panic*'}, 'regex': {re.compile(r'nvme\d')}})
|
|
124
|
+
for line in ('quiet line', 'another quiet line', 'host firewatcher[12]: nothing'):
|
|
125
|
+
assert matcher.find(line) is None
|
|
126
|
+
assert calls == []
|
|
127
|
+
assert matcher.find('host firewatcher[12]: panic') is None
|
|
128
|
+
assert matcher.find('kernel panic') == '*panic*'
|
|
129
|
+
assert len(calls) == 2
|
|
@@ -2,6 +2,7 @@ import io
|
|
|
2
2
|
import json
|
|
3
3
|
import sys
|
|
4
4
|
import urllib.error
|
|
5
|
+
import urllib.request
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
7
8
|
|
|
@@ -93,7 +94,7 @@ def test_capture_notifies_without_llm(tmp_path, monkeypatch):
|
|
|
93
94
|
calls.append((req, timeout))
|
|
94
95
|
return Body(b'ok')
|
|
95
96
|
|
|
96
|
-
monkeypatch.setattr(
|
|
97
|
+
monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
|
|
97
98
|
resolved = settings_from(tmp_path, '[notify]\nenabled = true\nntfy_url = https://ntfy.example/alerts\npriority = 4\ntoken = secret\n')
|
|
98
99
|
pat = tmp_path / 'p.txt'
|
|
99
100
|
pat.write_text('HIT\n')
|
|
@@ -164,7 +165,7 @@ def test_llm_notify_true_appends_report_and_pushes(tmp_path, monkeypatch):
|
|
|
164
165
|
return chat(payload)
|
|
165
166
|
return Body(b'ok')
|
|
166
167
|
|
|
167
|
-
monkeypatch.setattr(
|
|
168
|
+
monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
|
|
168
169
|
resolved = settings_from(
|
|
169
170
|
tmp_path,
|
|
170
171
|
'[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\napi_key = key\n'
|
|
@@ -202,7 +203,7 @@ def test_llm_decline_does_not_push(tmp_path, monkeypatch):
|
|
|
202
203
|
calls.append(req.full_url)
|
|
203
204
|
return chat(payload)
|
|
204
205
|
|
|
205
|
-
monkeypatch.setattr(
|
|
206
|
+
monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
|
|
206
207
|
resolved = settings_from(
|
|
207
208
|
tmp_path,
|
|
208
209
|
'[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n'
|
|
@@ -228,7 +229,7 @@ def test_llm_bad_json_and_http_error_fail_open(tmp_path, monkeypatch):
|
|
|
228
229
|
return chat('not json')
|
|
229
230
|
return Body(b'ok')
|
|
230
231
|
|
|
231
|
-
monkeypatch.setattr(
|
|
232
|
+
monkeypatch.setattr(urllib.request, 'urlopen', bad_json)
|
|
232
233
|
resolved = settings_from(
|
|
233
234
|
tmp_path,
|
|
234
235
|
'[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\ntimeout = 3\n'
|
|
@@ -251,7 +252,7 @@ def test_llm_bad_json_and_http_error_fail_open(tmp_path, monkeypatch):
|
|
|
251
252
|
raise urllib.error.HTTPError(req.full_url, 503, 'down', {}, io.BytesIO(b'busy'))
|
|
252
253
|
return Body(b'ok')
|
|
253
254
|
|
|
254
|
-
monkeypatch.setattr(
|
|
255
|
+
monkeypatch.setattr(urllib.request, 'urlopen', http_down)
|
|
255
256
|
path2 = tmp_path / 'cap2.log'
|
|
256
257
|
path2.write_text('HIT again\n')
|
|
257
258
|
dispatcher = fw.IncidentDispatcher(resolved, start_thread=True)
|
|
@@ -267,7 +268,7 @@ def test_ntfy_failure_is_journaled(tmp_path, monkeypatch):
|
|
|
267
268
|
def urlopen(req, timeout=None):
|
|
268
269
|
raise urllib.error.URLError('unreachable')
|
|
269
270
|
|
|
270
|
-
monkeypatch.setattr(
|
|
271
|
+
monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
|
|
271
272
|
resolved = settings_from(tmp_path, '[notify]\nenabled = true\nntfy_url = https://ntfy.example/alerts\n')
|
|
272
273
|
dispatcher = fw.IncidentDispatcher(resolved, start_thread=True)
|
|
273
274
|
dispatcher.submit(incident(tmp_path / 'cap.log'))
|
|
@@ -283,7 +284,7 @@ def test_queue_full_skips_llm_and_notifies_immediately(tmp_path, monkeypatch):
|
|
|
283
284
|
calls.append((req.full_url, timeout))
|
|
284
285
|
return Body(b'ok')
|
|
285
286
|
|
|
286
|
-
monkeypatch.setattr(
|
|
287
|
+
monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
|
|
287
288
|
resolved = settings_from(
|
|
288
289
|
tmp_path,
|
|
289
290
|
'[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n'
|
|
@@ -302,7 +303,7 @@ def test_queue_full_skips_llm_and_notifies_immediately(tmp_path, monkeypatch):
|
|
|
302
303
|
|
|
303
304
|
def test_notify_only_jobs_use_higher_priority(tmp_path, monkeypatch):
|
|
304
305
|
silence_syslog(monkeypatch)
|
|
305
|
-
monkeypatch.setattr(
|
|
306
|
+
monkeypatch.setattr(urllib.request, 'urlopen', lambda *args, **kwargs: Body(b'ok'))
|
|
306
307
|
resolved = settings_from(tmp_path, '[notify]\nenabled = true\nntfy_url = https://ntfy.example/alerts\n')
|
|
307
308
|
dispatcher = fw.IncidentDispatcher(resolved, start_thread=False)
|
|
308
309
|
dispatcher.submit(incident(tmp_path / 'cap.log'))
|
|
@@ -351,10 +352,10 @@ def test_helper_edges(tmp_path, monkeypatch, capsys):
|
|
|
351
352
|
def urlopen(req, timeout=None):
|
|
352
353
|
raise urllib.error.HTTPError(req.full_url, 500, 'err', {}, Boom())
|
|
353
354
|
|
|
354
|
-
monkeypatch.setattr(
|
|
355
|
+
monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
|
|
355
356
|
with pytest.raises(RuntimeError, match='HTTP 500'):
|
|
356
357
|
fw._urlopen_bytes('http://llm.example/v1', b'{}', {}, 1)
|
|
357
|
-
monkeypatch.setattr(
|
|
358
|
+
monkeypatch.setattr(urllib.request, 'urlopen', lambda req, timeout=None: Body(b'not-json'))
|
|
358
359
|
settings = settings_from(tmp_path, '[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n')
|
|
359
360
|
with pytest.raises(ValueError, match='unreadable'):
|
|
360
361
|
fw.call_llm(settings, incident(path))
|
|
@@ -362,7 +363,7 @@ def test_helper_edges(tmp_path, monkeypatch, capsys):
|
|
|
362
363
|
|
|
363
364
|
def test_overflow_without_notify_and_unwritable_failure_note(tmp_path, monkeypatch):
|
|
364
365
|
records = silence_syslog(monkeypatch)
|
|
365
|
-
monkeypatch.setattr(
|
|
366
|
+
monkeypatch.setattr(urllib.request, 'urlopen', lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('no http')))
|
|
366
367
|
resolved = settings_from(tmp_path, '[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n')
|
|
367
368
|
dispatcher = fw.IncidentDispatcher(resolved, start_thread=False)
|
|
368
369
|
for index in range(fw.ANALYSIS_QUEUE_MAX):
|
|
@@ -385,7 +386,7 @@ def test_llm_without_notify_writes_report_only(tmp_path, monkeypatch):
|
|
|
385
386
|
calls.append(req.full_url)
|
|
386
387
|
return chat(payload)
|
|
387
388
|
|
|
388
|
-
monkeypatch.setattr(
|
|
389
|
+
monkeypatch.setattr(urllib.request, 'urlopen', urlopen)
|
|
389
390
|
resolved = settings_from(
|
|
390
391
|
tmp_path,
|
|
391
392
|
'[llm]\nenabled = true\nbase_url = http://llm.example/v1\nmodel = m\n',
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|