Files
bigbrother/core/scheduler.py
T
Cobra ffd384f64b Strip OPSEC tool identity fingerprints
Replace all sensor.* logger namespaces with __name__ (generic module
identifiers instead of discoverable 'sensor.*' prefixes).

Change hardcoded 'bb' API user to 'admin' in config and code defaults.

Change hardcoded relay_user 'bb' to 'operator' — prevents network
profiling from exposing tool identity via SSH config.

Fixes #457, #458, #459
2026-04-08 22:18:35 -04:00

193 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Cron-like task scheduler with jitter.
Modules register periodic tasks with an interval and optional jitter range.
Tasks execute in a thread pool. Used for PCAP rotation, health checks,
baseline captures, data pruning, resource checks.
"""
import time
import random
import logging
import threading
from dataclasses import dataclass, field
from typing import Callable, Optional
from concurrent.futures import ThreadPoolExecutor
logger = logging.getLogger(__name__)
@dataclass
class ScheduledTask:
"""A registered periodic task."""
name: str
callback: Callable
interval: float # seconds between executions
jitter: float = 0.0 # max random jitter added to interval (seconds)
run_immediately: bool = False
enabled: bool = True
# Runtime state
last_run: float = 0.0
next_run: float = 0.0
run_count: int = 0
error_count: int = 0
_last_error: Optional[str] = None
class Scheduler:
"""Thread-safe periodic task scheduler with jitter support.
Tasks are checked every second and dispatched to a thread pool.
Jitter is re-randomized on each scheduling cycle to avoid patterns.
"""
def __init__(self, max_workers: int = 4):
self._tasks: dict[str, ScheduledTask] = {}
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._pool = ThreadPoolExecutor(max_workers=max_workers,
thread_name_prefix="bb-sched")
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def start(self) -> None:
"""Start the scheduler loop."""
if self._running:
return
self._running = True
# Initialize next_run for tasks that want immediate execution
now = time.time()
with self._lock:
for task in self._tasks.values():
if task.run_immediately:
task.next_run = now
elif task.next_run == 0.0:
task.next_run = now + task.interval + random.uniform(0, task.jitter)
self._thread = threading.Thread(
target=self._scheduler_loop, daemon=True, name="sensor-scheduler"
)
self._thread.start()
logger.info("Scheduler started (%d tasks)", len(self._tasks))
def stop(self) -> None:
"""Stop the scheduler and wait for running tasks to finish."""
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5.0)
self._pool.shutdown(wait=False)
logger.info("Scheduler stopped")
# ------------------------------------------------------------------
# Task management
# ------------------------------------------------------------------
def register(self, name: str, callback: Callable, interval: float,
jitter: float = 0.0, run_immediately: bool = False) -> None:
"""Register a periodic task.
Args:
name: Unique task name.
callback: Callable (no arguments) to execute.
interval: Base interval in seconds.
jitter: Maximum random seconds added to interval.
run_immediately: If True, run as soon as the scheduler starts.
"""
now = time.time()
task = ScheduledTask(
name=name,
callback=callback,
interval=interval,
jitter=jitter,
run_immediately=run_immediately,
next_run=now if run_immediately else now + interval + random.uniform(0, jitter),
)
with self._lock:
self._tasks[name] = task
logger.debug("Registered task: %s (interval=%.1fs, jitter=%.1fs)",
name, interval, jitter)
def unregister(self, name: str) -> None:
"""Remove a task."""
with self._lock:
self._tasks.pop(name, None)
def enable(self, name: str) -> None:
"""Enable a disabled task."""
with self._lock:
task = self._tasks.get(name)
if task:
task.enabled = True
def disable(self, name: str) -> None:
"""Disable a task (stays registered but won't execute)."""
with self._lock:
task = self._tasks.get(name)
if task:
task.enabled = False
def run_now(self, name: str) -> None:
"""Trigger immediate execution of a task."""
with self._lock:
task = self._tasks.get(name)
if task:
task.next_run = time.time()
# ------------------------------------------------------------------
# Scheduler loop
# ------------------------------------------------------------------
def _scheduler_loop(self) -> None:
"""Check tasks every second, dispatch due tasks to thread pool."""
while self._running:
now = time.time()
with self._lock:
tasks = list(self._tasks.values())
for task in tasks:
if not task.enabled:
continue
if now >= task.next_run:
self._pool.submit(self._execute_task, task)
# Schedule next run with fresh jitter
jitter = random.uniform(0, task.jitter) if task.jitter > 0 else 0
task.next_run = now + task.interval + jitter
time.sleep(1.0)
def _execute_task(self, task: ScheduledTask) -> None:
"""Execute a task and update bookkeeping."""
try:
task.callback()
task.run_count += 1
task.last_run = time.time()
except Exception as e:
task.error_count += 1
task._last_error = str(e)
logger.exception("Scheduler task %s failed", task.name)
# ------------------------------------------------------------------
# Introspection
# ------------------------------------------------------------------
def get_tasks(self) -> dict:
"""Return info about all registered tasks."""
with self._lock:
return {
name: {
"interval": t.interval,
"jitter": t.jitter,
"enabled": t.enabled,
"run_count": t.run_count,
"error_count": t.error_count,
"last_run": t.last_run,
"next_run": t.next_run,
"last_error": t._last_error,
}
for name, t in self._tasks.items()
}