diff --git a/oslo_service/backend/__init__.py b/oslo_service/backend/__init__.py new file mode 100644 index 00000000..c42c91fe --- /dev/null +++ b/oslo_service/backend/__init__.py @@ -0,0 +1,106 @@ +# Copyright (C) 2024 Red Hat, Inc. +# +# 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. +from __future__ import annotations + +import enum +import importlib +import logging +from typing import Any +from typing import TYPE_CHECKING + +from . import exceptions + +if TYPE_CHECKING: + from .base import BaseBackend + +LOG = logging.getLogger(__name__) + + +class BackendType(enum.Enum): + EVENTLET = "eventlet" + THREADING = "threading" + + +DEFAULT_BACKEND_TYPE = BackendType.EVENTLET + +_cached_backend_type: BackendType | None = None # backend type +_cached_backend: BaseBackend | None = None # current backend +_cached_components: dict[str, Any] | None = None # backend components + + +def _reset_backend() -> None: + """used by test functions to reset the selected backend""" + + global _cached_backend, _cached_components, _cached_backend_type + _cached_backend_type = _cached_backend = _cached_components = None + + +def init_backend(type_: BackendType) -> None: + """establish which backend will be used when get_backend() is called""" + + global _cached_backend, _cached_components, _cached_backend_type + + if _cached_backend_type is not None: + raise exceptions.BackendAlreadySelected( + f"The {_cached_backend_type.value!r} backend is already set up" + ) + + backend_name = type_.value + + LOG.info(f"Loading backend: {backend_name}") + try: + module_name = f"oslo_service.backend.{backend_name}" + module = importlib.import_module(module_name) + backend_class = getattr(module, f"{backend_name.capitalize()}Backend") + + new_backend: BaseBackend + _cached_backend = new_backend = backend_class() + + except ModuleNotFoundError: + LOG.error(f"Backend module {module_name} not found.") + raise ValueError(f"Unknown backend: {backend_name!r}") + except AttributeError: + LOG.error(f"Backend class not found in module {module_name}.") + raise ImportError(f"Backend class not found in module {module_name}") + + _cached_backend_type = type_ + _cached_components = new_backend.get_service_components() + LOG.info(f"Backend {type_.value!r} successfully loaded and cached.") + + +def get_backend() -> BaseBackend: + """Load backend dynamically based on the default constant.""" + + global _cached_backend + if _cached_backend is None: + init_backend(DEFAULT_BACKEND_TYPE) + + assert _cached_backend is not None # nosec B101 : this is for typing + + return _cached_backend + + +def get_component(name: str) -> Any: + """Retrieve a specific component from the backend.""" + global _cached_components + + if _cached_components is None: + get_backend() + + assert _cached_components is not None # nosec B101 : this is for typing + + if name not in _cached_components: + raise KeyError(f"Component {name!r} not found in backend.") + return _cached_components[name] diff --git a/oslo_service/backend/base.py b/oslo_service/backend/base.py new file mode 100644 index 00000000..82f7c07d --- /dev/null +++ b/oslo_service/backend/base.py @@ -0,0 +1,81 @@ +# Copyright (C) 2024 Red Hat, Inc. +# +# 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. + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import Any + +from oslo_service.backend.exceptions import BackendComponentNotAvailable + + +class BaseBackend(ABC): + """Base class for all backend implementations.""" + + @abstractmethod + def get_service_components(self) -> dict[str, Any]: + """Return the backend components. + + This method should return a dictionary containing all the components + required for the backend to function. For example: + { + "ServiceLauncher": Class or function, + "ProcessLauncher": Class or function, + "Service": Class or function, + "LoopingCall": Class or function, + ... + } + """ + pass + + +class ComponentRegistry: + """A registry to manage access to backend components. + + This class ensures that attempting to access a missing component + raises an explicit error, improving clarity and debugging. It acts + as a centralized registry for backend components. + """ + def __init__(self, components): + """Initialize the registry with a dictionary of components. + + :param components: A dictionary containing backend components, where + the keys are component names and the values are + the respective implementations. + """ + self._components = components + + def __getitem__(self, key): + """Retrieve a component by its key from the registry. + + :param key: The name of the component to retrieve. + :raises NotImplementedError: If the component is + not registered or available. + :return: The requested component instance. + """ + if key not in self._components or self._components[key] is None: + raise BackendComponentNotAvailable( + f"Component '{key}' is not available in this backend.") + return self._components[key] + + def __contains__(self, key): + """Check if a component is registered and available. + + :param key: The name of the component to check. + :return: True if the component is registered + and available, False otherwise. + """ + return key in self._components and self._components[key] is not None diff --git a/oslo_service/backend/eventlet/__init__.py b/oslo_service/backend/eventlet/__init__.py new file mode 100644 index 00000000..a10eb828 --- /dev/null +++ b/oslo_service/backend/eventlet/__init__.py @@ -0,0 +1,67 @@ +# Copyright (C) 2024 Red Hat, Inc. +# +# 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. + + +from oslo_service.backend.base import BaseBackend +from oslo_service import loopingcall +from oslo_service import service +from oslo_service import threadgroup + + +class EventletBackend(BaseBackend): + """Backend implementation for Eventlet. + + In this revision, this is a "stub" for a real backend; right now it imports + the regular implementation of oslo.service, which will be moved + entirely into a backend in a subsequent patch. + + """ + + @staticmethod + def get_service_components(): + """Return the components provided by the Eventlet backend.""" + + return { + # Classes + "ServiceBase": service.ServiceBase, + "ServiceLauncher": service.ServiceLauncher, + "Launcher": service.Launcher, + "ProcessLauncher": service.ProcessLauncher, + "Service": service.Service, + "Services": service.Services, + "ServiceWrapper": service.ServiceWrapper, + "SignalHandler": service.SignalHandler, + "SignalExit": service.SignalExit, + + # Looping call-related classes + "LoopingCallBase": loopingcall.LoopingCallBase, + "LoopingCallDone": loopingcall.LoopingCallDone, + "LoopingCallTimeOut": loopingcall.LoopingCallTimeOut, + "FixedIntervalLoopingCall": loopingcall.FixedIntervalLoopingCall, + "FixedIntervalWithTimeoutLoopingCall": + loopingcall.FixedIntervalWithTimeoutLoopingCall, + "DynamicLoopingCall": loopingcall.DynamicLoopingCall, + "BackOffLoopingCall": loopingcall.BackOffLoopingCall, + "RetryDecorator": loopingcall.RetryDecorator, + + # Threadgroup call-related classes + "ThreadGroup": threadgroup.ThreadGroup, + "Thread": threadgroup.Thread, + + # Functions + "launch": service.launch, + "_is_daemon": service._is_daemon, + "_is_sighup_and_daemon": service._is_sighup_and_daemon, + } diff --git a/oslo_service/backend/exceptions.py b/oslo_service/backend/exceptions.py new file mode 100644 index 00000000..6c3e3cee --- /dev/null +++ b/oslo_service/backend/exceptions.py @@ -0,0 +1,24 @@ +# Copyright (C) 2024 Red Hat, Inc. +# +# 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. + + +class BackendAlreadySelected(Exception): + """raised when init_backend() is called more than once""" + pass + + +class BackendComponentNotAvailable(Exception): + """Raised when a requested component is not available in the backend.""" + pass diff --git a/oslo_service/tests/backend/__init__.py b/oslo_service/tests/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/oslo_service/tests/backend/tests/__init__.py b/oslo_service/tests/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/oslo_service/tests/backend/tests/test_backend_base.py b/oslo_service/tests/backend/tests/test_backend_base.py new file mode 100644 index 00000000..f7f1cd23 --- /dev/null +++ b/oslo_service/tests/backend/tests/test_backend_base.py @@ -0,0 +1,91 @@ +# Copyright (C) 2024 Red Hat, Inc. +# +# 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 unittest +from unittest import mock + +from oslo_service.backend.base import BaseBackend +from oslo_service.backend.base import ComponentRegistry +from oslo_service.backend.exceptions import BackendComponentNotAvailable + + +class TestBaseBackend(unittest.TestCase): + def test_backend_with_valid_implementation(self): + """Test that a valid backend subclass works correctly.""" + class ValidBackend(BaseBackend): + def get_service_components(self): + return {"ServiceLauncher": "mock_service_launcher"} + + backend = ValidBackend() + components = backend.get_service_components() + self.assertIn("ServiceLauncher", components) + self.assertEqual( + components["ServiceLauncher"], "mock_service_launcher") + + +class TestComponentRegistry(unittest.TestCase): + def setUp(self): + """Set up components for testing.""" + self.components = { + "ServiceLauncher": "mock_service_launcher", + "ProcessLauncher": "mock_process_launcher", + } + self.registry = ComponentRegistry(self.components) + + def test_get_existing_component(self): + """Test getting an existing component.""" + self.assertEqual( + self.registry["ServiceLauncher"], "mock_service_launcher") + self.assertEqual( + self.registry["ProcessLauncher"], "mock_process_launcher") + + def test_get_missing_component(self): + """Test accessing a missing component raises NotImplementedError.""" + with self.assertRaises(BackendComponentNotAvailable) as context: + _ = self.registry["LoopingCall"] + self.assertIn( + "Component 'LoopingCall' is not available", + str(context.exception)) + + def test_contains_existing_component(self): + """Test checking if an existing component is in the proxy.""" + self.assertIn("ServiceLauncher", self.registry) + self.assertIn("ProcessLauncher", self.registry) + + def test_contains_missing_component(self): + """Test checking if a missing component is in the proxy.""" + self.assertNotIn("LoopingCall", self.registry) + + +class TestComponentRegistryIntegration(unittest.TestCase): + def test_service_integration_with_backend(self): + """Test registry usage.""" + # Mock backend components + components = { + "ServiceLauncher": mock.Mock(), + "ProcessLauncher": None, # Simulate an unimplemented component + } + registry = ComponentRegistry(components) + + # Validate successful retrieval of an available component + self.assertIn("ServiceLauncher", registry) + self.assertIsNotNone(registry["ServiceLauncher"]) + + # Validate BackendNotAvailable exception for unavailable components + with self.assertRaises(BackendComponentNotAvailable): + registry["ProcessLauncher"] + + with self.assertRaises(BackendComponentNotAvailable): + registry["NonExistentComponent"] diff --git a/oslo_service/tests/backend/tests/test_backend_init.py b/oslo_service/tests/backend/tests/test_backend_init.py new file mode 100644 index 00000000..7e3f08b7 --- /dev/null +++ b/oslo_service/tests/backend/tests/test_backend_init.py @@ -0,0 +1,91 @@ +# Copyright (C) 2024 Red Hat, Inc. +# +# 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 unittest + +import oslo_service.backend as backend_module +from oslo_service.backend import BackendType +from oslo_service.backend import exceptions +from oslo_service.backend import get_backend +from oslo_service.backend import init_backend + + +class TestBackend(unittest.TestCase): + def tearDown(self): + backend_module._reset_backend() + + def test_default_backend(self): + """Test default backend is eventlet.""" + backend = get_backend() + self.assertEqual(backend.__class__.__name__, "EventletBackend") + + def test_init_backend_explicit(self): + """test that init_backend() can be called before get_backend()""" + + init_backend(BackendType.EVENTLET) + + backend = get_backend() + self.assertEqual(backend.__class__.__name__, "EventletBackend") + + def test_dont_reinit_backend_from_default(self): + """test that init_backend() can't be called after get_backend()""" + + get_backend() + + with self.assertRaisesRegex( + exceptions.BackendAlreadySelected, + "The 'eventlet' backend is already set up", + ): + init_backend(BackendType.EVENTLET) + + def test_dont_reinit_backend_explicit_init(self): + """test that init_backend() can't be called twice""" + + init_backend(BackendType.EVENTLET) + + with self.assertRaisesRegex( + exceptions.BackendAlreadySelected, + "The 'eventlet' backend is already set up", + ): + init_backend(BackendType.EVENTLET) + + def test_cached_backend(self): + """Test backend is cached after initial load.""" + backend1 = get_backend() + backend2 = get_backend() + self.assertIs( + backend1, backend2, "Backend should be cached and reused.") + + def test_cache_invalidation(self): + """Test that cache is invalidated correctly.""" + backend1 = get_backend() + backend_module._reset_backend() + backend2 = get_backend() + self.assertNotEqual( + backend1, backend2, "Cache invalidation should reload the backend." + ) + + def test_backend_components(self): + """Test that components are cached when init_backend is called.""" + + init_backend(BackendType.EVENTLET) + + backend = get_backend() + + self.assertTrue( + {"ServiceBase", "ServiceLauncher"}.intersection( + backend.get_service_components() + ) + )