fix(syslog): a device through Docker's address stays one source when that address changes (#6717)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m45s
CI / publish (push) Successful in 1m24s

Docker's ingress address depends on the node Steward's task is on, and sources
were keyed by (address, sender). After a task move the same device arrived under
a new key and became a new "offered" row, so its lines were counted, not kept.

A named sender is now one row whatever address it last came from, enforced by a
partial unique index. Migration syslog_005 folds rows sharing a name into the
added one and moves their lines and patterns across.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:02:04 -04:00
co-authored by Claude Opus 5.5
parent 57b2aafd1a
commit 50b8922895
5 changed files with 150 additions and 16 deletions
+46
View File
@@ -120,6 +120,52 @@ def test_devices_through_dockers_address_are_told_apart_by_name(app, monkeypatch
asyncio.run(go())
def test_a_device_stays_its_source_when_dockers_address_changes(app, monkeypatch):
"""Docker's address depends on the node Steward's task is on: an added
device keeps its row, its state and its lines after the task moves, even
when a round carries both addresses."""
from sqlalchemy import delete, select
SyslogLine, SyslogSource = _mod("models").SyslogLine, _mod("models").SyslogSource
receiver = _mod("receiver")
parse = _mod("parse").parse
monkeypatch.setattr(receiver, "on_own_network", lambda address: address.startswith("10.0.0."))
now = datetime(2026, 10, 19, 3, 0, tzinfo=timezone.utc)
def line(address, host, text):
return receiver.Received(now, address, parse(f"<14>Oct 19 01:12:03 {host} kernel: {text}"))
async def go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete(SyslogSource))
s.add(SyslogSource(address="10.0.0.3", sender="SynNAS", state="accepted", name="Mine",
first_seen_at=now, last_seen_at=now, lines_total=1))
async with app.db_sessionmaker() as s:
async with s.begin():
await receiver.store_lines(s, [line("10.0.0.3", "SynNAS", "a"), line("10.0.0.4", "SynNAS", "b")])
async with app.db_sessionmaker() as s:
async with s.begin():
await receiver.store_lines(s, [line("10.0.0.4", "SynNAS", "c")])
async with app.db_sessionmaker() as s:
src = (await s.execute(select(SyslogSource))).scalar_one()
assert (src.address, src.state, src.name, src.lines_total) == ("10.0.0.4", "accepted", "Mine", 4)
kept = (await s.execute(select(SyslogLine.message))).scalars().all()
assert sorted(kept) == ["a", "b", "c"]
# A name first heard from two addresses in one round is still one source.
async with app.db_sessionmaker() as s:
async with s.begin():
await receiver.store_lines(s, [line("10.0.0.3", "AsusNAS", "d"), line("10.0.0.4", "AsusNAS", "e")])
async with app.db_sessionmaker() as s:
src = (await s.execute(select(SyslogSource).where(SyslogSource.sender == "AsusNAS"))).scalar_one()
assert (src.address, src.state, src.lines_total) == ("10.0.0.4", "offered", 2)
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete(SyslogSource))
asyncio.run(go())
def test_the_ring_keeps_each_source_within_its_window_and_cap(app):
from sqlalchemy import delete, select