hiac-utils 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hiac_utils/__init__.py +30 -0
- hiac_utils/__main__.py +49 -0
- hiac_utils/log_context.py +81 -0
- hiac_utils/log_monitor/__init__.py +27 -0
- hiac_utils/log_monitor/__main__.py +197 -0
- hiac_utils/log_monitor/engine.py +252 -0
- hiac_utils/log_monitor/listener.py +106 -0
- hiac_utils/log_monitor/profile.py +175 -0
- hiac_utils/log_monitor/signals.py +88 -0
- hiac_utils/log_monitor/thread.py +190 -0
- hiac_utils/merge_yaml_files.py +159 -0
- hiac_utils-0.1.0.dist-info/METADATA +8 -0
- hiac_utils-0.1.0.dist-info/RECORD +16 -0
- hiac_utils-0.1.0.dist-info/WHEEL +5 -0
- hiac_utils-0.1.0.dist-info/entry_points.txt +2 -0
- hiac_utils-0.1.0.dist-info/top_level.txt +1 -0
hiac_utils/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Scripts for automated Nextcloud setup.
|
|
2
|
+
# Copyright (C) 2026 Guillermo López Alejos
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""hiac_utils — shared HIAC utilities."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
22
|
+
|
|
23
|
+
from hiac_utils.log_context import LogContext
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = _pkg_version("hiac_utils")
|
|
27
|
+
except PackageNotFoundError: # pragma: no cover - running from an uninstalled checkout
|
|
28
|
+
__version__ = "0+unknown"
|
|
29
|
+
|
|
30
|
+
__all__ = ["__version__", "LogContext"]
|
hiac_utils/__main__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Scripts for automated Nextcloud setup.
|
|
2
|
+
# Copyright (C) 2026 Guillermo López Alejos
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Entry point: ``python -m hiac_utils``.
|
|
18
|
+
|
|
19
|
+
hiac_utils is primarily an Ansible filter-plugin library with no operational
|
|
20
|
+
CLI of its own; this entry point exists so the component can report its own
|
|
21
|
+
version, per AGENTS.md's "Version file" section.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
|
|
28
|
+
from . import __version__
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="python -m hiac_utils",
|
|
34
|
+
description="HIAC shared utility library (Ansible filter plugins).",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--version",
|
|
38
|
+
action="version",
|
|
39
|
+
version=f"hiac_utils {__version__}",
|
|
40
|
+
)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> None:
|
|
45
|
+
build_parser().parse_args()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Scripts for automated Nextcloud setup.
|
|
2
|
+
# Copyright (C) 2026 Guillermo López Alejos
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Per-thread logging context labels, stackable via context managers."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextvars
|
|
22
|
+
import logging
|
|
23
|
+
from types import TracebackType
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
__all__ = ["LogContext"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LogContext:
|
|
33
|
+
"""Push a label onto the logging context stack (thread-safe via ContextVar).
|
|
34
|
+
|
|
35
|
+
Labels from nested contexts are joined with ".":
|
|
36
|
+
|
|
37
|
+
with LogContext("SCN0004"):
|
|
38
|
+
with LogContext("hiac_entrypoint"):
|
|
39
|
+
# log records carry ctx="SCN0004.hiac_entrypoint"
|
|
40
|
+
|
|
41
|
+
Use `install_filter` once per handler to inject ``record.ctx`` into every
|
|
42
|
+
log record that passes through it. Then include ``%(ctx)s`` in the format
|
|
43
|
+
string; the value is ``"[label] "`` when a context is active, ``""`` when
|
|
44
|
+
not, so existing format strings need only a ``%(ctx)s`` prefix added.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_var: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
48
|
+
"hiac_log_ctx", default=""
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
class _Filter(logging.Filter):
|
|
52
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
53
|
+
ctx = LogContext._var.get()
|
|
54
|
+
record.ctx = f"[{ctx}] " if ctx else "" # type: ignore[attr-defined]
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def __init__(self, label: str) -> None:
|
|
58
|
+
current = self._var.get()
|
|
59
|
+
new = (f"{current}.{label}" if current else label) if label else current
|
|
60
|
+
self._token = self._var.set(new)
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> "LogContext":
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(
|
|
66
|
+
self,
|
|
67
|
+
exc_type: type[BaseException] | None,
|
|
68
|
+
exc_val: BaseException | None,
|
|
69
|
+
exc_tb: TracebackType | None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._var.reset(self._token)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def current(cls) -> str:
|
|
75
|
+
"""Return the active context string for propagation to child threads."""
|
|
76
|
+
return cls._var.get()
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def install_filter(cls, handler: logging.Handler) -> None:
|
|
80
|
+
"""Attach the ctx-injecting filter to *handler*. Call once per handler."""
|
|
81
|
+
handler.addFilter(cls._Filter())
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Scripts for automated Nextcloud setup.
|
|
2
|
+
# Copyright (C) 2026 Guillermo López Alejos
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""hiac_utils.log_monitor — log monitoring and early-error-detection engine."""
|
|
18
|
+
|
|
19
|
+
from .profile import GroupRule, MessageRule, Profile, load_profile
|
|
20
|
+
from .engine import LogMonitorEngine
|
|
21
|
+
from .thread import LogMonitorThread
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"GroupRule", "MessageRule", "Profile", "load_profile",
|
|
25
|
+
"LogMonitorEngine",
|
|
26
|
+
"LogMonitorThread",
|
|
27
|
+
]
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Scripts for automated Nextcloud setup.
|
|
2
|
+
# Copyright (C) 2026 Guillermo López Alejos
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""CLI entry point: python -m hiac_utils.log_monitor <subcommand>
|
|
18
|
+
|
|
19
|
+
Subcommands
|
|
20
|
+
-----------
|
|
21
|
+
process
|
|
22
|
+
Read log lines from stdin, evaluate profile rules, emit D-Bus signals.
|
|
23
|
+
Also writes human-friendly log output to --human-friendly-log-path.
|
|
24
|
+
|
|
25
|
+
Usage::
|
|
26
|
+
|
|
27
|
+
python -m hiac_utils.log_monitor process \\
|
|
28
|
+
--profile /path/to/log_monitor_profile.yml \\
|
|
29
|
+
--log-processor-id hiac_server_journalctl \\
|
|
30
|
+
--dbus-address unix:path=/run/user/1000/bus \\
|
|
31
|
+
--human-friendly-log-path /evidence/nodes/hiac_server_1/journalctl.log
|
|
32
|
+
|
|
33
|
+
listen
|
|
34
|
+
Subscribe to D-Bus, write all events to --events-log, and print
|
|
35
|
+
CRITICAL events to stdout (one line per event).
|
|
36
|
+
|
|
37
|
+
Usage::
|
|
38
|
+
|
|
39
|
+
python -m hiac_utils.log_monitor listen \\
|
|
40
|
+
--dbus-address unix:path=/run/user/1000/bus \\
|
|
41
|
+
--events-log /evidence/log/events.log
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import select
|
|
48
|
+
import signal
|
|
49
|
+
import sys
|
|
50
|
+
import threading
|
|
51
|
+
import time
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _process_stdin(engine, human_fh) -> None:
|
|
56
|
+
"""Read log lines from stdin until EOF, feeding each line to the engine."""
|
|
57
|
+
stdin = sys.stdin
|
|
58
|
+
# 1-second select timeout so tick() fires even during idle periods.
|
|
59
|
+
while True:
|
|
60
|
+
ready, _, _ = select.select([stdin], [], [], 1.0)
|
|
61
|
+
if ready:
|
|
62
|
+
line = stdin.readline()
|
|
63
|
+
if not line: # EOF — SSH process terminated
|
|
64
|
+
break
|
|
65
|
+
human_fh.write(line)
|
|
66
|
+
human_fh.flush()
|
|
67
|
+
engine.process_line(line)
|
|
68
|
+
else:
|
|
69
|
+
engine.tick()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _process_file(path: str, retry_sleep: float, retry_max: int,
|
|
73
|
+
engine, human_fh) -> None:
|
|
74
|
+
"""Wait for `path` to exist, then follow it line by line until SIGTERM/SIGINT.
|
|
75
|
+
|
|
76
|
+
retry_sleep: seconds to wait between existence checks.
|
|
77
|
+
retry_max: max number of sleep cycles before giving up (0 = wait forever).
|
|
78
|
+
"""
|
|
79
|
+
_stop = threading.Event()
|
|
80
|
+
|
|
81
|
+
def _on_signal(signum, frame):
|
|
82
|
+
_stop.set()
|
|
83
|
+
|
|
84
|
+
signal.signal(signal.SIGTERM, _on_signal)
|
|
85
|
+
signal.signal(signal.SIGINT, _on_signal)
|
|
86
|
+
|
|
87
|
+
file_path = Path(path)
|
|
88
|
+
attempts = 0
|
|
89
|
+
while not file_path.exists() and not _stop.is_set():
|
|
90
|
+
if retry_max > 0 and attempts >= retry_max:
|
|
91
|
+
return # file never appeared within the retry budget
|
|
92
|
+
engine.tick()
|
|
93
|
+
_stop.wait(retry_sleep) # interruptible sleep — wakes immediately on SIGTERM
|
|
94
|
+
attempts += 1
|
|
95
|
+
|
|
96
|
+
if _stop.is_set() or not file_path.exists():
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
with file_path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
100
|
+
while not _stop.is_set():
|
|
101
|
+
line = fh.readline()
|
|
102
|
+
if line:
|
|
103
|
+
human_fh.write(line)
|
|
104
|
+
human_fh.flush()
|
|
105
|
+
engine.process_line(line)
|
|
106
|
+
else:
|
|
107
|
+
engine.tick()
|
|
108
|
+
_stop.wait(0.5)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cmd_process(args: argparse.Namespace) -> int:
|
|
112
|
+
from .engine import LogMonitorEngine
|
|
113
|
+
from .profile import load_profile
|
|
114
|
+
from .signals import make_dbus_sender
|
|
115
|
+
|
|
116
|
+
profile = load_profile(Path(args.profile), timeout_factor=args.timeout_factor)
|
|
117
|
+
signal_fn = make_dbus_sender(args.dbus_address, args.log_processor_id)
|
|
118
|
+
engine = LogMonitorEngine(profile, signal_fn)
|
|
119
|
+
|
|
120
|
+
human_fh = open(args.human_friendly_log_path, "w", encoding="utf-8")
|
|
121
|
+
try:
|
|
122
|
+
if args.file:
|
|
123
|
+
_process_file(
|
|
124
|
+
args.file, args.retry_sleep, args.retry_max, engine, human_fh,
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
_process_stdin(engine, human_fh)
|
|
128
|
+
finally:
|
|
129
|
+
human_fh.close()
|
|
130
|
+
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cmd_listen(args: argparse.Namespace) -> int:
|
|
135
|
+
from .listener import run_listener
|
|
136
|
+
|
|
137
|
+
run_listener(
|
|
138
|
+
dbus_address=args.dbus_address,
|
|
139
|
+
events_log_path=Path(args.events_log),
|
|
140
|
+
)
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
145
|
+
parser = argparse.ArgumentParser(
|
|
146
|
+
prog="python -m hiac_utils.log_monitor",
|
|
147
|
+
description="HIAC log monitor — process log streams or listen for D-Bus events.",
|
|
148
|
+
)
|
|
149
|
+
sub = parser.add_subparsers(dest="subcommand", required=True)
|
|
150
|
+
|
|
151
|
+
# process subcommand
|
|
152
|
+
p = sub.add_parser("process", help="Read stdin or a file, evaluate rules, emit D-Bus signals")
|
|
153
|
+
p.add_argument("--profile", required=True, help="Path to log_monitor_profile.yml")
|
|
154
|
+
p.add_argument("--log-processor-id", required=True, help="Identifier for this processor")
|
|
155
|
+
p.add_argument("--dbus-address", required=True, help="D-Bus session bus address")
|
|
156
|
+
p.add_argument(
|
|
157
|
+
"--human-friendly-log-path", required=True,
|
|
158
|
+
help="Path to write human-readable log output",
|
|
159
|
+
)
|
|
160
|
+
p.add_argument(
|
|
161
|
+
"--timeout-factor", type=float, default=1.0, metavar="F",
|
|
162
|
+
help="Multiply all non-zero timeouts by F (default: 1.0)",
|
|
163
|
+
)
|
|
164
|
+
p.add_argument(
|
|
165
|
+
"--file", default=None, metavar="PATH",
|
|
166
|
+
help="Follow a log file instead of stdin (waits for the file to appear)",
|
|
167
|
+
)
|
|
168
|
+
p.add_argument(
|
|
169
|
+
"--retry-sleep", type=float, default=15.0, metavar="S",
|
|
170
|
+
help="Seconds between file-existence checks when --file is used (default: 15)",
|
|
171
|
+
)
|
|
172
|
+
p.add_argument(
|
|
173
|
+
"--retry-max", type=int, default=3, metavar="N",
|
|
174
|
+
help="Max existence-check cycles before giving up; 0 = wait forever (default: 3)",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# listen subcommand
|
|
178
|
+
ls = sub.add_parser("listen", help="Subscribe to D-Bus and write events.log")
|
|
179
|
+
ls.add_argument("--dbus-address", required=True, help="D-Bus session bus address")
|
|
180
|
+
ls.add_argument("--events-log", required=True, help="Path to events log file")
|
|
181
|
+
|
|
182
|
+
return parser
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main(argv=None) -> int:
|
|
186
|
+
parser = build_parser()
|
|
187
|
+
args = parser.parse_args(argv)
|
|
188
|
+
if args.subcommand == "process":
|
|
189
|
+
return _cmd_process(args)
|
|
190
|
+
if args.subcommand == "listen":
|
|
191
|
+
return _cmd_listen(args)
|
|
192
|
+
parser.print_help()
|
|
193
|
+
return 1
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
sys.exit(main())
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Scripts for automated Nextcloud setup.
|
|
2
|
+
# Copyright (C) 2026 Guillermo López Alejos
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Stateful rule evaluator for the log monitor engine.
|
|
18
|
+
|
|
19
|
+
The engine processes log lines one at a time. On each line (or on a
|
|
20
|
+
periodic tick) it:
|
|
21
|
+
|
|
22
|
+
1. Tries to match the line against currently-active rules.
|
|
23
|
+
2. Checks whether any active rule has exceeded its timeout.
|
|
24
|
+
3. Emits events via a signal_fn callback for completed or timed-out nodes.
|
|
25
|
+
|
|
26
|
+
signal_fn signature::
|
|
27
|
+
|
|
28
|
+
signal_fn(event_name: str, status: str, time_to_complete: float) -> None
|
|
29
|
+
|
|
30
|
+
``time_to_complete`` is the wall-clock seconds from when the node was
|
|
31
|
+
entered until the event fired.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
import time as _time
|
|
38
|
+
from abc import ABC, abstractmethod
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from typing import Callable
|
|
41
|
+
|
|
42
|
+
from .profile import GroupRule, MessageRule, Profile, RuleNode
|
|
43
|
+
|
|
44
|
+
SignalFn = Callable[[str, str, float], None]
|
|
45
|
+
|
|
46
|
+
_TIMEOUT_STATUS = "ERROR" # fallback; prefer rule.timeout_status when available
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Node states ───────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
class _NodeState(ABC):
|
|
52
|
+
def __init__(self, rule: RuleNode, entered_at: float) -> None:
|
|
53
|
+
self._rule = rule
|
|
54
|
+
self._entered_at = entered_at
|
|
55
|
+
self._done = False
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def done(self) -> bool:
|
|
59
|
+
return self._done
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def check(self, content: str | None, now: float, signal_fn: SignalFn) -> None:
|
|
63
|
+
"""Process one log line (or a tick if content is None)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _MessageState(_NodeState):
|
|
67
|
+
def __init__(self, rule: MessageRule, entered_at: float) -> None:
|
|
68
|
+
super().__init__(rule, entered_at)
|
|
69
|
+
self._rule: MessageRule
|
|
70
|
+
|
|
71
|
+
def check(self, content: str | None, now: float, signal_fn: SignalFn) -> None:
|
|
72
|
+
if self._done:
|
|
73
|
+
return
|
|
74
|
+
# Timeout check first.
|
|
75
|
+
if self._rule.timeout > 0 and (now - self._entered_at) >= self._rule.timeout:
|
|
76
|
+
self._done = True
|
|
77
|
+
if self._rule.notify:
|
|
78
|
+
signal_fn(
|
|
79
|
+
self._rule.name,
|
|
80
|
+
getattr(self._rule, "timeout_status", _TIMEOUT_STATUS),
|
|
81
|
+
now - self._entered_at,
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
if content is not None and self._rule.pattern.search(content):
|
|
85
|
+
self._done = True
|
|
86
|
+
if self._rule.notify:
|
|
87
|
+
signal_fn(
|
|
88
|
+
self._rule.name,
|
|
89
|
+
self._rule.status,
|
|
90
|
+
now - self._entered_at,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _GroupState(_NodeState):
|
|
95
|
+
def __init__(self, rule: GroupRule, entered_at: float) -> None:
|
|
96
|
+
super().__init__(rule, entered_at)
|
|
97
|
+
self._rule: GroupRule
|
|
98
|
+
self._children: list[_NodeState] = [
|
|
99
|
+
_make_state(child, entered_at) for child in rule.children
|
|
100
|
+
]
|
|
101
|
+
# sequence: index of the currently-active child
|
|
102
|
+
self._seq_index: int = 0
|
|
103
|
+
# all_any_order: set of child indices already matched
|
|
104
|
+
self._matched_indices: set[int] = set()
|
|
105
|
+
|
|
106
|
+
def check(self, content: str | None, now: float, signal_fn: SignalFn) -> None:
|
|
107
|
+
if self._done:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Group-level timeout.
|
|
111
|
+
if self._rule.timeout > 0 and (now - self._entered_at) >= self._rule.timeout:
|
|
112
|
+
self._done = True
|
|
113
|
+
if self._rule.notify:
|
|
114
|
+
signal_fn(
|
|
115
|
+
self._rule.name,
|
|
116
|
+
getattr(self._rule, "timeout_status", _TIMEOUT_STATUS),
|
|
117
|
+
now - self._entered_at,
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
if self._rule.constraint == "sequence":
|
|
122
|
+
self._check_sequence(content, now, signal_fn)
|
|
123
|
+
elif self._rule.constraint == "at_least_one":
|
|
124
|
+
self._check_at_least_one(content, now, signal_fn)
|
|
125
|
+
else: # all_any_order
|
|
126
|
+
self._check_all_any_order(content, now, signal_fn)
|
|
127
|
+
|
|
128
|
+
def _check_sequence(
|
|
129
|
+
self, content: str | None, now: float, signal_fn: SignalFn
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Advance through children in order; each must match before the next starts."""
|
|
132
|
+
if self._seq_index >= len(self._children):
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
active = self._children[self._seq_index]
|
|
136
|
+
was_done = active.done
|
|
137
|
+
active.check(content, now, signal_fn)
|
|
138
|
+
|
|
139
|
+
if active.done and not was_done:
|
|
140
|
+
self._seq_index += 1
|
|
141
|
+
if self._seq_index < len(self._children):
|
|
142
|
+
# Reset the next child's clock to now.
|
|
143
|
+
self._children[self._seq_index] = _make_state(
|
|
144
|
+
self._rule.children[self._seq_index], now
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
# All children matched.
|
|
148
|
+
self._done = True
|
|
149
|
+
if self._rule.notify:
|
|
150
|
+
signal_fn(
|
|
151
|
+
self._rule.name,
|
|
152
|
+
self._rule.status,
|
|
153
|
+
now - self._entered_at,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _check_at_least_one(
|
|
157
|
+
self, content: str | None, now: float, signal_fn: SignalFn
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Done when any single child matches."""
|
|
160
|
+
for child in self._children:
|
|
161
|
+
if child.done:
|
|
162
|
+
continue
|
|
163
|
+
was_done = child.done
|
|
164
|
+
child.check(content, now, signal_fn)
|
|
165
|
+
if child.done and not was_done:
|
|
166
|
+
self._done = True
|
|
167
|
+
if self._rule.notify:
|
|
168
|
+
signal_fn(
|
|
169
|
+
self._rule.name,
|
|
170
|
+
self._rule.status,
|
|
171
|
+
now - self._entered_at,
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
def _check_all_any_order(
|
|
176
|
+
self, content: str | None, now: float, signal_fn: SignalFn
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Done when every child has matched (in any order)."""
|
|
179
|
+
for i, child in enumerate(self._children):
|
|
180
|
+
if i in self._matched_indices:
|
|
181
|
+
continue
|
|
182
|
+
was_done = child.done
|
|
183
|
+
child.check(content, now, signal_fn)
|
|
184
|
+
if child.done and not was_done:
|
|
185
|
+
self._matched_indices.add(i)
|
|
186
|
+
|
|
187
|
+
if len(self._matched_indices) == len(self._children):
|
|
188
|
+
self._done = True
|
|
189
|
+
if self._rule.notify:
|
|
190
|
+
signal_fn(
|
|
191
|
+
self._rule.name,
|
|
192
|
+
self._rule.status,
|
|
193
|
+
now - self._entered_at,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _make_state(rule: RuleNode, entered_at: float) -> _NodeState:
|
|
198
|
+
if isinstance(rule, GroupRule):
|
|
199
|
+
return _GroupState(rule, entered_at)
|
|
200
|
+
return _MessageState(rule, entered_at)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Log line parsers ──────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
# dmesg --follow default format: "[timestamp] content" or "content"
|
|
206
|
+
_DMESG_RE = re.compile(r"^\[\s*\d+\.\d+\]\s*(.*)$")
|
|
207
|
+
# journalctl default format: "MMM DD HH:MM:SS hostname proc[pid]: content"
|
|
208
|
+
_JOURNALCTL_RE = re.compile(
|
|
209
|
+
r"^\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\S+\s+[^:]+:\s*(.*)$"
|
|
210
|
+
)
|
|
211
|
+
# docker service logs --follow --timestamps: "YYYY-MM-DDTHH:MM:SS.nZ svc.N.id | content"
|
|
212
|
+
_DOCKER_RE = re.compile(r"^\S+\s+\S+\s*\|\s*(.*)$")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _extract_content(line: str, fmt: str) -> str:
|
|
216
|
+
"""Strip format-specific prefix from a log line; return the raw message content."""
|
|
217
|
+
line = line.rstrip("\n\r")
|
|
218
|
+
if fmt == "dmesg":
|
|
219
|
+
m = _DMESG_RE.match(line)
|
|
220
|
+
return m.group(1) if m else line
|
|
221
|
+
if fmt == "journalctl":
|
|
222
|
+
m = _JOURNALCTL_RE.match(line)
|
|
223
|
+
return m.group(1) if m else line
|
|
224
|
+
if fmt == "docker":
|
|
225
|
+
m = _DOCKER_RE.match(line)
|
|
226
|
+
return m.group(1) if m else line
|
|
227
|
+
return line
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ── Public engine ─────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
class LogMonitorEngine:
|
|
233
|
+
"""Feed log lines into the rule tree and emit events via signal_fn."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, profile: Profile, signal_fn: SignalFn) -> None:
|
|
236
|
+
self._format = profile.format
|
|
237
|
+
self._signal_fn = signal_fn
|
|
238
|
+
self._root = _make_state(profile.root_rule, _time.monotonic())
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def done(self) -> bool:
|
|
242
|
+
"""True when the root rule has fully matched or timed out."""
|
|
243
|
+
return self._root.done
|
|
244
|
+
|
|
245
|
+
def process_line(self, line: str) -> None:
|
|
246
|
+
"""Feed one log line to the engine."""
|
|
247
|
+
content = _extract_content(line, self._format)
|
|
248
|
+
self._root.check(content, _time.monotonic(), self._signal_fn)
|
|
249
|
+
|
|
250
|
+
def tick(self) -> None:
|
|
251
|
+
"""Check timeouts without a new log line (called on read timeout)."""
|
|
252
|
+
self._root.check(None, _time.monotonic(), self._signal_fn)
|