Skip to content

Commit 3b63849

Browse files
committed
check_nvme_health: Add NVMe disk remaining life check
Discovers NVMe devices via /sys/class/nvme and queries percent_used from nvme-cli smart-log to report remaining drive life. Raises WARNING at <=20% and CRITICAL at <=10% remaining life (configurable via -w/-c).
1 parent 6963acf commit 3b63849

1 file changed

Lines changed: 136 additions & 0 deletions

File tree

src/check_nvme_health.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#!/usr/bin/env python3
2+
"""InnoGames Monitoring Plugins - NVMe Health Check
3+
4+
This script checks the health of NVMe disks by discovering devices via sysfs
5+
and querying SMART data with nvme-cli. It raises a warning or critical state
6+
when the remaining life falls below the configured thresholds.
7+
8+
Copyright (c) 2026 InnoGames GmbH
9+
"""
10+
# Permission is hereby granted, free of charge, to any person obtaining a copy
11+
# of this software and associated documentation files (the "Software"), to deal
12+
# in the Software without restriction, including without limitation the rights
13+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14+
# copies of the Software, and to permit persons to whom the Software is
15+
# furnished to do so, subject to the following conditions:
16+
#
17+
# The above copyright notice and this permission notice shall be included in
18+
# all copies or substantial portions of the Software.
19+
#
20+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
23+
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
26+
# THE SOFTWARE.
27+
28+
import json
29+
import os
30+
import sys
31+
from argparse import ArgumentParser
32+
from subprocess import CalledProcessError, STDOUT, check_output
33+
34+
35+
class ExitCodes():
36+
OK = 0
37+
WARNING = 1
38+
CRITICAL = 2
39+
UNKNOWN = 3
40+
41+
@classmethod
42+
def label(cls, code):
43+
return {cls.OK: 'OK', cls.WARNING: 'WARNING', cls.CRITICAL: 'CRITICAL', cls.UNKNOWN: 'UNKNOWN'}[code]
44+
45+
46+
def parse_args():
47+
parser = ArgumentParser(
48+
description='Check NVMe disk remaining life via nvme-cli smart-log',
49+
)
50+
parser.add_argument(
51+
'--warning', '-w',
52+
help='warning threshold for remaining life percentage (default: 20)',
53+
default=20,
54+
type=int,
55+
)
56+
parser.add_argument(
57+
'--critical', '-c',
58+
help='critical threshold for remaining life percentage (default: 10)',
59+
default=10,
60+
type=int,
61+
)
62+
return parser.parse_args()
63+
64+
65+
def main():
66+
args = parse_args()
67+
68+
devices = get_nvme_devices()
69+
if not devices:
70+
print('UNKNOWN - No NVMe devices found in /sys/class/nvme')
71+
sys.exit(ExitCodes.UNKNOWN)
72+
73+
code = ExitCodes.OK
74+
summaries = []
75+
perfdata = []
76+
77+
for device in devices:
78+
name = os.path.basename(device)
79+
80+
try:
81+
smart = get_smart_log(device)
82+
except CalledProcessError as e:
83+
print(f'UNKNOWN - Failed to query {device}: {e.output.decode().strip()}')
84+
sys.exit(ExitCodes.UNKNOWN)
85+
except (OSError, ValueError) as e:
86+
print(f'UNKNOWN - Error reading {device}: {e}')
87+
sys.exit(ExitCodes.UNKNOWN)
88+
89+
percent_used = smart.get('percent_used')
90+
if percent_used is None:
91+
print(f'UNKNOWN - percent_used missing in smart-log output for {device}')
92+
sys.exit(ExitCodes.UNKNOWN)
93+
94+
remaining = 100 - percent_used
95+
96+
if remaining <= args.critical:
97+
code = max(code, ExitCodes.CRITICAL)
98+
elif remaining <= args.warning:
99+
code = max(code, ExitCodes.WARNING)
100+
101+
summaries.append(f'{name} life={remaining}%')
102+
perfdata.append(f'{name}_life={remaining}%;{args.warning};{args.critical};0;100')
103+
104+
status = ExitCodes.label(code)
105+
if len(summaries) == 1:
106+
lines = [f'{status} - {summaries[0]} | {perfdata[0]}']
107+
else:
108+
lines = [status]
109+
for summary in summaries[:-1]:
110+
lines.append(summary)
111+
lines.append(f'{summaries[-1]} | {perfdata[0]}')
112+
for perf in perfdata[1:]:
113+
lines.append(perf)
114+
print('\n'.join(lines))
115+
sys.exit(code)
116+
117+
118+
def get_nvme_devices():
119+
"""Return sorted list of /dev/nvmeN device paths discovered via sysfs"""
120+
sysfs_path = '/sys/class/nvme'
121+
if not os.path.exists(sysfs_path):
122+
return []
123+
return sorted('/dev/' + entry for entry in os.listdir(sysfs_path))
124+
125+
126+
def get_smart_log(device):
127+
"""Run nvme smart-log on device and return parsed JSON dict"""
128+
raw = check_output(
129+
['nvme', 'smart-log', '--output-format=json', device],
130+
stderr=STDOUT,
131+
)
132+
return json.loads(raw.decode())
133+
134+
135+
if __name__ == '__main__':
136+
main()

0 commit comments

Comments
 (0)