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.
 
 

667 lines
19 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 json
  48. import orm
  49. import os
  50. import socket
  51. import sqlite3
  52. import subprocess
  53. import sys
  54. import tempfile
  55. import unittest
  56. import urllib
  57. # fix up parse_socket_addr for hypercorn
  58. from hypercorn.utils import parse_socket_addr
  59. from hypercorn.asyncio import tcp_server
  60. def new_parse_socket_addr(domain, addr):
  61. if domain == socket.AF_UNIX:
  62. return (addr, -1)
  63. return parse_socket_addr(domain, addr)
  64. tcp_server.parse_socket_addr = new_parse_socket_addr
  65. class BoardImpl:
  66. def __init__(self, name, brdclass, options):
  67. self.name = name
  68. self.brdclass = brdclass
  69. self.options = options
  70. self.reserved = False
  71. self.attrmap = {}
  72. self.lock = asyncio.Lock()
  73. for i in options:
  74. self.attrmap[i.defattrname] = i
  75. self.attrcache = {}
  76. def __repr__(self): #pragma: no cover
  77. return repr(Board.from_orm(self))
  78. async def reserve(self):
  79. assert self.lock.locked() and not self.reserved
  80. self.reserved = True
  81. async def release(self):
  82. assert self.lock.locked() and self.reserved
  83. self.reserved = False
  84. async def update(self):
  85. for i in self.attrmap:
  86. self.attrcache[i] = await self.attrmap[i].getvalue()
  87. def add_info(self, d):
  88. self.attrcache.update(d)
  89. def clean_info(self):
  90. # clean up attributes
  91. for i in set(self.attrcache) - set(self.attrmap):
  92. del self.attrcache[i]
  93. @property
  94. def attrs(self):
  95. return dict(self.attrcache)
  96. @dataclass
  97. class BITEError(Exception):
  98. errobj: Error
  99. status_code: int
  100. class BoardManager(object):
  101. board_class_info = {
  102. 'cora-z7s': {
  103. 'clsname': 'cora-z7s',
  104. 'arch': 'arm-armv7',
  105. },
  106. }
  107. # Naming scheme:
  108. # <abbreviated class>-<num>
  109. #
  110. board_gen = [
  111. dict(name='cora-1', brdclass='cora-z7s', options=[
  112. SNMPPower(host='poe', port=2),
  113. ]),
  114. ]
  115. def __init__(self, settings):
  116. self._settings = settings
  117. self.boards = dict(**{ x.name: x for x in
  118. (BoardImpl(**y) for y in self.board_gen)})
  119. def classes(self):
  120. return self.board_class_info
  121. def unhashable_lru():
  122. def newwrapper(fun):
  123. cache = {}
  124. @wraps(fun)
  125. def wrapper(*args, **kwargs):
  126. idargs = tuple(id(x) for x in args)
  127. idkwargs = tuple(sorted((k, id(v)) for k, v in
  128. kwargs.items()))
  129. k = (idargs, idkwargs)
  130. if k in cache:
  131. realargs, realkwargs, res = cache[k]
  132. if all(x is y for x, y in zip(args,
  133. realargs)) and all(realkwargs[x] is
  134. kwargs[x] for x in realkwargs):
  135. return res
  136. res = fun(*args, **kwargs)
  137. cache[k] = (args, kwargs, res)
  138. return res
  139. return wrapper
  140. return newwrapper
  141. class BiteAuth(Auth):
  142. def __init__(self, token):
  143. self.token = token
  144. def __eq__(self, o):
  145. return self.token == o.token
  146. def auth_flow(self, request):
  147. request.headers['Authorization'] = 'Bearer ' + self.token
  148. yield request
  149. @lru_cache()
  150. def sync_get_board_lock():
  151. return asyncio.Lock()
  152. async def get_board_lock():
  153. return sync_get_board_lock()
  154. # how to get coverage for this?
  155. @lru_cache()
  156. def get_settings(): # pragma: no cover
  157. return config.Settings()
  158. # how to get coverage for this?
  159. @unhashable_lru()
  160. def get_data(settings: config.Settings = Depends(get_settings)):
  161. #print(repr(settings))
  162. database = data.databases.Database('sqlite:///' + settings.db_file)
  163. d = make_orm(database)
  164. return d
  165. @unhashable_lru()
  166. def sync_get_boardmanager(settings):
  167. return BoardManager(settings)
  168. async def get_boardmanager(settings: config.Settings = Depends(get_settings)):
  169. return sync_get_boardmanager(settings)
  170. oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/nonexistent')
  171. async def lookup_user(token: str = Depends(oauth2_scheme),
  172. data: data.DataWrapper = Depends(get_data)):
  173. try:
  174. return (await data.APIKey.objects.get(key=token)).user
  175. except orm.exceptions.NoMatch:
  176. raise HTTPException(
  177. status_code=HTTP_401_UNAUTHORIZED,
  178. detail='Invalid authentication credentials',
  179. headers={'WWW-Authenticate': 'Bearer'},
  180. )
  181. router = APIRouter()
  182. def board_priority(request: Request):
  183. # Get the board, if any, from the connection
  184. scope = request.scope
  185. return scope['server']
  186. @router.get('/board/classes', response_model=Dict[str, BoardClassInfo])
  187. async def get_board_classes(user: str = Depends(lookup_user),
  188. brdmgr: BoardManager = Depends(get_boardmanager)):
  189. return brdmgr.classes()
  190. @router.get('/board/{board_id}', response_model=Board)
  191. async def get_board_info(board_id, user: str = Depends(lookup_user),
  192. brdmgr: BoardManager = Depends(get_boardmanager)):
  193. brd = brdmgr.boards[board_id]
  194. await brd.update()
  195. return brd
  196. @router.post('/board/{board_id_or_class}/reserve', response_model=Union[Board, Error])
  197. async def reserve_board(board_id_or_class, user: str = Depends(lookup_user),
  198. brdmgr: BoardManager = Depends(get_boardmanager),
  199. brdlck: asyncio.Lock = Depends(get_board_lock),
  200. settings: config.Settings = Depends(get_settings),
  201. data: data.DataWrapper = Depends(get_data)):
  202. board_id = board_id_or_class
  203. brd = brdmgr.boards[board_id]
  204. async with brd.lock:
  205. try:
  206. obrdreq = await data.BoardStatus.objects.create(board=board_id,
  207. user=user)
  208. # XXX - There is a bug in orm where the returned
  209. # object has an incorrect board value
  210. # see: https://github.com/encode/orm/issues/47
  211. #assert obrdreq.board == board_id and \
  212. # obrdreq.user == user
  213. brdreq = await data.BoardStatus.objects.get(board=board_id,
  214. user=user)
  215. await brd.reserve()
  216. # XXX - orm isn't doing it's job here
  217. except sqlite3.IntegrityError:
  218. raise BITEError(
  219. status_code=HTTP_409_CONFLICT,
  220. errobj=Error(error='Board currently reserved.',
  221. board=Board.from_orm(brd)),
  222. )
  223. # Initialize board
  224. try:
  225. sub = await asyncio.create_subprocess_exec(
  226. settings.setup_script, 'reserve', brd.name, user,
  227. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  228. stdout, stderr = await sub.communicate()
  229. if sub.returncode:
  230. raise RuntimeError(sub.returncode, stderr)
  231. brd.add_info(json.loads(stdout))
  232. except Exception as e:
  233. await brdreq.delete()
  234. await brd.release()
  235. if isinstance(e, RuntimeError):
  236. retcode, stderr = e.args
  237. raise BITEError(
  238. status_code=HTTP_500_INTERNAL_SERVER_ERROR,
  239. errobj=Error(error=
  240. 'Failed to init board, ret: %d, stderr: %s' %
  241. (retcode, repr(stderr)),
  242. board=Board.from_orm(brd)),
  243. )
  244. raise
  245. await brd.update()
  246. return brd
  247. @router.post('/board/{board_id}/release', response_model=Union[Board, Error])
  248. async def release_board(board_id, user: str = Depends(lookup_user),
  249. brdmgr: BoardManager = Depends(get_boardmanager),
  250. brdlck: asyncio.Lock = Depends(get_board_lock),
  251. settings: config.Settings = Depends(get_settings),
  252. data: data.DataWrapper = Depends(get_data)):
  253. brd = brdmgr.boards[board_id]
  254. async with brd.lock:
  255. try:
  256. brduser = await data.BoardStatus.objects.get(board=board_id)
  257. if user != brduser.user:
  258. raise BITEError(
  259. status_code=HTTP_403_FORBIDDEN,
  260. errobj=Error(error='Board reserved by %s.' % repr(brduser.user),
  261. board=Board.from_orm(brd)))
  262. except orm.exceptions.NoMatch:
  263. raise BITEError(
  264. status_code=HTTP_400_BAD_REQUEST,
  265. errobj=Error(error='Board not reserved.',
  266. board=Board.from_orm(brd)),
  267. )
  268. sub = await asyncio.create_subprocess_exec(
  269. settings.setup_script, 'release', brd.name, user,
  270. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  271. stdout, stderr = await sub.communicate()
  272. if sub.returncode:
  273. raise RuntimeError(sub.returncode, stderr)
  274. await data.BoardStatus.delete(brduser)
  275. await brd.release()
  276. brd.clean_info()
  277. await brd.update()
  278. return brd
  279. @router.post('/board/{board_id_or_class}/attrs', response_model=Union[Board, Error])
  280. async def set_board_attrs():
  281. pass
  282. @router.get('/board/',response_model=Dict[str, Board])
  283. async def get_boards(user: str = Depends(lookup_user),
  284. brdmgr: BoardManager = Depends(get_boardmanager)):
  285. brds = brdmgr.boards
  286. for i in brds:
  287. await brds[i].update()
  288. return brds
  289. @router.get('/')
  290. async def root_test(board_prio: dict = Depends(board_priority),
  291. settings: config.Settings = Depends(get_settings)):
  292. return { 'foo': 'bar', 'board': board_prio }
  293. def getApp():
  294. app = FastAPI()
  295. app.include_router(router)
  296. @app.exception_handler(BITEError)
  297. async def error_handler(request, exc):
  298. return JSONResponse(exc.errobj.dict(), status_code=exc.status_code)
  299. return app
  300. # uvicorn can't call the above function, while hypercorn can
  301. #app = getApp()
  302. class TestUnhashLRU(unittest.TestCase):
  303. def test_unhashlru(self):
  304. lsta = []
  305. lstb = []
  306. # that a wrapped function
  307. cachefun = unhashable_lru()(lambda x: object())
  308. # handles unhashable objects
  309. resa = cachefun(lsta)
  310. resb = cachefun(lstb)
  311. # that they return the same object again
  312. self.assertIs(resa, cachefun(lsta))
  313. self.assertIs(resb, cachefun(lstb))
  314. # that the object returned is not the same
  315. self.assertIsNot(cachefun(lsta), cachefun(lstb))
  316. # that a second wrapped funcion
  317. cachefun2 = unhashable_lru()(lambda x: object())
  318. # does not return the same object as the first cache
  319. self.assertIsNot(cachefun(lsta), cachefun2(lsta))
  320. async def _setup_data(data):
  321. await data.APIKey.objects.create(user='foo', key='thisisanapikey')
  322. await data.APIKey.objects.create(user='bar', key='anotherlongapikey')
  323. # Per RFC 5737 (https://tools.ietf.org/html/rfc5737):
  324. # The blocks 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2),
  325. # and 203.0.113.0/24 (TEST-NET-3) are provided for use in
  326. # documentation.
  327. # Note: this will not work under python before 3.8 before
  328. # IsolatedAsyncioTestCase was added. The tearDown has to happen
  329. # with the event loop running, otherwise the task and other things
  330. # do not get cleaned up properly.
  331. class TestBiteLab(unittest.IsolatedAsyncioTestCase):
  332. def get_settings_override(self):
  333. return self.settings
  334. def get_data_override(self):
  335. return self.data
  336. async def asyncSetUp(self):
  337. self.app = getApp()
  338. # setup test database
  339. self.dbtempfile = tempfile.NamedTemporaryFile()
  340. self.database = data.databases.Database('sqlite:///' +
  341. self.dbtempfile.name)
  342. self.data = make_orm(self.database)
  343. await _setup_data(self.data)
  344. # setup settings
  345. self.settings = config.Settings(db_file=self.dbtempfile.name,
  346. setup_script='somesetupscript',
  347. )
  348. self.app.dependency_overrides[get_settings] = \
  349. self.get_settings_override
  350. self.app.dependency_overrides[get_data] = self.get_data_override
  351. self.client = AsyncClient(app=self.app,
  352. base_url='http://testserver')
  353. def tearDown(self):
  354. self.app = None
  355. asyncio.run(self.client.aclose())
  356. self.client = None
  357. async def test_basic(self):
  358. res = await self.client.get('/')
  359. self.assertNotEqual(res.status_code, HTTP_404_NOT_FOUND)
  360. async def test_notauth(self):
  361. # test that simple accesses are denied
  362. res = await self.client.get('/board/classes')
  363. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  364. res = await self.client.get('/board/')
  365. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  366. # test that invalid api keys are denied
  367. res = await self.client.get('/board/classes',
  368. auth=BiteAuth('badapikey'))
  369. self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)
  370. async def test_classes(self):
  371. # that when requesting the board classes
  372. res = await self.client.get('/board/classes',
  373. auth=BiteAuth('thisisanapikey'))
  374. # it is successful
  375. self.assertEqual(res.status_code, HTTP_200_OK)
  376. # and returns the correct data
  377. self.assertEqual(res.json(), { 'cora-z7s': BoardClassInfo(**{
  378. 'arch': 'arm-armv7', 'clsname': 'cora-z7s', }) })
  379. @patch('asyncio.create_subprocess_exec')
  380. @patch('bitelab.snmp.snmpget')
  381. async def test_board_reserve_release(self, sg, cse):
  382. # that when releasing a board that is not yet reserved
  383. res = await self.client.post('/board/cora-1/release',
  384. auth=BiteAuth('anotherlongapikey'))
  385. # that it returns an error
  386. self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST)
  387. # that when snmpget returns False
  388. sg.return_value = False
  389. # that when the setup script will fail
  390. wrap_subprocess_exec(cse, stderr=b'error', retcode=1)
  391. # that reserving the board
  392. res = await self.client.post('/board/cora-1/reserve',
  393. auth=BiteAuth('thisisanapikey'))
  394. # that it is a failure
  395. self.assertEqual(res.status_code, HTTP_500_INTERNAL_SERVER_ERROR)
  396. # and returns the correct data
  397. info = Error(error='Failed to init board, ret: 1, stderr: b\'error\'',
  398. board=Board(name='cora-1',
  399. brdclass='cora-z7s',
  400. reserved=False,
  401. ),
  402. ).dict()
  403. self.assertEqual(res.json(), info)
  404. # and that it called the start script
  405. cse.assert_called_with(self.settings.setup_script, 'reserve',
  406. 'cora-1', 'foo', stdout=subprocess.PIPE,
  407. stderr=subprocess.PIPE)
  408. # that when the setup script returns
  409. wrap_subprocess_exec(cse,
  410. json.dumps(dict(ip='192.0.2.10')).encode('utf-8'))
  411. # that reserving the board
  412. res = await self.client.post('/board/cora-1/reserve',
  413. auth=BiteAuth('thisisanapikey'))
  414. # that it is successful
  415. self.assertEqual(res.status_code, HTTP_200_OK)
  416. # and returns the correct data
  417. brdinfo = Board(name='cora-1',
  418. brdclass='cora-z7s',
  419. reserved=True,
  420. attrs=dict(power=False,
  421. ip='192.0.2.10',
  422. ),
  423. ).dict()
  424. self.assertEqual(res.json(), brdinfo)
  425. # and that it called the start script
  426. cse.assert_called_with(self.settings.setup_script, 'reserve',
  427. 'cora-1', 'foo', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  428. # that another user reserving the board
  429. res = await self.client.post('/board/cora-1/reserve',
  430. auth=BiteAuth('anotherlongapikey'))
  431. # that the request is fails with a conflict
  432. self.assertEqual(res.status_code, HTTP_409_CONFLICT)
  433. # and returns the correct data
  434. info = {
  435. 'error': 'Board currently reserved.',
  436. 'board': brdinfo,
  437. }
  438. self.assertEqual(res.json(), info)
  439. # that another user releases the board
  440. res = await self.client.post('/board/cora-1/release',
  441. auth=BiteAuth('anotherlongapikey'))
  442. # that it is denied
  443. self.assertEqual(res.status_code, HTTP_403_FORBIDDEN)
  444. # and returns the correct data
  445. info = {
  446. 'error': 'Board reserved by \'foo\'.',
  447. 'board': brdinfo,
  448. }
  449. self.assertEqual(res.json(), info)
  450. # that when the correct user releases the board
  451. res = await self.client.post('/board/cora-1/release',
  452. auth=BiteAuth('thisisanapikey'))
  453. # it is allowed
  454. self.assertEqual(res.status_code, HTTP_200_OK)
  455. # and returns the correct data
  456. info = {
  457. 'name': 'cora-1',
  458. 'brdclass': 'cora-z7s',
  459. 'reserved': False,
  460. 'attrs': { 'power': False },
  461. }
  462. self.assertEqual(res.json(), info)
  463. # and that it called the release script
  464. cse.assert_called_with(self.settings.setup_script, 'release',
  465. 'cora-1', 'foo', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  466. # that it can be reserved by a different user
  467. res = await self.client.post('/board/cora-1/reserve',
  468. auth=BiteAuth('anotherlongapikey'))
  469. # that it is successful
  470. self.assertEqual(res.status_code, HTTP_200_OK)
  471. @patch('bitelab.snmp.snmpget')
  472. async def test_board_info(self, sg):
  473. # that when snmpget returns False
  474. sg.return_value = False
  475. # that getting the board info
  476. res = await self.client.get('/board/',
  477. auth=BiteAuth('thisisanapikey'))
  478. # calls snmpget w/ the correct args
  479. sg.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  480. 'bool')
  481. # that it is successful
  482. self.assertEqual(res.status_code, HTTP_200_OK)
  483. # and returns the correct data
  484. info = {
  485. 'cora-1': {
  486. 'name': 'cora-1',
  487. 'brdclass': 'cora-z7s',
  488. 'reserved': False,
  489. 'attrs': { 'power': False },
  490. },
  491. }
  492. self.assertEqual(res.json(), info)
  493. # that when snmpget returns True
  494. sg.return_value = True
  495. # that getting the board info
  496. res = await self.client.get('/board/cora-1',
  497. auth=BiteAuth('thisisanapikey'))
  498. # calls snmpget w/ the correct args
  499. sg.assert_called_with('poe', 'pethPsePortAdminEnable.1.2',
  500. 'bool')
  501. # that it is successful
  502. self.assertEqual(res.status_code, HTTP_200_OK)
  503. # and returns the correct data
  504. info = {
  505. 'name': 'cora-1',
  506. 'brdclass': 'cora-z7s',
  507. 'reserved': False,
  508. 'attrs': { 'power': True },
  509. }
  510. self.assertEqual(res.json(), info)
  511. class TestDatabase(unittest.IsolatedAsyncioTestCase):
  512. def setUp(self):
  513. # setup temporary directory
  514. self.dbtempfile = tempfile.NamedTemporaryFile()
  515. self.database = data.databases.Database('sqlite:///' +
  516. self.dbtempfile.name)
  517. self.data = make_orm(self.database)
  518. def tearDown(self):
  519. self.data = None
  520. self.database = None
  521. self.dbtempfile = None
  522. async def test_apikey(self):
  523. data = self.data
  524. # that the test database starts empty
  525. self.assertEqual(await data.APIKey.objects.all(), [])
  526. # that when it is populated with test data
  527. await _setup_data(data)
  528. # the data can be accessed
  529. self.assertEqual((await data.APIKey.objects.get(
  530. key='thisisanapikey')).user, 'foo')
  531. self.assertEqual((await data.APIKey.objects.get(
  532. key='anotherlongapikey')).user, 'bar')