1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
|
#!/usr/bin/env python3
#
# Copyright (C) VyOS Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import typing
from pathlib import Path
from vyos import opmode
from vyos import http_api_client as http
from vyos.utils.file import read_json
from vyos.utils.dict import dict_to_paths
from vyos.utils.list import list_contains_sublist
from vyos.utils.config import get_saved_config_tree
from vyos.configtree import ConfigTree
from vyos.configtree import ConfigTreeError
from vyos.configtree import DiffTree
from vyos.config import Config
from vyos.derivedtree import apply_exclusion_list
from vyos.derivedtree import DerivedTreeError
from vyos.defaults import config_sync_exclusion_list
CONFIG_FILE = Path('/run/config_sync_conf.conf')
def _normalize_section(section: typing.Optional[str]) -> list:
"""Convert optional CLI section argument to config tree path list"""
if not section:
return []
# Section can be passed as a single string token ('interfaces ethernet')
return list(section.split())
def _read_json_config() -> dict:
"""Read config-sync service runtime JSON file"""
if not CONFIG_FILE.exists():
raise opmode.UnconfiguredObject('Config-sync service is not configured')
return read_json(CONFIG_FILE, defaultonfailure={})
def _load_config_sync_sections() -> list:
"""Load sections from config-sync service runtime JSON file"""
cfg = _read_json_config()
sections = cfg.get('section', {})
return list(dict_to_paths(sections)) if sections else []
def _load_config_sync_settings() -> dict:
# pylint: disable=use-dict-literal
"""Load remote API settings from config-sync service runtime JSON file"""
cfg = _read_json_config()
secondary = cfg.get('secondary', {})
address = secondary.get('address')
key = secondary.get('key')
port = int(secondary.get('port', 443))
timeout = int(secondary.get('timeout')) if secondary.get('timeout') else None
if not address or not key:
raise opmode.UnconfiguredObject(
'Config-sync is not fully configured: missing secondary address/key'
)
return dict(host=address, key=key, port=port, timeout=timeout)
class ConfigSyncDiffManager:
def __init__(self):
api_settings = _load_config_sync_settings()
self._client = http.ApiClient(http.ApiClientConfig(**api_settings))
self._config = Config()
self.running_config = self._config.get_config_tree(effective=True)
self.session_config = self._config.get_config_tree(effective=False)
def _get_remote_config_tree(self, section_path: list = None) -> ConfigTree:
# pylint: disable=redefined-outer-name
"""
Retrieve remote config (or subtree) as ConfigTree via HTTPS API.
Note: Endpoint name is expected to be available on remote VyOS instance.
"""
payload = {
'configFormat': 'raw',
'op': 'showConfig',
'path': section_path or [],
}
try:
resp_data = self._client.post('retrieve', payload, raise_on_error=False)
except http.ApiError as e:
raise opmode.InternalError(f'Remote API failed: {e}') from e
error = (resp_data.get('error') or resp_data.get('detail') or '').strip()
if error:
ignored_errors = ('configuration under specified path is empty',)
if error.lower() not in ignored_errors:
raise opmode.InternalError(
f'Remote API responded with an error: {error}'
)
config_raw = resp_data.get('data') or ''
try:
return ConfigTree(config_raw)
except ConfigTreeError as e:
raise opmode.InternalError(f'Unable to build remote ConfigTree: {e}') from e
def _get_exclude_list(self) -> list[list[str]]:
# add as instance method, for future access to CLI settings from self.Config
exclude_file = Path(config_sync_exclusion_list)
if not exclude_file.exists():
raise opmode.InternalError('Config-sync exclusion list file not available')
return read_json(exclude_file, defaultonfailure=[])
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()] # noqa: E741
add_lines = [f'+ {l}' for l in add_tree_lines if l.strip()] # noqa: E741
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: typing.Optional[list] = None,
commands: bool = False,
) -> str:
# pylint: disable=redefined-outer-name
"""
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.running_config
elif source == 'candidate':
local_tree = self.session_config
elif source == 'saved':
try:
local_tree = get_saved_config_tree()
except ConfigTreeError as e:
raise opmode.InternalError(str(e)) from e
else:
raise opmode.IncorrectValue(
'Invalid source, must be one of: running, candidate, saved'
)
exclude_list = self._get_exclude_list()
try:
masked_local = apply_exclusion_list(local_tree, exclude_list)
masked_remote = apply_exclusion_list(remote_tree, exclude_list)
except DerivedTreeError as e:
raise opmode.InternalError(str(e)) from e
try:
diff_tree = DiffTree(masked_remote, masked_local)
except ConfigTreeError as e:
raise opmode.InternalError(str(e)) from e
return self._format_remote_diff(diff_tree, path, commands)
def get_sync_diff(
self,
source: str,
sections: list,
commands: typing.Optional[bool] = False,
) -> str:
"""Returns differences between local config and remote config for a given sections"""
results = []
remote_tree = self._get_remote_config_tree()
for section_path in sections:
result = self.remote_compare(
source,
remote_tree,
path=section_path,
commands=commands,
)
result = result.strip()
if result:
results.append(result)
return '\n'.join(results)
def show_sync_diff(
raw: bool,
source: typing.Optional[str],
section: typing.Optional[str],
commands: typing.Optional[bool],
) -> str:
"""Show differences between local config and remote config for a given section.
Args:
raw: unused (op-mode convention); output is always text.
source: local source config: running/candidate/saved.
section: optional top-level section to diff (e.g. "nat", "system time-zone").
commands: flag which indicates format of output.
Returns:
Diff output (string). Empty diff is rendered as "no changes".
"""
_ = raw # op-mode framework passes it; keep signature consistent
source = source or 'running'
selected_section = _normalize_section(section)
configured_sections = _load_config_sync_sections()
if selected_section:
if not list_contains_sublist(configured_sections, selected_section):
raise opmode.UnconfiguredObject(
f"Config-sync is not configured for '{section}' section. "
f"Use 'set service config-sync section {section}' for this."
)
sections = [selected_section]
else:
sections = configured_sections
manager = ConfigSyncDiffManager()
output = manager.get_sync_diff(source, sections, commands=commands)
return output if output else 'No changes between local and remote configuration'
if __name__ == '__main__':
try:
res = opmode.run(sys.modules[__name__])
if res:
print(res)
except (ValueError, opmode.Error) as e:
print(e)
sys.exit(1)
|