|
|
@@ -1,4 +1,5 @@ |
|
|
|
from typing import Optional, Dict, Any |
|
|
|
from typing import Optional, Union, Dict, Any |
|
|
|
from dataclasses import dataclass |
|
|
|
from functools import lru_cache, wraps |
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request |
|
|
@@ -6,8 +7,10 @@ from fastapi.security import OAuth2PasswordBearer |
|
|
|
from httpx import AsyncClient, Auth |
|
|
|
from mock import patch, AsyncMock, Mock |
|
|
|
from pydantic import BaseModel |
|
|
|
from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND, \ |
|
|
|
HTTP_401_UNAUTHORIZED |
|
|
|
from starlette.responses import JSONResponse |
|
|
|
from starlette.status import HTTP_200_OK |
|
|
|
from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, \ |
|
|
|
HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT |
|
|
|
|
|
|
|
from . import config |
|
|
|
from . import data |
|
|
@@ -16,6 +19,7 @@ import asyncio |
|
|
|
import gc |
|
|
|
import orm |
|
|
|
import socket |
|
|
|
import sqlite3 |
|
|
|
import sys |
|
|
|
import tempfile |
|
|
|
import unittest |
|
|
@@ -46,8 +50,8 @@ async def snmpget(host, oid, type): |
|
|
|
|
|
|
|
raise RuntimeError('unknown type: %s' % repr(type)) |
|
|
|
|
|
|
|
async def snmpset(host, oid, value): |
|
|
|
return await _snmpwrapper('set', host, oid, value=value) |
|
|
|
#async def snmpset(host, oid, value): |
|
|
|
# return await _snmpwrapper('set', host, oid, value=value) |
|
|
|
|
|
|
|
class Attribute: |
|
|
|
defattrname = None |
|
|
@@ -65,8 +69,8 @@ class SNMPPower(Power): |
|
|
|
return await snmpget(self.host, |
|
|
|
'pethPsePortAdminEnable.1.%d' % self.port, 'bool') |
|
|
|
|
|
|
|
async def setvalue(self, v): |
|
|
|
pass |
|
|
|
#async def setvalue(self, v): |
|
|
|
# pass |
|
|
|
|
|
|
|
class BoardClassInfo(BaseModel): |
|
|
|
clsname: str |
|
|
@@ -79,11 +83,22 @@ class BoardImpl: |
|
|
|
self.options = options |
|
|
|
self.reserved = False |
|
|
|
self.attrmap = {} |
|
|
|
self.lock = asyncio.Lock() |
|
|
|
for i in options: |
|
|
|
self.attrmap[i.defattrname] = i |
|
|
|
|
|
|
|
self.attrcache = {} |
|
|
|
|
|
|
|
async def reserve(self): |
|
|
|
assert self.lock.locked() and not self.reserved |
|
|
|
|
|
|
|
self.reserved = True |
|
|
|
|
|
|
|
async def release(self): |
|
|
|
assert self.lock.locked() and self.reserved |
|
|
|
|
|
|
|
self.reserved = False |
|
|
|
|
|
|
|
async def update(self): |
|
|
|
for i in self.attrmap: |
|
|
|
self.attrcache[i] = await self.attrmap[i].getvalue() |
|
|
@@ -101,6 +116,15 @@ class Board(BaseModel): |
|
|
|
class Config: |
|
|
|
orm_mode = True |
|
|
|
|
|
|
|
class Error(BaseModel): |
|
|
|
error: str |
|
|
|
board: Optional[Board] |
|
|
|
|
|
|
|
@dataclass |
|
|
|
class BITEError(Exception): |
|
|
|
errobj: Error |
|
|
|
status_code: int |
|
|
|
|
|
|
|
class BoardManager(object): |
|
|
|
board_class_info = { |
|
|
|
'cora-z7s': { |
|
|
@@ -161,20 +185,32 @@ class BiteAuth(Auth): |
|
|
|
yield request |
|
|
|
|
|
|
|
@lru_cache() |
|
|
|
def get_settings(): |
|
|
|
def sync_get_board_lock(): |
|
|
|
return asyncio.Lock() |
|
|
|
|
|
|
|
async def get_board_lock(): |
|
|
|
return sync_get_board_lock() |
|
|
|
|
|
|
|
# how to get coverage for this? |
|
|
|
@lru_cache() |
|
|
|
def get_settings(): # pragma: no cover |
|
|
|
return config.Settings() |
|
|
|
|
|
|
|
# how to get coverage for this? |
|
|
|
@unhashable_lru() |
|
|
|
def get_data(settings: config.Settings = Depends(get_settings)): |
|
|
|
def get_data(settings: config.Settings = Depends(get_settings)): # pragma: no cover |
|
|
|
print(repr(settings)) |
|
|
|
database = data.databases.Database('sqlite:///' + settings.db_file) |
|
|
|
d = data.make_orm(self.database) |
|
|
|
return d |
|
|
|
|
|
|
|
@unhashable_lru() |
|
|
|
def get_boardmanager(settings: config.Settings = Depends(get_settings)): |
|
|
|
def sync_get_boardmanager(settings): |
|
|
|
return BoardManager(settings) |
|
|
|
|
|
|
|
async def get_boardmanager(settings: config.Settings = Depends(get_settings)): |
|
|
|
return sync_get_boardmanager(settings) |
|
|
|
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/nonexistent') |
|
|
|
|
|
|
|
async def lookup_user(token: str = Depends(oauth2_scheme), |
|
|
@@ -208,6 +244,57 @@ async def get_board_info(board_id, user: str = Depends(lookup_user), |
|
|
|
|
|
|
|
return brd |
|
|
|
|
|
|
|
@router.post('/board/{board_id}/reserve', response_model=Union[Board, Error]) |
|
|
|
async def reserve_board(board_id, user: str = Depends(lookup_user), |
|
|
|
brdmgr: BoardManager = Depends(get_boardmanager), |
|
|
|
brdlck: asyncio.Lock = Depends(get_board_lock), |
|
|
|
data: data.DataWrapper = Depends(get_data)): |
|
|
|
brd = brdmgr.boards[board_id] |
|
|
|
|
|
|
|
async with brd.lock: |
|
|
|
try: |
|
|
|
await data.BoardStatus.objects.create(board=board_id, user=user) |
|
|
|
await brd.reserve() |
|
|
|
# XXX - orm isn't doing it's job here |
|
|
|
except sqlite3.IntegrityError: |
|
|
|
raise BITEError( |
|
|
|
status_code=HTTP_409_CONFLICT, |
|
|
|
errobj=Error(error='Board currently reserved.', board=Board.from_orm(brd)), |
|
|
|
) |
|
|
|
|
|
|
|
await brd.update() |
|
|
|
|
|
|
|
return brd |
|
|
|
|
|
|
|
@router.post('/board/{board_id}/release', response_model=Union[Board, Error]) |
|
|
|
async def release_board(board_id, user: str = Depends(lookup_user), |
|
|
|
brdmgr: BoardManager = Depends(get_boardmanager), |
|
|
|
brdlck: asyncio.Lock = Depends(get_board_lock), |
|
|
|
data: data.DataWrapper = Depends(get_data)): |
|
|
|
brd = brdmgr.boards[board_id] |
|
|
|
|
|
|
|
async with brd.lock: |
|
|
|
try: |
|
|
|
brduser = await data.BoardStatus.objects.get(board=board_id) |
|
|
|
if user != brduser.user: |
|
|
|
raise BITEError( |
|
|
|
status_code=HTTP_403_FORBIDDEN, |
|
|
|
errobj=Error(error='Board reserved by %s.' % repr(brduser), |
|
|
|
board=Board.from_orm(brd))) |
|
|
|
|
|
|
|
await data.BoardStatus.delete(brduser) |
|
|
|
await brd.release() |
|
|
|
|
|
|
|
except orm.exceptions.NoMatch: |
|
|
|
raise BITEError( |
|
|
|
status_code=HTTP_400_BAD_REQUEST, |
|
|
|
errobj=Error(error='Board not reserved.', board=Board.from_orm(brd)), |
|
|
|
) |
|
|
|
|
|
|
|
await brd.update() |
|
|
|
|
|
|
|
return brd |
|
|
|
|
|
|
|
@router.get('/board/',response_model=Dict[str, Board]) |
|
|
|
async def get_boards(user: str = Depends(lookup_user), |
|
|
|
brdmgr: BoardManager = Depends(get_boardmanager)): |
|
|
@@ -226,6 +313,10 @@ def getApp(): |
|
|
|
app = FastAPI() |
|
|
|
app.include_router(router) |
|
|
|
|
|
|
|
@app.exception_handler(BITEError) |
|
|
|
async def error_handler(request, exc): |
|
|
|
return JSONResponse(exc.errobj.dict(), status_code=exc.status_code) |
|
|
|
|
|
|
|
return app |
|
|
|
|
|
|
|
# uvicorn can't call the above function, while hypercorn can |
|
|
@@ -344,6 +435,95 @@ class TestBiteLab(unittest.IsolatedAsyncioTestCase): |
|
|
|
|
|
|
|
self.assertEqual(r, True) |
|
|
|
|
|
|
|
# that a bogus return value |
|
|
|
proc.communicate.return_value = 'bogus\n' |
|
|
|
|
|
|
|
# raises an error |
|
|
|
with self.assertRaises(RuntimeError): |
|
|
|
await snmpget('somehost', 'snmpoid', 'bool') |
|
|
|
|
|
|
|
# that an unknown type, raises an error |
|
|
|
with self.assertRaises(RuntimeError): |
|
|
|
await snmpget('somehost', 'snmpoid', 'randomtype') |
|
|
|
|
|
|
|
@patch('bitelab.snmpget') |
|
|
|
async def test_board_reserve_release(self, sg): |
|
|
|
# that when releasing a board that is not yet reserved |
|
|
|
res = await self.client.post('/board/cora-1/release', |
|
|
|
auth=BiteAuth('anotherlongapikey')) |
|
|
|
|
|
|
|
# that it returns an error |
|
|
|
self.assertEqual(res.status_code, HTTP_400_BAD_REQUEST) |
|
|
|
|
|
|
|
# that when snmpget returns False |
|
|
|
sg.return_value = False |
|
|
|
|
|
|
|
# that reserving the board |
|
|
|
res = await self.client.post('/board/cora-1/reserve', |
|
|
|
auth=BiteAuth('thisisanapikey')) |
|
|
|
|
|
|
|
# that it is successful |
|
|
|
self.assertEqual(res.status_code, HTTP_200_OK) |
|
|
|
|
|
|
|
# and returns the correct data |
|
|
|
info = { |
|
|
|
'name': 'cora-1', |
|
|
|
'brdclass': 'cora-z7s', |
|
|
|
'reserved': True, |
|
|
|
'attrs': { 'power': False }, |
|
|
|
} |
|
|
|
self.assertEqual(res.json(), info) |
|
|
|
|
|
|
|
# that another user reserving the board |
|
|
|
res = await self.client.post('/board/cora-1/reserve', |
|
|
|
auth=BiteAuth('anotherlongapikey')) |
|
|
|
|
|
|
|
# that the request is successful |
|
|
|
# it should likely be this, but can't get FastAPI exceptions to work |
|
|
|
self.assertEqual(res.status_code, HTTP_409_CONFLICT) |
|
|
|
|
|
|
|
# and returns the correct data |
|
|
|
info = { |
|
|
|
'error': 'Board currently reserved.', |
|
|
|
'board': info, |
|
|
|
} |
|
|
|
self.assertEqual(res.json(), info) |
|
|
|
|
|
|
|
# that another user releases the board |
|
|
|
res = await self.client.post('/board/cora-1/release', |
|
|
|
auth=BiteAuth('anotherlongapikey')) |
|
|
|
|
|
|
|
# that it is denied |
|
|
|
self.assertEqual(res.status_code, HTTP_403_FORBIDDEN) |
|
|
|
|
|
|
|
# and returns the correct data |
|
|
|
info = { |
|
|
|
'error': 'Board not reserved by you.', |
|
|
|
'board': info, |
|
|
|
} |
|
|
|
|
|
|
|
# that when the correct user releases the board |
|
|
|
res = await self.client.post('/board/cora-1/release', |
|
|
|
auth=BiteAuth('thisisanapikey')) |
|
|
|
|
|
|
|
# it is allowed |
|
|
|
self.assertEqual(res.status_code, HTTP_200_OK) |
|
|
|
|
|
|
|
# and returns the correct data |
|
|
|
info = { |
|
|
|
'name': 'cora-1', |
|
|
|
'brdclass': 'cora-z7s', |
|
|
|
'reserved': False, |
|
|
|
'attrs': { 'power': False }, |
|
|
|
} |
|
|
|
|
|
|
|
# that it can be reserved by a different user |
|
|
|
res = await self.client.post('/board/cora-1/reserve', |
|
|
|
auth=BiteAuth('anotherlongapikey')) |
|
|
|
|
|
|
|
# that it is successful |
|
|
|
self.assertEqual(res.status_code, HTTP_200_OK) |
|
|
|
|
|
|
|
@patch('bitelab.snmpget') |
|
|
|
async def test_board_info(self, sg): |
|
|
|
# that when snmpget returns False |
|
|
|