diff --git a/python/vyos/component_version.py b/python/vyos/component_version.py index 1513ac5ed..6db1768d3 100644 --- a/python/vyos/component_version.py +++ b/python/vyos/component_version.py @@ -1,330 +1,207 @@ # Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> # # 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/>. """ Functions for reading/writing component versions. The config file version string has the following form: VyOS 1.3/1.4: // Warning: Do not remove the following line. // vyos-config-version: "broadcast-relay@1:cluster@1:config-management@1:conntrack@3:conntrack-sync@2:dhcp-relay@2:dhcp-server@6:dhcpv6-server@1:dns-forwarding@3:firewall@5:https@2:interfaces@22:ipoe-server@1:ipsec@5:isis@1:l2tp@3:lldp@1:mdns@1:nat@5:ntp@1:pppoe-server@5:pptp@2:qos@1:quagga@8:rpki@1:salt@1:snmp@2:ssh@2:sstp@3:system@21:vrrp@2:vyos-accel-ppp@2:wanloadbalance@3:webproxy@2:zone-policy@1" // Release version: 1.3.0 VyOS 1.2: /* Warning: Do not remove the following line. */ /* === vyatta-config-version: "broadcast-relay@1:cluster@1:config-management@1:conntrack-sync@1:conntrack@1:dhcp-relay@2:dhcp-server@5:dns-forwarding@1:firewall@5:ipsec@5:l2tp@1:mdns@1:nat@4:ntp@1:pppoe-server@2:pptp@1:qos@1:quagga@7:snmp@1:ssh@1:system@10:vrrp@2:wanloadbalance@3:webgui@1:webproxy@2:zone-policy@1" === */ /* Release version: 1.2.8 */ """ import os import re import sys from dataclasses import dataclass from dataclasses import replace from typing import Optional from vyos.xml_ref import component_version from vyos.utils.file import write_file from vyos.version import get_version from vyos.defaults import directories DEFAULT_CONFIG_PATH = os.path.join(directories['config'], 'config.boot') REGEX_WARN_VYOS = r'(// Warning: Do not remove the following line.)' REGEX_WARN_VYATTA = r'(/\* Warning: Do not remove the following line. \*/)' REGEX_COMPONENT_VERSION_VYOS = r'// vyos-config-version:\s+"([\w@:-]+)"\s*' REGEX_COMPONENT_VERSION_VYATTA = r'/\* === vyatta-config-version:\s+"([\w@:-]+)"\s+=== \*/' REGEX_RELEASE_VERSION_VYOS = r'// Release version:\s+(\S*)\s*' REGEX_RELEASE_VERSION_VYATTA = r'/\* Release version:\s+(\S*)\s*\*/' CONFIG_FILE_VERSION = """\ // Warning: Do not remove the following line. // vyos-config-version: "{}" // Release version: {}\n """ warn_filter_vyos = re.compile(REGEX_WARN_VYOS) warn_filter_vyatta = re.compile(REGEX_WARN_VYATTA) regex_filter = { 'vyos': dict(zip(['component', 'release'], [re.compile(REGEX_COMPONENT_VERSION_VYOS), re.compile(REGEX_RELEASE_VERSION_VYOS)])), 'vyatta': dict(zip(['component', 'release'], [re.compile(REGEX_COMPONENT_VERSION_VYATTA), re.compile(REGEX_RELEASE_VERSION_VYATTA)])) } @dataclass class VersionInfo: component: Optional[dict[str,int]] = None release: str = get_version() vintage: str = 'vyos' config_body: Optional[str] = None footer_lines: Optional[list[str]] = None def component_is_none(self) -> bool: return bool(self.component is None) def config_body_is_none(self) -> bool: return bool(self.config_body is None) def update_footer(self): f = CONFIG_FILE_VERSION.format(component_to_string(self.component), self.release) self.footer_lines = f.splitlines() def update_syntax(self): self.vintage = 'vyos' self.update_footer() def update_release(self, release: str): self.release = release self.update_footer() def update_component(self, key: str, version: int): if not isinstance(version, int): raise ValueError('version must be int') if self.component is None: self.component = {} self.component[key] = version self.component = dict(sorted(self.component.items(), key=lambda x: x[0])) self.update_footer() def update_config_body(self, config_str: str): self.config_body = config_str def write_string(self) -> str: config_body = '' if self.config_body is None else self.config_body footer_lines = [] if self.footer_lines is None else self.footer_lines return config_body + '\n' + '\n'.join(footer_lines) + '\n' def write(self, config_file): string = self.write_string() try: write_file(config_file, string, mode=0o660) except Exception as e: raise ValueError(e) from e def component_to_string(component: dict) -> str: l = [f'{k}@{v}' for k, v in sorted(component.items(), key=lambda x: x[0])] return ':'.join(l) def component_from_string(string: str) -> dict: return {k: int(v) for k, v in re.findall(r'([\w,-]+)@(\d+)', string)} def version_info_from_file(config_file) -> VersionInfo: version_info = VersionInfo() try: with open(config_file) as f: config_str = f.read() except OSError: return None if len(parts := warn_filter_vyos.split(config_str)) > 1: vintage = 'vyos' elif len(parts := warn_filter_vyatta.split(config_str)) > 1: vintage = 'vyatta' else: version_info.config_body = parts[0] if parts else None return version_info version_info.vintage = vintage version_info.config_body = parts[0] version_lines = ''.join(parts[1:]).splitlines() version_lines = [k for k in version_lines if k] if len(version_lines) != 3: raise ValueError(f'Malformed version strings: {version_lines}') m = regex_filter[vintage]['component'].match(version_lines[1]) if not m: raise ValueError(f'Malformed component string: {version_lines[1]}') version_info.component = component_from_string(m.group(1)) m = regex_filter[vintage]['release'].match(version_lines[2]) if not m: raise ValueError(f'Malformed component string: {version_lines[2]}') version_info.release = m.group(1) version_info.footer_lines = version_lines return version_info def version_info_from_system() -> VersionInfo: """ Return system component versions. """ d = component_version() sort_d = dict(sorted(d.items(), key=lambda x: x[0])) version_info = VersionInfo( component = sort_d, release = get_version(), vintage = 'vyos' ) return version_info def version_info_copy(v: VersionInfo) -> VersionInfo: """ Make a copy of dataclass. """ return replace(v) def version_info_prune_component(x: VersionInfo, y: VersionInfo) -> VersionInfo: """ In place pruning of component keys of x not in y. """ x.component = { k: v for k,v in x.component.items() if k in y.component } def add_system_version(config_str: str = None, out_file: str = None): """ Wrap config string with system version and write to out_file. For convenience, calling with no argument will write system version string to stdout, for use in bash scripts. """ version_info = version_info_from_system() if config_str is not None: version_info.update_config_body(config_str) version_info.update_footer() if out_file is not None: version_info.write(out_file) else: sys.stdout.write(version_info.write_string()) - -def from_string(string_line, vintage='vyos'): - """ - Get component version dictionary from string. - Return empty dictionary if string contains no config information - or raise error if component version string malformed. - """ - version_dict = {} - - if vintage == 'vyos': - if re.match(r'// vyos-config-version:.+', string_line): - if not re.match(r'// vyos-config-version:\s+"([\w,-]+@\d+:)+([\w,-]+@\d+)"\s*', string_line): - raise ValueError(f"malformed configuration string: {string_line}") - - for pair in re.findall(r'([\w,-]+)@(\d+)', string_line): - version_dict[pair[0]] = int(pair[1]) - - elif vintage == 'vyatta': - if re.match(r'/\* === vyatta-config-version:.+=== \*/$', string_line): - if not re.match(r'/\* === vyatta-config-version:\s+"([\w,-]+@\d+:)+([\w,-]+@\d+)"\s+=== \*/$', string_line): - raise ValueError(f"malformed configuration string: {string_line}") - - for pair in re.findall(r'([\w,-]+)@(\d+)', string_line): - version_dict[pair[0]] = int(pair[1]) - else: - raise ValueError("Unknown config string vintage") - - return version_dict - -def from_file(config_file_name=DEFAULT_CONFIG_PATH, vintage='vyos'): - """ - Get component version dictionary parsing config file line by line - """ - with open(config_file_name, 'r') as f: - for line_in_config in f: - version_dict = from_string(line_in_config, vintage=vintage) - if version_dict: - return version_dict - - # no version information - return {} - -def from_system(): - """ - Get system component version dict. - """ - return component_version() - -def format_string(ver: dict) -> str: - """ - Version dict to string. - """ - keys = list(ver) - keys.sort() - l = [] - for k in keys: - v = ver[k] - l.append(f'{k}@{v}') - sep = ':' - return sep.join(l) - -def version_footer(ver: dict, vintage='vyos') -> str: - """ - Version footer as string. - """ - ver_str = format_string(ver) - release = get_version() - if vintage == 'vyos': - ret_str = (f'// Warning: Do not remove the following line.\n' - + f'// vyos-config-version: "{ver_str}"\n' - + f'// Release version: {release}\n') - elif vintage == 'vyatta': - ret_str = (f'/* Warning: Do not remove the following line. */\n' - + f'/* === vyatta-config-version: "{ver_str}" === */\n' - + f'/* Release version: {release} */\n') - else: - raise ValueError("Unknown config string vintage") - - return ret_str - -def system_footer(vintage='vyos') -> str: - """ - System version footer as string. - """ - ver_d = from_system() - return version_footer(ver_d, vintage=vintage) - -def write_version_footer(ver: dict, file_name, vintage='vyos'): - """ - Write version footer to file. - """ - footer = version_footer(ver=ver, vintage=vintage) - if file_name: - with open(file_name, 'a') as f: - f.write(footer) - else: - sys.stdout.write(footer) - -def write_system_footer(file_name, vintage='vyos'): - """ - Write system version footer to file. - """ - ver_d = from_system() - return write_version_footer(ver_d, file_name=file_name, vintage=vintage) - -def remove_footer(file_name): - """ - Remove old version footer. - """ - for line in fileinput.input(file_name, inplace=True): - if re.match(r'/\* Warning:.+ \*/$', line): - continue - if re.match(r'/\* === vyatta-config-version:.+=== \*/$', line): - continue - if re.match(r'/\* Release version:.+ \*/$', line): - continue - if re.match('// vyos-config-version:.+', line): - continue - if re.match('// Warning:.+', line): - continue - if re.match('// Release version:.+', line): - continue - sys.stdout.write(line) diff --git a/python/vyos/migrator.py b/python/vyos/migrator.py deleted file mode 100644 index 872682bc0..000000000 --- a/python/vyos/migrator.py +++ /dev/null @@ -1,226 +0,0 @@ -# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> -# -# 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/>. - -import sys -import os -import json -import logging - -import vyos.defaults -import vyos.component_version as component_version -from vyos.utils.process import cmd - -log_file = os.path.join(vyos.defaults.directories['config'], 'vyos-migrate.log') - -class MigratorError(Exception): - pass - -class Migrator(object): - def __init__(self, config_file, force=False, set_vintage='vyos'): - self._config_file = config_file - self._force = force - self._set_vintage = set_vintage - self._config_file_vintage = None - self._changed = False - - def init_logger(self): - self.logger = logging.getLogger(__name__) - self.logger.setLevel(logging.DEBUG) - - # on adding the file handler, allow write permission for cfg_group; - # restore original umask on exit - mask = os.umask(0o113) - fh = logging.FileHandler(log_file) - formatter = logging.Formatter('%(message)s') - fh.setFormatter(formatter) - self.logger.addHandler(fh) - os.umask(mask) - - def read_config_file_versions(self): - """ - Get component versions from config file footer and set vintage; - return empty dictionary if config string is missing. - """ - cfg_file = self._config_file - component_versions = {} - - cfg_versions = component_version.from_file(cfg_file, vintage='vyatta') - - if cfg_versions: - self._config_file_vintage = 'vyatta' - component_versions = cfg_versions - - cfg_versions = component_version.from_file(cfg_file, vintage='vyos') - - if cfg_versions: - self._config_file_vintage = 'vyos' - component_versions = cfg_versions - - return component_versions - - def update_vintage(self): - old_vintage = self._config_file_vintage - - if self._set_vintage: - self._config_file_vintage = self._set_vintage - - if self._config_file_vintage not in ['vyatta', 'vyos']: - raise MigratorError("Unknown vintage.") - - if self._config_file_vintage == old_vintage: - return False - else: - return True - - def run_migration_scripts(self, config_file_versions, system_versions): - """ - Run migration scripts iteratively, until config file version equals - system component version. - """ - os.environ['VYOS_MIGRATION'] = '1' - self.init_logger() - - self.logger.info("List of executed migration scripts:") - - cfg_versions = config_file_versions - sys_versions = system_versions - - sys_keys = list(sys_versions.keys()) - sys_keys.sort() - - # XXX 'bgp' needs to follow 'quagga': - if 'bgp' in sys_keys and 'quagga' in sys_keys: - sys_keys.insert(sys_keys.index('quagga'), - sys_keys.pop(sys_keys.index('bgp'))) - - rev_versions = {} - - for key in sys_keys: - sys_ver = sys_versions[key] - if key in cfg_versions: - cfg_ver = cfg_versions[key] - else: - cfg_ver = 0 - - migrate_script_dir = os.path.join( - vyos.defaults.directories['migrate'], key) - - while cfg_ver < sys_ver: - next_ver = cfg_ver + 1 - - migrate_script = os.path.join(migrate_script_dir, - '{}-to-{}'.format(cfg_ver, next_ver)) - - try: - out = cmd([migrate_script, self._config_file]) - self.logger.info(f'{migrate_script}') - if out: self.logger.info(out) - except FileNotFoundError: - pass - except Exception as err: - print("\nMigration script error: {0}: {1}." - "".format(migrate_script, err)) - sys.exit(1) - - cfg_ver = next_ver - rev_versions[key] = cfg_ver - - del os.environ['VYOS_MIGRATION'] - return rev_versions - - def write_config_file_versions(self, cfg_versions): - """ - Write new versions string. - """ - if self._config_file_vintage == 'vyatta': - component_version.write_version_footer(cfg_versions, - self._config_file, - vintage='vyatta') - - if self._config_file_vintage == 'vyos': - component_version.write_version_footer(cfg_versions, - self._config_file, - vintage='vyos') - - def save_json_record(self, component_versions: dict): - """ - Write component versions to a json file - """ - mask = os.umask(0o113) - version_file = vyos.defaults.component_version_json - try: - with open(version_file, 'w') as f: - f.write(json.dumps(component_versions, indent=2, sort_keys=True)) - except OSError: - pass - finally: - os.umask(mask) - - def run(self): - """ - Gather component versions from config file and system. - Run migration scripts. - Update vintage ('vyatta' or 'vyos'), if needed. - If changed, remove old versions string from config file, and - write new versions string. - """ - cfg_file = self._config_file - - cfg_versions = self.read_config_file_versions() - if self._force: - # This will force calling all migration scripts: - cfg_versions = {} - - sys_versions = component_version.from_system() - - # save system component versions in json file for easy reference - self.save_json_record(sys_versions) - - rev_versions = self.run_migration_scripts(cfg_versions, sys_versions) - - if rev_versions != cfg_versions: - self._changed = True - - if self.update_vintage(): - self._changed = True - - if not self._changed: - return - - component_version.remove_footer(cfg_file) - - self.write_config_file_versions(rev_versions) - - def config_changed(self): - return self._changed - -class VirtualMigrator(Migrator): - def run(self): - cfg_file = self._config_file - - cfg_versions = self.read_config_file_versions() - if not cfg_versions: - return - - if self.update_vintage(): - self._changed = True - - if not self._changed: - return - - component_version.remove_footer(cfg_file) - - self.write_config_file_versions(cfg_versions) -