From 4f15b27c8945f84ff5cbe6c7064a4084f2afacb8 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 4 May 2026 06:57:10 -0600 Subject: [PATCH 1/2] coordinator: Serve env file via new GetEnvironment RPC The lab admin currently has to distribute the env file to every client out of band - via a shared filesystem, git checkout, scp etc. This is awkward duplication for casual clients who just want to talk to a few boards from a remote machine. It also makes it harder to scale to larger labs with a lot of clients. For some labs, particularly smaller ones, the environment is very tied to the client, with each client having its own special file. For other labs, such as larger labs where changes are few, the environment is the same for each client. Add a new GetEnvironment RPC that returns the env file's text content, and a coordinator --environment flag pointing at the file to serve. The file is read fresh on each request, so admins can edit it in place and clients pick up the new version on their next invocation. The default (no --environment) is unchanged: GetEnvironment returns an empty string and clients keep loading env from a local file as before. Signed-off-by: Simon Glass --- doc/man/coordinator.rst | 14 +++++++ labgrid/remote/coordinator.py | 27 +++++++++++-- .../generated/labgrid_coordinator_pb2.py | 10 +++-- .../generated/labgrid_coordinator_pb2.pyi | 10 +++++ .../generated/labgrid_coordinator_pb2_grpc.py | 33 ++++++++++++++++ .../remote/proto/labgrid-coordinator.proto | 12 ++++++ man/labgrid-coordinator.1 | 14 +++++++ tests/conftest.py | 18 ++++++++- tests/test_coordinator.py | 39 +++++++++++++++++++ 9 files changed, 168 insertions(+), 9 deletions(-) diff --git a/doc/man/coordinator.rst b/doc/man/coordinator.rst index c89eccad4..2dd9f755b 100644 --- a/doc/man/coordinator.rst +++ b/doc/man/coordinator.rst @@ -25,9 +25,23 @@ OPTIONS display command line help -l ADDRESS, --listen ADDRESS make coordinator listen on host and port +-e FILE, --environment FILE + serve the given YAML env file to clients via the ``GetEnvironment`` RPC. + Clients opt in by setting ``LG_ENV=coordinator:`` (see + ``labgrid-client``\(1)) -d, --debug enable debug mode +-e / --environment +~~~~~~~~~~~~~~~~~~ +When this option is set the coordinator reads the file fresh on each +``GetEnvironment`` request and returns its contents verbatim to the client. +This means a remote user no longer needs a local copy of the env file - they +only need network access to the coordinator. + +The default (no ``--environment``) is unchanged: ``GetEnvironment`` returns an +empty string and clients keep loading env from a local file as before. + SEE ALSO -------- diff --git a/labgrid/remote/coordinator.py b/labgrid/remote/coordinator.py index a63a21482..87605734f 100644 --- a/labgrid/remote/coordinator.py +++ b/labgrid/remote/coordinator.py @@ -209,11 +209,12 @@ class ExporterError(Exception): class Coordinator(labgrid_coordinator_pb2_grpc.CoordinatorServicer): - def __init__(self) -> None: + def __init__(self, environment_file: str | None = None) -> None: self.places: dict[str, Place] = {} self.reservations = {} self.poll_tasks = [] self.save_scheduled = False + self.environment_file = environment_file self.lock = asyncio.Lock() self.exporters: dict[str, ExporterSession] = {} @@ -1105,8 +1106,17 @@ async def GetReservations(self, request: labgrid_coordinator_pb2.GetReservations reservations = [x.as_pb2() for x in self.reservations.values()] return labgrid_coordinator_pb2.GetReservationsResponse(reservations=reservations) + async def GetEnvironment(self, request: labgrid_coordinator_pb2.GetEnvironmentRequest, context): + if not self.environment_file: + return labgrid_coordinator_pb2.GetEnvironmentResponse(config="") + try: + with open(self.environment_file, encoding="utf-8") as f: + return labgrid_coordinator_pb2.GetEnvironmentResponse(config=f.read()) + except OSError as e: + await context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"cannot read environment file: {e}") + -async def serve(listen, cleanup) -> None: +async def serve(listen, cleanup, environment_file=None) -> None: asyncio.current_task().set_name("coordinator-serve") # It seems since https://github.com/grpc/grpc/pull/34647, the # ping_timeout_ms default of 60 seconds overrides keepalive_timeout_ms, @@ -1124,7 +1134,7 @@ async def serve(listen, cleanup) -> None: server = grpc.aio.server( options=channel_options, ) - coordinator = Coordinator() + coordinator = Coordinator(environment_file=environment_file) labgrid_coordinator_pb2_grpc.add_CoordinatorServicer_to_server(coordinator, server) # enable reflection for use with grpcurl reflection.enable_server_reflection( @@ -1172,6 +1182,15 @@ def main(): default="[::]:20408", help="coordinator listening host and port", ) + parser.add_argument( + "-e", + "--environment", + metavar="FILE", + type=str, + default=None, + help="path to a YAML environment file to serve to clients via " + "GetEnvironment (LG_ENV=coordinator: on the client side)", + ) parser.add_argument("-d", "--debug", action="store_true", default=False, help="enable debug mode") parser.add_argument("--pystuck", action="store_true", help="enable pystuck") parser.add_argument( @@ -1201,7 +1220,7 @@ def main(): cleanup = [] loop.set_debug(True) try: - loop.run_until_complete(serve(args.listen, cleanup)) + loop.run_until_complete(serve(args.listen, cleanup, environment_file=args.environment)) finally: if cleanup: loop.run_until_complete(*cleanup) diff --git a/labgrid/remote/generated/labgrid_coordinator_pb2.py b/labgrid/remote/generated/labgrid_coordinator_pb2.py index 37652bff7..fff357073 100644 --- a/labgrid/remote/generated/labgrid_coordinator_pb2.py +++ b/labgrid/remote/generated/labgrid_coordinator_pb2.py @@ -14,7 +14,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19labgrid-coordinator.proto\x12\x07labgrid\"\x8a\x01\n\x0f\x43lientInMessage\x12\x1d\n\x04sync\x18\x01 \x01(\x0b\x32\r.labgrid.SyncH\x00\x12\'\n\x07startup\x18\x02 \x01(\x0b\x32\x14.labgrid.StartupDoneH\x00\x12\'\n\tsubscribe\x18\x03 \x01(\x0b\x32\x12.labgrid.SubscribeH\x00\x42\x06\n\x04kind\"\x12\n\x04Sync\x12\n\n\x02id\x18\x01 \x01(\x04\",\n\x0bStartupDone\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\"r\n\tSubscribe\x12\x1b\n\x0eis_unsubscribe\x18\x01 \x01(\x08H\x01\x88\x01\x01\x12\x14\n\nall_places\x18\x02 \x01(\x08H\x00\x12\x17\n\rall_resources\x18\x03 \x01(\x08H\x00\x42\x06\n\x04kindB\x11\n\x0f_is_unsubscribe\"g\n\x10\x43lientOutMessage\x12 \n\x04sync\x18\x01 \x01(\x0b\x32\r.labgrid.SyncH\x00\x88\x01\x01\x12(\n\x07updates\x18\x02 \x03(\x0b\x32\x17.labgrid.UpdateResponseB\x07\n\x05_sync\"\xa5\x01\n\x0eUpdateResponse\x12%\n\x08resource\x18\x01 \x01(\x0b\x32\x11.labgrid.ResourceH\x00\x12.\n\x0c\x64\x65l_resource\x18\x02 \x01(\x0b\x32\x16.labgrid.Resource.PathH\x00\x12\x1f\n\x05place\x18\x03 \x01(\x0b\x32\x0e.labgrid.PlaceH\x00\x12\x13\n\tdel_place\x18\x04 \x01(\tH\x00\x42\x06\n\x04kind\"\x9a\x01\n\x11\x45xporterInMessage\x12%\n\x08resource\x18\x01 \x01(\x0b\x32\x11.labgrid.ResourceH\x00\x12\'\n\x07startup\x18\x02 \x01(\x0b\x32\x14.labgrid.StartupDoneH\x00\x12-\n\x08response\x18\x03 \x01(\x0b\x32\x19.labgrid.ExporterResponseH\x00\x42\x06\n\x04kind\"\x9e\x03\n\x08Resource\x12$\n\x04path\x18\x01 \x01(\x0b\x32\x16.labgrid.Resource.Path\x12\x0b\n\x03\x63ls\x18\x02 \x01(\t\x12-\n\x06params\x18\x03 \x03(\x0b\x32\x1d.labgrid.Resource.ParamsEntry\x12+\n\x05\x65xtra\x18\x04 \x03(\x0b\x32\x1c.labgrid.Resource.ExtraEntry\x12\x10\n\x08\x61\x63quired\x18\x05 \x01(\t\x12\r\n\x05\x61vail\x18\x06 \x01(\x08\x1a_\n\x04Path\x12\x1a\n\rexporter_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x12\n\ngroup_name\x18\x02 \x01(\t\x12\x15\n\rresource_name\x18\x03 \x01(\tB\x10\n\x0e_exporter_name\x1a@\n\x0bParamsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.labgrid.MapValue:\x02\x38\x01\x1a?\n\nExtraEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.labgrid.MapValue:\x02\x38\x01\"\x82\x01\n\x08MapValue\x12\x14\n\nbool_value\x18\x01 \x01(\x08H\x00\x12\x13\n\tint_value\x18\x02 \x01(\x03H\x00\x12\x14\n\nuint_value\x18\x03 \x01(\x04H\x00\x12\x15\n\x0b\x66loat_value\x18\x04 \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x05 \x01(\tH\x00\x42\x06\n\x04kind\"C\n\x10\x45xporterResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x06reason\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_reason\"\x18\n\x05Hello\x12\x0f\n\x07version\x18\x01 \x01(\t\"\x82\x01\n\x12\x45xporterOutMessage\x12\x1f\n\x05hello\x18\x01 \x01(\x0b\x32\x0e.labgrid.HelloH\x00\x12\x43\n\x14set_acquired_request\x18\x02 \x01(\x0b\x32#.labgrid.ExporterSetAcquiredRequestH\x00\x42\x06\n\x04kind\"o\n\x1a\x45xporterSetAcquiredRequest\x12\x12\n\ngroup_name\x18\x01 \x01(\t\x12\x15\n\rresource_name\x18\x02 \x01(\t\x12\x17\n\nplace_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\r\n\x0b_place_name\"\x1f\n\x0f\x41\x64\x64PlaceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x12\n\x10\x41\x64\x64PlaceResponse\"\"\n\x12\x44\x65letePlaceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x15\n\x13\x44\x65letePlaceResponse\"\x12\n\x10GetPlacesRequest\"3\n\x11GetPlacesResponse\x12\x1e\n\x06places\x18\x01 \x03(\x0b\x32\x0e.labgrid.Place\"\xd2\x02\n\x05Place\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07\x61liases\x18\x02 \x03(\t\x12\x0f\n\x07\x63omment\x18\x03 \x01(\t\x12&\n\x04tags\x18\x04 \x03(\x0b\x32\x18.labgrid.Place.TagsEntry\x12\'\n\x07matches\x18\x05 \x03(\x0b\x32\x16.labgrid.ResourceMatch\x12\x15\n\x08\x61\x63quired\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x1a\n\x12\x61\x63quired_resources\x18\x07 \x03(\t\x12\x0f\n\x07\x61llowed\x18\x08 \x03(\t\x12\x0f\n\x07\x63reated\x18\t \x01(\x01\x12\x0f\n\x07\x63hanged\x18\n \x01(\x01\x12\x18\n\x0breservation\x18\x0b \x01(\tH\x01\x88\x01\x01\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x0b\n\t_acquiredB\x0e\n\x0c_reservation\"y\n\rResourceMatch\x12\x10\n\x08\x65xporter\x18\x01 \x01(\t\x12\r\n\x05group\x18\x02 \x01(\t\x12\x0b\n\x03\x63ls\x18\x03 \x01(\t\x12\x11\n\x04name\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06rename\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nameB\t\n\x07_rename\"8\n\x14\x41\x64\x64PlaceAliasRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\r\n\x05\x61lias\x18\x02 \x01(\t\"\x17\n\x15\x41\x64\x64PlaceAliasResponse\";\n\x17\x44\x65letePlaceAliasRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\r\n\x05\x61lias\x18\x02 \x01(\t\"\x1a\n\x18\x44\x65letePlaceAliasResponse\"\x8b\x01\n\x13SetPlaceTagsRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x34\n\x04tags\x18\x02 \x03(\x0b\x32&.labgrid.SetPlaceTagsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x16\n\x14SetPlaceTagsResponse\"<\n\x16SetPlaceCommentRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x0f\n\x07\x63omment\x18\x02 \x01(\t\"\x19\n\x17SetPlaceCommentResponse\"Z\n\x14\x41\x64\x64PlaceMatchRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x0f\n\x07pattern\x18\x02 \x01(\t\x12\x13\n\x06rename\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_rename\"\x17\n\x15\x41\x64\x64PlaceMatchResponse\"]\n\x17\x44\x65letePlaceMatchRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x0f\n\x07pattern\x18\x02 \x01(\t\x12\x13\n\x06rename\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_rename\"\x1a\n\x18\x44\x65letePlaceMatchResponse\"(\n\x13\x41\x63quirePlaceRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\"\x16\n\x14\x41\x63quirePlaceResponse\"L\n\x13ReleasePlaceRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x15\n\x08\x66romuser\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0b\n\t_fromuser\"\x16\n\x14ReleasePlaceResponse\"4\n\x11\x41llowPlaceRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x0c\n\x04user\x18\x02 \x01(\t\"\x14\n\x12\x41llowPlaceResponse\"\xb6\x01\n\x18\x43reateReservationRequest\x12?\n\x07\x66ilters\x18\x01 \x03(\x0b\x32..labgrid.CreateReservationRequest.FiltersEntry\x12\x0c\n\x04prio\x18\x02 \x01(\x01\x1aK\n\x0c\x46iltersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0b\x32\x1b.labgrid.Reservation.Filter:\x02\x38\x01\"F\n\x19\x43reateReservationResponse\x12)\n\x0breservation\x18\x01 \x01(\x0b\x32\x14.labgrid.Reservation\"\xcd\x03\n\x0bReservation\x12\r\n\x05owner\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\r\n\x05state\x18\x03 \x01(\x05\x12\x0c\n\x04prio\x18\x04 \x01(\x01\x12\x32\n\x07\x66ilters\x18\x05 \x03(\x0b\x32!.labgrid.Reservation.FiltersEntry\x12:\n\x0b\x61llocations\x18\x06 \x03(\x0b\x32%.labgrid.Reservation.AllocationsEntry\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x01\x12\x0f\n\x07timeout\x18\x08 \x01(\x01\x1ap\n\x06\x46ilter\x12\x37\n\x06\x66ilter\x18\x01 \x03(\x0b\x32\'.labgrid.Reservation.Filter.FilterEntry\x1a-\n\x0b\x46ilterEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1aK\n\x0c\x46iltersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0b\x32\x1b.labgrid.Reservation.Filter:\x02\x38\x01\x1a\x32\n\x10\x41llocationsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\")\n\x18\x43\x61ncelReservationRequest\x12\r\n\x05token\x18\x01 \x01(\t\"\x1b\n\x19\x43\x61ncelReservationResponse\"\'\n\x16PollReservationRequest\x12\r\n\x05token\x18\x01 \x01(\t\"D\n\x17PollReservationResponse\x12)\n\x0breservation\x18\x01 \x01(\x0b\x32\x14.labgrid.Reservation\"E\n\x17GetReservationsResponse\x12*\n\x0creservations\x18\x01 \x03(\x0b\x32\x14.labgrid.Reservation\"\x18\n\x16GetReservationsRequest2\xd2\x0b\n\x0b\x43oordinator\x12I\n\x0c\x43lientStream\x12\x18.labgrid.ClientInMessage\x1a\x19.labgrid.ClientOutMessage\"\x00(\x01\x30\x01\x12O\n\x0e\x45xporterStream\x12\x1a.labgrid.ExporterInMessage\x1a\x1b.labgrid.ExporterOutMessage\"\x00(\x01\x30\x01\x12\x41\n\x08\x41\x64\x64Place\x12\x18.labgrid.AddPlaceRequest\x1a\x19.labgrid.AddPlaceResponse\"\x00\x12J\n\x0b\x44\x65letePlace\x12\x1b.labgrid.DeletePlaceRequest\x1a\x1c.labgrid.DeletePlaceResponse\"\x00\x12\x44\n\tGetPlaces\x12\x19.labgrid.GetPlacesRequest\x1a\x1a.labgrid.GetPlacesResponse\"\x00\x12P\n\rAddPlaceAlias\x12\x1d.labgrid.AddPlaceAliasRequest\x1a\x1e.labgrid.AddPlaceAliasResponse\"\x00\x12Y\n\x10\x44\x65letePlaceAlias\x12 .labgrid.DeletePlaceAliasRequest\x1a!.labgrid.DeletePlaceAliasResponse\"\x00\x12M\n\x0cSetPlaceTags\x12\x1c.labgrid.SetPlaceTagsRequest\x1a\x1d.labgrid.SetPlaceTagsResponse\"\x00\x12V\n\x0fSetPlaceComment\x12\x1f.labgrid.SetPlaceCommentRequest\x1a .labgrid.SetPlaceCommentResponse\"\x00\x12P\n\rAddPlaceMatch\x12\x1d.labgrid.AddPlaceMatchRequest\x1a\x1e.labgrid.AddPlaceMatchResponse\"\x00\x12Y\n\x10\x44\x65letePlaceMatch\x12 .labgrid.DeletePlaceMatchRequest\x1a!.labgrid.DeletePlaceMatchResponse\"\x00\x12M\n\x0c\x41\x63quirePlace\x12\x1c.labgrid.AcquirePlaceRequest\x1a\x1d.labgrid.AcquirePlaceResponse\"\x00\x12M\n\x0cReleasePlace\x12\x1c.labgrid.ReleasePlaceRequest\x1a\x1d.labgrid.ReleasePlaceResponse\"\x00\x12G\n\nAllowPlace\x12\x1a.labgrid.AllowPlaceRequest\x1a\x1b.labgrid.AllowPlaceResponse\"\x00\x12\\\n\x11\x43reateReservation\x12!.labgrid.CreateReservationRequest\x1a\".labgrid.CreateReservationResponse\"\x00\x12\\\n\x11\x43\x61ncelReservation\x12!.labgrid.CancelReservationRequest\x1a\".labgrid.CancelReservationResponse\"\x00\x12V\n\x0fPollReservation\x12\x1f.labgrid.PollReservationRequest\x1a .labgrid.PollReservationResponse\"\x00\x12V\n\x0fGetReservations\x12\x1f.labgrid.GetReservationsRequest\x1a .labgrid.GetReservationsResponse\"\x00\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19labgrid-coordinator.proto\x12\x07labgrid\"\x8a\x01\n\x0f\x43lientInMessage\x12\x1d\n\x04sync\x18\x01 \x01(\x0b\x32\r.labgrid.SyncH\x00\x12\'\n\x07startup\x18\x02 \x01(\x0b\x32\x14.labgrid.StartupDoneH\x00\x12\'\n\tsubscribe\x18\x03 \x01(\x0b\x32\x12.labgrid.SubscribeH\x00\x42\x06\n\x04kind\"\x12\n\x04Sync\x12\n\n\x02id\x18\x01 \x01(\x04\",\n\x0bStartupDone\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\"r\n\tSubscribe\x12\x1b\n\x0eis_unsubscribe\x18\x01 \x01(\x08H\x01\x88\x01\x01\x12\x14\n\nall_places\x18\x02 \x01(\x08H\x00\x12\x17\n\rall_resources\x18\x03 \x01(\x08H\x00\x42\x06\n\x04kindB\x11\n\x0f_is_unsubscribe\"g\n\x10\x43lientOutMessage\x12 \n\x04sync\x18\x01 \x01(\x0b\x32\r.labgrid.SyncH\x00\x88\x01\x01\x12(\n\x07updates\x18\x02 \x03(\x0b\x32\x17.labgrid.UpdateResponseB\x07\n\x05_sync\"\xa5\x01\n\x0eUpdateResponse\x12%\n\x08resource\x18\x01 \x01(\x0b\x32\x11.labgrid.ResourceH\x00\x12.\n\x0c\x64\x65l_resource\x18\x02 \x01(\x0b\x32\x16.labgrid.Resource.PathH\x00\x12\x1f\n\x05place\x18\x03 \x01(\x0b\x32\x0e.labgrid.PlaceH\x00\x12\x13\n\tdel_place\x18\x04 \x01(\tH\x00\x42\x06\n\x04kind\"\x9a\x01\n\x11\x45xporterInMessage\x12%\n\x08resource\x18\x01 \x01(\x0b\x32\x11.labgrid.ResourceH\x00\x12\'\n\x07startup\x18\x02 \x01(\x0b\x32\x14.labgrid.StartupDoneH\x00\x12-\n\x08response\x18\x03 \x01(\x0b\x32\x19.labgrid.ExporterResponseH\x00\x42\x06\n\x04kind\"\x9e\x03\n\x08Resource\x12$\n\x04path\x18\x01 \x01(\x0b\x32\x16.labgrid.Resource.Path\x12\x0b\n\x03\x63ls\x18\x02 \x01(\t\x12-\n\x06params\x18\x03 \x03(\x0b\x32\x1d.labgrid.Resource.ParamsEntry\x12+\n\x05\x65xtra\x18\x04 \x03(\x0b\x32\x1c.labgrid.Resource.ExtraEntry\x12\x10\n\x08\x61\x63quired\x18\x05 \x01(\t\x12\r\n\x05\x61vail\x18\x06 \x01(\x08\x1a_\n\x04Path\x12\x1a\n\rexporter_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x12\n\ngroup_name\x18\x02 \x01(\t\x12\x15\n\rresource_name\x18\x03 \x01(\tB\x10\n\x0e_exporter_name\x1a@\n\x0bParamsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.labgrid.MapValue:\x02\x38\x01\x1a?\n\nExtraEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.labgrid.MapValue:\x02\x38\x01\"\x82\x01\n\x08MapValue\x12\x14\n\nbool_value\x18\x01 \x01(\x08H\x00\x12\x13\n\tint_value\x18\x02 \x01(\x03H\x00\x12\x14\n\nuint_value\x18\x03 \x01(\x04H\x00\x12\x15\n\x0b\x66loat_value\x18\x04 \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x05 \x01(\tH\x00\x42\x06\n\x04kind\"C\n\x10\x45xporterResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x06reason\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_reason\"\x18\n\x05Hello\x12\x0f\n\x07version\x18\x01 \x01(\t\"\x82\x01\n\x12\x45xporterOutMessage\x12\x1f\n\x05hello\x18\x01 \x01(\x0b\x32\x0e.labgrid.HelloH\x00\x12\x43\n\x14set_acquired_request\x18\x02 \x01(\x0b\x32#.labgrid.ExporterSetAcquiredRequestH\x00\x42\x06\n\x04kind\"o\n\x1a\x45xporterSetAcquiredRequest\x12\x12\n\ngroup_name\x18\x01 \x01(\t\x12\x15\n\rresource_name\x18\x02 \x01(\t\x12\x17\n\nplace_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\r\n\x0b_place_name\"\x1f\n\x0f\x41\x64\x64PlaceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x12\n\x10\x41\x64\x64PlaceResponse\"\"\n\x12\x44\x65letePlaceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x15\n\x13\x44\x65letePlaceResponse\"\x12\n\x10GetPlacesRequest\"3\n\x11GetPlacesResponse\x12\x1e\n\x06places\x18\x01 \x03(\x0b\x32\x0e.labgrid.Place\"\xd2\x02\n\x05Place\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07\x61liases\x18\x02 \x03(\t\x12\x0f\n\x07\x63omment\x18\x03 \x01(\t\x12&\n\x04tags\x18\x04 \x03(\x0b\x32\x18.labgrid.Place.TagsEntry\x12\'\n\x07matches\x18\x05 \x03(\x0b\x32\x16.labgrid.ResourceMatch\x12\x15\n\x08\x61\x63quired\x18\x06 \x01(\tH\x00\x88\x01\x01\x12\x1a\n\x12\x61\x63quired_resources\x18\x07 \x03(\t\x12\x0f\n\x07\x61llowed\x18\x08 \x03(\t\x12\x0f\n\x07\x63reated\x18\t \x01(\x01\x12\x0f\n\x07\x63hanged\x18\n \x01(\x01\x12\x18\n\x0breservation\x18\x0b \x01(\tH\x01\x88\x01\x01\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x0b\n\t_acquiredB\x0e\n\x0c_reservation\"y\n\rResourceMatch\x12\x10\n\x08\x65xporter\x18\x01 \x01(\t\x12\r\n\x05group\x18\x02 \x01(\t\x12\x0b\n\x03\x63ls\x18\x03 \x01(\t\x12\x11\n\x04name\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06rename\x18\x05 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nameB\t\n\x07_rename\"8\n\x14\x41\x64\x64PlaceAliasRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\r\n\x05\x61lias\x18\x02 \x01(\t\"\x17\n\x15\x41\x64\x64PlaceAliasResponse\";\n\x17\x44\x65letePlaceAliasRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\r\n\x05\x61lias\x18\x02 \x01(\t\"\x1a\n\x18\x44\x65letePlaceAliasResponse\"\x8b\x01\n\x13SetPlaceTagsRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x34\n\x04tags\x18\x02 \x03(\x0b\x32&.labgrid.SetPlaceTagsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x16\n\x14SetPlaceTagsResponse\"<\n\x16SetPlaceCommentRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x0f\n\x07\x63omment\x18\x02 \x01(\t\"\x19\n\x17SetPlaceCommentResponse\"Z\n\x14\x41\x64\x64PlaceMatchRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x0f\n\x07pattern\x18\x02 \x01(\t\x12\x13\n\x06rename\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_rename\"\x17\n\x15\x41\x64\x64PlaceMatchResponse\"]\n\x17\x44\x65letePlaceMatchRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x0f\n\x07pattern\x18\x02 \x01(\t\x12\x13\n\x06rename\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_rename\"\x1a\n\x18\x44\x65letePlaceMatchResponse\"(\n\x13\x41\x63quirePlaceRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\"\x16\n\x14\x41\x63quirePlaceResponse\"L\n\x13ReleasePlaceRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x15\n\x08\x66romuser\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0b\n\t_fromuser\"\x16\n\x14ReleasePlaceResponse\"4\n\x11\x41llowPlaceRequest\x12\x11\n\tplacename\x18\x01 \x01(\t\x12\x0c\n\x04user\x18\x02 \x01(\t\"\x14\n\x12\x41llowPlaceResponse\"\xb6\x01\n\x18\x43reateReservationRequest\x12?\n\x07\x66ilters\x18\x01 \x03(\x0b\x32..labgrid.CreateReservationRequest.FiltersEntry\x12\x0c\n\x04prio\x18\x02 \x01(\x01\x1aK\n\x0c\x46iltersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0b\x32\x1b.labgrid.Reservation.Filter:\x02\x38\x01\"F\n\x19\x43reateReservationResponse\x12)\n\x0breservation\x18\x01 \x01(\x0b\x32\x14.labgrid.Reservation\"\xcd\x03\n\x0bReservation\x12\r\n\x05owner\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\r\n\x05state\x18\x03 \x01(\x05\x12\x0c\n\x04prio\x18\x04 \x01(\x01\x12\x32\n\x07\x66ilters\x18\x05 \x03(\x0b\x32!.labgrid.Reservation.FiltersEntry\x12:\n\x0b\x61llocations\x18\x06 \x03(\x0b\x32%.labgrid.Reservation.AllocationsEntry\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x01\x12\x0f\n\x07timeout\x18\x08 \x01(\x01\x1ap\n\x06\x46ilter\x12\x37\n\x06\x66ilter\x18\x01 \x03(\x0b\x32\'.labgrid.Reservation.Filter.FilterEntry\x1a-\n\x0b\x46ilterEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1aK\n\x0c\x46iltersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0b\x32\x1b.labgrid.Reservation.Filter:\x02\x38\x01\x1a\x32\n\x10\x41llocationsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\")\n\x18\x43\x61ncelReservationRequest\x12\r\n\x05token\x18\x01 \x01(\t\"\x1b\n\x19\x43\x61ncelReservationResponse\"\'\n\x16PollReservationRequest\x12\r\n\x05token\x18\x01 \x01(\t\"D\n\x17PollReservationResponse\x12)\n\x0breservation\x18\x01 \x01(\x0b\x32\x14.labgrid.Reservation\"E\n\x17GetReservationsResponse\x12*\n\x0creservations\x18\x01 \x03(\x0b\x32\x14.labgrid.Reservation\"\x18\n\x16GetReservationsRequest\"\x17\n\x15GetEnvironmentRequest\"(\n\x16GetEnvironmentResponse\x12\x0e\n\x06\x63onfig\x18\x01 \x01(\t2\xa7\x0c\n\x0b\x43oordinator\x12I\n\x0c\x43lientStream\x12\x18.labgrid.ClientInMessage\x1a\x19.labgrid.ClientOutMessage\"\x00(\x01\x30\x01\x12O\n\x0e\x45xporterStream\x12\x1a.labgrid.ExporterInMessage\x1a\x1b.labgrid.ExporterOutMessage\"\x00(\x01\x30\x01\x12\x41\n\x08\x41\x64\x64Place\x12\x18.labgrid.AddPlaceRequest\x1a\x19.labgrid.AddPlaceResponse\"\x00\x12J\n\x0b\x44\x65letePlace\x12\x1b.labgrid.DeletePlaceRequest\x1a\x1c.labgrid.DeletePlaceResponse\"\x00\x12\x44\n\tGetPlaces\x12\x19.labgrid.GetPlacesRequest\x1a\x1a.labgrid.GetPlacesResponse\"\x00\x12P\n\rAddPlaceAlias\x12\x1d.labgrid.AddPlaceAliasRequest\x1a\x1e.labgrid.AddPlaceAliasResponse\"\x00\x12Y\n\x10\x44\x65letePlaceAlias\x12 .labgrid.DeletePlaceAliasRequest\x1a!.labgrid.DeletePlaceAliasResponse\"\x00\x12M\n\x0cSetPlaceTags\x12\x1c.labgrid.SetPlaceTagsRequest\x1a\x1d.labgrid.SetPlaceTagsResponse\"\x00\x12V\n\x0fSetPlaceComment\x12\x1f.labgrid.SetPlaceCommentRequest\x1a .labgrid.SetPlaceCommentResponse\"\x00\x12P\n\rAddPlaceMatch\x12\x1d.labgrid.AddPlaceMatchRequest\x1a\x1e.labgrid.AddPlaceMatchResponse\"\x00\x12Y\n\x10\x44\x65letePlaceMatch\x12 .labgrid.DeletePlaceMatchRequest\x1a!.labgrid.DeletePlaceMatchResponse\"\x00\x12M\n\x0c\x41\x63quirePlace\x12\x1c.labgrid.AcquirePlaceRequest\x1a\x1d.labgrid.AcquirePlaceResponse\"\x00\x12M\n\x0cReleasePlace\x12\x1c.labgrid.ReleasePlaceRequest\x1a\x1d.labgrid.ReleasePlaceResponse\"\x00\x12G\n\nAllowPlace\x12\x1a.labgrid.AllowPlaceRequest\x1a\x1b.labgrid.AllowPlaceResponse\"\x00\x12\\\n\x11\x43reateReservation\x12!.labgrid.CreateReservationRequest\x1a\".labgrid.CreateReservationResponse\"\x00\x12\\\n\x11\x43\x61ncelReservation\x12!.labgrid.CancelReservationRequest\x1a\".labgrid.CancelReservationResponse\"\x00\x12V\n\x0fPollReservation\x12\x1f.labgrid.PollReservationRequest\x1a .labgrid.PollReservationResponse\"\x00\x12V\n\x0fGetReservations\x12\x1f.labgrid.GetReservationsRequest\x1a .labgrid.GetReservationsResponse\"\x00\x12S\n\x0eGetEnvironment\x12\x1e.labgrid.GetEnvironmentRequest\x1a\x1f.labgrid.GetEnvironmentResponse\"\x00\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -153,6 +153,10 @@ _globals['_GETRESERVATIONSRESPONSE']._serialized_end=4215 _globals['_GETRESERVATIONSREQUEST']._serialized_start=4217 _globals['_GETRESERVATIONSREQUEST']._serialized_end=4241 - _globals['_COORDINATOR']._serialized_start=4244 - _globals['_COORDINATOR']._serialized_end=5734 + _globals['_GETENVIRONMENTREQUEST']._serialized_start=4243 + _globals['_GETENVIRONMENTREQUEST']._serialized_end=4266 + _globals['_GETENVIRONMENTRESPONSE']._serialized_start=4268 + _globals['_GETENVIRONMENTRESPONSE']._serialized_end=4308 + _globals['_COORDINATOR']._serialized_start=4311 + _globals['_COORDINATOR']._serialized_end=5886 # @@protoc_insertion_point(module_scope) diff --git a/labgrid/remote/generated/labgrid_coordinator_pb2.pyi b/labgrid/remote/generated/labgrid_coordinator_pb2.pyi index 366f4e438..5768d022a 100644 --- a/labgrid/remote/generated/labgrid_coordinator_pb2.pyi +++ b/labgrid/remote/generated/labgrid_coordinator_pb2.pyi @@ -446,3 +446,13 @@ class GetReservationsResponse(_message.Message): class GetReservationsRequest(_message.Message): __slots__ = () def __init__(self) -> None: ... + +class GetEnvironmentRequest(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class GetEnvironmentResponse(_message.Message): + __slots__ = ("config",) + CONFIG_FIELD_NUMBER: _ClassVar[int] + config: str + def __init__(self, config: _Optional[str] = ...) -> None: ... diff --git a/labgrid/remote/generated/labgrid_coordinator_pb2_grpc.py b/labgrid/remote/generated/labgrid_coordinator_pb2_grpc.py index debfb24f2..be73f3969 100644 --- a/labgrid/remote/generated/labgrid_coordinator_pb2_grpc.py +++ b/labgrid/remote/generated/labgrid_coordinator_pb2_grpc.py @@ -104,6 +104,11 @@ def __init__(self, channel): request_serializer=labgrid__coordinator__pb2.GetReservationsRequest.SerializeToString, response_deserializer=labgrid__coordinator__pb2.GetReservationsResponse.FromString, ) + self.GetEnvironment = channel.unary_unary( + '/labgrid.Coordinator/GetEnvironment', + request_serializer=labgrid__coordinator__pb2.GetEnvironmentRequest.SerializeToString, + response_deserializer=labgrid__coordinator__pb2.GetEnvironmentResponse.FromString, + ) class CoordinatorServicer(object): @@ -217,6 +222,12 @@ def GetReservations(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def GetEnvironment(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_CoordinatorServicer_to_server(servicer, server): rpc_method_handlers = { @@ -310,6 +321,11 @@ def add_CoordinatorServicer_to_server(servicer, server): request_deserializer=labgrid__coordinator__pb2.GetReservationsRequest.FromString, response_serializer=labgrid__coordinator__pb2.GetReservationsResponse.SerializeToString, ), + 'GetEnvironment': grpc.unary_unary_rpc_method_handler( + servicer.GetEnvironment, + request_deserializer=labgrid__coordinator__pb2.GetEnvironmentRequest.FromString, + response_serializer=labgrid__coordinator__pb2.GetEnvironmentResponse.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'labgrid.Coordinator', rpc_method_handlers) @@ -625,3 +641,20 @@ def GetReservations(request, labgrid__coordinator__pb2.GetReservationsResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def GetEnvironment(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/labgrid.Coordinator/GetEnvironment', + labgrid__coordinator__pb2.GetEnvironmentRequest.SerializeToString, + labgrid__coordinator__pb2.GetEnvironmentResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/labgrid/remote/proto/labgrid-coordinator.proto b/labgrid/remote/proto/labgrid-coordinator.proto index e0585f7e1..471f174be 100644 --- a/labgrid/remote/proto/labgrid-coordinator.proto +++ b/labgrid/remote/proto/labgrid-coordinator.proto @@ -38,6 +38,8 @@ service Coordinator { rpc PollReservation(PollReservationRequest) returns (PollReservationResponse) {} rpc GetReservations(GetReservationsRequest) returns (GetReservationsResponse) {} + + rpc GetEnvironment(GetEnvironmentRequest) returns (GetEnvironmentResponse) {} } message ClientInMessage { @@ -295,3 +297,13 @@ message GetReservationsResponse { message GetReservationsRequest { }; + +message GetEnvironmentRequest { +}; + +message GetEnvironmentResponse { + // Raw text of the lab environment file (typically YAML) curated by + // the lab admin and served to clients. Empty if the coordinator was + // started without a configured environment file. + string config = 1; +}; diff --git a/man/labgrid-coordinator.1 b/man/labgrid-coordinator.1 index e99c30f93..ddb620ef5 100644 --- a/man/labgrid-coordinator.1 +++ b/man/labgrid-coordinator.1 @@ -51,9 +51,23 @@ display command line help .BI \-l \ ADDRESS\fR,\fB \ \-\-listen \ ADDRESS make coordinator listen on host and port .TP +.BI \-e \ FILE\fR,\fB \ \-\-environment \ FILE +serve the given YAML env file to clients via the \fBGetEnvironment\fP RPC. +Clients opt in by setting \fBLG_ENV=coordinator:\fP (see +\fBlabgrid\-client\fP(1)) +.TP .B \-d\fP,\fB \-\-debug enable debug mode .UNINDENT +.SS \-e / \-\-environment +.sp +When this option is set the coordinator reads the file fresh on each +\fBGetEnvironment\fP request and returns its contents verbatim to the client. +This means a remote user no longer needs a local copy of the env file \- they +only need network access to the coordinator. +.sp +The default (no \fB\-\-environment\fP) is unchanged: \fBGetEnvironment\fP returns an +empty string and clients keep loading env from a local file as before. .SS SEE ALSO .sp \fBlabgrid\-client\fP(1), \fBlabgrid\-exporter\fP(1) diff --git a/tests/conftest.py b/tests/conftest.py index 3bb2641f9..3a8114ab5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -142,12 +142,15 @@ def start(self): class Coordinator(LabgridComponent): - def start(self): + def start(self, args=''): assert self.spawn is None assert self.reader is None + cmd = 'python -m labgrid.remote.coordinator' + if args: + cmd = f'{cmd} {args}' self.spawn = pexpect.spawn( - 'python -m labgrid.remote.coordinator', + cmd, logfile=Prefixer(sys.stdout.buffer, 'coordinator'), cwd=self.cwd) try: @@ -204,6 +207,17 @@ def coordinator(tmpdir): coordinator.stop() +@pytest.fixture(scope='function') +def coordinator_with_env(tmpdir): + env_file = tmpdir.join('env.yaml') + env_file.write('targets:\n') + coordinator = Coordinator(tmpdir) + coordinator.start(f'--environment {env_file}') + + yield coordinator, env_file + + coordinator.stop() + @pytest.fixture(scope='function') def exporter(tmpdir, coordinator): config = "exports.yaml" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 2907baed0..726a9f098 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -4,6 +4,8 @@ import labgrid.remote.generated.labgrid_coordinator_pb2_grpc as labgrid_coordinator_pb2_grpc import labgrid.remote.generated.labgrid_coordinator_pb2 as labgrid_coordinator_pb2 +from conftest import Coordinator + @pytest.fixture(scope="function") def channel_stub(): @@ -191,3 +193,40 @@ def test_coordinator_create_reservation(coordinator, coordinator_place): assert res res: labgrid_coordinator_pb2.CreateReservationResponse assert len(res.reservation.token) > 0 + + +def test_coordinator_get_environment_default(coordinator, channel_stub): + res = channel_stub.GetEnvironment(labgrid_coordinator_pb2.GetEnvironmentRequest()) + assert res.config == "" + + +def test_coordinator_get_environment_serves_file(coordinator_with_env, channel_stub): + _, env_file = coordinator_with_env + env_file.write("targets:\n main: {}\n") + res = channel_stub.GetEnvironment(labgrid_coordinator_pb2.GetEnvironmentRequest()) + assert res.config == "targets:\n main: {}\n" + + +def test_coordinator_get_environment_refreshes(coordinator_with_env, channel_stub): + _, env_file = coordinator_with_env + env_file.write("targets:\n first: {}\n") + res = channel_stub.GetEnvironment(labgrid_coordinator_pb2.GetEnvironmentRequest()) + assert res.config == "targets:\n first: {}\n" + + env_file.write("targets:\n second: {}\n") + res = channel_stub.GetEnvironment(labgrid_coordinator_pb2.GetEnvironmentRequest()) + assert res.config == "targets:\n second: {}\n" + + +def test_coordinator_get_environment_missing_file(tmpdir): + coordinator = Coordinator(tmpdir) + missing = tmpdir.join("nope.yaml") + coordinator.start(f"--environment {missing}") + try: + with grpc.insecure_channel("127.0.0.1:20408") as channel: + stub = labgrid_coordinator_pb2_grpc.CoordinatorStub(channel) + with pytest.raises(grpc.RpcError) as excinfo: + stub.GetEnvironment(labgrid_coordinator_pb2.GetEnvironmentRequest()) + assert excinfo.value.code() == grpc.StatusCode.FAILED_PRECONDITION + finally: + coordinator.stop() From a71588f9156d59ea8487587726cd9a8e1f072896 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 4 May 2026 06:57:21 -0600 Subject: [PATCH 2/2] client: Allow fetching the env from the coordinator Remote users have to keep a local copy of the lab env file in sync with the coordinator before they can run labgrid-client against it. This is friction for casual workflows like triage or console access from a laptop, and is awkward to maintain across many client machines. Provide a way for clients to fetch the env from the coordinator on startup. Setting LG_ENV (or --config) to the literal string 'coordinator:' issues a one-shot GetEnvironment RPC against the coordinator. The returned YAML is written under $XDG_CACHE_HOME/labgrid (or ~/.cache/labgrid) and loaded exactly as if the user had pointed at a local file, so the rest of the client is untouched. The cache file is overwritten on each fetch so users can inspect what env the client just loaded. Remote users then only need labgrid, network access to the coordinator and three environment variables (PATH, LG_COORDINATOR, LG_ENV=coordinator:) to run labgrid-client console -p from anywhere. Per-user paths inside the served env (build dirs, log dirs, source trees) are still resolvable via the existing LG_* template substitution, so individual clients can override only the few values that matter to them without forking the env file. The coordinator address used for the fetch comes from --coordinator on the command line if given, otherwise LG_COORDINATOR, with 127.0.0.1:20408 as the final default - matching the fallback chain main() uses elsewhere. Signed-off-by: Simon Glass --- doc/man/client.rst | 9 ++++++++ doc/usage.rst | 31 +++++++++++++++++++++++++++ labgrid/remote/client.py | 45 ++++++++++++++++++++++++++++++++++++++++ man/labgrid-client.1 | 9 ++++++++ tests/test_client.py | 32 ++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+) diff --git a/doc/man/client.rst b/doc/man/client.rst index 309e36365..ae7bcae61 100644 --- a/doc/man/client.rst +++ b/doc/man/client.rst @@ -49,6 +49,15 @@ LG_ENV This variable can be used to specify the configuration file to use without using the ``--config`` option, the ``--config`` option overrides it. +The special value ``coordinator:`` (or ``--config coordinator:``) tells the +client to fetch the environment from the coordinator over the ``GetEnvironment`` +RPC instead of reading a local file, which is useful when working from a remote +machine that does not have the lab env file available locally. The fetched +YAML is cached under ``$XDG_CACHE_HOME/labgrid/env.cfg`` (or +``~/.cache/labgrid/env.cfg``) so it can be inspected after the fact. The +coordinator must have been started with ``--environment``; see +``labgrid-coordinator``\(1). + LG_COORDINATOR ~~~~~~~~~~~~~~ This variable can be used to set the default coordinator in the format diff --git a/doc/usage.rst b/doc/usage.rst index 37527fb40..ea29be724 100644 --- a/doc/usage.rst +++ b/doc/usage.rst @@ -166,6 +166,37 @@ allocated before returning. A reservation will time out after a short time, if it is neither refreshed nor used by locked places. +Fetching the Environment from the Coordinator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When several developers (or CI jobs) share a lab, distributing the env file out +of band - via a shared filesystem, git checkout or scp - is awkward duplication. +``labgrid-coordinator`` can serve a curated env to clients on demand, so a +remote user only needs network access to the coordinator and a labgrid install. + +Start the coordinator with ``--environment`` pointing at a YAML file: + +.. code-block:: bash + + $ labgrid-coordinator --environment /etc/labgrid/lab.cfg + +On the client side, set ``LG_ENV=coordinator:`` (or ``--config coordinator:``) +to fetch the env via the ``GetEnvironment`` RPC instead of reading a local file. +The returned YAML is cached under ``$XDG_CACHE_HOME/labgrid/env.cfg`` (or +``~/.cache/labgrid/env.cfg``), overwritten on each fetch so a user can inspect +what env the client just loaded. + +.. code-block:: bash + + $ export LG_COORDINATOR=lab-host:20408 + $ export LG_ENV=coordinator: + $ labgrid-client console -p + +Per-user paths inside the served env (build dirs, log dirs, source trees) are +still resolvable via the existing ``LG_*`` template substitution, so individual +clients can override only the few values that matter to them without forking the +env file. + Library ------- labgrid can be used directly as a Python library, without the infrastructure diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py index 4d2eb0bfa..115905f8d 100755 --- a/labgrid/remote/client.py +++ b/labgrid/remote/client.py @@ -1725,6 +1725,40 @@ def start_session( return session +def fetch_coordinator_environment(address): + """Fetch the env file served by the coordinator and cache it on disk + + Used when the user sets ``LG_ENV=coordinator:`` (or + ``--config coordinator:``) instead of pointing at a local file. + + Returns the path to the cached file (under ``$XDG_CACHE_HOME`` or + ``~/.cache/labgrid/env.cfg``), overwritten on each call so users + can inspect what env the client just loaded. Raises UserError if + the coordinator is not configured to serve an environment. + """ + address = proxymanager.get_grpc_address(address, default_port=20408) + with grpc.insecure_channel(address) as channel: + stub = labgrid_coordinator_pb2_grpc.CoordinatorStub(channel) + try: + response = stub.GetEnvironment( + labgrid_coordinator_pb2.GetEnvironmentRequest(), + timeout=10.0, + ) + except grpc.RpcError as e: + # pylint: disable-next=no-member + raise UserError(f"failed to fetch environment from coordinator at {address}: {e.details() or e}") from e + if not response.config: + raise UserError( + f"coordinator at {address} has no environment configured (start coordinator with --environment )" + ) + cache_dir = os.path.join(os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), "labgrid") + os.makedirs(cache_dir, exist_ok=True) + path = os.path.join(cache_dir, "env.cfg") + with open(path, "w", encoding="utf-8") as f: + f.write(response.config) + return path + + def find_role_by_place(config, place): for role, role_config in config.items(): resources, _ = target_factory.normalize_config(role_config) @@ -2305,6 +2339,17 @@ def main(): if args.proxy: proxymanager.force_proxy(args.proxy) + if args.config == "coordinator:": + # Fetch the env from the coordinator. The address can't be taken from + # env.config yet (no env loaded), so use the same fallback chain the + # rest of main() uses, minus that. + addr = args.coordinator or os.environ.get("LG_COORDINATOR", "127.0.0.1:20408") + try: + args.config = fetch_coordinator_environment(addr) + except UserError as e: + print(e, file=sys.stderr) + exit(1) + env = None if args.config: env = Environment(config_file=args.config) diff --git a/man/labgrid-client.1 b/man/labgrid-client.1 index dfc95b2e8..4aeea263a 100644 --- a/man/labgrid-client.1 +++ b/man/labgrid-client.1 @@ -1145,6 +1145,15 @@ A desired state must be set using \fBLG_STATE\fP or \fB\-s\fP/\fB\-\-state\fP\&. .sp This variable can be used to specify the configuration file to use without using the \fB\-\-config\fP option, the \fB\-\-config\fP option overrides it. +.sp +The special value \fBcoordinator:\fP (or \fB\-\-config coordinator:\fP) tells the +client to fetch the environment from the coordinator over the \fBGetEnvironment\fP +RPC instead of reading a local file, which is useful when working from a remote +machine that does not have the lab env file available locally. The fetched +YAML is cached under \fB$XDG_CACHE_HOME/labgrid/env.cfg\fP (or +\fB~/.cache/labgrid/env.cfg\fP) so it can be inspected after the fact. The +coordinator must have been started with \fB\-\-environment\fP; see +\fBlabgrid\-coordinator\fP(1). .SS LG_COORDINATOR .sp This variable can be used to set the default coordinator in the format diff --git a/tests/test_client.py b/tests/test_client.py index 8d2cc1c9f..23f939c8c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -616,3 +616,35 @@ def test_same_name_resources(place, exporter, tmpdir): spawn.expect(pexpect.EOF) spawn.close() assert spawn.exitstatus == 0, spawn.before.strip() + + +def test_config_coordinator(coordinator_with_env, monkeypatch, tmpdir): + _, env_file = coordinator_with_env + env_content = ( + "targets:\n" + " main:\n" + " resources:\n" + " RemotePlace:\n" + " name: test\n" + ) + env_file.write(env_content) + + cache = tmpdir.join('cache') + monkeypatch.setenv("XDG_CACHE_HOME", str(cache)) + + with pexpect.spawn('python -m labgrid.remote.client -c coordinator: places') as spawn: + spawn.expect(pexpect.EOF) + assert spawn.exitstatus == 0, spawn.before.strip() + + cached = cache.join('labgrid', 'env.cfg') + assert cached.check(file=1), f"cache file missing: {cached}" + assert cached.read() == env_content + + +def test_config_coordinator_unconfigured(coordinator, monkeypatch, tmpdir): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmpdir.join('cache'))) + + with pexpect.spawn('python -m labgrid.remote.client -c coordinator: places') as spawn: + spawn.expect("no environment configured") + spawn.expect(pexpect.EOF) + assert spawn.exitstatus == 1, spawn.before.strip()