Commit c6085005 authored by Waleed Akbar's avatar Waleed Akbar
Browse files

Refactor Pluggables Service and add SBI support

- Removed unused import of google.protobuf.field_mask from pluggables.proto.
- Updated DigitalSubcarrierGroupState and PluggableConfig messages in pluggables.proto to include additional fields for configuration management.
- Enhanced PluggablesServiceServicerImpl to support pushing configurations to devices, including error handling and logging.
- Added new methods for translating pluggable configurations to NETCONF format in config_translator.py.
- Created CommonObjects.py and PreparePluggablesTestScenario.py for test setup and device configuration.
- Implemented comprehensive tests for creating, configuring, retrieving, and deleting pluggables with NETCONF devices in test_pluggables_with_SBI.py.
- Updated pytest.ini to include integration test marker for better test categorization.
parent a7c55b2a
Loading
Loading
Loading
Loading
+8 −6
Original line number Diff line number Diff line
@@ -3,7 +3,6 @@ syntax = "proto3";
package tfs.pluggables.v0;

import "context.proto";
import "google/protobuf/field_mask.proto";

service PluggablesService {
  rpc CreatePluggable      (CreatePluggableRequest)      returns (Pluggable)              {}
@@ -65,6 +64,10 @@ message DigitalSubcarrierGroupState {

message PluggableConfig {
  PluggableId id                                   = 1;
  double target_output_power_dbm                   = 2;   // target output power for the pluggable
  double center_frequency_mhz                      = 3;   // center frequency in MHz
  int32  operational_mode                          = 4;   // e.g., 0=off and 1=on 
  int32  line_port                                 = 5;   // line port number
  repeated DigitalSubcarrierGroupConfig dsc_groups = 10;
}

@@ -109,8 +112,7 @@ message DeletePluggableRequest {

message ConfigurePluggableRequest {
  PluggableConfig config      = 1;
  google.protobuf.FieldMask update_mask = 2;    // Not Implemented yet (for partial updates)
  View view_level                       = 3;
  View view_level             = 2;
  int32 apply_timeout_seconds = 10;   // Not Implemented yet (for timeout)
}

+1 −1
Original line number Diff line number Diff line
@@ -16,7 +16,7 @@ syntax = "proto3";
package policy;

import "context.proto";
import "policy_condition.proto";    // WARNING: Not used  
import "policy_condition.proto";
import "policy_action.proto";
import "monitoring.proto"; // to be migrated to: "kpi_manager.proto"

+13 −13
Original line number Diff line number Diff line
@@ -98,19 +98,19 @@ if LOAD_ALL_DEVICE_DRIVERS:
            }
        ]))

if LOAD_ALL_DEVICE_DRIVERS:
    from .gnmi_openconfig.GnmiOpenConfigDriver import GnmiOpenConfigDriver # pylint: disable=wrong-import-position
    DRIVERS.append(
        (GnmiOpenConfigDriver, [
            {
                # Real Packet Router, specifying gNMI OpenConfig Driver => use GnmiOpenConfigDriver
                FilterFieldEnum.DEVICE_TYPE: [
                    DeviceTypeEnum.PACKET_POP,
                    DeviceTypeEnum.PACKET_ROUTER,
                ],
                FilterFieldEnum.DRIVER     : DeviceDriverEnum.DEVICEDRIVER_GNMI_OPENCONFIG,
            }
        ]))
# if LOAD_ALL_DEVICE_DRIVERS:
#     from .gnmi_openconfig.GnmiOpenConfigDriver import GnmiOpenConfigDriver # pylint: disable=wrong-import-position
#     DRIVERS.append(
#         (GnmiOpenConfigDriver, [
#             {
#                 # Real Packet Router, specifying gNMI OpenConfig Driver => use GnmiOpenConfigDriver
#                 FilterFieldEnum.DEVICE_TYPE: [
#                     DeviceTypeEnum.PACKET_POP,
#                     DeviceTypeEnum.PACKET_ROUTER,
#                 ],
#                 FilterFieldEnum.DRIVER     : DeviceDriverEnum.DEVICEDRIVER_GNMI_OPENCONFIG,
#             }
#         ]))

if LOAD_ALL_DEVICE_DRIVERS:
    from .gnmi_nokia_srlinux.GnmiNokiaSrLinuxDriver import GnmiNokiaSrLinuxDriver # pylint: disable=wrong-import-position
+133 −6
Original line number Diff line number Diff line
@@ -12,22 +12,98 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import logging, grpc
import json, logging, grpc
from common.method_wrappers.Decorator import MetricsPool, safe_and_metered_rpc_method
from common.proto.context_pb2 import Empty
from common.proto.context_pb2 import Empty, Device, DeviceId
from common.proto.pluggables_pb2_grpc import PluggablesServiceServicer
from common.proto.pluggables_pb2 import (
    Pluggable, CreatePluggableRequest, ListPluggablesRequest, ListPluggablesResponse, 
    GetPluggableRequest, DeletePluggableRequest, ConfigurePluggableRequest)
from common.method_wrappers.ServiceExceptions import NotFoundException, AlreadyExistsException 
from common.method_wrappers.ServiceExceptions import (
    NotFoundException, AlreadyExistsException, InvalidArgumentException)
from common.tools.object_factory.ConfigRule import json_config_rule_set
from context.client.ContextClient import ContextClient
from device.client.DeviceClient import DeviceClient
from .config_translator import translate_pluggable_config_to_netconf, create_config_rule_from_dict

LOGGER = logging.getLogger(__name__)
METRICS_POOL = MetricsPool('DscmPluggable', 'ServicegRPC')
METRICS_POOL = MetricsPool('Pluggables', 'ServicegRPC')

class PluggablesServiceServicerImpl(PluggablesServiceServicer):
    def __init__(self):
        LOGGER.info('Init DscmPluggableService')
        LOGGER.info('Initiate PluggablesService')
        self.pluggables = {}  # In-memory store for pluggables
        self.context_client = ContextClient()
        self.device_client = DeviceClient()

    def _push_config_to_device(self, device_uuid: str, pluggable_index: int, pluggable_config):  # type: ignore
        """
        Push pluggable configuration to the actual device via DeviceClient.
        Args:
            device_uuid: UUID of the target device
            pluggable_index: Index of the pluggable
            pluggable_config: PluggableConfig protobuf message
        """
        LOGGER.info(f"Configuring device {device_uuid}, pluggable index {pluggable_index}")
        
        # Step 1: Get the device from Context service (to extract IP address and determine template)
        try:
            device = self.context_client.GetDevice(DeviceId(device_uuid={'uuid': device_uuid}))  # type: ignore
            LOGGER.info(f"Retrieved device from Context: {device.name}")
        except grpc.RpcError as e:
            LOGGER.error(f"Failed to get device {device_uuid} from Context: {e}")
            raise
        
        # Translate pluggable config to NETCONF format
        component_name = f"channel-{pluggable_index}"
        netconf_config = translate_pluggable_config_to_netconf(pluggable_config, component_name=component_name)
        
        LOGGER.info(f"Translated pluggable config to NETCONF format: {netconf_config}")
        
        # Step 2: Extract device IP address from _connect/address config rule
        device_address = None
        for config_rule in device.device_config.config_rules:  # type: ignore
            if config_rule.custom.resource_key == '_connect/address':  # type: ignore
                device_address = config_rule.custom.resource_value  # type: ignore
                break
        
        # Step 3: Determine the appropriate template based on device IP address (TODO: This need to be updated later)
        if device_address == '10.30.7.7':
            template_identifier = 'hub'
        elif device_address == '10.30.7.8':
            template_identifier = 'leaf'
        else:
            # Default to hub template if IP address cannot be determined
            LOGGER.warning(f"Cannot determine device type from IP address {device_address}, defaulting to hub template")
            raise InvalidArgumentException( 'Device IP address', device_address, extra_details='Unknown device IP adress')
        
        LOGGER.info(f"Using template identifier: {template_identifier} for device {device.name} (IP: {device_address})")
        
        # Step 4: Create configuration rule with template-specific resource key
        # For simplicity, we use a fixed pluggable index of 1 for template lookup
        template_index = 1  # TODO: This should be dynamic based on actual pluggable index
        resource_key = f"/pluggable/{template_index}/config/{template_identifier}"
        
        # Create config rule dict and convert to protobuf
        config_json = json.dumps(netconf_config)
        config_rule_dict = json_config_rule_set(resource_key, config_json)
        config_rule = create_config_rule_from_dict(config_rule_dict)
        
        # Step 5: Create a minimal Device object with only the DSCM config rule
        config_device = Device()
        config_device.device_id.device_uuid.uuid = device_uuid  # type: ignore
        config_device.device_config.config_rules.append(config_rule)  # type: ignore
        
        LOGGER.info(f"Created minimal device with config rule: resource_key={resource_key}, template={template_identifier}")

        # Step 6: Call ConfigureDevice to push the configuration
        try:
            device_id = self.device_client.ConfigureDevice(config_device)
            LOGGER.info(f"Successfully configured device {device_id.device_uuid.uuid}")  # type: ignore
        except grpc.RpcError as e:
            LOGGER.error(f"Failed to configure device {device_uuid}: {e}")
            raise InvalidArgumentException(
                'Device configuration', f'{device_uuid}:{pluggable_index}', extra_details=str(e))

    @safe_and_metered_rpc_method(METRICS_POOL, LOGGER)
    def CreatePluggable(
@@ -37,7 +113,7 @@ class PluggablesServiceServicerImpl(PluggablesServiceServicer):
        
        device_uuid = request.device.device_uuid.uuid
        
        if request.preferred_pluggable_index and request.preferred_pluggable_index >= 0:
        if request.preferred_pluggable_index >= 0:
            pluggable_index = request.preferred_pluggable_index
        else:
            pluggable_index = -1
@@ -71,6 +147,25 @@ class PluggablesServiceServicerImpl(PluggablesServiceServicer):
        pluggable.state.id.device.device_uuid.uuid = device_uuid
        pluggable.state.id.pluggable_index         = pluggable_index

        # Verify device exists in Context service
        try:
            device = self.context_client.GetDevice(DeviceId(device_uuid={'uuid': device_uuid}))  # type: ignore
            LOGGER.info(f"Device {device_uuid} found in Context service: {device.name}")
        except grpc.RpcError as e:
            LOGGER.error(f"Device {device_uuid} not found in Context service: {e}")
            raise NotFoundException('Device', device_uuid, extra_details='Device must exist before creating pluggable')
        
        # If initial_config is provided, push configuration to device
        if request.HasField('initial_config') and len(pluggable.config.dsc_groups) > 0:
            LOGGER.info(f"Pushing initial configuration to device {device_uuid}")
            try:
                self._push_config_to_device(device_uuid, pluggable_index, pluggable.config)
            except Exception as e:
                LOGGER.error(f"Failed to push initial config to device: {e}")
                raise 
                # We still create the pluggable in memory, but log the error
                # In production, you might want to raise the exception instead
        
        self.pluggables[pluggable_key] = pluggable
        
        LOGGER.info(f"Created pluggable: device={device_uuid}, index={pluggable_index}")
@@ -119,6 +214,21 @@ class PluggablesServiceServicerImpl(PluggablesServiceServicer):
            LOGGER.info(f'No matching pluggable found: device={device_uuid}, index={pluggable_index}')
            raise NotFoundException('Pluggable', pluggable_key)
        
        # Remove pluggable config from device via DSCM driver
        try:
            pluggable = self.pluggables[pluggable_key]
            # Create empty config to trigger deletion
            from common.proto.pluggables_pb2 import PluggableConfig
            empty_config = PluggableConfig()
            empty_config.id.device.device_uuid.uuid = device_uuid
            empty_config.id.pluggable_index = pluggable_index
            
            LOGGER.info(f"Removing configuration from device {device_uuid}")
            self._push_config_to_device(device_uuid, pluggable_index, empty_config)
        except Exception as e:
            LOGGER.error(f"Failed to remove config from device: {e}")
            # Continue with deletion from memory even if device config removal fails
        
        del self.pluggables[pluggable_key]
        LOGGER.info(f"Deleted pluggable: device={device_uuid}, index={pluggable_index}")
        
@@ -184,6 +294,23 @@ class PluggablesServiceServicerImpl(PluggablesServiceServicer):
                subcarrier.id.group.pluggable.pluggable_index         = pluggable_index
        
        has_config = len(pluggable.config.dsc_groups) > 0
        
        # Push pluggable config to device via DSCM driver
        if has_config:
            LOGGER.info(f"Pushing configuration to device {device_uuid}")
            try:
                self._push_config_to_device(device_uuid, pluggable_index, pluggable.config)
            except Exception as e:
                LOGGER.error(f"Failed to push config to device: {e}")
                # Continue even if device configuration fails
                # In production, you might want to raise the exception
        else:
            LOGGER.info(f"Empty configuration - removing config from device {device_uuid}")
            try:
                self._push_config_to_device(device_uuid, pluggable_index, pluggable.config)
            except Exception as e:
                LOGGER.error(f"Failed to remove config from device: {e}")
        
        state_msg  = "configured" if has_config else "deconfigured (empty config)"
        LOGGER.info(f"Successfully {state_msg} pluggable: device={device_uuid}, index={pluggable_index}")
        
+101 −0
Original line number Diff line number Diff line
# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
from typing import Dict, Any, List, Optional
from common.proto.pluggables_pb2 import PluggableConfig
from common.proto.context_pb2 import ConfigRule, ConfigActionEnum

LOGGER = logging.getLogger(__name__)


def create_config_rule_from_dict(config_rule_dict: Dict[str, Any]) -> ConfigRule:  # type: ignore
    config_rule = ConfigRule()
    config_rule.action                = config_rule_dict['action']
    config_rule.custom.resource_key   = config_rule_dict['custom']['resource_key']
    config_rule.custom.resource_value = config_rule_dict['custom']['resource_value']
    return config_rule


def translate_pluggable_config_to_netconf(
    pluggable_config: PluggableConfig,  # type: ignore
    component_name: str = "channel-1"           # channel-1 for HUB and channel-1/3/5 for LEAF
) -> Dict[str, Any]:
    """
    Translate PluggableConfig protobuf message to the format expected by NetConfDriver.
    Args:
        pluggable_config: PluggableConfig message containing DSC groups and subcarriers
        component_name: Name of the optical channel component (default: "channel-1")
    Returns:
        Dictionary in the format expected by NetConfDriver templates:
    """
    
    if not pluggable_config or not pluggable_config.dsc_groups:
        LOGGER.warning("Empty pluggable config provided")
        return {
            "name": component_name,
            "operation": "delete"
        }
    
    if not hasattr(pluggable_config, 'center_frequency_mhz') or pluggable_config.center_frequency_mhz <= 0:
        raise ValueError("center_frequency_mhz is required and must be greater than 0 in PluggableConfig")
    center_frequency_mhz = int(pluggable_config.center_frequency_mhz)
    
    if not hasattr(pluggable_config, 'operational_mode') or pluggable_config.operational_mode <= 0:
        raise ValueError("operational_mode is required and must be greater than 0 in PluggableConfig")
    operational_mode = pluggable_config.operational_mode
    
    if not hasattr(pluggable_config, 'target_output_power_dbm'):
        raise ValueError("target_output_power_dbm is required in PluggableConfig")
    target_output_power = pluggable_config.target_output_power_dbm
    
    if not hasattr(pluggable_config, 'line_port'):
        raise ValueError("line_port is required in PluggableConfig")
    line_port = pluggable_config.line_port
    
    LOGGER.debug(f"Extracted config values: freq={center_frequency_mhz} MHz, "
                 f"op_mode={operational_mode}, power={target_output_power} dBm, line_port={line_port}")
    
    # Build digital subcarriers groups
    digital_sub_carriers_groups = []
    
    for group_dsc in pluggable_config.dsc_groups:
        group_dsc_data = {
            "digital_sub_carriers_group_id": group_dsc.id.group_index,
            "digital_sub_carrier_id": []
        }
        
        for subcarrier in group_dsc.subcarriers:
            # Only subcarrier_index and active status are needed for Jinja2 template
            subcarrier_data = {
                "sub_carrier_id": subcarrier.id.subcarrier_index,
                "active": "true" if subcarrier.active else "false"
            }
            group_dsc_data["digital_sub_carrier_id"].append(subcarrier_data)
        
        digital_sub_carriers_groups.append(group_dsc_data)
    
    # Build the final configuration dictionary
    config = {
        "name": component_name,
        "frequency": center_frequency_mhz,
        "operational_mode": operational_mode, 
        "target_output_power": target_output_power,
        "digital_sub_carriers_group": digital_sub_carriers_groups
    }
    
    LOGGER.info(f"Translated pluggable config to NETCONF format: component={component_name}, "
                f"frequency={center_frequency_mhz} MHz, groups={len(digital_sub_carriers_groups)}")
    
    return config
Loading