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.
 
 

1949 lines
54 KiB

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