A REST API for cloud embedded board reservation.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1682 lines
46 KiB

  1. #
  2. # Copyright (c) 2020 The FreeBSD Foundation
  3. #
  4. # This software1 was developed by John-Mark Gurney under sponsorship
  5. # from the FreeBSD Foundation.
  6. #
  7. # Redistribution and use in source and binary forms, with or without
  8. # modification, are permitted provided that the following conditions
  9. # are met:
  10. # 1. Redistributions of source code must retain the above copyright
  11. # notice, this list of conditions and the following disclaimer.
  12. # 2. Redistributions in binary form must reproduce the above copyright
  13. # notice, this list of conditions and the following disclaimer in the
  14. # documentation and/or other materials provided with the distribution.
  15. #
  16. # THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
  17. # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  18. # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
  19. # ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
  20. # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  21. # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
  22. # OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
  23. # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
  24. # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
  25. # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
  26. # SUCH DAMAGE.
  27. #
  28. from dataclasses import dataclass
  29. from functools import lru_cache, wraps
  30. from io import StringIO
  31. from typing import Optional, Union, Dict, Any
  32. from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException
  33. from fastapi import Path, Request
  34. from fastapi.security import OAuth2PasswordBearer
  35. from fastapi.websockets import WebSocket
  36. from httpx import AsyncClient, Auth
  37. from starlette.responses import JSONResponse
  38. from starlette.status import HTTP_200_OK
  39. from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, \
  40. HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
  41. from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
  42. from unittest.mock import create_autospec, patch, AsyncMock, Mock, PropertyMock
  43. from wsfwd import WSFWDServer, WSFWDClient, timeout, _tbprinter
  44. # For WebSocket testing
  45. from hypercorn.config import Config
  46. from hypercorn.asyncio import serve
  47. from . import config
  48. from .data import *
  49. from .abstract import *
  50. from .snmp import *
  51. from .mocks import *
  52. from .iso8601 import parse_date
  53. import asyncio
  54. import contextlib
  55. import json
  56. import logging
  57. import orm
  58. import os
  59. import shutil
  60. import socket
  61. import sqlite3
  62. import subprocess
  63. import sys
  64. import tempfile
  65. import time
  66. import ucl
  67. import unittest
  68. import urllib
  69. import websockets
  70. epsilon = sys.float_info.epsilon
  71. # fix up parse_socket_addr for hypercorn
  72. from hypercorn.utils import parse_socket_addr
  73. from hypercorn.asyncio import tcp_server
  74. def new_parse_socket_addr(domain, addr):
  75. if domain == socket.AF_UNIX:
  76. return (addr, -1)
  77. return parse_socket_addr(domain, addr)
  78. tcp_server.parse_socket_addr = new_parse_socket_addr
  79. def looptoutc(looptime):
  80. '''The argument looptime, which is a time stamp relative to the
  81. current event loop's clock, to UTC.
  82. It does this by calculating the current offset, and applying that
  83. offset. This will deal with any time drift issues as it is
  84. expected that the loop's clock does not stay in sync w/ UTC, but
  85. it does mean that large differences from the current time are less
  86. accurate. That is if the returned value - current UTC is large,
  87. then the accuracy of the time is not very high.
  88. Modern clocks are pretty accurate, but modern crystals do have an
  89. error that will accumulate over time.
  90. This is only tested to nanosecond precision, as floating point does
  91. not allow higher precision (and even if it did, as there is no way
  92. to get the offset between the two in a single call, it will likely
  93. introduce a larger offset than nanoseconds).
  94. '''
  95. loop = asyncio.get_running_loop()
  96. curlooptime = loop.time()
  97. utctime = time.time()
  98. off = looptime - curlooptime
  99. return utctime + off
  100. def utctoloop(utctime):
  101. '''For documentation, see looptoutc. This is the inverse, but
  102. all the warnings in there apply here as well.
  103. '''
  104. loop = asyncio.get_running_loop()
  105. looptime = loop.time()
  106. curutctime = time.time()
  107. off = utctime - curutctime
  108. return looptime + off
  109. async def log_event(tag, board=None, user=None, extra={}):
  110. info = extra.copy()
  111. info['event'] = tag
  112. if board is not None:
  113. info['board_name'] = board.name
  114. else:
  115. info.pop('board_name', None)
  116. if user is not None:
  117. info['user'] = user
  118. else:
  119. info.pop('user', None)
  120. t = time.time()
  121. info['date'] = time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime(t)) + \
  122. '.%03dZ' % (int((t * 1000) % 1000),)
  123. logging.info(json.dumps(info))
  124. class TimeOut(Attribute):
  125. '''
  126. Implement a TimeOut functionality. The argument val (first and
  127. only) to __init__ is a number of seconds for the timeout to
  128. last. This will start the time ticking on activation. If it
  129. is not deactivated before the timer expires, it will deactivate
  130. the board itself.
  131. Not that this uses the asyncio loop timescale and NOT UTC. This
  132. means that over large durations, the clock will drift. This means
  133. that over time, the "expired" time will change.
  134. While the board is not activated, it will display the timeout in
  135. seconds. When the board is activated, the getvalue will return
  136. the time the board will be deactivated.
  137. '''
  138. defattrname = 'timeout'
  139. def __init__(self, val):
  140. self._value = val
  141. self._brd = None
  142. # proteted by brd.lock
  143. self._cb = None
  144. self._task = None
  145. self._exp = None
  146. async def getvalue(self):
  147. if self._exp is None:
  148. return self._value
  149. t = looptoutc(self._exp)
  150. return time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime(t)) + \
  151. '.%03dZ' % (int((t * 1000) % 1000),)
  152. async def setvalue(self, v):
  153. if self._exp is None:
  154. raise RuntimeError('cannot set when not activate')
  155. loop = asyncio.get_running_loop()
  156. t = parse_date(v).timestamp()
  157. loopvalue = utctoloop(t)
  158. async with self._brd.lock:
  159. if loopvalue > self._exp:
  160. raise ValueError('value in the future')
  161. # I really don't know how test this
  162. if self._task is not None:
  163. raise ValueError('should never happen')
  164. await self.deactivate(self._brd)
  165. self._cb = loop.call_at(loopvalue, self.timeout_callback)
  166. self._exp = self._cb.when()
  167. async def activate(self, brd):
  168. assert brd.lock.locked()
  169. loop = asyncio.get_running_loop()
  170. self._brd = brd
  171. self._cb = loop.call_later(self._value, self.timeout_callback)
  172. self._exp = self._cb.when()
  173. self._task = None
  174. async def deactivate(self, brd):
  175. assert brd.lock.locked()
  176. if self._cb is not None:
  177. self._cb.cancel()
  178. self._cb = None
  179. if self._task is not None:
  180. self._task.cancel()
  181. # awaiting on a canceled task blocks, spin the
  182. # loop and make sure it was cancelled
  183. await asyncio.sleep(0)
  184. assert self._task.cancelled()
  185. self._task = None
  186. self._exp = None
  187. @_tbprinter
  188. async def timeout_coro(self):
  189. print('tc1')
  190. async with self._brd.lock:
  191. print('tc2')
  192. await self._brd.release()
  193. print('tc3')
  194. def timeout_callback(self):
  195. self._task = asyncio.create_task(self.timeout_coro())
  196. class EtherIface(DefROAttribute):
  197. defattrname = 'eiface'
  198. async def activate(self, brd):
  199. cmd = ('ifconfig', self._value, 'vnet', brd.name,)
  200. sub = await asyncio.create_subprocess_exec(*cmd,
  201. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  202. stderr=subprocess.DEVNULL)
  203. ret = await sub.wait()
  204. if ret:
  205. raise RuntimeError('activate failed: %d' % ret)
  206. class SerialConsole(DefROAttribute):
  207. defattrname = 'console'
  208. async def activate(self, brd):
  209. devname = os.path.basename(self._value)
  210. for i in (devname, devname + '.*'):
  211. cmd = ('devfs', '-m', brd.attrs['devfspath'], 'rule',
  212. 'apply', 'path', i, 'unhide', )
  213. sub = await asyncio.create_subprocess_exec(*cmd,
  214. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  215. stderr=subprocess.DEVNULL)
  216. ret = await sub.wait()
  217. if ret:
  218. raise RuntimeError('activate failed: %d' % ret)
  219. class BoardImpl:
  220. def __init__(self, name, brdclass, options):
  221. self.name = name
  222. self.brdclass = brdclass
  223. self.options = options
  224. self.reserved = False
  225. self.attrmap = {}
  226. self.lock = asyncio.Lock()
  227. for i in options:
  228. cls, kwargs = i
  229. opt = cls(**kwargs)
  230. if opt.defattrname in self.attrmap:
  231. raise ValueError(
  232. 'attribute name %s duplicated' %
  233. repr(opt.defattrname))
  234. self.attrmap[opt.defattrname] = opt
  235. self.attrcache = {}
  236. def __repr__(self): #pragma: no cover
  237. return repr(Board.from_orm(self))
  238. async def reserve(self):
  239. assert self.lock.locked() and not self.reserved
  240. self.reserved = True
  241. async def release(self):
  242. assert self.lock.locked() and self.reserved
  243. self.reserved = False
  244. async def update_attrs(self, **attrs):
  245. assert self.lock.locked() and self.reserved
  246. for i in attrs:
  247. self.attrcache[i] = await self.attrmap[i].setvalue(attrs[i])
  248. async def update(self):
  249. for i in self.attrmap:
  250. self.attrcache[i] = await self.attrmap[i].getvalue()
  251. async def activate(self):
  252. assert self.lock.locked() and self.reserved
  253. for i in self.attrmap.values():
  254. await i.activate(self)
  255. async def deactivate(self):
  256. assert self.lock.locked() and self.reserved
  257. for i in self.attrmap.values():
  258. await i.deactivate(self)
  259. def add_info(self, d):
  260. self.attrcache.update(d)
  261. def clean_info(self):
  262. # clean up attributes
  263. for i in set(self.attrcache) - set(self.attrmap):
  264. del self.attrcache[i]
  265. @property
  266. def attrs(self):
  267. return dict(self.attrcache)
  268. @dataclass
  269. class BITEError(Exception):
  270. errobj: Error
  271. status_code: int
  272. class BoardManager(object):
  273. _option_map = dict(
  274. etheriface=EtherIface,
  275. serialconsole=SerialConsole,
  276. snmppower=SNMPPower,
  277. )
  278. def __init__(self, cls_info, boards):
  279. # add the name to the classes
  280. classes = { k: dict(clsname=k, **cls_info[k]) for k in cls_info }
  281. self.board_class_info = classes
  282. self.boards = dict(**{ x.name: x for x in
  283. (BoardImpl(**y) for y in boards)})
  284. @classmethod
  285. def from_settings(cls, settings):
  286. return cls.from_ucl(settings.board_conf)
  287. @classmethod
  288. def from_ucl(cls, fname):
  289. with open(fname) as fp:
  290. conf = ucl.load(fp.read())
  291. classes = conf['classes']
  292. brds = conf['boards']
  293. makeopt = lambda x: (cls._option_map[x['cls']], { k: v for k, v in x.items() if k != 'cls' })
  294. for i in brds:
  295. opt = i['options']
  296. opt[:] = [ makeopt(x) for x in opt ]
  297. return cls(classes, brds)
  298. def classes(self):
  299. return self.board_class_info
  300. def unhashable_lru():
  301. def newwrapper(fun):
  302. cache = {}
  303. @wraps(fun)
  304. def wrapper(*args, **kwargs):
  305. idargs = tuple(id(x) for x in args)
  306. idkwargs = tuple(sorted((k, id(v)) for k, v in
  307. kwargs.items()))
  308. k = (idargs, idkwargs)
  309. if k in cache:
  310. realargs, realkwargs, res = cache[k]
  311. if all(x is y for x, y in zip(args,
  312. realargs)) and all(realkwargs[x] is
  313. kwargs[x] for x in realkwargs):
  314. return res
  315. res = fun(*args, **kwargs)
  316. cache[k] = (args, kwargs, res)
  317. return res
  318. return wrapper
  319. return newwrapper
  320. class BiteAuth(Auth):
  321. def __init__(self, token):
  322. self.token = token
  323. def __eq__(self, o):
  324. return self.token == o.token
  325. def auth_flow(self, request):
  326. request.headers['Authorization'] = 'Bearer ' + self.token
  327. yield request
  328. # how to get coverage for this?
  329. @lru_cache()
  330. def get_settings(): # pragma: no cover
  331. return config.Settings()
  332. # how to get coverage for this?
  333. @unhashable_lru()
  334. def get_data(settings: config.Settings = Depends(get_settings)):
  335. #print(repr(settings))
  336. database = data.databases.Database('sqlite:///' + settings.db_file)
  337. d = make_orm(database)
  338. return d
  339. async def real_get_boardmanager(settings, data):
  340. brdmgr = BoardManager.from_settings(settings)
  341. # Clean up the database
  342. # XXX - This isn't a complete fix, we need a better solution.
  343. all = await data.BoardStatus.objects.all()
  344. await asyncio.gather(*(x.delete() for x in all))
  345. return brdmgr
  346. _global_lock = asyncio.Lock()
  347. _global_brdmgr = None
  348. async def get_boardmanager(settings: config.Settings = Depends(get_settings),
  349. data: data.DataWrapper = Depends(get_data)):
  350. global _global_brdmgr
  351. if _global_brdmgr is not None:
  352. return _global_brdmgr
  353. async with _global_lock:
  354. if _global_brdmgr is None:
  355. _global_brdmgr = await real_get_boardmanager(settings, data)
  356. return _global_brdmgr
  357. oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/nonexistent')
  358. def get_authorized_board_parms(board_id, token: str = Depends(oauth2_scheme),
  359. data: data.DataWrapper = Depends(get_data),
  360. brdmgr: BoardManager = Depends(get_boardmanager)):
  361. '''This dependancy is used to collect the parameters needed for
  362. the validate_board_params context manager.'''
  363. return dict(board_id=board_id, token=token, data=data, brdmgr=brdmgr)
  364. @contextlib.asynccontextmanager
  365. async def validate_board_params(board_id, data, brdmgr, user=None, token=None):
  366. '''This context manager checks to see if the request is authorized
  367. for the board_id. This requires that the board is reserved by
  368. the user, or the connection came from the board's jail (TBI).
  369. '''
  370. brd = brdmgr.boards[board_id]
  371. async with brd.lock:
  372. if user is None:
  373. user = await lookup_user(token, data)
  374. try:
  375. brduser = await data.BoardStatus.objects.get(board=board_id)
  376. except orm.exceptions.NoMatch:
  377. raise BITEError(
  378. status_code=HTTP_400_BAD_REQUEST,
  379. errobj=Error(error='Board not reserved.',
  380. board=Board.from_orm(brd)))
  381. if user != brduser.user:
  382. raise BITEError(
  383. status_code=HTTP_403_FORBIDDEN,
  384. errobj=Error(error='Board reserved by %s.' % repr(brduser.user),
  385. board=Board.from_orm(brd)))
  386. yield brd
  387. async def lookup_user(token: str = Depends(oauth2_scheme),
  388. data: data.DataWrapper = Depends(get_data)):
  389. try:
  390. return (await data.APIKey.objects.get(key=token)).user
  391. except orm.exceptions.NoMatch:
  392. raise HTTPException(
  393. status_code=HTTP_401_UNAUTHORIZED,
  394. detail='Invalid authentication credentials',
  395. headers={'WWW-Authenticate': 'Bearer'},
  396. )
  397. router = APIRouter()
  398. def board_priority(request: Request):
  399. # Get the board, if any, from the connection
  400. scope = request.scope
  401. return scope['server']
  402. @router.get('/board/classes', response_model=Dict[str, BoardClassInfo])
  403. async def get_board_classes(user: str = Depends(lookup_user),
  404. brdmgr: BoardManager = Depends(get_boardmanager)):
  405. return brdmgr.classes()
  406. @router.get('/board/{board_id}', response_model=Board)
  407. async def get_board_info(board_id, user: str = Depends(lookup_user),
  408. brdmgr: BoardManager = Depends(get_boardmanager)):
  409. brd = brdmgr.boards[board_id]
  410. await brd.update()
  411. return brd
  412. @router.post('/board/{board_id_or_class}/reserve', response_model=Union[Board, Error])
  413. async def reserve_board(board_id_or_class,
  414. req: Request,
  415. user: str = Depends(lookup_user),
  416. brdmgr: BoardManager = Depends(get_boardmanager),
  417. settings: config.Settings = Depends(get_settings),
  418. sshpubkey: str = Body(embed=True, default=None,
  419. title='Default public ssh key to install.'),
  420. data: data.DataWrapper = Depends(get_data)):
  421. #print('reserve:', repr(sshpubkey), repr(await req.body()))
  422. board_id = board_id_or_class
  423. brd = brdmgr.boards[board_id]
  424. async with brd.lock:
  425. try:
  426. obrdreq = await data.BoardStatus.objects.create(board=board_id,
  427. user=user)
  428. # XXX - There is a bug in orm where the returned
  429. # object has an incorrect board value
  430. # see: https://github.com/encode/orm/issues/47
  431. #assert obrdreq.board == board_id and \
  432. # obrdreq.user == user
  433. brdreq = await data.BoardStatus.objects.get(board=board_id,
  434. user=user)
  435. await brd.reserve()
  436. # XXX - orm isn't doing it's job here
  437. except sqlite3.IntegrityError:
  438. raise BITEError(
  439. status_code=HTTP_409_CONFLICT,
  440. errobj=Error(error='Board currently reserved.',
  441. board=Board.from_orm(brd)),
  442. )
  443. # Initialize board
  444. try:
  445. args = ( settings.setup_script, 'reserve',
  446. brd.name, user, )
  447. if sshpubkey is not None:
  448. args += (sshpubkey, )
  449. sub = await asyncio.create_subprocess_exec(*args,
  450. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  451. stdout, stderr = await sub.communicate()
  452. if sub.returncode:
  453. raise RuntimeError(sub.returncode, stderr)
  454. except Exception as e:
  455. await brdreq.delete()
  456. await brd.release()
  457. if isinstance(e, RuntimeError):
  458. retcode, stderr = e.args
  459. raise BITEError(
  460. status_code=HTTP_500_INTERNAL_SERVER_ERROR,
  461. errobj=Error(error=
  462. 'Failed to init board, ret: %d, stderr: %s' %
  463. (retcode, repr(stderr)),
  464. board=Board.from_orm(brd)),
  465. )
  466. raise
  467. brd.add_info(json.loads(stdout))
  468. await brd.activate()
  469. await log_event('reserve', user=user, board=brd)
  470. await brd.update()
  471. return brd
  472. class HandleExec(WSFWDServer):
  473. def __init__(self, *args, board_id, data, brdmgr, **kwargs):
  474. super().__init__(*args, **kwargs)
  475. self._board_id = board_id
  476. self._data = data
  477. self._brdmgr = brdmgr
  478. self._auth_user = None
  479. self._did_exec = False
  480. self._finish_handler = asyncio.Event()
  481. async def handle_auth(self, msg):
  482. try:
  483. user = await lookup_user(msg['auth']['bearer'],
  484. self._data)
  485. except Exception:
  486. raise RuntimeError('invalid token')
  487. self._auth_user = user
  488. async def shutdown(self):
  489. pass
  490. async def process_stdin(self, data):
  491. stdin = self._proc.stdin
  492. stdin.write(data)
  493. await stdin.drain()
  494. async def process_stdout(self):
  495. stdout = self._proc.stdout
  496. stream = self._stdout_stream
  497. try:
  498. while True:
  499. data = await stdout.read(16384)
  500. if not data:
  501. break
  502. self.sendstream(stream, data)
  503. await self.drain(stream)
  504. finally:
  505. await self.sendcmd(dict(cmd='chanclose', chan=stream))
  506. async def process_proc_wait(self):
  507. # Wait for process to exit
  508. code = await self._proc.wait()
  509. await self.sendcmd(dict(cmd='exit', code=code))
  510. # Make sure that all stdout is sent
  511. await self._stdout_task
  512. await self._stdin_event.wait()
  513. self._finish_handler.set()
  514. async def handle_chanclose(self, msg):
  515. self.clear_stream_handler(self._stdin_stream)
  516. self._proc.stdin.close()
  517. await self._proc.stdin.wait_closed()
  518. self._stdin_event.set()
  519. async def handle_exec(self, msg):
  520. if self._did_exec:
  521. raise RuntimeError('already did exec')
  522. if self._auth_user is None:
  523. raise RuntimeError('not authenticated')
  524. try:
  525. async with validate_board_params(self._board_id, self._data,
  526. self._brdmgr, user=self._auth_user) as brd:
  527. self._proc = await \
  528. asyncio.create_subprocess_exec('jexec',
  529. self._board_id, *msg['args'],
  530. stdin=subprocess.PIPE,
  531. stdout=subprocess.PIPE,
  532. stderr=subprocess.STDOUT)
  533. except BITEError as e:
  534. raise RuntimeError(e.errobj.error)
  535. self._did_exec = True
  536. self._stdin_stream = msg['stdin']
  537. self._stdout_stream = msg['stdout']
  538. # handle stdin
  539. self._stdin_event = asyncio.Event()
  540. self.add_stream_handler(msg['stdin'], self.process_stdin)
  541. # handle stdout
  542. self._stdout_task = asyncio.create_task(self.process_stdout())
  543. # handle process exit
  544. self._proc_wait_task = asyncio.create_task(self.process_proc_wait())
  545. async def get_finish_handler(self):
  546. return await self._finish_handler.wait()
  547. @router.websocket("/board/{board_id}/exec")
  548. async def board_exec_ws(
  549. board_id,
  550. websocket: WebSocket,
  551. brdmgr: BoardManager = Depends(get_boardmanager),
  552. settings: config.Settings = Depends(get_settings),
  553. data: data.DataWrapper = Depends(get_data)):
  554. await websocket.accept()
  555. try:
  556. async with HandleExec(websocket.receive_bytes,
  557. websocket.send_bytes, data=data,
  558. board_id=board_id, brdmgr=brdmgr) as server:
  559. await server.get_finish_handler()
  560. finally:
  561. await websocket.close()
  562. @router.post('/board/{board_id}/release', response_model=Union[Board, Error])
  563. async def release_board(board_id, user: str = Depends(lookup_user),
  564. brdmgr: BoardManager = Depends(get_boardmanager),
  565. settings: config.Settings = Depends(get_settings),
  566. data: data.DataWrapper = Depends(get_data)):
  567. brd = brdmgr.boards[board_id]
  568. async with brd.lock:
  569. # XXX - how to handle a release error?
  570. await log_event('release', user=user, board=brd)
  571. try:
  572. brduser = await data.BoardStatus.objects.get(board=board_id)
  573. if user != brduser.user:
  574. raise BITEError(
  575. status_code=HTTP_403_FORBIDDEN,
  576. errobj=Error(error='Board reserved by %s.' % repr(brduser.user),
  577. board=Board.from_orm(brd)))
  578. except orm.exceptions.NoMatch:
  579. raise BITEError(
  580. status_code=HTTP_400_BAD_REQUEST,
  581. errobj=Error(error='Board not reserved.',
  582. board=Board.from_orm(brd)),
  583. )
  584. await brd.deactivate()
  585. env = os.environ.copy()
  586. addkeys = { 'iface', 'ip', 'devfsrule', 'devfspath' }
  587. env.update((k, brd.attrs[k]) for k in addkeys if k in brd.attrs)
  588. sub = await asyncio.create_subprocess_exec(
  589. settings.setup_script, 'release', brd.name, user, env=env,
  590. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  591. stdout, stderr = await sub.communicate()
  592. retcode = sub.returncode
  593. if retcode:
  594. logging.error('release script failure: ' +
  595. 'board: %s, ret: %s, stderr: %s' % (repr(brd.name),
  596. retcode, repr(stderr)))
  597. raise BITEError(
  598. status_code=HTTP_500_INTERNAL_SERVER_ERROR,
  599. errobj=Error(error=
  600. 'Failed to release board, ret: %d, stderr: %s' %
  601. (retcode, repr(stderr)),
  602. board=Board.from_orm(brd)),
  603. )
  604. await data.BoardStatus.delete(brduser)
  605. await brd.release()
  606. brd.clean_info()
  607. await brd.update()
  608. return brd
  609. @router.post('/board/{board_id}/attrs', response_model=Union[Board, Error])
  610. async def set_board_attrs(
  611. attrs: Dict[str, Any],
  612. brdparams: dict = Depends(get_authorized_board_parms)):
  613. async with validate_board_params(**brdparams) as brd:
  614. await brd.update_attrs(**attrs)
  615. return brd
  616. @router.get('/board/',response_model=Dict[str, Board])
  617. async def get_boards(user: str = Depends(lookup_user),
  618. brdmgr: BoardManager = Depends(get_boardmanager)):
  619. brds = brdmgr.boards
  620. for i in brds:
  621. await brds[i].update()
  622. return brds
  623. @router.get('/')
  624. async def root_test(board_prio: dict = Depends(board_priority),
  625. settings: config.Settings = Depends(get_settings)):
  626. return { 'foo': 'bar', 'board': board_prio }
  627. def getApp():
  628. app = FastAPI()
  629. app.include_router(router)
  630. @app.exception_handler(BITEError)
  631. async def error_handler(request, exc):
  632. return JSONResponse(exc.errobj.dict(), status_code=exc.status_code)
  633. return app
  634. # uvicorn can't call the above function, while hypercorn can
  635. #app = getApp()
  636. class TestUnhashLRU(unittest.TestCase):
  637. def test_unhashlru(self):
  638. lsta = []
  639. lstb = []
  640. # that a wrapped function
  641. cachefun = unhashable_lru()(lambda x: object())
  642. # handles unhashable objects
  643. resa = cachefun(lsta)
  644. resb = cachefun(lstb)
  645. # that they return the same object again
  646. self.assertIs(resa, cachefun(lsta))
  647. self.assertIs(resb, cachefun(lstb))
  648. # that the object returned is not the same
  649. self.assertIsNot(cachefun(lsta), cachefun(lstb))
  650. # that a second wrapped funcion
  651. cachefun2 = unhashable_lru()(lambda x: object())
  652. # does not return the same object as the first cache
  653. self.assertIsNot(cachefun(lsta), cachefun2(lsta))
  654. class TestCommon(unittest.IsolatedAsyncioTestCase):
  655. def get_settings_override(self):
  656. return self.settings
  657. def get_data_override(self):
  658. return self.data
  659. def get_boardmanager_override(self):
  660. return self.brdmgr
  661. async def asyncSetUp(self):
  662. self.app = getApp()
  663. # setup test database
  664. self.dbtempfile = tempfile.NamedTemporaryFile()
  665. self.database = data.databases.Database('sqlite:///' +
  666. self.dbtempfile.name)
  667. self.data = make_orm(self.database)
  668. await data._setup_data(self.data)
  669. # setup settings
  670. self.settings = config.Settings(db_file=self.dbtempfile.name,
  671. setup_script='somesetupscript',
  672. board_conf = os.path.join('fixtures', 'board_conf.ucl')
  673. )
  674. self.brdmgr = BoardManager.from_settings(self.settings)
  675. self.app.dependency_overrides[get_settings] = \
  676. self.get_settings_override
  677. self.app.dependency_overrides[get_data] = self.get_data_override
  678. self.app.dependency_overrides[get_boardmanager] = \
  679. self.get_boardmanager_override
  680. # This is a different class then the other tests, as at the time of
  681. # writing, there is no async WebSocket client that will talk directly
  682. # to an ASGI server. The websockets client library can talk to a unix
  683. # domain socket, so that is used.
  684. class TestWebSocket(TestCommon):
  685. async def asyncSetUp(self):
  686. await super().asyncSetUp()
  687. d = os.path.realpath(tempfile.mkdtemp())
  688. self.basetempdir = d
  689. self.shutdown_event = asyncio.Event()
  690. self.socketpath = os.path.join(self.basetempdir, 'wstest.sock')
  691. config = Config()
  692. config.graceful_timeout = .01
  693. config.bind = [ 'unix:' + self.socketpath ]
  694. config.loglevel = 'ERROR'
  695. self.serv_task = asyncio.create_task(serve(self.app, config,
  696. shutdown_trigger=self.shutdown_event.wait))
  697. # get the unix domain socket connected
  698. # need a startup_trigger
  699. await asyncio.sleep(.01)
  700. async def asyncTearDown(self):
  701. self.app = None
  702. self.shutdown_event.set()
  703. await self.serv_task
  704. shutil.rmtree(self.basetempdir)
  705. self.basetempdir = None
  706. @patch('asyncio.create_subprocess_exec')
  707. @timeout(2)
  708. async def test_exec_sshd(self, cse):
  709. def wrapper(corofun):
  710. async def foo(*args, **kwargs):
  711. r = await corofun(*args, **kwargs)
  712. #print('foo:', repr(corofun), repr((args, kwargs)), repr(r))
  713. return r
  714. return foo
  715. async with websockets.connect('ws://foo/board/cora-1/exec',
  716. path=self.socketpath) as websocket, \
  717. WSFWDClient(wrapper(websocket.recv), wrapper(websocket.send)) as client:
  718. mstdout = AsyncMock()
  719. cmdargs = [ 'sshd', '-i' ]
  720. # that w/o auth, it fails
  721. with self.assertRaises(RuntimeError):
  722. await client.exec(cmdargs, stdin=1, stdout=2)
  723. # that and invalid token fails
  724. with self.assertRaises(RuntimeError):
  725. await client.auth(dict(bearer='invalidtoken'))
  726. # that a valid auth token works
  727. await client.auth(dict(bearer='thisisanapikey'))
  728. # That since the board isn't reserved, it fails
  729. with self.assertRaisesRegex(RuntimeError,
  730. 'Board not reserved.'):
  731. await client.exec([ 'sshd', '-i' ], stdin=1,
  732. stdout=2)
  733. # that when the board is reserved by the wrong user
  734. brd = self.brdmgr.boards['cora-1']
  735. obrdreq = await self.data.BoardStatus.objects.create(
  736. board='cora-1', user='bar')
  737. async with brd.lock:
  738. await brd.reserve()
  739. # that it fails
  740. with self.assertRaisesRegex(RuntimeError, 'Board reserved by \'bar\'.'):
  741. await client.exec([ 'sshd', '-i' ], stdin=1, stdout=2)
  742. brduser = await self.data.BoardStatus.objects.get(board='cora-1')
  743. obrdreq = await self.data.BoardStatus.delete(brduser)
  744. # that when the board is reserved by the correct user
  745. obrdreq = await self.data.BoardStatus.objects.create(
  746. board='cora-1', user='foo')
  747. echodata = b'somedata'
  748. wrap_subprocess_exec(cse, stdout=echodata, retcode=0)
  749. client.add_stream_handler(2, mstdout)
  750. proc = await client.exec([ 'sshd', '-i' ], stdin=1, stdout=2)
  751. with self.assertRaises(RuntimeError):
  752. await client.exec([ 'sshd', '-i' ], stdin=1, stdout=2)
  753. stdin, stdout = proc.stdin, proc.stdout
  754. stdin.write(echodata)
  755. await stdin.drain()
  756. # that we get our data
  757. self.assertEqual(await stdout.read(len(echodata)), echodata)
  758. # and that there is no more
  759. self.assertEqual(await stdout.read(len(echodata)), b'')
  760. # and we are truly at EOF
  761. self.assertTrue(stdout.at_eof())
  762. stdin.close()
  763. await stdin.wait_closed()
  764. await proc.wait()
  765. cse.assert_called_with('jexec', 'cora-1', *cmdargs,
  766. stdin=subprocess.PIPE, stdout=subprocess.PIPE,
  767. stderr=subprocess.STDOUT)
  768. # spin things, not sure best way to handle this
  769. await asyncio.sleep(.01)
  770. cse.return_value.stdin.close.assert_called_with()
  771. # Per RFC 5737 (https://tools.ietf.org/html/rfc5737):
  772. # The blocks 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2),
  773. # and 203.0.113.0/24 (TEST-NET-3) are provided for use in
  774. # documentation.
  775. # Note: this will not work under python before 3.8 before
  776. # IsolatedAsyncioTestCase was added. The tearDown has to happen
  777. # with the event loop running, otherwise the task and other things
  778. # do not get cleaned up properly.
  779. class TestBiteLab(TestCommon):
  780. async def asyncSetUp(self):
  781. await super().asyncSetUp()
  782. self.client = AsyncClient(app=self.app,
  783. base_url='http://testserver')
  784. async def asyncTearDown(self):
  785. self.app = None
  786. await self.client.aclose()
  787. self.client = None
  788. async def test_basic(self):
  789. res = await self.client.get('/')
  790. self.assertNotEqual(res.status_code, HTTP_404_NOT_FOUND)
  791. async def test_notauth(self):
  792. # test that simple accesses are denied
  793. res = await self.client.get('/board/classes')
  794. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  795. res = await self.client.get('/board/')
  796. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  797. # test that invalid api keys are denied
  798. res = await self.client.get('/board/classes',
  799. auth=BiteAuth('badapikey'))
  800. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  801. async def test_classes(self):
  802. # that when requesting the board classes
  803. res = await self.client.get('/board/classes',
  804. auth=BiteAuth('thisisanapikey'))
  805. # it is successful
  806. self.assertEqual(res.status_code, HTTP_200_OK)
  807. # and returns the correct data
  808. self.assertEqual(res.json(), { 'cora-z7s': BoardClassInfo(**{
  809. 'arch': 'arm-armv7', 'clsname': 'cora-z7s', }) })
  810. @patch('bitelab.BoardImpl.deactivate')
  811. @patch('asyncio.create_subprocess_exec')
  812. @patch('bitelab.snmp.snmpget')
  813. @patch('logging.error')
  814. async def test_board_release_script_fail(self, le, sg, cse, bideact):
  815. # that when snmpget returns False
  816. sg.return_value = False
  817. # that when the setup script will fail
  818. wrap_subprocess_exec(cse, stderr=b'error', retcode=1)
  819. # that the cora-1 board is reserved
  820. data = self.data
  821. brd = self.brdmgr.boards['cora-1']
  822. attrs = dict(iface='a', ip='b', devfsrule='c')
  823. async with brd.lock:
  824. await brd.reserve()
  825. obrdreq = await data.BoardStatus.objects.create(
  826. board='cora-1', user='foo')
  827. brd.attrcache.update(attrs)
  828. # that when the correct user releases the board
  829. res = await self.client.post('/board/cora-1/release',
  830. auth=BiteAuth('thisisanapikey'))
  831. # it fails
  832. self.assertEqual(res.status_code, HTTP_500_INTERNAL_SERVER_ERROR)
  833. # and returns the correct data
  834. info = Error(error='Failed to release board, ret: 1, stderr: b\'error\'',
  835. board=Board(name='cora-1',
  836. brdclass='cora-z7s',
  837. reserved=True,
  838. attrs=attrs,
  839. ),
  840. ).dict()
  841. self.assertEqual(res.json(), info)
  842. # and that it called the release script
  843. env = os.environ.copy()
  844. env.update(attrs)
  845. cse.assert_called_with(self.settings.setup_script,
  846. 'release', 'cora-1', 'foo', env=env,
  847. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  848. # and that the error got logged
  849. le.assert_called_with('release script failure: board: \'cora-1\', ret: 1, stderr: b\'error\'')
  850. @patch('bitelab.log_event')
  851. @patch('bitelab.BoardImpl.deactivate')
  852. @patch('bitelab.BoardImpl.activate')
  853. @patch('asyncio.create_subprocess_exec')
  854. @patch('bitelab.snmp.snmpget')
  855. async def test_board_reserve_release(self, sg, cse, biact, bideact, le):
  856. # that when releasing a board that is not yet reserved
  857. res = await self.client.post('/board/cora-1/release',
  858. auth=BiteAuth('anotherlongapikey'))
  859. # that it returns an error
  860. self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST)
  861. # that when snmpget returns False
  862. sg.return_value = False
  863. # that when the setup script will fail
  864. wrap_subprocess_exec(cse, stderr=b'error', retcode=1)
  865. # that reserving the board
  866. res = await self.client.post('/board/cora-1/reserve',
  867. auth=BiteAuth('thisisanapikey'))
  868. # that it is a failure
  869. self.assertEqual(res.status_code, HTTP_500_INTERNAL_SERVER_ERROR)
  870. # and returns the correct data
  871. info = Error(error='Failed to init board, ret: 1, stderr: b\'error\'',
  872. board=Board(name='cora-1',
  873. brdclass='cora-z7s',
  874. reserved=False,
  875. ),
  876. ).dict()
  877. self.assertEqual(res.json(), info)
  878. # and that it called the start script
  879. cse.assert_called_with(self.settings.setup_script, 'reserve',
  880. 'cora-1', 'foo', stdout=subprocess.PIPE,
  881. stderr=subprocess.PIPE)
  882. # that when the setup script returns
  883. wrap_subprocess_exec(cse,
  884. json.dumps(dict(ip='192.0.2.10',
  885. iface='epair0b',
  886. devfsrule='14',
  887. devfspath='devpath',
  888. )).encode('utf-8'))
  889. keydata = 'pubsshkey'
  890. # that reserving the board
  891. res = await self.client.post('/board/cora-1/reserve',
  892. json=dict(sshpubkey=keydata),
  893. auth=BiteAuth('thisisanapikey'))
  894. # that it is successful
  895. self.assertEqual(res.status_code, HTTP_200_OK)
  896. # and returns the correct data
  897. brdinfo = Board(name='cora-1',
  898. brdclass='cora-z7s',
  899. reserved=True,
  900. attrs=dict(power=False,
  901. ip='192.0.2.10',
  902. iface='epair0b',
  903. devfsrule='14',
  904. devfspath='devpath',
  905. ),
  906. ).dict()
  907. self.assertEqual(res.json(), brdinfo)
  908. # and that it called the start script
  909. cse.assert_called_with(self.settings.setup_script, 'reserve',
  910. 'cora-1', 'foo', 'pubsshkey', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  911. # and that the board was activated
  912. biact.assert_called()
  913. # and that log_event was called properly
  914. le.assert_called_with('reserve', user='foo',
  915. board=self.brdmgr.boards['cora-1'])
  916. # that another user reserving the board
  917. res = await self.client.post('/board/cora-1/reserve',
  918. auth=BiteAuth('anotherlongapikey'))
  919. # that the request is fails with a conflict
  920. self.assertEqual(res.status_code, HTTP_409_CONFLICT)
  921. # and returns the correct data
  922. info = {
  923. 'error': 'Board currently reserved.',
  924. 'board': brdinfo,
  925. }
  926. self.assertEqual(res.json(), info)
  927. # that another user releases the board
  928. res = await self.client.post('/board/cora-1/release',
  929. auth=BiteAuth('anotherlongapikey'))
  930. # that it is denied
  931. self.assertEqual(res.status_code, HTTP_403_FORBIDDEN)
  932. # and returns the correct data
  933. info = {
  934. 'error': 'Board reserved by \'foo\'.',
  935. 'board': brdinfo,
  936. }
  937. self.assertEqual(res.json(), info)
  938. # that when the correct user releases the board
  939. res = await self.client.post('/board/cora-1/release',
  940. auth=BiteAuth('thisisanapikey'))
  941. # it is allowed
  942. self.assertEqual(res.status_code, HTTP_200_OK)
  943. # and returns the correct data
  944. info = {
  945. 'name': 'cora-1',
  946. 'brdclass': 'cora-z7s',
  947. 'reserved': False,
  948. 'attrs': { 'power': False },
  949. }
  950. self.assertEqual(res.json(), info)
  951. # and that log_event was called properly
  952. le.assert_called_with('release', user='foo',
  953. board=self.brdmgr.boards['cora-1'])
  954. env = os.environ.copy()
  955. env['ip'] = brdinfo['attrs']['ip']
  956. env['iface'] = brdinfo['attrs']['iface']
  957. env['devfsrule'] = brdinfo['attrs']['devfsrule']
  958. env['devfspath'] = brdinfo['attrs']['devfspath']
  959. # and that it called the release script
  960. cse.assert_called_with(self.settings.setup_script, 'release',
  961. 'cora-1', 'foo', env=env,
  962. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  963. # and deactivated attributes
  964. bideact.assert_called()
  965. # that it can be reserved by a different user
  966. res = await self.client.post('/board/cora-1/reserve',
  967. auth=BiteAuth('anotherlongapikey'))
  968. # that it is successful
  969. self.assertEqual(res.status_code, HTTP_200_OK)
  970. @patch('bitelab.snmp.snmpget')
  971. async def test_board_info(self, sg):
  972. # that when snmpget returns False
  973. sg.return_value = False
  974. # that getting the board info
  975. res = await self.client.get('/board/',
  976. auth=BiteAuth('thisisanapikey'))
  977. # calls snmpget w/ the correct args
  978. sg.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  979. 'bool')
  980. # that it is successful
  981. self.assertEqual(res.status_code, HTTP_200_OK)
  982. # and returns the correct data
  983. info = {
  984. 'cora-1': {
  985. 'name': 'cora-1',
  986. 'brdclass': 'cora-z7s',
  987. 'reserved': False,
  988. 'attrs': { 'power': False },
  989. },
  990. }
  991. self.assertEqual(res.json(), info)
  992. # that when snmpget returns True
  993. sg.return_value = True
  994. # that getting the board info
  995. res = await self.client.get('/board/cora-1',
  996. auth=BiteAuth('thisisanapikey'))
  997. # calls snmpget w/ the correct args
  998. sg.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  999. 'bool')
  1000. # that it is successful
  1001. self.assertEqual(res.status_code, HTTP_200_OK)
  1002. # and returns the correct data
  1003. info = {
  1004. 'name': 'cora-1',
  1005. 'brdclass': 'cora-z7s',
  1006. 'reserved': False,
  1007. 'attrs': { 'power': True },
  1008. }
  1009. self.assertEqual(res.json(), info)
  1010. @patch('bitelab.snmp.snmpset')
  1011. async def test_board_attrs(self, ss):
  1012. data = self.data
  1013. # that when snmpset returns False
  1014. ss.return_value = False
  1015. attrs = dict(power=False)
  1016. # that setting the board attributes requires auth
  1017. res = await self.client.post('/board/cora-1/attrs',
  1018. auth=BiteAuth('badapi'),
  1019. json=attrs)
  1020. # that it fails auth
  1021. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  1022. # that when properly authorized, but board is not reserved
  1023. res = await self.client.post('/board/cora-1/attrs',
  1024. auth=BiteAuth('thisisanapikey'),
  1025. json=attrs)
  1026. # that it is a bad request
  1027. self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST)
  1028. # that the cora-1 board is reserved
  1029. brd = self.brdmgr.boards['cora-1']
  1030. async with brd.lock:
  1031. await brd.reserve()
  1032. obrdreq = await data.BoardStatus.objects.create(
  1033. board='cora-1', user='foo')
  1034. # that setting the board attributes
  1035. res = await self.client.post('/board/cora-1/attrs',
  1036. auth=BiteAuth('thisisanapikey'),
  1037. json=attrs)
  1038. # that it is successful
  1039. self.assertEqual(res.status_code, HTTP_200_OK)
  1040. # calls snmpset w/ the correct args
  1041. ss.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  1042. 'bool', False)
  1043. # and returns the correct data
  1044. info = {
  1045. 'name': 'cora-1',
  1046. 'brdclass': 'cora-z7s',
  1047. 'reserved': True,
  1048. 'attrs': { 'power': False },
  1049. }
  1050. self.assertEqual(res.json(), info)
  1051. # that when snmpset returns True
  1052. ss.return_value = True
  1053. attrs = dict(power=True)
  1054. # that setting the board attributes
  1055. res = await self.client.post('/board/cora-1/attrs',
  1056. auth=BiteAuth('thisisanapikey'),
  1057. json=attrs)
  1058. # calls snmpget w/ the correct args
  1059. ss.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  1060. 'bool', True)
  1061. # that it is successful
  1062. self.assertEqual(res.status_code, HTTP_200_OK)
  1063. # and returns the correct data
  1064. info = {
  1065. 'name': 'cora-1',
  1066. 'brdclass': 'cora-z7s',
  1067. 'reserved': True,
  1068. 'attrs': { 'power': True },
  1069. }
  1070. self.assertEqual(res.json(), info)
  1071. class TestBoardImpl(unittest.IsolatedAsyncioTestCase):
  1072. async def test_activate(self):
  1073. # that a board impl
  1074. opttup = create_autospec, dict(spec=Attribute)
  1075. brd = BoardImpl('foo', 'bar', [ opttup ])
  1076. (opt,) = tuple(brd.attrmap.values())
  1077. async with brd.lock:
  1078. await brd.reserve()
  1079. await brd.activate()
  1080. opt.activate.assert_called_with(brd)
  1081. async def test_deactivate(self):
  1082. # that a board impl
  1083. opttup = create_autospec, dict(spec=Attribute)
  1084. brd = BoardImpl('foo', 'bar', [ opttup ])
  1085. (opt,) = tuple(brd.attrmap.values())
  1086. async with brd.lock:
  1087. await brd.reserve()
  1088. await brd.deactivate()
  1089. opt.deactivate.assert_called_with(brd)
  1090. class TestLogEvent(unittest.IsolatedAsyncioTestCase):
  1091. @patch('time.time')
  1092. @patch('logging.info')
  1093. async def test_log_event(self, li, tt):
  1094. tag = 'eslkjdf'
  1095. user = 'weoijsdfkj'
  1096. brdname = 'woied'
  1097. extra = dict(something=2323, someelse='asdlfkj')
  1098. brd = BoardImpl(brdname, {}, [])
  1099. tt.return_value = 1607650392.384
  1100. await log_event(tag, user=user, board=brd, extra=extra)
  1101. res = dict(event=tag, board_name=brdname, user=user,
  1102. date='2020-12-11T01:33:12.384Z', **extra)
  1103. # that log_event logs the correct data
  1104. self.assertEqual(len(li.call_args[0]), 1)
  1105. # that the logged data can be parsed as json, and results
  1106. # in the correct object
  1107. self.assertEqual(json.loads(li.call_args[0][0]), res)
  1108. tt.return_value = 1607650393.289
  1109. # that log_event handles no board/user
  1110. await log_event(tag)
  1111. res = json.dumps(dict(event=tag,
  1112. date='2020-12-11T01:33:13.289Z'))
  1113. li.assert_called_with(res)
  1114. # that log_event doesn't allow board/user from extra
  1115. await log_event(tag, extra=dict(board_name='sldkfj',
  1116. user='sod'))
  1117. res = json.dumps(dict(event=tag,
  1118. date='2020-12-11T01:33:13.289Z'))
  1119. li.assert_called_with(res)
  1120. class TestAttrs(unittest.IsolatedAsyncioTestCase):
  1121. @patch('asyncio.create_subprocess_exec')
  1122. async def test_serialconsole(self, cse):
  1123. data = 'somepath'
  1124. sctup = (SerialConsole, dict(val=data))
  1125. brd = BoardImpl('foo', 'bar', [ sctup ])
  1126. sc = brd.attrmap['console']
  1127. self.assertEqual(sc.defattrname, 'console')
  1128. self.assertEqual(data, await sc.getvalue())
  1129. with self.assertRaises(TypeError):
  1130. await sc.setvalue(data)
  1131. devfspath = 'eifd'
  1132. brd.add_info(dict(devfspath=devfspath))
  1133. wrap_subprocess_exec(cse, retcode=0)
  1134. await sc.activate(brd)
  1135. cse.assert_any_call('devfs', '-m', devfspath, 'rule',
  1136. 'apply', 'path', os.path.basename(await sc.getvalue()),
  1137. 'unhide',
  1138. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  1139. stderr=subprocess.DEVNULL)
  1140. cse.assert_any_call('devfs', '-m', devfspath, 'rule',
  1141. 'apply', 'path',
  1142. os.path.basename(await sc.getvalue()) + '.*', 'unhide',
  1143. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  1144. stderr=subprocess.DEVNULL)
  1145. wrap_subprocess_exec(cse, retcode=1)
  1146. with self.assertRaises(RuntimeError):
  1147. await sc.activate(brd)
  1148. @patch('asyncio.create_subprocess_exec')
  1149. async def test_etheriface(self, cse):
  1150. eiface = 'aneiface'
  1151. eitup = EtherIface, dict(val=eiface)
  1152. brd = BoardImpl('foo', 'bar', [ eitup ])
  1153. ei = brd.attrmap['eiface']
  1154. self.assertEqual(ei.defattrname, 'eiface')
  1155. self.assertEqual(eiface, await ei.getvalue())
  1156. with self.assertRaises(TypeError):
  1157. await ei.setvalue('randomdata')
  1158. wrap_subprocess_exec(cse, retcode=0)
  1159. await ei.activate(brd)
  1160. cse.assert_called_with('ifconfig', eiface, 'vnet', 'foo',
  1161. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  1162. stderr=subprocess.DEVNULL)
  1163. wrap_subprocess_exec(cse, retcode=1)
  1164. with self.assertRaises(RuntimeError):
  1165. await ei.activate(brd)
  1166. async def test_multipleattrs(self):
  1167. attrs = [ (Power, dict()) ] * 2
  1168. # That multiple attributes w/ same name raises ValueError
  1169. with self.assertRaises(ValueError):
  1170. BoardImpl('foo', 'bar', attrs)
  1171. # Enough of this code depends upon the event loop using the
  1172. # code in BaseEventLoop wrt scheduling that this is not a
  1173. # terrible test. If this fails, it is likely the selector
  1174. # doesn't use a sane event loop.
  1175. @patch('asyncio.BaseEventLoop.time')
  1176. @patch('time.time')
  1177. async def test_looptoutc(self, ttime, beltime):
  1178. loop = asyncio.get_running_loop()
  1179. utctime = 19239
  1180. belsrctime = 892934
  1181. ttime.return_value = utctime
  1182. beltime.return_value = belsrctime
  1183. # that when given the current loop time, that
  1184. # it returns the current utc time
  1185. self.assertEqual(looptoutc(belsrctime), utctime)
  1186. # then when an offset is applied
  1187. import random
  1188. offset = random.random() * 1000000
  1189. offset = .000999 * 100000
  1190. # the utc has the same offset
  1191. # it'd be nice if this was exact, but it's not because
  1192. # floating point. 9 places gets us nanosecond precision
  1193. self.assertAlmostEqual(looptoutc(belsrctime + offset), utctime + offset, places=9)
  1194. # make sure w/ the new code, it round trips
  1195. sometime = 238974.34
  1196. self.assertAlmostEqual(utctoloop(looptoutc(sometime)), sometime)
  1197. self.assertAlmostEqual(looptoutc(utctoloop(sometime)), sometime)
  1198. @timeout(2)
  1199. @patch('asyncio.BaseEventLoop.time')
  1200. @patch('time.time')
  1201. async def test_timeout_vals(self, ttime, belt):
  1202. # that a TimeOut with args
  1203. totup = TimeOut, dict(val=10)
  1204. # passed to a board w/ the totup
  1205. brd = BoardImpl('foo', 'bar', [ totup ])
  1206. to = brd.attrmap['timeout']
  1207. with self.assertRaises(RuntimeError):
  1208. # that setting the value when not activate errors
  1209. await to.setvalue(234987)
  1210. # that an update will populate the attrs.
  1211. await brd.update()
  1212. # and that the board attrs will be present
  1213. # and contain the current timeout
  1214. self.assertEqual(brd.attrs, dict(timeout=10))
  1215. # that a given loop time
  1216. looptime = 100.384
  1217. belt.return_value = 100.384
  1218. # and a given UTC time (hu Dec 10 14:06:35 UTC 2020)
  1219. utctime = 1607609195.28
  1220. ttime.return_value = utctime
  1221. # that when reserved/activated
  1222. async with brd.lock:
  1223. await brd.reserve()
  1224. await brd.activate()
  1225. await brd.update()
  1226. # That it returns timeout seconds in the future.
  1227. self.assertEqual(brd.attrs, dict(timeout='2020-12-10T14:06:45.280Z'))
  1228. with self.assertRaises(ValueError):
  1229. # that setting it to a value farther into
  1230. # the future fails
  1231. await to.setvalue('2020-12-10T14:06:55.280Z')
  1232. with self.assertRaises(ValueError):
  1233. # that passing a non-Z ending (not UTC) date fails
  1234. await to.setvalue('2020-12-10T14:06:55.28')
  1235. # that setting it to a time slightly earlier
  1236. await to.setvalue('2020-12-10T14:06:44.280Z')
  1237. await brd.update()
  1238. # That it returns that time
  1239. self.assertEqual(brd.attrs, dict(timeout='2020-12-10T14:06:44.280Z'))
  1240. @timeout(2)
  1241. async def test_timeout(self):
  1242. # that a TimeOut with args
  1243. totup = TimeOut, dict(val=.01)
  1244. # passed to a board w/ the totup
  1245. brd = BoardImpl('foo', 'bar', [ totup ])
  1246. to = brd.attrmap['timeout']
  1247. # that when reserved/activated
  1248. async with brd.lock:
  1249. await brd.reserve()
  1250. await brd.activate()
  1251. evt = asyncio.Event()
  1252. loop = asyncio.get_running_loop()
  1253. loop.call_at(to._exp + epsilon, evt.set)
  1254. await evt.wait()
  1255. # that the board is no longer reserved
  1256. self.assertFalse(brd.reserved)
  1257. # that when reserved/activated/deactivated/released
  1258. async with brd.lock:
  1259. await brd.reserve()
  1260. await brd.activate()
  1261. exp = to._exp
  1262. await brd.deactivate()
  1263. await brd.release()
  1264. # that the expiration is no longer there
  1265. self.assertIsNone(to._exp)
  1266. print('z')
  1267. # and the timeout passes
  1268. evt = asyncio.Event()
  1269. loop = asyncio.get_running_loop()
  1270. loop.call_at(exp + epsilon, evt.set)
  1271. await evt.wait()
  1272. print('a')
  1273. # that when reserved/activated
  1274. async with brd.lock:
  1275. await brd.reserve()
  1276. await brd.activate()
  1277. print('b')
  1278. # but the board is locked for some reason
  1279. await brd.lock.acquire()
  1280. print('c')
  1281. # and the callback is called
  1282. await asyncio.sleep(.02)
  1283. print('d')
  1284. # that the task has been scheduled
  1285. self.assertIsNotNone(to._task)
  1286. print('e')
  1287. # that it can be deactivated
  1288. await brd.deactivate()
  1289. print('f')
  1290. # and when the board lock is released
  1291. brd.lock.release()
  1292. print('g')
  1293. # that the board was not released
  1294. self.assertTrue(brd.reserved)