summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
authorOleksandr Kuchmystyi <o.kuchmystyi@vyos.io>2026-03-24 16:26:33 +0300
committerOleksandr Kuchmystyi <o.kuchmystyi@vyos.io>2026-04-01 11:30:15 +0300
commit54fc0e1199625c8b6223e5d034bf895cfbf57c77 (patch)
tree0dc3ba44371d966fde332bba7d60357d38c71cfc /python
parent3823523c01e07ca010d035db7311b3c7f0e4e511 (diff)
downloadvyos-1x-54fc0e1199625c8b6223e5d034bf895cfbf57c77.tar.gz
vyos-1x-54fc0e1199625c8b6223e5d034bf895cfbf57c77.zip
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).
Diffstat (limited to 'python')
-rw-r--r--python/vyos/config_mgmt.py75
-rw-r--r--python/vyos/http_api_client.py148
-rw-r--r--python/vyos/utils/list.py20
3 files changed, 243 insertions, 0 deletions
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 <http://www.gnu.org/licenses/>.
+
+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