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>
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user