Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions demos/system-services-monitor/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM python:3.13-slim

@coderabbitai coderabbitai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Pin the Python base image by digest.

python:3.13-slim is a mutable tag. Rebuilds can use different base-image contents without a Dockerfile change. Pin the selected image to an immutable digest.

🧰 Tools
🪛 Trivy (0.72.0)

[error] 1-1: Image user should not be 'root'

Specify at least 1 USER command in Dockerfile with non-root user as argument

Rule: DS-0002

Learn more

(IaC/Dockerfile)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demos/system-services-monitor/Dockerfile` at line 1, Update the Dockerfile’s
Python base image reference from the mutable python:3.13-slim tag to the
corresponding immutable image digest, preserving the Python 3.13 slim base while
ensuring reproducible rebuilds.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed in principle — FROM python:3.13-slim (Dockerfile:1) is mutable, so two builds of the same commit can differ. Leaving OPEN rather than pinning a digest here, for one reason worth stating: I would have to pick a digest, and a stale pin in demo code that nobody rebuilds is its own trap (the base stops receiving CVE fixes and the demo silently ships an old libc/openssl). If this tree survives the direction call I would rather pin the digest and record the tag it resolved from, so the next person can re-resolve deliberately. Your call on which you prefer for demos/.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmvevents, retain the finding as open.

For demos/, prefer a digest pin and an adjacent comment that states the source tag, for example python:3.13-slim. This gives reproducible builds and makes the intended update path explicit.

A digest pin does require a maintenance process. Update the digest deliberately when the base image is refreshed. If this demo is removed or changed to consume the shipped package, the finding no longer applies.

🐇


✏️ Learnings added
Learnt from: dmvevents
Repo: NVIDIA/NVSentinel PR: 1383
File: demos/system-services-monitor/Dockerfile:1-1
Timestamp: 2026-08-22T10:54:04.861Z
Learning: For standalone demo Dockerfiles in this repository, prefer an immutable base-image digest with the resolved mutable source tag recorded adjacent to the `FROM` instruction. Refresh the digest deliberately to receive base-image security updates.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.


# nsenter is in util-linux (already in slim), but ensure it's available
RUN apt-get update && apt-get install -y --no-install-recommends \
util-linux \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY config.py metrics.py monitor.py ./
COPY checks/ ./checks/

# nsenter requires root to enter host namespaces.
# The DaemonSet securityContext controls the actual privilege level.

EXPOSE 9101

ENTRYPOINT ["python", "monitor.py"]
76 changes: 76 additions & 0 deletions demos/system-services-monitor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# System Service Monitor — Standalone Demo

A standalone DaemonSet companion to NVSentinel that catches GPU infrastructure failures invisible to DCGM-based monitoring.

**Related issue:** [#883 - NVSentinel not detecting fabric health on H100s](https://github.com/NVIDIA/NVSentinel/issues/883)

## Problem

NVIDIA Fabric Manager can fail and stay broken for weeks undetected. NVSentinel's existing monitors (DCGM-based, syslog-based) miss it because individual GPUs appear healthy to DCGM even when Fabric Manager is down. This tool fills the gap with service-level health checks.

**Requirements:** Kubernetes cluster with GPU nodes, Prometheus Operator

## What It Monitors

| # | Check | What It Catches | Method |
|---|-------|-----------------|--------|
| 1 | **Fabric Manager Service** | FM not running, flapping, error state | `nsenter` + `systemctl` |
| 2 | **Critical GPU Services** | persistenced dead | `nsenter` + `systemctl` |
| 3 | **Per-GPU Fabric State** | FM_NOT_STARTED, FM_REGISTRATION_STUCK, FM_FABRIC_ERROR | `nsenter` + `nvidia-smi` |

> **CUDA validation is not part of this monitor.** Polling CUDA context/memory tests from a long-running daemon contends for GPU memory with active workloads (see the [#891 review](https://github.com/NVIDIA/NVSentinel/pull/891)). The supported form is a preflight init-container that runs once before workloads schedule — see [`preflight-checks/cuda-validation/`](../../preflight-checks/cuda-validation/) (#1384).

## Quick Start

```bash
# Build
docker build -t system-services-monitor:latest .
Comment thread
dmvevents marked this conversation as resolved.
Outdated

# Deploy (assumes nvsentinel namespace exists)
kubectl apply -f k8s/rbac.yaml
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/daemonset.yaml
kubectl apply -f k8s/servicemonitor.yaml

# Verify
kubectl get ds -n nvsentinel system-services-monitor

# Port-forward to a specific node's pod
NODE=<node-name>
POD=$(kubectl get pod -n nvsentinel -o wide --field-selector spec.nodeName=${NODE} \
-l app=system-services-monitor -o jsonpath='{.items[0].metadata.name}')
Comment thread
dmvevents marked this conversation as resolved.
Outdated
kubectl port-forward -n nvsentinel pod/${POD} 9101:9101
curl -s localhost:9101/metrics | grep fabric_manager_up
```

## Metrics

Exposed on port 9101. Key metrics:

| Metric | Description |
|--------|-------------|
| `fabric_manager_up` | Fabric Manager running (1/0) |
| `gpu_node_health_up` | Overall node health (1/0) |
| `nvidia_service_up` | Per-service status |
| `fabric_state_healthy` | Per-GPU fabric state (1/0) |

## Alert Rules

The ServiceMonitor includes PrometheusRule with alerts:
- `FabricManagerDown` (critical, 5m)
- `FabricManagerFlapping` (warning, 5m)
- `FabricStateUnhealthy` (critical, 5m) -- per-GPU fabric orchestration failure
- `GPUServiceDown` (critical, 3m)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Validated On

- 2x P4d.24xlarge (8x A100-SXM4-40GB each) -- Amazon Linux 2023, EKS 1.32
- All check categories produce correct metrics

## Configuration

All settings via ConfigMap environment variables. See `k8s/configmap.yaml`.

## Relationship to NVSentinel

This is a **standalone companion tool** that exposes Prometheus metrics and alerts. It does not integrate with NVSentinel's gRPC event pipeline or remediation workflow. See the native `health-monitors/system-services-monitor/` for an integrated version that emits HealthEvents to platform-connector.
15 changes: 15 additions & 0 deletions demos/system-services-monitor/checks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Health check modules for GPU Node Health Validator."""
229 changes: 229 additions & 0 deletions demos/system-services-monitor/checks/service_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""Checks 1 & 2: Systemd service health for Fabric Manager and GPU services.

Uses nsenter to inspect host systemd services from within a container.
Includes flap detection (rapid restart cycling) and journal error parsing.
"""
Comment thread
dmvevents marked this conversation as resolved.

import logging
import subprocess
import time
from collections import deque
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional

logger = logging.getLogger(__name__)


class ErrorCategory(Enum):
NVSWITCH_ERROR = "nvswitch_error"
INITIALIZATION_FAILED = "initialization_failed"
TIMEOUT = "timeout"
GENERAL_ERROR = "general_error"


# Journal patterns that indicate specific failure modes
_ERROR_PATTERNS = {
ErrorCategory.NVSWITCH_ERROR: [
"nvswitch",
"NVSwitch",
"fabric error",
],
ErrorCategory.INITIALIZATION_FAILED: [
"initialization failed",
"failed to initialize",
"Init Failed",
"unable to start",
],
ErrorCategory.TIMEOUT: [
"timed out",
"timeout",
"deadline exceeded",
],
}


@dataclass
class ServiceStatus:
"""Result of a single systemd service check."""
name: str
active: bool # True if ActiveState == "active"
sub_state: str = "" # e.g. "running", "dead", "failed"
main_pid: int = 0
n_restarts: int = 0
start_timestamp: str = ""
error: Optional[str] = None # non-None if the check itself failed
Comment thread
dmvevents marked this conversation as resolved.


@dataclass
class FabricManagerStatus(ServiceStatus):
"""Extended status for Fabric Manager with journal analysis."""
journal_errors: List[ErrorCategory] = field(default_factory=list)
flapping: bool = False


class ServiceChecker:
"""Checks host systemd services via nsenter."""

def __init__(self, flap_window: int = 600, flap_threshold: int = 3):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self._flap_window = flap_window
self._flap_threshold = flap_threshold
# Track restart timestamps per service for flap detection
self._restart_history: Dict[str, deque] = {}
# Track last-seen restart count to detect new restarts
self._last_restart_count: Dict[str, int] = {}

def _run_host_cmd(self, cmd: List[str], timeout: int = 10) -> subprocess.CompletedProcess:
"""Run a command on the host via nsenter into PID 1's mount namespace."""
full_cmd = ["nsenter", "-t", "1", "-m", "--"] + cmd
return subprocess.run(
full_cmd,
capture_output=True,
text=True,
timeout=timeout,
)

def check_service(self, service_name: str) -> ServiceStatus:
"""Check a single systemd service via nsenter.

Parses systemctl show output for ActiveState, SubState, MainPID,
and ExecMainStartTimestamp. NRestarts is queried separately since
older systemd versions don't support it.
"""
try:
result = self._run_host_cmd([
"systemctl", "show", service_name,
"--property=ActiveState,SubState,MainPID,ExecMainStartTimestamp",
])
Comment on lines +94 to +97

@coderabbitai coderabbitai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Query and preserve LoadState.

This command does not request LoadState. A missing unit then becomes active=False, which is indistinguishable from a loaded but stopped unit. Downstream code can emit a false *_NOT_RUNNING or GPU_SERVICE_NOT_RUNNING event.

Request LoadState with ActiveState. If LoadState=not-found, return an explicit not-applicable status and skip the unit. Add coverage for absent Fabric Manager and GPU-support units.

Based on learnings, “If LoadState=not-found, it must skip the unit as not applicable and must not emit *_NOT_RUNNING or GPU_SERVICE_NOT_RUNNING.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demos/system-services-monitor/checks/service_check.py` around lines 94 - 97,
Update the systemctl query in the service-check command to request LoadState
alongside the existing properties, then preserve and inspect that value in the
service status handling. When LoadState is not-found, return the explicit
not-applicable result and skip the unit without emitting *_NOT_RUNNING or
GPU_SERVICE_NOT_RUNNING events; add coverage for absent Fabric Manager and
GPU-support units.

Source: Learnings

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed real, and it is the one genuine divergence from the implementation PR. #1382's service_check.py:133 requests --property=LoadState,ActiveState,SubState,MainPID,ExecMainStartTimestamp and keeps it at :161; this fork asks for the same set minus LoadState (checks/service_check.py:96) and has zero references to it, so not-found and masked are indistinguishable from inactive here. Leaving OPEN deliberately — it is the concrete evidence for the direction proposed in #1383 (comment) (import the shipped package instead of maintaining a second copy that has to re-earn this fix). Not resolving until that call is made.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmvevents, confirmed. The finding remains valid.

The demo copy diverges from #1382 because it does not request or preserve LoadState. The current code cannot distinguish not-found or masked from inactive.

Keeping this thread open is appropriate until the project decides whether to import the shipped package or maintain this duplicate implementation with the required behavior.


✏️ Learnings added
Learnt from: dmvevents
Repo: NVIDIA/NVSentinel PR: 1383
File: demos/system-services-monitor/checks/service_check.py:94-97
Timestamp: 2026-08-22T10:45:10.217Z
Learning: For NVIDIA/NVSentinel PR `#1383`, `demos/system-services-monitor/checks/service_check.py` is a duplicate of the implementation in PR `#1382` and currently diverges by omitting `LoadState` from the `systemctl show` query and status handling. This makes `LoadState=not-found` and `LoadState=masked` indistinguishable from `ActiveState=inactive`. The preferred direction under discussion is to import the shipped package instead of maintaining a second copy that must independently receive behavior fixes.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: dmvevents
Repo: NVIDIA/NVSentinel PR: 1383
File: demos/system-services-monitor/checks/service_check.py:1-5
Timestamp: 2026-08-22T10:44:42.920Z
Learning: If the standalone demos/system-services-monitor probe is retained, every Python file in this directory must include the repository-required Apache 2.0 license header. If the demo is deleted in favor of the shipped package, this guideline no longer applies.

You are interacting with an AI system.


if result.returncode != 0 and not result.stdout.strip():
return ServiceStatus(
name=service_name,
active=False,
error=f"systemctl show failed: {result.stderr.strip()}",
)

props = {}
for line in result.stdout.strip().splitlines():
if "=" in line:
key, _, value = line.partition("=")
props[key.strip()] = value.strip()

active_state = props.get("ActiveState", "unknown")

# NRestarts isn't available on older systemd; query separately
n_restarts = self._get_restart_count(service_name)

# Flap detection
self._update_flap_tracking(service_name, n_restarts)

return ServiceStatus(
name=service_name,
active=(active_state == "active"),
sub_state=props.get("SubState", ""),
main_pid=int(props.get("MainPID", "0")),
n_restarts=n_restarts,
start_timestamp=props.get("ExecMainStartTimestamp", ""),
)

except subprocess.TimeoutExpired:
return ServiceStatus(
name=service_name,
active=False,
error="systemctl show timed out",
)
except Exception as e:
return ServiceStatus(
name=service_name,
active=False,
error=str(e),
)

def _get_restart_count(self, service_name: str) -> int:
"""Get NRestarts from systemd, returning 0 if unsupported."""
try:
result = self._run_host_cmd([
"systemctl", "show", service_name, "--property=NRestarts",
])
if result.returncode == 0 and result.stdout.strip():
_, _, val = result.stdout.strip().partition("=")
return int(val)
except Exception:
pass
return 0

def _update_flap_tracking(self, service_name: str, current_restarts: int) -> None:
"""Track restart events for flap detection."""
if service_name not in self._restart_history:
self._restart_history[service_name] = deque()
self._last_restart_count[service_name] = current_restarts
return

last_count = self._last_restart_count[service_name]
if current_restarts > last_count:
# New restarts detected — record timestamp for each
now = time.monotonic()
for _ in range(current_restarts - last_count):
self._restart_history[service_name].append(now)
self._last_restart_count[service_name] = current_restarts

# Prune entries outside the flap window
cutoff = time.monotonic() - self._flap_window
history = self._restart_history[service_name]
while history and history[0] < cutoff:
history.popleft()

def is_flapping(self, service_name: str) -> bool:
"""Return True if the service has restarted too many times within the window."""
history = self._restart_history.get(service_name, deque())
return len(history) >= self._flap_threshold

def check_fabric_manager(self) -> FabricManagerStatus:
"""Check Fabric Manager with journal error analysis."""
base = self.check_service("nvidia-fabricmanager")

journal_errors = self._parse_journal_errors("nvidia-fabricmanager")
flapping = self.is_flapping("nvidia-fabricmanager")

return FabricManagerStatus(
name=base.name,
active=base.active,
sub_state=base.sub_state,
main_pid=base.main_pid,
n_restarts=base.n_restarts,
start_timestamp=base.start_timestamp,
error=base.error,
journal_errors=journal_errors,
flapping=flapping,
)

def _parse_journal_errors(self, service_name: str) -> List[ErrorCategory]:
"""Scan recent journal entries for known error patterns."""
try:
result = self._run_host_cmd([
"journalctl", "-u", service_name,
"--since", "5 minutes ago",
"--no-pager", "-q",
], timeout=15)

if result.returncode != 0 or not result.stdout.strip():
return []

found: List[ErrorCategory] = []
text = result.stdout.lower()
for category, patterns in _ERROR_PATTERNS.items():
if any(p.lower() in text for p in patterns):
found.append(category)

return found

except (subprocess.TimeoutExpired, Exception) as e:
logger.warning("Journal parsing failed for %s: %s", service_name, e)
return []
Comment on lines +209 to +222

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve journal probe failures as UNKNOWN.

A non-zero journalctl result, timeout, or exception returns []. check_fabric_manager then receives a status that is indistinguishable from a successful journal probe with no errors.

Return or raise an explicit probe error. Propagate it through FabricManagerStatus so the monitor can record a check error and suppress HealthEvent emission.

Based on learnings, “A failed or timed-out probe is UNKNOWN state, not service-down.”

🧰 Tools
🪛 Ruff (0.16.1)

[warning] 220-220: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demos/system-services-monitor/checks/service_check.py` around lines 209 -
222, The journal parsing flow around the error-pattern scan must distinguish
probe failures from successful probes with no matches: update the non-zero
return-code/empty-output branch and the `except` handling to return or raise an
explicit probe error, then propagate that failure through `check_fabric_manager`
and `FabricManagerStatus` so the monitor records a check error, treats the state
as UNKNOWN, and suppresses `HealthEvent` emission.

Source: Learnings

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The finding is right, with one correction to the framing: this is not fork drift. #1382 has the identical shape — _parse_journal_errors returns [] on result.returncode != 0 (:298) and swallows into except (subprocess.TimeoutExpired, Exception)return [] (:309), so a failed or timed-out journal probe is indistinguishable from a clean one in the shipping package as well, not just in this demo. Worth fixing once against #1382's ServiceStatus/FabricManagerStatus (an explicit probe-error field the monitor can use to suppress HealthEvent emission) rather than patching the fork. Leaving OPEN and flagging it to @XRFXLP / @deesharma24 for #1382.


def check_all_gpu_services(self, service_names: List[str]) -> Dict[str, ServiceStatus]:
"""Check all configured GPU services."""
results = {}
for name in service_names:
results[name] = self.check_service(name)
return results
Loading