Merge "Introduce configurable backend system in oslo.service"
This commit is contained in:
commit
2b2f9675d7
106
oslo_service/backend/__init__.py
Normal file
106
oslo_service/backend/__init__.py
Normal file
@ -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]
|
81
oslo_service/backend/base.py
Normal file
81
oslo_service/backend/base.py
Normal file
@ -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
|
67
oslo_service/backend/eventlet/__init__.py
Normal file
67
oslo_service/backend/eventlet/__init__.py
Normal file
@ -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,
|
||||
}
|
24
oslo_service/backend/exceptions.py
Normal file
24
oslo_service/backend/exceptions.py
Normal file
@ -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
|
0
oslo_service/tests/backend/__init__.py
Normal file
0
oslo_service/tests/backend/__init__.py
Normal file
0
oslo_service/tests/backend/tests/__init__.py
Normal file
0
oslo_service/tests/backend/tests/__init__.py
Normal file
91
oslo_service/tests/backend/tests/test_backend_base.py
Normal file
91
oslo_service/tests/backend/tests/test_backend_base.py
Normal file
@ -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"]
|
91
oslo_service/tests/backend/tests/test_backend_init.py
Normal file
91
oslo_service/tests/backend/tests/test_backend_init.py
Normal file
@ -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()
|
||||
)
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user