Skip to content

Commit 44601ca

Browse files
committed
Use prometheus_client for writing out metrics
1 parent aad34c1 commit 44601ca

File tree

2 files changed

+117
-83
lines changed

2 files changed

+117
-83
lines changed

etc/kayobe/ansible/scripts/smartmon.py

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import json
55
import re
66
import datetime
7+
import os
78

9+
from prometheus_client import CollectorRegistry, Gauge, write_to_textfile
810
from pySMART import DeviceList
911

1012
SMARTCTL_PATH = "/usr/sbin/smartctl"
@@ -110,21 +112,24 @@ def parse_device_info(device):
110112
"serial_number": serial_number,
111113
"firmware_version": device.firmware or "",
112114
}
113-
label_str = ",".join(f'{k}="{v}"' for k, v in labels.items())
115+
sorted_labels = sorted(labels.items())
116+
label_str = ",".join(f'{k}="{v}"' for k, v in sorted_labels)
117+
118+
metric_labels = f'disk="{device.name}",serial_number="{serial_number}",type="{device.interface}"'
114119

115120
metrics = [
116-
f'device_info{{{label_str}}} 1',
117-
f'device_smart_available{{disk="{device.name}",type="{device.interface}",serial_number="{serial_number}"}} {1 if device.smart_capable else 0}',
121+
f'device_info{{{label_str}}} 1.0',
122+
f'device_smart_available{{{metric_labels}}} {float(1) if device.smart_capable else float(0)}',
118123
]
119124

120125
if device.smart_capable:
121126
metrics.append(
122-
f'device_smart_enabled{{disk="{device.name}",type="{device.interface}",serial_number="{serial_number}"}} {1 if device.smart_enabled else 0}'
127+
f'device_smart_enabled{{{metric_labels}}} {float(1) if device.smart_enabled else float(0)}'
123128
)
124129
if device.assessment:
125130
is_healthy = 1 if device.assessment.upper() == "PASS" else 0
126131
metrics.append(
127-
f'device_smart_healthy{{disk="{device.name}",type="{device.interface}",serial_number="{serial_number}"}} {is_healthy}'
132+
f'device_smart_healthy{{{metric_labels}}} {float(is_healthy)}'
128133
)
129134

130135
return metrics
@@ -143,7 +148,7 @@ def parse_if_attributes(device):
143148
disk = device.name
144149
disk_type = device.interface or ""
145150
serial_number = (device.serial or "").lower()
146-
labels = f'disk="{disk}",type="{disk_type}",serial_number="{serial_number}"'
151+
labels = f'disk="{disk}",serial_number="{serial_number}",type="{disk_type}"'
147152

148153
# Inspect all public attributes on device.if_attributes
149154
for attr_name in dir(device.if_attributes):
@@ -156,27 +161,48 @@ def parse_if_attributes(device):
156161
snake_name = camel_to_snake(attr_name)
157162

158163
if snake_name in SMARTMON_ATTRS and isinstance(val, (int, float)):
159-
metrics.append(f"{snake_name}{{{labels}}} {val}")
164+
metrics.append(f"{snake_name}{{{labels}}} {float(val)}")
160165

161166
return metrics
162167

163-
def format_output(metrics):
168+
def write_metrics_to_textfile(metrics, output_path=None):
164169
"""
165-
Convert a list of lines like "some_metric{...} value"
166-
into a Prometheus text output with # HELP / # TYPE lines.
170+
Write metrics to a Prometheus textfile using prometheus_client.
171+
Args:
172+
metrics (List[str]): List of metric strings in 'name{labels} value' format.
173+
output_path (str): Path to write the metrics file. Defaults to node_exporter textfile collector path.
167174
"""
168-
output = []
169-
last_metric = ""
170-
for metric in sorted(metrics):
171-
metric_name = metric.split("{")[0]
172-
if metric_name != last_metric:
173-
output.append(f"# HELP smartmon_{metric_name} SMART metric {metric_name}")
174-
output.append(f"# TYPE smartmon_{metric_name} gauge")
175-
last_metric = metric_name
176-
output.append(f"smartmon_{metric}")
177-
return "\n".join(output)
178-
179-
def main():
175+
registry = CollectorRegistry()
176+
metric_gauges = {}
177+
for metric in metrics:
178+
# Split metric into name, labels, and value
179+
metric_name, rest = metric.split('{', 1)
180+
label_str, value = rest.split('}', 1)
181+
value = value.strip()
182+
# Parse labels into a dictionary
183+
labels = {}
184+
label_keys = []
185+
label_values = []
186+
for label in label_str.split(','):
187+
if '=' in label:
188+
k, v = label.split('=', 1)
189+
k = k.strip()
190+
v = v.strip('"')
191+
labels[k] = v
192+
label_keys.append(k)
193+
label_values.append(v)
194+
help_str = f"SMART metric {metric_name}"
195+
# Create Gauge if not already present
196+
if metric_name not in metric_gauges:
197+
metric_gauges[metric_name] = Gauge(metric_name, help_str, label_keys, registry=registry)
198+
# Set metric value
199+
gauge = metric_gauges[metric_name]
200+
gauge.labels(*label_values).set(float(value))
201+
if output_path is None:
202+
output_path = '/var/lib/node_exporter/textfile_collector/smartmon.prom'
203+
write_to_textfile(output_path, registry) # Write all metrics to file
204+
205+
def main(output_path=None):
180206
all_metrics = []
181207

182208
try:
@@ -197,7 +223,7 @@ def main():
197223
disk_type = dev.interface or ""
198224
serial_number = (dev.serial or "").lower()
199225

200-
run_timestamp = int(datetime.datetime.now(datetime.UTC).timestamp())
226+
run_timestamp = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
201227
all_metrics.append(f'smartctl_run{{disk="{disk_name}",type="{disk_type}"}} {run_timestamp}')
202228

203229
active = 1
@@ -220,7 +246,11 @@ def main():
220246
all_metrics.extend(parse_device_info(dev))
221247
all_metrics.extend(parse_if_attributes(dev))
222248

223-
print(format_output(all_metrics))
249+
write_metrics_to_textfile(all_metrics, output_path)
224250

225251
if __name__ == "__main__":
226-
main()
252+
import argparse
253+
parser = argparse.ArgumentParser(description="Export SMART metrics to Prometheus textfile format.")
254+
parser.add_argument('--output', type=str, default=None, help='Output path for Prometheus textfile (default: /var/lib/node_exporter/textfile_collector/smartmon.prom)')
255+
args = parser.parse_args()
256+
main(args.output)

etc/kayobe/ansible/scripts/test_smartmon.py

Lines changed: 62 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import glob
22
import json
33
import os
4-
import re
54
import unittest
5+
import tempfile
6+
import math
7+
from time import sleep
68

79
from unittest.mock import patch, MagicMock
8-
910
from smartmon import (
1011
parse_device_info,
1112
parse_if_attributes,
1213
main,
1314
SMARTMON_ATTRS,
14-
camel_to_snake
15+
camel_to_snake,
16+
write_metrics_to_textfile,
1517
)
1618

1719
def load_json_fixture(filename):
@@ -75,7 +77,6 @@ def _test_parse_device_info(self, fixture_name):
7577
dev_serial = device_info["serial"].lower()
7678

7779
# The device_info line should exist for every device
78-
# e.g. device_info{disk="/dev/...",type="...",serial_number="..."} 1
7980
device_info_found = any(
8081
line.startswith("device_info{") and
8182
f'disk="{dev_name}"' in line and
@@ -94,32 +95,32 @@ def _test_parse_device_info(self, fixture_name):
9495
line.startswith("device_smart_available{") and
9596
f'disk="{dev_name}"' in line and
9697
f'serial_number="{dev_serial}"' in line and
97-
line.endswith(" 1")
98+
line.endswith(" 1.0")
9899
for line in metrics
99100
)
100101
self.assertTrue(
101102
smart_available_found,
102-
f"Expected device_smart_available=1 for {dev_name}, not found."
103+
f"Expected device_smart_available=1.0 for {dev_name}, not found."
103104
)
104105

105106
# If smart_enabled is true, we expect device_smart_enabled = 1
106107
if device_info.get("smart_enabled"):
107108
smart_enabled_found = any(
108109
line.startswith("device_smart_enabled{") and
109110
f'disk="{dev_name}"' in line and
110-
line.endswith(" 1")
111+
line.endswith(" 1.0")
111112
for line in metrics
112113
)
113114
self.assertTrue(
114115
smart_enabled_found,
115-
f"Expected device_smart_enabled=1 for {dev_name}, not found."
116+
f"Expected device_smart_enabled=1.0 for {dev_name}, not found."
116117
)
117118

118119
# device_smart_healthy if assessment in [PASS, WARN, FAIL]
119120
# PASS => 1, otherwise => 0
120121
assessment = device_info.get("assessment", "").upper()
121122
if assessment in ["PASS", "WARN", "FAIL"]:
122-
expected_val = 1 if assessment == "PASS" else 0
123+
expected_val = float(1) if assessment == "PASS" else float(0)
123124
smart_healthy_found = any(
124125
line.startswith("device_smart_healthy{") and
125126
f'disk="{dev_name}"' in line and
@@ -162,9 +163,8 @@ def _test_parse_if_attributes(self, fixture_name):
162163
snake_key = camel_to_snake(attr_key)
163164

164165
if isinstance(attr_val, (int, float)) and snake_key in SMARTMON_ATTRS:
165-
# We expect e.g. critical_warning{disk="/dev/..."} <value>
166166
expected_line = (
167-
f"{snake_key}{{disk=\"{dev_name}\",type=\"{dev_iface}\",serial_number=\"{dev_serial}\"}} {attr_val}"
167+
f"{snake_key}{{disk=\"{dev_name}\",serial_number=\"{dev_serial}\",type=\"{dev_iface}\"}} {float(attr_val)}"
168168
)
169169
self.assertIn(
170170
expected_line,
@@ -175,7 +175,7 @@ def _test_parse_if_attributes(self, fixture_name):
175175
# If it's not in SMARTMON_ATTRS or not numeric,
176176
# we do NOT expect a line with that name+value
177177
unexpected_line = (
178-
f"{snake_key}{{disk=\"{dev_name}\",type=\"{dev_iface}\",serial_number=\"{dev_serial}\"}} {attr_val}"
178+
f"{snake_key}{{disk=\"{dev_name}\",serial_number=\"{dev_serial}\",type=\"{dev_iface}\"}} {float(attr_val)}"
179179
)
180180
self.assertNotIn(
181181
unexpected_line,
@@ -204,28 +204,32 @@ def test_parse_if_attributes(self):
204204

205205
@patch("smartmon.run_command")
206206
@patch("smartmon.DeviceList")
207-
def test_main(self, mock_devicelist_class, mock_run_cmd):
207+
@patch("smartmon.write_metrics_to_textfile", wraps=write_metrics_to_textfile)
208+
def test_main(self, mock_write_metrics, mock_devicelist_class, mock_run_cmd):
208209
"""
209210
End-to-end test of main() for every JSON fixture in ./tests/.
210211
This ensures we can handle multiple disks (multiple fixture files).
212+
Checks metrics written to a temp file, and that write_metrics_to_textfile is called once.
211213
"""
214+
215+
# Patch run_command to return a version & "active" power_mode
216+
def run_command_side_effect(cmd, parse_json=False):
217+
if "--version" in cmd:
218+
return "smartctl 7.3 5422 [x86_64-linux-5.15.0]\n..."
219+
if "-n" in cmd and "standby" in cmd and parse_json:
220+
return {"power_mode": "active"}
221+
return ""
222+
223+
mock_run_cmd.side_effect = run_command_side_effect
224+
212225
for fixture_path in self.fixture_files:
213226
fixture_name = os.path.basename(fixture_path)
214227
with self.subTest(msg=f"Testing main() with {fixture_name}"):
228+
mock_write_metrics.reset_mock()
215229
data = load_json_fixture(fixture_name)
216230
device_info = data["device_info"]
217231
if_attrs = data.get("if_attributes", {})
218232

219-
# Patch run_command to return a version & "active" power_mode
220-
def run_command_side_effect(cmd, parse_json=False):
221-
if "--version" in cmd:
222-
return "smartctl 7.3 5422 [x86_64-linux-5.15.0]\n..."
223-
if "-n" in cmd and "standby" in cmd and parse_json:
224-
return {"power_mode": "active"}
225-
return ""
226-
227-
mock_run_cmd.side_effect = run_command_side_effect
228-
229233
# Mock a single device from the fixture
230234
device_mock = self.create_mock_device_from_json(device_info, if_attrs)
231235

@@ -234,41 +238,41 @@ def run_command_side_effect(cmd, parse_json=False):
234238
mock_dev_list.devices = [device_mock]
235239
mock_devicelist_class.return_value = mock_dev_list
236240

237-
with patch("builtins.print") as mock_print:
238-
main()
239-
240-
printed_lines = []
241-
for call_args in mock_print.call_args_list:
242-
printed_lines.extend(call_args[0][0].split("\n"))
243-
dev_name = device_info["name"]
244-
dev_iface = device_info["interface"]
245-
dev_serial = device_info["serial"].lower()
246-
247-
# We expect a line for the run timestamp, e.g.:
248-
# smartmon_smartctl_run{disk="/dev/...",type="..."} 1671234567
249-
run_line_found = any(
250-
line.startswith("smartmon_smartctl_run{") and
251-
f'disk="{dev_name}"' in line and
252-
f'type="{dev_iface}"' in line
253-
for line in printed_lines
254-
)
255-
self.assertTrue(
256-
run_line_found,
257-
f"Expected 'smartmon_smartctl_run' metric line for {dev_name} not found."
258-
)
259-
260-
# Because we mocked "power_mode": "active", we expect device_active=1
261-
active_line_found = any(
262-
line.startswith("smartmon_device_active{") and
263-
f'disk="{dev_name}"' in line and
264-
f'serial_number="{dev_serial}"' in line and
265-
line.endswith(" 1")
266-
for line in printed_lines
267-
)
268-
self.assertTrue(
269-
active_line_found,
270-
f"Expected 'device_active{{...}} 1' line for {dev_name} not found."
271-
)
241+
with tempfile.NamedTemporaryFile(mode="r+", delete_on_close=False) as tmpfile:
242+
path= tmpfile.name
243+
main(output_path=path)
244+
tmpfile.close()
245+
246+
# Ensure write_metrics_to_textfile was called once
247+
self.assertEqual(mock_write_metrics.call_count, 1)
248+
249+
with open(path, "r") as f:
250+
# Read the metrics from the file
251+
metrics_lines = [line.strip() for line in f.readlines() if line.strip() and not line.startswith('#')]
252+
print(f"Metrics lines: {metrics_lines}")
253+
254+
# Generate expected metrics using the parse functions
255+
expected_metrics = []
256+
expected_metrics.extend(parse_device_info(device_mock))
257+
expected_metrics.extend(parse_if_attributes(device_mock))
258+
259+
# Check that all expected metrics are present in the file
260+
for expected in expected_metrics:
261+
exp_metric, exp_val_str = expected.rsplit(" ", 1)
262+
exp_val = float(exp_val_str)
263+
found = any(
264+
(exp_metric in line) and
265+
math.isclose(float(line.rsplit(" ", 1)[1]), exp_val)
266+
for line in metrics_lines
267+
)
268+
self.assertTrue(found, f"Expected metric '{expected}' not found")
269+
270+
# Check that smartctl_version metric is present
271+
version_found = any(line.startswith("smartctl_version{") for line in metrics_lines)
272+
self.assertTrue(version_found, "Expected 'smartctl_version' metric not found in output file.")
273+
274+
# Check that the output file is not empty
275+
self.assertTrue(metrics_lines, "Metrics output file is empty.")
272276

273277
if __name__ == "__main__":
274278
unittest.main()

0 commit comments

Comments
 (0)