# steward/ansible/scheduler.py """Fire due Ansible schedules. Driven by the core ScheduledTask loop (~60s).""" from __future__ import annotations import logging from datetime import datetime, timezone from sqlalchemy import select from steward.ansible.runner import trigger_run from steward.models.ansible_schedule import AnsibleSchedule logger = logging.getLogger(__name__) def is_due(schedule: AnsibleSchedule, now: datetime) -> bool: """True when an enabled schedule has never run or its interval has elapsed. Cadence is measured from the previous *fire* time (last_run_at), not run completion, so a long-running playbook doesn't cause catch-up storms. """ if not schedule.enabled: return False if schedule.last_run_at is None: return True return (now - schedule.last_run_at).total_seconds() >= schedule.interval_seconds async def run_due_schedules(app) -> int: """Trigger every due schedule once. Returns the number launched.""" now = datetime.now(timezone.utc) async with app.db_sessionmaker() as db: schedules = (await db.execute( select(AnsibleSchedule).where(AnsibleSchedule.enabled.is_(True)) )).scalars().all() fired = 0 for s in schedules: if not is_due(s, now): continue run = None err: str | None = None try: run, _source, err = await trigger_run( app, source_name=s.source_name, playbook_path=s.playbook_path, inventory_scope=s.inventory_scope, params=s.params, triggered_by=None, ) except Exception: logger.exception("Schedule %s (%s) failed to launch", s.id, s.name) err = "launch error" async with app.db_sessionmaker() as db: async with db.begin(): fresh = await db.get(AnsibleSchedule, s.id) if fresh: fresh.last_run_at = now fresh.last_run_id = run.id if run else None fresh.last_error = err if run: fired += 1 logger.info("Schedule %s (%s) fired run %s", s.id, s.name, run.id) return fired