Merge "Introduce configurable backend system in oslo.service"

This commit is contained in:
Zuul 2025-01-28 16:16:48 +00:00 committed by Gerrit Code Review
commit 2b2f9675d7
8 changed files with 460 additions and 0 deletions

View 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]

View 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

View 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,
}

View 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

View File

View 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"]

View 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()
)
)