Files
calvana/ayn-antivirus/ayn_antivirus/core/scheduler.py

216 lines
7.7 KiB
Python

"""Scheduler for recurring scans and signature updates.
Wraps the ``schedule`` library to provide cron-like recurring tasks that
drive the :class:`ScanEngine` and signature updater in a long-running
daemon loop.
"""
from __future__ import annotations
import logging
import time
from typing import Optional
import schedule
from ayn_antivirus.config import Config
from ayn_antivirus.core.engine import ScanEngine, ScanResult
from ayn_antivirus.core.event_bus import EventType, event_bus
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Cron expression helpers
# ---------------------------------------------------------------------------
def _parse_cron_field(field: str, min_val: int, max_val: int) -> list[int]:
"""Parse a single cron field (e.g. ``*/5``, ``1,3,5``, ``0-23``, ``*``).
Returns a sorted list of matching integer values.
"""
values: set[int] = set()
for part in field.split(","):
part = part.strip()
# */step
if part.startswith("*/"):
step = int(part[2:])
values.update(range(min_val, max_val + 1, step))
# range with optional step (e.g. 1-5 or 1-5/2)
elif "-" in part:
range_part, _, step_part = part.partition("/")
lo, hi = range_part.split("-", 1)
step = int(step_part) if step_part else 1
values.update(range(int(lo), int(hi) + 1, step))
# wildcard
elif part == "*":
values.update(range(min_val, max_val + 1))
# literal
else:
values.add(int(part))
return sorted(values)
def _cron_to_schedule(cron_expr: str) -> dict:
"""Convert a 5-field cron expression into components.
Returns a dict with keys ``minutes``, ``hours``, ``days``, ``months``,
``weekdays`` — each a list of integers.
Only *minute* and *hour* are used by the ``schedule`` library adapter
below; the rest are validated but not fully honoured (``schedule`` lacks
calendar-level granularity).
"""
parts = cron_expr.strip().split()
if len(parts) != 5:
raise ValueError(f"Expected 5-field cron expression, got: {cron_expr!r}")
return {
"minutes": _parse_cron_field(parts[0], 0, 59),
"hours": _parse_cron_field(parts[1], 0, 23),
"days": _parse_cron_field(parts[2], 1, 31),
"months": _parse_cron_field(parts[3], 1, 12),
"weekdays": _parse_cron_field(parts[4], 0, 6),
}
# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------
class Scheduler:
"""Manages recurring scan and update jobs.
Parameters
----------
config:
Application configuration — used to build a :class:`ScanEngine` and
read schedule expressions.
engine:
Optional pre-built engine instance. If ``None``, one is created from
*config*.
"""
def __init__(self, config: Config, engine: Optional[ScanEngine] = None) -> None:
self.config = config
self.engine = engine or ScanEngine(config)
self._scheduler = schedule.Scheduler()
# ------------------------------------------------------------------
# Job builders
# ------------------------------------------------------------------
def schedule_scan(self, cron_expr: str, scan_type: str = "full") -> None:
"""Schedule a recurring scan using a cron expression.
Parameters
----------
cron_expr:
Standard 5-field cron string (``minute hour dom month dow``).
scan_type:
One of ``"full"``, ``"quick"``, or ``"deep"``.
"""
parsed = _cron_to_schedule(cron_expr)
# ``schedule`` doesn't natively support cron, so we approximate by
# scheduling at every matching hour:minute combination. For simple
# expressions like ``0 2 * * *`` this is exact.
for hour in parsed["hours"]:
for minute in parsed["minutes"]:
time_str = f"{hour:02d}:{minute:02d}"
self._scheduler.every().day.at(time_str).do(
self._run_scan, scan_type=scan_type
)
logger.info("Scheduled %s scan at %s daily", scan_type, time_str)
def schedule_update(self, interval_hours: int = 6) -> None:
"""Schedule recurring signature updates.
Parameters
----------
interval_hours:
How often (in hours) to pull fresh signatures.
"""
self._scheduler.every(interval_hours).hours.do(self._run_update)
logger.info("Scheduled signature update every %d hour(s)", interval_hours)
# ------------------------------------------------------------------
# Daemon loop
# ------------------------------------------------------------------
def run_daemon(self) -> None:
"""Start the blocking scheduler loop.
Runs all pending jobs and sleeps between iterations. Designed to be
the main loop of a background daemon process.
Press ``Ctrl+C`` (or send ``SIGINT``) to exit cleanly.
"""
logger.info("AYN scheduler daemon started — %d job(s)", len(self._scheduler.get_jobs()))
try:
while True:
self._scheduler.run_pending()
time.sleep(30)
except KeyboardInterrupt:
logger.info("Scheduler daemon stopped by user")
# ------------------------------------------------------------------
# Job implementations
# ------------------------------------------------------------------
def _run_scan(self, scan_type: str = "full") -> None:
"""Execute a scan job."""
logger.info("Starting scheduled %s scan", scan_type)
try:
if scan_type == "quick":
result: ScanResult = self.engine.quick_scan()
else:
# "full" and "deep" both scan all paths; deep adds process/network
# via full_scan on the engine, but here we keep it simple.
result = ScanResult()
for path in self.config.scan_paths:
partial = self.engine.scan_path(path, recursive=True)
result.files_scanned += partial.files_scanned
result.files_skipped += partial.files_skipped
result.threats.extend(partial.threats)
logger.info(
"Scheduled %s scan complete — %d files, %d threats",
scan_type,
result.files_scanned,
len(result.threats),
)
except Exception:
logger.exception("Scheduled %s scan failed", scan_type)
def _run_update(self) -> None:
"""Execute a signature update job."""
logger.info("Starting scheduled signature update")
try:
from ayn_antivirus.signatures.manager import SignatureManager
manager = SignatureManager(self.config)
summary = manager.update_all()
total = summary.get("total_new", 0)
errors = summary.get("errors", [])
logger.info(
"Scheduled signature update complete: %d new, %d errors",
total,
len(errors),
)
if errors:
for err in errors:
logger.warning("Feed error: %s", err)
manager.close()
event_bus.publish(EventType.SIGNATURE_UPDATED, {
"total_new": total,
"feeds": list(summary.get("feeds", {}).keys()),
"errors": errors,
})
except Exception:
logger.exception("Scheduled signature update failed")