diff --git a/.vscode/settings.json b/.vscode/settings.json index 07cfc57ae..d4c2ea8ad 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.organizeImports": true + "source.organizeImports": "explicit" }, "python.linting.enabled": true, "python.linting.flake8Enabled": true, diff --git a/README.rst b/README.rst index 1e3c42cb6..03c851a6a 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,3 @@ -.. figure:: https://thumb.tildacdn.com/tild6637-3937-4565-b861-386330386132/-/resize/560x/-/format/webp/FEDn_logo.png - :alt: FEDn logo .. image:: https://github.com/scaleoutsystems/fedn/actions/workflows/integration-tests.yaml/badge.svg :target: https://github.com/scaleoutsystems/fedn/actions/workflows/integration-tests.yaml @@ -10,73 +8,77 @@ .. image:: https://readthedocs.org/projects/fedn/badge/?version=latest&style=flat :target: https://fedn.readthedocs.io -FEDn is a modular and model agnostic framework for -federated machine learning. FEDn is designed to scale from pseudo-distributed -development on your laptop to real-world production setups in geographically distributed environments. +FEDn +-------- + +FEDn empowers developers, researchers, and data scientists to create federated learning applications that seamlessly transition from local proofs-of-concept to real-world distributed deployments. Develop your federated learning use case in a pseudo-local environment, and deploy it to FEDn Studio for real-world Federated Learning without any need for code changes. Core Features ============= -- **Scalable and resilient.** FEDn is scalable and resilient via a tiered - architecture where multiple aggregation servers (combiners) divide up the work to coordinate clients and aggregate models. - Benchmarks show high performance both for thousands of clients in a cross-device - setting and for large model updates in a cross-silo setting. - FEDn has the ability to recover from failure in all critical components. - -- **Security**. FEDn is built using secure industry standard communication protocols (gRPC). A key feature is that - clients do not have to expose any ingress ports. +- **Scalable and resilient.** FEDn facilitates the coordination of clients and model aggregation through multiple aggregation servers sharing the workload. This design makes the framework highly scalable, accommodating large numbers of clients. The system is engineered to seamlessly recover from failures, ensuring robust deployment in production environments. Furthermore, FEDn adeptly manages asynchronous federated learning scenarios, accommodating clients that may connect or drop out during training. -- **Track events and training progress in real-time**. FEDn tracks events for clients and aggregation servers, logging to MongoDB. This - helps developers monitor traning progress in real-time, and to troubleshoot the distributed computation. - Tracking and model validation data can easily be retrieved using the API enabling development of custom dashboards and visualizations. +- **Security**. FL clients do not need to open any ingress ports, facilitating real-world deployments across a wide variety of settings. Additionally, FEDn utilizes secure, industry-standard communication protocols and supports token-based authentication for FL clients, enhancing security and ease of integration in diverse environments. -- **Flexible handling of asynchronous clients**. FEDn supports flexible experimentation - with clients coming in and dropping out during training sessions. Extend aggregators to experiment - with different strategies to handle so called stragglers. +- **Track events and training progress in real-time**. Extensive event logging and distributed tracing enable developers to monitor experiments in real-time, simplifying troubleshooting and auditing processes. Machine learning validation metrics from clients can be accessed via the API, allowing for flexible analysis of federated experiments. -- **ML-framework agnostic**. Model updates are treated as black-box - computations. This means that it is possible to support any - ML model type or framework. Support for Keras and PyTorch is +- **ML-framework agnostic**. FEDn is compatible with all major ML frameworks. Examples for Keras, PyTorch and scikit-learn are available out-of-the-box. +From development to real-world FL: + +- Develop a FEDn project in a local development environment, and then deploy it to FEDn Studio +- The FEDn server-side as a managed, production-grade service on Kubernetes. +- Token-based authentication for FL clients +- Role-based access control (RBAC) +- REST API +- Dashboard for orchestrating runs, visualizing and downloading results +- Admin dashboard for managing and scaling the FEDn network +- Collaborate with other data-scientists in a shared workspace. +- Cloud or on-premise deployment -Getting started + +Getting started with FEDn =============== The best way to get started is to take the quickstart tutorial: -- `Quickstart PyTorch `__ +- `Quickstart `__ Documentation ============= -You will find more details about the architecture, compute package and how to deploy FEDn fully distributed in the documentation: + +More details about the architecture, deployment, and how to develop your own application and framework extensions (such as custom aggregators) are found in the documentation: - `Documentation `__ -- `Paper `__ -FEDn Studio +Deploying a project to FEDn Studio =============== -Scaleout also develops FEDn Studio, a web application that extends the FEDn SDK with a UI, production-grade deployment of the FEDn server side on Kubernetes, user authentication/authorization, client identity/API-token management, and project-based multitenancy for segmenting work and resources into collaboration workspaces. FEDn Studio is available as a fully managed service. -There is also additional tooling and charts for self-managed deployment on Kubernetes including integration with several projects from the cloud native landscape. -See `FEDn Framework `__ for more information. +Studio offers a production-grade deployment of the FEDn server-side infrastructure on Kubernetes. With Studio, you can also manage token-based authentication for clients and collaborate with other users in joint project workspaces. In addition to a REST API, Studio features intuitive dashboards that allows you to orchestrate FL experiments and visualize and manage global models, event logs and metrics. These features enhance your ability to monitor and analyze federated learning projects. Studio is available as-a service hosted by Scaleout and one project is provided for free for testing and research. -Making contributions -==================== +- `Register for a project in Studio `__ +- `Deploy you project to FEDn Studio `__ -All pull requests will be considered and are much appreciated. Reach out -to one of the maintainers if you are interested in making contributions, -and we will help you find a good first issue to get you started. For -more details please refer to our `contribution -guidelines `__. +Options and charts are also available for self-managed deployment of FEDn Studio, reach out to the Scaleout team for more information. -Community support + +Support ================= Community support in available in our `Discord server `__. +Options are also available for `Enterprise support `__. + +Making contributions +==================== + +All pull requests will be considered and are much appreciated. For +more details please refer to our `contribution +guidelines `__. + Citation ======== @@ -91,10 +93,6 @@ If you use FEDn in your research, please cite: year={2021} } -Organizational collaborators, contributors and supporters -========================================================= - -|FEDn logo| |UU logo| |AI Sweden logo| |Zenseact logo| |Scania logo| License ======= @@ -102,13 +100,4 @@ License FEDn is licensed under Apache-2.0 (see `LICENSE `__ file for full information). -.. |FEDn logo| image:: https://github.com/scaleoutsystems/fedn/raw/master/docs/img/logos/Scaleout.png - :width: 15% -.. |UU logo| image:: https://github.com/scaleoutsystems/fedn/raw/master/docs/img/logos/UU.png - :width: 15% -.. |AI Sweden logo| image:: https://github.com/scaleoutsystems/fedn/raw/master/docs/img/logos/ai-sweden-logo.png - :width: 15% -.. |Zenseact logo| image:: https://github.com/scaleoutsystems/fedn/raw/master/docs/img/logos/zenseact-logo.png - :width: 15% -.. |Scania logo| image:: https://github.com/scaleoutsystems/fedn/raw/master/docs/img/logos/Scania.png - :width: 15% +Use of FEDn Studio (SaaS) is subject to the `Terms of Use `__. diff --git a/docs/auth.rst b/docs/auth.rst new file mode 100644 index 000000000..1866bd4f1 --- /dev/null +++ b/docs/auth.rst @@ -0,0 +1,90 @@ +.. _auth-label: + +Authentication and Authorization (RBAC) +============================================= +.. warning:: The FEDn RBAC system is an experimental feature and may change in the future. + +FEDn supports Role-Based Access Control (RBAC) for controlling access to the FEDn API and gRPC endpoints. The RBAC system is based on JSON Web Tokens (JWT) and is implemented using the `jwt` package. The JWT tokens are used to authenticate users and to control access to the FEDn API. +There are two types of JWT tokens used in the FEDn RBAC system: +- Access tokens: Used to authenticate users and to control access to the FEDn API. +- Refresh tokens: Used to obtain new access tokens when the old ones expire. + +.. note:: Please note that the FEDn RBAC system is not enabled by default and does not issue JWT tokens. It is used to integrate with external authentication and authorization systems such as FEDn Studio. + +FEDn RBAC system is by default configured with four types of roles: +- `admin`: Has full access to the FEDn API. This role is used to manage the FEDn network using the API client or the FEDn CLI. +- `combiner`: Has access to the /add_combiner endpoint in the API. +- `client`: Has access to the /add_client endpoint in the API and various gRPC endpoint to participate in federated learning sessions. + +A full list of the "roles to endpoint" mappings for gRPC can be found in the `fedn/network/grpc/auth.py`. For the API, the mappings are defined using custom decorators defined in `fedn/network/api/auth.py`. + +.. note:: The roles are handled by a custom claim in the JWT token called `role`. The claim is used to control access to the FEDn API and gRPC endpoints. + +To enable the FEDn RBAC system, you need to set the following environment variables in the controller and combiner: + +.. envvar:: FEDN_JWT_SECRET_KEY + :type: str + :required: yes + :default: None + :description: The secret key used for JWT token encryption. + +.. envvar:: FEDN_JWT_ALGORITHM + :type: str + :required: no + :default: "HS256" + :description: The algorithm used for JWT token encryption. + +.. envvar:: FEDN_AUTH_SCHEME + :type: str + :required: no + :default: "Token" + :description: The authentication scheme used in the FEDn API and gRPC interceptors. + +For further fexibility, you can also set the following environment variables: + +.. envvar:: FEDN_CUSTOM_URL_PREFIX + :type: str + :required: no + :default: None + :description: Add a custom URL prefix used in the FEDn API, such as /internal or /v1. + +.. envvar:: FEDN_AUTH_WHITELIST_URL + :type: str + :required: no + :default: None + :description: A URL patterns to the API that should be excluded from the FEDn RBAC system. For example /internal (to enable internal API calls). + +.. envvar:: FEDN_JWT_CUSTOM_CLAIM_KEY + :type: str + :required: no + :default: None + :description: The custom claim key used in the JWT token. + +.. envvar:: FEDN_JWT_CUSTOM_CLAIM_VALUE + :type: str + :required: no + :default: None + :description: The custom claim value used in the JWT token. + + +For the client you need to set the following environment variables: + +.. envvar:: FEDN_JWT_ACCESS_TOKEN + :type: str + :required: yes + :default: None + :description: The access token used to authenticate the client to the FEDn API. + +.. envvar:: FEDN_JWT_REFRESH_TOKEN + :type: str + :required: no + :default: None + :description: The refresh token used to obtain new access tokens when the old ones expire. + +.. envvar:: FEDN_AUTH_SCHEME + :type: str + :required: no + :default: "Token" + :description: The authentication scheme used in the FEDn API and gRPC interceptors. + +You can also use `--token` flags in the FEDn CLI to set the access token. \ No newline at end of file diff --git a/docs/distributed.rst b/docs/distributed.rst new file mode 100644 index 000000000..179369eb2 --- /dev/null +++ b/docs/distributed.rst @@ -0,0 +1,70 @@ +Distributed Deployment +=================================== + +This tutorial outlines the steps for deploying the FEDn framework over a **local network**, using a workstation as +the host and different devices as clients. For general steps on how to run FEDn, see one of the quickstart tutorials. + + +.. note:: + For a secure and production-grade deployment solution over **public networks**, explore the FEDn Studio service at + **studio.scaleoutsystems.com**. + + Alternatively follow this tutorial substituting the hosts local IP with your public IP, open the neccesary + ports (see which ports are used in docker-compose.yaml), and ensure you have taken additional neccesary security + precautions. + +Prerequisites +------------- +- `One host workstation and atleast one client device` +- `Python 3.8, 3.9 or 3.10 `__ +- `Docker `__ +- `Docker Compose `__ + +Launch a distributed FEDn Network +------------- + + +Start by noting your host's local IP address, used within your network. Discover it by running ifconfig on UNIX or +ipconfig on Windows, typically listed under inet for Unix and IPv4 for Windows. + +Continue with following the standard procedure to initiate a FEDn network, for example using by docker-compose. +Once the network is active, upload your compute package and seed (for comprehensive details, see the quickstart tutorials). + + +Configuring and Attaching Clients +------------- + +On your client device, continue with initializing your client. To connect to the host machine we need to ensure we are +routing the correct DNS to our hosts local IP address. We can do this using the standard FEDn `client.yaml`: + +.. code-block:: + + network_id: fedn-network + discover_host: api-server + discover_port: 8092 + + +We can then run using docker by adding the hosts in the docker run command: + +.. code-block:: + + docker run \ + -v $PWD/client.yaml: \ + + —add-host=api-server: \ + —add-host=combiner: \ + run client -in client.yaml --name client1 + + +Alternatively updating the `/etc/hosts` file, appending the following lines for running naitively: + +.. code-block:: + + api-server + combiner + + +Start a training session +------------- + +After connecting with your clients, you are ready to start training sessions from the host machine. \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 3f09dbda5..098b37a2e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,11 +10,13 @@ :caption: Documentation studio + distributed apiclient tutorial architecture aggregators helpers + auth .. toctree:: :maxdepth: 2 diff --git a/examples/async-simulation/.gitignore b/examples/async-clients/.gitignore similarity index 90% rename from examples/async-simulation/.gitignore rename to examples/async-clients/.gitignore index 4ab9fa59f..a3e7562db 100644 --- a/examples/async-simulation/.gitignore +++ b/examples/async-clients/.gitignore @@ -2,5 +2,6 @@ data *.npz *.tgz *.tar.gz +*.log .async-simulation client.yaml \ No newline at end of file diff --git a/examples/async-clients/Experiment.ipynb b/examples/async-clients/Experiment.ipynb new file mode 100644 index 000000000..1035eb3e4 --- /dev/null +++ b/examples/async-clients/Experiment.ipynb @@ -0,0 +1,387 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "622f7047", + "metadata": {}, + "source": [ + "## Cross-device FL with FEDn Part I: A local development environment for intermittent and asyncronous clients \n", + "\n", + "In this example we set up a local development environment for experimenting with cross-device use-cases. We will here use FEDn in pseudo-local mode and simulate a fleet of intermittent and asynchronous clients solving a classification problem using incremental learning.\n", + "\n", + "A key feature of this research sandbox is that while experiments are able to run on a single laptop or workstation - the same code will seamlessly transition to distributed and real-world deployments (this we will study in future posts in this series)." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "1a2686dd", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from sklearn.datasets import make_classification\n", + "from sklearn.neural_network import MLPClassifier\n", + "from sklearn.metrics import accuracy_score\n", + "from sklearn.model_selection import train_test_split\n", + "from client.entrypoint import compile_model, load_parameters, make_data \n", + "\n", + "\n", + "from fedn import APIClient\n", + "import uuid\n", + "import json\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import copy\n", + "\n", + "import warnings\n", + "warnings.filterwarnings(\"ignore\")" + ] + }, + { + "cell_type": "markdown", + "id": "e4ab4a64", + "metadata": {}, + "source": [ + "### The ML model\n", + "\n", + "As a centralized model baseline we generate synthetic data for a classification problem with 4 features. We train a MLPClassifier using ReLU activation and Adam as optimizer, on 80k training points, then test on 20k points. A maximum of 1000 epochs is used for centralized training. " + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "70c5f5c9", + "metadata": {}, + "outputs": [], + "source": [ + "X, y = make_classification(n_samples=100000, n_features=4, n_informative=4, n_redundant=0, random_state=42)\n", + "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)" + ] + }, + { + "cell_type": "markdown", + "id": "1a121b39", + "metadata": {}, + "source": [ + "We train a centralized baseline model for a maximum of 1000 epochs. " + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a985c6b3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Training accuracy: 0.9208875\n", + "Test accuracy: 0.9193\n" + ] + } + ], + "source": [ + "clf = MLPClassifier(max_iter=1000)\n", + "clf.fit(X_train, y_train)\n", + "central_test_acc = accuracy_score(y_test, clf.predict(X_test))\n", + "\n", + "print(\"Training accuracy: \", accuracy_score(y_train, clf.predict(X_train)))\n", + "print(\"Test accuracy: \", accuracy_score(y_test, clf.predict(X_test)))" + ] + }, + { + "cell_type": "markdown", + "id": "b4976986", + "metadata": {}, + "source": [ + "Next we simulate the FL training procedure each individual FL client will follow. The client will in each iteration (=simulated global round) draw a random number of data points in the interval (n_min, n_max) from (X_train, y_train) and perform 'n_epochs' partial fits on the sampled dataset. Then for each global round we test on the centralized test set (X_test, y_test). In this experiment we simulate 600 global rounds. The client performs 10 local epochs in each round. " + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "34ce6b7d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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BwfrztXevXuRk5OjO1c//fST7gtgzZo1iIyMdPtiaYnsdjvKy8t5rlyMGDECP/30E7Zv3+68DRkyBBMnTnTe5/nyrLS0FAcPHkRiYiL/rlwMGzbMbeiFffv2oXPnzgAC8Pu9QUt4W5hly5YpNptNefXVV5Vdu3Yp06ZNU6Kjo3XV461BSUmJsm3bNmXbtm0KAGXBggXKtm3blCNHjiiKIrq9RUdHKx999JGyY8cO5frrr/fY7W3QoEHKpk2blPXr1ys9evRocd2a77rrLiUqKkpZu3atrkvl2bNnndtMnz5d6dSpk/L1118rW7ZsUdLS0pS0tDTn87JL5ciRI5Xt27crq1evVmJjY1tkl8qHH35YWbdunXL48GFlx44dysMPP6yYTCblyy+/VBSF58obbS8hReH5ku6//35l7dq1yuHDh5XvvvtOSU9PV2JiYpSCggJFUXietDZv3qwEBQUpTz/9tLJ//37lrbfeUsLCwpQ333zTuU0gfb8zYPHiX//6l9KpUyfFarUqQ4cOVTZu3Gj0ITW5b775RgHgdps8ebKiKKLr29y5c5X4+HjFZrMpI0aMUPbu3avbx6lTp5Tx48cr4eHhSmRkpDJlyhSlpKTEgE/TeDydIwDKK6+84tzm3Llzyt133620bdtWCQsLU2644QblxIkTuv388ssvyqhRo5TQ0FAlJiZGuf/++5XKysom/jSN7w9/+IPSuXNnxWq1KrGxscqIESOcwYqi8Fx54xqw8HwJ48aNUxITExWr1ap06NBBGTdunG5cEZ4nvU8++US58MILFZvNpvTu3Vt56aWXdM8H0ve7SVEUpWFzNkREREQNizUsREREFPAYsBAREVHAY8BCREREAY8BCxEREQU8BixEREQU8BiwEBERUcBjwEJEREQBjwELERERBTwGLERERBTwGLAQERFRwGPAQkRERAGPAQsREREFvP8HF9TES/3jU/cAAAAASUVORK5CYII=\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "clf = compile_model()\n", + "\n", + "n_global_rounds=600\n", + "n_epochs = 10\n", + "central_acc_one_client = []\n", + "for i in range(n_global_rounds):\n", + " x,y,_,_ = make_data(n_min=10,n_max=100)\n", + " for j in range(n_epochs):\n", + " clf.partial_fit(x, y)\n", + " central_acc_one_client.append(accuracy_score(y_test, clf.predict(X_test)))\n", + "\n", + "plt.plot(range(n_global_rounds),[central_test_acc]*n_global_rounds)\n", + "plt.plot(range(n_global_rounds), central_acc_one_client)" + ] + }, + { + "cell_type": "markdown", + "id": "1dfb237f", + "metadata": {}, + "source": [ + "We proceed by simulating the scenario that a number 'n_clients' clients in a fleet of devices send their locally collected/sampled datasets to a central server (we emulate this by scaling n_min and n_max by n_clients). The server then performs incremental learning using the collected data batches (which are thus larger than in the experiment above by a factor n_clients). " + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "79f02df8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "clf = compile_model()\n", + "\n", + "n_global_rounds=600\n", + "n_epochs = 10\n", + "n_clients = 10\n", + "central_acc_all_clients = []\n", + "for i in range(n_global_rounds):\n", + " x,y,_,_ = make_data(n_min=n_clients*10, n_max=n_clients*100)\n", + " for j in range(n_epochs):\n", + " clf.partial_fit(x, y)\n", + " central_acc_all_clients.append(accuracy_score(y_test, clf.predict(X_test)))\n", + "\n", + "plt.plot(range(n_global_rounds),[central_test_acc]*n_global_rounds)\n", + "plt.plot(range(n_global_rounds), central_acc_one_client)\n", + "plt.plot(range(n_global_rounds), central_acc_all_clients)\n", + "plt.legend(['Central baseline, all data','Incremental learning, one client','Inceremental learning, all clients'])" + ] + }, + { + "cell_type": "markdown", + "id": "06c05caf", + "metadata": {}, + "source": [ + "### Federated learning with clients connecting and disconnecting intermittently \n", + "\n", + "The figure below illustrates the federated learning scenario. We will run clients that: \n", + "\n", + "- Connect to the server at a random time t_{on}\n", + "- Stay online for training for a fixed period of time (e.g one minute).\n", + "- Disconnect at time t_{off}. \n", + "\n", + "This completes one cycle in our setup, which we then repeat a configurable number of cycles. " + ] + }, + { + "cell_type": "markdown", + "id": "dfadf3e3", + "metadata": {}, + "source": [ + "![title](img/async-clients.png)" + ] + }, + { + "cell_type": "markdown", + "id": "037cae62", + "metadata": {}, + "source": [ + "### Running the experiment\n", + "\n", + "Now we run federated learning experiments over a FEDn network. For this we first need to start a pseudo-distributed FEDn network (we can use the provided docker-compose template). To run clients that follow the logic in the illustration above will use the script 'run_clients.py'. This script will start clients running in subprocesses on the host machine. Once clients are up and running, you can proceed below and exectute experiments using the script 'run_experiment.py'. Note that these runs can take some time to complete (600 global rounds for 10 clients took about 1.5h on a 2020 MacBook Pro). " + ] + }, + { + "cell_type": "markdown", + "id": "1046a4e5", + "metadata": {}, + "source": [ + "### Analyzing the resluts using the FEDn APIClient\n", + "\n", + "One the experiment has been started we can use the FEDn APIClient to analyze the result. We make a client connection to the FEDn API service. Here we assume that FEDn is deployed locally in pseudo-distributed mode with default ports." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "1061722d", + "metadata": {}, + "outputs": [], + "source": [ + "DISCOVER_HOST = '127.0.0.1'\n", + "DISCOVER_PORT = 8092\n", + "client = APIClient(DISCOVER_HOST, DISCOVER_PORT)" + ] + }, + { + "cell_type": "markdown", + "id": "29552af9", + "metadata": {}, + "source": [ + "Next, we retrive global models for this session and score the models on the central test set. We can use the API client to download model paramters for a given global model_id. We then initialize a model and set the loaded parameters. " + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "5fdabea3", + "metadata": {}, + "outputs": [], + "source": [ + "def load_fedn_model(model_id):\n", + "\n", + " data = client.download_model(model_id, 'temp.npz')\n", + " parameters = load_parameters('temp.npz')\n", + " model = compile_model()\n", + " n = len(parameters)//2\n", + " model.coefs_ = parameters[:n]\n", + " model.intercepts_ = parameters[n:]\n", + " return model" + ] + }, + { + "cell_type": "markdown", + "id": "296e04ee", + "metadata": {}, + "source": [ + "By default, 'get_model_trail' returns a list of all models leading up to the current active global model. We iterate over all these models and score them on the centralized test set. " + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "13b75b1c", + "metadata": {}, + "outputs": [], + "source": [ + "model_trail_fedavg = client.get_model_trail()\n", + "\n", + "acc_fedavg = []\n", + "for model in model_trail_fedavg: \n", + " model = load_fedn_model(model['id'])\n", + " acc_fedavg.append(accuracy_score(y_test, model.predict(X_test)))" + ] + }, + { + "cell_type": "markdown", + "id": "40db4542", + "metadata": {}, + "source": [ + "Plot the result. " + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "id": "f0c3c51c", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 50, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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/PCDy/kWLlxIrVq18PPzo2PHjhw+fLhEr6ktU+Ln50e1atUKnfell16iVq1amM1mqlWrxqhRo1xmtd66dSsdO3YkMDCQoKAgmjdvzsaNG+33r1q1ig4dOuDn50dcXBzDhg0jPT29RK/L4cOH0TSNuXPn0rFjR8xmM40bN2bt2rUux5T2MYRnSXDjRrIquBDF03WdjJy8q/5ln1yzBM6dO8fixYsZMmQI/v7+he4PCQmx/3z33Xdz+vRpFi1axKZNm2jWrBmdO3fm3Llz9n0OHDjA/Pnz+eWXX/jll19Yvnw548ePB+CDDz6gbdu2DBo0iJMnT3Ly5Eni4uLsx7788suMHz+e3bt306hRI9LS0rjllltYunQpf//9Nz169KBXr14kJCSU6LmNGDHC/jgnT55k4sSJmM1mWrRoAcC4ceP46quvmDJlCjt37uS5557j/vvvZ/ny5QAcPXqUO++8k169erFlyxYee+wxXn755RI99qhRo+jbty9bt25lwIAB3Hvvvezevdt+f2BgIF9++SW7du3igw8+4LPPPuO9996z3z9gwAAqVarEhg0b2LRpEy+//LJ9DpQDBw7Qo0cP+vbty7Zt25g9ezarVq1i6NChJWqbzciRIxkxYgRbtmyhVq1a3HfffeTl5bn1McTVI91S7iQ1N0IUKzPXQr3Xf73qj7vrje6YfUr2Vrd//350XadOnToX3W/VqlWsX7+e06dPYzKZAJg4cSLz58/nhx9+4PHHHwdUNuTLL78kMDAQgAceeIClS5fy3//+l+DgYHx8fDCbzUV2k7zxxht07drVfjssLIzGjRvbb7/55pvMmzePn376qUQX2YCAAAICAgD466+/eO2115g+fToNGjQgOzubt956iyVLltC2bVsAqlWrxqpVq/j000+56aab+OSTT6hevTqTJk0CoHbt2mzfvp0JEyZc8rHvvvtuHnvsMXu7f//9dz788EM+/vhjAF577TX7vvHx8YwYMYJZs2bx4osvApCQkMALL7xg/73UrFnTvv+4ceMYMGCAvXapZs2aTJ482d5mX1/fS7YPVPB36623AjB27Fjq16/P/v37qVOnjtseQ1w9Ety4k+0Doiy/IES5VNIsz9atW0lLSys0+3JmZiYHDhyw346Pj7cHNgAxMTGcPn26RI9hy6jYpKWlMWbMGBYsWMDJkyfJy8sjMzOz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KgsyWsqNdGv+UHApKBbiunHTTTdx44030rdvX37//XcOHTrEokWLWLx4cbHH1KpViwEDBjBw4EDmzp3LoUOHWL9+PePGjWPBggWX3RZ3nTc+Pp5t27axd+9ezpw5Q25uLjVr1iQhIYFZs2Zx4MABJk+ezLx58y67raBGX61Zs4ahQ4eyZcsW9u3bx48//mgfwVO5cmV8fHz48MMPOXjwID/99BNvvvnmFT3mlXj66adZuHAh7777Lvv27ePTTz9l0aJFLlm5S/H39+fJJ5/khRdeYPHixezatYtBgwaRkZHBo48+WuLzvP7663z11VeMHTuWnTt3snv3bmbNmsVrr71W7DFjxoxh0qRJTJ48mX379rF582Y+/PDDEj3ekCFDOHfuHPfddx8bNmzgwIED/Prrrzz88MOlClZsAVZiYiLnz58v8XGlJcGNO+VnbjTJ3AhxXZkzZw4tW7bkvvvuo169erz44ouXfMOfNm0aAwcO5Pnnn6d27dr07t2bDRs2ULly5StqizvOO2jQIGrXrk2LFi2IjIxk9erV3H777Tz33HMMHTqUJk2asGbNmssaKeSsUaNGLF++nH/++YcOHTrQtGlTXn/9dWJj1ai6yMhIvvzyS77//nvq1avH+PHjmThx4hU95pVo3749U6ZM4d1336Vx48YsXryY5557zqUbyDaE2nn4fUHjx4+nb9++PPDAAzRr1oz9+/fz66+/2kd5lUT37t355Zdf+O2332jZsiVt2rThvffes2eDivLggw/y/vvv8/HHH1O/fn1uu+029u3bV6LHi42NZfXq1VgsFrp160bDhg159tlnCQkJKdWcTZMmTeL3338nLi6Opk2blvi40tL0S3W0XmNSUlIIDg4mOTn5igrhinL8hRdJ+flnol56ifCHH3LruYUoT7Kysjh06BBVq1Ytsv9fiGvFoEGD2LNnDytXrgTgzz//5M477+TgwYOlClaEcrH3jtJcv6Xmxp2koFgIIa5pEydOpGvXrvj7+7No0SKmT5/Oxx9/bL9/4cKFvPrqqxLYeJgEN25kLyg2Sm+fEEJci9avX8/bb79Namoq1apVY/LkyTz22GP2+9955x0Ptk7YSHDjRvaCYpnnRgghrknfffedp5sgSkBSDO4kBcVCCCGEx0lw40YyQ7EQQgjheRLcuJOtoFgyN0IIIYTHSHDjRrolf4rp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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "x = range(1,len(acc_fedavg)+1)\n", + "plt.plot(x,[central_test_acc]*len(x))\n", + "plt.plot(range(n_global_rounds), central_acc_one_client)\n", + "plt.plot(range(n_global_rounds), central_acc_all_clients)\n", + "plt.plot(range(len(acc_fedavg)),acc_fedavg)\n", + "plt.xlabel('Global round')\n", + "plt.ylabel('Accuracy score')\n", + "plt.legend(['Centralized baseline', 'Incremental learning, one client','Incremental learning, all clients', 'FL (10 clients, FedAdam)'])" + ] + }, + { + "cell_type": "markdown", + "id": "11241c81", + "metadata": {}, + "source": [ + "As can be seen, FEDn trains a federated model that reaches the same level of performace as the centralized baseline, with convergence close to the simulated case where 10 clients send data to a central server. Here we used FedAdam with a fixed learning rate 1e-2 as the server-side aggregator. It is possible that hyperparameter tuning, or adapting the learning rate, could improve convergence further. This was not the focus of this experiment though - the objective was to set up a pseudo-local experiment environment where clients connect intermittently and validate the robustness of FEDn in this scenario. In future parts of this series we will build on this in different ways as we explore various aspects of cross-device FL. " + ] + } + ], + "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" + }, + "vscode": { + "interpreter": { + "hash": "21345b455230dd04cf84c108e7c182ecfe8d1aa1242b8b64881a6d2c0a5951ac" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/async-clients/README.md b/examples/async-clients/README.md new file mode 100644 index 000000000..2e2724104 --- /dev/null +++ b/examples/async-clients/README.md @@ -0,0 +1,77 @@ +# ASYNC CLIENTS +This example shows how to experiment with intermittent and asynchronous client workflows. + +## Prerequisites +- [Python 3.8, 3.9 or 3.10](https://www.python.org/downloads) +- [Docker](https://docs.docker.com/get-docker) +- [Docker Compose](https://docs.docker.com/compose/install) + +## Running the example (pseudo-distributed, single host) + +First, make sure that FEDn is installed (we recommend using a virtual environment) + +Clone FEDn +```sh +git clone https://github.com/scaleoutsystems/fedn.git +``` + +Install FEDn and dependencies + +`` +pip install fedn +``` + +Or from source, standing in the folder 'fedn/fedn' + +``` +pip install . +``` + +### Prepare the example environment, the compute package and seed model + +Standing in the folder fedn/examples/async-clients +``` +pip install -r requirements.txt +``` + +Create the compute package and seed model: +``` +tar -czvf package.tgz client +``` + +``` +python client/entrypoint init_seed +``` + +You will now have a file 'seed.npz' in the directory. + +### Running a simulation + +Deploy FEDn on localhost. Standing in the the FEDn root directory: + +``` +docker-compose up +``` + +Initialize FEDn with the compute package and seed model + +``` +python init_fedn.py +``` + +Start simulating clients +``` +python run_clients.py +``` + +Start the experiment / training sessions: + +``` +python run_experiment.py +``` + +Once global models start being produced, you can start analyzing results using API Client, refer to the notebook "Experiment.ipynb" for instructions. + + + + diff --git a/examples/async-clients/client/entrypoint.py b/examples/async-clients/client/entrypoint.py new file mode 100644 index 000000000..4ddddd956 --- /dev/null +++ b/examples/async-clients/client/entrypoint.py @@ -0,0 +1,142 @@ +# /bin/python +import fire +import numpy as np +from sklearn.datasets import make_classification +from sklearn.metrics import accuracy_score +from sklearn.model_selection import train_test_split +from sklearn.neural_network import MLPClassifier + +from fedn.utils.helpers.helpers import get_helper, save_metadata, save_metrics + +HELPER_MODULE = 'numpyhelper' +ARRAY_SIZE = 10000 + + +def compile_model(max_iter=1): + clf = MLPClassifier(max_iter=max_iter) + # This is needed to initialize some state variables needed to make predictions + # We will overwrite weights and biases during FL training + X_train, y_train, _, _ = make_data() + clf.fit(X_train, y_train) + return clf + + +def save_parameters(model, out_path): + """ Save model to disk. + + :param model: The model to save. + :type model: torch.nn.Module + :param out_path: The path to save to. + :type out_path: str + """ + helper = get_helper(HELPER_MODULE) + parameters = model.coefs_ + model.intercepts_ + + helper.save(parameters, out_path) + + +def load_parameters(model_path): + """ Load model from disk. + + param model_path: The path to load from. + :type model_path: str + :return: The loaded model. + :rtype: torch.nn.Module + """ + helper = get_helper(HELPER_MODULE) + parameters = helper.load(model_path) + + return parameters + + +def init_seed(out_path='seed.npz'): + """ Initialize seed model. + + :param out_path: The path to save the seed model to. + :type out_path: str + """ + # Init and save + model = compile_model() + save_parameters(model, out_path) + + +def make_data(n_min=50, n_max=100): + """ Generate / simulate a random number n data points. + + n will fall in the interval (n_min, n_max) + + """ + n_samples = 100000 + X, y = make_classification(n_samples=n_samples, n_features=4, n_informative=4, n_redundant=0, random_state=42) + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) + + n = np.random.randint(n_min, n_max, 1)[0] + ind = np.random.choice(len(X_train), n) + X_train = X_train[ind, :] + y_train = y_train[ind] + return X_train, y_train, X_test, y_test + + +def train(in_model_path, out_model_path): + """ Train model. + + """ + + # Load model + parameters = load_parameters(in_model_path) + model = compile_model() + n = len(parameters)//2 + model.coefs_ = parameters[:n] + model.intercepts_ = parameters[n:] + + # Train + X_train, y_train, _, _ = make_data() + epochs = 10 + for i in range(epochs): + model.partial_fit(X_train, y_train) + + # Metadata needed for aggregation server side + metadata = { + 'num_examples': len(X_train), + } + + # Save JSON metadata file + save_metadata(metadata, out_model_path) + + # Save model update + save_parameters(model, out_model_path) + + +def validate(in_model_path, out_json_path): + """ Validate model. + + :param in_model_path: The path to the input model. + :type in_model_path: str + :param out_json_path: The path to save the output JSON to. + :type out_json_path: str + :param data_path: The path to the data file. + :type data_path: str + """ + parameters = load_parameters(in_model_path) + model = compile_model() + n = len(parameters)//2 + model.coefs_ = parameters[:n] + model.intercepts_ = parameters[n:] + + X_train, y_train, X_test, y_test = make_data() + + # JSON schema + report = { + "accuracy_score": accuracy_score(y_test, model.predict(X_test)), + } + + # Save JSON + save_metrics(report, out_json_path) + + +if __name__ == '__main__': + fire.Fire({ + 'init_seed': init_seed, + 'train': train, + 'validate': validate + }) diff --git a/examples/async-clients/client/fedn.yaml b/examples/async-clients/client/fedn.yaml new file mode 100644 index 000000000..09002ea0c --- /dev/null +++ b/examples/async-clients/client/fedn.yaml @@ -0,0 +1,5 @@ +entry_points: + train: + command: python entrypoint.py train $ENTRYPOINT_OPTS + validate: + command: python entrypoint.py validate $ENTRYPOINT_OPTS \ No newline at end of file diff --git a/examples/async-clients/img/async-clients.png b/examples/async-clients/img/async-clients.png new file mode 100644 index 000000000..71ae8c123 Binary files /dev/null and b/examples/async-clients/img/async-clients.png differ diff --git a/examples/async-simulation/init_fedn.py b/examples/async-clients/init_fedn.py similarity index 57% rename from examples/async-simulation/init_fedn.py rename to examples/async-clients/init_fedn.py index 23078fcd9..2aa298602 100644 --- a/examples/async-simulation/init_fedn.py +++ b/examples/async-clients/init_fedn.py @@ -4,5 +4,5 @@ DISCOVER_PORT = 8092 client = APIClient(DISCOVER_HOST, DISCOVER_PORT) -client.set_package('package.tgz', 'numpyhelper') -client.set_initial_model('seed.npz') +client.set_active_package('package.tgz', 'numpyhelper') +client.set_active_model('seed.npz') diff --git a/examples/async-clients/requirements.txt b/examples/async-clients/requirements.txt new file mode 100644 index 000000000..7529e3699 --- /dev/null +++ b/examples/async-clients/requirements.txt @@ -0,0 +1,3 @@ +fire==0.3.1 +numpy +scikit-learn \ No newline at end of file diff --git a/examples/async-clients/run_clients.py b/examples/async-clients/run_clients.py new file mode 100644 index 000000000..2293c6be2 --- /dev/null +++ b/examples/async-clients/run_clients.py @@ -0,0 +1,77 @@ +"""This scripts starts N_CLIENTS using the SDK. + + + + + +If you are running with a local deploy of FEDn +using docker compose, you need to make sure that clients +are able to resolve the name "combiner" to 127.0.0.1 + +One way to accomplish this is to edit your /etc/host, +adding the line: + +combiner 127.0.0.1 + +(this requires root previliges) +""" + +import copy +import time +from multiprocessing import Process + +import numpy as np + +from fedn.network.clients.client import Client + +settings = { + 'DISCOVER_HOST': '127.0.0.1', + 'DISCOVER_PORT': 8092, + 'N_CLIENTS': 10, + 'N_CYCLES': 100, + 'CLIENTS_MAX_DELAY': 10, + 'CLIENTS_ONLINE_FOR_SECONDS': 120 +} + +client_config = {'discover_host': settings['DISCOVER_HOST'], 'discover_port': settings['DISCOVER_PORT'], 'token': None, 'name': 'testclient', + 'client_id': 1, 'remote_compute_context': True, 'force_ssl': False, 'dry_run': False, 'secure': False, + 'preshared_cert': False, 'verify': False, 'preferred_combiner': False, + 'validator': True, 'trainer': True, 'init': None, 'logfile': 'test.log', 'heartbeat_interval': 2, + 'reconnect_after_missed_heartbeat': 30} + + +def run_client(online_for=120, name='client'): + """ Simulates a client that starts and stops + at random intervals. + + The client will start after a radom time 'mean_delay', + stay online for 'online_for' seconds (deterministic), + then disconnect. + + This is repeated for N_CYCLES. + + """ + + conf = copy.deepcopy(client_config) + conf['name'] = name + + for i in range(settings['N_CYCLES']): + # Sample a delay until the client starts + t_start = np.random.randint(0, settings['CLIENTS_MAX_DELAY']) + time.sleep(t_start) + fl_client = Client(conf) + time.sleep(online_for) + fl_client.disconnect() + + +if __name__ == '__main__': + + # We start N_CLIENTS independent client processes + processes = [] + for i in range(settings['N_CLIENTS']): + p = Process(target=run_client, args=(settings['CLIENTS_ONLINE_FOR_SECONDS'], 'client{}'.format(i),)) + processes.append(p) + p.start() + + for p in processes: + p.join() diff --git a/examples/async-clients/run_experiment.py b/examples/async-clients/run_experiment.py new file mode 100644 index 000000000..d8d12dca2 --- /dev/null +++ b/examples/async-clients/run_experiment.py @@ -0,0 +1,34 @@ +import time +import uuid + +from fedn import APIClient + +DISCOVER_HOST = '127.0.0.1' +DISCOVER_PORT = 8092 +client = APIClient(DISCOVER_HOST, DISCOVER_PORT) + +if __name__ == '__main__': + + # Run six sessions, each with 100 rounds. + num_sessions = 6 + for s in range(num_sessions): + + session_config = { + "helper": "numpyhelper", + "id": str(uuid.uuid4()), + "aggregator": "fedopt", + "round_timeout": 20, + "rounds": 100, + "validate": False, + } + + session = client.start_session(**session_config) + if session['success'] is False: + print(session['message']) + exit(0) + + print("Started session: {}".format(session)) + + # Wait for session to finish + while not client.session_is_finished(session_config['id']): + time.sleep(2) diff --git a/examples/async-simulation/Experiment.ipynb b/examples/async-simulation/Experiment.ipynb deleted file mode 100644 index 51ec4e9e7..000000000 --- a/examples/async-simulation/Experiment.ipynb +++ /dev/null @@ -1,186 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "622f7047", - "metadata": {}, - "source": [ - "## FEDn 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 using FedAvg and one session using FedAdam and compare the results.\n", - "\n", - "When you start this tutorial you should have a deployed FEDn Network up and running, and you should have created the compute package and the initial model, see the README for instructions." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "743dfe47", - "metadata": {}, - "outputs": [], - "source": [ - "from fedn import APIClient\n", - "from fedn.network.clients.client import Client\n", - "import uuid\n", - "import json\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import collections\n", - "import copy" - ] - }, - { - "cell_type": "markdown", - "id": "1046a4e5", - "metadata": {}, - "source": [ - "We make a client connection to the FEDn API service. Here we assume that FEDn is deployed locally in pseudo-distributed mode with default ports." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "1061722d", - "metadata": {}, - "outputs": [], - "source": [ - "DISCOVER_HOST = '127.0.0.1'\n", - "DISCOVER_PORT = 8092\n", - "client = APIClient(DISCOVER_HOST, DISCOVER_PORT)" - ] - }, - { - "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 by follwing instructions in the README." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "5107f6f9", - "metadata": {}, - "outputs": [], - "source": [ - "client.set_package('package.tgz', 'numpyhelper')\n", - "client.set_initial_model('seed.npz')\n", - "seed_model = client.get_initial_model()" - ] - }, - { - "cell_type": "markdown", - "id": "4e26c50b", - "metadata": {}, - "source": [ - "Next we start a training session using FedAvg:" - ] - }, - { - "cell_type": "code", -<<<<<<< HEAD - "execution_count": 9, -======= - "execution_count": 74, ->>>>>>> master - "id": "f0380d35", - "metadata": {}, - "outputs": [], - "source": [ - "session_config_fedavg = {\n", - " \"helper\": \"numpyhelper\",\n", - " \"session_id\": \"experiment_fedavg6\",\n", - " \"aggregator\": \"fedavg\",\n", - " \"model_id\": seed_model['model_id'],\n", - " \"rounds\": 1,\n", - " }\n", - "\n", - "result_fedavg = client.start_session(**session_config_fedavg)" - ] - }, - { - "cell_type": "markdown", - "id": "29552af9", - "metadata": {}, - "source": [ - "Next, we retrive all model validations from all clients, extract the training accuracy metric, and compute its mean value accross all clients" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "id": "11fd17ef", - "metadata": { - "scrolled": true - }, - "outputs": [], - "source": [ - "models = client.list_models(session_id = \"experiment_fedavg\")\n", - "\n", - "validations = []\n", - "acc = collections.OrderedDict()\n", - "for model in models[\"result\"]:\n", - " model_id = model[\"model\"]\n", - " validations = client.list_validations(modelId=model_id)\n", - "\n", - " for _ , validation in validations.items(): \n", - " metrics = json.loads(validation['data'])\n", - " try:\n", - " acc[model_id].append(metrics['training_accuracy'])\n", - " except KeyError: \n", - " acc[model_id] = [metrics['training_accuracy']]\n", - " \n", - "mean_acc_fedavg = []\n", - "for model, data in acc.items():\n", - " mean_acc_fedavg.append(np.mean(data))\n", - "mean_acc_fedavg.reverse()" - ] - }, - { - "cell_type": "markdown", - "id": "40db4542", - "metadata": {}, - "source": [ - "Finally, plot the result." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d064aaf9", - "metadata": {}, - "outputs": [], - "source": [ - "x = range(1,len(mean_acc_fedavg)+1)\n", - "plt.plot(x, mean_acc_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" - }, - "vscode": { - "interpreter": { - "hash": "21345b455230dd04cf84c108e7c182ecfe8d1aa1242b8b64881a6d2c0a5951ac" - } - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/async-simulation/README.md b/examples/async-simulation/README.md deleted file mode 100644 index b5cbfe2ed..000000000 --- a/examples/async-simulation/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# ASYNC SIMULATION -This example is intended as a test for asynchronous clients. - -## Prerequisites -- [Python 3.8, 3.9 or 3.10](https://www.python.org/downloads) -- [Docker](https://docs.docker.com/get-docker) -- [Docker Compose](https://docs.docker.com/compose/install) - -## Running the example (pseudo-distributed, single host) - -Clone FEDn and locate into this directory. -```sh -git clone https://github.com/scaleoutsystems/fedn.git -cd fedn/examples/async-simulation -``` - -### Preparing the environment, the local data, the compute package and seed model - -Install FEDn and dependencies (we recommend using a virtual environment): - -Standing in the folder 'fedn/fedn' - -``` -pip install -e . -``` - -From examples/async-simulation -``` -pip install -r requirements.txt -``` - -Create the compute package and a seed model that you will be asked to upload in the next step. -``` -tar -czvf package.tgz client -``` - -``` -python client/entrypoint init_seed -``` - -### Deploy FEDn and two clients -docker-compose -f ../../docker-compose.yaml -f docker-compose.override.yaml up - -### Initialize the federated model -See 'Experiments.pynb' or 'launch_client.py' to set the package and seed model. - -> **Note**: run with `--scale client=N` to start *N* clients. - -### Run federated training -See 'Experiment.ipynb'. - -## Clean up -You can clean up by running `docker-compose down -v`. diff --git a/examples/async-simulation/client/entrypoint b/examples/async-simulation/client/entrypoint deleted file mode 100644 index dd2216fc0..000000000 --- a/examples/async-simulation/client/entrypoint +++ /dev/null @@ -1,98 +0,0 @@ -# /bin/python -import time - -import fire -import numpy as np - -from fedn.utils.helpers.helpers import get_helper, save_metadata, save_metrics - -HELPER_MODULE = 'numpyhelper' -ARRAY_SIZE = 1000000 - - -def save_model(weights, out_path): - """ Save model to disk. - - :param model: The model to save. - :type model: torch.nn.Module - :param out_path: The path to save to. - :type out_path: str - """ - helper = get_helper(HELPER_MODULE) - helper.save(weights, out_path) - - -def load_model(model_path): - """ Load model from disk. - - param model_path: The path to load from. - :type model_path: str - :return: The loaded model. - :rtype: torch.nn.Module - """ - helper = get_helper(HELPER_MODULE) - weights = helper.load(model_path) - return weights - - -def init_seed(out_path='seed.npz'): - """ Initialize seed model. - - :param out_path: The path to save the seed model to. - :type out_path: str - """ - # Init and save - weights = [np.random.rand(1, ARRAY_SIZE)] - save_model(weights, out_path) - - -def train(in_model_path, out_model_path): - """ Train model. - - """ - - # Load model - weights = load_model(in_model_path) - - # Train - time.sleep(np.random.randint(4, 15)) - - # Metadata needed for aggregation server side - metadata = { - 'num_examples': ARRAY_SIZE, - } - - # Save JSON metadata file - save_metadata(metadata, out_model_path) - - # Save model update - save_model(weights, out_model_path) - - -def validate(in_model_path, out_json_path): - """ Validate model. - - :param in_model_path: The path to the input model. - :type in_model_path: str - :param out_json_path: The path to save the output JSON to. - :type out_json_path: str - :param data_path: The path to the data file. - :type data_path: str - """ - weights = load_model(in_model_path) - - # JSON schema - report = { - "mean": np.mean(weights), - } - - # Save JSON - save_metrics(report, out_json_path) - - -if __name__ == '__main__': - fire.Fire({ - 'init_seed': init_seed, - 'train': train, - 'validate': validate - }) diff --git a/examples/async-simulation/client/fedn.yaml b/examples/async-simulation/client/fedn.yaml deleted file mode 100644 index 68cb70cef..000000000 --- a/examples/async-simulation/client/fedn.yaml +++ /dev/null @@ -1,5 +0,0 @@ -entry_points: - train: - command: /venv/bin/python entrypoint train $ENTRYPOINT_OPTS - validate: - command: /venv/bin/python entrypoint validate $ENTRYPOINT_OPTS \ No newline at end of file diff --git a/examples/async-simulation/docker-compose.override.yaml b/examples/async-simulation/docker-compose.override.yaml deleted file mode 100644 index 61034ce69..000000000 --- a/examples/async-simulation/docker-compose.override.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Compose schema version -version: '3.3' - -# Overriding requirements -services: - client: - build: - args: - REQUIREMENTS: examples/async-simulation/requirements.txt - deploy: - replicas: 2 - volumes: - - ${HOST_REPO_DIR:-.}/fedn:/app/fedn - - ${HOST_REPO_DIR:-.}/examples/async-simulation/data:/var/data - - /var/run/docker.sock:/var/run/docker.sock diff --git a/examples/async-simulation/launch_clients.py b/examples/async-simulation/launch_clients.py deleted file mode 100644 index 6cffbedd3..000000000 --- a/examples/async-simulation/launch_clients.py +++ /dev/null @@ -1,41 +0,0 @@ -"""This scripts starts N_CLIENTS using the SDK. - -If you are running with a local deploy of FEDn -using docker compose, you need to make sure that clients -are able to resolve the name "combiner" to 127.0.0.1 - -One way to accomplish this is to edit your /etc/host, -adding the line: - -combiner 127.0.0.1 - -""" - - -import copy -import time - -from fedn.network.clients.client import Client - -DISCOVER_HOST = '127.0.0.1' -DISCOVER_PORT = 8092 -N_CLIENTS = 5 -CLIENTS_AVAILABLE_TIME = 120 - -config = {'discover_host': DISCOVER_HOST, 'discover_port': DISCOVER_PORT, 'token': None, 'name': 'testclient', - 'client_id': 1, 'remote_compute_context': True, 'force_ssl': False, 'dry_run': False, 'secure': False, - 'preshared_cert': False, 'verify': False, 'preferred_combiner': False, - 'validator': True, 'trainer': True, 'init': None, 'logfile': 'test.log', 'heartbeat_interval': 2, - 'reconnect_after_missed_heartbeat': 30} - -# Start up N_CLIENTS clients -clients = [] -for i in range(N_CLIENTS): - config_i = copy.deepcopy(config) - config['name'] = 'client{}'.format(i) - clients.append(Client(config)) - -# Disconnect clients after some time -time.sleep(CLIENTS_AVAILABLE_TIME) -for client in clients: - client.detach() diff --git a/examples/async-simulation/requirements.txt b/examples/async-simulation/requirements.txt deleted file mode 100644 index c6bceff1d..000000000 --- a/examples/async-simulation/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -fire==0.3.1 \ No newline at end of file diff --git a/fedn/fedn/common/config.py b/fedn/fedn/common/config.py index f6c827d0d..0a261c4af 100644 --- a/fedn/fedn/common/config.py +++ b/fedn/fedn/common/config.py @@ -5,6 +5,17 @@ global STATESTORE_CONFIG global MODELSTORAGE_CONFIG +SECRET_KEY = os.environ.get('FEDN_JWT_SECRET_KEY', False) +FEDN_JWT_CUSTOM_CLAIM_KEY = os.environ.get('FEDN_JWT_CUSTOM_CLAIM_KEY', False) +FEDN_JWT_CUSTOM_CLAIM_VALUE = os.environ.get('FEDN_JWT_CUSTOM_CLAIM_VALUE', False) + +FEDN_AUTH_WHITELIST_URL_PREFIX = os.environ.get('FEDN_AUTH_WHITELIST_URL_PREFIX', False) +FEDN_JWT_ALGORITHM = os.environ.get('FEDN_JWT_ALGORITHM', 'HS256') +FEDN_AUTH_SCHEME = os.environ.get('FEDN_AUTH_SCHEME', 'Token') +FEDN_AUTH_REFRESH_TOKEN_URI = os.environ.get('FEDN_AUTH_REFRESH_TOKEN_URI', False) +FEDN_AUTH_REFRESH_TOKEN = os.environ.get('FEDN_AUTH_REFRESH_TOKEN', False) +FEDN_CUSTOM_URL_PREFIX = os.environ.get('FEDN_CUSTOM_URL_PREFIX', '') + def get_environment_config(): """ Get the configuration from environment variables. diff --git a/fedn/fedn/network/api/auth.py b/fedn/fedn/network/api/auth.py new file mode 100644 index 000000000..bf43c2f69 --- /dev/null +++ b/fedn/fedn/network/api/auth.py @@ -0,0 +1,70 @@ +from functools import wraps + +import jwt +from flask import jsonify, request + +from fedn.common.config import (FEDN_AUTH_SCHEME, + FEDN_AUTH_WHITELIST_URL_PREFIX, + FEDN_JWT_ALGORITHM, FEDN_JWT_CUSTOM_CLAIM_KEY, + FEDN_JWT_CUSTOM_CLAIM_VALUE, SECRET_KEY) + + +def check_role_claims(payload, role): + if 'role' not in payload: + return False + if payload['role'] != role: + return False + + return True + + +def check_custom_claims(payload): + if FEDN_JWT_CUSTOM_CLAIM_KEY and FEDN_JWT_CUSTOM_CLAIM_VALUE: + if payload[FEDN_JWT_CUSTOM_CLAIM_KEY] != FEDN_JWT_CUSTOM_CLAIM_VALUE: + return False + return True + + +def if_whitelisted_url_prefix(path): + if FEDN_AUTH_WHITELIST_URL_PREFIX and path.startswith(FEDN_AUTH_WHITELIST_URL_PREFIX): + return True + else: + return False + + +def jwt_auth_required(role=None): + def actual_decorator(func): + if not SECRET_KEY: + return func + + @wraps(func) + def decorated(*args, **kwargs): + if if_whitelisted_url_prefix(request.path): + return func(*args, **kwargs) + token = request.headers.get('Authorization') + if not token: + return jsonify({'message': 'Missing token'}), 401 + # Get token from the header Bearer + if token.startswith(FEDN_AUTH_SCHEME): + token = token.split(' ')[1] + else: + return jsonify({'message': + f'Invalid token scheme, expected {FEDN_AUTH_SCHEME}' + }), 401 + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[FEDN_JWT_ALGORITHM]) + if not check_role_claims(payload, role): + return jsonify({'message': 'Invalid token'}), 401 + if not check_custom_claims(payload): + return jsonify({'message': 'Invalid token'}), 401 + + except jwt.ExpiredSignatureError: + return jsonify({'message': 'Token expired'}), 401 + + except jwt.InvalidTokenError: + return jsonify({'message': 'Invalid token'}), 401 + + return func(*args, **kwargs) + + return decorated + return actual_decorator diff --git a/fedn/fedn/network/api/client.py b/fedn/fedn/network/api/client.py index b33c38626..58337137f 100644 --- a/fedn/fedn/network/api/client.py +++ b/fedn/fedn/network/api/client.py @@ -27,7 +27,7 @@ def __init__(self, host, port=None, secure=False, verify=False, token=None, auth # Auth scheme passed as argument overrides environment variable. # "Token" is the default auth scheme. if not auth_scheme: - auth_scheme = os.environ.get("FEDN_AUTH_SCHEME", "Token") + auth_scheme = os.environ.get("FEDN_AUTH_SCHEME", "Bearer") # Override potential env variable if token is passed as argument. if not token: token = os.environ.get("FEDN_AUTH_TOKEN", False) diff --git a/fedn/fedn/network/api/server.py b/fedn/fedn/network/api/server.py index 597feca20..4e6d1a7e2 100644 --- a/fedn/fedn/network/api/server.py +++ b/fedn/fedn/network/api/server.py @@ -1,17 +1,13 @@ +import os + from flasgger import Swagger from flask import Flask, jsonify, request from fedn.common.config import (get_controller_config, get_modelstorage_config, get_network_config, get_statestore_config) +from fedn.network.api.auth import jwt_auth_required from fedn.network.api.interface import API -from fedn.network.api.v1.client_routes import bp as client_bp -from fedn.network.api.v1.combiner_routes import bp as combiner_bp -from fedn.network.api.v1.model_routes import bp as model_bp -from fedn.network.api.v1.package_routes import bp as package_bp -from fedn.network.api.v1.round_routes import bp as round_bp -from fedn.network.api.v1.session_routes import bp as session_bp -from fedn.network.api.v1.status_routes import bp as status_bp -from fedn.network.api.v1.validation_routes import bp as validation_bp +from fedn.network.api.v1 import _routes from fedn.network.controller.control import Control from fedn.network.storage.statestore.mongostatestore import MongoStateStore @@ -21,30 +17,31 @@ statestore = MongoStateStore(network_id, statestore_config["mongo_config"]) statestore.set_storage_backend(modelstorage_config) control = Control(statestore=statestore) + +custom_url_prefix = os.environ.get("FEDN_CUSTOM_URL_PREFIX", False) api = API(statestore, control) app = Flask(__name__) -app.register_blueprint(client_bp) -app.register_blueprint(status_bp) -app.register_blueprint(model_bp) -app.register_blueprint(validation_bp) -app.register_blueprint(package_bp) -app.register_blueprint(session_bp) -app.register_blueprint(combiner_bp) -app.register_blueprint(round_bp) +for bp in _routes: + app.register_blueprint(bp) + if custom_url_prefix: + app.register_blueprint(bp, + name=f"{bp.name}_custom", + url_prefix=f"{custom_url_prefix}{bp.url_prefix}") template = { - "swagger": "2.0", - "info": { - "title": "FEDn API", - "description": "API for the FEDn network.", - "version": "0.0.1" - } + "swagger": "2.0", + "info": { + "title": "FEDn API", + "description": "API for the FEDn network.", + "version": "0.0.1" + } } swagger = Swagger(app, template=template) @app.route("/get_model_trail", 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. @@ -55,7 +52,12 @@ def get_model_trail(): return api.get_model_trail() +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_model_trail", view_func=get_model_trail, methods=["GET"]) + + @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. @@ -71,7 +73,12 @@ def get_model_ancestors(): return api.get_model_ancestors(model, limit) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_model_ancestors", view_func=get_model_ancestors, methods=["GET"]) + + @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. @@ -87,7 +94,12 @@ def get_model_descendants(): return api.get_model_descendants(model, limit) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_model_descendants", view_func=get_model_descendants, methods=["GET"]) + + @app.route("/list_models", methods=["GET"]) +@jwt_auth_required(role="admin") def list_models(): """Get models from the statestore. param: @@ -108,7 +120,12 @@ def list_models(): return api.get_models(session_id, limit, skip, include_active) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/list_models", view_func=list_models, methods=["GET"]) + + @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. @@ -123,7 +140,12 @@ def get_model(): return api.get_model(model) +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. @@ -134,7 +156,12 @@ def delete_model_trail(): 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. @@ -148,7 +175,12 @@ def list_clients(): return api.get_clients(limit, skip, status) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/list_clients", view_func=list_clients, methods=["GET"]) + + @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. @@ -165,7 +197,12 @@ def get_active_clients(): return api.get_active_clients(combiner_id) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_active_clients", view_func=get_active_clients, methods=["GET"]) + + @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. @@ -178,7 +215,12 @@ def list_combiners(): return api.get_all_combiners(limit, skip) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/list_combiners", view_func=list_combiners, methods=["GET"]) + + @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. @@ -195,7 +237,12 @@ def get_combiner(): return api.get_combiner(combiner_id) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_combiner", view_func=get_combiner, methods=["GET"]) + + @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. @@ -204,7 +251,12 @@ def list_rounds(): return api.get_all_rounds() +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/list_rounds", view_func=list_rounds, methods=["GET"]) + + @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. @@ -218,7 +270,12 @@ def get_round(): return api.get_round(round_id) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_round", view_func=get_round, methods=["GET"]) + + @app.route("/start_session", methods=["GET", "POST"]) +@jwt_auth_required(role="admin") def start_session(): """Start a new session. return: The response from control. @@ -228,7 +285,12 @@ def start_session(): return api.start_session(**json_data) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/start_session", view_func=start_session, methods=["GET", "POST"]) + + @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. @@ -240,7 +302,12 @@ def list_sessions(): return api.get_all_sessions(limit, skip) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/list_sessions", view_func=list_sessions, methods=["GET"]) + + @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. @@ -257,13 +324,23 @@ def get_session(): return api.get_session(session_id) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_session", view_func=get_session, methods=["GET"]) + + @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) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/set_active_package", view_func=set_active_package, methods=["PUT"]) + + @app.route("/set_package", methods=["POST"]) +@jwt_auth_required(role="admin") def set_package(): """ Set the compute package in the statestore. Usage with curl: @@ -295,7 +372,12 @@ def set_package(): ) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/set_package", view_func=set_package, methods=["POST"]) + + @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. @@ -304,7 +386,12 @@ def get_package(): return api.get_compute_package() +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_package", view_func=get_package, methods=["GET"]) + + @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. @@ -320,7 +407,12 @@ def list_compute_packages(): ) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/list_compute_packages", view_func=list_compute_packages, methods=["GET"]) + + @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. @@ -330,13 +422,23 @@ def download_package(): return api.download_compute_package(name) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/download_package", view_func=download_package, methods=["GET"]) + + @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) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_package_checksum", view_func=get_package_checksum, methods=["GET"]) + + @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. @@ -345,7 +447,12 @@ def get_latest_model(): return api.get_latest_model() +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_latest_model", view_func=get_latest_model, methods=["GET"]) + + @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: @@ -364,10 +471,14 @@ def set_current_model(): return api.set_current_model(id) +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. @@ -376,7 +487,12 @@ def get_initial_model(): return api.get_initial_model() +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_initial_model", view_func=get_initial_model, methods=["GET"]) + + @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: @@ -396,7 +512,12 @@ def set_initial_model(): return api.set_initial_model(file) +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. @@ -405,7 +526,12 @@ def get_controller_status(): 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. @@ -416,7 +542,12 @@ def get_client_config(): return api.get_client_config(checksum) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_client_config", view_func=get_client_config, methods=["GET"]) + + @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. @@ -428,7 +559,12 @@ def get_events(): return api.get_events(**kwargs) +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_events", view_func=get_client_config, methods=["GET"]) + + @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. @@ -439,7 +575,12 @@ def list_validations(): return api.get_all_validations(**kwargs) +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. @@ -454,7 +595,12 @@ def add_combiner(): 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. @@ -470,7 +616,12 @@ def add_client(): return response +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/add_client", view_func=add_client, methods=["POST"]) + + @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. @@ -489,7 +640,12 @@ def list_combiners_data(): return response +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/list_combiners_data", view_func=list_combiners_data, methods=["POST"]) + + @app.route("/get_plot_data", methods=["GET"]) +@jwt_auth_required(role="admin") def get_plot_data(): """Get plot data from the statestore. rtype: json @@ -503,6 +659,9 @@ def get_plot_data(): return response +if custom_url_prefix: + app.add_url_rule(f"{custom_url_prefix}/get_plot_data", view_func=get_plot_data, methods=["GET"]) + if __name__ == "__main__": config = get_controller_config() port = config["port"] diff --git a/fedn/fedn/network/api/v1/__init__.py b/fedn/fedn/network/api/v1/__init__.py index e69de29bb..bb8d8d33c 100644 --- a/fedn/fedn/network/api/v1/__init__.py +++ b/fedn/fedn/network/api/v1/__init__.py @@ -0,0 +1,10 @@ +from fedn.network.api.v1.client_routes import bp as client_bp +from fedn.network.api.v1.combiner_routes import bp as combiner_bp +from fedn.network.api.v1.model_routes import bp as model_bp +from fedn.network.api.v1.package_routes import bp as package_bp +from fedn.network.api.v1.round_routes import bp as round_bp +from fedn.network.api.v1.session_routes import bp as session_bp +from fedn.network.api.v1.status_routes import bp as status_bp +from fedn.network.api.v1.validation_routes import bp as validation_bp + +_routes = [client_bp, combiner_bp, model_bp, package_bp, round_bp, session_bp, status_bp, validation_bp] diff --git a/fedn/fedn/network/api/v1/client_routes.py b/fedn/fedn/network/api/v1/client_routes.py index c4215bd54..30322a9b7 100644 --- a/fedn/fedn/network/api/v1/client_routes.py +++ b/fedn/fedn/network/api/v1/client_routes.py @@ -1,5 +1,6 @@ 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.client_store import ClientStore @@ -11,6 +12,7 @@ @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_clients(): """Get clients Retrieves a list of clients based on the provided parameters. @@ -127,6 +129,7 @@ def get_clients(): @bp.route("/list", methods=["POST"]) +@jwt_auth_required(role="admin") def list_clients(): """List clients Retrieves a list of clients based on the provided parameters. @@ -213,6 +216,7 @@ def list_clients(): @bp.route("/count", methods=["GET"]) +@jwt_auth_required(role="admin") def get_clients_count(): """Clients count Retrieves the total number of clients based on the provided parameters. @@ -273,6 +277,7 @@ def get_clients_count(): @bp.route("/count", methods=["POST"]) +@jwt_auth_required(role="admin") def clients_count(): """Clients count Retrieves the total number of clients based on the provided parameters. @@ -325,6 +330,7 @@ def clients_count(): @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_client(id: str): """Get client Retrieves a client based on the provided id. diff --git a/fedn/fedn/network/api/v1/combiner_routes.py b/fedn/fedn/network/api/v1/combiner_routes.py index ba6bf5dbd..7d1761bee 100644 --- a/fedn/fedn/network/api/v1/combiner_routes.py +++ b/fedn/fedn/network/api/v1/combiner_routes.py @@ -1,5 +1,6 @@ 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.combiner_store import CombinerStore @@ -11,6 +12,7 @@ @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_combiners(): """Get combiners Retrieves a list of combiners based on the provided parameters. @@ -119,6 +121,7 @@ def get_combiners(): @bp.route("/list", methods=["POST"]) +@jwt_auth_required(role="admin") def list_combiners(): """List combiners Retrieves a list of combiners based on the provided parameters. @@ -203,6 +206,7 @@ def list_combiners(): @bp.route("/count", methods=["GET"]) +@jwt_auth_required(role="admin") def get_combiners_count(): """Combiners count Retrieves the count of combiners based on the provided parameters. @@ -249,6 +253,7 @@ def get_combiners_count(): @bp.route("/count", methods=["POST"]) +@jwt_auth_required(role="admin") def combiners_count(): """Combiners count Retrieves the count of combiners based on the provided parameters. @@ -297,6 +302,7 @@ def combiners_count(): @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_combiner(id: str): """Get combiner Retrieves a combiner based on the provided id. diff --git a/fedn/fedn/network/api/v1/model_routes.py b/fedn/fedn/network/api/v1/model_routes.py index 2db99083c..8e9308408 100644 --- a/fedn/fedn/network/api/v1/model_routes.py +++ b/fedn/fedn/network/api/v1/model_routes.py @@ -3,6 +3,7 @@ import numpy as np from flask import Blueprint, jsonify, request, send_file +from fedn.network.api.auth import jwt_auth_required from fedn.network.api.v1.shared import (api_version, get_limit, get_post_data_to_kwargs, get_reverse, get_typed_list_headers, mdb, @@ -22,6 +23,7 @@ @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_models(): """Get models Retrieves a list of models based on the provided parameters. @@ -124,6 +126,7 @@ def get_models(): @bp.route("/list", methods=["POST"]) +@jwt_auth_required(role="admin") def list_models(): """List models Retrieves a list of models based on the provided parameters. @@ -210,6 +213,7 @@ def list_models(): @bp.route("/count", methods=["GET"]) +@jwt_auth_required(role="admin") def get_models_count(): """Models count Retrieves the count of models based on the provided parameters. @@ -257,6 +261,7 @@ def get_models_count(): @bp.route("/count", methods=["POST"]) +@jwt_auth_required(role="admin") def models_count(): """Models count Retrieves the count of models based on the provided parameters. @@ -308,6 +313,7 @@ def models_count(): @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_model(id: str): """Get model Retrieves a model based on the provided id. @@ -353,6 +359,7 @@ def get_model(id: str): @bp.route("//descendants", methods=["GET"]) +@jwt_auth_required(role="admin") def get_descendants(id: str): """Get model descendants Retrieves a list of model descendants of the provided model id/model property. @@ -406,6 +413,7 @@ def get_descendants(id: str): @bp.route("//ancestors", methods=["GET"]) +@jwt_auth_required(role="admin") def get_ancestors(id: str): """Get model ancestors Retrieves a list of model ancestors of the provided model id/model property. diff --git a/fedn/fedn/network/api/v1/package_routes.py b/fedn/fedn/network/api/v1/package_routes.py index b62aa96a9..30ac4d51e 100644 --- a/fedn/fedn/network/api/v1/package_routes.py +++ b/fedn/fedn/network/api/v1/package_routes.py @@ -1,5 +1,6 @@ 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) @@ -12,6 +13,7 @@ @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_packages(): """Get packages Retrieves a list of packages based on the provided parameters. @@ -132,6 +134,7 @@ def get_packages(): @bp.route("/list", methods=["POST"]) +@jwt_auth_required(role="admin") def list_packages(): """List packages Retrieves a list of packages based on the provided parameters. @@ -221,6 +224,7 @@ def list_packages(): @bp.route("/count", methods=["GET"]) +@jwt_auth_required(role="admin") def get_packages_count(): """Package count Retrieves the count of packages based on the provided parameters. @@ -281,6 +285,7 @@ def get_packages_count(): @bp.route("/count", methods=["POST"]) +@jwt_auth_required(role="admin") def packages_count(): """Package count Retrieves the count of packages based on the provided parameters. @@ -342,6 +347,7 @@ def packages_count(): @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_package(id: str): """Get package Retrieves a package based on the provided id. @@ -388,6 +394,7 @@ def get_package(id: str): @bp.route("/active", methods=["GET"]) +@jwt_auth_required(role="admin") def get_active_package(): """Get active package Retrieves the active package diff --git a/fedn/fedn/network/api/v1/round_routes.py b/fedn/fedn/network/api/v1/round_routes.py index 317c767ee..8890c510a 100644 --- a/fedn/fedn/network/api/v1/round_routes.py +++ b/fedn/fedn/network/api/v1/round_routes.py @@ -1,5 +1,6 @@ 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 @@ -11,6 +12,7 @@ @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_rounds(): """Get rounds Retrieves a list of rounds based on the provided parameters. @@ -107,6 +109,7 @@ def get_rounds(): @bp.route("/list", methods=["POST"]) +@jwt_auth_required(role="admin") def list_rounds(): """List rounds Retrieves a list of rounds based on the provided parameters. @@ -187,6 +190,7 @@ def list_rounds(): @bp.route("/count", methods=["GET"]) +@jwt_auth_required(role="admin") def get_rounds_count(): """Rounds count Retrieves the count of rounds based on the provided parameters. @@ -227,6 +231,7 @@ def get_rounds_count(): @bp.route("/count", methods=["POST"]) +@jwt_auth_required(role="admin") def rounds_count(): """Rounds count Retrieves the count of rounds based on the provided parameters. @@ -271,6 +276,7 @@ def rounds_count(): @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_round(id: str): """Get round Retrieves a round based on the provided id. diff --git a/fedn/fedn/network/api/v1/session_routes.py b/fedn/fedn/network/api/v1/session_routes.py index 4d3fe493f..99c52d8db 100644 --- a/fedn/fedn/network/api/v1/session_routes.py +++ b/fedn/fedn/network/api/v1/session_routes.py @@ -1,5 +1,6 @@ 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.session_store import SessionStore @@ -11,6 +12,7 @@ @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_sessions(): """Get sessions Retrieves a list of sessions based on the provided parameters. @@ -99,6 +101,7 @@ def get_sessions(): @bp.route("/list", methods=["POST"]) +@jwt_auth_required(role="admin") def list_sessions(): """List sessions Retrieves a list of sessions based on the provided parameters. @@ -178,6 +181,7 @@ def list_sessions(): @bp.route("/count", methods=["GET"]) +@jwt_auth_required(role="admin") def get_sessions_count(): """Sessions count Retrieves the count of sessions based on the provided parameters. @@ -218,6 +222,7 @@ def get_sessions_count(): @bp.route("/count", methods=["POST"]) +@jwt_auth_required(role="admin") def sessions_count(): """Sessions count Retrieves the count of sessions based on the provided parameters. @@ -262,6 +267,7 @@ def sessions_count(): @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_session(id: str): """Get session Retrieves a session based on the provided id. diff --git a/fedn/fedn/network/api/v1/status_routes.py b/fedn/fedn/network/api/v1/status_routes.py index 562b971db..e78c18533 100644 --- a/fedn/fedn/network/api/v1/status_routes.py +++ b/fedn/fedn/network/api/v1/status_routes.py @@ -1,5 +1,6 @@ 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) @@ -12,6 +13,7 @@ @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_statuses(): """Get statuses Retrieves a list of statuses based on the provided parameters. @@ -144,6 +146,7 @@ def get_statuses(): @bp.route("/list", methods=["POST"]) +@jwt_auth_required(role="admin") def list_statuses(): """Get statuses Retrieves a list of statuses based on the provided parameters. @@ -246,6 +249,7 @@ def list_statuses(): @bp.route("/count", methods=["GET"]) +@jwt_auth_required(role="admin") def get_statuses_count(): """Statuses count Retrieves the count of statuses based on the provided parameters. @@ -307,6 +311,7 @@ def get_statuses_count(): @bp.route("/count", methods=["POST"]) +@jwt_auth_required(role="admin") def statuses_count(): """Statuses count Retrieves the count of statuses based on the provided parameters. @@ -368,6 +373,7 @@ def statuses_count(): @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_status(id: str): """Get status Retrieves a status based on the provided id. diff --git a/fedn/fedn/network/api/v1/validation_routes.py b/fedn/fedn/network/api/v1/validation_routes.py index 874154dbc..96fbac55c 100644 --- a/fedn/fedn/network/api/v1/validation_routes.py +++ b/fedn/fedn/network/api/v1/validation_routes.py @@ -1,5 +1,6 @@ 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) @@ -13,6 +14,7 @@ @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_validations(): """Get validations Retrieves a list of validations based on the provided parameters. @@ -152,6 +154,7 @@ def get_validations(): @bp.route("/list", methods=["POST"]) +@jwt_auth_required(role="admin") def list_validations(): """Get validations Retrieves a list of validations based on the provided parameters. @@ -257,6 +260,7 @@ def list_validations(): @bp.route("/count", methods=["GET"]) +@jwt_auth_required(role="admin") def get_validations_count(): """Validations count Retrieves the count of validations based on the provided parameters. @@ -322,6 +326,7 @@ def get_validations_count(): @bp.route("/count", methods=["POST"]) +@jwt_auth_required(role="admin") def validations_count(): """Validations count Retrieves the count of validations based on the provided parameters. @@ -386,6 +391,7 @@ def validations_count(): @bp.route("/", methods=["GET"]) +@jwt_auth_required(role="admin") def get_validation(id: str): """Get validation Retrieves a validation based on the provided id. diff --git a/fedn/fedn/network/clients/client.py b/fedn/fedn/network/clients/client.py index a6408d66c..54c06ca51 100644 --- a/fedn/fedn/network/clients/client.py +++ b/fedn/fedn/network/clients/client.py @@ -18,9 +18,11 @@ from cryptography.hazmat.primitives.serialization import Encoding from google.protobuf.json_format import MessageToJson from OpenSSL import SSL +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 from fedn.common.log_config import (logger, set_log_level_from_string, set_log_stream) from fedn.network.clients.connect import ConnectorClient, Status @@ -39,7 +41,7 @@ def __init__(self, key): self._key = key def __call__(self, context, callback): - callback((('authorization', f'Token {self._key}'),), None) + callback((('authorization', f'{FEDN_AUTH_SCHEME} {self._key}'),), None) class Client: @@ -55,7 +57,7 @@ def __init__(self, config): """Initialize the client.""" self.state = None self.error_state = False - self._attached = False + self._connected = False self._missed_heartbeat = 0 self.config = config @@ -77,8 +79,9 @@ def __init__(self, config): 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'] - dirname = time.strftime("%Y%m%d-%H%M%S") + dirname = self.name+"-"+time.strftime("%Y%m%d-%H%M%S") self.run_path = os.path.join(os.getcwd(), dirname) os.mkdir(self.run_path) @@ -88,20 +91,21 @@ def __init__(self, config): self.inbox = queue.Queue() # Attach to the FEDn network (get combiner) - client_config = self._attach() + combiner_config = self.assign() + self.connect(combiner_config) self._initialize_dispatcher(config) - self._initialize_helper(client_config) + self._initialize_helper(combiner_config) if not self.helper: logger.warning("Failed to retrieve helper class settings: {}".format( - client_config)) + combiner_config)) self._subscribe_to_combiner(config) self.state = ClientState.idle - def _assign(self): + def assign(self): """Contacts the controller and asks for combiner assignment. :return: A configuration dictionary containing connection information for combiner. @@ -112,11 +116,12 @@ def _assign(self): while True: status, response = self.connector.assign() if status == Status.TryAgain: - logger.info(response) + logger.warning(response) + logger.info("Assignment request failed. Retrying in 5 seconds.") time.sleep(5) continue if status == Status.Assigned: - client_config = response + combiner_config = response break if status == Status.UnAuthorized: logger.critical(response) @@ -125,10 +130,9 @@ def _assign(self): logger.critical(response) sys.exit("Exiting: UnMatchedConfig") time.sleep(5) - logger.info("Assignment successfully received.") - logger.info("Received combiner configuration: {}".format(client_config)) - return client_config + logger.info("Received combiner configuration: {}".format(combiner_config)) + return combiner_config def _add_grpc_metadata(self, key, value): """Add metadata for gRPC calls. @@ -166,32 +170,36 @@ def _get_ssl_certificate(self, domain, port=443): cert = cert.to_cryptography().public_bytes(Encoding.PEM).decode() return cert - def _connect(self, client_config): - """Connect to assigned combiner. + def connect(self, combiner_config): + """Connect to combiner. - :param client_config: A configuration dictionary containing connection information for + :param combiner_config: A configuration dictionary containing connection information for the combiner. - :type client_config: dict + :type combiner_config: dict """ - # TODO use the client_config['certificate'] for setting up secure comms' - host = client_config['host'] + if self._connected: + logger.info("Client is already attached. ") + return None + + # 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.info("Client using metadata: {}.".format(self.metadata)) - port = client_config['port'] + logger.debug("Client using metadata: {}.".format(self.metadata)) + port = combiner_config['port'] secure = False - if client_config['fqdn'] is not None: - host = client_config['fqdn'] + if 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 client_config['certificate']: + if combiner_config['certificate']: logger.info("Utilizing CA certificate for GRPC channel authentication.") secure = True cert = base64.b64decode( - client_config['certificate']) # .decode('utf-8') + combiner_config['certificate']) # .decode('utf-8') credentials = grpc.ssl_channel_credentials(root_certificates=cert) channel = grpc.secure_channel("{}:{}".format(host, str(port)), credentials) elif os.getenv("FEDN_GRPC_ROOT_CERT_PATH"): @@ -231,48 +239,31 @@ def _connect(self, client_config): port)) logger.info("Using {} compute package.".format( - client_config["package"])) - - def _disconnect(self): - """Disconnect from the combiner.""" - self.channel.close() - - def detach(self): - """Detach from the FEDn network (disconnect from combiner)""" - # Setting _attached to False will make all processing threads return - if not self._attached: - logger.info("Client is not attached.") + combiner_config["package"])) - self._attached = False - # Close gRPC connection to combiner - self._disconnect() + self._connected = True - def _attach(self): - """Attach to the FEDn network (connect to combiner)""" - # Ask controller for a combiner and connect to that combiner. - if self._attached: - logger.info("Client is already attached. ") - return None - - client_config = self._assign() - self._connect(client_config) + def disconnect(self): + """Disconnect from the combiner.""" + if not self._connected: + logger.info("Client is not connected.") - if client_config: - self._attached = True - return client_config + self.channel.close() + self._connected = False + logger.info("Client {} disconnected.".format(self.name)) - def _initialize_helper(self, client_config): + def _initialize_helper(self, combiner_config): """Initialize the helper class for the client. - :param client_config: A configuration dictionary containing connection information for + :param combiner_config: A configuration dictionary containing connection information for | the discovery service (controller) and settings governing e.g. | client-combiner assignment behavior. - :type client_config: dict + :type combiner_config: dict :return: """ - if 'helper_type' in client_config.keys(): - self.helper = get_helper(client_config['helper_type']) + if 'helper_type' in combiner_config.keys(): + self.helper = get_helper(combiner_config['helper_type']) def _subscribe_to_combiner(self, config): """Listen to combiner message stream and start all processing threads. @@ -289,11 +280,15 @@ def _subscribe_to_combiner(self, config): # Start listening for combiner training and validation messages threading.Thread( target=self._listen_to_task_stream, daemon=True).start() - self._attached = True + 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_runtime.unpack() + def _initialize_dispatcher(self, config): """ Initialize the dispatcher for the client. @@ -334,7 +329,8 @@ def _initialize_dispatcher(self, config): return if retval: - pr.unpack() + self.untar_package(pr) + # pr.unpack() self.dispatcher = pr.dispatcher(self.run_path) try: @@ -370,21 +366,24 @@ def get_model_from_combiner(self, id, timeout=20): request.sender.name = self.name request.sender.role = fedn.WORKER - for part in self.modelStub.Download(request, metadata=self.metadata): + 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.IN_PROGRESS: + data.write(part.data) - if part.status == fedn.ModelStatus.OK: - return 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: + if part.status == fedn.ModelStatus.FAILED: return None - continue + + 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 @@ -409,7 +408,10 @@ def send_model_to_combiner(self, model, id): bt.seek(0, 0) - result = self.modelStub.Upload(upload_request_generator(bt, id), metadata=self.metadata) + 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 @@ -426,15 +428,15 @@ def _listen_to_task_stream(self): # Add client to metadata self._add_grpc_metadata('client', self.name) - while self._attached: + while self._connected: try: 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.Status.AUDIT, - type=fedn.StatusType.MODEL_UPDATE_REQUEST, request=request, sesssion_id=request.session_id) + self.send_status("Received model update request.", log_level=fedn.Status.AUDIT, + type=fedn.StatusType.MODEL_UPDATE_REQUEST, request=request, sesssion_id=request.session_id) logger.info("Received model update request of type {} for model_id {}".format(request.type, request.model_id)) if request.type == fedn.StatusType.MODEL_UPDATE and self.config['trainer']: @@ -448,19 +450,29 @@ def _listen_to_task_stream(self): # Handle gRPC errors status_code = e.code() if status_code == grpc.StatusCode.UNAVAILABLE: - logger.warning("GRPC server unavailable during model update request stream. Retrying.") + logger.warning("GRPC TaskStream: server unavailable during model update request stream. Retrying.") # Retry after a delay time.sleep(5) + if status_code == grpc.StatusCode.UNAUTHENTICATED: + details = e.details() + if details == 'Token expired': + logger.warning("GRPC TaskStream: Token expired. Reconnecting.") + self.detach() + + if status_code == grpc.StatusCode.CANCELLED: + # Expected if the client is detached + logger.critical("GRPC TaskStream: Client detached from combiner. Atempting to reconnect.") + else: # Log the error and continue - logger.error(f"An error occurred during model update request stream: {e}") + logger.error(f"GRPC TaskStream: An error occurred during model update request stream: {e}") except Exception as ex: # Handle other exceptions - logger.error(f"An error occurred during model update request stream: {ex}") + logger.error(f"GRPC TaskStream: An error occurred during model update request stream: {ex}") # Detach if not attached - if not self._attached: + if not self._connected: return def _process_training_request(self, model_id: str, session_id: str = None): @@ -474,7 +486,7 @@ def _process_training_request(self, model_id: str, session_id: str = None): :rtype: tuple """ - self._send_status( + self.send_status( "\t Starting processing of training request for model_id {}".format(model_id), sesssion_id=session_id) self.state = ClientState.training @@ -546,7 +558,7 @@ def _process_validation_request(self, model_id: str, is_inference: bool, session else: cmd = 'validate' - self._send_status( + self.send_status( f"Processing {cmd} request for model_id {model_id}", sesssion_id=session_id) self.state = ClientState.validating try: @@ -580,7 +592,7 @@ def process_request(self): """Process training and validation tasks. """ while True: - if not self._attached: + if not self._connected: return try: @@ -609,15 +621,24 @@ def process_request(self): update.timestamp = str(datetime.now()) update.correlation_id = request.correlation_id update.meta = json.dumps(meta) - # TODO: Check responses - _ = self.combinerStub.SendModelUpdate(update, metadata=self.metadata) - self._send_status("Model update completed.", log_level=fedn.Status.AUDIT, - type=fedn.StatusType.MODEL_UPDATE, request=update, sesssion_id=request.session_id) + try: + _ = self.combinerStub.SendModelUpdate(update, metadata=self.metadata) + self.send_status("Model update completed.", log_level=fedn.Status.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.Status.WARNING, - request=request, sesssion_id=request.session_id) + self.send_status("Client {} failed to complete model update.", + log_level=fedn.Status.WARNING, + request=request, sesssion_id=request.session_id) + self.state = ClientState.idle self.inbox.task_done() @@ -639,27 +660,37 @@ def process_request(self): validation.correlation_id = request.correlation_id validation.session_id = request.session_id - _ = self.combinerStub.SendModelValidation( - validation, metadata=self.metadata) - - status_type = fedn.StatusType.MODEL_VALIDATION - - self._send_status("Model validation completed.", log_level=fedn.Status.AUDIT, - type=status_type, request=validation, sesssion_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.Status.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.Status.WARNING, request=request, sesssion_id=request.session_id) + self.send_status("Client {} failed to complete model validation.".format(self.name), + log_level=fedn.Status.WARNING, request=request, sesssion_id=request.session_id) self.state = ClientState.idle self.inbox.task_done() except queue.Empty: pass + except grpc.RpcError as e: + logger.critical(f"GRPC process_request: An error occurred during process request: {e}") def _handle_combiner_failure(self): """ Register failed combiner connection.""" self._missed_heartbeat += 1 if self._missed_heartbeat > self.config['reconnect_after_missed_heartbeat']: - self.detach()() + self.disconnect() def _send_heartbeat(self, update_frequency=2.0): """Send a heartbeat to the combiner. @@ -677,16 +708,22 @@ def _send_heartbeat(self, update_frequency=2.0): self._missed_heartbeat = 0 except grpc.RpcError as e: status_code = e.code() - logger.warning("Client heartbeat: GRPC error, {}. Retrying.".format( - status_code.name)) + if status_code == grpc.StatusCode.UNAVAILABLE: + logger.warning("GRPC hearbeat: server unavailable during send heartbeat. Retrying.") + if status_code == grpc.StatusCode.UNAUTHENTICATED: + details = e.details() + if details == 'Token expired': + logger.warning("GRPC hearbeat: Token expired. Reconnecting.") + self.detach() logger.debug(e) self._handle_combiner_failure() time.sleep(update_frequency) - if not self._attached: + if not self._connected: + logger.info("SendStatus: Client disconnected.") return - def _send_status(self, msg, log_level=fedn.Status.INFO, type=None, request=None, sesssion_id: str = None): + def send_status(self, msg, log_level=fedn.Status.INFO, type=None, request=None, sesssion_id: str = None): """Send status message. :param msg: The message to send. @@ -698,6 +735,11 @@ def _send_status(self, msg, log_level=fedn.Status.INFO, type=None, request=None, :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 @@ -714,7 +756,16 @@ def _send_status(self, msg, log_level=fedn.Status.INFO, type=None, request=None, self.logs.append( "{} {} LOG LEVEL {} MESSAGE {}".format(str(datetime.now()), status.sender.name, status.log_level, status.status)) - _ = self.connectorStub.SendStatus(status, metadata=self.metadata) + 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. """ @@ -728,10 +779,10 @@ def run(self): cnt = 1 if self.state != old_state: logger.info("Client in {} state.".format(ClientStateToString(self.state))) - if not self._attached: + if not self._connected: logger.info("Detached from combiner.") # TODO: Implement a check/condition to ulitmately close down if too many reattachment attepts have failed. s - self._attach() + self.attach() self._subscribe_to_combiner(self.config) if self.error_state: return diff --git a/fedn/fedn/network/clients/connect.py b/fedn/fedn/network/clients/connect.py index 478844d26..2e9345ebb 100644 --- a/fedn/fedn/network/clients/connect.py +++ b/fedn/fedn/network/clients/connect.py @@ -8,6 +8,9 @@ import requests +from fedn.common.config import (FEDN_AUTH_REFRESH_TOKEN, + FEDN_AUTH_REFRESH_TOKEN_URI, FEDN_AUTH_SCHEME, + FEDN_CUSTOM_URL_PREFIX) from fedn.common.log_config import logger @@ -77,12 +80,11 @@ def assign(self): try: retval = None payload = {'client_id': self.name, 'preferred_combiner': self.preferred_combiner} - - retval = requests.post(self.connect_string + '/add_client', + retval = requests.post(self.connect_string + FEDN_CUSTOM_URL_PREFIX + '/add_client', json=payload, verify=self.verify, allow_redirects=True, - headers={'Authorization': 'Token {}'.format(self.token)}) + headers={'Authorization': f"{FEDN_AUTH_SCHEME} {self.token}"}) except Exception as e: print('***** {}'.format(e), flush=True) return Status.Unassigned, {} @@ -93,6 +95,16 @@ def assign(self): return Status.UnMatchedConfig, reason if retval.status_code == 401: + if 'message' in retval.json(): + reason = retval.json()['message'] + logger.warning(reason) + if reason == 'Token expired': + status_code = self.refresh_token() + if status_code >= 200 and status_code < 204: + logger.info("Token refreshed.") + return Status.TryAgain, reason + else: + return Status.UnAuthorized, "Could not refresh token" reason = "Unauthorized connection to reducer, make sure the correct token is set" return Status.UnAuthorized, reason @@ -115,3 +127,21 @@ def assign(self): return Status.Assigned, retval.json() return Status.Unassigned, None + + def refresh_token(self): + """ + Refresh client token. + + :return: Tuple with assingment status, combiner connection information if sucessful, else None. + :rtype: tuple(:class:`fedn.network.clients.connect.Status`, str) + """ + if not FEDN_AUTH_REFRESH_TOKEN_URI or not FEDN_AUTH_REFRESH_TOKEN: + logger.error("No refresh token URI/Token set, cannot refresh token.") + return 401 + + payload = requests.post(FEDN_AUTH_REFRESH_TOKEN_URI, + verify=self.verify, + allow_redirects=True, + json={'refresh': FEDN_AUTH_REFRESH_TOKEN}) + self.token = payload.json()['access'] + return payload.status_code diff --git a/fedn/fedn/network/clients/package.py b/fedn/fedn/network/clients/package.py index d56296de8..cc98eae94 100644 --- a/fedn/fedn/network/clients/package.py +++ b/fedn/fedn/network/clients/package.py @@ -9,6 +9,7 @@ import requests import yaml +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 @@ -52,13 +53,13 @@ def download(self, host, port, token, force_ssl=False, secure=False, name=None): else: scheme = "http" if port: - path = f"{scheme}://{host}:{port}/download_package" + path = f"{scheme}://{host}:{port}{FEDN_CUSTOM_URL_PREFIX}/download_package" else: - path = f"{scheme}://{host}/download_package" + 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': 'Token {}'.format(token)}) as r: + 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( @@ -73,13 +74,13 @@ def download(self, host, port, token, force_ssl=False, secure=False, name=None): for chunk in r.iter_content(chunk_size=8192): f.write(chunk) if port: - path = f"{scheme}://{host}:{port}/get_package_checksum" + path = f"{scheme}://{host}:{port}{FEDN_CUSTOM_URL_PREFIX}/get_package_checksum" else: - path = f"{scheme}://{host}/get_package_checksum" + 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': 'Token {}'.format(token)}) as r: + with requests.get(path, verify=False, headers={'Authorization': f'{FEDN_AUTH_SCHEME} {token}'}) as r: if 200 <= r.status_code < 204: data = r.json() diff --git a/fedn/fedn/network/combiner/aggregators/fedopt.py b/fedn/fedn/network/combiner/aggregators/fedopt.py index d3152c957..ccabb2789 100644 --- a/fedn/fedn/network/combiner/aggregators/fedopt.py +++ b/fedn/fedn/network/combiner/aggregators/fedopt.py @@ -34,13 +34,13 @@ def __init__(self, storage, server, modelservice, round_handler): self.m = None # Server side hyperparameters. Note that these may need extensive fine tuning. - self.eta = 0.1 + self.eta = 1e-2 self.beta1 = 0.9 self.beta2 = 0.99 self.tau = 1e-4 def combine_models(self, helper=None, delete_models=True): - """Compute pseudo gradients usigng model updates in the queue. + """Compute pseudo gradients using model updates in the queue. :param helper: An instance of :class: `fedn.utils.helpers.helpers.HelperBase`, ML framework specific helper, defaults to None :type helper: class: `fedn.utils.helpers.helpers.HelperBase`, optional @@ -88,7 +88,7 @@ def combine_models(self, helper=None, delete_models=True): pseudo_gradient = helper.increment_average( pseudo_gradient, pseudo_gradient_next, metadata['num_examples'], total_examples) - print("NORM PSEUDOGRADIENT: ", helper.norm(pseudo_gradient), flush=True) + logger.info("NORM PSEUDOGRADIENT: {}".format(helper.norm(pseudo_gradient))) nr_aggregated_models += 1 # Delete model from storage diff --git a/fedn/fedn/network/combiner/connect.py b/fedn/fedn/network/combiner/connect.py index 4c1c94266..7dc388261 100644 --- a/fedn/fedn/network/combiner/connect.py +++ b/fedn/fedn/network/combiner/connect.py @@ -5,6 +5,7 @@ # # import enum +import os import requests @@ -72,10 +73,14 @@ def __init__(self, host, port, myhost, fqdn, myport, token, name, secure=False, self.myhost = myhost self.myport = myport self.token = token + self.token_scheme = os.environ.get('FEDN_AUTH_SCHEME', 'Bearer') self.name = name self.secure = secure self.verify = verify + if not self.token: + self.token = os.environ.get('FEDN_AUTH_TOKEN', None) + # for https we assume a an ingress handles permanent redirect (308) self.prefix = "http://" if port: @@ -101,10 +106,11 @@ def announce(self): "port": self.myport, "secure_grpc": self.secure } + url_prefix = os.environ.get('FEDN_CUSTOM_URL_PREFIX', '') try: - retval = requests.post(self.connect_string + '/add_combiner', json=payload, + retval = requests.post(self.connect_string + url_prefix + '/add_combiner', json=payload, verify=self.verify, - headers={'Authorization': 'Token {}'.format(self.token)}) + headers={'Authorization': f'{self.token_scheme} {self.token}'}) except Exception: return Status.Unassigned, {} diff --git a/fedn/fedn/network/combiner/roundhandler.py b/fedn/fedn/network/combiner/roundhandler.py index 8d87b2b69..d27b7ffdb 100644 --- a/fedn/fedn/network/combiner/roundhandler.py +++ b/fedn/fedn/network/combiner/roundhandler.py @@ -254,7 +254,7 @@ def _assign_round_clients(self, n, type="trainers"): return clients - def _check_nr_round_clients(self, config, timeout=0.0): + def _check_nr_round_clients(self, config): """Check that the minimal number of clients required to start a round are available. :param config: The round config object. @@ -265,27 +265,14 @@ def _check_nr_round_clients(self, config, timeout=0.0): :rtype: bool """ - ready = False - t = 0.0 - while not ready: - active = self.server.nr_active_trainers() - - if active >= int(config['clients_requested']): - return True - else: - logger.info("waiting for {} clients to get started, currently: {}".format( - int(config['clients_requested']) - active, - active)) - if t >= timeout: - if active >= int(config['clients_required']): - return True - else: - return False - - time.sleep(1.0) - t += 1.0 - - return ready + active = self.server.nr_active_trainers() + if active >= int(config['clients_required']): + logger.info("Number of clients required ({0}) to start round met {1}.".format( + config['clients_required'], active)) + return True + else: + logger.info("Too few clients to start round.") + return False def execute_validation_round(self, round_config): """ Coordinate validation rounds as specified in config. diff --git a/fedn/fedn/network/controller/controlbase.py b/fedn/fedn/network/controller/controlbase.py index de13ce32a..e825e8e8b 100644 --- a/fedn/fedn/network/controller/controlbase.py +++ b/fedn/fedn/network/controller/controlbase.py @@ -177,7 +177,7 @@ def get_compute_package(self, compute_package=""): else: return None - def create_session(self, config): + def create_session(self, config, status='Initialized'): """ Initialize a new session in backend db. """ if "session_id" not in config.keys(): @@ -188,6 +188,7 @@ def create_session(self, config): 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): """ Set the round round stats. diff --git a/fedn/fedn/network/grpc/auth.py b/fedn/fedn/network/grpc/auth.py new file mode 100644 index 000000000..d879cd812 --- /dev/null +++ b/fedn/fedn/network/grpc/auth.py @@ -0,0 +1,95 @@ +import grpc +import jwt + +from fedn.common.config import FEDN_AUTH_SCHEME, FEDN_JWT_ALGORITHM, SECRET_KEY +from fedn.common.log_config import logger +from fedn.network.api.auth import check_custom_claims + +ENDPOINT_ROLES_MAPPING = { + '/fedn.Combiner/TaskStream': ['client'], + '/fedn.Combiner/SendModelUpdate': ['client'], + '/fedn.Combiner/SendModelValidation': ['client'], + '/fedn.Connector/SendHeartbeat': ['client'], + '/fedn.Connector/SendStatus': ['client'], + '/fedn.ModelService/Download': ['client'], + '/fedn.ModelService/Upload': ['client'], + '/fedn.Control/Start': ['controller'], + '/fedn.Control/Stop': ['controller'], + '/fedn.Control/FlushAggregationQueue': ['controller'], + '/fedn.Control/SetAggregator': ['controller'], +} + +ENDPOINT_WHITELIST = [ + '/fedn.Connector/AcceptingClients', + '/fedn.Connector/ListActiveClients', + '/fedn.Control/Start', + '/fedn.Control/Stop', + '/fedn.Control/FlushAggregationQueue', + '/fedn.Control/SetAggregator', +] + +USER_AGENT_WHITELIST = [ + 'grpc_health_probe' +] + + +def check_role_claims(payload, endpoint): + user_role = payload.get('role', '') + + # Perform endpoint-specific RBAC check + allowed_roles = ENDPOINT_ROLES_MAPPING.get(endpoint) + if allowed_roles and user_role not in allowed_roles: + return False + return True + + +def _unary_unary_rpc_terminator(code, details): + def terminate(ignored_request, context): + context.abort(code, details) + + return grpc.unary_unary_rpc_method_handler(terminate) + + +class JWTInterceptor(grpc.ServerInterceptor): + def __init__(self): + pass + + def intercept_service(self, continuation, handler_call_details): + # Pass if no secret key is set + if not SECRET_KEY: + return continuation(handler_call_details) + metadata = dict(handler_call_details.invocation_metadata) + # Pass whitelisted methods + if handler_call_details.method in ENDPOINT_WHITELIST: + return continuation(handler_call_details) + # Pass if the request comes from whitelisted user agents + user_agent = metadata.get('user-agent').split(' ')[0] + if user_agent in USER_AGENT_WHITELIST: + return continuation(handler_call_details) + + token = metadata.get('authorization') + if token is None: + return _unary_unary_rpc_terminator(grpc.StatusCode.UNAUTHENTICATED, 'Token is missing') + + if not token.startswith(FEDN_AUTH_SCHEME): + return _unary_unary_rpc_terminator(grpc.StatusCode.UNAUTHENTICATED, f'Invalid token scheme, expected {FEDN_AUTH_SCHEME}') + + token = token.split(' ')[1] + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[FEDN_JWT_ALGORITHM]) + + if not check_role_claims(payload, handler_call_details.method): + return _unary_unary_rpc_terminator(grpc.StatusCode.PERMISSION_DENIED, 'Insufficient permissions') + + if not check_custom_claims(payload): + return _unary_unary_rpc_terminator(grpc.StatusCode.PERMISSION_DENIED, 'Insufficient permissions') + + return continuation(handler_call_details) + except jwt.InvalidTokenError: + return _unary_unary_rpc_terminator(grpc.StatusCode.UNAUTHENTICATED, 'Invalid token') + except jwt.ExpiredSignatureError: + return _unary_unary_rpc_terminator(grpc.StatusCode.UNAUTHENTICATED, 'Token expired') + except Exception as e: + logger.error(str(e)) + return _unary_unary_rpc_terminator(grpc.StatusCode.UNKNOWN, str(e)) diff --git a/fedn/fedn/network/grpc/server.py b/fedn/fedn/network/grpc/server.py index 59ed6b1ba..916b4756e 100644 --- a/fedn/fedn/network/grpc/server.py +++ b/fedn/fedn/network/grpc/server.py @@ -6,6 +6,7 @@ import fedn.network.grpc.fedn_pb2_grpc as rpc from fedn.common.log_config import (logger, set_log_level_from_string, set_log_stream) +from fedn.network.grpc.auth import JWTInterceptor class Server: @@ -16,7 +17,7 @@ def __init__(self, servicer, modelservicer, config): set_log_level_from_string(config.get('verbosity', "INFO")) set_log_stream(config.get('logfile', None)) - self.server = grpc.server(futures.ThreadPoolExecutor(max_workers=350)) + self.server = grpc.server(futures.ThreadPoolExecutor(max_workers=350), interceptors=[JWTInterceptor()]) self.certificate = None self.health_servicer = health.HealthServicer() diff --git a/fedn/fedn/tests/__init__.py b/fedn/fedn/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/fedn/setup.py b/fedn/setup.py index cd604773d..dd3d9df5e 100644 --- a/fedn/setup.py +++ b/fedn/setup.py @@ -10,30 +10,22 @@ py_modules=['fedn'], python_requires='>=3.8,<3.11', install_requires=[ - "PyYAML>=5.4", "requests", "urllib3>=1.26.4", "minio", - "python-slugify", "grpcio~=1.57.0", "grpcio-tools~=1.57.0", "numpy>=1.21.6", "protobuf", "pymongo", "Flask", - "Flask-WTF", "pyjwt", "pyopenssl", - "ttictoc", "psutil", "click==8.0.1", - "jinja2", - "plotly", - "pandas", - "bokeh<3.0.0", - "networkx", "grpcio-health-checking~=1.57.0", - "flasgger==0.9.5" + "flasgger==0.9.5", + "plotly", ], license='Apache 2.0', zip_safe=False,