From 30a530839cdbd934ea62369e385dc33fa50ab6de Mon Sep 17 00:00:00 2001
From: John Estabrook <jestabro@vyos.io>
Date: Thu, 28 Mar 2024 14:34:20 -0500
Subject: config-sync: T6185: combine data for sections/configs in one command

Package path/section data in single command containing a tree (dict) of
section paths and the accompanying config data. This drops the call to
get_config_dict and the need for a list of commands in request.
---
 src/helpers/vyos_config_sync.py   | 66 ++++++++++++++++++++-------------------
 src/services/vyos-http-api-server | 38 ++++++++++++++++------
 2 files changed, 63 insertions(+), 41 deletions(-)

(limited to 'src')

diff --git a/src/helpers/vyos_config_sync.py b/src/helpers/vyos_config_sync.py
index 77f7cd810..0604b2837 100755
--- a/src/helpers/vyos_config_sync.py
+++ b/src/helpers/vyos_config_sync.py
@@ -21,9 +21,11 @@ import json
 import requests
 import urllib3
 import logging
-from typing import Optional, List, Union, Dict, Any
+from typing import Optional, List, Tuple, Dict, Any
 
 from vyos.config import Config
+from vyos.configtree import ConfigTree
+from vyos.configtree import mask_inclusive
 from vyos.template import bracketize_ipv6
 
 
@@ -61,39 +63,45 @@ def post_request(url: str,
 
 
 
-def retrieve_config(section: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
+def retrieve_config(sections: List[list[str]]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
     """Retrieves the configuration from the local server.
 
     Args:
-        section: List[str]: The section of the configuration to retrieve.
-        Default is None.
+        sections: List[list[str]]: The list of sections of the configuration
+        to retrieve, given as list of paths.
 
     Returns:
-        Optional[Dict[str, Any]]: The retrieved configuration as a
-        dictionary, or None if an error occurred.
+        Tuple[Dict[str, Any],Dict[str,Any]]: The tuple (mask, config) where:
+            - mask: The tree of paths of sections, as a dictionary.
+            - config: The subtree of masked config data, as a dictionary.
     """
-    if section is None:
-        section = []
 
-    conf = Config()
-    config = conf.get_config_dict(section, get_first_key=True)
-    if config:
-        return config
-    return None
+    mask = ConfigTree('')
+    for section in sections:
+        mask.set(section)
+    mask_dict = json.loads(mask.to_json())
+
+    config = Config()
+    config_tree = config.get_config_tree()
+    masked = mask_inclusive(config_tree, mask)
+    config_dict = json.loads(masked.to_json())
 
+    return mask_dict, config_dict
 
 def set_remote_config(
         address: str,
         key: str,
-        commands: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+        op: str,
+        mask: Dict[str, Any],
+        config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
     """Loads the VyOS configuration in JSON format to a remote host.
 
     Args:
         address (str): The address of the remote host.
         key (str): The key to use for loading the configuration.
-        commands (list): List of set/load commands for request, given as:
-                         [{'op': str, 'path': list[str], 'section': dict},
-                         ...]
+        op (str): The operation to perform (set or load).
+        mask (dict): The dict of paths in sections.
+        config (dict): The dict of masked config data.
 
     Returns:
         Optional[Dict[str, Any]]: The response from the remote host as a
@@ -107,7 +115,9 @@ def set_remote_config(
 
     url = f'https://{address}/configure-section'
     data = json.dumps({
-        'commands': commands,
+        'op': op,
+        'mask': mask,
+        'config': config,
         'key': key
     })
 
@@ -140,23 +150,15 @@ def config_sync(secondary_address: str,
     )
 
     # Sync sections ("nat", "firewall", etc)
-    commands = []
-    for section in sections:
-        config_json = retrieve_config(section=section)
-        # Check if config path deesn't exist, for example "set nat"
-        # we set empty value for config_json data
-        # As we cannot send to the remote host section "nat None" config
-        if not config_json:
-            config_json = {}
-        logger.debug(
-            f"Retrieved config for section '{section}': {config_json}")
-
-        d = {'op': mode, 'path': section, 'section': config_json}
-        commands.append(d)
+    mask_dict, config_dict = retrieve_config(sections)
+    logger.debug(
+        f"Retrieved config for sections '{sections}': {config_dict}")
 
     set_config = set_remote_config(address=secondary_address,
                                    key=secondary_key,
-                                   commands=commands)
+                                   op=mode,
+                                   mask=mask_dict,
+                                   config=config_dict)
 
     logger.debug(f"Set config for sections '{sections}': {set_config}")
 
diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server
index 77870a84c..ecbf6fcf9 100755
--- a/src/services/vyos-http-api-server
+++ b/src/services/vyos-http-api-server
@@ -140,6 +140,14 @@ class ConfigSectionModel(ApiModel, BaseConfigSectionModel):
 class ConfigSectionListModel(ApiModel):
     commands: List[BaseConfigSectionModel]
 
+class BaseConfigSectionTreeModel(BaseModel):
+    op: StrictStr
+    mask: Dict
+    config: Dict
+
+class ConfigSectionTreeModel(ApiModel, BaseConfigSectionTreeModel):
+    pass
+
 class RetrieveModel(ApiModel):
     op: StrictStr
     path: List[StrictStr]
@@ -374,7 +382,7 @@ class MultipartRequest(Request):
                         self.form_err = (400,
                         f"Malformed command '{c}': missing 'op' field")
                     if endpoint not in ('/config-file', '/container-image',
-                                        '/image'):
+                                        '/image', '/configure-section'):
                         if 'path' not in c:
                             self.form_err = (400,
                             f"Malformed command '{c}': missing 'path' field")
@@ -392,12 +400,9 @@ class MultipartRequest(Request):
                             self.form_err = (400,
                             f"Malformed command '{c}': 'value' field must be a string")
                     if endpoint in ('/configure-section'):
-                        if 'section' not in c:
-                            self.form_err = (400,
-                            f"Malformed command '{c}': missing 'section' field")
-                        elif not isinstance(c['section'], dict):
+                        if 'section' not in c and 'config' not in c:
                             self.form_err = (400,
-                            f"Malformed command '{c}': 'section' field must be JSON of dict")
+                            f"Malformed command '{c}': missing 'section' or 'config' field")
 
                 if 'key' not in forms and 'key' not in merge:
                     self.form_err = (401, "Valid API key is required")
@@ -455,7 +460,8 @@ def call_commit(s: ConfigSession):
             logger.warning(f"ConfigSessionError: {e}")
 
 def _configure_op(data: Union[ConfigureModel, ConfigureListModel,
-                              ConfigSectionModel, ConfigSectionListModel],
+                              ConfigSectionModel, ConfigSectionListModel,
+                              ConfigSectionTreeModel],
                   request: Request, background_tasks: BackgroundTasks):
     session = app.state.vyos_session
     env = session.get_session_env()
@@ -481,7 +487,8 @@ def _configure_op(data: Union[ConfigureModel, ConfigureListModel,
     try:
         for c in data:
             op = c.op
-            path = c.path
+            if not isinstance(c, BaseConfigSectionTreeModel):
+                path = c.path
 
             if isinstance(c, BaseConfigureModel):
                 if c.value:
@@ -495,6 +502,10 @@ def _configure_op(data: Union[ConfigureModel, ConfigureListModel,
             elif isinstance(c, BaseConfigSectionModel):
                 section = c.section
 
+            elif isinstance(c, BaseConfigSectionTreeModel):
+                mask = c.mask
+                config = c.config
+
             if isinstance(c, BaseConfigureModel):
                 if op == 'set':
                     session.set(path, value=value)
@@ -514,6 +525,14 @@ def _configure_op(data: Union[ConfigureModel, ConfigureListModel,
                     session.load_section(path, section)
                 else:
                     raise ConfigSessionError(f"'{op}' is not a valid operation")
+
+            elif isinstance(c, BaseConfigSectionTreeModel):
+                if op == 'set':
+                    session.set_section_tree(config)
+                elif op == 'load':
+                    session.load_section_tree(mask, config)
+                else:
+                    raise ConfigSessionError(f"'{op}' is not a valid operation")
         # end for
         config = Config(session_env=env)
         d = get_config_diff(config)
@@ -554,7 +573,8 @@ def configure_op(data: Union[ConfigureModel,
 
 @app.post('/configure-section')
 def configure_section_op(data: Union[ConfigSectionModel,
-                                     ConfigSectionListModel],
+                                     ConfigSectionListModel,
+                                     ConfigSectionTreeModel],
                                request: Request, background_tasks: BackgroundTasks):
     return _configure_op(data, request, background_tasks)
 
-- 
cgit v1.2.3