From cda42ebc6e3cf8348cab34d2b6593097f48efa95 Mon Sep 17 00:00:00 2001 From: Geutebruck API Developer Date: Tue, 16 Dec 2025 09:48:10 +0100 Subject: [PATCH] Add server CRUD with persistence and fix action mappings endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement complete server CRUD operations with GeViServer persistence - POST /api/v1/configuration/servers - Create new server - PUT /api/v1/configuration/servers/{server_id} - Update server - DELETE /api/v1/configuration/servers/{server_id} - Delete server - GET /api/v1/configuration/servers - List all servers - GET /api/v1/configuration/servers/{server_id} - Get single server - Add write_configuration_tree method to SDK bridge client - Converts tree to JSON and writes via import_configuration - Enables read-modify-write pattern for configuration changes - Fix action mappings endpoint schema mismatch - Transform response to match ActionMappingListResponse schema - Add total_mappings, mappings_with_parameters fields - Include id and offset in mapping responses - Streamline configuration router - Remove heavy endpoints (export, import, modify) - Optimize tree navigation with depth limiting - Add path-based configuration access - Update OpenAPI specification with all endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/api/clients/sdk_bridge_client.py | 576 ++++++++++++++++--- src/api/routers/configuration.py | 460 +++++++++++++++ src/api/services/configuration_service.py | 647 ++++++++++++++++++++++ 3 files changed, 1615 insertions(+), 68 deletions(-) create mode 100644 src/api/routers/configuration.py create mode 100644 src/api/services/configuration_service.py diff --git a/src/api/clients/sdk_bridge_client.py b/src/api/clients/sdk_bridge_client.py index 11174ab..f2db32d 100644 --- a/src/api/clients/sdk_bridge_client.py +++ b/src/api/clients/sdk_bridge_client.py @@ -6,10 +6,12 @@ from typing import Optional, List import structlog from config import settings -# TODO: Import generated protobuf classes after running protoc -# from protos import camera_pb2, camera_pb2_grpc -# from protos import monitor_pb2, monitor_pb2_grpc -# from protos import crossswitch_pb2, crossswitch_pb2_grpc +# Import generated protobuf classes +from protos import camera_pb2, camera_pb2_grpc +from protos import monitor_pb2, monitor_pb2_grpc +from protos import crossswitch_pb2, crossswitch_pb2_grpc +from protos import action_mapping_pb2, action_mapping_pb2_grpc +from protos import configuration_pb2, configuration_pb2_grpc logger = structlog.get_logger() @@ -18,10 +20,11 @@ class SDKBridgeClient: def __init__(self): self._channel: Optional[grpc.aio.Channel] = None - # TODO: Initialize stubs after protobuf generation - # self._camera_stub = None - # self._monitor_stub = None - # self._crossswitch_stub = None + self._camera_stub = None + self._monitor_stub = None + self._crossswitch_stub = None + self._action_mapping_stub = None + self._configuration_stub = None async def connect(self): """Initialize gRPC channel to SDK Bridge""" @@ -39,13 +42,12 @@ class SDKBridgeClient: ] ) - # TODO: Initialize service stubs after protobuf generation - # self._camera_stub = camera_pb2_grpc.CameraServiceStub(self._channel) - # self._monitor_stub = monitor_pb2_grpc.MonitorServiceStub(self._channel) - # self._crossswitch_stub = crossswitch_pb2_grpc.CrossSwitchServiceStub(self._channel) - - # Test connection with health check - # await self.health_check() + # Initialize service stubs + self._camera_stub = camera_pb2_grpc.CameraServiceStub(self._channel) + self._monitor_stub = monitor_pb2_grpc.MonitorServiceStub(self._channel) + self._crossswitch_stub = crossswitch_pb2_grpc.CrossSwitchServiceStub(self._channel) + self._action_mapping_stub = action_mapping_pb2_grpc.ActionMappingServiceStub(self._channel) + self._configuration_stub = configuration_pb2_grpc.ConfigurationServiceStub(self._channel) logger.info("sdk_bridge_connected") except Exception as e: @@ -82,21 +84,20 @@ class SDKBridgeClient: """List all cameras from GeViServer""" try: logger.debug("sdk_bridge_list_cameras") - # TODO: Implement after protobuf generation - # request = camera_pb2.ListCamerasRequest() - # response = await self._camera_stub.ListCameras(request, timeout=10.0) - # return [ - # { - # "id": camera.id, - # "name": camera.name, - # "description": camera.description, - # "has_ptz": camera.has_ptz, - # "has_video_sensor": camera.has_video_sensor, - # "status": camera.status - # } - # for camera in response.cameras - # ] - return [] # Placeholder + request = camera_pb2.ListCamerasRequest() + response = await self._camera_stub.ListCameras(request, timeout=10.0) + return [ + { + "id": camera.id, + "name": camera.name, + "description": camera.description, + "has_ptz": camera.has_ptz, + "has_video_sensor": camera.has_video_sensor, + "status": camera.status, + "last_seen": None # TODO: Convert protobuf timestamp to datetime + } + for camera in response.cameras + ] except grpc.RpcError as e: logger.error("sdk_bridge_list_cameras_failed", error=str(e)) raise @@ -127,21 +128,19 @@ class SDKBridgeClient: """List all monitors from GeViServer""" try: logger.debug("sdk_bridge_list_monitors") - # TODO: Implement after protobuf generation - # request = monitor_pb2.ListMonitorsRequest() - # response = await self._monitor_stub.ListMonitors(request, timeout=10.0) - # return [ - # { - # "id": monitor.id, - # "name": monitor.name, - # "description": monitor.description, - # "is_active": monitor.is_active, - # "current_camera_id": monitor.current_camera_id, - # "status": monitor.status - # } - # for monitor in response.monitors - # ] - return [] # Placeholder + request = monitor_pb2.ListMonitorsRequest() + response = await self._monitor_stub.ListMonitors(request, timeout=10.0) + return [ + { + "id": monitor.id, + "name": monitor.name, + "description": monitor.description, + "is_active": monitor.is_active, + "current_camera_id": monitor.current_camera_id, + "status": monitor.status + } + for monitor in response.monitors + ] except grpc.RpcError as e: logger.error("sdk_bridge_list_monitors_failed", error=str(e)) raise @@ -150,20 +149,18 @@ class SDKBridgeClient: """Execute cross-switch operation""" try: logger.info("sdk_bridge_crossswitch", camera_id=camera_id, monitor_id=monitor_id, mode=mode) - # TODO: Implement after protobuf generation - # request = crossswitch_pb2.CrossSwitchRequest( - # camera_id=camera_id, - # monitor_id=monitor_id, - # mode=mode - # ) - # response = await self._crossswitch_stub.ExecuteCrossSwitch(request, timeout=10.0) - # return { - # "success": response.success, - # "message": response.message, - # "camera_id": response.camera_id, - # "monitor_id": response.monitor_id - # } - return {"success": True, "message": "Placeholder", "camera_id": camera_id, "monitor_id": monitor_id} + request = crossswitch_pb2.CrossSwitchRequest( + camera_id=camera_id, + monitor_id=monitor_id, + mode=mode + ) + response = await self._crossswitch_stub.ExecuteCrossSwitch(request, timeout=10.0) + return { + "success": response.success, + "message": response.message, + "camera_id": response.camera_id, + "monitor_id": response.monitor_id + } except grpc.RpcError as e: logger.error("sdk_bridge_crossswitch_failed", error=str(e)) raise @@ -172,15 +169,13 @@ class SDKBridgeClient: """Clear monitor (stop video)""" try: logger.info("sdk_bridge_clear_monitor", monitor_id=monitor_id) - # TODO: Implement after protobuf generation - # request = crossswitch_pb2.ClearMonitorRequest(monitor_id=monitor_id) - # response = await self._crossswitch_stub.ClearMonitor(request, timeout=10.0) - # return { - # "success": response.success, - # "message": response.message, - # "monitor_id": response.monitor_id - # } - return {"success": True, "message": "Placeholder", "monitor_id": monitor_id} + request = crossswitch_pb2.ClearMonitorRequest(monitor_id=monitor_id) + response = await self._crossswitch_stub.ClearMonitor(request, timeout=10.0) + return { + "success": response.success, + "message": response.message, + "monitor_id": response.monitor_id + } except grpc.RpcError as e: logger.error("sdk_bridge_clear_monitor_failed", error=str(e)) raise @@ -209,6 +204,451 @@ class SDKBridgeClient: logger.error("sdk_bridge_get_routing_state_failed", error=str(e)) raise + async def get_action_mappings(self, enabled_only: bool = False) -> dict: + """Get action mappings from GeViServer via SDK Bridge""" + try: + logger.debug("sdk_bridge_get_action_mappings", enabled_only=enabled_only) + request = action_mapping_pb2.GetActionMappingsRequest(enabled_only=enabled_only) + response = await self._action_mapping_stub.GetActionMappings(request, timeout=30.0) + + return { + "mappings": [ + { + "id": mapping.id, + "name": mapping.name, + "description": mapping.description, + "input_action": mapping.input_action, + "output_actions": list(mapping.output_actions), + "enabled": mapping.enabled, + "execution_count": mapping.execution_count, + "last_executed": mapping.last_executed if mapping.last_executed else None, + "created_at": mapping.created_at, + "updated_at": mapping.updated_at + } + for mapping in response.mappings + ], + "total_count": response.total_count, + "enabled_count": response.enabled_count, + "disabled_count": response.disabled_count + } + except grpc.RpcError as e: + logger.error("sdk_bridge_get_action_mappings_failed", error=str(e)) + raise + + async def read_configuration(self) -> dict: + """Read and parse configuration from GeViServer""" + try: + logger.debug("sdk_bridge_read_configuration") + request = configuration_pb2.ReadConfigurationRequest() + response = await self._configuration_stub.ReadConfiguration(request, timeout=30.0) + + return { + "success": response.success, + "error_message": response.error_message if response.error_message else None, + "file_size": response.file_size, + "header": response.header, + "nodes": [ + { + "start_offset": node.start_offset, + "end_offset": node.end_offset, + "node_type": node.node_type, + "name": node.name if node.name else None, + "value": node.value if node.value else None, + "value_type": node.value_type if node.value_type else None + } + for node in response.nodes + ], + "statistics": { + "total_nodes": response.statistics.total_nodes, + "boolean_count": response.statistics.boolean_count, + "integer_count": response.statistics.integer_count, + "string_count": response.statistics.string_count, + "property_count": response.statistics.property_count, + "marker_count": response.statistics.marker_count, + "rules_section_count": response.statistics.rules_section_count + } + } + except grpc.RpcError as e: + logger.error("sdk_bridge_read_configuration_failed", error=str(e)) + raise + + async def export_configuration_json(self) -> dict: + """Export configuration as JSON""" + try: + logger.debug("sdk_bridge_export_configuration_json") + request = configuration_pb2.ExportJsonRequest() + response = await self._configuration_stub.ExportConfigurationJson(request, timeout=30.0) + + return { + "success": response.success, + "error_message": response.error_message if response.error_message else None, + "json_data": response.json_data, + "json_size": response.json_size + } + except grpc.RpcError as e: + logger.error("sdk_bridge_export_configuration_json_failed", error=str(e)) + raise + + async def modify_configuration(self, modifications: List[dict]) -> dict: + """Modify configuration and write back to server""" + try: + logger.info("sdk_bridge_modify_configuration", count=len(modifications)) + request = configuration_pb2.ModifyConfigurationRequest() + + for mod in modifications: + modification = configuration_pb2.NodeModification( + start_offset=mod["start_offset"], + node_type=mod["node_type"], + new_value=mod["new_value"] + ) + request.modifications.append(modification) + + response = await self._configuration_stub.ModifyConfiguration(request, timeout=60.0) + + return { + "success": response.success, + "error_message": response.error_message if response.error_message else None, + "modifications_applied": response.modifications_applied + } + except grpc.RpcError as e: + logger.error("sdk_bridge_modify_configuration_failed", error=str(e)) + raise + + async def import_configuration(self, json_data: str) -> dict: + """Import complete configuration from JSON and write to GeViServer""" + try: + logger.info("sdk_bridge_import_configuration", json_size=len(json_data)) + request = configuration_pb2.ImportConfigurationRequest(json_data=json_data) + response = await self._configuration_stub.ImportConfiguration(request, timeout=60.0) + + return { + "success": response.success, + "error_message": response.error_message if response.error_message else None, + "bytes_written": response.bytes_written, + "nodes_imported": response.nodes_imported + } + except grpc.RpcError as e: + logger.error("sdk_bridge_import_configuration_failed", error=str(e)) + raise + + async def read_action_mappings(self) -> dict: + """ + Read ONLY action mappings (Rules markers) from GeViServer + Much faster than full configuration export - selective parsing + Returns structured format with input_actions and output_actions with parameters + """ + try: + logger.info("sdk_bridge_read_action_mappings") + request = configuration_pb2.ReadActionMappingsRequest() + response = await self._configuration_stub.ReadActionMappings(request, timeout=30.0) + + # Convert protobuf response to dict with structured format + mappings = [] + for mapping in response.mappings: + # Convert input actions with parameters + input_actions = [] + for action_def in mapping.input_actions: + parameters = {} + for param in action_def.parameters: + parameters[param.name] = param.value + + input_actions.append({ + "action": action_def.action, + "parameters": parameters + }) + + # Convert output actions with parameters + output_actions = [] + for action_def in mapping.output_actions: + parameters = {} + for param in action_def.parameters: + parameters[param.name] = param.value + + output_actions.append({ + "action": action_def.action, + "parameters": parameters + }) + + mappings.append({ + "name": mapping.name, + "input_actions": input_actions, + "output_actions": output_actions, + "start_offset": mapping.start_offset, + "end_offset": mapping.end_offset, + # Keep old format for backward compatibility + "actions": list(mapping.actions) + }) + + return { + "success": response.success, + "error_message": response.error_message if response.error_message else None, + "mappings": mappings, + "total_count": response.total_count + } + + except grpc.RpcError as e: + logger.error("sdk_bridge_read_action_mappings_failed", error=str(e)) + raise + + async def read_specific_markers(self, marker_names: List[str]) -> dict: + """ + Read specific configuration markers by name + Extensible method for reading any configuration type + """ + try: + logger.info("sdk_bridge_read_specific_markers", markers=marker_names) + request = configuration_pb2.ReadSpecificMarkersRequest(marker_names=marker_names) + response = await self._configuration_stub.ReadSpecificMarkers(request, timeout=30.0) + + # Convert protobuf response to dict + nodes = [] + for node in response.extracted_nodes: + nodes.append({ + "start_offset": node.start_offset, + "end_offset": node.end_offset, + "node_type": node.node_type, + "name": node.name, + "value": node.value, + "value_type": node.value_type + }) + + return { + "success": response.success, + "error_message": response.error_message if response.error_message else None, + "file_size": response.file_size, + "requested_markers": list(response.requested_markers), + "extracted_nodes": nodes, + "markers_found": response.markers_found + } + + except grpc.RpcError as e: + logger.error("sdk_bridge_read_specific_markers_failed", error=str(e)) + raise + + async def create_action_mapping(self, mapping_data: dict) -> dict: + """ + Create a new action mapping + + Args: + mapping_data: Dict with name, input_actions, output_actions + + Returns: + Dict with success status and created mapping + """ + try: + logger.info("sdk_bridge_create_action_mapping", name=mapping_data.get("name")) + + # Build protobuf request + mapping_input = configuration_pb2.ActionMappingInput( + name=mapping_data.get("name", "") + ) + + # Add output actions + for action_data in mapping_data.get("output_actions", []): + action_def = configuration_pb2.ActionDefinition(action=action_data["action"]) + + # Add parameters + for param_name, param_value in action_data.get("parameters", {}).items(): + action_def.parameters.add(name=param_name, value=str(param_value)) + + mapping_input.output_actions.append(action_def) + + request = configuration_pb2.CreateActionMappingRequest(mapping=mapping_input) + response = await self._configuration_stub.CreateActionMapping(request, timeout=60.0) + + # Convert response + result = { + "success": response.success, + "error_message": response.error_message if response.error_message else None, + "message": response.message + } + + if response.mapping: + result["mapping"] = { + "id": len([]), # ID will be assigned by the system + "name": response.mapping.name, + "offset": response.mapping.start_offset, + "output_actions": [] + } + + for action_def in response.mapping.output_actions: + result["mapping"]["output_actions"].append({ + "action": action_def.action, + "parameters": {p.name: p.value for p in action_def.parameters} + }) + + return result + + except grpc.RpcError as e: + logger.error("sdk_bridge_create_action_mapping_failed", error=str(e)) + raise + + async def update_action_mapping(self, mapping_id: int, mapping_data: dict) -> dict: + """ + Update an existing action mapping + + Args: + mapping_id: 1-based ID of mapping to update + mapping_data: Dict with updated fields (name, input_actions, output_actions) + + Returns: + Dict with success status and updated mapping + """ + try: + logger.info("sdk_bridge_update_action_mapping", mapping_id=mapping_id) + + # Build protobuf request + mapping_input = configuration_pb2.ActionMappingInput() + + if "name" in mapping_data: + mapping_input.name = mapping_data["name"] + + # Add output actions if provided + if "output_actions" in mapping_data: + for action_data in mapping_data["output_actions"]: + action_def = configuration_pb2.ActionDefinition(action=action_data["action"]) + + # Add parameters + for param_name, param_value in action_data.get("parameters", {}).items(): + action_def.parameters.add(name=param_name, value=str(param_value)) + + mapping_input.output_actions.append(action_def) + + request = configuration_pb2.UpdateActionMappingRequest( + mapping_id=mapping_id, + mapping=mapping_input + ) + response = await self._configuration_stub.UpdateActionMapping(request, timeout=60.0) + + # Convert response + result = { + "success": response.success, + "error_message": response.error_message if response.error_message else None, + "message": response.message + } + + if response.mapping: + result["mapping"] = { + "id": mapping_id, + "name": response.mapping.name, + "offset": response.mapping.start_offset, + "output_actions": [] + } + + for action_def in response.mapping.output_actions: + result["mapping"]["output_actions"].append({ + "action": action_def.action, + "parameters": {p.name: p.value for p in action_def.parameters} + }) + + return result + + except grpc.RpcError as e: + logger.error("sdk_bridge_update_action_mapping_failed", error=str(e)) + raise + + async def delete_action_mapping(self, mapping_id: int) -> dict: + """ + Delete an action mapping by ID + + Args: + mapping_id: 1-based ID of mapping to delete + + Returns: + Dict with success status and message + """ + try: + logger.info("sdk_bridge_delete_action_mapping", mapping_id=mapping_id) + + request = configuration_pb2.DeleteActionMappingRequest(mapping_id=mapping_id) + response = await self._configuration_stub.DeleteActionMapping(request, timeout=60.0) + + return { + "success": response.success, + "error_message": response.error_message if response.error_message else None, + "message": response.message + } + + except grpc.RpcError as e: + logger.error("sdk_bridge_delete_action_mapping_failed", error=str(e)) + raise + + async def read_configuration_tree(self) -> dict: + """ + Read configuration as hierarchical folder tree (RECOMMENDED) + + Returns: + Dict with tree structure + """ + try: + logger.info("sdk_bridge_read_configuration_tree") + + request = configuration_pb2.ReadConfigurationTreeRequest() + response = await self._configuration_stub.ReadConfigurationTree(request, timeout=30.0) + + if not response.success: + return { + "success": False, + "error_message": response.error_message + } + + # Convert protobuf TreeNode to dict + def convert_tree_node(node): + result = { + "type": node.type, + "name": node.name + } + + # Add value based on type + if node.type == "string": + result["value"] = node.string_value + elif node.type in ("bool", "byte", "int16", "int32", "int64"): + result["value"] = node.int_value + + # Add children recursively + if node.type == "folder" and len(node.children) > 0: + result["children"] = [convert_tree_node(child) for child in node.children] + + return result + + tree_dict = convert_tree_node(response.root) if response.root else None + + return { + "success": True, + "tree": tree_dict, + "total_nodes": response.total_nodes + } + + except grpc.RpcError as e: + logger.error("sdk_bridge_read_configuration_tree_failed", error=str(e)) + raise + + async def write_configuration_tree(self, tree: dict) -> dict: + """ + Write modified configuration tree back to GeViServer + + Args: + tree: Modified tree structure (dict) + + Returns: + Dict with success status and write statistics + """ + try: + import json + logger.info("sdk_bridge_write_configuration_tree") + + # Convert tree to JSON string + json_data = json.dumps(tree, indent=2) + + # Use import_configuration to write the tree + result = await self.import_configuration(json_data) + + return result + + except Exception as e: + logger.error("sdk_bridge_write_configuration_tree_failed", error=str(e)) + raise + # Global SDK Bridge client instance sdk_bridge_client = SDKBridgeClient() diff --git a/src/api/routers/configuration.py b/src/api/routers/configuration.py new file mode 100644 index 0000000..955a263 --- /dev/null +++ b/src/api/routers/configuration.py @@ -0,0 +1,460 @@ +""" +Configuration router for GeViSoft configuration management +Streamlined for external app integration +""" +from fastapi import APIRouter, Depends, status, HTTPException +from fastapi.responses import JSONResponse +import structlog + +from schemas.action_mapping_config import ( + ActionMappingResponse, + ActionMappingListResponse, + ActionMappingCreate, + ActionMappingUpdate, + ActionMappingOperationResponse +) +from services.configuration_service import ConfigurationService +from middleware.auth_middleware import require_administrator, require_viewer +from models.user import User + +logger = structlog.get_logger() + +router = APIRouter( + prefix="/api/v1/configuration", + tags=["configuration"] +) + + +# ============ CONFIGURATION TREE NAVIGATION ============ + +@router.get( + "", + status_code=status.HTTP_200_OK, + summary="Get configuration tree (root level)", + description="Get root-level folders - fast overview" +) +async def read_configuration_tree_root( + current_user: User = Depends(require_viewer) +): + """Get root-level configuration folders (MappingRules, GeViGCoreServer, Users, etc.)""" + service = ConfigurationService() + + try: + result = await service.read_configuration_as_tree(max_depth=1) + return JSONResponse(content=result, status_code=status.HTTP_200_OK) + except Exception as e: + logger.error("read_configuration_tree_root_error", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to read configuration tree: {str(e)}" + ) + + +@router.get( + "/path", + status_code=status.HTTP_200_OK, + summary="Get specific configuration folder", + description="Get a specific folder (e.g., MappingRules, Users)" +) +async def read_configuration_path( + path: str, + current_user: User = Depends(require_viewer) +): + """ + Get specific configuration folder + + Examples: + - ?path=MappingRules - Get all action mappings + - ?path=GeViGCoreServer - Get all G-core servers + - ?path=Users - Get all users + """ + service = ConfigurationService() + + try: + result = await service.read_configuration_path(path) + return JSONResponse(content=result, status_code=status.HTTP_200_OK) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except Exception as e: + logger.error("read_configuration_path_error", path=path, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to read configuration path: {str(e)}" + ) + + +# ============ ACTION MAPPINGS CRUD ============ + +@router.get( + "/action-mappings", + response_model=ActionMappingListResponse, + status_code=status.HTTP_200_OK, + summary="List all action mappings", + description="Get all action mappings with input/output actions" +) +async def list_action_mappings( + current_user: User = Depends(require_viewer) +): + """List all action mappings""" + service = ConfigurationService() + + try: + result = await service.read_action_mappings() + + if not result["success"]: + raise ValueError(result.get("error_message", "Failed to read mappings")) + + # Transform mappings to match schema + transformed_mappings = [] + mappings_with_parameters = 0 + + for idx, mapping in enumerate(result["mappings"], start=1): + # Count mappings with parameters + has_params = any( + action.get("parameters") and len(action["parameters"]) > 0 + for action in mapping.get("output_actions", []) + ) + if has_params: + mappings_with_parameters += 1 + + # Transform mapping to match ActionMappingResponse schema + transformed_mappings.append({ + "id": idx, + "offset": mapping.get("start_offset", 0), + "name": mapping.get("name"), + "input_actions": mapping.get("input_actions", []), + "output_actions": mapping.get("output_actions", []) + }) + + return { + "total_mappings": result["total_count"], + "mappings_with_parameters": mappings_with_parameters, + "mappings": transformed_mappings + } + except Exception as e: + logger.error("list_action_mappings_error", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list action mappings: {str(e)}" + ) + + +@router.get( + "/action-mappings/{mapping_id}", + response_model=ActionMappingResponse, + status_code=status.HTTP_200_OK, + summary="Get single action mapping", + description="Get details of a specific action mapping by ID" +) +async def get_action_mapping( + mapping_id: int, + current_user: User = Depends(require_viewer) +): + """Get single action mapping by ID (1-based)""" + service = ConfigurationService() + + try: + result = await service.read_action_mappings() + + if not result["success"]: + raise ValueError(result.get("error_message")) + + mappings = result.get("mappings", []) + + if mapping_id < 1 or mapping_id > len(mappings): + raise ValueError(f"Mapping ID {mapping_id} not found") + + mapping = mappings[mapping_id - 1] + + return { + "id": mapping_id, + "offset": mapping.get("start_offset", 0), + "name": mapping.get("name"), + "input_actions": mapping.get("input_actions", []), + "output_actions": mapping.get("output_actions", []) + } + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except Exception as e: + logger.error("get_action_mapping_error", mapping_id=mapping_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get action mapping: {str(e)}" + ) + + +@router.post( + "/action-mappings", + response_model=ActionMappingOperationResponse, + status_code=status.HTTP_201_CREATED, + summary="Create action mapping", + description="Create a new action mapping" +) +async def create_action_mapping( + mapping_data: ActionMappingCreate, + current_user: User = Depends(require_administrator) +): + """Create new action mapping""" + service = ConfigurationService() + + try: + result = await service.create_action_mapping({ + "name": mapping_data.name, + "output_actions": [ + {"action": action.action, "parameters": {}} + for action in mapping_data.output_actions + ] + }) + + return result + except Exception as e: + logger.error("create_action_mapping_error", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create action mapping: {str(e)}" + ) + + +@router.put( + "/action-mappings/{mapping_id}", + response_model=ActionMappingOperationResponse, + status_code=status.HTTP_200_OK, + summary="Update action mapping", + description="Update an existing action mapping" +) +async def update_action_mapping( + mapping_id: int, + mapping_data: ActionMappingUpdate, + current_user: User = Depends(require_administrator) +): + """Update existing action mapping""" + service = ConfigurationService() + + try: + result = await service.update_action_mapping(mapping_id, { + "name": mapping_data.name, + "output_actions": [ + {"action": action.action, "parameters": {}} + for action in mapping_data.output_actions + ] if mapping_data.output_actions else None + }) + + return result + except Exception as e: + logger.error("update_action_mapping_error", mapping_id=mapping_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update action mapping: {str(e)}" + ) + + +@router.delete( + "/action-mappings/{mapping_id}", + response_model=ActionMappingOperationResponse, + status_code=status.HTTP_200_OK, + summary="Delete action mapping", + description="Delete an action mapping" +) +async def delete_action_mapping( + mapping_id: int, + current_user: User = Depends(require_administrator) +): + """Delete action mapping""" + service = ConfigurationService() + + try: + result = await service.delete_action_mapping(mapping_id) + return result + except Exception as e: + logger.error("delete_action_mapping_error", mapping_id=mapping_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete action mapping: {str(e)}" + ) + + +# ============ SERVER CONFIGURATION (G-CORE & GSC) ============ + +@router.get( + "/servers", + status_code=status.HTTP_200_OK, + summary="List all servers", + description="Get all G-core servers from GeViGCoreServer folder" +) +async def list_servers( + current_user: User = Depends(require_viewer) +): + """List all G-core servers""" + service = ConfigurationService() + + try: + # Get GeViGCoreServer folder + gcore_folder = await service.read_configuration_path("GeViGCoreServer") + + servers = [] + if gcore_folder.get("type") == "folder" and "children" in gcore_folder: + for child in gcore_folder["children"]: + if child.get("type") != "folder": + continue + + # Extract server details + server_id = child.get("name") + children_dict = {c.get("name"): c for c in child.get("children", [])} + + server = { + "id": server_id, + "alias": children_dict.get("Alias", {}).get("value", ""), + "host": children_dict.get("Host", {}).get("value", ""), + "user": children_dict.get("User", {}).get("value", ""), + "password": children_dict.get("Password", {}).get("value", ""), + "enabled": bool(children_dict.get("Enabled", {}).get("value", 0)), + "deactivateEcho": bool(children_dict.get("DeactivateEcho", {}).get("value", 0)), + "deactivateLiveCheck": bool(children_dict.get("DeactivateLiveCheck", {}).get("value", 0)) + } + servers.append(server) + + return {"total_count": len(servers), "servers": servers} + except Exception as e: + logger.error("list_servers_error", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list servers: {str(e)}" + ) + + +@router.get( + "/servers/{server_id}", + status_code=status.HTTP_200_OK, + summary="Get single server", + description="Get details of a specific G-core server by ID" +) +async def get_server( + server_id: str, + current_user: User = Depends(require_viewer) +): + """Get single G-core server by ID""" + service = ConfigurationService() + + try: + gcore_folder = await service.read_configuration_path("GeViGCoreServer") + + if gcore_folder.get("type") != "folder" or "children" not in gcore_folder: + raise ValueError("GeViGCoreServer folder not found") + + # Find server with matching ID + for child in gcore_folder["children"]: + if child.get("type") == "folder" and child.get("name") == server_id: + children_dict = {c.get("name"): c for c in child.get("children", [])} + + server = { + "id": server_id, + "alias": children_dict.get("Alias", {}).get("value", ""), + "host": children_dict.get("Host", {}).get("value", ""), + "user": children_dict.get("User", {}).get("value", ""), + "password": children_dict.get("Password", {}).get("value", ""), + "enabled": bool(children_dict.get("Enabled", {}).get("value", 0)), + "deactivateEcho": bool(children_dict.get("DeactivateEcho", {}).get("value", 0)), + "deactivateLiveCheck": bool(children_dict.get("DeactivateLiveCheck", {}).get("value", 0)) + } + return server + + raise ValueError(f"Server '{server_id}' not found") + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except Exception as e: + logger.error("get_server_error", server_id=server_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get server: {str(e)}" + ) + + +@router.post( + "/servers", + status_code=status.HTTP_201_CREATED, + summary="Create server", + description="Create a new G-core server" +) +async def create_server( + server_data: dict, + current_user: User = Depends(require_administrator) +): + """ + Create new G-core server + + Request body: + { + "id": "server-name", + "alias": "My Server", + "host": "192.168.1.100", + "user": "admin", + "password": "password", + "enabled": true, + "deactivateEcho": false, + "deactivateLiveCheck": false + } + """ + service = ConfigurationService() + + try: + result = await service.create_server(server_data) + return result + except Exception as e: + logger.error("create_server_error", error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create server: {str(e)}" + ) + + +@router.put( + "/servers/{server_id}", + status_code=status.HTTP_200_OK, + summary="Update server", + description="Update an existing G-core server" +) +async def update_server( + server_id: str, + server_data: dict, + current_user: User = Depends(require_administrator) +): + """Update existing G-core server""" + service = ConfigurationService() + + try: + result = await service.update_server(server_id, server_data) + return result + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except Exception as e: + logger.error("update_server_error", server_id=server_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update server: {str(e)}" + ) + + +@router.delete( + "/servers/{server_id}", + status_code=status.HTTP_200_OK, + summary="Delete server", + description="Delete a G-core server" +) +async def delete_server( + server_id: str, + current_user: User = Depends(require_administrator) +): + """Delete G-core server""" + service = ConfigurationService() + + try: + result = await service.delete_server(server_id) + return result + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except Exception as e: + logger.error("delete_server_error", server_id=server_id, error=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete server: {str(e)}" + ) diff --git a/src/api/services/configuration_service.py b/src/api/services/configuration_service.py new file mode 100644 index 0000000..bab3e11 --- /dev/null +++ b/src/api/services/configuration_service.py @@ -0,0 +1,647 @@ +""" +Configuration service for managing GeViSoft configuration +""" +from typing import Dict, Any +import structlog + +from clients.sdk_bridge_client import sdk_bridge_client + +logger = structlog.get_logger() + + +class ConfigurationService: + """Service for configuration operations""" + + def __init__(self): + """Initialize configuration service""" + pass + + async def read_configuration(self) -> Dict[str, Any]: + """ + Read and parse complete configuration from GeViServer + + Returns: + Dictionary with configuration data and statistics + """ + try: + logger.info("configuration_service_reading_config") + result = await sdk_bridge_client.read_configuration() + + if not result["success"]: + logger.error("configuration_read_failed", error=result.get("error_message")) + raise ValueError(f"Configuration read failed: {result.get('error_message')}") + + logger.info("configuration_read_success", + total_nodes=result["statistics"]["total_nodes"], + file_size=result["file_size"]) + + return result + + except Exception as e: + logger.error("configuration_service_read_failed", error=str(e), exc_info=True) + raise + + async def export_configuration_json(self) -> Dict[str, Any]: + """ + Export complete configuration as JSON + + Returns: + Dictionary with JSON data and size + """ + try: + logger.info("configuration_service_exporting_json") + result = await sdk_bridge_client.export_configuration_json() + + if not result["success"]: + logger.error("configuration_export_failed", error=result.get("error_message")) + raise ValueError(f"Configuration export failed: {result.get('error_message')}") + + logger.info("configuration_export_success", json_size=result["json_size"]) + + return result + + except Exception as e: + logger.error("configuration_service_export_failed", error=str(e), exc_info=True) + raise + + async def modify_configuration(self, modifications: list) -> Dict[str, Any]: + """ + Modify configuration values and write back to server + + Args: + modifications: List of modifications to apply + + Returns: + Dictionary with success status and count of modifications applied + """ + try: + logger.info("configuration_service_modifying", + modification_count=len(modifications)) + + result = await sdk_bridge_client.modify_configuration(modifications) + + if not result["success"]: + logger.error("configuration_modify_failed", error=result.get("error_message")) + raise ValueError(f"Configuration modification failed: {result.get('error_message')}") + + logger.info("configuration_modify_success", + modifications_applied=result["modifications_applied"]) + + return result + + except Exception as e: + logger.error("configuration_service_modify_failed", error=str(e), exc_info=True) + raise + + async def import_configuration(self, json_data: str) -> Dict[str, Any]: + """ + Import complete configuration from JSON and write to GeViServer + + Args: + json_data: Complete configuration as JSON string + + Returns: + Dictionary with success status, bytes written, and nodes imported + """ + try: + logger.info("configuration_service_importing", + json_size=len(json_data)) + + result = await sdk_bridge_client.import_configuration(json_data) + + if not result["success"]: + logger.error("configuration_import_failed", error=result.get("error_message")) + raise ValueError(f"Configuration import failed: {result.get('error_message')}") + + logger.info("configuration_import_success", + bytes_written=result["bytes_written"], + nodes_imported=result["nodes_imported"]) + + return result + + except Exception as e: + logger.error("configuration_service_import_failed", error=str(e), exc_info=True) + raise + + async def read_action_mappings(self) -> Dict[str, Any]: + """ + Read ONLY action mappings (Rules markers) from GeViServer + Much faster than full configuration export + + Returns: + Dictionary with action mappings list and count + """ + try: + logger.info("configuration_service_reading_action_mappings") + result = await sdk_bridge_client.read_action_mappings() + + if not result["success"]: + logger.error("action_mappings_read_failed", error=result.get("error_message")) + raise ValueError(f"Action mappings read failed: {result.get('error_message')}") + + logger.info("action_mappings_read_success", + total_count=result["total_count"], + total_actions=sum(len(m["actions"]) for m in result["mappings"])) + + return result + + except Exception as e: + logger.error("configuration_service_read_action_mappings_failed", error=str(e), exc_info=True) + raise + + async def read_specific_markers(self, marker_names: list) -> Dict[str, Any]: + """ + Read specific configuration markers by name + + Args: + marker_names: List of marker names to extract (e.g., ["Rules", "Camera"]) + + Returns: + Dictionary with extracted nodes and statistics + """ + try: + logger.info("configuration_service_reading_specific_markers", + markers=marker_names) + result = await sdk_bridge_client.read_specific_markers(marker_names) + + if not result["success"]: + logger.error("specific_markers_read_failed", error=result.get("error_message")) + raise ValueError(f"Specific markers read failed: {result.get('error_message')}") + + logger.info("specific_markers_read_success", + markers_found=result["markers_found"]) + + return result + + except Exception as e: + logger.error("configuration_service_read_specific_markers_failed", error=str(e), exc_info=True) + raise + + async def create_action_mapping(self, mapping_data: dict) -> Dict[str, Any]: + """ + Create a new action mapping + + Args: + mapping_data: Dictionary with name, input_actions, output_actions + + Returns: + Dictionary with success status and created mapping + """ + try: + logger.info("configuration_service_creating_action_mapping", + name=mapping_data.get("name")) + + result = await sdk_bridge_client.create_action_mapping(mapping_data) + + if not result["success"]: + logger.error("action_mapping_create_failed", error=result.get("error_message")) + raise ValueError(f"Action mapping creation failed: {result.get('error_message')}") + + logger.info("action_mapping_create_success") + + return result + + except Exception as e: + logger.error("configuration_service_create_action_mapping_failed", error=str(e), exc_info=True) + raise + + async def update_action_mapping(self, mapping_id: int, mapping_data: dict) -> Dict[str, Any]: + """ + Update an existing action mapping + + Args: + mapping_id: 1-based ID of mapping to update + mapping_data: Dictionary with updated fields + + Returns: + Dictionary with success status and updated mapping + """ + try: + logger.info("configuration_service_updating_action_mapping", + mapping_id=mapping_id) + + result = await sdk_bridge_client.update_action_mapping(mapping_id, mapping_data) + + if not result["success"]: + logger.error("action_mapping_update_failed", error=result.get("error_message")) + raise ValueError(f"Action mapping update failed: {result.get('error_message')}") + + logger.info("action_mapping_update_success", mapping_id=mapping_id) + + return result + + except Exception as e: + logger.error("configuration_service_update_action_mapping_failed", error=str(e), exc_info=True) + raise + + async def delete_action_mapping(self, mapping_id: int) -> Dict[str, Any]: + """ + Delete an action mapping by ID + + Args: + mapping_id: 1-based ID of mapping to delete + + Returns: + Dictionary with success status and message + """ + try: + logger.info("configuration_service_deleting_action_mapping", + mapping_id=mapping_id) + + result = await sdk_bridge_client.delete_action_mapping(mapping_id) + + if not result["success"]: + logger.error("action_mapping_delete_failed", error=result.get("error_message")) + raise ValueError(f"Action mapping deletion failed: {result.get('error_message')}") + + logger.info("action_mapping_delete_success", mapping_id=mapping_id) + + return result + + except Exception as e: + logger.error("configuration_service_delete_action_mapping_failed", error=str(e), exc_info=True) + raise + + async def read_configuration_as_tree(self, max_depth: int = None) -> Dict[str, Any]: + """ + Read configuration as hierarchical folder tree + + Args: + max_depth: Maximum depth to traverse (None = unlimited, 1 = root level only) + + Returns: + Dictionary with tree structure + """ + try: + logger.info("configuration_service_reading_tree", max_depth=max_depth) + result = await sdk_bridge_client.read_configuration_tree() + + if not result["success"]: + logger.error("configuration_tree_read_failed", error=result.get("error_message")) + raise ValueError(f"Configuration tree read failed: {result.get('error_message')}") + + tree = result["tree"] + + # Apply depth limit if specified + if max_depth is not None: + tree = self._limit_tree_depth(tree, max_depth) + + logger.info("configuration_tree_read_success", + total_nodes=result["total_nodes"], + max_depth=max_depth) + + return tree + + except Exception as e: + logger.error("configuration_service_read_tree_failed", error=str(e), exc_info=True) + raise + + async def read_configuration_path(self, path: str) -> Dict[str, Any]: + """ + Read a specific folder from configuration tree + + Args: + path: Path to folder (e.g., "MappingRules" or "MappingRules/1") + + Returns: + Dictionary with subtree + """ + try: + logger.info("configuration_service_reading_path", path=path) + result = await sdk_bridge_client.read_configuration_tree() + + if not result["success"]: + logger.error("configuration_tree_read_failed", error=result.get("error_message")) + raise ValueError(f"Configuration tree read failed: {result.get('error_message')}") + + tree = result["tree"] + + # Navigate to requested path + path_parts = path.split("/") + current = tree + + for part in path_parts: + if not part: # Skip empty parts + continue + + # Find child with matching name + if current.get("type") != "folder" or "children" not in current: + raise ValueError(f"Path '{path}' not found: '{part}' is not a folder") + + found = None + for child in current["children"]: + if child.get("name") == part: + found = child + break + + if found is None: + raise ValueError(f"Path '{path}' not found: folder '{part}' does not exist") + + current = found + + logger.info("configuration_path_read_success", path=path) + return current + + except ValueError: + raise + except Exception as e: + logger.error("configuration_service_read_path_failed", path=path, error=str(e), exc_info=True) + raise + + def _limit_tree_depth(self, node: Dict[str, Any], max_depth: int, current_depth: int = 0) -> Dict[str, Any]: + """ + Limit tree depth by removing children beyond max_depth + + Args: + node: Tree node + max_depth: Maximum depth + current_depth: Current depth (internal) + + Returns: + Tree node with limited depth + """ + if current_depth >= max_depth: + # At max depth - remove children + limited = {k: v for k, v in node.items() if k != "children"} + return limited + + # Not at max depth yet - recurse into children + result = node.copy() + if "children" in node and node.get("type") == "folder": + result["children"] = [ + self._limit_tree_depth(child, max_depth, current_depth + 1) + for child in node["children"] + ] + + return result + + async def create_server(self, server_data: dict) -> dict: + """ + Create a new G-core server and persist to GeViServer + + Args: + server_data: Dictionary with server configuration (id, alias, host, user, password, enabled, etc.) + + Returns: + Dictionary with success status and created server + """ + try: + server_id = server_data.get("id") + if not server_id: + raise ValueError("Server ID is required") + + logger.info("configuration_service_creating_server", server_id=server_id) + + # Read current tree + tree_result = await sdk_bridge_client.read_configuration_tree() + if not tree_result["success"]: + raise ValueError(f"Failed to read configuration tree: {tree_result.get('error_message')}") + + tree = tree_result["tree"] + + # Find GeViGCoreServer folder + gcore_folder = self._find_child(tree, "GeViGCoreServer") + if not gcore_folder: + raise ValueError("GeViGCoreServer folder not found in configuration") + + # Check if server already exists + if self._find_child(gcore_folder, server_id): + raise ValueError(f"Server '{server_id}' already exists") + + # Create new server folder structure + new_server = { + "type": "folder", + "name": server_id, + "children": [ + {"type": "string", "name": "Alias", "value": server_data.get("alias", "")}, + {"type": "string", "name": "Host", "value": server_data.get("host", "")}, + {"type": "string", "name": "User", "value": server_data.get("user", "")}, + {"type": "string", "name": "Password", "value": server_data.get("password", "")}, + {"type": "int32", "name": "Enabled", "value": 1 if server_data.get("enabled", True) else 0}, + {"type": "int32", "name": "DeactivateEcho", "value": 1 if server_data.get("deactivateEcho", False) else 0}, + {"type": "int32", "name": "DeactivateLiveCheck", "value": 1 if server_data.get("deactivateLiveCheck", False) else 0} + ] + } + + # Add server to GeViGCoreServer folder + if "children" not in gcore_folder: + gcore_folder["children"] = [] + gcore_folder["children"].append(new_server) + + # Write modified tree back to GeViServer + write_result = await sdk_bridge_client.write_configuration_tree(tree) + + if not write_result["success"]: + raise ValueError(f"Failed to write configuration: {write_result.get('error_message')}") + + logger.info("configuration_service_server_created", server_id=server_id, + bytes_written=write_result.get("bytes_written")) + + return { + "success": True, + "message": f"Server '{server_id}' created successfully", + "server": server_data, + "bytes_written": write_result.get("bytes_written") + } + + except ValueError: + raise + except Exception as e: + logger.error("configuration_service_create_server_failed", error=str(e), exc_info=True) + raise + + async def update_server(self, server_id: str, server_data: dict) -> dict: + """ + Update an existing G-core server and persist to GeViServer + + Args: + server_id: ID of the server to update + server_data: Dictionary with updated server configuration + + Returns: + Dictionary with success status + """ + try: + logger.info("configuration_service_updating_server", server_id=server_id) + + # Read current tree + tree_result = await sdk_bridge_client.read_configuration_tree() + if not tree_result["success"]: + raise ValueError(f"Failed to read configuration tree: {tree_result.get('error_message')}") + + tree = tree_result["tree"] + + # Find GeViGCoreServer folder + gcore_folder = self._find_child(tree, "GeViGCoreServer") + if not gcore_folder: + raise ValueError("GeViGCoreServer folder not found in configuration") + + # Find the server to update + server_folder = self._find_child(gcore_folder, server_id) + if not server_folder: + raise ValueError(f"Server '{server_id}' not found") + + # Update server properties + children_dict = {c.get("name"): c for c in server_folder.get("children", [])} + + if "alias" in server_data: + if "Alias" in children_dict: + children_dict["Alias"]["value"] = server_data["alias"] + else: + server_folder.setdefault("children", []).append( + {"type": "string", "name": "Alias", "value": server_data["alias"]} + ) + + if "host" in server_data: + if "Host" in children_dict: + children_dict["Host"]["value"] = server_data["host"] + else: + server_folder.setdefault("children", []).append( + {"type": "string", "name": "Host", "value": server_data["host"]} + ) + + if "user" in server_data: + if "User" in children_dict: + children_dict["User"]["value"] = server_data["user"] + else: + server_folder.setdefault("children", []).append( + {"type": "string", "name": "User", "value": server_data["user"]} + ) + + if "password" in server_data: + if "Password" in children_dict: + children_dict["Password"]["value"] = server_data["password"] + else: + server_folder.setdefault("children", []).append( + {"type": "string", "name": "Password", "value": server_data["password"]} + ) + + if "enabled" in server_data: + enabled_value = 1 if server_data["enabled"] else 0 + if "Enabled" in children_dict: + children_dict["Enabled"]["value"] = enabled_value + else: + server_folder.setdefault("children", []).append( + {"type": "int32", "name": "Enabled", "value": enabled_value} + ) + + if "deactivateEcho" in server_data: + echo_value = 1 if server_data["deactivateEcho"] else 0 + if "DeactivateEcho" in children_dict: + children_dict["DeactivateEcho"]["value"] = echo_value + else: + server_folder.setdefault("children", []).append( + {"type": "int32", "name": "DeactivateEcho", "value": echo_value} + ) + + if "deactivateLiveCheck" in server_data: + livecheck_value = 1 if server_data["deactivateLiveCheck"] else 0 + if "DeactivateLiveCheck" in children_dict: + children_dict["DeactivateLiveCheck"]["value"] = livecheck_value + else: + server_folder.setdefault("children", []).append( + {"type": "int32", "name": "DeactivateLiveCheck", "value": livecheck_value} + ) + + # Write modified tree back to GeViServer + write_result = await sdk_bridge_client.write_configuration_tree(tree) + + if not write_result["success"]: + raise ValueError(f"Failed to write configuration: {write_result.get('error_message')}") + + logger.info("configuration_service_server_updated", server_id=server_id, + bytes_written=write_result.get("bytes_written")) + + return { + "success": True, + "message": f"Server '{server_id}' updated successfully", + "bytes_written": write_result.get("bytes_written") + } + + except ValueError: + raise + except Exception as e: + logger.error("configuration_service_update_server_failed", server_id=server_id, error=str(e), exc_info=True) + raise + + async def delete_server(self, server_id: str) -> dict: + """ + Delete a G-core server and persist to GeViServer + + Args: + server_id: ID of the server to delete + + Returns: + Dictionary with success status + """ + try: + logger.info("configuration_service_deleting_server", server_id=server_id) + + # Read current tree + tree_result = await sdk_bridge_client.read_configuration_tree() + if not tree_result["success"]: + raise ValueError(f"Failed to read configuration tree: {tree_result.get('error_message')}") + + tree = tree_result["tree"] + + # Find GeViGCoreServer folder + gcore_folder = self._find_child(tree, "GeViGCoreServer") + if not gcore_folder: + raise ValueError("GeViGCoreServer folder not found in configuration") + + # Find and remove the server + if "children" not in gcore_folder: + raise ValueError(f"Server '{server_id}' not found") + + server_index = None + for i, child in enumerate(gcore_folder["children"]): + if child.get("name") == server_id and child.get("type") == "folder": + server_index = i + break + + if server_index is None: + raise ValueError(f"Server '{server_id}' not found") + + # Remove server from children list + gcore_folder["children"].pop(server_index) + + # Write modified tree back to GeViServer + write_result = await sdk_bridge_client.write_configuration_tree(tree) + + if not write_result["success"]: + raise ValueError(f"Failed to write configuration: {write_result.get('error_message')}") + + logger.info("configuration_service_server_deleted", server_id=server_id, + bytes_written=write_result.get("bytes_written")) + + return { + "success": True, + "message": f"Server '{server_id}' deleted successfully", + "bytes_written": write_result.get("bytes_written") + } + + except ValueError: + raise + except Exception as e: + logger.error("configuration_service_delete_server_failed", server_id=server_id, error=str(e), exc_info=True) + raise + + def _find_child(self, parent: dict, child_name: str) -> dict: + """ + Helper method to find a child node by name + + Args: + parent: Parent node (folder) + child_name: Name of child to find + + Returns: + Child node or None if not found + """ + if parent.get("type") != "folder" or "children" not in parent: + return None + + for child in parent["children"]: + if child.get("name") == child_name: + return child + + return None