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 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 functools import lru_cache, wraps


from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
@@ -6,8 +7,10 @@ from fastapi.security import OAuth2PasswordBearer
from httpx import AsyncClient, Auth from httpx import AsyncClient, Auth
from mock import patch, AsyncMock, Mock from mock import patch, AsyncMock, Mock
from pydantic import BaseModel 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 config
from . import data from . import data
@@ -16,6 +19,7 @@ import asyncio
import gc import gc
import orm import orm
import socket import socket
import sqlite3
import sys import sys
import tempfile import tempfile
import unittest import unittest
@@ -46,8 +50,8 @@ async def snmpget(host, oid, type):


raise RuntimeError('unknown type: %s' % repr(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: class Attribute:
defattrname = None defattrname = None
@@ -65,8 +69,8 @@ class SNMPPower(Power):
return await snmpget(self.host, return await snmpget(self.host,
'pethPsePortAdminEnable.1.%d' % self.port, 'bool') 'pethPsePortAdminEnable.1.%d' % self.port, 'bool')


async def setvalue(self, v):
pass
#async def setvalue(self, v):
# pass


class BoardClassInfo(BaseModel): class BoardClassInfo(BaseModel):
clsname: str clsname: str
@@ -79,11 +83,22 @@ class BoardImpl:
self.options = options self.options = options
self.reserved = False self.reserved = False
self.attrmap = {} self.attrmap = {}
self.lock = asyncio.Lock()
for i in options: for i in options:
self.attrmap[i.defattrname] = i self.attrmap[i.defattrname] = i


self.attrcache = {} 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): async def update(self):
for i in self.attrmap: for i in self.attrmap:
self.attrcache[i] = await self.attrmap[i].getvalue() self.attrcache[i] = await self.attrmap[i].getvalue()
@@ -101,6 +116,15 @@ class Board(BaseModel):
class Config: class Config:
orm_mode = True orm_mode = True


class Error(BaseModel):
error: str
board: Optional[Board]

@dataclass
class BITEError(Exception):
errobj: Error
status_code: int

class BoardManager(object): class BoardManager(object):
board_class_info = { board_class_info = {
'cora-z7s': { 'cora-z7s': {
@@ -161,20 +185,32 @@ class BiteAuth(Auth):
yield request yield request


@lru_cache() @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() return config.Settings()


# how to get coverage for this?
@unhashable_lru() @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)) print(repr(settings))
database = data.databases.Database('sqlite:///' + settings.db_file) database = data.databases.Database('sqlite:///' + settings.db_file)
d = data.make_orm(self.database) d = data.make_orm(self.database)
return d return d


@unhashable_lru() @unhashable_lru()
def get_boardmanager(settings: config.Settings = Depends(get_settings)):
def sync_get_boardmanager(settings):
return 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') oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/nonexistent')


async def lookup_user(token: str = Depends(oauth2_scheme), 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 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]) @router.get('/board/',response_model=Dict[str, Board])
async def get_boards(user: str = Depends(lookup_user), async def get_boards(user: str = Depends(lookup_user),
brdmgr: BoardManager = Depends(get_boardmanager)): brdmgr: BoardManager = Depends(get_boardmanager)):
@@ -226,6 +313,10 @@ def getApp():
app = FastAPI() app = FastAPI()
app.include_router(router) 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 return app


# uvicorn can't call the above function, while hypercorn can # uvicorn can't call the above function, while hypercorn can
@@ -344,6 +435,95 @@ class TestBiteLab(unittest.IsolatedAsyncioTestCase):


self.assertEqual(r, True) 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') @patch('bitelab.snmpget')
async def test_board_info(self, sg): async def test_board_info(self, sg):
# that when snmpget returns False # that when snmpget returns False


+ 10
- 0
bitelab/data.py View File

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


@@ -14,6 +15,15 @@ class DataWrapper(object):
def make_orm(database): def make_orm(database):
metadata = sqlalchemy.MetaData() 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): class APIKey(orm.Model):
__tablename__ = 'apikeys' __tablename__ = 'apikeys'
__database__ = database __database__ = database


Loading…
Cancel
Save