Skip to content

Commit

Permalink
load-config: T5815: provide a variety of load config methods
Browse files Browse the repository at this point in the history
Collect in a module several versions of a 'load config' function.
They have different use cases according to performance and error
reporting, and allow comparison of non-legacy and legacy variants.

(cherry picked from commit 7e4caa1)
  • Loading branch information
jestabro authored and mergify[bot] committed Dec 12, 2023
1 parent bc46027 commit 9c53c3e
Show file tree
Hide file tree
Showing 2 changed files with 203 additions and 0 deletions.
3 changes: 3 additions & 0 deletions python/vyos/configtree.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ def __str__(self):
def _get_config(self):
return self.__config

def get_version_string(self):
return self.__version

def to_string(self, ordered_values=False):
config_string = self.__to_string(self.__config, ordered_values).decode()
config_string = "{0}\n{1}".format(config_string, self.__version)
Expand Down
200 changes: 200 additions & 0 deletions python/vyos/load_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Copyright 2023 VyOS maintainers and contributors <[email protected]>
#
# 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 <http://www.gnu.org/licenses/>.

"""This module abstracts the loading of a config file into the running
config. It provides several varieties of loading a config file, from the
legacy version to the developing versions, as a means of offering
alternatives for competing use cases, and a base for profiling the
performance of each.
"""

import sys
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Union, Literal, TypeAlias, get_type_hints, get_args

from vyos.config import Config
from vyos.configtree import ConfigTree, DiffTree
from vyos.configsource import ConfigSourceSession, VyOSError
from vyos.component_version import from_string as version_from_string
from vyos.component_version import from_system as version_from_system
from vyos.migrator import Migrator, VirtualMigrator, MigratorError
from vyos.utils.process import popen, DEVNULL

Variety: TypeAlias = Literal['explicit', 'batch', 'tree', 'legacy']
ConfigObj: TypeAlias = Union[str, ConfigTree]

thismod = sys.modules[__name__]

class LoadConfigError(Exception):
"""Raised when an error occurs loading a config file.
"""

# utility functions

def get_running_config(config: Config) -> ConfigTree:
return config.get_config_tree(effective=True)

def get_proposed_config(config_file: str = None) -> ConfigTree:
config_str = Path(config_file).read_text()
return ConfigTree(config_str)

def migration_needed(config_obj: ConfigObj) -> bool:
"""Check if a migration is needed for the config object.
"""
if not isinstance(config_obj, ConfigTree):
atree = get_proposed_config(config_obj)
else:
atree = config_obj
version_str = atree.get_version_string()
if not version_str:
return True
aversion = version_from_string(version_str.splitlines()[1])
bversion = version_from_system()
return aversion != bversion

def check_session(strict: bool, switch: Variety) -> None:
"""Check if we are in a config session, with no uncommitted changes, if
strict. This is not needed for legacy load, as these checks are
implicit.
"""

if switch == 'legacy':
return

context = ConfigSourceSession()

if not context.in_session():
raise LoadConfigError('not in a config session')

if strict and context.session_changed():
raise LoadConfigError('commit or discard changes before loading config')

# methods to call for each variety

# explicit
def diff_to_commands(ctree: ConfigTree, ntree: ConfigTree) -> list:
"""Calculate the diff between the current and proposed config."""
# Calculate the diff between the current and new config tree
commands = DiffTree(ctree, ntree).to_commands()
# on an empty set of 'add' or 'delete' commands, to_commands
# returns '\n'; prune below
command_list = commands.splitlines()
command_list = [c for c in command_list if c]
return command_list

def set_commands(cmds: list) -> None:
"""Set commands in the config session."""
if not cmds:
print('no commands to set')
return
error_out = []
for op in cmds:
out, rc = popen(f'/opt/vyatta/sbin/my_{op}', shell=True, stderr=DEVNULL)
if rc != 0:
error_out.append(out)
continue
if error_out:
out = '\n'.join(error_out)
raise LoadConfigError(out)

# legacy
class LoadConfig(ConfigSourceSession):
"""A subclass for calling 'loadFile'.
"""
def load_config(self, file_name):
return self._run(['/bin/cli-shell-api','loadFile', file_name])

# end methods to call for each variety

def migrate(config_obj: ConfigObj) -> ConfigObj:
"""Migrate a config object to the current version.
"""
if isinstance(config_obj, ConfigTree):
config_file = NamedTemporaryFile(delete=False).name
Path(config_file).write_text(config_obj.to_string())
else:
config_file = config_obj

virtual_migration = VirtualMigrator(config_file)
migration = Migrator(config_file)
try:
virtual_migration.run()
migration.run()
except MigratorError as e:
raise LoadConfigError(e) from e
else:
if isinstance(config_obj, ConfigTree):
return ConfigTree(Path(config_file).read_text())
return config_file
finally:
if isinstance(config_obj, ConfigTree):
Path(config_file).unlink()

def load_explicit(config_obj: ConfigObj):
"""Explicit load from file or configtree.
"""
config = Config()
ctree = get_running_config(config)
if isinstance(config_obj, ConfigTree):
ntree = config_obj
else:
ntree = get_proposed_config(config_obj)
# Calculate the diff between the current and proposed config
cmds = diff_to_commands(ctree, ntree)
# Set the commands in the config session
set_commands(cmds)

def load_batch(config_obj: ConfigObj):
# requires legacy backend patch
raise NotImplementedError('batch loading not implemented')

def load_tree(config_obj: ConfigObj):
# requires vyconf backend patch
raise NotImplementedError('tree loading not implemented')

def load_legacy(config_obj: ConfigObj):
"""Legacy load from file or configtree.
"""
if isinstance(config_obj, ConfigTree):
config_file = NamedTemporaryFile(delete=False).name
Path(config_file).write_text(config_obj.to_string())
else:
config_file = config_obj

config = LoadConfig()

try:
config.load_config(config_file)
except VyOSError as e:
raise LoadConfigError(e) from e
finally:
if isinstance(config_obj, ConfigTree):
Path(config_file).unlink()

def load(config_obj: ConfigObj, strict: bool = True,
switch: Variety = 'legacy'):
type_hints = get_type_hints(load)
switch_choice = get_args(type_hints['switch'])
if switch not in switch_choice:
raise ValueError(f'invalid switch: {switch}')

check_session(strict, switch)

if migration_needed(config_obj):
config_obj = migrate(config_obj)

func = getattr(thismod, f'load_{switch}')
func(config_obj)

0 comments on commit 9c53c3e

Please sign in to comment.