Files
FabledSteward/plugins/syslog/models.py
T
bvandeusenandClaude Opus 5.5 fcd58224de
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m32s
CI / integration (push) Successful in 1m42s
CI / publish (push) Successful in 20s
feat(syslog): a device through Docker's address is placed by its name, or a device address set on it (#6096)
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>
2026-10-19 12:24:58 -04:00

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}"