Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

260 lines
8.7 KiB
Python

"""``opensre hermes`` command group: live-tail Hermes logs and dispatch to Telegram.
The ``opensre hermes watch`` command wires the existing detection
backbone (:class:`~integrations.hermes.HermesAgent`) to the
:class:`~integrations.hermes.TelegramSink` and blocks until ``SIGINT`` /
``SIGTERM``. Credentials are loaded via
:func:`~integrations.telegram.credentials.load_credentials_from_env`, so the
``TELEGRAM_BOT_TOKEN`` env var must be set; ``--chat-id`` overrides the
``TELEGRAM_DEFAULT_CHAT_ID`` env var when both are present.
This command intentionally does *not* run an OpenSRE investigation by
default. Pass ``--investigate`` (or set ``OPENSRE_HERMES_INVESTIGATE=1``)
to enable the RCA bridge for ``HIGH``/``CRITICAL`` incidents.
"""
from __future__ import annotations
import os
import signal
import threading
from pathlib import Path
from typing import Any
import click
from integrations.hermes.agent import DEFAULT_LOG_PATH, HermesAgent
from integrations.hermes.correlating_sink import CorrelatingSink
from integrations.hermes.correlator import IncidentCorrelator, RouteDestination
from integrations.hermes.investigation import run_incident_investigation
from integrations.hermes.sinks import TelegramSink
from integrations.telegram.alarms import AlarmDispatcher
from integrations.telegram.credentials import load_credentials_from_env
from tools.investigation.capability import run_investigation
@click.group(name="hermes", invoke_without_command=True)
@click.pass_context
def hermes_command(ctx: click.Context) -> None:
"""Live-tail Hermes logs and route detected incidents to Telegram."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
ctx.exit(0)
@hermes_command.command(name="watch")
@click.option(
"--log-path",
type=click.Path(dir_okay=False, path_type=Path),
default=None,
help=(
f"Path to the Hermes log file. Defaults to {DEFAULT_LOG_PATH}. "
"The file does not need to exist yet — the tailer waits for it to appear."
),
)
@click.option(
"--chat-id",
"chat_id",
type=str,
default=None,
help=(
"Telegram chat ID to deliver incidents to. Overrides "
"TELEGRAM_DEFAULT_CHAT_ID when both are set."
),
)
@click.option(
"--cooldown-seconds",
type=float,
default=300.0,
show_default=True,
help="Per-incident-fingerprint cooldown before a duplicate is re-sent.",
)
@click.option(
"--from-start/--from-end",
default=False,
show_default=True,
help=(
"Replay the existing log contents before live-tailing. Off by default so "
"starting the watcher does not flood Telegram with backlog."
),
)
@click.option(
"--investigate/--no-investigate",
"investigate",
default=None,
help=(
"Run an OpenSRE investigation for HIGH/CRITICAL incidents and append "
"the RCA summary before delivery. Defaults to off; set "
"OPENSRE_HERMES_INVESTIGATE=1 to enable globally."
),
)
@click.option(
"--correlate/--no-correlate",
"correlate",
default=True,
show_default=True,
help=(
"Route classifier output through the IncidentCorrelator (dedup, "
"severity escalation, rule→destination routing). Disable to pipe "
"raw incidents straight into the TelegramSink — the legacy behavior."
),
)
@click.option(
"--dedup-window-seconds",
type=float,
default=300.0,
show_default=True,
help=(
"Correlator dedup window. Identical fingerprints within this window "
"are suppressed unless escalation breaks through. Ignored when "
"--no-correlate is set."
),
)
@click.option(
"--escalation-threshold",
type=click.IntRange(min=2),
default=3,
show_default=True,
help=(
"Number of repeat hits within --escalation-window-seconds that "
"bumps an incident's severity one rung (HIGH→CRITICAL). Must be ≥2. "
"Ignored when --no-correlate is set."
),
)
@click.option(
"--escalation-window-seconds",
type=float,
default=600.0,
show_default=True,
help=(
"Window over which repeat hits are counted for escalation. Ignored "
"when --no-correlate is set."
),
)
def hermes_watch(
log_path: Path | None,
chat_id: str | None,
cooldown_seconds: float,
from_start: bool,
investigate: bool | None,
correlate: bool,
dedup_window_seconds: float,
escalation_threshold: int,
escalation_window_seconds: float,
) -> None:
"""Start the Hermes log watcher and block until interrupted.
Loads Telegram credentials from the environment, constructs a
:class:`HermesAgent` wired to a :class:`TelegramSink`, then waits
for ``SIGINT``/``SIGTERM`` before shutting the agent down cleanly.
"""
creds = load_credentials_from_env(chat_id_override=chat_id)
dispatcher = AlarmDispatcher(creds, cooldown_seconds=cooldown_seconds)
investigate_enabled = _resolve_investigate_flag(investigate)
bridge = (
(lambda incident: run_incident_investigation(incident, run_investigation))
if investigate_enabled
else None
)
telegram_sink = TelegramSink(dispatcher, investigation_bridge=bridge)
correlator: IncidentCorrelator | None = None
sink: Any
if correlate:
correlator = IncidentCorrelator(
dedup_window_s=dedup_window_seconds,
escalation_window_s=escalation_window_seconds,
escalation_threshold=escalation_threshold,
)
# PAGER destinations currently fall back to Telegram with a clear
# marker — there is no separate pager integration yet. Routing the
# same sink under both keys keeps escalations visible until a
# dedicated PagerDuty/Opsgenie sink is added.
sink = CorrelatingSink(
correlator=correlator,
routes={
RouteDestination.TELEGRAM: telegram_sink,
RouteDestination.TELEGRAM_WITH_RCA: telegram_sink,
RouteDestination.PAGER: telegram_sink,
},
default_route=telegram_sink,
)
else:
sink = telegram_sink
resolved_log_path = log_path or DEFAULT_LOG_PATH
agent = HermesAgent(
sink=sink,
log_path=resolved_log_path,
from_start=from_start,
)
stop_event = threading.Event()
_install_shutdown_handlers(stop_event)
click.echo(
f"hermes-watch: tailing {resolved_log_path} "
f"(cooldown={cooldown_seconds:.0f}s, "
f"investigate={'on' if investigate_enabled else 'off'}, "
f"correlate={'on' if correlate else 'off'})"
)
agent.start()
try:
# Block on the stop_event so SIGINT/SIGTERM wake the main
# thread immediately. A plain ``thread.join()`` would not
# respond to signals on its own.
stop_event.wait()
finally:
click.echo("hermes-watch: stopping…")
agent.stop()
# Drain in-flight investigation calls (no-op when bridge is
# disabled). Done after agent.stop() so no new bridge submits
# can race the shutdown.
telegram_sink.close()
if correlator is not None and isinstance(sink, CorrelatingSink):
snapshot = sink.metrics_snapshot()
click.echo(
"hermes-watch: correlator metrics "
f"delivered={snapshot['delivered']} "
f"suppressed={snapshot['suppressed']} "
f"escalated={snapshot['escalated']} "
f"dropped={snapshot['dropped']} "
f"unrouted={snapshot['unrouted']} "
f"sink_errors={snapshot['sink_errors']}"
)
click.echo("hermes-watch: stopped.")
def _resolve_investigate_flag(cli_value: bool | None) -> bool:
"""CLI flag wins; otherwise fall back to the env var."""
if cli_value is not None:
return cli_value
env_value = os.getenv("OPENSRE_HERMES_INVESTIGATE", "").strip().lower()
return env_value in {"1", "true", "yes", "on"}
def _install_shutdown_handlers(stop_event: threading.Event) -> None:
"""Install SIGINT/SIGTERM handlers that set ``stop_event``.
Click already installs a SIGINT-based ``KeyboardInterrupt`` path,
but the watcher blocks on a ``threading.Event`` rather than a
user-input prompt, so we replace it with a handler that wakes the
event explicitly. SIGTERM gets the same treatment for systemd /
container shutdowns.
"""
def _handler(_signum: int, _frame: Any) -> None:
stop_event.set()
signal.signal(signal.SIGINT, _handler)
# SIGTERM is not defined on Windows; guard for portability even
# though the watcher is a Linux/macOS workflow today.
sigterm = getattr(signal, "SIGTERM", None)
if sigterm is not None:
signal.signal(sigterm, _handler)
__all__ = ["hermes_command"]