From 0160823d084d141be71299c0e51d9dd3c27ede94 Mon Sep 17 00:00:00 2001 From: zsdc Date: Wed, 3 Jul 2024 22:45:52 +0300 Subject: locking: T6544: Added locking util Sometimes we need a reliable way to lock an execution until some other operation is not done. This commit introduces locking util, which can be used as a common lock, even between different processes. Usage example: ``` from vyos.utils.locking import Lock lock = Lock('my_lock_id') lock.acquire(timeout=10) print(f'Lock acquired: {lock.is_locked}') lock.release() ``` --- python/vyos/utils/__init__.py | 1 + python/vyos/utils/locking.py | 115 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 python/vyos/utils/locking.py diff --git a/python/vyos/utils/__init__.py b/python/vyos/utils/__init__.py index 90620071b..3759b2125 100644 --- a/python/vyos/utils/__init__.py +++ b/python/vyos/utils/__init__.py @@ -25,6 +25,7 @@ from vyos.utils import file from vyos.utils import io from vyos.utils import kernel from vyos.utils import list +from vyos.utils import locking from vyos.utils import misc from vyos.utils import network from vyos.utils import permission diff --git a/python/vyos/utils/locking.py b/python/vyos/utils/locking.py new file mode 100644 index 000000000..63cb1a816 --- /dev/null +++ b/python/vyos/utils/locking.py @@ -0,0 +1,115 @@ +# Copyright 2024 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +import fcntl +import re +import time +from pathlib import Path + + +class LockTimeoutError(Exception): + """Custom exception raised when lock acquisition times out.""" + + pass + + +class InvalidLockNameError(Exception): + """Custom exception raised when the lock name is invalid.""" + + pass + + +class Lock: + """Lock class to acquire and release a lock file""" + + def __init__(self, lock_name: str) -> None: + """Lock class constructor + + Args: + lock_name (str): Name of the lock file + + Raises: + InvalidLockNameError: If the lock name is invalid + """ + # Validate lock name + if not re.match(r'^[a-zA-Z0-9_\-]+$', lock_name): + raise InvalidLockNameError(f'Invalid lock name: {lock_name}') + + self.__lock_dir = Path('/run/vyos/lock') + self.__lock_dir.mkdir(parents=True, exist_ok=True) + + self.__lock_file_path: Path = self.__lock_dir / f'{lock_name}.lock' + self.__lock_file = None + + self._is_locked = False + + def __del__(self) -> None: + """Ensure the lock file is removed when the object is deleted""" + self.release() + + @property + def is_locked(self) -> bool: + """Check if the lock is acquired + + Returns: + bool: True if the lock is acquired, False otherwise + """ + return self._is_locked + + def __unlink_lockfile(self) -> None: + """Remove the lock file if it is not currently locked.""" + try: + with self.__lock_file_path.open('w') as f: + fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.__lock_file_path.unlink(missing_ok=True) + except IOError: + # If we cannot acquire the lock, it means another process has it, so we do nothing. + pass + + def acquire(self, timeout: int = 0) -> None: + """Acquire a lock file + + Args: + timeout (int, optional): A time to wait for lock. Defaults to 0. + + Raises: + LockTimeoutError: If lock could not be acquired within timeout + """ + start_time: float = time.time() + while True: + try: + self.__lock_file = self.__lock_file_path.open('w') + fcntl.flock(self.__lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + self._is_locked = True + return + except IOError: + if timeout > 0 and (time.time() - start_time) >= timeout: + if self.__lock_file: + self.__lock_file.close() + raise LockTimeoutError( + f'Could not acquire lock within {timeout} seconds' + ) + time.sleep(0.1) + + def release(self) -> None: + """Release a lock file""" + if self.__lock_file and self._is_locked: + try: + fcntl.flock(self.__lock_file, fcntl.LOCK_UN) + self._is_locked = False + finally: + self.__lock_file.close() + self.__lock_file = None + self.__unlink_lockfile() -- cgit v1.2.3 From 86e26bc41e573c2c72d472fba132bfd657b6fbfa Mon Sep 17 00:00:00 2001 From: zsdc Date: Wed, 3 Jul 2024 23:00:58 +0300 Subject: vyos_net_name: T6544: Updated the `vyos_net_name` script Improvements in the `vyos_net_name`: - Used a new locking system, to be sure that multiple running scripts will not try to perform operations at the same time. - Replace logging from a file to syslog. This is common with all the rest logs, and additionally gives a better view of actions done during a boot. - Small bug fix in `get_configfile_interfaces()`: exit with an error in case a config file cannot be parsed. This resolves potentially an unbound `config` object. - Minor formatting fixes to follow our requirements. --- src/helpers/vyos_net_name | 143 ++++++++++++++++++++++++++-------------------- 1 file changed, 81 insertions(+), 62 deletions(-) diff --git a/src/helpers/vyos_net_name b/src/helpers/vyos_net_name index 518e204f9..f5de182c6 100755 --- a/src/helpers/vyos_net_name +++ b/src/helpers/vyos_net_name @@ -18,42 +18,35 @@ import os import re import time import logging +import logging.handlers import tempfile -import threading +from pathlib import Path from sys import argv from vyos.configtree import ConfigTree from vyos.defaults import directories from vyos.utils.process import cmd from vyos.utils.boot import boot_configuration_complete +from vyos.utils.locking import Lock from vyos.migrate import ConfigMigrate +# Define variables vyos_udev_dir = directories['vyos_udev_dir'] -vyos_log_dir = '/run/udev/log' -vyos_log_file = os.path.join(vyos_log_dir, 'vyos-net-name') - config_path = '/opt/vyatta/etc/config/config.boot' -lock = threading.Lock() - -try: - os.mkdir(vyos_log_dir) -except FileExistsError: - pass - -logging.basicConfig(filename=vyos_log_file, level=logging.DEBUG) def is_available(intfs: dict, intf_name: str) -> bool: - """ Check if interface name is already assigned - """ + """Check if interface name is already assigned""" if intf_name in list(intfs.values()): return False return True + def find_available(intfs: dict, prefix: str) -> str: - """ Find lowest indexed iterface name that is not assigned - """ - index_list = [int(x.replace(prefix, '')) for x in list(intfs.values()) if prefix in x] + """Find lowest indexed iterface name that is not assigned""" + index_list = [ + int(x.replace(prefix, '')) for x in list(intfs.values()) if prefix in x + ] index_list.sort() # find 'holes' in list, if any missing = sorted(set(range(index_list[0], index_list[-1])) - set(index_list)) @@ -62,21 +55,22 @@ def find_available(intfs: dict, prefix: str) -> str: return f'{prefix}{len(index_list)}' + def mod_ifname(ifname: str) -> str: - """ Check interface with names eX and return ifname on the next format eth{ifindex} - 2 - """ - if re.match("^e[0-9]+$", ifname): - intf = ifname.split("e") + """Check interface with names eX and return ifname on the next format eth{ifindex} - 2""" + if re.match('^e[0-9]+$', ifname): + intf = ifname.split('e') if intf[1]: if int(intf[1]) >= 2: - return "eth" + str(int(intf[1]) - 2) + return 'eth' + str(int(intf[1]) - 2) else: - return "eth" + str(intf[1]) + return 'eth' + str(intf[1]) return ifname + def get_biosdevname(ifname: str) -> str: - """ Use legacy vyatta-biosdevname to query for name + """Use legacy vyatta-biosdevname to query for name This is carried over for compatability only, and will likely be dropped going forward. @@ -95,11 +89,12 @@ def get_biosdevname(ifname: str) -> str: try: biosname = cmd(f'/sbin/biosdevname --policy all_ethN -i {ifname}') except Exception as e: - logging.error(f'biosdevname error: {e}') + logger.error(f'biosdevname error: {e}') biosname = '' return intf if biosname == '' else biosname + def leave_rescan_hint(intf_name: str, hwid: str): """Write interface information reported by udev @@ -112,18 +107,18 @@ def leave_rescan_hint(intf_name: str, hwid: str): except FileExistsError: pass except Exception as e: - logging.critical(f"Error creating rescan hint directory: {e}") + logger.critical(f'Error creating rescan hint directory: {e}') exit(1) try: with open(os.path.join(vyos_udev_dir, intf_name), 'w') as f: f.write(hwid) except OSError as e: - logging.critical(f"OSError {e}") + logger.critical(f'OSError {e}') + def get_configfile_interfaces() -> dict: - """Read existing interfaces from config file - """ + """Read existing interfaces from config file""" interfaces: dict = {} if not os.path.isfile(config_path): @@ -134,14 +129,14 @@ def get_configfile_interfaces() -> dict: with open(config_path) as f: config_file = f.read() except OSError as e: - logging.critical(f"OSError {e}") + logger.critical(f'OSError {e}') exit(1) try: config = ConfigTree(config_file) except Exception: try: - logging.debug(f"updating component version string syntax") + logger.debug('updating component version string syntax') # this will update the component version string syntax, # required for updates 1.2 --> 1.3/1.4 with tempfile.NamedTemporaryFile() as fp: @@ -157,7 +152,8 @@ def get_configfile_interfaces() -> dict: config = ConfigTree(config_file) except Exception as e: - logging.critical(f"ConfigTree error: {e}") + logger.critical(f'ConfigTree error: {e}') + exit(1) base = ['interfaces', 'ethernet'] if config.exists(base): @@ -165,11 +161,13 @@ def get_configfile_interfaces() -> dict: for intf in eth_intfs: path = base + [intf, 'hw-id'] if not config.exists(path): - logging.warning(f"no 'hw-id' entry for {intf}") + logger.warning(f"no 'hw-id' entry for {intf}") continue hwid = config.return_value(path) if hwid in list(interfaces): - logging.warning(f"multiple entries for {hwid}: {interfaces[hwid]}, {intf}") + logger.warning( + f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}' + ) continue interfaces[hwid] = intf @@ -179,21 +177,23 @@ def get_configfile_interfaces() -> dict: for intf in wlan_intfs: path = base + [intf, 'hw-id'] if not config.exists(path): - logging.warning(f"no 'hw-id' entry for {intf}") + logger.warning(f"no 'hw-id' entry for {intf}") continue hwid = config.return_value(path) if hwid in list(interfaces): - logging.warning(f"multiple entries for {hwid}: {interfaces[hwid]}, {intf}") + logger.warning( + f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}' + ) continue interfaces[hwid] = intf - logging.debug(f"config file entries: {interfaces}") + logger.debug(f'config file entries: {interfaces}') return interfaces + def add_assigned_interfaces(intfs: dict): - """Add interfaces found by previous invocation of udev rule - """ + """Add interfaces found by previous invocation of udev rule""" if not os.path.isdir(vyos_udev_dir): return @@ -203,55 +203,74 @@ def add_assigned_interfaces(intfs: dict): with open(path) as f: hwid = f.read().rstrip() except OSError as e: - logging.error(f"OSError {e}") + logger.error(f'OSError {e}') continue intfs[hwid] = intf + def on_boot_event(intf_name: str, hwid: str, predefined: str = '') -> str: - """Called on boot by vyos-router: 'coldplug' in vyatta_net_name - """ - logging.info(f"lookup {intf_name}, {hwid}") + """Called on boot by vyos-router: 'coldplug' in vyatta_net_name""" + logger.info(f'lookup {intf_name}, {hwid}') interfaces = get_configfile_interfaces() - logging.debug(f"config file interfaces are {interfaces}") + logger.debug(f'config file interfaces are {interfaces}') if hwid in list(interfaces): - logging.info(f"use mapping from config file: '{hwid}' -> '{interfaces[hwid]}'") + logger.info(f"use mapping from config file: '{hwid}' -> '{interfaces[hwid]}'") return interfaces[hwid] add_assigned_interfaces(interfaces) - logging.debug(f"adding assigned interfaces: {interfaces}") + logger.debug(f'adding assigned interfaces: {interfaces}') if predefined: newname = predefined - logging.info(f"predefined interface name for '{intf_name}' is '{newname}'") + logger.info(f"predefined interface name for '{intf_name}' is '{newname}'") else: newname = get_biosdevname(intf_name) - logging.info(f"biosdevname returned '{newname}' for '{intf_name}'") + logger.info(f"biosdevname returned '{newname}' for '{intf_name}'") if not is_available(interfaces, newname): prefix = re.sub(r'\d+$', '', newname) newname = find_available(interfaces, prefix) - logging.info(f"new name for '{intf_name}' is '{newname}'") + logger.info(f"new name for '{intf_name}' is '{newname}'") leave_rescan_hint(newname, hwid) return newname + def hotplug_event(): # Not yet implemented, since interface-rescan will only be run on boot. pass -if len(argv) > 3: - predef_name = argv[3] -else: - predef_name = '' - -lock.acquire() -if not boot_configuration_complete(): - res = on_boot_event(argv[1], argv[2], predefined=predef_name) - logging.debug(f"on boot, returned name is {res}") - print(res) -else: - logging.debug("boot configuration complete") -lock.release() + +if __name__ == '__main__': + # Set up logging to syslog + syslog_handler = logging.handlers.SysLogHandler(address='/dev/log') + formatter = logging.Formatter(f'{Path(__file__).name}: %(message)s') + syslog_handler.setFormatter(formatter) + + logger = logging.getLogger() + logger.addHandler(syslog_handler) + logger.setLevel(logging.DEBUG) + + logger.debug(f'Started with arguments: {argv}') + + if len(argv) > 3: + predef_name = argv[3] + else: + predef_name = '' + + lock = Lock('vyos_net_name') + # Wait 60 seconds for other running scripts to finish + lock.acquire(60) + + if not boot_configuration_complete(): + res = on_boot_event(argv[1], argv[2], predefined=predef_name) + logger.debug(f'on boot, returned name is {res}') + print(res) + else: + logger.debug('boot configuration complete') + + lock.release() + logger.debug('Finished') -- cgit v1.2.3