feat(retention): every growing table is pruned; the audit log has its own window; charts read the raw window from Settings (#5155)
- prune_by_age(column) registers a time-series column with the hourly cleanup; Traefik (metrics, access summaries, global stats) and UniFi (WAN, client, WLAN, DPI, events, alarms, speedtests) register theirs in setup(). - Alert events join monitor results and Ansible runs on data.retention_days, read from Settings each run; Ansible run log files older than it are deleted. - audit.retention_days (365) keeps the audit log longer, set on Settings > General. - metric_series.points reads metrics.retention.raw_days, so the chart's raw/rollup seam is where the rollup put it (RAW_DAYS removed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"""Unit: what the hourly cleanup prunes (#5155)."""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from steward.core import cleanup
|
||||
|
||||
|
||||
def test_run_logs_older_than_the_window_are_pruned(tmp_path):
|
||||
now = datetime.now(timezone.utc)
|
||||
old, new, other = tmp_path / "old.log", tmp_path / "new.log", tmp_path / "notes.txt"
|
||||
for f in (old, new, other):
|
||||
f.write_text("x")
|
||||
aged = (now - timedelta(days=100)).timestamp()
|
||||
os.utime(old, (aged, aged))
|
||||
os.utime(other, (aged, aged))
|
||||
|
||||
assert cleanup.prune_run_logs(str(tmp_path), now - timedelta(days=90)) == 1
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == ["new.log", "notes.txt"]
|
||||
|
||||
|
||||
def test_no_run_log_directory_prunes_nothing(tmp_path):
|
||||
assert cleanup.prune_run_logs(str(tmp_path / "missing"), datetime.now(timezone.utc)) == 0
|
||||
|
||||
|
||||
def test_core_time_series_are_on_the_data_window():
|
||||
from steward.models.alerts import AlertEvent
|
||||
from steward.models.ansible import AnsibleRun
|
||||
from steward.models.audit import AuditEvent
|
||||
from steward.models.monitors import MonitorResult
|
||||
|
||||
tables = {c.class_.__tablename__ for c in cleanup._AGED}
|
||||
assert {MonitorResult.__tablename__, AnsibleRun.__tablename__,
|
||||
AlertEvent.__tablename__} <= tables
|
||||
# The audit log has its own, longer window, not the data one.
|
||||
assert AuditEvent.__tablename__ not in tables
|
||||
|
||||
|
||||
def test_a_column_registers_once(monkeypatch):
|
||||
from steward.models.monitors import MonitorResult
|
||||
|
||||
monkeypatch.setattr(cleanup, "_AGED", [])
|
||||
cleanup.prune_by_age(MonitorResult.checked_at)
|
||||
cleanup.prune_by_age(MonitorResult.checked_at) # a plugin set up twice
|
||||
assert len(cleanup._AGED) == 1
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Integration: retention windows read from Settings reach the pruning (#5155)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
if not os.environ.get("STEWARD_DATABASE_URL"):
|
||||
pytest.skip("needs Postgres")
|
||||
from steward.app import create_app
|
||||
return create_app(testing=False)
|
||||
|
||||
|
||||
def test_the_audit_window_comes_from_settings(app):
|
||||
from sqlalchemy import text
|
||||
from steward.core.cleanup import run_cleanup
|
||||
from steward.core.settings import set_setting
|
||||
from steward.models.audit import AuditEvent
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
tag = f"test.cleanup.{uuid.uuid4().hex[:8]}"
|
||||
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await set_setting(s, "audit.retention_days", 30)
|
||||
s.add(AuditEvent(action=tag, entity_id="old", timestamp=now - timedelta(days=40)))
|
||||
s.add(AuditEvent(action=tag, entity_id="new", timestamp=now - timedelta(days=20)))
|
||||
try:
|
||||
await run_cleanup(app)
|
||||
finally:
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await set_setting(s, "audit.retention_days", 365)
|
||||
async with app.db_sessionmaker() as s:
|
||||
return [r[0] for r in (await s.execute(text(
|
||||
"SELECT entity_id FROM audit_events WHERE action = :a"), {"a": tag})).all()]
|
||||
|
||||
assert asyncio.run(_go()) == ["new"]
|
||||
|
||||
|
||||
def test_the_chart_seam_follows_the_raw_metrics_setting(app):
|
||||
# With raw_days 1, a range two days back reads the hourly rollup there and
|
||||
# not the raw sample beside it; at the default 7 it would read the raw one.
|
||||
from steward.core import metric_series
|
||||
from steward.core.settings import set_setting
|
||||
from steward.models.metrics import PluginMetric, PluginMetricHourly
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
two_days = (now - timedelta(days=2)).replace(minute=0, second=0, microsecond=0)
|
||||
res = f"seam-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await set_setting(s, "metrics.retention.raw_days", 1)
|
||||
s.add(PluginMetricHourly(source_module="test", resource_name=res,
|
||||
metric_name="cpu_pct", bucket=two_days,
|
||||
value_avg=10.0, value_max=10.0, sample_count=1))
|
||||
s.add(PluginMetric(source_module="test", resource_name=res,
|
||||
metric_name="cpu_pct", value=99.0, recorded_at=two_days))
|
||||
try:
|
||||
async with app.db_sessionmaker() as s:
|
||||
return await metric_series.points(s, "test", res, "cpu_pct",
|
||||
now - timedelta(days=3))
|
||||
finally:
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await set_setting(s, "metrics.retention.raw_days", 7)
|
||||
|
||||
assert [v for _, v in asyncio.run(_go())] == [10.0]
|
||||
Reference in New Issue
Block a user