diff --git a/README.md b/README.md index 5788e62..9d94906 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ not-my-board is a tool to manage a pool of embedded hardware setups and to schedule and provide access to those setups over a local network. The concept is known as a *board farm*. -Check out the **[Documentation]** for installation and usage information -details. +Check out the **[Documentation]** for installation and usage information. [Documentation]: http://not-my-board.readthedocs.io diff --git a/doc/how-to-guides/set-up-agent.md b/doc/how-to-guides/set-up-agent.md index 42c09ce..6b28cd3 100644 --- a/doc/how-to-guides/set-up-agent.md +++ b/doc/how-to-guides/set-up-agent.md @@ -17,7 +17,24 @@ Log out and log back in again for the changes to take effect. ## Configuring the Service -Create a `systemd` service file (replace `` with the address or +Configure `systemd` to create and listen on a Unix domain socket: +```{code-block} systemd +:caption: /etc/systemd/system/not-my-board-agent.socket + +[Unit] +Description=Board Farm Agent Socket + +[Socket] +ListenStream=/run/not-my-board-agent.sock +SocketGroup=not-my-board +SocketMode=0660 + +[Install] +WantedBy=sockets.target +``` + +Then create a `systemd` service, that is started, the fist time a `not-my-board` +command connects to the *Agent* (replace `` with the address or domain name of the *Hub*): ```{code-block} systemd :caption: /etc/systemd/system/not-my-board-agent.service @@ -26,10 +43,9 @@ domain name of the *Hub*): Description=Board Farm Agent [Service] -ExecStart=/usr/local/bin/not-my-board agent https:// - -[Install] -WantedBy=multi-user.target +ExecStart=/usr/local/bin/not-my-board agent --fd 0 https:// +StandardInput=socket +StandardOutput=journal ``` If authentication is configured in the *Hub*, log in: @@ -37,7 +53,7 @@ If authentication is configured in the *Hub*, log in: $ sudo not-my-board login https:// ``` -Enable and start the service: +Enable and start the socket: ```console -$ sudo systemctl enable --now not-my-board-agent +$ sudo systemctl enable --now not-my-board-agent.socket ``` diff --git a/doc/installation.md b/doc/installation.md index 52b5abc..d3e10b8 100644 --- a/doc/installation.md +++ b/doc/installation.md @@ -1,7 +1,7 @@ # Installation -Since you'll need to run `not-my-board` as root user to export USB devices, -you should install it globally in an isolated environment with [`pipx`][1]. +Since you'll need to run `not-my-board` as root user for some operations, you +should install it globally in an isolated environment with [`pipx`][1]. [1]: https://pypa.github.io/pipx/ diff --git a/doc/reference/cli.md b/doc/reference/cli.md index d23d210..29e47eb 100644 --- a/doc/reference/cli.md +++ b/doc/reference/cli.md @@ -1,4 +1,4 @@ -# CLI Interface +# Command Line Interface Here's a description of all the commands and options `not-my-board` supports. @@ -11,7 +11,7 @@ Here's a description of all the commands and options `not-my-board` supports. **`export`** \[**`-h`**|**`--help`**\] \[**`--cacert`** *cacert*\] \[**`--token-cmd`** *token_cmd*\] *hub_url* *export_description* : Make connected boards and equipment available in the board farm. -**`agent`** \[**`-h`**|**`--help`**\] \[**`--cacert`** *cacert*\] \[**`--token-cmd`** *token_cmd*\] *hub_url* +**`agent`** \[**`-h`**|**`--help`**\] \[**`--cacert`** *cacert*\] \[**`--token-cmd`** *token_cmd*\] \[**`--fd`** *fd*\] *hub_url* : Start an *Agent*. **`login`** \[**`-h`**|**`--help`**\] \[**`-v`**|**`--verbose`**\] \[**`--cacert`** *cacert*\] *hub_url* @@ -66,6 +66,11 @@ This option is an alternative to the `login` command. It can be used in non-interactive environments. ``` +```{option} --fd fd +Use file descriptor *fd*, instead of creating the listening socket. Should be a +Unix domain socket with the address `/run/not-my-board-agent.sock`. +``` + ```{option} hub_url HTTP or HTTPS URL of the *Hub*. ``` diff --git a/not_my_board/_agent.py b/not_my_board/_agent.py index 864ea2c..fc5fa21 100644 --- a/not_my_board/_agent.py +++ b/not_my_board/_agent.py @@ -7,6 +7,7 @@ import logging import pathlib import shutil +import socket import traceback import urllib.parse import weakref @@ -24,9 +25,10 @@ class AgentIO: - def __init__(self, hub_url, http_client): + def __init__(self, hub_url, http_client, unix_server_fd=None): self._hub_url = hub_url self._http = http_client + self._unix_server_fd = unix_server_fd @contextlib.asynccontextmanager async def hub_rpc(self): @@ -36,10 +38,15 @@ async def hub_rpc(self): @contextlib.asynccontextmanager async def unix_server(self, api_obj): - socket_path = pathlib.Path("/run") / "not-my-board-agent.sock" + if self._unix_server_fd is not None: + s = socket.socket(fileno=self._unix_server_fd) + else: + socket_path = pathlib.Path("/run") / "not-my-board-agent.sock" + if socket_path.is_socket(): + socket_path.unlink(missing_ok=True) - connection_handler = functools.partial(self._handle_unix_client, api_obj) - async with util.UnixServer(connection_handler, socket_path) as unix_server: + s = socket.socket(family=socket.AF_UNIX) + s.bind(socket_path.as_posix()) socket_path.chmod(0o660) try: shutil.chown(socket_path, group="not-my-board") @@ -48,6 +55,8 @@ async def unix_server(self, api_obj): 'Failed to change group on agent socket "%s": %s', socket_path, e ) + connection_handler = functools.partial(self._handle_unix_client, api_obj) + async with util.UnixServer(connection_handler, sock=s) as unix_server: yield unix_server @staticmethod diff --git a/not_my_board/_hub.py b/not_my_board/_hub.py index 1707693..0bcec11 100644 --- a/not_my_board/_hub.py +++ b/not_my_board/_hub.py @@ -29,7 +29,22 @@ def run_hub(): - asgineer.run(asgi_app, "uvicorn", ":2092") + import socket + + import uvicorn + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + host = "0.0.0.0" # noqa: S104 + port = 2092 + s.bind((host, port)) + + s.listen() + print("ready", flush=True) # noqa: T201 + + fd = s.fileno() + uvicorn.main([f"--fd={fd}", __name__ + ":asgi_app"]) async def asgi_app(scope, receive, send): @@ -46,18 +61,7 @@ async def _handle_lifespan(scope, receive, send): message = await receive() if message["type"] == "lifespan.startup": try: - config_file = os.environ.get("NOT_MY_BOARD_HUB_CONFIG") - if not config_file: - config_file = "/etc/not-my-board/hub.toml" - config_file = pathlib.Path(config_file) - - if config_file.exists(): - config = util.toml_loads(config_file.read_text()) - else: - config = {} - - hub = Hub(config, http.Client()) - await hub.startup() + hub = await _on_startup() scope["state"]["hub"] = hub except Exception as err: await send({"type": "lifespan.startup.failed", "message": str(err)}) @@ -75,6 +79,24 @@ async def _handle_lifespan(scope, receive, send): logger.warning("Unknown lifespan message %s", message["type"]) +async def _on_startup(): + config_file = os.environ.get("NOT_MY_BOARD_HUB_CONFIG") + if not config_file: + config_file = "/etc/not-my-board/hub.toml" + config_file = pathlib.Path(config_file) + + if config_file.exists(): + config = util.toml_loads(config_file.read_text()) + else: + logger.warning('Config file "%s" not found', config_file) + config = {} + + hub = Hub(config, http.Client()) + await hub.startup() + + return hub + + @asgineer.to_asgi async def _handle_request(request): hub = request.scope["state"]["hub"] diff --git a/not_my_board/_usbip.py b/not_my_board/_usbip.py index 2bbac29..cb9fe16 100644 --- a/not_my_board/_usbip.py +++ b/not_my_board/_usbip.py @@ -536,6 +536,7 @@ async def _main(): server = util.Server(usbip_server.handle_client, port=args.port) async with server: logger.info("listening") + print("ready", flush=True) # noqa: T201 await server.serve_forever() diff --git a/not_my_board/cli/__init__.py b/not_my_board/cli/__init__.py index d5ea828..d729b2f 100644 --- a/not_my_board/cli/__init__.py +++ b/not_my_board/cli/__init__.py @@ -72,6 +72,9 @@ def add_cacert_arg(subparser): subparser.set_defaults(verbose=True) add_cacert_arg(subparser) subparser.add_argument("--token-cmd", help="generate ID tokens with shell command") + subparser.add_argument( + "--fd", type=int, help="listen on socket from this file descriptor" + ) subparser.add_argument("hub_url", help="http(s) URL of the hub") subparser = add_subcommand("reserve", help="reserve a place") @@ -168,15 +171,18 @@ async def _export_command(args): args.hub_url, args.export_description, http_client, token_src ) as exporter: await exporter.register_place() + print("ready", flush=True) await exporter.serve_forever() async def _agent_command(args): http_client = http.Client(args.cacert) - io = agent.AgentIO(args.hub_url, http_client) + io = agent.AgentIO(args.hub_url, http_client, args.fd) token_src = _token_src(args, http_client) async with agent.Agent(args.hub_url, io, token_src) as agent_: + if args.fd is None: + print("ready", flush=True) await agent_.serve_forever() diff --git a/tests/test_agent.py b/tests/test_agent.py index d39d8c9..7603a8c 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -209,7 +209,7 @@ async def port_forward(self, ready_event, proxy, target, local_port): del self.port_forwards[local_port] -@pytest.fixture() +@pytest.fixture async def agent_io(): io = FakeAgentIO() async with agentmodule.Agent(HUB_URL, io, None) as agent: diff --git a/tests/test_auth.py b/tests/test_auth.py index 3f560c5..f8600db 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -179,12 +179,12 @@ async def send(self, data): await self._send(data) -@pytest.fixture() +@pytest.fixture def http_client(): return FakeHttpClient() -@pytest.fixture() +@pytest.fixture def hub(http_client): config = { "auth": { @@ -201,7 +201,7 @@ def hub(http_client): return hubmodule.Hub(config, http_client) -@pytest.fixture() +@pytest.fixture def token_store_path(): path = pathlib.Path(__file__).parent / "auth_tokens.json" path.unlink(missing_ok=True) @@ -352,7 +352,7 @@ async def test_permission_lost(hub, http_client, token_store_path, fake_time): assert "Permission lost" in str(execinfo.value) -@pytest.fixture() +@pytest.fixture def fake_time(monkeypatch): fake_time_ = FakeTime() fake_datetime = fake_time_.fake_datetime() diff --git a/tests/test_http.py b/tests/test_http.py index e97fbe4..6f5db47 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -25,18 +25,14 @@ async def tinyproxy(): async def test_proxy_connect(tinyproxy): - async with sh_task("not-my-board hub", "hub"): - await wait_for_ports(2092) - + async with sh_task("not-my-board hub", "hub", wait_ready=True): client = http.Client(proxies={"http": tinyproxy}) response = await client.get_json("http://127.0.0.1:2092/api/v1/places") assert response == {"places": []} async def test_proxy_ignore(): - async with sh_task("not-my-board hub", "hub"): - await wait_for_ports(2092) - + async with sh_task("not-my-board hub", "hub", wait_ready=True): client = http.Client( proxies={"http": "http://non-existing.localhost", "no": "127.0.0.1"} ) @@ -183,7 +179,7 @@ async def get_written(self): return await self._queue.get() -@pytest.fixture() +@pytest.fixture def fake_server(monkeypatch): fake_server_ = FakeServer() monkeypatch.setattr(util, "connect", fake_server_.connect) @@ -260,7 +256,7 @@ def _send(self, conn, events): self._reader.feed(data) -@pytest.fixture() +@pytest.fixture def fake_time(monkeypatch): fake_time_ = FakeTime() fake_datetime = fake_time_.fake_datetime() @@ -288,7 +284,7 @@ def add_time(self, time): self._now += time -@pytest.fixture() +@pytest.fixture def http_client(): return http.Client(proxies={}) diff --git a/tests/test_hub.py b/tests/test_hub.py index 8a41f38..fe75332 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -13,7 +13,7 @@ DEFAULT_AGENT_IP = "6.1.1.1" -@pytest.fixture() +@pytest.fixture def hub(): return hubmodule.Hub() diff --git a/tests/test_jsonrpc.py b/tests/test_jsonrpc.py index 63d51d0..0ebabb1 100644 --- a/tests/test_jsonrpc.py +++ b/tests/test_jsonrpc.py @@ -73,7 +73,7 @@ def is_empty(self): Fakes = collections.namedtuple("Fakes", ["channel", "api", "transport"]) -@pytest.fixture() +@pytest.fixture async def fakes(): transport = FakeTransport() api = FakeApi() diff --git a/tests/test_usbip.py b/tests/test_usbip.py index bba3215..f66eadc 100644 --- a/tests/test_usbip.py +++ b/tests/test_usbip.py @@ -3,11 +3,8 @@ async def test_raw_usb_forwarding(vms): async with vms.exporter.ssh_task_root( - "python3 -m not_my_board._usbip export 2-1", "usbip export" + "python3 -m not_my_board._usbip export 2-1", "usbip export", wait_ready=True ): - # wait for listening socket - await vms.exporter.ssh_poll("nc -z 127.0.0.1 3240") - async with vms.client.ssh_task_root( "python3 -m not_my_board._usbip import exporter.local 2-1 0", "usbip import", @@ -26,27 +23,15 @@ async def test_raw_usb_forwarding(vms): async def test_usb_forwarding(vms): - async with vms.hub.ssh_task("not-my-board hub", "hub"): - # wait for listening socket - await vms.hub.ssh_poll("nc -z 127.0.0.1 2092") - + async with vms.hub.ssh_task("not-my-board hub", "hub", wait_ready=True): async with vms.exporter.ssh_task_root( "not-my-board export http://hub.local:2092 ./src/tests/qemu-usb-place.toml", "export", + wait_ready=True, ): - await vms.client.ssh("""'doas rm -f "/run/not-my-board-agent.sock"'""") async with vms.client.ssh_task_root( - "not-my-board agent http://hub.local:2092", "agent" + "not-my-board agent http://hub.local:2092", "agent", wait_ready=True ): - # wait until exported place is registered - await vms.client.ssh_poll( - "wget -q -O - http://192.168.200.1:2092/api/v1/places | grep -q qemu-usb" - ) - # wait until agent is ready - await vms.client.ssh_poll( - """'test -e "/run/not-my-board-agent.sock"'""" - ) - await vms.client.ssh("not-my-board attach ./src/tests/qemu-usb.toml") # TODO attach still returns before the device is available. # would be nice if it blocks until the device is ready. diff --git a/tests/util.py b/tests/util.py index f9e2458..906f93d 100644 --- a/tests/util.py +++ b/tests/util.py @@ -43,7 +43,7 @@ def ssh_task_root(self, cmd, *args, **kwargs): async def ssh(self, cmd, *args, **kwargs): return await sh(f"./scripts/vmctl ssh {self._name} " + cmd, *args, **kwargs) - async def ssh_poll(self, cmd, timeout=None): + async def ssh_poll(self, cmd, timeout=None): # noqa: ASYNC109 return await sh_poll(f"./scripts/vmctl ssh {self._name} " + cmd, timeout) @@ -85,19 +85,22 @@ async def sh(cmd, check=True, strip=True, prefix=None): @contextlib.asynccontextmanager -async def sh_task(cmd, prefix=None, terminate=True): +async def sh_task(cmd, prefix=None, terminate=True, wait_ready=False): # need to exec, otherwise only the shell process is killed with # proc.terminate() proc = await asyncio.create_subprocess_shell( f"exec {cmd}", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, + stderr=asyncio.subprocess.PIPE if wait_ready else asyncio.subprocess.STDOUT, ) logging_task = None + log_stream = proc.stderr if wait_ready else proc.stdout try: - logging_task = asyncio.create_task(_log_output(proc.stdout, cmd, prefix)) + logging_task = asyncio.create_task(_log_output(log_stream, cmd, prefix)) + if wait_ready: + await proc.stdout.readuntil(b"\n") yield finally: proc.stdin.close() @@ -112,7 +115,7 @@ async def sh_task(cmd, prefix=None, terminate=True): await logging_task -async def sh_poll(cmd, timeout=None): +async def sh_poll(cmd, timeout=None): # noqa: ASYNC109 if pathlib.Path("/dev/kvm").exists(): if timeout is None: timeout = 7 @@ -143,7 +146,7 @@ async def _log_output(stream, cmd, prefix): sys.stderr.buffer.flush() -async def wait_for_ports(*ports, timeout=7): +async def wait_for_ports(*ports, timeout=7): # noqa: ASYNC109 async with util.timeout(timeout): while True: try: