Init commit for standalone enodebd

Change-Id: I88eeef5135dd7ba8551ddd9fb6a0695f5325337b
diff --git a/state_machines/enb_acs_manager.py b/state_machines/enb_acs_manager.py
new file mode 100644
index 0000000..7ad6b02
--- /dev/null
+++ b/state_machines/enb_acs_manager.py
@@ -0,0 +1,253 @@
+"""
+Copyright 2020 The Magma Authors.
+
+This source code is licensed under the BSD-style license found in the
+LICENSE file in the root directory of this source tree.
+
+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.
+"""
+
+from typing import Any, List, Optional
+
+from common.service import MagmaService
+from device_config.configuration_util import is_enb_registered
+from devices.device_map import get_device_handler_from_name
+from devices.device_utils import EnodebDeviceName
+from exceptions import UnrecognizedEnodebError
+from logger import EnodebdLogger as logger
+from state_machines.acs_state_utils import (
+    get_device_name_from_inform,
+)
+from state_machines.enb_acs import EnodebAcsStateMachine
+from tr069 import models
+from spyne import ComplexModelBase
+from spyne.server.wsgi import WsgiMethodContext
+
+
+class StateMachineManager:
+    """
+    Delegates tr069 message handling to a dedicated state machine for the
+    device.
+    """
+
+    def __init__(
+        self,
+        service: MagmaService,
+    ):
+        self._ip_serial_mapping = IpToSerialMapping()
+        self._service = service
+        self._state_machine_by_ip = {}
+
+    def handle_tr069_message(
+        self,
+        ctx: WsgiMethodContext,
+        tr069_message: ComplexModelBase,
+    ) -> Any:
+        """ Delegate message handling to the appropriate eNB state machine """
+        client_ip = self._get_client_ip(ctx)
+        if isinstance(tr069_message, models.Inform):
+            try:
+                self._update_device_mapping(client_ip, tr069_message)
+            except UnrecognizedEnodebError as err:
+                logger.warning(
+                    'Received TR-069 Inform message from an '
+                    'unrecognized device. '
+                    'Ending TR-069 session with empty HTTP '
+                    'response. Error: (%s)', err,
+                )
+                return models.DummyInput()
+
+        handler = self._get_handler(client_ip)
+        if handler is None:
+            logger.warning(
+                'Received non-Inform TR-069 message from unknown '
+                'eNB. Ending session with empty HTTP response.',
+            )
+            return models.DummyInput()
+
+        return handler.handle_tr069_message(tr069_message)
+
+    def get_handler_by_ip(self, client_ip: str) -> EnodebAcsStateMachine:
+        return self._state_machine_by_ip[client_ip]
+
+    def get_handler_by_serial(self, enb_serial: str) -> EnodebAcsStateMachine:
+        client_ip = self._ip_serial_mapping.get_ip(enb_serial)
+        return self._state_machine_by_ip[client_ip]
+
+    def get_connected_serial_id_list(self) -> List[str]:
+        return self._ip_serial_mapping.get_serial_list()
+
+    def get_ip_of_serial(self, enb_serial: str) -> str:
+        return self._ip_serial_mapping.get_ip(enb_serial)
+
+    def get_serial_of_ip(self, client_ip: str) -> str:
+        serial = self._ip_serial_mapping.get_serial(client_ip)
+        return serial or 'default'
+
+    def _get_handler(
+        self,
+        client_ip: str,
+    ) -> EnodebAcsStateMachine:
+        return self._state_machine_by_ip[client_ip]
+
+    def _update_device_mapping(
+        self,
+        client_ip: str,
+        inform: models.Inform,
+    ) -> None:
+        """
+        When receiving an Inform message, we can figure out what device we
+        are talking to. We can also see if the IP has changed, and the
+        StateMachineManager must track this so that subsequent tr069
+        messages can be handled correctly.
+        """
+        enb_serial = self._parse_msg_for_serial(inform)
+        if enb_serial is None:
+            raise UnrecognizedEnodebError(
+                'eNB does not have serial number '
+                'under expected param path',
+            )
+        if not is_enb_registered(self._service.mconfig, enb_serial):
+            raise UnrecognizedEnodebError(
+                'eNB not registered to this Access '
+                'Gateway (serial #%s)' % enb_serial,
+            )
+        self._associate_serial_to_ip(client_ip, enb_serial)
+        handler = self._get_handler(client_ip)
+        if handler is None:
+            device_name = get_device_name_from_inform(inform)
+            handler = self._build_handler(device_name)
+            self._state_machine_by_ip[client_ip] = handler
+
+    def _associate_serial_to_ip(
+        self,
+        client_ip: str,
+        enb_serial: str,
+    ) -> None:
+        """
+        If a device/IP combination changes, then the StateMachineManager
+        must detect this, and update its mapping of what serial/IP corresponds
+        to which handler.
+        """
+        if self._ip_serial_mapping.has_ip(client_ip):
+            # Same IP, different eNB connected
+            prev_serial = self._ip_serial_mapping.get_serial(client_ip)
+            if enb_serial != prev_serial:
+                logger.info(
+                    'eNodeB change on IP <%s>, from %s to %s',
+                    client_ip, prev_serial, enb_serial,
+                )
+                self._ip_serial_mapping.set_ip_and_serial(client_ip, enb_serial)
+                self._state_machine_by_ip[client_ip] = None
+        elif self._ip_serial_mapping.has_serial(enb_serial):
+            # Same eNB, different IP
+            prev_ip = self._ip_serial_mapping.get_ip(enb_serial)
+            if client_ip != prev_ip:
+                logger.info(
+                    'eNodeB <%s> changed IP from %s to %s',
+                    enb_serial, prev_ip, client_ip,
+                )
+                self._ip_serial_mapping.set_ip_and_serial(client_ip, enb_serial)
+                handler = self._state_machine_by_ip[prev_ip]
+                self._state_machine_by_ip[client_ip] = handler
+                del self._state_machine_by_ip[prev_ip]
+        else:
+            # TR069 message is coming from a different IP, and a different
+            # serial ID. No need to change mapping
+            handler = None
+            self._ip_serial_mapping.set_ip_and_serial(client_ip, enb_serial)
+            self._state_machine_by_ip[client_ip] = handler
+
+    @staticmethod
+    def _parse_msg_for_serial(tr069_message: models.Inform) -> Optional[str]:
+        """ Return the eNodeB serial ID if it's found in the message """
+        if not isinstance(tr069_message, models.Inform):
+            return
+
+        # Mikrotik Intercell does not return serial in ParameterList
+        if hasattr(tr069_message, 'DeviceId') and \
+                hasattr(tr069_message.DeviceId, 'SerialNumber'):
+            return tr069_message.DeviceId.SerialNumber
+
+        if not hasattr(tr069_message, 'ParameterList') or \
+                not hasattr(tr069_message.ParameterList, 'ParameterValueStruct'):
+            return None
+
+        # Parse the parameters
+        param_values_by_path = {}
+        for param_value in tr069_message.ParameterList.ParameterValueStruct:
+            path = param_value.Name
+            value = param_value.Value.Data
+            param_values_by_path[path] = value
+
+        possible_sn_paths = [
+            'Device.DeviceInfo.SerialNumber',
+            'InternetGatewayDevice.DeviceInfo.SerialNumber',
+        ]
+        for path in possible_sn_paths:
+            if path in param_values_by_path:
+                return param_values_by_path[path]
+        return None
+
+    @staticmethod
+    def _get_client_ip(ctx: WsgiMethodContext) -> str:
+        return ctx.transport.req_env.get("REMOTE_ADDR", "unknown")
+
+    def _build_handler(
+        self,
+        device_name: EnodebDeviceName,
+    ) -> EnodebAcsStateMachine:
+        """
+        Create a new state machine based on the device type
+        """
+        device_handler_class = get_device_handler_from_name(device_name)
+        acs_state_machine = device_handler_class(self._service)
+        return acs_state_machine
+
+
+class IpToSerialMapping:
+    """ Bidirectional map between <eNodeB IP> and <eNodeB serial ID> """
+
+    def __init__(self) -> None:
+        self.ip_by_enb_serial = {}
+        self.enb_serial_by_ip = {}
+
+    def del_ip(self, ip: str) -> None:
+        if ip not in self.enb_serial_by_ip:
+            raise KeyError('Cannot delete missing IP')
+        serial = self.enb_serial_by_ip[ip]
+        del self.enb_serial_by_ip[ip]
+        del self.ip_by_enb_serial[serial]
+
+    def del_serial(self, serial: str) -> None:
+        if serial not in self.ip_by_enb_serial:
+            raise KeyError('Cannot delete missing eNodeB serial ID')
+        ip = self.ip_by_enb_serial[serial]
+        del self.ip_by_enb_serial[serial]
+        del self.enb_serial_by_ip[ip]
+
+    def set_ip_and_serial(self, ip: str, serial: str) -> None:
+        self.ip_by_enb_serial[serial] = ip
+        self.enb_serial_by_ip[ip] = serial
+
+    def get_ip(self, serial: str) -> str:
+        return self.ip_by_enb_serial[serial]
+
+    def get_serial(self, ip: str) -> Optional[str]:
+        return self.enb_serial_by_ip.get(ip, None)
+
+    def has_ip(self, ip: str) -> bool:
+        return ip in self.enb_serial_by_ip
+
+    def has_serial(self, serial: str) -> bool:
+        return serial in self.ip_by_enb_serial
+
+    def get_serial_list(self) -> List[str]:
+        return list(self.ip_by_enb_serial.keys())
+
+    def get_ip_list(self) -> List[str]:
+        return list(self.enb_serial_by_ip.keys())