# 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_`, 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}"