Browse Source

implement basic reserve/release... need to call the functions to

implement them...  add warning about other databases...
main
John-Mark Gurney 4 years ago
parent
commit
f5e318ea25
3 changed files with 208 additions and 11 deletions
  1. +8
    -1
      README.md
  2. +190
    -10
      bitelab/__init__.py
  3. +10
    -0
      bitelab/data.py

+ 8
- 1
README.md View File

@@ -1,4 +1,11 @@
BITELAB
=======

TODO

NOTES
=====

This will only work w/ the sqlite3 backend. The orm package does not
properly wrap database errors in a database independant exception.
The necessary errors should be caught by the test suite, so supporting
other databases should be straight forward to do.

+ 190
- 10
bitelab/__init__.py View File

@@ -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


+ 10
- 0
bitelab/data.py View File

@@ -1,4 +1,5 @@
import databases
from datetime import datetime
import orm
import sqlalchemy

@@ -14,6 +15,15 @@ class DataWrapper(object):
def make_orm(database):
metadata = sqlalchemy.MetaData()

class BoardStatus(orm.Model):
__tablename__ = 'boardstatus'
__database__ = database
__metadata__ = metadata

board = orm.Text(primary_key=True)
user = orm.Text(index=True)
time_reserved = orm.DateTime(default=lambda: datetime.utcnow())

class APIKey(orm.Model):
__tablename__ = 'apikeys'
__database__ = database


Loading…
Cancel
Save