commit 0b5546dd8e757206096bfc813088ac18e339c0da Author: John-Mark Gurney Date: Sat Nov 14 01:29:13 2020 -0800 Initial work on the API for the lab... This gets the basic framework together, and testing infrastructure. Basic bearer authentication is implemented and working. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee5ec9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.coverage + +*.pyc + +bitelab.egg-info +p diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9042f6b --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +MODULES=bitelab +VIRTUALENV?=virtualenv-3.8 + +test: + (ls $(MODULES)/*.py | entr sh -c 'python -m coverage run -m unittest $(basename $(MODULES)) && coverage report --omit=p/\* -m -i') + +env: + ($(VIRTUALENV) p && . ./p/bin/activate && pip install -r requirements.txt) + +run: + ($(VIRTUALENV) p && . ./p/bin/activate && hypercorn --bind '0.0.0.0:7742' --bind '[::]:7742' --bind 'unix:/tmp/bitelab.sock' 'bitelab:getApp()') diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b2ec93 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +BITELAB +======= + +TODO diff --git a/bitelab/__init__.py b/bitelab/__init__.py new file mode 100644 index 0000000..3420846 --- /dev/null +++ b/bitelab/__init__.py @@ -0,0 +1,183 @@ +from typing import Optional +from functools import lru_cache, wraps + +from fastapi import APIRouter, Depends, FastAPI, Request +from fastapi.security import OAuth2PasswordBearer +from httpx import AsyncClient, Auth +from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_401_UNAUTHORIZED + +from . import config + +import asyncio +import gc +import socket +import sys +import unittest + +# fix up parse_socket_addr for hypercorn +from hypercorn.utils import parse_socket_addr +from hypercorn.asyncio import tcp_server +def new_parse_socket_addr(domain, addr): + if domain == socket.AF_UNIX: + return (addr, -1) + + return parse_socket_addr(domain, addr) + +tcp_server.parse_socket_addr = new_parse_socket_addr + +class BoardManager(object): + board_classes = [ 'cora-z7s' ] + + def __init__(self, settings): + self._settings = settings + + def classes(self): + return self.board_classes + +def unhashable_lru(): + def newwrapper(fun): + cache = {} + + @wraps(fun) + def wrapper(*args, **kwargs): + idargs = tuple(id(x) for x in args) + idkwargs = tuple(sorted((k, id(v)) for k, v in + kwargs.items())) + k = (idargs, idkwargs) + if k in cache: + realargs, realkwargs, res = cache[k] + if all(x is y for x, y in zip(args, + realargs)) and all(realkwargs[x] is + kwargs[x] for x in realkwargs): + return res + + res = fun(*args, **kwargs) + cache[k] = (args, kwargs, res) + + return res + + return wrapper + + return newwrapper + +class BiteAuth(Auth): + def __init__(self, token): + self.token = token + + def auth_flow(self, request): + request.headers['Authorization'] = 'Bearer ' + self.token + yield request + +@lru_cache() +def get_settings(): + return config.Settings() + +@unhashable_lru() +def get_boardmanager(settings: config.Settings = Depends(get_settings)): + return BoardManager(settings) + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/nonexistent') + +def lookup_user(token: str = Depends(oauth2_scheme), settings: config.Settings = Depends(get_settings)): + try: + return settings.apikeytouser(token) + except KeyError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid authentication credentials', + headers={'WWW-Authenticate': 'Bearer'}, + ) + +router = APIRouter() + +def board_priority(request: Request): + # Get the board, if any, from the connection + scope = request.scope + return scope['server'] + +@router.get('/board_classes') +async def foo(user: str = Depends(lookup_user), brdmgr: BoardManager = Depends(get_boardmanager)): + return brdmgr.classes() + +@router.get('/board_info') +async def foo(user: str = Depends(lookup_user), brdmgr: BoardManager = Depends(get_boardmanager)): + return brdmgr.classes() + +@router.get('/') +async def foo(board_prio: dict = Depends(board_priority), settings: config.Settings = Depends(get_settings)): + return { 'foo': 'bar', 'board': board_prio } + +def getApp(): + app = FastAPI() + app.include_router(router) + + return app + +# uvicorn can't call the above function, while hypercorn can +#app = getApp() + +class TestUnhashLRU(unittest.TestCase): + def test_unhashlru(self): + lsta = [] + lstb = [] + + # that a wrapped function + cachefun = unhashable_lru()(lambda x: object()) + + # handles unhashable objects + resa = cachefun(lsta) + resb = cachefun(lstb) + + # that they return the same object again + self.assertIs(resa, cachefun(lsta)) + self.assertIs(resb, cachefun(lstb)) + + # that the object returned is not the same + self.assertIsNot(cachefun(lsta), cachefun(lstb)) + + # that a second wrapped funcion + cachefun2 = unhashable_lru()(lambda x: object()) + + # does not return the same object as the first cache + self.assertIsNot(cachefun(lsta), cachefun2(lsta)) + +# Note: this will not work under python before 3.8 before +# IsolatedAsyncioTestCase was added. The tearDown has to happen +# with the event loop running, otherwise the task and other things +# do not get cleaned up properly. +class TestBiteLab(unittest.IsolatedAsyncioTestCase): + async def get_settings_override(self): + # Note: this gets run on each request. + return config.Settings(apikeyfile="fixtures/api_keys") + + def setUp(self): + self.app = getApp() + self.app.dependency_overrides[get_settings] = self.get_settings_override + self.client = AsyncClient(app=self.app, base_url='http://testserver') + + def tearDown(self): + self.app = None + asyncio.run(self.client.aclose()) + self.client = None + + async def test_config(self): + settings = await self.get_settings_override() + self.assertEqual(settings.apikeytouser('thisisanapikey'), 'foo') + self.assertEqual(settings.apikeytouser('anotherlongapikey'), 'bar') + + async def test_basic(self): + res = await self.client.get('/') + self.assertNotEqual(res.status_code, HTTP_404_NOT_FOUND) + self.assertEqual(res.json(), { 'foo': 'bar', 'board': [ 'testserver', None ] }) + + async def test_notauth(self): + res = await self.client.get('/board_classes') + self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED) + + res = await self.client.get('/board_info') + self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED) + + async def test_classes(self): + res = await self.client.get('/board_classes', auth=BiteAuth('thisisanapikey')) + self.assertEqual(res.status_code, HTTP_200_OK) + self.assertEqual(res.json(), [ 'cora-z7s' ]) diff --git a/bitelab/config.py b/bitelab/config.py new file mode 100644 index 0000000..4f5b023 --- /dev/null +++ b/bitelab/config.py @@ -0,0 +1,47 @@ +from pydantic import BaseSettings + +import asyncio +import aiokq + +# How to deal w/ private vars: +# https://web.archive.org/web/20201113005838/https://github.com/samuelcolvin/pydantic/issues/655 +class Settings(BaseSettings): + __slots__ = ('_apikeydb', '_updatetask', '_fp', ) + apikeyfile: str + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + fp = open(self.apikeyfile) + self._sync_updateapikeys(fp) + updtsk = asyncio.create_task(aiokq.run_on_modify(fp, + self._updateapikeys)) + + object.__setattr__(self, '_fp', fp) + object.__setattr__(self, '_updatetask', updtsk) + + # Mark this as covered because coverage says the code isn't run, + # despite the fact that it does get run (and can trigger an + # exception) + def __del__(self): # pragma: no cover + self._updatetask.cancel() + # XXX - looks like task is done before getting here + self._fp.close() + + async def _updateapikeys(self, fp): + self._sync_updateapikeys(fp) + + def _sync_updateapikeys(self, fp): + fp.seek(0) + keydb = {} + for line in fp.readlines(): + user, key = line.split() + keydb[key] = user + + object.__setattr__(self, '_apikeydb', keydb) + + def apikeytouser(self, key): + return self._apikeydb[key] + + class Config: + env_file = ".env" diff --git a/fixtures/api_keys b/fixtures/api_keys new file mode 100644 index 0000000..21d5baf --- /dev/null +++ b/fixtures/api_keys @@ -0,0 +1,2 @@ +foo thisisanapikey +bar anotherlongapikey diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c518e6b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# use setup.py for dependancy info +-e . + +-e .[dev] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6632d68 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ + +# python setup.py --dry-run --verbose install + +import os.path +from setuptools import setup, find_packages + +from distutils.core import setup + +setup( + name='bitelab', + version='0.1.0', + author='John-Mark Gurney', + author_email='jmg@FreeBSD.org', + packages=find_packages(), + #url='', + license='BSD', + description='Build Integrity and Testing of Embedded systems LAB', + #download_url='', + long_description=open('README.md').read(), + install_requires=[ + 'fastapi', + 'httpx', + 'hypercorn', # option, for server only? + 'pydantic[dotenv]', + 'aiokq @ git+https://www.funkthat.com/gitea/jmg/aiokq.git' + ], + extras_require = { + 'dev': [ 'coverage' ], + }, +)