From 54fc0e1199625c8b6223e5d034bf895cfbf57c77 Mon Sep 17 00:00:00 2001 From: Oleksandr Kuchmystyi Date: Tue, 24 Mar 2026 16:26:33 +0300 Subject: config-sync: T7784: Add command to diff configuration with secondary node Add a new operational command to compare configuration between nodes participating in config synchronization. New command: - `show configuration secondary sync [commands] [running|candidate|saved] [config-node-path]`. This allows operators to view configuration differences across secondary peer before applying or syncing changes. Supports: - displaying using raw diff and 'commands' format; - optional section filtering (subtree comparison); - selectable config source (running, candidate, saved). --- python/vyos/config_mgmt.py | 75 +++++++++++++++++++++ python/vyos/http_api_client.py | 148 +++++++++++++++++++++++++++++++++++++++++ python/vyos/utils/list.py | 20 ++++++ 3 files changed, 243 insertions(+) create mode 100644 python/vyos/http_api_client.py (limited to 'python') diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index e1e38b26f..f7d8883c4 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -38,6 +38,7 @@ from vyos.configtree import ConfigTreeError from vyos.configsession import ConfigSession from vyos.configsession import ConfigSessionError from vyos.configtree import diff_compare +from vyos.configtree import DiffTree from vyos.load_config import load from vyos.load_config import LoadConfigError from vyos.defaults import directories @@ -446,6 +447,80 @@ Proceed ?""" return self.compare(commands=cmnds, rev1=r1, rev2=r2) + def _format_remote_diff(self, diff_tree: DiffTree, path: list, commands: bool): + add_tree = diff_tree.add + del_tree = diff_tree.delete + command_prefix = ' '.join(path) + + result_lines = [] + if commands: + # Process the deleted elements into command format and filter based on prefix (path) + for line in del_tree.to_commands(op='delete').splitlines(): + if line.startswith(f'delete {command_prefix}'): + result_lines.append(line) + + # Process the added elements into command format and filter based on prefix (path) + for line in add_tree.to_commands(op='set').splitlines(): + if line.startswith(f'set {command_prefix}'): + result_lines.append(line) + else: + with_node = len(path) > 1 + # Retrieve subtrees for the specified path from both added and deleted trees + del_tree = del_tree.get_subtree(path, with_node=with_node) + add_tree = add_tree.get_subtree(path, with_node=with_node) + + # Convert the subtrees to string lines for further processing + del_tree_lines = str(del_tree).splitlines() + add_tree_lines = str(add_tree).splitlines() + + # Format the lines with a prefix ('-', '+') and filter out empty lines + del_lines = [f'- {l}' for l in del_tree_lines if l.strip()] + add_lines = [f'+ {l}' for l in add_tree_lines if l.strip()] + + if del_lines or add_lines: + # Adjust command prefix if a node is present in the path + command_prefix = ' '.join(path[:-1]) if with_node else command_prefix + if command_prefix: + result_lines.append(f'[{command_prefix}]') + + # Combine both deleted and added lines and process them + result_lines.extend(del_lines + add_lines) + + # Join the result lines into a single string, excluding empty lines + return '\n'.join((line for line in result_lines if line.strip())) + + def remote_compare( + self, + source: str, + remote_tree: ConfigTree, + path: Optional[list] = None, + commands: bool = False, + ) -> str: + """ + Compares a local configuration tree with a remote + configuration tree based on the specified source ('running', 'candidate', 'saved'). + """ + path = path or [] + + # Determine the correct local configuration tree based on the 'source' parameter + if source == 'running': + local_tree = self.active_config + elif source == 'candidate': + local_tree = self.working_config + elif source == 'saved': + local_tree = self._get_saved_config_tree() + else: + raise ConfigMgmtError( + 'Invalid source, must be one of: running, candidate, saved' + ) + + try: + diff_tree = DiffTree(remote_tree, local_tree) + except ConfigTreeError as e: + raise ConfigMgmtError(e) from e + + return self._format_remote_diff(diff_tree, path, commands) + # Initialization and post-commit hooks for conf-mode # def initialize_revision(self): diff --git a/python/vyos/http_api_client.py b/python/vyos/http_api_client.py new file mode 100644 index 000000000..ae16cfa54 --- /dev/null +++ b/python/vyos/http_api_client.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# 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 json +import urllib3 +import requests +from typing import Optional +from dataclasses import dataclass + +from vyos.version import get_version +from vyos.template import bracketize_ipv6 + + +class ApiError(Exception): + """Generic VyOS HTTP API client error""" + + +class ApiAuthError(ApiError): + """Authentication/authorization error""" + + +class ApiTransportError(ApiError): + """Network/transport error (timeouts, connection errors, etc.)""" + + +class ApiResponseError(ApiError): + """Server responded, but payload is invalid or indicates an error""" + + def __init__(self, message, response=None): + super().__init__(message) + self.response = response + + +@dataclass(frozen=True) +class ApiClientConfig: + host: str + key: str + port: int = 443 + timeout: Optional[int] = None + verify_tls: bool = False + + +class ApiClient: + """Small helper for talking to VyOS HTTP API using requests. + + Design goals: + - minimal surface area (thin wrapper around requests) + - consistent error handling + typed exceptions + - safe defaults for VyOS typical self-signed HTTPS usage (verify_tls=False) + """ + + _DEFAULT_HEADERS = { + 'Content-Type': 'application/json', + 'User-Agent': f'VyOS/{get_version()}', + } + + def __init__(self, config: ApiClientConfig): + assert isinstance(config, ApiClientConfig) + + self._cfg = config + self._host = bracketize_ipv6(config.host) + + self._session = requests.Session() + self._session.headers.update(self._DEFAULT_HEADERS) + + if not config.verify_tls: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + @property + def base_url(self) -> str: + return f'https://{self._host}:{self._cfg.port}' + + def post( + self, + endpoint: str, + payload: dict, + *, + params: Optional[dict] = None, + raise_on_error: bool = True, + ) -> dict: + """POST JSON to API and return decoded JSON response. + + Args: + endpoint: URL path, e.g. '/configure-section' or 'configure' + payload: dict that will be JSON-encoded and sent as request body + params: Optional query parameters + raise_on_error: If True, raise when response envelope contains bad status code + + Raises: + ApiTransportError: network problems/timeouts + ApiAuthError: 401/403 responses + ApiResponseError: non-2xx responses or invalid JSON + """ + if not endpoint.startswith('/'): + endpoint = f'/{endpoint}' + + # Most VyOS endpoints in this repo expect 'key' inside body. + body = dict(payload) + body.setdefault('key', self._cfg.key) + + url = f'{self.base_url}{endpoint}' + + try: + resp = self._session.post( + url, + data=json.dumps(body), + params=params, + timeout=self._cfg.timeout, + verify=self._cfg.verify_tls, + ) + except requests.exceptions.Timeout as e: + raise ApiTransportError(f'Request timed out: {e}') from e + except requests.exceptions.RequestException as e: + raise ApiTransportError(f'Request failed: {e}') from e + + if not resp.ok: + text = resp.text.strip() + err_msg = f'HTTP {resp.status_code} from {endpoint}: {text}' + + if resp.status_code in (401, 403): + raise ApiAuthError(err_msg) + elif resp.status_code >= 500: + raise ApiResponseError(err_msg, response=resp) + + if raise_on_error: + raise ApiResponseError(err_msg, response=resp) + + try: + return resp.json() + except json.JSONDecodeError as e: + raise ApiResponseError( + f'Invalid JSON response from {endpoint}: {e}', + response=resp, + ) from e diff --git a/python/vyos/utils/list.py b/python/vyos/utils/list.py index ea4bcd3ba..01a399026 100644 --- a/python/vyos/utils/list.py +++ b/python/vyos/utils/list.py @@ -41,3 +41,23 @@ def list_strip(lst: list, sub: list, right: bool = False) -> list: sub = [] return lst + + +def list_contains_sublist(lst: list, sub: list) -> bool: + """ + Check if any sublist in lst contains any element from list sub. + + Parameters: + lst (list of list): A list of sublists to be searched. + sub (list): A list of elements to search for. + + Returns: + bool: True if any element from sub is found in any sublist of lst, otherwise False. + """ + + for sublist in lst: + if len(sub) == len(sublist): + # Ensure all elements the same and position in right position + if all(a == b for a, b in zip(sub, sublist, strict=True)): + return True + return False -- cgit v1.2.3