Skip to content

Commit 34865ac

Browse files
coordinator/client: add lease-based place locking
Place acquisition is currently indefinite, which can lead to stale locks when users stop interacting with a place without releasing it. Add a lease-based locking mode that allows places to be acquired for a limited time and expire automatically unless extended. Leases are tied to the lifetime of a reservation. This prevents stale locks and reduces the need for manual cleanup. Signed-off-by: Asher Pemberton <asher.pemberton@arm.com> Reviewed-by: Asher Pemberton <asher.pemberton@arm.com> # gatekeeper Co-authored-by: Idan Saadon <idan.saadon@arm.com>
1 parent 915b591 commit 34865ac

14 files changed

Lines changed: 1542 additions & 261 deletions

doc/getting_started.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ To interact with this place, it needs to be acquired first, this is done by
311311
312312
labgrid-venv $ labgrid-client -p example-place acquire
313313
314+
For short-lived access, a place can also be leased using a reservation:
315+
316+
.. code-block:: bash
317+
318+
labgrid-venv $ labgrid-client reserve board=example --shell
319+
labgrid-venv $ labgrid-client wait
320+
labgrid-venv $ labgrid-client -p + lease
321+
314322
Now we can connect to the serial console:
315323

316324
.. code-block:: bash

doc/man/client.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ a name defined within the exporter, cls is the class of the exported resource
8888
and name is its name. Wild cards in match patterns are explicitly allowed, *
8989
matches anything.
9090

91+
Leases
92+
------
93+
A lease is a time-limited acquisition of a place.
94+
95+
Leases are always associated with a reservation. The reserved place is referenced
96+
using ``-p +``. To keep a lease active, the reservation must be extended using
97+
the ``extend`` command.
98+
99+
Use ``labgrid-client extend`` to refresh the lease once, or
100+
``labgrid-client extend --keepalive`` to keep it alive automatically.
101+
102+
If a lease extension fails, the coordinator cancels the lease and releases the
103+
place to avoid leaving the system in an inconsistent state.
104+
105+
If the reservation expires, the coordinator automatically releases the leased
106+
place.
107+
91108
Adding Named Resources
92109
----------------------
93110
If a target contains multiple Resources of the same type, named matches need to
@@ -122,6 +139,28 @@ Open a console to the acquired place:
122139
123140
$ labgrid-client -p <placename> console
124141
142+
Lease a place using a reservation:
143+
144+
.. code-block:: bash
145+
146+
$ labgrid-client reserve board=imx8 --shell
147+
$ labgrid-client wait
148+
$ labgrid-client -p + lease
149+
150+
Extend the lease to keep it alive:
151+
152+
.. code-block:: bash
153+
154+
# refresh once by passing the token explicitly
155+
$ labgrid-client extend ABC123
156+
157+
# or take the token from the environment (LG_TOKEN)
158+
$ export LG_TOKEN=ABC123
159+
$ labgrid-client extend
160+
161+
# keep the lease alive automatically
162+
$ labgrid-client extend --keepalive
163+
125164
Add all resources with the group "example-group" to the place example-place:
126165

127166
.. code-block:: bash

doc/usage.rst

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ can lock that place.
117117
timeout: 2019-08-06 12:59:11.840780
118118
$ labgrid-client -p +SP37P5OQRU console
119119
120+
120121
When using reservation in a CI job or to save some typing, the ``labgrid-client
121122
reserve`` command supports a ``--shell`` command to print code for evaluating
122123
in the shell.
@@ -166,6 +167,65 @@ allocated before returning.
166167
A reservation will time out after a short time, if it is neither refreshed nor
167168
used by locked places.
168169

170+
Leasing a place using a reservation
171+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
172+
173+
After a reservation has been allocated, the reserved place can be acquired
174+
either indefinitely or as a lease.
175+
176+
First, create a reservation and export the reservation token:
177+
178+
.. code-block:: bash
179+
180+
$ labgrid-client reserve board=imx8 --shell
181+
export LG_TOKEN=ABC123
182+
183+
Once the reservation is allocated, the reserved place can be referenced using
184+
``-p +``, which expands to the place allocated to the reservation.
185+
186+
.. code-block:: bash
187+
188+
$ labgrid-client -p + lease
189+
leased place board-01
190+
191+
A lease is time-limited and must be kept alive by extending the reservation.
192+
This is done using the ``extend`` command.
193+
194+
To refresh the reservation once:
195+
196+
.. code-block:: bash
197+
198+
$ labgrid-client extend ABC123
199+
200+
The reservation token can also be taken from the environment variable
201+
``LG_TOKEN``:
202+
203+
.. code-block:: bash
204+
205+
$ export LG_TOKEN=ABC123
206+
$ labgrid-client extend
207+
208+
To continuously keep the reservation alive, use the keepalive mode:
209+
210+
.. code-block:: bash
211+
212+
$ labgrid-client extend --keepalive
213+
214+
In keepalive mode, the client automatically renews the lease at a suitable interval.
215+
216+
If a lease extension fails (for example due to exporter issues), the lease is
217+
cancelled and the place is released to avoid inconsistent state.
218+
219+
To acquire the place indefinitely (unchanged behaviour), use ``lock`` or
220+
``acquire`` without lease mode:
221+
222+
.. code-block:: bash
223+
224+
$ labgrid-client -p + lock
225+
# or
226+
$ labgrid-client -p + acquire
227+
228+
169229
Library
170230
-------
171231
labgrid can be used directly as a Python library, without the infrastructure

labgrid/remote/client.py

Lines changed: 152 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,19 @@ async def print_resources(self):
382382
else:
383383
print(line)
384384

385+
async def _get_reservation_by_token(self, token):
386+
request = labgrid_coordinator_pb2.GetReservationsRequest()
387+
try:
388+
response = await self.stub.GetReservations(request)
389+
except grpc.aio.AioRpcError as e:
390+
raise ServerError(e.details()) from e
391+
392+
for res_pb in response.reservations:
393+
res = Reservation.from_pb2(res_pb)
394+
if res.token == token:
395+
return res
396+
return None
397+
385398
async def print_places(self):
386399
"""Print out the places"""
387400
if self.args.sort_last_changed:
@@ -524,8 +537,13 @@ def get_acquired_place(self, place=None):
524537
async def print_place(self):
525538
"""Print out the current place and related resources"""
526539
place = self.get_place()
540+
reservation = None
541+
if place.reservation:
542+
reservation = await self._get_reservation_by_token(place.reservation)
543+
527544
print(f"Place '{place.name}':")
528-
place.show(level=1)
545+
place.show(level=1, reservation=reservation)
546+
529547
if place.acquired:
530548
for resource_path in place.acquired_resources:
531549
(exporter, group_name, cls, resource_name) = resource_path
@@ -722,6 +740,20 @@ async def acquire(self):
722740
raise errors[0]
723741
raise ErrorGroup("Multiple errors occurred during acquire", errors)
724742

743+
async def lease(self):
744+
errors = []
745+
places = self.get_place_names_from_env() if self.env else [self.args.place]
746+
for place in places:
747+
try:
748+
await self._lease_place(place)
749+
except Error as e:
750+
errors.append(e)
751+
752+
if errors:
753+
if len(errors) == 1:
754+
raise errors[0]
755+
raise ErrorGroup("Multiple errors occurred during lease", errors)
756+
725757
async def _acquire_place(self, place):
726758
"""Acquire a place, marking it unavailable for other clients"""
727759
place = self.get_place(place)
@@ -768,6 +800,55 @@ async def _acquire_place(self, place):
768800

769801
raise ServerError(e.details())
770802

803+
async def _lease_place(self, place):
804+
"""Lease a place using a reservation"""
805+
place = self.get_place(place)
806+
807+
if place.acquired:
808+
host, user = place.acquired.split("/")
809+
allowhelp = f"'labgrid-client -p {place.name} allow {self.gethostname()}/{self.getuser()}' on {host}."
810+
811+
if self.getuser() != user:
812+
raise UserError(
813+
f"Place {place.name} is already acquired by {place.acquired}. "
814+
f"To work simultaneously, {user} can execute {allowhelp}"
815+
)
816+
817+
if self.gethostname() == host:
818+
raise UserError(f"You have already acquired place {place.name}.")
819+
820+
raise UserError(
821+
f"You have already acquired place {place.name} on {host}. To work simultaneously, execute {allowhelp}"
822+
)
823+
824+
if not self.args.allow_unmatched:
825+
self.check_matches(place)
826+
827+
request = labgrid_coordinator_pb2.LeasePlaceRequest(placename=place.name)
828+
829+
try:
830+
await self.stub.LeasePlace(request)
831+
await self.sync_with_coordinator()
832+
print(f"leased place {place.name}")
833+
except grpc.aio.AioRpcError as e:
834+
# check potential failure causes
835+
for exporter, groups in sorted(self.resources.items()):
836+
for group_name, group in sorted(groups.items()):
837+
for resource_name, resource in sorted(group.items()):
838+
resource_path = (exporter, group_name, resource.cls, resource_name)
839+
if not resource.acquired:
840+
continue
841+
match = place.getmatch(resource_path)
842+
if match is None:
843+
continue
844+
name = resource_name
845+
if match.rename:
846+
name = match.rename
847+
print(
848+
f"Matching resource '{name}' ({exporter}/{group_name}/{resource.cls}/{resource_name}) already acquired by place '{resource.acquired}'"
849+
) # pylint: disable=line-too-long
850+
raise ServerError(e.details())
851+
771852
async def release(self):
772853
errors = []
773854
places = self.get_place_names_from_env() if self.env else [self.args.place]
@@ -1591,16 +1672,25 @@ async def cancel_reservation(self):
15911672
except grpc.aio.AioRpcError as e:
15921673
raise ServerError(e.details())
15931674

1594-
async def _wait_reservation(self, token: str, verbose=True):
1595-
while True:
1596-
request = labgrid_coordinator_pb2.PollReservationRequest(token=token)
1675+
async def _extend_lease(self, token: str) -> Reservation:
1676+
request = labgrid_coordinator_pb2.ExtendLeaseRequest(token=token)
1677+
try:
1678+
response = await self.stub.ExtendLease(request)
1679+
except grpc.aio.AioRpcError as e:
1680+
raise ServerError(e.details())
1681+
return Reservation.from_pb2(response.reservation)
15971682

1598-
try:
1599-
response: labgrid_coordinator_pb2.PollReservationResponse = await self.stub.PollReservation(request)
1600-
except grpc.aio.AioRpcError as e:
1601-
raise ServerError(e.details())
1683+
async def _poll_reservation(self, token: str) -> Reservation:
1684+
request = labgrid_coordinator_pb2.PollReservationRequest(token=token)
1685+
try:
1686+
response: labgrid_coordinator_pb2.PollReservationResponse = await self.stub.PollReservation(request)
1687+
except grpc.aio.AioRpcError as e:
1688+
raise ServerError(e.details())
1689+
return Reservation.from_pb2(response.reservation)
16021690

1603-
res = Reservation.from_pb2(response.reservation)
1691+
async def _wait_reservation(self, token: str, verbose=True):
1692+
while True:
1693+
res = await self._poll_reservation(token)
16041694
if verbose:
16051695
res.show()
16061696
if res.state is ReservationState.waiting:
@@ -1612,6 +1702,37 @@ async def wait_reservation(self):
16121702
token = self.args.token
16131703
await self._wait_reservation(token)
16141704

1705+
async def _get_lease_config(self):
1706+
request = labgrid_coordinator_pb2.GetLeaseConfigRequest()
1707+
try:
1708+
response = await self.stub.GetLeaseConfig(request)
1709+
except grpc.aio.AioRpcError as e:
1710+
raise ServerError(e.details()) from e
1711+
return response
1712+
1713+
async def extend_reservation(self):
1714+
token = self.args.token
1715+
1716+
if not self.args.keepalive:
1717+
res = await self._extend_lease(token)
1718+
if not self.args.quiet:
1719+
print(f"extended lease {res.token} until {datetime.fromtimestamp(res.timeout)}")
1720+
return
1721+
1722+
lease_config = await self._get_lease_config()
1723+
interval = max(1.0, lease_config.default_extend_duration / 2.0)
1724+
1725+
while True:
1726+
res = await self._extend_lease(token)
1727+
if res.state is ReservationState.expired:
1728+
raise UserError("Reservation is expired; cannot extend lease")
1729+
if not self.args.quiet:
1730+
print(
1731+
f"extended lease {res.token} until {datetime.fromtimestamp(res.timeout)} "
1732+
f"(next keepalive in {interval:.1f}s)"
1733+
)
1734+
await asyncio.sleep(interval)
1735+
16151736
async def print_reservations(self):
16161737
request = labgrid_coordinator_pb2.GetReservationsRequest()
16171738

@@ -1959,6 +2080,12 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg
19592080
)
19602081
subparser.set_defaults(func=ClientSession.acquire)
19612082

2083+
subparser = subparsers.add_parser("lease", help="lease a place (time-limited, requires extension)")
2084+
subparser.add_argument(
2085+
"--allow-unmatched", action="store_true", help="allow missing resources for matches when locking the place"
2086+
)
2087+
subparser.set_defaults(func=ClientSession.lease)
2088+
19622089
subparser = subparsers.add_parser("release", aliases=("unlock",), help="release a place")
19632090
subparser.add_argument(
19642091
"-k", "--kick", action="store_true", help="release a place even if it is acquired by a different user"
@@ -2217,6 +2344,21 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg
22172344
subparser.add_argument("token", type=str, nargs="?")
22182345
subparser.set_defaults(func=ClientSession.wait_reservation)
22192346

2347+
subparser = subparsers.add_parser("extend", help="extend a lease by the coordinator default duration")
2348+
subparser.add_argument("token", type=str, nargs="?")
2349+
subparser.add_argument(
2350+
"--keepalive",
2351+
action="store_true",
2352+
help="keep extending the lease automatically using coordinator policy",
2353+
)
2354+
subparser.add_argument(
2355+
"-q",
2356+
"--quiet",
2357+
action="store_true",
2358+
help="do not print lease status on each extension",
2359+
)
2360+
subparser.set_defaults(func=ClientSession.extend_reservation)
2361+
22202362
subparser = subparsers.add_parser("reservations", help="list current reservations")
22212363
subparser.set_defaults(func=ClientSession.print_reservations)
22222364

@@ -2281,7 +2423,7 @@ def main():
22812423
if args.initial_state is None:
22822424
args.initial_state = initial_state
22832425

2284-
if args.command in ["cancel-reservation", "wait"] and args.token is None:
2426+
if args.command in ["cancel-reservation", "wait", "extend"] and args.token is None:
22852427
if token:
22862428
args.token = token
22872429
else:

0 commit comments

Comments
 (0)