From 811bee956b1806f4ddfcd1f7af94272bd8613f7d Mon Sep 17 00:00:00 2001 From: Grant Slater Date: Mon, 10 Nov 2025 15:16:38 +0000 Subject: [PATCH] T7101: Add support for hardware watchdog support via systemd --- data/templates/system/watchdog.conf.j2 | 5 + interface-definitions/system_watchdog.xml.in | 70 +++++++++ smoketest/scripts/cli/test_system_watchdog.py | 62 ++++++++ src/conf_mode/system_watchdog.py | 138 ++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 data/templates/system/watchdog.conf.j2 create mode 100644 interface-definitions/system_watchdog.xml.in create mode 100644 smoketest/scripts/cli/test_system_watchdog.py create mode 100755 src/conf_mode/system_watchdog.py diff --git a/data/templates/system/watchdog.conf.j2 b/data/templates/system/watchdog.conf.j2 new file mode 100644 index 0000000000..d5f7e5ec4a --- /dev/null +++ b/data/templates/system/watchdog.conf.j2 @@ -0,0 +1,5 @@ +### Autogenerated by system_watchdog.py ### +[Manager] +RuntimeWatchdogSec={{ timeout }} +ShutdownWatchdogSec={{ shutdown_timeout }} +RebootWatchdogSec={{ reboot_timeout }} diff --git a/interface-definitions/system_watchdog.xml.in b/interface-definitions/system_watchdog.xml.in new file mode 100644 index 0000000000..c390ad6a9c --- /dev/null +++ b/interface-definitions/system_watchdog.xml.in @@ -0,0 +1,70 @@ + + + + + + + Hardware watchdog configuration + 9999 + + + + + Kernel module to load for watchdog device (optional) + + txt + Module name (e.g. 'softdog', 'iTCO_wdt', 'sp5100_tco') + + + [a-zA-Z0-9_\-]+ + + Module name must be alphanumeric/underscore/hyphen + + + + + Watchdog timeout for runtime in seconds (1-86400) + + u32:1-86400 + Seconds + + + + + Timeout must be between 1 and 86400 seconds + + 10 + + + + Watchdog timeout during shutdown in seconds (1-86400) + + u32:60-86400 + Seconds + + + + + Shutdown timeout must be between 60 and 86400 seconds + + 120 + + + + Watchdog timeout during reboot in seconds (60-86400) + + u32:60-86400 + Seconds + + + + + Reboot timeout must be between 60 and 86400 seconds + + 120 + + + + + + diff --git a/smoketest/scripts/cli/test_system_watchdog.py b/smoketest/scripts/cli/test_system_watchdog.py new file mode 100644 index 0000000000..5d8b9efbfe --- /dev/null +++ b/smoketest/scripts/cli/test_system_watchdog.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# Copyright VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import unittest + +from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.utils.process import cmd + +base_path = ['system', 'watchdog'] + + +class TestSystemWatchdog(VyOSUnitTestSHIM.TestCase): + def tearDown(self): + self.cli_delete(base_path) + self.cli_commit() + super().tearDown() + + def test_enable_watchdog_softdog(self): + """Configure watchdog (presence enables) with softdog and check state""" + # Presence of 'system watchdog' enables watchdog; set module to softdog + self.cli_set(base_path) + self.cli_set(base_path + ['module', 'softdog']) + self.cli_commit() + # Check if softdog module is loaded + lsmod = cmd('lsmod') + self.assertIn('softdog', lsmod) + # Check /dev/watchdog0 exists + self.assertTrue( + os.path.exists('/dev/watchdog0'), '/dev/watchdog0 does not exist' + ) + # Check systemd config file exists + config_path = '/run/systemd/system.conf.d/watchdog.conf' + self.assertTrue( + os.path.exists(config_path), f"Systemd config file not found: {config_path}" + ) + + def test_invalid_module_rejected(self): + """Verify that a non-existent watchdog module causes commit failure""" + # Choose a module name unlikely to exist; include a prefix to avoid collision with real names + bogus_module = 'zzzx_watchdog_unit_test_fake' + self.cli_set(base_path) + self.cli_set(base_path + ['module', bogus_module]) + # Commit should fail due to verify() raising ConfigError on modprobe dry-run failure + with self.assertRaisesRegex(Exception, r"Watchdog module '.*' was not found"): + self.cli_commit() + + +if __name__ == '__main__': + unittest.main(verbosity=2, failfast=VyOSUnitTestSHIM.TestCase.debug_on()) diff --git a/src/conf_mode/system_watchdog.py b/src/conf_mode/system_watchdog.py new file mode 100755 index 0000000000..97f9878c24 --- /dev/null +++ b/src/conf_mode/system_watchdog.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from sys import exit +from pathlib import Path + +from vyos.config import Config +from vyos.template import render +from vyos.utils.process import call, run +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + +watchdog_config_dir = Path('/run/systemd/system.conf.d') +watchdog_config_file = Path(watchdog_config_dir / 'watchdog.conf') +modules_load_directory = Path('/run/modules-load.d') +modules_load_file = Path(modules_load_directory / 'watchdog.conf') +WATCHDOG_DEV = Path('/dev/watchdog0') + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'watchdog'] + + if not conf.exists(base): + return None + + watchdog = conf.get_config_dict( + base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True + ) + + return watchdog + + +def verify(watchdog): + if watchdog is None: + return None + + module = watchdog.get('module') + device_exists = WATCHDOG_DEV.exists() + + # Require a usable watchdog: either device already present or a module provided + if not module and not device_exists: + raise ConfigError( + "No watchdog device found at /dev/watchdog0 and no module configured. " + "Either configure 'system watchdog module ' or ensure the kernel auto-loads a watchdog driver." + ) + + # If a module is provided, verify it resolves via modprobe (dry-run) + if module: + # Dry-run modprobe (-n) in quiet mode (-q) verifies availability without loading + rc = run(f'modprobe -n -q {module}') + if rc != 0: + raise ConfigError( + f"Watchdog module '{module}' was not found or cannot be loaded" + ) + + return None + + +def generate(watchdog): + # If watchdog node removed entirely, clean up everything + if watchdog is None: + watchdog_config_file.unlink(missing_ok=True) + modules_load_file.unlink(missing_ok=True) + return None + + # Persist kernel module autoload on boot if specified (even if not enabled) + module = watchdog.get('module') + if module: + try: + modules_load_directory.mkdir(exist_ok=True) + modules_load_file.write_text(f"{module}\n") + except Exception as e: + print(f"Warning: Failed writing modules-load configuration: {e}") + else: + # If module option removed, drop persisted autoload file + modules_load_file.unlink(missing_ok=True) + + # Try to load kernel module if specified and /dev/watchdog0 is missing + if not WATCHDOG_DEV.exists(): + if module: + # Try to load the module using vyos call wrapper for logging/airbag integration + try: + call(f'modprobe {module}') + except Exception as e: + print(f"Warning: Could not load watchdog module '{module}': {e}") + # Re-check for device + if not WATCHDOG_DEV.exists(): + print( + "Warning: /dev/watchdog0 not found. Systemd watchdog will not be enabled." + ) + watchdog_config_file.unlink(missing_ok=True) + return None + + # Ensure the directory exists + watchdog_config_dir.mkdir(parents=True, exist_ok=True) + + # Pass through configured time values directly as seconds + render(str(watchdog_config_file), 'system/watchdog.conf.j2', watchdog) + + return None + + +def apply(watchdog): + # Reload systemd daemon to apply/unload the watchdog configuration + # The watchdog settings take immediate effect after systemd is reloaded + call('systemctl daemon-reload') + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1)