@@ -26,10 +26,10 @@
# SUCH DAMAGE.
# SUCH DAMAGE.
#
#
from typing import Optional, Union, Dict, Any
from dataclasses import dataclass
from dataclasses import dataclass
from functools import lru_cache, wraps
from functools import lru_cache, wraps
from io import StringIO
from io import StringIO
from typing import Optional, Union, Dict, Any
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException
from fastapi import Path, Request
from fastapi import Path, Request
@@ -53,6 +53,7 @@ from .data import *
from .abstract import *
from .abstract import *
from .snmp import *
from .snmp import *
from .mocks import *
from .mocks import *
from .iso8601 import parse_date
import asyncio
import asyncio
import contextlib
import contextlib
@@ -72,6 +73,8 @@ import unittest
import urllib
import urllib
import websockets
import websockets
epsilon = sys.float_info.epsilon
# fix up parse_socket_addr for hypercorn
# fix up parse_socket_addr for hypercorn
from hypercorn.utils import parse_socket_addr
from hypercorn.utils import parse_socket_addr
from hypercorn.asyncio import tcp_server
from hypercorn.asyncio import tcp_server
@@ -83,6 +86,47 @@ def new_parse_socket_addr(domain, addr):
tcp_server.parse_socket_addr = new_parse_socket_addr
tcp_server.parse_socket_addr = new_parse_socket_addr
def looptoutc(looptime):
'''The argument looptime, which is a time stamp relative to the
current event loop's clock, to UTC.
It does this by calculating the current offset, and applying that
offset. This will deal with any time drift issues as it is
expected that the loop's clock does not stay in sync w/ UTC, but
it does mean that large differences from the current time are less
accurate. That is if the returned value - current UTC is large,
then the accuracy of the time is not very high.
Modern clocks are pretty accurate, but modern crystals do have an
error that will accumulate over time.
This is only tested to nanosecond precision, as floating point does
not allow higher precision (and even if it did, as there is no way
to get the offset between the two in a single call, it will likely
introduce a larger offset than nanoseconds).
'''
loop = asyncio.get_running_loop()
curlooptime = loop.time()
utctime = time.time()
off = looptime - curlooptime
return utctime + off
def utctoloop(utctime):
'''For documentation, see looptoutc. This is the inverse, but
all the warnings in there apply here as well.
'''
loop = asyncio.get_running_loop()
looptime = loop.time()
curutctime = time.time()
off = utctime - curutctime
return looptime + off
async def log_event(tag, board=None, user=None, extra={}):
async def log_event(tag, board=None, user=None, extra={}):
info = extra.copy()
info = extra.copy()
info['event'] = tag
info['event'] = tag
@@ -103,8 +147,101 @@ async def log_event(tag, board=None, user=None, extra={}):
logging.info(json.dumps(info))
logging.info(json.dumps(info))
class TimeOut(DefROAttribute):
defattername = 'timeout'
class TimeOut(Attribute):
'''
Implement a TimeOut functionality. The argument val (first and
only) to __init__ is a number of seconds for the timeout to
last. This will start the time ticking on activation. If it
is not deactivated before the timer expires, it will deactivate
the board itself.
Not that this uses the asyncio loop timescale and NOT UTC. This
means that over large durations, the clock will drift. This means
that over time, the "expired" time will change.
While the board is not activated, it will display the timeout in
seconds. When the board is activated, the getvalue will return
the time the board will be deactivated.
'''
defattrname = 'timeout'
def __init__(self, val):
self._value = val
self._brd = None
# proteted by brd.lock
self._cb = None
self._task = None
self._exp = None
async def getvalue(self):
if self._exp is None:
return self._value
t = looptoutc(self._exp)
return time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime(t)) + \
'.%03dZ' % (int((t * 1000) % 1000),)
async def setvalue(self, v):
if self._exp is None:
raise RuntimeError('cannot set when not activate')
loop = asyncio.get_running_loop()
t = parse_date(v).timestamp()
loopvalue = utctoloop(t)
async with self._brd.lock:
if loopvalue > self._exp:
raise ValueError('value in the future')
# I really don't know how test this
if self._task is not None:
raise ValueError('should never happen')
await self.deactivate(self._brd)
self._cb = loop.call_at(loopvalue, self.timeout_callback)
self._exp = self._cb.when()
async def activate(self, brd):
assert brd.lock.locked()
loop = asyncio.get_running_loop()
self._brd = brd
self._cb = loop.call_later(self._value, self.timeout_callback)
self._exp = self._cb.when()
self._task = None
async def deactivate(self, brd):
assert brd.lock.locked()
if self._cb is not None:
self._cb.cancel()
self._cb = None
if self._task is not None:
self._task.cancel()
# awaiting on a canceled task blocks, spin the
# loop and make sure it was cancelled
await asyncio.sleep(0)
assert self._task.cancelled()
self._task = None
self._exp = None
@_tbprinter
async def timeout_coro(self):
print('tc1')
async with self._brd.lock:
print('tc2')
await self._brd.release()
print('tc3')
def timeout_callback(self):
self._task = asyncio.create_task(self.timeout_coro())
class EtherIface(DefROAttribute):
class EtherIface(DefROAttribute):
defattrname = 'eiface'
defattrname = 'eiface'
@@ -147,7 +284,13 @@ class BoardImpl:
self.attrmap = {}
self.attrmap = {}
self.lock = asyncio.Lock()
self.lock = asyncio.Lock()
for i in options:
for i in options:
self.attrmap[i.defattrname] = i
cls, kwargs = i
opt = cls(**kwargs)
if opt.defattrname in self.attrmap:
raise ValueError(
'attribute name %s duplicated' %
repr(opt.defattrname))
self.attrmap[opt.defattrname] = opt
self.attrcache = {}
self.attrcache = {}
@@ -177,13 +320,13 @@ class BoardImpl:
async def activate(self):
async def activate(self):
assert self.lock.locked() and self.reserved
assert self.lock.locked() and self.reserved
for i in self.options :
for i in self.attrmap.values() :
await i.activate(self)
await i.activate(self)
async def deactivate(self):
async def deactivate(self):
assert self.lock.locked() and self.reserved
assert self.lock.locked() and self.reserved
for i in self.options :
for i in self.attrmap.values() :
await i.deactivate(self)
await i.deactivate(self)
def add_info(self, d):
def add_info(self, d):
@@ -230,7 +373,7 @@ class BoardManager(object):
classes = conf['classes']
classes = conf['classes']
brds = conf['boards']
brds = conf['boards']
makeopt = lambda x: cls._option_map[x['cls']](** { k: v for k, v in x.items() if k != 'cls' })
makeopt = lambda x: (cls._option_map[x['cls']], { k: v for k, v in x.items() if k != 'cls' })
for i in brds:
for i in brds:
opt = i['options']
opt = i['options']
opt[:] = [ makeopt(x) for x in opt ]
opt[:] = [ makeopt(x) for x in opt ]
@@ -1232,8 +1375,9 @@ class TestBiteLab(TestCommon):
class TestBoardImpl(unittest.IsolatedAsyncioTestCase):
class TestBoardImpl(unittest.IsolatedAsyncioTestCase):
async def test_activate(self):
async def test_activate(self):
# that a board impl
# that a board impl
opt = create_autospec(Attribute)
brd = BoardImpl('foo', 'bar', [ opt ])
opttup = create_autospec, dict(spec=Attribute)
brd = BoardImpl('foo', 'bar', [ opttup ])
(opt,) = tuple(brd.attrmap.values())
async with brd.lock:
async with brd.lock:
await brd.reserve()
await brd.reserve()
@@ -1243,8 +1387,9 @@ class TestBoardImpl(unittest.IsolatedAsyncioTestCase):
async def test_deactivate(self):
async def test_deactivate(self):
# that a board impl
# that a board impl
opt = create_autospec(Attribute)
brd = BoardImpl('foo', 'bar', [ opt ])
opttup = create_autospec, dict(spec=Attribute)
brd = BoardImpl('foo', 'bar', [ opttup ])
(opt,) = tuple(brd.attrmap.values())
async with brd.lock:
async with brd.lock:
await brd.reserve()
await brd.reserve()
@@ -1299,7 +1444,11 @@ class TestAttrs(unittest.IsolatedAsyncioTestCase):
@patch('asyncio.create_subprocess_exec')
@patch('asyncio.create_subprocess_exec')
async def test_serialconsole(self, cse):
async def test_serialconsole(self, cse):
data = 'somepath'
data = 'somepath'
sc = SerialConsole(data)
sctup = (SerialConsole, dict(val=data))
brd = BoardImpl('foo', 'bar', [ sctup ])
sc = brd.attrmap['console']
self.assertEqual(sc.defattrname, 'console')
self.assertEqual(sc.defattrname, 'console')
@@ -1310,7 +1459,6 @@ class TestAttrs(unittest.IsolatedAsyncioTestCase):
devfspath = 'eifd'
devfspath = 'eifd'
brd = BoardImpl('foo', 'bar', [ sc ])
brd.add_info(dict(devfspath=devfspath))
brd.add_info(dict(devfspath=devfspath))
wrap_subprocess_exec(cse, retcode=0)
wrap_subprocess_exec(cse, retcode=0)
@@ -1337,7 +1485,11 @@ class TestAttrs(unittest.IsolatedAsyncioTestCase):
async def test_etheriface(self, cse):
async def test_etheriface(self, cse):
eiface = 'aneiface'
eiface = 'aneiface'
ei = EtherIface(eiface)
eitup = EtherIface, dict(val=eiface)
brd = BoardImpl('foo', 'bar', [ eitup ])
ei = brd.attrmap['eiface']
self.assertEqual(ei.defattrname, 'eiface')
self.assertEqual(ei.defattrname, 'eiface')
@@ -1346,8 +1498,6 @@ class TestAttrs(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(TypeError):
with self.assertRaises(TypeError):
await ei.setvalue('randomdata')
await ei.setvalue('randomdata')
brd = BoardImpl('foo', 'bar', [ ei ])
wrap_subprocess_exec(cse, retcode=0)
wrap_subprocess_exec(cse, retcode=0)
await ei.activate(brd)
await ei.activate(brd)
@@ -1360,9 +1510,172 @@ class TestAttrs(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(RuntimeError):
with self.assertRaises(RuntimeError):
await ei.activate(brd)
await ei.activate(brd)
async def test_multipleattrs(self):
attrs = [ (Power, dict()) ] * 2
# That multiple attributes w/ same name raises ValueError
with self.assertRaises(ValueError):
BoardImpl('foo', 'bar', attrs)
# Enough of this code depends upon the event loop using the
# code in BaseEventLoop wrt scheduling that this is not a
# terrible test. If this fails, it is likely the selector
# doesn't use a sane event loop.
@patch('asyncio.BaseEventLoop.time')
@patch('time.time')
async def test_looptoutc(self, ttime, beltime):
loop = asyncio.get_running_loop()
utctime = 19239
belsrctime = 892934
ttime.return_value = utctime
beltime.return_value = belsrctime
# that when given the current loop time, that
# it returns the current utc time
self.assertEqual(looptoutc(belsrctime), utctime)
# then when an offset is applied
import random
offset = random.random() * 1000000
offset = .000999 * 100000
# the utc has the same offset
# it'd be nice if this was exact, but it's not because
# floating point. 9 places gets us nanosecond precision
self.assertAlmostEqual(looptoutc(belsrctime + offset), utctime + offset, places=9)
# make sure w/ the new code, it round trips
sometime = 238974.34
self.assertAlmostEqual(utctoloop(looptoutc(sometime)), sometime)
self.assertAlmostEqual(looptoutc(utctoloop(sometime)), sometime)
@timeout(2)
@patch('asyncio.BaseEventLoop.time')
@patch('time.time')
async def test_timeout_vals(self, ttime, belt):
# that a TimeOut with args
totup = TimeOut, dict(val=10)
# passed to a board w/ the totup
brd = BoardImpl('foo', 'bar', [ totup ])
to = brd.attrmap['timeout']
with self.assertRaises(RuntimeError):
# that setting the value when not activate errors
await to.setvalue(234987)
# that an update will populate the attrs.
await brd.update()
# and that the board attrs will be present
# and contain the current timeout
self.assertEqual(brd.attrs, dict(timeout=10))
# that a given loop time
looptime = 100.384
belt.return_value = 100.384
# and a given UTC time (hu Dec 10 14:06:35 UTC 2020)
utctime = 1607609195.28
ttime.return_value = utctime
# that when reserved/activated
async with brd.lock:
await brd.reserve()
await brd.activate()
await brd.update()
# That it returns timeout seconds in the future.
self.assertEqual(brd.attrs, dict(timeout='2020-12-10T14:06:45.280Z'))
with self.assertRaises(ValueError):
# that setting it to a value farther into
# the future fails
await to.setvalue('2020-12-10T14:06:55.280Z')
with self.assertRaises(ValueError):
# that passing a non-Z ending (not UTC) date fails
await to.setvalue('2020-12-10T14:06:55.28')
# that setting it to a time slightly earlier
await to.setvalue('2020-12-10T14:06:44.280Z')
await brd.update()
# That it returns that time
self.assertEqual(brd.attrs, dict(timeout='2020-12-10T14:06:44.280Z'))
@timeout(2)
async def test_timeout(self):
async def test_timeout(self):
# that a TimeOut can be created
to = TimeOut(.1)
# that a TimeOut with args
totup = TimeOut, dict(val=.01)
# passed to a board w/ the totup
brd = BoardImpl('foo', 'bar', [ totup ])
to = brd.attrmap['timeout']
# that when reserved/activated
async with brd.lock:
await brd.reserve()
await brd.activate()
evt = asyncio.Event()
loop = asyncio.get_running_loop()
loop.call_at(to._exp + epsilon, evt.set)
await evt.wait()
# that the board is no longer reserved
self.assertFalse(brd.reserved)
# that when reserved/activated/deactivated/released
async with brd.lock:
await brd.reserve()
await brd.activate()
exp = to._exp
await brd.deactivate()
await brd.release()
# that the expiration is no longer there
self.assertIsNone(to._exp)
print('z')
# and the timeout passes
evt = asyncio.Event()
loop = asyncio.get_running_loop()
loop.call_at(exp + epsilon, evt.set)
await evt.wait()
print('a')
# that when reserved/activated
async with brd.lock:
await brd.reserve()
await brd.activate()
print('b')
# but the board is locked for some reason
await brd.lock.acquire()
print('c')
# and the callback is called
await asyncio.sleep(.02)
print('d')
# that the task has been scheduled
self.assertIsNotNone(to._task)
print('e')
# that it can be deactivated
await brd.deactivate()
print('f')
# and when the board lock is released
brd.lock.release()
print('g')
# that the board was not released
self.assertTrue(brd.reserved)
# that a board w/ the to
brd = BoardImpl('foo', 'bar', [ to ])