Files
FabledSteward/tests/core/test_ansible_executor.py
T
bvandeusenandClaude Opus 5.5 d2ade9c1c7
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Successful in 1m11s
CI / publish (push) Successful in 53s
fix(ansible): runs end cleanly: output read in chunks, the process group always ended, a cancelled task recorded (#5156)
- Output is read in 64 KiB chunks and split into lines here; a line past
  64 KiB is cut and marked instead of raising out of readline's limit.
- ansible-playbook runs in its own session; whatever ends the read (finish,
  an error, the task being cancelled) SIGTERMs then SIGKILLs its whole group,
  so no ssh or worker it forked outlives the run.
- A run whose task is cancelled (shutdown) records 'cancelled' with
  finished_at before re-raising, instead of staying 'running'.
- A cancel that lands after the process exited 0 no longer turns a
  successful run into 'cancelled'.

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

64 lines
2.1 KiB
Python

"""Unit tests for executor structured-results parsing + cancel flagging."""
from steward.ansible.executor import _cancelled, cancel_run, parse_recap
def test_parse_recap_extracts_per_host_counts():
lines = [
"PLAY RECAP *****************************************************************",
"web01 : ok=5 changed=2 unreachable=0 failed=0 skipped=1 rescued=0",
"db01 : ok=3 changed=0 unreachable=1 failed=0 skipped=0",
]
hosts = parse_recap(lines)
assert hosts["web01"] == {"ok": 5, "changed": 2, "unreachable": 0, "failed": 0, "skipped": 1}
assert hosts["db01"]["unreachable"] == 1
assert hosts["db01"]["ok"] == 3
def test_parse_recap_ignores_lines_before_recap():
lines = [
"TASK [whatever] ****",
"ok: [web01]",
"PLAY RECAP ****",
"web01 : ok=1 changed=0 unreachable=0 failed=0 skipped=0",
]
assert set(parse_recap(lines)) == {"web01"}
def test_parse_recap_empty_without_recap():
assert parse_recap(["TASK [x] ***", "ok: [h]"]) == {}
class _Stream:
def __init__(self, chunks):
self.chunks = list(chunks)
async def read(self, _n):
return self.chunks.pop(0) if self.chunks else b""
def _read_lines(chunks, monkeypatch, limit=8):
import asyncio
from steward.ansible import executor
monkeypatch.setattr(executor, "_MAX_LINE_BYTES", limit)
async def _go():
return [line async for line in executor._lines(_Stream(chunks))]
return asyncio.run(_go())
def test_lines_split_across_chunks(monkeypatch):
assert _read_lines([b"ab", b"c\nde", b"f\n", b"tail"], monkeypatch) == [b"abc", b"def", b"tail"]
def test_a_line_past_the_limit_is_cut_and_the_rest_dropped(monkeypatch):
# #5156: a long line can't stall or break the reader; the next line is intact.
got = _read_lines([b"0123456789", b"abcdef", b"gh\nnext\n"], monkeypatch)
assert got == [b"0123456789"[:8] + b" [line cut at 8 bytes]", b"next"]
def test_cancel_run_flags_untracked_run():
rid = "nonexistent-run-id"
assert cancel_run(rid) is True
assert rid in _cancelled
_cancelled.discard(rid)