Commit 6f2d6038 authored by Waleed Akbar's avatar Waleed Akbar
Browse files

feat(pluggables): ECOC2026-Implement cleanup configuration for inactive DSC...

feat(pluggables): ECOC2026-Implement cleanup configuration for inactive DSC groups and enhance NETCONF translation
parent 0b6f608c
Loading
Loading
Loading
Loading
+148 −31
Original line number Diff line number Diff line
@@ -37,6 +37,91 @@ class PluggablesServiceServicerImpl(PluggablesServiceServicer):
        self.context_client = ContextClient()
        self.device_client  = DeviceClient()

    @staticmethod
    def _clone_group_all_false(dst_group, src_group, device_uuid: str, pluggable_index: int):  # type: ignore
        """Clone a DSC group placing all subcarriers in inactive state.

        Copies group-level metadata (group_index, group_size, group_capacity_gbps,
        subcarrier_spacing_mhz) from *src_group* to *dst_group*, then adds one
        subcarrier entry per source subcarrier with ``active=False``.
        """
        dst_group.id.pluggable.device.device_uuid.uuid = device_uuid
        dst_group.id.pluggable.pluggable_index         = pluggable_index
        dst_group.id.group_index                       = src_group.id.group_index
        dst_group.group_size                           = src_group.group_size
        dst_group.group_capacity_gbps                  = src_group.group_capacity_gbps
        dst_group.subcarrier_spacing_mhz               = src_group.subcarrier_spacing_mhz

        for src_sc in src_group.subcarriers:
            dst_sc = dst_group.subcarriers.add()
            dst_sc.id.group.pluggable.device.device_uuid.uuid = device_uuid
            dst_sc.id.group.pluggable.pluggable_index         = pluggable_index
            dst_sc.id.group.group_index                       = src_group.id.group_index
            dst_sc.id.subcarrier_index                        = src_sc.id.subcarrier_index
            dst_sc.active                                     = False
            dst_sc.center_frequency_hz                        = src_sc.center_frequency_hz
            dst_sc.target_output_power_dbm                    = src_sc.target_output_power_dbm
            dst_sc.symbol_rate_baud                           = src_sc.symbol_rate_baud

    @staticmethod
    def _has_device_config(config) -> bool:  # type: ignore
        """Return True when *config* contains HUB ``dsc_groups`` or LEAF
        ``channel_configs`` that should be pushed to the device."""
        return bool(config.dsc_groups or config.channel_configs)

    def _build_all_false_config(self, stored_pluggable):  # type: ignore
        """Build an all-false cleanup config from a stored pluggable.

        Clones the DSC group / subcarrier structure but sets every subcarrier
        ``active`` field to ``False``.

        * HUB-style: one component with ``dsc_groups`` at the root level.
        * LEAF-style: multiple ``channel_configs``, each with its own groups.

        Returns ``None`` when the pluggable has no DSC groups and no channel
        configs — in that case there is nothing to clean on the device and the
        managed pluggable can be deleted directly from memory.
        """
        stored_config = stored_pluggable.config
        has_config = (
            len(stored_config.dsc_groups) > 0 or
            len(stored_config.channel_configs) > 0
        )
        if not has_config:
            return None

        device_uuid     = stored_pluggable.id.device.device_uuid.uuid
        pluggable_index = stored_pluggable.id.pluggable_index

        cleanup = PluggableConfig()
        cleanup.id.device.device_uuid.uuid = device_uuid
        cleanup.id.pluggable_index         = pluggable_index
        cleanup.operational_mode           = stored_config.operational_mode
        cleanup.center_frequency_mhz       = stored_config.center_frequency_mhz
        cleanup.target_output_power_dbm    = stored_config.target_output_power_dbm
        cleanup.channel_name               = stored_config.channel_name
        cleanup.line_port                  = stored_config.line_port

        if stored_config.channel_configs:
            # LEAF-style: one component per channel_config
            for src_ch in stored_config.channel_configs:
                dst_ch = cleanup.channel_configs.add()
                dst_ch.channel_name            = src_ch.channel_name
                dst_ch.center_frequency_mhz    = src_ch.center_frequency_mhz
                dst_ch.target_output_power_dbm = src_ch.target_output_power_dbm
                for src_group in src_ch.dsc_groups:
                    self._clone_group_all_false(
                        dst_ch.dsc_groups.add(), src_group, device_uuid, pluggable_index
                    )
        else:
            # HUB-style: top-level DSC groups
            for src_group in stored_config.dsc_groups:
                self._clone_group_all_false(
                    cleanup.dsc_groups.add(), src_group, device_uuid, pluggable_index
                )

        return cleanup

    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.
@@ -119,6 +204,7 @@ class PluggablesServiceServicerImpl(PluggablesServiceServicer):
            pluggable.config.id.device.device_uuid.uuid = device_uuid
            pluggable.config.id.pluggable_index         = pluggable_index
            
            # HUB configuration: normalise nested IDs for DSC groups and subcarriers
            for dsc_group in pluggable.config.dsc_groups:
                dsc_group.id.pluggable.device.device_uuid.uuid = device_uuid
                dsc_group.id.pluggable.pluggable_index         = pluggable_index
@@ -127,6 +213,15 @@ class PluggablesServiceServicerImpl(PluggablesServiceServicer):
                    subcarrier.id.group.pluggable.device.device_uuid.uuid = device_uuid
                    subcarrier.id.group.pluggable.pluggable_index         = pluggable_index

            # Normalise nested IDs for LEAF channel_configs
            for channel_config in pluggable.config.channel_configs:
                for dsc_group in channel_config.dsc_groups:
                    dsc_group.id.pluggable.device.device_uuid.uuid = device_uuid
                    dsc_group.id.pluggable.pluggable_index         = pluggable_index
                    for subcarrier in dsc_group.subcarriers:
                        subcarrier.id.group.pluggable.device.device_uuid.uuid = device_uuid
                        subcarrier.id.group.pluggable.pluggable_index         = pluggable_index
        
        pluggable.state.id.device.device_uuid.uuid = device_uuid
        pluggable.state.id.pluggable_index         = pluggable_index

@@ -140,7 +235,7 @@ class PluggablesServiceServicerImpl(PluggablesServiceServicer):
                '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:
        if request.HasField('initial_config') and self._has_device_config(pluggable.config):
            LOGGER.info(f"Pushing initial configuration to device {device_uuid}")
            try:
                self._push_config_to_device(device_uuid, pluggable_index, pluggable.config)
@@ -197,39 +292,52 @@ 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 before deleting from memory
        # Use empty config to signal deletion to the device driver
        stored_pluggable = self.pluggables[pluggable_key]
        
        # Build an all-false cleanup config from the stored pluggable shape.
        cleanup_config = self._build_all_false_config(stored_pluggable)
        
        if cleanup_config is None:
            # Bare pluggable with no device-side DSC config — memory-only delete.
            LOGGER.info(
                f"No stored DSC config for device={device_uuid}, "
                f"index={pluggable_index} — deleting from memory only"
            )
            del self.pluggables[pluggable_key]
            LOGGER.info(
                f"Deleted pluggable from memory: device={device_uuid}, "
                f"index={pluggable_index}"
            )
            return Empty()
        
        # Device-side cleanup needed — push the all-false config first.
        LOGGER.info(
            f"Pushing all-false cleanup config to device {device_uuid}, "
            f"index={pluggable_index}"
        )
        try:
            del_config = PluggableConfig()
            del_config.id.device.device_uuid.uuid = device_uuid
            del_config.id.pluggable_index         = pluggable_index
            del_config.operational_mode           = 0            # Operational mode
            del_config.channel_name               = "channel-1"  # Channel name for component

            group_1 = del_config.dsc_groups.add()
            group_1.id.pluggable.device.device_uuid.uuid = device_uuid
            group_1.id.pluggable.pluggable_index         = pluggable_index
            group_1.id.group_index                       = 0

            subcarrier_1 = group_1.subcarriers.add()
            subcarrier_1.id.group.pluggable.device.device_uuid.uuid = device_uuid
            subcarrier_1.id.group.pluggable.pluggable_index         = pluggable_index
            subcarrier_1.id.group.group_index                       = 0
            subcarrier_1.id.subcarrier_index                        = 0
            subcarrier_1.active                                     = False

            # ADD MORE SUBCARRIERS IF NEEDED
            
            LOGGER.info(f"Removing configuration from device {device_uuid}")
            self._push_config_to_device(device_uuid, pluggable_index, del_config)
            self._push_config_to_device(device_uuid, pluggable_index, cleanup_config)
        except (grpc.RpcError, InvalidArgumentException, OperationFailedException) as e:
            LOGGER.warning(f"Failed to remove config from device (continuing with memory deletion): {e}")
            # Continue with deletion from memory even if device config removal fails
        
        # Always delete from memory (even if device operation failed)
            LOGGER.error(
                f"Device cleanup failed for device={device_uuid}, "
                f"index={pluggable_index} — keeping managed pluggable: {e}"
            )
            raise OperationFailedException(
                'DeletePluggable device cleanup',
                extra_details=(
                    f"Device cleanup failed for {device_uuid}:{pluggable_index}. "
                    f"The managed pluggable was preserved so the operation can be retried. "
                    f"Error: {e}"
                )
            )
        
        # Device cleanup succeeded — now safe to remove from memory.
        LOGGER.info(
            f"Device cleanup succeeded for device={device_uuid}, "
            f"index={pluggable_index} — deleting from memory"
        )
        del self.pluggables[pluggable_key]
        LOGGER.info(f"Deleted pluggable from memory: device={device_uuid}, index={pluggable_index}")
        
        return Empty()
    
    @safe_and_metered_rpc_method(METRICS_POOL, LOGGER)
@@ -291,7 +399,16 @@ class PluggablesServiceServicerImpl(PluggablesServiceServicer):
                subcarrier.id.group.pluggable.device.device_uuid.uuid = device_uuid
                subcarrier.id.group.pluggable.pluggable_index         = pluggable_index

        has_config = len(pluggable.config.dsc_groups) > 0
        # Normalise nested IDs for LEAF channel_configs
        for channel_config in pluggable.config.channel_configs:
            for dsc_group in channel_config.dsc_groups:
                dsc_group.id.pluggable.device.device_uuid.uuid = device_uuid
                dsc_group.id.pluggable.pluggable_index         = pluggable_index
                for subcarrier in dsc_group.subcarriers:
                    subcarrier.id.group.pluggable.device.device_uuid.uuid = device_uuid
                    subcarrier.id.group.pluggable.pluggable_index         = pluggable_index
        
        has_config = self._has_device_config(pluggable.config)
        
        # Push pluggable config to device via DSCM driver
        if has_config:
+112 −39
Original line number Diff line number Diff line
@@ -28,57 +28,130 @@ def create_config_rule_from_dict(config_rule_dict: Dict[str, Any]) -> ConfigRule
    return config_rule


def translate_pluggable_config_to_netconf(
    pluggable_config: PluggableConfig,  # type: ignore
    component_name: str = "channel-1"           # Fallback if channel_name not provided (channel-1 for HUB and channel-1/3/5 for LEAF)
def _build_component_dict(
    name: str,
    dsc_groups,
    frequency_mhz: float = 0.0,
    target_output_power_dbm: float = 0.0,
) -> Dict[str, Any]:
    """
    Translate PluggableConfig protobuf message to the format expected by OpenConfig.
    """Build a single component dict for the NETCONF template.

    Args:
        pluggable_config: PluggableConfig message containing DSC groups and subcarriers
        component_name: Fallback name if channel_name is not specified in config (default: "channel-1")
        name: Component/channel name.
        dsc_groups: Iterable of DigitalSubcarrierGroupConfig protos.
        frequency_mhz: Center frequency in MHz.
        target_output_power_dbm: Target output power in dBm.
    Returns:
        Dictionary in the format expected by OpenConfig templates:
        Component dict with name, frequency, target_output_power and DSC groups.
    """
      
    if not pluggable_config or not pluggable_config.dsc_groups:
        LOGGER.warning("Empty pluggable config provided")
        return {
            "operation": "delete"
        }
    if hasattr(pluggable_config, 'channel_name') and pluggable_config.channel_name:
        channel_name = pluggable_config.channel_name
        LOGGER.debug(f"Using channel_name from PluggableConfig: {channel_name}")
    # Format frequency: strip ".0" for whole-number frequencies
    if frequency_mhz == int(frequency_mhz):
        frequency_str = str(int(frequency_mhz))
    else:
        channel_name = component_name
        LOGGER.debug(f"Using fallback component_name: {channel_name}")
        frequency_str = f"{frequency_mhz}"

    # Format target output power: one decimal
    power_str = f"{target_output_power_dbm}"

    # Build digital subcarriers groups
    digital_sub_carriers_groups = []

    for group_dsc in pluggable_config.dsc_groups:
        group_dsc_data = {
    for group_dsc in dsc_groups:
        group_dsc_data: Dict[str, Any] = {
            "digital_sub_carriers_group_id": group_dsc.id.group_index,
            "digital_sub_carrier_id": []
            "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"
                "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": channel_name,
        "digital_sub_carriers_group": digital_sub_carriers_groups
    component: Dict[str, Any] = {
        "name": name,
        "digital_sub_carriers_group": digital_sub_carriers_groups,
    }
    # Always include frequency when non-zero (0 Hz is never a valid center freq).
    if frequency_mhz != 0.0:
        component["frequency"] = frequency_str
    # Always include target_output_power, even when 0.0 dBm, because the
    # device configuration requires this field and 0.0 is a real value.
    component["target_output_power"] = power_str

    return component


def translate_pluggable_config_to_netconf(
    pluggable_config: PluggableConfig,  # type: ignore
    component_name: str = "channel-1",           # Fallback if channel_name not provided
) -> Dict[str, Any]:
    """
    Translate PluggableConfig protobuf message to the format expected by OpenConfig.

    If ``channel_configs`` is non-empty, one component is created per channel
    config (leaf / multi-channel mode).  Otherwise a single component is built
    from the top-level ``PluggableConfig`` fields (hub / backward-compatible mode).

    Args:
        pluggable_config: PluggableConfig message containing DSC groups and subcarriers.
        component_name: Fallback name if channel_name is not specified (default: "channel-1").
    Returns:
        Dictionary with a ``components`` list, each entry consumable by the Jinja2 template.
    """

    if not pluggable_config or (
        not pluggable_config.dsc_groups and not pluggable_config.channel_configs
    ):
        LOGGER.warning("Empty pluggable config provided")
        return {"components": [], "operation": "delete"}

    components: list = []

    # ── Multi-channel mode (leaf): one component per channel_config ──────────
    if pluggable_config.channel_configs:
        for channel_cfg in pluggable_config.channel_configs:
            name = (
                channel_cfg.channel_name
                or component_name
            )
            component = _build_component_dict(
                name=name,
                dsc_groups=channel_cfg.dsc_groups,
                frequency_mhz=channel_cfg.center_frequency_mhz,
                target_output_power_dbm=channel_cfg.target_output_power_dbm,
            )
            components.append(component)

        LOGGER.info(
            f"Translated pluggable config to NETCONF format: "
            f"{len(components)} component(s) from channel_configs"
        )
        return {"components": components}

    # ── Single-channel mode (hub / backward compatible) ──────────────────────
    if hasattr(pluggable_config, "channel_name") and pluggable_config.channel_name:
        channel_name = pluggable_config.channel_name
    else:
        channel_name = component_name

    LOGGER.debug(
        f"Using channel_name from PluggableConfig: {channel_name}"
    )

    component = _build_component_dict(
        name=channel_name,
        dsc_groups=pluggable_config.dsc_groups,
        frequency_mhz=pluggable_config.center_frequency_mhz,
        target_output_power_dbm=pluggable_config.target_output_power_dbm,
    )
    components.append(component)

    LOGGER.info(f"Translated pluggable config to NETCONF format: component={channel_name}, "
                f"groups={len(digital_sub_carriers_groups)}")
    LOGGER.info(
        f"Translated pluggable config to NETCONF format: "
        f"component={channel_name}, groups={len(pluggable_config.dsc_groups)}"
    )

    return config
    return {"components": components}