""" node_agent.py — Homelab node health monitor daemon. Runs as a Docker container on every managed node. Each cycle it: 1. Collects system metrics (disk, memory, CPU). 2. Checks Docker container health. 3. Emits structured event JSON files to /opt/homelab/events//. 4. Applies safe Docker / filesystem cleanup per the conservative policy. 5. Pulls any action the control-plane executor has dispatched to this node (container_restart only — whitelisted, self-restart guarded, idempotent) and executes it locally through the docker socket, reporting the result as an action_result event. 6. Optionally rsyncs events (including action_result) to VPS so the control-plane observer/executor can process them. Cleanup policy (matches health-monitor.sh): lte_node (chelsty-infra, chelsty-ha) : NO cleanup, NO image operations sd_card (piha, saturn) : dangling images + stopped containers, max once per 24 h ai_node (solaria) : dangling + containers + build cache, NEVER docker image prune -a standard (vps) : dangling + containers + build cache + control-plane filesystem rotation NEVER TOUCHED on any node: /opt/homelab/data/ Frigate recordings, Ollama models, HA db, MQTT state /opt/homelab/config/ All hand-crafted and repo-seeded configuration /opt/homelab/state/ Heartbeat files, observer checkpoint actions/pending|approved|running Live work queue """ import json import logging import os import re import shutil import socket import subprocess import time from datetime import datetime, timezone from pathlib import Path try: import docker as docker_sdk DOCKER_AVAILABLE = True except ImportError: DOCKER_AVAILABLE = False docker_sdk = None # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) logger = logging.getLogger("node-agent") # --------------------------------------------------------------------------- # Runtime paths # --------------------------------------------------------------------------- RUNTIME_PATH = Path(os.getenv("RUNTIME_PATH", "/opt/homelab")) REPO_ROOT = Path(os.getenv("REPO_ROOT", "/repo")) EVENTS_DIR = RUNTIME_PATH / "events" STATE_DIR = RUNTIME_PATH / "state" LOGS_DIR = RUNTIME_PATH / "logs" ACTIONS_DIR = RUNTIME_PATH / "actions" # --------------------------------------------------------------------------- # Node identity # --------------------------------------------------------------------------- NODE_NAME = os.getenv("NODE_NAME") or socket.gethostname() NODE_TYPE = os.getenv("NODE_TYPE", "") # override auto-detection if set VPS_NODE_NAME = "vps" LTE_NODES = {"chelsty-infra", "chelsty-ha"} SD_CARD_NODES = {"piha", "saturn"} AI_NODES = {"solaria"} # --------------------------------------------------------------------------- # Event shipping (optional — requires VPS_EVENTS_HOST + SSH key in container) # --------------------------------------------------------------------------- VPS_EVENTS_HOST = os.getenv("VPS_EVENTS_HOST", "") VPS_EVENTS_USER = os.getenv("VPS_EVENTS_USER", "oskar") VPS_EVENTS_PATH = os.getenv("VPS_EVENTS_PATH", "/opt/homelab/events") # --------------------------------------------------------------------------- # Remediation dispatch (pull-based — reuses the same VPS_EVENTS_HOST / SSH key # as event shipping above, just in the opposite direction). See # docs/backlog.md "PROJEKT: remediacja bez SSH": the control-plane executor on # VPS has no SSH client and no key to the fleet, so it never reaches out to a # node directly. Instead it drops a small JSON file under # /opt/homelab/actions/dispatch// on VPS; the node-agent for that node # pulls its own subdirectory (remote nodes, via rsync over the existing # shipping key) or reads it directly (VPS's own node-agent — same filesystem, # no network hop) and executes it locally through the docker socket it # already holds. # --------------------------------------------------------------------------- VPS_DISPATCH_PATH = os.getenv("VPS_DISPATCH_PATH", "/opt/homelab/actions/dispatch") # Action types the agent is willing to execute on its own. Deliberately just # one to start: redeploy stays VPS/manual, disk_cleanup is out of scope for # this change (see docs/backlog.md). Anything not in this set is refused with # a clear action_result error rather than silently ignored. ALLOWED_DISPATCH_ACTION_TYPES = {"container_restart"} # Container names the agent will never restart, regardless of what a dispatch # file asks for. Restarting node-agent's own container mid-execution would # kill the very process performing the restart. Matches the CLAUDE.md # convention that a service's container name equals its service name. SELF_RESTART_GUARD_NAMES = {"node-agent"} # --------------------------------------------------------------------------- # Thresholds # --------------------------------------------------------------------------- DISK_WARN_PCT = 75 DISK_CRIT_PCT = 85 MEM_WARN_PCT = 85 MEM_CRIT_PCT = 95 # A container Docker reports as "restarting" that has accumulated at least this # many restarts is treated as a genuine crash-loop (a real fault) rather than a # benign post-deploy / one-off restart. # # Rationale for the threshold: Docker's restart backoff keeps a *genuinely* # crash-looping container cycling through the "restarting" state, so a 60 s # health cycle is very likely to catch it there. A container that merely # restarted once or twice (fresh deploy, a single transient failure that then # recovers) is almost always back in "running" by the next cycle. Requiring # RestartCount >= 3 clears normal deploy churn while still catching a container # that is actually stuck flapping. Configurable via env for tuning per fleet. CRASH_LOOP_RESTART_THRESHOLD = int(os.getenv("CRASH_LOOP_RESTART_THRESHOLD", "3")) # SD-card nodes: enforce 24-hour gap between Docker cleanup runs CLEANUP_INTERVAL_SECS = 86_400 LAST_CLEANUP_FILE = STATE_DIR / "last-docker-cleanup" # How long to wait between full health-check cycles HEALTH_CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "60")) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _resolve_node_type() -> str: if NODE_TYPE: return NODE_TYPE if NODE_NAME in LTE_NODES: return "lte_node" if NODE_NAME in SD_CARD_NODES: return "sd_card" if NODE_NAME in AI_NODES: return "ai_node" return "standard" def _utc_iso() -> str: return datetime.now(timezone.utc).isoformat() # Matches evt----.json (this module's own emit_event # naming, mirrored by observer.py's _EVENT_TS_RE / _ts_from_event_name so both # sides of the checkpoint agree on how to read a filename's embedded epoch). _EVENT_TS_RE = re.compile(r"-(\d{9,11})-") _EVENT_TYPE_RE = re.compile(r"^evt-.+?-\d{9,11}-(.+)$") def _event_ts_from_filename(name: str): """Return the embedded from an event filename, or None if absent.""" m = _EVENT_TS_RE.search(Path(name).stem) return int(m.group(1)) if m else None def _event_type_from_filename(name: str) -> str: """Best-effort event type from evt----.json. Used only to decide retention eligibility (never for dispatch), so an imprecise match on an unrecognized type is safe: it simply keeps the file. """ stem = Path(name).stem m = _EVENT_TYPE_RE.match(stem) if not m: return "unknown" rest = m.group(1) for t in _RETENTION_NOISE_TYPES: if rest == t or rest.startswith(t + "-"): return t return rest def _checkpoint_ts_from_value(value) -> int: """Coerce a stored observer_checkpoint.json value into an int epoch. Mirrors observer.py's own _checkpoint_ts_from_value: current format is an int/float epoch; older observer builds stored a lexical path string, from which the embedded is extracted. Unparseable/absent -> 0, which compares as "nothing processed yet" and safely keeps every file for that node rather than guessing a checkpoint too high and deleting unprocessed events. """ if isinstance(value, bool): return 0 if isinstance(value, (int, float)): return int(value) if isinstance(value, str) and value: ts = _event_ts_from_filename(value) if ts is not None: return ts return 0 # Event types eligible for the ongoing retention sweep in # _cleanup_control_plane_fs: pure positive-confirmation noise, never the # actionable signals (healthcheck_failed, containers_not_running, ha_*, # incidents, disk/memory/cpu pressure, deployment/remediation records, node # liveness transitions), which are kept indefinitely by this method — an # operator or the panel event feed may need to look back at those. _RETENTION_NOISE_TYPES = frozenset({"service_healthy", "node_health"}) # --------------------------------------------------------------------------- # NodeAgent # --------------------------------------------------------------------------- class NodeAgent: def __init__(self): self.node_name = NODE_NAME self.node_type = _resolve_node_type() self._ensure_dirs() # Tracks the last known health classification per service (by canonical # name — container service names AND the synthetic "control-plane" key # used by _check_control_plane_health share this dict) so service_healthy # is emitted only on the unhealthy->healthy transition, not once per cycle # for a service that is already healthy. In-memory only: reset on process # restart is acceptable — it just re-emits one confirmation event, not a # flood, and the observer treats that re-confirmation as idempotent (see # process_event). self._service_health_state: dict = {} self.docker_client = None if DOCKER_AVAILABLE: try: self.docker_client = docker_sdk.from_env() logger.info("Docker SDK connected") except Exception as exc: logger.warning(f"Docker unavailable: {exc}") logger.info(f"node-agent starting: node={self.node_name} type={self.node_type}") # ------------------------------------------------------------------ # Directories # ------------------------------------------------------------------ def _ensure_dirs(self): for d in [EVENTS_DIR, STATE_DIR, LOGS_DIR, EVENTS_DIR / self.node_name]: d.mkdir(parents=True, exist_ok=True) def _node_events_dir(self) -> Path: return EVENTS_DIR / self.node_name # ------------------------------------------------------------------ # Event emission # ------------------------------------------------------------------ def emit_event(self, event_type: str, severity: str, service, message: str, payload: dict = None): ts = int(time.time()) # Include service slug in the ID so that multiple events of the same type # emitted within the same second (e.g. service_healthy for N containers) # don't overwrite each other — each gets a unique filename. svc_slug = re.sub(r"[^a-z0-9]", "-", (service or "node").lower())[:32].strip("-") event_id = f"evt-{self.node_name}-{ts}-{event_type}-{svc_slug}" event = { "id": event_id, "timestamp": ts, "date": _utc_iso(), "type": event_type, "severity": severity, "node": self.node_name, "service": service or "", "message": message, "payload": payload or {}, } path = self._node_events_dir() / f"{event_id}.json" try: path.write_text(json.dumps(event, indent=2)) except Exception as exc: logger.error(f"Failed to write event {event_id}: {exc}") # ------------------------------------------------------------------ # System metrics # ------------------------------------------------------------------ def check_disk(self) -> int: """ Check disk usage of the filesystem that hosts RUNTIME_PATH. Using shutil.disk_usage on the mounted runtime path works both natively on the host and inside a container that mounts /opt/homelab from the host — the reported usage reflects the host partition. """ try: usage = shutil.disk_usage(str(RUNTIME_PATH)) pct = int(usage.used * 100 / usage.total) avail_mb = int(usage.free / (1024 * 1024)) total_mb = int(usage.total / (1024 * 1024)) payload = { "usage_pct": pct, "avail_mb": avail_mb, "total_mb": total_mb, "mount": str(RUNTIME_PATH), } if pct >= DISK_CRIT_PCT: logger.warning(f"Disk critical: {pct}% used") self.emit_event( "disk_pressure", "high", None, f"Disk usage critical: {pct}% on {RUNTIME_PATH} ({avail_mb} MB free)", payload, ) elif pct >= DISK_WARN_PCT: logger.info(f"Disk elevated: {pct}% used") self.emit_event( "disk_pressure", "medium", None, f"Disk usage elevated: {pct}% on {RUNTIME_PATH} ({avail_mb} MB free)", payload, ) return pct except Exception as exc: logger.error(f"Disk check failed: {exc}") return 0 def check_memory(self) -> int: """Read host memory from /proc/meminfo (visible in container without special mounts).""" try: info: dict = {} with open("/proc/meminfo") as fh: for line in fh: k, v = line.split(":") info[k.strip()] = int(v.strip().split()[0]) total = info.get("MemTotal", 1) avail = info.get("MemAvailable", total) pct = int((total - avail) * 100 / total) avail_mb = avail // 1024 total_mb = total // 1024 payload = {"usage_pct": pct, "avail_mb": avail_mb, "total_mb": total_mb} if pct >= MEM_CRIT_PCT: logger.warning(f"Memory critical: {pct}%") self.emit_event("high_memory", "high", None, f"Memory usage critical: {pct}% ({avail_mb} MB available)", payload) elif pct >= MEM_WARN_PCT: self.emit_event("high_memory", "medium", None, f"Memory usage elevated: {pct}% ({avail_mb} MB available)", payload) return pct except Exception as exc: logger.error(f"Memory check failed: {exc}") return 0 def check_cpu(self) -> int: """Two-sample /proc/stat delta for accurate instantaneous CPU usage.""" try: def _read(): with open("/proc/stat") as fh: for line in fh: if line.startswith("cpu "): vals = list(map(int, line.split()[1:])) return vals[3], sum(vals) # idle, total return 0, 1 idle1, total1 = _read() time.sleep(0.5) idle2, total2 = _read() d_total = total2 - total1 d_idle = idle2 - idle1 pct = 100 - int(d_idle * 100 / d_total) if d_total else 0 if pct >= 90: self.emit_event("high_cpu", "medium", None, f"CPU usage elevated: {pct}%", {"usage_pct": pct}) return pct except Exception as exc: logger.error(f"CPU check failed: {exc}") return 0 # ------------------------------------------------------------------ # Docker container health # ------------------------------------------------------------------ def _canonical_container_name(self, c) -> str: """Return a stable, human-readable service name for a container. Priority: 1. com.docker.compose.service label — always the clean compose-file key (e.g. "mosquitto", "zigbee2mqtt"), immune to the hash-prefix corruption Docker uses for stale project-state tracking entries. 2. c.name with hash prefix stripped — fallback for non-Compose containers. When a container is removed outside of compose and then recreated, Docker stores the old container record as "<12-char-hex-id>_". c.name returns that corrupted form; we strip the prefix here. Using c.name directly is the source of ghost service keys like "vps/9e36297651e7_control-plane-observer" that accumulate in services.json every time containers are rebuilt. """ labels = c.attrs.get("Config", {}).get("Labels", {}) or {} compose_svc = labels.get("com.docker.compose.service", "").strip() if compose_svc: return compose_svc # Strip Docker-internal stale-state prefix: "<12-char hex>_" name = c.name if (len(name) > 13 and name[12] == "_" and all(ch in "0123456789abcdef" for ch in name[:12])): return name[13:] return name def check_containers(self): if not self.docker_client: return try: containers = self.docker_client.containers.list(all=True) except Exception as exc: logger.error(f"Docker container list failed: {exc}") return for c in containers: try: name = self._canonical_container_name(c) status = c.status host_config = c.attrs.get("HostConfig", {}) restart_policy = host_config.get("RestartPolicy", {}).get("Name", "") health_status = (c.attrs.get("State", {}) .get("Health", {}) .get("Status", "")) # RestartCount is a top-level inspect field (sibling of State), # not nested under State. It monotonically counts how many times # Docker has auto-restarted this container — the signal that # distinguishes a crash-loop from a one-off restart. restart_count = c.attrs.get("RestartCount", 0) # Skip containers in "created" state — these are Docker Compose # internal tracking artifacts (never started, often hash-prefixed) # that appear when a container is rebuilt outside of compose. # This is a conscious skip, not a blind spot: a "created" container # is not a running service and has no fault to report. if status == "created": continue # Only track containers with a restart policy (long-running services) is_managed = restart_policy in ("unless-stopped", "always", "on-failure") if not is_managed: continue base_payload = { "container": name, "status": status, "restart_policy": restart_policy, "restart_count": restart_count, } # Exited container that carries an auto-restart policy if status in ("exited", "dead"): self._service_health_state[name] = False logger.warning(f"Container exited: {name} (restart={restart_policy})") self.emit_event( "containers_not_running", "high", name, f"Container '{name}' has exited (restart={restart_policy})", base_payload, ) # Container stuck in Docker's "restarting" state. This is the gap # that previously made crash-loops invisible: "restarting" matched # neither the exited/dead branch nor the running branch, so a # container flapping under its restart policy emitted ZERO events # and the observer kept showing its last (stale) "healthy" state. # # We split on RestartCount so a benign post-deploy restart does not # alarm, while a real crash-loop escalates to the same actionable # signal as an exited container. elif status == "restarting": self._service_health_state[name] = False if restart_count >= CRASH_LOOP_RESTART_THRESHOLD: # Genuine crash-loop: reuse containers_not_running so it # rides the existing, supervisor-wired remediation path # (parity with exited/dead — the container is, by # definition, not staying running). logger.warning( f"Container crash-looping: {name} " f"(restarting, {restart_count} restarts, restart={restart_policy})" ) self.emit_event( "containers_not_running", "high", name, f"Container '{name}' is crash-looping " f"(restarting, {restart_count} restarts, restart={restart_policy})", {**base_payload, "crash_loop": True}, ) else: # Fresh / transient restart. Make it VISIBLE (so the state # is no longer a silent hole) but do NOT trigger remediation # — a restart action here would be premature and noisy. If # it keeps flapping, RestartCount crosses the threshold on a # later cycle and escalates to containers_not_running above. # container_restarting is intentionally observational: it is # not wired to any supervisor trigger. logger.info( f"Container restarting: {name} " f"({restart_count} restarts) — watching for crash-loop" ) self.emit_event( "container_restarting", "low", name, f"Container '{name}' is restarting " f"({restart_count} restarts) — watching for crash-loop", {**base_payload, "crash_loop": False}, ) # Running container with a failing health check elif status == "running" and health_status == "unhealthy": self._service_health_state[name] = False logger.warning(f"Container unhealthy: {name}") self.emit_event( "healthcheck_failed", "high", name, f"Container '{name}' is running but its health check is failing", {**base_payload, "health_status": health_status}, ) # Running container that is healthy — confirm to observer so that # services.json stays populated for the supervisor's drift detection. # Without this, the supervisor sees services.json as empty and treats # all desired services as "missing", flooding the action queue. # # Emitted ONLY on the unhealthy/unknown -> healthy transition, not # every cycle: a healthy service otherwise re-confirms itself once # per health-check interval forever, which is exactly the flood # that paralyzed the supervisor's reconcile() glob (358k backlog # files, 91% service_healthy). The positive "still healthy" state is # already carried by the presence of a `healthy` services.json entry # (written once here) plus the node_health heartbeat every cycle — # so a stuck/frozen node_agent is still visible via node liveness, # without needing a fresh service_healthy file per service per cycle. elif status == "running": if self._service_health_state.get(name) is not True: self.emit_event( "service_healthy", "info", name, f"Container '{name}' is running", {**base_payload, "health_status": health_status or "none"}, ) self._service_health_state[name] = True # Deliberately paused. docker pause is always an operator/tool # action (Docker never auto-pauses), so it is not a fault — but a # managed service should not normally sit paused, so surface it # observationally rather than swallowing it. Non-actionable. elif status == "paused": self._service_health_state[name] = False logger.info(f"Container paused: {name}") self.emit_event( "container_state_unexpected", "medium", name, f"Container '{name}' is paused (restart={restart_policy})", base_payload, ) # Ephemeral teardown state: the container is being removed and will # be gone (or replaced) by the next cycle. Emitting a fault here # would be a guaranteed false positive on every recreate/deploy, so # this is a conscious, documented skip — not a silent hole. elif status == "removing": logger.debug(f"Container removing (transient teardown): {name}") # FALLBACK — any Docker state we do not explicitly handle (including # states a future Docker engine might introduce). Never fall through # silently: emit a diagnostic so a new/unknown state becomes visible # instead of quietly recreating the very blind spot this fix closes. else: self._service_health_state[name] = False logger.warning( f"Container in unhandled Docker state: {name} (state='{status}')" ) self.emit_event( "container_state_unexpected", "medium", name, f"Container '{name}' is in unhandled Docker state '{status}' " f"(restart={restart_policy})", base_payload, ) except Exception as exc: logger.error(f"Error checking container {c.name}: {exc}") # ------------------------------------------------------------------ # Safe Docker cleanup # ------------------------------------------------------------------ def _sd_card_rate_ok(self) -> bool: """Return True only if 24 hours have elapsed since last cleanup.""" if LAST_CLEANUP_FILE.exists(): try: last_ts = int(LAST_CLEANUP_FILE.read_text().strip()) elapsed = time.time() - last_ts if elapsed < CLEANUP_INTERVAL_SECS: logger.debug( f"Docker cleanup rate-limited ({elapsed:.0f}s < {CLEANUP_INTERVAL_SECS}s)" ) return False except Exception: pass return True def _mark_cleanup_done(self): try: LAST_CLEANUP_FILE.write_text(str(int(time.time()))) except Exception as exc: logger.error(f"Failed to update cleanup timestamp: {exc}") def _prune_dangling_images(self): if not self.docker_client: return try: result = self.docker_client.images.prune(filters={"dangling": True}) reclaimed = result.get("SpaceReclaimed", 0) // (1024 * 1024) logger.info(f"Pruned dangling images ({reclaimed} MB reclaimed)") except Exception as exc: logger.error(f"Image prune failed: {exc}") def _prune_stopped_containers(self): if not self.docker_client: return try: result = self.docker_client.containers.prune() reclaimed = result.get("SpaceReclaimed", 0) // (1024 * 1024) logger.info(f"Pruned stopped containers ({reclaimed} MB reclaimed)") except Exception as exc: logger.error(f"Container prune failed: {exc}") def _prune_build_cache(self): if not self.docker_client: return try: result = self.docker_client.api.prune_builds() reclaimed = result.get("SpaceReclaimed", 0) // (1024 * 1024) logger.info(f"Pruned build cache ({reclaimed} MB reclaimed)") except Exception as exc: # prune_builds() was added in docker-py 5.0; log and continue logger.warning(f"Build cache prune unavailable or failed: {exc}") def run_safe_cleanup(self): """Apply the per-node-type Docker cleanup policy.""" if self.node_type == "lte_node": # No cleanup on LTE nodes — any Docker op risks a pull over a # metered/intermittent connection. logger.debug("Skipping Docker cleanup: LTE node") return if self.node_type == "sd_card": if not self._sd_card_rate_ok(): return self._prune_dangling_images() self._prune_stopped_containers() # No builder prune: minimise write cycles on SD card self._mark_cleanup_done() return # ai_node and standard: dangling + containers + build cache # ai_node: NEVER -a (would remove Ollama runtime images) self._prune_dangling_images() self._prune_stopped_containers() self._prune_build_cache() # ------------------------------------------------------------------ # VPS-specific: control-plane filesystem rotation # ------------------------------------------------------------------ def _cleanup_control_plane_fs(self): """ Rotate control-plane filesystem artefacts on VPS. Safe targets only — never touches data/, config/, state/, or live action directories (pending/approved/running). """ now = time.time() seven_days = 7 * 86_400 thirty_days = 30 * 86_400 three_days = 3 * 86_400 # 1. Completed / failed actions older than 7 days for status in ("completed", "failed"): sdir = ACTIONS_DIR / status if not sdir.exists(): continue for f in sdir.glob("*.json"): try: if now - f.stat().st_mtime > seven_days: f.unlink(missing_ok=True) logger.info(f"Cleaned old {status} action: {f.name}") except Exception as exc: logger.error(f"Failed to remove {f}: {exc}") # 2. Deploy logs older than 30 days deploy_dir = LOGS_DIR / "deploy" if deploy_dir.exists(): for f in deploy_dir.glob("*.log"): try: if now - f.stat().st_mtime > thirty_days: f.unlink(missing_ok=True) logger.info(f"Cleaned old deploy log: {f.name}") except Exception as exc: logger.error(f"Failed to remove {f}: {exc}") # 3. Noise event files (service_healthy / node_health) that are BOTH # already past the observer's per-node checkpoint (guaranteed # processed into world_state — see _checkpoint_ts_from_value) AND # older than 3 days (safety buffer past the checkpoint). The dual # condition guarantees we never delete an unprocessed event, and # the type restriction guarantees healthcheck_failed / incidents / # ha_* / node-liveness events are kept indefinitely by this method. # # BUG FIX (event-flood cleanup): this previously compared str(f) # (a full file path) against the checkpoint value with <=, which # only ever worked when the checkpoint stored the pre-fix lexical # path format. Since observer.py migrated node_checkpoints to int # epoch timestamps, every comparison here raised # "'<=' not supported between instances of 'str' and 'int'", # silently caught by the broad except below — so this cleanup has # been a no-op ever since, which is the direct cause of the 358k-file # backlog that paralyzed the supervisor's reconcile(). Fixed by # comparing epoch-to-epoch, same as observer.py's own checkpoint # logic. checkpoint_file = STATE_DIR / "observer_checkpoint.json" node_checkpoints: dict = {} if checkpoint_file.exists(): try: cp = json.loads(checkpoint_file.read_text()) if "node_checkpoints" in cp: node_checkpoints = { node: _checkpoint_ts_from_value(val) for node, val in (cp["node_checkpoints"] or {}).items() } elif "last_processed_file" in cp: # Migrate old single-file format old = cp.get("last_processed_file", "") if old: try: node_dir = Path(old).relative_to(EVENTS_DIR).parts[0] node_checkpoints = {node_dir: _checkpoint_ts_from_value(old)} except Exception: pass except Exception as exc: logger.error(f"Failed to read observer checkpoint: {exc}") if node_checkpoints: for f in EVENTS_DIR.glob("**/*.json"): try: etype = _event_type_from_filename(f.name) if etype not in _RETENTION_NOISE_TYPES: continue # Determine which node directory this event belongs to rel = Path(f).relative_to(EVENTS_DIR) node_dir = str(rel.parts[0]) if rel.parts else "__unknown__" checkpoint_ts = node_checkpoints.get(node_dir) if not checkpoint_ts: continue event_ts = _event_ts_from_filename(f.name) if event_ts is None: event_ts = int(f.stat().st_mtime) if (event_ts < checkpoint_ts and now - f.stat().st_mtime > three_days): f.unlink(missing_ok=True) logger.info(f"Cleaned old event: {f.name}") except Exception as exc: logger.error(f"Failed to remove {f}: {exc}") else: logger.debug("No observer checkpoint present; skipping event cleanup") # ------------------------------------------------------------------ # Optional: ship events to VPS via rsync # ------------------------------------------------------------------ def _ship_events_to_vps(self): """ Rsync local events to VPS so the observer can process them. Requires: - VPS_EVENTS_HOST env var set to the VPS hostname/IP - SSH key accessible inside the container (mount via docker-compose) - The node is NOT VPS itself """ if not VPS_EVENTS_HOST or self.node_name == VPS_NODE_NAME: return local_dir = str(self._node_events_dir()) + "/" remote_dir = (f"{VPS_EVENTS_USER}@{VPS_EVENTS_HOST}:" f"{VPS_EVENTS_PATH}/{self.node_name}/") cmd = [ "rsync", "-az", "--remove-source-files", # --omit-dir-times: the remote per-node event dir on VPS is owned # by aerbot:aerbot; the ssh user (oskar) is only a group member, # so it can write files into the dir but cannot chown/chmod/touch # the dir itself. -a implies -t (preserve times) which also # applies to directories, so rsync would otherwise try to set the # dir's mtime, get EPERM, and return non-zero even though every # file transferred fine. Files still keep their mtime via -t; # this only skips the (harmless, doomed) directory mtime set. # TODO tech-debt: fix ownership of /opt/homelab/events/ on # VPS so this workaround isn't needed (see docs/backlog.md, # "Tech-debt: globalny porządek uid/gid/uprawnień"). "--omit-dir-times", # --no-perms/--no-owner/--no-group: same root cause as --omit-dir-times above. # After omitting dir times, rsync next tried chmod on the same aerbot-owned # dir and got EPERM ("failed to set permissions", exit 23) — then would try # chown/chgrp for the same reason. -a implies -pgo; we drop all three for the # destination dir. File CONTENT still transfers correctly (that is all the # observer needs); only attribute-setting on the remote dir is skipped. # TODO tech-debt: fix ownership of /opt/homelab/events/ on VPS so none # of these workarounds are needed (see docs/backlog.md, uid/gid section). "--no-perms", "--no-owner", "--no-group", # -F /dev/null: skip ~/.ssh/config entirely. The .ssh dir is # mounted from the host oskar user into the container which runs # as root; OpenSSH rejects config files owned by a different UID. # UserKnownHostsFile=/dev/null pairs with StrictHostKeyChecking=no # so we never try to write a known_hosts inside a read-only mount. "-e", ("ssh -F /dev/null" " -o StrictHostKeyChecking=no" " -o UserKnownHostsFile=/dev/null" " -o ConnectTimeout=10" " -o BatchMode=yes"), local_dir, remote_dir, ] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode == 0: logger.debug(f"Events shipped to {remote_dir}") else: logger.warning(f"Event shipping failed: {result.stderr.strip()}") except Exception as exc: logger.warning(f"Event shipping error: {exc}") # ------------------------------------------------------------------ # Remediation dispatch: pull queued actions for this node and execute them # ------------------------------------------------------------------ def _dispatch_inbox_dir(self) -> Path: return ACTIONS_DIR / "dispatch" / self.node_name def _processed_marker_path(self, action_id: str) -> Path: return STATE_DIR / "processed-actions" / f"{action_id}.done" def _already_processed(self, action_id: str) -> bool: return self._processed_marker_path(action_id).exists() def _mark_processed(self, action_id: str): marker = self._processed_marker_path(action_id) try: marker.parent.mkdir(parents=True, exist_ok=True) marker.touch() except Exception as exc: logger.error(f"Failed to record action {action_id} as processed: {exc}") def pull_dispatched_actions(self): """ Rsync-pull this node's dispatch inbox from VPS. Reuses the exact same SSH key / connection settings as _ship_events_to_vps, just as sender/receiver reversed: VPS is now the source, this node the destination. --remove-source-files deletes the action file on VPS once it has been fetched, so a dispatch file is collected by exactly one node and cannot be re-pulled after that. Requires VPS_EVENTS_HOST (same var event shipping uses) and is a no-op on VPS itself, whose node-agent reads the dispatch dir directly off the shared /opt/homelab mount — no network hop needed. """ if not VPS_EVENTS_HOST or self.node_name == VPS_NODE_NAME: return inbox = self._dispatch_inbox_dir() inbox.mkdir(parents=True, exist_ok=True) local_dir = str(inbox) + "/" remote_dir = (f"{VPS_EVENTS_USER}@{VPS_EVENTS_HOST}:" f"{VPS_DISPATCH_PATH}/{self.node_name}/") cmd = [ "rsync", "-az", "--remove-source-files", "--omit-dir-times", "--no-perms", "--no-owner", "--no-group", "-e", ("ssh -F /dev/null" " -o StrictHostKeyChecking=no" " -o UserKnownHostsFile=/dev/null" " -o ConnectTimeout=10" " -o BatchMode=yes"), remote_dir, local_dir, ] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) # rsync returns 23/24 ("partial transfer"/"vanished source files") # when the remote dispatch dir is simply empty — the common case, # not an error worth logging every cycle. if result.returncode not in (0, 23, 24): logger.warning(f"Dispatch pull failed: {result.stderr.strip()}") except Exception as exc: logger.warning(f"Dispatch pull error: {exc}") def process_dispatched_actions(self): """Execute every action currently sitting in this node's dispatch inbox.""" inbox = self._dispatch_inbox_dir() if not inbox.exists(): return for action_file in sorted(inbox.glob("*.json")): try: action = json.loads(action_file.read_text()) except Exception as exc: logger.error(f"Failed to read dispatched action {action_file.name}: {exc}") action_file.unlink(missing_ok=True) continue self._execute_dispatched_action(action) # Delete regardless of outcome: a rejected/failed action is not # retried automatically (the whole point of the action_result # report is that the executor decides what happens next — retry # is a fresh dispatch with a fresh action_id, not this file living on). action_file.unlink(missing_ok=True) def _execute_dispatched_action(self, action: dict): """ Validate and execute one dispatched action, then report the result. Security gates, in order (each rejection short-circuits execution and reports a clear action_result error rather than running anything): 1. Idempotency — already processed this action_id? no-op. 2. Node scoping — action addressed to a DIFFERENT node? refuse. (Defense in depth: the dispatch dir is already scoped by node name, but a dispatch file could in principle be mis-delivered.) 3. Type whitelist — only container_restart, nothing else. 4. Self-restart guard — never restart node-agent's own container. """ action_id = action.get("action_id") or "unknown" node = action.get("node") action_type = action.get("type") container_name = action.get("container_name") or action.get("service") if self._already_processed(action_id): logger.info(f"Action {action_id} already processed — skipping (idempotency)") return if node != self.node_name: self._report_action_result( action_id, container_name, False, f"Action addressed to node '{node}', not '{self.node_name}' — refused", ) self._mark_processed(action_id) return if action_type not in ALLOWED_DISPATCH_ACTION_TYPES: self._report_action_result( action_id, container_name, False, f"Action type '{action_type}' is not whitelisted for agent-side " f"execution (allowed: {sorted(ALLOWED_DISPATCH_ACTION_TYPES)})", ) self._mark_processed(action_id) return if not container_name: self._report_action_result( action_id, container_name, False, "No container_name in dispatched action", ) self._mark_processed(action_id) return if container_name in SELF_RESTART_GUARD_NAMES: self._report_action_result( action_id, container_name, False, f"Refusing to restart '{container_name}': node-agent will not restart itself", ) self._mark_processed(action_id) return if not self.docker_client: self._report_action_result( action_id, container_name, False, "Docker SDK unavailable on this node", ) self._mark_processed(action_id) return try: container = self.docker_client.containers.get(container_name) container.restart() logger.info(f"Restarted container '{container_name}' for action {action_id}") self._report_action_result(action_id, container_name, True, "") except Exception as exc: logger.error(f"Failed to restart '{container_name}' for action {action_id}: {exc}") self._report_action_result(action_id, container_name, False, str(exc)) self._mark_processed(action_id) def _report_action_result(self, action_id: str, container_name, success: bool, error: str): """Emit an action_result event — picked up by the executor's _reconcile_running_actions() to move the action to completed/failed. Rides the existing event pipeline (and, for remote nodes, the existing _ship_events_to_vps rsync) with no changes to either. """ self.emit_event( "action_result", "info" if success else "high", container_name or action_id, f"Action {action_id} {'succeeded' if success else 'failed'}", { "action_id": action_id, "success": success, "error": error, "node": self.node_name, }, ) # ------------------------------------------------------------------ # VPS-specific: control-plane service health check # ------------------------------------------------------------------ def _check_control_plane_health(self): """ VPS-only: probe the control-plane HTTP endpoint and emit a service health event so the observer can populate services.json for the 'control-plane' entry in services.yaml. The control-plane is a multi-container stack (observer, supervisor, executor, ui), so individual container names don't match the service name in services.yaml. Checking the HTTP endpoint gives a clean boundary that maps 1-to-1 with the logical service. """ import urllib.request endpoint = "http://localhost:18180/summary" try: resp = urllib.request.urlopen(endpoint, timeout=5) if resp.status == 200: # Transition-only, same rationale as check_containers(): without # this gate, control-plane re-confirmed itself every cycle # forever, contributing to the same service_healthy flood. if self._service_health_state.get("control-plane") is not True: self.emit_event( "service_healthy", "info", "control-plane", "Control-plane HTTP endpoint is reachable", {"endpoint": endpoint}, ) self._service_health_state["control-plane"] = True else: self._service_health_state["control-plane"] = False self.emit_event( "service_unhealthy", "high", "control-plane", f"Control-plane HTTP endpoint returned HTTP {resp.status}", {"endpoint": endpoint, "http_status": resp.status}, ) except Exception as exc: self._service_health_state["control-plane"] = False self.emit_event( "service_unhealthy", "high", "control-plane", f"Control-plane HTTP endpoint unreachable: {exc}", {"endpoint": endpoint, "error": str(exc)}, ) # ------------------------------------------------------------------ # Heartbeat # ------------------------------------------------------------------ def _update_heartbeat(self): try: (STATE_DIR / "node-agent.heartbeat").touch() except Exception as exc: logger.error(f"Failed to update heartbeat: {exc}") # ------------------------------------------------------------------ # Main loop # ------------------------------------------------------------------ def run_once(self): self._update_heartbeat() disk_pct = self.check_disk() mem_pct = self.check_memory() cpu_pct = self.check_cpu() self.check_containers() self.run_safe_cleanup() if self.node_name == VPS_NODE_NAME: self._cleanup_control_plane_fs() self._check_control_plane_health() # Remediation dispatch: fetch and execute any action the executor has # queued for this node, then report the outcome (via emit_event below, # shipped in the same cycle by _ship_events_to_vps). self.pull_dispatched_actions() self.process_dispatched_actions() # Emit a node_health heartbeat so the observer can update node status # and the supervisor can correlate disk/memory metrics with service issues. self.emit_event( "node_health", "info", None, f"Health check completed on {self.node_name}", {"disk_pct": disk_pct, "mem_pct": mem_pct, "cpu_pct": cpu_pct}, ) self._ship_events_to_vps() def loop(self, interval: int = HEALTH_CHECK_INTERVAL): logger.info( f"node-agent ready — node={self.node_name} type={self.node_type} " f"interval={interval}s" ) while True: try: self.run_once() except Exception as exc: logger.error(f"Health cycle error: {exc}") time.sleep(interval) if __name__ == "__main__": NodeAgent().loop()