diff --git a/etc/kayobe/ansible/scripts/smartmon.py b/etc/kayobe/ansible/scripts/smartmon.py new file mode 100644 index 000000000..202e6981c --- /dev/null +++ b/etc/kayobe/ansible/scripts/smartmon.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 + +import subprocess +import json +import re +import datetime + +from pySMART import DeviceList + +SMARTCTL_PATH = "/usr/sbin/smartctl" + +SMARTMON_ATTRS = { + "airflow_temperature_cel", + "command_timeout", + "current_pending_sector", + "end_to_end_error", + "erase_fail_count", + "g_sense_error_rate", + "hardware_ecc_recovered", + "host_reads_32mib", + "host_reads_mib", + "host_writes_32mib", + "host_writes_mib", + "load_cycle_count", + "media_wearout_indicator", + "nand_writes_1gib", + "offline_uncorrectable", + "power_cycle_count", + "power_on_hours", + "program_fail_cnt_total", + "program_fail_count", + "raw_read_error_rate", + "reallocated_event_count", + "reallocated_sector_ct", + "reported_uncorrect", + "runtime_bad_block", + "sata_downshift_count", + "seek_error_rate", + "spin_retry_count", + "spin_up_time", + "start_stop_count", + "temperature_case", + "temperature_celsius", + "temperature_internal", + "total_lbas_read", + "total_lbas_written", + "udma_crc_error_count", + "unsafe_shutdown_count", + "unused_rsvd_blk_cnt_tot", + "wear_leveling_count", + "workld_host_reads_perc", + "workld_media_wear_indic", + "workload_minutes", + "critical_warning", + "temperature", + "available_spare", + "available_spare_threshold", + "percentage_used", + "data_units_read", + "data_units_written", + "host_reads", + "host_writes", + "controller_busy_time", + "power_cycles", + "unsafe_shutdowns", + "media_errors", + "num_err_log_entries", + "warning_temp_time", + "critical_comp_time", +} + +def run_command(command, parse_json=False): + """ + Helper to run a subprocess command and optionally parse JSON output. + """ + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if parse_json: + return json.loads(result.stdout) + return result.stdout.strip() + +def camel_to_snake(name): + """ + Convert a CamelCase string to snake_case. + + Reference: https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case + """ + return re.sub(r'(? 1, otherwise => 0 + assessment = device_info.get("assessment", "").upper() + if assessment in ["PASS", "WARN", "FAIL"]: + expected_val = 1 if assessment == "PASS" else 0 + smart_healthy_found = any( + line.startswith("device_smart_healthy{") and + f'disk="{dev_name}"' in line and + line.endswith(f" {expected_val}") + for line in metrics + ) + self.assertTrue( + smart_healthy_found, + f"Expected device_smart_healthy={expected_val} for {dev_name}, not found." + ) + + def test_parse_device_info(self): + """ + Test parse_device_info() for every JSON fixture in ./tests/. + Each fixture is tested individually with clear error reporting. + """ + for fixture_path in self.fixture_files: + fixture_name = os.path.basename(fixture_path) + with self.subTest(fixture=fixture_name): + self._test_parse_device_info(fixture_name) + + def _test_parse_if_attributes(self, fixture_name): + """ + Helper method to test parse_if_attributes() for a single JSON fixture. + """ + data = load_json_fixture(fixture_name) + device_info = data["device_info"] + if_attrs = data.get("if_attributes", {}) + + device = self.create_mock_device_from_json(device_info, if_attrs) + metrics = parse_if_attributes(device) + + dev_name = device_info["name"] + dev_iface = device_info["interface"] + dev_serial = device_info["serial"].lower() + + # For each numeric attribute in JSON, if it's in SMARTMON_ATTRS, + # we expect a line in the script's output. + for attr_key, attr_val in if_attrs.items(): + snake_key = camel_to_snake(attr_key) + + if isinstance(attr_val, (int, float)) and snake_key in SMARTMON_ATTRS: + # We expect e.g. critical_warning{disk="/dev/..."} + expected_line = ( + f"{snake_key}{{disk=\"{dev_name}\",type=\"{dev_iface}\",serial_number=\"{dev_serial}\"}} {attr_val}" + ) + self.assertIn( + expected_line, + metrics, + f"Expected metric '{expected_line}' for attribute '{attr_key}' not found." + ) + else: + # If it's not in SMARTMON_ATTRS or not numeric, + # we do NOT expect a line with that name+value + unexpected_line = ( + f"{snake_key}{{disk=\"{dev_name}\",type=\"{dev_iface}\",serial_number=\"{dev_serial}\"}} {attr_val}" + ) + self.assertNotIn( + unexpected_line, + metrics, + f"Unexpected metric '{unexpected_line}' found for {attr_key}." + ) + + # Also ensure that non-numeric or disallowed attributes do not appear + # For instance "notInSmartmonAttrs" should never appear. + for line in metrics: + self.assertNotIn( + "not_in_smartmon_attrs", + line, + f"'notInSmartmonAttrs' attribute unexpectedly found in metric line: {line}" + ) + + def test_parse_if_attributes(self): + """ + Test parse_if_attributes() for every JSON fixture in ./tests/. + Each fixture is tested individually with clear error reporting. + """ + for fixture_path in self.fixture_files: + fixture_name = os.path.basename(fixture_path) + with self.subTest(fixture=fixture_name): + self._test_parse_if_attributes(fixture_name) + + @patch("smartmon.run_command") + @patch("smartmon.DeviceList") + def test_main(self, mock_devicelist_class, mock_run_cmd): + """ + End-to-end test of main() for every JSON fixture in ./tests/. + This ensures we can handle multiple disks (multiple fixture files). + """ + for fixture_path in self.fixture_files: + fixture_name = os.path.basename(fixture_path) + with self.subTest(msg=f"Testing main() with {fixture_name}"): + data = load_json_fixture(fixture_name) + device_info = data["device_info"] + if_attrs = data.get("if_attributes", {}) + + # Patch run_command to return a version & "active" power_mode + def run_command_side_effect(cmd, parse_json=False): + if "--version" in cmd: + return "smartctl 7.3 5422 [x86_64-linux-5.15.0]\n..." + if "-n" in cmd and "standby" in cmd and parse_json: + return {"power_mode": "active"} + return "" + + mock_run_cmd.side_effect = run_command_side_effect + + # Mock a single device from the fixture + device_mock = self.create_mock_device_from_json(device_info, if_attrs) + + # Make DeviceList() return our single mock device + mock_dev_list = MagicMock() + mock_dev_list.devices = [device_mock] + mock_devicelist_class.return_value = mock_dev_list + + with patch("builtins.print") as mock_print: + main() + + printed_lines = [] + for call_args in mock_print.call_args_list: + printed_lines.extend(call_args[0][0].split("\n")) + dev_name = device_info["name"] + dev_iface = device_info["interface"] + dev_serial = device_info["serial"].lower() + + # We expect a line for the run timestamp, e.g.: + # smartmon_smartctl_run{disk="/dev/...",type="..."} 1671234567 + run_line_found = any( + line.startswith("smartmon_smartctl_run{") and + f'disk="{dev_name}"' in line and + f'type="{dev_iface}"' in line + for line in printed_lines + ) + self.assertTrue( + run_line_found, + f"Expected 'smartmon_smartctl_run' metric line for {dev_name} not found." + ) + + # Because we mocked "power_mode": "active", we expect device_active=1 + active_line_found = any( + line.startswith("smartmon_device_active{") and + f'disk="{dev_name}"' in line and + f'serial_number="{dev_serial}"' in line and + line.endswith(" 1") + for line in printed_lines + ) + self.assertTrue( + active_line_found, + f"Expected 'device_active{{...}} 1' line for {dev_name} not found." + ) + +if __name__ == "__main__": + unittest.main() diff --git a/etc/kayobe/ansible/scripts/tests/nvme.json b/etc/kayobe/ansible/scripts/tests/nvme.json new file mode 100644 index 000000000..bbff19ec0 --- /dev/null +++ b/etc/kayobe/ansible/scripts/tests/nvme.json @@ -0,0 +1,24 @@ +{ + "device_info": { + "name": "/dev/nvme0", + "interface": "nvme", + "vendor": "AcmeCorp", + "family": "Acme NVMe Family", + "model": "Acme NVMe 1TB", + "serial": "ABCD1234", + "firmware": "3.0.1", + "smart_capable": true, + "smart_enabled": true, + "assessment": "PASS" + }, + "if_attributes": { + "criticalWarning": 0, + "temperature": 36, + "availableSpare": 100, + "availableSpareThreshold": 10, + "percentageUsed": 0, + "dataUnitsRead": 117446405, + "dataUnitsWritten": 84630284, + "notInSmartmonAttrs": 999 + } +}