Rearchitect so a single poller process is the sole owner of all SAS/SMART/ SES hardware I/O, serialized behind one gate and paced, writing results to Redis. The API/web and MQTT publisher become pure Redis readers — they no longer issue any subprocess and can restart freely without touching the bus. This addresses backplane/expander stress from concurrent + restart-triggered SMART/SES storms (the prior model re-ran a hardware sweep on every container start, and polled sg_ses 0x02+0x07 every 60s; 0x07 errored on the IOM6/ Xyratex expanders). - poller.py: paced sweep (inventory, SMART per-drive w/ gap, SES 0x02, ZFS, host/MegaRAID), startup jitter, single-instance Redis lock, LED-queue worker - services/hwgate.py: global serialization semaphore (POLL_CONCURRENCY=1) - services/store.py: Redis as the only producer<->consumer interface + LED queue - services/health.py: shared drive-health classifier (fixes overview double-count) - gate smartctl/sg_ses/zpool/ledctl; drop sg_ses 0x07 from the hot path - routers + temps read-only from the store; main.py drops the in-process poller - compose: 3 services (privileged poller + unprivileged app + redis) w/ pacing knobs
187 lines
6.3 KiB
Python
187 lines
6.3 KiB
Python
"""Dedicated hardware poller — the sole owner of SAS/SMART/SES I/O.
|
|
|
|
Runs as its own (privileged, pid:host) container. Everything that touches
|
|
the bus happens here, serialized behind the hardware gate and paced, then
|
|
written to Redis. The API and MQTT services are pure Redis readers and
|
|
never issue a hardware command — so they can crash-loop freely without
|
|
ever storming the backplanes.
|
|
|
|
Design points that matter for fragile SAS expanders:
|
|
- one global semaphore (POLL_CONCURRENCY=1) serializes all hardware I/O
|
|
- a gap (POLL_DRIVE_GAP) between each drive keeps the bus mostly quiet
|
|
- startup jitter avoids a thundering herd on (re)start
|
|
- a single-instance Redis lock means two pollers can't both poll
|
|
- last-good results stay in Redis; a failed sweep never blanks them
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import random
|
|
import socket
|
|
import time
|
|
|
|
from services import store
|
|
from services.cache import close_cache, init_cache, redis_available
|
|
from services.enclosure import discover_enclosures, get_enclosure_status, list_slots
|
|
from services.host import get_host_drives
|
|
from services.leds import run_led
|
|
from services.smart import _run_smartctl, sg_ses_available, smartctl_available
|
|
from services.zfs import get_zfs_pool_map
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger("poller")
|
|
|
|
SWEEP_INTERVAL = int(os.environ.get("POLL_SWEEP_INTERVAL", "300"))
|
|
DRIVE_GAP = float(os.environ.get("POLL_DRIVE_GAP", "0.5"))
|
|
STARTUP_JITTER = float(os.environ.get("POLL_STARTUP_JITTER", "10"))
|
|
LOCK_TTL = 30
|
|
|
|
TOKEN = f"{socket.gethostname()}-{os.getpid()}"
|
|
|
|
|
|
async def sweep() -> None:
|
|
"""One full, paced pass over all hardware → Redis."""
|
|
t0 = time.monotonic()
|
|
errors: list[str] = []
|
|
|
|
# 1. Discover enclosures + slots (sysfs, no bus I/O).
|
|
enclosures = discover_enclosures()
|
|
inv_enclosures = []
|
|
drive_devices: list[str] = []
|
|
for enc in enclosures:
|
|
slots = list_slots(enc["id"])
|
|
inv_enclosures.append({**enc, "slots": slots})
|
|
for s in slots:
|
|
if s["device"]:
|
|
drive_devices.append(s["device"])
|
|
|
|
# 2. ZFS topology (gated).
|
|
pool_map = await store.get_zfs_map()
|
|
try:
|
|
pool_map = await get_zfs_pool_map()
|
|
await store.set_zfs_map(pool_map)
|
|
except Exception as e:
|
|
errors.append(f"zfs:{e}")
|
|
|
|
# 3. Per-drive SMART, one at a time with a gap between each.
|
|
for dev in drive_devices:
|
|
try:
|
|
data = await _run_smartctl(dev)
|
|
await store.set_smart(dev, data)
|
|
except Exception as e:
|
|
errors.append(f"smart:{dev}:{e}")
|
|
await asyncio.sleep(DRIVE_GAP)
|
|
|
|
# 4. Per-enclosure SES status (gated), paced.
|
|
for enc in enclosures:
|
|
if not enc.get("sg_device"):
|
|
continue
|
|
try:
|
|
ses = await get_enclosure_status(enc["sg_device"])
|
|
if ses is not None:
|
|
await store.set_ses(enc["id"], ses)
|
|
except Exception as e:
|
|
errors.append(f"ses:{enc['id']}:{e}")
|
|
await asyncio.sleep(DRIVE_GAP)
|
|
|
|
# 5. Host (non-enclosure) drives + MegaRAID (gated, reuses pool_map).
|
|
try:
|
|
host_drives = await get_host_drives(pool_map)
|
|
await store.set_host_drives(host_drives)
|
|
except Exception as e:
|
|
errors.append(f"host:{e}")
|
|
|
|
# 6. Inventory + heartbeat.
|
|
await store.set_inventory({"enclosures": inv_enclosures, "ts": store.now()})
|
|
duration = round(time.monotonic() - t0, 1)
|
|
await store.set_meta({
|
|
"last_sweep_ts": store.now(),
|
|
"last_sweep_duration": duration,
|
|
"drive_count": len(drive_devices),
|
|
"enclosure_count": len(enclosures),
|
|
"errors": errors[:20],
|
|
"sweep_interval": SWEEP_INTERVAL,
|
|
})
|
|
logger.info(
|
|
"sweep done: %d drives, %d enclosures, %.1fs, %d error(s)",
|
|
len(drive_devices), len(enclosures), duration, len(errors),
|
|
)
|
|
|
|
|
|
async def led_consumer() -> None:
|
|
"""Drain locate-LED requests from Redis and run them (gated)."""
|
|
while True:
|
|
try:
|
|
cmd = await store.pop_led(timeout=5)
|
|
if cmd:
|
|
try:
|
|
await run_led(cmd["device"], cmd["state"])
|
|
logger.info("LED %s -> %s", cmd.get("device"), cmd.get("state"))
|
|
except Exception as e:
|
|
logger.warning("LED command failed %s: %s", cmd, e)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
logger.warning("LED consumer error: %s", e)
|
|
await asyncio.sleep(2)
|
|
|
|
|
|
async def lock_refresher() -> None:
|
|
while True:
|
|
await asyncio.sleep(LOCK_TTL / 3)
|
|
if not await store.refresh_lock(TOKEN, LOCK_TTL):
|
|
logger.error("lost poller lock — exiting to avoid double-polling")
|
|
os._exit(1)
|
|
|
|
|
|
async def main() -> None:
|
|
await init_cache()
|
|
while not redis_available():
|
|
logger.warning("Redis unavailable — poller needs Redis; retrying in 5s")
|
|
await asyncio.sleep(5)
|
|
await init_cache()
|
|
|
|
# Single-instance guard.
|
|
while not await store.acquire_lock(TOKEN, LOCK_TTL):
|
|
logger.warning("another poller holds the lock; waiting %ds", LOCK_TTL // 2)
|
|
await asyncio.sleep(LOCK_TTL // 2)
|
|
logger.info("poller lock acquired (%s)", TOKEN)
|
|
|
|
if not smartctl_available():
|
|
logger.warning("smartctl not found — install smartmontools")
|
|
if not sg_ses_available():
|
|
logger.warning("sg_ses not found — install sg3-utils")
|
|
if os.geteuid() != 0:
|
|
logger.warning("not running as root — smartctl/sg_ses may fail")
|
|
|
|
# Stagger startup so a restart doesn't immediately storm the bus.
|
|
jitter = random.uniform(0, STARTUP_JITTER)
|
|
logger.info("startup jitter %.1fs", jitter)
|
|
await asyncio.sleep(jitter)
|
|
|
|
refresher = asyncio.create_task(lock_refresher())
|
|
leds = asyncio.create_task(led_consumer())
|
|
try:
|
|
while True:
|
|
t0 = time.monotonic()
|
|
try:
|
|
await sweep()
|
|
except Exception as e:
|
|
logger.error("sweep error: %s", e)
|
|
elapsed = time.monotonic() - t0
|
|
await asyncio.sleep(max(5, SWEEP_INTERVAL - elapsed))
|
|
finally:
|
|
refresher.cancel()
|
|
leds.cancel()
|
|
await close_cache()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
pass
|