diff --git a/plugins/traefik/__init__.py b/plugins/traefik/__init__.py index 5a85a3e..e98bc3a 100644 --- a/plugins/traefik/__init__.py +++ b/plugins/traefik/__init__.py @@ -12,6 +12,11 @@ def setup(app: "Quart") -> None: global _app _app = app from .models import TraefikMetric, TraefikCert, TraefikGlobalStat, TraefikAccessSummary # noqa: F401 + from steward.core.cleanup import prune_by_age + # Samples older than data.retention_days are pruned by the cleanup (#5155). + prune_by_age(TraefikMetric.scraped_at) + prune_by_age(TraefikGlobalStat.scraped_at) + prune_by_age(TraefikAccessSummary.period_end) def get_scheduled_tasks() -> list: diff --git a/plugins/unifi/__init__.py b/plugins/unifi/__init__.py index e00a334..80b7337 100644 --- a/plugins/unifi/__init__.py +++ b/plugins/unifi/__init__.py @@ -16,6 +16,14 @@ def setup(app: "Quart") -> None: UnifiDevice, UnifiKnownClient, UnifiEvent, UnifiAlarm, UnifiSpeedtestResult, UnifiNetwork, UnifiPortForward, UnifiFwRule, ) + from steward.core.cleanup import prune_by_age + # Samples and history older than data.retention_days are pruned by the + # cleanup (#5155). Devices, known clients and config tables are current state. + for column in (UnifiWanStat.scraped_at, UnifiClientSnapshot.scraped_at, + UnifiWlanStat.scraped_at, UnifiDpiSnapshot.scraped_at, + UnifiEvent.event_time, UnifiAlarm.alarm_time, + UnifiSpeedtestResult.run_at): + prune_by_age(column) def get_scheduled_tasks() -> list: diff --git a/steward/core/cleanup.py b/steward/core/cleanup.py index 28bbe76..5161bf1 100644 --- a/steward/core/cleanup.py +++ b/steward/core/cleanup.py @@ -1,12 +1,16 @@ from __future__ import annotations +import asyncio import logging +import os from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING from sqlalchemy import delete -from steward.models.monitors import MonitorResult +from steward.models.alerts import AlertEvent from steward.models.ansible import AnsibleRun +from steward.models.audit import AuditEvent +from steward.models.monitors import MonitorResult if TYPE_CHECKING: from quart import Quart @@ -14,24 +18,46 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -async def run_cleanup(app: "Quart") -> None: - """Delete rows older than DATA_RETENTION_DAYS from time-series tables, then - run Docker-specific rollup + retention (delegated to the docker plugin).""" - retention_days: int = app.config.get("DATA_RETENTION_DAYS", 90) - now = datetime.now(timezone.utc) - cutoff = now - timedelta(days=retention_days) +# Time-series columns pruned at data.retention_days (#5155). A plugin adds its +# own with prune_by_age() in setup(), so the core never imports plugin models. +# Tables kept another way are not listed: plugin_metrics and the docker tables +# are rolled up (below), and the audit log has its own, longer window. +_AGED: list = [MonitorResult.checked_at, AnsibleRun.started_at, AlertEvent.transitioned_at] + +def prune_by_age(column) -> None: + """Have the hourly cleanup delete rows whose `column` (a mapped DateTime + attribute, e.g. TraefikMetric.scraped_at) is older than data.retention_days.""" + if not any(c is column for c in _AGED): + _AGED.append(column) + + +async def run_cleanup(app: "Quart") -> None: + """Delete rows older than their retention window from every growing table, + and run logs older than the data window, then run the metric and Docker + rollups. Windows are read from Settings each run (rule 25: no restart).""" + from steward.core.settings import get_setting + from steward.ansible.executor import ARTIFACT_DIR + + now = datetime.now(timezone.utc) async with app.db_sessionmaker() as session: async with session.begin(): - for model, ts_col in [ - (MonitorResult, MonitorResult.checked_at), - (AnsibleRun, AnsibleRun.started_at), - ]: + data_days = int(await get_setting(session, "data.retention_days") or 90) + audit_days = int(await get_setting(session, "audit.retention_days") or 365) + cutoff = now - timedelta(days=data_days) + aged = [(c, cutoff) for c in _AGED] + aged.append((AuditEvent.timestamp, now - timedelta(days=audit_days))) + for column, before in aged: result = await session.execute( - delete(model).where(ts_col < cutoff) + delete(column.class_).where(column < before) ) if result.rowcount: - logger.info(f"Pruned {result.rowcount} rows from {model.__tablename__}") + logger.info("Pruned %d rows from %s", result.rowcount, + column.class_.__tablename__) + + removed = await asyncio.to_thread(prune_run_logs, ARTIFACT_DIR, cutoff) + if removed: + logger.info("Pruned %d Ansible run log files from %s", removed, ARTIFACT_DIR) # plugin_metrics is NOT blanket-deleted here — it's rolled up to hourly # then pruned, so multi-week host history stays cheap. @@ -39,6 +65,26 @@ async def run_cleanup(app: "Quart") -> None: await _run_docker_retention(session, now) +def prune_run_logs(directory: str, before: datetime) -> int: + """Delete the run log files in `directory` last written before `before`, + the same window as the runs they belong to. Returns how many went.""" + removed = 0 + try: + entries = list(os.scandir(directory)) + except OSError: # no run has written one yet, or not readable + return 0 + limit = before.timestamp() + for entry in entries: + try: + if (entry.name.endswith(".log") and entry.is_file() + and entry.stat().st_mtime < limit): + os.unlink(entry.path) + removed += 1 + except OSError: + logger.warning("Could not prune run log %s", entry.path) + return removed + + async def _run_metrics_retention(session, now: datetime) -> None: """Roll up + prune plugin_metrics (raw → hourly → gone). Windows read fresh from settings each run (rule 25 — UI change takes effect next cleanup, no diff --git a/steward/core/metric_series.py b/steward/core/metric_series.py index 97a0c2e..256beeb 100644 --- a/steward/core/metric_series.py +++ b/steward/core/metric_series.py @@ -13,10 +13,6 @@ from sqlalchemy import func, or_, select, text from steward.core.time_range import bucket_seconds from steward.models.metrics import PluginMetric, PluginMetricHourly -# Raw rows older than this are rolled into plugin_metrics_hourly and deleted -# (see core/metrics_retention.py), so a range reaching past it reads the rollup. -RAW_DAYS = 7 - # Loose index scans: step through the distinct leading values of the # (source_module, resource_name, …) index one probe at a time instead of # scanning every sample. Postgres has no native skip scan before 18. @@ -79,9 +75,14 @@ async def points(db, source_module: str, resource: str, metric: str, since: date The part of the range older than the raw window comes from the hourly rollup; the rest is bucket-averaged in SQL (buckets no wider than an hour when both are present, so the series is evenly grained across the seam). + The raw window is metrics.retention.raw_days, the setting the rollup in + core/metrics_retention.py reads, so the seam is where the rollup put it. """ + from steward.core.settings import get_setting + + raw_days = int(await get_setting(db, "metrics.retention.raw_days") or 7) now = datetime.now(timezone.utc) - raw_cutoff = (now - timedelta(days=RAW_DAYS)).replace(minute=0, second=0, microsecond=0) + raw_cutoff = (now - timedelta(days=raw_days)).replace(minute=0, second=0, microsecond=0) out: list[tuple[datetime, float]] = [] if since < raw_cutoff: diff --git a/steward/core/settings.py b/steward/core/settings.py index ccd3706..feab97c 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -47,7 +47,11 @@ DEFAULTS: dict[str, Any] = { # Failed logins from one address before it is refused for the window. "login.max_failures": 5, "login.lockout_minutes": 15, + # Every growing time-series table (monitor results, Ansible runs and their + # log files, alert history, Traefik and UniFi samples) keeps this many days. "data.retention_days": 90, + # The audit log is a security record: kept longer than operational data. + "audit.retention_days": 365, "monitors.poll_interval_seconds": 60, "smtp.host": "", "smtp.port": 587, diff --git a/steward/settings/routes.py b/steward/settings/routes.py index 5e1666f..893ef1c 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -101,7 +101,7 @@ async def save_general(): form = await request.form whole: dict[str, int] = {} for key in ("session.lifetime_hours", "general.trusted_proxy_hops", - "login.max_failures", "login.lockout_minutes"): + "login.max_failures", "login.lockout_minutes", "audit.retention_days"): try: whole[key] = int(form.get(key, DEFAULTS[key])) except (TypeError, ValueError): diff --git a/steward/templates/settings/general.html b/steward/templates/settings/general.html index 2ff61a3..5d9e3e1 100644 --- a/steward/templates/settings/general.html +++ b/steward/templates/settings/general.html @@ -58,7 +58,14 @@ -