Skip to content

Commit 7ca6f9c

Browse files
authored
feat: add ResourceList and NamespacedResourceList classes (#2437)
1 parent 9cd734e commit 7ca6f9c

File tree

3 files changed

+283
-6
lines changed

3 files changed

+283
-6
lines changed

examples/resource_list.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
openshift-python-wrapper allows the creation of several similar resources at the same time
3+
by using the ResourceList and NamespacedResourceList classes.
4+
When used as a contextmanager, deployment and deletion is handled automatically like in other classes.
5+
"""
6+
7+
from ocp_resources.namespace import Namespace
8+
from ocp_resources.resource import get_client, ResourceList, NamespacedResourceList
9+
from ocp_resources.role import Role
10+
11+
client = get_client()
12+
13+
# We create a list of three namespaces: ns-1, ns-2, ns-3
14+
namespaces = ResourceList(resource_class=Namespace, num_resources=3, client=client, name="ns")
15+
namespaces.deploy()
16+
17+
assert namespaces[2].name == "ns-3"
18+
19+
# Now we create one role on each namespace
20+
roles = NamespacedResourceList(
21+
client=client,
22+
resource_class=Role,
23+
name="role",
24+
namespaces=[ns.name for ns in namespaces],
25+
rules=[
26+
{
27+
"apiGroups": ["serving.kserve.io"],
28+
"resources": ["inferenceservices"],
29+
"verbs": ["get", "list", "watch"],
30+
}
31+
],
32+
)
33+
34+
assert roles[2].namespace == "ns-3"
35+
36+
# We clean up all the resources we created
37+
namespaces.clean_up()
38+
roles.clean_up()
39+
40+
41+
# We can also work with these classes using contextmanagers
42+
# for automatic clean up
43+
with ResourceList(client=client, resource_class=Namespace, name="ns", num_resources=3) as namespaces:
44+
assert namespaces[0].name == "ns-1"

ocp_resources/resource.py

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
import re
99
import sys
1010
import threading
11+
from abc import abstractmethod, ABC
1112
from collections.abc import Callable, Generator
1213
from io import StringIO
1314
from signal import SIGINT, signal
1415
from types import TracebackType
15-
from typing import Any
16+
from typing import Any, Type
1617
from urllib.parse import parse_qs, urlencode, urlparse
1718
from warnings import warn
1819

@@ -59,6 +60,7 @@
5960
from ocp_resources.utils.resource_constants import ResourceConstants
6061
from ocp_resources.utils.utils import skip_existing_resource_creation_teardown
6162

63+
6264
LOGGER = get_logger(name=__name__)
6365
MAX_SUPPORTED_API_VERSION = "v2"
6466

@@ -736,7 +738,7 @@ def _sigint_handler(self, signal_received: int, frame: Any) -> None:
736738
self.__exit__()
737739
sys.exit(signal_received)
738740

739-
def deploy(self, wait: bool = False) -> Any:
741+
def deploy(self, wait: bool = False) -> Resource | NamespacedResource:
740742
"""
741743
For debug, export REUSE_IF_RESOURCE_EXISTS to skip resource create.
742744
Spaces are important in the export dict
@@ -1756,3 +1758,160 @@ def _apply_patches_sampler(self, patches: dict[Any, Any], action_text: str, acti
17561758
timeout=TIMEOUT_30SEC,
17571759
sleep_time=TIMEOUT_5SEC,
17581760
)
1761+
1762+
1763+
class BaseResourceList(ABC):
1764+
"""
1765+
Abstract base class for managing collections of resources.
1766+
1767+
Provides common functionality for resource lists including context management,
1768+
iteration, indexing, deployment, and cleanup operations.
1769+
"""
1770+
1771+
def __init__(self, client: DynamicClient):
1772+
self.resources: list[Resource] = []
1773+
self.client = client
1774+
1775+
def __enter__(self):
1776+
"""Enters the runtime context and deploys all resources."""
1777+
self.deploy()
1778+
return self
1779+
1780+
def __exit__(
1781+
self,
1782+
exc_type: type[BaseException] | None,
1783+
exc_val: BaseException | None,
1784+
exc_tb: TracebackType | None,
1785+
) -> None:
1786+
"""Exits the runtime context and cleans up all resources."""
1787+
self.clean_up()
1788+
1789+
def __iter__(self) -> Generator[Resource | NamespacedResource, None, None]:
1790+
"""Allows iteration over the resources in the list."""
1791+
yield from self.resources
1792+
1793+
def __getitem__(self, index: int) -> Resource | NamespacedResource:
1794+
"""Retrieves a resource from the list by its index."""
1795+
return self.resources[index]
1796+
1797+
def __len__(self) -> int:
1798+
"""Returns the number of resources in the list."""
1799+
return len(self.resources)
1800+
1801+
def deploy(self, wait: bool = False) -> list[Resource | NamespacedResource]:
1802+
"""
1803+
Deploys all resources in the list.
1804+
1805+
Args:
1806+
wait (bool): If True, wait for each resource to be ready.
1807+
1808+
Returns:
1809+
List[Any]: A list of the results from each resource's deploy() call.
1810+
"""
1811+
return [resource.deploy(wait=wait) for resource in self.resources]
1812+
1813+
def clean_up(self, wait: bool = True) -> bool:
1814+
"""
1815+
Deletes all resources in the list.
1816+
1817+
Args:
1818+
wait (bool): If True, wait for each resource to be deleted.
1819+
1820+
Returns:
1821+
bool: Returns True if all resources are cleaned up correclty.
1822+
"""
1823+
# Deleting in reverse order to resolve dependencies correctly.
1824+
return all(resource.clean_up(wait=wait) for resource in reversed(self.resources))
1825+
1826+
@abstractmethod
1827+
def _create_resources(self, resource_class: Type, **kwargs: Any) -> None:
1828+
"""Abstract method to create resources based on specific logic."""
1829+
pass
1830+
1831+
1832+
class ResourceList(BaseResourceList):
1833+
"""
1834+
A class to manage a collection of a specific resource type.
1835+
1836+
This class creates and manages N copies of a given resource,
1837+
each with a unique name derived from a base name.
1838+
"""
1839+
1840+
def __init__(
1841+
self,
1842+
resource_class: Type[Resource],
1843+
num_resources: int,
1844+
client: DynamicClient,
1845+
**kwargs: Any,
1846+
) -> None:
1847+
"""
1848+
Initializes a list of N resource objects.
1849+
1850+
Args:
1851+
resource_class (Type[Resource]): The resource class to instantiate (e.g., Namespace).
1852+
num_resources (int): The number of resource copies to create.
1853+
client (DynamicClient): The dynamic client to use. Defaults to None.
1854+
**kwargs (Any): Arguments to be passed to the constructor of the resource_class.
1855+
A 'name' key is required in kwargs to serve as the base name for the resources.
1856+
"""
1857+
super().__init__(client)
1858+
1859+
self.num_resources = num_resources
1860+
self._create_resources(resource_class, **kwargs)
1861+
1862+
def _create_resources(self, resource_class: Type[Resource], **kwargs: Any) -> None:
1863+
"""Creates N resources with indexed names."""
1864+
base_name = kwargs["name"]
1865+
1866+
for i in range(1, self.num_resources + 1):
1867+
resource_name = f"{base_name}-{i}"
1868+
resource_kwargs = kwargs.copy()
1869+
resource_kwargs["name"] = resource_name
1870+
1871+
instance = resource_class(client=self.client, **resource_kwargs)
1872+
self.resources.append(instance)
1873+
1874+
1875+
class NamespacedResourceList(BaseResourceList):
1876+
"""
1877+
Manages a collection of a specific namespaced resource (e.g., Pod, Service, etc), creating one instance per provided namespace.
1878+
1879+
This class creates one copy of a given namespaced resource in each of the
1880+
namespaces provided in a list.
1881+
"""
1882+
1883+
def __init__(
1884+
self,
1885+
resource_class: Type[NamespacedResource],
1886+
namespaces: ResourceList,
1887+
client: DynamicClient,
1888+
**kwargs: Any,
1889+
) -> None:
1890+
"""
1891+
Initializes a list of resource objects, one for each specified namespace.
1892+
1893+
Args:
1894+
resource_class (Type[NamespacedResource]): The namespaced resource class to instantiate (e.g., Pod).
1895+
namespaces (ResourceList): A ResourceList containing namespaces where the resources will be created.
1896+
client (DynamicClient): The dynamic client to use for cluster communication.
1897+
**kwargs (Any): Additional arguments to be passed to the resource_class constructor.
1898+
A 'name' key is required in kwargs to serve as the base name for the resources.
1899+
"""
1900+
for ns in namespaces:
1901+
if ns.kind != "Namespace":
1902+
raise TypeError("All the resources in namespaces should be namespaces.")
1903+
1904+
super().__init__(client)
1905+
1906+
self.namespaces = namespaces
1907+
self._create_resources(resource_class, **kwargs)
1908+
1909+
def _create_resources(self, resource_class: Type[NamespacedResource], **kwargs: Any) -> None:
1910+
"""Creates one resource per namespace."""
1911+
for ns in self.namespaces:
1912+
instance = resource_class(
1913+
namespace=ns.name,
1914+
client=self.client,
1915+
**kwargs,
1916+
)
1917+
self.resources.append(instance)

tests/test_resource.py

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
from ocp_resources.exceptions import ResourceTeardownError
99
from ocp_resources.namespace import Namespace
1010
from ocp_resources.pod import Pod
11-
from ocp_resources.resource import Resource
11+
from ocp_resources.resource import Resource, ResourceList, NamespacedResourceList
1212
from ocp_resources.secret import Secret
1313
from fake_kubernetes_client import FakeDynamicClient
1414

15+
BASE_NAMESPACE_NAME: str = "test-namespace"
16+
BASE_POD_NAME: str = "test-pod"
17+
POD_CONTAINERS: list[dict[str, str]] = [{"name": "test-container", "image": "nginx:latest"}]
18+
1519

1620
class SecretTestExit(Secret):
1721
def deploy(self, wait: bool = False):
@@ -28,17 +32,22 @@ def client():
2832

2933
@pytest.fixture(scope="class")
3034
def namespace(client):
31-
return Namespace(client=client, name="test-namespace")
35+
return Namespace(client=client, name=BASE_NAMESPACE_NAME)
36+
37+
38+
@pytest.fixture(scope="class")
39+
def namespaces(client):
40+
return ResourceList(client=client, resource_class=Namespace, num_resources=3, name=BASE_NAMESPACE_NAME)
3241

3342

3443
@pytest.fixture(scope="class")
3544
def pod(client):
3645
# Create a test pod for testing purposes
3746
test_pod = Pod(
3847
client=client,
39-
name="test-pod",
48+
name=BASE_POD_NAME,
4049
namespace="default",
41-
containers=[{"name": "test-container", "image": "nginx:latest"}],
50+
containers=POD_CONTAINERS,
4251
annotations={"fake-client.io/ready": "false"}, # Create pod with Ready status FALSE
4352
)
4453
deployed_pod = test_pod.deploy()
@@ -47,6 +56,17 @@ def pod(client):
4756
test_pod.clean_up()
4857

4958

59+
@pytest.fixture(scope="class")
60+
def pods(client, namespaces):
61+
return NamespacedResourceList(
62+
client=client,
63+
resource_class=Pod,
64+
namespaces=namespaces,
65+
name=BASE_POD_NAME,
66+
containers=POD_CONTAINERS,
67+
)
68+
69+
5070
@pytest.mark.incremental
5171
class TestResource:
5272
def test_get(self, client):
@@ -119,6 +139,60 @@ def test_resource_context_manager_exit(self, client):
119139
pass
120140

121141

142+
@pytest.mark.incremental
143+
class TestResourceList:
144+
def test_resource_list_deploy(self, namespaces):
145+
namespaces.deploy()
146+
assert namespaces
147+
148+
def test_resource_list_len(self, namespaces):
149+
assert len(namespaces) == 3
150+
151+
def test_resource_list_name(self, namespaces):
152+
for i, ns in enumerate(namespaces.resources, start=1):
153+
assert ns.name == f"{BASE_NAMESPACE_NAME}-{i}"
154+
155+
def test_resource_list_teardown(self, namespaces):
156+
namespaces.clean_up(wait=False)
157+
158+
def test_resource_list_context_manager(self, client):
159+
with ResourceList(
160+
client=client, resource_class=Namespace, name=BASE_NAMESPACE_NAME, num_resources=3
161+
) as namespaces:
162+
assert namespaces
163+
164+
165+
@pytest.mark.incremental
166+
class TestNamespacedResourceList:
167+
def test_namespaced_resource_list_deploy(self, client, pods):
168+
pods.deploy()
169+
assert pods
170+
171+
def test_resource_list_len(self, namespaces, pods):
172+
assert len(pods) == len(namespaces)
173+
174+
def test_resource_list_name(self, pods):
175+
for pod in pods.resources:
176+
assert pod.name == BASE_POD_NAME
177+
178+
def test_namespaced_resource_list_namespace(self, namespaces, pods):
179+
for pod, namespace in zip(pods.resources, namespaces):
180+
assert pod.namespace == namespace.name
181+
182+
def test_resource_list_teardown(self, pods):
183+
pods.clean_up(wait=False)
184+
185+
def test_namespaced_resource_list_context_manager(self, client, namespaces):
186+
with NamespacedResourceList(
187+
client=client,
188+
resource_class=Pod,
189+
namespaces=namespaces,
190+
name=BASE_POD_NAME,
191+
containers=POD_CONTAINERS,
192+
) as pods:
193+
assert pods
194+
195+
122196
@pytest.mark.xfail(reason="Need debug")
123197
class TestClientProxy:
124198
@patch.dict(os.environ, {"HTTP_PROXY": "http://env-http-proxy.com"})

0 commit comments

Comments
 (0)