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.
 
 

1351 lines
37 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 typing import Optional, Union, Dict, Any
  29. from dataclasses import dataclass
  30. from functools import lru_cache, wraps
  31. from io import StringIO
  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. import asyncio
  53. import contextlib
  54. import json
  55. import logging
  56. import orm
  57. import os
  58. import shutil
  59. import socket
  60. import sqlite3
  61. import subprocess
  62. import sys
  63. import tempfile
  64. import time
  65. import ucl
  66. import unittest
  67. import urllib
  68. import websockets
  69. # fix up parse_socket_addr for hypercorn
  70. from hypercorn.utils import parse_socket_addr
  71. from hypercorn.asyncio import tcp_server
  72. def new_parse_socket_addr(domain, addr):
  73. if domain == socket.AF_UNIX:
  74. return (addr, -1)
  75. return parse_socket_addr(domain, addr)
  76. tcp_server.parse_socket_addr = new_parse_socket_addr
  77. async def log_event(tag, board=None, user=None, extra={}):
  78. info = extra.copy()
  79. info['event'] = tag
  80. if board is not None:
  81. info['board_name'] = board.name
  82. else:
  83. info.pop('board_name', None)
  84. if user is not None:
  85. info['user'] = user
  86. else:
  87. info.pop('user', None)
  88. t = time.time()
  89. info['date'] = time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime(t)) + \
  90. '.%03dZ' % (int((t * 1000) % 1000),)
  91. logging.info(json.dumps(info))
  92. class EtherIface(DefROAttribute):
  93. defattrname = 'eiface'
  94. async def activate(self, brd):
  95. cmd = ('ifconfig', self._value, 'vnet', brd.name,)
  96. sub = await asyncio.create_subprocess_exec(*cmd,
  97. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  98. stderr=subprocess.DEVNULL)
  99. ret = await sub.wait()
  100. if ret:
  101. raise RuntimeError('activate failed: %d' % ret)
  102. class SerialConsole(DefROAttribute):
  103. defattrname = 'console'
  104. async def activate(self, brd):
  105. devname = os.path.basename(self._value)
  106. for i in (devname, devname + '.*'):
  107. cmd = ('devfs', '-m', brd.attrs['devfspath'], 'rule',
  108. 'apply', 'path', i, 'unhide', )
  109. sub = await asyncio.create_subprocess_exec(*cmd,
  110. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  111. stderr=subprocess.DEVNULL)
  112. ret = await sub.wait()
  113. if ret:
  114. raise RuntimeError('activate failed: %d' % ret)
  115. class BoardImpl:
  116. def __init__(self, name, brdclass, options):
  117. self.name = name
  118. self.brdclass = brdclass
  119. self.options = options
  120. self.reserved = False
  121. self.attrmap = {}
  122. self.lock = asyncio.Lock()
  123. for i in options:
  124. self.attrmap[i.defattrname] = i
  125. self.attrcache = {}
  126. def __repr__(self): #pragma: no cover
  127. return repr(Board.from_orm(self))
  128. async def reserve(self):
  129. assert self.lock.locked() and not self.reserved
  130. self.reserved = True
  131. async def release(self):
  132. assert self.lock.locked() and self.reserved
  133. self.reserved = False
  134. async def update_attrs(self, **attrs):
  135. assert self.lock.locked() and self.reserved
  136. for i in attrs:
  137. self.attrcache[i] = await self.attrmap[i].setvalue(attrs[i])
  138. async def update(self):
  139. for i in self.attrmap:
  140. self.attrcache[i] = await self.attrmap[i].getvalue()
  141. async def activate(self):
  142. for i in self.options:
  143. await i.activate(self)
  144. async def deactivate(self):
  145. for i in self.options:
  146. await i.deactivate(self)
  147. def add_info(self, d):
  148. self.attrcache.update(d)
  149. def clean_info(self):
  150. # clean up attributes
  151. for i in set(self.attrcache) - set(self.attrmap):
  152. del self.attrcache[i]
  153. @property
  154. def attrs(self):
  155. return dict(self.attrcache)
  156. @dataclass
  157. class BITEError(Exception):
  158. errobj: Error
  159. status_code: int
  160. class BoardManager(object):
  161. _option_map = dict(
  162. etheriface=EtherIface,
  163. serialconsole=SerialConsole,
  164. snmppower=SNMPPower,
  165. )
  166. def __init__(self, cls_info, boards):
  167. # add the name to the classes
  168. classes = { k: dict(clsname=k, **cls_info[k]) for k in cls_info }
  169. self.board_class_info = classes
  170. self.boards = dict(**{ x.name: x for x in
  171. (BoardImpl(**y) for y in boards)})
  172. @classmethod
  173. def from_settings(cls, settings):
  174. return cls.from_ucl(settings.board_conf)
  175. @classmethod
  176. def from_ucl(cls, fname):
  177. with open(fname) as fp:
  178. conf = ucl.load(fp.read())
  179. classes = conf['classes']
  180. brds = conf['boards']
  181. makeopt = lambda x: cls._option_map[x['cls']](**{ k: v for k, v in x.items() if k != 'cls' })
  182. for i in brds:
  183. opt = i['options']
  184. opt[:] = [ makeopt(x) for x in opt ]
  185. return cls(classes, brds)
  186. def classes(self):
  187. return self.board_class_info
  188. def unhashable_lru():
  189. def newwrapper(fun):
  190. cache = {}
  191. @wraps(fun)
  192. def wrapper(*args, **kwargs):
  193. idargs = tuple(id(x) for x in args)
  194. idkwargs = tuple(sorted((k, id(v)) for k, v in
  195. kwargs.items()))
  196. k = (idargs, idkwargs)
  197. if k in cache:
  198. realargs, realkwargs, res = cache[k]
  199. if all(x is y for x, y in zip(args,
  200. realargs)) and all(realkwargs[x] is
  201. kwargs[x] for x in realkwargs):
  202. return res
  203. res = fun(*args, **kwargs)
  204. cache[k] = (args, kwargs, res)
  205. return res
  206. return wrapper
  207. return newwrapper
  208. class BiteAuth(Auth):
  209. def __init__(self, token):
  210. self.token = token
  211. def __eq__(self, o):
  212. return self.token == o.token
  213. def auth_flow(self, request):
  214. request.headers['Authorization'] = 'Bearer ' + self.token
  215. yield request
  216. # how to get coverage for this?
  217. @lru_cache()
  218. def get_settings(): # pragma: no cover
  219. return config.Settings()
  220. # how to get coverage for this?
  221. @unhashable_lru()
  222. def get_data(settings: config.Settings = Depends(get_settings)):
  223. #print(repr(settings))
  224. database = data.databases.Database('sqlite:///' + settings.db_file)
  225. d = make_orm(database)
  226. return d
  227. async def real_get_boardmanager(settings, data):
  228. brdmgr = BoardManager.from_settings(settings)
  229. # Clean up the database
  230. # XXX - This isn't a complete fix, we need a better solution.
  231. all = await data.BoardStatus.objects.all()
  232. await asyncio.gather(*(x.delete() for x in all))
  233. return brdmgr
  234. _global_lock = asyncio.Lock()
  235. _global_brdmgr = None
  236. async def get_boardmanager(settings: config.Settings = Depends(get_settings),
  237. data: data.DataWrapper = Depends(get_data)):
  238. global _global_brdmgr
  239. if _global_brdmgr is not None:
  240. return _global_brdmgr
  241. async with _global_lock:
  242. if _global_brdmgr is None:
  243. _global_brdmgr = await real_get_boardmanager(settings, data)
  244. return _global_brdmgr
  245. oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/nonexistent')
  246. def get_authorized_board_parms(board_id, token: str = Depends(oauth2_scheme),
  247. data: data.DataWrapper = Depends(get_data),
  248. brdmgr: BoardManager = Depends(get_boardmanager)):
  249. '''This dependancy is used to collect the parameters needed for
  250. the validate_board_params context manager.'''
  251. return dict(board_id=board_id, token=token, data=data, brdmgr=brdmgr)
  252. @contextlib.asynccontextmanager
  253. async def validate_board_params(board_id, data, brdmgr, user=None, token=None):
  254. '''This context manager checks to see if the request is authorized
  255. for the board_id. This requires that the board is reserved by
  256. the user, or the connection came from the board's jail (TBI).
  257. '''
  258. brd = brdmgr.boards[board_id]
  259. async with brd.lock:
  260. if user is None:
  261. user = await lookup_user(token, data)
  262. try:
  263. brduser = await data.BoardStatus.objects.get(board=board_id)
  264. except orm.exceptions.NoMatch:
  265. raise BITEError(
  266. status_code=HTTP_400_BAD_REQUEST,
  267. errobj=Error(error='Board not reserved.',
  268. board=Board.from_orm(brd)))
  269. if user != brduser.user:
  270. raise BITEError(
  271. status_code=HTTP_403_FORBIDDEN,
  272. errobj=Error(error='Board reserved by %s.' % repr(brduser.user),
  273. board=Board.from_orm(brd)))
  274. yield brd
  275. async def lookup_user(token: str = Depends(oauth2_scheme),
  276. data: data.DataWrapper = Depends(get_data)):
  277. try:
  278. return (await data.APIKey.objects.get(key=token)).user
  279. except orm.exceptions.NoMatch:
  280. raise HTTPException(
  281. status_code=HTTP_401_UNAUTHORIZED,
  282. detail='Invalid authentication credentials',
  283. headers={'WWW-Authenticate': 'Bearer'},
  284. )
  285. router = APIRouter()
  286. def board_priority(request: Request):
  287. # Get the board, if any, from the connection
  288. scope = request.scope
  289. return scope['server']
  290. @router.get('/board/classes', response_model=Dict[str, BoardClassInfo])
  291. async def get_board_classes(user: str = Depends(lookup_user),
  292. brdmgr: BoardManager = Depends(get_boardmanager)):
  293. return brdmgr.classes()
  294. @router.get('/board/{board_id}', response_model=Board)
  295. async def get_board_info(board_id, user: str = Depends(lookup_user),
  296. brdmgr: BoardManager = Depends(get_boardmanager)):
  297. brd = brdmgr.boards[board_id]
  298. await brd.update()
  299. return brd
  300. @router.post('/board/{board_id_or_class}/reserve', response_model=Union[Board, Error])
  301. async def reserve_board(board_id_or_class,
  302. req: Request,
  303. user: str = Depends(lookup_user),
  304. brdmgr: BoardManager = Depends(get_boardmanager),
  305. settings: config.Settings = Depends(get_settings),
  306. sshpubkey: str = Body(embed=True, default=None,
  307. title='Default public ssh key to install.'),
  308. data: data.DataWrapper = Depends(get_data)):
  309. #print('reserve:', repr(sshpubkey), repr(await req.body()))
  310. board_id = board_id_or_class
  311. brd = brdmgr.boards[board_id]
  312. async with brd.lock:
  313. try:
  314. obrdreq = await data.BoardStatus.objects.create(board=board_id,
  315. user=user)
  316. # XXX - There is a bug in orm where the returned
  317. # object has an incorrect board value
  318. # see: https://github.com/encode/orm/issues/47
  319. #assert obrdreq.board == board_id and \
  320. # obrdreq.user == user
  321. brdreq = await data.BoardStatus.objects.get(board=board_id,
  322. user=user)
  323. await brd.reserve()
  324. # XXX - orm isn't doing it's job here
  325. except sqlite3.IntegrityError:
  326. raise BITEError(
  327. status_code=HTTP_409_CONFLICT,
  328. errobj=Error(error='Board currently reserved.',
  329. board=Board.from_orm(brd)),
  330. )
  331. # Initialize board
  332. try:
  333. args = ( settings.setup_script, 'reserve',
  334. brd.name, user, )
  335. if sshpubkey is not None:
  336. args += (sshpubkey, )
  337. sub = await asyncio.create_subprocess_exec(*args,
  338. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  339. stdout, stderr = await sub.communicate()
  340. if sub.returncode:
  341. raise RuntimeError(sub.returncode, stderr)
  342. except Exception as e:
  343. await brdreq.delete()
  344. await brd.release()
  345. if isinstance(e, RuntimeError):
  346. retcode, stderr = e.args
  347. raise BITEError(
  348. status_code=HTTP_500_INTERNAL_SERVER_ERROR,
  349. errobj=Error(error=
  350. 'Failed to init board, ret: %d, stderr: %s' %
  351. (retcode, repr(stderr)),
  352. board=Board.from_orm(brd)),
  353. )
  354. raise
  355. brd.add_info(json.loads(stdout))
  356. await brd.activate()
  357. await log_event('reserve', user=user, board=brd)
  358. await brd.update()
  359. return brd
  360. class HandleExec(WSFWDServer):
  361. def __init__(self, *args, board_id, data, brdmgr, **kwargs):
  362. super().__init__(*args, **kwargs)
  363. self._board_id = board_id
  364. self._data = data
  365. self._brdmgr = brdmgr
  366. self._auth_user = None
  367. self._did_exec = False
  368. self._finish_handler = asyncio.Event()
  369. async def handle_auth(self, msg):
  370. try:
  371. user = await lookup_user(msg['auth']['bearer'],
  372. self._data)
  373. except Exception:
  374. raise RuntimeError('invalid token')
  375. self._auth_user = user
  376. async def shutdown(self):
  377. pass
  378. async def process_stdin(self, data):
  379. stdin = self._proc.stdin
  380. stdin.write(data)
  381. await stdin.drain()
  382. async def process_stdout(self):
  383. stdout = self._proc.stdout
  384. stream = self._stdout_stream
  385. try:
  386. while True:
  387. data = await stdout.read(16384)
  388. if not data:
  389. break
  390. self.sendstream(stream, data)
  391. await self.drain(stream)
  392. finally:
  393. await self.sendcmd(dict(cmd='chanclose', chan=stream))
  394. async def process_proc_wait(self):
  395. # Wait for process to exit
  396. code = await self._proc.wait()
  397. await self.sendcmd(dict(cmd='exit', code=code))
  398. # Make sure that all stdout is sent
  399. await self._stdout_task
  400. await self._stdin_event.wait()
  401. self._finish_handler.set()
  402. async def handle_chanclose(self, msg):
  403. self.clear_stream_handler(self._stdin_stream)
  404. self._proc.stdin.close()
  405. await self._proc.stdin.wait_closed()
  406. self._stdin_event.set()
  407. async def handle_exec(self, msg):
  408. if self._did_exec:
  409. raise RuntimeError('already did exec')
  410. if self._auth_user is None:
  411. raise RuntimeError('not authenticated')
  412. try:
  413. async with validate_board_params(self._board_id, self._data,
  414. self._brdmgr, user=self._auth_user) as brd:
  415. self._proc = await \
  416. asyncio.create_subprocess_exec('jexec',
  417. self._board_id, *msg['args'],
  418. stdin=subprocess.PIPE,
  419. stdout=subprocess.PIPE,
  420. stderr=subprocess.STDOUT)
  421. except BITEError as e:
  422. raise RuntimeError(e.errobj.error)
  423. self._did_exec = True
  424. self._stdin_stream = msg['stdin']
  425. self._stdout_stream = msg['stdout']
  426. # handle stdin
  427. self._stdin_event = asyncio.Event()
  428. self.add_stream_handler(msg['stdin'], self.process_stdin)
  429. # handle stdout
  430. self._stdout_task = asyncio.create_task(self.process_stdout())
  431. # handle process exit
  432. self._proc_wait_task = asyncio.create_task(self.process_proc_wait())
  433. async def get_finish_handler(self):
  434. return await self._finish_handler.wait()
  435. @router.websocket("/board/{board_id}/exec")
  436. async def board_exec_ws(
  437. board_id,
  438. websocket: WebSocket,
  439. brdmgr: BoardManager = Depends(get_boardmanager),
  440. settings: config.Settings = Depends(get_settings),
  441. data: data.DataWrapper = Depends(get_data)):
  442. await websocket.accept()
  443. try:
  444. async with HandleExec(websocket.receive_bytes,
  445. websocket.send_bytes, data=data,
  446. board_id=board_id, brdmgr=brdmgr) as server:
  447. await server.get_finish_handler()
  448. finally:
  449. await websocket.close()
  450. @router.post('/board/{board_id}/release', response_model=Union[Board, Error])
  451. async def release_board(board_id, user: str = Depends(lookup_user),
  452. brdmgr: BoardManager = Depends(get_boardmanager),
  453. settings: config.Settings = Depends(get_settings),
  454. data: data.DataWrapper = Depends(get_data)):
  455. brd = brdmgr.boards[board_id]
  456. async with brd.lock:
  457. # XXX - how to handle a release error?
  458. await log_event('release', user=user, board=brd)
  459. try:
  460. brduser = await data.BoardStatus.objects.get(board=board_id)
  461. if user != brduser.user:
  462. raise BITEError(
  463. status_code=HTTP_403_FORBIDDEN,
  464. errobj=Error(error='Board reserved by %s.' % repr(brduser.user),
  465. board=Board.from_orm(brd)))
  466. except orm.exceptions.NoMatch:
  467. raise BITEError(
  468. status_code=HTTP_400_BAD_REQUEST,
  469. errobj=Error(error='Board not reserved.',
  470. board=Board.from_orm(brd)),
  471. )
  472. await brd.deactivate()
  473. env = os.environ.copy()
  474. addkeys = { 'iface', 'ip', 'devfsrule', 'devfspath' }
  475. env.update((k, brd.attrs[k]) for k in addkeys if k in brd.attrs)
  476. sub = await asyncio.create_subprocess_exec(
  477. settings.setup_script, 'release', brd.name, user, env=env,
  478. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  479. stdout, stderr = await sub.communicate()
  480. retcode = sub.returncode
  481. if retcode:
  482. logging.error('release script failure: ' +
  483. 'board: %s, ret: %s, stderr: %s' % (repr(brd.name),
  484. retcode, repr(stderr)))
  485. raise BITEError(
  486. status_code=HTTP_500_INTERNAL_SERVER_ERROR,
  487. errobj=Error(error=
  488. 'Failed to release board, ret: %d, stderr: %s' %
  489. (retcode, repr(stderr)),
  490. board=Board.from_orm(brd)),
  491. )
  492. await data.BoardStatus.delete(brduser)
  493. await brd.release()
  494. brd.clean_info()
  495. await brd.update()
  496. return brd
  497. @router.post('/board/{board_id}/attrs', response_model=Union[Board, Error])
  498. async def set_board_attrs(
  499. attrs: Dict[str, Any],
  500. brdparams: dict = Depends(get_authorized_board_parms)):
  501. async with validate_board_params(**brdparams) as brd:
  502. await brd.update_attrs(**attrs)
  503. return brd
  504. @router.get('/board/',response_model=Dict[str, Board])
  505. async def get_boards(user: str = Depends(lookup_user),
  506. brdmgr: BoardManager = Depends(get_boardmanager)):
  507. brds = brdmgr.boards
  508. for i in brds:
  509. await brds[i].update()
  510. return brds
  511. @router.get('/')
  512. async def root_test(board_prio: dict = Depends(board_priority),
  513. settings: config.Settings = Depends(get_settings)):
  514. return { 'foo': 'bar', 'board': board_prio }
  515. def getApp():
  516. app = FastAPI()
  517. app.include_router(router)
  518. @app.exception_handler(BITEError)
  519. async def error_handler(request, exc):
  520. return JSONResponse(exc.errobj.dict(), status_code=exc.status_code)
  521. return app
  522. # uvicorn can't call the above function, while hypercorn can
  523. #app = getApp()
  524. class TestUnhashLRU(unittest.TestCase):
  525. def test_unhashlru(self):
  526. lsta = []
  527. lstb = []
  528. # that a wrapped function
  529. cachefun = unhashable_lru()(lambda x: object())
  530. # handles unhashable objects
  531. resa = cachefun(lsta)
  532. resb = cachefun(lstb)
  533. # that they return the same object again
  534. self.assertIs(resa, cachefun(lsta))
  535. self.assertIs(resb, cachefun(lstb))
  536. # that the object returned is not the same
  537. self.assertIsNot(cachefun(lsta), cachefun(lstb))
  538. # that a second wrapped funcion
  539. cachefun2 = unhashable_lru()(lambda x: object())
  540. # does not return the same object as the first cache
  541. self.assertIsNot(cachefun(lsta), cachefun2(lsta))
  542. class TestCommon(unittest.IsolatedAsyncioTestCase):
  543. def get_settings_override(self):
  544. return self.settings
  545. def get_data_override(self):
  546. return self.data
  547. def get_boardmanager_override(self):
  548. return self.brdmgr
  549. async def asyncSetUp(self):
  550. self.app = getApp()
  551. # setup test database
  552. self.dbtempfile = tempfile.NamedTemporaryFile()
  553. self.database = data.databases.Database('sqlite:///' +
  554. self.dbtempfile.name)
  555. self.data = make_orm(self.database)
  556. await data._setup_data(self.data)
  557. # setup settings
  558. self.settings = config.Settings(db_file=self.dbtempfile.name,
  559. setup_script='somesetupscript',
  560. board_conf = os.path.join('fixtures', 'board_conf.ucl')
  561. )
  562. self.brdmgr = BoardManager.from_settings(self.settings)
  563. self.app.dependency_overrides[get_settings] = \
  564. self.get_settings_override
  565. self.app.dependency_overrides[get_data] = self.get_data_override
  566. self.app.dependency_overrides[get_boardmanager] = \
  567. self.get_boardmanager_override
  568. # This is a different class then the other tests, as at the time of
  569. # writing, there is no async WebSocket client that will talk directly
  570. # to an ASGI server. The websockets client library can talk to a unix
  571. # domain socket, so that is used.
  572. class TestWebSocket(TestCommon):
  573. async def asyncSetUp(self):
  574. await super().asyncSetUp()
  575. d = os.path.realpath(tempfile.mkdtemp())
  576. self.basetempdir = d
  577. self.shutdown_event = asyncio.Event()
  578. self.socketpath = os.path.join(self.basetempdir, 'wstest.sock')
  579. config = Config()
  580. config.graceful_timeout = .01
  581. config.bind = [ 'unix:' + self.socketpath ]
  582. config.loglevel = 'ERROR'
  583. self.serv_task = asyncio.create_task(serve(self.app, config,
  584. shutdown_trigger=self.shutdown_event.wait))
  585. # get the unix domain socket connected
  586. # need a startup_trigger
  587. await asyncio.sleep(.01)
  588. async def asyncTearDown(self):
  589. self.app = None
  590. self.shutdown_event.set()
  591. await self.serv_task
  592. shutil.rmtree(self.basetempdir)
  593. self.basetempdir = None
  594. @patch('asyncio.create_subprocess_exec')
  595. @timeout(2)
  596. async def test_exec_sshd(self, cse):
  597. def wrapper(corofun):
  598. async def foo(*args, **kwargs):
  599. r = await corofun(*args, **kwargs)
  600. #print('foo:', repr(corofun), repr((args, kwargs)), repr(r))
  601. return r
  602. return foo
  603. async with websockets.connect('ws://foo/board/cora-1/exec',
  604. path=self.socketpath) as websocket, \
  605. WSFWDClient(wrapper(websocket.recv), wrapper(websocket.send)) as client:
  606. mstdout = AsyncMock()
  607. cmdargs = [ 'sshd', '-i' ]
  608. # that w/o auth, it fails
  609. with self.assertRaises(RuntimeError):
  610. await client.exec(cmdargs, stdin=1, stdout=2)
  611. # that and invalid token fails
  612. with self.assertRaises(RuntimeError):
  613. await client.auth(dict(bearer='invalidtoken'))
  614. # that a valid auth token works
  615. await client.auth(dict(bearer='thisisanapikey'))
  616. # That since the board isn't reserved, it fails
  617. with self.assertRaisesRegex(RuntimeError,
  618. 'Board not reserved.'):
  619. await client.exec([ 'sshd', '-i' ], stdin=1,
  620. stdout=2)
  621. # that when the board is reserved by the wrong user
  622. brd = self.brdmgr.boards['cora-1']
  623. obrdreq = await self.data.BoardStatus.objects.create(
  624. board='cora-1', user='bar')
  625. async with brd.lock:
  626. await brd.reserve()
  627. # that it fails
  628. with self.assertRaisesRegex(RuntimeError, 'Board reserved by \'bar\'.'):
  629. await client.exec([ 'sshd', '-i' ], stdin=1, stdout=2)
  630. brduser = await self.data.BoardStatus.objects.get(board='cora-1')
  631. obrdreq = await self.data.BoardStatus.delete(brduser)
  632. # that when the board is reserved by the correct user
  633. obrdreq = await self.data.BoardStatus.objects.create(
  634. board='cora-1', user='foo')
  635. echodata = b'somedata'
  636. wrap_subprocess_exec(cse, stdout=echodata, retcode=0)
  637. client.add_stream_handler(2, mstdout)
  638. proc = await client.exec([ 'sshd', '-i' ], stdin=1, stdout=2)
  639. with self.assertRaises(RuntimeError):
  640. await client.exec([ 'sshd', '-i' ], stdin=1, stdout=2)
  641. stdin, stdout = proc.stdin, proc.stdout
  642. stdin.write(echodata)
  643. await stdin.drain()
  644. # that we get our data
  645. self.assertEqual(await stdout.read(len(echodata)), echodata)
  646. # and that there is no more
  647. self.assertEqual(await stdout.read(len(echodata)), b'')
  648. # and we are truly at EOF
  649. self.assertTrue(stdout.at_eof())
  650. stdin.close()
  651. await stdin.wait_closed()
  652. await proc.wait()
  653. cse.assert_called_with('jexec', 'cora-1', *cmdargs,
  654. stdin=subprocess.PIPE, stdout=subprocess.PIPE,
  655. stderr=subprocess.STDOUT)
  656. # spin things, not sure best way to handle this
  657. await asyncio.sleep(.01)
  658. cse.return_value.stdin.close.assert_called_with()
  659. # Per RFC 5737 (https://tools.ietf.org/html/rfc5737):
  660. # The blocks 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2),
  661. # and 203.0.113.0/24 (TEST-NET-3) are provided for use in
  662. # documentation.
  663. # Note: this will not work under python before 3.8 before
  664. # IsolatedAsyncioTestCase was added. The tearDown has to happen
  665. # with the event loop running, otherwise the task and other things
  666. # do not get cleaned up properly.
  667. class TestBiteLab(TestCommon):
  668. async def asyncSetUp(self):
  669. await super().asyncSetUp()
  670. self.client = AsyncClient(app=self.app,
  671. base_url='http://testserver')
  672. async def asyncTearDown(self):
  673. self.app = None
  674. await self.client.aclose()
  675. self.client = None
  676. async def test_basic(self):
  677. res = await self.client.get('/')
  678. self.assertNotEqual(res.status_code, HTTP_404_NOT_FOUND)
  679. async def test_notauth(self):
  680. # test that simple accesses are denied
  681. res = await self.client.get('/board/classes')
  682. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  683. res = await self.client.get('/board/')
  684. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  685. # test that invalid api keys are denied
  686. res = await self.client.get('/board/classes',
  687. auth=BiteAuth('badapikey'))
  688. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  689. async def test_classes(self):
  690. # that when requesting the board classes
  691. res = await self.client.get('/board/classes',
  692. auth=BiteAuth('thisisanapikey'))
  693. # it is successful
  694. self.assertEqual(res.status_code, HTTP_200_OK)
  695. # and returns the correct data
  696. self.assertEqual(res.json(), { 'cora-z7s': BoardClassInfo(**{
  697. 'arch': 'arm-armv7', 'clsname': 'cora-z7s', }) })
  698. @patch('bitelab.BoardImpl.deactivate')
  699. @patch('asyncio.create_subprocess_exec')
  700. @patch('bitelab.snmp.snmpget')
  701. @patch('logging.error')
  702. async def test_board_release_script_fail(self, le, sg, cse, bideact):
  703. # that when snmpget returns False
  704. sg.return_value = False
  705. # that when the setup script will fail
  706. wrap_subprocess_exec(cse, stderr=b'error', retcode=1)
  707. # that the cora-1 board is reserved
  708. data = self.data
  709. brd = self.brdmgr.boards['cora-1']
  710. attrs = dict(iface='a', ip='b', devfsrule='c')
  711. async with brd.lock:
  712. await brd.reserve()
  713. obrdreq = await data.BoardStatus.objects.create(
  714. board='cora-1', user='foo')
  715. brd.attrcache.update(attrs)
  716. # that when the correct user releases the board
  717. res = await self.client.post('/board/cora-1/release',
  718. auth=BiteAuth('thisisanapikey'))
  719. # it fails
  720. self.assertEqual(res.status_code, HTTP_500_INTERNAL_SERVER_ERROR)
  721. # and returns the correct data
  722. info = Error(error='Failed to release board, ret: 1, stderr: b\'error\'',
  723. board=Board(name='cora-1',
  724. brdclass='cora-z7s',
  725. reserved=True,
  726. attrs=attrs,
  727. ),
  728. ).dict()
  729. self.assertEqual(res.json(), info)
  730. # and that it called the release script
  731. env = os.environ.copy()
  732. env.update(attrs)
  733. cse.assert_called_with(self.settings.setup_script,
  734. 'release', 'cora-1', 'foo', env=env,
  735. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  736. # and that the error got logged
  737. le.assert_called_with('release script failure: board: \'cora-1\', ret: 1, stderr: b\'error\'')
  738. @patch('bitelab.log_event')
  739. @patch('bitelab.BoardImpl.deactivate')
  740. @patch('bitelab.BoardImpl.activate')
  741. @patch('asyncio.create_subprocess_exec')
  742. @patch('bitelab.snmp.snmpget')
  743. async def test_board_reserve_release(self, sg, cse, biact, bideact, le):
  744. # that when releasing a board that is not yet reserved
  745. res = await self.client.post('/board/cora-1/release',
  746. auth=BiteAuth('anotherlongapikey'))
  747. # that it returns an error
  748. self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST)
  749. # that when snmpget returns False
  750. sg.return_value = False
  751. # that when the setup script will fail
  752. wrap_subprocess_exec(cse, stderr=b'error', retcode=1)
  753. # that reserving the board
  754. res = await self.client.post('/board/cora-1/reserve',
  755. auth=BiteAuth('thisisanapikey'))
  756. # that it is a failure
  757. self.assertEqual(res.status_code, HTTP_500_INTERNAL_SERVER_ERROR)
  758. # and returns the correct data
  759. info = Error(error='Failed to init board, ret: 1, stderr: b\'error\'',
  760. board=Board(name='cora-1',
  761. brdclass='cora-z7s',
  762. reserved=False,
  763. ),
  764. ).dict()
  765. self.assertEqual(res.json(), info)
  766. # and that it called the start script
  767. cse.assert_called_with(self.settings.setup_script, 'reserve',
  768. 'cora-1', 'foo', stdout=subprocess.PIPE,
  769. stderr=subprocess.PIPE)
  770. # that when the setup script returns
  771. wrap_subprocess_exec(cse,
  772. json.dumps(dict(ip='192.0.2.10',
  773. iface='epair0b',
  774. devfsrule='14',
  775. devfspath='devpath',
  776. )).encode('utf-8'))
  777. keydata = 'pubsshkey'
  778. # that reserving the board
  779. res = await self.client.post('/board/cora-1/reserve',
  780. json=dict(sshpubkey=keydata),
  781. auth=BiteAuth('thisisanapikey'))
  782. # that it is successful
  783. self.assertEqual(res.status_code, HTTP_200_OK)
  784. # and returns the correct data
  785. brdinfo = Board(name='cora-1',
  786. brdclass='cora-z7s',
  787. reserved=True,
  788. attrs=dict(power=False,
  789. ip='192.0.2.10',
  790. iface='epair0b',
  791. devfsrule='14',
  792. devfspath='devpath',
  793. ),
  794. ).dict()
  795. self.assertEqual(res.json(), brdinfo)
  796. # and that it called the start script
  797. cse.assert_called_with(self.settings.setup_script, 'reserve',
  798. 'cora-1', 'foo', 'pubsshkey', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  799. # and that the board was activated
  800. biact.assert_called()
  801. # and that log_event was called properly
  802. le.assert_called_with('reserve', user='foo',
  803. board=self.brdmgr.boards['cora-1'])
  804. # that another user reserving the board
  805. res = await self.client.post('/board/cora-1/reserve',
  806. auth=BiteAuth('anotherlongapikey'))
  807. # that the request is fails with a conflict
  808. self.assertEqual(res.status_code, HTTP_409_CONFLICT)
  809. # and returns the correct data
  810. info = {
  811. 'error': 'Board currently reserved.',
  812. 'board': brdinfo,
  813. }
  814. self.assertEqual(res.json(), info)
  815. # that another user releases the board
  816. res = await self.client.post('/board/cora-1/release',
  817. auth=BiteAuth('anotherlongapikey'))
  818. # that it is denied
  819. self.assertEqual(res.status_code, HTTP_403_FORBIDDEN)
  820. # and returns the correct data
  821. info = {
  822. 'error': 'Board reserved by \'foo\'.',
  823. 'board': brdinfo,
  824. }
  825. self.assertEqual(res.json(), info)
  826. # that when the correct user releases the board
  827. res = await self.client.post('/board/cora-1/release',
  828. auth=BiteAuth('thisisanapikey'))
  829. # it is allowed
  830. self.assertEqual(res.status_code, HTTP_200_OK)
  831. # and returns the correct data
  832. info = {
  833. 'name': 'cora-1',
  834. 'brdclass': 'cora-z7s',
  835. 'reserved': False,
  836. 'attrs': { 'power': False },
  837. }
  838. self.assertEqual(res.json(), info)
  839. # and that log_event was called properly
  840. le.assert_called_with('release', user='foo',
  841. board=self.brdmgr.boards['cora-1'])
  842. env = os.environ.copy()
  843. env['ip'] = brdinfo['attrs']['ip']
  844. env['iface'] = brdinfo['attrs']['iface']
  845. env['devfsrule'] = brdinfo['attrs']['devfsrule']
  846. env['devfspath'] = brdinfo['attrs']['devfspath']
  847. # and that it called the release script
  848. cse.assert_called_with(self.settings.setup_script, 'release',
  849. 'cora-1', 'foo', env=env,
  850. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  851. # and deactivated attributes
  852. bideact.assert_called()
  853. # that it can be reserved by a different user
  854. res = await self.client.post('/board/cora-1/reserve',
  855. auth=BiteAuth('anotherlongapikey'))
  856. # that it is successful
  857. self.assertEqual(res.status_code, HTTP_200_OK)
  858. @patch('bitelab.snmp.snmpget')
  859. async def test_board_info(self, sg):
  860. # that when snmpget returns False
  861. sg.return_value = False
  862. # that getting the board info
  863. res = await self.client.get('/board/',
  864. auth=BiteAuth('thisisanapikey'))
  865. # calls snmpget w/ the correct args
  866. sg.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  867. 'bool')
  868. # that it is successful
  869. self.assertEqual(res.status_code, HTTP_200_OK)
  870. # and returns the correct data
  871. info = {
  872. 'cora-1': {
  873. 'name': 'cora-1',
  874. 'brdclass': 'cora-z7s',
  875. 'reserved': False,
  876. 'attrs': { 'power': False },
  877. },
  878. }
  879. self.assertEqual(res.json(), info)
  880. # that when snmpget returns True
  881. sg.return_value = True
  882. # that getting the board info
  883. res = await self.client.get('/board/cora-1',
  884. auth=BiteAuth('thisisanapikey'))
  885. # calls snmpget w/ the correct args
  886. sg.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  887. 'bool')
  888. # that it is successful
  889. self.assertEqual(res.status_code, HTTP_200_OK)
  890. # and returns the correct data
  891. info = {
  892. 'name': 'cora-1',
  893. 'brdclass': 'cora-z7s',
  894. 'reserved': False,
  895. 'attrs': { 'power': True },
  896. }
  897. self.assertEqual(res.json(), info)
  898. @patch('bitelab.snmp.snmpset')
  899. async def test_board_attrs(self, ss):
  900. data = self.data
  901. # that when snmpset returns False
  902. ss.return_value = False
  903. attrs = dict(power=False)
  904. # that setting the board attributes requires auth
  905. res = await self.client.post('/board/cora-1/attrs',
  906. auth=BiteAuth('badapi'),
  907. json=attrs)
  908. # that it fails auth
  909. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  910. # that when properly authorized, but board is not reserved
  911. res = await self.client.post('/board/cora-1/attrs',
  912. auth=BiteAuth('thisisanapikey'),
  913. json=attrs)
  914. # that it is a bad request
  915. self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST)
  916. # that the cora-1 board is reserved
  917. brd = self.brdmgr.boards['cora-1']
  918. async with brd.lock:
  919. await brd.reserve()
  920. obrdreq = await data.BoardStatus.objects.create(
  921. board='cora-1', user='foo')
  922. # that setting the board attributes
  923. res = await self.client.post('/board/cora-1/attrs',
  924. auth=BiteAuth('thisisanapikey'),
  925. json=attrs)
  926. # that it is successful
  927. self.assertEqual(res.status_code, HTTP_200_OK)
  928. # calls snmpset w/ the correct args
  929. ss.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  930. 'bool', False)
  931. # and returns the correct data
  932. info = {
  933. 'name': 'cora-1',
  934. 'brdclass': 'cora-z7s',
  935. 'reserved': True,
  936. 'attrs': { 'power': False },
  937. }
  938. self.assertEqual(res.json(), info)
  939. # that when snmpset returns True
  940. ss.return_value = True
  941. attrs = dict(power=True)
  942. # that setting the board attributes
  943. res = await self.client.post('/board/cora-1/attrs',
  944. auth=BiteAuth('thisisanapikey'),
  945. json=attrs)
  946. # calls snmpget w/ the correct args
  947. ss.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  948. 'bool', True)
  949. # that it is successful
  950. self.assertEqual(res.status_code, HTTP_200_OK)
  951. # and returns the correct data
  952. info = {
  953. 'name': 'cora-1',
  954. 'brdclass': 'cora-z7s',
  955. 'reserved': True,
  956. 'attrs': { 'power': True },
  957. }
  958. self.assertEqual(res.json(), info)
  959. class TestBoardImpl(unittest.IsolatedAsyncioTestCase):
  960. async def test_activate(self):
  961. # that a board impl
  962. opt = create_autospec(Attribute)
  963. brd = BoardImpl('foo', 'bar', [ opt ])
  964. await brd.activate()
  965. opt.activate.assert_called_with(brd)
  966. async def test_deactivate(self):
  967. # that a board impl
  968. opt = create_autospec(Attribute)
  969. brd = BoardImpl('foo', 'bar', [ opt ])
  970. await brd.deactivate()
  971. opt.deactivate.assert_called_with(brd)
  972. class TestLogEvent(unittest.IsolatedAsyncioTestCase):
  973. @patch('time.time')
  974. @patch('logging.info')
  975. async def test_log_event(self, li, tt):
  976. tag = 'eslkjdf'
  977. user = 'weoijsdfkj'
  978. brdname = 'woied'
  979. extra = dict(something=2323, someelse='asdlfkj')
  980. brd = BoardImpl(brdname, {}, [])
  981. tt.return_value = 1607650392.384
  982. await log_event(tag, user=user, board=brd, extra=extra)
  983. res = dict(event=tag, board_name=brdname, user=user,
  984. date='2020-12-11T01:33:12.384Z', **extra)
  985. # that log_event logs the correct data
  986. self.assertEqual(len(li.call_args[0]), 1)
  987. # that the logged data can be parsed as json, and results
  988. # in the correct object
  989. self.assertEqual(json.loads(li.call_args[0][0]), res)
  990. tt.return_value = 1607650393.289
  991. # that log_event handles no board/user
  992. await log_event(tag)
  993. res = json.dumps(dict(event=tag,
  994. date='2020-12-11T01:33:13.289Z'))
  995. li.assert_called_with(res)
  996. # that log_event doesn't allow board/user from extra
  997. await log_event(tag, extra=dict(board_name='sldkfj',
  998. user='sod'))
  999. res = json.dumps(dict(event=tag,
  1000. date='2020-12-11T01:33:13.289Z'))
  1001. li.assert_called_with(res)
  1002. class TestAttrs(unittest.IsolatedAsyncioTestCase):
  1003. @patch('asyncio.create_subprocess_exec')
  1004. async def test_serialconsole(self, cse):
  1005. data = 'somepath'
  1006. sc = SerialConsole(data)
  1007. self.assertEqual(sc.defattrname, 'console')
  1008. self.assertEqual(data, await sc.getvalue())
  1009. with self.assertRaises(TypeError):
  1010. await sc.setvalue(data)
  1011. devfspath = 'eifd'
  1012. brd = BoardImpl('foo', 'bar', [ sc ])
  1013. brd.add_info(dict(devfspath=devfspath))
  1014. wrap_subprocess_exec(cse, retcode=0)
  1015. await sc.activate(brd)
  1016. cse.assert_any_call('devfs', '-m', devfspath, 'rule',
  1017. 'apply', 'path', os.path.basename(await sc.getvalue()),
  1018. 'unhide',
  1019. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  1020. stderr=subprocess.DEVNULL)
  1021. cse.assert_any_call('devfs', '-m', devfspath, 'rule',
  1022. 'apply', 'path',
  1023. os.path.basename(await sc.getvalue()) + '.*', 'unhide',
  1024. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  1025. stderr=subprocess.DEVNULL)
  1026. wrap_subprocess_exec(cse, retcode=1)
  1027. with self.assertRaises(RuntimeError):
  1028. await sc.activate(brd)
  1029. @patch('asyncio.create_subprocess_exec')
  1030. async def test_etheriface(self, cse):
  1031. eiface = 'aneiface'
  1032. ei = EtherIface(eiface)
  1033. self.assertEqual(ei.defattrname, 'eiface')
  1034. self.assertEqual(eiface, await ei.getvalue())
  1035. with self.assertRaises(TypeError):
  1036. await ei.setvalue('randomdata')
  1037. brd = BoardImpl('foo', 'bar', [ ei ])
  1038. wrap_subprocess_exec(cse, retcode=0)
  1039. await ei.activate(brd)
  1040. cse.assert_called_with('ifconfig', eiface, 'vnet', 'foo',
  1041. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
  1042. stderr=subprocess.DEVNULL)
  1043. wrap_subprocess_exec(cse, retcode=1)
  1044. with self.assertRaises(RuntimeError):
  1045. await ei.activate(brd)