fix(host_agent): disks behind a RAID controller each keep their own name (#5806)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 1m6s

smartctl reports every disk behind a PERC/MegaRAID controller as /dev/bus/0
with type megaraid,N; the helper named a drive by its path alone, so all of
VDNT-Docker02's and Docker03's disks read as one drive "0", each overwriting
the last. The slot is now part of the name (bus0-megaraid3), and Steward gives
a repeated name an ordinal so two drives never share a series. Agent 1.20.1.

fix(ansible): Steward stopping mid-run logs no traceback (#5749)

The run's done-callback called exception() on a cancelled task, which raises;
it now logs a run cut short by shutdown at INFO. A finished run's in-memory
state is dropped by a timer rather than a sleeping task, which shutdown found
still pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 09:41:20 -04:00
co-authored by Claude Opus 5.5
parent 6c4ef368f9
commit e2055242f1
8 changed files with 138 additions and 19 deletions
+2 -2
View File
@@ -266,7 +266,7 @@ The figures are recorded at most once every 30 s, whichever manager sent them, s
A maintenance job on a swarm node opens a second window over `docker` / `swarm` beside its host's (`maintenance._swarm_window`). An alert the drain or reboot causes then waits for the job, and one still firing when the window ends is sent then.
### Drive health (1.20.0, #5789)
### Drive health (1.20.0, #5789; 1.20.1, #5806)
Reading SMART needs root, so the root helper does it, as the one action on its fixed list that only reads: `drives`. Hourly, and at start, the agent leaves a `drives` request. The helper runs `smartctl --scan-open` to find the drives itself (the request names none), reads each with `smartctl --json -a -d <type> <dev>`, and writes a summary to `/var/lib/steward-maint/drives.json` (0644). It writes no job result, because a drive read is not a maintenance job. The agent's newest copy rides every sample, as updates do.
@@ -277,7 +277,7 @@ smartctl ≥ 7.0 answers in JSON, the same on every distribution and for SATA, S
- reallocated / pending / offline-uncorrectable sectors for ATA, and life used where the drive reports it;
- grown defects for SCSI.
A drive with no SMART (a virtual disk, a bridge that hides it) reads `available: false`, and one smartctl couldn't open carries `error`.
A drive with no SMART (a virtual disk, a bridge that hides it) reads `available: false`, and one smartctl couldn't open carries `error`. Disks behind a RAID controller share one path (`/dev/bus/0`) and differ by the slot in their type (`megaraid,3`), so the slot is part of the name: `bus0-megaraid3` (1.20.1, #5806). Steward also gives a repeated name an ordinal (`0#2`), so two drives never share a series.
Steward judges the figures in `drives.py`, so the judgment can change without an agent update:
+1 -1
View File
@@ -22,7 +22,7 @@ from collections import deque
from concurrent.futures import ThreadPoolExecutor, wait as wait_futures
from datetime import datetime, timezone
AGENT_VERSION = "1.20.0"
AGENT_VERSION = "1.20.1"
# Default path to the local Docker Engine socket. Overridable via the
# `docker_socket` config key; collection is silently skipped if it's absent or
+13 -2
View File
@@ -69,9 +69,20 @@ def judge(d: dict) -> dict:
def drives_of(report) -> list[dict]:
"""The report's drives, judged, what needs attention first."""
"""The report's drives, judged, what needs attention first. A name seen
twice gets an ordinal, so two drives never share one series: agent
1.20.0 named every disk behind a RAID controller "0" (#5806)."""
drives = report.get("drives") if isinstance(report, dict) else None
judged = [judge(d) for d in drives or [] if isinstance(d, dict) and d.get("device")]
judged, seen = [], {}
for d in drives or []:
if not isinstance(d, dict) or not d.get("device"):
continue
d = judge(d)
name = str(d["device"])
seen[name] = seen.get(name, 0) + 1
if seen[name] > 1:
d["device"] = f"{name}#{seen[name]}"
judged.append(d)
return sorted(judged, key=lambda d: (_ORDER[d["state"]], str(d["device"])))
+15 -3
View File
@@ -199,9 +199,21 @@ def _int(v):
return v if isinstance(v, int) and not isinstance(v, bool) else None
def drive_summary(name: str, status, data) -> dict:
def drive_name(name: str, kind) -> str:
"""A drive's name, unique on its host. Disks behind a RAID controller
share one path (/dev/bus/0) and differ only by the slot in their type
(megaraid,3; cciss,0), so the slot is part of the name (#5806)."""
base = os.path.basename(name)
if name.startswith("/dev/bus/"):
base = "bus" + base
if isinstance(kind, str) and "," in kind:
base += "-" + re.sub(r"[^A-Za-z0-9]", "", kind.split("+")[-1])
return base
def drive_summary(name: str, status, data, kind=None) -> dict:
"""The few figures Steward reads from one drive's `smartctl -a` JSON."""
out = {"device": os.path.basename(name)}
out = {"device": drive_name(name, kind)}
if not isinstance(data, dict) or status is None or status & _SMARTCTL_UNREAD:
messages = ((data.get("smartctl") or {}).get("messages") or []) if isinstance(data, dict) else []
text = "; ".join(str(m.get("string", "")) for m in messages if isinstance(m, dict))
@@ -267,7 +279,7 @@ def do_drives() -> dict:
continue
args = ["-a", name] if not isinstance(kind, str) else ["-a", "-d", kind, name]
status, data = smartctl_json(args)
drives.append(drive_summary(name, status, data))
drives.append(drive_summary(name, status, data, kind))
return {**base, "drives": drives}
+9 -6
View File
@@ -182,14 +182,17 @@ def deregister_listener(run_id: str, q: asyncio.Queue) -> None:
listeners.remove(q)
def _drop_run_state(run_id: str) -> None:
_run_lines.pop(run_id, None)
_run_done.pop(run_id, None)
def _schedule_cleanup(run_id: str) -> None:
"""Drop a finished run's in-memory state after a grace window."""
async def _cleanup() -> None:
await asyncio.sleep(_MEM_TTL_SECS)
_run_lines.pop(run_id, None)
_run_done.pop(run_id, None)
"""Drop a finished run's in-memory state after a grace window. A timer,
not a task (#5749): a run cut short by shutdown schedules this as the
loop closes, and a sleeping task would be destroyed while pending."""
try:
asyncio.create_task(_cleanup())
asyncio.get_running_loop().call_later(_MEM_TTL_SECS, _drop_run_state, run_id)
except RuntimeError:
pass # no running loop (e.g. tests) — fine to skip
+14 -5
View File
@@ -109,9 +109,18 @@ async def trigger_run(
connection=connection, secret_vars=secret_vars, redact=redact,
)
)
task.add_done_callback(
lambda t: t.exception() and app.logger.error(
"Ansible run %s raised: %s", run_id, t.exception()
)
)
task.add_done_callback(run_ended(app, run_id))
return run, source, None
def run_ended(app, run_id: str):
"""A run task's done-callback: an error is logged as one. A run cut short
by Steward stopping is not an error (#5749): `exception()` would raise
CancelledError on it, and the next start marks the run interrupted."""
def callback(t: asyncio.Task) -> None:
if t.cancelled():
app.logger.info("Ansible run %s interrupted: Steward is stopping; "
"it is marked interrupted at the next start", run_id)
elif t.exception() is not None:
app.logger.error("Ansible run %s raised: %s", run_id, t.exception())
return callback
@@ -80,6 +80,38 @@ def test_a_drive_with_no_smart_and_one_that_can_t_be_opened_say_so(maint):
assert maint.drive_summary("/dev/sdd", None, None)["error"] == "smartctl couldn't read it"
@pytest.mark.parametrize("name, kind, device", [
("/dev/sda", "sat", "sda"),
("/dev/nvme0", "nvme", "nvme0"),
("/dev/bus/0", "megaraid,3", "bus0-megaraid3"), # VDNT-Docker02's PERC (#5806)
("/dev/bus/0", "sat+megaraid,12", "bus0-megaraid12"),
("/dev/sg1", "cciss,0", "sg1-cciss0"),
("/dev/sdc", None, "sdc"),
])
def test_each_drive_has_a_name_of_its_own(maint, name, kind, device):
assert maint.drive_name(name, kind) == device
def test_disks_behind_one_controller_stay_apart(maint, monkeypatch):
def fake(args):
if args == ["--scan-open"]:
return 0, {"devices": [{"name": "/dev/bus/0", "type": "megaraid,0"},
{"name": "/dev/bus/0", "type": "megaraid,1"}]}
return 0, ATA
monkeypatch.setattr(maint, "smartctl_version", lambda: (7, 4))
monkeypatch.setattr(maint, "smartctl_json", fake)
assert [d["device"] for d in maint.do_drives()["drives"]] == ["bus0-megaraid0", "bus0-megaraid1"]
def test_a_name_seen_twice_is_still_two_drives():
"""What agent 1.20.0 sent from a PERC: every disk named "0"."""
report = {"drives": [{"device": "0", "passed": True}, {"device": "0", "passed": False},
{"device": "0", "passed": True}]}
assert sorted(d["device"] for d in dh.drives_of(report)) == ["0", "0#2", "0#3"]
rows = {(m, r) for m, r, _ in dh.metric_rows(report, "box") if m == "drive_failing"}
assert len(rows) == 3
def test_a_failing_disk_s_figures_still_count(maint):
"""Exit bit 3 says the disk is failing; its JSON is the point, not an error."""
failing = {**ATA, "smart_status": {"passed": False}}
+52
View File
@@ -0,0 +1,52 @@
"""#5749: a redeploy during an Ansible run logged two CancelledError tracebacks
from the run's done-callback and two "Task was destroyed but it is pending!"
lines for its in-memory cleanup. Steward stopping mid-run is not an error: the
next start marks the run interrupted."""
import asyncio
import logging
from steward.ansible import executor
from steward.ansible.runner import run_ended
class _App:
logger = logging.getLogger("test.ansible.run_end")
async def test_a_run_cut_short_by_shutdown_logs_no_error(caplog):
async def run():
await asyncio.sleep(60)
task = asyncio.create_task(run())
task.add_done_callback(run_ended(_App, "r1"))
await asyncio.sleep(0)
task.cancel()
with caplog.at_level(logging.INFO, logger="test.ansible.run_end"):
await asyncio.gather(task, return_exceptions=True)
await asyncio.sleep(0) # done-callbacks run on the next turn
assert [r.levelname for r in caplog.records] == ["INFO"]
assert "Ansible run r1 interrupted" in caplog.records[0].getMessage()
async def test_a_run_that_raised_is_still_an_error(caplog):
async def run():
raise RuntimeError("ansible-playbook vanished")
task = asyncio.create_task(run())
task.add_done_callback(run_ended(_App, "r2"))
with caplog.at_level(logging.INFO, logger="test.ansible.run_end"):
await asyncio.gather(task, return_exceptions=True)
await asyncio.sleep(0)
assert [(r.levelname, r.getMessage()) for r in caplog.records] == [
("ERROR", "Ansible run r2 raised: ansible-playbook vanished")]
async def test_a_finished_run_s_state_is_dropped_by_a_timer_not_a_task(monkeypatch):
"""A task left sleeping is what shutdown found pending; a timer isn't one."""
monkeypatch.setattr(executor, "_MEM_TTL_SECS", 0.01)
executor._run_lines["r3"] = ["ok"]
before = asyncio.all_tasks()
executor._schedule_cleanup("r3")
assert asyncio.all_tasks() == before
await asyncio.sleep(0.05)
assert "r3" not in executor._run_lines