#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>
85 lines
3.1 KiB
Python
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
|