remove infra.md.example, infra.md is the source of truth
This commit is contained in:
215
ayn-antivirus/ayn_antivirus/core/scheduler.py
Normal file
215
ayn-antivirus/ayn_antivirus/core/scheduler.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user