Skip to content

Commit

Permalink
config-mgmt: T5976: add option for commit-confirm to use 'soft' rollback
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jestabro committed Oct 5, 2024
1 parent f9c81b1 commit 4d5f2a5
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 23 deletions.
20 changes: 20 additions & 0 deletions interface-definitions/system_config-management.xml.in
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@
<constraintErrorMessage>Number of revisions must be between 0 and 65535</constraintErrorMessage>
</properties>
</leafNode>
<leafNode name="commit-confirm">
<properties>
<help>Commit confirm rollback type if no confirmation</help>
<completionHelp>
<list>reload reboot</list>
</completionHelp>
<valueHelp>
<format>reload</format>
<description>Reload previous configuration if not confirmed</description>
</valueHelp>
<valueHelp>
<format>reboot</format>
<description>Reboot to saved configuration if not confirmed</description>
</valueHelp>
<constraint>
<regex>(reload|reboot)</regex>
</constraint>
</properties>
<defaultValue>reboot</defaultValue>
</leafNode>
</children>
</node>
</children>
Expand Down
83 changes: 71 additions & 12 deletions python/vyos/config_mgmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
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
Expand Down Expand Up @@ -139,13 +141,19 @@ def __init__(self, session_env=None, config=None):
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']):
Expand Down Expand Up @@ -175,42 +183,63 @@ def __init__(self, session_env=None, config=None):
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'):
Expand All @@ -234,7 +263,11 @@ def confirm(self) -> Tuple[str, int]:
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]:
Expand All @@ -248,6 +281,28 @@ def revert(self) -> Tuple[str, int]:

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 = ''
Expand Down Expand Up @@ -684,7 +739,10 @@ def _read_tmp_log_entry(self) -> dict:
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)

Expand Down Expand Up @@ -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')
Expand Down
9 changes: 9 additions & 0 deletions python/vyos/configsession.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,15 @@ def load_config(self, file_path):
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
Expand Down
6 changes: 5 additions & 1 deletion src/conf_mode/system_config-management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
42 changes: 32 additions & 10 deletions src/helpers/commit-confirm-notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit 4d5f2a5

Please sign in to comment.