#!/usr/bin/env python3
"""
The R2lab sidecar is a websocket service that runs on
``wss://r2lab.inria.fr:999/`` and that exposes the status of the testbed.
This module implements client classes, for interacting with the sidecar service.
The core of the implementation is ``asyncio``-friendly and accessible
through the :class:`~r2lab.sidecar.SidecarAsyncClient` class, but for
convenience some features are also available to synchronous code through
the :class:`~r2lab.sidecar.SidecarSyncClient` class.
"""
# pylint: disable=w1203
import logging
import asyncio
import websockets
# required with websockets v9.x
import websockets.client
from .sidecar_payload import SidecarPayload as Payload
default_sidecar_url = 'wss://r2lab.inria.fr:999/'
# provide a simpler way to turn on debugging
logging.basicConfig(level=logging.INFO)
def _websockets_logging_to_stdout(level):
logger = logging.getLogger('sidecar')
logger.setLevel(level)
channel = logging.StreamHandler()
channel.setLevel(level)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%m-%d %H:%M:%S")
channel.setFormatter(formatter)
logger.addHandler(channel)
return logger
# when doing
# async with AsyncSidecarProxy(url) as proxy:
# the proxy variable actually points at the underlying protocol
# so that's where to add our send / receive methods
[docs]
class SidecarProtocol(websockets.client.WebSocketClientProtocol):
"""
The SidecarProtocol class is an asyncio-compliant implementation
of the R2lab sidecar system.
It inherits ``websockets.client.WebSocketClientProtocol``
as documented here:
https://websockets.readthedocs.io/en/stable/api.html#module-websockets.client
"""
[docs]
async def send_payload(self, payload):
"""
Send a :class:`~r2lab.sidecar_payload.SidecarPayload`
object.
"""
return await self.send(payload.string)
[docs]
async def recv_payload(self):
"""
Receives and returns a
:class:`~r2lab.sidecar_payload.SidecarPayload` object.
"""
wired = await self.recv()
return Payload(string=wired)
# for historical reasons, if 'incremental' is not present
# then its a full info message
@staticmethod
def is_not_incremental(umbrella):
return ('incremental' not in umbrella
or not umbrella['incremental'])
[docs]
async def send_umbrella(self, category, action, message):
"""
Set one payload, constructed from its parts
"""
return await self.send_payload(
Payload(
umbrella=dict(category=category, action=action, message=message)))
[docs]
async def recv_umbrella(self):
"""
Read one payload, and returns it as a dict with 3 keys.
"""
payload = await self.recv_payload()
return payload.umbrella
async def _probe_category(self, category):
# send a request and wait for answer
# as opposed to socketio, we may receive other traffic here
# since all goes into the same pipe
# so, wait until we receive corresponding 'info'
# improvement could be to repeat the 'request' after a timeout
infos = None
await self.send_umbrella(category, 'request', "PLEASE")
while True:
umbrella = await self.recv_umbrella()
logging.debug(f"receives answer={umbrella}")
if (umbrella['category'] == category
and umbrella['action'] == 'info'
and self.is_not_incremental(umbrella)):
infos = umbrella['message']
info_by_id = {info['id']: info for info in infos}
return info_by_id
async def _set_triples(self, category, triples):
# build the corresponding infos - a list of the form
# [ { 'id' : id, 'attibute' : value, ..}, ...]
# and emit that on the proper channel
# for that we start with a hash id -> info
# send infos on proper channel and json-encoded
payload = Payload().fill_from_triples(category, triples)
await self.send_payload(payload)
# nodes
[docs]
async def nodes_status(self):
"""
A function call that returns the JSON nodes status for the complete testbed.
Returns:
A python dictionary indexed by integers 1 to 37, whose values are
dictionaries with keys corresponding to each node's attributes at that time.
Example:
Get the complete testbed status::
async with SidecarAsyncClient() as sidecar:
nodes_status = await sidecar.nodes_status()
print(nodes_status[1]['usrp_type'])
"""
return await self._probe_category('nodes')
[docs]
async def set_nodes_triples(self, *triples):
"""
Parameters:
triples: each argument is expected to be a tuple (or list)
of the form ``id, attribute, value``. The same node
id can be used in several triples.
Example:
To mark node 1 as unavailable and node 2 as turned off::
await sidecar.set_nodes_triples(
(1, 'available', 'ok'),
(2, 'cmc_on_off', 'off'),
)
"""
return await self._set_triples('nodes', triples)
[docs]
async def set_node_attribute(self, id, attribute, value):
"""
Parameters:
id: a node_id as an int or str
attribute(str): the name of the attribute to be written
value(str): the new value
Example:
To mark node 1 as unavailable::
await sidecar.set_node_attribute(1, 'available', 'ko')
"""
return await self.set_nodes_triples((id, attribute, value))
# phones
[docs]
async def phones_status(self):
"Just like ``nodes_status`` but on phones"
return await self._probe_category('phones')
[docs]
async def set_phones_triples(self, *triples):
"Identical to ``set_nodes_triples`` but on phones"
return await self._set_triples('phones', triples)
[docs]
async def set_phone_attribute(self, id, attribute, value):
"""
Similar to ``set_node_attribute`` on a phone
Example:
To mark phone 2 as being turned off (although this is constantly
recomputed by the phones monitor)::
await sidecar.set_phone_attribute(2, 'airplane_mode', 'on')
"""
return await self.set_phones_triples((id, attribute, value))
[docs]
class SidecarAsyncClient(websockets.connect):
"""
This class behaves as an asynchronous context manager for
talking with the R2lab sidecar server.
Optional arguments `args` and `kwds` are passed as-is to
`websockets.client.connect`, see
https://websockets.readthedocs.io/en/stable/api.html#websockets.client.connect
Example:
Set a node as available from some asynchronous code::
async with SidecarAsyncClient() as sidecar:
await sidecar.set_node_attribute(1, 'available', 'ok')
In this example, the ``sidecar`` object is
a :class:`~r2lab.sidecar.SidecarProtocol` instance.
Note:
**About SSL server certificate verification:**
Verifying server certificates relies on a set of "trusted" CAs.
Web browsers do come with a maintained set of such trust anchors,
however the standard Python installation has no such knowledge;
and for that reason attempting to check for the testbed's certificate
will fail unless you've taken the time to somehow configure all this.
If you just want to probe the testbed though, this looks like a lot
of hassle. In that case you can turn off server verification as foolows::
import ssl
ssl_context = ssl.SSLContext()
# this is where we ask for no verification
ssl_context.verify_mode = ssl.CERT_NONE
async with SidecarSyncClient(ssl=ssl_context) as sidecar:
await sidecar.set_node_attribute(1, 'available', 'ok')
"""
def __init__(self, url=default_sidecar_url, *args, **kwds):
if 'create_protocol' in kwds:
logging.error("should not overwrite create_protocol")
super().__init__(url, create_protocol=SidecarProtocol,
*args, **kwds)
# -------- synchronous wrapper
[docs]
class SidecarSyncClient:
"""
A synchronous wrapper to perform the same operations
from sequential code without having to worry about the
event loop, asynchronous context manager and coroutine business.
Example:
Set a node as available from some synchronous code::
with SidecarSyncClient() as sidecar:
sidecar.set_node_attribute(1, 'available', 'ok')
.. warning::
This is a convenience only, it would be unwise, obviously,
to call this from asynchronous code; if it works at all.
Use :class:`~r2lab.sidecar.SidecarAsyncClient` instead
in this use case.
"""
def __init__(self, url=default_sidecar_url, *args, **kwds):
self.aclient = SidecarAsyncClient(url, *args, **kwds)
self.proto = None
def connect(self):
if self.proto:
logging.warning("SyncClient already connected")
async def coro():
self.proto = await self.aclient
asyncio.get_event_loop().run_until_complete(coro())
def close(self):
if not self.proto:
logging.warning("SyncClient not connected")
else:
async def coro():
await self.proto.close()
self.proto = None
asyncio.get_event_loop().run_until_complete(coro())
# of course we can't inherit from the async class as-is
# so let's wrap the async methods
def __getattr__(self, method):
#print(f"SyncClient resolving method {method}")
if method not in dir(SidecarProtocol):
raise AttributeError(f"no such method {method} in SidecarProtocol")
def wrapper(*args, **kwds):
async def coro():
return await getattr(self.proto, method)(*args, **kwds)
return asyncio.get_event_loop().run_until_complete(coro())
return wrapper
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc, traceback):
self.close()