This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/karldns/store/records.py
T

286 lines
10 KiB
Python
Raw Normal View History

2026-05-20 23:55:27 +02:00
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
2026-05-20 23:55:27 +02:00
from datetime import UTC, datetime
2026-05-21 17:23:08 +02:00
from ..models import UpdateResult
2026-05-22 17:05:06 +02:00
from .base import StoreConnectionMixin
from .types import RecordRow, UpdateEventRow, record_row, update_event_row
2026-05-20 23:55:27 +02:00
@dataclass(frozen=True, slots=True)
class UpdateEventInput:
hostname: str
ipv4: str | None
ipv6: str | None
status: str
detail: str
source_ip: str
@dataclass(frozen=True, slots=True)
class RecordUpdateInput:
hostname: str
ipv4: str | None
ipv6: str | None
update_ipv4: bool = True
update_ipv6: bool = True
def _update_event_input(event: UpdateEventInput | str, legacy_values: tuple[object, ...]) -> UpdateEventInput:
if isinstance(event, UpdateEventInput):
return event
if len(legacy_values) != 5:
raise TypeError("legacy update event values must be ipv4, ipv6, status, detail, source_ip")
ipv4, ipv6, status, detail, source_ip = legacy_values
return UpdateEventInput(
hostname=event,
ipv4=str(ipv4) if ipv4 is not None else None,
ipv6=str(ipv6) if ipv6 is not None else None,
status=str(status),
detail=str(detail),
source_ip=str(source_ip),
)
def _planned_record_update(
update: RecordUpdateInput,
*,
current_ipv4: str | None,
current_ipv6: str | None,
existing: bool,
) -> UpdateResult:
effective_ipv4 = update.ipv4 if update.update_ipv4 else current_ipv4
effective_ipv6 = update.ipv6 if update.update_ipv6 else current_ipv6
return UpdateResult(
hostname=update.hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=not existing or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6,
update_ipv4=update.update_ipv4,
update_ipv6=update.update_ipv6,
)
2026-05-22 17:05:06 +02:00
class RecordsEventsMixin(StoreConnectionMixin):
2026-05-20 23:55:27 +02:00
def upsert(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
now = datetime.now(UTC).isoformat()
update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
2026-05-20 23:55:27 +02:00
with self.connect() as conn:
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
result = _planned_record_update(
update,
current_ipv4=current_ipv4,
current_ipv6=current_ipv6,
existing=existing is not None,
)
2026-05-20 23:55:27 +02:00
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(result.hostname, result.ipv4, result.ipv6, now),
2026-05-20 23:55:27 +02:00
)
return result
2026-05-20 23:55:27 +02:00
def commit_update_result(self, result: UpdateResult) -> UpdateResult:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(result.hostname, result.ipv4, result.ipv6, now),
)
return result
def preview_update(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
2026-05-20 23:55:27 +02:00
with self.connect() as conn:
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
return _planned_record_update(
update,
current_ipv4=current_ipv4,
current_ipv6=current_ipv6,
existing=existing is not None,
2026-05-20 23:55:27 +02:00
)
2026-05-22 17:05:06 +02:00
def list_records(self) -> list[RecordRow]:
2026-05-20 23:55:27 +02:00
return self.list_records_page(limit=10_000, offset=0)
2026-05-22 17:05:06 +02:00
def list_records_page(self, limit: int = 100, offset: int = 0) -> list[RecordRow]:
2026-05-20 23:55:27 +02:00
with self.connect() as conn:
rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, updated_at
FROM records
ORDER BY hostname
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
2026-05-22 17:05:06 +02:00
return [record_row(row) for row in rows]
2026-05-20 23:55:27 +02:00
def count_records(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM records").fetchone()
return int(row[0])
def log_update_event(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
event = _update_event_input(event, legacy_values)
2026-05-20 23:55:27 +02:00
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
event.hostname,
event.ipv4,
event.ipv6,
event.status,
event.detail[:500],
event.source_ip,
datetime.now(UTC).isoformat(),
),
2026-05-20 23:55:27 +02:00
)
def log_update_rollup(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
event = _update_event_input(event, legacy_values)
2026-05-20 23:55:27 +02:00
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_event_rollups(
hostname, ipv4, ipv6, status, detail, source_ip, first_seen_at, last_seen_at, count
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(hostname, ipv4, ipv6, status, detail, source_ip) DO UPDATE SET
last_seen_at=excluded.last_seen_at,
count=count + 1
""",
(
event.hostname,
event.ipv4 or "",
event.ipv6 or "",
event.status,
event.detail[:500],
event.source_ip,
now,
now,
),
2026-05-20 23:55:27 +02:00
)
def list_update_events(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
return self.list_update_events_page(limit=limit, offset=offset)
def list_update_events_page(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
with self.connect() as conn:
2026-05-21 10:05:40 +02:00
rows = conn.execute(
2026-05-20 23:55:27 +02:00
"""
2026-05-21 10:05:40 +02:00
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, count
FROM (
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
UNION ALL
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
)
2026-05-20 23:55:27 +02:00
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
2026-05-21 10:05:40 +02:00
(limit, offset),
2026-05-20 23:55:27 +02:00
).fetchall()
2026-05-22 17:05:06 +02:00
return [update_event_row(row) for row in rows]
2026-05-21 10:05:40 +02:00
def list_update_events_before(self, limit: int = 100, before: str | None = None) -> list[UpdateEventRow]:
where_clause = "WHERE created_at < ?" if before else ""
params: tuple[object, ...] = (before, limit) if before else (limit,)
with self.connect() as conn:
rows = conn.execute(
f"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, count
FROM (
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
UNION ALL
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
)
{where_clause}
ORDER BY created_at DESC
LIMIT ?
2026-05-20 23:55:27 +02:00
""",
2026-05-21 10:05:40 +02:00
params,
2026-05-20 23:55:27 +02:00
).fetchall()
2026-05-22 17:05:06 +02:00
return [update_event_row(row) for row in rows]
2026-05-20 23:55:27 +02:00
def count_update_events(self) -> int:
with self.connect() as conn:
event_count = conn.execute("SELECT COUNT(*) FROM update_events").fetchone()[0]
rollup_count = conn.execute("SELECT COUNT(*) FROM update_event_rollups").fetchone()[0]
return int(event_count) + int(rollup_count)
def list_update_events_for_hostname(self, hostname: str, limit: int = 25) -> list[UpdateEventRow]:
with self.connect() as conn:
event_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
WHERE hostname = ?
ORDER BY created_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
rollup_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
WHERE hostname = ?
ORDER BY last_seen_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
return _merge_event_rows(event_rows, rollup_rows, limit, 0)
def _merge_event_rows(
event_rows: list[sqlite3.Row],
rollup_rows: list[sqlite3.Row],
limit: int,
offset: int,
) -> list[UpdateEventRow]:
2026-05-22 17:05:06 +02:00
rows = [update_event_row(row) for row in event_rows]
rows.extend(update_event_row(row) for row in rollup_rows)
2026-05-20 23:55:27 +02:00
rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
return rows[offset : offset + limit]