From 70c083a475e93e49f4fda04e72a0242a9fb52ef5 Mon Sep 17 00:00:00 2001 From: Andreas Hellander Date: Tue, 7 Jan 2025 15:28:29 +0100 Subject: [PATCH 01/10] Feature/SK-1235 | Improve API documentation (#770) --- docs/apiclient.rst | 35 ++- docs/img/find_controller_url.png | Bin 0 -> 200646 bytes docs/img/generate_admin_token.png | Bin 0 -> 100140 bytes examples/api-tutorials/API_Example.ipynb | 204 ++++++++++++++++++ .../Aggregators.ipynb | 0 .../Hyperparameter_Tuning.ipynb | 0 examples/notebooks/API_Example.ipynb | 204 ------------------ 7 files changed, 229 insertions(+), 214 deletions(-) create mode 100644 docs/img/find_controller_url.png create mode 100644 docs/img/generate_admin_token.png create mode 100644 examples/api-tutorials/API_Example.ipynb rename examples/{notebooks => api-tutorials}/Aggregators.ipynb (100%) rename examples/{notebooks => api-tutorials}/Hyperparameter_Tuning.ipynb (100%) delete mode 100644 examples/notebooks/API_Example.ipynb diff --git a/docs/apiclient.rst b/docs/apiclient.rst index 360691d18..c17451820 100644 --- a/docs/apiclient.rst +++ b/docs/apiclient.rst @@ -1,13 +1,12 @@ .. _apiclient-label: -Using the FEDn API Client +Using the API Client ==================== -FEDn comes with an *APIClient* - a Python3 library that can be used to interact with FEDn programmatically. -In this tutorial we show how to use the APIClient to initialize the server-side with the compute package and seed models, -run and control training sessions, use different aggregators, and to retrieve models and metrics. +FEDn comes with an *APIClient* - a Python3 library that is used to interact with FEDn programmatically. -We assume a basic understanding of the FEDn framework, i.e. that the user has taken the :ref:`quickstart-label` tutorial. +This guide assumes that the user has aleady taken the :ref:`quickstart-label` tutorial. If this is not the case, please start there to learn how to set up a FEDn Studio project and learn +to connect clients. In this guide we will build on that same PyTorch example (MNIST), showing how to use the APIClient to control training sessions, use different aggregators, and to retrieve models and metrics. **Installation** @@ -17,14 +16,22 @@ The APIClient is available as a Python package on PyPI, and can be installed usi $ pip install fedn -**Initialize the APIClient to a FEDn Studio project** +**Connect the APIClient to the FEDn project** -The FEDn REST API is available at /api/v1/. To access this API you need the url to the controller-host, as well as an admin API token. The controller host can be found in the project dashboard (top right corner). -To obtain an admin API token, +To access the API you need the URL to the controller-host, as well as an admin API token. You +obtain these from your Studio project. The controller host can be found in the top-right corner of the dashboard: + +.. image:: img/find_controller_url.png + +To obtain an admin API token, #. Navigate to the "Settings" tab in your Studio project and click on the "Generate token" button. #. Copy the 'access' token and use it to access the API using the instructions below. +.. image:: img/generate_admin_token.png + +To initalize the connection to the FEDn REST API: + .. code-block:: python >>> from fedn import APIClient @@ -43,6 +50,11 @@ Then passing a token as an argument is not required. >>> from fedn import APIClient >>> client = APIClient(host="", secure=True, verify=True) +We are now ready to work with the API. + +We here assume that you have worked through steps 1-2 in the quisktart tutorial, i.e. that you have created the compute package and seed model on your local machine. +In the next step, we will use the API to upload these objects to the Studio project (corresponding to step 3 in the quickstart tutorial). + **Set the active compute package and seed model** To set the active compute package in the FEDn Studio Project: @@ -69,6 +81,8 @@ using the default aggregator (FedAvg): >>> model_id = models[-1]['model'] >>> validations = client.get_validations(model_id=model_id) +You can follow the progress of the training in the Studio UI. + To run a session using the FedAdam aggregator using custom hyperparamters: .. code-block:: python @@ -130,9 +144,10 @@ To get a specific session: >>> session = client.get_session(id="session_name") -For more information on how to use the APIClient, see the :py:mod:`fedn.network.api.client`, and the collection of example Jupyter Notebooks: +For more information on how to use the APIClient, see the :py:mod:`fedn.network.api.client`. +There is also a collection of Jupyter Notebooks showcasing more advanced use of the API, including how to work with other built-in aggregators and how to automate hyperparameter tuning: -- `API Example `_ . +- `API Example `_ . .. meta:: diff --git a/docs/img/find_controller_url.png b/docs/img/find_controller_url.png new file mode 100644 index 0000000000000000000000000000000000000000..c266e2019413352358362f0b2769b51480611c8e GIT binary patch literal 200646 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

O1e!c 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 L`njxgN@xNASFYLi literal 0 HcmV?d00001 diff --git a/docs/img/generate_admin_token.png b/docs/img/generate_admin_token.png new file mode 100644 index 0000000000000000000000000000000000000000..d0a5b4d8b4930dd449d2a5c2b1d004b5442826f3 GIT binary patch literal 100140 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 I_0Gfp13VV0%>V!Z literal 0 HcmV?d00001 diff --git a/examples/api-tutorials/API_Example.ipynb b/examples/api-tutorials/API_Example.ipynb new file mode 100644 index 000000000..bb04472d6 --- /dev/null +++ b/examples/api-tutorials/API_Example.ipynb @@ -0,0 +1,204 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "622f7047", + "metadata": {}, + "source": [ + "## API Example\n", + "\n", + "This notebook provides an example of how to use the FEDn API to organize experiments and to analyze validation results. We will here run one training session (a collection of global rounds) using FedAvg, then retrive and visualize the results. For a complete list of implemented interfaces, please refer to the [FEDn APIs](https://fedn.readthedocs.io/en/latest/fedn.network.api.html#module-fedn.network.api.client).\n", + "\n", + "Before starting this tutorial, make sure you have a project running in FEDn Studio and have created the compute package and the initial model. If you're not sure how to do this, please follow the instructions in sections 1, 2, and 3 of the [quickstart guide](https://fedn.readthedocs.io/en/latest/quickstart.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "743dfe47", + "metadata": {}, + "outputs": [], + "source": [ + "from fedn import APIClient\n", + "import time\n", + "import uuid\n", + "import json\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import collections" + ] + }, + { + "cell_type": "markdown", + "id": "1046a4e5", + "metadata": {}, + "source": [ + "We connect to the FEDn API service. In this example, we assume the project is hosted on the public FEDn Studio. You can find the CONTROLLER_HOST address in the project dashboard. \n", + "\n", + "NOTE: If you're using a local sandbox, the CONTROLLER_HOST will be \"localhost or 127.0.0.1 or your local node's IP address\" and the CONTROLLER_PORT will be 8092. \n", + "\n", + "Next, you'll need to generate an access token. To do this, go to the project page in FEDn Studio, click on \"Settings,\" then \"Generate token.\" Copy the access token from the Studio and paste it into the notebook. In case you need further details, have a look at the [Fedn ClientAPIs](https://fedn.readthedocs.io/en/latest/apiclient.html#). " + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "1061722d", + "metadata": {}, + "outputs": [], + "source": [ + "CONTROLLER_HOST = '' \n", + "ACCESS_TOKEN = ''\n", + "client = APIClient(CONTROLLER_HOST,token=ACCESS_TOKEN, secure=True,verify=True)" + ] + }, + { + "cell_type": "markdown", + "id": "07f69f5f", + "metadata": {}, + "source": [ + "Initialize FEDn with the compute package and seed model. Note that these files needs to be created separately. If you're not sure how to do this, please follow the instructions in the [quickstart guide](https://fedn.readthedocs.io/en/latest/quickstart.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "5107f6f9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'committed_at': 'Sun, 01 Dec 2024 18:41:40 GMT', 'id': '674cade4e17757ea8146d74d', 'key': 'models', 'model': 'd25f0bd9-6fc9-4cf1-9d8d-4ed9e04dfef0', 'parent_model': None, 'session_id': None}\n" + ] + } + ], + "source": [ + "client.set_active_package('../mnist-pytorch/package.tgz', 'numpyhelper')\n", + "client.set_active_model('../mnist-pytorch/seed.npz')\n", + "seed_model = client.get_active_model()\n", + "print(seed_model)" + ] + }, + { + "cell_type": "markdown", + "id": "4e26c50b", + "metadata": {}, + "source": [ + "Next we start a training session using FedAvg and wait until it has finished:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "f0380d35", + "metadata": {}, + "outputs": [], + "source": [ + "session_id = \"experiment1\"\n", + "\n", + "session_config = {\n", + " \"helper\": \"numpyhelper\",\n", + " \"id\": session_id,\n", + " \"aggregator\": \"fedavg\",\n", + " \"model_id\": seed_model['model'],\n", + " \"rounds\": 10\n", + " }\n", + "\n", + "result_fedavg = client.start_session(**session_config)\n", + "\n", + "# We wait for the session to finish\n", + "while not client.session_is_finished(session_config['id']):\n", + " time.sleep(2)" + ] + }, + { + "cell_type": "markdown", + "id": "16874cec", + "metadata": {}, + "source": [ + "Next, we get the model trail, retrieve all model validations from all clients, extract the training accuracy metric, and compute its mean value accross all clients." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "4e8044b7", + "metadata": {}, + "outputs": [], + "source": [ + "models = client.get_model_trail()\n", + "\n", + "acc = []\n", + "for model in models:\n", + " \n", + " model_id = model[\"model\"]\n", + " validations = client.get_validations(model_id=model_id)\n", + "\n", + " a = []\n", + " for validation in validations['result']: \n", + " metrics = json.loads(validation['data'])\n", + " a.append(metrics['training_accuracy'])\n", + " \n", + " acc.append(a)\n", + "\n", + "mean_acc = [np.mean(x) for x in acc]" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "42425c43", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "

" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "x = range(1,len(mean_acc)+1)\n", + "plt.plot(x, mean_acc)\n", + "plt.legend(['Training Accuracy (FedAvg)'])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/Aggregators.ipynb b/examples/api-tutorials/Aggregators.ipynb similarity index 100% rename from examples/notebooks/Aggregators.ipynb rename to examples/api-tutorials/Aggregators.ipynb diff --git a/examples/notebooks/Hyperparameter_Tuning.ipynb b/examples/api-tutorials/Hyperparameter_Tuning.ipynb similarity index 100% rename from examples/notebooks/Hyperparameter_Tuning.ipynb rename to examples/api-tutorials/Hyperparameter_Tuning.ipynb diff --git a/examples/notebooks/API_Example.ipynb b/examples/notebooks/API_Example.ipynb deleted file mode 100644 index 79e3501c6..000000000 --- a/examples/notebooks/API_Example.ipynb +++ /dev/null @@ -1,204 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "622f7047", - "metadata": {}, - "source": [ - "## API Example\n", - "\n", - "This notebook provides an example of how to use the FEDn API to organize experiments and to analyze validation results. We will here run one training session (a collection of global rounds) using FedAvg, then retrive and visualize the results. For a complete list of implemented interfaces, please refer to the [FEDn APIs](https://fedn.readthedocs.io/en/latest/fedn.network.api.html#module-fedn.network.api.client).\n", - "\n", - "Before starting this tutorial, make sure you have a project running in FEDn Studio and have created the compute package and the initial model. If you're not sure how to do this, please follow the instructions in sections 1, 2, and 3 of the [quickstart guide](https://fedn.readthedocs.io/en/latest/quickstart.html)." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "743dfe47", - "metadata": {}, - "outputs": [], - "source": [ - "from fedn import APIClient\n", - "import time\n", - "import uuid\n", - "import json\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import collections" - ] - }, - { - "cell_type": "markdown", - "id": "1046a4e5", - "metadata": {}, - "source": [ - "We connect to the FEDn API service. In this example, we assume the project is hosted on the public FEDn Studio. You can find the CONTROLLER_HOST address in the project dashboard. \n", - "\n", - "NOTE: If you're using a local sandbox, the CONTROLLER_HOST will be \"localhost or 127.0.0.1 or your local node's IP address\" and the CONTROLLER_PORT will be 8092. \n", - "\n", - "Next, you'll need to generate an access token. To do this, go to the project page in FEDn Studio, click on \"Settings,\" then \"Generate token.\" Copy the access token from the Studio and paste it into the notebook. In case you need further details, have a look at the [Fedn ClientAPIs](https://fedn.readthedocs.io/en/latest/apiclient.html#). " - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "1061722d", - "metadata": {}, - "outputs": [], - "source": [ - "CONTROLLER_HOST = 'fedn.scaleoutsystems.com/my-project...' \n", - "ACCESS_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzI3MzQ3NDA4LCJpYXQiOjE3MjQ3NTU0MDgsImp0aSI6ImQxMTY4OTJkODJlMjRhZjJiYzQzZTllZjVlNGVlZDhmIiwidXNlcl9pZCI6NTUsImNyZWF0b3IiOiJzYWxtYW4iLCJyb2xlIjoiYWRtaW4iLCJwcm9qZWN0X3NsdWciOiJldXJvcGFyMjQtd29ya3Nob3AtZWJ4In0.k9pXUh6Ldb-jEzl77FjsxvAAjcbPoB'\n", - "client = APIClient(CONTROLLER_HOST,token=ACCESS_TOKEN, secure=True,verify=True)" - ] - }, - { - "cell_type": "markdown", - "id": "07f69f5f", - "metadata": {}, - "source": [ - "Initialize FEDn with the compute package and seed model. Note that these files needs to be created separately. If you're not sure how to do this, please follow the instructions only in section 3 of the [quickstart guide](https://fedn.readthedocs.io/en/latest/quickstart.html#create-the-compute-package-and-seed-model)." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "5107f6f9", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'committed_at': 'Mon, 01 Apr 2024 17:59:42 GMT', 'id': '660af60e7025e379dc7e8412', 'key': 'models', 'model': '6b7e9ada-0c38-470e-a62b-9ed46f72c465', 'parent_model': None, 'session_id': None}\n" - ] - } - ], - "source": [ - "client.set_active_package('../mnist-pytorch/package.tgz', 'numpyhelper')\n", - "client.set_active_model('../mnist-pytorch/seed.npz')\n", - "seed_model = client.get_active_model()\n", - "print(seed_model)" - ] - }, - { - "cell_type": "markdown", - "id": "4e26c50b", - "metadata": {}, - "source": [ - "Next we start a training session using FedAvg and wait until it has finished:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "f0380d35", - "metadata": {}, - "outputs": [], - "source": [ - "session_id = \"experiment1\"\n", - "\n", - "session_config = {\n", - " \"helper\": \"numpyhelper\",\n", - " \"id\": session_id,\n", - " \"aggregator\": \"fedavg\",\n", - " \"model_id\": seed_model['model'],\n", - " \"rounds\": 10\n", - " }\n", - "\n", - "result_fedavg = client.start_session(**session_config)\n", - "\n", - "# We wait for the session to finish\n", - "while not client.session_is_finished(session_config['id']):\n", - " time.sleep(2)" - ] - }, - { - "cell_type": "markdown", - "id": "16874cec", - "metadata": {}, - "source": [ - "Next, we get the model trail, retrieve all model validations from all clients, extract the training accuracy metric, and compute its mean value accross all clients." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4e8044b7", - "metadata": {}, - "outputs": [], - "source": [ - "models = client.get_model_trail()\n", - "\n", - "acc = []\n", - "for model in models:\n", - " \n", - " model_id = model[\"model\"]\n", - " validations = client.get_validations(model_id=model_id)\n", - "\n", - " a = []\n", - " for validation in validations['result']: \n", - " metrics = json.loads(validation['data'])\n", - " a.append(metrics['training_accuracy'])\n", - " \n", - " acc.append(a)\n", - "\n", - "mean_acc = [np.mean(x) for x in acc]" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "42425c43", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "x = range(1,len(mean_acc)+1)\n", - "plt.plot(x, mean_acc)\n", - "plt.legend(['Training Accuracy (FedAvg)'])" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.6" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From 8df89db3c71c22b06aa6dd76ded1aab15eca8ccd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jan 2025 15:33:47 +0100 Subject: [PATCH 02/10] Fix/SK-000 | Bump click from 8.1.7 to 8.1.8 (#778) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d679541cc..54bf79671 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "pyjwt", "pyopenssl", "psutil", - "click==8.1.7", + "click==8.1.8", "grpcio-health-checking>=1.60,<1.69", "pyyaml", "plotly", From 40764cb77a6f096cd41ba2afab257684fe24dab8 Mon Sep 17 00:00:00 2001 From: Niklas Date: Tue, 7 Jan 2025 16:02:13 +0100 Subject: [PATCH 03/10] Feature/SK-1109 | Make abstract class Store (#779) --- fedn/network/api/v1/client_routes.py | 23 ++---- fedn/network/api/v1/combiner_routes.py | 24 ++---- fedn/network/api/v1/model_routes.py | 30 +++---- fedn/network/api/v1/package_routes.py | 30 ++----- fedn/network/api/v1/prediction_routes.py | 18 ++--- fedn/network/api/v1/round_routes.py | 18 ++--- fedn/network/api/v1/session_routes.py | 27 +++---- fedn/network/api/v1/shared.py | 25 +++--- fedn/network/api/v1/status_routes.py | 27 ++----- fedn/network/api/v1/validation_routes.py | 23 ++---- .../storage/statestore/stores/client_store.py | 38 +++------ .../statestore/stores/combiner_store.py | 38 ++------- .../storage/statestore/stores/model_store.py | 43 +++------- .../statestore/stores/package_store.py | 79 ++++++++----------- .../statestore/stores/prediction_store.py | 35 ++------ .../storage/statestore/stores/round_store.py | 36 ++------- .../statestore/stores/session_store.py | 29 ++----- .../storage/statestore/stores/status_store.py | 36 ++------- .../storage/statestore/stores/store.py | 44 ++++++++--- .../statestore/stores/validation_store.py | 34 ++------ 20 files changed, 205 insertions(+), 452 deletions(-) diff --git a/fedn/network/api/v1/client_routes.py b/fedn/network/api/v1/client_routes.py index fb268905b..e1eb7ef5a 100644 --- a/fedn/network/api/v1/client_routes.py +++ b/fedn/network/api/v1/client_routes.py @@ -7,7 +7,6 @@ bp = Blueprint("client", __name__, url_prefix=f"/api/{api_version}/clients") - @bp.route("/", methods=["GET"]) @jwt_auth_required(role="admin") def get_clients(): @@ -109,14 +108,10 @@ def get_clients(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = request.args.to_dict() - clients = client_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = clients["result"] - - response = {"count": clients["count"], "result": result} + response = client_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -195,13 +190,9 @@ def list_clients(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = get_post_data_to_kwargs(request) - clients = client_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = clients["result"] - - response = {"count": clients["count"], "result": result} + response = client_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -357,9 +348,7 @@ def get_client(id: str): type: string """ try: - client = client_store.get(id, use_typing=False) - - response = client + response = client_store.get(id) return jsonify(response), 200 except EntityNotFound: @@ -367,7 +356,7 @@ def get_client(id: str): except Exception: return jsonify({"message": "An unexpected error occurred"}), 500 -# delete client + @bp.route("/", methods=["DELETE"]) @jwt_auth_required(role="admin") def delete_client(id: str): diff --git a/fedn/network/api/v1/combiner_routes.py b/fedn/network/api/v1/combiner_routes.py index 02617b7bb..966aea1dd 100644 --- a/fedn/network/api/v1/combiner_routes.py +++ b/fedn/network/api/v1/combiner_routes.py @@ -102,15 +102,11 @@ def get_combiners(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = request.args.to_dict() - combiners = combiner_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = combiners["result"] - - response = {"count": combiners["count"], "result": result} + response = combiner_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -185,15 +181,11 @@ def list_combiners(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = get_post_data_to_kwargs(request) - combiners = combiner_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = combiners["result"] - - response = {"count": combiners["count"], "result": result} + response = combiner_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -331,8 +323,7 @@ def get_combiner(id: str): type: string """ try: - combiner = combiner_store.get(id, use_typing=False) - response = combiner + response = combiner_store.get(id) return jsonify(response), 200 except EntityNotFound: @@ -340,6 +331,7 @@ def get_combiner(id: str): except Exception: return jsonify({"message": "An unexpected error occurred"}), 500 + @bp.route("/", methods=["DELETE"]) @jwt_auth_required(role="admin") def delete_combiner(id: str): @@ -421,9 +413,7 @@ def number_of_clients_connected(): combiners = combiners.split(",") if combiners else [] response = client_store.connected_client_count(combiners) - result = { - "result": response - } + result = {"result": response} return jsonify(result), 200 except Exception: diff --git a/fedn/network/api/v1/model_routes.py b/fedn/network/api/v1/model_routes.py index 9a3abdc47..c5f4e2fae 100644 --- a/fedn/network/api/v1/model_routes.py +++ b/fedn/network/api/v1/model_routes.py @@ -101,14 +101,10 @@ def get_models(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = request.args.to_dict() - models = model_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = models["result"] - - response = {"count": models["count"], "result": result} + response = model_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -186,14 +182,10 @@ def list_models(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = get_post_data_to_kwargs(request) - models = model_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = models["result"] - - response = {"count": models["count"], "result": result} + response = model_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -335,7 +327,7 @@ def get_model(id: str): type: string """ try: - model = model_store.get(id, use_typing=False) + model = model_store.get(id) response = model @@ -386,7 +378,7 @@ def patch_model(id: str): type: string """ try: - model = model_store.get(id, use_typing=False) + model = model_store.get(id) data = request.get_json() _id = model["id"] @@ -451,7 +443,7 @@ def put_model(id: str): type: string """ try: - model = model_store.get(id, use_typing=False) + model = model_store.get(id) data = request.get_json() _id = model["id"] @@ -511,7 +503,7 @@ def get_descendants(id: str): try: limit = get_limit(request.headers) - descendants = model_store.list_descendants(id, limit or 10, use_typing=False) + descendants = model_store.list_descendants(id, limit or 10) response = descendants @@ -580,7 +572,7 @@ def get_ancestors(id: str): include_self: bool = include_self_param and include_self_param.lower() == "true" - ancestors = model_store.list_ancestors(id, limit or 10, include_self=include_self, reverse=reverse, use_typing=False) + ancestors = model_store.list_ancestors(id, limit or 10, include_self=include_self, reverse=reverse) response = ancestors @@ -626,7 +618,7 @@ def download(id: str): """ try: if minio_repository is not None: - model = model_store.get(id, use_typing=False) + model = model_store.get(id) model_id = model["model"] file = minio_repository.get_artifact_stream(model_id, modelstorage_config["storage_config"]["storage_bucket"]) @@ -680,7 +672,7 @@ def get_parameters(id: str): """ try: if minio_repository is not None: - model = model_store.get(id, use_typing=False) + model = model_store.get(id) model_id = model["model"] file = minio_repository.get_artifact_stream(model_id, modelstorage_config["storage_config"]["storage_bucket"]) diff --git a/fedn/network/api/v1/package_routes.py b/fedn/network/api/v1/package_routes.py index c92d77cd4..4ed138369 100644 --- a/fedn/network/api/v1/package_routes.py +++ b/fedn/network/api/v1/package_routes.py @@ -5,15 +5,12 @@ from fedn.common.config import FEDN_COMPUTE_PACKAGE_DIR from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import (api_version, get_post_data_to_kwargs, - get_typed_list_headers, get_use_typing, - package_store, repository) +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, package_store, repository from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("package", __name__, url_prefix=f"/api/{api_version}/packages") - @bp.route("/", methods=["GET"]) @jwt_auth_required(role="admin") def get_packages(): @@ -119,14 +116,10 @@ def get_packages(): """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = request.args.to_dict() - packages = package_store.list(limit, skip, sort_key, sort_order, use_typing=True, **kwargs) - - result = [package.__dict__ for package in packages["result"]] - - response = {"count": packages["count"], "result": result} + response = package_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -207,14 +200,10 @@ def list_packages(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = get_post_data_to_kwargs(request) - packages = package_store.list(limit, skip, sort_key, sort_order, use_typing=True, **kwargs) - - result = [package.__dict__ for package in packages["result"]] - - response = {"count": packages["count"], "result": result} + response = package_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -379,10 +368,7 @@ def get_package(id: str): type: string """ try: - use_typing: bool = get_use_typing(request.headers) - package = package_store.get(id, use_typing=use_typing) - - response = package.__dict__ if use_typing else package + response = package_store.get(id) return jsonify(response), 200 except EntityNotFound: @@ -420,9 +406,7 @@ def get_active_package(): type: string """ try: - use_typing: bool = get_use_typing(request.headers) - package = package_store.get_active(use_typing=use_typing) - response = package.__dict__ if use_typing else package + response = package_store.get_active() return jsonify(response), 200 except EntityNotFound: diff --git a/fedn/network/api/v1/prediction_routes.py b/fedn/network/api/v1/prediction_routes.py index e5ce8edb7..0ea34224a 100644 --- a/fedn/network/api/v1/prediction_routes.py +++ b/fedn/network/api/v1/prediction_routes.py @@ -4,7 +4,7 @@ from fedn.network.api.auth import jwt_auth_required from fedn.network.api.shared import control -from fedn.network.api.v1.shared import api_version, mdb, get_typed_list_headers, get_post_data_to_kwargs +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, mdb from fedn.network.storage.statestore.stores.model_store import ModelStore from fedn.network.storage.statestore.stores.prediction_store import PredictionStore from fedn.network.storage.statestore.stores.shared import EntityNotFound @@ -170,14 +170,10 @@ def get_predictions(): type: string """ try: - limit, skip, sort_key, sort_order, use_typing = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = request.args.to_dict() - predictions = prediction_store.list(limit, skip, sort_key, sort_order, use_typing=use_typing, **kwargs) - - result = [prediction.__dict__ for prediction in predictions["result"]] if use_typing else predictions["result"] - - response = {"count": predictions["count"], "result": result} + response = prediction_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -268,14 +264,10 @@ def list_predictions(): type: string """ try: - limit, skip, sort_key, sort_order, use_typing = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = get_post_data_to_kwargs(request) - predictions = prediction_store.list(limit, skip, sort_key, sort_order, use_typing=use_typing, **kwargs) - - result = [prediction.__dict__ for prediction in predictions["result"]] if use_typing else predictions["result"] - - response = {"count": predictions["count"], "result": result} + response = prediction_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: diff --git a/fedn/network/api/v1/round_routes.py b/fedn/network/api/v1/round_routes.py index 14476a091..c4093059c 100644 --- a/fedn/network/api/v1/round_routes.py +++ b/fedn/network/api/v1/round_routes.py @@ -90,15 +90,11 @@ def get_rounds(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = request.args.to_dict() - rounds = round_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = rounds["result"] - - response = {"count": rounds["count"], "result": result} + response = round_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -169,15 +165,11 @@ def list_rounds(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = get_post_data_to_kwargs(request) - rounds = round_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = rounds["result"] - - response = {"count": rounds["count"], "result": result} + response = round_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -305,7 +297,7 @@ def get_round(id: str): type: string """ try: - round = round_store.get(id, use_typing=False) + round = round_store.get(id) response = round return jsonify(response), 200 diff --git a/fedn/network/api/v1/session_routes.py b/fedn/network/api/v1/session_routes.py index 9158b47df..1079566fe 100644 --- a/fedn/network/api/v1/session_routes.py +++ b/fedn/network/api/v1/session_routes.py @@ -90,14 +90,10 @@ def get_sessions(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = request.args.to_dict() - sessions = session_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = sessions["result"] - - response = {"count": sessions["count"], "result": result} + response = session_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -168,14 +164,10 @@ def list_sessions(): type: string """ try: - limit, skip, sort_key, sort_order, _ = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = get_post_data_to_kwargs(request) - sessions = session_store.list(limit, skip, sort_key, sort_order, use_typing=False, **kwargs) - - result = sessions["result"] - - response = {"count": sessions["count"], "result": result} + response = session_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -303,8 +295,7 @@ def get_session(id: str): type: string """ try: - session = session_store.get(id, use_typing=False) - response = session + response = session_store.get(id) return jsonify(response), 200 except EntityNotFound: @@ -386,7 +377,7 @@ def start_session(): if not session_id or session_id == "": return jsonify({"message": "Session ID is required"}), 400 - session = session_store.get(session_id, use_typing=False) + session = session_store.get(session_id) session_config = session["session_config"] model_id = session_config["model_id"] @@ -402,7 +393,7 @@ def start_session(): if nr_available_clients < min_clients: return jsonify({"message": f"Number of available clients is lower than the required minimum of {min_clients}"}), 400 - _ = model_store.get(model_id, use_typing=False) + _ = model_store.get(model_id) threading.Thread(target=control.start_session, args=(session_id, rounds, round_timeout)).start() @@ -451,7 +442,7 @@ def patch_session(id: str): type: string """ try: - session = session_store.get(id, use_typing=False) + session = session_store.get(id) data = request.get_json() _id = session["id"] @@ -516,7 +507,7 @@ def put_session(id: str): type: string """ try: - session = session_store.get(id, use_typing=False) + session = session_store.get(id) data = request.get_json() _id = session["id"] diff --git a/fedn/network/api/v1/shared.py b/fedn/network/api/v1/shared.py index 75b5d264b..946382cd5 100644 --- a/fedn/network/api/v1/shared.py +++ b/fedn/network/api/v1/shared.py @@ -3,8 +3,7 @@ import pymongo from pymongo.database import Database -from fedn.network.api.shared import (modelstorage_config, network_id, - statestore_config) +from fedn.network.api.shared import modelstorage_config, network_id, statestore_config from fedn.network.storage.s3.base import RepositoryBase from fedn.network.storage.s3.miniorepository import MINIORepository from fedn.network.storage.s3.repository import Repository @@ -39,11 +38,6 @@ def is_positive_integer(s): return s is not None and s.isdigit() and int(s) > 0 -def get_use_typing(headers: object) -> bool: - skip_typing: str = headers.get("X-Skip-Typing", "false") - return False if skip_typing.lower() == "true" else True - - def get_limit(headers: object) -> int: limit: str = headers.get("X-Limit") if is_positive_integer(limit): @@ -73,25 +67,32 @@ def get_typed_list_headers( limit: int = get_limit(headers) skip: int = get_skip(headers) - use_typing: bool = get_use_typing(headers) if sort_order is not None: sort_order = pymongo.ASCENDING if sort_order.lower() == "asc" else pymongo.DESCENDING else: sort_order = pymongo.DESCENDING - return limit, skip, sort_key, sort_order, use_typing + return limit, skip, sort_key, sort_order def get_post_data_to_kwargs(request: object) -> dict: - request_data = request.form.to_dict() + try: + # Try to get data from form + request_data = request.form.to_dict() + except Exception: + request_data = None if not request_data: - request_data = request.json + try: + # Try to get data from JSON + request_data = request.get_json() + except Exception: + request_data = {} kwargs = {} for key, value in request_data.items(): - if "," in value: + if isinstance(value, str) and "," in value: kwargs[key] = {"$in": value.split(",")} else: kwargs[key] = value diff --git a/fedn/network/api/v1/status_routes.py b/fedn/network/api/v1/status_routes.py index 0716f965b..00c69dae6 100644 --- a/fedn/network/api/v1/status_routes.py +++ b/fedn/network/api/v1/status_routes.py @@ -1,7 +1,7 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, get_use_typing, mdb +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, mdb from fedn.network.storage.statestore.stores.shared import EntityNotFound from fedn.network.storage.statestore.stores.status_store import StatusStore @@ -121,18 +121,10 @@ def get_statuses(): type: string """ try: - limit, skip, sort_key, sort_order, use_typing = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = request.args.to_dict() - # print all the typed headers - print(f"limit: {limit}, skip: {skip}, sort_key: {sort_key}, sort_order: {sort_order}, use_typing: {use_typing}") - print(f"kwargs: {kwargs}") - statuses = status_store.list(limit, skip, sort_key, sort_order, use_typing=use_typing, **kwargs) - - print(f"statuses: {statuses}") - result = [status.__dict__ for status in statuses["result"]] if use_typing else statuses["result"] - - response = {"count": statuses["count"], "result": result} + response = status_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -220,14 +212,10 @@ def list_statuses(): type: string """ try: - limit, skip, sort_key, sort_order, use_typing = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = get_post_data_to_kwargs(request) - statuses = status_store.list(limit, skip, sort_key, sort_order, use_typing=use_typing, **kwargs) - - result = [status.__dict__ for status in statuses["result"]] if use_typing else statuses["result"] - - response = {"count": statuses["count"], "result": result} + response = status_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -393,10 +381,7 @@ def get_status(id: str): type: string """ try: - use_typing: bool = get_use_typing(request.headers) - status = status_store.get(id, use_typing=use_typing) - - response = status.__dict__ if use_typing else status + response = status_store.get(id) return jsonify(response), 200 except EntityNotFound: diff --git a/fedn/network/api/v1/validation_routes.py b/fedn/network/api/v1/validation_routes.py index 665abbb4b..8294d41d4 100644 --- a/fedn/network/api/v1/validation_routes.py +++ b/fedn/network/api/v1/validation_routes.py @@ -1,7 +1,7 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, get_use_typing, mdb +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, mdb from fedn.network.storage.statestore.stores.shared import EntityNotFound from fedn.network.storage.statestore.stores.validation_store import ValidationStore @@ -128,14 +128,10 @@ def get_validations(): type: string """ try: - limit, skip, sort_key, sort_order, use_typing = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = request.args.to_dict() - validations = validation_store.list(limit, skip, sort_key, sort_order, use_typing=use_typing, **kwargs) - - result = [validation.__dict__ for validation in validations["result"]] if use_typing else validations["result"] - - response = {"count": validations["count"], "result": result} + response = validation_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -226,14 +222,10 @@ def list_validations(): type: string """ try: - limit, skip, sort_key, sort_order, use_typing = get_typed_list_headers(request.headers) + limit, skip, sort_key, sort_order = get_typed_list_headers(request.headers) kwargs = get_post_data_to_kwargs(request) - validations = validation_store.list(limit, skip, sort_key, sort_order, use_typing=use_typing, **kwargs) - - result = [validation.__dict__ for validation in validations["result"]] if use_typing else validations["result"] - - response = {"count": validations["count"], "result": result} + response = validation_store.list(limit, skip, sort_key, sort_order, **kwargs) return jsonify(response), 200 except Exception: @@ -406,10 +398,7 @@ def get_validation(id: str): type: string """ try: - use_typing: bool = get_use_typing(request.headers) - validation = validation_store.get(id, use_typing=use_typing) - - response = validation.__dict__ if use_typing else validation + response = validation_store.get(id) return jsonify(response), 200 except EntityNotFound: diff --git a/fedn/network/storage/statestore/stores/client_store.py b/fedn/network/storage/statestore/stores/client_store.py index dd521860f..4f5cd18e6 100644 --- a/fedn/network/storage/statestore/stores/client_store.py +++ b/fedn/network/storage/statestore/stores/client_store.py @@ -5,7 +5,7 @@ from bson import ObjectId from pymongo.database import Database -from fedn.network.storage.statestore.stores.store import Store +from fedn.network.storage.statestore.stores.store import MongoDBStore from .shared import EntityNotFound, from_document @@ -21,33 +21,23 @@ def __init__(self, id: str, name: str, combiner: str, combiner_preferred: str, i self.updated_at = updated_at self.last_seen = last_seen - def from_dict(data: dict) -> "Client": - return Client( - id=str(data["_id"]), - name=data["name"] if "name" in data else None, - combiner=data["combiner"] if "combiner" in data else None, - combiner_preferred=data["combiner_preferred"] if "combiner_preferred" in data else None, - ip=data["ip"] if "ip" in data else None, - status=data["status"] if "status" in data else None, - updated_at=data["updated_at"] if "updated_at" in data else None, - last_seen=data["last_seen"] if "last_seen" in data else None, - ) - -class ClientStore(Store[Client]): +class ClientStore(MongoDBStore[Client]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) - def get(self, id: str, use_typing: bool = False) -> Client: + def get(self, id: str) -> Client: """Get an entity by id param id: The id of the entity type: str - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ - response = super().get(id, use_typing=use_typing) - return Client.from_dict(response) if use_typing else response + if ObjectId.is_valid(id): + response = super().get(id) + else: + obj = self._get_client_by_client_id(id) + response = from_document(obj) + return response def _get_client_by_client_id(self, client_id: str) -> Dict: document = self.database[self.collection].find_one({"client_id": client_id}) @@ -85,7 +75,7 @@ def delete(self, id: str) -> bool: return super().delete(document["_id"]) - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[Client]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Client]]: """List entities param limit: The maximum number of entities to return type: int @@ -95,18 +85,12 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI type: str param sort_order: The order to sort by type: pymongo.DESCENDING | pymongo.ASCENDING - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool param kwargs: Additional query parameters type: dict example: {"key": "models"} return: A dictionary with the count and the result """ - response = super().list(limit, skip, sort_key or "last_seen", sort_order, use_typing=use_typing, **kwargs) - - result = [Client.from_dict(item) for item in response["result"]] if use_typing else response["result"] - - return {"count": response["count"], "result": result} + return super().list(limit, skip, sort_key or "last_seen", sort_order, **kwargs) def count(self, **kwargs) -> int: return super().count(**kwargs) diff --git a/fedn/network/storage/statestore/stores/combiner_store.py b/fedn/network/storage/statestore/stores/combiner_store.py index 2ad6437ea..8a938d06c 100644 --- a/fedn/network/storage/statestore/stores/combiner_store.py +++ b/fedn/network/storage/statestore/stores/combiner_store.py @@ -4,7 +4,7 @@ from bson import ObjectId from pymongo.database import Database -from fedn.network.storage.statestore.stores.store import Store +from fedn.network.storage.statestore.stores.store import MongoDBStore from .shared import EntityNotFound, from_document @@ -38,34 +38,16 @@ def __init__( self.status = status self.updated_at = updated_at - def from_dict(data: dict) -> "Combiner": - return Combiner( - id=str(data["_id"]), - name=data["name"] if "name" in data else None, - address=data["address"] if "address" in data else None, - certificate=data["certificate"] if "certificate" in data else None, - config=data["config"] if "config" in data else None, - fqdn=data["fqdn"] if "fqdn" in data else None, - ip=data["ip"] if "ip" in data else None, - key=data["key"] if "key" in data else None, - parent=data["parent"] if "parent" in data else None, - port=data["port"] if "port" in data else None, - status=data["status"] if "status" in data else None, - updated_at=data["updated_at"] if "updated_at" in data else None, - ) - - -class CombinerStore(Store[Combiner]): + +class CombinerStore(MongoDBStore[Combiner]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) - def get(self, id: str, use_typing: bool = False) -> Combiner: + def get(self, id: str) -> Combiner: """Get an entity by id param id: The id of the entity type: str description: The id of the entity, can be either the id or the name (property) - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ if ObjectId.is_valid(id): @@ -77,7 +59,7 @@ def get(self, id: str, use_typing: bool = False) -> Combiner: if document is None: raise EntityNotFound(f"Entity with (id | name) {id} not found") - return Combiner.from_dict(document) if use_typing else from_document(document) + return from_document(document) def update(self, id: str, item: Combiner) -> bool: raise NotImplementedError("Update not implemented for CombinerStore") @@ -98,7 +80,7 @@ def delete(self, id: str) -> bool: return super().delete(document["_id"]) - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[Combiner]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Combiner]]: """List entities param limit: The maximum number of entities to return type: int @@ -108,18 +90,14 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI type: str param sort_order: The order to sort by type: pymongo.DESCENDING | pymongo.ASCENDING - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool param kwargs: Additional query parameters type: dict example: {"key": "models"} return: A dictionary with the count and the result """ - response = super().list(limit, skip, sort_key or "updated_at", sort_order, use_typing=use_typing, **kwargs) - - result = [Combiner.from_dict(item) for item in response["result"]] if use_typing else response["result"] + response = super().list(limit, skip, sort_key or "updated_at", sort_order, **kwargs) - return {"count": response["count"], "result": result} + return response def count(self, **kwargs) -> int: return super().count(**kwargs) diff --git a/fedn/network/storage/statestore/stores/model_store.py b/fedn/network/storage/statestore/stores/model_store.py index 27efcc9a3..d6b96121b 100644 --- a/fedn/network/storage/statestore/stores/model_store.py +++ b/fedn/network/storage/statestore/stores/model_store.py @@ -5,7 +5,7 @@ from bson import ObjectId from pymongo.database import Database -from fedn.network.storage.statestore.stores.store import Store +from fedn.network.storage.statestore.stores.store import MongoDBStore from .shared import EntityNotFound, from_document @@ -19,28 +19,16 @@ def __init__(self, id: str, key: str, model: str, parent_model: str, session_id: self.session_id = session_id self.committed_at = committed_at - def from_dict(data: dict) -> "Model": - return Model( - id=str(data["_id"]), - key=data["key"] if "key" in data else None, - model=data["model"] if "model" in data else None, - parent_model=data["parent_model"] if "parent_model" in data else None, - session_id=data["session_id"] if "session_id" in data else None, - committed_at=data["committed_at"] if "committed_at" in data else None, - ) - -class ModelStore(Store[Model]): +class ModelStore(MongoDBStore[Model]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) - def get(self, id: str, use_typing: bool = False) -> Model: + def get(self, id: str) -> Model: """Get an entity by id param id: The id of the entity type: str description: The id of the entity, can be either the id or the model (property) - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ kwargs = {"key": "models"} @@ -55,7 +43,7 @@ def get(self, id: str, use_typing: bool = False) -> Model: if document is None: raise EntityNotFound(f"Entity with (id | model) {id} not found") - return Model.from_dict(document) if use_typing else from_document(document) + return from_document(document) def _validate(self, item: Model) -> Tuple[bool, str]: if "model" not in item or not item["model"]: @@ -82,7 +70,7 @@ def add(self, item: Model) -> Tuple[bool, Any]: def delete(self, id: str) -> bool: raise NotImplementedError("Delete not implemented for ModelStore") - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[Model]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Model]]: """List entities param limit: The maximum number of entities to return type: int @@ -92,8 +80,6 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI type: str param sort_order: The order to sort by type: pymongo.DESCENDING | pymongo.ASCENDING - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool param kwargs: Additional query parameters type: dict example: {"key": "models"} @@ -101,20 +87,15 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI """ kwargs["key"] = "models" - response = super().list(limit, skip, sort_key or "committed_at", sort_order, use_typing=use_typing, **kwargs) - - result = [Model.from_dict(item) for item in response["result"]] if use_typing else response["result"] - return {"count": response["count"], "result": result} + return super().list(limit, skip, sort_key or "committed_at", sort_order, **kwargs) - def list_descendants(self, id: str, limit: int, use_typing: bool = False) -> List[Model]: + def list_descendants(self, id: str, limit: int) -> List[Model]: """List descendants param id: The id of the entity type: str description: The id of the entity, can be either the id or the model (property) param limit: The maximum number of entities to return type: int - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool return: A list of entities """ kwargs = {"key": "models"} @@ -139,7 +120,7 @@ def list_descendants(self, id: str, limit: int, use_typing: bool = False) -> Lis model: str = self.database[self.collection].find_one({"key": "models", "parent_model": current_model_id}) if model is not None: - formatted_model = Model.from_dict(model) if use_typing else from_document(model) + formatted_model = Model.from_dict(model) result.append(formatted_model) current_model_id = model["model"] else: @@ -149,15 +130,13 @@ def list_descendants(self, id: str, limit: int, use_typing: bool = False) -> Lis return result - def list_ancestors(self, id: str, limit: int, include_self: bool = False, reverse: bool = False, use_typing: bool = False) -> List[Model]: + def list_ancestors(self, id: str, limit: int, include_self: bool = False, reverse: bool = False) -> List[Model]: """List ancestors param id: The id of the entity type: str description: The id of the entity, can be either the id or the model (property) param limit: The maximum number of entities to return type: int - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool return: A list of entities """ kwargs = {"key": "models"} @@ -176,7 +155,7 @@ def list_ancestors(self, id: str, limit: int, include_self: bool = False, revers result: list = [] if include_self: - formatted_model = Model.from_dict(model) if use_typing else from_document(model) + formatted_model = from_document(model) result.append(formatted_model) for _ in range(limit): @@ -186,7 +165,7 @@ def list_ancestors(self, id: str, limit: int, include_self: bool = False, revers model = self.database[self.collection].find_one({"key": "models", "model": current_model_id}) if model is not None: - formatted_model = Model.from_dict(model) if use_typing else from_document(model) + formatted_model = from_document(model) result.append(formatted_model) current_model_id = model["parent_model"] else: diff --git a/fedn/network/storage/statestore/stores/package_store.py b/fedn/network/storage/statestore/stores/package_store.py index de55d888c..44dece2ab 100644 --- a/fedn/network/storage/statestore/stores/package_store.py +++ b/fedn/network/storage/statestore/stores/package_store.py @@ -7,9 +7,28 @@ from pymongo.database import Database from werkzeug.utils import secure_filename -from fedn.network.storage.statestore.stores.store import Store +from fedn.network.storage.statestore.stores.store import MongoDBStore -from .shared import EntityNotFound, from_document +from .shared import EntityNotFound + + +def from_document(data: dict, active_package: dict): + active = False + if active_package: + if "id" in active_package and "id" in data: + active = active_package["id"] == data["id"] + + return { + "id": data["id"] if "id" in data else None, + "key": data["key"] if "key" in data else None, + "committed_at": data["committed_at"] if "committed_at" in data else None, + "description": data["description"] if "description" in data else None, + "file_name": data["file_name"] if "file_name" in data else None, + "helper": data["helper"] if "helper" in data else None, + "name": data["name"] if "name" in data else None, + "storage_file_name": data["storage_file_name"] if "storage_file_name" in data else None, + "active": active, + } class Package: @@ -26,38 +45,16 @@ def __init__( self.storage_file_name = storage_file_name self.active = active - def from_dict(data: dict, active_package: dict) -> "Package": - active = False - if active_package: - if "id" in active_package and "id" in data: - active = active_package["id"] == data["id"] - - return Package( - id=data["id"] if "id" in data else None, - key=data["key"] if "key" in data else None, - committed_at=data["committed_at"] if "committed_at" in data else None, - description=data["description"] if "description" in data else None, - file_name=data["file_name"] if "file_name" in data else None, - helper=data["helper"] if "helper" in data else None, - name=data["name"] if "name" in data else None, - storage_file_name=data["storage_file_name"] if "storage_file_name" in data else None, - active=active, - ) - - -class PackageStore(Store[Package]): + +class PackageStore(MongoDBStore[Package]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) - def get(self, id: str, use_typing: bool = False) -> Package: + def get(self, id: str) -> Package: """Get an entity by id param id: The id of the entity type: str - description: The id of the entity, can be either the id or the model (property) - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool - description: Whether to return the entities as typed objects or as dicts. - If True, and active property will be set based on the active package. + description: The id of the entity, can be either the id or the docuemnt _id return: The entity """ document = self.database[self.collection].find_one({"id": id}) @@ -65,12 +62,9 @@ def get(self, id: str, use_typing: bool = False) -> Package: if document is None: raise EntityNotFound(f"Entity with id {id} not found") - if not use_typing: - return from_document(document) - response_active = self.database[self.collection].find_one({"key": "active"}) - return Package.from_dict(document, response_active) + return from_document(document, response_active) def _validate(self, item: Package) -> Tuple[bool, str]: if "file_name" not in item or not item["file_name"]: @@ -130,19 +124,16 @@ def set_active(self, id: str) -> bool: return True - def get_active(self, use_typing: bool = False) -> Package: + def get_active(self) -> Package: """Get the active entity - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ kwargs = {"key": "active"} response = self.database[self.collection].find_one(kwargs) - if response is None: raise EntityNotFound("Entity not found") - return Package.from_dict(response, response) if use_typing else from_document(response) + return from_document(response, {"id": response["id"]}) def set_active_helper(self, helper: str) -> bool: """Set the active helper @@ -217,7 +208,7 @@ def delete_active(self): return super().delete(document_active["_id"]) - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[Package]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Package]]: """List entities param limit: The maximum number of entities to return type: int @@ -227,10 +218,6 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI type: str param sort_order: The order to sort by type: pymongo.DESCENDING | pymongo.ASCENDING - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool - description: Whether to return the entities as typed objects or as dicts. - If True, and active property will be set based on the active package. param kwargs: Additional query parameters type: dict example: {"key": "models"} @@ -238,13 +225,15 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI """ kwargs["key"] = "package_trail" - response = super().list(limit, skip, sort_key or "committed_at", sort_order, use_typing=True, **kwargs) + response = self.database[self.collection].find(kwargs).sort(sort_key or "committed_at", sort_order).skip(skip or 0).limit(limit or 0) + + count = self.database[self.collection].count_documents(kwargs) response_active = self.database[self.collection].find_one({"key": "active"}) - result = [Package.from_dict(item, response_active) for item in response["result"]] + result = [from_document(item, response_active) for item in response] - return {"count": response["count"], "result": result} + return {"count": count, "result": result} def count(self, **kwargs) -> int: kwargs["key"] = "package_trail" diff --git a/fedn/network/storage/statestore/stores/prediction_store.py b/fedn/network/storage/statestore/stores/prediction_store.py index 1ae29b94c..5b918c41e 100644 --- a/fedn/network/storage/statestore/stores/prediction_store.py +++ b/fedn/network/storage/statestore/stores/prediction_store.py @@ -3,7 +3,7 @@ import pymongo from pymongo.database import Database -from fedn.network.storage.statestore.stores.store import Store +from fedn.network.storage.statestore.stores.store import MongoDBStore class Prediction: @@ -20,35 +20,20 @@ def __init__( self.sender = sender self.receiver = receiver - def from_dict(data: dict) -> "Prediction": - return Prediction( - id=str(data["_id"]), - model_id=data["modelId"] if "modelId" in data else None, - data=data["data"] if "data" in data else None, - correlation_id=data["correlationId"] if "correlationId" in data else None, - timestamp=data["timestamp"] if "timestamp" in data else None, - prediction_id=data["predictionId"] if "predictionId" in data else None, - meta=data["meta"] if "meta" in data else None, - sender=data["sender"] if "sender" in data else None, - receiver=data["receiver"] if "receiver" in data else None, - ) - -class PredictionStore(Store[Prediction]): +class PredictionStore(MongoDBStore[Prediction]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) - def get(self, id: str, use_typing: bool = False) -> Prediction: + def get(self, id: str) -> Prediction: """Get an entity by id param id: The id of the entity type: str description: The id of the entity, can be either the id or the Prediction (property) - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ - response = super().get(id, use_typing=use_typing) - return Prediction.from_dict(response) if use_typing else response + response = super().get(id) + return response def update(self, id: str, item: Prediction) -> bool: raise NotImplementedError("Update not implemented for PredictionStore") @@ -59,7 +44,7 @@ def add(self, item: Prediction) -> Tuple[bool, Any]: def delete(self, id: str) -> bool: raise NotImplementedError("Delete not implemented for PredictionStore") - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[Prediction]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Prediction]]: """List entities param limit: The maximum number of entities to return type: int @@ -73,12 +58,6 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI param sort_order: The order to sort by type: pymongo.DESCENDING description: The order to sort by - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool - description: Whether to return the entities as typed objects or as dicts return: A dictionary with the count and a list of entities """ - response = super().list(limit, skip, sort_key or "timestamp", sort_order, use_typing=use_typing, **kwargs) - - result = [Prediction.from_dict(item) for item in response["result"]] if use_typing else response["result"] - return {"count": response["count"], "result": result} + return super().list(limit, skip, sort_key or "timestamp", sort_order, **kwargs) diff --git a/fedn/network/storage/statestore/stores/round_store.py b/fedn/network/storage/statestore/stores/round_store.py index 2eff2a993..9148f0c63 100644 --- a/fedn/network/storage/statestore/stores/round_store.py +++ b/fedn/network/storage/statestore/stores/round_store.py @@ -3,7 +3,7 @@ import pymongo from pymongo.database import Database -from fedn.network.storage.statestore.stores.store import Store +from fedn.network.storage.statestore.stores.store import MongoDBStore class Round: @@ -15,42 +15,29 @@ def __init__(self, id: str, round_id: str, status: str, round_config: dict, comb self.combiners = combiners self.round_data = round_data - def from_dict(data: dict) -> "Round": - return Round( - id=str(data["_id"]), - round_id=data["round_id"] if "round_id" in data else None, - status=data["status"] if "status" in data else None, - round_config=data["round_config"] if "round_config" in data else None, - combiners=data["combiners"] if "combiners" in data else None, - round_data=data["round_data"] if "round_data" in data else None - ) - -class RoundStore(Store[Round]): +class RoundStore(MongoDBStore[Round]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) - def get(self, id: str, use_typing: bool = False) -> Round: + def get(self, id: str) -> Round: """Get an entity by id param id: The id of the entity type: str - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ - response = super().get(id, use_typing=use_typing) - return Round.from_dict(response) if use_typing else response + return super().get(id) def update(self, id: str, item: Round) -> bool: raise NotImplementedError("Update not implemented for RoundStore") - def add(self, item: Round)-> Tuple[bool, Any]: + def add(self, item: Round) -> Tuple[bool, Any]: raise NotImplementedError("Add not implemented for RoundStore") def delete(self, id: str) -> bool: raise NotImplementedError("Delete not implemented for RoundStore") - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[Round]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Round]]: """List entities param limit: The maximum number of entities to return type: int @@ -64,15 +51,6 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI param sort_order: The order to sort by type: pymongo.DESCENDING description: The order to sort by - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entities """ - response = super().list(limit, skip, sort_key or "round_id", sort_order, use_typing=use_typing, **kwargs) - - result = [Round.from_dict(item) for item in response["result"]] if use_typing else response["result"] - - return { - "count": response["count"], - "result": result - } + return super().list(limit, skip, sort_key or "round_id", sort_order, **kwargs) diff --git a/fedn/network/storage/statestore/stores/session_store.py b/fedn/network/storage/statestore/stores/session_store.py index 7b29354ce..cd0a333de 100644 --- a/fedn/network/storage/statestore/stores/session_store.py +++ b/fedn/network/storage/statestore/stores/session_store.py @@ -6,7 +6,7 @@ from bson import ObjectId from pymongo.database import Database -from fedn.network.storage.statestore.stores.store import Store +from fedn.network.storage.statestore.stores.store import MongoDBStore from .shared import EntityNotFound, from_document @@ -18,16 +18,8 @@ def __init__(self, id: str, session_id: str, status: str, session_config: dict = self.status = status self.session_config = session_config - def from_dict(data: dict) -> "Session": - return Session( - id=str(data["_id"]), - session_id=data["session_id"] if "session_id" in data else None, - status=data["status"] if "status" in data else None, - session_config=data["session_config"] if "session_config" in data else None, - ) - -class SessionStore(Store[Session]): +class SessionStore(MongoDBStore[Session]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) @@ -101,13 +93,11 @@ def _complement(self, item: Session): if "session_id" not in item or item["session_id"] == "" or not isinstance(item["session_id"], str): item["session_id"] = str(uuid.uuid4()) - def get(self, id: str, use_typing: bool = False) -> Session: + def get(self, id: str) -> Session: """Get an entity by id param id: The id of the entity type: str description: The id of the entity, can be either the id or the session_id (property) - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ if ObjectId.is_valid(id): @@ -119,7 +109,7 @@ def get(self, id: str, use_typing: bool = False) -> Session: if document is None: raise EntityNotFound(f"Entity with (id | session_id) {id} not found") - return Session.from_dict(document) if use_typing else from_document(document) + return from_document(document) def update(self, id: str, item: Session) -> Tuple[bool, Any]: valid, message = self._validate(item) @@ -146,7 +136,7 @@ def add(self, item: Session) -> Tuple[bool, Any]: def delete(self, id: str) -> bool: raise NotImplementedError("Delete not implemented for SessionStore") - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[Session]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Session]]: """List entities param limit: The maximum number of entities to return type: int @@ -160,16 +150,9 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI param sort_order: The order to sort by type: pymongo.DESCENDING description: The order to sort by - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool - description: Whether to return the entities as typed objects or as dicts. param kwargs: Additional query parameters type: dict description: Additional query parameters return: The entities """ - response = super().list(limit, skip, sort_key or "session_id", sort_order, use_typing=use_typing, **kwargs) - - result = [Session.from_dict(item) for item in response["result"]] if use_typing else response["result"] - - return {"count": response["count"], "result": result} + return super().list(limit, skip, sort_key or "session_id", sort_order, **kwargs) diff --git a/fedn/network/storage/statestore/stores/status_store.py b/fedn/network/storage/statestore/stores/status_store.py index 9233d0b23..a6aae34e8 100644 --- a/fedn/network/storage/statestore/stores/status_store.py +++ b/fedn/network/storage/statestore/stores/status_store.py @@ -3,7 +3,7 @@ import pymongo from pymongo.database import Database -from fedn.network.storage.statestore.stores.store import Store +from fedn.network.storage.statestore.stores.store import MongoDBStore class Status: @@ -21,36 +21,19 @@ def __init__( self.session_id = session_id self.sender = sender - def from_dict(data: dict) -> "Status": - return Status( - id=str(data["_id"]), - status=data["status"] if "status" in data else None, - timestamp=data["timestamp"] if "timestamp" in data else None, - log_level=data["logLevel"] if "logLevel" in data else None, - data=data["data"] if "data" in data else None, - correlation_id=data["correlationId"] if "correlationId" in data else None, - type=data["type"] if "type" in data else None, - extra=data["extra"] if "extra" in data else None, - session_id=data["sessionId"] if "sessionId" in data else None, - sender=data["sender"] if "sender" in data else None, - ) - -class StatusStore(Store[Status]): +class StatusStore(MongoDBStore[Status]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) - def get(self, id: str, use_typing: bool = False) -> Status: + def get(self, id: str) -> Status: """Get an entity by id param id: The id of the entity type: str description: The id of the entity, can be either the id or the status (property) - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ - response = super().get(id, use_typing=use_typing) - return Status.from_dict(response) if use_typing else response + return super().get(id) def update(self, id: str, item: Status) -> bool: raise NotImplementedError("Update not implemented for StatusStore") @@ -61,7 +44,7 @@ def add(self, item: Status) -> Tuple[bool, Any]: def delete(self, id: str) -> bool: raise NotImplementedError("Delete not implemented for StatusStore") - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[Status]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Status]]: """List entities param limit: The maximum number of entities to return type: int @@ -75,12 +58,5 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI param sort_order: The order to sort by type: pymongo.DESCENDING description: The order to sort by - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool - description: Whether to return the entities as typed objects or as dicts. """ - response = super().list(limit, skip, sort_key or "timestamp", sort_order, use_typing=use_typing, **kwargs) - - result = [Status.from_dict(item) for item in response["result"]] if use_typing else response["result"] - - return {"count": response["count"], "result": result} + return super().list(limit, skip, sort_key or "timestamp", sort_order, **kwargs) diff --git a/fedn/network/storage/statestore/stores/store.py b/fedn/network/storage/statestore/stores/store.py index f6a8f67e0..ec5e4e9be 100644 --- a/fedn/network/storage/statestore/stores/store.py +++ b/fedn/network/storage/statestore/stores/store.py @@ -1,3 +1,4 @@ +from abc import ABC, abstractmethod from typing import Any, Dict, Generic, List, Tuple, TypeVar import pymongo @@ -9,26 +10,51 @@ T = TypeVar("T") -class Store(Generic[T]): +class Store(ABC, Generic[T]): + @abstractmethod + def get(self, id: str) -> T: + pass + + @abstractmethod + def update(self, id: str, item: T) -> Tuple[bool, Any]: + pass + + @abstractmethod + def add(self, item: T) -> Tuple[bool, Any]: + pass + + @abstractmethod + def delete(self, id: str) -> bool: + pass + + @abstractmethod + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[T]]: + pass + + @abstractmethod + def count(self, **kwargs) -> int: + pass + + +class MongoDBStore(Store[T], Generic[T]): def __init__(self, database: Database, collection: str): self.database = database self.collection = collection - def get(self, id: str, use_typing: bool = False) -> T: + def get(self, id: str) -> T: """Get an entity by id param id: The id of the entity type: str - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ + if not ObjectId.is_valid(id): + raise EntityNotFound(f"Invalid id {id}") id_obj = ObjectId(id) document = self.database[self.collection].find_one({"_id": id_obj}) - if document is None: raise EntityNotFound(f"Entity with id {id} not found") - return from_document(document) if not use_typing else document + return from_document(document) def update(self, id: str, item: T) -> Tuple[bool, Any]: try: @@ -54,7 +80,7 @@ def delete(self, id: str) -> bool: result = self.database[self.collection].delete_one({"_id": ObjectId(id)}) return result.deleted_count == 1 - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[T]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[T]]: """List entities param limit: The maximum number of entities to return type: int @@ -64,8 +90,6 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI type: str param sort_order: The order to sort by type: pymongo.DESCENDING | pymongo.ASCENDING - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool param kwargs: Additional query parameters type: dict example: {"key": "models"} @@ -75,7 +99,7 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI count = self.database[self.collection].count_documents(kwargs) - result = [document for document in cursor] if use_typing else [from_document(document) for document in cursor] + result = [from_document(document) for document in cursor] return {"count": count, "result": result} diff --git a/fedn/network/storage/statestore/stores/validation_store.py b/fedn/network/storage/statestore/stores/validation_store.py index 59b5a0730..f5e9ef604 100644 --- a/fedn/network/storage/statestore/stores/validation_store.py +++ b/fedn/network/storage/statestore/stores/validation_store.py @@ -3,7 +3,7 @@ import pymongo from pymongo.database import Database -from fedn.network.storage.statestore.stores.store import Store +from fedn.network.storage.statestore.stores.store import MongoDBStore class Validation: @@ -20,35 +20,19 @@ def __init__( self.sender = sender self.receiver = receiver - def from_dict(data: dict) -> "Validation": - return Validation( - id=str(data["_id"]), - model_id=data["modelId"] if "modelId" in data else None, - data=data["data"] if "data" in data else None, - correlation_id=data["correlationId"] if "correlationId" in data else None, - timestamp=data["timestamp"] if "timestamp" in data else None, - session_id=data["sessionId"] if "sessionId" in data else None, - meta=data["meta"] if "meta" in data else None, - sender=data["sender"] if "sender" in data else None, - receiver=data["receiver"] if "receiver" in data else None, - ) - -class ValidationStore(Store[Validation]): +class ValidationStore(MongoDBStore[Validation]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) - def get(self, id: str, use_typing: bool = False) -> Validation: + def get(self, id: str) -> Validation: """Get an entity by id param id: The id of the entity type: str description: The id of the entity, can be either the id or the validation (property) - param use_typing: Whether to return the entity as a typed object or as a dict - type: bool return: The entity """ - response = super().get(id, use_typing=use_typing) - return Validation.from_dict(response) if use_typing else response + return super().get(id) def update(self, id: str, item: Validation) -> bool: raise NotImplementedError("Update not implemented for ValidationStore") @@ -59,7 +43,7 @@ def add(self, item: Validation) -> Tuple[bool, Any]: def delete(self, id: str) -> bool: raise NotImplementedError("Delete not implemented for ValidationStore") - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, use_typing: bool = False, **kwargs) -> Dict[int, List[Validation]]: + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Validation]]: """List entities param limit: The maximum number of entities to return type: int @@ -73,12 +57,6 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI param sort_order: The order to sort by type: pymongo.DESCENDING description: The order to sort by - param use_typing: Whether to return the entities as typed objects or as dicts - type: bool - description: Whether to return the entities as typed objects or as dicts return: A dictionary with the count and a list of entities """ - response = super().list(limit, skip, sort_key or "timestamp", sort_order, use_typing=use_typing, **kwargs) - - result = [Validation.from_dict(item) for item in response["result"]] if use_typing else response["result"] - return {"count": response["count"], "result": result} + return super().list(limit, skip, sort_key or "timestamp", sort_order, **kwargs) From 1875fec81f02356284a29a2eecf0cba81fb00dc1 Mon Sep 17 00:00:00 2001 From: Fredrik Wrede Date: Tue, 7 Jan 2025 16:16:20 +0100 Subject: [PATCH 04/10] Fix/SK-000 | Bump grpcio requirement from <1.69,>=1.60 to >=1.60,<=1.70 (#783) --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 54bf79671..2ed7ef70a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ dependencies = [ "urllib3>=1.26.4", "gunicorn>=20.0.4", "minio", - "grpcio>=1.60,<1.69", - "grpcio-tools>=1.60,<1.69", + "grpcio>=1.60,<=1.70", + "grpcio-tools>=1.60,<=1.70", "numpy>=1.21.6", "protobuf>=5.0.0,<5.30.0", "pymongo", @@ -42,7 +42,7 @@ dependencies = [ "pyopenssl", "psutil", "click==8.1.8", - "grpcio-health-checking>=1.60,<1.69", + "grpcio-health-checking>=1.60,<=1.70", "pyyaml", "plotly", "virtualenv", From e7ea3b6869c56c5ddcb35d02baa3ce3517b9ce7d Mon Sep 17 00:00:00 2001 From: Niklas Date: Wed, 8 Jan 2025 09:09:04 +0100 Subject: [PATCH 05/10] Feature/SK-1262 | Add graphql server to FEDn (#777) --- Dockerfile.dev | 54 ++++++ docker-compose.dev.yaml | 163 ++++++++++++++++++ fedn/network/api/server.py | 33 +++- fedn/network/api/v1/combiner_routes.py | 5 +- fedn/network/api/v1/graphql/__init__.py | 0 fedn/network/api/v1/graphql/schema.py | 208 +++++++++++++++++++++++ fedn/network/api/v1/model_routes.py | 5 +- fedn/network/api/v1/round_routes.py | 5 +- fedn/network/api/v1/session_routes.py | 7 +- fedn/network/api/v1/shared.py | 12 ++ fedn/network/api/v1/status_routes.py | 5 +- fedn/network/api/v1/validation_routes.py | 5 +- pyproject.toml | 1 + 13 files changed, 475 insertions(+), 28 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 docker-compose.dev.yaml create mode 100644 fedn/network/api/v1/graphql/__init__.py create mode 100644 fedn/network/api/v1/graphql/schema.py diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..b651dbea4 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,54 @@ +# Base image +ARG BASE_IMG=python:3.10-slim +FROM $BASE_IMG + +ARG GRPC_HEALTH_PROBE_VERSION="" + +# Requirements (use MNIST Keras as default) +ARG REQUIREMENTS="" + +# Add FEDn and default configs +COPY . /app +COPY config/settings-client.yaml.template /app/config/settings-client.yaml +COPY config/settings-combiner.yaml.template /app/config/settings-combiner.yaml +COPY config/settings-hooks.yaml.template /app/config/settings-hooks.yaml +COPY config/settings-reducer.yaml.template /app/config/settings-reducer.yaml +COPY $REQUIREMENTS /app/config/requirements.txt + +# Install developer tools (needed for psutil) +RUN apt-get update && apt-get install -y python3-dev gcc + +# Install grpc health probe checker +RUN if [ ! -z "$GRPC_HEALTH_PROBE_VERSION" ]; then \ + apt-get install -y wget && \ + wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \ + chmod +x /bin/grpc_health_probe && \ + apt-get remove -y wget && apt autoremove -y; \ + else \ + echo "No grpc_health_probe version specified, skipping installation"; \ + fi + +# Setup working directory +WORKDIR /app + +# Create FEDn app directory +SHELL ["/bin/bash", "-c"] +RUN mkdir -p /app \ + && mkdir -p /app/client \ + && mkdir -p /app/certs \ + && mkdir -p /app/client/package \ + && mkdir -p /app/certs \ + # + # Install FEDn and requirements + && python -m venv /venv \ + && /venv/bin/pip install --upgrade pip \ + && /venv/bin/pip install --no-cache-dir 'setuptools>=65' \ + && /venv/bin/pip install --no-cache-dir -e . \ + && if [[ ! -z "$REQUIREMENTS" ]]; then \ + /venv/bin/pip install --no-cache-dir -r /app/config/requirements.txt; \ + fi \ + # + # Clean up + && rm -r /app/config/requirements.txt + +ENTRYPOINT [ "/venv/bin/fedn" ] \ No newline at end of file diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 000000000..4d449109f --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,163 @@ +# Compose schema version +version: '3.4' + +# Setup network +networks: + default: + name: fedn_default + +services: + # Base services + minio: + image: minio/minio:14128-5ee91dc + hostname: minio + environment: + - GET_HOSTS_FROM=dns + - MINIO_HOST=minio + - MINIO_PORT=9000 + - MINIO_ROOT_USER=fedn_admin + - MINIO_ROOT_PASSWORD=password + command: server /data --console-address minio:9001 + healthcheck: + test: [ "CMD", "curl", "-f", "http://minio:9000/minio/health/live" ] + interval: 30s + timeout: 20s + retries: 3 + ports: + - 9000:9000 + - 9001:9001 + + mongo: + image: mongo:7.0 + restart: always + environment: + - MONGO_INITDB_ROOT_USERNAME=fedn_admin + - MONGO_INITDB_ROOT_PASSWORD=password + ports: + - 6534:6534 + command: mongod --port 6534 + + mongo-express: + image: mongo-express:latest + restart: always + depends_on: + - "mongo" + environment: + - ME_CONFIG_MONGODB_SERVER=mongo + - ME_CONFIG_MONGODB_PORT=6534 + - ME_CONFIG_MONGODB_ADMINUSERNAME=fedn_admin + - ME_CONFIG_MONGODB_ADMINPASSWORD=password + - ME_CONFIG_BASICAUTH_USERNAME=fedn_admin + - ME_CONFIG_BASICAUTH_PASSWORD=password + ports: + - 8081:8081 + + api-server: + environment: + - GET_HOSTS_FROM=dns + - USER=test + - PROJECT=project + - FLASK_DEBUG=1 + - STATESTORE_CONFIG=/app/config/settings-reducer.yaml.template + - MODELSTORAGE_CONFIG=/app/config/settings-reducer.yaml.template + - FEDN_COMPUTE_PACKAGE_DIR=/app + - TMPDIR=/app/tmp + build: + context: . + dockerfile: Dockerfile.dev + args: + BASE_IMG: ${BASE_IMG:-python:3.12-slim} + working_dir: /app + volumes: + - ${HOST_REPO_DIR:-.}/fedn:/app/fedn + depends_on: + - minio + - mongo + command: + - controller + - start + ports: + - 8092:8092 + + # Combiner + combiner: + environment: + - PYTHONUNBUFFERED=0 + - GET_HOSTS_FROM=dns + - STATESTORE_CONFIG=/app/config/settings-combiner.yaml.template + - MODELSTORAGE_CONFIG=/app/config/settings-combiner.yaml.template + - HOOK_SERVICE_HOST=hook:12081 + - TMPDIR=/app/tmp + build: + context: . + dockerfile: Dockerfile.dev + args: + BASE_IMG: ${BASE_IMG:-python:3.12-slim} + GRPC_HEALTH_PROBE_VERSION: v0.4.35 + working_dir: /app + volumes: + - ${HOST_REPO_DIR:-.}/fedn:/app/fedn + command: + - combiner + - start + - --init + - config/settings-combiner.yaml.template + ports: + - 12080:12080 + healthcheck: + test: [ "CMD", "/app/grpc_health_probe", "-addr=localhost:12080" ] + interval: 20s + timeout: 10s + retries: 5 + depends_on: + - api-server + - hooks + # Hooks + hooks: + container_name: hook + environment: + - GET_HOSTS_FROM=dns + - TMPDIR=/app/tmp + build: + context: . + dockerfile: Dockerfile.dev + args: + BASE_IMG: ${BASE_IMG:-python:3.12-slim} + GRPC_HEALTH_PROBE_VERSION: v0.4.35 + working_dir: /app + volumes: + - ${HOST_REPO_DIR:-.}/fedn:/app/fedn + entrypoint: [ "sh", "-c" ] + command: + - "/venv/bin/pip install --no-cache-dir -e . && /venv/bin/fedn hooks start" + ports: + - 12081:12081 + healthcheck: + test: [ "CMD", "/bin/grpc_health_probe", "-addr=localhost:12081" ] + interval: 20s + timeout: 10s + retries: 5 + + # Client + client: + environment: + - GET_HOSTS_FROM=dns + - FEDN_PACKAGE_EXTRACT_DIR=package + build: + context: . + dockerfile: Dockerfile.dev + args: + BASE_IMG: ${BASE_IMG:-python:3.10-slim} + working_dir: /app + volumes: + - ${HOST_REPO_DIR:-.}/fedn:/app/fedn + command: + - client + - start + - --api-url + - http://api-server:8092 + deploy: + replicas: 0 + depends_on: + combiner: + condition: service_healthy diff --git a/fedn/network/api/server.py b/fedn/network/api/server.py index 185877577..5055653a6 100644 --- a/fedn/network/api/server.py +++ b/fedn/network/api/server.py @@ -3,11 +3,12 @@ from flask import Flask, jsonify, request from fedn.common.config import get_controller_config +from fedn.network.api import gunicorn_app from fedn.network.api.auth import jwt_auth_required from fedn.network.api.interface import API from fedn.network.api.shared import control, statestore from fedn.network.api.v1 import _routes -from fedn.network.api import gunicorn_app +from fedn.network.api.v1.graphql.schema import schema custom_url_prefix = os.environ.get("FEDN_CUSTOM_URL_PREFIX", False) # statestore_config,modelstorage_config,network_id,control=set_statestore_config() @@ -28,6 +29,32 @@ def health_check(): app.add_url_rule(f"{custom_url_prefix}/health", view_func=health_check, methods=["GET"]) +@app.route("/api/v1/graphql", methods=["POST"]) +def graphql_endpoint(): + data = request.get_json() + + if not data or "query" not in data: + return jsonify({"error": "Missing query in request"}), 400 + + # Execute the GraphQL query + result = schema.execute( + data["query"], + variables=data.get("variables"), + context_value={"request": request}, # Pass Flask request object as context if needed + ) + + # Format the result as a JSON response + response = {"data": result.data} + if result.errors: + response["errors"] = [str(error) for error in result.errors] + + return jsonify(response) + + +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/api/v1/graphql", view_func=graphql_endpoint, methods=["POST"]) + + @app.route("/get_model_trail", methods=["GET"]) @jwt_auth_required(role="admin") def get_model_trail(): @@ -638,7 +665,9 @@ def start_server_api(): if debug: app.run(debug=debug, port=port, host=host) else: - workers=os.cpu_count() + workers = os.cpu_count() gunicorn_app.run_gunicorn(app, host, port, workers) + + if __name__ == "__main__": start_server_api() diff --git a/fedn/network/api/v1/combiner_routes.py b/fedn/network/api/v1/combiner_routes.py index 966aea1dd..ce012645e 100644 --- a/fedn/network/api/v1/combiner_routes.py +++ b/fedn/network/api/v1/combiner_routes.py @@ -1,14 +1,11 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, client_store, get_post_data_to_kwargs, get_typed_list_headers, mdb -from fedn.network.storage.statestore.stores.combiner_store import CombinerStore +from fedn.network.api.v1.shared import api_version, client_store, combiner_store, get_post_data_to_kwargs, get_typed_list_headers from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("combiner", __name__, url_prefix=f"/api/{api_version}/combiners") -combiner_store = CombinerStore(mdb, "network.combiners") - @bp.route("/", methods=["GET"]) @jwt_auth_required(role="admin") diff --git a/fedn/network/api/v1/graphql/__init__.py b/fedn/network/api/v1/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fedn/network/api/v1/graphql/schema.py b/fedn/network/api/v1/graphql/schema.py new file mode 100644 index 000000000..0436c73ea --- /dev/null +++ b/fedn/network/api/v1/graphql/schema.py @@ -0,0 +1,208 @@ +import graphene +import pymongo + +from fedn.network.api.v1.shared import model_store, session_store, status_store, validation_store + + +class ActorType(graphene.ObjectType): + name = graphene.String() + role = graphene.String() + + +class StatusType(graphene.ObjectType): + data = graphene.String() + extra = graphene.String() + id = graphene.String() + logLevel = graphene.String() # noqa: N815 + sender = graphene.Field(ActorType) + sessionId = graphene.String() # noqa: N815 + status = graphene.String() + timestamp = graphene.String() + type = graphene.String() + + +class ValidationType(graphene.ObjectType): + correlationId = graphene.String() # noqa: N815 + data = graphene.String() + id = graphene.String() + meta = graphene.String() + modelId = graphene.String() # noqa: N815 + receiver = graphene.Field(ActorType) + sender = graphene.Field(ActorType) + sessionId = graphene.String() # noqa: N815 + timestamp = graphene.String() + + def resolve_receiver(self, info): + return self["receiver"] + + def resolve_sender(self, info): + return self["sender"] + + +class ModelType(graphene.ObjectType): + id = graphene.String() + model = graphene.String() + name = graphene.String() + committed_at = graphene.DateTime() + session_id = graphene.String() + parent_model = graphene.String() + validations = graphene.List(ValidationType) + + def resolve_validations(self, info): + kwargs = {"modelId": self["model"]} + response = validation_store.list(0, 0, None, sort_order=pymongo.DESCENDING, **kwargs) + result = response["result"] + + return result + + +class SessionConfigType(graphene.ObjectType): + aggregator = graphene.String() + round_timeout = graphene.Int() + buffer_size = graphene.Int() + model_id = graphene.String() + delete_models_storage = graphene.Boolean() + clients_required = graphene.Int() + helper_type = graphene.String() + validate = graphene.Boolean() + session_id = graphene.String() + model_id = graphene.String() + + +class SessionType(graphene.ObjectType): + id = graphene.String() + session_id = graphene.String() + name = graphene.String() + committed_at = graphene.DateTime() + session_config = graphene.Field(SessionConfigType) + models = graphene.List(ModelType) + validations = graphene.List(ValidationType) + statuses = graphene.List(StatusType) + + def resolve_session_config(self, info): + return self["session_config"] + + def resolve_models(self, info): + kwargs = {"session_id": self["session_id"]} + response = model_store.list(0, 0, None, sort_order=pymongo.DESCENDING, **kwargs) + result = response["result"] + + return result + + def resolve_validations(self, info): + kwargs = {"sessionId": self["session_id"]} + response = validation_store.list(0, 0, None, sort_order=pymongo.DESCENDING, **kwargs) + result = response["result"] + + return result + + def resolve_statuses(self, info): + kwargs = {"sessionId": self["session_id"]} + response = status_store.list(0, 0, None, sort_order=pymongo.DESCENDING, **kwargs) + result = response["result"] + + return result + + +class Query(graphene.ObjectType): + session = graphene.Field( + SessionType, + id=graphene.String(required=True), + ) + sessions = graphene.List( + SessionType, + name=graphene.String(), + ) + + model = graphene.Field( + ModelType, + id=graphene.String(required=True), + ) + + models = graphene.List( + ModelType, + session_id=graphene.String(), + ) + + validation = graphene.Field( + ValidationType, + id=graphene.String(required=True), + ) + + validations = graphene.List( + ValidationType, + session_id=graphene.String(), + ) + + status = graphene.Field( + StatusType, + id=graphene.String(required=True), + ) + + statuses = graphene.List( + StatusType, + session_id=graphene.String(), + ) + + def resolve_session(root, info, id: str = None): + result = session_store.get(id) + + return result + + def resolve_sessions(root, info, name: str = None): + response = None + if name: + kwargs = {"name": name} + response = session_store.list(0, 0, None, sort_order=pymongo.DESCENDING, **kwargs) + else: + response = session_store.list(0, 0, None) + + return response["result"] + + def resolve_model(root, info, id: str = None): + result = model_store.get(id) + + return result + + def resolve_models(root, info, session_id: str = None): + response = None + if session_id: + kwargs = {"session_id": session_id} + response = model_store.list(0, 0, None, sort_order=pymongo.DESCENDING, **kwargs) + else: + response = model_store.list(0, 0, None) + + return response["result"] + + def resolve_validation(root, info, id: str = None): + result = validation_store.get(id) + + return result + + def resolve_validations(root, info, session_id: str = None): + response = None + if session_id: + kwargs = {"session_id": session_id} + response = validation_store.list(0, 0, None, sort_order=pymongo.DESCENDING, **kwargs) + else: + response = validation_store.list(0, 0, None) + + return response["result"] + + def resolve_status(root, info, id: str = None): + result = status_store.get(id) + + return result + + def resolve_statuses(root, info, session_id: str = None): + response = None + if session_id: + kwargs = {"sessionId": session_id} + response = status_store.list(0, 0, None, sort_order=pymongo.DESCENDING, **kwargs) + else: + response = status_store.list(0, 0, None) + + return response["result"] + + +schema = graphene.Schema(query=Query) diff --git a/fedn/network/api/v1/model_routes.py b/fedn/network/api/v1/model_routes.py index c5f4e2fae..76e854494 100644 --- a/fedn/network/api/v1/model_routes.py +++ b/fedn/network/api/v1/model_routes.py @@ -5,14 +5,11 @@ from fedn.network.api.auth import jwt_auth_required from fedn.network.api.shared import modelstorage_config -from fedn.network.api.v1.shared import api_version, get_limit, get_post_data_to_kwargs, get_reverse, get_typed_list_headers, mdb, minio_repository -from fedn.network.storage.statestore.stores.model_store import ModelStore +from fedn.network.api.v1.shared import api_version, get_limit, get_post_data_to_kwargs, get_reverse, get_typed_list_headers, minio_repository, model_store from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("model", __name__, url_prefix=f"/api/{api_version}/models") -model_store = ModelStore(mdb, "control.model") - @bp.route("/", methods=["GET"]) @jwt_auth_required(role="admin") diff --git a/fedn/network/api/v1/round_routes.py b/fedn/network/api/v1/round_routes.py index c4093059c..2c0f6cc9a 100644 --- a/fedn/network/api/v1/round_routes.py +++ b/fedn/network/api/v1/round_routes.py @@ -1,14 +1,11 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, mdb -from fedn.network.storage.statestore.stores.round_store import RoundStore +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, round_store from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("round", __name__, url_prefix=f"/api/{api_version}/rounds") -round_store = RoundStore(mdb, "control.rounds") - @bp.route("/", methods=["GET"]) @jwt_auth_required(role="admin") diff --git a/fedn/network/api/v1/session_routes.py b/fedn/network/api/v1/session_routes.py index 1079566fe..52c68fb63 100644 --- a/fedn/network/api/v1/session_routes.py +++ b/fedn/network/api/v1/session_routes.py @@ -4,18 +4,13 @@ from fedn.network.api.auth import jwt_auth_required from fedn.network.api.shared import control -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, mdb +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, model_store, session_store from fedn.network.combiner.interfaces import CombinerUnavailableError from fedn.network.state import ReducerState -from fedn.network.storage.statestore.stores.session_store import SessionStore from fedn.network.storage.statestore.stores.shared import EntityNotFound -from .model_routes import model_store - bp = Blueprint("session", __name__, url_prefix=f"/api/{api_version}/sessions") -session_store = SessionStore(mdb, "control.sessions") - @bp.route("/", methods=["GET"]) @jwt_auth_required(role="admin") diff --git a/fedn/network/api/v1/shared.py b/fedn/network/api/v1/shared.py index 946382cd5..0f6267b38 100644 --- a/fedn/network/api/v1/shared.py +++ b/fedn/network/api/v1/shared.py @@ -8,7 +8,13 @@ from fedn.network.storage.s3.miniorepository import MINIORepository from fedn.network.storage.s3.repository import Repository from fedn.network.storage.statestore.stores.client_store import ClientStore +from fedn.network.storage.statestore.stores.combiner_store import CombinerStore +from fedn.network.storage.statestore.stores.model_store import ModelStore from fedn.network.storage.statestore.stores.package_store import PackageStore +from fedn.network.storage.statestore.stores.round_store import RoundStore +from fedn.network.storage.statestore.stores.session_store import SessionStore +from fedn.network.storage.statestore.stores.status_store import StatusStore +from fedn.network.storage.statestore.stores.validation_store import ValidationStore api_version = "v1" mc = pymongo.MongoClient(**statestore_config["mongo_config"]) @@ -17,6 +23,12 @@ client_store = ClientStore(mdb, "network.clients") package_store = PackageStore(mdb, "control.package") +session_store = SessionStore(mdb, "control.sessions") +model_store = ModelStore(mdb, "control.model") +combiner_store = CombinerStore(mdb, "network.combiners") +round_store = RoundStore(mdb, "control.rounds") +status_store = StatusStore(mdb, "control.status") +validation_store = ValidationStore(mdb, "control.validations") minio_repository: RepositoryBase = None diff --git a/fedn/network/api/v1/status_routes.py b/fedn/network/api/v1/status_routes.py index 00c69dae6..0cb1f8194 100644 --- a/fedn/network/api/v1/status_routes.py +++ b/fedn/network/api/v1/status_routes.py @@ -1,14 +1,11 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, mdb +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, status_store from fedn.network.storage.statestore.stores.shared import EntityNotFound -from fedn.network.storage.statestore.stores.status_store import StatusStore bp = Blueprint("status", __name__, url_prefix=f"/api/{api_version}/statuses") -status_store = StatusStore(mdb, "control.status") - @bp.route("/", methods=["GET"]) @jwt_auth_required(role="admin") diff --git a/fedn/network/api/v1/validation_routes.py b/fedn/network/api/v1/validation_routes.py index 8294d41d4..8fd5f2bb7 100644 --- a/fedn/network/api/v1/validation_routes.py +++ b/fedn/network/api/v1/validation_routes.py @@ -1,14 +1,11 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, mdb +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, validation_store from fedn.network.storage.statestore.stores.shared import EntityNotFound -from fedn.network.storage.statestore.stores.validation_store import ValidationStore bp = Blueprint("validation", __name__, url_prefix=f"/api/{api_version}/validations") -validation_store = ValidationStore(mdb, "control.validations") - @bp.route("/", methods=["GET"]) @jwt_auth_required(role="admin") diff --git a/pyproject.toml b/pyproject.toml index 2ed7ef70a..25c4027d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "plotly", "virtualenv", "tenacity!=8.4.0", + "graphene>=3.1" ] [project.urls] From 449e6a3653efa06fafd6128956475affb73b697b Mon Sep 17 00:00:00 2001 From: Niklas Date: Mon, 20 Jan 2025 16:59:05 +0100 Subject: [PATCH 06/10] Feature/SK-1265 | Implement SQL store (#785) --- config/settings-combiner.yaml.local.template | 1 + config/settings-combiner.yaml.template | 8 +- .../settings-controller.yaml.local.template | 1 + config/settings-reducer.yaml.template | 6 + docker-compose.dev.yaml | 10 + fedn/cli/client_cmd.py | 118 +- fedn/cli/run_cmd.py | 105 -- fedn/common/settings-controller.yaml.template | 5 + fedn/network/api/client.py | 27 +- fedn/network/api/interface.py | 1025 ----------------- fedn/network/api/network.py | 72 +- fedn/network/api/server.py | 510 +++----- fedn/network/api/shared.py | 107 +- fedn/network/api/tests.py | 323 +----- fedn/network/api/v1/client_routes.py | 160 ++- fedn/network/api/v1/combiner_routes.py | 3 +- fedn/network/api/v1/graphql/schema.py | 2 +- fedn/network/api/v1/helper_routes.py | 6 +- fedn/network/api/v1/model_routes.py | 72 +- fedn/network/api/v1/package_routes.py | 86 +- fedn/network/api/v1/prediction_routes.py | 9 +- fedn/network/api/v1/round_routes.py | 3 +- fedn/network/api/v1/session_routes.py | 4 +- fedn/network/api/v1/shared.py | 41 - fedn/network/api/v1/status_routes.py | 3 +- fedn/network/api/v1/validation_routes.py | 3 +- fedn/network/clients/client.py | 859 -------------- fedn/network/clients/fedn_client.py | 3 +- fedn/network/clients/package.py | 160 --- fedn/network/clients/package_runtime.py | 6 +- fedn/network/combiner/combiner.py | 56 +- fedn/network/combiner/interfaces.py | 35 - fedn/network/combiner/roundhandler.py | 10 +- fedn/network/combiner/shared.py | 42 +- fedn/network/controller/control.py | 139 +-- fedn/network/controller/controlbase.py | 184 +-- .../storage/statestore/mongostatestore.py | 961 ---------------- .../storage/statestore/statestorebase.py | 49 - .../storage/statestore/stores/client_store.py | 214 +++- .../statestore/stores/combiner_store.py | 114 +- .../storage/statestore/stores/model_store.py | 230 +++- .../statestore/stores/package_store.py | 299 ++++- .../statestore/stores/prediction_store.py | 166 ++- .../storage/statestore/stores/round_store.py | 401 ++++++- .../statestore/stores/session_store.py | 355 +++++- .../storage/statestore/stores/shared.py | 1 - .../storage/statestore/stores/sql/shared.py | 117 ++ .../storage/statestore/stores/status_store.py | 155 ++- .../storage/statestore/stores/store.py | 69 +- .../statestore/stores/validation_store.py | 172 ++- pyproject.toml | 4 +- 51 files changed, 3042 insertions(+), 4469 deletions(-) delete mode 100644 fedn/network/api/interface.py delete mode 100644 fedn/network/clients/client.py delete mode 100644 fedn/network/clients/package.py delete mode 100644 fedn/network/storage/statestore/mongostatestore.py delete mode 100644 fedn/network/storage/statestore/statestorebase.py create mode 100644 fedn/network/storage/statestore/stores/sql/shared.py diff --git a/config/settings-combiner.yaml.local.template b/config/settings-combiner.yaml.local.template index b49917389..9fbc2282c 100644 --- a/config/settings-combiner.yaml.local.template +++ b/config/settings-combiner.yaml.local.template @@ -10,6 +10,7 @@ cert_path: tmp/server.crt key_path: tmp/server.key statestore: + # Available DB types are MongoDB, PostgreSQL, SQLite type: MongoDB mongo_config: username: fedn_admin diff --git a/config/settings-combiner.yaml.template b/config/settings-combiner.yaml.template index 11911cc6f..73e93cf7f 100644 --- a/config/settings-combiner.yaml.template +++ b/config/settings-combiner.yaml.template @@ -8,13 +8,19 @@ port: 12080 max_clients: 30 statestore: + # Available DB types are MongoDB, PostgreSQL, SQLite type: MongoDB mongo_config: username: fedn_admin password: password host: mongo port: 6534 - + postgres_config: + username: fedn_admin + password: password + host: fedn_postgres + port: 5432 + storage: storage_type: S3 storage_config: diff --git a/config/settings-controller.yaml.local.template b/config/settings-controller.yaml.local.template index a5266a38b..96e6e07d3 100644 --- a/config/settings-controller.yaml.local.template +++ b/config/settings-controller.yaml.local.template @@ -5,6 +5,7 @@ controller: debug: True statestore: + # Available DB types are MongoDB, PostgreSQL, SQLite type: MongoDB mongo_config: username: fedn_admin diff --git a/config/settings-reducer.yaml.template b/config/settings-reducer.yaml.template index fd9352331..eeab66ff8 100644 --- a/config/settings-reducer.yaml.template +++ b/config/settings-reducer.yaml.template @@ -5,12 +5,18 @@ controller: debug: True statestore: + # Available DB types are MongoDB, PostgreSQL, SQLite type: MongoDB mongo_config: username: fedn_admin password: password host: mongo port: 6534 + postgres_config: + username: fedn_admin + password: password + host: fedn_postgres + port: 5432 storage: storage_type: S3 diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 4d449109f..0a8425ce8 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -52,6 +52,15 @@ services: ports: - 8081:8081 + fedn_postgres: + image: postgres:15 + environment: + POSTGRES_USER: fedn_admin + POSTGRES_PASSWORD: password + POSTGRES_DB: fedn_db + ports: + - "5432:5432" + api-server: environment: - GET_HOSTS_FROM=dns @@ -73,6 +82,7 @@ services: depends_on: - minio - mongo + - fedn_postgres command: - controller - start diff --git a/fedn/cli/client_cmd.py b/fedn/cli/client_cmd.py index 7c9ffc1e7..7a55f37b4 100644 --- a/fedn/cli/client_cmd.py +++ b/fedn/cli/client_cmd.py @@ -6,7 +6,6 @@ from fedn.cli.main import main from fedn.cli.shared import CONTROLLER_DEFAULTS, apply_config, get_api_url, get_token, print_response from fedn.common.exceptions import InvalidClientConfig -from fedn.network.clients.client import Client from fedn.network.clients.client_v2 import Client as ClientV2 from fedn.network.clients.client_v2 import ClientOptions @@ -60,13 +59,13 @@ def list_clients(ctx, protocol: str, host: str, port: str, token: str = None, n_ if _token: headers["Authorization"] = _token - try: response = requests.get(url, headers=headers) print_response(response, "clients", None) except requests.exceptions.ConnectionError: click.echo(f"Error: Could not connect to {url}") + @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @click.option("-H", "--host", required=False, default=CONTROLLER_DEFAULTS["host"], help="Hostname of controller (api)") @click.option("-P", "--port", required=False, default=CONTROLLER_DEFAULTS["port"], help="Port of controller (api)") @@ -83,7 +82,6 @@ def get_client(ctx, protocol: str, host: str, port: str, token: str = None, id: url = get_api_url(protocol=protocol, host=host, port=port, endpoint="clients") headers = {} - _token = get_token(token) if _token: @@ -92,7 +90,6 @@ def get_client(ctx, protocol: str, host: str, port: str, token: str = None, id: if id: url = f"{url}{id}" - try: response = requests.get(url, headers=headers) print_response(response, "client", id) @@ -100,119 +97,6 @@ def get_client(ctx, protocol: str, host: str, port: str, token: str = None, id: click.echo(f"Error: Could not connect to {url}") -@client_cmd.command("start-v1") -@click.option("-d", "--discoverhost", required=False, help="Hostname for discovery services(reducer).") -@click.option("-p", "--discoverport", required=False, help="Port for discovery services (reducer).") -@click.option("--token", required=False, help="Set token provided by reducer if enabled") -@click.option("-n", "--name", required=False, default="client" + str(uuid.uuid4())[:8]) -@click.option("-i", "--client_id", required=False) -@click.option("--local-package", is_flag=True, help="Enable local compute package") -@click.option("--force-ssl", is_flag=True, help="Force SSL/TLS for REST service") -@click.option("-u", "--dry-run", required=False, default=False) -@click.option("-s", "--secure", required=False, default=False) -@click.option("-pc", "--preshared-cert", required=False, default=False) -@click.option("-v", "--verify", is_flag=True, help="Verify SSL/TLS for REST service") -@click.option("-c", "--preferred-combiner", type=str, required=False, default="", help="name of the preferred combiner") -@click.option("--combiner", type=str, required=False, default="", help="Skip combiner assignment from discover service and attatch directly to combiner host.") -@click.option("--combiner-port", type=str, required=False, default="12080", help="Combiner port, need to be used with --combiner") -@click.option("--proxy-server", type=str, required=False, default="", help="gRPC proxy server, need to be used together with --combiner") -@click.option("-va", "--validator", required=False, default=True) -@click.option("-tr", "--trainer", required=False, default=True) -@click.option("-in", "--init", required=False, default=None, help="Set to a filename to (re)init client from file state.") -@click.option("-l", "--logfile", required=False, default=None, help="Set logfile for client log to file.") -@click.option("--heartbeat-interval", required=False, default=2) -@click.option("--reconnect-after-missed-heartbeat", required=False, default=30) -@click.option("--verbosity", required=False, default="INFO", type=click.Choice(["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], case_sensitive=False)) -@click.pass_context -def client_start_cmd( - ctx, - discoverhost, - discoverport, - token, - name, - client_id, - local_package, - force_ssl, - dry_run, - secure, - preshared_cert, - verify, - preferred_combiner, - combiner, - combiner_port, - proxy_server, - validator, - trainer, - init, - logfile, - heartbeat_interval, - reconnect_after_missed_heartbeat, - verbosity, -): - """:param ctx: - :param discoverhost: - :param discoverport: - :param token: - :param name: - :param client_id: - :param remote: - :param dry_run: - :param secure: - :param preshared_cert: - :param verify_cert: - :param preferred_combiner: - :param init: - :param logfile: - :param hearbeat_interval - :param reconnect_after_missed_heartbeat - :param verbosity - :return: - """ - remote = False if local_package else True - config = { - "discover_host": discoverhost, - "discover_port": discoverport, - "token": token, - "name": name, - "client_id": client_id, - "remote_compute_context": remote, - "force_ssl": force_ssl, - "dry_run": dry_run, - "secure": secure, - "preshared_cert": preshared_cert, - "verify": verify, - "preferred_combiner": preferred_combiner, - "combiner": combiner, - "combiner_port": combiner_port, - "proxy_server": proxy_server, - "validator": validator, - "trainer": trainer, - "logfile": logfile, - "heartbeat_interval": heartbeat_interval, - "reconnect_after_missed_heartbeat": reconnect_after_missed_heartbeat, - "verbosity": verbosity, - } - - if init: - apply_config(init, config) - click.echo(f"\nClient configuration loaded from file: {init}") - click.echo("Values set in file override defaults and command line arguments...\n") - - # proxy_server needs combiner check - if config["proxy_server"]: - if not config["combiner"]: - click.echo("--proxy-server/proxy_server requires a combiner host in --combiner/combiner") - return - try: - validate_client_config(config) - except InvalidClientConfig as e: - click.echo(f"Error: {e}") - return - - client = Client(config) - client.run() - - def _validate_client_params(config: dict): api_url = config["api_url"] combiner = config["combiner"] diff --git a/fedn/cli/run_cmd.py b/fedn/cli/run_cmd.py index 0342bb39d..7be9458bd 100644 --- a/fedn/cli/run_cmd.py +++ b/fedn/cli/run_cmd.py @@ -5,12 +5,9 @@ import click import yaml -from fedn.cli.client_cmd import validate_client_config from fedn.cli.main import main from fedn.cli.shared import apply_config -from fedn.common.exceptions import InvalidClientConfig from fedn.common.log_config import logger -from fedn.network.clients.client import Client from fedn.utils.dispatcher import Dispatcher, _read_yaml_file @@ -172,108 +169,6 @@ def build_cmd(ctx, path, keep_venv): delete_virtual_environment(dispatcher) -@run_cmd.command("client") -@click.option("-d", "--discoverhost", required=False, help="Hostname for discovery services(reducer).") -@click.option("-p", "--discoverport", required=False, help="Port for discovery services (reducer).") -@click.option("--token", required=False, help="Set token provided by reducer if enabled") -@click.option("-n", "--name", required=False, default="client" + str(uuid.uuid4())[:8]) -@click.option("-i", "--client_id", required=False) -@click.option("--local-package", is_flag=True, help="Enable local compute package") -@click.option("--force-ssl", is_flag=True, help="Force SSL/TLS for REST service") -@click.option("-u", "--dry-run", required=False, default=False) -@click.option("-s", "--secure", required=False, default=False) -@click.option("-pc", "--preshared-cert", required=False, default=False) -@click.option("-v", "--verify", is_flag=True, help="Verify SSL/TLS for REST service") -@click.option("-c", "--preferred-combiner", required=False, type=str, default="", help="url to the combiner or name of the preferred combiner") -@click.option("-va", "--validator", required=False, default=True) -@click.option("-tr", "--trainer", required=False, default=True) -@click.option("-in", "--init", required=False, default=None, help="Set to a filename to (re)init client from file state.") -@click.option("-l", "--logfile", required=False, default=None, help="Set logfile for client log to file.") -@click.option("--heartbeat-interval", required=False, default=2) -@click.option("--reconnect-after-missed-heartbeat", required=False, default=30) -@click.option("--verbosity", required=False, default="INFO", type=click.Choice(["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], case_sensitive=False)) -@click.pass_context -def client_cmd( - ctx, - discoverhost, - discoverport, - token, - name, - client_id, - local_package, - force_ssl, - dry_run, - secure, - preshared_cert, - verify, - preferred_combiner, - validator, - trainer, - init, - logfile, - heartbeat_interval, - reconnect_after_missed_heartbeat, - verbosity, -): - """:param ctx: - :param discoverhost: - :param discoverport: - :param token: - :param name: - :param client_id: - :param remote: - :param dry_run: - :param secure: - :param preshared_cert: - :param verify_cert: - :param preferred_combiner: - :param init: - :param logfile: - :param hearbeat_interval - :param reconnect_after_missed_heartbeat - :param verbosity - :return: - """ - remote = False if local_package else True - config = { - "discover_host": discoverhost, - "discover_port": discoverport, - "token": token, - "name": name, - "client_id": client_id, - "remote_compute_context": remote, - "force_ssl": force_ssl, - "dry_run": dry_run, - "secure": secure, - "preshared_cert": preshared_cert, - "verify": verify, - "preferred_combiner": preferred_combiner, - "validator": validator, - "trainer": trainer, - "logfile": logfile, - "heartbeat_interval": heartbeat_interval, - "reconnect_after_missed_heartbeat": reconnect_after_missed_heartbeat, - "verbosity": verbosity, - } - - click.echo( - click.style("\n*** fedn run client is deprecated and will be removed. Please use fedn client start instead. ***\n", blink=True, bold=True, fg="red") - ) - - if init: - apply_config(init, config) - click.echo(f"\nClient configuration loaded from file: {init}") - click.echo("Values set in file override defaults and command line arguments...\n") - try: - validate_client_config(config) - except InvalidClientConfig as e: - click.echo(f"Error: {e}") - return - - client = Client(config) - client.run() - - @run_cmd.command("combiner") @click.option("-d", "--discoverhost", required=False, help="Hostname for discovery services (reducer).") @click.option("-p", "--discoverport", required=False, help="Port for discovery services (reducer).") diff --git a/fedn/common/settings-controller.yaml.template b/fedn/common/settings-controller.yaml.template index a5266a38b..3667eb189 100644 --- a/fedn/common/settings-controller.yaml.template +++ b/fedn/common/settings-controller.yaml.template @@ -11,6 +11,11 @@ statestore: password: password host: localhost port: 6534 + postgres_config: + username: fedn_admin + password: password + host: fedn_postgres + port: 5432 storage: storage_type: S3 diff --git a/fedn/network/api/client.py b/fedn/network/api/client.py index 344ae6453..98cbae663 100644 --- a/fedn/network/api/client.py +++ b/fedn/network/api/client.py @@ -108,7 +108,7 @@ def get_client_config(self, checksum=True): """ _params = {"checksum": "true" if checksum else "false"} - response = requests.get(self._get_url("get_client_config"), params=_params, verify=self.verify, headers=self.headers) + response = requests.get(self._get_url_api_v1("clients/config"), params=_params, verify=self.verify, headers=self.headers) _json = response.json() @@ -338,9 +338,7 @@ def set_active_model(self, path): response = requests.put(self._get_url_api_v1("helpers/active"), json={"helper": helper}, verify=self.verify, headers=self.headers) with open(path, "rb") as file: - response = requests.post( - self._get_url("set_initial_model"), files={"file": file}, data={"helper": helper}, verify=self.verify, headers=self.headers - ) + response = requests.post(self._get_url_api_v1("models"), files={"file": file}, data={"helper": helper}, verify=self.verify, headers=self.headers) return response.json() # --- Packages --- # @@ -408,7 +406,7 @@ def get_package_checksum(self): :return: The checksum. :rtype: dict """ - response = requests.get(self._get_url("get_package_checksum"), verify=self.verify, headers=self.headers) + response = requests.get(self._get_url_api_v1("packages/checksum"), verify=self.verify, headers=self.headers) _json = response.json() @@ -422,7 +420,7 @@ def download_package(self, path: str): :return: Message with success or failure. :rtype: dict """ - response = requests.get(self._get_url("download_package"), verify=self.verify, headers=self.headers) + response = requests.get(self._get_url_api_v1("packages/download"), verify=self.verify, headers=self.headers) if response.status_code == 200: with open(path, "wb") as file: file.write(response.content) @@ -442,7 +440,7 @@ def set_active_package(self, path: str, helper: str, name: str = None, descripti """ with open(path, "rb") as file: response = requests.post( - self._get_url("set_package"), + self._get_url_api_v1("packages"), files={"file": file}, data={"helper": helper, "name": name, "description": description}, verify=self.verify, @@ -575,7 +573,7 @@ def session_is_finished(self, id: str): def start_session( self, - id: str = None, + name: str = None, aggregator: str = "fedavg", aggregator_kwargs: dict = None, model_id: str = None, @@ -584,15 +582,15 @@ def start_session( round_buffer_size: int = -1, delete_models: bool = True, validate: bool = True, - helper: str = "", + helper: str = "numpyhelper", min_clients: int = 1, requested_clients: int = 8, server_functions: ServerFunctionsBase = None, ): """Start a new session. - :param id: The session id to start. - :type id: str + :param name: The name of the session + :type name: str :param aggregator: The aggregator plugin to use. :type aggregator: str :param model_id: The id of the initial model. @@ -626,7 +624,7 @@ def start_session( response = requests.post( self._get_url_api_v1("sessions/"), json={ - "session_id": id, + "name": name, "session_config": { "aggregator": aggregator, "aggregator_kwargs": aggregator_kwargs, @@ -646,12 +644,11 @@ def start_session( ) if response.status_code == 201: - if id is None: - id = response.json()["session_id"] + session_id = response.json()["session_id"] response = requests.post( self._get_url_api_v1("sessions/start"), json={ - "session_id": id, + "session_id": session_id, "rounds": rounds, "round_timeout": round_timeout, }, diff --git a/fedn/network/api/interface.py b/fedn/network/api/interface.py deleted file mode 100644 index 6031051c1..000000000 --- a/fedn/network/api/interface.py +++ /dev/null @@ -1,1025 +0,0 @@ -import os -import threading -import uuid -from io import BytesIO - -from flask import jsonify, send_from_directory -from werkzeug.security import safe_join -from werkzeug.utils import secure_filename - -from fedn.common.config import FEDN_COMPUTE_PACKAGE_DIR, get_controller_config, get_network_config -from fedn.common.log_config import logger -from fedn.network.combiner.interfaces import CombinerUnavailableError -from fedn.network.state import ReducerState, ReducerStateToString -from fedn.utils.checksum import sha - -__all__ = ("API",) - - -class API: - """The API class is a wrapper for the statestore. It is used to expose the statestore to the network API.""" - - def __init__(self, statestore, control): - self.statestore = statestore - self.control = control - self.name = "api" - - def _to_dict(self): - """Convert the object to a dict. - - ::return: The object as a dict. - ::rtype: dict - """ - data = {"name": self.name} - return data - - def _allowed_file_extension(self, filename, ALLOWED_EXTENSIONS={"gz", "bz2", "tar", "zip", "tgz"}): - """Check if file extension is allowed. - - :param filename: The filename to check. - :type filename: str - :return: True and extension str if file extension is allowed, else False and None. - :rtype: Tuple (bool, str) - """ - if "." in filename: - extension = filename.rsplit(".", 1)[1].lower() - if extension in ALLOWED_EXTENSIONS: - return (True, extension) - - return (False, None) - - def get_clients(self, limit=None, skip=None, status=False): - """Get all clients from the statestore. - - :return: All clients as a json response. - :rtype: :class:`flask.Response` - """ - # Will return list of ObjectId - response = self.statestore.list_clients(limit, skip, status) - - arr = [] - - for element in response["result"]: - obj = { - "id": element["name"], - "combiner": element["combiner"], - "combiner_preferred": element["combiner_preferred"], - "ip": element["ip"], - "status": element["status"], - "last_seen": element["last_seen"] if "last_seen" in element else "", - } - - arr.append(obj) - - result = {"result": arr, "count": response["count"]} - - return jsonify(result) - - def get_all_combiners(self, limit=None, skip=None): - """Get all combiners from the statestore. - - :return: All combiners as a json response. - :rtype: :class:`flask.Response` - """ - # Will return list of ObjectId - projection = {"name": True, "updated_at": True} - response = self.statestore.get_combiners(limit, skip, projection=projection) - arr = [] - for element in response["result"]: - obj = { - "name": element["name"], - "updated_at": element["updated_at"], - } - - arr.append(obj) - - result = {"result": arr, "count": response["count"]} - - return jsonify(result) - - def get_combiner(self, combiner_id): - """Get a combiner from the statestore. - - :param combiner_id: The combiner id to get. - :type combiner_id: str - :return: The combiner info dict as a json response. - :rtype: :class:`flask.Response` - """ - # Will return ObjectId - object = self.statestore.get_combiner(combiner_id) - payload = {} - id = object["name"] - info = { - "address": object["address"], - "fqdn": object["fqdn"], - "parent_reducer": object["parent"]["name"], - "port": object["port"], - "updated_at": object["updated_at"], - } - payload[id] = info - - return jsonify(payload) - - def get_all_sessions(self, limit=None, skip=None): - """Get all sessions from the statestore. - - :return: All sessions as a json response. - :rtype: :class:`flask.Response` - """ - sessions_object = self.statestore.get_sessions(limit, skip) - if sessions_object is None: - return ( - jsonify({"success": False, "message": "No sessions found."}), - 404, - ) - arr = [] - for element in sessions_object["result"]: - obj = element["session_config"][0] - arr.append(obj) - - result = {"result": arr, "count": sessions_object["count"]} - - return jsonify(result) - - def get_session(self, session_id): - """Get a session from the statestore. - - :param session_id: The session id to get. - :type session_id: str - :return: The session info dict as a json response. - :rtype: :class:`flask.Response` - """ - session_object = self.statestore.get_session(session_id) - if session_object is None: - return ( - jsonify( - { - "success": False, - "message": f"Session {session_id} not found.", - } - ), - 404, - ) - payload = {} - id = session_object["session_id"] - info = session_object["session_config"][0] - status = session_object["status"] - payload[id] = info - payload["status"] = status - return jsonify(payload) - - def set_active_compute_package(self, id: str): - success = self.statestore.set_active_compute_package(id) - - if not success: - return ( - jsonify( - { - "success": False, - "message": "Failed to set compute package.", - } - ), - 400, - ) - - return jsonify({"success": True, "message": "Compute package set."}) - - def set_compute_package(self, file, helper_type: str, name: str = None, description: str = None): - """Set the compute package in the statestore. - - :param file: The compute package to set. - :type file: file - :return: A json response with success or failure message. - :rtype: :class:`flask.Response` - """ - if self.control.state() == ReducerState.instructing or self.control.state() == ReducerState.monitoring: - return ( - jsonify( - { - "success": False, - "message": "Reducer is in instructing or monitoring state." "Cannot set compute package.", - } - ), - 400, - ) - - if file is None: - return ( - jsonify( - { - "success": False, - "message": "No file provided.", - } - ), - 404, - ) - - success, extension = self._allowed_file_extension(file.filename) - - if not success: - return ( - jsonify( - { - "success": False, - "message": f"File extension {extension} not allowed.", - } - ), - 404, - ) - - file_name = file.filename - storage_file_name = secure_filename(f"{str(uuid.uuid4())}.{extension}") - - file_path = safe_join(FEDN_COMPUTE_PACKAGE_DIR, storage_file_name) - file.save(file_path) - - self.control.set_compute_package(storage_file_name, file_path) - success = self.statestore.set_compute_package(file_name, storage_file_name, helper_type, name, description) - - if not success: - return ( - jsonify( - { - "success": False, - "message": "Failed to set compute package.", - } - ), - 400, - ) - # Delete the file after it has been saved - os.remove(file_path) - return jsonify({"success": True, "message": "Compute package set."}) - - def _get_compute_package_name(self): - """Get the compute package name from the statestore. - - :return: The compute package name. - :rtype: str - """ - package_objects = self.statestore.get_compute_package() - if package_objects is None: - message = "No compute package found." - return None, message - else: - try: - name = package_objects["storage_file_name"] - except KeyError as e: - message = "No compute package found. Key error." - logger.debug(e) - return None, message - return name, "success" - - def get_compute_package(self): - """Get the compute package from the statestore. - - :return: The compute package as a json response. - :rtype: :class:`flask.Response` - """ - result = self.statestore.get_compute_package() - if result is None or "file_name" not in result: - return ( - jsonify({"success": False, "message": "No compute package found."}), - 404, - ) - - obj = { - "id": result["id"] if "id" in result else "", - "file_name": result["file_name"], - "helper": result["helper"], - "committed_at": result["committed_at"], - "storage_file_name": result["storage_file_name"] if "storage_file_name" in result else "", - "name": result["name"] if "name" in result else "", - "description": result["description"] if "description" in result else "", - } - - return jsonify(obj) - - def list_compute_packages(self, limit: str = None, skip: str = None, include_active: str = None): - """Get paginated list of compute packages from the statestore. - :param limit: The number of compute packages to return. - :type limit: str - :param skip: The number of compute packages to skip. - :type skip: str - :param include_active: Whether to include the active compute package or not. - :type include_active: str - :return: All compute packages as a json response. - :rtype: :class:`flask.Response` - """ - if limit is not None and skip is not None: - limit = int(limit) - skip = int(skip) - - include_active: bool = include_active == "true" - - result = self.statestore.list_compute_packages(limit, skip) - if result is None: - return ( - jsonify({"success": False, "message": "No compute packages found."}), - 404, - ) - - active_package_id: str = None - - if include_active: - active_package = self.statestore.get_compute_package() - - if active_package is not None: - active_package_id = active_package["id"] if "id" in active_package else "" - - if include_active: - arr = [ - { - "id": element["id"] if "id" in element else "", - "file_name": element["file_name"], - "helper": element["helper"], - "committed_at": element["committed_at"], - "storage_file_name": element["storage_file_name"] if "storage_file_name" in element else "", - "name": element["name"] if "name" in element else "", - "description": element["description"] if "description" in element else "", - "active": "id" in element and element["id"] == active_package_id, - } - for element in result["result"] - ] - else: - arr = [ - { - "id": element["id"] if "id" in element else "", - "file_name": element["file_name"], - "helper": element["helper"], - "committed_at": element["committed_at"], - "storage_file_name": element["storage_file_name"] if "storage_file_name" in element else "", - "name": element["name"] if "name" in element else "", - "description": element["description"] if "description" in element else "", - } - for element in result["result"] - ] - - result = {"result": arr, "count": result["count"]} - return jsonify(result) - - def download_compute_package(self, name): - """Download the compute package. - - :return: The compute package as a json object. - :rtype: :class:`flask.Response` - """ - if name is None: - name, message = self._get_compute_package_name() - if name is None: - return jsonify({"success": False, "message": message}), 404 - try: - mutex = threading.Lock() - mutex.acquire() - - return send_from_directory(FEDN_COMPUTE_PACKAGE_DIR, name, as_attachment=True) - except Exception: - try: - data = self.control.get_compute_package(name) - # TODO: make configurable, perhaps in config.py or package.py - file_path = safe_join(FEDN_COMPUTE_PACKAGE_DIR, name) - with open(file_path, "wb") as fh: - fh.write(data) - # TODO: make configurable, perhaps in config.py or package.py - return send_from_directory(FEDN_COMPUTE_PACKAGE_DIR, name, as_attachment=True) - except Exception: - raise - finally: - mutex.release() - - def _create_checksum(self, name=None): - """Create the checksum of the compute package. - - :param name: The name of the compute package. - :type name: str - :return: Success or failure boolean, message and the checksum. - :rtype: bool, str, str - """ - if name is None: - name, message = self._get_compute_package_name() - if name is None: - return False, message, "" - file_path = safe_join(os.getcwd(), name) # TODO: make configurable, perhaps in config.py or package.py - try: - sum = str(sha(file_path)) - except FileNotFoundError: - sum = "" - message = "File not found." - return True, message, sum - - def get_checksum(self, name): - """Get the checksum of the compute package. - - :param name: The name of the compute package. - :type name: str - :return: The checksum as a json object. - :rtype: :py:class:`flask.Response` - """ - success, message, sum = self._create_checksum(name) - if not success: - return jsonify({"success": False, "message": message}), 404 - payload = {"checksum": sum} - - return jsonify(payload) - - def get_controller_status(self): - """Get the status of the controller. - - :return: The status of the controller as a json object. - :rtype: :py:class:`flask.Response` - """ - return jsonify({"state": ReducerStateToString(self.control.state())}) - - def get_events(self, **kwargs): - """Get the events of the federated network. - - :return: The events as a json object. - :rtype: :py:class:`flask.Response` - """ - response = self.statestore.get_events(**kwargs) - - result = response["result"] - if result is None: - return ( - jsonify({"success": False, "message": "No events found."}), - 404, - ) - - events = [] - for evt in result: - events.append(evt) - - return jsonify({"result": events, "count": response["count"]}) - - def get_all_validations(self, **kwargs): - """Get all validations from the statestore. - - :return: All validations as a json response. - :rtype: :class:`flask.Response` - """ - validations_objects = self.statestore.get_validations(**kwargs) - if validations_objects is None: - return ( - jsonify( - { - "success": False, - "message": "No validations found.", - "filter_used": kwargs, - } - ), - 404, - ) - payload = {} - for object in validations_objects: - id = str(object["_id"]) - info = { - "model_id": object["modelId"], - "data": object["data"], - "timestamp": object["timestamp"], - "meta": object["meta"], - "sender": object["sender"], - "receiver": object["receiver"], - } - payload[id] = info - return jsonify(payload) - - def add_combiner(self, combiner_id, secure_grpc, address, remote_addr, fqdn, port): - """Add a combiner to the network. - - :param combiner_id: The combiner id to add. - :type combiner_id: str - :param secure_grpc: Whether to use secure grpc or not. - :type secure_grpc: bool - :param name: The name of the combiner. - :type name: str - :param address: The address of the combiner. - :type address: str - :param remote_addr: The remote address of the combiner. - :type remote_addr: str - :param fqdn: The fqdn of the combiner. - :type fqdn: str - :param port: The port of the combiner. - :type port: int - :return: Config of the combiner as a json response. - :rtype: :class:`flask.Response` - """ - payload = { - "success": False, - "message": "Adding combiner via REST API is obsolete. Include statestore and object store config in combiner config.", - "status": "abort", - } - - return jsonify(payload) - - def add_client(self, client_id, preferred_combiner, remote_addr, name, package): - """Add a client to the network. - - :param client_id: The client id to add. - :type client_id: str - :param preferred_combiner: The preferred combiner for the client.If None, the combiner will be chosen based on availability. - :type preferred_combiner: str - :return: A json response with combiner assignment config. - :rtype: :class:`flask.Response` - """ - if package == "remote": - package_object = self.statestore.get_compute_package() - if package_object is None: - return ( - jsonify( - { - "success": False, - "status": "retry", - "message": "No compute package found. Set package in controller.", - } - ), - 203, - ) - helper_type = self.control.statestore.get_helper() - else: - # Else package is "local": - helper_type = "" - - # Assign client to combiner - if preferred_combiner: - combiner = self.control.network.get_combiner(preferred_combiner) - if combiner is None: - return ( - jsonify( - { - "success": False, - "message": f"Combiner {preferred_combiner} not found or unavailable.", - } - ), - 400, - ) - else: - combiner = self.control.network.find_available_combiner() - if combiner is None: - return ( - jsonify({"success": False, "message": "No combiner available."}), - 400, - ) - - client_config = { - "client_id": client_id, - "name": name, - "combiner_preferred": preferred_combiner, - "combiner": combiner.name, - "ip": remote_addr, - "status": "available", - "package": package, - } - # Add client to network - self.control.network.add_client(client_config) - - payload = { - "status": "assigned", - "host": combiner.address, - "fqdn": combiner.fqdn, - "package": package, - "ip": combiner.ip, - "port": combiner.port, - "helper_type": helper_type, - } - return jsonify(payload) - - def get_initial_model(self): - """Get the initial model from the statestore. - - :return: The initial model as a json response. - :rtype: :class:`flask.Response` - """ - model_id = self.statestore.get_initial_model() - payload = {"model_id": model_id} - return jsonify(payload) - - def set_initial_model(self, file): - """Add an initial model to the network. - - :param file: The initial model to add. - :type file: file - :return: A json response with success or failure message. - :rtype: :class:`flask.Response` - """ - logger.info("Adding model") - try: - object = BytesIO() - object.seek(0, 0) - file.seek(0) - object.write(file.read()) - helper = self.control.get_helper() - logger.info(f"Loading model from file using helper {helper.name}") - object.seek(0) - model = helper.load(object) - self.control.commit(file.filename, model) - except Exception as e: - logger.error("Error occured during model loading") - logger.debug(e) - status_code = 400 - return ( - jsonify( - { - "success": False, - "message": "Failed to add model.", - } - ), - status_code, - ) - - return jsonify({"success": True, "message": "Initial model added successfully."}) - - def get_latest_model(self): - """Get the latest model from the statestore. - - :return: The initial model as a json response. - :rtype: :class:`flask.Response` - """ - if self.statestore.get_latest_model(): - model_id = self.statestore.get_latest_model() - payload = {"model_id": model_id} - return jsonify(payload) - else: - return jsonify({"success": False, "message": "No initial model set."}) - - def set_current_model(self, model_id: str): - """Set the active model in the statestore. - - :param model_id: The model id to set. - :type model_id: str - :return: A json response with success or failure message. - :rtype: :class:`flask.Response` - """ - success = self.statestore.set_current_model(model_id) - - if not success: - return ( - jsonify( - { - "success": False, - "message": "Failed to set active model.", - } - ), - 400, - ) - - return jsonify({"success": True, "message": "Active model set."}) - - def get_models(self, session_id: str = None, limit: str = None, skip: str = None, include_active: str = None): - result = self.statestore.list_models(session_id, limit, skip) - - if result is None: - return ( - jsonify({"success": False, "message": "No models found."}), - 404, - ) - - include_active: bool = include_active == "true" - - if include_active: - latest_model = self.statestore.get_latest_model() - - arr = [ - { - "committed_at": element["committed_at"], - "model": element["model"], - "session_id": element["session_id"], - "active": element["model"] == latest_model, - } - for element in result["result"] - ] - else: - arr = [ - { - "committed_at": element["committed_at"], - "model": element["model"], - "session_id": element["session_id"], - } - for element in result["result"] - ] - - result = {"result": arr, "count": result["count"]} - - return jsonify(result) - - def get_model(self, model_id: str): - result = self.statestore.get_model(model_id) - - if result is None: - return ( - jsonify({"success": False, "message": "No model found."}), - 404, - ) - - payload = { - "committed_at": result["committed_at"], - "parent_model": result["parent_model"], - "model": result["model"], - "session_id": result["session_id"], - } - - return jsonify(payload) - - def get_model_trail(self): - """Get the model trail for a given session. - - :param session: The session id to get the model trail for. - :type session: str - :return: The model trail for the given session as a json response. - :rtype: :class:`flask.Response` - """ - model_info = self.statestore.get_model_trail() - if model_info: - return jsonify(model_info) - else: - return jsonify({"success": False, "message": "No model trail available."}) - - def get_model_ancestors(self, model_id: str, limit: str = None): - """Get the model ancestors for a given model. - - :param model_id: The model id to get the model ancestors for. - :type model_id: str - :param limit: The number of ancestors to return. - :type limit: str - :return: The model ancestors for the given model as a json response. - :rtype: :class:`flask.Response` - """ - if model_id is None: - return jsonify({"success": False, "message": "No model id provided."}) - - limit: int = int(limit) if limit is not None else 10 # if limit is None, default to 10 - - response = self.statestore.get_model_ancestors(model_id, limit) - if response: - arr: list = [] - - for element in response: - obj = { - "model": element["model"], - "committed_at": element["committed_at"], - "session_id": element["session_id"], - "parent_model": element["parent_model"], - } - arr.append(obj) - - result = {"result": arr} - - return jsonify(result) - else: - return jsonify({"success": False, "message": "No model ancestors available."}) - - def get_model_descendants(self, model_id: str, limit: str = None): - """Get the model descendants for a given model. - - :param model_id: The model id to get the model descendants for. - :type model_id: str - :param limit: The number of descendants to return. - :type limit: str - :return: The model descendants for the given model as a json response. - :rtype: :class:`flask.Response` - """ - if model_id is None: - return jsonify({"success": False, "message": "No model id provided."}) - - limit: int = int(limit) if limit is not None else 10 - - response: list = self.statestore.get_model_descendants(model_id, limit) - - if response: - arr: list = [] - - for element in response: - obj = { - "model": element["model"], - "committed_at": element["committed_at"], - "session_id": element["session_id"], - "parent_model": element["parent_model"], - } - arr.append(obj) - - result = {"result": arr} - - return jsonify(result) - else: - return jsonify({"success": False, "message": "No model descendants available."}) - - def get_all_rounds(self): - """Get all rounds. - - :return: The rounds as json response. - :rtype: :class:`flask.Response` - """ - rounds_objects = self.statestore.get_rounds() - if rounds_objects is None: - jsonify({"success": False, "message": "No rounds available."}) - payload = {} - for object in rounds_objects: - id = object["round_id"] - if "reducer" in object.keys(): - reducer = object["reducer"] - else: - reducer = None - if "combiners" in object.keys(): - combiners = object["combiners"] - else: - combiners = None - - info = { - "reducer": reducer, - "combiners": combiners, - } - payload[id] = info - return jsonify(payload) - - def get_round(self, round_id): - """Get a round. - - :param round_id: The round id to get. - :type round_id: str - :return: The round as json response. - :rtype: :class:`flask.Response` - """ - round_object = self.statestore.get_round(round_id) - if round_object is None: - return jsonify({"success": False, "message": "Round not found."}) - payload = { - "round_id": round_object["round_id"], - "combiners": round_object["combiners"], - } - return jsonify(payload) - - def get_client_config(self, checksum=True): - """Get the client config. - - :return: The client config as json response. - :rtype: :py:class:`flask.Response` - """ - config = get_controller_config() - network_id = get_network_config() - port = config["port"] - host = config["host"] - payload = { - "network_id": network_id, - "discover_host": host, - "discover_port": port, - } - if checksum: - success, _, checksum_str = self._create_checksum() - if success: - payload["checksum"] = checksum_str - return jsonify(payload) - - def list_combiners_data(self, combiners): - """Get combiners data. - - :param combiners: The combiners to get data for. - :type combiners: list - :return: The combiners data as json response. - :rtype: :py:class:`flask.Response` - """ - response = self.statestore.list_combiners_data(combiners) - - arr = [] - - # order list by combiner name - for element in response: - obj = { - "combiner": element["_id"], - "count": element["count"], - } - - arr.append(obj) - - result = {"result": arr} - - return jsonify(result) - - def start_session( - self, - session_id, - aggregator="fedavg", - aggregator_kwargs=None, - model_id=None, - rounds=5, - round_timeout=180, - round_buffer_size=-1, - delete_models=True, - validate=True, - helper="", - min_clients=1, - requested_clients=8, - server_functions=None, - ): - """Start a session. - - :param session_id: The session id to start. - :type session_id: str - :param aggregator: The aggregator plugin to use. - :type aggregator: str - :param initial_model: The initial model for the session. - :type initial_model: str - :param rounds: The number of rounds to perform. - :type rounds: int - :param round_timeout: The round timeout to use in seconds. - :type round_timeout: int - :param round_buffer_size: The round buffer size to use. - :type round_buffer_size: int - :param delete_models: Whether to delete models after each round at combiner (save storage). - :type delete_models: bool - :param validate: Whether to validate the model after each round. - :type validate: bool - :param min_clients: The minimum number of clients required. - :type min_clients: int - :param requested_clients: The requested number of clients. - :type requested_clients: int - :return: A json response with success or failure message and session config. - :rtype: :class:`flask.Response` - """ - # Check if session already exists - session = self.statestore.get_session(session_id) - if session: - return jsonify({"success": False, "message": "Session already exists."}) - - # Check if session is running - if self.control.state() == ReducerState.monitoring: - return jsonify({"success": False, "message": "A session is already running."}) - - # Check if compute package is set - package = self.statestore.get_compute_package() - if not package: - return jsonify( - { - "success": False, - "message": "No compute package set. Set compute package before starting session.", - } - ) - if not helper: - # get helper from compute package - helper = package["helper"] - - # Check that initial (seed) model is set - if not self.statestore.get_initial_model(): - return jsonify( - { - "success": False, - "message": "No initial model set. Set initial model before starting session.", - } - ) - - # Check available clients per combiner - clients_available = 0 - for combiner in self.control.network.get_combiners(): - try: - nr_active_clients = len(combiner.list_active_clients()) - clients_available = clients_available + int(nr_active_clients) - except CombinerUnavailableError as e: - # TODO: Handle unavailable combiner, stop session or continue? - logger.error("COMBINER UNAVAILABLE: {}".format(e)) - continue - - if clients_available < min_clients: - return jsonify( - { - "success": False, - "message": "Not enough clients available to start session.", - } - ) - - # Check if validate is string and convert to bool - if isinstance(validate, str): - if validate.lower() == "true": - validate = True - else: - validate = False - - # Get lastest model as initial model for session - if not model_id: - model_id = self.statestore.get_latest_model() - - # Setup session config - session_config = { - "session_id": session_id if session_id else str(uuid.uuid4()), - "aggregator": aggregator, - "aggregator_kwargs": aggregator_kwargs, - "round_timeout": round_timeout, - "buffer_size": round_buffer_size, - "model_id": model_id, - "rounds": rounds, - "delete_models_storage": delete_models, - "clients_required": min_clients, - "clients_requested": requested_clients, - "task": (""), - "validate": validate, - "helper_type": helper, - "server_functions": server_functions, - } - - # Start session - threading.Thread(target=self.control.session, args=(session_config,)).start() - - # Return success response - return jsonify( - { - "success": True, - "message": "Session started successfully.", - "config": session_config, - } - ) diff --git a/fedn/network/api/network.py b/fedn/network/api/network.py index 542761f49..9707efdf5 100644 --- a/fedn/network/api/network.py +++ b/fedn/network/api/network.py @@ -3,6 +3,8 @@ from fedn.common.log_config import logger from fedn.network.combiner.interfaces import CombinerInterface from fedn.network.loadbalancer.leastpacked import LeastPacked +from fedn.network.storage.statestore.stores.client_store import ClientStore +from fedn.network.storage.statestore.stores.combiner_store import CombinerStore __all__ = ("Network",) @@ -13,11 +15,12 @@ class Network: Some methods has been moved to :class:`fedn.network.api.interface.API`. """ - def __init__(self, control, statestore, load_balancer=None): + def __init__(self, control, network_id: str, combiner_store: CombinerStore, client_store: ClientStore, load_balancer=None): """ """ - self.statestore = statestore + self.combiner_store = combiner_store + self.client_store = client_store self.control = control - self.id = statestore.network_id + self.id = network_id if not load_balancer: self.load_balancer = LeastPacked(self) @@ -44,7 +47,7 @@ def get_combiners(self): :return: list of combiners objects :rtype: list(:class:`fedn.network.combiner.interfaces.CombinerInterface`) """ - data = self.statestore.get_combiners() + data = self.combiner_store.list(limit=0, skip=0, sort_key=None) combiners = [] for c in data["result"]: name = c["name"].upper() @@ -63,35 +66,6 @@ def get_combiners(self): return combiners - def add_combiner(self, combiner): - """Add a new combiner to the network. - - :param combiner: The combiner instance object - :type combiner: :class:`fedn.network.combiner.interfaces.CombinerInterface` - :return: None - """ - if not self.control.idle(): - logger.warning("Reducer is not idle, cannot add additional combiner.") - return - - if self.get_combiner(combiner.name): - return - - logger.info("adding combiner {}".format(combiner.name)) - self.statestore.set_combiner(combiner.to_dict()) - - def remove_combiner(self, combiner): - """Remove a combiner from the network. - - :param combiner: The combiner instance object - :type combiner: :class:`fedn.network.combiner.interfaces.CombinerInterface` - :return: None - """ - if not self.control.idle(): - logger.warning("Reducer is not idle, cannot remove combiner.") - return - self.statestore.delete_combiner(combiner.name) - def find_available_combiner(self): """Find an available combiner in the network. @@ -122,31 +96,21 @@ def add_client(self, client): return logger.info("adding client {}".format(client["client_id"])) - self.statestore.set_client(client) + self.client_store.upsert(client) - def get_client(self, name): - """Get client by name. + def get_client(self, client_id: str): + """Get client by client_id. - :param name: name of client - :type name: str + :param client_id: client_id of client + :type client_id: str :return: The client instance object :rtype: ObjectId """ - ret = self.statestore.get_client(name) - return ret - - def update_client_data(self, client_data, status, role): - """Update client status in statestore. - - :param client_data: The client instance object - :type client_data: dict - :param status: The client status - :type status: str - :param role: The client role - :type role: str - :return: None - """ - self.statestore.update_client_status(client_data, status, role) + try: + client = self.client_store.get(client_id) + return client + except Exception: + return None def get_client_info(self): """List available client in statestore. @@ -154,4 +118,4 @@ def get_client_info(self): :return: list of client objects :rtype: list(ObjectId) """ - return self.statestore.list_clients() + return self.client_store.list(limit=0, skip=0, sort_key=None) diff --git a/fedn/network/api/server.py b/fedn/network/api/server.py index 5055653a6..834797aab 100644 --- a/fedn/network/api/server.py +++ b/fedn/network/api/server.py @@ -5,14 +5,12 @@ from fedn.common.config import get_controller_config from fedn.network.api import gunicorn_app from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.interface import API -from fedn.network.api.shared import control, statestore +from fedn.network.api.shared import control from fedn.network.api.v1 import _routes from fedn.network.api.v1.graphql.schema import schema +from fedn.network.state import ReducerStateToString custom_url_prefix = os.environ.get("FEDN_CUSTOM_URL_PREFIX", False) -# statestore_config,modelstorage_config,network_id,control=set_statestore_config() -api = API(statestore, control) app = Flask(__name__) for bp in _routes: app.register_blueprint(bp) @@ -55,16 +53,50 @@ def graphql_endpoint(): app.add_url_rule(f"{custom_url_prefix}/api/v1/graphql", view_func=graphql_endpoint, methods=["POST"]) -@app.route("/get_model_trail", methods=["GET"]) +@app.route("/get_controller_status", methods=["GET"]) @jwt_auth_required(role="admin") -def get_model_trail(): - """Get the model trail for a given session. - param: session: The session id to get the model trail for. - type: session: str - return: The model trail for the given session as a json object. +def get_controller_status(): + """Get the status of the controller. + return: The status as a json object. rtype: json """ - return api.get_model_trail() + return jsonify({"state": ReducerStateToString(control.state())}), 200 + + +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_controller_status", view_func=get_controller_status, methods=["GET"]) + + +@app.route("/add_combiner", methods=["POST"]) +@jwt_auth_required(role="combiner") +def add_combiner(): + """Add a combiner to the network. + return: The response from the statestore. + rtype: json + """ + payload = { + "success": False, + "message": "Adding combiner via REST API is obsolete. Include statestore and object store config in combiner config.", + "status": "abort", + } + + return jsonify(payload), 410 + + +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/add_combiner", view_func=add_combiner, methods=["POST"]) + + +# deprecated endpoints + + +@app.route("/get_model_trail", methods=["GET"]) +@jwt_auth_required(role="admin") +def get_model_trail(): + response = { + "message": "This endpoint is deprecated. Use /api/v1/models or the GraphQL API instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -74,18 +106,10 @@ def get_model_trail(): @app.route("/get_model_ancestors", methods=["GET"]) @jwt_auth_required(role="admin") def get_model_ancestors(): - """Get the ancestors of a model. - param: model: The model id to get the ancestors for. - type: model: str - param: limit: The maximum number of ancestors to return. - type: limit: int - return: A list of model objects that the model derives from. - rtype: json - """ - model = request.args.get("model", None) - limit = request.args.get("limit", None) - - return api.get_model_ancestors(model, limit) + response = { + "message": "This endpoint is deprecated. Use /api/v1/models//ancestors or the GraphQL API instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -95,18 +119,10 @@ def get_model_ancestors(): @app.route("/get_model_descendants", methods=["GET"]) @jwt_auth_required(role="admin") def get_model_descendants(): - """Get the ancestors of a model. - param: model: The model id to get the child for. - type: model: str - param: limit: The maximum number of descendants to return. - type: limit: int - return: A list of model objects that are descendents of the provided model id. - rtype: json - """ - model = request.args.get("model", None) - limit = request.args.get("limit", None) - - return api.get_model_descendants(model, limit) + response = { + "message": "This endpoint is deprecated. Use /api/v1/models//descendants or the GraphQL API instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -116,22 +132,10 @@ def get_model_descendants(): @app.route("/list_models", methods=["GET"]) @jwt_auth_required(role="admin") def list_models(): - """Get models from the statestore. - param: - session_id: The session id to get the model trail for. - limit: The maximum number of models to return. - type: limit: int - param: skip: The number of models to skip. - type: skip: int - Returns: - _type_: json - """ - session_id = request.args.get("session_id", None) - limit = request.args.get("limit", None) - skip = request.args.get("skip", None) - include_active = request.args.get("include_active", None) - - return api.get_models(session_id, limit, skip, include_active) + response = { + "message": "This endpoint is deprecated. Use /api/v1/models or the GraphQL API instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -141,51 +145,23 @@ def list_models(): @app.route("/get_model", methods=["GET"]) @jwt_auth_required(role="admin") def get_model(): - """Get a model from the statestore. - param: model: The model id to get. - type: model: str - return: The model as a json object. - rtype: json - """ - model = request.args.get("model", None) - if model is None: - return jsonify({"success": False, "message": "Missing model id."}), 400 - - return api.get_model(model) + response = { + "message": "This endpoint is deprecated. Use /api/v1/models/ or the GraphQL API instead.", + } + return jsonify(response), 410 if custom_url_prefix: app.add_url_rule(f"{custom_url_prefix}/get_model", view_func=get_model, methods=["GET"]) -@app.route("/delete_model_trail", methods=["GET", "POST"]) -@jwt_auth_required(role="admin") -def delete_model_trail(): - """Delete the model trail for a given session. - param: session: The session id to delete the model trail for. - type: session: str - return: The response from the statestore. - rtype: json - """ - return jsonify({"message": "Not implemented"}), 501 - - -if custom_url_prefix: - app.add_url_rule(f"{custom_url_prefix}/delete_model_trail", view_func=delete_model_trail, methods=["GET", "POST"]) - - @app.route("/list_clients", methods=["GET"]) @jwt_auth_required(role="admin") def list_clients(): - """Get all clients from the statestore. - return: All clients as a json object. - rtype: json - """ - limit = request.args.get("limit", None) - skip = request.args.get("skip", None) - status = request.args.get("status", None) - - return api.get_clients(limit, skip, status) + response = { + "message": "This endpoint is deprecated. Use /api/v1/clients instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -195,19 +171,10 @@ def list_clients(): @app.route("/get_active_clients", methods=["GET"]) @jwt_auth_required(role="admin") def get_active_clients(): - """Get all active clients from the statestore. - param: combiner_id: The combiner id to get active clients for. - type: combiner_id: str - return: All active clients as a json object. - rtype: json - """ - combiner_id = request.args.get("combiner", None) - if combiner_id is None: - return ( - jsonify({"success": False, "message": "Missing combiner id."}), - 400, - ) - return api.get_active_clients(combiner_id) + response = { + "message": "This endpoint is deprecated. Use /api/v1/clients instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -217,14 +184,10 @@ def get_active_clients(): @app.route("/list_combiners", methods=["GET"]) @jwt_auth_required(role="admin") def list_combiners(): - """Get all combiners in the network. - return: All combiners as a json object. - rtype: json - """ - limit = request.args.get("limit", None) - skip = request.args.get("skip", None) - - return api.get_all_combiners(limit, skip) + response = { + "message": "This endpoint is deprecated. Use /api/v1/combiners instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -234,19 +197,10 @@ def list_combiners(): @app.route("/get_combiner", methods=["GET"]) @jwt_auth_required(role="admin") def get_combiner(): - """Get a combiner from the statestore. - param: combiner_id: The combiner id to get. - type: combiner_id: str - return: The combiner as a json object. - rtype: json - """ - combiner_id = request.args.get("combiner", None) - if combiner_id is None: - return ( - jsonify({"success": False, "message": "Missing combiner id."}), - 400, - ) - return api.get_combiner(combiner_id) + response = { + "message": "This endpoint is deprecated. Use /api/v1/combiners/ instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -256,11 +210,10 @@ def get_combiner(): @app.route("/list_rounds", methods=["GET"]) @jwt_auth_required(role="admin") def list_rounds(): - """Get all rounds from the statestore. - return: All rounds as a json object. - rtype: json - """ - return api.get_all_rounds() + response = { + "message": "This endpoint is deprecated. Use /api/v1/rounds instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -270,16 +223,10 @@ def list_rounds(): @app.route("/get_round", methods=["GET"]) @jwt_auth_required(role="admin") def get_round(): - """Get a round from the statestore. - param: round_id: The round id to get. - type: round_id: str - return: The round as a json object. - rtype: json - """ - round_id = request.args.get("round_id", None) - if round_id is None: - return jsonify({"success": False, "message": "Missing round id."}), 400 - return api.get_round(round_id) + response = { + "message": "This endpoint is deprecated. Use /api/v1/rounds/ instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -289,12 +236,10 @@ def get_round(): @app.route("/start_session", methods=["GET", "POST"]) @jwt_auth_required(role="admin") def start_session(): - """Start a new session. - return: The response from control. - rtype: json - """ - json_data = request.get_json() - return api.start_session(**json_data) + response = { + "message": "This endpoint is deprecated. Use /api/v1/sessions and /api/v1/sessions/start instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -304,14 +249,10 @@ def start_session(): @app.route("/list_sessions", methods=["GET"]) @jwt_auth_required(role="admin") def list_sessions(): - """Get all sessions from the statestore. - return: All sessions as a json object. - rtype: json - """ - limit = request.args.get("limit", None) - skip = request.args.get("skip", None) - - return api.get_all_sessions(limit, skip) + response = { + "message": "This endpoint is deprecated. Use /api/v1/sessions or the GraphQL API instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -321,19 +262,10 @@ def list_sessions(): @app.route("/get_session", methods=["GET"]) @jwt_auth_required(role="admin") def get_session(): - """Get a session from the statestore. - param: session_id: The session id to get. - type: session_id: str - return: The session as a json object. - rtype: json - """ - session_id = request.args.get("session_id", None) - if session_id is None: - return ( - jsonify({"success": False, "message": "Missing session id."}), - 400, - ) - return api.get_session(session_id) + response = { + "message": "This endpoint is deprecated. Use /api/v1/sessions or the GraphQL API instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -343,8 +275,10 @@ def get_session(): @app.route("/set_active_package", methods=["PUT"]) @jwt_auth_required(role="admin") def set_active_package(): - id = request.args.get("id", None) - return api.set_active_compute_package(id) + response = { + "message": "This endpoint is deprecated. Use /api/v1/packages/active instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -354,32 +288,10 @@ def set_active_package(): @app.route("/set_package", methods=["POST"]) @jwt_auth_required(role="admin") def set_package(): - """ Set the compute package in the statestore. - Usage with curl: - curl -k -X POST \ - -F file=@package.tgz \ - -F helper="kerashelper" \ - http://localhost:8092/set_package - - param: file: The compute package file to set. - type: file: file - return: The response from the statestore. - rtype: json - """ - helper_type = request.form.get("helper", None) - name = request.form.get("name", None) - description = request.form.get("description", None) - - if helper_type is None: - return ( - jsonify({"success": False, "message": "Missing helper type."}), - 400, - ) - try: - file = request.files["file"] - except KeyError: - return jsonify({"success": False, "message": "Missing file."}), 400 - return api.set_compute_package(file=file, helper_type=helper_type, name=name, description=description) + response = { + "message": "This endpoint is deprecated. Use /api/v1/packages instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -389,11 +301,10 @@ def set_package(): @app.route("/get_package", methods=["GET"]) @jwt_auth_required(role="admin") def get_package(): - """Get the compute package from the statestore. - return: The compute package as a json object. - rtype: json - """ - return api.get_compute_package() + response = { + "message": "This endpoint is deprecated. Use /api/v1/packages/active or /api/v1/packages/ instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -403,15 +314,10 @@ def get_package(): @app.route("/list_compute_packages", methods=["GET"]) @jwt_auth_required(role="admin") def list_compute_packages(): - """Get the compute package from the statestore. - return: The compute package as a json object. - rtype: json - """ - limit = request.args.get("limit", None) - skip = request.args.get("skip", None) - include_active = request.args.get("include_active", None) - - return api.list_compute_packages(limit=limit, skip=skip, include_active=include_active) + response = { + "message": "This endpoint is deprecated. Use /api/v1/packages instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -421,12 +327,10 @@ def list_compute_packages(): @app.route("/download_package", methods=["GET"]) @jwt_auth_required(role="client") def download_package(): - """Download the compute package. - return: The compute package as a json object. - rtype: json - """ - name = request.args.get("name", None) - return api.download_compute_package(name) + response = { + "message": "This endpoint is deprecated. Use /api/v1/packages/download instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -436,8 +340,10 @@ def download_package(): @app.route("/get_package_checksum", methods=["GET"]) @jwt_auth_required(role="client") def get_package_checksum(): - name = request.args.get("name", None) - return api.get_checksum(name) + response = { + "message": "This endpoint is deprecated. Use /api/v1/packages/checksum instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -447,11 +353,10 @@ def get_package_checksum(): @app.route("/get_latest_model", methods=["GET"]) @jwt_auth_required(role="admin") def get_latest_model(): - """Get the latest model from the statestore. - return: The initial model as a json object. - rtype: json - """ - return api.get_latest_model() + response = { + "message": "This endpoint is deprecated. Use /api/v1/models/active instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -461,37 +366,23 @@ def get_latest_model(): @app.route("/set_current_model", methods=["PUT"]) @jwt_auth_required(role="admin") def set_current_model(): - """Set the initial model in the statestore and upload to model repository. - Usage with curl: - curl -k -X PUT - -F id= - http://localhost:8092/set_current_model - - param: id: The model id to set. - type: id: str - return: boolean. - rtype: json - """ - id = request.args.get("id", None) - if id is None: - return jsonify({"success": False, "message": "Missing model id."}), 400 - return api.set_current_model(id) + response = { + "message": "This endpoint is deprecated. Use /api/v1/models/active instead.", + } + return jsonify(response), 410 if custom_url_prefix: app.add_url_rule(f"{custom_url_prefix}/set_current_model", view_func=set_current_model, methods=["PUT"]) -# Get initial model endpoint - @app.route("/get_initial_model", methods=["GET"]) @jwt_auth_required(role="admin") def get_initial_model(): - """Get the initial model from the statestore. - return: The initial model as a json object. - rtype: json - """ - return api.get_initial_model() + response = { + "message": "This endpoint is deprecated. Use /api/v1/models/active instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -501,52 +392,23 @@ def get_initial_model(): @app.route("/set_initial_model", methods=["POST"]) @jwt_auth_required(role="admin") def set_initial_model(): - """Set the initial model in the statestore and upload to model repository. - Usage with curl: - curl -k -X POST - -F file=@seed.npz - http://localhost:8092/set_initial_model - - param: file: The initial model file to set. - type: file: file - return: The response from the statestore. - rtype: json - """ - try: - file = request.files["file"] - except KeyError: - return jsonify({"success": False, "message": "Missing file."}), 400 - return api.set_initial_model(file) + response = { + "message": "This endpoint is deprecated. Use /api/v1/models instead.", + } + return jsonify(response), 410 if custom_url_prefix: app.add_url_rule(f"{custom_url_prefix}/set_initial_model", view_func=set_initial_model, methods=["POST"]) -@app.route("/get_controller_status", methods=["GET"]) -@jwt_auth_required(role="admin") -def get_controller_status(): - """Get the status of the controller. - return: The status as a json object. - rtype: json - """ - return api.get_controller_status() - - -if custom_url_prefix: - app.add_url_rule(f"{custom_url_prefix}/get_controller_status", view_func=get_controller_status, methods=["GET"]) - - @app.route("/get_client_config", methods=["GET"]) @jwt_auth_required(role="admin") def get_client_config(): - """Get the client configuration. - return: The client configuration as a json object. - rtype: json - """ - checksum_arg = request.args.get("checksum", "true") - checksum = checksum_arg.lower() != "false" - return api.get_client_config(checksum) + response = { + "message": "This endpoint is deprecated. Use /api/v1/clients/config instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -556,14 +418,10 @@ def get_client_config(): @app.route("/get_events", methods=["GET"]) @jwt_auth_required(role="admin") def get_events(): - """Get the events from the statestore. - return: The events as a json object. - rtype: json - """ - # TODO: except filter with request.get_json() - kwargs = request.args.to_dict() - - return api.get_events(**kwargs) + response = { + "message": "This endpoint is deprecated. Use /api/v1/statuses instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -573,59 +431,23 @@ def get_events(): @app.route("/list_validations", methods=["GET"]) @jwt_auth_required(role="admin") def list_validations(): - """Get all validations from the statestore. - return: All validations as a json object. - rtype: json - """ - # TODO: except filter with request.get_json() - kwargs = request.args.to_dict() - return api.get_all_validations(**kwargs) + response = { + "message": "This endpoint is deprecated. Use /api/v1/validations or the GraphQL API instead.", + } + return jsonify(response), 410 if custom_url_prefix: app.add_url_rule(f"{custom_url_prefix}/list_validations", view_func=list_validations, methods=["GET"]) -@app.route("/add_combiner", methods=["POST"]) -@jwt_auth_required(role="combiner") -def add_combiner(): - """Add a combiner to the network. - return: The response from the statestore. - rtype: json - """ - json_data = request.get_json() - remote_addr = request.remote_addr - try: - response = api.add_combiner(**json_data, remote_addr=remote_addr) - except TypeError: - return jsonify({"success": False, "message": "Invalid data provided"}), 400 - except Exception: - return jsonify({"success": False, "message": "An unexpected error occurred"}), 500 - return response - - -if custom_url_prefix: - app.add_url_rule(f"{custom_url_prefix}/add_combiner", view_func=add_combiner, methods=["POST"]) - - @app.route("/add_client", methods=["POST"]) @jwt_auth_required(role="client") def add_client(): - """Add a client to the network. - return: The response from control. - rtype: json - """ - json_data = request.get_json() - remote_addr = request.remote_addr - try: - response = api.add_client(**json_data, remote_addr=remote_addr) - except TypeError as e: - print(e) - return jsonify({"success": False, "message": "Invalid data provided"}), 400 - except Exception as e: - print(e) - return jsonify({"success": False, "message": "An unexpected error occurred"}), 500 - return response + response = { + "message": "This endpoint is deprecated. Use /api/v1/clients/add instead.", + } + return jsonify(response), 410 if custom_url_prefix: @@ -635,26 +457,26 @@ def add_client(): @app.route("/list_combiners_data", methods=["POST"]) @jwt_auth_required(role="admin") def list_combiners_data(): - """List data from combiners. - return: The response from control. - rtype: json - """ - json_data = request.get_json() + response = { + "message": "This endpoint is deprecated. Use /api/v1/combiners/clients/count instead.", + } + return jsonify(response), 410 + + +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/list_combiners_data", view_func=list_combiners_data, methods=["POST"]) - # expects a list of combiner names (strings) in an array - combiners = json_data.get("combiners", None) +# not implemented - try: - response = api.list_combiners_data(combiners) - except TypeError: - return jsonify({"success": False, "message": "Invalid data provided"}), 400 - except Exception: - return jsonify({"success": False, "message": "An unexpected error occurred"}), 500 - return response + +@app.route("/delete_model_trail", methods=["GET", "POST"]) +@jwt_auth_required(role="admin") +def delete_model_trail(): + return jsonify({"message": "Not implemented"}), 501 if custom_url_prefix: - app.add_url_rule(f"{custom_url_prefix}/list_combiners_data", view_func=list_combiners_data, methods=["POST"]) + app.add_url_rule(f"{custom_url_prefix}/delete_model_trail", view_func=delete_model_trail, methods=["GET", "POST"]) def start_server_api(): diff --git a/fedn/network/api/shared.py b/fedn/network/api/shared.py index 9e0e5acbd..5cd397566 100644 --- a/fedn/network/api/shared.py +++ b/fedn/network/api/shared.py @@ -1,10 +1,109 @@ +import os + +import pymongo +from pymongo.database import Database +from werkzeug.security import safe_join + from fedn.common.config import get_modelstorage_config, get_network_config, get_statestore_config from fedn.network.controller.control import Control -from fedn.network.storage.statestore.mongostatestore import MongoStateStore +from fedn.network.storage.s3.base import RepositoryBase +from fedn.network.storage.s3.miniorepository import MINIORepository +from fedn.network.storage.s3.repository import Repository +from fedn.network.storage.statestore.stores.client_store import ClientStore, MongoDBClientStore, SQLClientStore +from fedn.network.storage.statestore.stores.combiner_store import CombinerStore, MongoDBCombinerStore, SQLCombinerStore +from fedn.network.storage.statestore.stores.model_store import MongoDBModelStore, SQLModelStore +from fedn.network.storage.statestore.stores.package_store import MongoDBPackageStore, PackageStore, SQLPackageStore +from fedn.network.storage.statestore.stores.prediction_store import MongoDBPredictionStore, PredictionStore, SQLPredictionStore +from fedn.network.storage.statestore.stores.round_store import MongoDBRoundStore, RoundStore, SQLRoundStore +from fedn.network.storage.statestore.stores.session_store import MongoDBSessionStore, SQLSessionStore +from fedn.network.storage.statestore.stores.shared import EntityNotFound +from fedn.network.storage.statestore.stores.status_store import MongoDBStatusStore, SQLStatusStore, StatusStore +from fedn.network.storage.statestore.stores.store import MyAbstractBase, engine +from fedn.network.storage.statestore.stores.validation_store import MongoDBValidationStore, SQLValidationStore, ValidationStore +from fedn.utils.checksum import sha statestore_config = get_statestore_config() modelstorage_config = get_modelstorage_config() network_id = get_network_config() -statestore = MongoStateStore(network_id, statestore_config["mongo_config"]) -statestore.set_storage_backend(modelstorage_config) -control = Control(statestore=statestore) + +client_store: ClientStore = None +validation_store: ValidationStore = None +combiner_store: CombinerStore = None +status_store: StatusStore = None +prediction_store: PredictionStore = None +round_store: RoundStore = None +package_store: PackageStore = None +model_store: SQLModelStore = None +session_store: SQLSessionStore = None + +if statestore_config["type"] == "MongoDB": + mc = pymongo.MongoClient(**statestore_config["mongo_config"]) + mc.server_info() + mdb: Database = mc[network_id] + + client_store = MongoDBClientStore(mdb, "network.clients") + validation_store = MongoDBValidationStore(mdb, "control.validations") + combiner_store = MongoDBCombinerStore(mdb, "network.combiners") + status_store = MongoDBStatusStore(mdb, "control.status") + prediction_store = MongoDBPredictionStore(mdb, "control.predictions") + round_store = MongoDBRoundStore(mdb, "control.rounds") + package_store = MongoDBPackageStore(mdb, "control.packages") + model_store = MongoDBModelStore(mdb, "control.models") + session_store = MongoDBSessionStore(mdb, "control.sessions") + +elif statestore_config["type"] in ["SQLite", "PostgreSQL"]: + MyAbstractBase.metadata.create_all(engine, checkfirst=True) + + client_store = SQLClientStore() + validation_store = SQLValidationStore() + combiner_store = SQLCombinerStore() + status_store = SQLStatusStore() + prediction_store = SQLPredictionStore() + round_store = SQLRoundStore() + package_store = SQLPackageStore() + model_store = SQLModelStore() + session_store = SQLSessionStore() +else: + raise ValueError("Unknown statestore type") + + +repository = Repository(modelstorage_config["storage_config"]) + +control = Control( + network_id=network_id, + session_store=session_store, + model_store=model_store, + round_store=round_store, + package_store=package_store, + combiner_store=combiner_store, + client_store=client_store, + model_repository=repository, +) + +# TODO: use Repository +minio_repository: RepositoryBase = None + +if modelstorage_config["storage_type"] == "S3": + minio_repository = MINIORepository(modelstorage_config["storage_config"]) + + +def get_checksum(name: str = None): + message = None + sum = None + success = False + + if name is None: + try: + active_package = package_store.get_active() + name = active_package["storage_file_name"] + except EntityNotFound: + message = "No compute package uploaded" + return success, message, sum + file_path = safe_join(os.getcwd(), name) + try: + sum = str(sha(file_path)) + success = True + message = "Checksum created." + except FileNotFoundError: + message = "File not found." + return success, message, sum diff --git a/fedn/network/api/tests.py b/fedn/network/api/tests.py index fbd4f4972..284c9d008 100644 --- a/fedn/network/api/tests.py +++ b/fedn/network/api/tests.py @@ -24,337 +24,40 @@ # python3 -m unittest fedn.tests.network.api.tests.NetworkAPITests.test_get_model_trail # -import io -import time import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import patch -import fedn +import fedn # noqa: F401 class NetworkAPITests(unittest.TestCase): """ Unittests for the Network API. """ - @patch('fedn.network.statestore.mongostatestore.MongoStateStore', autospec=True) @patch('fedn.network.controller.controlbase.ControlBase', autospec=True) - def setUp(self, mock_mongo, mock_control): + def setUp(self, mock_control): # start Flask server in testing mode import fedn.network.api.server self.app = fedn.network.api.server.app.test_client() - def test_get_model_trail(self): - """ Test get_model_trail endpoint. """ - # Mock api.get_model_trail - model_id = "test" - time_stamp = time.time() - return_value = {model_id: time_stamp} - fedn.network.api.server.api.get_model_trail = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_model_trail') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_model_trail was called - fedn.network.api.server.api.get_model_trail.assert_called_once_with() - - def test_get_latest_model(self): - """ Test get_latest_model endpoint. """ - # Mock api.get_latest_model - model_id = "test" - time_stamp = time.time() - return_value = {model_id: time_stamp} - fedn.network.api.server.api.get_latest_model = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_latest_model') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_latest_model was called - fedn.network.api.server.api.get_latest_model.assert_called_once_with() - - def test_get_initial_model(self): - """ Test get_initial_model endpoint. """ - # Mock api.get_initial_model - model_id = "test" - time_stamp = time.time() - return_value = {model_id: time_stamp} - fedn.network.api.server.api.get_initial_model = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_initial_model') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_initial_model was called - fedn.network.api.server.api.get_initial_model.assert_called_once_with() - - def test_set_initial_model(self): - """ Test set_initial_model endpoint. """ - # Mock api.set_initial_model - success = True - message = "test" - return_value = {'success': success, 'message': message} - fedn.network.api.server.api.set_initial_model = MagicMock(return_value=return_value) - # Create test file - request_file = (io.BytesIO(b"abcdef"), 'test.txt') - # Make request - response = self.app.post('/set_initial_model', data={"file": request_file}) - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.set_initial_model was called - fedn.network.api.server.api.set_initial_model.assert_called_once() - - def test_list_clients(self): - """ Test list_clients endpoint. """ - # Mock api.get_all_clients - return_value = {"test": "test"} - fedn.network.api.server.api.get_all_clients = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/list_clients') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_all_clients was called - fedn.network.api.server.api.get_all_clients.assert_called_once_with() - - def test_get_active_clients(self): - """ Test get_active_clients endpoint. """ - # Mock api.get_active_clients - return_value = {"test": "test"} - fedn.network.api.server.api.get_active_clients = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_active_clients?combiner=test') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_active_clients was called - fedn.network.api.server.api.get_active_clients.assert_called_once_with("test") - - def test_add_client(self): - """ Test add_client endpoint. """ - # Mock api.add_client - return_value = {"test": "test"} - fedn.network.api.server.api.add_client = MagicMock(return_value=return_value) - # Make request - response = self.app.post('/add_client', json={ - 'preferred_combiner': 'test', - }) - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.add_client was called - fedn.network.api.server.api.add_client.assert_called_once_with( - preferred_combiner="test", - remote_addr='127.0.0.1' - ) - - def test_list_combiners(self): - """ Test list_combiners endpoint. """ - # Mock api.get_all_combiners - return_value = {"test": "test"} - fedn.network.api.server.api.get_all_combiners = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/list_combiners') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_all_combiners was called - fedn.network.api.server.api.get_all_combiners.assert_called_once_with() - - def test_list_compute_packages(self): - """ Test list_compute_packages endpoint. """ - # Mock api.list_compute_packages - return_value = {"test": "test"} - fedn.network.api.server.api.list_compute_packages = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/list_combiners') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.list_compute_packages was called - fedn.network.api.server.api.list_compute_packages.assert_called_once_with() - - def test_list_rounds(self): - """ Test list_rounds endpoint. """ - # Mock api.get_all_rounds - return_value = {"test": "test"} - fedn.network.api.server.api.get_all_rounds = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/list_rounds') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_all_rounds was called - fedn.network.api.server.api.get_all_rounds.assert_called_once_with() - - def test_get_round(self): - """ Test get_round endpoint. """ - # Mock api.get_round - return_value = {"test": "test"} - fedn.network.api.server.api.get_round = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_round?round_id=test') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_round was called - fedn.network.api.server.api.get_round.assert_called_once_with("test") - def test_get_combiner(self): - """ Test get_combiner endpoint. """ - # Mock api.get_combiner - return_value = {"test": "test"} - fedn.network.api.server.api.get_combiner = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_combiner?combiner=test') + def test_health(self): + """ Test get_models endpoint. """ + response = self.app.get('/health') # Assert response self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_combiner was called - fedn.network.api.server.api.get_combiner.assert_called_once_with("test") def test_add_combiner(self): - """ Test add_combiner endpoint. """ - # Mock api.add_combiner - success = True - message = "test" - return_value = {'success': success, 'message': message} - fedn.network.api.server.api.add_combiner = MagicMock(return_value=return_value) - # Make request - response = self.app.post('/add_combiner', json={ - 'combiner_id': 'test', - 'address': '1234', - 'port': '1234', - 'secure_grpc': 'True', - 'fqdn': 'test', - }) - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.add_combiner was called - fedn.network.api.server.api.add_combiner.assert_called_once_with( - combiner_id='test', - remote_addr='127.0.0.1', - address='1234', - port='1234', - secure_grpc='True', - fqdn='test', - ) - - def test_get_events(self): - """ Test get_events endpoint. """ - # Mock api.get_events - return_value = {"test": "test"} - fedn.network.api.server.api.get_events = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_events') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_events was called - fedn.network.api.server.api.get_events.assert_called_once() - - def test_get_status(self): - """ Test get_status endpoint. """ - # Mock api.get_status - return_value = {"test": "test"} - fedn.network.api.server.api.get_controller_status = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_controller_status') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_status was called - fedn.network.api.server.api.get_controller_status.assert_called_once() - - def test_start_session(self): - """ Test start_session endpoint. """ - # Mock api.start_session - success = True - message = "test" - return_value = {'success': success, 'message': message} - fedn.network.api.server.api.start_session = MagicMock(return_value=return_value) - # Make request with only session_id - json = {'session_id': 'test', - 'round_timeout': float(60), - 'rounds': 1, - 'round_buffer_size': -1, - } - response = self.app.post('/start_session', json=json) - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.start_session was called - fedn.network.api.server.api.start_session.assert_called_once_with( - session_id='test', - round_timeout=float(60), - rounds=1, - round_buffer_size=-1, - ) - - def test_list_sessions(self): - """ Test list_sessions endpoint. """ - # Mock api.list_sessions - return_value = {"test": "test"} - fedn.network.api.server.api.get_all_sessions = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/list_sessions') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.list_sessions was called - fedn.network.api.server.api.get_all_sessions.assert_called_once() - - def test_list_models(self): - """ Test list_models endpoint. """ - # Mock api.list_models - return_value = {"test": "test"} - fedn.network.api.server.api.get_models = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/list_models') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.list_models was called - fedn.network.api.server.api.get_models.assert_called_once() - - def test_get_package(self): - """ Test get_package endpoint. """ - # Mock api.get_package - return_value = {"test": "test"} - fedn.network.api.server.api.get_compute_package = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_package') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_package was called - fedn.network.api.server.api.get_compute_package.assert_called_once_with() + """ Test get_models endpoint. """ + # Mock api.get_models + + response = self.app.post('/add_combiner') + + self.assertEqual(response.status_code, 410) def test_get_controller_status(self): - """ Test get_controller_status endpoint. """ - # Mock api.get_controller_status - return_value = {"test": "test"} - fedn.network.api.server.api.get_controller_status = MagicMock(return_value=return_value) - # Make request + """ Test get_models endpoint. """ response = self.app.get('/get_controller_status') # Assert response self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_controller_status was called - fedn.network.api.server.api.get_controller_status.assert_called_once_with() - - def test_get_client_config(self): - """ Test get_client_config endpoint. """ - # Mock api.get_client_config - return_value = {"test": "test"} - fedn.network.api.server.api.get_client_config = MagicMock(return_value=return_value) - # Make request - response = self.app.get('/get_client_config') - # Assert response - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, return_value) - # Assert api.get_client_config was called - fedn.network.api.server.api.get_client_config.assert_called_once_with(True) if __name__ == '__main__': diff --git a/fedn/network/api/v1/client_routes.py b/fedn/network/api/v1/client_routes.py index e1eb7ef5a..a3d9973f8 100644 --- a/fedn/network/api/v1/client_routes.py +++ b/fedn/network/api/v1/client_routes.py @@ -1,7 +1,9 @@ from flask import Blueprint, jsonify, request +from fedn.common.config import get_controller_config, get_network_config from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, client_store, get_post_data_to_kwargs, get_typed_list_headers +from fedn.network.api.shared import client_store, control, get_checksum, package_store +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("client", __name__, url_prefix=f"/api/{api_version}/clients") @@ -399,3 +401,159 @@ def delete_client(id: str): return jsonify({"message": f"Entity with id: {id} not found"}), 404 except Exception: return jsonify({"message": "An unexpected error occurred"}), 500 + + +@bp.route("/add", methods=["POST"]) +@jwt_auth_required(role="admin") +def add_client(): + """Add client + Adds a client to the network. + --- + tags: + - Clients + parameters: + - name: client + in: body + required: true + type: object + description: Object containing the parameters to create the client + schema: + type: object + properties: + name: + type: string + combiner: + type: string + combiner_preferred: + type: string + ip: + type: string + status: + type: string + responses: + 200: + description: The client was added + 500: + description: An error occurred + schema: + type: object + properties: + message: + type: string + """ + try: + json_data = request.get_json() + remote_addr = request.remote_addr + + client_id = json_data.get("client_id", None) + name = json_data.get("name", None) + preferred_combiner = json_data.get("combiner_preferred", None) + package = json_data.get("package", "local") + helper_type: str = "" + + if package == "remote": + try: + package_object = package_store.get_active() + except EntityNotFound: + return jsonify( + { + "success": False, + "status": "retry", + "message": "No compute package found. Set package in controller.", + } + ), 203 + helper_type = package_object["helper"] + else: + helper_type = "" + + if preferred_combiner: + combiner = control.network.get_combiner(preferred_combiner) + if combiner is None: + return jsonify( + { + "success": False, + "message": f"Combiner {preferred_combiner} not found or unavailable.", + }, + 400, + ) + else: + combiner = control.network.find_available_combiner() + if combiner is None: + return jsonify({"success": False, "message": "No combiner available."}), 400 + + client_config = { + "client_id": client_id, + "name": name, + "combiner_preferred": preferred_combiner, + "combiner": combiner.name, + "ip": remote_addr, + "status": "available", + "package": package, + } + + control.network.add_client(client_config) + + payload = { + "status": "assigned", + "host": combiner.address, + "fqdn": combiner.fqdn, + "package": package, + "ip": combiner.ip, + "port": combiner.port, + "helper_type": helper_type, + } + return jsonify(payload), 200 + except Exception: + return jsonify({"success": False, "message": "An unexpected error occurred"}), 500 + + +@bp.route("/config", methods=["GET"]) +@jwt_auth_required(role="admin") +def get_client_config(): + """Get client config + Retrieves the client configuration. + --- + tags: + - Clients + responses: + 200: + description: The client configuration + schema: + type: object + properties: + network_id: + type: string + discover_host: + type: string + discover_port: + type: integer + 500: + description: An error occurred + schema: + type: object + properties: + message: + type: string + """ + try: + checksum_arg = request.args.get("checksum", "true") + include_checksum = checksum_arg.lower() == "true" + + config = get_controller_config() + network_id = get_network_config() + port = config["port"] + host = config["host"] + payload = { + "network_id": network_id, + "discover_host": host, + "discover_port": port, + } + + if include_checksum: + success, _, checksum_str = get_checksum() + if success: + payload["checksum"] = checksum_str + + return jsonify(payload), 200 + except Exception: + return jsonify({"message": "An unexpected error occurred"}), 500 diff --git a/fedn/network/api/v1/combiner_routes.py b/fedn/network/api/v1/combiner_routes.py index ce012645e..0bd4b545e 100644 --- a/fedn/network/api/v1/combiner_routes.py +++ b/fedn/network/api/v1/combiner_routes.py @@ -1,7 +1,8 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, client_store, combiner_store, get_post_data_to_kwargs, get_typed_list_headers +from fedn.network.api.shared import client_store, combiner_store +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("combiner", __name__, url_prefix=f"/api/{api_version}/combiners") diff --git a/fedn/network/api/v1/graphql/schema.py b/fedn/network/api/v1/graphql/schema.py index 0436c73ea..23871067b 100644 --- a/fedn/network/api/v1/graphql/schema.py +++ b/fedn/network/api/v1/graphql/schema.py @@ -1,7 +1,7 @@ import graphene import pymongo -from fedn.network.api.v1.shared import model_store, session_store, status_store, validation_store +from fedn.network.api.shared import model_store, session_store, status_store, validation_store class ActorType(graphene.ObjectType): diff --git a/fedn/network/api/v1/helper_routes.py b/fedn/network/api/v1/helper_routes.py index 03cfed7bb..5a8f7e357 100644 --- a/fedn/network/api/v1/helper_routes.py +++ b/fedn/network/api/v1/helper_routes.py @@ -1,13 +1,13 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, package_store +from fedn.network.api.shared import package_store +from fedn.network.api.v1.shared import api_version from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("helper", __name__, url_prefix=f"/api/{api_version}/helpers") - @bp.route("/active", methods=["GET"]) @jwt_auth_required(role="admin") def get_active_helper(): @@ -25,7 +25,6 @@ def get_active_helper(): description: An unexpected error occurred """ try: - active_package = package_store.get_active() response = active_package["helper"] @@ -36,6 +35,7 @@ def get_active_helper(): except Exception: return jsonify({"message": "An unexpected error occurred"}), 500 + @bp.route("/active", methods=["PUT"]) @jwt_auth_required(role="admin") def set_active_helper(): diff --git a/fedn/network/api/v1/model_routes.py b/fedn/network/api/v1/model_routes.py index 76e854494..b943c2364 100644 --- a/fedn/network/api/v1/model_routes.py +++ b/fedn/network/api/v1/model_routes.py @@ -1,11 +1,13 @@ import io +from io import BytesIO import numpy as np from flask import Blueprint, jsonify, request, send_file +from fedn.common.log_config import logger from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.shared import modelstorage_config -from fedn.network.api.v1.shared import api_version, get_limit, get_post_data_to_kwargs, get_reverse, get_typed_list_headers, minio_repository, model_store +from fedn.network.api.shared import control, minio_repository, model_store, modelstorage_config +from fedn.network.api.v1.shared import api_version, get_limit, get_post_data_to_kwargs, get_reverse, get_typed_list_headers from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("model", __name__, url_prefix=f"/api/{api_version}/models") @@ -766,3 +768,69 @@ def set_active_model(): return jsonify({"message": "Failed to set active model"}), 500 except Exception: return jsonify({"message": "An unexpected error occurred"}), 500 + + +@bp.route("/", methods=["POST"]) +@jwt_auth_required(role="admin") +def upload_model(): + """Upload model + Uploads a model to the storage backend. + --- + tags: + - Models + parameters: + - name: model + in: body + required: true + type: object + description: The model data to upload + responses: + 200: + description: The uploaded model + schema: + $ref: '#/definitions/Model' + 500: + description: An error occurred + schema: + type: object + properties: + message: + type: string + """ + try: + data = request.form.to_dict() + file = request.files["file"] + name: str = data.get("name", None) + + try: + object = BytesIO() + object.seek(0, 0) + file.seek(0) + object.write(file.read()) + helper = control.get_helper() + logger.info(f"Loading model from file using helper {helper.name}") + object.seek(0) + model = helper.load(object) + control.commit(model_id=None, model=model, name=name) + except Exception as e: + logger.error("Error occured during model loading") + logger.debug(e) + status_code = 400 + return ( + jsonify( + { + "success": False, + "message": "Failed to add model.", + } + ), + status_code, + ) + + return jsonify( + { + "success": True, + "message": "Model added successfully", + } + ), 200 + except Exception: + return jsonify({"message": "An unexpected error occurred"}), 500 diff --git a/fedn/network/api/v1/package_routes.py b/fedn/network/api/v1/package_routes.py index 4ed138369..a115549d7 100644 --- a/fedn/network/api/v1/package_routes.py +++ b/fedn/network/api/v1/package_routes.py @@ -1,11 +1,15 @@ import os +import threading -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, send_from_directory from werkzeug.security import safe_join from fedn.common.config import FEDN_COMPUTE_PACKAGE_DIR from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, package_store, repository +from fedn.network.api.shared import control +from fedn.network.api.shared import get_checksum as _get_checksum +from fedn.network.api.shared import package_store, repository +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("package", __name__, url_prefix=f"/api/{api_version}/packages") @@ -566,3 +570,81 @@ def upload_package(): return jsonify({"message": "Package uploaded"}), 200 except Exception: return jsonify({"message": "An unexpected error occurred"}), 500 + + +@bp.route("/download", methods=["GET"]) +@jwt_auth_required(role="admin") +def download_package(): + """Download package + Downloads a package based on the provided id. + --- + tags: + - Packages + parameters: + - name: name + in: query + required: false + type: string + description: The name of the package + + responses: + 200: + description: The package file + schema: + type: object + properties: + message: + type: string + 404: + description: The package was not found + schema: + type: object + properties: + message: + type: string + 500: + description: An error occurred + schema: + type: object + properties: + message: + type: string + """ + name = request.args.get("name", None) + + if name is None: + try: + active_package = package_store.get_active() + name = active_package["storage_file_name"] + except EntityNotFound: + return jsonify({"message": "No active package"}), 404 + + try: + mutex = threading.Lock() + mutex.acquire() + + return send_from_directory(FEDN_COMPUTE_PACKAGE_DIR, name, as_attachment=True) + except Exception: + try: + data = control.get_compute_package(name) + # TODO: make configurable, perhaps in config.py or package.py + file_path = safe_join(FEDN_COMPUTE_PACKAGE_DIR, name) + with open(file_path, "wb") as fh: + fh.write(data) + # TODO: make configurable, perhaps in config.py or package.py + return send_from_directory(FEDN_COMPUTE_PACKAGE_DIR, name, as_attachment=True) + except Exception: + raise + finally: + mutex.release() + + +@bp.route("/checksum", methods=["GET"]) +@jwt_auth_required(role="client") +def get_checksum(): + name = request.args.get("name", None) + + success, message, sum = _get_checksum(name) + if success: + return jsonify({"message": message, "checksum": sum}), 200 + return jsonify({"message": message, "checksum": sum}), 404 diff --git a/fedn/network/api/v1/prediction_routes.py b/fedn/network/api/v1/prediction_routes.py index 0ea34224a..f67617ea6 100644 --- a/fedn/network/api/v1/prediction_routes.py +++ b/fedn/network/api/v1/prediction_routes.py @@ -3,17 +3,12 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.shared import control -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, mdb -from fedn.network.storage.statestore.stores.model_store import ModelStore -from fedn.network.storage.statestore.stores.prediction_store import PredictionStore +from fedn.network.api.shared import control, model_store, prediction_store +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("prediction", __name__, url_prefix=f"/api/{api_version}/predict") -prediction_store = PredictionStore(mdb, "control.predictions") -model_store = ModelStore(mdb, "control.model") - @bp.route("/start", methods=["POST"]) @jwt_auth_required(role="admin") diff --git a/fedn/network/api/v1/round_routes.py b/fedn/network/api/v1/round_routes.py index 2c0f6cc9a..052cb7b93 100644 --- a/fedn/network/api/v1/round_routes.py +++ b/fedn/network/api/v1/round_routes.py @@ -1,7 +1,8 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, round_store +from fedn.network.api.shared import round_store +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("round", __name__, url_prefix=f"/api/{api_version}/rounds") diff --git a/fedn/network/api/v1/session_routes.py b/fedn/network/api/v1/session_routes.py index 52c68fb63..bf69d3bbd 100644 --- a/fedn/network/api/v1/session_routes.py +++ b/fedn/network/api/v1/session_routes.py @@ -3,8 +3,8 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.shared import control -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, model_store, session_store +from fedn.network.api.shared import control, model_store, session_store +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers from fedn.network.combiner.interfaces import CombinerUnavailableError from fedn.network.state import ReducerState from fedn.network.storage.statestore.stores.shared import EntityNotFound diff --git a/fedn/network/api/v1/shared.py b/fedn/network/api/v1/shared.py index 0f6267b38..844f31d03 100644 --- a/fedn/network/api/v1/shared.py +++ b/fedn/network/api/v1/shared.py @@ -1,49 +1,8 @@ from typing import Tuple import pymongo -from pymongo.database import Database - -from fedn.network.api.shared import modelstorage_config, network_id, statestore_config -from fedn.network.storage.s3.base import RepositoryBase -from fedn.network.storage.s3.miniorepository import MINIORepository -from fedn.network.storage.s3.repository import Repository -from fedn.network.storage.statestore.stores.client_store import ClientStore -from fedn.network.storage.statestore.stores.combiner_store import CombinerStore -from fedn.network.storage.statestore.stores.model_store import ModelStore -from fedn.network.storage.statestore.stores.package_store import PackageStore -from fedn.network.storage.statestore.stores.round_store import RoundStore -from fedn.network.storage.statestore.stores.session_store import SessionStore -from fedn.network.storage.statestore.stores.status_store import StatusStore -from fedn.network.storage.statestore.stores.validation_store import ValidationStore api_version = "v1" -mc = pymongo.MongoClient(**statestore_config["mongo_config"]) -mc.server_info() -mdb: Database = mc[network_id] - -client_store = ClientStore(mdb, "network.clients") -package_store = PackageStore(mdb, "control.package") -session_store = SessionStore(mdb, "control.sessions") -model_store = ModelStore(mdb, "control.model") -combiner_store = CombinerStore(mdb, "network.combiners") -round_store = RoundStore(mdb, "control.rounds") -status_store = StatusStore(mdb, "control.status") -validation_store = ValidationStore(mdb, "control.validations") - -minio_repository: RepositoryBase = None - -if modelstorage_config["storage_type"] == "S3": - minio_repository = MINIORepository(modelstorage_config["storage_config"]) - - -storage_collection = mdb["network.storage"] - -storage_config = storage_collection.find_one({"status": "enabled"}, projection={"_id": False}) - -repository: RepositoryBase = None - -if storage_config["storage_type"] == "S3": - repository = Repository(storage_config["storage_config"]) def is_positive_integer(s): diff --git a/fedn/network/api/v1/status_routes.py b/fedn/network/api/v1/status_routes.py index 0cb1f8194..712863106 100644 --- a/fedn/network/api/v1/status_routes.py +++ b/fedn/network/api/v1/status_routes.py @@ -1,7 +1,8 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, status_store +from fedn.network.api.shared import status_store +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("status", __name__, url_prefix=f"/api/{api_version}/statuses") diff --git a/fedn/network/api/v1/validation_routes.py b/fedn/network/api/v1/validation_routes.py index 8fd5f2bb7..f0f349097 100644 --- a/fedn/network/api/v1/validation_routes.py +++ b/fedn/network/api/v1/validation_routes.py @@ -1,7 +1,8 @@ from flask import Blueprint, jsonify, request from fedn.network.api.auth import jwt_auth_required -from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers, validation_store +from fedn.network.api.shared import validation_store +from fedn.network.api.v1.shared import api_version, get_post_data_to_kwargs, get_typed_list_headers from fedn.network.storage.statestore.stores.shared import EntityNotFound bp = Blueprint("validation", __name__, url_prefix=f"/api/{api_version}/validations") diff --git a/fedn/network/clients/client.py b/fedn/network/clients/client.py deleted file mode 100644 index d935ac588..000000000 --- a/fedn/network/clients/client.py +++ /dev/null @@ -1,859 +0,0 @@ -import io -import json -import os -import queue -import re -import sys -import threading -import time -import uuid -from datetime import datetime -from io import BytesIO - -import grpc -import requests -from google.protobuf.json_format import MessageToJson -from tenacity import retry, stop_after_attempt - -import fedn.network.grpc.fedn_pb2 as fedn -import fedn.network.grpc.fedn_pb2_grpc as rpc -from fedn.common.config import FEDN_AUTH_SCHEME, FEDN_PACKAGE_EXTRACT_DIR -from fedn.common.log_config import logger, set_log_level_from_string, set_log_stream -from fedn.network.clients.connect import ConnectorClient, Status -from fedn.network.clients.package import PackageRuntime -from fedn.network.clients.state import ClientState, ClientStateToString -from fedn.network.combiner.modelservice import get_tmp_path, upload_request_generator -from fedn.utils.helpers.helpers import get_helper, load_metadata, save_metadata - -CHUNK_SIZE = 1024 * 1024 -VALID_NAME_REGEX = "^[a-zA-Z0-9_-]*$" - - -class GrpcAuth(grpc.AuthMetadataPlugin): - def __init__(self, key): - self._key = key - - def __call__(self, context, callback): - callback((("authorization", f"{FEDN_AUTH_SCHEME} {self._key}"),), None) - - -class Client: - """FEDn Client. Service running on client/datanodes in a federation, - recieving and handling model update and model validation requests. - - :param config: A configuration dictionary containing connection information for the discovery service (controller) - and settings governing e.g. client-combiner assignment behavior. - :type config: dict - """ - - def __init__(self, config): - """Initialize the client.""" - self.state = None - self.error_state = False - self._connected = False - self._missed_heartbeat = 0 - self.config = config - self.trace_attribs = False - set_log_level_from_string(config.get("verbosity", "INFO")) - set_log_stream(config.get("logfile", None)) - - self.id = config["client_id"] or str(uuid.uuid4()) - - # Validate client name - match = re.search(VALID_NAME_REGEX, config["name"]) - if not match: - raise ValueError("Unallowed character in client name. Allowed characters: a-z, A-Z, 0-9, _, -.") - - # Folder where the client will store downloaded compute package and logs - self.name = config["name"] - if FEDN_PACKAGE_EXTRACT_DIR: - self.run_path = os.path.join(os.getcwd(), FEDN_PACKAGE_EXTRACT_DIR) - else: - dirname = self.name + "-" + time.strftime("%Y%m%d-%H%M%S") - self.run_path = os.path.join(os.getcwd(), dirname) - if not os.path.exists(self.run_path): - os.mkdir(self.run_path) - - self.started_at = datetime.now() - self.logs = [] - - self.inbox = queue.Queue() - - # Attach to the FEDn network (get combiner or attach directly) - if config["combiner"]: - combiner_config = {"status": "assigned", "host": config["combiner"], "port": config["combiner_port"], "helper_type": ""} - if config["proxy_server"]: - combiner_config["fqdn"] = config["proxy_server"] - else: - self.connector = ConnectorClient( - host=config["discover_host"], - port=config["discover_port"], - token=config["token"], - name=config["name"], - remote_package=config["remote_compute_context"], - force_ssl=config["force_ssl"], - verify=config["verify"], - combiner=config["preferred_combiner"], - id=self.id, - ) - combiner_config = self.assign() - self.connect(combiner_config) - - self._initialize_dispatcher(self.config) - - self._initialize_helper(combiner_config) - if not self.helper: - logger.warning("Failed to retrieve helper class settings: {}".format(combiner_config)) - - self._subscribe_to_combiner(self.config) - - self.state = ClientState.idle - - def assign(self): - """Contacts the controller and asks for combiner assignment. - - :return: A configuration dictionary containing connection information for combiner. - :rtype: dict - """ - logger.info("Initiating assignment request.") - while True: - status, response = self.connector.assign() - if status == Status.TryAgain: - logger.warning(response) - logger.info("Assignment request failed. Retrying in 5 seconds.") - time.sleep(5) - continue - if status == Status.Assigned: - combiner_config = response - break - if status == Status.UnAuthorized: - logger.critical(response) - sys.exit("Exiting: Unauthorized") - if status == Status.UnMatchedConfig: - logger.critical(response) - sys.exit("Exiting: UnMatchedConfig") - time.sleep(5) - logger.info("Assignment successfully received.") - logger.info("Received combiner configuration: {}".format(combiner_config)) - return combiner_config - - def _add_grpc_metadata(self, key, value): - """Add metadata for gRPC calls. - - :param key: The key of the metadata. - :type key: str - :param value: The value of the metadata. - :type value: str - """ - # Check if metadata exists and add if not - if not hasattr(self, "metadata"): - self.metadata = () - - # Check if metadata key already exists and replace value if so - for i, (k, v) in enumerate(self.metadata): - if k == key: - # Replace value - self.metadata = self.metadata[:i] + ((key, value),) + self.metadata[i + 1 :] - return - - # Set metadata using tuple concatenation - self.metadata += ((key, value),) - - def connect(self, combiner_config): - """Connect to combiner. - - :param combiner_config: connection information for the combiner. - :type combiner_config: dict - """ - if self._connected: - logger.info("Client is already attached. ") - return - - # TODO use the combiner_config['certificate'] for setting up secure comms' - host = combiner_config["host"] - # Add host to gRPC metadata - self._add_grpc_metadata("grpc-server", host) - logger.debug("Client using metadata: {}.".format(self.metadata)) - port = combiner_config["port"] - secure = False - if "fqdn" in combiner_config.keys() and combiner_config["fqdn"] is not None: - host = combiner_config["fqdn"] - # assuming https if fqdn is used - port = 443 - logger.info(f"Initiating connection to combiner host at: {host}:{port}") - - if os.getenv("FEDN_GRPC_ROOT_CERT_PATH"): - secure = True - logger.info("Using root certificate from environment variable for GRPC channel.") - with open(os.environ["FEDN_GRPC_ROOT_CERT_PATH"], "rb") as f: - credentials = grpc.ssl_channel_credentials(f.read()) - channel = grpc.secure_channel("{}:{}".format(host, str(port)), credentials) - elif self.config["secure"]: - secure = True - logger.info("Using default location for root certificates.") - credentials = grpc.ssl_channel_credentials() - if self.config["token"]: - token = self.config["token"] - auth_creds = grpc.metadata_call_credentials(GrpcAuth(token)) - channel = grpc.secure_channel("{}:{}".format(host, str(port)), grpc.composite_channel_credentials(credentials, auth_creds)) - else: - channel = grpc.secure_channel("{}:{}".format(host, str(port)), credentials) - else: - logger.info("Using insecure GRPC channel.") - if port == 443: - port = 80 - channel = grpc.insecure_channel("{}:{}".format(host, str(port))) - - self.channel = channel - - self.connectorStub = rpc.ConnectorStub(channel) - self.combinerStub = rpc.CombinerStub(channel) - self.modelStub = rpc.ModelServiceStub(channel) - - logger.info("Successfully established {} connection to {}:{}".format("secure" if secure else "insecure", host, port)) - - self._connected = True - - def disconnect(self): - """Disconnect from the combiner.""" - if not self._connected: - logger.info("Client is not connected.") - - self.channel.close() - self._connected = False - logger.info("Client {} disconnected.".format(self.name)) - - def _initialize_helper(self, combiner_config): - """Initialize the helper class for the client. - - :param combiner_config: A configuration dictionary containing connection information for - | the discovery service (controller) and settings governing e.g. - | client-combiner assignment behavior. - :type combiner_config: dict - :return: - """ - if "helper_type" in combiner_config.keys(): - if not combiner_config["helper_type"]: - # Default to numpyhelper - self.helper = get_helper("numpyhelper") - else: - self.helper = get_helper(combiner_config["helper_type"]) - - def _subscribe_to_combiner(self, config): - """Listen to combiner message stream and start all processing threads. - - :param config: A configuration dictionary containing connection information for - | the discovery service (controller) and settings governing e.g. - | client-combiner assignment behavior. - """ - # Start sending heartbeats to the combiner. - threading.Thread(target=self._send_heartbeat, kwargs={"update_frequency": config["heartbeat_interval"]}, daemon=True).start() - - # Start listening for combiner training and validation messages - threading.Thread(target=self._listen_to_task_stream, daemon=True).start() - self._connected = True - - # Start processing the client message inbox - threading.Thread(target=self.process_request, daemon=True).start() - - @retry(stop=stop_after_attempt(3)) - def untar_package(self, package_runtime): - _, package_runpath = package_runtime.unpack() - return package_runpath - - def _initialize_dispatcher(self, config): - """Initialize the dispatcher for the client. - - :param config: A configuration dictionary containing connection information for - | the discovery service (controller) and settings governing e.g. - | client-combiner assignment behavior. - :type config: dict - :return: - """ - pr = PackageRuntime(self.run_path) - if config["remote_compute_context"]: - retval = None - tries = 10 - - while tries > 0: - retval = pr.download( - host=config["discover_host"], port=config["discover_port"], token=config["token"], force_ssl=config["force_ssl"], secure=config["secure"] - ) - if retval: - break - time.sleep(60) - logger.warning("Compute package not available. Retrying in 60 seconds. {} attempts remaining.".format(tries)) - tries -= 1 - - if retval: - if "checksum" not in config: - logger.warning("Bypassing validation of package checksum. Ensure the package source is trusted.") - else: - checks_out = pr.validate(config["checksum"]) - if not checks_out: - logger.critical("Validation of local package failed. Client terminating.") - self.error_state = True - return - package_runpath = "" - if retval: - package_runpath = self.untar_package(pr) - - self.dispatcher = pr.dispatcher(package_runpath) - try: - logger.info("Initiating Dispatcher with entrypoint set to: startup") - activate_cmd = self.dispatcher._get_or_create_python_env() - self.dispatcher.run_cmd("startup") - except KeyError: - logger.info("No startup command found in package. Continuing.") - pass - except Exception as e: - logger.error(f"Caught exception: {type(e).__name__}") - - else: - from_path = os.path.join(os.getcwd(), "client") - self.dispatcher = pr.dispatcher(from_path) - # Get or create python environment - activate_cmd = self.dispatcher._get_or_create_python_env() - if activate_cmd: - logger.info("To activate the virtual environment, run: {}".format(activate_cmd)) - - def get_model_from_combiner(self, id, timeout=20): - """Fetch a model from the assigned combiner. - Downloads the model update object via a gRPC streaming channel. - - :param id: The id of the model update object. - :type id: str - :return: The model update object. - :rtype: BytesIO - """ - data = BytesIO() - time_start = time.time() - request = fedn.ModelRequest(id=id) - request.sender.name = self.name - request.sender.role = fedn.CLIENT - - try: - for part in self.modelStub.Download(request, metadata=self.metadata): - if part.status == fedn.ModelStatus.IN_PROGRESS: - data.write(part.data) - - if part.status == fedn.ModelStatus.OK: - return data - - if part.status == fedn.ModelStatus.FAILED: - return None - - if part.status == fedn.ModelStatus.UNKNOWN: - if time.time() - time_start >= timeout: - return None - continue - except grpc.RpcError as e: - logger.critical(f"GRPC: An error occurred during model download: {e}") - - return data - - def send_model_to_combiner(self, model, id): - """Send a model update to the assigned combiner. - Uploads the model updated object via a gRPC streaming channel, Upload. - - :param model: The model update object. - :type model: BytesIO - :param id: The id of the model update object. - :type id: str - :return: The model update object. - :rtype: BytesIO - """ - if not isinstance(model, BytesIO): - bt = BytesIO() - - for d in model.stream(32 * 1024): - bt.write(d) - else: - bt = model - - bt.seek(0, 0) - - try: - result = self.modelStub.Upload(upload_request_generator(bt, id), metadata=self.metadata) - except grpc.RpcError as e: - logger.critical(f"GRPC: An error occurred during model upload: {e}") - - return result - - def _listen_to_task_stream(self): - """Subscribe to the model update request stream. - - :return: None - :rtype: None - """ - r = fedn.ClientAvailableMessage() - r.sender.name = self.name - r.sender.role = fedn.CLIENT - r.sender.client_id = self.id - # Add client to metadata - self._add_grpc_metadata("client", self.name) - status_code = None - - while self._connected: - try: - if status_code == grpc.StatusCode.UNAVAILABLE: - logger.info("GRPC TaskStream: server available again.") - status_code = None - for request in self.combinerStub.TaskStream(r, metadata=self.metadata): - if request: - logger.debug("Received model update request from combiner: {}.".format(request)) - if request.sender.role == fedn.COMBINER: - # Process training request - self.send_status( - "Received model update request.", - log_level=fedn.LogLevel.AUDIT, - type=fedn.StatusType.MODEL_UPDATE_REQUEST, - request=request, - sesssion_id=request.session_id, - ) - logger.info("Received task request of type {} for model_id {}".format(request.type, request.model_id)) - - if request.type == fedn.StatusType.MODEL_UPDATE and self.config["trainer"]: - self.inbox.put(("train", request)) - elif request.type == fedn.StatusType.MODEL_VALIDATION and self.config["validator"]: - self.inbox.put(("validate", request)) - elif request.type == fedn.StatusType.MODEL_PREDICTION and self.config["validator"]: - logger.info("Received prediction request for model_id {}".format(request.model_id)) - presigned_url = json.loads(request.data) - presigned_url = presigned_url["presigned_url"] - logger.info("Prediction presigned URL: {}".format(presigned_url)) - self.inbox.put(("predict", request)) - else: - logger.error("Unknown request type: {}".format(request.type)) - - except grpc.RpcError as e: - # Handle gRPC errors - status_code = e.code() - if status_code == grpc.StatusCode.UNAVAILABLE: - logger.warning("GRPC TaskStream: server unavailable during model update request stream. Retrying.") - # Retry after a delay - time.sleep(5) - continue - if status_code == grpc.StatusCode.UNAUTHENTICATED: - details = e.details() - if details == "Token expired": - logger.warning("GRPC TaskStream: Token expired. Reconnecting.") - self.disconnect() - - if status_code == grpc.StatusCode.CANCELLED: - # Expected if the client is disconnected - logger.critical("GRPC TaskStream: Client disconnected from combiner. Trying to reconnect.") - - else: - # Log the error and continue - logger.error(f"GRPC TaskStream: An error occurred during model update request stream: {e}") - - except Exception as ex: - # Handle other exceptions - logger.error(f"GRPC TaskStream: An error occurred during model update request stream: {ex}") - - # Detach if not attached - if not self._connected: - return - - def _process_training_request(self, model_id: str, session_id: str = None, client_settings: dict = None): - """Process a training (model update) request. - - :param model_id: The model id of the model to be updated. - :type model_id: str - :param session_id: The id of the current session - :type session_id: str - :return: The model id of the updated model, or None if the update failed. And a dict with metadata. - :rtype: tuple - """ - self.send_status("\t Starting processing of training request for model_id {}".format(model_id), sesssion_id=session_id) - self.state = ClientState.training - - try: - meta = {} - tic = time.time() - mdl = self.get_model_from_combiner(str(model_id)) - if mdl is None: - logger.error("Could not retrieve model from combiner. Aborting training request.") - return None, None - meta["fetch_model"] = time.time() - tic - - inpath = self.helper.get_tmp_path() - with open(inpath, "wb") as fh: - fh.write(mdl.getbuffer()) - - save_metadata(metadata=client_settings, filename=inpath) - - outpath = self.helper.get_tmp_path() - tic = time.time() - # TODO: Check return status, fail gracefully - - self.dispatcher.run_cmd("train {} {}".format(inpath, outpath)) - - meta["exec_training"] = time.time() - tic - - tic = time.time() - out_model = None - - with open(outpath, "rb") as fr: - out_model = io.BytesIO(fr.read()) - - # Stream model update to combiner server - updated_model_id = uuid.uuid4() - self.send_model_to_combiner(out_model, str(updated_model_id)) - meta["upload_model"] = time.time() - tic - - # Read the metadata file - training_metadata = load_metadata(outpath) - meta["training_metadata"] = training_metadata - - os.unlink(inpath) - os.unlink(outpath) - os.unlink(outpath + "-metadata") - - except Exception as e: - logger.error("Could not process training request due to error: {}".format(e)) - updated_model_id = None - meta = {"status": "failed", "error": str(e)} - - self.state = ClientState.idle - - return updated_model_id, meta - - def _process_validation_request(self, model_id: str, session_id: str = None): - """Process a validation request. - - :param model_id: The model id of the model to be validated. - :type model_id: str - :param session_id: The id of the current session. - :type session_id: str - :return: The validation metrics, or None if validation failed. - :rtype: dict - """ - self.send_status(f"Processing validation request for model_id {model_id}", sesssion_id=session_id) - self.state = ClientState.validating - try: - model = self.get_model_from_combiner(str(model_id)) - if model is None: - logger.error("Could not retrieve model from combiner. Aborting validation request.") - return None - inpath = self.helper.get_tmp_path() - - with open(inpath, "wb") as fh: - fh.write(model.getbuffer()) - - outpath = get_tmp_path() - self.dispatcher.run_cmd(f"validate {inpath} {outpath}") - - with open(outpath, "r") as fh: - validation = json.loads(fh.read()) - - os.unlink(inpath) - os.unlink(outpath) - - except Exception as e: - logger.warning("Validation failed with exception {}".format(e)) - self.state = ClientState.idle - return None - - self.state = ClientState.idle - return validation - - def _process_prediction_request(self, model_id: str, session_id: str, presigned_url: str): - """Process a prediction request. - - :param model_id: The model id of the model to be used for prediction. - :type model_id: str - :param session_id: The id of the current session. - :type session_id: str - :param presigned_url: The presigned URL for the data to be used for prediction. - :type presigned_url: str - :return: None - """ - self.send_status(f"Processing prediction request for model_id {model_id}", sesssion_id=session_id) - try: - model = self.get_model_from_combiner(str(model_id)) - if model is None: - logger.error("Could not retrieve model from combiner. Aborting prediction request.") - return - inpath = self.helper.get_tmp_path() - - with open(inpath, "wb") as fh: - fh.write(model.getbuffer()) - - outpath = get_tmp_path() - self.dispatcher.run_cmd(f"predict {inpath} {outpath}") - - # Upload the prediction result to the presigned URL - with open(outpath, "rb") as fh: - response = requests.put(presigned_url, data=fh.read()) - - os.unlink(inpath) - os.unlink(outpath) - - if response.status_code != 200: - logger.warning("Prediction upload failed with status code {}".format(response.status_code)) - self.state = ClientState.idle - return - - except Exception as e: - logger.warning("Prediction failed with exception {}".format(e)) - self.state = ClientState.idle - return - - self.state = ClientState.idle - return - - def process_request(self): - """Process training and validation tasks.""" - while True: - if not self._connected: - return - - try: - (task_type, request) = self.inbox.get(timeout=1.0) - if task_type == "train": - tic = time.time() - self.state = ClientState.training - client_settings = json.loads(request.data).get("client_settings", {}) - model_id, meta = self._process_training_request(request.model_id, session_id=request.session_id, client_settings=client_settings) - - if meta is not None: - processing_time = time.time() - tic - meta["processing_time"] = processing_time - meta["config"] = request.data - - if model_id is not None: - # Send model update to combiner - update = fedn.ModelUpdate() - update.sender.name = self.name - update.sender.client_id = self.id - update.sender.role = fedn.CLIENT - update.receiver.name = request.sender.name - update.receiver.role = request.sender.role - update.model_id = request.model_id - update.model_update_id = str(model_id) - update.timestamp = str(datetime.now()) - update.correlation_id = request.correlation_id - update.meta = json.dumps(meta) - - try: - _ = self.combinerStub.SendModelUpdate(update, metadata=self.metadata) - self.send_status( - "Model update completed.", - log_level=fedn.LogLevel.AUDIT, - type=fedn.StatusType.MODEL_UPDATE, - request=update, - sesssion_id=request.session_id, - ) - except grpc.RpcError as e: - status_code = e.code() - logger.error("GRPC error, {}.".format(status_code.name)) - logger.debug(e) - except ValueError as e: - logger.error("GRPC error, RPC channel closed. {}".format(e)) - logger.debug(e) - else: - self.send_status( - "Client {} failed to complete model update.", log_level=fedn.LogLevel.WARNING, request=request, sesssion_id=request.session_id - ) - - self.state = ClientState.idle - self.inbox.task_done() - - elif task_type == "validate": - self.state = ClientState.validating - metrics = self._process_validation_request(request.model_id, request.session_id) - - if metrics is not None: - # Send validation - validation = fedn.ModelValidation() - validation.sender.name = self.name - validation.sender.role = fedn.CLIENT - validation.receiver.name = request.sender.name - validation.receiver.role = request.sender.role - validation.model_id = str(request.model_id) - validation.data = json.dumps(metrics) - validation.timestamp.GetCurrentTime() - validation.correlation_id = request.correlation_id - validation.session_id = request.session_id - - try: - _ = self.combinerStub.SendModelValidation(validation, metadata=self.metadata) - - status_type = fedn.StatusType.MODEL_VALIDATION - self.send_status( - "Model validation completed.", - log_level=fedn.LogLevel.AUDIT, - type=status_type, - request=validation, - sesssion_id=request.session_id, - ) - except grpc.RpcError as e: - status_code = e.code() - logger.error("GRPC error, {}.".format(status_code.name)) - logger.debug(e) - except ValueError as e: - logger.error("GRPC error, RPC channel closed. {}".format(e)) - logger.debug(e) - else: - self.send_status( - "Client {} failed to complete model validation.".format(self.name), - log_level=fedn.LogLevel.WARNING, - request=request, - sesssion_id=request.session_id, - ) - - self.state = ClientState.idle - self.inbox.task_done() - elif task_type == "predict": - self.state = ClientState.predicting - try: - presigned_url = json.loads(request.data) - except json.JSONDecodeError as e: - logger.error(f"Failed to decode prediction request data: {e}") - self.state = ClientState.idle - continue - - if "presigned_url" not in presigned_url: - logger.error("Prediction request missing presigned_url.") - self.state = ClientState.idle - continue - presigned_url = presigned_url["presigned_url"] - # Obs that session_id in request is the prediction_id - _ = self._process_prediction_request(request.model_id, request.session_id, presigned_url) - prediction = fedn.ModelPrediction() - prediction.sender.name = self.name - prediction.sender.role = fedn.CLIENT - prediction.receiver.name = request.sender.name - prediction.receiver.name = request.sender.name - prediction.receiver.role = request.sender.role - prediction.model_id = str(request.model_id) - # TODO: Add prediction data - prediction.data = "" - prediction.timestamp.GetCurrentTime() - prediction.correlation_id = request.correlation_id - # Obs that session_id in request is the prediction_id - prediction.prediction_id = request.session_id - - try: - _ = self.combinerStub.SendModelPrediction(prediction, metadata=self.metadata) - status_type = fedn.StatusType.MODEL_PREDICTION - self.send_status( - "Model prediction completed.", log_level=fedn.LogLevel.AUDIT, type=status_type, request=prediction, sesssion_id=request.session_id - ) - except grpc.RpcError as e: - status_code = e.code() - logger.error("GRPC error, {}.".format(status_code.name)) - logger.debug(e) - - self.state = ClientState.idle - except queue.Empty: - pass - except grpc.RpcError as e: - logger.critical(f"GRPC process_request: An error occurred during process request: {e}") - - def _send_heartbeat(self, update_frequency=2.0): - """Send a heartbeat to the combiner. - - :param update_frequency: The frequency of the heartbeat in seconds. - :type update_frequency: float - :return: None if the client is disconnected. - :rtype: None - """ - while True: - heartbeat = fedn.Heartbeat(sender=fedn.Client(name=self.name, role=fedn.CLIENT, client_id=self.id)) - try: - self.connectorStub.SendHeartbeat(heartbeat, metadata=self.metadata) - if self._missed_heartbeat > 0: - logger.info("GRPC heartbeat: combiner available again after {} missed heartbeats.".format(self._missed_heartbeat)) - self._missed_heartbeat = 0 - except grpc.RpcError as e: - status_code = e.code() - if status_code == grpc.StatusCode.UNAVAILABLE: - self._missed_heartbeat += 1 - logger.error( - "GRPC hearbeat: combiner unavailable, retrying (attempt {}/{}).".format( - self._missed_heartbeat, self.config["reconnect_after_missed_heartbeat"] - ) - ) - if self._missed_heartbeat > self.config["reconnect_after_missed_heartbeat"]: - self.disconnect() - self._missed_heartbeat = 0 - if status_code == grpc.StatusCode.UNAUTHENTICATED: - details = e.details() - if details == "Token expired": - logger.error("GRPC hearbeat: Token expired. Disconnecting.") - self.disconnect() - sys.exit("Unauthorized. Token expired. Please obtain a new token.") - logger.debug(e) - - time.sleep(update_frequency) - if not self._connected: - logger.info("SendStatus: Client disconnected.") - return - - def send_status(self, msg, log_level=fedn.LogLevel.INFO, type=None, request=None, sesssion_id: str = None): - """Send status message. - - :param msg: The message to send. - :type msg: str - :param log_level: The log level of the message. - :type log_level: fedn.LogLevel.INFO, fedn.LogLevel.WARNING, fedn.LogLevel.ERROR - :param type: The type of the message. - :type type: str - :param request: The request message. - :type request: fedn.Request - """ - if not self._connected: - logger.info("SendStatus: Client disconnected.") - return - - status = fedn.Status() - status.timestamp.GetCurrentTime() - status.sender.name = self.name - status.sender.role = fedn.CLIENT - status.log_level = log_level - status.status = str(msg) - status.session_id = sesssion_id - if type is not None: - status.type = type - - if request is not None: - status.data = MessageToJson(request) - - self.logs.append("{} {} LOG LEVEL {} MESSAGE {}".format(str(datetime.now()), status.sender.name, status.log_level, status.status)) - try: - _ = self.connectorStub.SendStatus(status, metadata=self.metadata) - except grpc.RpcError as e: - status_code = e.code() - if status_code == grpc.StatusCode.UNAVAILABLE: - logger.warning("GRPC SendStatus: server unavailable during send status.") - if status_code == grpc.StatusCode.UNAUTHENTICATED: - details = e.details() - if details == "Token expired": - logger.warning("GRPC SendStatus: Token expired.") - - def run(self): - """Run the client.""" - try: - cnt = 0 - old_state = self.state - while True: - time.sleep(1) - if cnt == 0: - logger.info("Client is active, waiting for model update requests.") - cnt = 1 - if self.state != old_state: - logger.info("Client in {} state.".format(ClientStateToString(self.state))) - if not self._connected: - logger.warning("Client lost connection to combiner. Attempting to reconnect to FEDn network.") - combiner_config = self.assign() - self.connect(combiner_config) - self._subscribe_to_combiner(self.config) - cnt = 0 - if self.error_state: - logger.error("Client in error state. Terminiating.") - sys.exit("Client in error state. Terminiating.") - except KeyboardInterrupt: - logger.info("Shutting down.") diff --git a/fedn/network/clients/fedn_client.py b/fedn/network/clients/fedn_client.py index c38347a7b..b8759f3ce 100644 --- a/fedn/network/clients/fedn_client.py +++ b/fedn/network/clients/fedn_client.py @@ -75,8 +75,7 @@ def set_predict_callback(self, callback: callable): self.predict_callback = callback def connect_to_api(self, url: str, token: str, json: dict) -> Tuple[ConnectToApiResult, Any]: - # TODO: Use new API endpoint (v1) - url_endpoint = f"{url}add_client" + url_endpoint = f"{url}api/v1/clients/add" logger.info(f"Connecting to API endpoint: {url_endpoint}") try: diff --git a/fedn/network/clients/package.py b/fedn/network/clients/package.py deleted file mode 100644 index 2cdf970d7..000000000 --- a/fedn/network/clients/package.py +++ /dev/null @@ -1,160 +0,0 @@ -# This file contains the PackageRuntime class, which is used to download, validate and unpack compute packages. -# -# -import cgi -import os -import tarfile - -import requests - -from fedn.common.config import FEDN_AUTH_SCHEME, FEDN_CUSTOM_URL_PREFIX -from fedn.common.log_config import logger -from fedn.utils.checksum import sha -from fedn.utils.dispatcher import Dispatcher, _read_yaml_file - - -class PackageRuntime: - """PackageRuntime is used to download, validate and unpack compute packages. - - :param package_path: path to compute package - :type package_path: str - :param package_dir: directory to unpack compute package - :type package_dir: str - """ - - def __init__(self, package_path): - self.dispatch_config = { - "entry_points": { - "predict": {"command": "python3 predict.py"}, - "train": {"command": "python3 train.py"}, - "validate": {"command": "python3 validate.py"}, - } - } - - self.pkg_path = package_path - self.pkg_name = None - self.checksum = None - self.expected_checksum = None - - def download(self, host, port, token, force_ssl=False, secure=False, name=None): - """Download compute package from controller - - :param host: host of controller - :param port: port of controller - :param token: token for authentication - :param name: name of package - :return: True if download was successful, None otherwise - :rtype: bool - """ - # for https we assume a an ingress handles permanent redirect (308) - if force_ssl: - scheme = "https" - else: - scheme = "http" - if port: - path = f"{scheme}://{host}:{port}{FEDN_CUSTOM_URL_PREFIX}/download_package" - else: - path = f"{scheme}://{host}{FEDN_CUSTOM_URL_PREFIX}/download_package" - if name: - path = path + "?name={}".format(name) - - with requests.get(path, stream=True, verify=False, headers={"Authorization": f"{FEDN_AUTH_SCHEME} {token}"}) as r: - if 200 <= r.status_code < 204: - params = cgi.parse_header(r.headers.get("Content-Disposition", ""))[-1] - try: - self.pkg_name = params["filename"] - except KeyError: - logger.error("No package returned.") - return None - r.raise_for_status() - with open(os.path.join(self.pkg_path, self.pkg_name), "wb") as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - if port: - path = f"{scheme}://{host}:{port}{FEDN_CUSTOM_URL_PREFIX}/get_package_checksum" - else: - path = f"{scheme}://{host}{FEDN_CUSTOM_URL_PREFIX}/get_package_checksum" - - if name: - path = path + "?name={}".format(name) - with requests.get(path, verify=False, headers={"Authorization": f"{FEDN_AUTH_SCHEME} {token}"}) as r: - if 200 <= r.status_code < 204: - data = r.json() - try: - self.checksum = data["checksum"] - except Exception: - logger.error("Could not extract checksum.") - - return True - - def validate(self, expected_checksum): - """Validate the package against the checksum provided by the controller - - :param expected_checksum: checksum provided by the controller - :return: True if checksums match, False otherwise - :rtype: bool - """ - self.expected_checksum = expected_checksum - - # crosscheck checksum and unpack if security checks are ok. - file_checksum = str(sha(os.path.join(self.pkg_path, self.pkg_name))) - - if self.checksum == self.expected_checksum == file_checksum: - logger.info("Package validated {}".format(self.checksum)) - return True - else: - return False - - def unpack(self): - """Unpack the compute package - - :return: True if unpacking was successful, False otherwise - :rtype: bool - """ - if self.pkg_name: - f = None - if self.pkg_name.endswith("tar.gz"): - f = tarfile.open(os.path.join(self.pkg_path, self.pkg_name), "r:gz") - if self.pkg_name.endswith(".tgz"): - f = tarfile.open(os.path.join(self.pkg_path, self.pkg_name), "r:gz") - if self.pkg_name.endswith("tar.bz2"): - f = tarfile.open(os.path.join(self.pkg_path, self.pkg_name), "r:bz2") - else: - logger.error("Failed to unpack compute package, no pkg_name set." "Has the reducer been configured with a compute package?") - return False - - try: - if f: - f.extractall(self.pkg_path) - logger.info("Successfully extracted compute package content in {}".format(self.pkg_path)) - # delete the tarball - logger.info("Deleting temporary package tarball file.") - f.close() - os.remove(os.path.join(self.pkg_path, self.pkg_name)) - # search for file fedn.yaml in extracted package - for root, dirs, files in os.walk(self.pkg_path): - if "fedn.yaml" in files: - # Get the path to where fedn.yaml is located - logger.info("Found fedn.yaml file in {}".format(root)) - return True, root - - logger.error("No fedn.yaml file found in extracted package!") - return False, "" - except Exception: - logger.error("Error extracting files.") - # delete the tarball - os.remove(os.path.join(self.pkg_path, self.pkg_name)) - return False, "" - - def dispatcher(self, run_path): - """Dispatch the compute package - - :param run_path: path to dispatch the compute package - :type run_path: str - :return: Dispatcher object - :rtype: :class:`fedn.utils.dispatcher.Dispatcher` - """ - self.dispatch_config = _read_yaml_file(os.path.join(run_path, "fedn.yaml")) - dispatcher = Dispatcher(self.dispatch_config, run_path) - - return dispatcher diff --git a/fedn/network/clients/package_runtime.py b/fedn/network/clients/package_runtime.py index 42006ce01..487de2888 100644 --- a/fedn/network/clients/package_runtime.py +++ b/fedn/network/clients/package_runtime.py @@ -46,8 +46,7 @@ def download_compute_package(self, url: str, token: str, name: str = None) -> bo :rtype: bool """ try: - # TODO: use new endpoint (v1) - path = f"{url}/download_package?name={name}" if name else f"{url}/download_package" + path = f"{url}/api/v1/packages/download?name={name}" if name else f"{url}/api/v1/packages/download" with requests.get(path, stream=True, verify=False, headers={"Authorization": f"{FEDN_AUTH_SCHEME} {token}"}) as r: if 200 <= r.status_code < 204: @@ -76,8 +75,7 @@ def set_checksum(self, url: str, token: str, name: str = None) -> bool: :rtype: str """ try: - # TODO: use new endpoint (v1) - path = f"{url}/get_package_checksum?name={name}" if name else f"{url}/get_package_checksum" + path = f"{url}/api/v1/packages/checksum?name={name}" if name else f"{url}/api/v1/packages/checksum" with requests.get(path, verify=False, headers={"Authorization": f"{FEDN_AUTH_SCHEME} {token}"}) as r: if 200 <= r.status_code < 204: diff --git a/fedn/network/combiner/combiner.py b/fedn/network/combiner/combiner.py index e30860b4e..a6d04dd5e 100644 --- a/fedn/network/combiner/combiner.py +++ b/fedn/network/combiner/combiner.py @@ -9,6 +9,7 @@ from enum import Enum from typing import TypedDict +import pymongo from google.protobuf.json_format import MessageToDict import fedn.network.grpc.fedn_pb2 as fedn @@ -16,7 +17,7 @@ from fedn.common.certificate.certificate import Certificate from fedn.common.log_config import logger, set_log_level_from_string, set_log_stream from fedn.network.combiner.roundhandler import RoundConfig, RoundHandler -from fedn.network.combiner.shared import client_store, combiner_store, prediction_store, repository, statestore, status_store, validation_store +from fedn.network.combiner.shared import client_store, combiner_store, prediction_store, repository, round_store, status_store, validation_store from fedn.network.grpc.server import Server, ServerConfig from fedn.network.storage.statestore.stores.shared import EntityNotFound @@ -72,6 +73,7 @@ class CombinerConfig(TypedDict): verbosity: str +# TODO: dependency injection class Combiner(rpc.CombinerServicer, rpc.ReducerServicer, rpc.ConnectorServicer, rpc.ControlServicer): """Combiner gRPC server. @@ -105,7 +107,7 @@ def __init__(self, config): # Set up model repository self.repository = repository - self.statestore = statestore + self.round_store = round_store # Add combiner to statestore interface_config = { @@ -127,18 +129,16 @@ def __init__(self, config): # If a client and a combiner goes down at the same time, # the client will be stuck listed as "online" in the statestore. # Set the status to offline for previous clients. - previous_clients = client_store.list(limit=0, skip=0, sort_key=None, kwargs={"combiner": self.id}) + previous_clients = client_store.list(limit=0, skip=0, sort_key=None, sort_order=pymongo.DESCENDING, **{"combiner": self.id}) count = previous_clients["count"] result = previous_clients["result"] logger.info(f"Found {count} previous clients") logger.info("Updating previous clients status to offline") for client in result: try: - if "client_id" in client.keys(): - client_store.update("client_id", client["client_id"], {"name": client["name"], "status": "offline"}) - else: - # Old clients might not have a client_id - client_store.update("name", client["name"], {"name": client["name"], "status": "offline"}) + client_to_update = client_store.get(client["client_id"]) + client_to_update["status"] = "offline" + client_store.update(client["client_id"], client_to_update) except Exception as e: logger.error("Failed to update previous client status: {}".format(str(e))) @@ -389,10 +389,14 @@ def _list_active_clients(self, channel): # Update statestore with client status if len(clients["update_active_clients"]) > 0: for client in clients["update_active_clients"]: - client_store.update("client_id", client, {"status": "online"}) + client_to_update = client_store.get(client) + client_to_update["status"] = "online" + client_store.update(client, client_to_update) if len(clients["update_offline_clients"]) > 0: for client in clients["update_offline_clients"]: - client_store.update("client_id", client, {"status": "offline"}) + client_to_update = client_store.get(client) + client_to_update["status"] = "offline" + client_store.update(client, client_to_update) return clients["active_clients"] @@ -684,25 +688,18 @@ def TaskStream(self, response, context): self.clients[client.client_id]["status"] = "online" try: # If the client is already in the client store, update the status - success, result = client_store.update( - "client_id", - client.client_id, - {"name": client.name, "status": "online", "client_id": client.client_id, "last_seen": datetime.now(), "combiner": self.id}, - ) - if not success and result == "Entity not found": - # If the client is not in the client store, add the client - success, result = client_store.add( - { - "name": client.name, - "status": "online", - "client_id": client.client_id, - "last_seen": datetime.now(), - "combiner": self.id, - "combiner_preferred": self.id, - "updated_at": datetime.now(), - } - ) - elif not success: + + client_to_upsert = { + "name": client.name, + "status": "online", + "client_id": client.client_id, + "last_seen": datetime.now(), + "combiner": self.id, + "updated_at": datetime.now(), + } + + success, result = client_store.upsert(client_to_upsert) + if not success: logger.error(result) except Exception as e: logger.error(f"Failed to update client status: {str(e)}") @@ -807,3 +804,4 @@ def run(self): except (KeyboardInterrupt, SystemExit): pass self.server.stop() + self.server.stop() diff --git a/fedn/network/combiner/interfaces.py b/fedn/network/combiner/interfaces.py index 32dbdb0f7..2dc485754 100644 --- a/fedn/network/combiner/interfaces.py +++ b/fedn/network/combiner/interfaces.py @@ -1,8 +1,6 @@ import base64 import copy import json -import time -from io import BytesIO import grpc @@ -245,39 +243,6 @@ def submit(self, config: RoundConfig): return response - def get_model(self, id, timeout=10): - """Download a model from the combiner server. - - :param id: The model id. - :type id: str - :return: A file-like object containing the model. - :rtype: :class:`io.BytesIO`, None if the model is not available. - """ - channel = Channel(self.address, self.port, self.certificate).get_channel() - modelservice = rpc.ModelServiceStub(channel) - - data = BytesIO() - data.seek(0, 0) - - time_start = time.time() - - request = fedn.ModelRequest(id=id) - request.sender.name = self.name - request.sender.role = fedn.CLIENT - - parts = modelservice.Download(request) - for part in parts: - if part.status == fedn.ModelStatus.IN_PROGRESS: - data.write(part.data) - if part.status == fedn.ModelStatus.OK: - return data - if part.status == fedn.ModelStatus.FAILED: - return None - if part.status == fedn.ModelStatus.UNKNOWN: - if time.time() - time_start > timeout: - return None - continue - def allowing_clients(self): """Check if the combiner is allowing additional client connections. diff --git a/fedn/network/combiner/roundhandler.py b/fedn/network/combiner/roundhandler.py index fa3d83e8f..20ed336c9 100644 --- a/fedn/network/combiner/roundhandler.py +++ b/fedn/network/combiner/roundhandler.py @@ -31,7 +31,7 @@ class RoundConfig(TypedDict): :param round_timeout: The round timeout in seconds. Set by user interfaces or Controller. :type round_timeout: str :param rounds: The number of rounds. Set by user interfaces. - :param model_id: The model identifier. Set by user interfaces or Controller (get_latest_model). + :param model_id: The model identifier. Set by user interfaces or Controller. :type model_id: str :param model_version: The model version. Currently not used. :type model_version: str @@ -373,7 +373,13 @@ def run(self, polling_interval=1.0): round_meta["time_exec_training"] = time.time() - tic round_meta["status"] = "Success" round_meta["name"] = self.server.id - self.server.statestore.set_round_combiner_data(round_meta) + active_round = self.server.round_store.get(round_meta["round_id"]) + if "combiners" not in active_round: + active_round["combiners"] = [] + active_round["combiners"].append(round_meta) + updated = self.server.round_store.update(active_round["id"], active_round) + if not updated: + raise Exception("Failed to update round data in round store.") elif round_config["task"] == "validation": session_id = round_config["session_id"] model_id = round_config["model_id"] diff --git a/fedn/network/combiner/shared.py b/fedn/network/combiner/shared.py index bf9a63032..a0aa66441 100644 --- a/fedn/network/combiner/shared.py +++ b/fedn/network/combiner/shared.py @@ -4,29 +4,47 @@ from fedn.common.config import get_modelstorage_config, get_network_config, get_statestore_config from fedn.network.combiner.modelservice import ModelService from fedn.network.storage.s3.repository import Repository -from fedn.network.storage.statestore.mongostatestore import MongoStateStore -from fedn.network.storage.statestore.stores.client_store import ClientStore -from fedn.network.storage.statestore.stores.combiner_store import CombinerStore -from fedn.network.storage.statestore.stores.prediction_store import PredictionStore -from fedn.network.storage.statestore.stores.status_store import StatusStore -from fedn.network.storage.statestore.stores.validation_store import ValidationStore +from fedn.network.storage.statestore.stores.client_store import ClientStore, MongoDBClientStore, SQLClientStore +from fedn.network.storage.statestore.stores.combiner_store import CombinerStore, MongoDBCombinerStore, SQLCombinerStore +from fedn.network.storage.statestore.stores.prediction_store import MongoDBPredictionStore, PredictionStore, SQLPredictionStore +from fedn.network.storage.statestore.stores.round_store import MongoDBRoundStore, RoundStore, SQLRoundStore +from fedn.network.storage.statestore.stores.status_store import MongoDBStatusStore, SQLStatusStore, StatusStore +from fedn.network.storage.statestore.stores.store import MyAbstractBase, engine +from fedn.network.storage.statestore.stores.validation_store import MongoDBValidationStore, SQLValidationStore, ValidationStore statestore_config = get_statestore_config() modelstorage_config = get_modelstorage_config() network_id = get_network_config() -statestore = MongoStateStore(network_id, statestore_config["mongo_config"]) +client_store: ClientStore = None +validation_store: ValidationStore = None +combiner_store: CombinerStore = None +status_store: StatusStore = None +prediction_store: PredictionStore = None +round_store: RoundStore = None if statestore_config["type"] == "MongoDB": mc = pymongo.MongoClient(**statestore_config["mongo_config"]) mc.server_info() mdb: Database = mc[network_id] -client_store = ClientStore(mdb, "network.clients") -validation_store = ValidationStore(mdb, "control.validations") -combiner_store = CombinerStore(mdb, "network.combiners") -status_store = StatusStore(mdb, "control.status") -prediction_store = PredictionStore(mdb, "control.predictions") + client_store = MongoDBClientStore(mdb, "network.clients") + validation_store = MongoDBValidationStore(mdb, "control.validations") + combiner_store = MongoDBCombinerStore(mdb, "network.combiners") + status_store = MongoDBStatusStore(mdb, "control.status") + prediction_store = MongoDBPredictionStore(mdb, "control.predictions") + round_store = MongoDBRoundStore(mdb, "control.rounds") +elif statestore_config["type"] in ["SQLite", "PostgreSQL"]: + MyAbstractBase.metadata.create_all(engine, checkfirst=True) + + client_store = SQLClientStore() + validation_store = SQLValidationStore() + combiner_store = SQLCombinerStore() + status_store = SQLStatusStore() + prediction_store = SQLPredictionStore() + round_store = SQLRoundStore() +else: + raise ValueError("Unknown statestore type") repository = Repository(modelstorage_config["storage_config"], init_buckets=False) diff --git a/fedn/network/controller/control.py b/fedn/network/controller/control.py index c2b430fd5..bf7b9954a 100644 --- a/fedn/network/controller/control.py +++ b/fedn/network/controller/control.py @@ -11,6 +11,13 @@ from fedn.network.combiner.roundhandler import RoundConfig from fedn.network.controller.controlbase import ControlBase from fedn.network.state import ReducerState +from fedn.network.storage.s3.repository import Repository +from fedn.network.storage.statestore.stores.client_store import ClientStore +from fedn.network.storage.statestore.stores.combiner_store import CombinerStore +from fedn.network.storage.statestore.stores.model_store import ModelStore +from fedn.network.storage.statestore.stores.package_store import PackageStore +from fedn.network.storage.statestore.stores.round_store import RoundStore +from fedn.network.storage.statestore.stores.session_store import SessionStore class UnsupportedStorageBackend(Exception): @@ -88,9 +95,19 @@ class Control(ControlBase): :type statestore: class: `fedn.network.statestorebase.StateStorageBase` """ - def __init__(self, statestore): + def __init__( + self, + network_id: str, + session_store: SessionStore, + model_store: ModelStore, + round_store: RoundStore, + package_store: PackageStore, + combiner_store: CombinerStore, + client_store: ClientStore, + model_repository: Repository, + ): """Constructor method.""" - super().__init__(statestore) + super().__init__(network_id, session_store, model_store, round_store, package_store, combiner_store, client_store, model_repository) self.name = "DefaultControl" def start_session(self, session_id: str, rounds: int, round_timeout: int) -> None: @@ -98,13 +115,22 @@ def start_session(self, session_id: str, rounds: int, round_timeout: int) -> Non logger.info("Controller already in INSTRUCTING state. A session is in progress.") return - if not self.statestore.get_latest_model(): + model_set: bool = False + + try: + active_model_id = self.model_store.get_active() + if active_model_id not in ["", " "]: + model_set = True + except Exception: + logger.error("Failed to get active model") + + if not model_set: logger.warning("No model in model chain, please provide a seed model!") return self._state = ReducerState.instructing - session = self.statestore.get_session(session_id) + session = self.session_store.get(session_id) if not session: logger.error("Session not found.") @@ -121,7 +147,7 @@ def start_session(self, session_id: str, rounds: int, round_timeout: int) -> Non self._state = ReducerState.monitoring - last_round = int(self.get_latest_round_id()) + last_round = self.get_latest_round_id() aggregator = session_config["aggregator"] @@ -145,12 +171,11 @@ def start_session(self, session_id: str, rounds: int, round_timeout: int) -> Non logger.info("Session terminated.") break _, round_data = self.round(session_config, str(current_round)) + logger.info("Round completed with status {}".format(round_data["status"])) except TypeError as e: logger.error("Failed to execute round: {0}".format(e)) - logger.info("Round completed with status {}".format(round_data["status"])) - - session_config["model_id"] = self.statestore.get_latest_model() + session_config["model_id"] = self.model_store.get_active() if self.get_session_status(session_id) == "Started": self.set_session_status(session_id, "Finished") @@ -158,63 +183,6 @@ def start_session(self, session_id: str, rounds: int, round_timeout: int) -> Non self.set_session_config(session_id, session_config) - def session(self, config: RoundConfig) -> None: - """Execute a new training session. A session consists of one - or several global rounds. All rounds in the same session - have the same round_config. - - :param config: The session config. - :type config: dict - - """ - if self._state == ReducerState.instructing: - logger.info("Controller already in INSTRUCTING state. A session is in progress.") - return - - if not self.statestore.get_latest_model(): - logger.warning("No model in model chain, please provide a seed model!") - return - - self._state = ReducerState.instructing - config["committed_at"] = datetime.datetime.now() - - self.create_session(config) - - self._state = ReducerState.monitoring - - last_round = int(self.get_latest_round_id()) - - for combiner in self.network.get_combiners(): - combiner.set_aggregator(config["aggregator"]) - if config["server_functions"] is not None: - combiner.set_server_functions(config["server_functions"]) - - self.set_session_status(config["session_id"], "Started") - # Execute the rounds in this session - for round in range(1, int(config["rounds"] + 1)): - # Increment the round number - if last_round: - current_round = last_round + round - else: - current_round = round - - try: - if self.get_session_status(config["session_id"]) == "Terminated": - logger.info("Session terminated.") - break - _, round_data = self.round(config, str(current_round)) - except TypeError as e: - logger.error("Failed to execute round: {0}".format(e)) - - logger.info("Round completed with status {}".format(round_data["status"])) - - config["model_id"] = self.statestore.get_latest_model() - - # TODO: Report completion of session - if self.get_session_status(config["session_id"]) == "Started": - self.set_session_status(config["session_id"], "Finished") - self._state = ReducerState.idle - def prediction_session(self, config: RoundConfig) -> None: """Execute a new prediction session. @@ -231,7 +199,7 @@ def prediction_session(self, config: RoundConfig) -> None: return if "model_id" not in config.keys(): - config["model_id"] = self.statestore.get_latest_model() + config["model_id"] = self.model_store.get_active() config["committed_at"] = datetime.datetime.now() config["task"] = "prediction" @@ -264,7 +232,7 @@ def round(self, session_config: RoundConfig, round_id: str): if len(self.network.get_combiners()) < 1: logger.warning("Round cannot start, no combiners connected!") self.set_round_status(round_id, "Failed") - return None, self.statestore.get_round(round_id) + return None, self.round_store.get(round_id) # Assemble round config for this global round round_config = copy.deepcopy(session_config) @@ -286,7 +254,7 @@ def round(self, session_config: RoundConfig, round_id: str): else: logger.warning("Round start policy not met, skipping round!") self.set_round_status(round_id, "Failed") - return None, self.statestore.get_round(round_id) + return None, self.round_store.get(round_id) # Ask participating combiners to coordinate model updates _ = self.request_model_updates(participating_combiners) @@ -305,7 +273,7 @@ def do_if_round_times_out(result): retry=retry_if_exception_type(CombinersNotDoneException), ) def combiners_done(): - round = self.statestore.get_round(round_id) + round = self.round_store.get(round_id) session_status = self.get_session_status(session_id) if session_status == "Terminated": self.set_round_status(round_id, "Terminated") @@ -322,44 +290,44 @@ def combiners_done(): combiners_are_done = combiners_done() if not combiners_are_done: - return None, self.statestore.get_round(round_id) + return None, self.round_store.get(round_id) # Due to the distributed nature of the computation, there might be a # delay before combiners have reported the round data to the db, # so we need some robustness here. @retry(wait=wait_random(min=0.1, max=1.0), retry=retry_if_exception_type(KeyError)) def check_combiners_done_reporting(): - round = self.statestore.get_round(round_id) + round = self.round_store.get(round_id) combiners = round["combiners"] return combiners _ = check_combiners_done_reporting() - round = self.statestore.get_round(round_id) + round = self.round_store.get(round_id) round_valid = self.evaluate_round_validity_policy(round) if not round_valid: logger.error("Round failed. Invalid - evaluate_round_validity_policy: False") self.set_round_status(round_id, "Failed") - return None, self.statestore.get_round(round_id) + return None, self.round_store.get(round_id) logger.info("Reducing combiner level models...") # Reduce combiner models into a new global model round_data = {} try: - round = self.statestore.get_round(round_id) + round = self.round_store.get(round_id) model, data = self.reduce(round["combiners"]) round_data["reduce"] = data logger.info("Done reducing models from combiners!") except Exception as e: logger.error("Failed to reduce models from combiners, reason: {}".format(e)) self.set_round_status(round_id, "Failed") - return None, self.statestore.get_round(round_id) + return None, self.round_store.get(round_id) # Commit the new global model to the model trail if model is not None: logger.info("Committing global model to model trail...") tic = time.time() - model_id = uuid.uuid4() + model_id = str(uuid.uuid4()) session_id = session_config["session_id"] if "session_id" in session_config else None self.commit(model_id, model, session_id) round_data["time_commit"] = time.time() - tic @@ -367,7 +335,7 @@ def check_combiners_done_reporting(): else: logger.error("Failed to commit model to global model trail.") self.set_round_status(round_id, "Failed") - return None, self.statestore.get_round(round_id) + return None, self.round_store.get(round_id) self.set_round_status(round_id, "Success") @@ -376,9 +344,18 @@ def check_combiners_done_reporting(): if validate: combiner_config = copy.deepcopy(session_config) combiner_config["round_id"] = round_id - combiner_config["model_id"] = self.statestore.get_latest_model() + combiner_config["model_id"] = self.model_store.get_active() combiner_config["task"] = "validation" - combiner_config["helper_type"] = self.statestore.get_helper() + + helper_type: str = None + + try: + active_package = self.package_store.get_active() + helper_type = active_package["helper"] + except Exception: + logger.error("Failed to get active helper") + + combiner_config["helper_type"] = helper_type validating_combiners = self.get_participating_combiners(combiner_config) @@ -392,7 +369,7 @@ def check_combiners_done_reporting(): self.set_round_data(round_id, round_data) self.set_round_status(round_id, "Finished") - return model_id, self.statestore.get_round(round_id) + return model_id, self.round_store.get(round_id) def reduce(self, combiners): """Combine updated models from Combiner nodes into one global model. @@ -483,7 +460,7 @@ def prediction_round(self, config): # Setup combiner configuration combiner_config = copy.deepcopy(config) - combiner_config["model_id"] = self.statestore.get_latest_model() + combiner_config["model_id"] = self.model_store.get_active() combiner_config["task"] = "prediction" combiner_config["helper_type"] = self.statestore.get_framework() diff --git a/fedn/network/controller/controlbase.py b/fedn/network/controller/controlbase.py index 397a117bb..bdf4f9284 100644 --- a/fedn/network/controller/controlbase.py +++ b/fedn/network/controller/controlbase.py @@ -1,7 +1,8 @@ import os -import uuid from abc import ABC, abstractmethod -from time import sleep +from datetime import datetime +from typing import Any, Tuple + import fedn.utils.helpers.helpers from fedn.common.log_config import logger @@ -10,6 +11,12 @@ from fedn.network.combiner.roundhandler import RoundConfig from fedn.network.state import ReducerState from fedn.network.storage.s3.repository import Repository +from fedn.network.storage.statestore.stores.client_store import ClientStore +from fedn.network.storage.statestore.stores.combiner_store import CombinerStore +from fedn.network.storage.statestore.stores.model_store import ModelStore +from fedn.network.storage.statestore.stores.package_store import PackageStore +from fedn.network.storage.statestore.stores.round_store import RoundStore +from fedn.network.storage.statestore.stores.session_store import MongoDBSessionStore # Maximum number of tries to connect to statestore and retrieve storage configuration MAX_TRIES_BACKEND = os.getenv("MAX_TRIES_BACKEND", 10) @@ -36,43 +43,27 @@ class ControlBase(ABC): """ @abstractmethod - def __init__(self, statestore): + def __init__( + self, + network_id: str, + session_store: MongoDBSessionStore, + model_store: ModelStore, + round_store: RoundStore, + package_store: PackageStore, + combiner_store: CombinerStore, + client_store: ClientStore, + model_repository: Repository, + ): """Constructor.""" self._state = ReducerState.setup - self.statestore = statestore - if self.statestore.is_inited(): - self.network = Network(self, statestore) - - try: - not_ready = True - tries = 0 - while not_ready: - storage_config = self.statestore.get_storage_backend() - if storage_config: - not_ready = False - else: - logger.warning("Storage backend not configured, waiting...") - sleep(5) - tries += 1 - if tries > MAX_TRIES_BACKEND: - raise Exception - except Exception: - logger.error("Failed to retrive storage configuration, exiting.") - raise MisconfiguredStorageBackend() - - if storage_config["storage_type"] == "S3": - self.model_repository = Repository(storage_config["storage_config"]) - else: - logger.error("Unsupported storage backend, exiting.") - raise UnsupportedStorageBackend() - - if self.statestore.is_inited(): - self._state = ReducerState.idle + self.session_store = session_store + self.model_store = model_store + self.round_store = round_store + self.package_store = package_store + self.network = Network(self, network_id, combiner_store, client_store) - @abstractmethod - def session(self, config): - pass + self.model_repository = model_repository @abstractmethod def round(self, config, round_number): @@ -88,7 +79,14 @@ def get_helper(self): :return: Helper instance. :rtype: :class:`fedn.utils.plugins.helperbase.HelperBase` """ - helper_type = self.statestore.get_helper() + helper_type: str = None + + try: + active_package = self.package_store.get_active() + helper_type = active_package["helper"] + except Exception: + logger.error("Failed to get active helper") + helper = fedn.utils.helpers.helpers.get_helper(helper_type) if not helper: raise MisconfiguredHelper("Unsupported helper type {}, please configure compute_package.helper !".format(helper_type)) @@ -113,29 +111,12 @@ def idle(self): else: return False - def get_model_info(self): - """:return:""" - return self.statestore.get_model_trail() - - # TODO: remove use statestore.get_events() instead - def get_events(self): - """:return:""" - return self.statestore.get_events() - - def get_latest_round_id(self): - last_round = self.statestore.get_latest_round() - if not last_round: - return 0 - else: - return last_round["round_id"] - - def get_latest_round(self): - round = self.statestore.get_latest_round() - return round + def get_latest_round_id(self) -> int: + return self.round_store.get_latest_round_id() def get_compute_package_name(self): """:return:""" - definition = self.statestore.get_compute_package() + definition = self.package_store.get_active() if definition: try: package_name = definition["storage_file_name"] @@ -161,19 +142,7 @@ def get_compute_package(self, compute_package=""): else: return None - def create_session(self, config: RoundConfig, status: str = "Initialized") -> None: - """Initialize a new session in backend db.""" - if "session_id" not in config.keys(): - session_id = uuid.uuid4() - config["session_id"] = str(session_id) - else: - session_id = config["session_id"] - - self.statestore.create_session(id=session_id) - self.statestore.set_session_config(session_id, config) - self.statestore.set_session_status(session_id, status) - - def set_session_status(self, session_id, status): + def set_session_status(self, session_id: str, status: str) -> Tuple[bool, Any]: """Set the round round stats. :param round_id: The round unique identifier @@ -181,9 +150,13 @@ def set_session_status(self, session_id, status): :param status: The status :type status: str """ - self.statestore.set_session_status(session_id, status) + session = self.session_store.get(session_id) + session["status"] = status + updated, msg = self.session_store.update(session["id"], session) + if not updated: + raise Exception(msg) - def get_session_status(self, session_id): + def get_session_status(self, session_id: str): """Get the status of a session. :param session_id: The session unique identifier @@ -191,9 +164,10 @@ def get_session_status(self, session_id): :return: The status :rtype: str """ - return self.statestore.get_session_status(session_id) + session = self.session_store.get(session_id) + return session["status"] - def set_session_config(self, session_id: str, config: dict): + def set_session_config(self, session_id: str, config: dict) -> Tuple[bool, Any]: """Set the model id for a session. :param session_id: The session unique identifier @@ -201,13 +175,17 @@ def set_session_config(self, session_id: str, config: dict): :param config: The session config :type config: dict """ - self.statestore.set_session_config_v2(session_id, config) + session = self.session_store.get(session_id) + session["session_config"] = config + updated, msg = self.session_store.update(session["id"], session) + if not updated: + raise Exception(msg) def create_round(self, round_data): """Initialize a new round in backend db.""" - self.statestore.create_round(round_data) + self.round_store.add(round_data) - def set_round_data(self, round_id, round_data): + def set_round_data(self, round_id: str, round_data: dict): """Set round data. :param round_id: The round unique identifier @@ -215,9 +193,13 @@ def set_round_data(self, round_id, round_data): :param round_data: The status :type status: dict """ - self.statestore.set_round_data(round_id, round_data) + round = self.round_store.get(round_id) + round["round_data"] = round_data + updated, _ = self.round_store.update(round["id"], round) + if not updated: + raise Exception("Failed to update round") - def set_round_status(self, round_id, status): + def set_round_status(self, round_id: str, status: str): """Set the round round stats. :param round_id: The round unique identifier @@ -225,9 +207,13 @@ def set_round_status(self, round_id, status): :param status: The status :type status: str """ - self.statestore.set_round_status(round_id, status) + round = self.round_store.get(round_id) + round["status"] = status + updated, _ = self.round_store.update(round["id"], round) + if not updated: + raise Exception("Failed to update round") - def set_round_config(self, round_id, round_config: RoundConfig): + def set_round_config(self, round_id: str, round_config: RoundConfig): """Upate round in backend db. :param round_id: The round unique identifier @@ -235,7 +221,11 @@ def set_round_config(self, round_id, round_config: RoundConfig): :param round_config: The round configuration :type round_config: dict """ - self.statestore.set_round_config(round_id, round_config) + round = self.round_store.get(round_id) + round["round_config"] = round_config + updated, _ = self.round_store.update(round["id"], round) + if not updated: + raise Exception("Failed to update round") def request_model_updates(self, combiners): """Ask Combiner server to produce a model update. @@ -249,7 +239,7 @@ def request_model_updates(self, combiners): cl.append((combiner, response)) return cl - def commit(self, model_id, model=None, session_id=None): + def commit(self, model_id: str, model: dict = None, session_id: str = None, name: str = None): """Commit a model to the global model trail. The model commited becomes the lastest consensus model. :param model_id: Unique identifier for the model to commit. @@ -270,7 +260,35 @@ def commit(self, model_id, model=None, session_id=None): os.unlink(outfile_name) logger.info("Committing model {} to global model trail in statestore...".format(model_id)) - self.statestore.set_latest_model(model_id, session_id) + + active_model: str = None + + try: + active_model = self.model_store.get_active() + except Exception: + logger.info("No active model, adding...") + + parent_model = None + if active_model and session_id: + parent_model = active_model + + committed_at = datetime.now() + + updated, _ = self.model_store.add( + { + "key": "models", + "model": model_id, + "parent_model": parent_model, + "session_id": session_id, + "committed_at": committed_at, + "name": name, + } + ) + + if not updated: + raise Exception("Failed to commit model to global model trail") + + self.model_store.set_active(model_id) def get_combiner(self, name): for combiner in self.network.get_combiners(): diff --git a/fedn/network/storage/statestore/mongostatestore.py b/fedn/network/storage/statestore/mongostatestore.py deleted file mode 100644 index 316cd4965..000000000 --- a/fedn/network/storage/statestore/mongostatestore.py +++ /dev/null @@ -1,961 +0,0 @@ -import copy -import uuid -from datetime import datetime - -import pymongo -from google.protobuf.json_format import MessageToDict - -from fedn.common.log_config import logger -from fedn.network.state import ReducerStateToString, StringToReducerState - - -class MongoStateStore: - """Statestore implementation using MongoDB. - - :param network_id: The network id. - :type network_id: str - :param config: The statestore configuration. - :type config: dict - :param defaults: The default configuration. Given by config/settings-reducer.yaml.template - :type defaults: dict - """ - - def __init__(self, network_id, config): - """Constructor.""" - self.__inited = False - try: - self.config = config - self.network_id = network_id - self.mdb = self.connect() - - # FEDn network - self.network = self.mdb["network"] - self.reducer = self.network["reducer"] - self.combiners = self.network["combiners"] - self.clients = self.network["clients"] - self.storage = self.network["storage"] - - # Control - self.control = self.mdb["control"] - self.package = self.control["package"] - self.state = self.control["state"] - self.model = self.control["model"] - self.sessions = self.control["sessions"] - self.rounds = self.control["rounds"] - self.validations = self.control["validations"] - - # Logging - self.status = self.control["status"] - - self.__inited = True - except Exception as e: - logger.error("FAILED TO CONNECT TO MONGODB, {}".format(e)) - self.state = None - self.model = None - self.control = None - self.network = None - self.combiners = None - self.clients = None - raise - - self.init_index() - - def connect(self): - """Establish client connection to MongoDB. - - :param config: Dictionary containing connection strings and security credentials. - :type config: dict - :param network_id: Unique identifier for the FEDn network, used as db name - :type network_id: str - :return: MongoDB client pointing to the db corresponding to network_id - """ - try: - mc = pymongo.MongoClient(**self.config) - # This is so that we check that the connection is live - mc.server_info() - mdb = mc[self.network_id] - return mdb - except Exception: - raise - - def init_index(self): - self.package.create_index([("id", pymongo.DESCENDING)]) - self.clients.create_index([("client_id", pymongo.DESCENDING)]) - - def is_inited(self): - """Check if the statestore is intialized. - - :return: True if initialized, else False. - :rtype: bool - """ - return self.__inited - - def get_config(self): - """Retrive the statestore config. - - :return: The statestore config. - :rtype: dict - """ - data = { - "type": "MongoDB", - "mongo_config": self.config, - "network_id": self.network_id, - } - return data - - def state(self): - """Get the current state. - - :return: The current state. - :rtype: str - """ - return StringToReducerState(self.state.find_one()["current_state"]) - - def transition(self, state): - """Transition to a new state. - - :param state: The new state. - :type state: str - :return: - """ - old_state = self.state.find_one({"state": "current_state"}) - if old_state != state: - return self.state.update_one( - {"state": "current_state"}, - {"$set": {"state": ReducerStateToString(state)}}, - True, - ) - else: - logger.info("Not updating state, already in {}".format(ReducerStateToString(state))) - - def get_sessions(self, limit=None, skip=None, sort_key="_id", sort_order=pymongo.DESCENDING): - """Get all sessions. - - :param limit: The maximum number of sessions to return. - :type limit: int - :param skip: The number of sessions to skip. - :type skip: int - :param sort_key: The key to sort by. - :type sort_key: str - :param sort_order: The sort order. - :type sort_order: pymongo.ASCENDING or pymongo.DESCENDING - :return: Dictionary of sessions in result (array of session objects) and count. - """ - result = None - - if limit is not None and skip is not None: - limit = int(limit) - skip = int(skip) - - result = self.sessions.find().limit(limit).skip(skip).sort(sort_key, sort_order) - else: - result = self.sessions.find().sort(sort_key, sort_order) - - count = self.sessions.count_documents({}) - - return { - "result": result, - "count": count, - } - - def get_session(self, session_id): - """Get session with id. - - :param session_id: The session id. - :type session_id: str - :return: The session. - :rtype: ObjectID - """ - return self.sessions.find_one({"session_id": session_id}) - - def get_session_status(self, session_id): - """Get the session status. - - :param session_id: The session id. - :type session_id: str - :return: The session status. - :rtype: str - """ - session = self.sessions.find_one({"session_id": session_id}) - return session["status"] - - def set_latest_model(self, model_id, session_id=None): - """Set the latest model id. - - :param model_id: The model id. - :type model_id: str - :return: - """ - committed_at = datetime.now() - current_model = self.model.find_one({"key": "current_model"}) - parent_model = None - - # if session_id is set the it means the model is generated from a session - # and we need to set the parent model - # if not the model is uploaded by the user and we don't need to set the parent model - if session_id is not None: - parent_model = current_model["model"] if current_model and "model" in current_model else None - - self.model.insert_one( - { - "key": "models", - "model": model_id, - "parent_model": parent_model, - "session_id": session_id, - "committed_at": committed_at, - } - ) - - self.model.update_one({"key": "current_model"}, {"$set": {"model": model_id}}, True) - self.model.update_one( - {"key": "model_trail"}, - { - "$push": { - "model": model_id, - "committed_at": str(committed_at), - } - }, - True, - ) - - def get_initial_model(self): - """Return model_id for the initial model in the model trail - - :return: The initial model id. None if no model is found. - :rtype: str - """ - result = self.model.find_one({"key": "model_trail"}, sort=[("committed_at", pymongo.ASCENDING)]) - if result is None: - return None - - try: - model_id = result["model"] - if model_id == "" or model_id == " ": - return None - return model_id[0] - except (KeyError, IndexError): - return None - - def get_latest_model(self): - """Return model_id for the latest model in the model_trail - - :return: The latest model id. None if no model is found. - :rtype: str - """ - result = self.model.find_one({"key": "current_model"}) - if result is None: - return None - - try: - model_id = result["model"] - if model_id == "" or model_id == " ": - return None - return model_id - except (KeyError, IndexError): - return None - - def set_current_model(self, model_id: str): - """Set the current model in statestore. - - :param model_id: The model id. - :type model_id: str - :return: - """ - try: - committed_at = datetime.now() - - existing_model = self.model.find_one({"key": "models", "model": model_id}) - - if existing_model is not None: - self.model.update_one({"key": "current_model"}, {"$set": {"model": model_id, "committed_at": committed_at, "session_id": None}}, True) - - return True - except Exception as e: - logger.error("ERROR: {}".format(e)) - - return False - - def get_latest_round(self): - """Get the id of the most recent round. - - :return: The id of the most recent round. - :rtype: ObjectId - """ - return self.rounds.find_one(sort=[("_id", pymongo.DESCENDING)]) - - def get_round(self, id): - """Get round with id. - - :param id: id of round to get - :type id: int - :return: round with id, reducer and combiners - :rtype: ObjectId - """ - return self.rounds.find_one({"round_id": str(id)}) - - def get_rounds(self): - """Get all rounds. - - :return: All rounds. - :rtype: ObjectId - """ - return self.rounds.find() - - def get_validations(self, **kwargs): - """Get validations from the database. - - :param kwargs: query to filter validations - :type kwargs: dict - :return: validations matching query - :rtype: ObjectId - """ - result = self.control.validations.find(kwargs) - return result - - def set_active_compute_package(self, id: str): - """Set the active compute package in statestore. - - :param id: The id of the compute package (not document _id). - :type id: str - :return: True if successful. - :rtype: bool - """ - try: - find = {"id": id} - projection = {"_id": False, "key": False} - - doc = self.control.package.find_one(find, projection) - - if doc is None: - return False - - doc["key"] = "active" - - self.control.package.replace_one({"key": "active"}, doc) - - except Exception as e: - logger.error("ERROR: {}".format(e)) - return False - - return True - - def set_compute_package(self, file_name: str, storage_file_name: str, helper_type: str, name: str = None, description: str = None): - """Set the active compute package in statestore. - - :param file_name: The file_name of the compute package. - :type file_name: str - :return: True if successful. - :rtype: bool - """ - obj = { - "file_name": file_name, - "storage_file_name": storage_file_name, - "helper": helper_type, - "committed_at": datetime.now(), - "name": name, - "description": description, - "id": str(uuid.uuid4()), - } - - self.control.package.update_one( - {"key": "active"}, - {"$set": obj}, - True, - ) - - trail_obj = {**{"key": "package_trail"}, **obj} - - self.control.package.insert_one(trail_obj) - - return True - - def get_compute_package(self): - """Get the active compute package. - - :return: The active compute package. - :rtype: ObjectID - """ - try: - find = {"key": "active"} - projection = {"key": False, "_id": False} - ret = self.control.package.find_one(find, projection) - return ret - except Exception as e: - logger.error("ERROR: {}".format(e)) - return None - - def list_compute_packages(self, limit: int = None, skip: int = None, sort_key="committed_at", sort_order=pymongo.DESCENDING): - """List compute packages in the statestore (paginated). - - :param limit: The maximum number of compute packages to return. - :type limit: int - :param skip: The number of compute packages to skip. - :type skip: int - :param sort_key: The key to sort by. - :type sort_key: str - :param sort_order: The sort order. - :type sort_order: pymongo.ASCENDING or pymongo.DESCENDING - :return: Dictionary of compute packages in result and count. - :rtype: dict - """ - result = None - count = None - - find_option = {"key": "package_trail"} - projection = {"key": False, "_id": False} - - try: - if limit is not None and skip is not None: - result = self.control.package.find(find_option, projection).limit(limit).skip(skip).sort(sort_key, sort_order) - else: - result = self.control.package.find(find_option, projection).sort(sort_key, sort_order) - - count = self.control.package.count_documents(find_option) - - except Exception as e: - logger.error("ERROR: {}".format(e)) - return None - - return { - "result": result or [], - "count": count or 0, - } - - def set_helper(self, helper): - """Set the active helper package in statestore. - - :param helper: The name of the helper package. See helper.py for available helpers. - :type helper: str - :return: - """ - self.control.package.update_one({"key": "active"}, {"$set": {"helper": helper}}, True) - - def get_helper(self): - """Get the active helper package. - - :return: The active helper set for the package. - :rtype: str - """ - ret = self.control.package.find_one({"key": "active"}) - # if local compute package used, then 'package' is None - # if not ret: - # get framework from round_config instead - # ret = self.control.config.find_one({'key': 'round_config'}) - try: - retcheck = ret["helper"] - if retcheck == "" or retcheck == " ": # ugly check for empty string - return None - return retcheck - except (KeyError, IndexError): - return None - - def list_models( - self, - session_id=None, - limit=None, - skip=None, - sort_key="committed_at", - sort_order=pymongo.DESCENDING, - ): - """List all models in the statestore. - - :param session_id: The session id. - :type session_id: str - :param limit: The maximum number of models to return. - :type limit: int - :param skip: The number of models to skip. - :type skip: int - :return: List of models. - :rtype: list - """ - result = None - - find_option = {"key": "models"} if session_id is None else {"key": "models", "session_id": session_id} - - projection = {"_id": False, "key": False} - - if limit is not None and skip is not None: - limit = int(limit) - skip = int(skip) - - result = self.model.find(find_option, projection).limit(limit).skip(skip).sort(sort_key, sort_order) - - else: - result = self.model.find(find_option, projection).sort(sort_key, sort_order) - - count = self.model.count_documents(find_option) - - return { - "result": result, - "count": count, - } - - def get_model_trail(self): - """Get the model trail. - - :return: dictionary of model_id: committed_at - :rtype: dict - """ - # TODO Make it so that model order from db is preserved. - result = self.model.find_one({"key": "model_trail"}) - try: - if result is not None: - committed_at = result["committed_at"] - model = result["model"] - model_dictionary = dict(zip(model, committed_at)) - return model_dictionary - else: - return None - except (KeyError, IndexError): - return None - - def get_model_ancestors(self, model_id: str, limit: int): - """Get the model ancestors. - - :param model_id: The model id. - :type model_id: str - :param limit: The maximum number of ancestors to return. - :type limit: int - :return: List of model ancestors. - :rtype: list - """ - model = self.model.find_one({"key": "models", "model": model_id}) - current_model_id = model["parent_model"] if model is not None else None - result = [] - - for _ in range(limit): - if current_model_id is None: - break - - model = self.model.find_one({"key": "models", "model": current_model_id}) - - if model is not None: - result.append(model) - current_model_id = model["parent_model"] - - return result - - def get_model_descendants(self, model_id: str, limit: int): - """Get the model descendants. - - :param model_id: The model id. - :type model_id: str - :param limit: The maximum number of descendants to return. - :type limit: int - :return: List of model descendants. - :rtype: list - """ - model: object = self.model.find_one({"key": "models", "model": model_id}) - current_model_id: str = model["model"] if model is not None else None - result: list = [] - - for _ in range(limit): - if current_model_id is None: - break - - model: str = self.model.find_one({"key": "models", "parent_model": current_model_id}) - - if model is not None: - result.append(model) - current_model_id = model["model"] - - result.reverse() - - return result - - def get_model(self, model_id): - """Get model with id. - - :param model_id: id of model to get - :type model_id: str - :return: model with id - :rtype: ObjectId - """ - return self.model.find_one({"key": "models", "model": model_id}) - - def get_events(self, **kwargs): - """Get events from the database. - - :param kwargs: query to filter events - :type kwargs: dict - :return: events matching query - :rtype: ObjectId - """ - # check if kwargs is empty - - result = None - count = None - projection = {"_id": False} - - if not kwargs: - result = self.control.status.find({}, projection).sort("timestamp", pymongo.DESCENDING) - count = self.control.status.count_documents({}) - else: - limit = kwargs.pop("limit", None) - skip = kwargs.pop("skip", None) - - if limit is not None and skip is not None: - limit = int(limit) - skip = int(skip) - result = self.control.status.find(kwargs, projection).sort("timestamp", pymongo.DESCENDING).limit(limit).skip(skip) - else: - result = self.control.status.find(kwargs, projection).sort("timestamp", pymongo.DESCENDING) - - count = self.control.status.count_documents(kwargs) - - return { - "result": result, - "count": count, - } - - def get_storage_backend(self): - """Get the storage backend. - - :return: The storage backend. - :rtype: ObjectID - """ - try: - ret = self.storage.find({"status": "enabled"}, projection={"_id": False}) - return ret[0] - except (KeyError, IndexError): - return None - - def set_storage_backend(self, config): - """Set the storage backend. - - :param config: The storage backend configuration. - :type config: dict - :return: - """ - config = copy.deepcopy(config) - config["updated_at"] = str(datetime.now()) - config["status"] = "enabled" - self.storage.update_one({"storage_type": config["storage_type"]}, {"$set": config}, True) - - def set_reducer(self, reducer_data): - """Set the reducer in the statestore. - - :param reducer_data: dictionary of reducer config. - :type reducer_data: dict - :return: - """ - reducer_data["updated_at"] = str(datetime.now()) - self.reducer.update_one({"name": reducer_data["name"]}, {"$set": reducer_data}, True) - - def get_reducer(self): - """Get reducer.config. - - return: reducer config. - rtype: ObjectId - """ - try: - ret = self.reducer.find_one() - return ret - except Exception: - return None - - def get_combiner(self, name): - """Get combiner by name. - - :param name: name of combiner to get. - :type name: str - :return: The combiner. - :rtype: ObjectId - """ - try: - ret = self.combiners.find_one({"name": name}) - return ret - except Exception: - return None - - def get_combiners(self, limit=None, skip=None, sort_key="updated_at", sort_order=pymongo.DESCENDING, projection={}): - """Get all combiners. - - :param limit: The maximum number of combiners to return. - :type limit: int - :param skip: The number of combiners to skip. - :type skip: int - :param sort_key: The key to sort by. - :type sort_key: str - :param sort_order: The sort order. - :type sort_order: pymongo.ASCENDING or pymongo.DESCENDING - :param projection: The projection. - :type projection: dict - :return: Dictionary of combiners in result and count. - :rtype: dict - """ - result = None - count = None - - try: - if limit is not None and skip is not None: - limit = int(limit) - skip = int(skip) - result = self.combiners.find({}, projection).limit(limit).skip(skip).sort(sort_key, sort_order) - else: - result = self.combiners.find({}, projection).sort(sort_key, sort_order) - - count = self.combiners.count_documents({}) - - except Exception: - return None - - return { - "result": result, - "count": count, - } - - def set_combiner(self, combiner_data): - """Set combiner in statestore. - - :param combiner_data: dictionary of combiner config - :type combiner_data: dict - :return: - """ - combiner_data["updated_at"] = str(datetime.now()) - self.combiners.update_one({"name": combiner_data["name"]}, {"$set": combiner_data}, True) - - def delete_combiner(self, combiner): - """Delete a combiner from statestore. - - :param combiner: name of combiner to delete. - :type combiner: str - :return: - """ - try: - self.combiners.delete_one({"name": combiner}) - except Exception: - logger.error( - "Failed to delete combiner: {}".format(combiner), - ) - - def set_client(self, client_data): - """Set client in statestore. - - :param client_data: dictionary of client config. - :type client_data: dict - :return: - """ - client_data["updated_at"] = str(datetime.now()) - try: - # self.clients.update_one({"client_id": client_data["client_id"]}, {"$set": client_data}, True) - self.clients.update_one({"client_id": client_data["client_id"]}, {"$set": {k: v for k, v in client_data.items() if v is not None}}, upsert=True) - except KeyError: - # If client_id is not present, use name as identifier, for backwards compatibility - id = str(uuid.uuid4()) - client_data["client_id"] = id - # self.clients.update_one({"name": client_data["name"]}, {"$set": client_data}, True) - self.clients.update_one({"client_id": client_data["client_id"]}, {"$set": {k: v for k, v in client_data.items() if v is not None}}, upsert=True) - - def get_client(self, client_id): - """Get client by client_id. - - :param client_id: client_id of client to get. - :type client_id: str - :return: The client. None if not found. - :rtype: ObjectId - """ - try: - ret = self.clients.find({"key": client_id}) - if list(ret) == []: - return None - else: - return ret - except Exception: - return None - - def list_clients(self, limit=None, skip=None, status=None, sort_key="last_seen", sort_order=pymongo.DESCENDING): - """List all clients registered on the network. - - :param limit: The maximum number of clients to return. - :type limit: int - :param skip: The number of clients to skip. - :type skip: int - :param status: online | offline - :type status: str - :param sort_key: The key to sort by. - """ - result = None - count = None - - try: - find = {} if status is None else {"status": status} - projection = {"_id": False, "updated_at": False} - - if limit is not None and skip is not None: - limit = int(limit) - skip = int(skip) - result = self.clients.find(find, projection).limit(limit).skip(skip).sort(sort_key, sort_order) - else: - result = self.clients.find(find, projection).sort(sort_key, sort_order) - - count = self.clients.count_documents(find) - - except Exception as e: - logger.error("{}".format(e)) - - return { - "result": result, - "count": count, - } - - def list_combiners_data(self, combiners, sort_key="count", sort_order=pymongo.DESCENDING): - """List all combiner data. - - :param combiners: list of combiners to get data for. - :type combiners: list - :param sort_key: The key to sort by. - :type sort_key: str - :param sort_order: The sort order. - :type sort_order: pymongo.ASCENDING or pymongo.DESCENDING - :return: list of combiner data. - :rtype: list(ObjectId) - """ - result = None - - try: - pipeline = ( - [ - {"$match": {"combiner": {"$in": combiners}, "status": "online"}}, - {"$group": {"_id": "$combiner", "count": {"$sum": 1}}}, - {"$sort": {sort_key: sort_order, "_id": pymongo.ASCENDING}}, - ] - if combiners is not None - else [{"$group": {"_id": "$combiner", "count": {"$sum": 1}}}, {"$sort": {sort_key: sort_order, "_id": pymongo.ASCENDING}}] - ) - - result = self.clients.aggregate(pipeline) - - except Exception as e: - logger.error(e) - - return result - - def report_status(self, msg): - """Write status message to the database. - - :param msg: The status message. - :type msg: str - """ - data = MessageToDict(msg) - - if self.status is not None: - self.status.insert_one(data) - - def report_validation(self, validation): - """Write model validation to database. - - :param validation: The model validation. - :type validation: dict - """ - data = MessageToDict(validation) - - if self.validations is not None: - self.validations.insert_one(data) - - def drop_status(self): - """Drop the status collection.""" - if self.status: - self.status.drop() - - def create_session(self, id=None): - """Create a new session object. - - :param id: The ID of the created session. - :type id: uuid, str - - """ - if not id: - id = uuid.uuid4() - data = {"session_id": str(id)} - self.sessions.insert_one(data) - - def create_round(self, round_data): - """Create a new round. - - :param round_data: Dictionary with round data. - :type round_data: dict - """ - # TODO: Add check if round_id already exists - self.rounds.insert_one(round_data) - - def set_session_config(self, id: str, config) -> None: - """Set the session configuration. - - :param id: The session id - :type id: str - :param config: Session configuration - :type config: dict - """ - self.sessions.update_one({"session_id": str(id)}, {"$push": {"session_config": config}}, True) - - # Added to accomodate new session config structure - def set_session_config_v2(self, id: str, config) -> None: - """Set the session configuration. - - :param id: The session id - :type id: str - :param config: Session configuration - :type config: dict - """ - self.sessions.update_one({"session_id": str(id)}, {"$set": {"session_config": config}}, True) - - def set_session_status(self, id, status): - """Set session status. - - :param round_id: The round unique identifier - :type round_id: str - :param round_status: The status of the session. - """ - self.sessions.update_one({"session_id": str(id)}, {"$set": {"status": status}}, True) - - def set_round_combiner_data(self, data): - """Set combiner round controller data. - - :param data: The combiner data - :type data: dict - """ - self.rounds.update_one({"round_id": str(data["round_id"])}, {"$push": {"combiners": data}}, True) - - def set_round_config(self, round_id, round_config): - """Set round configuration. - - :param round_id: The round unique identifier - :type round_id: str - :param round_config: The round configuration - :type round_config: dict - """ - self.rounds.update_one({"round_id": round_id}, {"$set": {"round_config": round_config}}, True) - - def set_round_status(self, round_id, round_status): - """Set round status. - - :param round_id: The round unique identifier - :type round_id: str - :param round_status: The status of the round. - """ - self.rounds.update_one({"round_id": round_id}, {"$set": {"status": round_status}}, True) - - def set_round_data(self, round_id, round_data): - """Update round metadata - - :param round_id: The round unique identifier - :type round_id: str - :param round_data: The round metadata - :type round_data: dict - """ - self.rounds.update_one({"round_id": round_id}, {"$set": {"round_data": round_data}}, True) - - def update_client_status(self, clients, status): - """Update client status in statestore. - :param client_name: The client name - :type client_name: str - :param status: The client status - :type status: str - :return: None - """ - datetime_now = datetime.now() - filter_query = {"client_id": {"$in": clients}} - - update_query = {"$set": {"last_seen": datetime_now, "status": status}} - self.clients.update_many(filter_query, update_query) diff --git a/fedn/network/storage/statestore/statestorebase.py b/fedn/network/storage/statestore/statestorebase.py deleted file mode 100644 index 7c6681682..000000000 --- a/fedn/network/storage/statestore/statestorebase.py +++ /dev/null @@ -1,49 +0,0 @@ -from abc import ABC, abstractmethod - - -class StateStoreBase(ABC): - """ """ - - def __init__(self): - pass - - @abstractmethod - def state(self): - """Return the current state of the statestore.""" - pass - - @abstractmethod - def transition(self, state): - """Transition the statestore to a new state. - - :param state: The new state. - :type state: str - """ - pass - - @abstractmethod - def set_latest_model(self, model_id): - """Set the latest model id in the statestore. - - :param model_id: The model id. - :type model_id: str - """ - pass - - @abstractmethod - def get_latest_model(self): - """Get the latest model id from the statestore. - - :return: The model object. - :rtype: ObjectId - """ - pass - - @abstractmethod - def is_inited(self): - """Check if the statestore is initialized. - - :return: True if initialized, else False. - :rtype: bool - """ - pass diff --git a/fedn/network/storage/statestore/stores/client_store.py b/fedn/network/storage/statestore/stores/client_store.py index 4f5cd18e6..9753238fd 100644 --- a/fedn/network/storage/statestore/stores/client_store.py +++ b/fedn/network/storage/statestore/stores/client_store.py @@ -1,11 +1,14 @@ +from abc import abstractmethod from datetime import datetime -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import pymongo from bson import ObjectId from pymongo.database import Database +from sqlalchemy import String, func, or_, select +from sqlalchemy.orm import Mapped, mapped_column -from fedn.network.storage.statestore.stores.store import MongoDBStore +from fedn.network.storage.statestore.stores.store import MongoDBStore, MyAbstractBase, Session, SQLStore, Store from .shared import EntityNotFound, from_document @@ -22,9 +25,20 @@ def __init__(self, id: str, name: str, combiner: str, combiner_preferred: str, i self.last_seen = last_seen -class ClientStore(MongoDBStore[Client]): +class ClientStore(Store[Client]): + @abstractmethod + def upsert(self, item: Client) -> Tuple[bool, Any]: + pass + + @abstractmethod + def connected_client_count(self, combiners: List[str]) -> List[Client]: + pass + + +class MongoDBClientStore(ClientStore, MongoDBStore[Client]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) + self.database[self.collection].create_index([("client_id", pymongo.DESCENDING)]) def get(self, id: str) -> Client: """Get an entity by id @@ -45,26 +59,28 @@ def _get_client_by_client_id(self, client_id: str) -> Dict: raise EntityNotFound(f"Entity with client_id {client_id} not found") return document - def _get_client_by_name(self, name: str) -> Dict: - document = self.database[self.collection].find_one({"name": name}) - if document is None: - raise EntityNotFound(f"Entity with name {name} not found") - return document - - def update(self, by_key: str, value: str, item: Client) -> bool: + def update(self, id: str, item: Client) -> Tuple[bool, Any]: try: - result = self.database[self.collection].update_one({by_key: value}, {"$set": item}) - if result.modified_count == 1: - document = self.database[self.collection].find_one({by_key: value}) - return True, from_document(document) - else: - return False, "Entity not found" + existing_client = self.get(id) + + return super().update(existing_client["id"], item) except Exception as e: return False, str(e) def add(self, item: Client) -> Tuple[bool, Any]: return super().add(item) + def upsert(self, item: Client) -> Tuple[bool, Any]: + try: + result = self.database[self.collection].update_one( + {"client_id": item["client_id"]}, {"$set": {k: v for k, v in item.items() if v is not None}}, upsert=True + ) + id = result.upserted_id + document = self.database[self.collection].find_one({"_id": id}) + return True, from_document(document) + except Exception as e: + return False, str(e) + def delete(self, id: str) -> bool: kwargs = {"_id": ObjectId(id)} if ObjectId.is_valid(id) else {"client_id": id} @@ -95,7 +111,7 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI def count(self, **kwargs) -> int: return super().count(**kwargs) - def connected_client_count(self, combiners): + def connected_client_count(self, combiners: List[str]) -> List[Client]: """Count the number of connected clients for each combiner. :param combiners: list of combiners to get data for. @@ -127,3 +143,167 @@ def connected_client_count(self, combiners): result = {} return result + + +class ClientModel(MyAbstractBase): + __tablename__ = "clients" + + client_id: Mapped[str] = mapped_column(String(255), unique=True) + combiner: Mapped[str] = mapped_column(String(255)) + ip: Mapped[Optional[str]] = mapped_column(String(255)) + name: Mapped[str] = mapped_column(String(255)) + package: Mapped[Optional[str]] = mapped_column(String(255)) + status: Mapped[str] = mapped_column(String(255)) + last_seen: Mapped[datetime] = mapped_column(default=datetime.now()) + + +def from_row(row: ClientModel) -> Client: + return { + "id": row.id, + "client_id": row.client_id, + "combiner": row.combiner, + "ip": row.ip, + "name": row.name, + "package": row.package, + "status": row.status, + "last_seen": row.last_seen, + } + + +class SQLClientStore(ClientStore, SQLStore[Client]): + def get(self, id: str) -> Client: + with Session() as session: + stmt = select(ClientModel).where(or_(ClientModel.id == id, ClientModel.client_id == id)) + item = session.scalars(stmt).first() + + if item is None: + raise EntityNotFound(f"Entity with (id | client_id) {id} not found") + + return from_row(item) + + def update(self, id: str, item: Client) -> Tuple[bool, Any]: + with Session() as session: + stmt = select(ClientModel).where(or_(ClientModel.id == id, ClientModel.client_id == id)) + existing_item = session.scalars(stmt).first() + + if existing_item is None: + raise EntityNotFound(f"Entity with (id | client_id) {id} not found") + + existing_item.combiner = item.get("combiner") + existing_item.ip = item.get("ip") + existing_item.name = item.get("name") + existing_item.package = item.get("package") + existing_item.status = item.get("status") + existing_item.last_seen = item.get("last_seen") + + session.commit() + + return True, from_row(existing_item) + + def add(self, item: Client) -> Tuple[bool, Any]: + with Session() as session: + entity = ClientModel( + client_id=item.get("client_id"), + combiner=item.get("combiner"), + ip=item.get("ip"), + name=item.get("name"), + package=item.get("package"), + status=item.get("status"), + last_seen=item.get("last_seen"), + ) + + session.add(entity) + session.commit() + + return True, from_row(entity) + + def delete(self, id): + raise NotImplementedError + + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs): + with Session() as session: + stmt = select(ClientModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(ClientModel, key) == value) + + _sort_order: str = "DESC" if sort_order == pymongo.DESCENDING else "ASC" + _sort_key: str = sort_key or "committed_at" + + if _sort_key in ClientModel.__table__.columns: + sort_obj = ClientModel.__table__.columns.get(_sort_key) if _sort_order == "ASC" else ClientModel.__table__.columns.get(_sort_key).desc() + + stmt = stmt.order_by(sort_obj) + + if limit != 0: + stmt = stmt.offset(skip or 0).limit(limit) + + items = session.scalars(stmt).all() + + result = [] + for i in items: + result.append(from_row(i)) + + count = session.scalar(select(func.count()).select_from(ClientModel)) + + return {"count": count, "result": result} + + def count(self, **kwargs): + with Session() as session: + stmt = select(func.count()).select_from(ClientModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(ClientModel, key) == value) + + count = session.scalar(stmt) + + return count + + def upsert(self, item: Client) -> Tuple[bool, Any]: + with Session() as session: + id = item.get("id") + client_id = item.get("client_id") + + stmt = select(ClientModel).where(or_(ClientModel.id == id, ClientModel.client_id == client_id)) + existing_item = session.scalars(stmt).first() + + if existing_item is None: + entity = ClientModel( + client_id=item.get("client_id"), + combiner=item.get("combiner"), + ip=item.get("ip"), + name=item.get("name"), + package=item.get("package"), + status=item.get("status"), + last_seen=item.get("last_seen"), + ) + + session.add(entity) + session.commit() + + return True, from_row(entity) + + existing_item.combiner = item.get("combiner") + existing_item.ip = item.get("ip") + existing_item.name = item.get("name") + existing_item.package = item.get("package") + existing_item.status = item.get("status") + existing_item.last_seen = item.get("last_seen") + + session.commit() + + return True, from_row(existing_item) + + def connected_client_count(self, combiners): + with Session() as session: + stmt = select(ClientModel.combiner, func.count(ClientModel.combiner)).group_by(ClientModel.combiner) + if combiners: + stmt = stmt.where(ClientModel.combiner.in_(combiners)) + + items = session.execute(stmt).fetchall() + + result = [] + for i in items: + result.append({"combiner": i[0], "count": i[1]}) + + return result diff --git a/fedn/network/storage/statestore/stores/combiner_store.py b/fedn/network/storage/statestore/stores/combiner_store.py index 8a938d06c..448985a84 100644 --- a/fedn/network/storage/statestore/stores/combiner_store.py +++ b/fedn/network/storage/statestore/stores/combiner_store.py @@ -1,10 +1,13 @@ -from typing import Any, Dict, List, Tuple +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple import pymongo from bson import ObjectId from pymongo.database import Database +from sqlalchemy import String, func, or_, select +from sqlalchemy.orm import Mapped, mapped_column -from fedn.network.storage.statestore.stores.store import MongoDBStore +from fedn.network.storage.statestore.stores.store import MongoDBStore, MyAbstractBase, Session, SQLStore, Store from .shared import EntityNotFound, from_document @@ -39,7 +42,11 @@ def __init__( self.updated_at = updated_at -class CombinerStore(MongoDBStore[Combiner]): +class CombinerStore(Store[Combiner]): + pass + + +class MongoDBCombinerStore(MongoDBStore[Combiner]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) @@ -101,3 +108,104 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI def count(self, **kwargs) -> int: return super().count(**kwargs) + + +class CombinerModel(MyAbstractBase): + __tablename__ = "combiners" + + address: Mapped[str] = mapped_column(String(255)) + fqdn: Mapped[Optional[str]] = mapped_column(String(255)) + ip: Mapped[Optional[str]] = mapped_column(String(255)) + name: Mapped[str] = mapped_column(String(255)) + parent: Mapped[Optional[str]] = mapped_column(String(255)) + port: Mapped[int] + updated_at: Mapped[datetime] = mapped_column(default=datetime.now()) + + +def from_row(row: CombinerModel) -> Combiner: + return { + "id": row.id, + "committed_at": row.committed_at, + "address": row.address, + "ip": row.ip, + "name": row.name, + "parent": row.parent, + "fqdn": row.fqdn, + "port": row.port, + "updated_at": row.updated_at, + } + + +class SQLCombinerStore(CombinerStore, SQLStore[Combiner]): + def get(self, id: str) -> Combiner: + with Session() as session: + stmt = select(CombinerModel).where(or_(CombinerModel.id == id, CombinerModel.name == id)) + item = session.scalars(stmt).first() + if item is None: + raise EntityNotFound("Entity not found") + return from_row(item) + + def update(self, id, item): + raise NotImplementedError + + def add(self, item): + with Session() as session: + entity = CombinerModel( + address=item["address"], + fqdn=item["fqdn"], + ip=item["ip"], + name=item["name"], + parent=item["parent"], + port=item["port"], + ) + session.add(entity) + session.commit() + return True, from_row(entity) + + def delete(self, id: str) -> bool: + with Session() as session: + stmt = select(CombinerModel).where(CombinerModel.id == id) + item = session.scalars(stmt).first() + if item is None: + raise EntityNotFound("Entity not found") + session.delete(item) + return True + + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs): + with Session() as session: + stmt = select(CombinerModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(CombinerModel, key) == value) + + _sort_order: str = "DESC" if sort_order == pymongo.DESCENDING else "ASC" + _sort_key: str = sort_key or "committed_at" + + if _sort_key in CombinerModel.__table__.columns: + sort_obj = CombinerModel.__table__.columns.get(_sort_key) if _sort_order == "ASC" else CombinerModel.__table__.columns.get(_sort_key).desc() + + stmt = stmt.order_by(sort_obj) + + if limit != 0: + stmt = stmt.offset(skip or 0).limit(limit) + + items = session.scalars(stmt).all() + + result = [] + for i in items: + result.append(from_row(i)) + + count = session.scalar(select(func.count()).select_from(CombinerModel)) + + return {"count": count, "result": result} + + def count(self, **kwargs): + with Session() as session: + stmt = select(func.count()).select_from(CombinerModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(CombinerModel, key) == value) + + count = session.scalar(stmt) + + return count diff --git a/fedn/network/storage/statestore/stores/model_store.py b/fedn/network/storage/statestore/stores/model_store.py index d6b96121b..98334cd53 100644 --- a/fedn/network/storage/statestore/stores/model_store.py +++ b/fedn/network/storage/statestore/stores/model_store.py @@ -1,13 +1,16 @@ +from abc import abstractmethod from datetime import datetime from typing import Any, Dict, List, Tuple import pymongo from bson import ObjectId from pymongo.database import Database +from sqlalchemy import func, select +from sqlalchemy.orm import aliased -from fedn.network.storage.statestore.stores.store import MongoDBStore - -from .shared import EntityNotFound, from_document +from fedn.network.storage.statestore.stores.shared import EntityNotFound, from_document +from fedn.network.storage.statestore.stores.sql.shared import ModelModel +from fedn.network.storage.statestore.stores.store import MongoDBStore, Session, SQLStore, Store class Model: @@ -20,9 +23,35 @@ def __init__(self, id: str, key: str, model: str, parent_model: str, session_id: self.committed_at = committed_at -class ModelStore(MongoDBStore[Model]): +class ModelStore(Store[Model]): + @abstractmethod + def list_descendants(self, id: str, limit: int) -> List[Model]: + pass + + @abstractmethod + def list_ancestors(self, id: str, limit: int, include_self: bool = False, reverse: bool = False) -> List[Model]: + pass + + @abstractmethod + def get_active(self) -> str: + pass + + @abstractmethod + def set_active(self, id: str) -> bool: + pass + + +def validate(item: Model) -> Tuple[bool, str]: + if "model" not in item or not item["model"]: + return False, "Model is required" + + return True, "" + + +class MongoDBModelStore(ModelStore, MongoDBStore[Model]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) + self.database[self.collection].create_index([("model", pymongo.DESCENDING)]) def get(self, id: str) -> Model: """Get an entity by id @@ -45,18 +74,12 @@ def get(self, id: str) -> Model: return from_document(document) - def _validate(self, item: Model) -> Tuple[bool, str]: - if "model" not in item or not item["model"]: - return False, "Model is required" - - return True, "" - def _complement(self, item: Model): if "key" not in item or item["key"] is None: item["key"] = "models" def update(self, id: str, item: Model) -> Tuple[bool, Any]: - valid, message = self._validate(item) + valid, message = validate(item) if not valid: return False, message @@ -65,7 +88,13 @@ def update(self, id: str, item: Model) -> Tuple[bool, Any]: return super().update(id, item) def add(self, item: Model) -> Tuple[bool, Any]: - raise NotImplementedError("Add not implemented for ModelStore") + valid, message = validate(item) + if not valid: + return False, message + + self._complement(item) + + return super().add(item) def delete(self, id: str) -> bool: raise NotImplementedError("Delete not implemented for ModelStore") @@ -216,6 +245,181 @@ def set_active(self, id: str) -> bool: if model is None: raise EntityNotFound(f"Entity with (id | model) {id} not found") - self.database[self.collection].update_one({"key": "current_model"}, {"$set": {"model": model["model"]}}) + self.database[self.collection].update_one({"key": "current_model"}, {"$set": {"model": model["model"]}}, upsert=True) + + return True + + +def from_row(row: ModelModel) -> Model: + return { + "id": row.id, + "model": row.id, + "committed_at": row.committed_at, + "parent_model": row.parent_model, + "session_id": row.session_id, + "name": row.name, + "active": row.active, + } + + +class SQLModelStore(ModelStore, SQLStore[Model]): + def get(self, id: str) -> Model: + with Session() as session: + stmt = select(ModelModel).where(ModelModel.id == id) + item = session.scalars(stmt).first() + if item is None: + raise EntityNotFound("Entity not found") + return from_row(item) + + def update(self, id: str, item: Model) -> Tuple[bool, Any]: + valid, message = validate(item) + if not valid: + return False, message + with Session() as session: + stmt = select(ModelModel).where(ModelModel.id == id) + existing_item = session.execute(stmt).first() + if existing_item is None: + raise EntityNotFound(f"Entity not found {id}") + + existing_item.parent_model = item["parent_model"] + existing_item.name = item["name"] + existing_item.session_id = item.get("session_id") + existing_item.committed_at = item["committed_at"] + existing_item.active = item["active"] + + return True, from_row(existing_item) + + def add(self, item: Model) -> Tuple[bool, Any]: + valid, message = validate(item) + if not valid: + return False, message + + with Session() as session: + id: str = None + if "model" in item: + id = item["model"] + elif "id" in item: + id = item["id"] + + item = ModelModel( + id=id, + parent_model=item["parent_model"], + name=item["name"], + session_id=item.get("session_id"), + committed_at=item["committed_at"], + active=item["active"] if "active" in item else False, + ) + session.add(item) + session.commit() + return True, from_row(item) + + def delete(self, id: str) -> bool: + raise NotImplementedError + + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs): + with Session() as session: + stmt = select(ModelModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(ModelModel, key) == value) + + _sort_order: str = "DESC" if sort_order == pymongo.DESCENDING else "ASC" + _sort_key: str = sort_key or "committed_at" + + if _sort_key in ModelModel.__table__.columns: + sort_obj = ModelModel.__table__.columns.get(_sort_key) if _sort_order == "ASC" else ModelModel.__table__.columns.get(_sort_key).desc() + + stmt = stmt.order_by(sort_obj) + + if limit != 0: + stmt = stmt.offset(skip or 0).limit(limit) + + items = session.scalars(stmt).all() + + result = [] + for i in items: + result.append(from_row(i)) + + count = session.scalar(select(func.count()).select_from(ModelModel)) + + return {"count": count, "result": result} + + def count(self, **kwargs): + with Session() as session: + stmt = select(func.count()).select_from(ModelModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(ModelModel, key) == value) + + count = session.scalar(stmt) + + return count + + def list_descendants(self, id: str, limit: int): + with Session() as session: + # Define the recursive CTE + descendant = aliased(ModelModel) # Alias for recursion + cte = select(ModelModel).where(ModelModel.parent_model == id).cte(name="descendant_cte", recursive=True) + cte = cte.union_all(select(descendant).where(descendant.parent_model == cte.c.id)) + + # Final query with optional limit + query = select(cte) + if limit is not None: + query = query.limit(limit) + + # Execute the query + items = session.execute(query).fetchall() + + # Return the list of descendants + result = [] + for i in items: + result.append(from_row(i)) + + return result + + def list_ancestors(self, id: str, limit: int, include_self=False, reverse=False): + with Session() as session: + # Define the recursive CTE + ancestor = aliased(ModelModel) # Alias for recursion + cte = select(ModelModel).where(ModelModel.id == id).cte(name="ancestor_cte", recursive=True) + cte = cte.union_all(select(ancestor).where(ancestor.id == cte.c.parent_model)) + + # Final query with optional limit + query = select(cte) + if limit is not None: + query = query.limit(limit) + + # Execute the query + items = session.execute(query).fetchall() + + # Return the list of ancestors + result = [] + for i in items: + result.append(from_row(i)) + + return result + + def get_active(self) -> str: + with Session() as session: + active_stmt = select(ModelModel).where(ModelModel.active) + active_item = session.scalars(active_stmt).first() + if active_item: + return active_item.id + raise EntityNotFound("Entity not found") + + def set_active(self, id: str) -> bool: + with Session() as session: + active_stmt = select(ModelModel).where(ModelModel.active) + active_item = session.scalars(active_stmt).first() + if active_item: + active_item.active = False + + stmt = select(ModelModel).where(ModelModel.id == id) + item = session.scalars(stmt).first() + + if item is None: + raise EntityNotFound("Entity not found") + item.active = True + session.commit() return True diff --git a/fedn/network/storage/statestore/stores/package_store.py b/fedn/network/storage/statestore/stores/package_store.py index 44dece2ab..55d74d5e2 100644 --- a/fedn/network/storage/statestore/stores/package_store.py +++ b/fedn/network/storage/statestore/stores/package_store.py @@ -1,15 +1,17 @@ import uuid +from abc import abstractmethod from datetime import datetime -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import pymongo from bson import ObjectId from pymongo.database import Database +from sqlalchemy import String, func, select +from sqlalchemy.orm import Mapped, mapped_column from werkzeug.utils import secure_filename -from fedn.network.storage.statestore.stores.store import MongoDBStore - -from .shared import EntityNotFound +from fedn.network.storage.statestore.stores.shared import EntityNotFound +from fedn.network.storage.statestore.stores.store import MongoDBStore, MyAbstractBase, Session, SQLStore, Store def from_document(data: dict, active_package: dict): @@ -33,7 +35,16 @@ def from_document(data: dict, active_package: dict): class Package: def __init__( - self, id: str, key: str, committed_at: datetime, description: str, file_name: str, helper: str, name: str, storage_file_name: str, active: bool = False + self, + id: str, + key: str, + committed_at: datetime, + description: str, + file_name: str, + helper: str, + name: str, + storage_file_name: str, + active: bool = False, ): self.key = key self.committed_at = committed_at @@ -46,9 +57,57 @@ def __init__( self.active = active -class PackageStore(MongoDBStore[Package]): +class PackageStore(Store[Package]): + @abstractmethod + def set_active(self, id: str) -> bool: + pass + + @abstractmethod + def get_active(self) -> Package: + pass + + @abstractmethod + def set_active_helper(self, helper: str) -> bool: + pass + + @abstractmethod + def delete_active(self): + pass + + +def allowed_file_extension(filename: str, ALLOWED_EXTENSIONS={"gz", "bz2", "tar", "zip", "tgz"}) -> bool: + """Check if file extension is allowed. + + :param filename: The filename to check. + :type filename: str + :return: True and extension str if file extension is allowed, else False and None. + :rtype: Tuple (bool, str) + """ + if "." in filename: + extension = filename.rsplit(".", 1)[1].lower() + if extension in ALLOWED_EXTENSIONS: + return True + + return False + + +def validate(item: Package) -> Tuple[bool, str]: + if "file_name" not in item or not item["file_name"]: + return False, "File name is required" + + if not allowed_file_extension(item["file_name"]): + return False, "File extension not allowed" + + if "helper" not in item or not item["helper"]: + return False, "Helper is required" + + return True, "" + + +class MongoDBPackageStore(PackageStore, MongoDBStore[Package]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) + self.database[self.collection].create_index([("id", pymongo.DESCENDING)]) def get(self, id: str) -> Package: """Get an entity by id @@ -66,18 +125,6 @@ def get(self, id: str) -> Package: return from_document(document, response_active) - def _validate(self, item: Package) -> Tuple[bool, str]: - if "file_name" not in item or not item["file_name"]: - return False, "File name is required" - - if not self._allowed_file_extension(item["file_name"]): - return False, "File extension not allowed" - - if "helper" not in item or not item["helper"]: - return False, "Helper is required" - - return True, "" - def _complement(self, item: Package): if "id" not in item or item.id is None: item["id"] = str(uuid.uuid4()) @@ -111,12 +158,12 @@ def set_active(self, id: str) -> bool: committed_at = datetime.now() obj_to_insert = { "key": "active", - "id": document["id"], + "id": document["id"] if "id" in document else "", "committed_at": committed_at, - "description": document["description"], - "file_name": document["file_name"], - "helper": document["helper"], - "name": document["name"], + "description": document["description"] if "description" in document else "", + "file_name": document["file_name"] if "file_name" in document else "", + "helper": document["helper"] if "helper" in document else "", + "name": document["name"] if "name" in document else "", "storage_file_name": document["storage_file_name"], } @@ -133,7 +180,9 @@ def get_active(self) -> Package: if response is None: raise EntityNotFound("Entity not found") - return from_document(response, {"id": response["id"]}) + active_package = {"id": response["id"]} if "id" in response else {} + + return from_document(response, active_package=active_package) def set_active_helper(self, helper: str) -> bool: """Set the active helper @@ -149,26 +198,11 @@ def set_active_helper(self, helper: str) -> bool: except Exception: return False - def _allowed_file_extension(self, filename: str, ALLOWED_EXTENSIONS={"gz", "bz2", "tar", "zip", "tgz"}) -> bool: - """Check if file extension is allowed. - - :param filename: The filename to check. - :type filename: str - :return: True and extension str if file extension is allowed, else False and None. - :rtype: Tuple (bool, str) - """ - if "." in filename: - extension = filename.rsplit(".", 1)[1].lower() - if extension in ALLOWED_EXTENSIONS: - return True - - return False - def update(self, id: str, item: Package) -> bool: raise NotImplementedError("Update not implemented for PackageStore") def add(self, item: Package) -> Tuple[bool, Any]: - valid, message = self._validate(item) + valid, message = validate(item) if not valid: return False, message @@ -198,7 +232,7 @@ def delete(self, id: str) -> bool: return True - def delete_active(self): + def delete_active(self) -> bool: kwargs = {"key": "active"} document_active = self.database[self.collection].find_one(kwargs) @@ -208,7 +242,14 @@ def delete_active(self): return super().delete(document_active["_id"]) - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[Package]]: + def list( + self, + limit: int, + skip: int, + sort_key: str, + sort_order=pymongo.DESCENDING, + **kwargs, + ) -> Dict[int, List[Package]]: """List entities param limit: The maximum number of entities to return type: int @@ -238,3 +279,177 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI def count(self, **kwargs) -> int: kwargs["key"] = "package_trail" return super().count(**kwargs) + + +class PackageModel(MyAbstractBase): + __tablename__ = "packages" + + active: Mapped[bool] = mapped_column(default=False) + description: Mapped[Optional[str]] = mapped_column(String(255)) + file_name: Mapped[str] = mapped_column(String(255)) + helper: Mapped[str] = mapped_column(String(255)) + name: Mapped[str] = mapped_column(String(255)) + storage_file_name: Mapped[str] = mapped_column(String(255)) + + +def from_row(row: PackageModel) -> Package: + return { + "id": row.id, + "committed_at": row.committed_at, + "description": row.description, + "file_name": row.file_name, + "helper": row.helper, + "name": row.name, + "storage_file_name": row.storage_file_name, + "active": row.active, + } + + +class SQLPackageStore(PackageStore, SQLStore[Package]): + def _complement(self, item: Package): + if "committed_at" not in item or item.committed_at is None: + item["committed_at"] = datetime.now() + + extension = item["file_name"].rsplit(".", 1)[1].lower() + + if "storage_file_name" not in item or item.storage_file_name is None: + storage_file_name = secure_filename(f"{str(uuid.uuid4())}.{extension}") + item["storage_file_name"] = storage_file_name + + def add(self, item: Package) -> Tuple[bool, Any]: + valid, message = validate(item) + if not valid: + return False, message + + self._complement(item) + with Session() as session: + item = PackageModel( + committed_at=item["committed_at"], + description=item["description"] if "description" in item else "", + file_name=item["file_name"], + helper=item["helper"], + name=item["name"] if "name" in item else "", + storage_file_name=item["storage_file_name"], + ) + session.add(item) + session.commit() + return True, from_row(item) + + def get(self, id: str) -> Package: + with Session() as session: + stmt = select(PackageModel).where(PackageModel.id == id) + item = session.scalars(stmt).first() + if item is None: + raise EntityNotFound("Entity not found") + return from_row(item) + + def update(self, id: str, item: Package) -> bool: + raise NotImplementedError + + def delete(self, id: str) -> bool: + with Session() as session: + stmt = select(PackageModel).where(PackageModel.id == id) + item = session.scalars(stmt).first() + if item is None: + raise EntityNotFound("Entity not found") + session.delete(item) + session.commit() + return True + + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs): + with Session() as session: + stmt = select(PackageModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(PackageModel, key) == value) + + _sort_order: str = "DESC" if sort_order == pymongo.DESCENDING else "ASC" + _sort_key: str = sort_key or "committed_at" + + if _sort_key in PackageModel.__table__.columns: + sort_obj = PackageModel.__table__.columns.get(_sort_key) if _sort_order == "ASC" else PackageModel.__table__.columns.get(_sort_key).desc() + + stmt = stmt.order_by(sort_obj) + + if limit != 0: + stmt = stmt.offset(skip or 0).limit(limit) + + items = session.scalars(stmt).all() + + result = [] + for i in items: + result.append(from_row(i)) + + count = session.scalar(select(func.count()).select_from(PackageModel)) + + return {"count": count, "result": result} + + def count(self, **kwargs): + with Session() as session: + stmt = select(func.count()).select_from(PackageModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(PackageModel, key) == value) + + count = session.scalar(stmt) + + return count + + def set_active(self, id: str): + with Session() as session: + active_stmt = select(PackageModel).where(PackageModel.active) + active_item = session.scalars(active_stmt).first() + if active_item: + active_item.active = False + + stmt = select(PackageModel).where(PackageModel.id == id) + item = session.scalars(stmt).first() + + if item is None: + raise EntityNotFound("Entity not found") + + item.active = True + session.commit() + return True + + def get_active(self) -> Package: + with Session() as session: + active_stmt = select(PackageModel).where(PackageModel.active) + active_item = session.scalars(active_stmt).first() + if active_item: + return from_row(active_item) + raise EntityNotFound("Entity not found") + + def set_active_helper(self, helper: str) -> bool: + if not helper or helper == "" or helper not in ["numpyhelper", "binaryhelper", "androidhelper"]: + raise ValueError() + + with Session() as session: + active_stmt = select(PackageModel).where(PackageModel.active) + active_item = session.scalars(active_stmt).first() + if active_item: + active_item.helper = helper + session.commit() + return True + item = PackageModel( + committed_at=datetime.now(), + description="", + file_name="", + helper=helper, + name="", + storage_file_name="", + active=True, + ) + + session.add(item) + session.commit() + + def delete_active(self) -> bool: + with Session() as session: + active_stmt = select(PackageModel).where(PackageModel.active) + active_item = session.scalars(active_stmt).first() + if active_item: + active_item.active = False + session.commit() + return True + raise EntityNotFound("Entity not found") diff --git a/fedn/network/storage/statestore/stores/prediction_store.py b/fedn/network/storage/statestore/stores/prediction_store.py index 5b918c41e..c019b72c1 100644 --- a/fedn/network/storage/statestore/stores/prediction_store.py +++ b/fedn/network/storage/statestore/stores/prediction_store.py @@ -1,9 +1,12 @@ -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import pymongo from pymongo.database import Database +from sqlalchemy import ForeignKey, String, func, select +from sqlalchemy.orm import Mapped, mapped_column -from fedn.network.storage.statestore.stores.store import MongoDBStore +from fedn.network.storage.statestore.stores.shared import EntityNotFound +from fedn.network.storage.statestore.stores.store import MongoDBStore, MyAbstractBase, Session, SQLStore, Store class Prediction: @@ -21,7 +24,11 @@ def __init__( self.receiver = receiver -class PredictionStore(MongoDBStore[Prediction]): +class PredictionStore(Store[Prediction]): + pass + + +class MongoDBPredictionStore(MongoDBStore[Prediction]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) @@ -61,3 +68,156 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI return: A dictionary with the count and a list of entities """ return super().list(limit, skip, sort_key or "timestamp", sort_order, **kwargs) + + +class PredictionModel(MyAbstractBase): + __tablename__ = "predictions" + + correlation_id: Mapped[str] + data: Mapped[Optional[str]] + model_id: Mapped[Optional[str]] = mapped_column(ForeignKey("models.id")) + receiver_name: Mapped[Optional[str]] = mapped_column(String(255)) + receiver_role: Mapped[Optional[str]] = mapped_column(String(255)) + sender_name: Mapped[Optional[str]] = mapped_column(String(255)) + sender_role: Mapped[Optional[str]] = mapped_column(String(255)) + timestamp: Mapped[str] = mapped_column(String(255)) + prediction_id: Mapped[str] = mapped_column(String(255)) + + +def from_row(row: PredictionModel) -> Prediction: + return { + "id": row.id, + "model_id": row.model_id, + "data": row.data, + "correlation_id": row.correlation_id, + "timestamp": row.timestamp, + "prediction_id": row.prediction_id, + "sender": {"name": row.sender_name, "role": row.sender_role}, + "receiver": {"name": row.receiver_name, "role": row.receiver_role}, + } + + +class SQLPredictionStore(PredictionStore, SQLStore[Prediction]): + def get(self, id: str) -> Prediction: + with Session() as session: + stmt = select(Prediction).where(Prediction.id == id) + item = session.scalars(stmt).first() + + if item is None: + raise EntityNotFound(f"Entity with (id | round_id) {id} not found") + + return from_row(item) + + def update(self, id: str, item: Prediction) -> bool: + raise NotImplementedError("Update not implemented for PredictionStore") + + def add(self, item: Prediction) -> Tuple[bool, Any]: + with Session() as session: + sender = item["sender"] if "sender" in item else None + receiver = item["receiver"] if "receiver" in item else None + + validation = PredictionModel( + correlation_id=item.get("correlationId") or item.get("correlation_id"), + data=item.get("data"), + model_id=item.get("modelId") or item.get("model_id"), + receiver_name=receiver.get("name"), + receiver_role=receiver.get("role"), + sender_name=sender.get("name"), + sender_role=sender.get("role"), + prediction_id=item.get("predictionId") or item.get("prediction_id"), + timestamp=item.get("timestamp"), + ) + + session.add(validation) + session.commit() + + return True, validation + + def delete(self, id: str) -> bool: + raise NotImplementedError("Delete not implemented for PredictionStore") + + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs): + with Session() as session: + stmt = select(PredictionModel) + + for key, value in kwargs.items(): + if key == "_id": + key = "id" + elif key == "sender.name": + key = "sender_name" + elif key == "sender.role": + key = "sender_role" + elif key == "receiver.name": + key = "receiver_name" + elif key == "receiver.role": + key = "receiver_role" + elif key == "correlationId": + key = "correlation_id" + elif key == "modelId": + key = "model_id" + + stmt = stmt.where(getattr(PredictionModel, key) == value) + + if sort_key: + _sort_order: str = "DESC" if sort_order == pymongo.DESCENDING else "ASC" + _sort_key: str = sort_key + + if _sort_key == "_id": + _sort_key = "id" + elif _sort_key == "sender.name": + _sort_key = "sender_name" + elif _sort_key == "sender.role": + _sort_key = "sender_role" + elif _sort_key == "receiver.name": + _sort_key = "receiver_name" + elif _sort_key == "receiver.role": + _sort_key = "receiver_role" + elif _sort_key == "correlationId": + _sort_key = "correlation_id" + elif _sort_key == "modelId": + _sort_key = "model_id" + + if _sort_key in PredictionModel.__table__.columns: + sort_obj = ( + PredictionModel.__table__.columns.get(_sort_key) if _sort_order == "ASC" else PredictionModel.__table__.columns.get(_sort_key).desc() + ) + + stmt = stmt.order_by(sort_obj) + + if limit != 0: + stmt = stmt.offset(skip or 0).limit(limit) + + items = session.execute(stmt) + + result = [] + + for item in items: + (r,) = item + + result.append(from_row(r)) + + return {"count": len(result), "result": result} + + def count(self, **kwargs): + with Session() as session: + stmt = select(func.count()).select_from(PredictionModel) + + for key, value in kwargs.items(): + if key == "sender.name": + key = "sender_name" + elif key == "sender.role": + key = "sender_role" + elif key == "receiver.name": + key = "receiver_name" + elif key == "receiver.role": + key = "receiver_role" + elif key == "correlationId": + key = "correlation_id" + elif key == "modelId": + key = "model_id" + + stmt = stmt.where(getattr(PredictionModel, key) == value) + + count = session.scalar(stmt) + + return count diff --git a/fedn/network/storage/statestore/stores/round_store.py b/fedn/network/storage/statestore/stores/round_store.py index 9148f0c63..c74b8d599 100644 --- a/fedn/network/storage/statestore/stores/round_store.py +++ b/fedn/network/storage/statestore/stores/round_store.py @@ -1,9 +1,15 @@ +from abc import abstractmethod from typing import Any, Dict, List, Tuple import pymongo +from bson import ObjectId from pymongo.database import Database +from sqlalchemy import Integer, func, or_, select -from fedn.network.storage.statestore.stores.store import MongoDBStore +from fedn.network.storage.statestore.stores.sql.shared import RoundCombinerModel, RoundConfigModel, RoundDataModel, RoundModel +from fedn.network.storage.statestore.stores.store import MongoDBStore, Session, SQLStore, Store + +from .shared import EntityNotFound, from_document class Round: @@ -16,9 +22,16 @@ def __init__(self, id: str, round_id: str, status: str, round_config: dict, comb self.round_data = round_data -class RoundStore(MongoDBStore[Round]): +class RoundStore(Store[Round]): + @abstractmethod + def get_latest_round_id(self) -> int: + pass + + +class MongoDBRoundStore(RoundStore, MongoDBStore[Round]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) + self.database[self.collection].create_index([("round_id", pymongo.DESCENDING)]) def get(self, id: str) -> Round: """Get an entity by id @@ -26,13 +39,31 @@ def get(self, id: str) -> Round: type: str return: The entity """ - return super().get(id) + kwargs = {} + if ObjectId.is_valid(id): + id_obj = ObjectId(id) + kwargs["_id"] = id_obj + else: + kwargs["round_id"] = id + + document = self.database[self.collection].find_one(kwargs) + + if document is None: + raise EntityNotFound(f"Entity with (id | model) {id} not found") - def update(self, id: str, item: Round) -> bool: - raise NotImplementedError("Update not implemented for RoundStore") + return from_document(document) + + def update(self, id: str, item: Round) -> Tuple[bool, Any]: + return super().update(id, item) def add(self, item: Round) -> Tuple[bool, Any]: - raise NotImplementedError("Add not implemented for RoundStore") + round_id = item["round_id"] + existing = self.database[self.collection].find_one({"round_id": round_id}) + + if existing is not None: + return False, "Round with round_id already exists" + + return super().add(item) def delete(self, id: str) -> bool: raise NotImplementedError("Delete not implemented for RoundStore") @@ -54,3 +85,361 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI return: The entities """ return super().list(limit, skip, sort_key or "round_id", sort_order, **kwargs) + + def get_latest_round_id(self) -> int: + obj = self.database[self.collection].find_one(sort=[("_id", pymongo.DESCENDING)]) + if obj: + return int(obj["round_id"]) + else: + return 0 + + +def from_row(row: RoundModel) -> Round: + round_data = None + if row.round_data is not None: + round_data = { + "time_commit": row.round_data.time_commit, + "reduce": { + "time_aggregate_model": row.round_data.reduce_time_aggregate_model, + "time_fetch_model": row.round_data.reduce_time_fetch_model, + "time_load_model": row.round_data.reduce_time_load_model, + }, + } + + round_config = None + + if row.round_config is not None: + round_config = { + "aggregator": row.round_config.aggregator, + "round_timeout": row.round_config.round_timeout, + "buffer_size": row.round_config.buffer_size, + "delete_models_storage": row.round_config.delete_models_storage, + "clients_required": row.round_config.clients_required, + "validate": row.round_config.validate, + "helper_type": row.round_config.helper_type, + "task": row.round_config.task, + "model_id": row.round_config.model_id, + "session_id": row.round_config.session_id, + "round_id": row.round_config.round_id, + "rounds": row.round_config.rounds, + } + + combiners = [ + { + "model_id": combiner.model_id, + "name": combiner.name, + "round_id": combiner.round_id, + "status": combiner.status, + "time_exec_training": combiner.time_exec_training, + "config": { + "_job_id": combiner.config__job_id, + "aggregator": combiner.config_aggregator, + "buffer_size": combiner.config_buffer_size, + "clients_required": combiner.config_clients_required, + "delete_models_storage": combiner.config_delete_models_storage, + "helper_type": combiner.config_helper_type, + "model_id": combiner.config_model_id, + "round_id": combiner.config_round_id, + "round_timeout": combiner.config_round_timeout, + "rounds": combiner.config_rounds, + "session_id": combiner.config_session_id, + "task": combiner.config_task, + "validate": combiner.config_validate, + }, + "data": { + "aggregation_time": { + "nr_aggregated_models": combiner.data_aggregation_time_nr_aggregated_models, + "time_model_aggregation": combiner.data_aggregation_time_time_model_aggregation, + "time_model_load": combiner.data_aggregation_time_time_model_load, + }, + "nr_expected_updates": combiner.data_nr_expected_updates, + "nr_required_updates": combiner.data_nr_required_updates, + "time_combination": combiner.data_time_combination, + "timeout": combiner.data_timeout, + }, + } + for combiner in row.combiners + ] + + return { + "id": row.id, + "committed_at": row.committed_at, + "round_id": row.round_id, + "status": row.status, + "round_config": round_config, + "round_data": round_data, + "combiners": combiners, + } + + +class SQLRoundStore(RoundStore, SQLStore[Round]): + def get(self, id: str) -> Round: + with Session() as session: + stmt = select(RoundModel).where(or_(RoundModel.id == id, RoundModel.round_id == id)) + item = session.scalars(stmt).first() + + if item is None: + raise EntityNotFound(f"Entity with (id | round_id) {id} not found") + + return from_row(item) + + def update(self, id, item: Round) -> Tuple[bool, Any]: + with Session() as session: + stmt = select(RoundModel).where(or_(RoundModel.id == id, RoundModel.round_id == id)) + existing_item = session.scalars(stmt).first() + + if existing_item is None: + raise EntityNotFound(f"Entity with (id | round_id) {id} not found") + + if "round_data" in item and item["round_data"] is not None: + round_data = item["round_data"] + reduce = round_data["reduce"] if "reduce" in round_data else {} + + if existing_item.round_data is None: + round_data = RoundDataModel( + time_commit=round_data["time_commit"] if "time_commit" in round_data else None, + reduce_time_aggregate_model=reduce["time_aggregate_model"] if "time_aggregate_model" in reduce else None, + reduce_time_fetch_model=reduce["time_fetch_model"] if "time_fetch_model" in reduce else None, + reduce_time_load_model=reduce["time_load_model"] if "time_load_model" in reduce else None, + ) + session.add(round_data) + existing_item.round_data = round_data + else: + existing_item.round_data.time_commit = round_data["time_commit"] if "time_commit" in round_data else None + existing_item.round_data.reduce_time_aggregate_model = reduce["time_aggregate_model"] if "time_aggregate_model" in reduce else None + existing_item.round_data.reduce_time_fetch_model = reduce["time_fetch_model"] if "time_fetch_model" in reduce else None + existing_item.round_data.reduce_time_load_model = reduce["time_load_model"] if "time_load_model" in reduce else None + + if "round_config" in item and item["round_config"] is not None: + if existing_item.round_config is None: + existing_item.round_config = RoundConfigModel( + aggregator=item["round_config"]["aggregator"], + round_timeout=item["round_config"]["round_timeout"], + buffer_size=item["round_config"]["buffer_size"], + delete_models_storage=item["round_config"]["delete_models_storage"], + clients_required=item["round_config"]["clients_required"], + validate=item["round_config"]["validate"], + helper_type=item["round_config"]["helper_type"], + task=item["round_config"]["task"], + model_id=item["round_config"]["model_id"], + session_id=item["round_config"]["session_id"], + round_id=item["round_config"]["round_id"], + rounds=item["round_config"]["rounds"], + ) + else: + existing_item.round_config.aggregator = item["round_config"]["aggregator"] + existing_item.round_config.round_timeout = item["round_config"]["round_timeout"] + existing_item.round_config.buffer_size = item["round_config"]["buffer_size"] + existing_item.round_config.delete_models_storage = item["round_config"]["delete_models_storage"] + existing_item.round_config.clients_required = item["round_config"]["clients_required"] + existing_item.round_config.validate = item["round_config"]["validate"] + existing_item.round_config.helper_type = item["round_config"]["helper_type"] + existing_item.round_config.task = item["round_config"]["task"] + existing_item.round_config.round_id = item["round_config"]["round_id"] + existing_item.round_config.rounds = item["round_config"]["rounds"] + + if "model_id" in item["round_config"]: + existing_item.round_config.model_id = item["round_config"]["model_id"] + + if "session_id" in item["round_config"]: + existing_item.round_config.session_id = item["round_config"]["session_id"] + + if "combiners" in item and item["combiners"] is not None: + if existing_item.combiners is not None: + existing_item.combiners.clear() + + for combiner in item["combiners"]: + config = combiner["config"] if "config" in combiner else {} + data = combiner["data"] if "data" in combiner else {} + aggregation_time = data["aggregation_time"] if "aggregation_time" in data else {} + + child = RoundCombinerModel( + model_id=combiner["model_id"], + name=combiner["name"], + round_id=combiner["round_id"], + status=combiner["status"], + time_exec_training=combiner["time_exec_training"], + config__job_id=config["_job_id"], + config_aggregator=config["aggregator"], + config_buffer_size=config["buffer_size"], + config_clients_required=config["clients_required"], + config_delete_models_storage=config["delete_models_storage"] + if isinstance(config["delete_models_storage"], bool) + else config["delete_models_storage"] == "True", + config_helper_type=config["helper_type"], + config_model_id=config["model_id"], + config_round_id=config["round_id"], + config_round_timeout=config["round_timeout"], + config_rounds=config["rounds"], + config_session_id=config["session_id"], + config_task=config["task"], + config_validate=config["validate"] if isinstance(config["validate"], bool) else config["validate"] == "True", + data_aggregation_time_nr_aggregated_models=aggregation_time["nr_aggregated_models"] + if "nr_aggregated_models" in aggregation_time + else None, + data_aggregation_time_time_model_aggregation=aggregation_time["time_model_aggregation"] + if "time_model_aggregation" in aggregation_time + else None, + data_aggregation_time_time_model_load=aggregation_time["time_model_load"] if "time_model_load" in aggregation_time else None, + data_nr_expected_updates=data["nr_expected_updates"] if "nr_expected_updates" in data else None, + data_nr_required_updates=data["nr_required_updates"] if "nr_required_updates" in data else None, + data_time_combination=data["time_combination"] if "time_combination" in data else None, + data_timeout=data["timeout"] if "timeout" in data else None, + ) + + existing_item.combiners.append(child) + + existing_item.status = item["status"] + session.commit() + + return True, from_row(existing_item) + + def add(self, item: Round) -> Tuple[bool, Any]: + with Session() as session: + round_id = item["round_id"] + stmt = select(RoundModel).where(RoundModel.round_id == round_id) + existing_item = session.scalars(stmt).first() + + if existing_item is not None: + return False, "Round with round_id already exists" + + entity = RoundModel(round_id=item["round_id"], status=item["status"]) + + round_data: RoundDataModel = None + if "round_data" in item: + round_data = RoundDataModel( + time_commit=item["round_data"]["time_commit"], + reduce_time_aggregate_model=item["round_data"]["reduce"]["time_aggregate_model"], + reduce_time_fetch_model=item["round_data"]["reduce"]["time_fetch_model"], + reduce_time_load_model=item["round_data"]["reduce"]["time_load_model"], + ) + entity.round_data = round_data + + round_config: RoundConfigModel = None + + if "round_config" in item: + round_config = RoundConfigModel( + aggregator=item["round_config"]["aggregator"], + round_timeout=item["round_config"]["round_timeout"], + buffer_size=item["round_config"]["buffer_size"], + delete_models_storage=item["round_config"]["delete_models_storage"], + clients_required=item["round_config"]["clients_required"], + validate=item["round_config"]["validate"], + helper_type=item["round_config"]["helper_type"], + task=item["round_config"]["task"], + model_id=item["round_config"]["model_id"], + session_id=item["round_config"]["session_id"], + round_id=item["round_config"]["round_id"], + rounds=item["round_config"]["rounds"], + ) + entity.round_config = round_config + + combiners: List[RoundCombinerModel] = [] + + if "combiners" in item: + combiners = [] + + for combiner in item["combiners"]: + config = combiner["config"] if "config" in combiner else {} + data = combiner["data"] if "data" in combiner else {} + aggregation_time = data["aggregation_time"] if "aggregation_time" in data else {} + + combiners.append( + RoundCombinerModel( + model_id=combiner["model_id"], + name=combiner["name"], + round_id=combiner["round_id"], + status=combiner["status"], + time_exec_training=combiner["time_exec_training"], + config__job_id=config["_job_id"], + config_aggregator=config["aggregator"], + config_buffer_size=config["buffer_size"], + config_clients_required=config["clients_required"], + config_delete_models_storage=config["delete_models_storage"] + if isinstance(config["delete_models_storage"], bool) + else config["delete_models_storage"] == "True", + config_helper_type=config["helper_type"], + config_model_id=config["model_id"], + config_round_id=config["round_id"], + config_round_timeout=config["round_timeout"], + config_rounds=config["rounds"], + config_session_id=config["session_id"], + config_task=config["task"], + config_validate=config["validate"] if isinstance(config["validate"], bool) else config["validate"] == "True", + data_aggregation_time_nr_aggregated_models=aggregation_time["nr_aggregated_models"] + if "nr_aggregated_models" in aggregation_time + else None, + data_aggregation_time_time_model_aggregation=aggregation_time["time_model_aggregation"] + if "time_model_aggregation" in aggregation_time + else None, + data_aggregation_time_time_model_load=aggregation_time["time_model_load"] if "time_model_load" in aggregation_time else None, + data_nr_expected_updates=data["nr_expected_updates"] if "nr_expected_updates" in data else None, + data_nr_required_updates=data["nr_required_updates"] if "nr_required_updates" in data else None, + data_time_combination=data["time_combination"] if "time_combination" in data else None, + data_timeout=data["timeout"] if "timeout" in data else None, + ) + ) + + entity.combiners = combiners + + session.add(entity) + session.commit() + + return True, from_row(entity) + + def delete(self, id: str) -> bool: + raise NotImplementedError + + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs): + with Session() as session: + stmt = select(RoundModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(RoundModel, key) == value) + + if sort_key: + _sort_order: str = "DESC" if sort_order == pymongo.DESCENDING else "ASC" + _sort_key: str = sort_key or "committed_at" + + if _sort_key == "_id": + _sort_key = "id" + + if _sort_key == "round_id": + sort_obj = RoundModel.round_id.cast(Integer) if _sort_order == "ASC" else RoundModel.round_id.cast(Integer).desc() + elif _sort_key in RoundModel.__table__.columns: + sort_obj = RoundModel.__table__.columns.get(_sort_key) if _sort_order == "ASC" else RoundModel.__table__.columns.get(_sort_key).desc() + + stmt = stmt.order_by(sort_obj) + + if limit != 0: + stmt = stmt.offset(skip or 0).limit(limit) + + items = session.execute(stmt) + + result = [] + + for item in items: + (r,) = item + + result.append(from_row(r)) + + return {"count": len(result), "result": result} + + def count(self, **kwargs): + with Session() as session: + stmt = select(func.count()).select_from(RoundModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(RoundModel, key) == value) + + count = session.scalar(stmt) + + return count + + def get_latest_round_id(self) -> int: + response = self.list(limit=1, skip=0, sort_key="round_id", sort_order=pymongo.DESCENDING) + if response and "result" in response and len(response["result"]) > 0: + round_id: str = response["result"][0]["round_id"] + return int(round_id) + else: + return 0 diff --git a/fedn/network/storage/statestore/stores/session_store.py b/fedn/network/storage/statestore/stores/session_store.py index cd0a333de..efc59806f 100644 --- a/fedn/network/storage/statestore/stores/session_store.py +++ b/fedn/network/storage/statestore/stores/session_store.py @@ -5,93 +5,127 @@ import pymongo from bson import ObjectId from pymongo.database import Database - -from fedn.network.storage.statestore.stores.store import MongoDBStore - -from .shared import EntityNotFound, from_document +from sqlalchemy import func, select + +from fedn.network.storage.statestore.stores.shared import EntityNotFound, from_document +from fedn.network.storage.statestore.stores.sql.shared import SessionConfigModel, SessionModel +from fedn.network.storage.statestore.stores.store import MongoDBStore, SQLStore, Store +from fedn.network.storage.statestore.stores.store import Session as SQLSession + + +class SessionConfig: + def __init__( + self, + aggregator: str, + round_timeout: int, + buffer_size: int, + model_id: str, + delete_models_storage: bool, + clients_required: int, + validate: bool, + helper_type: str, + ): + self.aggregator = aggregator + self.round_timeout = round_timeout + self.buffer_size = buffer_size + self.model_id = model_id + self.delete_models_storage = delete_models_storage + self.clients_required = clients_required + self.validate = validate + self.helper_type = helper_type class Session: - def __init__(self, id: str, session_id: str, status: str, session_config: dict = None): + def __init__(self, id: str, session_id: str, status: str, committed_at: datetime, name: str = None, session_config: SessionConfig = None): self.id = id self.session_id = session_id self.status = status + self.committed_at = committed_at self.session_config = session_config + self.name = name -class SessionStore(MongoDBStore[Session]): - def __init__(self, database: Database, collection: str): - super().__init__(database, collection) +class SessionStore(Store[Session]): + pass - def _validate_session_config(self, session_config: dict) -> Tuple[bool, str]: - if "aggregator" not in session_config: - return False, "session_config.aggregator is required" - if "round_timeout" not in session_config: - return False, "session_config.round_timeout is required" +def validate_session_config(session_config: SessionConfig) -> Tuple[bool, str]: + if "aggregator" not in session_config: + return False, "session_config.aggregator is required" - if not isinstance(session_config["round_timeout"], (int, float)): - return False, "session_config.round_timeout must be an integer" + if "round_timeout" not in session_config: + return False, "session_config.round_timeout is required" - if "buffer_size" not in session_config: - return False, "session_config.buffer_size is required" + if not isinstance(session_config["round_timeout"], (int, float)): + return False, "session_config.round_timeout must be an integer" - if not isinstance(session_config["buffer_size"], int): - return False, "session_config.buffer_size must be an integer" + if "buffer_size" not in session_config: + return False, "session_config.buffer_size is required" - if "model_id" not in session_config or session_config["model_id"] == "": - return False, "session_config.model_id is required" + if not isinstance(session_config["buffer_size"], int): + return False, "session_config.buffer_size must be an integer" - if not isinstance(session_config["model_id"], str): - return False, "session_config.model_id must be a string" + if "model_id" not in session_config or session_config["model_id"] == "": + return False, "session_config.model_id is required" - if "delete_models_storage" not in session_config: - return False, "session_config.delete_models_storage is required" + if not isinstance(session_config["model_id"], str): + return False, "session_config.model_id must be a string" - if not isinstance(session_config["delete_models_storage"], bool): - return False, "session_config.delete_models_storage must be a boolean" + if "delete_models_storage" not in session_config: + return False, "session_config.delete_models_storage is required" - if "clients_required" not in session_config: - return False, "session_config.clients_required is required" + if not isinstance(session_config["delete_models_storage"], bool): + return False, "session_config.delete_models_storage must be a boolean" - if not isinstance(session_config["clients_required"], int): - return False, "session_config.clients_required must be an integer" + if "clients_required" not in session_config: + return False, "session_config.clients_required is required" - if "validate" not in session_config: - return False, "session_config.validate is required" + if not isinstance(session_config["clients_required"], int): + return False, "session_config.clients_required must be an integer" - if not isinstance(session_config["validate"], bool): - return False, "session_config.validate must be a boolean" + if "validate" not in session_config: + return False, "session_config.validate is required" - if "helper_type" not in session_config or session_config["helper_type"] == "": - return False, "session_config.helper_type is required" + if not isinstance(session_config["validate"], bool): + return False, "session_config.validate must be a boolean" - if not isinstance(session_config["helper_type"], str): - return False, "session_config.helper_type must be a string" + if "helper_type" not in session_config or session_config["helper_type"] == "": + return False, "session_config.helper_type is required" - return True, "" + if not isinstance(session_config["helper_type"], str): + return False, "session_config.helper_type must be a string" - def _validate(self, item: Session) -> Tuple[bool, str]: - if "session_config" not in item or item["session_config"] is None: - return False, "session_config is required" + return True, "" - session_config = None - if isinstance(item["session_config"], dict): - session_config = item["session_config"] - elif isinstance(item["session_config"], list): - session_config = item["session_config"][0] - else: - return False, "session_config must be a dict" +def validate(item: Session) -> Tuple[bool, str]: + if "session_config" not in item or item["session_config"] is None: + return False, "session_config is required" + + session_config = None + + if isinstance(item["session_config"], dict): + session_config = item["session_config"] + elif isinstance(item["session_config"], list): + session_config = item["session_config"][0] + else: + return False, "session_config must be a dict" + + return validate_session_config(session_config) - return self._validate_session_config(session_config) - def _complement(self, item: Session): - item["status"] = "Created" - item["committed_at"] = datetime.datetime.now() +def complement(item: Session): + item["status"] = "Created" + item["committed_at"] = datetime.datetime.now() - if "session_id" not in item or item["session_id"] == "" or not isinstance(item["session_id"], str): - item["session_id"] = str(uuid.uuid4()) + if "session_id" not in item or item["session_id"] == "" or not isinstance(item["session_id"], str): + item["session_id"] = str(uuid.uuid4()) + + +class MongoDBSessionStore(MongoDBStore[Session]): + def __init__(self, database: Database, collection: str): + super().__init__(database, collection) + self.database[self.collection].create_index([("session_id", pymongo.DESCENDING)]) def get(self, id: str) -> Session: """Get an entity by id @@ -112,7 +146,7 @@ def get(self, id: str) -> Session: return from_document(document) def update(self, id: str, item: Session) -> Tuple[bool, Any]: - valid, message = self._validate(item) + valid, message = validate(item) if not valid: return False, message @@ -125,11 +159,11 @@ def add(self, item: Session) -> Tuple[bool, Any]: description: The entity to add return: A tuple with a boolean indicating success and the entity """ - valid, message = self._validate(item) + valid, message = validate(item) if not valid: return False, message - self._complement(item) + complement(item) return super().add(item) @@ -156,3 +190,206 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI return: The entities """ return super().list(limit, skip, sort_key or "session_id", sort_order, **kwargs) + + +def from_row(row: dict) -> Session: + return { + "id": row["id"], + "name": row["name"], + "session_id": row["id"], + "status": row["status"], + "committed_at": row["committed_at"], + "session_config": { + "aggregator": row["aggregator"], + "round_timeout": row["round_timeout"], + "buffer_size": row["buffer_size"], + "model_id": row["model_id"], + "delete_models_storage": row["delete_models_storage"], + "clients_required": row["clients_required"], + "validate": row["validate"], + "helper_type": row["helper_type"], + }, + } + + +class SQLSessionStore(SessionStore, SQLStore[Session]): + def get(self, id: str) -> Session: + with SQLSession() as session: + stmt = select(SessionModel, SessionConfigModel).join(SessionModel.session_config).where(SessionModel.id == id) + item = session.execute(stmt).first() + if item is None: + raise EntityNotFound(f"Entity not found {id}") + + s, c = item + combined_dict = { + "id": s.id, + "name": s.name, + "session_id": s.id, + "status": s.status, + "committed_at": s.committed_at, + "aggregator": c.aggregator, + "round_timeout": c.round_timeout, + "buffer_size": c.buffer_size, + "model_id": c.model_id, + "delete_models_storage": c.delete_models_storage, + "clients_required": c.clients_required, + "validate": c.validate, + "helper_type": c.helper_type, + } + return from_row(combined_dict) + + def update(self, id: str, item: Session) -> Tuple[bool, Any]: + valid, message = validate(item) + if not valid: + return False, message + with SQLSession() as session: + stmt = select(SessionModel, SessionConfigModel).join(SessionModel.session_config).where(SessionModel.id == id) + existing_item = session.execute(stmt).first() + if existing_item is None: + raise EntityNotFound(f"Entity not found {id}") + + s, c = existing_item + + s.name = item["name"] if "name" in item else None + s.status = item["status"] + s.committed_at = item["committed_at"] + + session_config = item["session_config"] + + c.aggregator = session_config["aggregator"] + c.round_timeout = session_config["round_timeout"] + c.buffer_size = session_config["buffer_size"] + c.model_id = session_config["model_id"] + c.delete_models_storage = session_config["delete_models_storage"] + c.clients_required = session_config["clients_required"] + c.validate = session_config["validate"] + c.helper_type = session_config["helper_type"] + + session.commit() + + combined_dict = { + "id": s.id, + "name": s.name, + "session_id": s.id, + "status": s.status, + "committed_at": s.committed_at, + "aggregator": c.aggregator, + "round_timeout": c.round_timeout, + "buffer_size": c.buffer_size, + "model_id": c.model_id, + "delete_models_storage": c.delete_models_storage, + "clients_required": c.clients_required, + "validate": c.validate, + "helper_type": c.helper_type, + } + + return True, from_row(combined_dict) + + def add(self, item: Session) -> Tuple[bool, Any]: + valid, message = validate(item) + if not valid: + return False, message + + complement(item) + + with SQLSession() as session: + parent_item = SessionModel( + id=item["session_id"], status=item["status"], name=item["name"] if "name" in item else None, committed_at=item["committed_at"] or None + ) + session.add(parent_item) + + session_config = item["session_config"] + + child_item = SessionConfigModel( + aggregator=session_config["aggregator"], + round_timeout=session_config["round_timeout"], + buffer_size=session_config["buffer_size"], + model_id=session_config["model_id"], + delete_models_storage=session_config["delete_models_storage"], + clients_required=session_config["clients_required"], + validate=session_config["validate"], + helper_type=session_config["helper_type"], + session_id=parent_item.id, + ) + + session.add(child_item) + session.commit() + + combined_dict = { + "id": parent_item.id, + "name": parent_item.name, + "session_id": parent_item.id, + "status": parent_item.status, + "committed_at": parent_item.committed_at, + "aggregator": child_item.aggregator, + "round_timeout": child_item.round_timeout, + "buffer_size": child_item.buffer_size, + "model_id": child_item.model_id, + "delete_models_storage": child_item.delete_models_storage, + "clients_required": child_item.clients_required, + "validate": child_item.validate, + "helper_type": child_item.helper_type, + } + + return True, from_row(combined_dict) + + def delete(self, id: str) -> bool: + raise NotImplementedError + + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs): + with SQLSession() as session: + stmt = select(SessionModel, SessionConfigModel).join(SessionModel.session_config) + for key, value in kwargs.items(): + if "session_config" in key: + key = key.replace("session_config.", "") + stmt = stmt.where(getattr(SessionConfigModel, key) == value) + else: + stmt = stmt.where(getattr(SessionModel, key) == value) + + if sort_key: + _sort_order: str = "DESC" if sort_order == pymongo.DESCENDING else "ASC" + _sort_key: str = sort_key or "committed_at" + + if _sort_key in SessionModel.__table__.columns: + sort_obj = SessionModel.__table__.columns.get(_sort_key) if _sort_order == "ASC" else SessionModel.__table__.columns.get(_sort_key).desc() + + stmt = stmt.order_by(sort_obj) + + if limit != 0: + stmt = stmt.offset(skip or 0).limit(limit) + + items = session.execute(stmt) + + result = [] + + for item in items: + s, c = item + combined_dict = { + "id": s.id, + "session_id": s.id, + "name": s.name, + "status": s.status, + "committed_at": s.committed_at, + "aggregator": c.aggregator, + "round_timeout": c.round_timeout, + "buffer_size": c.buffer_size, + "model_id": c.model_id, + "delete_models_storage": c.delete_models_storage, + "clients_required": c.clients_required, + "validate": c.validate, + "helper_type": c.helper_type, + } + result.append(from_row(combined_dict)) + + return {"count": len(result), "result": result} + + def count(self, **kwargs): + with SQLSession() as session: + stmt = select(func.count()).select_from(SessionModel) + + for key, value in kwargs.items(): + stmt = stmt.where(getattr(SessionModel, key) == value) + + count = session.scalar(stmt) + + return count diff --git a/fedn/network/storage/statestore/stores/shared.py b/fedn/network/storage/statestore/stores/shared.py index 1ccce636e..a6d45628c 100644 --- a/fedn/network/storage/statestore/stores/shared.py +++ b/fedn/network/storage/statestore/stores/shared.py @@ -1,4 +1,3 @@ - def from_document(document: dict) -> dict: document["id"] = str(document["_id"]) del document["_id"] diff --git a/fedn/network/storage/statestore/stores/sql/shared.py b/fedn/network/storage/statestore/stores/sql/shared.py new file mode 100644 index 000000000..28f0f14f0 --- /dev/null +++ b/fedn/network/storage/statestore/stores/sql/shared.py @@ -0,0 +1,117 @@ +from typing import List, Optional + +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from fedn.network.storage.statestore.stores.store import MyAbstractBase + + +class SessionConfigModel(MyAbstractBase): + __tablename__ = "session_configs" + + aggregator: Mapped[str] = mapped_column(String(255)) + round_timeout: Mapped[int] + buffer_size: Mapped[int] + delete_models_storage: Mapped[bool] + clients_required: Mapped[int] + validate: Mapped[bool] + helper_type: Mapped[str] = mapped_column(String(255)) + model_id: Mapped[str] = mapped_column(ForeignKey("models.id")) + session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id")) + session: Mapped["SessionModel"] = relationship(back_populates="session_config") + + +class SessionModel(MyAbstractBase): + __tablename__ = "sessions" + + name: Mapped[Optional[str]] = mapped_column(String(255)) + status: Mapped[str] = mapped_column(String(255)) + session_config: Mapped["SessionConfigModel"] = relationship(back_populates="session") + models: Mapped[List["ModelModel"]] = relationship(back_populates="session") + + +class ModelModel(MyAbstractBase): + __tablename__ = "models" + + active: Mapped[bool] = mapped_column(default=False) + parent_model: Mapped[Optional[str]] = mapped_column(String(255)) + name: Mapped[Optional[str]] = mapped_column(String(255)) + session_configs: Mapped[List["SessionConfigModel"]] = relationship() + session_id: Mapped[Optional[str]] = mapped_column(ForeignKey("sessions.id")) + session: Mapped[Optional["SessionModel"]] = relationship(back_populates="models") + + +class RoundConfigModel(MyAbstractBase): + __tablename__ = "round_configs" + + aggregator: Mapped[str] = mapped_column(String(255)) + round_timeout: Mapped[int] + buffer_size: Mapped[int] + delete_models_storage: Mapped[bool] + clients_required: Mapped[int] + validate: Mapped[bool] + helper_type: Mapped[str] = mapped_column(String(255)) + model_id: Mapped[Optional[str]] = mapped_column(ForeignKey("models.id")) + session_id: Mapped[Optional[str]] = mapped_column(ForeignKey("sessions.id")) + round: Mapped["RoundModel"] = relationship(back_populates="round_config") + task: Mapped[str] = mapped_column(String(255)) + round_id: Mapped[str] + rounds: Mapped[int] + + +class RoundDataModel(MyAbstractBase): + __tablename__ = "round_data" + + time_commit: Mapped[Optional[float]] + reduce_time_aggregate_model: Mapped[Optional[float]] + reduce_time_fetch_model: Mapped[Optional[float]] + reduce_time_load_model: Mapped[Optional[float]] + round: Mapped["RoundModel"] = relationship(back_populates="round_data") + + +class RoundCombinerModel(MyAbstractBase): + __tablename__ = "round_combiners" + + model_id: Mapped[str] + name: Mapped[str] = mapped_column(String(255)) + round_id: Mapped[str] + status: Mapped[str] = mapped_column(String(255)) + time_exec_training: Mapped[float] + + config__job_id: Mapped[str] = mapped_column(String(255)) + config_aggregator: Mapped[str] = mapped_column(String(255)) + config_buffer_size: Mapped[int] + config_clients_required: Mapped[int] + config_delete_models_storage: Mapped[bool] + config_helper_type: Mapped[str] = mapped_column(String(255)) + config_model_id: Mapped[str] = mapped_column(ForeignKey("models.id")) + config_round_id: Mapped[str] + config_round_timeout: Mapped[int] + config_rounds: Mapped[int] + config_session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id")) + config_task: Mapped[str] = mapped_column(String(255)) + config_validate: Mapped[bool] + + data_aggregation_time_nr_aggregated_models: Mapped[Optional[int]] + data_aggregation_time_time_model_aggregation: Mapped[Optional[float]] + data_aggregation_time_time_model_load: Mapped[Optional[float]] + data_nr_expected_updates: Mapped[Optional[int]] + data_nr_required_updates: Mapped[Optional[int]] + data_time_combination: Mapped[Optional[float]] + data_timeout: Mapped[Optional[float]] + + parent_round_id: Mapped[str] = mapped_column(ForeignKey("rounds.id")) + round: Mapped["RoundModel"] = relationship(back_populates="combiners") + + +class RoundModel(MyAbstractBase): + __tablename__ = "rounds" + + round_id: Mapped[str] = mapped_column(unique=True) # TODO: Add unique constraint. Does this work? + status: Mapped[str] = mapped_column() + + round_config_id: Mapped[Optional[str]] = mapped_column(ForeignKey("round_configs.id")) + round_config: Mapped[Optional["RoundConfigModel"]] = relationship(back_populates="round") + round_data_id: Mapped[Optional[str]] = mapped_column(ForeignKey("round_data.id")) + round_data: Mapped[Optional["RoundDataModel"]] = relationship(back_populates="round") + combiners: Mapped[List["RoundCombinerModel"]] = relationship(back_populates="round", cascade="all, delete-orphan") diff --git a/fedn/network/storage/statestore/stores/status_store.py b/fedn/network/storage/statestore/stores/status_store.py index a6aae34e8..78c063782 100644 --- a/fedn/network/storage/statestore/stores/status_store.py +++ b/fedn/network/storage/statestore/stores/status_store.py @@ -1,9 +1,12 @@ -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import pymongo from pymongo.database import Database +from sqlalchemy import ForeignKey, String, func, select +from sqlalchemy.orm import Mapped, mapped_column -from fedn.network.storage.statestore.stores.store import MongoDBStore +from fedn.network.storage.statestore.stores.shared import EntityNotFound +from fedn.network.storage.statestore.stores.store import MongoDBStore, MyAbstractBase, Session, SQLStore, Store class Status: @@ -22,7 +25,11 @@ def __init__( self.sender = sender -class StatusStore(MongoDBStore[Status]): +class StatusStore(Store[Status]): + pass + + +class MongoDBStatusStore(StatusStore, MongoDBStore[Status]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) @@ -60,3 +67,145 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI description: The order to sort by """ return super().list(limit, skip, sort_key or "timestamp", sort_order, **kwargs) + + +class StatusModel(MyAbstractBase): + __tablename__ = "statuses" + + log_level: Mapped[str] = mapped_column(String(255)) + sender_name: Mapped[Optional[str]] = mapped_column(String(255)) + sender_role: Mapped[Optional[str]] = mapped_column(String(255)) + status: Mapped[str] = mapped_column(String(255)) + timestamp: Mapped[str] = mapped_column(String(255)) + type: Mapped[str] = mapped_column(String(255)) + data: Mapped[Optional[str]] + correlation_id: Mapped[Optional[str]] + extra: Mapped[Optional[str]] + session_id: Mapped[Optional[str]] = mapped_column(ForeignKey("sessions.id")) + + +def from_row(row: StatusModel) -> Status: + return { + "id": row.id, + "log_level": row.log_level, + "sender": {"name": row.sender_name, "role": row.sender_role}, + "status": row.status, + "timestamp": row.timestamp, + "type": row.type, + "data": row.data, + "correlation_id": row.correlation_id, + "extra": row.extra, + "session_id": row.session_id, + } + + +class SQLStatusStore(StatusStore, SQLStore[Status]): + def get(self, id: str) -> Status: + with Session() as session: + stmt = select(StatusModel).where(StatusModel.id == id) + item = session.scalars(stmt).first() + + if item is None: + raise EntityNotFound(f"Entity with (id | round_id) {id} not found") + + return from_row(item) + + def update(self, id, item): + raise NotImplementedError + + def add(self, item: Status) -> Tuple[bool, Any]: + with Session() as session: + sender = item["sender"] if "sender" in item else None + + status = StatusModel( + log_level=item.get("log_level") or item.get("logLevel"), + sender_name=sender.get("name"), + sender_role=sender.get("role"), + status=item.get("status"), + timestamp=item.get("timestamp"), + type=item.get("type"), + data=item.get("data"), + correlation_id=item.get("correlation_id"), + extra=item.get("extra"), + session_id=item.get("session_id") or item.get("sessionId"), + ) + session.add(status) + session.commit() + return True, status + + def delete(self, id: str) -> bool: + raise NotImplementedError + + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs): + with Session() as session: + stmt = select(StatusModel) + + for key, value in kwargs.items(): + if key == "_id": + key = "id" + elif key == "logLevel": + key = "log_level" + elif key == "sender.name": + key = "sender_name" + elif key == "sender.role": + key = "sender_role" + elif key == "sessionId": + key = "session_id" + + stmt = stmt.where(getattr(StatusModel, key) == value) + + if sort_key: + _sort_order: str = "DESC" if sort_order == pymongo.DESCENDING else "ASC" + _sort_key: str = sort_key + + if _sort_key == "_id": + _sort_key = "id" + elif _sort_key == "logLevel": + _sort_key = "log_level" + elif _sort_key == "sender.name": + _sort_key = "sender_name" + elif _sort_key == "sender.role": + _sort_key = "sender_role" + elif _sort_key == "sessionId": + _sort_key = "session_id" + + if _sort_key in StatusModel.__table__.columns: + sort_obj = StatusModel.__table__.columns.get(_sort_key) if _sort_order == "ASC" else StatusModel.__table__.columns.get(_sort_key).desc() + + stmt = stmt.order_by(sort_obj) + + if limit != 0: + stmt = stmt.offset(skip or 0).limit(limit) + + items = session.execute(stmt) + + result = [] + + for item in items: + (r,) = item + + result.append(from_row(r)) + + return {"count": len(result), "result": result} + + def count(self, **kwargs): + with Session() as session: + stmt = select(func.count()).select_from(StatusModel) + + for key, value in kwargs.items(): + if key == "_id": + key = "id" + elif key == "logLevel": + key = "log_level" + elif key == "sender.name": + key = "sender_name" + elif key == "sender.role": + key = "sender_role" + elif key == "sessionId": + key = "session_id" + + stmt = stmt.where(getattr(StatusModel, key) == value) + + count = session.scalar(stmt) + + return count diff --git a/fedn/network/storage/statestore/stores/store.py b/fedn/network/storage/statestore/stores/store.py index ec5e4e9be..a628a40e8 100644 --- a/fedn/network/storage/statestore/stores/store.py +++ b/fedn/network/storage/statestore/stores/store.py @@ -1,11 +1,16 @@ +import uuid from abc import ABC, abstractmethod +from datetime import datetime from typing import Any, Dict, Generic, List, Tuple, TypeVar import pymongo from bson import ObjectId from pymongo.database import Database +from sqlalchemy import MetaData, create_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker -from .shared import EntityNotFound, from_document +from fedn.common.config import get_statestore_config +from fedn.network.storage.statestore.stores.shared import EntityNotFound, from_document T = TypeVar("T") @@ -28,7 +33,14 @@ def delete(self, id: str) -> bool: pass @abstractmethod - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[T]]: + def list( + self, + limit: int, + skip: int, + sort_key: str, + sort_order=pymongo.DESCENDING, + **kwargs, + ) -> Dict[int, List[T]]: pass @abstractmethod @@ -80,7 +92,14 @@ def delete(self, id: str) -> bool: result = self.database[self.collection].delete_one({"_id": ObjectId(id)}) return result.deleted_count == 1 - def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs) -> Dict[int, List[T]]: + def list( + self, + limit: int, + skip: int, + sort_key: str, + sort_order=pymongo.DESCENDING, + **kwargs, + ) -> Dict[int, List[T]]: """List entities param limit: The maximum number of entities to return type: int @@ -111,3 +130,47 @@ def count(self, **kwargs) -> int: return: The count (int) """ return self.database[self.collection].count_documents(kwargs) + + +class SQLStore(Store[T]): + pass + + +constraint_naming_conventions = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=constraint_naming_conventions) + + +class MyAbstractBase(Base): + __abstract__ = True + + id: Mapped[str] = mapped_column(primary_key=True, default=lambda: str(uuid.uuid4())) + committed_at: Mapped[datetime] = mapped_column(default=datetime.now()) + + +statestore_config = get_statestore_config() + +engine = None +Session = None + +if statestore_config["type"] in ["SQLite", "PostgreSQL"]: + if statestore_config["type"] == "SQLite": + engine = create_engine("sqlite:///my_database.db", echo=True) + elif statestore_config["type"] == "PostgreSQL": + postgres_config = statestore_config["postgres_config"] + username = postgres_config["username"] + password = postgres_config["password"] + host = postgres_config["host"] + port = postgres_config["port"] + + engine = create_engine(f"postgresql://{username}:{password}@{host}:{port}/fedn_db", echo=True) + + Session = sessionmaker(engine) diff --git a/fedn/network/storage/statestore/stores/validation_store.py b/fedn/network/storage/statestore/stores/validation_store.py index f5e9ef604..694195d00 100644 --- a/fedn/network/storage/statestore/stores/validation_store.py +++ b/fedn/network/storage/statestore/stores/validation_store.py @@ -1,9 +1,12 @@ -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import pymongo from pymongo.database import Database +from sqlalchemy import ForeignKey, String, func, select +from sqlalchemy.orm import Mapped, mapped_column -from fedn.network.storage.statestore.stores.store import MongoDBStore +from fedn.network.storage.statestore.stores.shared import EntityNotFound +from fedn.network.storage.statestore.stores.store import MongoDBStore, MyAbstractBase, Session, SQLStore, Store class Validation: @@ -21,7 +24,11 @@ def __init__( self.receiver = receiver -class ValidationStore(MongoDBStore[Validation]): +class ValidationStore(Store[Validation]): + pass + + +class MongoDBValidationStore(MongoDBStore[Validation]): def __init__(self, database: Database, collection: str): super().__init__(database, collection) @@ -60,3 +67,162 @@ def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDI return: A dictionary with the count and a list of entities """ return super().list(limit, skip, sort_key or "timestamp", sort_order, **kwargs) + + +class ValidationModel(MyAbstractBase): + __tablename__ = "validations" + + correlation_id: Mapped[str] + data: Mapped[Optional[str]] + model_id: Mapped[Optional[str]] = mapped_column(ForeignKey("models.id")) + receiver_name: Mapped[Optional[str]] = mapped_column(String(255)) + receiver_role: Mapped[Optional[str]] = mapped_column(String(255)) + sender_name: Mapped[Optional[str]] = mapped_column(String(255)) + sender_role: Mapped[Optional[str]] = mapped_column(String(255)) + session_id: Mapped[Optional[str]] = mapped_column(ForeignKey("sessions.id")) + timestamp: Mapped[str] = mapped_column(String(255)) + + +def from_row(row: ValidationModel) -> Validation: + return { + "id": row.id, + "model_id": row.model_id, + "data": row.data, + "correlation_id": row.correlation_id, + "timestamp": row.timestamp, + "session_id": row.session_id, + "sender": {"name": row.sender_name, "role": row.sender_role}, + "receiver": {"name": row.receiver_name, "role": row.receiver_role}, + } + + +class SQLValidationStore(ValidationStore, SQLStore[Validation]): + def get(self, id: str) -> Validation: + with Session() as session: + stmt = select(ValidationModel).where(ValidationModel.id == id) + item = session.scalars(stmt).first() + + if item is None: + raise EntityNotFound(f"Entity with (id | round_id) {id} not found") + + return from_row(item) + + def update(self, id: str, item: Validation) -> bool: + raise NotImplementedError("Update not implemented for ValidationStore") + + def add(self, item: Validation) -> Tuple[bool, Any]: + with Session() as session: + sender = item["sender"] if "sender" in item else None + receiver = item["receiver"] if "receiver" in item else None + + validation = ValidationModel( + correlation_id=item.get("correlationId") or item.get("correlation_id"), + data=item.get("data"), + model_id=item.get("modelId") or item.get("model_id"), + receiver_name=receiver.get("name"), + receiver_role=receiver.get("role"), + sender_name=sender.get("name"), + sender_role=sender.get("role"), + session_id=item.get("sessionId") or item.get("session_id"), + timestamp=item.get("timestamp"), + ) + + session.add(validation) + session.commit() + + return True, validation + + def delete(self, id: str) -> bool: + raise NotImplementedError("Delete not implemented for ValidationStore") + + def list(self, limit: int, skip: int, sort_key: str, sort_order=pymongo.DESCENDING, **kwargs): + with Session() as session: + stmt = select(ValidationModel) + + for key, value in kwargs.items(): + if key == "_id": + key = "id" + elif key == "sender.name": + key = "sender_name" + elif key == "sender.role": + key = "sender_role" + elif key == "receiver.name": + key = "receiver_name" + elif key == "receiver.role": + key = "receiver_role" + elif key == "correlationId": + key = "correlation_id" + elif key == "modelId": + key = "model_id" + elif key == "sessionId": + key = "session_id" + + stmt = stmt.where(getattr(ValidationModel, key) == value) + + if sort_key: + _sort_order: str = "DESC" if sort_order == pymongo.DESCENDING else "ASC" + _sort_key: str = sort_key + + if _sort_key == "_id": + _sort_key = "id" + elif _sort_key == "sender.name": + _sort_key = "sender_name" + elif _sort_key == "sender.role": + _sort_key = "sender_role" + elif _sort_key == "receiver.name": + _sort_key = "receiver_name" + elif _sort_key == "receiver.role": + _sort_key = "receiver_role" + elif _sort_key == "correlationId": + _sort_key = "correlation_id" + elif _sort_key == "modelId": + _sort_key = "model_id" + elif _sort_key == "sessionId": + _sort_key = "session_id" + + if _sort_key in ValidationModel.__table__.columns: + sort_obj = ( + ValidationModel.__table__.columns.get(_sort_key) if _sort_order == "ASC" else ValidationModel.__table__.columns.get(_sort_key).desc() + ) + + stmt = stmt.order_by(sort_obj) + + if limit != 0: + stmt = stmt.offset(skip or 0).limit(limit) + + items = session.execute(stmt) + + result = [] + + for item in items: + (r,) = item + + result.append(from_row(r)) + + return {"count": len(result), "result": result} + + def count(self, **kwargs): + with Session() as session: + stmt = select(func.count()).select_from(ValidationModel) + + for key, value in kwargs.items(): + if key == "sender.name": + key = "sender_name" + elif key == "sender.role": + key = "sender_role" + elif key == "receiver.name": + key = "receiver_name" + elif key == "receiver.role": + key = "receiver_role" + elif key == "correlationId": + key = "correlation_id" + elif key == "modelId": + key = "model_id" + elif key == "sessionId": + key = "session_id" + + stmt = stmt.where(getattr(ValidationModel, key) == value) + + count = session.scalar(stmt) + + return count diff --git a/pyproject.toml b/pyproject.toml index 25c4027d9..739f46080 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,9 @@ dependencies = [ "plotly", "virtualenv", "tenacity!=8.4.0", - "graphene>=3.1" + "graphene>=3.1", + "SQLAlchemy>=2.0.36", + "psycopg2-binary>=2.9.10" ] [project.urls] From 68d946cd8ada6cc6a4864f45c36fcd94358f3ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20=C3=85strand?= <112588563+benjaminastrand@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:37:35 +0100 Subject: [PATCH 07/10] Fix/SK-1318 | Fixed critical security vulnerability #847 (zlib) (#787) * Install zlib version 1.3.1 * Run trivy scan when pushing to this branch (for testing) * Upload trivy scan results when pushing to this branch * Check zlib version * Clear trivy cache * Fix clear cache command * Run Trivy scan on image built from this branch * Remove code to clear cache * Added CVE-2023-45853 to trivyignore * Run trivy scan on master * Upgrade packages in runtime stage * Remove warning about case mismatch * Final check zlib version on GitHub * Remove print of zlib version * Restore settings for when Trivy scan is run * Added link to PR --- .github/workflows/build-containers.yaml | 2 +- .trivyignore | 9 +++++++++ Dockerfile | 20 ++++++++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 .trivyignore diff --git a/.github/workflows/build-containers.yaml b/.github/workflows/build-containers.yaml index 6c33e90c8..9dc27fb65 100644 --- a/.github/workflows/build-containers.yaml +++ b/.github/workflows/build-containers.yaml @@ -53,7 +53,7 @@ jobs: tags: ${{ steps.meta1.outputs.tags }} labels: ${{ steps.meta1.outputs.labels }} file: Dockerfile - + # if push to master of release, run trivy scan on the image - name: Trivy scan if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 000000000..7b99c9385 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,9 @@ +# zlib version 1:1.3.dfsg+really1.3.1-1+b1 is installed from Debian Testing (Trixie) repository, +# but Trivy assumes an older version of zlib because base image uses Debian Bookworm and +# therefore raises the vulnerability alert CVE-2023-45853. +# +# See this discussion about a similar issue: https://github.com/aquasecurity/trivy/discussions/6059 +# +# Ignoring this vulnerability since it is fixed in this PR: https://github.com/scaleoutsystems/fedn/pull/787 +# +CVE-2023-45853 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 169fc5097..348beb747 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,21 @@ # Stage 1: Builder ARG BASE_IMG=python:3.12-slim -FROM $BASE_IMG as builder +FROM $BASE_IMG AS builder ARG GRPC_HEALTH_PROBE_VERSION="" ARG REQUIREMENTS="" WORKDIR /build +# Temporarily add the Debian Testing repository to install zlib1g 1:1.3.dfsg+really1.3.1-1+b1 (fixed CVE-2023-45853) +# Both zlib1g and zlib1g-dev are installed in the builder stage. +RUN echo "deb http://deb.debian.org/debian testing main" > /etc/apt/sources.list.d/testing.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends -t testing zlib1g=1:1.3.dfsg+really1.3.1-1+b1 zlib1g-dev=1:1.3.dfsg+really1.3.1-1+b1 \ + && rm -rf /etc/apt/sources.list.d/testing.list \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + # Install build dependencies RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends python3-dev gcc wget \ && rm -rf /var/lib/apt/lists/* @@ -49,12 +58,19 @@ RUN set -ex \ # Creare application specific tmp directory, set ENV TMPDIR to /app/tmp && mkdir -p /app/tmp \ && chown -R appuser:appgroup /venv /app \ - # Upgrade the package index and install security upgrades + # Temporarily add the Debian Testing repository to install zlib1g 1:1.3.dfsg+really1.3.1-1+b1 (fixed CVE-2023-45853) + && echo "deb http://deb.debian.org/debian testing main" > /etc/apt/sources.list.d/testing.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends -t testing zlib1g=1:1.3.dfsg+really1.3.1-1+b1 \ + && rm -rf /etc/apt/sources.list.d/testing.list \ + # Update package index and upgrade all installed packages && apt-get update \ && apt-get upgrade -y \ + # Clean up && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* + USER appuser ENTRYPOINT [ "/venv/bin/fedn" ] From a464c7ab68b69cbf679a05bf76ebaf689d155ae1 Mon Sep 17 00:00:00 2001 From: Niklas Date: Thu, 23 Jan 2025 13:38:59 +0100 Subject: [PATCH 08/10] Feature/SK-1288 | Test all get (/) api endpoints (#786) --- docker-compose.yaml | 10 ++++++ fedn/network/api/tests.py | 69 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 598aad0cb..b166130b2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -51,6 +51,15 @@ services: - ME_CONFIG_BASICAUTH_PASSWORD=password ports: - 8081:8081 + + fedn_postgres: + image: postgres:15 + environment: + POSTGRES_USER: fedn_admin + POSTGRES_PASSWORD: password + POSTGRES_DB: fedn_db + ports: + - "5432:5432" api-server: environment: @@ -72,6 +81,7 @@ services: depends_on: - minio - mongo + - fedn_postgres command: - controller - start diff --git a/fedn/network/api/tests.py b/fedn/network/api/tests.py index 284c9d008..ac37bed3f 100644 --- a/fedn/network/api/tests.py +++ b/fedn/network/api/tests.py @@ -25,10 +25,13 @@ # import unittest -from unittest.mock import patch +from unittest.mock import patch, MagicMock + +import pymongo import fedn # noqa: F401 +entitites = ['clients', 'combiners', 'models', 'packages', 'rounds', 'sessions', 'statuses', 'validations'] class NetworkAPITests(unittest.TestCase): """ Unittests for the Network API. """ @@ -59,6 +62,70 @@ def test_get_controller_status(self): # Assert response self.assertEqual(response.status_code, 200) + def test_get_endpoints(self): + """ Test allt get endpoints. """ + return_value = {"count": 1, "results": [{"id": "test"}]} + fedn.network.api.shared.client_store.list = MagicMock(return_value=return_value) + fedn.network.api.shared.combiner_store.list = MagicMock(return_value=return_value) + fedn.network.api.shared.model_store.list = MagicMock(return_value=return_value) + fedn.network.api.shared.package_store.list = MagicMock(return_value=return_value) + fedn.network.api.shared.round_store.list = MagicMock(return_value=return_value) + fedn.network.api.shared.session_store.list = MagicMock(return_value=return_value) + fedn.network.api.shared.status_store.list = MagicMock(return_value=return_value) + fedn.network.api.shared.validation_store.list = MagicMock(return_value=return_value) + + for entity in entitites: + response = self.app.get(f'/api/v1/{entity}/') + # Assert response + self.assertEqual(response.status_code, 200) + + count = response.json['count'] + expected_count = return_value['count'] + + self.assertEqual(count, expected_count) + + id = response.json['results'][0]['id'] + expected_id = return_value['results'][0]['id'] + + self.assertEqual(id, expected_id) + + fedn.network.api.shared.client_store.list.assert_called_with(0, 0, None, pymongo.DESCENDING) + fedn.network.api.shared.client_store.list.assert_called_once() + fedn.network.api.shared.combiner_store.list.assert_called_with(0, 0, None, pymongo.DESCENDING) + fedn.network.api.shared.combiner_store.list.assert_called_once() + fedn.network.api.shared.model_store.list.assert_called_with(0, 0, None, pymongo.DESCENDING) + fedn.network.api.shared.model_store.list.assert_called_once() + fedn.network.api.shared.package_store.list.assert_called_with(0, 0, None, pymongo.DESCENDING) + fedn.network.api.shared.package_store.list.assert_called_once() + fedn.network.api.shared.round_store.list.assert_called_with(0, 0, None, pymongo.DESCENDING) + fedn.network.api.shared.round_store.list.assert_called_once() + fedn.network.api.shared.session_store.list.assert_called_with(0, 0, None, pymongo.DESCENDING) + fedn.network.api.shared.session_store.list.assert_called_once() + fedn.network.api.shared.status_store.list.assert_called_with(0, 0, None, pymongo.DESCENDING) + fedn.network.api.shared.status_store.list.assert_called_once() + fedn.network.api.shared.validation_store.list.assert_called_with(0, 0, None, pymongo.DESCENDING) + fedn.network.api.shared.validation_store.list.assert_called_once() + + for entity in entitites: + headers = { + "X-Limit": 10, + "X-Skip": 10, + "X-Sort-Key": "test", + "X-Sort-Order": "asc" + } + response = self.app.get(f'/api/v1/{entity}/', headers=headers) + # Assert response + self.assertEqual(response.status_code, 200) + + fedn.network.api.shared.client_store.list.assert_called_with(10, 10, "test", pymongo.ASCENDING) + fedn.network.api.shared.combiner_store.list.assert_called_with(10, 10, "test", pymongo.ASCENDING) + fedn.network.api.shared.model_store.list.assert_called_with(10, 10, "test", pymongo.ASCENDING) + fedn.network.api.shared.package_store.list.assert_called_with(10, 10, "test", pymongo.ASCENDING) + fedn.network.api.shared.round_store.list.assert_called_with(10, 10, "test", pymongo.ASCENDING) + fedn.network.api.shared.session_store.list.assert_called_with(10, 10, "test", pymongo.ASCENDING) + fedn.network.api.shared.status_store.list.assert_called_with(10, 10, "test", pymongo.ASCENDING) + fedn.network.api.shared.validation_store.list.assert_called_with(10, 10, "test", pymongo.ASCENDING) + if __name__ == '__main__': unittest.main() From e475618b53bc0f1db6ce6800a0e87f58a9fa837e Mon Sep 17 00:00:00 2001 From: Fredrik Wrede Date: Thu, 23 Jan 2025 15:26:27 +0100 Subject: [PATCH 09/10] Fix/SK-1259 | Use api/v1 for set_active_package in APIClient (#775) --- .ci/tests/examples/run.sh | 2 +- docs/apiclient.rst | 2 +- docs/developer.rst | 2 +- examples/api-tutorials/API_Example.ipynb | 4 ++-- examples/api-tutorials/Aggregators.ipynb | 2 +- examples/api-tutorials/Hyperparameter_Tuning.ipynb | 2 +- fedn/network/api/client.py | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.ci/tests/examples/run.sh b/.ci/tests/examples/run.sh index aa498de5f..f85fbeec3 100755 --- a/.ci/tests/examples/run.sh +++ b/.ci/tests/examples/run.sh @@ -38,7 +38,7 @@ python ../../.ci/tests/examples/wait_for.py reducer python ../../.ci/tests/examples/wait_for.py combiners >&2 echo "Upload compute package" -python ../../.ci/tests/examples/api_test.py set_package --path package.tgz --helper "$helper" +python ../../.ci/tests/examples/api_test.py set_package --path package.tgz --helper "$helper" --name test >&2 echo "Wait for clients to connect" python ../../.ci/tests/examples/wait_for.py clients diff --git a/docs/apiclient.rst b/docs/apiclient.rst index c17451820..c4da710f2 100644 --- a/docs/apiclient.rst +++ b/docs/apiclient.rst @@ -61,7 +61,7 @@ To set the active compute package in the FEDn Studio Project: .. code:: python - >>> client.set_active_package("package.tgz", helper="numpyhelper") + >>> client.set_active_package("package.tgz", helper="numpyhelper", name="my-package") >>> client.set_active_model("seed.npz") **Start a training session** diff --git a/docs/developer.rst b/docs/developer.rst index cd55a596b..92fcdd3bb 100644 --- a/docs/developer.rst +++ b/docs/developer.rst @@ -38,7 +38,7 @@ so we will not require authentication of clients (insecure mode) when using the from fedn import APIClient client = APIClient(host="localhost", port=8092) - client.set_active_package("package.tgz", helper="numpyhelper") + client.set_active_package("package.tgz", helper="numpyhelper", name="my-package") client.set_active_model("seed.npz") To connect a native FEDn client to the sandbox deployment, you need to make sure that the combiner service can be resolved by the client using the name "combiner". diff --git a/examples/api-tutorials/API_Example.ipynb b/examples/api-tutorials/API_Example.ipynb index bb04472d6..436ab49fe 100644 --- a/examples/api-tutorials/API_Example.ipynb +++ b/examples/api-tutorials/API_Example.ipynb @@ -75,7 +75,7 @@ } ], "source": [ - "client.set_active_package('../mnist-pytorch/package.tgz', 'numpyhelper')\n", + "client.set_active_package('../mnist-pytorch/package.tgz', 'numpyhelper', 'mnist-pytorch')\n", "client.set_active_model('../mnist-pytorch/seed.npz')\n", "seed_model = client.get_active_model()\n", "print(seed_model)" @@ -164,7 +164,7 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ "
" ] diff --git a/examples/api-tutorials/Aggregators.ipynb b/examples/api-tutorials/Aggregators.ipynb index 265e2cdf7..86bd5626c 100644 --- a/examples/api-tutorials/Aggregators.ipynb +++ b/examples/api-tutorials/Aggregators.ipynb @@ -73,7 +73,7 @@ "metadata": {}, "outputs": [], "source": [ - "client.set_active_package('../mnist-pytorch/package.tgz', 'numpyhelper')\n", + "client.set_active_package('../mnist-pytorch/package.tgz', 'numpyhelper', 'mnist-pytorch')\n", "client.set_active_model('../mnist-pytorch/seed.npz')\n", "seed_model = client.get_active_model()\n", "print(seed_model)" diff --git a/examples/api-tutorials/Hyperparameter_Tuning.ipynb b/examples/api-tutorials/Hyperparameter_Tuning.ipynb index f929e2261..6a932e9b5 100644 --- a/examples/api-tutorials/Hyperparameter_Tuning.ipynb +++ b/examples/api-tutorials/Hyperparameter_Tuning.ipynb @@ -62,7 +62,7 @@ "metadata": {}, "outputs": [], "source": [ - "client.set_active_package('../mnist-pytorch/package.tgz', 'numpyhelper')\n", + "client.set_active_package('../mnist-pytorch/package.tgz', 'numpyhelper', 'mnist-pytorch')\n", "client.set_active_model('../mnist-pytorch/seed.npz')\n", "seed_model = client.get_active_model()" ] diff --git a/fedn/network/api/client.py b/fedn/network/api/client.py index 98cbae663..3e1d5549f 100644 --- a/fedn/network/api/client.py +++ b/fedn/network/api/client.py @@ -428,7 +428,7 @@ def download_package(self, path: str): else: return {"success": False, "message": "Failed to download package."} - def set_active_package(self, path: str, helper: str, name: str = None, description: str = None): + def set_active_package(self, path: str, helper: str, name: str, description: str = ""): """Set the compute package in the statestore. :param path: The file path of the compute package to set. From 363c0a6a2e1edfecba3593e3c258c103e632456b Mon Sep 17 00:00:00 2001 From: Katja Hellgren <96579188+KatHellg@users.noreply.github.com> Date: Thu, 23 Jan 2025 17:03:08 +0100 Subject: [PATCH 10/10] Feature/SK-1229 | Project resource for CLI extension (#784) * session_id flag added * minor code fix * added new line end of session cmd file * fixed acc to feedback * code fix * fixed conflict * conflict + minor fix * spelling fix:) * resolving conflict * trying to add get_client function again * header prints removed * fixed feedback * ruff linting fix * in progress * added default project to context file * changed from project name to project slug in context file * first draft of list, get, set and create project * Added project resource * bug fix in delete project * handle wrong project slug from user input * minor bug fix * bug fixes and studio default values added * added option to write username and password as arguments in login * bug and code redundancy fixes * fix * resolved fix requests * fixed change requests --------- Co-authored-by: KatHellg --- fedn/cli/__init__.py | 5 +- fedn/cli/client_cmd.py | 34 +----- fedn/cli/combiner_cmd.py | 34 +----- fedn/cli/login_cmd.py | 97 ++++++++++++---- fedn/cli/model_cmd.py | 52 ++------- fedn/cli/package_cmd.py | 40 +------ fedn/cli/project_cmd.py | 224 +++++++++++++++++++++++++++++++++++++ fedn/cli/round_cmd.py | 68 +++++------ fedn/cli/session_cmd.py | 39 +------ fedn/cli/shared.py | 81 ++++++++++++-- fedn/cli/status_cmd.py | 45 ++------ fedn/cli/validation_cmd.py | 54 +++------ 12 files changed, 463 insertions(+), 310 deletions(-) create mode 100644 fedn/cli/project_cmd.py diff --git a/fedn/cli/__init__.py b/fedn/cli/__init__.py index be680eb23..f00bb351b 100644 --- a/fedn/cli/__init__.py +++ b/fedn/cli/__init__.py @@ -1,14 +1,15 @@ from .client_cmd import client_cmd # noqa: F401 from .combiner_cmd import combiner_cmd # noqa: F401 from .config_cmd import config_cmd # noqa: F401 +from .controller_cmd import controller_cmd # noqa: F401 from .hooks_cmd import hooks_cmd # noqa: F401 +from .login_cmd import login_cmd # noqa: F401 from .main import main # noqa: F401 from .model_cmd import model_cmd # noqa: F401 from .package_cmd import package_cmd # noqa: F401 +from .project_cmd import project_cmd # noqa: F401 from .round_cmd import round_cmd # noqa: F401 from .run_cmd import run_cmd # noqa: F401 from .session_cmd import session_cmd # noqa: F401 from .status_cmd import status_cmd # noqa: F401 from .validation_cmd import validation_cmd # noqa: F401 -from .controller_cmd import controller_cmd # noqa: F401 -from .login_cmd import login_cmd # noqa: F401 diff --git a/fedn/cli/client_cmd.py b/fedn/cli/client_cmd.py index 7a55f37b4..e090a16d8 100644 --- a/fedn/cli/client_cmd.py +++ b/fedn/cli/client_cmd.py @@ -1,10 +1,9 @@ import uuid import click -import requests from fedn.cli.main import main -from fedn.cli.shared import CONTROLLER_DEFAULTS, apply_config, get_api_url, get_token, print_response +from fedn.cli.shared import CONTROLLER_DEFAULTS, apply_config, get_response, print_response from fedn.common.exceptions import InvalidClientConfig from fedn.network.clients.client_v2 import Client as ClientV2 from fedn.network.clients.client_v2 import ClientOptions @@ -48,22 +47,13 @@ def list_clients(ctx, protocol: str, host: str, port: str, token: str = None, n_ - result: list of clients """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="clients") headers = {} if n_max: headers["X-Limit"] = n_max - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - try: - response = requests.get(url, headers=headers) - print_response(response, "clients", None) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint="clients", token=token, headers=headers, usr_api=False, usr_token=False) + print_response(response, "clients", None) @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @@ -79,22 +69,8 @@ def get_client(ctx, protocol: str, host: str, port: str, token: str = None, id: - result: client with given id """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="clients") - headers = {} - - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - if id: - url = f"{url}{id}" - - try: - response = requests.get(url, headers=headers) - print_response(response, "client", id) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint=f"clients/{id}", token=token, headers={}, usr_api=False, usr_token=False) + print_response(response, "client", id) def _validate_client_params(config: dict): diff --git a/fedn/cli/combiner_cmd.py b/fedn/cli/combiner_cmd.py index 0a6403587..4df5846a4 100644 --- a/fedn/cli/combiner_cmd.py +++ b/fedn/cli/combiner_cmd.py @@ -1,10 +1,9 @@ import uuid import click -import requests from .main import main -from .shared import CONTROLLER_DEFAULTS, apply_config, get_api_url, get_token, print_response +from .shared import CONTROLLER_DEFAULTS, apply_config, get_response, print_response @main.group("combiner") @@ -77,22 +76,13 @@ def list_combiners(ctx, protocol: str, host: str, port: str, token: str = None, - result: list of combiners """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="combiners") headers = {} if n_max: headers["X-Limit"] = n_max - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - try: - response = requests.get(url, headers=headers) - print_response(response, "combiners", None) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint="combiners", token=token, headers=headers, usr_api=False, usr_token=False) + print_response(response, "combiners", None) @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @@ -108,19 +98,5 @@ def get_combiner(ctx, protocol: str, host: str, port: str, token: str = None, id - result: combiner with given id """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="combiners") - headers = {} - - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - if id: - url = f"{url}{id}" - - try: - response = requests.get(url, headers=headers) - print_response(response, "combiner", id) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint=f"combiners/{id}", token=token, headers={}, usr_api=False, usr_token=False) + print_response(response, "combiner", id) diff --git a/fedn/cli/login_cmd.py b/fedn/cli/login_cmd.py index d2ce8ac90..91cb49ddf 100644 --- a/fedn/cli/login_cmd.py +++ b/fedn/cli/login_cmd.py @@ -3,9 +3,9 @@ import click import requests -import yaml from .main import main +from .shared import STUDIO_DEFAULTS, get_response, set_context # Replace this with the platform's actual login endpoint home_dir = os.path.expanduser("~") @@ -19,19 +19,26 @@ def login_cmd(ctx): @login_cmd.command("login") -@click.option("-p", "--protocol", required=False, default="https", help="Communication protocol") -@click.option("-H", "--host", required=False, default="fedn.scaleoutsystems.com", help="Hostname of controller (api)") +@click.option("-u", "--username", required=False, default=None, help="username in studio") +@click.option("-P", "--password", required=False, default=None, help="password in studio") +@click.option("-p", "--protocol", required=False, default=STUDIO_DEFAULTS["protocol"], help="Communication protocol of studio (api)") +@click.option("-H", "--host", required=False, default=STUDIO_DEFAULTS["host"], help="Hostname of studio (api)") @click.pass_context -def login_cmd(ctx, protocol: str, host: str): - """Logging into FEDn Studio""" +def login_cmd(ctx, protocol: str, host: str, username: str, password: str): + """Login to FEDn Studio""" # Step 1: Display welcome message click.secho("Welcome to Scaleout FEDn!", fg="green") url = f"{protocol}://{host}/api/token/" # Step 3: Prompt for username and password - username = input("Please enter your username: ") - password = getpass("Please enter your password: ") + if username is None and password is None: + username = input("Please enter your username: ") + password = getpass("Please enter your password: ") + elif password is None: + password = getpass("Please enter your password: ") + else: + username = input("Please enter your username: ") # Call the authentication API try: @@ -44,18 +51,68 @@ def login_cmd(ctx, protocol: str, host: str): # Handle the response if response.status_code == 200: - data = response.json() - if data.get("access"): - click.secho("Login successful!", fg="green") - context_path = os.path.join(home_dir, ".fedn") - if not os.path.exists(context_path): - os.makedirs(context_path) - try: - with open(f"{context_path}/context.yaml", "w") as yaml_file: - yaml.dump(data, yaml_file, default_flow_style=False) # Add access and refresh tokens to context yaml file - except Exception as e: - print(f"Error: Failed to write to YAML file. Details: {e}") + context_data = get_context(response, protocol, host) + + context_path = os.path.join(home_dir, ".fedn") + if not os.path.exists(context_path): + os.makedirs(context_path) + set_context(context_path, context_data) + else: + click.secho(f"Unexpected error: {response.status_code}", fg="red") + + +# Sets the context for a given user +def get_context(response, protocol: str, host: str): + """Generates content for context file with the following data: + User tokens: access and refresh token to authenticate user towards Studio + Active project tokens: access and refresh token to authenticate user towards controller + Active project id: slug of active project + Active project url: controller url of active project + """ + context_data = {"User tokens": {}, "Active project tokens": {}, "Active project id": {}, "Active project url": {}} + user_token_data = response.json() + if user_token_data.get("access"): + context_data["User tokens"] = user_token_data + studio_api = True + headers_projects = {} + user_access_token = user_token_data.get("access") + response_projects = get_response( + protocol=protocol, + host=host, + port=None, + endpoint="projects", + token=user_access_token, + headers=headers_projects, + usr_api=studio_api, + usr_token=True, + ) + if response_projects.status_code == 200: + projects_response_json = response_projects.json() + if len(projects_response_json) > 0: + id = projects_response_json[0].get("slug") + context_data["Active project id"] = id + headers_projects["X-Project-Slug"] = id + response_project_tokens = get_response( + protocol=protocol, + host=host, + port=None, + endpoint="admin-token", + token=user_access_token, + headers=headers_projects, + usr_api=studio_api, + usr_token=False, + ) + if response_project_tokens.status_code == 200: + project_tokens = response_project_tokens.json() + context_data["Active project tokens"] = project_tokens + controller_url = f"{protocol}://{host}/{id}-fedn-reducer" + context_data["Active project url"] = controller_url + click.secho("Login successful!", fg="green") + else: + click.secho(f"Unexpected error: {response_project_tokens.status_code}", fg="red") else: - click.secho("Login failed. Please check your credentials.", fg="red") + click.secho(f"Unexpected error: {response_projects.status_code}", fg="red") else: - click.secho(f"Unexpected error: {response.text}", fg="red") + click.secho("Login failed. Please check your credentials.", fg="red") + + return context_data diff --git a/fedn/cli/model_cmd.py b/fedn/cli/model_cmd.py index 2e522e5a1..ae1dafe32 100644 --- a/fedn/cli/model_cmd.py +++ b/fedn/cli/model_cmd.py @@ -1,15 +1,13 @@ import click -import requests from .main import main -from .shared import CONTROLLER_DEFAULTS, get_api_url, get_token, print_response +from .shared import CONTROLLER_DEFAULTS, get_response, print_response @main.group("model") @click.pass_context def model_cmd(ctx): - """:param ctx: - """ + """:param ctx:""" pass @@ -17,7 +15,7 @@ def model_cmd(ctx): @click.option("-H", "--host", required=False, default=CONTROLLER_DEFAULTS["host"], help="Hostname of controller (api)") @click.option("-P", "--port", required=False, default=CONTROLLER_DEFAULTS["port"], help="Port of controller (api)") @click.option("-t", "--token", required=False, help="Authentication token") -@click.option("-session_id", "--session_id", required=False, help="models in session with given session id") +@click.option("-s", "--session_id", required=False, help="models in session with given session id") @click.option("--n_max", required=False, help="Number of items to list") @model_cmd.command("list") @click.pass_context @@ -28,28 +26,18 @@ def list_models(ctx, protocol: str, host: str, port: str, token: str = None, ses - result: list of models """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="models") - - headers = {} if n_max: headers["X-Limit"] = n_max - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - if session_id: - url = f"{url}?session_id={session_id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "models", None) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response( + protocol=protocol, host=host, port=port, endpoint=f"models/?session_id={session_id}", token=token, headers=headers, usr_api=False, usr_token=False + ) + else: + response = get_response(protocol=protocol, host=host, port=port, endpoint="models", token=token, headers=headers, usr_api=False, usr_token=False) + print_response(response, "models", None) @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @@ -65,23 +53,5 @@ def get_model(ctx, protocol: str, host: str, port: str, token: str = None, id: s - result: model with given id """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="models") - - - headers = {} - - - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - if id: - url = f"{url}{id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "model", id) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint=f"models/{id}", token=token, headers={}, usr_api=False, usr_token=False) + print_response(response, "model", id) diff --git a/fedn/cli/package_cmd.py b/fedn/cli/package_cmd.py index b8a130f68..e6ec14329 100644 --- a/fedn/cli/package_cmd.py +++ b/fedn/cli/package_cmd.py @@ -2,19 +2,17 @@ import tarfile import click -import requests from fedn.common.log_config import logger from .main import main -from .shared import CONTROLLER_DEFAULTS, get_api_url, get_token, print_response +from .shared import CONTROLLER_DEFAULTS, get_response, print_response @main.group("package") @click.pass_context def package_cmd(ctx): - """:param ctx: - """ + """:param ctx:""" pass @@ -55,23 +53,13 @@ def list_packages(ctx, protocol: str, host: str, port: str, token: str = None, n - result: list of packages """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="packages") headers = {} if n_max: headers["X-Limit"] = n_max - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - - try: - response = requests.get(url, headers=headers) - print_response(response, "packages", None) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint="packages", token=token, headers=headers, usr_api=False, usr_token=False) + print_response(response, "packages", None) @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @@ -87,21 +75,5 @@ def get_package(ctx, protocol: str, host: str, port: str, token: str = None, id: - result: package with given id """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="packages") - headers = {} - - - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - if id: - url = f"{url}{id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "package", id) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint=f"packages/{id}", token=token, headers={}, usr_api=False, usr_token=False) + print_response(response, "package", id) diff --git a/fedn/cli/project_cmd.py b/fedn/cli/project_cmd.py new file mode 100644 index 000000000..29c2d249c --- /dev/null +++ b/fedn/cli/project_cmd.py @@ -0,0 +1,224 @@ +import os + +import click +import requests + +from .main import main +from .shared import STUDIO_DEFAULTS, get_api_url, get_context, get_response, get_token, print_response, set_context + +home_dir = os.path.expanduser("~") + + +@main.group("project") +@click.pass_context +def project_cmd(ctx): + """:param ctx:""" + pass + + +@click.option("-id", "--id", required=True, help="ID of project.") +@click.option("-p", "--protocol", required=False, default=STUDIO_DEFAULTS["protocol"], help="Communication protocol of studio (api)") +@click.option("-H", "--host", required=False, default=STUDIO_DEFAULTS["host"], help="Hostname of studio (api)") +@project_cmd.command("delete") +@click.pass_context +def delete_project(ctx, id: str = None, protocol: str = None, host: str = None): + """Delete project with given ID.""" + # Check if project with given id exists + studio_api = True + + response = get_response(protocol=protocol, host=host, port=None, endpoint=f"projects/{id}", token=None, headers={}, usr_api=studio_api, usr_token=False) + if response.status_code == 200: + if response.json().get("error"): + click.secho(f"No project with id '{id}' exists.", fg="red") + else: + # Check if user wants to delete project with given id + user_input = input(f"Are you sure you want to delete project with id {id} (y/n)?: ") + if user_input == "y": + url = get_api_url(protocol=protocol, host=host, port=None, endpoint=f"projects/delete/{id}", usr_api=studio_api) + headers = {} + + _token = get_token(None, True) + + if _token: + headers["Authorization"] = _token + # Call the authentication API + try: + requests.delete(url, headers=headers) + click.secho(f"Project with slug {id} has been removed.", fg="green") + except requests.exceptions.RequestException as e: + click.echo(str(e), fg="red") + activate_project(None, protocol, host) + else: + click.secho(f"Unexpected error: {response.status_code}", fg="red") + + +@click.option("-n", "--name", required=False, default=None, help="Name of new projec.") +@click.option("-d", "--description", required=False, default=None, help="Description of new projec.") +@click.option("-p", "--protocol", required=False, default=STUDIO_DEFAULTS["protocol"], help="Communication protocol of studio (api)") +@click.option("-H", "--host", required=False, default=STUDIO_DEFAULTS["host"], help="Hostname of studio (api)") +@project_cmd.command("create") +@click.pass_context +def create_project(ctx, name: str = None, description: str = None, protocol: str = None, host: str = None): + """Create project. + :param ctx: + """ + # Check if user can create project + studio_api = True + url = get_api_url(protocol=protocol, host=host, port=None, endpoint="projects/create", usr_api=studio_api) + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + _token = get_token(None, True) + + if _token: + headers["Authorization"] = _token + if name is None: + name = input("Please enter a project name: ") + if description is None: + description = input("Please enter a project description (optional): ") + if len(name) > 46 or len(description) >= 255: + click.secho("Project name or description too long.", fg="red") + else: + # Call the authentication API + try: + requests.post(url, data={"name": name, "description": description}, headers=headers) + except requests.exceptions.RequestException as e: + click.secho(str(e), fg="red") + click.secho("Project created.", fg="green") + + +@click.option("-p", "--protocol", required=False, default=STUDIO_DEFAULTS["protocol"], help="Communication protocol of studio (api)") +@click.option("-H", "--host", required=False, default=STUDIO_DEFAULTS["host"], help="Hostname of studio (api)") +@project_cmd.command("list") +@click.pass_context +def list_projects(ctx, protocol: str = None, host: str = None): + """Return: + ------ + - result: list of projects + + """ + studio_api = True + + response = get_response(protocol=protocol, host=host, port=None, endpoint="projects", token=None, headers={}, usr_api=studio_api, usr_token=True) + + if response.status_code == 200: + response_json = response.json() + if len(response_json) > 0: + context_path = os.path.join(home_dir, ".fedn") + context_data = get_context(context_path) + active_project = context_data.get("Active project id") + + for i in response_json: + project_name = i.get("slug") + if project_name == active_project: + click.secho(f"{project_name} (active)", fg="green") + else: + click.secho(project_name) + else: + click.secho(f"Unexpected error: {response.status_code}", fg="red") + + +@click.option("-id", "--id", required=True, help="ID of project.") +@click.option("-p", "--protocol", required=False, default=STUDIO_DEFAULTS["protocol"], help="Communication protocol of studio (api)") +@click.option("-H", "--host", required=False, default=STUDIO_DEFAULTS["host"], help="Hostname of studio (api)") +@project_cmd.command("get") +@click.pass_context +def get_project(ctx, id: str = None, protocol: str = None, host: str = None): + """Return: + ------ + - result: project with given id + + """ + studio_api = True + + response = get_response(protocol=protocol, host=host, port=None, endpoint=f"projects/{id}", token=None, headers={}, usr_api=studio_api, usr_token=False) + + if response.status_code == 200: + response_json = response.json() + + if response_json.get("error"): + click.secho(f"No project with id '{id}' exists.", fg="red") + else: + print_response(response, "project", True) + else: + click.secho(f"Unexpected error: {response.status_code}", fg="red") + + +@click.option("-id", "--id", required=True, help="id name of project.") +@click.option("-p", "--protocol", required=False, default=STUDIO_DEFAULTS["protocol"], help="Communication protocol of studio (api)") +@click.option("-H", "--host", required=False, default=STUDIO_DEFAULTS["host"], help="Hostname of studio (api)") +@project_cmd.command("set-context") +@click.pass_context +def set_active_project(ctx, id: str = None, protocol: str = None, host: str = None): + """Set active project. + + :param ctx: + :param id: + """ + activate_project(id, protocol, host) + + +def activate_project(id: str = None, protocol: str = None, host: str = None): + """Sets project with give ID as active by updating context file.""" + studio_api = True + headers_projects = {} + context_path = os.path.join(home_dir, ".fedn") + context_data = get_context(context_path) + + user_access_token = context_data.get("User tokens").get("access") + + response_projects = get_response( + protocol=protocol, host=host, port=None, endpoint="projects", token=user_access_token, headers=headers_projects, usr_api=studio_api, usr_token=False + ) + if response_projects.status_code == 200: + projects_response_json = response_projects.json() + if len(projects_response_json) > 0: + if id is None: + headers_projects["X-Project-Slug"] = projects_response_json[0].get("slug") + id = projects_response_json[0].get("slug") + else: + for i in projects_response_json: + project_found = False + if i.get("slug") == id: + project_found = True + headers_projects["X-Project-Slug"] = i.get("slug") + if not project_found: + click.secho(f"No project found with id {id}", fg="red") + return + controller_url = f"{protocol}://{host}/{id}-fedn-reducer" + + response_project_tokens = get_response( + protocol=protocol, + host=host, + port=None, + endpoint="admin-token", + token=user_access_token, + headers=headers_projects, + usr_api=studio_api, + usr_token=False, + ) + if response_project_tokens.status_code == 200: + project_tokens = response_project_tokens.json() + context_data["Active project tokens"] = project_tokens + context_data["Active project id"] = id + context_data["Active project url"] = controller_url + + set_context(context_path, context_data) + + click.secho(f"Project with slug {id} is now active.", fg="green") + else: + click.secho(f"Unexpected error: {response_project_tokens.status_code}", fg="red") + else: + click.echo("No projects available to set current context.") + else: + click.secho(f"Unexpected error: {response_projects.status_code}", fg="red") + + +def no_project_exists(response) -> bool: + """Returns true if no project exists.""" + response_json = response.json() + print(response_json) + if type(response_json) is list: + return False + elif response_json.get("error"): + return True + return False diff --git a/fedn/cli/round_cmd.py b/fedn/cli/round_cmd.py index 2f889fef3..71c68df69 100644 --- a/fedn/cli/round_cmd.py +++ b/fedn/cli/round_cmd.py @@ -1,22 +1,20 @@ import click -import requests from .main import main -from .shared import CONTROLLER_DEFAULTS, get_api_url, get_token, print_response +from .shared import CONTROLLER_DEFAULTS, get_response, print_response @main.group("round") @click.pass_context def round_cmd(ctx): - """:param ctx: - """ + """:param ctx:""" pass @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @click.option("-H", "--host", required=False, default=CONTROLLER_DEFAULTS["host"], help="Hostname of controller (api)") @click.option("-P", "--port", required=False, default=CONTROLLER_DEFAULTS["port"], help="Port of controller (api)") -@click.option("-session_id", "--session_id", required=False, help="Rounds in session with given session id") +@click.option("-s", "--session_id", required=False, help="Rounds in session with given session id") @click.option("-t", "--token", required=False, help="Authentication token") @click.option("--n_max", required=False, help="Number of items to list") @round_cmd.command("list") @@ -28,28 +26,34 @@ def list_rounds(ctx, protocol: str, host: str, port: str, token: str = None, ses - result: list of rounds """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="rounds") - headers = {} if n_max: headers["X-Limit"] = n_max - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - if session_id: - url = f"{url}?round_config.session_id={session_id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "rounds", None) - - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response( + protocol=protocol, + host=host, + port=port, + endpoint=f"rounds/?round_config.session_id={session_id}", + token=token, + headers=headers, + usr_api=False, + usr_token=False, + ) + else: + response = get_response( + protocol=protocol, + host=host, + port=port, + endpoint="rounds", + token=token, + headers=headers, + usr_api=False, + usr_token=False, + ) + print_response(response, "rounds", None) @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @@ -65,23 +69,5 @@ def get_round(ctx, protocol: str, host: str, port: str, token: str = None, id: s - result: round with given id """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="rounds") - - headers = {} - - - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - if id: - url = f"{url}{id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "round", id) - - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint=f"rounds/{id}", token=token, headers={}, usr_api=False, usr_token=False) + print_response(response, "round", id) diff --git a/fedn/cli/session_cmd.py b/fedn/cli/session_cmd.py index a0f1e64c3..4a1624f4d 100644 --- a/fedn/cli/session_cmd.py +++ b/fedn/cli/session_cmd.py @@ -1,15 +1,13 @@ import click -import requests from .main import main -from .shared import CONTROLLER_DEFAULTS, get_api_url, get_token, print_response +from .shared import CONTROLLER_DEFAULTS, get_response, print_response @main.group("session") @click.pass_context def session_cmd(ctx): - """:param ctx: - """ + """:param ctx:""" pass @@ -27,23 +25,13 @@ def list_sessions(ctx, protocol: str, host: str, port: str, token: str = None, n - result: list of sessions """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="sessions") headers = {} if n_max: headers["X-Limit"] = n_max - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - - try: - response = requests.get(url, headers=headers) - print_response(response, "sessions", None) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint="sessions", token=token, headers=headers, usr_api=False, usr_token=False) + print_response(response, "sessions", None) @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @@ -59,20 +47,5 @@ def get_session(ctx, protocol: str, host: str, port: str, token: str = None, id: - result: session with given session id """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="sessions") - headers = {} - - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - if id: - url = f"{url}{id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "session", id) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint=f"sessions/{id}", token=token, headers={}, usr_api=False, usr_token=False) + print_response(response, "session", id) diff --git a/fedn/cli/shared.py b/fedn/cli/shared.py index 21fa2b072..f463b5554 100644 --- a/fedn/cli/shared.py +++ b/fedn/cli/shared.py @@ -1,12 +1,15 @@ import os import click +import requests import yaml from fedn.common.log_config import logger CONTROLLER_DEFAULTS = {"protocol": "http", "host": "localhost", "port": 8092, "debug": False} +STUDIO_DEFAULTS = {"protocol": "https", "host": "fedn.scaleoutstudio.com"} + COMBINER_DEFAULTS = {"discover_host": "localhost", "discover_port": 8092, "host": "localhost", "port": 12080, "name": "combiner", "max_clients": 30} CLIENT_DEFAULTS = { @@ -16,6 +19,8 @@ API_VERSION = "v1" +home_dir = os.path.expanduser("~") + def apply_config(path: str, config: dict): """Parse client config from file. @@ -35,24 +40,47 @@ def apply_config(path: str, config: dict): config[key] = val -def get_api_url(protocol: str, host: str, port: str, endpoint: str) -> str: - _url = os.environ.get("FEDN_CONTROLLER_URL") +def get_api_url(protocol: str, host: str, port: str, endpoint: str, usr_api: bool) -> str: + if usr_api: + _url = os.environ.get("FEDN_STUDIO_URL") + _protocol = protocol or os.environ.get("FEDN_STUDIO_PROTOCOL") or STUDIO_DEFAULTS["protocol"] + _host = host or os.environ.get("FEDN_STUDIO_HOST") or STUDIO_DEFAULTS["host"] - if _url: - return f"{_url}/api/{API_VERSION}/{endpoint}/" + if _url is None: + return f"{_protocol}://{_host}/api/{API_VERSION}/{endpoint}" + + return f"{_url}/api/{API_VERSION}/{endpoint}" + else: + _url = os.environ.get("FEDN_CONTROLLER_URL") + _protocol = protocol or os.environ.get("FEDN_CONTROLLER_PROTOCOL") or CONTROLLER_DEFAULTS["protocol"] + _host = host or os.environ.get("FEDN_CONTROLLER_HOST") or CONTROLLER_DEFAULTS["host"] + _port = port or os.environ.get("FEDN_CONTROLLER_PORT") or CONTROLLER_DEFAULTS["port"] - _protocol = protocol or os.environ.get("FEDN_CONTROLLER_PROTOCOL") or CONTROLLER_DEFAULTS["protocol"] - _host = host or os.environ.get("FEDN_CONTROLLER_HOST") or CONTROLLER_DEFAULTS["host"] - _port = port or os.environ.get("FEDN_CONTROLLER_PORT") or CONTROLLER_DEFAULTS["port"] + if _url is None: + context_path = os.path.join(home_dir, ".fedn") + try: + context_data = get_context(context_path) + _url = context_data.get("Active project url") + except Exception as e: + click.echo(f"Encountered error {e}. Make sure you are logged in and have activated a project. Using controller defaults instead.", fg="red") + _url = f"{_protocol}://{_host}:{_port}" - return f"{_protocol}://{_host}:{_port}/api/{API_VERSION}/{endpoint}/" + return f"{_url}/api/{API_VERSION}/{endpoint}" -def get_token(token: str) -> str: +def get_token(token: str, usr_token: bool) -> str: _token = token or os.environ.get("FEDN_AUTH_TOKEN", None) if _token is None: - return None + context_path = os.path.join(home_dir, ".fedn") + try: + context_data = get_context(context_path) + if usr_token: + _token = context_data.get("User tokens").get("access") + else: + _token = context_data.get("Active project tokens").get("access") + except Exception as e: + click.secho(f"Encountered error {e}. Make sure you are logged in and have activated a project.", fg="red") scheme = os.environ.get("FEDN_AUTH_SCHEME", "Bearer") @@ -99,3 +127,36 @@ def print_response(response, entity_name: str, so): click.echo(f'Error: {json_data["message"]}') else: click.echo(f"Error: {response.status_code}") + + +def set_context(context_path, context_data): + """Saves context data as yaml file in given path""" + try: + with open(f"{context_path}/context.yaml", "w") as yaml_file: + yaml.dump(context_data, yaml_file, default_flow_style=False) + except Exception as e: + print(f"Error: Failed to write to YAML file. Details: {e}") + + +def get_context(context_path): + """Retrieves context data from yaml file in given path""" + try: + with open(f"{context_path}/context.yaml", "r") as yaml_file: + context_data = yaml.safe_load(yaml_file) + except Exception as e: + print(f"Error: Failed to write to YAML file. Details: {e}") + return context_data + + +def get_response(protocol: str, host: str, port: str, endpoint: str, token: str, headers: dict, usr_api: bool, usr_token: str): + """Utility function to retrieve response from get request based on provided information.""" + url = get_api_url(protocol=protocol, host=host, port=port, endpoint=endpoint, usr_api=usr_api) + + _token = get_token(token=token, usr_token=usr_token) + + if _token: + headers["Authorization"] = _token + + response = requests.get(url, headers=headers) + + return response diff --git a/fedn/cli/status_cmd.py b/fedn/cli/status_cmd.py index 9b751f65b..56fd22801 100644 --- a/fedn/cli/status_cmd.py +++ b/fedn/cli/status_cmd.py @@ -1,8 +1,7 @@ import click -import requests from .main import main -from .shared import CONTROLLER_DEFAULTS, get_api_url, get_token, print_response +from .shared import CONTROLLER_DEFAULTS, get_response, print_response @main.group("status") @@ -16,7 +15,7 @@ def status_cmd(ctx): @click.option("-H", "--host", required=False, default=CONTROLLER_DEFAULTS["host"], help="Hostname of controller (api)") @click.option("-P", "--port", required=False, default=CONTROLLER_DEFAULTS["port"], help="Port of controller (api)") @click.option("-t", "--token", required=False, help="Authentication token") -@click.option("-session_id", "--session_id", required=False, help="statuses with given session id") +@click.option("-s", "--session_id", required=False, help="statuses with given session id") @click.option("--n_max", required=False, help="Number of items to list") @status_cmd.command("list") @click.pass_context @@ -27,26 +26,18 @@ def list_statuses(ctx, protocol: str, host: str, port: str, token: str = None, s - result: list of statuses """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="statuses") headers = {} if n_max: headers["X-Limit"] = n_max - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - if session_id: - url = f"{url}?sessionId={session_id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "statuses", None) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response( + protocol=protocol, host=host, port=port, endpoint=f"statuses/?sessionId={session_id}", token=token, headers=headers, usr_api=False, usr_token=False + ) + else: + response = get_response(protocol=protocol, host=host, port=port, endpoint="statuses", token=token, headers=headers, usr_api=False, usr_token=False) + print_response(response, "statuses", None) @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @@ -62,21 +53,5 @@ def get_status(ctx, protocol: str, host: str, port: str, token: str = None, id: - result: status with given id """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="statuses") - headers = {} - - - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - if id: - url = f"{url}{id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "status", id) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint=f"statuses{id}", token=token, headers={}, usr_api=False, usr_token=False) + print_response(response, "status", id) diff --git a/fedn/cli/validation_cmd.py b/fedn/cli/validation_cmd.py index b7417af5e..37600deb4 100644 --- a/fedn/cli/validation_cmd.py +++ b/fedn/cli/validation_cmd.py @@ -1,15 +1,13 @@ import click -import requests from .main import main -from .shared import CONTROLLER_DEFAULTS, get_api_url, get_token, print_response +from .shared import CONTROLLER_DEFAULTS, get_response, print_response @main.group("validation") @click.pass_context def validation_cmd(ctx): - """:param ctx: - """ + """:param ctx:""" pass @@ -17,7 +15,7 @@ def validation_cmd(ctx): @click.option("-H", "--host", required=False, default=CONTROLLER_DEFAULTS["host"], help="Hostname of controller (api)") @click.option("-P", "--port", required=False, default=CONTROLLER_DEFAULTS["port"], help="Port of controller (api)") @click.option("-t", "--token", required=False, help="Authentication token") -@click.option("-session_id", "--session_id", required=False, help="validations in session with given session id") +@click.option("-s", "--session_id", required=False, help="validations in session with given session id") @click.option("--n_max", required=False, help="Number of items to list") @validation_cmd.command("list") @click.pass_context @@ -28,26 +26,25 @@ def list_validations(ctx, protocol: str, host: str, port: str, token: str = None - result: list of validations """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="validations") headers = {} if n_max: headers["X-Limit"] = n_max - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - if session_id: - url = f"{url}?sessionId={session_id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "validations", None) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response( + protocol=protocol, + host=host, + port=port, + endpoint=f"validations/?sessionId={session_id}", + token=token, + headers=headers, + usr_api=False, + usr_token=False, + ) + else: + response = get_response(protocol=protocol, host=host, port=port, endpoint="validations", token=token, headers=headers, usr_api=False, usr_token=False) + print_response(response, "validations", None) @click.option("-p", "--protocol", required=False, default=CONTROLLER_DEFAULTS["protocol"], help="Communication protocol of controller (api)") @@ -63,20 +60,5 @@ def get_validation(ctx, protocol: str, host: str, port: str, token: str = None, - result: validation with given id """ - url = get_api_url(protocol=protocol, host=host, port=port, endpoint="validations") - headers = {} - - _token = get_token(token) - - if _token: - headers["Authorization"] = _token - - if id: - url = f"{url}{id}" - - - try: - response = requests.get(url, headers=headers) - print_response(response, "validation", id) - except requests.exceptions.ConnectionError: - click.echo(f"Error: Could not connect to {url}") + response = get_response(protocol=protocol, host=host, port=port, endpoint=f"validations/{id}", token=token, headers={}, usr_api=False, usr_token=False) + print_response(response, "validation", id)