From f5e318ea2578e3064e14085b8fd8ce706f4b61d3 Mon Sep 17 00:00:00 2001 From: John-Mark Gurney Date: Tue, 17 Nov 2020 21:37:20 -0800 Subject: [PATCH] implement basic reserve/release... need to call the functions to implement them... add warning about other databases... --- README.md | 9 +- bitelab/__init__.py | 200 +++++++++++++++++++++++++++++++++++++++++--- bitelab/data.py | 10 +++ 3 files changed, 208 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9b2ec93..2cc346e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bitelab/__init__.py b/bitelab/__init__.py index e56bbd6..a765887 100644 --- a/bitelab/__init__.py +++ b/bitelab/__init__.py @@ -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 diff --git a/bitelab/data.py b/bitelab/data.py index 04617ba..3ea2583 100644 --- a/bitelab/data.py +++ b/bitelab/data.py @@ -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