diff --git a/README.md b/README.md index 7f6a389..7a26dc4 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Add the SDK to your project by following the [installation](#installation) instr then create your `handler.py`: ```python +import logging from crowdstrike.foundry.function import ( APIError, Request, @@ -35,7 +36,8 @@ func = Function.instance() # *** (1) *** @func.handler(method='POST', path='/create') # *** (2) *** -def on_create(request: Request, config: [dict[str, any], None]) -> Response: # *** (3), (4) *** +def on_create(request: Request, config: [dict[str, any], None], + logger: logging.Logger) -> Response: # *** (3), (4), (5) *** if len(request.body) == 0: return Response( code=400, @@ -46,14 +48,30 @@ def on_create(request: Request, config: [dict[str, any], None]) -> Response: # # do something useful ##### - return Response( # *** (5) *** + return Response( # *** (6) *** body={'hello': 'world'}, code=200, ) +@func.handler(method='PUT', path='/update') +def on_update(request: Request) -> Response: # *** (7) *** + # do stuff + return Response( + # ...snip... + ) + + +@func.handler(method='DELETE', path='/foo') +def on_delete(request: Request, config: [dict[str, any], None]) -> Response: # *** (8) *** + # do stuff + return Response( + # ...snip... + ) + + if __name__ == '__main__': - func.run() # *** (6) *** + func.run() # *** (9) *** ``` 1. `Function`: The `Function` class wraps the Foundry Function implementation. @@ -75,14 +93,22 @@ if __name__ == '__main__': 3. `url`: The request path relative to the function as a string. 4. `method`: The request HTTP method or verb. 5. `access_token`: Caller-supplied access token. -5. Return from a `@handler` function: Returns a `Response` object. +5. `logger`: Unless there is specific reason not to, the function author should use the `Logger` provided to the + function. + When deployed, the supplied `Logger` will be formatted in a custom manner and will have fields injected to assist + with working against our internal logging infrastructure. + Failure to use the provided `Logger` can thus make triage more difficult. +6. Return from a `@handler` function: Returns a `Response` object. The `Response` object contains fields `body` (payload of the response as a `dict`), `code` (an `int` representing an HTTP status code), `errors` (a list of any `APIError`s), and `header` (a `dict[str, list[str]]` of any special HTTP headers which should be present on the response). If no `code` is provided but a list of `errors` is, the `code` will be derived from the greatest positive valid HTTP code present on the given `APIError`s. -6. `func.run()`: Runner method and general starting point of execution. +7. `on_update(request: Request)`: If only one argument is provided, only a `Request` will be provided. +8. `on_delete(request: Request, config: [dict[str, any], None])`: If two arguments are provided, a `Request` and config + will be provided. +9. `func.run()`: Runner method and general starting point of execution. Calling `run()` causes the `Function` to finish initializing and start executing. Any code declared following this method may not necessarily be executed. As such, it is recommended to place this as the last line of your script. @@ -110,21 +136,18 @@ curl -X POST 'http://localhost:8081' \ }' ``` -## Convenience Functionality 🧰 +## Working with `falconpy` -### `falconpy` +Function authors should import `falconpy` explicitly as a requirement in their project when needed. -Foundry Function Python ships with [falconpy](https://github.com/CrowdStrike/falconpy) pre-integrated and a convenience -constructor. -While it is not strictly necessary to use the convenience function, it is recommended. +### General usage **Important:** Create a new instance of each `falconpy` client you want on each request. ```python # omitting other imports from falconpy.alerts import Alerts -from falconpy.event_streams import EventStreams -from crowdstrike.foundry.function import falcon_client, Function +from crowdstrike.foundry.function import cloud, Function func = Function.instance() @@ -136,8 +159,7 @@ def endpoint(request, config): # !!! create a new client instance on each request !!! # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - alerts_client = falcon_client(Alerts) - event_streams_client = falcon_client(EventStreams) + falconpy_alerts = Alerts(access_token=request.access_token, base_url=cloud()) # ... omitting other code ... ``` diff --git a/requirements.txt b/requirements.txt index 09d11c7..0ab83fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ build>=1.0.3 -crowdstrike-falconpy>=1.3.2 pytest>=7.4.2 urllib3>=1.26.16,<2.0 diff --git a/setup.py b/setup.py index 138f80d..1748e07 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ SETUP_REQUIRES = [ 'setuptools', ] -VERSION = '0.6.0' +VERSION = '1.0.0' def main(): diff --git a/src/crowdstrike/foundry/function/__init__.py b/src/crowdstrike/foundry/function/__init__.py index 00cb192..35fe02d 100644 --- a/src/crowdstrike/foundry/function/__init__.py +++ b/src/crowdstrike/foundry/function/__init__.py @@ -1,5 +1,18 @@ -from crowdstrike.foundry.function.falconpy import falcon_client from crowdstrike.foundry.function.model import * +import sys +import logging + + +def _new_http_logger() -> logging.Logger: + f = logging.Formatter('%(asctime)s [%(levelname)s] %(filename)s %(funcName)s:%(lineno)d -> %(message)s') + + h = logging.StreamHandler(sys.stdout) + h.setFormatter(f) + + l = logging.getLogger("cs-logger") + l.setLevel('DEBUG') + l.addHandler(h) + return l class Function: @@ -17,6 +30,7 @@ def instance( loader=None, router=None, runner=None, + logger=None, ) -> 'Function': """ Fetch the singleton instance of the :class:`Function`, creating one if one does not yet exist. @@ -26,6 +40,8 @@ def instance( :param loader: :class:`Loader` instance. :param router: :class:`Router` instance. :param runner: :class:`RunnerBase` instance. + :param logger: :class:`Logger` instance. Note: A CrowdStrike-specific logging instance will be provided + internally. :returns: :class:`Function` singleton. """ if Function._instance is None: @@ -34,6 +50,7 @@ def instance( config=config, config_loader=config_loader, loader=loader, + logger=logger, router=router, runner=runner, ) @@ -45,6 +62,7 @@ def __init__( config=None, config_loader=None, loader=None, + logger=None, router=None, runner=None, ): @@ -53,6 +71,8 @@ def __init__( :param config: Configuration to provide to the user's code. :param config_loader: :class:`ConfigLoaderBase` instance capable of loading configuration if `config` is None. :param loader: :class:`Loader` instance. + :param logger: :class:`logging.Logger` instance. Note: A CrowdStrike-specific logging instance will be provided + internally. :param router: :class:`Router` instance. :param runner: :class:`RunnerBase` instance. """ @@ -61,6 +81,8 @@ def __init__( self._router = router self._runner = runner + if logger is None: + logger = _new_http_logger() if self._config is None: if config_loader is None: from crowdstrike.foundry.function.config_loader import ConfigLoader @@ -72,7 +94,7 @@ def __init__( self._loader = Loader() if self._router is None: from crowdstrike.foundry.function.router import Router - self._router = Router(self._config) + self._router = Router(self._config, logger=logger) if self._runner is None: from crowdstrike.foundry.function.runner import Runner from crowdstrike.foundry.function.runner_http import HTTPRunner @@ -106,3 +128,19 @@ def call(func): )) return call + + +def cloud() -> str: + """ + Retrieves a FalconPy-compatible identifier which identifies the cloud in which this function is running. + :return: Cloud in which this function is executing. + """ + import os + + _default = 'auto' + c = os.environ.get('CS_CLOUD', _default) + c = c.lower().replace('-', '').strip() + if c == '': + c = _default + + return c diff --git a/src/crowdstrike/foundry/function/falconpy.py b/src/crowdstrike/foundry/function/falconpy.py deleted file mode 100644 index 86812dd..0000000 --- a/src/crowdstrike/foundry/function/falconpy.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -from crowdstrike.foundry.function.context import ctx_request -from falconpy import ServiceClass -from typing import Type - - -def falcon_client(client_class: Type) -> ServiceClass: - """ - Returns an instance of a FalconPy client. - :param client_class: Class which extends :class:`falconpy.ServiceClass`. - :return: Initialized instance of the client_class. - """ - _cloud_default = 'auto' - if not issubclass(client_class, ServiceClass): - msg = f'provided class {client_class.__name__} does not extend falconpy.ServiceClass' - raise TypeError(msg) - - req = ctx_request.get() - if req is None: - msg = 'the falcon_client() convenience method requires a request be present' - raise AssertionError(msg) - - access_token = req.access_token - if access_token is None or type(access_token) is not str or access_token.strip() == '': - msg = 'request must have an access token to use the falcon_client()' - raise AssertionError(msg) - - cloud = os.environ.get('CS_CLOUD', _cloud_default) - cloud = cloud.lower().replace('-', '').strip() - if cloud == '': - cloud = _cloud_default - - # set cloud on the request object that is in the context - req.cloud = cloud - - client = client_class(access_token=access_token, base_url=cloud) - return client diff --git a/src/crowdstrike/foundry/function/model.py b/src/crowdstrike/foundry/function/model.py index 4d769b7..bba6f3f 100644 --- a/src/crowdstrike/foundry/function/model.py +++ b/src/crowdstrike/foundry/function/model.py @@ -19,10 +19,12 @@ class Request: access_token: str = field(default='') body: Dict[str, any] = field(default_factory=lambda: {}) context: Dict[str, any] = field(default_factory=lambda: {}) + fn_id: str = field(default='') + fn_version: int = field(default=0) method: str = field(default='') params: RequestParams = field(default_factory=lambda: RequestParams()) + trace_id: str = field(default='') url: str = field(default='') - cloud: str = field(default='') @dataclass diff --git a/src/crowdstrike/foundry/function/router.py b/src/crowdstrike/foundry/function/router.py index 1fb3783..31b78f4 100644 --- a/src/crowdstrike/foundry/function/router.py +++ b/src/crowdstrike/foundry/function/router.py @@ -1,6 +1,8 @@ from crowdstrike.foundry.function.model import FDKException, Request, Response from dataclasses import dataclass from http.client import BAD_REQUEST, METHOD_NOT_ALLOWED, NOT_FOUND, SERVICE_UNAVAILABLE +from inspect import signature +from logging import Logger from typing import Callable @@ -16,8 +18,9 @@ class Router: Serves to route function requests to the appropriate handler functions. """ - def __init__(self, config): + def __init__(self, config, logger: [Logger, None] = None): self._config = config + self._logger = logger self._routes = {} def route(self, req: Request) -> Response: @@ -35,15 +38,26 @@ def route(self, req: Request) -> Response: message="Unsupported method format, expects string: {}".format(req.method)) methods_for_url = self._routes.get(req.url, None) + req_method = req.method.strip().upper() if methods_for_url is None: - raise FDKException(code=NOT_FOUND, message="Not Found: {}".format(req.url)) + raise FDKException(code=NOT_FOUND, message="Not Found: {} {}".format(req_method, req.url)) - req_method = req.method.strip().upper() r = methods_for_url.get(req_method, None) if r is None: - raise FDKException(code=METHOD_NOT_ALLOWED, message="Method Not Allowed: {}".format(req_method)) + raise FDKException(code=METHOD_NOT_ALLOWED, message="Method Not Allowed: {} at endpoint".format(req_method)) + + return self._call_route(r, req) + + def _call_route(self, route: Route, req: Request): + f = route.func + len_params = len(signature(f).parameters) - return r.func(req, self._config) + # We'll make this more flexible in the future if needed. + if len_params == 3: + return f(req, self._config, self._logger) + if len_params == 2: + return f(req, self._config) + return f(req) def register(self, r: Route): """ diff --git a/tests/crowdstrike/foundry/function/test__init__.py b/tests/crowdstrike/foundry/function/test__init__.py index 83ffc01..481a338 100644 --- a/tests/crowdstrike/foundry/function/test__init__.py +++ b/tests/crowdstrike/foundry/function/test__init__.py @@ -1,13 +1,25 @@ -from crowdstrike.foundry.function import Function, Request, Response, FDKException +import os +from crowdstrike.foundry.function import Function, Request, Response, FDKException, cloud from crowdstrike.foundry.function.router import Route, Router +from logging import Logger from tests.crowdstrike.foundry.function.utils import CapturingRunner, StaticConfigLoader from unittest import main, TestCase +from unittest.mock import patch if __name__ == '__main__': main() -def do_request(req, config): +def do_request1(req): + return Response( + body={ + 'req': req.body, + }, + code=200, + ) + + +def do_request2(req, config): return Response( body={ 'config': config, @@ -17,15 +29,37 @@ def do_request(req, config): ) +def do_request3(req, config, logger): + return Response( + body={ + 'config': config, + 'logger': logger, + 'req': req.body, + }, + code=200, + ) + + class TestRequestLifecycle(TestCase): + logger = Logger(__name__) def setUp(self): config = {'a': 'b'} - router = Router(config) + router = Router(config, self.logger) router.register(Route( method='POST', - path='/request', - func=do_request, + path='/request1', + func=do_request1, + )) + router.register(Route( + method='POST', + path='/request2', + func=do_request2, + )) + router.register(Route( + method='POST', + path='/request3', + func=do_request3, )) self.runner = CapturingRunner() self.runner.bind_router(router) @@ -35,11 +69,27 @@ def setUp(self): runner=self.runner, ) - def test_request(self): + def test_request1(self): req = Request( body={'hello': 'world'}, method='POST', - url='/request', + url='/request1', + ) + self.function.run(req) + resp = self.runner.response + self.assertIsNotNone(resp, 'response is none') + self.assertEqual(200, resp.code, f'expected response of 200 but got {resp.code}') + self.assertDictEqual( + {'req': {'hello': 'world'}}, + resp.body, + 'actual body differs from expected body' + ) + + def test_request2(self): + req = Request( + body={'hello': 'world'}, + method='POST', + url='/request2', ) self.function.run(req) resp = self.runner.response @@ -51,8 +101,28 @@ def test_request(self): 'actual body differs from expected body' ) + def test_request3(self): + req = Request( + body={'hello': 'world'}, + method='POST', + url='/request3', + ) + self.function.run(req) + resp = self.runner.response + self.assertIsNotNone(resp, 'response is none') + self.assertEqual(200, resp.code, f'expected response of 200 but got {resp.code}') + self.assertDictEqual( + { + 'config': {'a': 'b'}, + 'logger': self.logger, + 'req': {'hello': 'world'}, + }, + resp.body, + 'actual body differs from expected body' + ) + def test_unknown_endpoint(self): - with self.assertRaisesRegex(FDKException, "Not Found: /xyz"): + with self.assertRaisesRegex(FDKException, "Not Found: GET /xyz"): req = Request( body={'hello': 'world'}, method='GET', @@ -65,6 +135,19 @@ def test_unknown_method(self): req = Request( body={'hello': 'world'}, method='GET', - url='/request', + url='/request1', ) self.function.run(req) + + +class TestCloud(TestCase): + + def test_cloud_returns_default_if_none_specified(self): + with patch.dict(os.environ, {}, clear=True): + c = cloud() + self.assertEqual("auto", c) + + def test_cloud_returns_cloud_in_env(self): + with patch.dict(os.environ, {'CS_CLOUD': 'us-gov-1'}, clear=True): + c = cloud() + self.assertEqual("usgov1", c) diff --git a/tests/crowdstrike/foundry/function/test_falconpy.py b/tests/crowdstrike/foundry/function/test_falconpy.py deleted file mode 100644 index abbe168..0000000 --- a/tests/crowdstrike/foundry/function/test_falconpy.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -import unittest -from unittest.mock import patch -from crowdstrike.foundry.function.context import ctx_request -from crowdstrike.foundry.function.falconpy import falcon_client -from crowdstrike.foundry.function.model import Request -from falconpy import Hosts - -if __name__ == '__main__': - unittest.main() - - -class TestFalconClient(unittest.TestCase): - - def setUp(self) -> None: - ctx_request.set(None) - - def tearDown(self) -> None: - ctx_request.set(None) - - def test_non_falconpy_class_raises_error(self): - with self.assertRaisesRegex(TypeError, 'does not extend falconpy.ServiceClass'): - falcon_client(unittest.TestCase) - - def test_no_request_raises_error(self): - with self.assertRaisesRegex(AssertionError, 'convenience method requires a request be present'): - falcon_client(Hosts) - - def test_request_without_access_token_raises_error(self): - r = Request() - ctx_request.set(r) - with self.assertRaisesRegex(AssertionError, 'request must have an access token to use the falcon_client()'): - falcon_client(Hosts) - - def test_request_with_access_token_returns_prepared_client_at_us1(self): - ctx_request.set(Request(access_token='foo')) - client = falcon_client(Hosts) - - self.assertIsInstance(client, Hosts) - self.assertEqual('Bearer foo', client.headers.get('Authorization')) - self.assertEqual('https://api.crowdstrike.com', client.base_url) - - def test_request_with_access_token_and_non_default_cloud_returns_prepared_client_at_appropriate_cloud(self): - ctx_request.set(Request(access_token='foo')) - - tests = [ - {'given': 'us-1', 'expected': 'https://api.crowdstrike.com'}, - {'given': 'us-gov-1', 'expected': 'https://api.laggar.gcw.crowdstrike.com'}, - ] - for t in tests: - with patch.dict(os.environ, {'CS_CLOUD': t['given']}, clear=True): - client = falcon_client(Hosts) - - self.assertIsInstance(client, Hosts) - self.assertEqual('Bearer foo', client.headers.get('Authorization')) - self.assertEqual(t['expected'], client.base_url) - - def test_request_inserts_cloud_into_request(self): - with patch.dict(os.environ, {'CS_CLOUD': 'us-gov-1'}, clear=True): - ctx_request.set(Request(access_token='foo')) - client = falcon_client(Hosts) - - self.assertIsInstance(client, Hosts) - self.assertEqual('Bearer foo', client.headers.get('Authorization')) - self.assertEqual('https://api.laggar.gcw.crowdstrike.com', client.base_url) - r = ctx_request.get() - self.assertEqual('usgov1', r.cloud) diff --git a/tests/crowdstrike/foundry/function/test_mapping.py b/tests/crowdstrike/foundry/function/test_mapping.py index 1fa0a5a..0e9d58b 100644 --- a/tests/crowdstrike/foundry/function/test_mapping.py +++ b/tests/crowdstrike/foundry/function/test_mapping.py @@ -21,6 +21,8 @@ def test_from_json_payload(self): context={ 'goodnight': 'moon', }, + fn_id='d31cd12d3e29422484a0d1ba0ac60e79', + fn_version=123, method='GET', params=RequestParams( header={ @@ -42,6 +44,8 @@ def test_from_json_payload(self): 'context': { 'goodnight': 'moon', }, + 'fn_id': 'd31cd12d3e29422484a0d1ba0ac60e79', + 'fn_version': 123, 'method': 'GET', 'params': { 'header': {