The SNMP poll asks each device's sysName in the same GET and keeps it (snmp_003_sys_name). A syslog source told apart by name finds its own address from the SNMP device (reported or given name) or host of that name, so its lines reach the SNMP device's and host's pages again; the edit page gains Device address, which always wins (syslog_004). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
5.6 KiB
Python
109 lines
5.6 KiB
Python
# plugins/syslog/models.py
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import (JSON, BigInteger, Boolean, DateTime, ForeignKey, Index, SmallInteger, String, Text,
|
|
UniqueConstraint)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from steward.core.ids import time_ordered_id
|
|
from steward.models.base import Base
|
|
|
|
|
|
class SyslogSource(Base):
|
|
"""A device that sends syslog to Steward, one row per sending address.
|
|
|
|
Through a port Swarm publishes on its ingress (the default), every device
|
|
arrives from one address of Docker's own; there a row is a device by the
|
|
name its lines carry, `sender` (#6096). Everywhere else `sender` is "".
|
|
|
|
A sender Steward hasn't been told about is a row too, `state="offered"`:
|
|
its lines are counted and its newest kept as a sample, so the page can
|
|
offer it ("1,204 lines from 192.168.0.7: add as a source?"), but none is
|
|
stored. Adding it makes it `"accepted"` and its lines are kept from then
|
|
on; `"ignored"` stops the offer and keeps counting.
|
|
"""
|
|
__tablename__ = "syslog_sources"
|
|
__table_args__ = (UniqueConstraint("address", "sender", name="uq_syslog_sources_address_sender"),)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
|
|
address: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
sender: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
# The device's own address when its lines don't come from it (through
|
|
# Docker's): set by hand; when empty, found from an SNMP device or host
|
|
# of the sender's name (service.device_address).
|
|
device_address: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
state: Mapped[str] = mapped_column(String(10), nullable=False, default="offered")
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
|
host_id: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("hosts.id", ondelete="SET NULL"), nullable=True)
|
|
# Steward's name for the device, from what the lines say they come from.
|
|
hostname_seen: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
first_seen_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
|
last_seen_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
|
lines_total: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
|
last_severity: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
|
last_line: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
|
|
|
|
class SyslogLine(Base):
|
|
"""One line an accepted source sent. A state table, not a time series
|
|
(System 29): bounded by a per-source size and age ring, like docker_logs.
|
|
`received_at` is when Steward got it, the time to trust; `sent_at` is the
|
|
device's own, kept only when the line says its zone (RFC 5424)."""
|
|
__tablename__ = "syslog_lines"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
|
|
source_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("syslog_sources.id", ondelete="CASCADE"), nullable=False)
|
|
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
facility: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
|
severity: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
|
hostname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
app: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
|
message: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
|
|
# A source's lines newest first, and the ring's age cut; the twin-index
|
|
# idiom of docker_logs.
|
|
__table_args__ = (
|
|
Index("ix_syslog_lines_source_time", "source_id", "received_at"),
|
|
Index("ix_syslog_lines_received", "received_at"),
|
|
)
|
|
|
|
|
|
STATES = ("offered", "accepted", "ignored")
|
|
|
|
|
|
class SyslogPattern(Base):
|
|
"""A telling phrase to count (#6001): lines whose message holds any of
|
|
`phrases` (case-insensitive) and, when `max_severity` is set, whose
|
|
severity is that or worse. Every minute each added source it covers (all,
|
|
or `source_id`'s) gets a sample of how many of its lines matched in the
|
|
last `window_minutes`, as metric `lines_matching_<key>`, so an ordinary
|
|
alert rule fires on a match and clears once the window passes it."""
|
|
__tablename__ = "syslog_patterns"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
|
|
key: Mapped[str] = mapped_column(String(48), nullable=False, unique=True)
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
phrases: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
|
max_severity: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
|
source_id: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("syslog_sources.id", ondelete="CASCADE"), nullable=True)
|
|
window_minutes: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=15)
|
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
|
|
|
@property
|
|
def metric(self) -> str:
|
|
return metric_for(self.key)
|
|
|
|
|
|
def metric_for(key: str) -> str:
|
|
return f"lines_matching_{key}"
|