Init commit for standalone enodebd

Change-Id: I88eeef5135dd7ba8551ddd9fb6a0695f5325337b
diff --git a/configuration/__init__.py b/configuration/__init__.py
new file mode 100644
index 0000000..b78acae
--- /dev/null
+++ b/configuration/__init__.py
@@ -0,0 +1,15 @@
+import importlib
+import logging
+
+from configuration.exceptions import LoadConfigError
+from configuration.service_configs import load_service_config
+
+# Import all mconfig-providing modules so for the protobuf symbol database
+try:
+    mconfig_modules = load_service_config('magmad').get('mconfig_modules', [])
+    for mod in mconfig_modules:
+        logging.info('Importing mconfig module %s', mod)
+        importlib.import_module(mod)
+except LoadConfigError:
+    logging.error('Could not load magmad yml config for mconfig modules')
+    importlib.import_module('orc8r.protos.mconfig.mconfigs_pb2')
diff --git a/configuration/environment.py b/configuration/environment.py
new file mode 100644
index 0000000..c2b6722
--- /dev/null
+++ b/configuration/environment.py
@@ -0,0 +1,28 @@
+"""
+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.
+"""
+
+import os
+
+
+def is_dev_mode() -> bool:
+    """
+    Returns whether the environment is set for dev mode
+    """
+    return os.environ.get('MAGMA_DEV_MODE') == '1'
+
+
+def is_docker_network_mode() -> bool:
+    """
+    Returns whether the environment is set for dev mode
+    """
+    return os.environ.get('DOCKER_NETWORK_MODE') == '1'
diff --git a/configuration/events.py b/configuration/events.py
new file mode 100644
index 0000000..308f1be
--- /dev/null
+++ b/configuration/events.py
@@ -0,0 +1,38 @@
+"""
+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.
+"""
+
+import snowflake
+from eventd.eventd_client import log_event
+from orc8r.protos.eventd_pb2 import Event
+
+
+def deleted_stored_mconfig():
+    log_event(
+        Event(
+            stream_name="magmad",
+            event_type="deleted_stored_mconfig",
+            tag=snowflake.snowflake(),
+            value="{}",
+        ),
+    )
+
+
+def updated_stored_mconfig():
+    log_event(
+        Event(
+            stream_name="magmad",
+            event_type="updated_stored_mconfig",
+            tag=snowflake.snowflake(),
+            value="{}",
+        ),
+    )
diff --git a/configuration/exceptions.py b/configuration/exceptions.py
new file mode 100644
index 0000000..79a8907
--- /dev/null
+++ b/configuration/exceptions.py
@@ -0,0 +1,16 @@
+"""
+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.
+"""
+
+
+class LoadConfigError(Exception):
+    pass
diff --git a/configuration/mconfig_managers.py b/configuration/mconfig_managers.py
new file mode 100644
index 0000000..bd39c4c
--- /dev/null
+++ b/configuration/mconfig_managers.py
@@ -0,0 +1,235 @@
+"""
+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.
+"""
+import abc
+import contextlib
+import json
+import os
+from typing import Any, Generic, TypeVar
+
+import configuration.events as magma_configuration_events
+from google.protobuf import json_format
+from common import serialization_utils
+from configuration.exceptions import LoadConfigError
+from configuration.mconfigs import (
+    filter_configs_by_key,
+    unpack_mconfig_any,
+)
+from orc8r.protos.mconfig_pb2 import GatewayConfigs, GatewayConfigsMetadata
+
+T = TypeVar('T')
+
+MCONFIG_DIR = './magma_configs'
+MCONFIG_OVERRIDE_DIR = './override_configs'
+DEFAULT_MCONFIG_DIR = os.environ.get('MAGMA_CONFIG_LOCATION', MCONFIG_DIR)
+
+
+def get_mconfig_manager():
+    """
+    Get the mconfig manager implementation that the system is configured to
+    use.
+
+    Returns: MconfigManager implementation
+    """
+    # This is stubbed out after deleting the streamed mconfig manager
+    return MconfigManagerImpl()
+
+
+def load_service_mconfig(service: str, mconfig_struct: Any) -> Any:
+    """
+    Utility function to load the mconfig for a specific service using the
+    configured mconfig manager.
+    """
+    return get_mconfig_manager().load_service_mconfig(service, mconfig_struct)
+
+
+def load_service_mconfig_as_json(service_name: str) -> Any:
+    """
+    Loads the managed configuration from its json file stored on disk.
+
+    Args:
+        service_name (str): name of the service to load the config for
+
+    Returns: Loaded config value for the service as parsed json struct, not
+    protobuf message struct
+    """
+    return get_mconfig_manager().load_service_mconfig_as_json(service_name)
+
+
+class MconfigManager(Generic[T]):
+    """
+    Interface for a class which handles loading and updating some cloud-
+    managed configuration (mconfig).
+    """
+
+    @abc.abstractmethod
+    def load_mconfig(self) -> T:
+        """
+        Load the managed configuration from its stored location.
+
+        Returns: Loaded mconfig
+        """
+        pass
+
+    @abc.abstractmethod
+    def load_service_mconfig(
+        self, service_name: str,
+        mconfig_struct: Any,
+    ) -> Any:
+        """
+        Load a specific service's managed configuration.
+
+        Args:
+            service_name (str): name of the service to load a config for
+            mconfig_struct (Any): protobuf message struct of the managed config
+            for the service
+
+        Returns: Loaded config value for the service
+        """
+        pass
+
+    @abc.abstractmethod
+    def load_mconfig_metadata(self) -> GatewayConfigsMetadata:
+        """
+        Load the metadata of the managed configuration.
+
+        Returns: Loaded mconfig metadata
+        """
+        pass
+
+    @abc.abstractmethod
+    def update_stored_mconfig(self, updated_value: str):
+        """
+        Update the stored mconfig to the provided serialized value
+
+        Args:
+            updated_value: Serialized value of new mconfig value to store
+        """
+        pass
+
+    @abc.abstractmethod
+    def deserialize_mconfig(
+        self, serialized_value: str,
+        allow_unknown_fields: bool = True,
+    ) -> T:
+        """
+        Deserialize the given string to the managed mconfig.
+
+        Args:
+            serialized_value:
+                Serialized value of a managed mconfig
+            allow_unknown_fields:
+                Set to true to suppress errors from parsing unknown fields
+
+        Returns: deserialized mconfig value
+        """
+        pass
+
+    @abc.abstractmethod
+    def delete_stored_mconfig(self):
+        """
+        Delete the stored mconfig file.
+        """
+        pass
+
+
+class MconfigManagerImpl(MconfigManager[GatewayConfigs]):
+    """
+    Legacy mconfig manager for non-offset mconfigs
+    """
+
+    MCONFIG_FILE_NAME = 'gateway.mconfig'
+    MCONFIG_PATH = os.path.join(MCONFIG_OVERRIDE_DIR, MCONFIG_FILE_NAME)
+
+    def load_mconfig(self) -> GatewayConfigs:
+        cfg_file_name = self._get_mconfig_file_path()
+        try:
+            with open(cfg_file_name, 'r', encoding='utf-8') as cfg_file:
+                mconfig_str = cfg_file.read()
+            return self.deserialize_mconfig(mconfig_str)
+        except (OSError, json.JSONDecodeError, json_format.ParseError) as e:
+            raise LoadConfigError('Error loading mconfig') from e
+
+    def load_service_mconfig(
+        self, service_name: str,
+        mconfig_struct: Any,
+    ) -> Any:
+        mconfig = self.load_mconfig()
+        if service_name not in mconfig.configs_by_key:
+            raise LoadConfigError(
+                "Service ({}) missing in mconfig".format(service_name),
+            )
+
+        service_mconfig = mconfig.configs_by_key[service_name]
+        return unpack_mconfig_any(service_mconfig, mconfig_struct)
+
+    def load_service_mconfig_as_json(self, service_name) -> Any:
+        cfg_file_name = self._get_mconfig_file_path()
+        with open(cfg_file_name, 'r', encoding='utf-8') as f:
+            json_mconfig = json.load(f)
+            service_configs = json_mconfig.get('configsByKey', {})
+            service_configs.update(json_mconfig.get('configs_by_key', {}))
+        if service_name not in service_configs:
+            raise LoadConfigError(
+                "Service ({}) missing in mconfig".format(service_name),
+            )
+
+        return service_configs[service_name]
+
+    def load_mconfig_metadata(self) -> GatewayConfigsMetadata:
+        mconfig = self.load_mconfig()
+        return mconfig.metadata
+
+    def deserialize_mconfig(
+        self, serialized_value: str,
+        allow_unknown_fields: bool = True,
+    ) -> GatewayConfigs:
+        # First parse as JSON in case there are types unrecognized by
+        # protobuf symbol database
+        json_mconfig = json.loads(serialized_value)
+        cfgs_by_key_json = json_mconfig.get('configs_by_key', {})
+        cfgs_by_key_json.update(json_mconfig.get('configsByKey', {}))
+        filtered_cfgs_by_key = filter_configs_by_key(cfgs_by_key_json)
+
+        # Set configs to filtered map, re-dump and parse
+        if 'configs_by_key' in json_mconfig:
+            json_mconfig.pop('configs_by_key')
+        json_mconfig['configsByKey'] = filtered_cfgs_by_key
+        json_mconfig_dumped = json.dumps(json_mconfig)
+
+        # Workaround for outdated protobuf library on sandcastle
+        if allow_unknown_fields:
+            return json_format.Parse(
+                json_mconfig_dumped,
+                GatewayConfigs(),
+                ignore_unknown_fields=True,
+            )
+        else:
+            return json_format.Parse(json_mconfig_dumped, GatewayConfigs())
+
+    def delete_stored_mconfig(self):
+        with contextlib.suppress(FileNotFoundError):
+            os.remove(self.MCONFIG_PATH)
+        magma_configuration_events.deleted_stored_mconfig()
+
+    def update_stored_mconfig(self, updated_value: str) -> GatewayConfigs:
+        parsed = json.loads(updated_value)
+        serialization_utils.write_to_file_atomically(
+            self.MCONFIG_PATH, json.dumps(parsed, indent=4, sort_keys=True),
+        )
+        magma_configuration_events.updated_stored_mconfig()
+
+    def _get_mconfig_file_path(self):
+        if os.path.isfile(self.MCONFIG_PATH):
+            return self.MCONFIG_PATH
+        else:
+            return os.path.join(DEFAULT_MCONFIG_DIR, self.MCONFIG_FILE_NAME)
diff --git a/configuration/mconfigs.py b/configuration/mconfigs.py
new file mode 100644
index 0000000..88862b5
--- /dev/null
+++ b/configuration/mconfigs.py
@@ -0,0 +1,65 @@
+"""
+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 as TAny
+from typing import Dict
+
+from google.protobuf.internal.well_known_types import Any
+from configuration import service_configs
+from configuration.exceptions import LoadConfigError
+
+
+def filter_configs_by_key(configs_by_key: Dict[str, TAny]) -> Dict[str, TAny]:
+    """
+    Given a JSON-deserialized map of mconfig protobuf Any's keyed by service
+    name, filter out any entires without a corresponding service or which have
+    values that aren't registered in the protobuf symbol database yet.
+
+    Args:
+        configs_by_key:
+            JSON-deserialized service mconfigs keyed by service name
+
+    Returns:
+        The input map without any services which currently don't exist.
+    """
+    magmad_cfg = service_configs.load_service_config('magmad')
+    services = magmad_cfg.get('magma_services', [])
+    services.append('magmad')
+    services += magmad_cfg.get('registered_dynamic_services', [])
+    services = set(services)
+
+    filtered_configs_by_key = {}
+    for srv, cfg in configs_by_key.items():
+        if srv not in services:
+            continue
+        filtered_configs_by_key[srv] = cfg
+    return filtered_configs_by_key
+
+
+def unpack_mconfig_any(mconfig_any: Any, mconfig_struct: TAny) -> TAny:
+    """
+    Unpack a protobuf Any type into a given an empty protobuf message struct
+    for a service.
+
+    Args:
+        mconfig_any: protobuf Any type to unpack
+        mconfig_struct: protobuf message struct
+
+    Returns: Concrete protobuf object that the provided Any wraps
+    """
+    unpacked = mconfig_any.Unpack(mconfig_struct)
+    if not unpacked:
+        raise LoadConfigError(
+            'Cannot unpack Any type into message: %s' % mconfig_struct,
+        )
+    return mconfig_struct
diff --git a/configuration/service_configs.py b/configuration/service_configs.py
new file mode 100644
index 0000000..c60c340
--- /dev/null
+++ b/configuration/service_configs.py
@@ -0,0 +1,152 @@
+"""
+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.
+"""
+
+import logging
+import os
+from typing import Optional  # noqa: lint doesn't handle inline typehints
+from typing import Any, Dict
+
+import yaml
+from configuration.exceptions import LoadConfigError
+
+# Location of configs (both service config and mconfig)
+CONFIG_DIR = './magma_configs'
+CONFIG_OVERRIDE_DIR = './override_configs'
+
+
+def load_override_config(service_name: str) -> Optional[Any]:
+    """
+    Load override service configuration from the file in the override
+    directory.
+
+    Args:
+        service_name: service to pull configs for; name of config file
+
+    Returns: json-decoded value of the service config, None if it's not found
+
+    Raises:
+        LoadConfigError:
+            Unable to load config due to missing file or missing key
+    """
+    override_file_name = _override_file_name(service_name)
+    if os.path.isfile(override_file_name):
+        return _load_yaml_file(override_file_name)
+    return None
+
+
+def save_override_config(service_name: str, cfg: Any):
+    """
+    Write the configuration object to its corresponding file in the override
+    directory.
+
+    Args:
+        service_name: service to write config object to; name of config file
+        cfg: json-decoded value of the service config
+    """
+    override_file_name = _override_file_name(service_name)
+    os.makedirs(CONFIG_OVERRIDE_DIR, exist_ok=True)
+    with open(override_file_name, 'w', encoding='utf-8') as override_file:
+        yaml.dump(cfg, override_file, default_flow_style=False)
+
+
+def load_service_config(service_name: str) -> Any:
+    """
+    Load service configuration from file. Also check override directory,
+    and, if service file present there, override the values.
+
+    Args:
+        service_name: service to pull configs for; name of config file
+
+    Returns: json-decoded value of the service config
+
+    Raises:
+        LoadConfigError:
+            Unable to load config due to missing file or missing key
+    """
+    print(CONFIG_DIR, service_name)
+    cfg_file_name = os.path.join(CONFIG_DIR, '%s.yml' % service_name)
+    cfg = _load_yaml_file(cfg_file_name)
+
+    overrides = load_override_config(service_name)
+    if overrides is not None:
+        # Update the keys in the config if they are present in the override
+        cfg.update(overrides)
+    return cfg
+
+
+cached_service_configs = {}     # type: Dict[str, Any]
+
+
+def get_service_config_value(service: str, param: str, default: Any) -> Any:
+    """
+    Get a config value for :service:, falling back to a :default: value.
+
+    Log error if the default config is returned.
+
+    Args:
+        service: name of service to get config for
+        param: config key to fetch the value for
+        default: default value to return on failure
+
+    Returns:
+        value of :param: in the config files for :service:
+    """
+    service_configs = cached_service_configs.get(service)
+    try:
+        service_configs = service_configs or load_service_config(service)
+    except LoadConfigError as e:
+        logging.error('Error retrieving config: %s', e)
+        return default
+
+    # Handle empty file
+    if not service_configs:
+        logging.error('Error retrieving config, file empty for: %s', service)
+        return default
+
+    cached_service_configs[service] = service_configs
+
+    config_value = service_configs.get(param)
+    if config_value is not None:
+        return config_value
+    else:
+        logging.error(
+            'Error retrieving config for %s, key not found: %s',
+            service, param,
+        )
+        return default
+
+
+def _override_file_name(service_name: str) -> str:
+    return os.path.join(CONFIG_OVERRIDE_DIR, '%s.yml' % service_name)
+
+
+def _load_yaml_file(file_name: str) -> Any:
+    """
+    Load the yaml file and returns the python object.
+
+    Args:
+        file_name: name of the .yml file
+
+    Returns:
+        Contents of the yml file deserialized into a Python object
+
+    Raises:
+        LoadConfigError: on error
+    """
+
+    try:
+        with open(file_name, 'r', encoding='utf-8') as stream:
+            data = yaml.safe_load(stream)
+            return data
+    except (OSError, yaml.YAMLError) as e:
+        raise LoadConfigError('Error loading yml config') from e
diff --git a/configuration/tests/__init__.py b/configuration/tests/__init__.py
new file mode 100644
index 0000000..5c6cb64
--- /dev/null
+++ b/configuration/tests/__init__.py
@@ -0,0 +1,12 @@
+"""
+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.
+"""
diff --git a/configuration/tests/mconfig_manager_impl_tests.py b/configuration/tests/mconfig_manager_impl_tests.py
new file mode 100644
index 0000000..4be0adc
--- /dev/null
+++ b/configuration/tests/mconfig_manager_impl_tests.py
@@ -0,0 +1,63 @@
+"""
+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.
+"""
+
+import unittest
+from unittest import mock
+
+from google.protobuf import any_pb2
+from google.protobuf.json_format import MessageToJson
+from configuration import mconfig_managers
+from configuration.exceptions import LoadConfigError
+from orc8r.protos.mconfig import mconfigs_pb2
+
+
+class MconfigManagerImplTest(unittest.TestCase):
+    @mock.patch('magma.configuration.service_configs.load_service_config')
+    def test_load_mconfig(self, get_service_config_value_mock):
+        # Fixture mconfig has 1 unrecognized service, 1 unregistered type
+        magmad_fixture = mconfigs_pb2.MagmaD(
+            checkin_interval=10,
+            checkin_timeout=5,
+            autoupgrade_enabled=True,
+            autoupgrade_poll_interval=300,
+            package_version='1.0.0-0',
+            images=[],
+            tier_id='default',
+            feature_flags={'flag1': False},
+        )
+        magmad_fixture_any = any_pb2.Any()
+        magmad_fixture_any.Pack(magmad_fixture)
+        magmad_fixture_serialized = MessageToJson(magmad_fixture_any)
+        fixture = '''
+        {
+            "configs_by_key": {
+                "magmad": %s,
+                "foo": {
+                    "@type": "type.googleapis.com/magma.mconfig.NotAType",
+                    "value": "test1"
+                },
+                "not_a_service": {
+                    "@type": "type.googleapis.com/magma.mconfig.MagmaD",
+                    "value": "test2"
+                }
+            }
+        }
+        ''' % magmad_fixture_serialized
+        get_service_config_value_mock.return_value = {
+            'magma_services': ['foo'],
+        }
+
+        with mock.patch('builtins.open', mock.mock_open(read_data=fixture)):
+            manager = mconfig_managers.MconfigManagerImpl()
+            with self.assertRaises(LoadConfigError):
+                manager.load_mconfig()
diff --git a/configuration/tests/mconfigs_tests.py b/configuration/tests/mconfigs_tests.py
new file mode 100644
index 0000000..be8ae0f
--- /dev/null
+++ b/configuration/tests/mconfigs_tests.py
@@ -0,0 +1,103 @@
+"""
+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.
+"""
+
+import unittest
+from unittest import mock
+
+from google.protobuf.any_pb2 import Any
+from configuration import mconfigs
+from orc8r.protos.mconfig import mconfigs_pb2
+
+
+class MconfigsTest(unittest.TestCase):
+
+    @mock.patch('magma.configuration.service_configs.load_service_config')
+    def test_filter_configs_by_key(self, load_service_config_mock):
+        # All services present, but 1 type not
+        configs_by_key = {
+            'magmad': {
+                '@type': 'type.googleapis.com/magma.mconfig.MagmaD',
+                'value': 'world'.encode(),
+            },
+            'directoryd': {
+                '@type': 'type.googleapis.com/magma.mconfig.DirectoryD',
+                'value': 'hello'.encode(),
+            },
+            'foo': {
+                '@type': 'type.googleapis.com/magma.mconfig.Foo',
+                'value': 'test'.encode(),
+            },
+        }
+
+        # Directoryd not present
+        load_service_config_mock.return_value = {
+            'magma_services': ['mme', 'foo'],
+        }
+        actual = mconfigs.filter_configs_by_key(configs_by_key)
+        expected = {
+            'magmad': configs_by_key['magmad'],
+            'foo': configs_by_key['foo'],
+        }
+        self.assertEqual(expected, actual)
+
+        # No services present
+        load_service_config_mock.return_value = {
+            'magma_services': [],
+        }
+        actual = mconfigs.filter_configs_by_key(configs_by_key)
+        expected = {'magmad': configs_by_key['magmad']}
+        self.assertEqual(expected, actual)
+
+        # Directoryd service present as a dynamic service
+        load_service_config_mock.return_value = {
+            'magma_services': [],
+            'registered_dynamic_services': ['directoryd'],
+        }
+        actual = mconfigs.filter_configs_by_key(configs_by_key)
+        expected = {
+            'magmad': configs_by_key['magmad'],
+            'directoryd': configs_by_key['directoryd'],
+        }
+        self.assertEqual(expected, actual)
+
+    def test_unpack_mconfig_any(self):
+        magmad_mconfig = mconfigs_pb2.MagmaD(
+            checkin_interval=10,
+            checkin_timeout=5,
+            autoupgrade_enabled=True,
+            autoupgrade_poll_interval=300,
+            package_version='1.0.0-0',
+            images=[],
+            tier_id='default',
+            feature_flags={'flag1': False},
+        )
+        magmad_any = Any(
+            type_url='type.googleapis.com/magma.mconfig.MagmaD',
+            value=magmad_mconfig.SerializeToString(),
+        )
+        actual = mconfigs.unpack_mconfig_any(magmad_any, mconfigs_pb2.MagmaD())
+        self.assertEqual(magmad_mconfig, actual)
+
+    def test_unpack_mconfig_directoryd(self):
+        directoryd_mconfig = mconfigs_pb2.DirectoryD(
+            log_level=5,
+        )
+        magmad_any = Any(
+            type_url='type.googleapis.com/magma.mconfig.DirectoryD',
+            value=directoryd_mconfig.SerializeToString(),
+        )
+
+        actual = mconfigs.unpack_mconfig_any(
+            magmad_any, mconfigs_pb2.DirectoryD(),
+        )
+        self.assertEqual(directoryd_mconfig, actual)