fix(ansible): overlapping runs: one per host, one per schedule, one action run per alert rule (#5158)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m27s
CI / integration (push) Failing after 1m1s
CI / publish (push) Skipped

- 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>
This commit is contained in:
2026-10-06 21:24:51 -04:00
co-authored by Claude Opus 5.5
parent edcfa4d3db
commit 3c24c633df
9 changed files with 514 additions and 25 deletions
+9 -1
View File
@@ -101,12 +101,20 @@ If a client connects after the run has already completed, the endpoint immediate
| Status | Description |
|---|---|
| `queued` | Waiting for a host another run holds, or for a run slot |
| `running` | Execution in progress |
| `success` | Playbook exited 0 |
| `failed` | Playbook exited non-zero |
| `interrupted` | App restarted while run was in progress |
| `cancelled` | Stopped by an operator, or by Steward shutting down mid-run |
On startup, the app marks any runs still in `running` state as `interrupted` (see `_mark_interrupted_runs()` in `app.py`).
On startup, the app marks any runs still `running` or `queued` as `interrupted` (see `_mark_interrupted_runs()` in `app.py`).
### Overlapping runs
- **One run per host.** Before it takes a run slot, a run claims every host it touches. Hosts come from the inventory Steward writes, keyed by address. A run on a source's own inventory file claims that file, because Steward doesn't read its hosts. While another run holds one of its hosts, the run shows `queued` and says which run it waits for. After an hour of waiting it fails and says so.
- **One run at a time per schedule.** A schedule that falls due while its previous run is still `queued` or `running` skips that firing. The Schedules page shows "Skipped: the previous run was still going" beside that run. The next firing comes an interval later.
- **One action run at a time per alert rule.** When a rule fires again while the action run from an earlier firing is still going, no new run starts. The firing's event says it was skipped and names the run.
Output stored in the DB is capped at 1 MB. If truncated, `[output truncated]` is appended to the DB column, but the live SSE stream continues unaffected.
+122 -8
View File
@@ -38,6 +38,111 @@ def artifact_path(run_id: str) -> str:
return os.path.join(ARTIFACT_DIR, f"{run_id}.log")
# ── One run per host (#5158) ──────────────────────────────────────────────────
# Two runs against the same host would race each other's changes, so a run
# claims every host it touches before it takes a run slot, and waits (queued)
# while another run holds one. The claim is all-or-nothing, so two runs over
# overlapping hosts can't each hold half and wait on the other.
HOST_WAIT_SECONDS = 3600.0 # a run waiting longer than this for a host fails
class HostBusy(Exception):
"""A run gave up waiting for a host another run held."""
def run_hosts(inventory_content: str | None, inventory_path: str | None) -> set[str]:
"""What a run touches, as claim keys: the address of each host in the YAML
inventory Steward wrote for it, or, for a source's own inventory file
(whose hosts Steward doesn't read), that file."""
if not inventory_content:
return {f"inventory:{inventory_path}"} if inventory_path else set()
import yaml
try:
data = yaml.safe_load(inventory_content)
except yaml.YAMLError:
return set() # ansible-playbook will refuse it and say why
# A host listed again under a group carries no vars there, so its address
# is whichever listing gives one.
addresses: dict[str, str | None] = {}
def walk(group) -> None:
if not isinstance(group, dict):
return
members = group.get("hosts")
if isinstance(members, dict):
for name, hostvars in members.items():
addr = hostvars.get("ansible_host") if isinstance(hostvars, dict) else None
addresses[str(name)] = addr or addresses.get(str(name))
children = group.get("children")
if isinstance(children, dict):
for child in children.values():
walk(child)
if isinstance(data, dict):
for group in data.values():
walk(group)
return {str(addr or name).strip().lower() for name, addr in addresses.items()}
def _host_gate(app: "Quart") -> dict:
"""The claims and the wake-up event, bound to the running loop like the
run-slot semaphore (one loop in prod, one per test)."""
loop = asyncio.get_running_loop()
gate = getattr(app, "_ansible_host_gate", None)
if gate is None or gate["loop"] is not loop:
gate = {"loop": loop, "held": {}, "changed": asyncio.Event()}
app._ansible_host_gate = gate
return gate
async def _claim_hosts(app: "Quart", run_id: str, hosts: set[str]) -> bool:
"""Hold every host in hosts for run_id, waiting while another run holds
any of them. Returns whether it had to wait. Raises _Aborted when the run
is cancelled while waiting, HostBusy after HOST_WAIT_SECONDS."""
from steward.models.ansible import AnsibleRunStatus
gate = _host_gate(app)
held: dict[str, str] = gate["held"]
give_up = time.monotonic() + HOST_WAIT_SECONDS
waited = False
while True:
busy = sorted({held[h] for h in hosts if h in held and held[h] != run_id})
if not busy:
for h in hosts:
held[h] = run_id
return waited
if run_id in _cancelled:
raise _Aborted
remaining = give_up - time.monotonic()
if remaining <= 0:
names = ", ".join(sorted(h for h in hosts if h in held))
raise HostBusy(f"gave up after {HOST_WAIT_SECONDS:.0f} s waiting for "
f"{names}, held by run {', '.join(busy)}")
if not waited:
waited = True
await _set_status(app, run_id, AnsibleRunStatus.queued)
_broadcast(run_id, f"[queued — waiting for run {', '.join(busy)} "
"to finish on the same host]")
changed = gate["changed"]
try:
# Woken by a release; the short timeout also notices a cancel.
await asyncio.wait_for(changed.wait(), timeout=min(remaining, 2.0))
except asyncio.TimeoutError:
pass
def _release_hosts(app: "Quart", run_id: str, hosts: set[str]) -> None:
gate = getattr(app, "_ansible_host_gate", None)
if gate is None:
return
for h in hosts:
if gate["held"].get(h) == run_id:
del gate["held"][h]
changed, gate["changed"] = gate["changed"], asyncio.Event()
changed.set()
@dataclass
class _Done:
status: str
@@ -392,8 +497,9 @@ async def start_run(
) -> None:
"""Execute ansible-playbook as a subprocess and update the DB run row.
Respects a global concurrency cap (app._ansible_semaphore): if no slot is
free the run shows as 'queued' until one frees. Supports cancellation, a
Waits (as 'queued') while another run holds one of its hosts (#5158), then
for a slot under the global concurrency cap (app._ansible_semaphore).
Supports cancellation, a
persistent full-log artifact, and a parsed per-host result summary.
connection is an optional per-run SSH override (user/password) for
@@ -442,17 +548,23 @@ async def start_run(
sem = asyncio.Semaphore(max(1, cap))
app._ansible_semaphore = sem
app._ansible_sem_loop = loop
queued = sem.locked()
if queued:
await _set_status(app, run_id, AnsibleRunStatus.queued)
_broadcast(run_id, "[queued — waiting for an Ansible run slot]")
await sem.acquire()
hosts = run_hosts(inventory_content, inventory_path)
final_status = "failed"
logf = None
proc = None
interrupted = False
slot_held = False
try:
# The hosts first, so a run waiting on a host doesn't sit on a run slot.
queued = await _claim_hosts(app, run_id, hosts)
if sem.locked():
if not queued:
await _set_status(app, run_id, AnsibleRunStatus.queued)
queued = True
_broadcast(run_id, "[queued — waiting for an Ansible run slot]")
await sem.acquire()
slot_held = True
if run_id in _cancelled: # cancelled while queued — abort before launching
raise _Aborted
if queued:
@@ -592,7 +704,9 @@ async def start_run(
logf.close()
_run_procs.pop(run_id, None)
_cancelled.discard(run_id)
sem.release()
if slot_held:
sem.release()
_release_hosts(app, run_id, hosts)
results = {"hosts": parse_recap(_run_lines.get(run_id, [])), "failures": failures}
try:
+13
View File
@@ -16,6 +16,19 @@ from steward.ansible.inventory_gen import (
)
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
# A run in one of these hasn't finished: a schedule or an alert action whose
# previous run is still in one skips its next firing (#5158).
IN_FLIGHT = (AnsibleRunStatus.queued, AnsibleRunStatus.running)
async def run_in_flight(db, run_id: str | None) -> bool:
"""Whether run_id names a run that hasn't finished. Runs left running by a
restart are marked interrupted at startup, so this can't stick."""
if not run_id:
return False
run = await db.get(AnsibleRun, run_id)
return run is not None and run.status in IN_FLIGHT
async def trigger_run(
app,
+18 -2
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from sqlalchemy import select
from steward.ansible.runner import trigger_run
from steward.ansible.runner import run_in_flight, trigger_run
from steward.models.ansible_schedule import AnsibleSchedule
logger = logging.getLogger(__name__)
@@ -27,7 +27,8 @@ def is_due(schedule: AnsibleSchedule, now: datetime) -> bool:
async def run_due_schedules(app) -> int:
"""Trigger every due schedule once. Returns the number launched."""
"""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(
@@ -38,6 +39,21 @@ async def run_due_schedules(app) -> int:
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:
+44 -7
View File
@@ -497,8 +497,47 @@ async def _run_ansible_action(
inventory must be ones the source lists (ansible/guards.py). The firing
event links to the run, or says why the action did not start.
"""
from steward.ansible.runner import trigger_run
from steward.ansible.runner import IN_FLIGHT, trigger_run
from steward.models.ansible import AnsibleRun
if rule.id in _actions_starting:
await _note_event(app, event_id, "Ansible action skipped: an earlier "
"firing's action was still starting")
return
async with app.db_sessionmaker() as db:
busy = (await db.execute(
select(AnsibleRun.id)
.join(AlertEvent, AlertEvent.ansible_run_id == AnsibleRun.id)
.where(AlertEvent.rule_id == rule.id, AnsibleRun.status.in_(IN_FLIGHT))
.limit(1))).scalar_one_or_none()
if busy:
# One action run at a time per rule (#5158): a re-firing while the
# last one is still going would run the same fix over it.
await _note_event(app, event_id, f"Ansible action skipped: run {busy} "
"from an earlier firing was still going")
return
_actions_starting.add(rule.id)
try:
await _start_ansible_action(app, rule, value, event_id, trigger_run)
finally:
_actions_starting.discard(rule.id)
# Rules whose action is between the in-flight check and its run being linked
# to the event, so two firings that close together can't both start one.
_actions_starting: set[str] = set()
async def _note_event(app: "Quart", event_id: str, why: str) -> None:
async with app.db_sessionmaker() as db:
async with db.begin():
ev = (await db.execute(
select(AlertEvent).where(AlertEvent.id == event_id))).scalar_one_or_none()
if ev is not None:
ev.note = f"{ev.note}; {why}" if ev.note else why
async def _start_ansible_action(app, rule, value, event_id, trigger_run) -> None:
action = rule.ansible_action or {}
playbook = action.get("playbook") or ""
inventory = action.get("inventory") or ""
@@ -529,17 +568,15 @@ async def _run_ansible_action(
if err:
logger.error("Ansible action for rule %r not started: %s", rule.name, err)
if run is None:
await _note_event(app, event_id, f"Ansible action not started: {err}")
return
async with app.db_sessionmaker() as db:
async with db.begin():
ev = (await db.execute(
select(AlertEvent).where(AlertEvent.id == event_id))).scalar_one_or_none()
if ev is None:
return
if run is not None:
if ev is not None:
ev.ansible_run_id = run.id
else:
why = f"Ansible action not started: {err}"
ev.note = f"{ev.note}; {why}" if ev.note else why
async def check_silence(app: "Quart") -> None:
+4 -2
View File
@@ -527,8 +527,10 @@ async def list_schedules() -> dict:
`check: true` schedules are dry runs that never change anything.
`last_error` is set when a firing could not start at all (a source or
playbook that went away); a run that started and failed shows in
`last_run_status`, and get_ansible_run(last_run_id) has its log.
playbook that went away), or was skipped because the schedule's previous
run was still going (then `last_run_id` still names that run). A run that
started and failed shows in `last_run_status`, and
get_ansible_run(last_run_id) has its log.
"""
async with db_session() as db:
scheds = await _schedules(db)
+10 -5
View File
@@ -38,13 +38,18 @@
<td style="font-size:0.85rem;" title="{{ s.inventory_scope }}">{{ row.scope_label }}</td>
<td style="font-size:0.85rem;">{{ fmt_interval(s.interval_seconds) }}</td>
<td style="font-size:0.82rem;">
{% if s.last_error %}
<span style="color:var(--red);" title="{{ s.last_error }}">launch error</span>
{% elif row.last_run %}
{% if row.last_run %}
{% set sc = {"queued":"var(--text-dim)","running":"var(--yellow)","success":"var(--green)","failed":"var(--red)","interrupted":"var(--orange)","cancelled":"var(--orange)"}.get(row.last_run.status.value, "var(--text-muted)") %}
<a href="/ansible/runs/{{ row.last_run.id }}" style="color:{{ sc }};font-weight:500;">{{ row.last_run.status.value }}</a>
<span style="color:var(--text-dim);">· {{ s.last_run_at.strftime("%m-%d %H:%M") }}</span>
{% else %}
<span style="color:var(--text-dim);">· {{ row.last_run.started_at.strftime("%m-%d %H:%M") }}</span>
{% endif %}
{% if s.last_error %}
{# With a run beside it, the last firing was skipped because that run was
still going (#5158); without one, the firing couldn't start a run. #}
<div style="color:{{ 'var(--text-muted)' if row.last_run else 'var(--red)' }};">
{{ s.last_error }} · {{ s.last_run_at.strftime("%m-%d %H:%M") }}
</div>
{% elif not row.last_run %}
<span style="color:var(--text-dim);">never</span>
{% endif %}
</td>
+93
View File
@@ -0,0 +1,93 @@
"""One run per host (#5158): what a run claims, and how a second run waits."""
import asyncio
from types import SimpleNamespace
import pytest
from steward.ansible import executor
from steward.ansible.inventory_gen import generate_inventory, inventory_to_yaml
def _target(name, address, groups=()):
return SimpleNamespace(name=name, address=address, ansible_vars={},
groups=[SimpleNamespace(name=g, ansible_vars={}) for g in groups])
def test_a_run_claims_the_addresses_in_the_inventory_steward_wrote():
inv = inventory_to_yaml(generate_inventory(
[_target("web", "10.0.0.5", ["linux"]), _target("db", "DB.lan", ["linux"])]))
# A host listed again under its group (no vars there) is still its address.
assert executor.run_hosts(inv, None) == {"10.0.0.5", "db.lan"}
def test_a_host_without_an_address_is_claimed_by_name():
assert executor.run_hosts("all:\n hosts:\n box1:\n token: x\n", None) == {"box1"}
def test_a_run_on_a_source_inventory_claims_the_file():
assert executor.run_hosts(None, "inventories/prod.ini") == {"inventory:inventories/prod.ini"}
assert executor.run_hosts(None, "") == set()
@pytest.fixture
def gate(monkeypatch):
statuses: list[tuple[str, str]] = []
async def fake_set_status(_app, run_id, status):
statuses.append((run_id, status.value))
monkeypatch.setattr(executor, "_set_status", fake_set_status)
yield SimpleNamespace(app=SimpleNamespace(), statuses=statuses)
for rid in ("a", "b", "c"):
executor._run_lines.pop(rid, None)
executor._cancelled.discard(rid)
def test_a_second_run_on_the_same_host_waits_for_the_first(gate):
app = gate.app
async def go():
assert await executor._claim_hosts(app, "a", {"web"}) is False
b = asyncio.create_task(executor._claim_hosts(app, "b", {"web", "db"}))
await asyncio.sleep(0.05)
assert not b.done()
held = app._ansible_host_gate["held"]
assert "db" not in held # all or nothing: b holds no half
assert await executor._claim_hosts(app, "c", {"db"}) is False
executor._release_hosts(app, "c", {"db"})
await asyncio.sleep(0.05)
assert not b.done() # web is still a's
executor._release_hosts(app, "a", {"web"})
assert await asyncio.wait_for(b, 5) is True
assert held == {"web": "b", "db": "b"}
asyncio.run(go())
assert ("b", "queued") in gate.statuses
assert any("waiting for run a" in line for line in executor._run_lines["b"])
def test_a_run_cancelled_while_waiting_for_a_host_never_claims_it(gate):
app = gate.app
async def go():
await executor._claim_hosts(app, "a", {"web"})
b = asyncio.create_task(executor._claim_hosts(app, "b", {"web"}))
await asyncio.sleep(0.05)
executor._cancelled.add("b")
with pytest.raises(executor._Aborted):
await asyncio.wait_for(b, 5)
assert app._ansible_host_gate["held"] == {"web": "a"}
asyncio.run(go())
def test_a_run_gives_up_waiting_for_a_host_and_says_which(gate, monkeypatch):
monkeypatch.setattr(executor, "HOST_WAIT_SECONDS", 0.2)
app = gate.app
async def go():
await executor._claim_hosts(app, "a", {"web"})
with pytest.raises(executor.HostBusy, match="waiting for web, held by run a"):
await asyncio.wait_for(executor._claim_hosts(app, "b", {"web"}), 5)
asyncio.run(go())
+201
View File
@@ -0,0 +1,201 @@
"""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())