From 4d5f2a58bbf5a366c43871ef27b75d31b3b2a114 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 2 Oct 2024 12:36:41 -0500 Subject: config-mgmt: T5976: add option for commit-confirm to use 'soft' rollback Commit-confirm will restore a previous configuration if a confirmation is not received in N minutes. Traditionally, this was restored by a reboot into the last configuration on disk; add a configurable option to reload the last completed commit without a reboot. The default setting is to reboot. --- .../system_config-management.xml.in | 20 ++++++ python/vyos/config_mgmt.py | 83 ++++++++++++++++++---- python/vyos/configsession.py | 9 +++ src/conf_mode/system_config-management.py | 6 +- src/helpers/commit-confirm-notify.py | 42 ++++++++--- 5 files changed, 137 insertions(+), 23 deletions(-) diff --git a/interface-definitions/system_config-management.xml.in b/interface-definitions/system_config-management.xml.in index e666633b7..b8fb6cdb5 100644 --- a/interface-definitions/system_config-management.xml.in +++ b/interface-definitions/system_config-management.xml.in @@ -67,6 +67,26 @@ Number of revisions must be between 0 and 65535 + + + Commit confirm rollback type if no confirmation + + reload reboot + + + reload + Reload previous configuration if not confirmed + + + reboot + Reboot to saved configuration if not confirmed + + + (reload|reboot) + + + reboot + diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index 920a19fec..851ac2134 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -33,6 +33,8 @@ from urllib.parse import urlunsplit from vyos.config import Config from vyos.configtree import ConfigTree from vyos.configtree import ConfigTreeError +from vyos.configsession import ConfigSession +from vyos.configsession import ConfigSessionError from vyos.configtree import show_diff from vyos.load_config import load from vyos.load_config import LoadConfigError @@ -139,13 +141,19 @@ class ConfigMgmt: config = Config() d = config.get_config_dict( - ['system', 'config-management'], key_mangling=('-', '_'), get_first_key=True + ['system', 'config-management'], + key_mangling=('-', '_'), + get_first_key=True, + with_defaults=True, ) self.max_revisions = int(d.get('commit_revisions', 0)) self.num_revisions = 0 self.locations = d.get('commit_archive', {}).get('location', []) self.source_address = d.get('commit_archive', {}).get('source_address', '') + self.reboot_unconfirmed = bool(d.get('commit_confirm') == 'reboot') + self.config_dict = d + if config.exists(['system', 'host-name']): self.hostname = config.return_value(['system', 'host-name']) if config.exists(['system', 'domain-name']): @@ -175,42 +183,63 @@ class ConfigMgmt: def commit_confirm( self, minutes: int = DEFAULT_TIME_MINUTES, no_prompt: bool = False ) -> Tuple[str, int]: - """Commit with reboot to saved config in 'minutes' minutes if + """Commit with reload/reboot to saved config in 'minutes' minutes if 'confirm' call is not issued. """ if is_systemd_service_active(f'{timer_name}.timer'): msg = 'Another confirm is pending' return msg, 1 - if unsaved_commits(): + if self.reboot_unconfirmed and unsaved_commits(): W = '\nYou should save previous commits before commit-confirm !\n' else: W = '' - prompt_str = f""" + if self.reboot_unconfirmed: + prompt_str = f""" commit-confirm will automatically reboot in {minutes} minutes unless changes -are confirmed.\n +are confirmed. +Proceed ?""" + else: + prompt_str = f""" +commit-confirm will automatically reload previous config in {minutes} minutes +unless changes are confirmed. Proceed ?""" + prompt_str = W + prompt_str if not no_prompt and not ask_yes_no(prompt_str, default=True): msg = 'commit-confirm canceled' return msg, 1 - action = 'sg vyattacfg "/usr/bin/config-mgmt revert"' + if self.reboot_unconfirmed: + action = 'sg vyattacfg "/usr/bin/config-mgmt revert"' + else: + action = 'sg vyattacfg "/usr/bin/config-mgmt revert_soft"' + cmd = f'sudo systemd-run --quiet --on-active={minutes}m --unit={timer_name} {action}' rc, out = rc_cmd(cmd) if rc != 0: raise ConfigMgmtError(out) # start notify - cmd = f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py {minutes}' + if self.reboot_unconfirmed: + cmd = ( + f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py --reboot {minutes}' + ) + else: + cmd = f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py {minutes}' + os.system(cmd) - msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot' + if self.reboot_unconfirmed: + msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot' + else: + msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reload' + return msg, 0 def confirm(self) -> Tuple[str, int]: - """Do not reboot to saved config following 'commit-confirm'. + """Do not reboot/reload to saved/completed config following 'commit-confirm'. Update commit log and archive. """ if not is_systemd_service_active(f'{timer_name}.timer'): @@ -234,7 +263,11 @@ Proceed ?""" self._add_log_entry(**entry) self._update_archive() - msg = 'Reboot timer stopped' + if self.reboot_unconfirmed: + msg = 'Reboot timer stopped' + else: + msg = 'Reload timer stopped' + return msg, 0 def revert(self) -> Tuple[str, int]: @@ -248,6 +281,28 @@ Proceed ?""" return '', 0 + def revert_soft(self) -> Tuple[str, int]: + """Reload last revision, dropping commits from 'commit-confirm'.""" + _ = self._read_tmp_log_entry() + + # commits under commit-confirm are not added to revision list unless + # confirmed, hence a soft revert is to revision 0 + revert_ct = self._get_config_tree_revision(0) + + mask = os.umask(0o002) + session = ConfigSession(os.getpid(), app='config-mgmt') + + try: + session.load_explicit(revert_ct) + session.commit() + except ConfigSessionError as e: + raise ConfigMgmtError(e) from e + finally: + os.umask(mask) + del session + + return '', 0 + def rollback(self, rev: int, no_prompt: bool = False) -> Tuple[str, int]: """Reboot to config revision 'rev'.""" msg = '' @@ -684,7 +739,10 @@ Proceed ?""" entry = f.read() os.unlink(tmp_log_entry) except OSError as e: - logger.critical(f'error on file {tmp_log_entry}: {e}') + logger.info(f'error on file {tmp_log_entry}: {e}') + # fail gracefully in corner case: + # delete commit-revisions; commit-confirm + return {} return self._get_log_entry(entry) @@ -752,7 +810,8 @@ def run(): ) subparsers.add_parser('confirm', help='Confirm commit') - subparsers.add_parser('revert', help='Revert commit-confirm') + subparsers.add_parser('revert', help='Revert commit-confirm with reboot') + subparsers.add_parser('revert_soft', help='Revert commit-confirm with reload') rollback = subparsers.add_parser('rollback', help='Rollback to earlier config') rollback.add_argument('--rev', type=int, help='Revision number for rollback') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index c0d3c7ecb..9c56d246a 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -268,6 +268,15 @@ class ConfigSession(object): out = self.__run_command(LOAD_CONFIG + [file_path]) return out + def load_explicit(self, file_path): + from vyos.load_config import load + from vyos.load_config import LoadConfigError + + try: + load(file_path, switch='explicit') + except LoadConfigError as e: + raise ConfigSessionError(e) from e + def migrate_and_load_config(self, file_path): out = self.__run_command(MIGRATE_LOAD_CONFIG + [file_path]) return out diff --git a/src/conf_mode/system_config-management.py b/src/conf_mode/system_config-management.py index 99f25bef6..a1ee136cd 100755 --- a/src/conf_mode/system_config-management.py +++ b/src/conf_mode/system_config-management.py @@ -38,7 +38,11 @@ def get_config(config=None): return mgmt -def verify(_mgmt): +def verify(mgmt): + d = mgmt.config_dict + if d.get('commit_confirm', '') == 'reload' and 'commit_revisions' not in d: + raise ConfigError('commit-confirm reload requires non-zero commit-revisions') + return diff --git a/src/helpers/commit-confirm-notify.py b/src/helpers/commit-confirm-notify.py index 69dda5112..af6167651 100755 --- a/src/helpers/commit-confirm-notify.py +++ b/src/helpers/commit-confirm-notify.py @@ -2,34 +2,56 @@ import os import sys import time +from argparse import ArgumentParser # Minutes before reboot to trigger notification. intervals = [1, 5, 15, 60] +parser = ArgumentParser() +parser.add_argument( + 'minutes', type=int, help='minutes before rollback to trigger notification' +) +parser.add_argument( + '--reboot', action='store_true', help="use 'soft' rollback instead of reboot" +) -def notify(interval): + +def notify(interval, reboot=False): s = '' if interval == 1 else 's' time.sleep((minutes - interval) * 60) - message = ( - '"[commit-confirm] System is going to reboot in ' - f'{interval} minute{s} to rollback the last commit.\n' - 'Confirm your changes to cancel the reboot."' - ) - os.system('wall -n ' + message) + if reboot: + message = ( + '"[commit-confirm] System will reboot in ' + f'{interval} minute{s}\nto rollback the last commit.\n' + 'Confirm your changes to cancel the reboot."' + ) + os.system('wall -n ' + message) + else: + message = ( + '"[commit-confirm] System will reload previous config in ' + f'{interval} minute{s}\nto rollback the last commit.\n' + 'Confirm your changes to cancel the reload."' + ) + os.system('wall -n ' + message) if __name__ == '__main__': # Must be run as root to call wall(1) without a banner. - if len(sys.argv) != 2 or os.getuid() != 0: + if os.getuid() != 0: print('This script requires superuser privileges.', file=sys.stderr) exit(1) - minutes = int(sys.argv[1]) + + args = parser.parse_args() + + minutes = args.minutes + reboot = args.reboot + # Drop the argument from the list so that the notification # doesn't kick in immediately. if minutes in intervals: intervals.remove(minutes) for interval in sorted(intervals, reverse=True): if minutes >= interval: - notify(interval) + notify(interval, reboot=reboot) minutes -= minutes - interval exit(0) -- cgit v1.2.3