Skip to content

Commit 817fbab

Browse files
committed
lockable_resources: add LockedResourceReservation context manager and various ResourceSelectors
1 parent 3764e94 commit 817fbab

File tree

2 files changed

+331
-2
lines changed

2 files changed

+331
-2
lines changed

jenkinsapi/lockable_resources.py

Lines changed: 225 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
from abc import ABC, abstractmethod
12
import logging
2-
from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, TypedDict
3+
from typing import (
4+
TYPE_CHECKING,
5+
Dict,
6+
Iterator,
7+
List,
8+
Mapping,
9+
Optional,
10+
TypedDict,
11+
)
12+
13+
from requests import Response
314

415
from jenkinsapi.custom_exceptions import JenkinsAPIException
516
from jenkinsapi.jenkinsbase import JenkinsBase
6-
from requests import Response
17+
from jenkinsapi.utils.retry import RetryConfig, SimpleRetryConfig
718

819
if TYPE_CHECKING:
920
from jenkinsapi.jenkins import Jenkins
@@ -86,6 +97,20 @@ class ResourceLockedError(JenkinsAPIException):
8697
pass
8798

8899

100+
class ResourceReservationTimeoutError(JenkinsAPIException, TimeoutError):
101+
"""Raised when resource reservation times out"""
102+
103+
pass
104+
105+
106+
DEFAULT_WAIT_SLEEP_PERIOD = 5
107+
DEFAULT_WAIT_TIMEOUT_PERIOD = 3600
108+
DEFAULT_RETRY_CONFIG = SimpleRetryConfig(
109+
sleep_period=DEFAULT_WAIT_SLEEP_PERIOD,
110+
timeout=DEFAULT_WAIT_TIMEOUT_PERIOD,
111+
)
112+
113+
89114
class LockableResources(JenkinsBase, Mapping[str, LockableResource]):
90115
"""Object representation of the lockable resource jenkins API"""
91116

@@ -179,3 +204,201 @@ def reserve(self, name: str) -> None:
179204

180205
def unreserve(self, name: str) -> None:
181206
self._make_resource_request("unreserve", name)
207+
208+
def try_reserve(
209+
self,
210+
selector: "ResourceSelector",
211+
) -> Optional[str]:
212+
"""
213+
Try to reserve a resource that matches the given condition
214+
215+
:return: the name of the reserved resource on success
216+
:return: None if all resources are busy
217+
"""
218+
for resource_name in selector.select(self):
219+
resource = self[resource_name]
220+
# if server reported that the resource is not free
221+
# don't try to reserve it
222+
if not resource.is_free():
223+
continue
224+
# if server reported that the resource is free
225+
# it might have been reserved since the last poll
226+
try:
227+
resource.reserve()
228+
except ResourceLockedError:
229+
continue
230+
return resource.name
231+
return None
232+
233+
def wait_reserve(
234+
self,
235+
selector: "ResourceSelector",
236+
retry: RetryConfig = DEFAULT_RETRY_CONFIG,
237+
) -> str:
238+
"""
239+
Wait for a resource that matches the given condition to become available
240+
241+
:return: the name of the reserved resource on success
242+
:raise ResourceReservationTimeoutError: if no matching resources are found during the timeout period.
243+
"""
244+
retry_state = retry.begin()
245+
while True:
246+
result = self.try_reserve(selector)
247+
if result is not None:
248+
return result
249+
try:
250+
retry_state.check_retry()
251+
except TimeoutError as err:
252+
raise ResourceReservationTimeoutError(
253+
f"Timeout waiting for a resource matching {selector} after {retry}"
254+
) from err
255+
logger.info("No free resources matching %r, retry", selector)
256+
self.poll()
257+
258+
def reservation_by_label(
259+
self,
260+
label: str,
261+
retry: RetryConfig = DEFAULT_RETRY_CONFIG,
262+
) -> "LockedResourceReservation":
263+
return LockedResourceReservation(
264+
self,
265+
ResourceLabelSelector(label),
266+
retry=retry,
267+
)
268+
269+
def reservation_by_name(
270+
self,
271+
name: str,
272+
retry: RetryConfig = DEFAULT_RETRY_CONFIG,
273+
) -> "LockedResourceReservation":
274+
return LockedResourceReservation(
275+
self,
276+
ResourceNameSelector(name),
277+
retry=retry,
278+
)
279+
280+
def reservation_by_name_list(
281+
self,
282+
name_list: List[str],
283+
retry: RetryConfig = DEFAULT_RETRY_CONFIG,
284+
) -> "LockedResourceReservation":
285+
return LockedResourceReservation(
286+
self,
287+
ResourceNameListSelector(name_list),
288+
retry=retry,
289+
)
290+
291+
292+
class ResourceSelector(ABC):
293+
"""Base class for which iterates acceptable resources for a reservation"""
294+
295+
@abstractmethod
296+
def select(self, lockable_resources: LockableResources) -> Iterator[str]:
297+
"""Iterate acceptable resource names"""
298+
pass
299+
300+
301+
class ResourceNameSelector(ResourceSelector):
302+
"""Implementation of :py:class:`ResourceSelector` that selects a single resource by name"""
303+
304+
def __init__(self, name: str):
305+
self.name = name
306+
307+
def select(self, lockable_resources: LockableResources) -> Iterator[str]:
308+
yield self.name
309+
310+
def __repr__(self) -> str:
311+
return f"{self.__class__.__name__}({self.name!r})"
312+
313+
314+
class ResourceNameListSelector(ResourceSelector):
315+
"""Implementation of :py:class:`ResourceSelector` that selects from a list of resources"""
316+
317+
def __init__(self, name_list: List[str]):
318+
self.name_list = name_list
319+
320+
def select(self, lockable_resources: LockableResources) -> Iterator[str]:
321+
return iter(self.name_list)
322+
323+
def __repr__(self) -> str:
324+
return f"{self.__class__.__name__}({self.name_list!r})"
325+
326+
327+
class ResourceLabelSelector(ResourceSelector):
328+
"""Implementation of :py:class:`ResourceSelector` that selects any resources with a given jenkins label"""
329+
330+
def __init__(self, label: str):
331+
self.label = label
332+
333+
def select(self, lockable_resources: LockableResources) -> Iterator[str]:
334+
for resource in lockable_resources.values():
335+
if self.label in resource.data["labelsAsList"]:
336+
yield resource.name
337+
338+
def __repr__(self) -> str:
339+
return f"{self.__class__.__name__}({self.label!r})"
340+
341+
342+
class LockedResourceReservation:
343+
"""
344+
Context manager for locking a Jenkins resource
345+
346+
Creating this object does not lock the resource, it is only locked and
347+
unlocked on :meth:`__enter__` and :meth:`__exit__` methods.
348+
349+
Example::
350+
351+
reservation: LockedResourceReservation = init_reservation()
352+
# .. possibly much later ...
353+
print("Resource will be locked ...")
354+
with reservation as locked_resource:
355+
name = locked_resource.locked_resource_name
356+
print(f"Resource currently locked: {name}")
357+
print("Resource no longer locked")
358+
359+
If resources are busy this will retry until it will eventually succeed or time out.
360+
361+
:raise ResourceReservationTimeoutError: if reservation process times out
362+
"""
363+
364+
_locked_resource_name: Optional[str] = None
365+
retry: RetryConfig
366+
367+
def __init__(
368+
self,
369+
api: LockableResources,
370+
selector: ResourceSelector,
371+
retry: RetryConfig = DEFAULT_RETRY_CONFIG,
372+
):
373+
self.api = api
374+
self.selector = selector
375+
self.retry = retry
376+
377+
def is_active(self) -> bool:
378+
"""Check if the resource is currently locked"""
379+
return self._locked_resource_name is not None
380+
381+
@property
382+
def locked_resource_name(self) -> str:
383+
"""
384+
Return the name of the locked resource
385+
386+
This throws an error if the resource is not currently locked.
387+
"""
388+
if self._locked_resource_name is None:
389+
raise RuntimeError("Resource not locked")
390+
return self._locked_resource_name
391+
392+
def __enter__(self) -> "LockedResourceReservation":
393+
"""Acquire a lock for the specified label."""
394+
if self._locked_resource_name is not None:
395+
raise RuntimeError("Lock already acquired")
396+
self._locked_resource_name = self.api.wait_reserve(
397+
self.selector, retry=self.retry
398+
)
399+
return self
400+
401+
def __exit__(self, *a) -> None:
402+
if self._locked_resource_name is not None:
403+
self.api.unreserve(self._locked_resource_name)
404+
self._locked_resource_name = None

jenkinsapi_tests/systests/test_lockable_resources.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
from contextlib import ExitStack
2+
from unittest import mock
13
import pytest
24

35
from jenkinsapi.jenkins import Jenkins
46
from jenkinsapi.lockable_resources import (
57
LockableResource,
68
LockableResources,
79
ResourceLockedError,
10+
ResourceReservationTimeoutError,
811
)
912
from jenkinsapi.utils.jenkins_launcher import JenkinsLancher
13+
from jenkinsapi.utils.retry import SimpleRetryConfig
1014

1115
GROOVY_SCRIPT_INIT_TEST_RESOURCES = """
1216
import org.jenkins.plugins.lockableresources.*
@@ -37,6 +41,16 @@ def test_lock_name() -> str:
3741
return "locktest"
3842

3943

44+
@pytest.fixture
45+
def test_lock_name2() -> str:
46+
return "locktest2"
47+
48+
49+
@pytest.fixture
50+
def test_lock_label() -> str:
51+
return "locktest"
52+
53+
4054
@pytest.fixture(scope="function")
4155
def lockable_resources(
4256
jenkins_admin_admin: Jenkins,
@@ -101,3 +115,95 @@ def test_reserve_unreserve_nopoll(
101115
assert lockable_resources.is_reserved(rn) is True
102116
lockable_resources.poll()
103117
assert lockable_resources.is_reserved(rn) is False
118+
119+
120+
def test_reservation_by_name(
121+
lockable_resources: LockableResources,
122+
test_lock_name: str,
123+
):
124+
reservation = lockable_resources.reservation_by_name(test_lock_name)
125+
assert lockable_resources.is_free(test_lock_name)
126+
with reservation:
127+
assert reservation.locked_resource_name == test_lock_name
128+
assert lockable_resources.is_free(test_lock_name) is False
129+
assert reservation.is_active()
130+
assert lockable_resources.is_free(test_lock_name)
131+
assert reservation.is_active() is False
132+
name = None
133+
with pytest.raises(RuntimeError):
134+
name = reservation.locked_resource_name
135+
assert name is None
136+
137+
138+
def test_reservation_by_name_list(
139+
lockable_resources: LockableResources,
140+
test_lock_name: str,
141+
test_lock_name2: str,
142+
):
143+
name_list = [test_lock_name, test_lock_name2]
144+
r1 = lockable_resources.reservation_by_name_list(name_list)
145+
assert lockable_resources.is_free(name_list[0])
146+
assert lockable_resources.is_free(name_list[1])
147+
with lockable_resources.reservation_by_name_list(name_list) as r1:
148+
assert r1.locked_resource_name == name_list[0]
149+
with lockable_resources.reservation_by_name_list(name_list) as r2:
150+
assert r2.locked_resource_name == name_list[1]
151+
assert lockable_resources.is_free(name_list[1]) is False
152+
assert lockable_resources.is_free(name_list[1])
153+
assert lockable_resources.is_free(name_list[0])
154+
assert lockable_resources.is_free(name_list[1])
155+
156+
157+
def test_reservation_by_label(
158+
lockable_resources: LockableResources,
159+
test_lock_label: str,
160+
):
161+
res = lockable_resources.reservation_by_label(test_lock_label)
162+
with res:
163+
locked_resource = lockable_resources[res.locked_resource_name]
164+
assert locked_resource.is_free() is False
165+
assert test_lock_label in locked_resource.data["labelsAsList"]
166+
assert locked_resource.is_free() is True
167+
168+
169+
def test_custom_retry(
170+
lockable_resources: LockableResources,
171+
test_lock_name: str,
172+
):
173+
with ExitStack() as exit_stack:
174+
exit_stack.enter_context(
175+
mock.patch(
176+
"time.monotonic",
177+
side_effect=range(1000, 10000),
178+
)
179+
)
180+
mock_time_sleep = exit_stack.enter_context(
181+
mock.patch("time.sleep"),
182+
)
183+
mock_try_reserve = exit_stack.enter_context(
184+
mock.patch.object(
185+
lockable_resources,
186+
"try_reserve",
187+
return_value=None,
188+
)
189+
)
190+
mock_poll = exit_stack.enter_context(
191+
mock.patch.object(
192+
lockable_resources,
193+
"poll",
194+
)
195+
)
196+
exit_stack.enter_context(
197+
pytest.raises(ResourceReservationTimeoutError)
198+
)
199+
with lockable_resources.reservation_by_name(
200+
test_lock_name,
201+
retry=SimpleRetryConfig(
202+
sleep_period=1,
203+
timeout=5.5,
204+
),
205+
):
206+
pass
207+
assert mock_time_sleep.call_count == 5
208+
assert mock_try_reserve.call_count == 6
209+
assert mock_poll.call_count == 5

0 commit comments

Comments
 (0)