diff --git a/bazel/labgrid/client/BUILD.bazel b/bazel/labgrid/client/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..458ba20d4ab3989f38c287a68d421cbcac777199 --- /dev/null +++ b/bazel/labgrid/client/BUILD.bazel @@ -0,0 +1,26 @@ +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +py_library( + name = "client", + srcs = [ + "__init__.py", + "common.py", + "lock.py", + "reserve.py", + ], + data = ["//labgrid/client"], + visibility = ["//visibility:public"], +) + +py_test( + name = "reservation_test", + srcs = ["reservation_test.py"], + data = ["//labgrid/client"], + visibility = ["//visibility:public"], + deps = [ + ":client", + "//bazel/labgrid/mock/coordinator", + "//bazel/labgrid/mock/exporter", + "@rules_python//python/runfiles", + ], +) diff --git a/bazel/labgrid/client/__init__.py b/bazel/labgrid/client/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2d40e50ba042ef1df271dec8550281a3385074df --- /dev/null +++ b/bazel/labgrid/client/__init__.py @@ -0,0 +1,3 @@ +from .reserve import reserve +from .lock import lock +from .common import labgrid_client diff --git a/bazel/labgrid/client/common.py b/bazel/labgrid/client/common.py new file mode 100644 index 0000000000000000000000000000000000000000..e5e4b707ec62bbcc2a759deddd9509245072be64 --- /dev/null +++ b/bazel/labgrid/client/common.py @@ -0,0 +1,29 @@ +import yaml +from subprocess import run, PIPE +from python.runfiles import Runfiles + + +def labgrid_client(*args, **kwargs): + env = None + url_params = () + timeout = None + + runfiles = Runfiles.Create() + labgrid_client_path = runfiles.Rlocation("rules_labgrid/labgrid/client/client") + + if "env" in kwargs: + env = kwargs["env"] + if "url" in kwargs: + url_params = ("-x", kwargs["url"]) + if "timeout" in kwargs: + timeout = kwargs["timeout"] + + cmd = (labgrid_client_path,) + url_params + args + process = run( + cmd, check=True, stdout=PIPE, env=env, timeout=timeout, encoding="utf-8" + ) + + if process.stdout: + return yaml.safe_load(process.stdout) + else: + return [] diff --git a/bazel/labgrid/client/lock.py b/bazel/labgrid/client/lock.py new file mode 100644 index 0000000000000000000000000000000000000000..0972779ad902e69806f193f333110d2ae5d38ad3 --- /dev/null +++ b/bazel/labgrid/client/lock.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from contextlib import contextmanager +from os import environ +from typing import Iterator + +from .reserve import reserve +from .common import labgrid_client + + +@contextmanager +def lock(reservation: reserve.Reservation) -> Iterator[str]: + env = environ | {"LG_TOKEN": reservation.token} + if reservation.owner: + env["LG_USERNAME"] = reservation.owner + labgrid_client("-p", "+", "lock", env=env, url=reservation.url) + try: + yield + finally: + labgrid_client("-p", "+", "unlock", env=env, url=reservation.url) diff --git a/bazel/labgrid/client/reservation_test.py b/bazel/labgrid/client/reservation_test.py new file mode 100644 index 0000000000000000000000000000000000000000..0847aed41695bd4986603c65d60486d713267392 --- /dev/null +++ b/bazel/labgrid/client/reservation_test.py @@ -0,0 +1,142 @@ +#! /usr/bin/env python3 + +import unittest +import os +import time +import threading + +from bazel.labgrid.mock.coordinator.main import coordinator + +# context managers that perform client operations: +from bazel.labgrid.client import lock, reserve, labgrid_client + + +class TestReservation(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.place_name = "test_place" + cls.lg_tags = {"board": "fake_board", "os": "fake_os"} + # in order to allow parallel execution of 2 coordinators, + # unique log_path must be set + cls.log_path = os.path.join(os.getcwd(), TestReservation.__name__) + os.mkdir(cls.log_path) + + cls.__cm = coordinator( + tags=TestReservation.lg_tags, + place=TestReservation.place_name, + log_path=TestReservation.log_path, + ) + cls.coordinator = cls.__cm.__enter__() + + @classmethod + def tearDownClass(cls): + cls.__cm.__exit__(None, None, None) + + def _get_place(self, name): + """Execute the labgrid client show command, parse the output + and return a dictionary with the place details. + Args: + place_name (string): The given resource place name in labgrid. + """ + show_command_dict = labgrid_client( + "-p", + name, + "show", + url=TestReservation.coordinator.url, + ) + place_details = list(show_command_dict.values())[0] + return place_details + + def _reserve_and_sleep(self, lg_tags, timeout, url): + with reserve(tags=lg_tags, url=url): + time.sleep(timeout) + + def test_reserve_and_lock_token_verification(self): + """ + Test that a reservation is created and locked for the owner. + Test that a reservation is unlocked and canceled after exiting. + from the context manager. + """ + lg_tags = TestReservation.lg_tags + place_name = TestReservation.place_name + username = "user_test" + url = TestReservation.coordinator.url + with reserve( + tags=lg_tags, timeout=3, username=username, url=url + ) as reservation: + with lock(reservation): + dict = self._get_place(place_name) + place_token = dict["reservation"] + owner = dict["acquired"].split("/")[-1] + self.assertEqual( + place_token, + reservation.token, + f"Reservation token mismatch: {place_token} != {reservation.token}", + ) + self.assertEqual( + owner, + username, + f"Username {username} is not acquired the reservation (owned by {owner})", + ) + + dict = self._get_place(place_name) + self.assertNotEqual(dict["acquired"], None, "Reservation was not unlocked") + self.assertNotIn("reservation", dict, "Reservation was not canceled") + + def test_exception_in_lock_context(self): + """ + Test that a reservation is cancelled when exception is thrown + in the lock context. + """ + lg_tags = TestReservation.lg_tags + place_name = TestReservation.place_name + url = TestReservation.coordinator.url + with self.assertRaises(Exception): + with reserve(tags=lg_tags, url=url) as reservation: + with lock(reservation): + raise Exception("This is a test exception") + + # waiting for reservation's new status to be updated + time.sleep(1) + # checking that the reservation is released despite the exception + dict = self._get_place(place_name) + self.assertNotEqual(dict["acquired"], None, "Reservation was not unlocked") + self.assertNotIn("reservation", dict, "Reservation was not canceled") + + def test_reserve_fails_if_timeout(self): + """ + Test that a reservation throws error when failed to reserve + """ + lg_tags = TestReservation.lg_tags + url = TestReservation.coordinator.url + with reserve(tags=lg_tags, url=url): + with self.assertRaises(RuntimeError): + with reserve(tags=lg_tags, timeout=1, url=url): + pass + + def test_waiting_reserve_is_acquired_eventually(self): + """ + Test that a reservation following an allocated reservation is acquired + eventually. This is done by running a process that reserves and sleep + in parallel to another process that waits enough time for it to be + released in order to allocate the new reservation. + """ + lg_tags = TestReservation.lg_tags + place_name = TestReservation.place_name + url = TestReservation.coordinator.url + + threading.Thread(target=self._reserve_and_sleep, args=(lg_tags, 1, url)).start() + + with reserve(tags=lg_tags, timeout=25, url=url) as reservation: + # if we entered here p1 process is already terminated + dict = self._get_place(place_name) + place_token = dict["reservation"] + self.assertEqual( + place_token, + reservation.token, + f"Reservation token mismatch: {place_token} != {reservation.token}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/bazel/labgrid/client/reserve.py b/bazel/labgrid/client/reserve.py new file mode 100644 index 0000000000000000000000000000000000000000..f0ccfcda9727c9faceee47ce00080f96396f9641 --- /dev/null +++ b/bazel/labgrid/client/reserve.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from os import environ +from contextlib import contextmanager +from dataclasses import dataclass +from subprocess import ( + TimeoutExpired, +) +from typing import Iterator, Mapping + +from python.runfiles import Runfiles +from .common import labgrid_client + + +@dataclass(frozen=True) +class Reservation: + token: str + owner: str + url: str + + +@contextmanager +def reserve( + tags: Mapping[str, str], url: str, timeout: int = 3, username: str = "" +) -> Iterator[Reservation]: + runfiles = Runfiles.Create() + client = runfiles.Rlocation("rules_labgrid/labgrid/client/client") + + # FIXME: no way to provide a timeout to the CLI + # FIXME: no way to know if tags will ever be satisfied + # FIXME: does not cancel reservation if killed + env = environ + if username: + env["LG_USERNAME"] = username + + reservation_dict = labgrid_client( + "reserve", *(f"{k}={v}" for k, v in tags.items()), env=env, url=url + ) + reservation_dict_values = list(reservation_dict.values())[0] + token = reservation_dict_values["token"] + owner = reservation_dict_values["owner"].split("/")[-1] + + assert token + + try: + labgrid_client("wait", token, env=env, timeout=timeout, url=url) + except TimeoutExpired: + labgrid_client("cancel-reservation", token, env=env, url=url) + raise RuntimeError("Failed to allocate reservation") + + try: + yield Reservation(token=token, owner=owner, url=url) + finally: + labgrid_client("cancel-reservation", token, env=env, url=url) diff --git a/bazel/labgrid/mock/__init__.py b/bazel/labgrid/mock/__init__.py index 37a4981a35f33ad16756435698ff435dde777584..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/bazel/labgrid/mock/__init__.py +++ b/bazel/labgrid/mock/__init__.py @@ -1 +0,0 @@ -from .coordinator.main import coordinator diff --git a/bazel/labgrid/mock/coordinator/BUILD.bazel b/bazel/labgrid/mock/coordinator/BUILD.bazel index ede76656f788daa02d1ddf4eaa86bcc2816a9279..5faf224a8efcc93f93ca6558c656633cbb7d3250 100644 --- a/bazel/labgrid/mock/coordinator/BUILD.bazel +++ b/bazel/labgrid/mock/coordinator/BUILD.bazel @@ -13,11 +13,15 @@ py_library( srcs = ["main.py"], data = [ "process.py", + "//bazel/labgrid/client", "//bazel/labgrid/mock/crossbar", "//bazel/labgrid/mock/exporter", "//labgrid/client", ], - visibility = ["//bazel/labgrid/mock:__pkg__"], + visibility = [ + "//bazel/labgrid/client:__pkg__", + "//bazel/labgrid/mock:__pkg__", + ], deps = [ "//labgrid:pkg", "@rules_python//python/runfiles", diff --git a/bazel/labgrid/mock/coordinator/main.py b/bazel/labgrid/mock/coordinator/main.py index b1fbe52fdc66c0d70c03bf747a0ac40549114ef1..835641e5c1dc728f216986447eb5b91a8b983a53 100644 --- a/bazel/labgrid/mock/coordinator/main.py +++ b/bazel/labgrid/mock/coordinator/main.py @@ -1,17 +1,13 @@ from __future__ import annotations -import asyncio import socket from contextlib import contextmanager from dataclasses import dataclass -from subprocess import PIPE, run from typing import Iterator, Mapping -from python.runfiles import Runfiles - from bazel.labgrid.mock.crossbar.main import crossbar from bazel.labgrid.mock.exporter.main import exporter -from labgrid.remote.coordinator import ApplicationRunner, CoordinatorComponent +from bazel.labgrid.client.common import labgrid_client from .process import coordinator_process @@ -28,38 +24,37 @@ class Coordinator: """The name of the place to create.""" -def _labgrid_client(*args): - runfiles = Runfiles.Create() - client = runfiles.Rlocation("rules_labgrid/labgrid/client/client") - cmd = (client, *args) - run(cmd, check=True, stdout=PIPE, encoding="utf-8") - - @contextmanager def coordinator( tags: Mapping[str, str], place: str = "test_place", - url: str = "ws://127.0.0.1:20408/ws", # web_socket_address + log_path: str = "", ) -> Iterator[Coordinator]: - with crossbar(): - with coordinator_process(): - _labgrid_client("--place", place, "create") - _labgrid_client( - "--place", - place, - "set-tags", - *(f"{k}={v}" for k, v in tags.items()), - ) - with exporter(): - # add resources to the "place" - _labgrid_client( + with crossbar(log_path=log_path) as url: + with coordinator_process(url=url): + labgrid_client("--place", place, "create", url=url) + try: + labgrid_client( "--place", place, - "add-match", - f"{socket.gethostname()}/targets/NetworkService", - ) - yield Coordinator( + "set-tags", + *(f"{k}={v}" for k, v in tags.items()), url=url, - tags=tags, - place=place, ) + + with exporter(url=url): + # add resources to the "place" + labgrid_client( + "--place", + place, + "add-match", + f"{socket.gethostname()}/targets/NetworkService", + url=url, + ) + yield Coordinator( + url=url, + tags=tags, + place=place, + ) + finally: + labgrid_client("--place", place, "delete", url=url) diff --git a/bazel/labgrid/mock/coordinator/test.py b/bazel/labgrid/mock/coordinator/test.py index dbabade3764c6d9318057ab4c5715cc7ff971ead..51a26aee278317b4a904196946f3da4708c4965f 100644 --- a/bazel/labgrid/mock/coordinator/test.py +++ b/bazel/labgrid/mock/coordinator/test.py @@ -1,3 +1,4 @@ +import os import socket from subprocess import PIPE, run from unittest import TestCase, main @@ -10,6 +11,11 @@ from bazel.labgrid.mock.coordinator.main import coordinator class CoordinatorTestCase(TestCase): @classmethod def setUpClass(cls) -> None: + # in order to allow parallel execution of 2 coordinators, + # unique log_path must be set + cls.log_path = os.path.join(os.getcwd(), CoordinatorTestCase.__name__) + os.mkdir(cls.log_path) + cls.__cm = coordinator(tags={"device": "fake"}, place="test_place") cls.coordinator = cls.__cm.__enter__() runfiles = Runfiles.Create() diff --git a/bazel/labgrid/mock/crossbar/config.yaml b/bazel/labgrid/mock/crossbar/config.yaml index 5684ca051eddbf89e88309ab52720ea34af32fcf..b09bab8e2c388a8fa081e48417f93c9e415fd14f 100644 --- a/bazel/labgrid/mock/crossbar/config.yaml +++ b/bazel/labgrid/mock/crossbar/config.yaml @@ -21,7 +21,7 @@ workers: - type: web endpoint: type: tcp - port: 20408 + portrange: [1024,65535] paths: /: type: static diff --git a/bazel/labgrid/mock/crossbar/main.py b/bazel/labgrid/mock/crossbar/main.py index 0ae29d5fce0cd5bf72e63a83287b4eda4ee28e4c..4976b98a6ea3aa862ed0e1f6c5cf48ac81783031 100644 --- a/bazel/labgrid/mock/crossbar/main.py +++ b/bazel/labgrid/mock/crossbar/main.py @@ -1,4 +1,5 @@ import multiprocessing +from os import environ from contextlib import contextmanager from subprocess import PIPE, Popen from typing import Iterator @@ -6,9 +7,11 @@ from typing import Iterator from python.runfiles import Runfiles -def crossbar_status_check(process): +def crossbar_status_check(process, queue): for line in process.stdout: - if "Router worker001 has started" in line: + if "Site starting on " in line: + port = line.split()[-1].strip() + queue.put(port) return @@ -18,8 +21,14 @@ class CrossbarError(RuntimeError): @contextmanager def crossbar_status(process, timeout: int = 10) -> Iterator[None]: + queue = multiprocessing.Queue() monitor_thread = multiprocessing.Process( - target=crossbar_status_check, args=(process,), daemon=True + target=crossbar_status_check, + args=( + process, + queue, + ), + daemon=True, ) monitor_thread.start() try: @@ -28,7 +37,8 @@ def crossbar_status(process, timeout: int = 10) -> Iterator[None]: raise CrossbarError( f"Failed to start the crossbar: {process.stderr.read().strip()}" ) - yield + port = queue.get() + yield port finally: monitor_thread.terminate() monitor_thread.join() @@ -36,7 +46,7 @@ def crossbar_status(process, timeout: int = 10) -> Iterator[None]: @contextmanager -def crossbar(timeout: int = 10) -> Iterator[int]: +def crossbar(timeout: int = 10, log_path="") -> Iterator[int]: runfiles = Runfiles.Create() crossbar = runfiles.Rlocation( "rules_labgrid/bazel/labgrid/mock/crossbar/crossbar_binary" @@ -44,7 +54,11 @@ def crossbar(timeout: int = 10) -> Iterator[int]: data_file_path = runfiles.Rlocation( "rules_labgrid/bazel/labgrid/mock/crossbar/config.yaml" ) + # ports are assigned automatically by crossbar using portrange value cmd = (crossbar, "start", "--config", data_file_path) + if log_path: + cmd += ("--cbdir", log_path) + process = Popen( cmd, stdout=PIPE, @@ -56,7 +70,11 @@ def crossbar(timeout: int = 10) -> Iterator[int]: try: pid = process.pid assert isinstance(pid, int) - with crossbar_status(process): - yield pid + with crossbar_status(process) as port: + url = f"ws://127.0.0.1:{port}/ws" + yield url finally: process.terminate() + cmd = (crossbar, "stop", "--cbdir", log_path) + process = Popen(cmd, stdout=PIPE, stderr=PIPE, encoding="utf-8") + process.wait() diff --git a/bazel/labgrid/mock/exporter/BUILD.bazel b/bazel/labgrid/mock/exporter/BUILD.bazel index 21ab15231c7f8c5a52e1324aa1823ed6c0c98ba1..95aa5ef4b76127fe40664248dc1a03f95c7ff336 100644 --- a/bazel/labgrid/mock/exporter/BUILD.bazel +++ b/bazel/labgrid/mock/exporter/BUILD.bazel @@ -5,5 +5,8 @@ py_library( "config.yaml", "@rules_labgrid//labgrid/exporter", ], - visibility = ["//bazel/labgrid/mock/coordinator:__subpackages__"], + visibility = [ + "//bazel/labgrid/client:__pkg__", + "//bazel/labgrid/mock/coordinator:__subpackages__", + ], ) diff --git a/bazel/labgrid/mock/exporter/main.py b/bazel/labgrid/mock/exporter/main.py index e34dd3f7c5995c3429f9f815dea45bccb0dce463..32b2b0c45c933a4a7e41b8b4f427ced26121a6f4 100644 --- a/bazel/labgrid/mock/exporter/main.py +++ b/bazel/labgrid/mock/exporter/main.py @@ -24,3 +24,8 @@ def exporter( yield pid finally: process.terminate() + process.wait() + if process.poll() is None: + print( + "Failed to terminate exporter process... will be terminated on crossbar stop." + )