"""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 None 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 None 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 "waiting for run a" in await asyncio.wait_for(b, 5) 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())