"""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