Files
FabledSteward/tests/integration/test_ansible_overlap.py
T
bvandeusenandClaude Opus 5.5 3c24c633df
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m27s
CI / integration (push) Failing after 1m1s
CI / publish (push) Skipped
fix(ansible): overlapping runs: one per host, one per schedule, one action run per alert rule (#5158)
- One run per host: start_run claims every host it touches (addresses read
  from the inventory Steward wrote; a source's own inventory file is claimed
  as that file) before it takes a run slot. The claim is all-or-nothing. A run
  waiting on a host shows queued and names the run it waits for, honours a
  cancel, and fails after HOST_WAIT_SECONDS (1 h) with which host and run.
- A due schedule whose previous run is still queued or running skips the
  firing: last_run_at moves on, last_run_id still names the run going, and
  last_error says "Skipped: the previous run was still going". The Schedules
  page shows that beside the run (muted), and a launch error in red.
- An alert rule that fires while the action run from an earlier firing is
  still going starts no new run; the event says so and names the run. An
  in-process set closes the gap between the check and the run being linked.
- docs/core/ansible.md: queued/cancelled statuses and the overlap rules.

Tests: tests/core/test_ansible_overlap.py (claim keys, waiting, all-or-nothing,
cancel, deadline) and tests/integration/test_ansible_overlap.py (two real
runs on one host take turns; schedule skip then fire; alert action skip then
start).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:24:51 -04:00

202 lines
8.1 KiB
Python

"""Integration: overlapping Ansible runs (#5158).
Two runs on one host take turns, a schedule whose run is still going skips its
firing, and an alert rule whose action run is still going doesn't start
another. Needs Postgres for the run, schedule and alert rows.
"""
from __future__ import annotations
import asyncio
import os
import sys
import uuid
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
pytestmark = pytest.mark.integration
RECAP = ("PLAY RECAP ****\\n"
"localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0")
# Notes when it starts and ends in a shared file, so the order shows overlap.
TURN = (
"import sys, time\n"
"open(sys.argv[1], 'a').write('start\\n')\n"
"time.sleep(1)\n"
"open(sys.argv[1], 'a').write('end\\n')\n"
f"print('{RECAP}')\n"
)
INVENTORY = "all:\n hosts:\n box:\n ansible_host: 10.99.0.7\n"
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _new_run(app, source: str, status=None) -> str:
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
run_id = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(AnsibleRun(id=run_id, playbook_path="t.yml", inventory_path=None,
source_name=source, triggered_by=None,
status=status or AnsibleRunStatus.running))
return run_id
async def _set_run(app, run_id, status) -> None:
from sqlalchemy import update
from steward.models.ansible import AnsibleRun
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(update(AnsibleRun).where(AnsibleRun.id == run_id)
.values(status=status))
def test_two_runs_on_one_host_take_turns(app, monkeypatch, tmp_path):
from sqlalchemy import select
from steward.ansible import executor
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
turns = tmp_path / "turns"
monkeypatch.setattr(executor, "build_ansible_command",
lambda *_a, **_k: [sys.executable, "-c", TURN, str(turns)])
source = f"overlap-{uuid.uuid4().hex[:8]}"
async def _go():
first, second = await _new_run(app, source), await _new_run(app, source)
await asyncio.wait_for(asyncio.gather(
executor.start_run(app, first, "t.yml", "", str(tmp_path),
inventory_content=INVENTORY),
executor.start_run(app, second, "t.yml", "", str(tmp_path),
inventory_content=INVENTORY),
), 60)
async with app.db_sessionmaker() as s:
return (await s.execute(select(AnsibleRun).where(
AnsibleRun.source_name == source))).scalars().all()
runs = asyncio.run(_go())
assert turns.read_text().split() == ["start", "end", "start", "end"]
assert {r.status for r in runs} == {AnsibleRunStatus.success}
assert any("waiting for run" in (r.output or "") for r in runs)
def test_a_schedule_skips_while_its_last_run_is_going(app, monkeypatch):
from steward.ansible import scheduler
from steward.models.ansible import AnsibleRunStatus
from steward.models.ansible_schedule import AnsibleSchedule
source = f"sched-{uuid.uuid4().hex[:8]}"
fired: list[str] = []
async def fake_trigger(_app, **kw):
fired.append(kw["playbook_path"])
return SimpleNamespace(id=str(uuid.uuid4())), None, None
monkeypatch.setattr(scheduler, "trigger_run", fake_trigger)
is_due = scheduler.is_due # leave other tests' schedules alone
monkeypatch.setattr(scheduler, "is_due",
lambda s, now: s.source_name == source and is_due(s, now))
long_ago = datetime.now(timezone.utc) - timedelta(hours=2)
async def _go():
running = await _new_run(app, source)
sid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(AnsibleSchedule(id=sid, name=source, source_name=source,
playbook_path="t.yml", interval_seconds=60,
enabled=True, last_run_at=long_ago,
last_run_id=running))
try:
await scheduler.run_due_schedules(app)
async with app.db_sessionmaker() as s:
skipped = await s.get(AnsibleSchedule, sid)
assert fired == []
assert skipped.last_error.startswith("Skipped")
assert skipped.last_run_id == running # still names the run going
assert skipped.last_run_at > long_ago # the cadence holds
await _set_run(app, running, AnsibleRunStatus.success)
async with app.db_sessionmaker() as s:
async with s.begin():
(await s.get(AnsibleSchedule, sid)).last_run_at = long_ago
await scheduler.run_due_schedules(app)
async with app.db_sessionmaker() as s:
fresh = await s.get(AnsibleSchedule, sid)
assert fired == ["t.yml"]
assert fresh.last_error is None and fresh.last_run_id != running
finally:
async with app.db_sessionmaker() as s:
async with s.begin():
await s.delete(await s.get(AnsibleSchedule, sid))
asyncio.run(_go())
def test_an_alert_action_skips_while_its_last_run_is_going(app, monkeypatch):
from steward.ansible import runner
from steward.core import alerts
from steward.models.alerts import AlertEvent, AlertOperator, AlertRule
from steward.models.ansible import AnsibleRunStatus
from steward.models.users import User, UserRole
tag = uuid.uuid4().hex[:8]
uid, rule_id = str(uuid.uuid4()), str(uuid.uuid4())
started: list[str] = []
action = {"source": "t", "playbook": "fix.yml", "inventory": "steward:all"}
def rule():
return AlertRule(id=rule_id, name=f"hot {tag}", source_module="host_agent",
resource_name=f"box-{tag}", metric_name="cpu_pct",
operator=AlertOperator.gt, threshold=90.0, enabled=True,
ansible_action=action, created_by=uid)
async def _event(run_id=None) -> str:
ev_id = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(AlertEvent(id=ev_id, rule_id=rule_id, from_state="inactive",
to_state="firing", metric_value=95.0,
ansible_run_id=run_id))
return ev_id
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"u{tag}", email=f"{tag}@e.test",
password_hash="x", role=UserRole.admin, is_active=True))
async with s.begin():
s.add(rule())
going = await _new_run(app, f"alert-{tag}")
await _event(going) # the earlier firing's run
nxt = await _new_run(app, f"alert-{tag}", AnsibleRunStatus.success)
async def fake_trigger(_app, **kw):
started.append(kw["playbook_path"])
return SimpleNamespace(id=nxt), None, None
monkeypatch.setattr(runner, "trigger_run", fake_trigger)
refire = await _event()
await alerts._run_ansible_action(app, rule(), 95.0, refire)
async with app.db_sessionmaker() as s:
skipped = await s.get(AlertEvent, refire)
assert started == []
assert f"skipped: run {going}" in skipped.note
assert skipped.ansible_run_id is None
await _set_run(app, going, AnsibleRunStatus.success)
later = await _event()
await alerts._run_ansible_action(app, rule(), 95.0, later)
async with app.db_sessionmaker() as s:
linked = await s.get(AlertEvent, later)
assert started == ["fix.yml"]
assert linked.ansible_run_id == nxt
asyncio.run(_go())