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.
 
 

856 lines
24 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, Depends, FastAPI, HTTPException, Request
  33. from fastapi.security import OAuth2PasswordBearer
  34. from httpx import AsyncClient, Auth
  35. from starlette.responses import JSONResponse
  36. from starlette.status import HTTP_200_OK
  37. from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, \
  38. HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
  39. from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
  40. from unittest.mock import patch, AsyncMock, Mock, PropertyMock
  41. from . import config
  42. from .data import *
  43. from .abstract import *
  44. from .snmp import *
  45. from .mocks import *
  46. import asyncio
  47. import contextlib
  48. import json
  49. import logging
  50. import orm
  51. import os
  52. import socket
  53. import sqlite3
  54. import subprocess
  55. import sys
  56. import tempfile
  57. import ucl
  58. import unittest
  59. import urllib
  60. # fix up parse_socket_addr for hypercorn
  61. from hypercorn.utils import parse_socket_addr
  62. from hypercorn.asyncio import tcp_server
  63. def new_parse_socket_addr(domain, addr):
  64. if domain == socket.AF_UNIX:
  65. return (addr, -1)
  66. return parse_socket_addr(domain, addr)
  67. tcp_server.parse_socket_addr = new_parse_socket_addr
  68. class SerialConsole(DefROAttribute):
  69. defattrname = 'console'
  70. class BoardImpl:
  71. def __init__(self, name, brdclass, options):
  72. self.name = name
  73. self.brdclass = brdclass
  74. self.options = options
  75. self.reserved = False
  76. self.attrmap = {}
  77. self.lock = asyncio.Lock()
  78. for i in options:
  79. self.attrmap[i.defattrname] = i
  80. self.attrcache = {}
  81. def __repr__(self): #pragma: no cover
  82. return repr(Board.from_orm(self))
  83. async def reserve(self):
  84. assert self.lock.locked() and not self.reserved
  85. self.reserved = True
  86. async def release(self):
  87. assert self.lock.locked() and self.reserved
  88. self.reserved = False
  89. async def update_attrs(self, **attrs):
  90. for i in attrs:
  91. self.attrcache[i] = await self.attrmap[i].setvalue(attrs[i])
  92. async def update(self):
  93. for i in self.attrmap:
  94. self.attrcache[i] = await self.attrmap[i].getvalue()
  95. def add_info(self, d):
  96. self.attrcache.update(d)
  97. def clean_info(self):
  98. # clean up attributes
  99. for i in set(self.attrcache) - set(self.attrmap):
  100. del self.attrcache[i]
  101. @property
  102. def attrs(self):
  103. return dict(self.attrcache)
  104. @dataclass
  105. class BITEError(Exception):
  106. errobj: Error
  107. status_code: int
  108. class BoardManager(object):
  109. _option_map = dict(
  110. snmppower=SNMPPower,
  111. serialconsole=SerialConsole,
  112. )
  113. def __init__(self, cls_info, boards):
  114. # add the name to the classes
  115. classes = { k: dict(clsname=k, **cls_info[k]) for k in cls_info }
  116. self.board_class_info = classes
  117. self.boards = dict(**{ x.name: x for x in
  118. (BoardImpl(**y) for y in boards)})
  119. @classmethod
  120. def from_settings(cls, settings):
  121. return cls.from_ucl(settings.board_conf)
  122. @classmethod
  123. def from_ucl(cls, fname):
  124. with open(fname) as fp:
  125. conf = ucl.load(fp.read())
  126. classes = conf['classes']
  127. brds = conf['boards']
  128. makeopt = lambda x: cls._option_map[x['cls']](**{ k: v for k, v in x.items() if k != 'cls' })
  129. for i in brds:
  130. opt = i['options']
  131. opt[:] = [ makeopt(x) for x in opt ]
  132. return cls(classes, brds)
  133. def classes(self):
  134. return self.board_class_info
  135. def unhashable_lru():
  136. def newwrapper(fun):
  137. cache = {}
  138. @wraps(fun)
  139. def wrapper(*args, **kwargs):
  140. idargs = tuple(id(x) for x in args)
  141. idkwargs = tuple(sorted((k, id(v)) for k, v in
  142. kwargs.items()))
  143. k = (idargs, idkwargs)
  144. if k in cache:
  145. realargs, realkwargs, res = cache[k]
  146. if all(x is y for x, y in zip(args,
  147. realargs)) and all(realkwargs[x] is
  148. kwargs[x] for x in realkwargs):
  149. return res
  150. res = fun(*args, **kwargs)
  151. cache[k] = (args, kwargs, res)
  152. return res
  153. return wrapper
  154. return newwrapper
  155. class BiteAuth(Auth):
  156. def __init__(self, token):
  157. self.token = token
  158. def __eq__(self, o):
  159. return self.token == o.token
  160. def auth_flow(self, request):
  161. request.headers['Authorization'] = 'Bearer ' + self.token
  162. yield request
  163. # how to get coverage for this?
  164. @lru_cache()
  165. def get_settings(): # pragma: no cover
  166. return config.Settings()
  167. # how to get coverage for this?
  168. @unhashable_lru()
  169. def get_data(settings: config.Settings = Depends(get_settings)):
  170. #print(repr(settings))
  171. database = data.databases.Database('sqlite:///' + settings.db_file)
  172. d = make_orm(database)
  173. return d
  174. async def real_get_boardmanager(settings, data):
  175. brdmgr = BoardManager.from_settings(settings)
  176. # Clean up the database
  177. # XXX - This isn't a complete fix, we need a better solution.
  178. all = await data.BoardStatus.objects.all()
  179. await asyncio.gather(*(x.delete() for x in all))
  180. return brdmgr
  181. _global_lock = asyncio.Lock()
  182. _global_brdmgr = None
  183. async def get_boardmanager(settings: config.Settings = Depends(get_settings),
  184. data: data.DataWrapper = Depends(get_data)):
  185. global _global_brdmgr
  186. if _global_brdmgr is not None:
  187. return _global_brdmgr
  188. async with _global_lock:
  189. if _global_brdmgr is None:
  190. _global_brdmgr = await real_get_boardmanager(settings, data)
  191. return _global_brdmgr
  192. oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/nonexistent')
  193. def get_authorized_board_parms(board_id, token: str = Depends(oauth2_scheme),
  194. data: data.DataWrapper = Depends(get_data),
  195. brdmgr: BoardManager = Depends(get_boardmanager)):
  196. '''This dependancy is used to collect the parameters needed for
  197. the validate_board_params context manager.'''
  198. return dict(board_id=board_id, token=token, data=data, brdmgr=brdmgr)
  199. @contextlib.asynccontextmanager
  200. async def validate_board_params(board_id, token, data, brdmgr):
  201. '''This context manager checks to see if the request is authorized
  202. for the board_id. This requires that the board is reserved by
  203. the user, or the connection came from the board's jail (TBI).
  204. '''
  205. brd = brdmgr.boards[board_id]
  206. async with brd.lock:
  207. user = await lookup_user(token, data)
  208. try:
  209. brduser = await data.BoardStatus.objects.get(board=board_id)
  210. except orm.exceptions.NoMatch:
  211. raise BITEError(
  212. status_code=HTTP_400_BAD_REQUEST,
  213. errobj=Error(error='Board not reserved.',
  214. board=Board.from_orm(brd)))
  215. if user != brduser.user:
  216. raise BITEError(
  217. status_code=HTTP_403_FORBIDDEN,
  218. errobj=Error(error='Board reserved by %s.' % repr(brduser.user),
  219. board=Board.from_orm(brd)))
  220. yield brd
  221. async def lookup_user(token: str = Depends(oauth2_scheme),
  222. data: data.DataWrapper = Depends(get_data)):
  223. try:
  224. return (await data.APIKey.objects.get(key=token)).user
  225. except orm.exceptions.NoMatch:
  226. raise HTTPException(
  227. status_code=HTTP_401_UNAUTHORIZED,
  228. detail='Invalid authentication credentials',
  229. headers={'WWW-Authenticate': 'Bearer'},
  230. )
  231. router = APIRouter()
  232. def board_priority(request: Request):
  233. # Get the board, if any, from the connection
  234. scope = request.scope
  235. return scope['server']
  236. @router.get('/board/classes', response_model=Dict[str, BoardClassInfo])
  237. async def get_board_classes(user: str = Depends(lookup_user),
  238. brdmgr: BoardManager = Depends(get_boardmanager)):
  239. return brdmgr.classes()
  240. @router.get('/board/{board_id}', response_model=Board)
  241. async def get_board_info(board_id, user: str = Depends(lookup_user),
  242. brdmgr: BoardManager = Depends(get_boardmanager)):
  243. brd = brdmgr.boards[board_id]
  244. await brd.update()
  245. return brd
  246. @router.post('/board/{board_id_or_class}/reserve', response_model=Union[Board, Error])
  247. async def reserve_board(board_id_or_class, user: str = Depends(lookup_user),
  248. brdmgr: BoardManager = Depends(get_boardmanager),
  249. settings: config.Settings = Depends(get_settings),
  250. data: data.DataWrapper = Depends(get_data)):
  251. board_id = board_id_or_class
  252. brd = brdmgr.boards[board_id]
  253. async with brd.lock:
  254. try:
  255. obrdreq = await data.BoardStatus.objects.create(board=board_id,
  256. user=user)
  257. # XXX - There is a bug in orm where the returned
  258. # object has an incorrect board value
  259. # see: https://github.com/encode/orm/issues/47
  260. #assert obrdreq.board == board_id and \
  261. # obrdreq.user == user
  262. brdreq = await data.BoardStatus.objects.get(board=board_id,
  263. user=user)
  264. await brd.reserve()
  265. # XXX - orm isn't doing it's job here
  266. except sqlite3.IntegrityError:
  267. raise BITEError(
  268. status_code=HTTP_409_CONFLICT,
  269. errobj=Error(error='Board currently reserved.',
  270. board=Board.from_orm(brd)),
  271. )
  272. # Initialize board
  273. try:
  274. sub = await asyncio.create_subprocess_exec(
  275. settings.setup_script, 'reserve', brd.name, user,
  276. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  277. stdout, stderr = await sub.communicate()
  278. if sub.returncode:
  279. raise RuntimeError(sub.returncode, stderr)
  280. brd.add_info(json.loads(stdout))
  281. except Exception as e:
  282. await brdreq.delete()
  283. await brd.release()
  284. if isinstance(e, RuntimeError):
  285. retcode, stderr = e.args
  286. raise BITEError(
  287. status_code=HTTP_500_INTERNAL_SERVER_ERROR,
  288. errobj=Error(error=
  289. 'Failed to init board, ret: %d, stderr: %s' %
  290. (retcode, repr(stderr)),
  291. board=Board.from_orm(brd)),
  292. )
  293. raise
  294. await brd.update()
  295. return brd
  296. @router.post('/board/{board_id}/release', response_model=Union[Board, Error])
  297. async def release_board(board_id, user: str = Depends(lookup_user),
  298. brdmgr: BoardManager = Depends(get_boardmanager),
  299. settings: config.Settings = Depends(get_settings),
  300. data: data.DataWrapper = Depends(get_data)):
  301. brd = brdmgr.boards[board_id]
  302. async with brd.lock:
  303. try:
  304. brduser = await data.BoardStatus.objects.get(board=board_id)
  305. if user != brduser.user:
  306. raise BITEError(
  307. status_code=HTTP_403_FORBIDDEN,
  308. errobj=Error(error='Board reserved by %s.' % repr(brduser.user),
  309. board=Board.from_orm(brd)))
  310. except orm.exceptions.NoMatch:
  311. raise BITEError(
  312. status_code=HTTP_400_BAD_REQUEST,
  313. errobj=Error(error='Board not reserved.',
  314. board=Board.from_orm(brd)),
  315. )
  316. sub = await asyncio.create_subprocess_exec(
  317. settings.setup_script, 'release', brd.name, user,
  318. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  319. stdout, stderr = await sub.communicate()
  320. retcode = sub.returncode
  321. if retcode:
  322. logging.error('release script failure: ' +
  323. 'board: %s, ret: %s, stderr: %s' % (repr(brd.name),
  324. retcode, repr(stderr)))
  325. raise BITEError(
  326. status_code=HTTP_500_INTERNAL_SERVER_ERROR,
  327. errobj=Error(error=
  328. 'Failed to release board, ret: %d, stderr: %s' %
  329. (retcode, repr(stderr)),
  330. board=Board.from_orm(brd)),
  331. )
  332. await data.BoardStatus.delete(brduser)
  333. await brd.release()
  334. brd.clean_info()
  335. await brd.update()
  336. return brd
  337. @router.post('/board/{board_id}/attrs', response_model=Union[Board, Error])
  338. async def set_board_attrs(
  339. attrs: Dict[str, Any],
  340. brdparams: dict = Depends(get_authorized_board_parms)):
  341. async with validate_board_params(**brdparams) as brd:
  342. await brd.update_attrs(**attrs)
  343. return brd
  344. @router.get('/board/',response_model=Dict[str, Board])
  345. async def get_boards(user: str = Depends(lookup_user),
  346. brdmgr: BoardManager = Depends(get_boardmanager)):
  347. brds = brdmgr.boards
  348. for i in brds:
  349. await brds[i].update()
  350. return brds
  351. @router.get('/')
  352. async def root_test(board_prio: dict = Depends(board_priority),
  353. settings: config.Settings = Depends(get_settings)):
  354. return { 'foo': 'bar', 'board': board_prio }
  355. def getApp():
  356. app = FastAPI()
  357. app.include_router(router)
  358. @app.exception_handler(BITEError)
  359. async def error_handler(request, exc):
  360. return JSONResponse(exc.errobj.dict(), status_code=exc.status_code)
  361. return app
  362. # uvicorn can't call the above function, while hypercorn can
  363. #app = getApp()
  364. class TestUnhashLRU(unittest.TestCase):
  365. def test_unhashlru(self):
  366. lsta = []
  367. lstb = []
  368. # that a wrapped function
  369. cachefun = unhashable_lru()(lambda x: object())
  370. # handles unhashable objects
  371. resa = cachefun(lsta)
  372. resb = cachefun(lstb)
  373. # that they return the same object again
  374. self.assertIs(resa, cachefun(lsta))
  375. self.assertIs(resb, cachefun(lstb))
  376. # that the object returned is not the same
  377. self.assertIsNot(cachefun(lsta), cachefun(lstb))
  378. # that a second wrapped funcion
  379. cachefun2 = unhashable_lru()(lambda x: object())
  380. # does not return the same object as the first cache
  381. self.assertIsNot(cachefun(lsta), cachefun2(lsta))
  382. # Per RFC 5737 (https://tools.ietf.org/html/rfc5737):
  383. # The blocks 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2),
  384. # and 203.0.113.0/24 (TEST-NET-3) are provided for use in
  385. # documentation.
  386. # Note: this will not work under python before 3.8 before
  387. # IsolatedAsyncioTestCase was added. The tearDown has to happen
  388. # with the event loop running, otherwise the task and other things
  389. # do not get cleaned up properly.
  390. class TestBiteLab(unittest.IsolatedAsyncioTestCase):
  391. def get_settings_override(self):
  392. return self.settings
  393. def get_data_override(self):
  394. return self.data
  395. def get_boardmanager_override(self):
  396. return self.brdmgr
  397. async def asyncSetUp(self):
  398. self.app = getApp()
  399. # setup test database
  400. self.dbtempfile = tempfile.NamedTemporaryFile()
  401. self.database = data.databases.Database('sqlite:///' +
  402. self.dbtempfile.name)
  403. self.data = make_orm(self.database)
  404. await data._setup_data(self.data)
  405. # setup settings
  406. self.settings = config.Settings(db_file=self.dbtempfile.name,
  407. setup_script='somesetupscript',
  408. board_conf = os.path.join('fixtures', 'board_conf.ucl')
  409. )
  410. self.brdmgr = BoardManager.from_settings(self.settings)
  411. self.app.dependency_overrides[get_settings] = \
  412. self.get_settings_override
  413. self.app.dependency_overrides[get_data] = self.get_data_override
  414. self.app.dependency_overrides[get_boardmanager] = self.get_boardmanager_override
  415. self.client = AsyncClient(app=self.app,
  416. base_url='http://testserver')
  417. def tearDown(self):
  418. self.app = None
  419. asyncio.run(self.client.aclose())
  420. self.client = None
  421. async def test_basic(self):
  422. res = await self.client.get('/')
  423. self.assertNotEqual(res.status_code, HTTP_404_NOT_FOUND)
  424. async def test_notauth(self):
  425. # test that simple accesses are denied
  426. res = await self.client.get('/board/classes')
  427. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  428. res = await self.client.get('/board/')
  429. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  430. # test that invalid api keys are denied
  431. res = await self.client.get('/board/classes',
  432. auth=BiteAuth('badapikey'))
  433. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  434. async def test_classes(self):
  435. # that when requesting the board classes
  436. res = await self.client.get('/board/classes',
  437. auth=BiteAuth('thisisanapikey'))
  438. # it is successful
  439. self.assertEqual(res.status_code, HTTP_200_OK)
  440. # and returns the correct data
  441. self.assertEqual(res.json(), { 'cora-z7s': BoardClassInfo(**{
  442. 'arch': 'arm-armv7', 'clsname': 'cora-z7s', }) })
  443. @patch('asyncio.create_subprocess_exec')
  444. @patch('bitelab.snmp.snmpget')
  445. @patch('logging.error')
  446. async def test_board_release_script_fail(self, le, sg, cse):
  447. # that when snmpget returns False
  448. sg.return_value = False
  449. # that when the setup script will fail
  450. wrap_subprocess_exec(cse, stderr=b'error', retcode=1)
  451. # that the cora-1 board is reserved
  452. data = self.data
  453. brd = self.brdmgr.boards['cora-1']
  454. async with brd.lock:
  455. await brd.reserve()
  456. obrdreq = await data.BoardStatus.objects.create(
  457. board='cora-1', user='foo')
  458. # that when the correct user releases the board
  459. res = await self.client.post('/board/cora-1/release',
  460. auth=BiteAuth('thisisanapikey'))
  461. # it fails
  462. self.assertEqual(res.status_code, HTTP_500_INTERNAL_SERVER_ERROR)
  463. # and returns the correct data
  464. info = Error(error='Failed to release board, ret: 1, stderr: b\'error\'',
  465. board=Board(name='cora-1',
  466. brdclass='cora-z7s',
  467. reserved=True,
  468. ),
  469. ).dict()
  470. self.assertEqual(res.json(), info)
  471. # and that it called the release script
  472. cse.assert_called_with(self.settings.setup_script, 'release',
  473. 'cora-1', 'foo', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  474. # and that the error got logged
  475. le.assert_called_with('release script failure: board: \'cora-1\', ret: 1, stderr: b\'error\'')
  476. @patch('asyncio.create_subprocess_exec')
  477. @patch('bitelab.snmp.snmpget')
  478. async def test_board_reserve_release(self, sg, cse):
  479. # that when releasing a board that is not yet reserved
  480. res = await self.client.post('/board/cora-1/release',
  481. auth=BiteAuth('anotherlongapikey'))
  482. # that it returns an error
  483. self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST)
  484. # that when snmpget returns False
  485. sg.return_value = False
  486. # that when the setup script will fail
  487. wrap_subprocess_exec(cse, stderr=b'error', retcode=1)
  488. # that reserving the board
  489. res = await self.client.post('/board/cora-1/reserve',
  490. auth=BiteAuth('thisisanapikey'))
  491. # that it is a failure
  492. self.assertEqual(res.status_code, HTTP_500_INTERNAL_SERVER_ERROR)
  493. # and returns the correct data
  494. info = Error(error='Failed to init board, ret: 1, stderr: b\'error\'',
  495. board=Board(name='cora-1',
  496. brdclass='cora-z7s',
  497. reserved=False,
  498. ),
  499. ).dict()
  500. self.assertEqual(res.json(), info)
  501. # and that it called the start script
  502. cse.assert_called_with(self.settings.setup_script, 'reserve',
  503. 'cora-1', 'foo', stdout=subprocess.PIPE,
  504. stderr=subprocess.PIPE)
  505. # that when the setup script returns
  506. wrap_subprocess_exec(cse,
  507. json.dumps(dict(ip='192.0.2.10')).encode('utf-8'))
  508. # that reserving the board
  509. res = await self.client.post('/board/cora-1/reserve',
  510. auth=BiteAuth('thisisanapikey'))
  511. # that it is successful
  512. self.assertEqual(res.status_code, HTTP_200_OK)
  513. # and returns the correct data
  514. brdinfo = Board(name='cora-1',
  515. brdclass='cora-z7s',
  516. reserved=True,
  517. attrs=dict(power=False,
  518. ip='192.0.2.10',
  519. ),
  520. ).dict()
  521. self.assertEqual(res.json(), brdinfo)
  522. # and that it called the start script
  523. cse.assert_called_with(self.settings.setup_script, 'reserve',
  524. 'cora-1', 'foo', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  525. # that another user reserving the board
  526. res = await self.client.post('/board/cora-1/reserve',
  527. auth=BiteAuth('anotherlongapikey'))
  528. # that the request is fails with a conflict
  529. self.assertEqual(res.status_code, HTTP_409_CONFLICT)
  530. # and returns the correct data
  531. info = {
  532. 'error': 'Board currently reserved.',
  533. 'board': brdinfo,
  534. }
  535. self.assertEqual(res.json(), info)
  536. # that another user releases the board
  537. res = await self.client.post('/board/cora-1/release',
  538. auth=BiteAuth('anotherlongapikey'))
  539. # that it is denied
  540. self.assertEqual(res.status_code, HTTP_403_FORBIDDEN)
  541. # and returns the correct data
  542. info = {
  543. 'error': 'Board reserved by \'foo\'.',
  544. 'board': brdinfo,
  545. }
  546. self.assertEqual(res.json(), info)
  547. # that when the correct user releases the board
  548. res = await self.client.post('/board/cora-1/release',
  549. auth=BiteAuth('thisisanapikey'))
  550. # it is allowed
  551. self.assertEqual(res.status_code, HTTP_200_OK)
  552. # and returns the correct data
  553. info = {
  554. 'name': 'cora-1',
  555. 'brdclass': 'cora-z7s',
  556. 'reserved': False,
  557. 'attrs': { 'power': False },
  558. }
  559. self.assertEqual(res.json(), info)
  560. # and that it called the release script
  561. cse.assert_called_with(self.settings.setup_script, 'release',
  562. 'cora-1', 'foo', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  563. # that it can be reserved by a different user
  564. res = await self.client.post('/board/cora-1/reserve',
  565. auth=BiteAuth('anotherlongapikey'))
  566. # that it is successful
  567. self.assertEqual(res.status_code, HTTP_200_OK)
  568. @patch('bitelab.snmp.snmpget')
  569. async def test_board_info(self, sg):
  570. # that when snmpget returns False
  571. sg.return_value = False
  572. # that getting the board info
  573. res = await self.client.get('/board/',
  574. auth=BiteAuth('thisisanapikey'))
  575. # calls snmpget w/ the correct args
  576. sg.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  577. 'bool')
  578. # that it is successful
  579. self.assertEqual(res.status_code, HTTP_200_OK)
  580. # and returns the correct data
  581. info = {
  582. 'cora-1': {
  583. 'name': 'cora-1',
  584. 'brdclass': 'cora-z7s',
  585. 'reserved': False,
  586. 'attrs': { 'power': False },
  587. },
  588. }
  589. self.assertEqual(res.json(), info)
  590. # that when snmpget returns True
  591. sg.return_value = True
  592. # that getting the board info
  593. res = await self.client.get('/board/cora-1',
  594. auth=BiteAuth('thisisanapikey'))
  595. # calls snmpget w/ the correct args
  596. sg.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  597. 'bool')
  598. # that it is successful
  599. self.assertEqual(res.status_code, HTTP_200_OK)
  600. # and returns the correct data
  601. info = {
  602. 'name': 'cora-1',
  603. 'brdclass': 'cora-z7s',
  604. 'reserved': False,
  605. 'attrs': { 'power': True },
  606. }
  607. self.assertEqual(res.json(), info)
  608. @patch('bitelab.snmp.snmpset')
  609. async def test_board_attrs(self, ss):
  610. data = self.data
  611. # that when snmpset returns False
  612. ss.return_value = False
  613. attrs = dict(power=False)
  614. # that setting the board attributes requires auth
  615. res = await self.client.post('/board/cora-1/attrs',
  616. auth=BiteAuth('badapi'),
  617. json=attrs)
  618. # that it fails auth
  619. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  620. # that when properly authorized, but board is not reserved
  621. res = await self.client.post('/board/cora-1/attrs',
  622. auth=BiteAuth('thisisanapikey'),
  623. json=attrs)
  624. # that it is a bad request
  625. self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST)
  626. # that the cora-1 board is reserved
  627. brd = self.brdmgr.boards['cora-1']
  628. async with brd.lock:
  629. await brd.reserve()
  630. obrdreq = await data.BoardStatus.objects.create(
  631. board='cora-1', user='foo')
  632. # that setting the board attributes
  633. res = await self.client.post('/board/cora-1/attrs',
  634. auth=BiteAuth('thisisanapikey'),
  635. json=attrs)
  636. # that it is successful
  637. self.assertEqual(res.status_code, HTTP_200_OK)
  638. # calls snmpset w/ the correct args
  639. ss.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  640. 'bool', False)
  641. # and returns the correct data
  642. info = {
  643. 'name': 'cora-1',
  644. 'brdclass': 'cora-z7s',
  645. 'reserved': True,
  646. 'attrs': { 'power': False },
  647. }
  648. self.assertEqual(res.json(), info)
  649. # that when snmpset returns True
  650. ss.return_value = True
  651. attrs = dict(power=True)
  652. # that setting the board attributes
  653. res = await self.client.post('/board/cora-1/attrs',
  654. auth=BiteAuth('thisisanapikey'),
  655. json=attrs)
  656. # calls snmpget w/ the correct args
  657. ss.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  658. 'bool', True)
  659. # that it is successful
  660. self.assertEqual(res.status_code, HTTP_200_OK)
  661. # and returns the correct data
  662. info = {
  663. 'name': 'cora-1',
  664. 'brdclass': 'cora-z7s',
  665. 'reserved': True,
  666. 'attrs': { 'power': True },
  667. }
  668. self.assertEqual(res.json(), info)
  669. class TestAttrs(unittest.IsolatedAsyncioTestCase):
  670. async def test_serialconsole(self):
  671. data = 'somepath'
  672. sc = SerialConsole(data)
  673. self.assertEqual(sc.defattrname, 'console')
  674. self.assertEqual(data, await sc.getvalue())
  675. with self.assertRaises(TypeError):
  676. await sc.setvalue(data)