Split into dedicated hardware poller + read-only consumers
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
This commit is contained in:
@@ -38,6 +38,11 @@ def redis_available() -> bool:
|
||||
return _redis is not None
|
||||
|
||||
|
||||
def get_client() -> "redis.Redis | None":
|
||||
"""Return the raw Redis client for primitives not covered by get/set."""
|
||||
return _redis
|
||||
|
||||
|
||||
async def cache_get(key: str) -> Any | None:
|
||||
"""GET key from Redis, return deserialized value or None on miss/error."""
|
||||
if _redis is None:
|
||||
|
||||
@@ -4,6 +4,8 @@ import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from services.hwgate import gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENCLOSURE_BASE = Path("/sys/class/enclosure")
|
||||
@@ -153,14 +155,16 @@ def _parse_slot_number(entry: Path) -> int | None:
|
||||
|
||||
|
||||
async def _run_sg_ses(sg_device: str, page: str) -> str | None:
|
||||
"""Run sg_ses for a given page, returning decoded stdout or None."""
|
||||
"""Run sg_ses for a given page, returning decoded stdout or None.
|
||||
Hardware-gated."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sg_ses", f"--page={page}", sg_device,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
async with gate():
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sg_ses", f"--page={page}", sg_device,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"sg_ses page %s failed for %s: %s",
|
||||
@@ -177,22 +181,18 @@ async def _run_sg_ses(sg_device: str, page: str) -> str | None:
|
||||
|
||||
|
||||
async def get_enclosure_status(sg_device: str) -> dict | None:
|
||||
"""Run sg_ses status (0x02) + element descriptor (0x07) pages and parse.
|
||||
"""Run sg_ses status page (0x02) and parse enclosure health data.
|
||||
|
||||
The element descriptor page provides human-readable names for each
|
||||
element (e.g. "Temp Inlet", "Fan 1"); these are merged into the status
|
||||
elements so callers get labelled sensors. Descriptor lookup is
|
||||
best-effort — if page 0x07 is unsupported, elements fall back to bare
|
||||
indices.
|
||||
Deliberately only the status page: the element descriptor page (0x07)
|
||||
is omitted because it errored on the IOM6/Xyratex expanders here
|
||||
(``couldn't read config page, res=35``) and returned empty names
|
||||
anyway. Elements fall back to generic labels. If descriptor names are
|
||||
ever wanted, fetch 0x07 ONCE at startup and cache — never per poll.
|
||||
"""
|
||||
status_text, desc_text = await asyncio.gather(
|
||||
_run_sg_ses(sg_device, "0x02"),
|
||||
_run_sg_ses(sg_device, "0x07"),
|
||||
)
|
||||
status_text = await _run_sg_ses(sg_device, "0x02")
|
||||
if status_text is None:
|
||||
return None
|
||||
names = _parse_element_descriptors(desc_text) if desc_text else {}
|
||||
return _parse_ses_page02(status_text, names)
|
||||
return _parse_ses_page02(status_text)
|
||||
|
||||
|
||||
def _norm_type(element_type: str) -> str:
|
||||
|
||||
21
services/health.py
Normal file
21
services/health.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Shared drive-health classification.
|
||||
|
||||
Single source of truth for turning a parsed SMART dict into a
|
||||
healthy/warning/error status, used by both the poller (host drives) and
|
||||
the API (enclosure drives) so the rule never drifts between them.
|
||||
"""
|
||||
|
||||
|
||||
def compute_health_status(smart: dict) -> str:
|
||||
"""Return "healthy", "warning", or "error" for a SMART dict."""
|
||||
healthy = smart.get("smart_healthy")
|
||||
realloc = smart.get("reallocated_sectors") or 0
|
||||
pending = smart.get("pending_sectors") or 0
|
||||
unc = smart.get("uncorrectable_errors") or 0
|
||||
supported = smart.get("smart_supported", True)
|
||||
|
||||
if healthy is False:
|
||||
return "error"
|
||||
if realloc > 0 or pending > 0 or unc > 0 or (healthy is None and supported):
|
||||
return "warning"
|
||||
return "healthy"
|
||||
@@ -3,22 +3,28 @@ import json
|
||||
import logging
|
||||
|
||||
from services.enclosure import discover_enclosures, list_slots
|
||||
from services.smart import get_smart_data, scan_megaraid_drives
|
||||
from services.zfs import get_zfs_pool_map
|
||||
from services.health import compute_health_status
|
||||
from services.hwgate import gate
|
||||
from services.smart import _run_smartctl, scan_megaraid_drives
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_host_drives() -> list[dict]:
|
||||
"""Discover non-enclosure block devices and return SMART data for each."""
|
||||
async def get_host_drives(pool_map: dict) -> list[dict]:
|
||||
"""Discover non-enclosure block devices and return SMART data for each.
|
||||
|
||||
Poller-side producer: serialized through the hardware gate. ``pool_map``
|
||||
is passed in (computed once per sweep) to avoid a second zpool call.
|
||||
"""
|
||||
# Get all block devices via lsblk
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"lsblk", "-d", "-o", "NAME,SIZE,TYPE,MODEL,ROTA,TRAN", "-J",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
async with gate():
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"lsblk", "-d", "-o", "NAME,SIZE,TYPE,MODEL,ROTA,TRAN", "-J",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
lsblk_data = json.loads(stdout)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
logger.warning("lsblk failed: %s", e)
|
||||
@@ -59,10 +65,11 @@ async def get_host_drives() -> list[dict]:
|
||||
|
||||
host_devices.append({"name": name, "drive_type": drive_type})
|
||||
|
||||
# Fetch SMART + ZFS data concurrently
|
||||
pool_map = await get_zfs_pool_map()
|
||||
smart_tasks = [get_smart_data(d["name"]) for d in host_devices]
|
||||
smart_results = await asyncio.gather(*smart_tasks, return_exceptions=True)
|
||||
# Fetch SMART for each host disk (serialized by the gate inside _run_smartctl)
|
||||
smart_results = await asyncio.gather(
|
||||
*[_run_smartctl(d["name"]) for d in host_devices],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
results: list[dict] = []
|
||||
for dev_info, smart_result in zip(host_devices, smart_results):
|
||||
@@ -72,21 +79,9 @@ async def get_host_drives() -> list[dict]:
|
||||
logger.warning("SMART query failed for host drive %s: %s", name, smart_result)
|
||||
smart = {"device": name, "smart_supported": False}
|
||||
else:
|
||||
smart, _ = smart_result
|
||||
|
||||
# Compute health_status (same logic as overview.py)
|
||||
healthy = smart.get("smart_healthy")
|
||||
realloc = smart.get("reallocated_sectors") or 0
|
||||
pending = smart.get("pending_sectors") or 0
|
||||
unc = smart.get("uncorrectable_errors") or 0
|
||||
|
||||
if healthy is False:
|
||||
health_status = "error"
|
||||
elif realloc > 0 or pending > 0 or unc > 0 or (healthy is None and smart.get("smart_supported", True)):
|
||||
health_status = "warning"
|
||||
else:
|
||||
health_status = "healthy"
|
||||
smart = smart_result
|
||||
|
||||
health_status = compute_health_status(smart)
|
||||
zfs_info = pool_map.get(name, {})
|
||||
|
||||
results.append({
|
||||
@@ -97,7 +92,7 @@ async def get_host_drives() -> list[dict]:
|
||||
"wwn": smart.get("wwn"),
|
||||
"firmware": smart.get("firmware"),
|
||||
"capacity_bytes": smart.get("capacity_bytes"),
|
||||
"smart_healthy": healthy,
|
||||
"smart_healthy": smart.get("smart_healthy"),
|
||||
"smart_supported": smart.get("smart_supported", True),
|
||||
"temperature_c": smart.get("temperature_c"),
|
||||
"power_on_hours": smart.get("power_on_hours"),
|
||||
@@ -116,16 +111,7 @@ async def get_host_drives() -> list[dict]:
|
||||
if has_raid:
|
||||
megaraid_drives = await scan_megaraid_drives()
|
||||
for pd in megaraid_drives:
|
||||
pd_healthy = pd.get("smart_healthy")
|
||||
pd_realloc = pd.get("reallocated_sectors") or 0
|
||||
pd_pending = pd.get("pending_sectors") or 0
|
||||
pd_unc = pd.get("uncorrectable_errors") or 0
|
||||
if pd_healthy is False:
|
||||
pd["health_status"] = "error"
|
||||
elif pd_realloc > 0 or pd_pending > 0 or pd_unc > 0:
|
||||
pd["health_status"] = "warning"
|
||||
else:
|
||||
pd["health_status"] = "healthy"
|
||||
pd["health_status"] = compute_health_status(pd)
|
||||
pd["drive_type"] = "physical"
|
||||
pd["physical_drives"] = []
|
||||
|
||||
|
||||
27
services/hwgate.py
Normal file
27
services/hwgate.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Global hardware-access gate.
|
||||
|
||||
Every command that touches the SAS bus — smartctl, sg_ses, zpool, ledctl —
|
||||
must run inside this gate. It serializes hardware I/O so the poller can
|
||||
never fire a thundering herd of subprocesses at fragile SAS expanders
|
||||
(which on some shelves drop out and suspend pools under concurrent load).
|
||||
|
||||
Concurrency defaults to 1 (fully serialized). It can be raised via
|
||||
POLL_CONCURRENCY for healthier backplanes, but 1 is the safe default.
|
||||
|
||||
The gate is a per-process asyncio.Semaphore — only the poller process
|
||||
calls hardware, so a per-process gate is sufficient. The semaphore is
|
||||
created lazily on first use so it binds to the running event loop.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
_sem: asyncio.Semaphore | None = None
|
||||
|
||||
|
||||
def gate() -> asyncio.Semaphore:
|
||||
"""Return the shared hardware semaphore (use as `async with gate():`)."""
|
||||
global _sem
|
||||
if _sem is None:
|
||||
n = max(1, int(os.environ.get("POLL_CONCURRENCY", "1")))
|
||||
_sem = asyncio.Semaphore(n)
|
||||
return _sem
|
||||
@@ -1,9 +1,12 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from services.hwgate import gate
|
||||
|
||||
async def set_led(device: str, state: str) -> dict:
|
||||
"""Set the locate LED on a drive via ledctl.
|
||||
|
||||
async def run_led(device: str, state: str) -> dict:
|
||||
"""Set the locate LED on a drive via ledctl. Hardware-gated; runs in the
|
||||
poller, fed by the Redis LED queue. The API enqueues, never calls this.
|
||||
|
||||
Args:
|
||||
device: Block device name (e.g. "sda"). Must be alphanumeric.
|
||||
@@ -14,13 +17,14 @@ async def set_led(device: str, state: str) -> dict:
|
||||
if state not in ("locate", "off"):
|
||||
raise ValueError(f"Invalid state: {state}")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ledctl",
|
||||
f"{state}=/dev/{device}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
async with gate():
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ledctl",
|
||||
f"{state}=/dev/{device}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
err = stderr.decode().strip() or stdout.decode().strip()
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from services.cache import cache_get, cache_set
|
||||
from services.hwgate import gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,35 +29,18 @@ def sg_ses_available() -> bool:
|
||||
return shutil.which("sg_ses") is not None
|
||||
|
||||
|
||||
async def get_smart_data(device: str) -> tuple[dict, bool]:
|
||||
"""Run smartctl -a -j against a device, with caching.
|
||||
|
||||
Returns (data, cache_hit) tuple.
|
||||
"""
|
||||
# Sanitize device name: only allow alphanumeric and hyphens
|
||||
if not re.match(r"^[a-zA-Z0-9\-]+$", device):
|
||||
raise ValueError(f"Invalid device name: {device}")
|
||||
|
||||
cached = await cache_get(f"jbod:smart:{device}")
|
||||
if cached is not None:
|
||||
return (cached, True)
|
||||
|
||||
result = await _run_smartctl(device)
|
||||
await cache_set(f"jbod:smart:{device}", result, SMART_CACHE_TTL)
|
||||
return (result, False)
|
||||
|
||||
|
||||
async def _run_smartctl(device: str) -> dict:
|
||||
"""Execute smartctl and parse JSON output."""
|
||||
"""Execute smartctl and parse JSON output. Hardware-gated."""
|
||||
dev_path = f"/dev/{device}"
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"smartctl", "-a", "-j", dev_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
async with gate():
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"smartctl", "-a", "-j", dev_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
except FileNotFoundError:
|
||||
return {"error": "smartctl not found", "smart_supported": False}
|
||||
|
||||
@@ -154,12 +137,13 @@ def _parse_smart_json(device: str, data: dict) -> dict:
|
||||
async def scan_megaraid_drives() -> list[dict]:
|
||||
"""Discover physical drives behind MegaRAID controllers via smartctl --scan."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"smartctl", "--scan", "-j",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
async with gate():
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"smartctl", "--scan", "-j",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
scan_data = json.loads(stdout)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
logger.warning("smartctl --scan failed: %s", e)
|
||||
@@ -179,12 +163,13 @@ async def scan_megaraid_drives() -> list[dict]:
|
||||
dev_path = entry["name"]
|
||||
dev_type = entry["type"]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"smartctl", "-a", "-j", "-d", dev_type, dev_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
async with gate():
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"smartctl", "-a", "-j", "-d", dev_type, dev_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if not stdout:
|
||||
return None
|
||||
data = json.loads(stdout)
|
||||
|
||||
134
services/store.py
Normal file
134
services/store.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Redis as the sole interface between the poller (producer) and the API/
|
||||
MQTT consumers.
|
||||
|
||||
The poller writes hardware-derived state here; the API and MQTT publisher
|
||||
read it and never touch hardware. Also carries the locate-LED command queue
|
||||
(API enqueues, poller drains) and the poller single-instance lock.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from services.cache import cache_get, cache_set, get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Data TTL: must comfortably exceed the sweep interval so values persist
|
||||
# between sweeps (last-good is served stale rather than blanked).
|
||||
DATA_TTL = int(os.environ.get("POLL_DATA_TTL", "900"))
|
||||
|
||||
K_SMART = "jbod:smart:{}"
|
||||
K_SES = "jbod:ses:{}"
|
||||
K_ZFS = "jbod:zfs_map"
|
||||
K_INVENTORY = "jbod:inventory"
|
||||
K_HOST_DRIVES = "jbod:host_drives"
|
||||
K_META = "jbod:poll:meta"
|
||||
K_LED_QUEUE = "jbod:led:queue"
|
||||
K_LOCK = "jbod:poll:lock"
|
||||
|
||||
|
||||
# ── writes (poller) ─────────────────────────────────────────────────────
|
||||
async def set_smart(device: str, data: dict) -> None:
|
||||
await cache_set(K_SMART.format(device), data, DATA_TTL)
|
||||
|
||||
|
||||
async def set_ses(enc_id: str, data: dict) -> None:
|
||||
await cache_set(K_SES.format(enc_id), data, DATA_TTL)
|
||||
|
||||
|
||||
async def set_zfs_map(pool_map: dict) -> None:
|
||||
await cache_set(K_ZFS, pool_map, DATA_TTL)
|
||||
|
||||
|
||||
async def set_inventory(inventory: dict) -> None:
|
||||
await cache_set(K_INVENTORY, inventory, DATA_TTL)
|
||||
|
||||
|
||||
async def set_host_drives(host_drives: list) -> None:
|
||||
await cache_set(K_HOST_DRIVES, host_drives, DATA_TTL)
|
||||
|
||||
|
||||
async def set_meta(meta: dict) -> None:
|
||||
# Meta has no TTL — it's the heartbeat; staleness is judged from its ts.
|
||||
await cache_set(K_META, meta, 0)
|
||||
|
||||
|
||||
# ── reads (consumers) ───────────────────────────────────────────────────
|
||||
async def get_smart(device: str) -> dict | None:
|
||||
return await cache_get(K_SMART.format(device))
|
||||
|
||||
|
||||
async def get_ses(enc_id: str) -> dict | None:
|
||||
return await cache_get(K_SES.format(enc_id))
|
||||
|
||||
|
||||
async def get_zfs_map() -> dict:
|
||||
return await cache_get(K_ZFS) or {}
|
||||
|
||||
|
||||
async def get_inventory() -> dict:
|
||||
return await cache_get(K_INVENTORY) or {"enclosures": []}
|
||||
|
||||
|
||||
async def get_host_drives() -> list:
|
||||
return await cache_get(K_HOST_DRIVES) or []
|
||||
|
||||
|
||||
async def get_meta() -> dict | None:
|
||||
return await cache_get(K_META)
|
||||
|
||||
|
||||
# ── locate-LED command queue ────────────────────────────────────────────
|
||||
async def enqueue_led(device: str, state: str) -> bool:
|
||||
"""API side: push a LED request for the poller to execute. Returns False
|
||||
if Redis is unavailable (so the API can surface 503)."""
|
||||
client = get_client()
|
||||
if client is None:
|
||||
return False
|
||||
await client.rpush(K_LED_QUEUE, json.dumps({"device": device, "state": state}))
|
||||
return True
|
||||
|
||||
|
||||
async def pop_led(timeout: int = 5) -> dict | None:
|
||||
"""Poller side: block up to `timeout`s for the next LED request."""
|
||||
client = get_client()
|
||||
if client is None:
|
||||
return None
|
||||
item = await client.blpop(K_LED_QUEUE, timeout=timeout)
|
||||
if not item:
|
||||
return None
|
||||
_key, raw = item
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ── poller single-instance lock ─────────────────────────────────────────
|
||||
async def acquire_lock(token: str, ttl: int = 30) -> bool:
|
||||
"""Try to claim the poller lock. Returns True if acquired/owned."""
|
||||
client = get_client()
|
||||
if client is None:
|
||||
return True # no Redis → no coordination possible; run anyway
|
||||
got = await client.set(K_LOCK, token, nx=True, ex=ttl)
|
||||
if got:
|
||||
return True
|
||||
current = await client.get(K_LOCK)
|
||||
return current == token
|
||||
|
||||
|
||||
async def refresh_lock(token: str, ttl: int = 30) -> bool:
|
||||
"""Extend the lock if we still own it."""
|
||||
client = get_client()
|
||||
if client is None:
|
||||
return True
|
||||
current = await client.get(K_LOCK)
|
||||
if current == token:
|
||||
await client.expire(K_LOCK, ttl)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def now() -> float:
|
||||
return time.time()
|
||||
@@ -1,74 +1,57 @@
|
||||
"""Per-enclosure temperature aggregation.
|
||||
"""Per-enclosure temperature aggregation (read-only consumer).
|
||||
|
||||
Builds a compact snapshot of enclosure temperatures suitable for Home
|
||||
Assistant: each named SES temperature sensor plus a derived per-enclosure
|
||||
hotspot (the hottest reading across SES sensors *and* the drives housed in
|
||||
that enclosure — drive temps are usually the real hotspot signal).
|
||||
Reads the poller's data out of Redis — never touches hardware. Each named
|
||||
SES temperature sensor plus a derived per-enclosure hotspot (the hottest
|
||||
reading across SES sensors *and* the drives housed in that enclosure —
|
||||
drive temps are usually the real hotspot signal).
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
|
||||
from services.enclosure import discover_enclosures, get_enclosure_status, list_slots
|
||||
from services.smart import get_smart_data
|
||||
from services import store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_node_id() -> str:
|
||||
"""Stable identifier for this monitor instance (defaults to hostname)."""
|
||||
import os
|
||||
|
||||
return os.environ.get("MQTT_NODE_ID") or socket.gethostname() or "jbod-monitor"
|
||||
|
||||
|
||||
async def build_temp_snapshot() -> dict:
|
||||
"""Collect per-enclosure temperature metrics for all enclosures."""
|
||||
enclosures = discover_enclosures()
|
||||
|
||||
async def _health(enc):
|
||||
if enc.get("sg_device"):
|
||||
return await get_enclosure_status(enc["sg_device"])
|
||||
return None
|
||||
|
||||
health_results = await asyncio.gather(
|
||||
*[_health(enc) for enc in enclosures], return_exceptions=True
|
||||
)
|
||||
|
||||
"""Collect per-enclosure temperature metrics from the store."""
|
||||
inventory = await store.get_inventory()
|
||||
out_enclosures: list[dict] = []
|
||||
for enc, health in zip(enclosures, health_results):
|
||||
if isinstance(health, Exception):
|
||||
logger.warning("SES health failed for %s: %s", enc["id"], health)
|
||||
health = None
|
||||
|
||||
for enc in inventory.get("enclosures", []):
|
||||
ses = await store.get_ses(enc["id"]) or {}
|
||||
|
||||
sensors: list[dict] = []
|
||||
sensor_temps: list[float] = []
|
||||
if isinstance(health, dict):
|
||||
for t in health.get("temps", []):
|
||||
temp = t.get("temperature_c")
|
||||
sensors.append({
|
||||
"index": t["index"],
|
||||
"name": t.get("name"),
|
||||
"status": t.get("status", "Unknown"),
|
||||
"temperature_c": temp,
|
||||
})
|
||||
if isinstance(temp, (int, float)) and temp > 0:
|
||||
sensor_temps.append(float(temp))
|
||||
for t in ses.get("temps", []):
|
||||
temp = t.get("temperature_c")
|
||||
sensors.append({
|
||||
"index": t["index"],
|
||||
"name": t.get("name"),
|
||||
"status": t.get("status", "Unknown"),
|
||||
"temperature_c": temp,
|
||||
})
|
||||
if isinstance(temp, (int, float)) and temp > 0:
|
||||
sensor_temps.append(float(temp))
|
||||
|
||||
# Drive temperatures for drives housed in this enclosure.
|
||||
drive_temps: list[float] = []
|
||||
devices = [s["device"] for s in list_slots(enc["id"]) if s.get("device")]
|
||||
if devices:
|
||||
smart_results = await asyncio.gather(
|
||||
*[get_smart_data(d) for d in devices], return_exceptions=True
|
||||
)
|
||||
for res in smart_results:
|
||||
if isinstance(res, Exception):
|
||||
continue
|
||||
data, _hit = res
|
||||
temp = data.get("temperature_c")
|
||||
if isinstance(temp, (int, float)) and temp > 0:
|
||||
drive_temps.append(float(temp))
|
||||
for slot in enc.get("slots", []):
|
||||
dev = slot.get("device")
|
||||
if not dev:
|
||||
continue
|
||||
sm = await store.get_smart(dev)
|
||||
if not sm:
|
||||
continue
|
||||
temp = sm.get("temperature_c")
|
||||
if isinstance(temp, (int, float)) and temp > 0:
|
||||
drive_temps.append(float(temp))
|
||||
|
||||
all_temps = sensor_temps + drive_temps
|
||||
out_enclosures.append({
|
||||
|
||||
@@ -4,26 +4,23 @@ import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from services.cache import cache_get, cache_set
|
||||
from services.hwgate import gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Allow overriding the zpool binary path via env (for bind-mounted host tools)
|
||||
ZPOOL_BIN = os.environ.get("ZPOOL_BIN", "zpool")
|
||||
|
||||
ZFS_CACHE_TTL = 300
|
||||
|
||||
|
||||
async def get_zfs_pool_map() -> dict[str, dict]:
|
||||
"""Return a dict mapping device names to ZFS pool and vdev info.
|
||||
|
||||
e.g. {"sda": {"pool": "tank", "vdev": "raidz2-0"},
|
||||
"sdb": {"pool": "fast", "vdev": "mirror-0"}}
|
||||
"""
|
||||
cached = await cache_get("jbod:zfs_map")
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
Hardware-gated producer; the poller persists the result to the store
|
||||
and consumers read it from there.
|
||||
"""
|
||||
pool_map = {}
|
||||
try:
|
||||
# When running in a container with pid:host, use nsenter to run
|
||||
@@ -34,12 +31,13 @@ async def get_zfs_pool_map() -> dict[str, dict]:
|
||||
else:
|
||||
cmd = [ZPOOL_BIN, "status", "-P"]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
async with gate():
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return pool_map
|
||||
|
||||
@@ -103,7 +101,6 @@ async def get_zfs_pool_map() -> dict[str, dict]:
|
||||
except FileNotFoundError:
|
||||
logger.debug("zpool not available")
|
||||
|
||||
await cache_set("jbod:zfs_map", pool_map, ZFS_CACHE_TTL)
|
||||
return pool_map
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user