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.
 
 

398 lines
11 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 httpx import AsyncClient, Auth
  29. from io import StringIO
  30. from starlette.status import HTTP_200_OK
  31. from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, \
  32. HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
  33. from unittest.mock import patch, AsyncMock, Mock, mock_open
  34. from . import BiteAuth, Board
  35. import argparse
  36. import asyncio
  37. import contextlib
  38. import io
  39. import os
  40. import sys
  41. import unittest
  42. import urllib
  43. def check_res_code(res):
  44. if res.status_code == HTTP_401_UNAUTHORIZED:
  45. print('Invalid authentication credentials.')
  46. sys.exit(1)
  47. elif res.status_code != HTTP_200_OK:
  48. print('Got status: %d, json: %s' % (res.status_code, res.json()))
  49. sys.exit(1)
  50. def makebool(s):
  51. s = s.lower()
  52. if s in { 'on', 'true', '1' }:
  53. return True
  54. elif s in { 'off', 'false', '0' }:
  55. return False
  56. raise ValueError('invalid value for bool: %s' % repr(s))
  57. _typeconv = dict(power=makebool)
  58. def make_attrs(*args):
  59. '''Convert list of attributes to a dictionary.'''
  60. r = {}
  61. for i in args:
  62. k, v = i.split('=', 1)
  63. r[k] = _typeconv[k](v)
  64. return r
  65. _httpxargs = dict(timeout=20)
  66. def output_board(brd):
  67. print('Name:\t%s' % brd.name)
  68. print('Class:\t%s' % brd.brdclass)
  69. print('Attributes:')
  70. for i in sorted(brd.attrs):
  71. print('\t%s\t%s' % (i, repr(brd.attrs[i])))
  72. def get_sshpubkey(fname):
  73. if fname is not None:
  74. with open(fname) as fp:
  75. return fp.read()
  76. for i in ('id_ed25519', 'id_rsa'):
  77. fname = os.path.expanduser(os.path.join('~', '.ssh', i + '.pub'))
  78. with contextlib.suppress(IOError), open(fname) as fp:
  79. return fp.read()
  80. raise IOError
  81. async def real_main():
  82. baseurl = os.environ['BITELAB_URL']
  83. authkey = os.environ['BITELAB_AUTH']
  84. client = AsyncClient(base_url=baseurl)
  85. parser = argparse.ArgumentParser()
  86. subparsers = parser.add_subparsers(title='subcommands',
  87. dest='subparser_name',
  88. description='valid subcommands', help='additional help')
  89. parse_list = subparsers.add_parser('list', help='list available board classes')
  90. parser_reserve = subparsers.add_parser('reserve', aliases=[ 'release' ], help='reserve/release a board')
  91. parser_reserve.add_argument('-i', metavar='identity_file', type=str, help='file name for ssh public key')
  92. parser_reserve.add_argument('board', type=str, help='name of the board or class')
  93. parser_set = subparsers.add_parser('set', help='set attributes on a board')
  94. parser_set.add_argument('setvars', type=str, nargs='+', help='name of the board or class')
  95. parser_set.add_argument('board', type=str, help='name of the board or class')
  96. args = parser.parse_args()
  97. #print(repr(args), file=sys.stderr)
  98. try:
  99. if args.subparser_name == 'list':
  100. res = await client.get('board/classes',
  101. auth=BiteAuth(authkey), **_httpxargs)
  102. check_res_code(res)
  103. print('Classes:')
  104. for i in res.json():
  105. print('\t' + i)
  106. res.close()
  107. elif args.subparser_name in ('reserve', 'release'):
  108. kwargs = _httpxargs.copy()
  109. with contextlib.suppress(IOError):
  110. kwargs['json'] = dict(sshpubkey=get_sshpubkey(args.i))
  111. res = await client.post('board/%s/%s' %
  112. (urllib.parse.quote(args.board, safe=''),
  113. args.subparser_name),
  114. auth=BiteAuth(authkey), **kwargs)
  115. check_res_code(res)
  116. brd = Board.parse_obj(res.json())
  117. output_board(brd)
  118. elif args.subparser_name == 'set':
  119. board_id = sys.argv[-1]
  120. res = await client.post('board/%s/attrs' %
  121. urllib.parse.quote(board_id, safe=''),
  122. auth=BiteAuth(authkey),
  123. json=make_attrs(*sys.argv[2:-1]), **_httpxargs)
  124. check_res_code(res)
  125. brd = Board.parse_obj(res.json())
  126. output_board(brd)
  127. finally:
  128. await client.aclose()
  129. def main():
  130. asyncio.run(real_main())
  131. if __name__ == '__main__': #pragma: no cover
  132. main()
  133. @patch.dict(os.environ, dict(BITELAB_URL='http://someserver/'))
  134. @patch.dict(os.environ, dict(BITELAB_AUTH='thisisanapikey'))
  135. class TestClient(unittest.TestCase):
  136. def setUp(self):
  137. self.ac_patcher = patch(__name__ + '.AsyncClient')
  138. self.ac = self.ac_patcher.start()
  139. self.addCleanup(self.ac_patcher.stop)
  140. self.acg = self.ac.return_value.get = AsyncMock()
  141. self.acaclose = self.ac.return_value.aclose = AsyncMock()
  142. self.acgr = self.acg.return_value = Mock()
  143. self.acp = self.ac.return_value.post = AsyncMock()
  144. self.acpr = self.acp.return_value = Mock()
  145. def runMain(self):
  146. try:
  147. stdout = StringIO()
  148. with patch.dict(sys.__dict__, dict(stdout=stdout)):
  149. main()
  150. ret = 0
  151. except SystemExit as e:
  152. ret = e.code
  153. return ret, stdout.getvalue()
  154. def test_sshpubkey(self):
  155. fname = 'fname'
  156. rdata = 'foo'
  157. with patch('builtins.open', mock_open(read_data=rdata)) as mock_file:
  158. self.assertEqual(get_sshpubkey(fname), rdata)
  159. mock_file.assert_called_with(fname)
  160. with patch('builtins.open') as mock_file:
  161. mock_file.side_effect = [ IOError(), mock_open(read_data=rdata)() ]
  162. self.assertEqual(get_sshpubkey(None), rdata)
  163. for i in ('id_ed25519', 'id_rsa'):
  164. fname = os.path.expanduser(os.path.join('~', '.ssh', i + '.pub'))
  165. mock_file.assert_any_call(fname)
  166. @patch.dict(sys.__dict__, dict(argv=[ '', 'list' ]))
  167. def test_list_failure(self):
  168. ac = self.ac
  169. acg = self.acg
  170. acg.return_value.status_code = HTTP_401_UNAUTHORIZED
  171. acg.return_value.json.return_value = {
  172. 'detail': 'Invalid authentication credentials'
  173. }
  174. ret, stdout = self.runMain()
  175. output = '''Invalid authentication credentials.
  176. '''
  177. self.assertEqual(ret, 1)
  178. # XXX -- really should go to stderr
  179. self.assertEqual(stdout, output)
  180. ac.assert_called_with(base_url='http://someserver/')
  181. acg.assert_called_with('board/classes', auth=BiteAuth('thisisanapikey'), **_httpxargs)
  182. # XXX - add error cases for UI
  183. @patch.dict(sys.__dict__, dict(argv=[ '', 'list' ]))
  184. def test_list(self):
  185. ac = self.ac
  186. acg = self.acg
  187. acg.return_value.status_code = HTTP_200_OK
  188. acg.return_value.json.return_value = { 'cora-z7s': {
  189. 'arch': 'arm-armv7', 'clsname': 'cora-z7s', }}
  190. ret, stdout = self.runMain()
  191. output = '''Classes:
  192. cora-z7s
  193. '''
  194. self.assertEqual(ret, 0)
  195. self.assertEqual(stdout, output)
  196. ac.assert_called_with(base_url='http://someserver/')
  197. acg.assert_called_with('board/classes', auth=BiteAuth('thisisanapikey'), **_httpxargs)
  198. # XXX - add error cases for UI
  199. @patch('bitelab.__main__.get_sshpubkey')
  200. @patch.dict(sys.__dict__, dict(argv=[ '', 'reserve', 'cora-z7s' ]))
  201. def test_reserve(self, gspk):
  202. gspk.side_effect = IOError()
  203. ac = self.ac
  204. acp = self.acp
  205. acp.return_value.status_code = HTTP_200_OK
  206. acp.return_value.json.return_value = Board(name='cora-1',
  207. brdclass='cora-z7s', reserved=True,
  208. attrs={
  209. 'ip': '172.20.20.5',
  210. 'power': False,
  211. }).dict()
  212. keydata = 'randomkeydata'
  213. ret, stdout = self.runMain()
  214. output = '''Name:\tcora-1
  215. Class:\tcora-z7s
  216. Attributes:
  217. \tip\t'172.20.20.5'
  218. \tpower\tFalse
  219. '''
  220. self.assertEqual(ret, 0)
  221. self.assertEqual(stdout, output)
  222. ac.assert_called_with(base_url='http://someserver/')
  223. #args = { 'sshpubkey': keydata }
  224. acp.assert_called_with('board/cora-z7s/reserve',
  225. #json=args,
  226. auth=BiteAuth('thisisanapikey'), **_httpxargs)
  227. @patch('bitelab.__main__.get_sshpubkey')
  228. @patch.dict(sys.__dict__, dict(argv=[ '', 'reserve', '-i', 'fixtures/testsshkey.pub', 'cora-z7s' ]))
  229. def test_reserve_ssh(self, gspk):
  230. ac = self.ac
  231. acp = self.acp
  232. acp.return_value.status_code = HTTP_200_OK
  233. acp.return_value.json.return_value = Board(name='cora-1',
  234. brdclass='cora-z7s', reserved=True,
  235. attrs={
  236. 'ip': '172.20.20.5',
  237. 'power': False,
  238. }).dict()
  239. keydata = 'keydata'
  240. gspk.return_value = keydata
  241. ret, stdout = self.runMain()
  242. output = '''Name:\tcora-1
  243. Class:\tcora-z7s
  244. Attributes:
  245. \tip\t'172.20.20.5'
  246. \tpower\tFalse
  247. '''
  248. self.assertEqual(ret, 0)
  249. self.assertEqual(stdout, output)
  250. ac.assert_called_with(base_url='http://someserver/')
  251. acp.assert_called_with('board/cora-z7s/reserve',
  252. json=dict(sshpubkey=keydata),
  253. auth=BiteAuth('thisisanapikey'), **_httpxargs)
  254. @patch('bitelab.__main__.get_sshpubkey')
  255. @patch.dict(sys.__dict__, dict(argv=[ '', 'release', 'cora-z7s' ]))
  256. def test_release(self, gspk):
  257. gspk.side_effect = IOError
  258. ac = self.ac
  259. acp = self.acp
  260. acp.return_value.status_code = HTTP_200_OK
  261. acp.return_value.json.return_value = Board(name='cora-1',
  262. brdclass='cora-z7s', reserved=False,
  263. attrs={
  264. 'power': False,
  265. }).dict()
  266. ret, stdout = self.runMain()
  267. output = '''Name:\tcora-1
  268. Class:\tcora-z7s
  269. Attributes:
  270. \tpower\tFalse
  271. '''
  272. self.assertEqual(ret, 0)
  273. self.assertEqual(stdout, output)
  274. ac.assert_called_with(base_url='http://someserver/')
  275. acp.assert_called_with('board/cora-z7s/release',
  276. auth=BiteAuth('thisisanapikey'), **_httpxargs)
  277. @patch('bitelab.__main__._typeconv', dict(power=makebool, other=makebool))
  278. @patch.dict(sys.__dict__, dict(argv=[ '', 'set', 'power=on', 'other=off', 'cora-z7s' ]))
  279. def test_set(self):
  280. ac = self.ac
  281. acp = self.acp
  282. acp.return_value.status_code = HTTP_200_OK
  283. acp.return_value.json.return_value = Board(name='cora-1',
  284. brdclass='cora-z7s', reserved=True,
  285. attrs={
  286. 'ip': '172.20.20.5',
  287. 'power': True,
  288. }).dict()
  289. ret, stdout = self.runMain()
  290. output = '''Name:\tcora-1
  291. Class:\tcora-z7s
  292. Attributes:
  293. \tip\t'172.20.20.5'
  294. \tpower\tTrue
  295. '''
  296. self.assertEqual(ret, 0)
  297. self.assertEqual(stdout, output)
  298. ac.assert_called_with(base_url='http://someserver/')
  299. acp.assert_called_with('board/cora-z7s/attrs',
  300. auth=BiteAuth('thisisanapikey'), json=dict(power=True, other=False),
  301. **_httpxargs)
  302. def test_make_attrs(self):
  303. self.assertEqual(make_attrs('power=on'), dict(power=True))
  304. self.assertEqual(make_attrs('power=True'), dict(power=True))
  305. self.assertEqual(make_attrs('power=1'), dict(power=True))
  306. self.assertEqual(make_attrs('power=off'), dict(power=False))
  307. self.assertEqual(make_attrs('power=FALSE'), dict(power=False))
  308. self.assertEqual(make_attrs('power=0'), dict(power=False))
  309. self.assertRaises(ValueError, make_attrs, 'power=bar')
  310. def test_check_res_code(self):
  311. # XXX - on weird errors, res.json() will fail w/ json.decoder.JSONDecodeError
  312. pass