Files
FabledSteward/steward/ansible/scheduler.py
T
bvandeusenandClaude Opus 5.5 e780932673
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 49s
CI / unit (push) Successful in 1m15s
CI / publish (push) Successful in 1m3s
feat(ui): each run says who or what started it; run detail without ids (#5212)
#5212 "Plain labels: no raw ids, scopes, metric keys or unexplained jargon",
batch 2:

- A run records where it came from: AnsibleRun.origin ("schedule", "alert"
  or "mcp") and origin_id, set by the scheduler, Run now, alert actions and
  the MCP tools. Migration 0031 backfills past runs from alert events,
  schedules' last runs, and MCP audit entries.
- The runs list has a Started by column: "schedule nightly", "alert rule
  nas down", "alice via MCP" or "alice", linked where there is a page.
  The raw scope tooltip is gone.
- Run detail: no run id in the subtitle or body, Started by in words, and
  the host summary lists failed and unreachable hosts first. The live
  output only follows new lines while the reader is at the bottom, and
  when the run finishes the page reloads once so the badge and summary
  are final.
- list_ansible_runs and get_ansible_run report origin and origin_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:26:17 -04:00

85 lines
3.1 KiB
Python

# 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 run_in_flight, 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. A due
schedule whose previous run hasn't finished skips this firing."""
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
async with app.db_sessionmaker() as db:
busy = await run_in_flight(db, s.last_run_id)
if busy:
# One run at a time per schedule (#5158). This firing is skipped,
# not deferred, so the cadence holds; last_run_id still names the
# run that's going.
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_error = "Skipped: the previous run was still going"
logger.info("Schedule %s (%s) skipped: run %s still going",
s.id, s.name, s.last_run_id)
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,
origin="schedule",
origin_id=s.id,
)
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